@andespindola/brainlink 1.6.12 → 1.6.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.14
4
+
5
+ - **Images in notes and context.** New `brainlink_add_image` MCP tool (and application use-case) stores an image inside the vault (content-addressed, encrypted at rest when the vault is), and can attach it to a note that embeds it. Markdown image references (`![[image.png]]` embeds and `![alt](path)`) are now extracted per document without leaking into wiki-link graph edges, and image descriptors (path, mime, alt) are surfaced alongside text in retrieved context — never the raw bytes. Additive: image-free notes are unchanged.
6
+
7
+ ## 1.6.13
8
+
9
+ - **Admin impersonation for the exposed graph.** New opt-in `GET /api/admin-session?token=<jwt>` on `brainlink server --expose`: a control-plane can open a tenant's graph authenticated WITHOUT the web password by presenting a short-lived HS256 JWT signed with `BRAINLINK_ADMIN_SESSION_SECRET` (claims bind `sub`/`aud` to the server's web user; `exp` ≤ 600s). Off by default — when the secret is unset the endpoint 404s and nothing changes. On success it mints the same session cookie as `/api/login` and redirects to `/`.
10
+
3
11
  ## 1.6.12
4
12
 
5
13
  - **Wiki links keep a `#` that is part of the title.** A link target such as `[[note (PR #34)]]` was truncated at the `#` — the parser always treated `#` as the start of a heading anchor (`[[Note#Heading]]`) — so the link resolved to a non-existent `note (PR ` title and surfaced as a broken link. A `#` is now treated as a heading anchor only when it directly follows the note name with no whitespace before it; a `#` preceded by a space is kept as part of the title. Existing `[[Note#Heading]]` anchor links keep resolving to `Note`.
@@ -0,0 +1,67 @@
1
+ import { basename, extname } from 'node:path';
2
+ import { createStableId } from '../domain/ids.js';
3
+ import { imageMimeForRef } from '../domain/image-refs.js';
4
+ import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
5
+ import { ensureVault, writeVaultBinaryFile } from '../infrastructure/file-system-vault.js';
6
+ import { encryptVaultContent, readOrCreateVaultKey } from '../infrastructure/vault-crypto.js';
7
+ import { vaultEncryptionEnabled } from './vault-encryption.js';
8
+ import { addNoteWithMetadata } from './add-note.js';
9
+ // Images live under a dedicated, git-ignored assets tree inside the vault's
10
+ // `.brainlink` directory (already secured to 0o700). This keeps binary bytes
11
+ // out of the Markdown/graph git-versioning flow while still being addressable by
12
+ // a stable vault-relative path referenced from notes. When the vault is
13
+ // encrypted at rest, the bytes are stored as ciphertext (a `.enc` payload) so
14
+ // nothing readable is written to disk.
15
+ const assetsRoot = '.brainlink/assets';
16
+ const knownImageExtensions = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif', 'heic']);
17
+ const extensionForImage = (sourceName, mimeType) => {
18
+ const fromName = sourceName ? extname(sourceName).replace(/^\./, '').toLowerCase() : '';
19
+ if (knownImageExtensions.has(fromName)) {
20
+ return fromName;
21
+ }
22
+ const fromMime = mimeType?.split('/')[1]?.toLowerCase();
23
+ if (fromMime && knownImageExtensions.has(fromMime === 'jpeg' ? 'jpg' : fromMime)) {
24
+ return fromMime === 'jpeg' ? 'jpg' : fromMime;
25
+ }
26
+ return 'png';
27
+ };
28
+ const buildImageNoteContent = (embed) => [embed, '', '#image'].join('\n');
29
+ // Persist an image into the vault and return its stable vault-relative path plus
30
+ // an embed string a note can reference. Optionally creates a note that embeds
31
+ // the image so retrieval can surface it in context.
32
+ export const addImage = async (input) => {
33
+ if (input.data.byteLength === 0) {
34
+ throw new Error('Refusing to store an empty image.');
35
+ }
36
+ const vaultRoot = await ensureVault(input.vaultPath);
37
+ const agentId = sanitizeAgentId(input.agentId ?? sharedAgentId);
38
+ const mimeType = input.mimeType ?? imageMimeForRef(input.sourceName ?? '') ?? 'image/png';
39
+ const extension = extensionForImage(input.sourceName, mimeType);
40
+ const digest = createStableId(input.data.toString('base64'));
41
+ const assetPath = `${assetsRoot}/${agentId}/${digest}.${extension}`;
42
+ const encrypted = await vaultEncryptionEnabled();
43
+ const storedPath = encrypted ? `${assetPath}.enc` : assetPath;
44
+ const payload = encrypted
45
+ ? encryptVaultContent(await readOrCreateVaultKey(vaultRoot), input.data)
46
+ : input.data;
47
+ const absolutePath = await writeVaultBinaryFile(input.vaultPath, storedPath, payload);
48
+ const alt = input.alt?.trim() || basename(input.sourceName ?? assetPath);
49
+ const embed = `![${alt}](${assetPath})`;
50
+ const note = input.noteTitle
51
+ ? await addNoteWithMetadata(input.vaultPath, input.noteTitle, buildImageNoteContent(embed), agentId, {
52
+ autoContextLinks: input.autoContextLinks,
53
+ context: input.noteContext
54
+ })
55
+ : undefined;
56
+ return {
57
+ assetPath,
58
+ absolutePath,
59
+ mimeType,
60
+ bytes: input.data.byteLength,
61
+ alt,
62
+ encrypted,
63
+ embed,
64
+ agentId,
65
+ ...(note ? { note } : {})
66
+ };
67
+ };
@@ -0,0 +1,125 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ // Admin impersonation for the exposed graph server.
3
+ //
4
+ // An external control-plane (the brainlink-saas admin) can open a tenant's
5
+ // graph authenticated WITHOUT the tenant's web password by redirecting the
6
+ // admin's browser to `GET /api/admin-session?token=<jwt>`. The token is a
7
+ // short-lived, compact HS256 JWT that the server verifies against a shared
8
+ // secret. This is OPT-IN and OFF by default: with no secret configured the
9
+ // endpoint is disabled and nothing about the existing auth changes.
10
+ //
11
+ // Token contract (so the SaaS can mint compatible tokens):
12
+ // - Format: compact JWT `base64url(header).base64url(payload).base64url(sig)`.
13
+ // - Header: { "alg": "HS256", "typ": "JWT" } — alg MUST be HS256.
14
+ // - Payload claims:
15
+ // - `sub` and/or `aud`: MUST equal the server's configured web user
16
+ // (BRAINLINK_WEB_USER). At least one must be present and match — this
17
+ // binds the token to THIS tenant so it cannot be replayed elsewhere.
18
+ // - `iat`: issued-at, seconds since epoch.
19
+ // - `exp`: expiry, seconds since epoch. REQUIRED, must be in the future,
20
+ // and MUST be within `maxTtlSeconds` (600s) of `iat` — long-lived
21
+ // tokens are rejected server-side.
22
+ // - Signature: HMAC-SHA256 over `base64url(header).base64url(payload)` using
23
+ // the raw UTF-8 bytes of BRAINLINK_ADMIN_SESSION_SECRET.
24
+ export const ADMIN_SESSION_SECRET_ENV = 'BRAINLINK_ADMIN_SESSION_SECRET';
25
+ // Server-side cap on how far in the future an admin token may be valid. A token
26
+ // whose lifetime (exp - iat) exceeds this is rejected regardless of signature.
27
+ export const adminSessionMaxTtlSeconds = 600;
28
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
29
+ // The signing secret for admin impersonation, or null when the feature is
30
+ // disabled (no secret configured). Read from env exactly like the other
31
+ // exposed-server auth material, so no new wiring through the CLI is required.
32
+ export const getAdminSessionSecret = () => {
33
+ const secret = process.env[ADMIN_SESSION_SECRET_ENV]?.trim();
34
+ return isNonEmptyString(secret) ? secret : null;
35
+ };
36
+ const constantTimeEqual = (a, b) => {
37
+ const actual = Buffer.from(a, 'utf8');
38
+ const expected = Buffer.from(b, 'utf8');
39
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
40
+ };
41
+ const parseHeader = (value) => {
42
+ if (typeof value !== 'object' || value === null) {
43
+ return null;
44
+ }
45
+ const candidate = value;
46
+ return isNonEmptyString(candidate.alg) ? { alg: candidate.alg } : null;
47
+ };
48
+ const parseClaims = (value) => {
49
+ if (typeof value !== 'object' || value === null) {
50
+ return null;
51
+ }
52
+ const candidate = value;
53
+ if (typeof candidate.iat !== 'number' || typeof candidate.exp !== 'number') {
54
+ return null;
55
+ }
56
+ if (!Number.isFinite(candidate.iat) || !Number.isFinite(candidate.exp)) {
57
+ return null;
58
+ }
59
+ const sub = isNonEmptyString(candidate.sub) ? candidate.sub : undefined;
60
+ const aud = isNonEmptyString(candidate.aud) ? candidate.aud : undefined;
61
+ return {
62
+ ...(sub === undefined ? {} : { sub }),
63
+ ...(aud === undefined ? {} : { aud }),
64
+ iat: candidate.iat,
65
+ exp: candidate.exp
66
+ };
67
+ };
68
+ const decodeSegment = (segment) => {
69
+ const json = Buffer.from(segment, 'base64url').toString('utf8');
70
+ return JSON.parse(json);
71
+ };
72
+ // Verify a compact HS256 admin-session token. Returns true only when the
73
+ // signature is valid, the algorithm is HS256, the token is bound to
74
+ // `expectedUser` via `sub`/`aud`, it is unexpired, and its lifetime is within
75
+ // the server-side cap. Never throws and never leaks token/secret detail.
76
+ export const verifyAdminSessionToken = (token, secret, expectedUser, nowMs, maxTtlSeconds = adminSessionMaxTtlSeconds) => {
77
+ try {
78
+ if (!isNonEmptyString(token) || !isNonEmptyString(secret) || !isNonEmptyString(expectedUser)) {
79
+ return false;
80
+ }
81
+ const parts = token.split('.');
82
+ if (parts.length !== 3) {
83
+ return false;
84
+ }
85
+ const [headerB64, payloadB64, signature] = parts;
86
+ if (!isNonEmptyString(headerB64) || !isNonEmptyString(payloadB64) || !isNonEmptyString(signature)) {
87
+ return false;
88
+ }
89
+ const header = parseHeader(decodeSegment(headerB64));
90
+ if (header === null || header.alg !== 'HS256') {
91
+ return false;
92
+ }
93
+ const expectedSignature = createHmac('sha256', secret)
94
+ .update(`${headerB64}.${payloadB64}`)
95
+ .digest('base64url');
96
+ if (!constantTimeEqual(signature, expectedSignature)) {
97
+ return false;
98
+ }
99
+ const claims = parseClaims(decodeSegment(payloadB64));
100
+ if (claims === null) {
101
+ return false;
102
+ }
103
+ // Bind the token to this server's web user: at least one of sub/aud must be
104
+ // present and equal to the configured user, so a token minted for one tenant
105
+ // cannot authenticate a different tenant's server.
106
+ const audienceMatches = (claims.sub !== undefined && constantTimeEqual(claims.sub, expectedUser)) ||
107
+ (claims.aud !== undefined && constantTimeEqual(claims.aud, expectedUser));
108
+ if (!audienceMatches) {
109
+ return false;
110
+ }
111
+ // exp must be present, in the future, and the token lifetime must not exceed
112
+ // the server-side cap (reject long-lived tokens even if correctly signed).
113
+ const nowSeconds = Math.floor(nowMs / 1000);
114
+ if (claims.exp <= nowSeconds) {
115
+ return false;
116
+ }
117
+ if (claims.exp - claims.iat > maxTtlSeconds) {
118
+ return false;
119
+ }
120
+ return true;
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ };
@@ -24,6 +24,7 @@ import { createClientWorkerJs } from '../frontend/client-worker-js.js';
24
24
  import { createClientRenderWorkerJs } from '../frontend/client-render-worker-js.js';
25
25
  import { createLoginPageHtml, createChangePasswordPageHtml } from '../frontend/login-page.js';
26
26
  import { buildClearSessionCookie, buildSessionSetCookie, changeWebPassword, createSessionToken, parseCookies, resolveWebAuth, SESSION_COOKIE_NAME, verifySessionToken, verifyWebPassword } from './web-auth.js';
27
+ import { getAdminSessionSecret, verifyAdminSessionToken } from './admin-session.js';
27
28
  import { getGraphVersion } from './graph-version.js';
28
29
  import { readAuthStatus, readEditableSettings, updateEditableSettings } from './settings-store.js';
29
30
  import { connectVaultRemote, ensureDeployKey, readVersioningStatus } from './versioning.js';
@@ -124,6 +125,26 @@ const runAuthGate = async (request, url) => {
124
125
  }
125
126
  return createResponse(createJsonResponse({ error: 'Invalid username or password' }), 401, contentTypes['.json']);
126
127
  }
128
+ // Admin impersonation (opt-in, off by default). When BRAINLINK_ADMIN_SESSION_SECRET
129
+ // is configured, an external control-plane can open this tenant's graph by
130
+ // redirecting the admin's browser to /api/admin-session?token=<jwt>. A valid,
131
+ // short-lived, audience-bound token mints a normal session cookie — identical
132
+ // to /api/login — and 302-redirects to the graph. Without the secret the
133
+ // endpoint is disabled and indistinguishable from any other unknown path.
134
+ if (isReadMethod(request) && url.pathname === '/api/admin-session') {
135
+ const secret = getAdminSessionSecret();
136
+ if (secret === null) {
137
+ return createResponse(createJsonResponse({ error: 'Not found' }), 404, contentTypes['.json']);
138
+ }
139
+ const token = url.searchParams.get('token') ?? '';
140
+ if (!verifyAdminSessionToken(token, secret, auth.user, Date.now())) {
141
+ return createResponse(createJsonResponse({ error: 'Unauthorized' }), 401, contentTypes['.json']);
142
+ }
143
+ const sessionToken = createSessionToken(auth, sessionTtlMs, Date.now());
144
+ return withHeaders(redirectResponse('/'), {
145
+ 'set-cookie': buildSessionSetCookie(sessionToken, { secure: requestIsSecure(request), ttlMs: sessionTtlMs })
146
+ });
147
+ }
127
148
  if (request.method === 'POST' && url.pathname === '/api/logout') {
128
149
  return withHeaders(createResponse(createJsonResponse({ ok: true }), 200, contentTypes['.json']), {
129
150
  'set-cookie': buildClearSessionCookie()
@@ -1,3 +1,4 @@
1
+ import { extractImageRefs } from './image-refs.js';
1
2
  import { middleOutIndices } from './middle-out.js';
2
3
  import { estimateTokenCount } from './tokens.js';
3
4
  import { orderByMaximalMarginalRelevance } from './diversity.js';
@@ -27,6 +28,22 @@ const estimateFramingTokens = (result) => {
27
28
  return estimateTokenCount(framing) + sectionSeparatorTokens;
28
29
  };
29
30
  const estimateSectionTokens = (result) => contentTokens(result) + estimateFramingTokens(result);
31
+ // Build a context section from a retrieval result, attaching image descriptors
32
+ // derived from the (possibly truncated) content that will actually be rendered.
33
+ // Descriptors travel alongside the text so a client can fetch/render the image
34
+ // without the raw bytes ever being inlined into the context.
35
+ const toSection = (result, content) => {
36
+ const images = extractImageRefs(content);
37
+ return {
38
+ title: result.title,
39
+ path: result.path,
40
+ content,
41
+ score: result.score,
42
+ searchMode: result.searchMode,
43
+ tags: result.tags,
44
+ ...(images.length > 0 ? { images } : {})
45
+ };
46
+ };
30
47
  const byScore = (left, right) => right.score - left.score || left.title.localeCompare(right.title);
31
48
  const byOrdinal = (left, right) => (left.chunkOrdinal ?? Number.MAX_SAFE_INTEGER) - (right.chunkOrdinal ?? Number.MAX_SAFE_INTEGER);
32
49
  const middleOutDocumentResults = (results) => {
@@ -72,17 +89,7 @@ export const selectContextSections = (results, maxTokens) => {
72
89
  break;
73
90
  }
74
91
  usedTokens += tokenCost;
75
- sections = [
76
- ...sections,
77
- {
78
- title: result.title,
79
- path: result.path,
80
- content: result.content,
81
- score: result.score,
82
- searchMode: result.searchMode,
83
- tags: result.tags
84
- }
85
- ];
92
+ sections = [...sections, toSection(result, result.content)];
86
93
  seenChunks = new Set([...seenChunks, result.chunkId]);
87
94
  }
88
95
  return {
@@ -106,16 +113,7 @@ export const selectContextSections = (results, maxTokens) => {
106
113
  const topResult = [...results].sort(byScore)[0];
107
114
  const framingTokens = estimateFramingTokens(topResult);
108
115
  const contentTokenBudget = maxTokens - headerReserveTokens - framingTokens;
109
- return [
110
- {
111
- title: topResult.title,
112
- path: topResult.path,
113
- content: truncateToTokens(topResult.content, contentTokenBudget),
114
- score: topResult.score,
115
- searchMode: topResult.searchMode,
116
- tags: topResult.tags
117
- }
118
- ];
116
+ return [toSection(topResult, truncateToTokens(topResult.content, contentTokenBudget))];
119
117
  };
120
118
  export const formatContextPackage = (query, sections) => {
121
119
  const body = sections
@@ -125,6 +123,11 @@ export const formatContextPackage = (query, sections) => {
125
123
  section.tags.length > 0 ? `Tags: ${section.tags.map((tag) => `#${tag}`).join(' ')}` : null,
126
124
  `Score: ${section.score.toFixed(3)}`,
127
125
  `Mode: ${section.searchMode}`,
126
+ section.images && section.images.length > 0
127
+ ? `Images: ${section.images
128
+ .map((image) => `${image.ref}${image.mime ? ` (${image.mime})` : ''}${image.alt ? ` — ${image.alt}` : ''}`)
129
+ .join('; ')}`
130
+ : null,
128
131
  section.volatile ? `Volatile: true${section.expiresAt ? `, expires ${section.expiresAt}` : ''}` : null,
129
132
  '',
130
133
  section.content
@@ -0,0 +1,63 @@
1
+ // Obsidian-style embed: ![[path/to/image.png|optional alt]]. The leading "!"
2
+ // distinguishes it from a plain [[wiki link]] and must be consumed here so wiki
3
+ // link extraction (which now ignores a "!"-prefixed opener) does not also treat
4
+ // the embed target as a graph link.
5
+ const imageEmbedPattern = /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
6
+ // Standard Markdown image: ![alt](path). The target may carry an optional
7
+ // "title" in quotes, which is dropped — only the resource path is kept.
8
+ const markdownImagePattern = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
9
+ const imageMimeByExtension = {
10
+ png: 'image/png',
11
+ jpg: 'image/jpeg',
12
+ jpeg: 'image/jpeg',
13
+ gif: 'image/gif',
14
+ webp: 'image/webp',
15
+ svg: 'image/svg+xml',
16
+ bmp: 'image/bmp',
17
+ avif: 'image/avif',
18
+ heic: 'image/heic'
19
+ };
20
+ const extensionOf = (ref) => {
21
+ const withoutQuery = ref.split(/[?#]/)[0] ?? ref;
22
+ const dot = withoutQuery.lastIndexOf('.');
23
+ return dot >= 0 ? withoutQuery.slice(dot + 1).toLowerCase() : '';
24
+ };
25
+ // Inferred image mime for a reference, or undefined when the extension is not a
26
+ // recognized image type (e.g. a remote URL without an extension).
27
+ export const imageMimeForRef = (ref) => imageMimeByExtension[extensionOf(ref)];
28
+ const isImageRef = (ref) => imageMimeForRef(ref) !== undefined;
29
+ const withMime = (partial) => {
30
+ const mime = imageMimeForRef(partial.ref);
31
+ return mime ? { ...partial, mime } : partial;
32
+ };
33
+ const dedupe = (refs) => {
34
+ const seen = new Set();
35
+ return refs.filter((image) => {
36
+ const key = `${image.kind}:${image.ref}`;
37
+ if (seen.has(key)) {
38
+ return false;
39
+ }
40
+ seen.add(key);
41
+ return true;
42
+ });
43
+ };
44
+ // Extract image references from raw Markdown text. Embeds (![[...]]) always
45
+ // count as images; inline images (![alt](path)) count only when the target has
46
+ // a recognizable image extension, so ![text](note.md) style non-image links are
47
+ // not misread. Callers that must ignore fenced code blocks should pre-filter
48
+ // the content — this operates on the text it is given.
49
+ export const extractImageRefs = (content) => {
50
+ const embeds = Array.from(content.matchAll(imageEmbedPattern), (match) => withMime({
51
+ kind: 'embed',
52
+ ref: match[1].trim(),
53
+ alt: (match[2] ?? match[1]).trim()
54
+ }));
55
+ const inline = Array.from(content.matchAll(markdownImagePattern), (match) => withMime({
56
+ kind: 'inline',
57
+ ref: match[2].trim(),
58
+ alt: match[1].trim()
59
+ })).filter((image) => isImageRef(image.ref));
60
+ return dedupe([...embeds, ...inline]);
61
+ };
62
+ // True when the content carries at least one image reference.
63
+ export const hasImageRefs = (content) => extractImageRefs(content).length > 0;
@@ -1,13 +1,17 @@
1
1
  import { basename, relative } from 'node:path';
2
2
  import { resolveAgentIdFromPath, sanitizeAgentId } from './agents.js';
3
3
  import { createStableId } from './ids.js';
4
+ import { extractImageRefs } from './image-refs.js';
4
5
  import { estimateTokenCount } from './tokens.js';
5
6
  const frontmatterPattern = /^---\n([\s\S]*?)\n---\n?/;
6
7
  // Target captures everything up to an optional "|alias" or the closing "]]",
7
8
  // INCLUDING "#" — the heading anchor is stripped later, and only when it is a
8
9
  // real anchor (see stripHeadingAnchor). Keeping "#" here means a title such as
9
10
  // "note (PR #34)" is not truncated at the "#".
10
- const wikiLinkPattern = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
11
+ // The negative lookbehind keeps a "!"-prefixed opener (an image embed
12
+ // ![[image.png]]) out of graph links: the embed target is an image reference,
13
+ // not a note link, and is captured separately by extractImageRefs.
14
+ const wikiLinkPattern = /(?<!!)\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
11
15
  const tagPattern = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
12
16
  const headingPattern = /^#\s+(.+)$/m;
13
17
  const contextLinksHeadingPattern = /^(#{1,6})\s+(?:context\s+links?|links?\s+de\s+contexto)\b/i;
@@ -168,6 +172,13 @@ const extractTitle = (filePath, content, frontmatter) => {
168
172
  };
169
173
  export const extractWikiLinks = (content) => unique(extractWikiLinkReferences(content).map((reference) => reference.title));
170
174
  export const extractTags = (content) => unique(Array.from(stripFencedCodeBlocks(stripFrontmatter(content)).matchAll(tagPattern), (match) => match[2]));
175
+ // Image references from the visible Markdown (fenced code blocks excluded, so a
176
+ // fenced example embed is not treated as a real image), mirroring how wiki
177
+ // links and tags ignore code blocks.
178
+ export const extractDocumentImageRefs = (content) => extractImageRefs(visibleMarkdownLines(content)
179
+ .filter((line) => !line.fenced)
180
+ .map((line) => line.content)
181
+ .join('\n'));
171
182
  const normalizeChunkContent = (content) => content
172
183
  .split('\n')
173
184
  .map((line) => line.trim())
@@ -241,6 +252,7 @@ export const parseMarkdownDocument = (input) => {
241
252
  const frontmatter = parseFrontmatter(input.content);
242
253
  const title = extractTitle(input.absolutePath, input.content, frontmatter);
243
254
  const agentId = frontmatter.agent ? sanitizeAgentId(frontmatter.agent) : resolveAgentIdFromPath(relativePath);
255
+ const images = extractDocumentImageRefs(input.content);
244
256
  return {
245
257
  id: createStableId(relativePath),
246
258
  agentId,
@@ -250,6 +262,9 @@ export const parseMarkdownDocument = (input) => {
250
262
  tags: extractTags(input.content),
251
263
  links: extractWikiLinks(input.content),
252
264
  contextLinks: extractContextLinkWeights(input.content).map((link) => link.title),
265
+ // Keep the field absent (not an empty array) for image-free notes so the
266
+ // shape matches documents parsed before image support.
267
+ ...(images.length > 0 ? { images } : {}),
253
268
  frontmatter,
254
269
  createdAt: input.createdAt.toISOString(),
255
270
  updatedAt: input.updatedAt.toISOString()
@@ -109,6 +109,26 @@ export const writeMarkdownFile = async (vaultPath, filename, content) => {
109
109
  await chmod(absolutePath, fileMode);
110
110
  return absolutePath;
111
111
  };
112
+ // Persist an arbitrary binary asset (e.g. an image) under a vault-relative
113
+ // path. Unlike writeMarkdownFile this keeps the caller-provided extension and
114
+ // writes raw bytes, but shares the same containment guard so nothing is written
115
+ // outside the vault. Encryption-at-rest for versioning is layered on by the
116
+ // caller (add-image), which mirrors the plaintext asset to a ".enc" file the
117
+ // same way Markdown notes are handled.
118
+ export const writeVaultBinaryFile = async (vaultPath, filename, data) => {
119
+ if (isBucketVaultUri(vaultPath)) {
120
+ throw new Error('Writing binary assets to bucket vaults is not supported yet.');
121
+ }
122
+ const absoluteVaultPath = await ensureVault(vaultPath);
123
+ const absolutePath = resolve(absoluteVaultPath, filename);
124
+ if (!isPathInside(absoluteVaultPath, absolutePath)) {
125
+ throw new Error(`Refusing to write outside vault: ${absolutePath}`);
126
+ }
127
+ await secureDirectory(dirname(absolutePath));
128
+ await writeFile(absolutePath, data, { mode: fileMode });
129
+ await chmod(absolutePath, fileMode);
130
+ return absolutePath;
131
+ };
112
132
  export const deleteMarkdownFile = async (vaultPath, filename) => {
113
133
  if (isBucketVaultUri(vaultPath)) {
114
134
  throw new Error('Deleting bucket vault notes is not supported from Brainlink yet. Remove bucket objects with your storage provider tooling.');
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, vaultBundleListInputSchema, vaultBundleListTool, vaultCommitInputSchema, vaultCommitTool, vaultExportBundleInputSchema, vaultExportBundleTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportBundleInputSchema, vaultImportBundleTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, versionInputSchema, versionTool } from './tools.js';
2
+ import { addNoteInputSchema, addFileInputSchema, addFileTool, addImageInputSchema, addImageTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, vaultBundleListInputSchema, vaultBundleListTool, vaultCommitInputSchema, vaultCommitTool, vaultExportBundleInputSchema, vaultExportBundleTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportBundleInputSchema, vaultImportBundleTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, versionInputSchema, versionTool } from './tools.js';
3
3
  import { getRuntimeVersion } from './runtime.js';
4
4
  import { brainlinkServerInstructions } from './server-instructions.js';
5
5
  import { guardToolHandler } from './tool-guard.js';
@@ -126,6 +126,11 @@ export const createBrainlinkMcpServer = () => {
126
126
  description: 'Read a local markdown/text file and ingest it as a Brainlink note. Reindex defaults to true.',
127
127
  inputSchema: addFileInputSchema
128
128
  }, addFileTool);
129
+ server.registerTool('brainlink_add_image', {
130
+ title: 'Add Brainlink Image',
131
+ description: 'Store an image in the vault (encrypted at rest when vault encryption is on) and address it by a stable vault-relative path. Provide imageBase64 or filePath. Optionally pass noteTitle to create a note that embeds the image so retrieval can surface it in context. Alt text is included in the returned context descriptor.',
132
+ inputSchema: addImageInputSchema
133
+ }, addImageTool);
129
134
  server.registerTool('brainlink_canonicalize_context_links', {
130
135
  title: 'Canonicalize Brainlink Context Links',
131
136
  description: 'Ensure notes have canonical Context Links to inferred context hubs. Supports dry-run and can create missing hub notes.',
@@ -1,5 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
2
3
  import { z } from 'zod';
4
+ import { addImage } from '../../application/add-image.js';
3
5
  import { addNoteWithMetadata } from '../../application/add-note.js';
4
6
  import { deleteNote } from '../../application/delete-note.js';
5
7
  import { scanDuplicateNotes } from '../../application/dedupe-notes.js';
@@ -353,6 +355,89 @@ export const volatileClearTool = async (input) => {
353
355
  cleared
354
356
  });
355
357
  };
358
+ export const addImageInputSchema = {
359
+ ...vaultInput,
360
+ ...agentInput,
361
+ imageBase64: z
362
+ .string()
363
+ .min(1)
364
+ .optional()
365
+ .describe('Base64-encoded image bytes. Provide this or filePath.'),
366
+ filePath: z
367
+ .string()
368
+ .min(1)
369
+ .optional()
370
+ .describe('Local filesystem path to an image file to read. Provide this or imageBase64.'),
371
+ fileName: z
372
+ .string()
373
+ .min(1)
374
+ .optional()
375
+ .describe('Original image file name (used to infer extension/mime when passing imageBase64).'),
376
+ mimeType: z.string().min(1).optional().describe('Explicit image mime type, e.g. image/png. Inferred from the name when omitted.'),
377
+ alt: z.string().min(1).optional().describe('Alt text / description for the image, surfaced in retrieved context.'),
378
+ noteTitle: z
379
+ .string()
380
+ .min(1)
381
+ .optional()
382
+ .describe('When set, create a Markdown note that embeds the stored image so retrieval can surface it in context.'),
383
+ context: z
384
+ .string()
385
+ .min(1)
386
+ .optional()
387
+ .describe('Domain context for the generated note (partitions it under a "<context> Hub"). Only used with noteTitle.'),
388
+ autoIndex: z.boolean().optional().default(true).describe('Reindex vault after storing the image (only meaningful when a note is created).')
389
+ };
390
+ export const addImageTool = async (input) => {
391
+ const context = await resolveExecutionContext(input);
392
+ if (!input.imageBase64 && !input.filePath) {
393
+ throw new Error('Provide either imageBase64 or filePath to add an image.');
394
+ }
395
+ if (input.imageBase64 && input.filePath) {
396
+ throw new Error('Provide only one of imageBase64 or filePath, not both.');
397
+ }
398
+ const data = input.filePath ? await readFile(input.filePath) : Buffer.from(input.imageBase64 ?? '', 'base64');
399
+ const sourceName = input.fileName ?? (input.filePath ? basename(input.filePath) : undefined);
400
+ const result = await addImage({
401
+ vaultPath: context.vault,
402
+ data,
403
+ agentId: context.agent,
404
+ sourceName,
405
+ mimeType: input.mimeType,
406
+ alt: input.alt,
407
+ noteTitle: input.noteTitle,
408
+ noteContext: input.context,
409
+ autoContextLinks: context.config.autoCanonicalContextLinks
410
+ });
411
+ const index = result.note && isTruthy(input.autoIndex) ? await indexVault(context.vault) : undefined;
412
+ return jsonResult({
413
+ vault: context.vault,
414
+ agent: result.agentId,
415
+ image: {
416
+ path: result.assetPath,
417
+ mime: result.mimeType,
418
+ bytes: result.bytes,
419
+ alt: result.alt,
420
+ encrypted: result.encrypted,
421
+ embed: result.embed
422
+ },
423
+ ...(result.note
424
+ ? {
425
+ note: {
426
+ title: input.noteTitle,
427
+ path: result.note.path,
428
+ writeConnectivity: {
429
+ autoLinked: result.note.autoLinked,
430
+ linkTarget: result.note.linkTarget,
431
+ context: result.note.context,
432
+ hubCreated: result.note.hubCreated,
433
+ guaranteedEdge: result.note.autoLinked
434
+ }
435
+ }
436
+ }
437
+ : {}),
438
+ ...(index ? { index } : {})
439
+ });
440
+ };
356
441
  export const addFileTool = async (input) => {
357
442
  const context = await resolveExecutionContext(input);
358
443
  const content = await readFile(input.filePath, 'utf8');
package/dist/mcp/tools.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { brokenLinksInputSchema, brokenLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, explainInputSchema, explainTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, orphansInputSchema, orphansTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools/read-tools.js';
2
- export { addFileInputSchema, addFileTool, addNoteInputSchema, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, rememberInputSchema, rememberTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool } from './tools/write-tools.js';
2
+ export { addFileInputSchema, addFileTool, addImageInputSchema, addImageTool, addNoteInputSchema, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, rememberInputSchema, rememberTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool } from './tools/write-tools.js';
3
3
  export { bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, indexInputSchema, indexTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, repairLinksInputSchema, repairLinksTool, sessionCloseInputSchema, sessionCloseTool, syncInputSchema, syncTool } from './tools/maintenance-tools.js';
4
4
  export { vaultBundleListInputSchema, vaultBundleListTool, vaultCommitInputSchema, vaultCommitTool, vaultExportBundleInputSchema, vaultExportBundleTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportBundleInputSchema, vaultImportBundleTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool } from './tools/vault-tools.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.6.12",
3
+ "version": "1.6.14",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",