@andespindola/brainlink 0.1.0-alpha.10 → 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/README.md CHANGED
@@ -66,6 +66,7 @@ Markdown is the source of truth. `.brainlink/brainlink.db` is only a rebuildable
66
66
  - Full-text, semantic and hybrid retrieval modes.
67
67
  - SQLite-backed semantic candidate buckets for larger vaults.
68
68
  - Agent namespaces under `agents/<agent-id>/`.
69
+ - S3-compatible bucket vaults through `s3://bucket/prefix` URIs.
69
70
  - CLI with machine-readable `--json` output.
70
71
  - Short CLI alias: `blink`.
71
72
  - Built-in MCP stdio server for agent tool integration.
@@ -256,6 +257,36 @@ http://127.0.0.1:4321
256
257
 
257
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.
258
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
+
259
290
  ## Core Model
260
291
 
261
292
  ```txt
@@ -612,6 +643,12 @@ Set `BRAINLINK_ALLOWED_VAULTS` for external wrappers, including MCP servers, so
612
643
  export BRAINLINK_ALLOWED_VAULTS="/absolute/path/to/project-vault,/absolute/path/to/team-vault"
613
644
  ```
614
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
+
615
652
  ## Note Format
616
653
 
617
654
  Brainlink supports Markdown with optional frontmatter:
package/SECURITY.md CHANGED
@@ -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.
@@ -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;
@@ -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 = resolveVaultPath(vaultPath);
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)) {
@@ -552,4 +552,5 @@ Weak retrieval usually means:
552
552
  - Local embeddings are deterministic and provider-free; remote embedding providers are not implemented yet.
553
553
  - MCP integration is available through the `brainlink-mcp` stdio server.
554
554
  - HTTP API is local and unauthenticated.
555
- - Watch mode depends on platform filesystem watcher behavior.
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.
@@ -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
 
@@ -240,6 +242,7 @@ Relevant content
240
242
  Permanent:
241
243
 
242
244
  - Markdown files
245
+ - S3-compatible Markdown objects when the vault is `s3://bucket/prefix`
243
246
  - optional Git history around the vault
244
247
 
245
248
  Canonical agent memory lives under:
@@ -251,6 +254,7 @@ vault/agents/<agent-id>/**/*.md
251
254
  Rebuildable:
252
255
 
253
256
  - `.brainlink/brainlink.db`
257
+ - `$BRAINLINK_HOME/bucket-cache`
254
258
  - FTS records
255
259
  - local embedding vectors
256
260
  - local embedding bucket index
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-alpha.10",
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",
@@ -54,6 +54,7 @@
54
54
  "pack:smoke": "npm pack --dry-run"
55
55
  },
56
56
  "dependencies": {
57
+ "@aws-sdk/client-s3": "^3.1038.0",
57
58
  "@modelcontextprotocol/sdk": "^1.29.0",
58
59
  "better-sqlite3": "^12.9.0",
59
60
  "commander": "^14.0.2",