@andespindola/brainlink 1.6.13 → 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 +4 -0
- package/dist/application/add-image.js +67 -0
- package/dist/domain/context.js +24 -21
- package/dist/domain/image-refs.js +63 -0
- package/dist/domain/markdown.js +16 -1
- package/dist/infrastructure/file-system-vault.js +20 -0
- package/dist/mcp/server.js +6 -1
- package/dist/mcp/tools/write-tools.js +85 -0
- package/dist/mcp/tools.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
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 ``) 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
|
+
|
|
3
7
|
## 1.6.13
|
|
4
8
|
|
|
5
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 `/`.
|
|
@@ -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 = ``;
|
|
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
|
+
};
|
package/dist/domain/context.js
CHANGED
|
@@ -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: . 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 () count only when the target has
|
|
46
|
+
// a recognizable image extension, so  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;
|
package/dist/domain/markdown.js
CHANGED
|
@@ -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
|
-
|
|
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.');
|
package/dist/mcp/server.js
CHANGED
|
@@ -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