@andespindola/brainlink 0.1.0-alpha.1 → 0.1.0-alpha.11
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 +6 -2
- package/README.md +87 -43
- package/SECURITY.md +14 -2
- package/assets/brainlink-logo.svg +25 -0
- package/dist/application/server/host-security.js +3 -3
- package/dist/application/start-server.js +2 -2
- package/dist/application/watch-vault.js +4 -1
- package/dist/cli/commands/write-commands.js +1 -3
- package/dist/cli/main.js +9 -2
- package/dist/infrastructure/bucket-vault.js +171 -0
- package/dist/infrastructure/file-system-vault.js +21 -3
- package/dist/mcp/main.js +10 -0
- package/dist/mcp/server.js +59 -0
- package/dist/mcp/tools.js +166 -0
- package/docs/AGENT_USAGE.md +74 -11
- package/docs/ARCHITECTURE.md +10 -8
- package/docs/RELEASE.md +9 -1
- package/package.json +9 -3
package/AGENTS.md
CHANGED
|
@@ -27,12 +27,16 @@ By default, the installed Brainlink CLI uses `$HOME/.brainlink/vault` as its vau
|
|
|
27
27
|
Use this loop when using Brainlink as memory:
|
|
28
28
|
|
|
29
29
|
1. Write durable knowledge into Markdown notes.
|
|
30
|
-
2. Link related notes with `[[Note Title]]
|
|
30
|
+
2. Link related notes with explicit `[[Note Title]]` wiki links inside the note body.
|
|
31
31
|
3. Add explicit `#tags` for retrieval.
|
|
32
32
|
4. Run `index` after writes.
|
|
33
33
|
5. Run `context "<task or question>"` before answering.
|
|
34
34
|
6. Use the returned sources as grounded context.
|
|
35
35
|
|
|
36
|
+
`context` is read-only. It does not create notes, backlinks, graph edges or durable memory by itself. A relationship exists only when a Markdown note contains a `[[wiki link]]` to another note and the vault has been indexed after that write.
|
|
37
|
+
|
|
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
|
+
|
|
36
40
|
## Commands
|
|
37
41
|
|
|
38
42
|
```bash
|
|
@@ -82,7 +86,7 @@ npm run dev -- watch --vault ./vault
|
|
|
82
86
|
Start MCP over stdio:
|
|
83
87
|
|
|
84
88
|
```bash
|
|
85
|
-
npm run dev
|
|
89
|
+
npm run dev:mcp
|
|
86
90
|
```
|
|
87
91
|
|
|
88
92
|
Automation-facing CLI commands support `--json`. When invoking through `npm`, use `npm run --silent dev -- ...` so stdout remains valid JSON.
|
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./assets/brainlink-logo.svg" alt="Brainlink" width="720">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# Brainlink
|
|
2
6
|
|
|
3
7
|
Local-first memory and knowledge graph for AI agents.
|
|
@@ -62,9 +66,10 @@ Markdown is the source of truth. `.brainlink/brainlink.db` is only a rebuildable
|
|
|
62
66
|
- Full-text, semantic and hybrid retrieval modes.
|
|
63
67
|
- SQLite-backed semantic candidate buckets for larger vaults.
|
|
64
68
|
- Agent namespaces under `agents/<agent-id>/`.
|
|
69
|
+
- S3-compatible bucket vaults through `s3://bucket/prefix` URIs.
|
|
65
70
|
- CLI with machine-readable `--json` output.
|
|
66
71
|
- Short CLI alias: `blink`.
|
|
67
|
-
-
|
|
72
|
+
- Built-in MCP stdio server for agent tool integration.
|
|
68
73
|
- Local HTTP API.
|
|
69
74
|
- Realtime graph UI with agent selector and colored knowledge groups.
|
|
70
75
|
|
|
@@ -76,7 +81,7 @@ Requirements:
|
|
|
76
81
|
- npm
|
|
77
82
|
|
|
78
83
|
```bash
|
|
79
|
-
npm install -g @andespindola/brainlink
|
|
84
|
+
npm install -g @andespindola/brainlink@latest
|
|
80
85
|
```
|
|
81
86
|
|
|
82
87
|
The package installs two equivalent commands:
|
|
@@ -88,6 +93,14 @@ blink --help
|
|
|
88
93
|
|
|
89
94
|
Use `brainlink` when clarity matters. Use `blink` for faster daily terminal usage.
|
|
90
95
|
|
|
96
|
+
The npm package page may show `npm i @andespindola/brainlink`. That installs
|
|
97
|
+
Brainlink as a project dependency. Use `-g` when you want the terminal commands
|
|
98
|
+
available globally, or run it without a global install:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npx @andespindola/brainlink@latest --help
|
|
102
|
+
```
|
|
103
|
+
|
|
91
104
|
## Agent Autonomous Setup
|
|
92
105
|
|
|
93
106
|
This section is written for agents. If you are an AI agent operating in a user workspace, use this workflow to install and use Brainlink without requiring the user to run commands manually.
|
|
@@ -101,7 +114,7 @@ command -v blink || command -v brainlink
|
|
|
101
114
|
If neither command exists, install it:
|
|
102
115
|
|
|
103
116
|
```bash
|
|
104
|
-
npm install -g @andespindola/brainlink
|
|
117
|
+
npm install -g @andespindola/brainlink@latest
|
|
105
118
|
```
|
|
106
119
|
|
|
107
120
|
Then verify:
|
|
@@ -177,6 +190,15 @@ blink add "Testing Policy" \
|
|
|
177
190
|
--content "Run npm run check before final delivery. Related: [[Release Checklist]]. #testing #process"
|
|
178
191
|
```
|
|
179
192
|
|
|
193
|
+
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
|
+
|
|
195
|
+
When adding memory, follow this contract:
|
|
196
|
+
|
|
197
|
+
- Link the new note to at least one existing note when there is a related concept.
|
|
198
|
+
- Use the exact target note title inside `[[...]]`.
|
|
199
|
+
- Add retrieval tags such as `#architecture`, `#decision`, `#runbook` or `#preference`.
|
|
200
|
+
- Do not leave isolated notes unless they are intentionally root concepts.
|
|
201
|
+
|
|
180
202
|
Rebuild the index:
|
|
181
203
|
|
|
182
204
|
```bash
|
|
@@ -199,9 +221,9 @@ Use this loop during real work:
|
|
|
199
221
|
2. Run `blink context "<task>" --agent "$BLINK_AGENT" --json`.
|
|
200
222
|
3. Use returned sources as project memory.
|
|
201
223
|
4. Perform the task.
|
|
202
|
-
5. Save only durable learnings with `blink add
|
|
224
|
+
5. Save only durable learnings with `blink add`, including `[[wiki links]]` to related notes.
|
|
203
225
|
6. Run `blink index`.
|
|
204
|
-
7. Validate with `blink validate
|
|
226
|
+
7. Validate with `blink validate`, `blink broken-links` and `blink orphans` when graph links matter.
|
|
205
227
|
|
|
206
228
|
Do not store secrets, credentials, private keys, access tokens or transient chat noise.
|
|
207
229
|
|
|
@@ -235,6 +257,36 @@ http://127.0.0.1:4321
|
|
|
235
257
|
|
|
236
258
|
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.
|
|
237
259
|
|
|
260
|
+
## Bucket Vaults
|
|
261
|
+
|
|
262
|
+
Brainlink can use an S3-compatible bucket as the Markdown source of truth:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
export AWS_REGION="us-east-1"
|
|
266
|
+
export AWS_ACCESS_KEY_ID="..."
|
|
267
|
+
export AWS_SECRET_ACCESS_KEY="..."
|
|
268
|
+
|
|
269
|
+
blink add "Architecture" \
|
|
270
|
+
--vault "s3://my-memory-bucket/brainlink" \
|
|
271
|
+
--content "Bucket Markdown is the source of truth. #architecture"
|
|
272
|
+
|
|
273
|
+
blink index --vault "s3://my-memory-bucket/brainlink"
|
|
274
|
+
blink context "architecture" --vault "s3://my-memory-bucket/brainlink"
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
For Cloudflare R2, MinIO or another S3-compatible endpoint:
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
export BRAINLINK_S3_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
|
|
281
|
+
export BRAINLINK_S3_FORCE_PATH_STYLE=1
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Bucket vaults mirror Markdown into a local cache under
|
|
285
|
+
`$BRAINLINK_HOME/bucket-cache`. The bucket remains canonical; the local
|
|
286
|
+
`.brainlink/brainlink.db` stays a disposable index. Run `index` after remote
|
|
287
|
+
bucket changes before relying on `search`, `context`, graph or validation
|
|
288
|
+
commands. Watch mode is only supported for local filesystem vaults.
|
|
289
|
+
|
|
238
290
|
## Core Model
|
|
239
291
|
|
|
240
292
|
```txt
|
|
@@ -313,50 +365,36 @@ This allows `coding-agent` and `research-agent` to both have a note named `Archi
|
|
|
313
365
|
|
|
314
366
|
## MCP Server Integration
|
|
315
367
|
|
|
316
|
-
Brainlink
|
|
317
|
-
|
|
318
|
-
An MCP server can use Brainlink by spawning `blink` or `brainlink` as a subprocess and reading `--json` output. This keeps Brainlink decoupled from any specific MCP SDK while still making it usable by MCP-compatible agents.
|
|
319
|
-
|
|
320
|
-
Minimum integration contract:
|
|
368
|
+
Brainlink ships a stdio MCP server with the npm package:
|
|
321
369
|
|
|
322
370
|
```bash
|
|
323
|
-
|
|
324
|
-
blink add "Decision Title" --agent "$BLINK_AGENT" --content "Durable memory. #decision"
|
|
325
|
-
blink index
|
|
371
|
+
brainlink-mcp
|
|
326
372
|
```
|
|
327
373
|
|
|
328
|
-
Example
|
|
329
|
-
|
|
330
|
-
```js
|
|
331
|
-
import { execFile } from 'node:child_process'
|
|
332
|
-
import { promisify } from 'node:util'
|
|
333
|
-
|
|
334
|
-
const execFileAsync = promisify(execFile)
|
|
374
|
+
Example MCP client configuration:
|
|
335
375
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
agent,
|
|
344
|
-
'--mode',
|
|
345
|
-
'hybrid',
|
|
346
|
-
'--json'
|
|
347
|
-
])
|
|
348
|
-
|
|
349
|
-
return JSON.parse(stdout)
|
|
376
|
+
```json
|
|
377
|
+
{
|
|
378
|
+
"mcpServers": {
|
|
379
|
+
"brainlink": {
|
|
380
|
+
"command": "brainlink-mcp"
|
|
381
|
+
}
|
|
382
|
+
}
|
|
350
383
|
}
|
|
351
384
|
```
|
|
352
385
|
|
|
353
|
-
|
|
386
|
+
Available tools:
|
|
387
|
+
|
|
388
|
+
- `brainlink_context`: read indexed context for a task or question.
|
|
389
|
+
- `brainlink_search`: search indexed notes.
|
|
390
|
+
- `brainlink_add_note`: write durable Markdown memory and reindex.
|
|
391
|
+
- `brainlink_index`: rebuild the vault index.
|
|
392
|
+
- `brainlink_validate`: validate broken links and orphan notes.
|
|
393
|
+
- `brainlink_graph`: read indexed graph nodes and links.
|
|
394
|
+
- `brainlink_broken_links`: list unresolved wiki links.
|
|
395
|
+
- `brainlink_orphans`: list disconnected notes.
|
|
354
396
|
|
|
355
|
-
|
|
356
|
-
- `brainlink_search`: calls `blink search ... --json`.
|
|
357
|
-
- `brainlink_add_note`: calls `blink add ... --json`, then `blink index`.
|
|
358
|
-
- `brainlink_graph`: calls `blink graph ... --json`.
|
|
359
|
-
- `brainlink_validate`: calls `blink validate ... --json`.
|
|
397
|
+
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.
|
|
360
398
|
|
|
361
399
|
## Graph UI
|
|
362
400
|
|
|
@@ -388,7 +426,7 @@ blink server --vault ./vault --no-index
|
|
|
388
426
|
|
|
389
427
|
The HTTP API is read-only and exists only to power the graph UI and local inspection workflows.
|
|
390
428
|
|
|
391
|
-
The server refuses non-loopback hosts
|
|
429
|
+
The server always refuses non-loopback hosts. Brainlink HTTP only runs on localhost.
|
|
392
430
|
|
|
393
431
|
Routes:
|
|
394
432
|
|
|
@@ -563,7 +601,7 @@ blink server --vault ./vault --watch
|
|
|
563
601
|
|
|
564
602
|
Starts the local read-only graph UI and HTTP API.
|
|
565
603
|
|
|
566
|
-
|
|
604
|
+
The HTTP server only binds to loopback hosts such as `127.0.0.1`, `localhost` or `::1`.
|
|
567
605
|
|
|
568
606
|
## Machine-Readable Output
|
|
569
607
|
|
|
@@ -605,6 +643,12 @@ Set `BRAINLINK_ALLOWED_VAULTS` for external wrappers, including MCP servers, so
|
|
|
605
643
|
export BRAINLINK_ALLOWED_VAULTS="/absolute/path/to/project-vault,/absolute/path/to/team-vault"
|
|
606
644
|
```
|
|
607
645
|
|
|
646
|
+
Bucket vaults can be allowlisted with the same variable:
|
|
647
|
+
|
|
648
|
+
```bash
|
|
649
|
+
export BRAINLINK_ALLOWED_VAULTS="s3://my-memory-bucket/brainlink"
|
|
650
|
+
```
|
|
651
|
+
|
|
608
652
|
## Note Format
|
|
609
653
|
|
|
610
654
|
Brainlink supports Markdown with optional frontmatter:
|
|
@@ -691,7 +735,6 @@ Detailed notes:
|
|
|
691
735
|
- Semantic search uses SQLite embedding buckets to narrow candidates before cosine scoring.
|
|
692
736
|
- `embeddingProvider` currently supports `local` and `none`.
|
|
693
737
|
- Link resolution is title-based inside each agent namespace, with `shared` as fallback.
|
|
694
|
-
- No embedded MCP server is shipped; MCP integration is done by external servers wrapping the CLI.
|
|
695
738
|
- HTTP API is local and unauthenticated.
|
|
696
739
|
- Watch mode depends on the platform filesystem watcher.
|
|
697
740
|
|
|
@@ -712,6 +755,7 @@ The alpha includes local semantic retrieval. Remote embedding providers, remote
|
|
|
712
755
|
Brainlink is local-first by default.
|
|
713
756
|
|
|
714
757
|
- Do not expose the HTTP server publicly without authentication.
|
|
758
|
+
- Brainlink HTTP is localhost-only and refuses non-loopback hosts.
|
|
715
759
|
- Brainlink blocks common secret patterns by default when adding notes. Use `--allow-sensitive` only for intentional, protected vaults.
|
|
716
760
|
- Do not store secrets, credentials, API keys or regulated personal data unless the vault is protected by your own storage controls.
|
|
717
761
|
- Treat `.brainlink/brainlink.db` as disposable derived data.
|
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.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<svg width="900" height="220" viewBox="0 0 900 220" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">Brainlink</title>
|
|
3
|
+
<desc id="desc">Brainlink logo with a linked memory graph mark and wordmark.</desc>
|
|
4
|
+
<rect width="900" height="220" fill="transparent"/>
|
|
5
|
+
<g transform="translate(42 24)">
|
|
6
|
+
<rect x="6" y="6" width="160" height="160" rx="36" fill="#F8FAFC"/>
|
|
7
|
+
<rect x="6" y="6" width="160" height="160" rx="36" stroke="#111827" stroke-width="8"/>
|
|
8
|
+
<path d="M47 84C47 65.225 62.225 50 81 50H92C110.775 50 126 65.225 126 84C126 102.775 110.775 118 92 118H81C62.225 118 47 102.775 47 84Z" stroke="#111827" stroke-width="12" stroke-linecap="round"/>
|
|
9
|
+
<path d="M67 84C67 76.268 73.268 70 81 70H92C99.732 70 106 76.268 106 84C106 91.732 99.732 98 92 98H81C73.268 98 67 91.732 67 84Z" fill="#FFFFFF"/>
|
|
10
|
+
<path d="M54 132L43 121L54 110" stroke="#2563EB" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
|
11
|
+
<path d="M118 110L129 121L118 132" stroke="#2563EB" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
+
<circle cx="55" cy="43" r="12" fill="#14B8A6"/>
|
|
13
|
+
<circle cx="121" cy="43" r="12" fill="#2563EB"/>
|
|
14
|
+
<circle cx="86" cy="136" r="12" fill="#111827"/>
|
|
15
|
+
<path d="M66.5 47.5L109.5 47.5" stroke="#94A3B8" stroke-width="7" stroke-linecap="round"/>
|
|
16
|
+
<path d="M62 54L79 125" stroke="#94A3B8" stroke-width="7" stroke-linecap="round"/>
|
|
17
|
+
<path d="M115 54L92 125" stroke="#94A3B8" stroke-width="7" stroke-linecap="round"/>
|
|
18
|
+
</g>
|
|
19
|
+
<g transform="translate(244 68)">
|
|
20
|
+
<text x="0" y="62" fill="#111827" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="72" font-weight="760">Brainlink</text>
|
|
21
|
+
<text x="4" y="102" fill="#475569" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="24" font-weight="500">Local-first memory for AI agents</text>
|
|
22
|
+
<path d="M0 124H268" stroke="#14B8A6" stroke-width="8" stroke-linecap="round"/>
|
|
23
|
+
<path d="M292 124H392" stroke="#2563EB" stroke-width="8" stroke-linecap="round"/>
|
|
24
|
+
</g>
|
|
25
|
+
</svg>
|
|
@@ -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
|
};
|
|
@@ -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;
|
|
@@ -89,7 +89,6 @@ export const registerWriteCommands = (program) => {
|
|
|
89
89
|
.option('-p, --port <port>', 'server port', '4321')
|
|
90
90
|
.option('--no-index', 'skip indexing before starting the server')
|
|
91
91
|
.option('-w, --watch', 'watch markdown files and reindex on changes')
|
|
92
|
-
.option('--allow-public', 'allow binding the server to a non-loopback host')
|
|
93
92
|
.option('--json', 'print machine-readable JSON')
|
|
94
93
|
.description('start a local web UI for the knowledge graph')
|
|
95
94
|
.action(async (options) => {
|
|
@@ -99,8 +98,7 @@ export const registerWriteCommands = (program) => {
|
|
|
99
98
|
host: options.host ?? resolved.config.host,
|
|
100
99
|
port: parsePositiveInteger(options.port ?? String(resolved.config.port), resolved.config.port),
|
|
101
100
|
shouldIndex: options.index,
|
|
102
|
-
shouldWatch: Boolean(options.watch)
|
|
103
|
-
allowPublic: Boolean(options.allowPublic)
|
|
101
|
+
shouldWatch: Boolean(options.watch)
|
|
104
102
|
});
|
|
105
103
|
print(options.json, { url: server.url, watch: Boolean(options.watch), readonly: true }, () => `Brainlink graph server running at ${server.url}`);
|
|
106
104
|
});
|
package/dist/cli/main.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
import { registerReadCommands } from './commands/read-commands.js';
|
|
5
7
|
import { registerWriteCommands } from './commands/write-commands.js';
|
|
8
|
+
const readPackageVersion = () => {
|
|
9
|
+
const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
10
|
+
const metadata = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
11
|
+
return metadata.version ?? '0.0.0';
|
|
12
|
+
};
|
|
6
13
|
const program = new Command();
|
|
7
14
|
const cliName = basename(process.argv[1] ?? 'brainlink');
|
|
8
15
|
const displayName = cliName === 'blink' ? 'blink' : 'brainlink';
|
|
@@ -11,7 +18,7 @@ program
|
|
|
11
18
|
.name(displayName)
|
|
12
19
|
.alias(aliasName)
|
|
13
20
|
.description('Local-first knowledge memory for agents')
|
|
14
|
-
.version(
|
|
21
|
+
.version(readPackageVersion());
|
|
15
22
|
registerWriteCommands(program);
|
|
16
23
|
registerReadCommands(program);
|
|
17
24
|
program.parseAsync().catch((error) => {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
5
|
+
import { posix } from 'node:path';
|
|
6
|
+
import { getBrainlinkHomePath } from './paths.js';
|
|
7
|
+
const directoryMode = 0o700;
|
|
8
|
+
const fileMode = 0o600;
|
|
9
|
+
const bucketScheme = 's3:';
|
|
10
|
+
const manifestPath = '.brainlink/bucket-manifest.json';
|
|
11
|
+
const excludedSegments = new Set(['.brainlink', '.git', 'node_modules', 'dist']);
|
|
12
|
+
export const isBucketVaultUri = (value) => value.trim().toLowerCase().startsWith('s3://');
|
|
13
|
+
const trimSlashes = (value) => value.replace(/^\/+|\/+$/g, '');
|
|
14
|
+
const normalizePrefix = (value) => trimSlashes(posix.normalize(trimSlashes(value))).replace(/^\.$/, '');
|
|
15
|
+
export const parseBucketVaultUri = (uri) => {
|
|
16
|
+
const parsed = new URL(uri);
|
|
17
|
+
if (parsed.protocol !== bucketScheme || !parsed.hostname) {
|
|
18
|
+
throw new Error(`Unsupported bucket vault URI: ${uri}. Use s3://bucket/prefix.`);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
uri: formatBucketVaultUri(parsed.hostname, normalizePrefix(decodeURIComponent(parsed.pathname))),
|
|
22
|
+
bucket: parsed.hostname,
|
|
23
|
+
prefix: normalizePrefix(decodeURIComponent(parsed.pathname))
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export const formatBucketVaultUri = (bucket, prefix) => prefix ? `s3://${bucket}/${prefix}` : `s3://${bucket}`;
|
|
27
|
+
export const getBucketVaultCachePath = (uri) => {
|
|
28
|
+
const hash = createHash('sha256').update(parseBucketVaultUri(uri).uri).digest('hex').slice(0, 24);
|
|
29
|
+
return join(getBrainlinkHomePath(), 'bucket-cache', hash);
|
|
30
|
+
};
|
|
31
|
+
const ensureDirectory = async (path) => {
|
|
32
|
+
await mkdir(path, { recursive: true, mode: directoryMode });
|
|
33
|
+
await chmod(path, directoryMode);
|
|
34
|
+
};
|
|
35
|
+
const isPathInside = (parent, child) => {
|
|
36
|
+
const path = relative(parent, child);
|
|
37
|
+
return path === '' || (!path.startsWith('..') && !isAbsolute(path));
|
|
38
|
+
};
|
|
39
|
+
const toSafeRelativePath = (key) => {
|
|
40
|
+
const normalized = normalizePrefix(key);
|
|
41
|
+
if (!normalized || normalized.split('/').some((segment) => segment === '..' || excludedSegments.has(segment))) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return normalized.endsWith('.md') ? normalized : null;
|
|
45
|
+
};
|
|
46
|
+
const toObjectKey = (reference, relativePath) => reference.prefix ? `${reference.prefix}/${relativePath}` : relativePath;
|
|
47
|
+
const toRelativeObjectKey = (reference, objectKey) => {
|
|
48
|
+
const relativePath = reference.prefix
|
|
49
|
+
? objectKey.startsWith(`${reference.prefix}/`)
|
|
50
|
+
? objectKey.slice(reference.prefix.length + 1)
|
|
51
|
+
: null
|
|
52
|
+
: objectKey;
|
|
53
|
+
return relativePath ? toSafeRelativePath(relativePath) : null;
|
|
54
|
+
};
|
|
55
|
+
const createBucketClient = () => new S3Client({
|
|
56
|
+
region: process.env.AWS_REGION ?? process.env.BRAINLINK_S3_REGION ?? 'us-east-1',
|
|
57
|
+
endpoint: process.env.BRAINLINK_S3_ENDPOINT ?? process.env.AWS_ENDPOINT_URL,
|
|
58
|
+
forcePathStyle: process.env.BRAINLINK_S3_FORCE_PATH_STYLE === '1'
|
|
59
|
+
});
|
|
60
|
+
const streamToString = async (body) => {
|
|
61
|
+
if (body && typeof body === 'object' && 'transformToString' in body && typeof body.transformToString === 'function') {
|
|
62
|
+
return body.transformToString();
|
|
63
|
+
}
|
|
64
|
+
throw new Error('Unsupported S3 object body.');
|
|
65
|
+
};
|
|
66
|
+
const readManifest = async (cachePath) => {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(await readFile(join(cachePath, manifestPath), 'utf8'));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
72
|
+
return {
|
|
73
|
+
uri: '',
|
|
74
|
+
keys: []
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const writeManifest = async (cachePath, manifest) => {
|
|
81
|
+
const path = join(cachePath, manifestPath);
|
|
82
|
+
await ensureDirectory(dirname(path));
|
|
83
|
+
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: 'utf8', mode: fileMode });
|
|
84
|
+
await chmod(path, fileMode);
|
|
85
|
+
};
|
|
86
|
+
const listBucketMarkdownKeys = async (client, reference) => {
|
|
87
|
+
const keys = [];
|
|
88
|
+
let continuationToken;
|
|
89
|
+
do {
|
|
90
|
+
const result = await client.send(new ListObjectsV2Command({
|
|
91
|
+
Bucket: reference.bucket,
|
|
92
|
+
Prefix: reference.prefix ? `${reference.prefix}/` : undefined,
|
|
93
|
+
ContinuationToken: continuationToken
|
|
94
|
+
}));
|
|
95
|
+
keys.push(...(result.Contents ?? []).flatMap((object) => (object.Key ? [object.Key] : [])));
|
|
96
|
+
continuationToken = result.NextContinuationToken;
|
|
97
|
+
} while (continuationToken);
|
|
98
|
+
return keys.flatMap((key) => {
|
|
99
|
+
const relativePath = toRelativeObjectKey(reference, key);
|
|
100
|
+
return relativePath ? [relativePath] : [];
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
const removeStaleCachedFiles = async (cachePath, previousKeys, currentKeys) => {
|
|
104
|
+
await Promise.all(previousKeys
|
|
105
|
+
.filter((key) => !currentKeys.has(key))
|
|
106
|
+
.map(async (key) => {
|
|
107
|
+
const absolutePath = join(cachePath, key);
|
|
108
|
+
if (isPathInside(cachePath, absolutePath)) {
|
|
109
|
+
await rm(absolutePath, { force: true });
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
};
|
|
113
|
+
const downloadMarkdownFiles = async (client, reference, cachePath, keys) => {
|
|
114
|
+
await Promise.all(keys.map(async (key) => {
|
|
115
|
+
const absolutePath = join(cachePath, key);
|
|
116
|
+
if (!isPathInside(cachePath, absolutePath)) {
|
|
117
|
+
throw new Error(`Refusing to cache bucket object outside vault cache: ${key}`);
|
|
118
|
+
}
|
|
119
|
+
const result = await client.send(new GetObjectCommand({
|
|
120
|
+
Bucket: reference.bucket,
|
|
121
|
+
Key: toObjectKey(reference, key)
|
|
122
|
+
}));
|
|
123
|
+
await ensureDirectory(dirname(absolutePath));
|
|
124
|
+
await writeFile(absolutePath, await streamToString(result.Body), { encoding: 'utf8', mode: fileMode });
|
|
125
|
+
await chmod(absolutePath, fileMode);
|
|
126
|
+
}));
|
|
127
|
+
};
|
|
128
|
+
export const syncBucketVaultToCache = async (uri) => {
|
|
129
|
+
const reference = parseBucketVaultUri(uri);
|
|
130
|
+
const cachePath = getBucketVaultCachePath(reference.uri);
|
|
131
|
+
const client = createBucketClient();
|
|
132
|
+
const previousManifest = await readManifest(cachePath);
|
|
133
|
+
const keys = await listBucketMarkdownKeys(client, reference);
|
|
134
|
+
const currentKeys = new Set(keys);
|
|
135
|
+
await ensureDirectory(join(cachePath, '.brainlink'));
|
|
136
|
+
await removeStaleCachedFiles(cachePath, previousManifest.uri === reference.uri ? previousManifest.keys : [], currentKeys);
|
|
137
|
+
await downloadMarkdownFiles(client, reference, cachePath, keys);
|
|
138
|
+
await writeManifest(cachePath, {
|
|
139
|
+
uri: reference.uri,
|
|
140
|
+
keys
|
|
141
|
+
});
|
|
142
|
+
return cachePath;
|
|
143
|
+
};
|
|
144
|
+
export const writeBucketMarkdownFile = async (uri, filename, content) => {
|
|
145
|
+
const reference = parseBucketVaultUri(uri);
|
|
146
|
+
const cachePath = getBucketVaultCachePath(reference.uri);
|
|
147
|
+
const relativePath = toSafeRelativePath(filename.endsWith('.md') ? filename : `${filename}.md`);
|
|
148
|
+
if (!relativePath) {
|
|
149
|
+
throw new Error(`Invalid bucket Markdown path: ${filename}`);
|
|
150
|
+
}
|
|
151
|
+
const absolutePath = join(cachePath, relativePath);
|
|
152
|
+
if (!isPathInside(cachePath, absolutePath)) {
|
|
153
|
+
throw new Error(`Refusing to write outside bucket cache: ${absolutePath}`);
|
|
154
|
+
}
|
|
155
|
+
await ensureDirectory(join(cachePath, '.brainlink'));
|
|
156
|
+
await ensureDirectory(dirname(absolutePath));
|
|
157
|
+
await writeFile(absolutePath, content, { encoding: 'utf8', mode: fileMode });
|
|
158
|
+
await chmod(absolutePath, fileMode);
|
|
159
|
+
await createBucketClient().send(new PutObjectCommand({
|
|
160
|
+
Bucket: reference.bucket,
|
|
161
|
+
Key: toObjectKey(reference, relativePath),
|
|
162
|
+
Body: content,
|
|
163
|
+
ContentType: 'text/markdown; charset=utf-8'
|
|
164
|
+
}));
|
|
165
|
+
const manifest = await readManifest(cachePath);
|
|
166
|
+
await writeManifest(cachePath, {
|
|
167
|
+
uri: reference.uri,
|
|
168
|
+
keys: Array.from(new Set([...manifest.keys, relativePath])).sort()
|
|
169
|
+
});
|
|
170
|
+
return `${reference.uri}/${relativePath}`;
|
|
171
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
3
|
import { resolvePath } from './paths.js';
|
|
4
|
+
import { getBucketVaultCachePath, isBucketVaultUri, parseBucketVaultUri, syncBucketVaultToCache, writeBucketMarkdownFile } from './bucket-vault.js';
|
|
4
5
|
const excludedDirectories = new Set(['.brainlink', '.git', 'node_modules', 'dist']);
|
|
5
6
|
const directoryMode = 0o700;
|
|
6
7
|
const fileMode = 0o600;
|
|
@@ -15,30 +16,44 @@ const walkMarkdownFiles = async (directory) => {
|
|
|
15
16
|
}));
|
|
16
17
|
return nested.flat();
|
|
17
18
|
};
|
|
18
|
-
export const resolveVaultPath = (vaultPath) => resolvePath(vaultPath);
|
|
19
|
+
export const resolveVaultPath = (vaultPath) => isBucketVaultUri(vaultPath) ? getBucketVaultCachePath(vaultPath) : resolvePath(vaultPath);
|
|
20
|
+
export const isBucketVaultPath = (vaultPath) => isBucketVaultUri(vaultPath);
|
|
19
21
|
const isPathInside = (parent, child) => {
|
|
20
22
|
const path = relative(parent, child);
|
|
21
23
|
return path === '' || (!path.startsWith('..') && !isAbsolute(path));
|
|
22
24
|
};
|
|
25
|
+
const isBucketPrefixInside = (parent, child) => parent === '' || child === parent || child.startsWith(`${parent}/`);
|
|
23
26
|
const secureDirectory = async (path) => {
|
|
24
27
|
await mkdir(path, { recursive: true, mode: directoryMode });
|
|
25
28
|
await chmod(path, directoryMode);
|
|
26
29
|
};
|
|
27
30
|
export const assertVaultAllowed = (vaultPath, allowedVaults) => {
|
|
31
|
+
if (isBucketVaultUri(vaultPath)) {
|
|
32
|
+
const vault = parseBucketVaultUri(vaultPath);
|
|
33
|
+
const allowed = allowedVaults.filter(isBucketVaultUri).map(parseBucketVaultUri);
|
|
34
|
+
if (allowedVaults.length > 0 &&
|
|
35
|
+
!allowed.some((allowedVault) => vault.bucket === allowedVault.bucket && isBucketPrefixInside(allowedVault.prefix, vault.prefix))) {
|
|
36
|
+
throw new Error(`Vault path is not allowed: ${vault.uri}. Configure BRAINLINK_ALLOWED_VAULTS or allowedVaults.`);
|
|
37
|
+
}
|
|
38
|
+
return vault.uri;
|
|
39
|
+
}
|
|
28
40
|
const absoluteVaultPath = resolveVaultPath(vaultPath);
|
|
29
|
-
const allowed = allowedVaults.map(resolveVaultPath);
|
|
41
|
+
const allowed = allowedVaults.filter((allowedVault) => !isBucketVaultUri(allowedVault)).map(resolveVaultPath);
|
|
30
42
|
if (allowed.length > 0 && !allowed.some((allowedPath) => isPathInside(allowedPath, absoluteVaultPath))) {
|
|
31
43
|
throw new Error(`Vault path is not allowed: ${absoluteVaultPath}. Configure BRAINLINK_ALLOWED_VAULTS or allowedVaults.`);
|
|
32
44
|
}
|
|
33
45
|
return absoluteVaultPath;
|
|
34
46
|
};
|
|
35
47
|
export const ensureVault = async (vaultPath) => {
|
|
48
|
+
if (isBucketVaultUri(vaultPath)) {
|
|
49
|
+
return syncBucketVaultToCache(vaultPath);
|
|
50
|
+
}
|
|
36
51
|
const absoluteVaultPath = resolveVaultPath(vaultPath);
|
|
37
52
|
await secureDirectory(join(absoluteVaultPath, '.brainlink'));
|
|
38
53
|
return absoluteVaultPath;
|
|
39
54
|
};
|
|
40
55
|
export const readMarkdownFiles = async (vaultPath) => {
|
|
41
|
-
const absoluteVaultPath =
|
|
56
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
42
57
|
const paths = await walkMarkdownFiles(absoluteVaultPath);
|
|
43
58
|
return Promise.all(paths.map(async (absolutePath) => {
|
|
44
59
|
const [content, stats] = await Promise.all([readFile(absolutePath, 'utf8'), stat(absolutePath)]);
|
|
@@ -51,6 +66,9 @@ export const readMarkdownFiles = async (vaultPath) => {
|
|
|
51
66
|
}));
|
|
52
67
|
};
|
|
53
68
|
export const writeMarkdownFile = async (vaultPath, filename, content) => {
|
|
69
|
+
if (isBucketVaultUri(vaultPath)) {
|
|
70
|
+
return writeBucketMarkdownFile(vaultPath, filename, content);
|
|
71
|
+
}
|
|
54
72
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
55
73
|
const absolutePath = resolve(absoluteVaultPath, filename.endsWith('.md') ? filename : `${filename}.md`);
|
|
56
74
|
if (!isPathInside(absoluteVaultPath, absolutePath)) {
|
package/dist/mcp/main.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { createBrainlinkMcpServer } from './server.js';
|
|
4
|
+
const server = createBrainlinkMcpServer();
|
|
5
|
+
const transport = new StdioServerTransport();
|
|
6
|
+
server.connect(transport).catch((error) => {
|
|
7
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8
|
+
console.error(message);
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { addNoteInputSchema, addNoteTool, brokenLinksInputSchema, brokenLinksTool, contextInputSchema, contextTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, searchInputSchema, searchTool, validateInputSchema, validateTool } from './tools.js';
|
|
6
|
+
const readPackageVersion = () => {
|
|
7
|
+
const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
8
|
+
const metadata = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
9
|
+
return metadata.version ?? '0.0.0';
|
|
10
|
+
};
|
|
11
|
+
export const createBrainlinkMcpServer = () => {
|
|
12
|
+
const server = new McpServer({
|
|
13
|
+
name: 'brainlink',
|
|
14
|
+
title: 'Brainlink',
|
|
15
|
+
version: readPackageVersion(),
|
|
16
|
+
description: 'Local-first Markdown memory tools for AI agents.'
|
|
17
|
+
});
|
|
18
|
+
server.registerTool('brainlink_context', {
|
|
19
|
+
title: 'Build Brainlink Context',
|
|
20
|
+
description: 'Read indexed Brainlink memory for a task or question. This is read-only and does not create graph links.',
|
|
21
|
+
inputSchema: contextInputSchema
|
|
22
|
+
}, contextTool);
|
|
23
|
+
server.registerTool('brainlink_search', {
|
|
24
|
+
title: 'Search Brainlink Memory',
|
|
25
|
+
description: 'Search indexed Brainlink notes with FTS, semantic or hybrid retrieval.',
|
|
26
|
+
inputSchema: searchInputSchema
|
|
27
|
+
}, searchTool);
|
|
28
|
+
server.registerTool('brainlink_add_note', {
|
|
29
|
+
title: 'Add Brainlink Note',
|
|
30
|
+
description: 'Write durable Markdown memory, then reindex the vault. Include explicit [[wiki links]] for connected graph memory.',
|
|
31
|
+
inputSchema: addNoteInputSchema
|
|
32
|
+
}, addNoteTool);
|
|
33
|
+
server.registerTool('brainlink_index', {
|
|
34
|
+
title: 'Index Brainlink Vault',
|
|
35
|
+
description: 'Rebuild the local Brainlink index from Markdown notes.',
|
|
36
|
+
inputSchema: indexInputSchema
|
|
37
|
+
}, indexTool);
|
|
38
|
+
server.registerTool('brainlink_validate', {
|
|
39
|
+
title: 'Validate Brainlink Vault',
|
|
40
|
+
description: 'Validate indexed graph health, including broken links and orphan notes.',
|
|
41
|
+
inputSchema: validateInputSchema
|
|
42
|
+
}, validateTool);
|
|
43
|
+
server.registerTool('brainlink_graph', {
|
|
44
|
+
title: 'Read Brainlink Graph',
|
|
45
|
+
description: 'Read indexed graph nodes and wiki-link edges.',
|
|
46
|
+
inputSchema: graphInputSchema
|
|
47
|
+
}, graphTool);
|
|
48
|
+
server.registerTool('brainlink_broken_links', {
|
|
49
|
+
title: 'List Brainlink Broken Links',
|
|
50
|
+
description: 'List unresolved indexed wiki links.',
|
|
51
|
+
inputSchema: brokenLinksInputSchema
|
|
52
|
+
}, brokenLinksTool);
|
|
53
|
+
server.registerTool('brainlink_orphans', {
|
|
54
|
+
title: 'List Brainlink Orphans',
|
|
55
|
+
description: 'List indexed notes without incoming or outgoing graph links.',
|
|
56
|
+
inputSchema: orphansInputSchema
|
|
57
|
+
}, orphansTool);
|
|
58
|
+
return server;
|
|
59
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getBrokenLinksReport, getOrphansReport, validateVault } from '../application/analyze-vault.js';
|
|
3
|
+
import { addNote } from '../application/add-note.js';
|
|
4
|
+
import { buildContextPackage } from '../application/build-context.js';
|
|
5
|
+
import { getGraph } from '../application/get-graph.js';
|
|
6
|
+
import { indexVault } from '../application/index-vault.js';
|
|
7
|
+
import { searchKnowledge } from '../application/search-knowledge.js';
|
|
8
|
+
import { sanitizeSearchMode } from '../infrastructure/config.js';
|
|
9
|
+
import { loadBrainlinkConfig } from '../infrastructure/config.js';
|
|
10
|
+
import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
|
|
11
|
+
const positiveInteger = (fallback) => z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.positive()
|
|
15
|
+
.optional()
|
|
16
|
+
.transform((value) => value ?? fallback);
|
|
17
|
+
const vaultInput = {
|
|
18
|
+
vault: z.string().min(1).optional().describe('Vault directory. Omit to use the configured Brainlink default vault.')
|
|
19
|
+
};
|
|
20
|
+
const agentInput = {
|
|
21
|
+
agent: z.string().min(1).optional().describe('Agent memory namespace. Omit to read shared/default indexed memory.')
|
|
22
|
+
};
|
|
23
|
+
const searchModeInput = {
|
|
24
|
+
mode: z.enum(['fts', 'semantic', 'hybrid']).optional().describe('Search mode. Defaults to the Brainlink config value.')
|
|
25
|
+
};
|
|
26
|
+
const resolveVault = async (vault) => {
|
|
27
|
+
const config = await loadBrainlinkConfig();
|
|
28
|
+
return assertVaultAllowed(vault ?? config.vault, config.allowedVaults);
|
|
29
|
+
};
|
|
30
|
+
const jsonResult = (value) => ({
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: 'text',
|
|
34
|
+
text: JSON.stringify(value, null, 2)
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
structuredContent: value
|
|
38
|
+
});
|
|
39
|
+
export const contextInputSchema = {
|
|
40
|
+
...vaultInput,
|
|
41
|
+
...agentInput,
|
|
42
|
+
...searchModeInput,
|
|
43
|
+
query: z.string().min(1).describe('Task or question to retrieve Brainlink context for.'),
|
|
44
|
+
limit: positiveInteger(12).describe('Maximum search results before context selection.'),
|
|
45
|
+
tokens: positiveInteger(2000).describe('Maximum estimated context tokens.')
|
|
46
|
+
};
|
|
47
|
+
export const searchInputSchema = {
|
|
48
|
+
...vaultInput,
|
|
49
|
+
...agentInput,
|
|
50
|
+
...searchModeInput,
|
|
51
|
+
query: z.string().min(1).describe('Search query.'),
|
|
52
|
+
limit: positiveInteger(10).describe('Maximum result count.')
|
|
53
|
+
};
|
|
54
|
+
export const addNoteInputSchema = {
|
|
55
|
+
...vaultInput,
|
|
56
|
+
title: z.string().min(1).describe('Markdown note title.'),
|
|
57
|
+
content: z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1)
|
|
60
|
+
.describe('Durable Markdown memory. Include explicit [[wiki links]] and #tags when the memory should be connected.'),
|
|
61
|
+
agent: z.string().min(1).optional().default('shared').describe('Agent memory namespace. Defaults to shared.'),
|
|
62
|
+
allowSensitive: z.boolean().optional().default(false).describe('Allow content that looks like a secret.')
|
|
63
|
+
};
|
|
64
|
+
export const indexInputSchema = {
|
|
65
|
+
...vaultInput
|
|
66
|
+
};
|
|
67
|
+
export const validateInputSchema = {
|
|
68
|
+
...vaultInput,
|
|
69
|
+
...agentInput
|
|
70
|
+
};
|
|
71
|
+
export const graphInputSchema = {
|
|
72
|
+
...vaultInput,
|
|
73
|
+
...agentInput
|
|
74
|
+
};
|
|
75
|
+
export const brokenLinksInputSchema = {
|
|
76
|
+
...vaultInput,
|
|
77
|
+
...agentInput
|
|
78
|
+
};
|
|
79
|
+
export const orphansInputSchema = {
|
|
80
|
+
...vaultInput,
|
|
81
|
+
...agentInput
|
|
82
|
+
};
|
|
83
|
+
export const contextTool = async (input) => {
|
|
84
|
+
const vault = await resolveVault(input.vault);
|
|
85
|
+
const config = await loadBrainlinkConfig();
|
|
86
|
+
const mode = sanitizeSearchMode(input.mode, config.defaultSearchMode);
|
|
87
|
+
const contextPackage = await buildContextPackage(vault, input.query, input.limit, input.tokens, input.agent, mode);
|
|
88
|
+
return jsonResult({
|
|
89
|
+
vault,
|
|
90
|
+
agent: input.agent,
|
|
91
|
+
mode,
|
|
92
|
+
...contextPackage
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
export const searchTool = async (input) => {
|
|
96
|
+
const vault = await resolveVault(input.vault);
|
|
97
|
+
const config = await loadBrainlinkConfig();
|
|
98
|
+
const mode = sanitizeSearchMode(input.mode, config.defaultSearchMode);
|
|
99
|
+
const results = await searchKnowledge(vault, input.query, input.limit, input.agent, mode);
|
|
100
|
+
return jsonResult({
|
|
101
|
+
vault,
|
|
102
|
+
agent: input.agent,
|
|
103
|
+
query: input.query,
|
|
104
|
+
limit: input.limit,
|
|
105
|
+
mode,
|
|
106
|
+
results
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
export const addNoteTool = async (input) => {
|
|
110
|
+
const vault = await resolveVault(input.vault);
|
|
111
|
+
const path = await addNote(vault, input.title, input.content, input.agent, {
|
|
112
|
+
allowSensitive: input.allowSensitive
|
|
113
|
+
});
|
|
114
|
+
const index = await indexVault(vault);
|
|
115
|
+
return jsonResult({
|
|
116
|
+
vault,
|
|
117
|
+
title: input.title,
|
|
118
|
+
agent: input.agent,
|
|
119
|
+
path,
|
|
120
|
+
index
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
export const indexTool = async (input) => {
|
|
124
|
+
const vault = await resolveVault(input.vault);
|
|
125
|
+
const result = await indexVault(vault);
|
|
126
|
+
return jsonResult({
|
|
127
|
+
vault,
|
|
128
|
+
...result
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
export const validateTool = async (input) => {
|
|
132
|
+
const vault = await resolveVault(input.vault);
|
|
133
|
+
const validation = await validateVault(vault, input.agent);
|
|
134
|
+
return jsonResult({
|
|
135
|
+
vault,
|
|
136
|
+
agent: input.agent,
|
|
137
|
+
...validation
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
export const graphTool = async (input) => {
|
|
141
|
+
const vault = await resolveVault(input.vault);
|
|
142
|
+
const graph = await getGraph(vault, input.agent);
|
|
143
|
+
return jsonResult({
|
|
144
|
+
vault,
|
|
145
|
+
agent: input.agent,
|
|
146
|
+
...graph
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
export const brokenLinksTool = async (input) => {
|
|
150
|
+
const vault = await resolveVault(input.vault);
|
|
151
|
+
const brokenLinks = await getBrokenLinksReport(vault, input.agent);
|
|
152
|
+
return jsonResult({
|
|
153
|
+
vault,
|
|
154
|
+
agent: input.agent,
|
|
155
|
+
brokenLinks
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
export const orphansTool = async (input) => {
|
|
159
|
+
const vault = await resolveVault(input.vault);
|
|
160
|
+
const orphans = await getOrphansReport(vault, input.agent);
|
|
161
|
+
return jsonResult({
|
|
162
|
+
vault,
|
|
163
|
+
agent: input.agent,
|
|
164
|
+
orphans
|
|
165
|
+
});
|
|
166
|
+
};
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -138,6 +138,41 @@ Rules:
|
|
|
138
138
|
- Prefer summaries over raw transcripts.
|
|
139
139
|
- Preserve dates when the timing matters.
|
|
140
140
|
|
|
141
|
+
## Linking Contract
|
|
142
|
+
|
|
143
|
+
Brainlink only builds graph edges from Markdown `[[wiki links]]`.
|
|
144
|
+
|
|
145
|
+
The `context` command is read-only. It retrieves indexed notes and returns a compact package for the model, but it does not write memory, create backlinks, infer relationships or modify the graph. If an agent reads context and then learns something durable, the agent must write a note with explicit links before that knowledge becomes connected memory.
|
|
146
|
+
|
|
147
|
+
Required write behavior:
|
|
148
|
+
|
|
149
|
+
1. Choose a clear title for the new note.
|
|
150
|
+
2. Look for an existing related concept with `search`, `links` or `backlinks`.
|
|
151
|
+
3. Add at least one `[[Existing Note Title]]` link unless the note is intentionally a root concept.
|
|
152
|
+
4. Add useful `#tags` for retrieval.
|
|
153
|
+
5. Run `index` after the write.
|
|
154
|
+
6. Run `validate`, `broken-links` or `orphans` when the graph should be connected.
|
|
155
|
+
|
|
156
|
+
Good linked note:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
blink add "SQLite Index Rebuild" \
|
|
160
|
+
--agent coding-agent \
|
|
161
|
+
--content "Legacy derived indexes without agent columns are rebuilt because SQLite is disposable. Related: [[Architecture]], [[Agent Namespaces]]. #sqlite #architecture #decision"
|
|
162
|
+
blink index
|
|
163
|
+
blink validate --agent coding-agent
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Poor disconnected note:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
blink add "SQLite Index Rebuild" \
|
|
170
|
+
--agent coding-agent \
|
|
171
|
+
--content "We rebuild old indexes now."
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The poor note may be searchable, but it will not create graph links, backlinks or useful traversal paths.
|
|
175
|
+
|
|
141
176
|
## Read Policy
|
|
142
177
|
|
|
143
178
|
Before answering a memory-dependent question, run:
|
|
@@ -396,19 +431,38 @@ blink watch --vault ./vault
|
|
|
396
431
|
|
|
397
432
|
This process watches Markdown files and rebuilds the index after changes.
|
|
398
433
|
|
|
399
|
-
### Use From
|
|
434
|
+
### Use From MCP
|
|
435
|
+
|
|
436
|
+
Brainlink ships a stdio MCP server:
|
|
437
|
+
|
|
438
|
+
```bash
|
|
439
|
+
brainlink-mcp
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Example MCP client configuration:
|
|
400
443
|
|
|
401
|
-
|
|
444
|
+
```json
|
|
445
|
+
{
|
|
446
|
+
"mcpServers": {
|
|
447
|
+
"brainlink": {
|
|
448
|
+
"command": "brainlink-mcp"
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
```
|
|
402
453
|
|
|
403
|
-
|
|
454
|
+
Available MCP tools:
|
|
404
455
|
|
|
405
|
-
- `brainlink_context
|
|
406
|
-
- `brainlink_search
|
|
407
|
-
- `brainlink_add_note
|
|
408
|
-
- `
|
|
409
|
-
- `brainlink_validate
|
|
456
|
+
- `brainlink_context`
|
|
457
|
+
- `brainlink_search`
|
|
458
|
+
- `brainlink_add_note`
|
|
459
|
+
- `brainlink_index`
|
|
460
|
+
- `brainlink_validate`
|
|
461
|
+
- `brainlink_graph`
|
|
462
|
+
- `brainlink_broken_links`
|
|
463
|
+
- `brainlink_orphans`
|
|
410
464
|
|
|
411
|
-
|
|
465
|
+
MCP clients can pass `vault` and `agent` arguments per tool call. Set `BRAINLINK_ALLOWED_VAULTS` when exposing Brainlink to an external agent process so a tool cannot pass arbitrary vault paths:
|
|
412
466
|
|
|
413
467
|
```bash
|
|
414
468
|
export BRAINLINK_ALLOWED_VAULTS="/absolute/path/to/project-vault"
|
|
@@ -453,6 +507,12 @@ Output:
|
|
|
453
507
|
|
|
454
508
|
Agents should include source paths in their reasoning or final answer when the user needs traceability.
|
|
455
509
|
|
|
510
|
+
Non-goals:
|
|
511
|
+
|
|
512
|
+
- `context` must not be treated as a write operation.
|
|
513
|
+
- Retrieved context must not be assumed to create graph edges.
|
|
514
|
+
- Backlinks are derived only from indexed `[[wiki links]]`.
|
|
515
|
+
|
|
456
516
|
## Operational Rules
|
|
457
517
|
|
|
458
518
|
- Re-run `index` after modifying notes.
|
|
@@ -461,6 +521,8 @@ Agents should include source paths in their reasoning or final answer when the u
|
|
|
461
521
|
- Do not manually edit the database.
|
|
462
522
|
- Keep generated context short enough for the target model.
|
|
463
523
|
- Prefer specific queries over broad queries.
|
|
524
|
+
- Write explicit `[[wiki links]]` when durable memory should be connected.
|
|
525
|
+
- Check `orphans` before assuming the graph is healthy.
|
|
464
526
|
|
|
465
527
|
## Failure Modes
|
|
466
528
|
|
|
@@ -488,6 +550,7 @@ Weak retrieval usually means:
|
|
|
488
550
|
|
|
489
551
|
- Search supports FTS, local semantic embeddings, SQLite semantic buckets and hybrid ranking.
|
|
490
552
|
- Local embeddings are deterministic and provider-free; remote embedding providers are not implemented yet.
|
|
491
|
-
- MCP integration is
|
|
553
|
+
- MCP integration is available through the `brainlink-mcp` stdio server.
|
|
492
554
|
- HTTP API is local and unauthenticated.
|
|
493
|
-
-
|
|
555
|
+
- Bucket vaults support S3-compatible `s3://bucket/prefix` URIs and use a local cache for SQLite indexes.
|
|
556
|
+
- Watch mode depends on platform filesystem watcher behavior and is only supported for local filesystem vaults.
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -105,13 +105,15 @@ Application code depends on domain rules and infrastructure interfaces.
|
|
|
105
105
|
The infrastructure layer handles side effects:
|
|
106
106
|
|
|
107
107
|
- reading Markdown files from disk
|
|
108
|
+
- mirroring S3-compatible bucket Markdown into a local cache
|
|
108
109
|
- writing Markdown notes
|
|
109
110
|
- creating `.brainlink`
|
|
110
111
|
- writing and querying SQLite
|
|
111
112
|
- running FTS, semantic and hybrid retrieval
|
|
112
113
|
- narrowing semantic candidates through SQLite embedding buckets before cosine scoring
|
|
113
114
|
|
|
114
|
-
SQLite is an index, not the canonical storage model.
|
|
115
|
+
SQLite is an index, not the canonical storage model. For bucket vaults, Markdown
|
|
116
|
+
objects in the bucket remain canonical and SQLite is still local derived data.
|
|
115
117
|
|
|
116
118
|
## Indexing Flow
|
|
117
119
|
|
|
@@ -167,20 +169,18 @@ HTTP request
|
|
|
167
169
|
|
|
168
170
|
The HTTP API is local-first and unauthenticated. It is meant for local agents, browser UI, and development workflows.
|
|
169
171
|
|
|
170
|
-
##
|
|
172
|
+
## MCP Flow
|
|
171
173
|
|
|
172
|
-
Brainlink
|
|
174
|
+
Brainlink includes a stdio MCP server for agent integrations.
|
|
173
175
|
|
|
174
176
|
```txt
|
|
175
177
|
MCP client
|
|
176
|
-
->
|
|
177
|
-
-> child_process execFile("blink", ["context", ..., "--json"])
|
|
178
|
-
-> Brainlink CLI
|
|
178
|
+
-> brainlink-mcp
|
|
179
179
|
-> application use case
|
|
180
|
-
->
|
|
180
|
+
-> MCP tool result
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
The MCP adapter stays thin. It validates tool inputs, resolves the configured vault and calls the same application use cases used by the CLI.
|
|
184
184
|
|
|
185
185
|
## Link Resolution
|
|
186
186
|
|
|
@@ -242,6 +242,7 @@ Relevant content
|
|
|
242
242
|
Permanent:
|
|
243
243
|
|
|
244
244
|
- Markdown files
|
|
245
|
+
- S3-compatible Markdown objects when the vault is `s3://bucket/prefix`
|
|
245
246
|
- optional Git history around the vault
|
|
246
247
|
|
|
247
248
|
Canonical agent memory lives under:
|
|
@@ -253,6 +254,7 @@ vault/agents/<agent-id>/**/*.md
|
|
|
253
254
|
Rebuildable:
|
|
254
255
|
|
|
255
256
|
- `.brainlink/brainlink.db`
|
|
257
|
+
- `$BRAINLINK_HOME/bucket-cache`
|
|
256
258
|
- FTS records
|
|
257
259
|
- local embedding vectors
|
|
258
260
|
- local embedding bucket index
|
package/docs/RELEASE.md
CHANGED
|
@@ -32,7 +32,7 @@ blink context "release smoke" --vault ./tmp-vault --mode hybrid --json
|
|
|
32
32
|
blink server --vault ./tmp-vault --host 127.0.0.1 --port 4321
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
9. Verify the server refuses
|
|
35
|
+
9. Verify the server refuses public binds:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
blink server --vault ./tmp-vault --host 0.0.0.0
|
|
@@ -47,10 +47,18 @@ blink server --vault ./tmp-vault --host 0.0.0.0
|
|
|
47
47
|
|
|
48
48
|
The preferred path is the `Publish npm` GitHub Actions workflow:
|
|
49
49
|
|
|
50
|
+
- Push to `main`: runs checks, pack smoke, then publishes the package to npm with `latest` when `package.json` contains a version that is not already published.
|
|
50
51
|
- GitHub Release `published`: runs checks, pack smoke, then publishes to npm with provenance.
|
|
51
52
|
- Manual `workflow_dispatch`: runs a dry run by default. Disable `dry_run` only for an intentional manual publish.
|
|
53
|
+
- Manual `workflow_dispatch` accepts an optional `dist_tag` override. Use `latest` only when the default npm install command should resolve to that version.
|
|
52
54
|
- Prerelease versions publish under their prerelease dist-tag, for example `0.1.0-alpha.1` publishes with `--tag alpha`.
|
|
53
55
|
|
|
56
|
+
On `main`, the publish job checks npm before publishing. If the version already exists, it automatically bumps the package inside the runner to the next available version before checks, packing and publishing. For example, `0.1.0-alpha.4` becomes `0.1.0-alpha.5`.
|
|
57
|
+
|
|
58
|
+
The automatic bump is intentionally not pushed back to `main`. The branch stays protected, and npm remains the source of truth for the latest published package version.
|
|
59
|
+
|
|
60
|
+
Manual and GitHub Release publishes do not auto-bump. If their version already exists, they skip `npm publish` because npm versions are immutable.
|
|
61
|
+
|
|
54
62
|
For emergency local publishing of scoped public packages:
|
|
55
63
|
|
|
56
64
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andespindola/brainlink",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.11",
|
|
4
4
|
"description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
],
|
|
26
26
|
"bin": {
|
|
27
27
|
"brainlink": "dist/cli/main.js",
|
|
28
|
-
"blink": "dist/cli/main.js"
|
|
28
|
+
"blink": "dist/cli/main.js",
|
|
29
|
+
"brainlink-mcp": "dist/mcp/main.js"
|
|
29
30
|
},
|
|
30
31
|
"files": [
|
|
31
32
|
"dist",
|
|
33
|
+
"assets",
|
|
32
34
|
"README.md",
|
|
33
35
|
"LICENSE",
|
|
34
36
|
"CHANGELOG.md",
|
|
@@ -44,6 +46,7 @@
|
|
|
44
46
|
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
45
47
|
"build": "npm run clean && tsc -p tsconfig.json",
|
|
46
48
|
"dev": "tsx src/cli/main.ts",
|
|
49
|
+
"dev:mcp": "tsx src/mcp/main.ts",
|
|
47
50
|
"test": "vitest run --config vitest.config.ts",
|
|
48
51
|
"check": "npm run build && npm run test",
|
|
49
52
|
"benchmark:large": "tsx src/benchmarks/large-vault.ts",
|
|
@@ -51,8 +54,11 @@
|
|
|
51
54
|
"pack:smoke": "npm pack --dry-run"
|
|
52
55
|
},
|
|
53
56
|
"dependencies": {
|
|
57
|
+
"@aws-sdk/client-s3": "^3.1038.0",
|
|
58
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
54
59
|
"better-sqlite3": "^12.9.0",
|
|
55
|
-
"commander": "^14.0.2"
|
|
60
|
+
"commander": "^14.0.2",
|
|
61
|
+
"zod": "^4.3.6"
|
|
56
62
|
},
|
|
57
63
|
"devDependencies": {
|
|
58
64
|
"@types/better-sqlite3": "^7.6.13",
|