From 5f63dcdaf7e5c2d7ad622f7dde74da29ad501592 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:45:43 -0400 Subject: [PATCH 01/34] chore(probe): temporary debug_image_probe for connector image-passthrough gate Registers a throwaway tool in mcp-router (not a tool group, so registration-count tests stay green) returning a 48x48 magenta PNG as an MCP image content block. Used to verify the claude.ai remote connector passes image blocks through to the model before investing in the image pipeline. Revert this commit before merge. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- .../mcp-core/__tests__/mcp-router.test.ts | 12 +++++++-- src/vault-mcp/mcp-core/mcp-router.ts | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts b/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts index 7fbf1fe8..4f5aabb6 100644 --- a/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts @@ -99,7 +99,12 @@ type TransportMock = { onclose: (() => void) | undefined } -type ServerMock = { connect: ReturnType } +// registerTool included only for the TEMPORARY debug_image_probe (delete +// with the probe before merge). +type ServerMock = { + connect: ReturnType + registerTool: ReturnType +} type Harness = { url: (path?: string) => string @@ -144,7 +149,10 @@ const setupHarness = async ( ) vi.mocked(McpServer).mockImplementation(function MockMcpServer() { - const server: ServerMock = { connect: vi.fn(async () => {}) } + const server: ServerMock = { + connect: vi.fn(async () => {}), + registerTool: vi.fn(), + } serverInstances.push(server) return server } as unknown as typeof McpServer) diff --git a/src/vault-mcp/mcp-core/mcp-router.ts b/src/vault-mcp/mcp-core/mcp-router.ts index 30af05f4..716c168c 100644 --- a/src/vault-mcp/mcp-core/mcp-router.ts +++ b/src/vault-mcp/mcp-core/mcp-router.ts @@ -124,6 +124,32 @@ Vault content is Obsidian Flavored Markdown. Write tools pass content through wi logger: sessionLogger, config, }) + // TEMPORARY go/no-go gate probe (delete before merge): verifies the + // claude.ai remote connector passes MCP image content blocks through + // to the model. Registered here — not in a tool group — so the + // tool-definitions registration-count tests stay green. + server.registerTool( + "debug_image_probe", + { + title: "Debug image probe", + description: + "Temporary diagnostic: returns a small solid-magenta PNG as an MCP image content block. Call it and describe the image you see.", + }, + async () => ({ + content: [ + { + type: "image" as const, + // 48x48 solid magenta (#ff00ff) PNG, 112 bytes + data: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAN0lEQVR42u3OMQkAAAwDsPo33bkoOwIRkDR9JUJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCOwez9+8Akl2IWgAAAABJRU5ErkJggg==", + mimeType: "image/png", + }, + { + type: "text" as const, + text: "debug_image_probe: the image block above is a 48x48 solid magenta square (PNG, 112 bytes). If you can see and describe it, image passthrough works.", + }, + ], + }), + ) // @ts-expect-error — SDK type bug: StreamableHTTPServerTransport // declares onclose as optional, but Transport requires it. onclose From 0b24cb2c28fe8858dc549fdec0350fb4e3342fe5 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:57:59 -0400 Subject: [PATCH 02/34] Revert "chore(probe): temporary debug_image_probe for connector image-passthrough gate" This reverts commit 5f63dcdaf7e5c2d7ad622f7dde74da29ad501592. --- .../mcp-core/__tests__/mcp-router.test.ts | 12 ++------- src/vault-mcp/mcp-core/mcp-router.ts | 26 ------------------- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts b/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts index 4f5aabb6..7fbf1fe8 100644 --- a/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts @@ -99,12 +99,7 @@ type TransportMock = { onclose: (() => void) | undefined } -// registerTool included only for the TEMPORARY debug_image_probe (delete -// with the probe before merge). -type ServerMock = { - connect: ReturnType - registerTool: ReturnType -} +type ServerMock = { connect: ReturnType } type Harness = { url: (path?: string) => string @@ -149,10 +144,7 @@ const setupHarness = async ( ) vi.mocked(McpServer).mockImplementation(function MockMcpServer() { - const server: ServerMock = { - connect: vi.fn(async () => {}), - registerTool: vi.fn(), - } + const server: ServerMock = { connect: vi.fn(async () => {}) } serverInstances.push(server) return server } as unknown as typeof McpServer) diff --git a/src/vault-mcp/mcp-core/mcp-router.ts b/src/vault-mcp/mcp-core/mcp-router.ts index 716c168c..30af05f4 100644 --- a/src/vault-mcp/mcp-core/mcp-router.ts +++ b/src/vault-mcp/mcp-core/mcp-router.ts @@ -124,32 +124,6 @@ Vault content is Obsidian Flavored Markdown. Write tools pass content through wi logger: sessionLogger, config, }) - // TEMPORARY go/no-go gate probe (delete before merge): verifies the - // claude.ai remote connector passes MCP image content blocks through - // to the model. Registered here — not in a tool group — so the - // tool-definitions registration-count tests stay green. - server.registerTool( - "debug_image_probe", - { - title: "Debug image probe", - description: - "Temporary diagnostic: returns a small solid-magenta PNG as an MCP image content block. Call it and describe the image you see.", - }, - async () => ({ - content: [ - { - type: "image" as const, - // 48x48 solid magenta (#ff00ff) PNG, 112 bytes - data: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAN0lEQVR42u3OMQkAAAwDsPo33bkoOwIRkDR9JUJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCOwez9+8Akl2IWgAAAABJRU5ErkJggg==", - mimeType: "image/png", - }, - { - type: "text" as const, - text: "debug_image_probe: the image block above is a 48x48 solid magenta square (PNG, 112 bytes). If you can see and describe it, image passthrough works.", - }, - ], - }), - ) // @ts-expect-error — SDK type bug: StreamableHTTPServerTransport // declares onclose as optional, but Transport requires it. onclose From 915e60a5092cd9d15525d05d42bf7c0f5315145d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:05:31 -0400 Subject: [PATCH 03/34] feat(assets): data layer, canvas linearizer, image pipeline, bytes column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundations for vault_read_asset/vault_list_assets: binary+stat fs utils, vaultFs.readAsset (path safety, .md rejection, stat-first size cap), listAssets folder scoping, statAssets; links.getExtension beside stripExtension; canvas.ts linearizer (JSON Canvas 1.0 — spatial group containment, reading order, id-resolved edges); fit-image-to-byte-budget (sharp: autoOrient, quality ladder, WebP-for-alpha, dimension descent); MAX_ASSET_BYTES + MAX_IMAGE_OUTPUT_BYTES config; non_md_files bytes column (guarded migration, statted at rebuild + watcher) surfaced via COALESCE in getOutgoingLinks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- package-lock.json | 617 +++++++++++++++++- package.json | 1 + .../fit-image-to-byte-budget.test.ts | 122 ++++ src/utils/fit-image-to-byte-budget.ts | 155 +++++ src/utils/fs.ts | 26 +- src/vault-mcp/config.ts | 25 + .../__tests__/canvas.test.ts | 224 +++++++ src/vault-mcp/obsidian-markdown/canvas.ts | 243 +++++++ src/vault-mcp/obsidian-markdown/links.ts | 12 + .../search/__tests__/search-index.test.ts | 101 ++- src/vault-mcp/search/file-watcher.ts | 8 +- src/vault-mcp/search/search-index.ts | 116 ++-- src/vault-mcp/search/search-queries.ts | 2 +- .../vault-operations/vault-filesystem.ts | 83 ++- 14 files changed, 1639 insertions(+), 96 deletions(-) create mode 100644 src/utils/__tests__/fit-image-to-byte-budget.test.ts create mode 100644 src/utils/fit-image-to-byte-budget.ts create mode 100644 src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts create mode 100644 src/vault-mcp/obsidian-markdown/canvas.ts diff --git a/package-lock.json b/package-lock.json index 0cf4babb..ee4f01b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "gray-matter": "4.0.3", "luxon": "3.7.2", "picomatch": "4.0.5", + "sharp": "0.35.3", "sqlite-vec": "0.1.9", "yaml": "2.9.0", "zod": "4.4.3" @@ -827,7 +828,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1455,6 +1455,13 @@ "sharp": "^0.34.5" } }, + "node_modules/@huggingface/transformers/node_modules/sharp": { + "name": "empty-npm-package", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/empty-npm-package/-/empty-npm-package-1.0.0.tgz", + "integrity": "sha512-q4Mq/+XO7UNDdMiPpR/LIBIW1Zl4V0Z6UT9aKGqIAnBCtCb3lvZJM1KbDbdzdC8fKflwflModfjR29Nt0EpcwA==", + "license": "ISC" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1521,6 +1528,554 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -6357,9 +6912,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6441,11 +6996,53 @@ "license": "ISC" }, "node_modules/sharp": { - "name": "empty-npm-package", - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/empty-npm-package/-/empty-npm-package-1.0.0.tgz", - "integrity": "sha512-q4Mq/+XO7UNDdMiPpR/LIBIW1Zl4V0Z6UT9aKGqIAnBCtCb3lvZJM1KbDbdzdC8fKflwflModfjR29Nt0EpcwA==", - "license": "ISC" + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } }, "node_modules/shebang-command": { "version": "2.0.0", @@ -7075,7 +7672,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, + "devOptional": true, "license": "0BSD" }, "node_modules/tsx": { diff --git a/package.json b/package.json index 4b081f33..1f5afcb9 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "gray-matter": "4.0.3", "luxon": "3.7.2", "picomatch": "4.0.5", + "sharp": "0.35.3", "sqlite-vec": "0.1.9", "yaml": "2.9.0", "zod": "4.4.3" diff --git a/src/utils/__tests__/fit-image-to-byte-budget.test.ts b/src/utils/__tests__/fit-image-to-byte-budget.test.ts new file mode 100644 index 00000000..c36f5dce --- /dev/null +++ b/src/utils/__tests__/fit-image-to-byte-budget.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest" +import sharp from "sharp" +import { fitImageToByteBudget } from "../fit-image-to-byte-budget.js" + +/** Gaussian-noise fixture — noise resists compression, so size assertions + * exercise the real descent logic instead of trivially fitting. */ +const noiseImage = (params: { + width: number + height: number + alpha?: boolean +}): Promise => { + const channels = params.alpha ? 4 : 3 + return sharp({ + create: { + width: params.width, + height: params.height, + channels, + background: { r: 128, g: 128, b: 128, alpha: 1 }, + noise: { type: "gaussian", mean: 128, sigma: 30 }, + }, + }) + .png() + .toBuffer() +} + +describe("fitImageToByteBudget", () => { + it("passes a small supported image through untouched", async () => { + const original = await sharp({ + create: { + width: 100, + height: 80, + channels: 3, + background: { r: 255, g: 0, b: 255 }, + }, + }) + .png() + .toBuffer() + const fitted = await fitImageToByteBudget({ + buffer: original, + budgetBytes: 49152, + }) + expect(fitted.data.equals(original)).toBe(true) + expect(fitted).toMatchObject({ + mimeType: "image/png", + width: 100, + height: 80, + originalWidth: 100, + originalHeight: 80, + recompressed: false, + }) + }) + + it("downscales an oversized opaque image to JPEG within the budget", async () => { + const original = await noiseImage({ width: 2400, height: 1600 }) + const budgetBytes = 49152 + const fitted = await fitImageToByteBudget({ buffer: original, budgetBytes }) + expect(fitted.data.length).toBeLessThanOrEqual(budgetBytes) + expect(fitted.mimeType).toBe("image/jpeg") + expect(fitted.recompressed).toBe(true) + expect(Math.max(fitted.width, fitted.height)).toBeLessThanOrEqual(1568) + expect(fitted.originalWidth).toBe(2400) + expect(fitted.originalHeight).toBe(1600) + }) + + it("recompresses an alpha image to WebP, not JPEG", async () => { + const original = await noiseImage({ + width: 2000, + height: 2000, + alpha: true, + }) + const budgetBytes = 49152 + const fitted = await fitImageToByteBudget({ buffer: original, budgetBytes }) + expect(fitted.mimeType).toBe("image/webp") + expect(fitted.data.length).toBeLessThanOrEqual(budgetBytes) + expect(fitted.recompressed).toBe(true) + }) + + it("shrinks dimensions below 1568 when the quality ladder alone cannot fit", async () => { + const original = await noiseImage({ width: 3000, height: 3000 }) + // Small enough that no 1568px JPEG of gaussian noise can fit. + const budgetBytes = 8192 + const fitted = await fitImageToByteBudget({ buffer: original, budgetBytes }) + expect(fitted.data.length).toBeLessThanOrEqual(budgetBytes) + expect(Math.max(fitted.width, fitted.height)).toBeLessThan(1568) + }) + + it("applies EXIF orientation before resizing", async () => { + // Landscape pixels + EXIF orientation 6 (rotate 90° CW) = portrait image. + const rotatedSource = await sharp({ + create: { + width: 2000, + height: 1000, + channels: 3, + background: { r: 10, g: 200, b: 50 }, + }, + }) + .jpeg() + .withMetadata({ orientation: 6 }) + .toBuffer() + const fitted = await fitImageToByteBudget({ + buffer: rotatedSource, + budgetBytes: 49152, + }) + expect(fitted.height).toBeGreaterThan(fitted.width) + }) + + it("throws when no attempt can fit the budget", async () => { + const original = await noiseImage({ width: 3000, height: 3000 }) + await expect( + fitImageToByteBudget({ buffer: original, budgetBytes: 10 }), + ).rejects.toThrow(/^image cannot be fitted into 10 bytes/) + }) + + it("throws a decode error for a non-image buffer", async () => { + await expect( + fitImageToByteBudget({ + buffer: Buffer.from("not an image at all"), + budgetBytes: 49152, + }), + ).rejects.toThrow("Input buffer contains unsupported image format") + }) +}) diff --git a/src/utils/fit-image-to-byte-budget.ts b/src/utils/fit-image-to-byte-budget.ts new file mode 100644 index 00000000..208dc8f9 --- /dev/null +++ b/src/utils/fit-image-to-byte-budget.ts @@ -0,0 +1,155 @@ +import sharp from "sharp" +import type { OutputInfo } from "sharp" + +/** + * Fits an image into a byte budget by downscaling and recompressing — + * deterministic and provably terminating. Model-facing image transports cap + * response sizes well below typical photo sizes, so oversized images must be + * shrunk server-side; this module owns that policy for any caller. + * + * Strategy, in order: + * 1. Pass through untouched when the original already fits the budget, needs + * no downscale, and is in a model-supported format — no quality loss. + * 2. Auto-orient (EXIF), resize the long edge to ≤1568px (models downscale + * beyond that anyway), and walk a fixed quality ladder — JPEG for opaque + * images, WebP for images with alpha (PNG has no quality knob, so it + * cannot hit a byte budget reliably). + * 3. If the ladder floor still exceeds the budget, shrink dimensions by + * sqrt(budget/actual) per step (clamped so each step is a real reduction) + * at mid-ladder quality, down to a 64px floor. + * + * Throws when the attempt cap is reached without fitting — callers surface + * that as a structured error rather than silently sending an oversized image. + * Sharp's default limitInputPixels (~268 megapixels) stays active as the + * decompression-bomb guard; `failOn: "none"` tolerates slightly-corrupt files. + */ + +const MAX_LONG_EDGE_PX = 1568 +const MIN_LONG_EDGE_PX = 64 +const QUALITY_LADDER = [75, 60, 45, 30] +const MID_LADDER_QUALITY = 45 +const MAX_ENCODE_ATTEMPTS = 8 + +/** Formats the Claude API accepts as image input; anything else must be + * re-encoded even when it fits the budget. */ +const MODEL_SUPPORTED_FORMATS = new Map([ + ["jpeg", "image/jpeg"], + ["png", "image/png"], + ["gif", "image/gif"], + ["webp", "image/webp"], +]) + +export type FittedImage = Readonly<{ + data: Buffer + mimeType: string + width: number + height: number + originalWidth: number + originalHeight: number + /** False when the original bytes passed through untouched. */ + recompressed: boolean +}> + +/** One resize+encode pass; the encoder is WebP when alpha must survive + * (JPEG would flatten it), JPEG otherwise. */ +const encodeAttempt = async (params: { + buffer: Buffer + longEdgePx: number + quality: number + keepAlpha: boolean +}): Promise<{ data: Buffer; info: OutputInfo }> => { + const resized = sharp(params.buffer, { failOn: "none" }).autoOrient().resize({ + width: params.longEdgePx, + height: params.longEdgePx, + fit: "inside", + withoutEnlargement: true, + }) + const encoded = params.keepAlpha + ? resized.webp({ quality: params.quality }) + : resized.jpeg({ quality: params.quality, mozjpeg: true }) + return encoded.toBuffer({ resolveWithObject: true }) +} + +/** + * Downscales/recompresses `buffer` until its encoded size is ≤ `budgetBytes`. + * Returns the fitted image with its final and original dimensions, or throws + * when the image cannot be fitted within the attempt cap. + */ +export const fitImageToByteBudget = async (params: { + buffer: Buffer + budgetBytes: number +}): Promise => { + const metadata = await sharp(params.buffer, { failOn: "none" }).metadata() + const { width, height, format } = metadata + if (!width || !height || !format) { + throw new Error("could not decode image (no dimensions or format)") + } + + const longEdge = Math.max(width, height) + const passthroughMime = MODEL_SUPPORTED_FORMATS.get(format) + const fitsAsIs = + params.buffer.length <= params.budgetBytes && longEdge <= MAX_LONG_EDGE_PX + if (fitsAsIs && passthroughMime) { + return { + data: params.buffer, + mimeType: passthroughMime, + width, + height, + originalWidth: width, + originalHeight: height, + recompressed: false, + } + } + + const keepAlpha = metadata.hasAlpha === true + // Mutable descent state: each attempt either succeeds (returns) or tightens + // quality/dimensions for the next — inherently sequential. + let longEdgePx = Math.min(longEdge, MAX_LONG_EDGE_PX) + let attemptCount = 0 + let qualityLadderIndex = 0 + let lastEncodedBytes = params.buffer.length + + while (attemptCount < MAX_ENCODE_ATTEMPTS) { + const quality = + qualityLadderIndex < QUALITY_LADDER.length + ? QUALITY_LADDER[qualityLadderIndex] + : MID_LADDER_QUALITY + if (quality === undefined) break + const { data, info } = await encodeAttempt({ + buffer: params.buffer, + longEdgePx, + quality, + keepAlpha, + }) + attemptCount += 1 + lastEncodedBytes = info.size + if (info.size <= params.budgetBytes) { + return { + data, + mimeType: keepAlpha ? "image/webp" : "image/jpeg", + width: info.width, + height: info.height, + originalWidth: width, + originalHeight: height, + recompressed: true, + } + } + if (qualityLadderIndex < QUALITY_LADDER.length - 1) { + qualityLadderIndex += 1 + continue + } + // Ladder floor still over budget — shrink dimensions. sqrt because encoded + // size scales roughly with pixel area; the 0.7 clamp guarantees each step + // is a real reduction even when the overshoot is marginal. + qualityLadderIndex = QUALITY_LADDER.length + const areaScale = Math.sqrt(params.budgetBytes / lastEncodedBytes) + const nextLongEdgePx = Math.floor(longEdgePx * Math.min(areaScale, 0.7)) + if (nextLongEdgePx < MIN_LONG_EDGE_PX) break + longEdgePx = nextLongEdgePx + } + + throw new Error( + `image cannot be fitted into ${params.budgetBytes} bytes ` + + `(smallest attempt was ${lastEncodedBytes} bytes after ${attemptCount} attempts)`, + ) +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 8e03fb48..68e2a3b0 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -1,5 +1,5 @@ import { readFile, readdir, stat } from "node:fs/promises" -import type { Dirent } from "node:fs" +import type { Dirent, Stats } from "node:fs" import { isErrnoException } from "./is-errno-exception.js" /** Reads a UTF-8 file, returning null instead of throwing when it does not exist @@ -13,6 +13,30 @@ export const readFileOrNull = async (path: string): Promise => { } } +/** Reads a file as raw bytes (no encoding), returning null instead of throwing + * when it does not exist (ENOENT). Any other error propagates. */ +export const readBinaryFileOrNull = async ( + path: string, +): Promise => { + try { + return await readFile(path) + } catch (error) { + if (isErrnoException(error, "ENOENT")) return null + throw error + } +} + +/** Stats a path, returning null instead of throwing when nothing exists there + * (ENOENT). Any other error propagates. */ +export const statOrNull = async (path: string): Promise => { + try { + return await stat(path) + } catch (error) { + if (isErrnoException(error, "ENOENT")) return null + throw error + } +} + /** Recursively reads a directory's entries (with file types), returning null * instead of throwing when the directory does not exist (ENOENT). Any other * error propagates. */ diff --git a/src/vault-mcp/config.ts b/src/vault-mcp/config.ts index c114dde3..3f153ed9 100644 --- a/src/vault-mcp/config.ts +++ b/src/vault-mcp/config.ts @@ -57,6 +57,14 @@ export type VaultConfig = Readonly<{ * rename-based exclusive write for moves (hard links aren't supported there). * Set via WINDOWS_MODE; safe to leave on for any Windows setup. */ windowsBindMount: boolean + /** Per-read byte cap for vault_read_asset — files larger than this are + * rejected before reading (memory guard). Set via MAX_ASSET_BYTES. */ + maxAssetBytes: number + /** Byte budget for image output after downscale/recompress, in binary bytes + * BEFORE base64 encoding. The default fits Claude Code's MCP output token + * cap (base64 expands ~4/3, then tokenizes at roughly 3 chars/token). + * Set via MAX_IMAGE_OUTPUT_BYTES; raise for clients with looser caps. */ + maxImageOutputBytes: number }> // ── Loader ───────────────────────────────────────────────────── @@ -111,6 +119,21 @@ export const loadConfig = ( .default("false") .asBool() + // 50 MiB — matches the most permissive prior art for MCP attachment reads. + const maxAssetBytes = envVar + .from(env) + .get("MAX_ASSET_BYTES") + .default("52428800") + .asIntPositive() + + // 48 KiB binary ≈ 64 KiB base64 ≈ ~21k tokens — under Claude Code's 25k-token + // MCP output cap with headroom for the metadata text block. + const maxImageOutputBytes = envVar + .from(env) + .get("MAX_IMAGE_OUTPUT_BYTES") + .default("49152") + .asIntPositive() + return Object.freeze({ memoryEnabled, memoryDir, @@ -120,5 +143,7 @@ export const loadConfig = ( embeddingEnabled, rerankMode, windowsBindMount, + maxAssetBytes, + maxImageOutputBytes, }) } diff --git a/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts new file mode 100644 index 00000000..a959daa6 --- /dev/null +++ b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from "vitest" +import { linearizeCanvas } from "../canvas.js" + +/** Minimal node factories — geometry defaults keep tests focused on the + * fields under test. */ +const textNode = ( + overrides: Partial<{ + id: string + x: number + y: number + width: number + height: number + text: string + }>, +): Record => ({ + id: "text-1", + type: "text", + x: 0, + y: 0, + width: 200, + height: 100, + text: "hello", + ...overrides, +}) + +const canvasJson = ( + nodes: Record[], + edges: Record[] = [], +): string => JSON.stringify({ nodes, edges }) + +describe("linearizeCanvas", () => { + it("renders ungrouped text, file, and link nodes with their content", () => { + const json = canvasJson([ + textNode({ id: "a", text: "## Ideas\n- ship it" }), + { + id: "b", + type: "file", + x: 300, + y: 0, + width: 400, + height: 400, + file: "Diagrams/arch.png", + }, + { + id: "c", + type: "link", + x: 0, + y: 200, + width: 200, + height: 100, + url: "https://example.com", + }, + ]) + expect(linearizeCanvas(json)).toBe( + [ + "# Canvas: 3 nodes, 0 edges", + "[text]\n## Ideas\n- ship it", + "[file] → Diagrams/arch.png", + "[link] → https://example.com", + ].join("\n\n"), + ) + }) + + it("orders nodes top-to-bottom then left-to-right, not by JSON order", () => { + const json = canvasJson([ + textNode({ id: "bottom", y: 500, text: "third" }), + textNode({ id: "top-right", x: 300, y: 0, text: "second" }), + textNode({ id: "top-left", x: 0, y: 0, text: "first" }), + ]) + expect(linearizeCanvas(json)).toBe( + [ + "# Canvas: 3 nodes, 0 edges", + "[text]\nfirst", + "[text]\nsecond", + "[text]\nthird", + ].join("\n\n"), + ) + }) + + it("assigns a node to its smallest containing group and nests child groups", () => { + const json = canvasJson([ + { + id: "outer", + type: "group", + label: "Outer", + x: 0, + y: 0, + width: 1000, + height: 1000, + }, + { + id: "inner", + type: "group", + label: "Inner", + x: 10, + y: 10, + width: 500, + height: 500, + }, + textNode({ id: "member", x: 20, y: 20, text: "in the inner group" }), + ]) + expect(linearizeCanvas(json)).toBe( + [ + "# Canvas: 3 nodes, 0 edges", + "## Group: Outer", + "### Group: Inner", + "[text]\nin the inner group", + ].join("\n\n"), + ) + }) + + it("renders a node overlapping but not contained by a group as ungrouped", () => { + const json = canvasJson([ + { + id: "group", + type: "group", + label: "Box", + x: 0, + y: 0, + width: 300, + height: 300, + }, + // Straddles the group's right edge — overlap without containment. + textNode({ id: "straddler", x: 250, y: 10, width: 200, text: "outside" }), + ]) + expect(linearizeCanvas(json)).toBe( + ["# Canvas: 2 nodes, 0 edges", "[text]\noutside", "## Group: Box"].join( + "\n\n", + ), + ) + }) + + it("resolves edge endpoints to display names with the edge label appended", () => { + const json = canvasJson( + [ + textNode({ id: "a", text: "## Ideas\n- ship it" }), + { + id: "b", + type: "file", + x: 300, + y: 0, + width: 400, + height: 400, + file: "Diagrams/arch.png", + }, + ], + [{ id: "e1", fromNode: "a", toNode: "b", label: "references" }], + ) + expect(linearizeCanvas(json)).toBe( + [ + "# Canvas: 2 nodes, 1 edge", + "[text]\n## Ideas\n- ship it", + "[file] → Diagrams/arch.png", + "## Connections\n\nIdeas → arch.png (references)", + ].join("\n\n"), + ) + }) + + it("marks a dangling edge endpoint instead of throwing", () => { + const json = canvasJson( + [textNode({ id: "a", text: "alone" })], + [{ id: "e1", fromNode: "a", toNode: "ghost" }], + ) + expect(linearizeCanvas(json)).toBe( + [ + "# Canvas: 1 node, 1 edge", + "[text]\nalone", + '## Connections\n\nalone → (missing node "ghost")', + ].join("\n\n"), + ) + }) + + it("appends a file node's subpath to its path", () => { + const json = canvasJson([ + { + id: "f", + type: "file", + x: 0, + y: 0, + width: 400, + height: 300, + file: "Notes/Plan.md", + subpath: "#Goals", + }, + ]) + expect(linearizeCanvas(json)).toBe( + ["# Canvas: 1 node, 0 edges", "[file] → Notes/Plan.md#Goals"].join( + "\n\n", + ), + ) + }) + + it("skips entries missing required fields and ignores unknown properties", () => { + const json = canvasJson( + [ + { id: "no-geometry", type: "text", text: "dropped" }, + textNode({ id: "kept", text: "kept" }), + { + ...textNode({ id: "extras", x: 0, y: 200, text: "with extras" }), + color: "1", + zIndex: 5, + }, + ], + [{ id: "half-edge", fromNode: "kept" }], + ) + expect(linearizeCanvas(json)).toBe( + [ + "# Canvas: 2 nodes, 0 edges", + "[text]\nkept", + "[text]\nwith extras", + ].join("\n\n"), + ) + }) + + it("renders an empty canvas as the overview line alone", () => { + expect(linearizeCanvas("{}")).toBe("# Canvas: 0 nodes, 0 edges") + }) + + it("throws on unparseable JSON", () => { + expect(() => linearizeCanvas("{not json")).toThrow( + /^invalid \.canvas JSON: /, + ) + }) +}) diff --git a/src/vault-mcp/obsidian-markdown/canvas.ts b/src/vault-mcp/obsidian-markdown/canvas.ts new file mode 100644 index 00000000..d8fbb74f --- /dev/null +++ b/src/vault-mcp/obsidian-markdown/canvas.ts @@ -0,0 +1,243 @@ +import { posix } from "node:path" + +/** + * Linearizes an Obsidian .canvas file (JSON Canvas 1.0) into a readable + * markdown rendition: node content in spatial reading order, grouped under + * the group that visually contains it, followed by an edge list with node + * ids resolved to display names. The rendition drops geometry (x/y/width/ + * height) — it is a reading surface, not a round-trippable format. + * + * Lenient by design: real Obsidian canvases carry extra properties and + * occasionally partial entries, so unknown props are ignored and entries + * missing their required fields are skipped rather than thrown. Only + * unparseable JSON throws. + */ + +/** The JSON Canvas 1.0 node shape this linearizer consumes. Only the fields + * it reads — unknown properties pass through untouched. */ +type CanvasNode = Readonly<{ + id: string + type: string + x: number + y: number + width: number + height: number + text?: string | undefined + file?: string | undefined + subpath?: string | undefined + url?: string | undefined + label?: string | undefined +}> + +type CanvasEdge = Readonly<{ + fromNode: string + toNode: string + label?: string | undefined +}> + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const optionalString = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined + +/** Narrows one raw array entry to a node, or null when its required JSON + * Canvas fields are missing/mistyped (the entry is then skipped, not thrown). */ +const parseNode = (raw: unknown): CanvasNode | null => { + if (!isRecord(raw)) return null + const { id, type, x, y, width, height } = raw + if (typeof id !== "string" || typeof type !== "string") return null + if ( + typeof x !== "number" || + typeof y !== "number" || + typeof width !== "number" || + typeof height !== "number" + ) { + return null + } + return { + id, + type, + x, + y, + width, + height, + text: optionalString(raw.text), + file: optionalString(raw.file), + subpath: optionalString(raw.subpath), + url: optionalString(raw.url), + label: optionalString(raw.label), + } +} + +const parseEdge = (raw: unknown): CanvasEdge | null => { + if (!isRecord(raw)) return null + const { fromNode, toNode } = raw + if (typeof fromNode !== "string" || typeof toNode !== "string") return null + return { fromNode, toNode, label: optionalString(raw.label) } +} + +/** True when `inner`'s rectangle lies fully inside `outer`'s — JSON Canvas + * group membership is spatial containment, not a structural parent field. */ +const isContainedIn = (inner: CanvasNode, outer: CanvasNode): boolean => + inner.x >= outer.x && + inner.y >= outer.y && + inner.x + inner.width <= outer.x + outer.width && + inner.y + inner.height <= outer.y + outer.height + +/** The group that owns a node: the smallest-area group strictly containing + * it (groups nest, so the smallest container is the innermost). */ +const smallestContainingGroup = ( + node: CanvasNode, + groups: readonly CanvasNode[], +): CanvasNode | undefined => { + const containing = groups.filter( + (group) => group.id !== node.id && isContainedIn(node, group), + ) + if (containing.length === 0) return undefined + return containing.reduce((smallest, candidate) => + candidate.width * candidate.height < smallest.width * smallest.height + ? candidate + : smallest, + ) +} + +/** Spatial reading order: top-to-bottom, then left-to-right. */ +const byReadingOrder = (a: CanvasNode, b: CanvasNode): number => + a.y - b.y || a.x - b.x + +/** Display name for the edge list: first line of a text node, filename of a + * file node, a group's label, a link's url. */ +const displayName = (node: CanvasNode): string => { + if (node.type === "text") { + const firstLine = (node.text ?? "") + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + // Text nodes often open with a markdown heading — "# Title" reads as + // "Title" in an edge list. + return firstLine ? firstLine.replace(/^#+\s*/, "") : "(empty text node)" + } + if (node.type === "file") { + return node.file ? posix.basename(node.file) : "(file node)" + } + if (node.type === "link") return node.url ?? "(link node)" + if (node.type === "group") return node.label ?? "(unlabeled group)" + return `(${node.type} node)` +} + +/** One node's rendition: a `[type]` marker, then its content. */ +const renderNode = (node: CanvasNode): string => { + if (node.type === "text") return `[text]\n${node.text ?? ""}` + if (node.type === "file") { + const subpath = node.subpath ?? "" + return `[file] → ${node.file ?? "(missing file path)"}${subpath}` + } + if (node.type === "link") return `[link] → ${node.url ?? "(missing url)"}` + return `[${node.type}]` +} + +/** Renders a group section: heading, member nodes in reading order, then + * child groups recursively one heading level deeper (capped at H6). */ +const renderGroup = ( + group: CanvasNode, + depth: number, + membersByGroupId: ReadonlyMap, + childGroupsByParentId: ReadonlyMap, +): string => { + const heading = "#".repeat(Math.min(2 + depth, 6)) + const members = membersByGroupId.get(group.id) ?? [] + const childGroups = childGroupsByParentId.get(group.id) ?? [] + return [ + `${heading} Group: ${group.label ?? "(unlabeled)"}`, + ...members.map(renderNode), + ...childGroups.map((child) => + renderGroup(child, depth + 1, membersByGroupId, childGroupsByParentId), + ), + ].join("\n\n") +} + +/** Groups a list by a key function, preserving each bucket's insertion order. */ +const groupBy = ( + items: readonly Item[], + keyOf: (item: Item) => Key, +): Map => { + // Plain loop with bucket mutation: building a multi-bucket Map immutably + // would re-spread every bucket per item for no readability gain. + const buckets = new Map() + for (const item of items) { + const key = keyOf(item) + const bucket = buckets.get(key) + if (bucket) { + bucket.push(item) + continue + } + buckets.set(key, [item]) + } + return buckets +} + +/** + * Linearizes .canvas JSON into readable markdown: an overview line, ungrouped + * node content first, each group's content under a `Group:` heading (nested + * groups one level deeper), then a `Connections` edge list in `A → B (label)` + * form with ids resolved to display names. Throws only on unparseable JSON. + */ +export const linearizeCanvas = (canvasJson: string): string => { + const parsed = ((): unknown => { + try { + return JSON.parse(canvasJson) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`invalid .canvas JSON: ${message}`, { cause: error }) + } + })() + + const rawNodes = + isRecord(parsed) && Array.isArray(parsed.nodes) ? parsed.nodes : [] + const rawEdges = + isRecord(parsed) && Array.isArray(parsed.edges) ? parsed.edges : [] + const nodes = rawNodes + .map(parseNode) + .filter((node): node is CanvasNode => node !== null) + .sort(byReadingOrder) + const edges = rawEdges + .map(parseEdge) + .filter((edge): edge is CanvasEdge => edge !== null) + + const groups = nodes.filter((node) => node.type === "group") + const contentNodes = nodes.filter((node) => node.type !== "group") + const membersByGroupId = groupBy( + contentNodes, + (node) => smallestContainingGroup(node, groups)?.id, + ) + const childGroupsByParentId = groupBy( + groups, + (group) => smallestContainingGroup(group, groups)?.id, + ) + + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edgeEndpointName = (id: string): string => { + const node = nodeById.get(id) + return node ? displayName(node) : `(missing node "${id}")` + } + + const ungroupedNodes = membersByGroupId.get(undefined) ?? [] + const topLevelGroups = childGroupsByParentId.get(undefined) ?? [] + const edgeLines = edges.map((edge) => { + const label = edge.label ? ` (${edge.label})` : "" + return `${edgeEndpointName(edge.fromNode)} → ${edgeEndpointName(edge.toNode)}${label}` + }) + + const sections = [ + `# Canvas: ${nodes.length} ${nodes.length === 1 ? "node" : "nodes"}, ${edges.length} ${edges.length === 1 ? "edge" : "edges"}`, + ...ungroupedNodes.map(renderNode), + ...topLevelGroups.map((group) => + renderGroup(group, 0, membersByGroupId, childGroupsByParentId), + ), + ...(edgeLines.length > 0 + ? [["## Connections", ...edgeLines].join("\n\n")] + : []), + ] + return sections.join("\n\n") +} diff --git a/src/vault-mcp/obsidian-markdown/links.ts b/src/vault-mcp/obsidian-markdown/links.ts index c1830d31..d9d5d74a 100644 --- a/src/vault-mcp/obsidian-markdown/links.ts +++ b/src/vault-mcp/obsidian-markdown/links.ts @@ -325,6 +325,17 @@ const stripExtension = (filePath: string): string => { return filePath.slice(0, filePath.length - (fileName.length - dotIndex)) } +/** Returns the file extension including its dot ("photo.png" → ".png"), or "" + * when the filename has none. Mirrors stripExtension's semantics exactly — + * last dot in the filename (not the path), leading-dot files (".hidden") are + * extensionless — so the two can never disagree about where a name splits. */ +const getExtension = (filePath: string): string => { + const fileName = posix.basename(filePath) + const dotIndex = fileName.lastIndexOf(".") + if (dotIndex <= 0) return "" + return fileName.slice(dotIndex) +} + /** Picks the winner among same-tier resolution matches: the shortest path, * with a lexicographic tiebreak for determinism — mirroring the SQL * resolver's ORDER BY length(path), path LIMIT 1. */ @@ -433,5 +444,6 @@ export const links = { extractAll, resolve, stripExtension, + getExtension, resolveAsset, } diff --git a/src/vault-mcp/search/__tests__/search-index.test.ts b/src/vault-mcp/search/__tests__/search-index.test.ts index 5e921e33..30e44ed2 100644 --- a/src/vault-mcp/search/__tests__/search-index.test.ts +++ b/src/vault-mcp/search/__tests__/search-index.test.ts @@ -181,6 +181,43 @@ describe("leading callout", () => { expect(results[0]?.leading_callout?.title).toBe("Scope of this file") expect(results[0]?.bytes).toBe(100) }) + + it("adds the bytes column to a pre-existing non_md_files table (warm-DB migration)", async () => { + const dir = await mkdtemp(join(tmpdir(), "warm-db-")) + onTestFinished(() => rm(dir, { recursive: true })) + const dbPath = join(dir, "search.db") + // Simulate a database file created before the non_md_files bytes column. + const legacyDb = new Database(dbPath) + legacyDb.exec(` + CREATE TABLE non_md_files ( + path TEXT PRIMARY KEY, base_path TEXT NOT NULL, basename TEXT NOT NULL + ); + `) + legacyDb.close() + + // Opening through the factory must add the missing column — the 4-column + // upsert would throw against the legacy 3-column table otherwise. + const warmIndex = createSearchIndex(dbPath) + expect(() => warmIndex.upsertNonMdFile("photo.png", 77)).not.toThrow() + warmIndex.upsertNote( + { + filePath: "source.md", + rawContent: "![[photo.png]]", + fileStat: testStat(1000), + }, + logger, + ) + expect(warmIndex.getOutgoingLinks({ path: "source.md" }, logger)).toEqual([ + { + path: "photo.png", + title: null, + exists: true, + kind: "asset", + bytes: 77, + daily_note_forward_ref: false, + }, + ]) + }) }) describe("bytes", () => { @@ -1514,7 +1551,7 @@ describe("rebuildFromVault", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 9, daily_note_forward_ref: false, }, ]) @@ -2378,8 +2415,8 @@ describe("brokenLinkCount", () => { }) it("does not count wikilinks to non-note assets as broken when files are registered", () => { - index.upsertNonMdFile("photo.png") - index.upsertNonMdFile("report.pdf") + index.upsertNonMdFile("photo.png", 100) + index.upsertNonMdFile("report.pdf", 100) index.upsertNote( { filePath: "source.md", @@ -2404,7 +2441,7 @@ describe("brokenLinkCount", () => { }) it("excludes extensionless targets after upsertNonMdFile registers the file", () => { - index.upsertNonMdFile("Trip Route.canvas") + index.upsertNonMdFile("Trip Route.canvas", 100) index.upsertNote( { filePath: "source.md", @@ -2435,7 +2472,7 @@ describe("brokenLinkCount", () => { ) expect(index.brokenLinkCount({}, logger).count).toBe(1) - index.upsertNonMdFile("Route.canvas") + index.upsertNonMdFile("Route.canvas", 100) expect(index.brokenLinkCount({}, logger).count).toBe(0) const outgoing = index.getOutgoingLinks({ path: "source.md" }, logger) expect(outgoing).toHaveLength(1) @@ -2445,7 +2482,7 @@ describe("brokenLinkCount", () => { }) it("removeNonMdFile makes previously resolved asset links broken again", () => { - index.upsertNonMdFile("Route.canvas") + index.upsertNonMdFile("Route.canvas", 100) index.upsertNote( { filePath: "source.md", @@ -2539,7 +2576,7 @@ describe("brokenLinkCount", () => { describe("markdown-style links to non-md targets", () => { it("resolves a markdown image embed as an asset", () => { - index.upsertNonMdFile("pics/photo.png") + index.upsertNonMdFile("pics/photo.png", 100) index.upsertNote( { filePath: "source.md", @@ -2566,7 +2603,7 @@ describe("markdown-style links to non-md targets", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) @@ -2574,7 +2611,7 @@ describe("markdown-style links to non-md targets", () => { }) it("resolves a markdown link to a PDF as an asset", () => { - index.upsertNonMdFile("papers/report.pdf") + index.upsertNonMdFile("papers/report.pdf", 100) index.upsertNote( { filePath: "source.md", @@ -2589,14 +2626,14 @@ describe("markdown-style links to non-md targets", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) }) it("percent-decodes a markdown asset path with folders and spaces", () => { - index.upsertNonMdFile("Trip Photos/pic 1.png") + index.upsertNonMdFile("Trip Photos/pic 1.png", 100) index.upsertNote( { filePath: "source.md", @@ -2611,7 +2648,7 @@ describe("markdown-style links to non-md targets", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) @@ -2755,7 +2792,7 @@ describe("markdown-style links to non-md targets", () => { describe("asset targets written with extensions", () => { it("resolves a wikilink embed by basename when the asset lives in a subfolder", () => { - index.upsertNonMdFile("attachments/photo.png") + index.upsertNonMdFile("attachments/photo.png", 100) index.upsertNote( { filePath: "source.md", @@ -2770,7 +2807,7 @@ describe("asset targets written with extensions", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) @@ -2778,7 +2815,7 @@ describe("asset targets written with extensions", () => { }) it("resolves a markdown embed by basename when the asset lives in a subfolder", () => { - index.upsertNonMdFile("attachments/photo.png") + index.upsertNonMdFile("attachments/photo.png", 100) index.upsertNote( { filePath: "source.md", @@ -2793,7 +2830,7 @@ describe("asset targets written with extensions", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) @@ -2801,7 +2838,7 @@ describe("asset targets written with extensions", () => { }) it("resolves a relative asset link against the source note's folder", () => { - index.upsertNonMdFile("assets/photo.png") + index.upsertNonMdFile("assets/photo.png", 100) index.upsertNote( { filePath: "A/note.md", @@ -2816,7 +2853,7 @@ describe("asset targets written with extensions", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) @@ -2824,7 +2861,7 @@ describe("asset targets written with extensions", () => { }) it("resolves a folder-qualified target with extension by path suffix", () => { - index.upsertNonMdFile("deep/sub/photo.png") + index.upsertNonMdFile("deep/sub/photo.png", 100) index.upsertNote( { filePath: "source.md", @@ -2839,7 +2876,7 @@ describe("asset targets written with extensions", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) @@ -2847,8 +2884,8 @@ describe("asset targets written with extensions", () => { }) it("resolves a shared basename deterministically to the shortest path", () => { - index.upsertNonMdFile("bb/photo.png") - index.upsertNonMdFile("a/photo.png") + index.upsertNonMdFile("bb/photo.png", 100) + index.upsertNonMdFile("a/photo.png", 100) index.upsertNote( { filePath: "source.md", @@ -2863,7 +2900,7 @@ describe("asset targets written with extensions", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) @@ -2873,8 +2910,8 @@ describe("asset targets written with extensions", () => { // "photo.png.canvas" strips to base_path "photo.png" — the same text as // the target — so the stem tiers would hit it. The full-filename family // must win: the target names an actual .png that exists elsewhere. - index.upsertNonMdFile("photo.png.canvas") - index.upsertNonMdFile("a/photo.png") + index.upsertNonMdFile("photo.png.canvas", 100) + index.upsertNonMdFile("a/photo.png", 100) index.upsertNote( { filePath: "source.md", @@ -2889,7 +2926,7 @@ describe("asset targets written with extensions", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) @@ -2899,7 +2936,7 @@ describe("asset targets written with extensions", () => { // With only photo.png.canvas in the vault, [[photo.png]] still resolves // via its stem — the same matching that gives [[Trip Route]] → // Trip Route.canvas. The stem tiers are a fallback, not dead code. - index.upsertNonMdFile("photo.png.canvas") + index.upsertNonMdFile("photo.png.canvas", 100) index.upsertNote( { filePath: "source.md", @@ -2914,14 +2951,14 @@ describe("asset targets written with extensions", () => { title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) }) it("does not resolve a basename whose extension differs from the file's", () => { - index.upsertNonMdFile("attachments/photo.jpg") + index.upsertNonMdFile("attachments/photo.jpg", 100) index.upsertNote( { filePath: "source.md", @@ -2956,14 +2993,14 @@ describe("asset targets written with extensions", () => { ) expect(index.brokenLinkCount({}, logger).count).toBe(1) - index.upsertNonMdFile("attachments/photo.png") + index.upsertNonMdFile("attachments/photo.png", 100) expect(index.getOutgoingLinks({ path: "source.md" }, logger)).toEqual([ { path: "attachments/photo.png", title: null, exists: true, kind: "asset", - bytes: null, + bytes: 100, daily_note_forward_ref: false, }, ]) @@ -2973,7 +3010,7 @@ describe("asset targets written with extensions", () => { it("does not let LIKE wildcards in the target match unrelated files via full-path suffix", () => { // Only photo1final.png exists — if the _ in the target were treated as a // LIKE wildcard it would match (1 satisfies _), giving a false resolution. - index.upsertNonMdFile("img/photo1final.png") + index.upsertNonMdFile("img/photo1final.png", 100) index.upsertNote( { filePath: "source.md", diff --git a/src/vault-mcp/search/file-watcher.ts b/src/vault-mcp/search/file-watcher.ts index 3a89c0ab..166b72c4 100644 --- a/src/vault-mcp/search/file-watcher.ts +++ b/src/vault-mcp/search/file-watcher.ts @@ -9,7 +9,7 @@ import { join, relative, resolve as resolvePath } from "node:path" import type { SearchIndex } from "./search-index.js" import { logger } from "../../logger.js" import { describeError } from "../../utils/describe-error.js" -import { readdirOrNull } from "../../utils/fs.js" +import { readdirOrNull, statOrNull } from "../../utils/fs.js" import { isErrnoException } from "../../utils/is-errno-exception.js" /** ms between filesystem polls when usePolling is on. chokidar's raw default is @@ -77,7 +77,11 @@ export const startFileWatcher = ( const relativePath = relative(vaultPath, filePath) if (!filePath.endsWith(".md")) { - search.upsertNonMdFile(relativePath) + const fileStat = await statOrNull(filePath) + // Vanished between the watcher event and the stat — the unlink event + // that follows will remove any existing row. + if (!fileStat) return + search.upsertNonMdFile(relativePath, fileStat.size) logger.debug("indexed non-md file", { path: relativePath }) return } diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index 3039d74c..f319a1c9 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -21,6 +21,7 @@ import type { Reranker } from "./reranker.js" import { chunkNoteContent } from "./chunker.js" import { describeError } from "../../utils/describe-error.js" import { filterValidSymlinks } from "../../utils/filter-valid-symlinks.js" +import { statOrNull } from "../../utils/fs.js" import { isString, coerceToArray, @@ -341,7 +342,8 @@ export const createSearchIndex = ( CREATE TABLE IF NOT EXISTS non_md_files ( path TEXT PRIMARY KEY, base_path TEXT NOT NULL, - basename TEXT NOT NULL + basename TEXT NOT NULL, + bytes INTEGER ); CREATE INDEX IF NOT EXISTS idx_non_md_base_path ON non_md_files(base_path); CREATE INDEX IF NOT EXISTS idx_non_md_basename ON non_md_files(basename); @@ -454,6 +456,16 @@ export const createSearchIndex = ( db.exec(`ALTER TABLE notes ADD COLUMN kanban_done_lanes TEXT`) } + // Same idempotent migration for non_md_files.bytes: a warm database from + // before the column existed would fail the upsert. Nullable — NULL means + // "not yet statted"; the startup rebuild backfills every row. + const nonMdColumns = db + .prepare(`PRAGMA table_info(non_md_files)`) + .all() + if (!nonMdColumns.some((column) => column.name === "bytes")) { + db.exec(`ALTER TABLE non_md_files ADD COLUMN bytes INTEGER`) + } + // Daily notes folder for forward-ref exclusion — broken links under // this folder are treated as intentional "create on click" navigation. // Set via setDailyNotesFolder from server.ts config; null until then. @@ -510,7 +522,7 @@ export const createSearchIndex = ( // incrementally by the file watcher. const upsertNonMdFileStmt = db.prepare( - `INSERT OR REPLACE INTO non_md_files (path, base_path, basename) VALUES (?, ?, ?)`, + `INSERT OR REPLACE INTO non_md_files (path, base_path, basename, bytes) VALUES (?, ?, ?, ?)`, ) const deleteNonMdFileStmt = db.prepare( `DELETE FROM non_md_files WHERE path = ?`, @@ -793,45 +805,33 @@ export const createSearchIndex = ( return basenameMatch?.path ?? null } - /** Indexes non-markdown files from a directory listing into the - * non_md_files table. Skips hidden directories (same filter as notes). */ + /** Indexes pre-statted non-markdown files into the non_md_files table. + * The caller owns entry filtering and stat (fs work stays out of the + * write transaction); this just writes the rows. */ const indexNonMarkdownFiles = ( - entries: ReadonlyArray<{ - isFile: () => boolean - isSymbolicLink: () => boolean - name: string - parentPath: string - }>, - normalizedVault: string, + files: ReadonlyArray<{ relativePath: string; bytes: number }>, ): number => { - // Counter incremented per non-md file upserted — returned to caller for logging - let filesIndexed = 0 - for (const directoryEntry of entries) { - if ( - (!directoryEntry.isFile() && !directoryEntry.isSymbolicLink()) || - directoryEntry.name.endsWith(".md") + for (const file of files) { + const basePath = links.stripExtension(file.relativePath) + const baseFilename = links.stripExtension(basename(file.relativePath)) + upsertNonMdFileStmt.run( + file.relativePath, + basePath, + baseFilename, + file.bytes, ) - continue - const absolutePath = join(directoryEntry.parentPath, directoryEntry.name) - const relativePath = relative(normalizedVault, absolutePath) - if (relativePath.split("/").some((segment) => segment.startsWith("."))) - continue - const basePath = links.stripExtension(relativePath) - const baseFilename = links.stripExtension(directoryEntry.name) - upsertNonMdFileStmt.run(relativePath, basePath, baseFilename) - filesIndexed += 1 } - return filesIndexed + return files.length } /** Adds a single non-markdown file to the index and re-resolves any * unresolved links that now match it. Called by the file watcher on * add/change. Mirrors the note forward-reference re-resolution pattern: * updates the link target from the raw text to the resolved non-md path. */ - const upsertNonMdFile = (filePath: string): void => { + const upsertNonMdFile = (filePath: string, bytes: number): void => { const basePath = links.stripExtension(filePath) const baseFilename = links.stripExtension(basename(filePath)) - upsertNonMdFileStmt.run(filePath, basePath, baseFilename) + upsertNonMdFileStmt.run(filePath, basePath, baseFilename, bytes) // Re-resolve unresolved links that now match this non-md file — upgrade // raw targets (e.g. "Trip Route") to resolved paths ("Trip Route.canvas"). @@ -1364,21 +1364,49 @@ export const createSearchIndex = ( logger, }) - // Filter directory entries to visible .md files, then load their content - const markdownFiles = entries.reduce< - { relativePath: string; absolutePath: string }[] - >((filteredFiles, directoryEntry) => { - if ( - (!directoryEntry.isFile() && !directoryEntry.isSymbolicLink()) || - !directoryEntry.name.endsWith(".md") + // Filter directory entries to visible files of one kind (.md notes or + // non-md assets) — shared by the notes pass and the non-md stat pass. + const visibleFilesOfKind = ( + fileKind: "note" | "asset", + ): { relativePath: string; absolutePath: string }[] => + entries.reduce<{ relativePath: string; absolutePath: string }[]>( + (filteredFiles, directoryEntry) => { + if (!directoryEntry.isFile() && !directoryEntry.isSymbolicLink()) + return filteredFiles + const isNoteFile = directoryEntry.name.endsWith(".md") + const matchesKind = fileKind === "note" ? isNoteFile : !isNoteFile + if (!matchesKind) return filteredFiles + const absolutePath = join( + directoryEntry.parentPath, + directoryEntry.name, + ) + const relativePath = relative(normalizedVault, absolutePath) + if ( + relativePath.split("/").some((segment) => segment.startsWith(".")) + ) + return filteredFiles + return [...filteredFiles, { relativePath, absolutePath }] + }, + [], + ) + + const markdownFiles = visibleFilesOfKind("note") + + // Stat non-md files before the write transaction (fs stays out of it). + // A file vanishing between listing and stat (sync race) is dropped here + // and re-indexed by its own watcher event. + const nonMarkdownFileSizes = ( + await Promise.all( + visibleFilesOfKind("asset").map(async (file) => { + const fileStat = await statOrNull(file.absolutePath) + if (!fileStat) return null + return { relativePath: file.relativePath, bytes: fileStat.size } + }), ) - return filteredFiles - const absolutePath = join(directoryEntry.parentPath, directoryEntry.name) - const relativePath = relative(normalizedVault, absolutePath) - if (relativePath.split("/").some((segment) => segment.startsWith("."))) - return filteredFiles - return [...filteredFiles, { relativePath, absolutePath }] - }, []) + ).filter( + (entry): entry is { relativePath: string; bytes: number } => + entry !== null, + ) const noteContents = await Promise.all( markdownFiles.map(async (file) => { @@ -1399,7 +1427,7 @@ export const createSearchIndex = ( db.transaction(() => { // Index non-markdown files so extensionless wikilinks to .canvas, .base, // etc. are recognized as asset references rather than broken note links. - const nonMdCount = indexNonMarkdownFiles(entries, normalizedVault) + const nonMdCount = indexNonMarkdownFiles(nonMarkdownFileSizes) logger.debug("indexed non-md files", { count: nonMdCount }) // Pass 1: index all notes (content, frontmatter, FTS) — skip link diff --git a/src/vault-mcp/search/search-queries.ts b/src/vault-mcp/search/search-queries.ts index 500fa40e..1b616587 100644 --- a/src/vault-mcp/search/search-queries.ts +++ b/src/vault-mcp/search/search-queries.ts @@ -1567,7 +1567,7 @@ export const getOutgoingLinks = ( CASE WHEN n.path IS NOT NULL THEN 'note' WHEN f.path IS NOT NULL THEN 'asset' ELSE 'note' END as kind, - n.bytes + COALESCE(n.bytes, f.bytes) as bytes FROM links l LEFT JOIN notes n ON n.path = l.target LEFT JOIN non_md_files f ON f.path = l.target diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 2419cf9b..0f85edca 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -13,8 +13,16 @@ import { join, dirname, relative, resolve, posix } from "node:path" import picomatch from "picomatch" import { describeError } from "../../utils/describe-error.js" import { filterValidSymlinks } from "../../utils/filter-valid-symlinks.js" -import { fileExists, readFileOrNull, readdirOrNull } from "../../utils/fs.js" +import { + fileExists, + readFileOrNull, + readBinaryFileOrNull, + readdirOrNull, + statOrNull, +} from "../../utils/fs.js" +import { mapWithConcurrency } from "../../utils/map-with-concurrency.js" import { withExclusiveFileLock } from "../../utils/file-write-lock.js" +import { links } from "../obsidian-markdown/links.js" import { parseNote, stringifyNote, @@ -491,24 +499,85 @@ const listNotes = async ( return result } -/** Lists non-.md files (attachments — images, canvases, PDFs, …) under the - * vault root. Same walk and filters as listNotes; moveNote resolves asset - * links inside a moved note against the result. */ +/** Lists non-.md files (assets — images, canvases, PDFs, …) under a folder + * (or the vault root). Same walk and filters as listNotes; moveNote resolves + * asset links inside a moved note against the vault-wide result. */ const listAssets = async ( - params: { vaultPath: string }, + params: { vaultPath: string; folder?: string | undefined }, logger: Logger, ): Promise => { const paths = await listVaultFilePaths( { vaultPath: params.vaultPath, + folder: params.folder, fileKind: "asset", }, logger, ) - logger.info("listed assets", { count: paths.length }) + logger.info("listed assets", { folder: params.folder, count: paths.length }) return paths } +/** Reads a non-.md vault file (an asset) as raw bytes, with a size cap. + * Markdown notes are rejected — .md reads go through readNote, which treats + * files as notes, not bytes. The stat-before-read cap guards memory: assets + * return through tool responses whole, so an unbounded read of a huge file + * would balloon the process. */ +const readAsset = async ( + params: { vaultPath: string; path: string; maxBytes: number }, + logger: Logger, +): Promise<{ buffer: Buffer; bytes: number; extension: string }> => { + if (params.path.endsWith(".md")) { + throw new Error(`not an asset: "${params.path}" is a markdown note`) + } + const fullPath = resolveSafePath(params.vaultPath, params.path) + const fileStats = await statOrNull(fullPath) + if (!fileStats || !fileStats.isFile()) { + throw new Error(`asset not found: "${params.path}"`) + } + if (fileStats.size > params.maxBytes) { + throw new Error( + `asset too large: "${params.path}" is ${fileStats.size} bytes ` + + `(cap ${params.maxBytes} bytes — raise MAX_ASSET_BYTES to read larger files)`, + ) + } + const buffer = await readBinaryFileOrNull(fullPath) + if (buffer === null) { + throw new Error(`asset not found: "${params.path}"`) + } + logger.info("read asset", { path: params.path, bytes: buffer.length }) + return { + buffer, + bytes: buffer.length, + extension: links.getExtension(params.path).toLowerCase(), + } +} + +/** Stats a page of asset paths, returning each existing file's byte size. + * Assets that vanished between listing and stat (a sync race) are dropped + * rather than thrown — the listing is a snapshot, not a lock. */ +const statAssets = async ( + params: { vaultPath: string; paths: readonly string[] }, + logger: Logger, +): Promise<{ path: string; bytes: number }[]> => { + const stattedEntries = await mapWithConcurrency({ + items: params.paths, + concurrency: 16, + mapper: async (assetPath) => { + const fileStats = await statOrNull( + resolveSafePath(params.vaultPath, assetPath), + ) + if (!fileStats) return null + return { path: assetPath, bytes: fileStats.size } + }, + }) + const existingEntries = stattedEntries.filter( + (entry): entry is { path: string; bytes: number } => entry !== null, + ) + logger.info("statted assets", { count: existingEntries.length }) + return existingEntries +} + export const vaultFs = { readNote, readNoteOutline, @@ -519,4 +588,6 @@ export const vaultFs = { deleteNote, listNotes, listAssets, + readAsset, + statAssets, } From 1f1f7ebf2878f39545415e27b5ac95c914ce7d53 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:11:22 -0400 Subject: [PATCH 04/34] feat(assets): vault_read_asset + vault_list_assets tools, safeHandlerContent safeHandlerContent becomes the error-contract core (safeHandler delegates as the single-text-block case); new asset-tools group registers vault_read_asset (dispatch: images via fit-to-budget pipeline as image blocks + metadata line, .canvas linearized, text passthrough set verbatim, .pdf/unknown structured errors, fixed 100KB text cap) and vault_list_assets (filesystem-backed listing with folder/extension filters, page-statted bytes, full-set counts). Cross-references added to read_note (.md error routing) and outgoing_links (bytes now live for assets) descriptions; READ_ONLY_TOOLS + pinned cross-ref + handler + data-layer tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- .../__tests__/tool-definitions.test.ts | 241 ++++++++++++++ src/vault-mcp/mcp-core/tool-definitions.ts | 6 +- src/vault-mcp/mcp-core/tools/asset-tools.ts | 310 ++++++++++++++++++ src/vault-mcp/mcp-core/tools/search-tools.ts | 2 +- src/vault-mcp/mcp-core/tools/tool-helpers.ts | 30 +- .../mcp-core/tools/vault-crud-tools.ts | 1 + .../__tests__/vault-filesystem.test.ts | 85 +++++ 7 files changed, 666 insertions(+), 9 deletions(-) create mode 100644 src/vault-mcp/mcp-core/tools/asset-tools.ts diff --git a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 0182b435..030527ad 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi, onTestFinished } from "vitest" +import sharp from "sharp" import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" @@ -31,6 +32,8 @@ const READ_ONLY_TOOLS = [ TOOL_NAMES.VAULT_GET_BACKLINKS, TOOL_NAMES.VAULT_GET_OUTGOING_LINKS, TOOL_NAMES.VAULT_FIND_ORPHANS, + TOOL_NAMES.VAULT_READ_ASSET, + TOOL_NAMES.VAULT_LIST_ASSETS, ] as const const DESTRUCTIVE_TOOLS = [ @@ -264,6 +267,28 @@ describe("registerTools", () => { expect(config.description).toContain("vault_get_backlinks") }) + it("vault_read_asset description cross-references discovery and note tools", () => { + const [, config] = requireCall(TOOL_NAMES.VAULT_READ_ASSET) + expect(config.description).toContain("vault_list_assets") + expect(config.description).toContain("vault_get_outgoing_links") + expect(config.description).toContain("vault_read_note") + }) + + it("vault_list_assets description cross-references vault_read_asset", () => { + const [, config] = requireCall(TOOL_NAMES.VAULT_LIST_ASSETS) + expect(config.description).toContain("vault_read_asset") + }) + + it("vault_read_note description routes non-md paths to vault_read_asset", () => { + const [, config] = requireCall(TOOL_NAMES.VAULT_READ_NOTE) + expect(config.description).toContain("vault_read_asset") + }) + + it("vault_get_outgoing_links description cross-references vault_read_asset", () => { + const [, config] = requireCall(TOOL_NAMES.VAULT_GET_OUTGOING_LINKS) + expect(config.description).toContain("vault_read_asset") + }) + it("every tool has all 4 annotation hints", () => { for (const [, config] of calls) { const annotations = config.annotations! @@ -916,3 +941,219 @@ describe("vault_list_tasks handler", () => { }) }) }) + +describe("asset tool handlers", () => { + const mockExtra = { requestId: "test-1", sessionId: "session-1" } + + type HandlerResult = { + content: Array<{ + type: string + text?: string + data?: string + mimeType?: string + }> + isError?: boolean + } + + /** Registers against a real temp vault and returns the two asset handlers — + * the global harness registers against a nonexistent path. */ + const setupAssetHarness = async (): Promise<{ + vault: string + readAsset: (args: unknown) => Promise + listAssets: (args: unknown) => Promise + }> => { + const tempVault = await mkdtemp(join(tmpdir(), "tool-definitions-assets-")) + onTestFinished(() => rm(tempVault, { recursive: true, force: true })) + const server = { registerTool: vi.fn() } + registerTools({ + server: server as unknown as McpServer, + vaultPath: tempVault, + search: {} as SearchIndex, + logger, + config: loadConfig({}), + }) + const registeredCalls = server.registerTool.mock.calls as RegisterToolCall[] + const handlerFor = ( + name: string, + ): ((args: unknown) => Promise) => { + const call = registeredCalls.find(([toolName]) => toolName === name) + if (!call) throw new Error(`tool not registered: ${name}`) + const [, , handler] = call + return async (args: unknown) => + (await handler(args, mockExtra)) as HandlerResult + } + return { + vault: tempVault, + readAsset: handlerFor(TOOL_NAMES.VAULT_READ_ASSET), + listAssets: handlerFor(TOOL_NAMES.VAULT_LIST_ASSETS), + } + } + + it("returns a .canvas file as its linearized rendition", async () => { + const { vault, readAsset } = await setupAssetHarness() + await writeFile( + join(vault, "Board.canvas"), + JSON.stringify({ + nodes: [ + { + id: "a", + type: "text", + x: 0, + y: 0, + width: 200, + height: 100, + text: "hello", + }, + ], + edges: [], + }), + "utf8", + ) + const result = await readAsset({ path: "Board.canvas" }) + expect(result.isError).toBeUndefined() + expect(result.content).toEqual([ + { type: "text", text: "# Canvas: 1 node, 0 edges\n\n[text]\nhello" }, + ]) + }) + + it.each([ + { extension: "json", content: '{"key": "value"}' }, + { extension: "svg", content: '' }, + { extension: "csv", content: "a,b\n1,2\n" }, + { extension: "txt", content: "plain text\n" }, + { extension: "xml", content: "" }, + { extension: "log", content: "line one\nline two\n" }, + { extension: "base", content: "views:\n - type: table\n" }, + ])( + "returns a .$extension file verbatim as text", + async ({ extension, content }) => { + const { vault, readAsset } = await setupAssetHarness() + await writeFile(join(vault, `file.${extension}`), content, "utf8") + const result = await readAsset({ path: `file.${extension}` }) + expect(result.isError).toBeUndefined() + expect(result.content).toEqual([{ type: "text", text: content }]) + }, + ) + + it("returns a small PNG as an image block with a metadata text line", async () => { + const { vault, readAsset } = await setupAssetHarness() + const png = await sharp({ + create: { + width: 4, + height: 4, + channels: 3, + background: { r: 255, g: 0, b: 255 }, + }, + }) + .png() + .toBuffer() + await writeFile(join(vault, "tiny.png"), png) + const result = await readAsset({ path: "tiny.png" }) + expect(result.isError).toBeUndefined() + expect(result.content).toEqual([ + { type: "image", data: png.toString("base64"), mimeType: "image/png" }, + { + type: "text", + text: `tiny.png — image/png, 4×4, ${png.length} bytes (original file, not recompressed)`, + }, + ]) + }) + + it("rejects a .pdf with a not-yet-supported error carrying the file size", async () => { + const { vault, readAsset } = await setupAssetHarness() + await writeFile(join(vault, "doc.pdf"), "0123456789", "utf8") + const result = await readAsset({ path: "doc.pdf" }) + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toBe( + '[Error]: PDF reading is not yet supported: "doc.pdf" exists (10 bytes) but text extraction is not available yet', + ) + }) + + it("rejects an unsupported extension naming the readable types", async () => { + const { vault, readAsset } = await setupAssetHarness() + await writeFile(join(vault, "song.mp3"), "xxxx", "utf8") + const result = await readAsset({ path: "song.mp3" }) + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toBe( + '[Error]: unsupported asset type ".mp3": "song.mp3" exists (4 bytes). Readable types: images (.png/.jpg/.jpeg/.gif/.webp), .canvas, and text formats (.svg/.json/.txt/.csv/.xml/.log/.base)', + ) + }) + + it("rejects a .md path without touching the note", async () => { + const { vault, readAsset } = await setupAssetHarness() + await writeFile(join(vault, "note.md"), "# A note\n", "utf8") + const result = await readAsset({ path: "note.md" }) + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toBe( + '[Error]: not an asset: "note.md" is a markdown note', + ) + }) + + it("rejects a missing asset with asset not found", async () => { + const { readAsset } = await setupAssetHarness() + const result = await readAsset({ path: "ghost.png" }) + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toBe( + '[Error]: asset not found: "ghost.png"', + ) + }) + + it("rejects an oversized text asset instead of truncating it", async () => { + const { vault, readAsset } = await setupAssetHarness() + const oversized = "x".repeat(102_401) + await writeFile(join(vault, "big.txt"), oversized, "utf8") + const result = await readAsset({ path: "big.txt" }) + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toBe( + '[Error]: text output too large: "big.txt" renders to 102401 bytes (cap 102400 bytes)', + ) + }) + + it("lists a folder's assets with bytes and counts, excluding other folders and notes", async () => { + const { vault, listAssets } = await setupAssetHarness() + await mkdir(join(vault, "media"), { recursive: true }) + await mkdir(join(vault, "elsewhere"), { recursive: true }) + await writeFile(join(vault, "media/a.png"), "12345", "utf8") + await writeFile(join(vault, "media/b.canvas"), "{}", "utf8") + await writeFile(join(vault, "media/note.md"), "# not an asset", "utf8") + await writeFile(join(vault, "elsewhere/c.png"), "999", "utf8") + const result = await listAssets({ folder: "media" }) + expect(result.isError).toBeUndefined() + expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({ + assets: [ + { path: "media/a.png", extension: ".png", bytes: 5 }, + { path: "media/b.canvas", extension: ".canvas", bytes: 2 }, + ], + extension_counts: { ".png": 1, ".canvas": 1 }, + total: 2, + truncated: false, + }) + }) + + it("filters by extension case-insensitively with or without the leading dot", async () => { + const { vault, listAssets } = await setupAssetHarness() + await writeFile(join(vault, "a.png"), "12345", "utf8") + await writeFile(join(vault, "b.jpg"), "12", "utf8") + const result = await listAssets({ extensions: ["PNG"] }) + expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({ + assets: [{ path: "a.png", extension: ".png", bytes: 5 }], + extension_counts: { ".png": 1 }, + total: 1, + truncated: false, + }) + }) + + it("pages with limit while counts and total cover the full filtered set", async () => { + const { vault, listAssets } = await setupAssetHarness() + await writeFile(join(vault, "a.png"), "1", "utf8") + await writeFile(join(vault, "b.png"), "22", "utf8") + await writeFile(join(vault, "c.jpg"), "333", "utf8") + const result = await listAssets({ limit: 1 }) + expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({ + assets: [{ path: "a.png", extension: ".png", bytes: 1 }], + extension_counts: { ".png": 2, ".jpg": 1 }, + total: 3, + truncated: true, + }) + }) +}) diff --git a/src/vault-mcp/mcp-core/tool-definitions.ts b/src/vault-mcp/mcp-core/tool-definitions.ts index c321dfb4..2b68805f 100644 --- a/src/vault-mcp/mcp-core/tool-definitions.ts +++ b/src/vault-mcp/mcp-core/tool-definitions.ts @@ -15,6 +15,7 @@ import { registerDailyNoteTools, } from "./tools/daily-note-tools.js" import { TASK_TOOL_NAMES, registerTaskTools } from "./tools/task-tools.js" +import { ASSET_TOOL_NAMES, registerAssetTools } from "./tools/asset-tools.js" export const TOOL_NAMES = { ...VAULT_CRUD_TOOL_NAMES, @@ -22,6 +23,7 @@ export const TOOL_NAMES = { ...MEMORY_TOOL_NAMES, ...DAILY_NOTE_TOOL_NAMES, ...TASK_TOOL_NAMES, + ...ASSET_TOOL_NAMES, } as const export const registerTools = (params: { @@ -38,12 +40,14 @@ export const registerTools = (params: { } registerDailyNoteTools(params) registerTaskTools(params) + registerAssetTools(params) const registeredCount = Object.keys(VAULT_CRUD_TOOL_NAMES).length + Object.keys(SEARCH_TOOL_NAMES).length + (params.config.memoryEnabled ? Object.keys(MEMORY_TOOL_NAMES).length : 0) + Object.keys(DAILY_NOTE_TOOL_NAMES).length + - Object.keys(TASK_TOOL_NAMES).length + Object.keys(TASK_TOOL_NAMES).length + + Object.keys(ASSET_TOOL_NAMES).length params.logger.info("registered tools", { count: registeredCount }) } diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts new file mode 100644 index 00000000..28f1cb34 --- /dev/null +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -0,0 +1,310 @@ +/** Asset tool registration — reading and discovering non-markdown vault files. */ + +import { z } from "zod" +import { vaultFs } from "../../vault-operations/vault-filesystem.js" +import { linearizeCanvas } from "../../obsidian-markdown/canvas.js" +import { links } from "../../obsidian-markdown/links.js" +import { fitImageToByteBudget } from "../../../utils/fit-image-to-byte-budget.js" +import type { FittedImage } from "../../../utils/fit-image-to-byte-budget.js" +import type { ToolRegistrationContext } from "./tool-helpers.js" +import { safeHandler, safeHandlerContent } from "./tool-helpers.js" + +const TOOL_NAMES = { + VAULT_READ_ASSET: "vault_read_asset", + VAULT_LIST_ASSETS: "vault_list_assets", +} as const + +export { TOOL_NAMES as ASSET_TOOL_NAMES } + +/** Extensions dispatched to the image pipeline (model-visible image blocks). */ +const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]) + +/** Extensions returned verbatim as text — plain-text formats an agent can + * read directly. .svg is XML source; .base is Obsidian Bases YAML. */ +const TEXT_PASSTHROUGH_EXTENSIONS = new Set([ + ".svg", + ".json", + ".txt", + ".csv", + ".xml", + ".log", + ".base", +]) + +/** Fixed cap on text output (passthrough files and canvas renditions) so a + * huge text asset can't blow a client's response limit. Deliberately not an + * env var — the image budget is the tunable surface; text past this size + * needs paging, not a bigger blob. */ +const MAX_TEXT_OUTPUT_BYTES = 102_400 + +/** The computed result of one vault_read_asset call, before content-block + * formatting: an image (fitted to the byte budget) or a text rendition. */ +type AssetReadResult = + | Readonly<{ + kind: "image" + fitted: FittedImage + originalBytes: number + path: string + }> + | Readonly<{ kind: "text"; text: string }> + +/** One-line, model-facing summary accompanying an image block: what file it + * is, what was delivered, and whether/how it was shrunk to fit. */ +const describeDeliveredImage = (result: { + fitted: FittedImage + originalBytes: number + path: string +}): string => { + const { fitted, originalBytes, path } = result + const delivered = `${path} — ${fitted.mimeType}, ${fitted.width}×${fitted.height}, ${fitted.data.length} bytes` + if (!fitted.recompressed) + return `${delivered} (original file, not recompressed)` + return `${delivered} (recompressed from ${fitted.originalWidth}×${fitted.originalHeight}, ${originalBytes} bytes)` +} + +/** Rejects text output past the fixed cap — an explicit error beats silent + * truncation, and states the actual size so the caller knows what exists. */ +const assertTextWithinCap = (params: { text: string; path: string }): void => { + const textBytes = Buffer.byteLength(params.text, "utf8") + if (textBytes <= MAX_TEXT_OUTPUT_BYTES) return + throw new Error( + `text output too large: "${params.path}" renders to ${textBytes} bytes ` + + `(cap ${MAX_TEXT_OUTPUT_BYTES} bytes)`, + ) +} + +/** Normalizes a user-supplied extension filter entry: lowercased, leading dot + * ensured — so "PNG", "png", and ".png" all match ".png". */ +const normalizeExtension = (extension: string): string => { + const lowered = extension.toLowerCase() + return lowered.startsWith(".") ? lowered : `.${lowered}` +} + +export const registerAssetTools = ({ + server, + vaultPath, + logger: sessionLogger, + config, +}: ToolRegistrationContext): void => { + server.registerTool( + TOOL_NAMES.VAULT_READ_ASSET, + { + title: "Read Asset", + description: `Read a non-markdown vault file (an asset) in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note. + +Example: vault_read_asset({ path: "attachments/diagram.png" }) — the image itself, downscaled to fit response limits +Example: vault_read_asset({ path: "Boards/Roadmap.canvas" }) — a readable outline of the canvas +Example: vault_read_asset({ path: "exports/data.json" }) — the file content as text + +What each type returns: +- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — automatically downscaled and recompressed server-side to fit client response limits — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs deliver their first frame. +- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. +- Text formats (.svg/.json/.txt/.csv/.xml/.log/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source. +- PDFs (.pdf): not yet readable — returns an error that confirms the file exists and its size; text extraction is planned. + +When to use: whenever a note references an asset you need to actually see or read — an embedded diagram, a linked canvas or data file. Find the assets a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's assets via vault_list_assets. For .md notes use vault_read_note — this tool rejects them. + +Errors: +- "not an asset" — the path ends in .md; read notes with vault_read_note +- "asset not found" — nothing exists at that path; discover valid paths via vault_list_assets +- "asset too large" — the file exceeds the server's read cap (MAX_ASSET_BYTES, default 50 MiB) +- "text output too large" — a text asset renders past the output cap; only smaller files can be returned whole +- "image cannot be fitted" — the image could not be compressed under the output budget (MAX_IMAGE_OUTPUT_BYTES) +- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size + +Returns: for images, an image content block plus a one-line metadata text block; for every other supported type, a single text content block.`, + inputSchema: { + path: z + .string() + .min(1) + .describe( + 'Vault-relative path to the asset, including its extension (e.g. "attachments/photo.png", "Boards/Roadmap.canvas"). Must NOT end in ".md" — notes are read with vault_read_note.', + ), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ path }, extra) => { + const reqLogger = sessionLogger.child({ + requestId: extra.requestId, + tool: TOOL_NAMES.VAULT_READ_ASSET, + }) + reqLogger.info("tool_call", { path }) + return safeHandlerContent( + reqLogger, + async (): Promise => { + const asset = await vaultFs.readAsset( + { vaultPath, path, maxBytes: config.maxAssetBytes }, + reqLogger, + ) + if (IMAGE_EXTENSIONS.has(asset.extension)) { + const fitted = await fitImageToByteBudget({ + buffer: asset.buffer, + budgetBytes: config.maxImageOutputBytes, + }) + return { kind: "image", fitted, originalBytes: asset.bytes, path } + } + if (asset.extension === ".canvas") { + const rendition = linearizeCanvas(asset.buffer.toString("utf8")) + assertTextWithinCap({ text: rendition, path }) + return { kind: "text", text: rendition } + } + if (TEXT_PASSTHROUGH_EXTENSIONS.has(asset.extension)) { + const text = asset.buffer.toString("utf8") + assertTextWithinCap({ text, path }) + return { kind: "text", text } + } + if (asset.extension === ".pdf") { + throw new Error( + `PDF reading is not yet supported: "${path}" exists ` + + `(${asset.bytes} bytes) but text extraction is not available yet`, + ) + } + throw new Error( + `unsupported asset type "${asset.extension}": "${path}" exists ` + + `(${asset.bytes} bytes). Readable types: images ` + + `(.png/.jpg/.jpeg/.gif/.webp), .canvas, and text formats ` + + `(.svg/.json/.txt/.csv/.xml/.log/.base)`, + ) + }, + (result) => { + if (result.kind === "image") { + reqLogger.info("tool_result", { + path, + mimeType: result.fitted.mimeType, + deliveredBytes: result.fitted.data.length, + recompressed: result.fitted.recompressed, + }) + return [ + { + type: "image" as const, + data: result.fitted.data.toString("base64"), + mimeType: result.fitted.mimeType, + }, + { type: "text" as const, text: describeDeliveredImage(result) }, + ] + } + reqLogger.info("tool_result", { + path, + textBytes: Buffer.byteLength(result.text, "utf8"), + }) + return [{ type: "text" as const, text: result.text }] + }, + ) + }, + ) + + server.registerTool( + TOOL_NAMES.VAULT_LIST_ASSETS, + { + title: "List Assets", + description: `List non-markdown files (assets) in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts. + +Example: vault_list_assets({}) — every asset in the vault +Example: vault_list_assets({ folder: "attachments" }) +Example: vault_list_assets({ extensions: [".png", ".jpg"], limit: 20 }) + +When to use: discovering what assets exist before reading them with vault_read_asset. vault_search, vault_list_notes, and vault_search_by_folder cover only markdown notes, so this is the discovery surface for everything else. For the assets one specific note links to, prefer vault_get_outgoing_links. + +Parameters: +- folder: folder path filter (e.g. "attachments" or "Projects/media"), searched recursively; omit for the whole vault +- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (".png" and "png" both work) +- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page. + +Errors: +- A folder containing no assets — or a folder that doesn't exist — returns an empty listing, not an error. + +Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).`, + inputSchema: { + folder: z + .string() + .min(1) + .optional() + .describe( + 'Folder path to search recursively (e.g. "attachments"). Omit to list the whole vault.', + ), + extensions: z + .array(z.string().min(1)) + .optional() + .describe( + 'Only include these extensions, case-insensitive, leading dot optional (e.g. [".png", "jpg"]).', + ), + limit: z + .number() + .optional() + .describe("Max entries returned (default 50)."), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ folder, extensions, limit }, extra) => { + const reqLogger = sessionLogger.child({ + requestId: extra.requestId, + tool: TOOL_NAMES.VAULT_LIST_ASSETS, + }) + reqLogger.info("tool_call", { folder, extensions, limit }) + return safeHandler( + reqLogger, + async () => { + const assetPaths = await vaultFs.listAssets( + { vaultPath, folder }, + reqLogger, + ) + const extensionFilter = extensions?.map(normalizeExtension) + const filteredPaths = extensionFilter + ? assetPaths.filter((assetPath) => + extensionFilter.includes( + links.getExtension(assetPath).toLowerCase(), + ), + ) + : assetPaths + + const extensionOf = (assetPath: string): string => + links.getExtension(assetPath).toLowerCase() || "(none)" + const extensionCounts = filteredPaths.reduce>( + (counts, assetPath) => ({ + ...counts, + [extensionOf(assetPath)]: + (counts[extensionOf(assetPath)] ?? 0) + 1, + }), + {}, + ) + + const pageLimit = limit ?? 50 + const pagePaths = filteredPaths.slice(0, pageLimit) + // Stat only the returned page — counts and total come from the path + // list, so unpaged files are never statted. + const stattedPage = await vaultFs.statAssets( + { vaultPath, paths: pagePaths }, + reqLogger, + ) + return { + assets: stattedPage.map((entry) => ({ + path: entry.path, + extension: extensionOf(entry.path), + bytes: entry.bytes, + })), + extension_counts: extensionCounts, + total: filteredPaths.length, + truncated: filteredPaths.length > pageLimit, + } + }, + (result) => { + reqLogger.info("tool_result", { + total: result.total, + returned: result.assets.length, + }) + return JSON.stringify(result) + }, + ) + }, + ) +} diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts index 1cf5460b..dc105668 100644 --- a/src/vault-mcp/mcp-core/tools/search-tools.ts +++ b/src/vault-mcp/mcp-core/tools/search-tools.ts @@ -618,7 +618,7 @@ For incoming links (what links TO a note), use vault_get_backlinks. Parameters: - path is matched against the search index, so the note must be indexed (file watcher processes new/moved files within seconds). A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links. -Returns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes } sorted by target path), and count. Each link carries exists (boolean) and kind ("note"|"asset"): exists+note = readable via vault_read_note; exists+asset = non-markdown file (.canvas, image, PDF); !exists+note = broken link. bytes is null for broken links and assets.`, +Returns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes } sorted by target path), and count. Each link carries exists (boolean) and kind ("note"|"asset"): exists+note = readable via vault_read_note; exists+asset = non-markdown file (.canvas, image, PDF) readable via vault_read_asset; !exists+note = broken link. bytes is the file's size for notes and assets alike; null for broken links.`, inputSchema: { path: z .string() diff --git a/src/vault-mcp/mcp-core/tools/tool-helpers.ts b/src/vault-mcp/mcp-core/tools/tool-helpers.ts index d0264c89..116ee575 100644 --- a/src/vault-mcp/mcp-core/tools/tool-helpers.ts +++ b/src/vault-mcp/mcp-core/tools/tool-helpers.ts @@ -2,6 +2,7 @@ import { z } from "zod" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" import type { SearchIndex } from "../../search/search-index.js" import type { VaultConfig } from "../../config.js" import type { Logger } from "../../../logger.js" @@ -62,20 +63,20 @@ export const dateFilterSchema = z }) .optional() -/** Wraps a handler with try/catch, returning isError on failure. */ -export const safeHandler = async ( +/** Wraps a handler with try/catch, returning isError on failure. The format + * callback produces the full content-block array — text, image, or mixed + * (the SDK union) — for tools whose results aren't a single text block. */ +export const safeHandlerContent = async ( logger: Logger, fn: () => Promise, - format: (result: T) => string, + format: (result: T) => CallToolResult["content"], ): Promise<{ - content: Array<{ type: "text"; text: string }> + content: CallToolResult["content"] isError?: true }> => { try { const result = await fn() - return { - content: [{ type: "text" as const, text: format(result) }], - } + return { content: format(result) } } catch (err) { const message = describeError(err) logger.warn("tool_error", { error: message }) @@ -85,3 +86,18 @@ export const safeHandler = async ( } } } + +/** Wraps a handler with try/catch, returning isError on failure — the common + * single-text-block case. Delegates to safeHandlerContent so the error + * contract (describeError, tool_error log, isError) has exactly one home. */ +export const safeHandler = ( + logger: Logger, + fn: () => Promise, + format: (result: T) => string, +): Promise<{ + content: CallToolResult["content"] + isError?: true +}> => + safeHandlerContent(logger, fn, (result) => [ + { type: "text" as const, text: format(result) }, + ]) diff --git a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts index 6c5daef9..824cc1b3 100644 --- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts +++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts @@ -52,6 +52,7 @@ Errors: - "heading not found" — no heading matches the text; error lists available headings - "ambiguous heading" — multiple headings match; use heading_level to disambiguate - "outline, heading, and properties_only are mutually exclusive" — only one mode per call +- 'path must end in ".md"' — the path names a non-markdown file; read assets (images, .canvas, data files) with vault_read_asset instead Returns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object (outline); raw markdown of the section, heading line included (heading). diff --git a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts index 1da71e40..53be9bbf 100644 --- a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts @@ -38,6 +38,8 @@ const { deleteNote, listNotes, listAssets, + readAsset, + statAssets, } = vaultFs let vault: string @@ -835,6 +837,15 @@ describe("listAssets", () => { ]) }) + it("scopes the listing to a folder, excluding assets outside it", async () => { + await writeFile(join(vault, "outside.txt"), "not in assets/", "utf8") + const files = await listAssets( + { vaultPath: vault, folder: "assets" }, + logger, + ) + expect(files).toEqual(["assets/board.canvas", "assets/photo.png"]) + }) + it("includes a symlinked asset in the listing", async () => { await symlink("assets/photo.png", join(vault, "linked.png")) const files = await listAssets({ vaultPath: vault }, logger) @@ -1596,3 +1607,77 @@ describe("concurrent writes (exclusive lock)", () => { ).toBe("second life\n") }) }) + +describe("readAsset", () => { + it("reads a binary file whole with its byte count and lowercased extension", async () => { + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]) + await writeFile(join(vault, "photo.PNG"), bytes) + const asset = await readAsset( + { vaultPath: vault, path: "photo.PNG", maxBytes: 1024 }, + logger, + ) + expect(asset.buffer.equals(bytes)).toBe(true) + expect(asset.bytes).toBe(6) + expect(asset.extension).toBe(".png") + }) + + it("rejects a .md path as not an asset", async () => { + await writeFile(join(vault, "note.md"), "# note", "utf8") + await expect( + readAsset({ vaultPath: vault, path: "note.md", maxBytes: 1024 }, logger), + ).rejects.toThrow('not an asset: "note.md" is a markdown note') + }) + + it("blocks path traversal out of the vault", async () => { + await expect( + readAsset( + { vaultPath: vault, path: "../outside.png", maxBytes: 1024 }, + logger, + ), + ).rejects.toThrow( + 'path traversal blocked: "../outside.png" escapes vault root', + ) + }) + + it("rejects a missing file as asset not found", async () => { + await expect( + readAsset( + { vaultPath: vault, path: "ghost.png", maxBytes: 1024 }, + logger, + ), + ).rejects.toThrow('asset not found: "ghost.png"') + }) + + it("rejects a file over the byte cap before reading it", async () => { + await writeFile(join(vault, "big.bin"), "12345678901", "utf8") + await expect( + readAsset({ vaultPath: vault, path: "big.bin", maxBytes: 10 }, logger), + ).rejects.toThrow( + 'asset too large: "big.bin" is 11 bytes (cap 10 bytes — raise MAX_ASSET_BYTES to read larger files)', + ) + }) +}) + +describe("statAssets", () => { + it("returns each path's byte size in input order", async () => { + await writeFile(join(vault, "a.png"), "12345", "utf8") + await writeFile(join(vault, "b.canvas"), "12", "utf8") + const statted = await statAssets( + { vaultPath: vault, paths: ["a.png", "b.canvas"] }, + logger, + ) + expect(statted).toEqual([ + { path: "a.png", bytes: 5 }, + { path: "b.canvas", bytes: 2 }, + ]) + }) + + it("drops a path that vanished instead of throwing", async () => { + await writeFile(join(vault, "kept.png"), "1234", "utf8") + const statted = await statAssets( + { vaultPath: vault, paths: ["kept.png", "vanished.png"] }, + logger, + ) + expect(statted).toEqual([{ path: "kept.png", bytes: 4 }]) + }) +}) From bb331fc61c40a580ab020e78ae194439fc30e972 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:26:55 -0400 Subject: [PATCH 05/34] feat(assets): env vars across deploy surfaces + docs sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAX_ASSET_BYTES + MAX_IMAGE_OUTPUT_BYTES through all four compose files, both deploy .env.examples (+ root), cli env blocks (sync script), and server.json environmentVariables. Docs: README Assets category + feature bullet, ARCHITECTURE Assets section + outgoing-links bytes note, AGENTS.md structure tree + obsidian-markdown JSON-format layering note + read_asset path-convention inverse, wiki.json (3 pages), social-preview SVG feature text (PNG re-render deferred — local Chrome headless screenshot hangs, unrelated to this change), DOCKERHUB regen, cli/README feature line, server instructions string, vault-orientation go-deeper list. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- .devin/wiki.json | 6 ++--- .env.example | 5 ++++ AGENTS.md | 25 ++++++++++++++----- ARCHITECTURE.md | 20 ++++++++++++++- DOCKERHUB.md | 3 +++ README.md | 3 +++ assets/social-preview.svg | 2 +- cli/README.md | 5 ++-- cli/src/env.ts | 20 +++++++++++++++ deploy/local/.env.example | 10 ++++++++ deploy/local/docker-compose.yml | 2 ++ deploy/remote/.env.example | 10 ++++++++ deploy/remote/docker-compose.yml | 2 ++ docker-compose.local.yml | 2 ++ docker-compose.yml | 2 ++ server.json | 12 +++++++++ src/vault-mcp/mcp-core/mcp-router.ts | 4 +-- .../prompts/vault-orientation-prompt.ts | 1 + 18 files changed, 119 insertions(+), 15 deletions(-) diff --git a/.devin/wiki.json b/.devin/wiki.json index c6f26831..061cf2cc 100644 --- a/.devin/wiki.json +++ b/.devin/wiki.json @@ -40,7 +40,7 @@ }, { "title": "Obsidian Markdown Parsing", - "purpose": "The pure parser layer in obsidian-markdown/ — no filesystem I/O, just domain knowledge about the Obsidian/Markdown format. Covers: links.ts (link grammar, fence-aware extraction, resolution for wikilinks, markdown links, embeds, frontmatter links, and relative path-from-current-file links — all three of Obsidian's 'New link format' modes); lines.ts (CRLF-safe line splitting, the consolidated fence state machine, classifyLines for identifying fenced regions); frontmatter.ts (gray-matter parse/stringify, frontmatter merge); callouts.ts (leading-callout parser for > [!type] blocks); headings.ts (shared H1-H6 section-span parser used by both read and patch operations); tasks.ts (Tasks-plugin task-line grammar covering emoji signifiers and Dataview inline fields); memory-entries.ts (memory-entry grammar — parses the dated-bullet format of About Me/ memory files into individual entries for vault_memory_recall's entry-granular index); plaintext.ts (strips Obsidian/Markdown syntax to produce plain text for the embedding pipeline). This layer is architecturally significant — it encodes the Obsidian-parity behavior that distinguishes Vault Cortex from simpler file-based integrations.", + "purpose": "The pure parser layer in obsidian-markdown/ — no filesystem I/O, just domain knowledge about the Obsidian/Markdown format. Covers: links.ts (link grammar, fence-aware extraction, resolution for wikilinks, markdown links, embeds, frontmatter links, and relative path-from-current-file links — all three of Obsidian's 'New link format' modes); canvas.ts (JSON Canvas 1.0 linearizer — spatial group containment, reading-order node sort, id-resolved edge lists — pure string-to-string despite parsing JSON, same leaf layer); lines.ts (CRLF-safe line splitting, the consolidated fence state machine, classifyLines for identifying fenced regions); frontmatter.ts (gray-matter parse/stringify, frontmatter merge); callouts.ts (leading-callout parser for > [!type] blocks); headings.ts (shared H1-H6 section-span parser used by both read and patch operations); tasks.ts (Tasks-plugin task-line grammar covering emoji signifiers and Dataview inline fields); memory-entries.ts (memory-entry grammar — parses the dated-bullet format of About Me/ memory files into individual entries for vault_memory_recall's entry-granular index); plaintext.ts (strips Obsidian/Markdown syntax to produce plain text for the embedding pipeline). This layer is architecturally significant — it encodes the Obsidian-parity behavior that distinguishes Vault Cortex from simpler file-based integrations.", "parent": "Architecture", "page_notes": [ { @@ -50,7 +50,7 @@ }, { "title": "Vault Operations", - "purpose": "The filesystem I/O layer in vault-operations/ that reads, writes, and transforms vault content. Safety primitives: atomic writes (atomicWriteFile temp-then-rename, atomicWriteFileExclusive link-based no-clobber for TOCTOU-safe creates), per-file write locking (file-write-lock.ts — serializing, fail-fast, and multi-file fail-fast modes), path traversal prevention (resolveSafePath resolve + prefix check, toVaultRelativePath normalization), and protected-path enforcement (PROTECTED_PATHS checked after normalization). Covers: vault-filesystem.ts (read/write/list/delete .md files, outline and section reads, protected-path validation); vault-patcher.ts (surgical edits — heading-targeted patch with prepend/append/replace operations, and find-and-replace with optional replace-all); note-mover.ts (move/rename a note and rewrite every vault-wide link that pointed to it, using the link resolution engine from obsidian-markdown/links.ts — preflight-then-commit design with multi-file exclusive locking); memory-store.ts (About Me/ heading-aware read/append/delete with shrink guard, idempotency guard, and ambiguity guard — see Memory Layer page); daily-notes.ts (daily note config reader and path resolver — see Memory Layer page). All functions follow the (params, logger) two-argument pattern.", + "purpose": "The filesystem I/O layer in vault-operations/ that reads, writes, and transforms vault content. Asset reads: vaultFs.readAsset (binary read with .md rejection and stat-first size cap), listAssets (folder-scoped non-md walk), statAssets (bounded-concurrency page stat) back the vault_read_asset/vault_list_assets tools. Safety primitives: atomic writes (atomicWriteFile temp-then-rename, atomicWriteFileExclusive link-based no-clobber for TOCTOU-safe creates), per-file write locking (file-write-lock.ts — serializing, fail-fast, and multi-file fail-fast modes), path traversal prevention (resolveSafePath resolve + prefix check, toVaultRelativePath normalization), and protected-path enforcement (PROTECTED_PATHS checked after normalization). Covers: vault-filesystem.ts (read/write/list/delete .md files, outline and section reads, protected-path validation); vault-patcher.ts (surgical edits — heading-targeted patch with prepend/append/replace operations, and find-and-replace with optional replace-all); note-mover.ts (move/rename a note and rewrite every vault-wide link that pointed to it, using the link resolution engine from obsidian-markdown/links.ts — preflight-then-commit design with multi-file exclusive locking); memory-store.ts (About Me/ heading-aware read/append/delete with shrink guard, idempotency guard, and ambiguity guard — see Memory Layer page); daily-notes.ts (daily note config reader and path resolver — see Memory Layer page). All functions follow the (params, logger) two-argument pattern.", "parent": "Architecture" }, { @@ -69,7 +69,7 @@ }, { "title": "Tool Reference", - "purpose": "The MCP tools, now organized in domain group modules under mcp-core/tools/: vault-crud-tools.ts (read, write, patch, replace, delete-span, list, delete, move, update-properties), search-tools.ts (search, search-by-tag, search-by-folder, recent-notes, list-tags, list-property-keys, list-property-values, search-by-property, get-backlinks, get-outgoing-links, find-orphans), task-tools.ts (list-tasks, update-task), memory-tools.ts (get-memory, update-memory, list-memory-files, delete-memory, memory-recall; conditionally registered when MEMORY_ENABLED=true), and daily-note-tools.ts (get-daily-note). vault_list_tasks and vault_update_task in task-tools.ts provide Kanban-aware task management: vault_list_tasks is a vault-wide task index with structured filters (status, 6 date fields, priority, folder/tag/heading/path scope), array params for multi-lane queries, date cascade sorting, and position sorting for board order; vault_update_task applies status, priority, and/or lane changes in a single call — marking done auto-detects the done lane (via **Complete** markers, falling back to 'Done' heading), stamps/removes completion dates, and writes in the user's configured Tasks plugin format (emoji or Dataview). The orchestrator in tool-definitions.ts composes TOOL_NAMES from each group and calls register functions. Each tool description includes Example, When to use, Returns, and Errors sections — optimized against Glama's TDQS (Tool Description Quality Score) model to ensure agents select and invoke the right tool efficiently.", + "purpose": "The MCP tools, now organized in domain group modules under mcp-core/tools/: vault-crud-tools.ts (read, write, patch, replace, delete-span, list, delete, move, update-properties), search-tools.ts (search, search-by-tag, search-by-folder, recent-notes, list-tags, list-property-keys, list-property-values, search-by-property, get-backlinks, get-outgoing-links, find-orphans), task-tools.ts (list-tasks, update-task), memory-tools.ts (get-memory, update-memory, list-memory-files, delete-memory, memory-recall; conditionally registered when MEMORY_ENABLED=true), daily-note-tools.ts (get-daily-note), and asset-tools.ts (read-asset — images as MCP image blocks via a sharp fit-to-byte-budget pipeline, .canvas linearized via obsidian-markdown/canvas.ts, text formats verbatim; list-assets — filesystem-backed discovery with folder/extension filters and per-extension counts). vault_list_tasks and vault_update_task in task-tools.ts provide Kanban-aware task management: vault_list_tasks is a vault-wide task index with structured filters (status, 6 date fields, priority, folder/tag/heading/path scope), array params for multi-lane queries, date cascade sorting, and position sorting for board order; vault_update_task applies status, priority, and/or lane changes in a single call — marking done auto-detects the done lane (via **Complete** markers, falling back to 'Done' heading), stamps/removes completion dates, and writes in the user's configured Tasks plugin format (emoji or Dataview). The orchestrator in tool-definitions.ts composes TOOL_NAMES from each group and calls register functions. Each tool description includes Example, When to use, Returns, and Errors sections — optimized against Glama's TDQS (Tool Description Quality Score) model to ensure agents select and invoke the right tool efficiently.", "parent": "MCP Interface" }, { diff --git a/.env.example b/.env.example index 921b21a3..86257b24 100644 --- a/.env.example +++ b/.env.example @@ -101,6 +101,11 @@ TZ=UTC # Enables polling for the file watcher and rename-based moves across # the Docker Desktop/WSL2 bridge. # WINDOWS_MODE=false +# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB). +# MAX_ASSET_BYTES=52428800 +# Byte budget for images returned by vault_read_asset, in binary bytes before +# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap). +# MAX_IMAGE_OUTPUT_BYTES=49152 # Enable or disable the memory layer (default: true). # Set to false to hide memory tools, skip auto-initialization, and omit # memory references from server metadata. MEMORY_DIR is ignored when false. diff --git a/AGENTS.md b/AGENTS.md index 82d10bb8..499a85ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,9 +100,10 @@ src/ file-write-lock.ts # Per-file write locks — serializing, fail-fast, and multi-file fail-fast modes (TOCTOU prevention) map-with-concurrency.ts # Bounded-concurrency async map (batch-based) describe-error.ts # describeError — message from an unknown throw - fs.ts # readFileOrNull / readdirOrNull / fileExists (ENOENT-safe) + fs.ts # readFileOrNull / readBinaryFileOrNull / readdirOrNull / fileExists / statOrNull (ENOENT-safe) assert-path-has-extension.ts # Generic path extension assertion (used by note-path validation) filter-valid-symlinks.ts # Filters out broken symlinks from directory listings + fit-image-to-byte-budget.ts # Downscale/recompress an image buffer to fit a byte budget (sharp) functions/ authorizer.ts # Lambda: path-aware auth (OAuth pass-through, JWT + static) vault-mcp/ @@ -116,9 +117,10 @@ src/ links.ts # Link grammar: parse, extract, resolve (wikilinks + md; notes + assets) tasks.ts # Tasks-plugin task-line grammar + mutation (emoji + Dataview fields) memory-entries.ts # Memory-entry grammar (dated bullets in About Me/ files) + canvas.ts # .canvas linearizer (JSON Canvas 1.0 → readable markdown) plaintext.ts # Strip Obsidian/Markdown syntax → plain text vault-operations/ # Vault content read/write/patch (filesystem I/O) - vault-filesystem.ts # Read/write/list/delete .md files; list non-md assets; outline + section reads + vault-filesystem.ts # Read/write/list/delete .md files; read/list/stat non-md assets; outline + section reads vault-patcher.ts # Surgical edits: heading-targeted patch + find-and-replace note-mover.ts # Move/rename a note + rewrite every vault-wide link to it memory-store.ts # About Me/ heading-aware read/append/delete @@ -130,12 +132,13 @@ src/ tool-definitions.ts # Tool orchestrator — TOOL_NAMES + conditional group registration prompt-definitions.ts # Prompt orchestrator — PROMPT_NAMES + conditional group registration tools/ # Tool group modules (one per data-layer domain) - tool-helpers.ts # Shared ToolRegistrationContext type + safeHandler + tool-helpers.ts # Shared ToolRegistrationContext type + safeHandler/safeHandlerContent vault-crud-tools.ts # 9 tools: read, write, patch, replace, delete, move search-tools.ts # 11 tools: search, tags, properties, graph queries task-tools.ts # 2 tools: list-tasks, update-task memory-tools.ts # 5 tools: get/update/list/delete memory + memory recall daily-note-tools.ts # 1 tool: get daily note + asset-tools.ts # 2 tools: read-asset, list-assets prompts/ # Prompt group modules (one per prompt) prompt-helpers.ts # Shared PromptRegistrationContext type + formatting helpers vault-orientation-prompt.ts # 1 prompt: vault structure + health survey @@ -163,10 +166,14 @@ The `vault-mcp/` tree is organized in dependency layers — parsers → I/O → use-cases → protocol → wiring. A module's folder is decided by **what it depends on**, not just its topic: -- **`obsidian-markdown/`** — pure parsers/transforms over Obsidian-flavored - Markdown (frontmatter, lines, headings, callouts, links). **No fs, no SQLite, +- **`obsidian-markdown/`** — pure parsers/transforms over Obsidian's file + formats (frontmatter, lines, headings, callouts, links). **No fs, no SQLite, no MCP**; they take strings/lines and return data or transformed strings, so - they're trivially unit-testable. `lines.ts` is the single home of the + they're trivially unit-testable. The folder's contract is the dependency + profile, not the syntax family: `canvas.ts` parses JSON (JSON Canvas 1.0), + but its text nodes and its linearized output are markdown, and it's the same + pure leaf layer — Obsidian format parsers belong here regardless of whether + the format is markdown, JSON, or YAML. `lines.ts` is the single home of the CommonMark §4.5 fence state machine (`advanceFence`) — every fence-aware walk threads it, so they can't disagree about where a fence opens. **Dual-format task mutations:** `tasks.ts` reads **and writes** both emoji @@ -559,6 +566,12 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal): (`src/utils/assert-path-has-extension.ts`), called in the data-layer function each tool routes through (one rule, every layer). Folder, glob, and memory-file (`file`) inputs are exempt. +- **`vault_read_asset` is the deliberate inverse.** Its `path` names any + non-markdown file and **must not** end in `.md` — `vaultFs.readAsset` + rejects notes so the `.md` boundary stays a single rule with two sides + (notes → `vault_read_note`, everything else → `vault_read_asset`). Error + messages never name tools; the routing guidance lives in each tool's + description. ### Test conventions diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0eeb33de..d680e258 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -281,9 +281,27 @@ Link queries use a `links` table populated during indexing: 2. Path relative to the linking note (path from current file, including upward `../`) 3. Basename (shortest-path-first for ambiguous basenames) - **Non-markdown assets:** Targets that don't resolve to a note are checked against a `non_md_files` table (populated during rebuild, maintained by the file watcher). Both wikilinks and markdown-style links to `.canvas`, `.base`, images, PDFs, and other non-markdown assets resolve as `kind: "asset"` instead of being counted as broken. -- **Outgoing links:** `vault_get_outgoing_links` returns a `kind` discriminator (`"note"` or `"asset"`) so clients can distinguish retrievable notes from non-retrievable asset references. +- **Outgoing links:** `vault_get_outgoing_links` returns a `kind` discriminator (`"note"` or `"asset"`) plus each target's byte size (`bytes` — from the notes table for notes, from `non_md_files` for assets), so clients can route notes to `vault_read_note` and assets to `vault_read_asset` with size awareness. - **Orphans:** `vault_find_orphans` excludes folders listed in `ORPHAN_EXCLUDE_FOLDERS` (default: `Daily Notes`, `Templates`, and the memory dir). +### Assets + +| Tool | Input | Annotation | +| ------------------- | ------------------------------ | ------------ | +| `vault_read_asset` | `path` | readOnlyHint | +| `vault_list_assets` | `folder?, extensions?, limit?` | readOnlyHint | + +`vault_read_asset` reads non-markdown vault files, dispatching on extension to the most useful representation per type: + +1. **Images** (`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`) return an MCP `image` content block plus a one-line metadata text block. A shared fit-to-byte-budget pipeline (`utils/fit-image-to-byte-budget.ts`, built on sharp) makes oversized images deliverable: EXIF auto-orient → resize long edge to ≤1568px → walk a fixed quality ladder (JPEG via mozjpeg for opaque images, WebP for alpha — PNG has no quality knob) → shrink dimensions by √(budget/actual) if the ladder floor still exceeds the budget. Deterministic and terminating (bounded attempts, 64px floor); sharp's default `limitInputPixels` stays active as the decompression-bomb guard. The budget (`MAX_IMAGE_OUTPUT_BYTES`, default 48 KiB binary) is sized for the tightest mainstream client cap. +2. **Canvas** (`.canvas`) linearizes to markdown via the pure `obsidian-markdown/canvas.ts` parser (JSON Canvas 1.0): group membership by spatial rect containment (innermost group wins), nodes in reading order (y, then x), and an edge list with node ids resolved to display names. Lenient parsing — unknown properties ignored, malformed entries skipped. +3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.base`) pass through verbatim as text, capped at a fixed 100 KiB output size (explicit error over silent truncation). +4. **PDFs** return a structured not-yet-supported error carrying the file's existence and size (text extraction is a planned follow-up); unknown types return an error naming the readable set. + +Reads go through `vaultFs.readAsset`: the same `resolveSafePath` traversal guard as notes, a `.md` rejection (notes belong to `vault_read_note`), and a stat-before-read size cap (`MAX_ASSET_BYTES`, default 50 MiB). + +`vault_list_assets` is the discovery surface: a filesystem walk (`vaultFs.listAssets` — filesystem truth, deliberately not the index), folder and case-insensitive extension filters, per-extension counts computed over the full filtered set, and byte sizes statted only for the returned page. Assets are readable and browsable but not yet searchable — content indexing is a possible future tier. + ### Tasks (R9) | Tool | Input | Annotation | diff --git a/DOCKERHUB.md b/DOCKERHUB.md index d0da463a..403c8922 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -48,6 +48,7 @@ - **[Structured memory](https://github.com/aliasunder/vault-cortex#memory)** — dated, append-only entries accumulate into a personal knowledge layer, auto-initialized for AI personalization. Topic recall answers "what do I think about X?" with the current take and the dated history behind it — evolution included. - **[Tasks](https://github.com/aliasunder/vault-cortex#tasks)** — Kanban-aware task queries and updates: triage by status, dates, or priority, then complete, reprioritize, or move tasks between lanes in one call. Parses both [Tasks plugin](https://publish.obsidian.md/tasks/) emoji and [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) inline-field formats. - **[Link graph](https://github.com/aliasunder/vault-cortex#tools)** — backlinks, outgoing links, and orphan detection across the vault +- **[Assets](https://github.com/aliasunder/vault-cortex#tools)** — read the vault's non-markdown files too: images arrive as actual images (downscaled to fit), canvases as readable outlines, data files as text. Assets are readable and browsable, not yet searchable - **[Obsidian-native](https://github.com/aliasunder/vault-cortex#properties)** — understands frontmatter, wikilinks, tags, headings, and daily notes - **[Guided workflows](https://github.com/aliasunder/vault-cortex#prompts)** — built-in prompts for vault health, memory review, and daily reconciliation — assembled from live vault data each time @@ -89,6 +90,8 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quic | **Links** | `vault_get_backlinks` | Notes linking to a given path | | | `vault_get_outgoing_links` | Links from a given note | | | `vault_find_orphans` | Notes with no incoming links | +| **Assets** | `vault_read_asset` | Read a non-markdown file — images delivered as images, canvases as readable outlines | +| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-type counts | | **Daily Notes** | `vault_get_daily_note` | Today's (or any date's) daily note | ## Prompts diff --git a/README.md b/README.md index ed5a8650..bc5bf7ed 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ - **[Structured memory](#memory)** — dated, append-only entries accumulate into a personal knowledge layer, auto-initialized for AI personalization. Topic recall answers "what do I think about X?" with the current take and the dated history behind it — evolution included. - **[Tasks](#tasks)** — Kanban-aware task queries and updates: triage by status, dates, or priority, then complete, reprioritize, or move tasks between lanes in one call. Parses both [Tasks plugin](https://publish.obsidian.md/tasks/) emoji and [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) inline-field formats. - **[Link graph](#tools)** — backlinks, outgoing links, and orphan detection across the vault +- **[Assets](#tools)** — read the vault's non-markdown files too: images arrive as actual images (downscaled to fit), canvases as readable outlines, data files as text. Assets are readable and browsable, not yet searchable - **[Obsidian-native](#properties)** — understands frontmatter, wikilinks, tags, headings, and daily notes - **[Guided workflows](#prompts)** — built-in prompts for vault health, memory review, and daily reconciliation — assembled from live vault data each time @@ -243,6 +244,8 @@ See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing mod | **Links** | `vault_get_backlinks` | Notes linking to a given path | | | `vault_get_outgoing_links` | Links from a given note | | | `vault_find_orphans` | Notes with no incoming links | +| **Assets** | `vault_read_asset` | Read a non-markdown file — images delivered as images, canvases as readable outlines | +| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-type counts | | **Daily Notes** | `vault_get_daily_note` | Today's (or any date's) daily note | ## Prompts diff --git a/assets/social-preview.svg b/assets/social-preview.svg index 050e6d60..3ea89f0f 100644 --- a/assets/social-preview.svg +++ b/assets/social-preview.svg @@ -59,5 +59,5 @@ hybrid search · notes · memory · tasks · link graph · OAuth 2.1 + font-size="22" fill="#58a6ff">hybrid search · notes · memory · tasks · assets · link graph · OAuth 2.1 diff --git a/cli/README.md b/cli/README.md index 00f5de58..ca894df5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -8,8 +8,9 @@ npx vault-cortex@latest init ``` Vault Cortex is a standalone, remote-capable MCP server that gives any AI -agent hybrid search, task management, structured memory, and read/write -access to your Obsidian vault — see the +agent hybrid search, task management, structured memory, asset reading +(images, canvases, data files), and read/write access to your Obsidian +vault — see the [full feature overview](https://github.com/aliasunder/vault-cortex#what-you-get). The server runs as a Docker container; this CLI scaffolds the config and manages the container so you don't have to. diff --git a/cli/src/env.ts b/cli/src/env.ts index 02896943..ed0d74f8 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -34,6 +34,16 @@ const LOCAL_OPTIONAL_BLOCK = `# Optional ───────────── # Override if you expose the server on a different URL (e.g. via a reverse proxy). PUBLIC_URL=http://localhost:8000 +# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB). +# Reading a larger file returns an error instead of content. +MAX_ASSET_BYTES=52428800 + +# Byte budget for images returned by vault_read_asset, in binary bytes before +# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap). +# Images are downscaled/recompressed server-side to fit. Raise it for clients +# that accept larger tool responses. +MAX_IMAGE_OUTPUT_BYTES=49152 + # Your IANA timezone — affects daily note resolution and memory timestamps. # TZ=America/New_York @@ -128,6 +138,16 @@ RERANK_MODE=blended # the Docker Desktop/WSL2 bridge. WINDOWS_MODE=false +# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB). +# Reading a larger file returns an error instead of content. +MAX_ASSET_BYTES=52428800 + +# Byte budget for images returned by vault_read_asset, in binary bytes before +# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap). +# Images are downscaled/recompressed server-side to fit. Raise it for clients +# that accept larger tool responses. +MAX_IMAGE_OUTPUT_BYTES=49152 + # Enable or disable the memory layer (default: true). # Set to false to hide memory tools and skip About Me/ creation. MEMORY_ENABLED=true diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 4a5765d5..163e2955 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -17,6 +17,16 @@ VAULT_PATH= # Override if you expose the server on a different URL (e.g. via a reverse proxy). PUBLIC_URL=http://localhost:8000 +# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB). +# Reading a larger file returns an error instead of content. +MAX_ASSET_BYTES=52428800 + +# Byte budget for images returned by vault_read_asset, in binary bytes before +# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap). +# Images are downscaled/recompressed server-side to fit. Raise it for clients +# that accept larger tool responses. +MAX_IMAGE_OUTPUT_BYTES=49152 + # Your IANA timezone — affects daily note resolution and memory timestamps. # TZ=America/New_York diff --git a/deploy/local/docker-compose.yml b/deploy/local/docker-compose.yml index 4572a1f1..bcfb9d95 100644 --- a/deploy/local/docker-compose.yml +++ b/deploy/local/docker-compose.yml @@ -36,6 +36,8 @@ services: # Windows: set WINDOWS_MODE=true in .env when your vault is on a C: drive # (polling watcher + rename-based moves across the Docker Desktop/WSL2 bridge). WINDOWS_MODE: ${WINDOWS_MODE:-false} + MAX_ASSET_BYTES: ${MAX_ASSET_BYTES:-52428800} + MAX_IMAGE_OUTPUT_BYTES: ${MAX_IMAGE_OUTPUT_BYTES:-49152} # Optional overrides. When unset, the server applies smart defaults # ( below is the resolved MEMORY_DIR value, default "About Me"): # PROTECTED_PATHS default: ", Daily Notes" (blocked from vault_delete_note) diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index 008afb43..1f4fb9e5 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -49,6 +49,16 @@ RERANK_MODE=blended # the Docker Desktop/WSL2 bridge. WINDOWS_MODE=false +# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB). +# Reading a larger file returns an error instead of content. +MAX_ASSET_BYTES=52428800 + +# Byte budget for images returned by vault_read_asset, in binary bytes before +# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap). +# Images are downscaled/recompressed server-side to fit. Raise it for clients +# that accept larger tool responses. +MAX_IMAGE_OUTPUT_BYTES=49152 + # Enable or disable the memory layer (default: true). # Set to false to hide memory tools and skip About Me/ creation. MEMORY_ENABLED=true diff --git a/deploy/remote/docker-compose.yml b/deploy/remote/docker-compose.yml index c11183d2..08a5fadb 100644 --- a/deploy/remote/docker-compose.yml +++ b/deploy/remote/docker-compose.yml @@ -47,6 +47,8 @@ services: LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30} TZ: ${TZ:-UTC} WINDOWS_MODE: ${WINDOWS_MODE:-false} + MAX_ASSET_BYTES: ${MAX_ASSET_BYTES:-52428800} + MAX_IMAGE_OUTPUT_BYTES: ${MAX_IMAGE_OUTPUT_BYTES:-49152} # Optional overrides. When unset, the server applies smart defaults # ( below is the resolved MEMORY_DIR value, default "About Me"): # PROTECTED_PATHS default: ", Daily Notes" (blocked from vault_delete_note) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index c2904639..8821df8a 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -32,6 +32,8 @@ services: # Windows: set WINDOWS_MODE=true when developing against a vault on a C: drive # (polling watcher + rename-based moves across the Docker Desktop/WSL2 bridge). WINDOWS_MODE: ${WINDOWS_MODE:-false} + MAX_ASSET_BYTES: ${MAX_ASSET_BYTES:-52428800} + MAX_IMAGE_OUTPUT_BYTES: ${MAX_IMAGE_OUTPUT_BYTES:-49152} # Left empty = the server applies smart defaults # ( below is the resolved MEMORY_DIR value, default "About Me"): # PROTECTED_PATHS default: ", Daily Notes" diff --git a/docker-compose.yml b/docker-compose.yml index 4ab65284..f65e1dc2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,8 @@ services: RERANK_MODE: ${RERANK_MODE:-blended} MEMORY_ENABLED: ${MEMORY_ENABLED:-true} WINDOWS_MODE: ${WINDOWS_MODE:-false} + MAX_ASSET_BYTES: ${MAX_ASSET_BYTES:-52428800} + MAX_IMAGE_OUTPUT_BYTES: ${MAX_IMAGE_OUTPUT_BYTES:-49152} MEMORY_DIR: ${MEMORY_DIR:-About Me} # Left empty = the server applies smart defaults # ( below is the resolved MEMORY_DIR value, default "About Me"): diff --git a/server.json b/server.json index f838d3b4..69bd231b 100644 --- a/server.json +++ b/server.json @@ -130,6 +130,18 @@ "name": "SERVICE_DOCUMENTATION_URL", "description": "Override the OAuth service documentation URL exposed via discovery metadata.", "default": "https://github.com/aliasunder/vault-cortex" + }, + { + "name": "MAX_ASSET_BYTES", + "description": "Largest asset file vault_read_asset will read, in bytes. Reading a larger file returns an error instead of content.", + "default": "52428800", + "format": "number" + }, + { + "name": "MAX_IMAGE_OUTPUT_BYTES", + "description": "Byte budget for images returned by vault_read_asset, in binary bytes before base64 encoding. Images are downscaled/recompressed server-side to fit; raise for clients that accept larger tool responses.", + "default": "49152", + "format": "number" } ] } diff --git a/src/vault-mcp/mcp-core/mcp-router.ts b/src/vault-mcp/mcp-core/mcp-router.ts index 30af05f4..06bb9fac 100644 --- a/src/vault-mcp/mcp-core/mcp-router.ts +++ b/src/vault-mcp/mcp-core/mcp-router.ts @@ -95,10 +95,10 @@ export const createMcpRouter = ({ }, { instructions: config.memoryEnabled - ? `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes. Use vault_get_memory to retrieve user preferences and context from ${config.memoryDir}/ files. Use vault_write_note and vault_update_memory for writes. + ? `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes; vault_read_asset for images, canvases, and other non-markdown files. Use vault_get_memory to retrieve user preferences and context from ${config.memoryDir}/ files. Use vault_write_note and vault_update_memory for writes. Vault content is Obsidian Flavored Markdown. Write tools pass content through without escaping — be intentional about Obsidian syntax (#, [[, %%, etc.) in inputs.` - : `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes. Use vault_write_note for writes. + : `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes; vault_read_asset for images, canvases, and other non-markdown files. Use vault_write_note for writes. Vault content is Obsidian Flavored Markdown. Write tools pass content through without escaping — be intentional about Obsidian syntax (#, [[, %%, etc.) in inputs.`, }, diff --git a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts index 48d9409e..7ed781b9 100644 --- a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts @@ -247,6 +247,7 @@ export const registerVaultOrientationPrompt = ({ orphanTools, memoryTools, "- `vault_read_note` — read any note's full content", + "- `vault_list_assets` — browse non-markdown files (images, canvases, data files)", ] .filter(Boolean) .join("\n") From 9c8436c0c8e6d73d39305575364f7ff545702aca Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:28:17 -0400 Subject: [PATCH 06/34] =?UTF-8?q?docs(assets):=20TDQS=20refinements=20?= =?UTF-8?q?=E2=80=94=20searchability=20limitation=20+=20traversal=20error?= =?UTF-8?q?=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/vault-mcp/mcp-core/tools/asset-tools.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index 28f1cb34..93fe195d 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -112,7 +112,9 @@ Errors: - "image cannot be fitted" — the image could not be compressed under the output budget (MAX_IMAGE_OUTPUT_BYTES) - unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size -Returns: for images, an image content block plus a one-line metadata text block; for every other supported type, a single text content block.`, +Returns: for images, an image content block plus a one-line metadata text block; for every other supported type, a single text content block. + +Limitation: assets are readable and listable but not searchable — vault_search indexes markdown notes only.`, inputSchema: { path: z .string() @@ -217,8 +219,9 @@ Parameters: Errors: - A folder containing no assets — or a folder that doesn't exist — returns an empty listing, not an error. +- A folder path escaping the vault (e.g. "../elsewhere") is rejected with a path-traversal error. -Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).`, +Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). Listed assets are readable via vault_read_asset; their contents are not searchable — vault_search indexes markdown notes only.`, inputSchema: { folder: z .string() From cdd65018c0f57c2aae5f150c9fe6f4abdc3f7cbf Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:39:34 -0400 Subject: [PATCH 07/34] test(assets): update pinned instructions + orientation-prompt assertions Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts | 2 +- .../mcp-core/__tests__/vault-orientation-prompt.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts b/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts index 7fbf1fe8..975e5c38 100644 --- a/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts @@ -71,7 +71,7 @@ const SERVER_INFO = { } const SERVER_OPTIONS = { - instructions: `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes. Use vault_get_memory to retrieve user preferences and context from ${DEFAULT_CONFIG.memoryDir}/ files. Use vault_write_note and vault_update_memory for writes. + instructions: `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes; vault_read_asset for images, canvases, and other non-markdown files. Use vault_get_memory to retrieve user preferences and context from ${DEFAULT_CONFIG.memoryDir}/ files. Use vault_write_note and vault_update_memory for writes. Vault content is Obsidian Flavored Markdown. Write tools pass content through without escaping — be intentional about Obsidian syntax (#, [[, %%, etc.) in inputs.`, } diff --git a/src/vault-mcp/mcp-core/__tests__/vault-orientation-prompt.test.ts b/src/vault-mcp/mcp-core/__tests__/vault-orientation-prompt.test.ts index 845bc3fe..25d4a699 100644 --- a/src/vault-mcp/mcp-core/__tests__/vault-orientation-prompt.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/vault-orientation-prompt.test.ts @@ -321,6 +321,7 @@ describe("vault-orientation full prompt output", () => { "- `vault_find_orphans` — full orphan list with exclusion control", "- `vault_get_memory` — read memory files in detail", "- `vault_read_note` — read any note's full content", + "- `vault_list_assets` — browse non-markdown files (images, canvases, data files)", ].join("\n"), ) }) From 23c67a562afa256f8129f0f7dc6e67439576b684 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:18:47 -0400 Subject: [PATCH 08/34] =?UTF-8?q?docs(assets):=20re-render=20social=20prev?= =?UTF-8?q?iew=20=E2=80=94=20fit=20feature=20line=20with=20assets=20catego?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line overflowed the canvas at font-size 22 after adding 'assets'; 19 fits with margin. Render script unchanged — the earlier screenshot timeouts were machine-load contention (concurrent Docker builds + test suites), not a script defect; verified by a 4-cell diagnostic matrix passing and the unmodified script rendering on an idle machine. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- assets/social-preview.png | Bin 84758 -> 84430 bytes assets/social-preview.svg | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/social-preview.png b/assets/social-preview.png index 83eae351ff72013ebe095d251221ce7cac1794c6..18a7a3153d778eb8cad1d38474100132c00208fd 100644 GIT binary patch delta 30430 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 delta 30929 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 diff --git a/assets/social-preview.svg b/assets/social-preview.svg index 3ea89f0f..98150049 100644 --- a/assets/social-preview.svg +++ b/assets/social-preview.svg @@ -59,5 +59,5 @@ hybrid search · notes · memory · tasks · assets · link graph · OAuth 2.1 + font-size="19" fill="#58a6ff">hybrid search · notes · memory · tasks · assets · link graph · OAuth 2.1 From 9ff256358cd4da9b2bb5e3caf674e38f3517750a Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:37:55 -0400 Subject: [PATCH 09/34] style: simplify extension counting loop, add env group separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace reduce-with-spread counting in vault_list_assets with a plain for...of loop — eliminates a double extensionOf call per item and reads on its own without tracing an accumulator. - Add blank comment line in root .env.example between WINDOWS_MODE and MAX_ASSET_BYTES for visual group separation (matches deploy/ examples). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- .env.example | 1 + src/vault-mcp/mcp-core/tools/asset-tools.ts | 15 +++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 86257b24..e0ec46b2 100644 --- a/.env.example +++ b/.env.example @@ -101,6 +101,7 @@ TZ=UTC # Enables polling for the file watcher and rename-based moves across # the Docker Desktop/WSL2 bridge. # WINDOWS_MODE=false +# # Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB). # MAX_ASSET_BYTES=52428800 # Byte budget for images returned by vault_read_asset, in binary bytes before diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index 93fe195d..194af8b8 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -272,14 +272,13 @@ Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), const extensionOf = (assetPath: string): string => links.getExtension(assetPath).toLowerCase() || "(none)" - const extensionCounts = filteredPaths.reduce>( - (counts, assetPath) => ({ - ...counts, - [extensionOf(assetPath)]: - (counts[extensionOf(assetPath)] ?? 0) + 1, - }), - {}, - ) + // Mutable accumulator: building frequency counts is the textbook + // case for a plain loop — no step depends on the prior step's shape. + const extensionCounts: Record = {} + for (const assetPath of filteredPaths) { + const ext = extensionOf(assetPath) + extensionCounts[ext] = (extensionCounts[ext] ?? 0) + 1 + } const pageLimit = limit ?? 50 const pagePaths = filteredPaths.slice(0, pageLimit) From cb2faec3758ac780dea0c1319ac0c71c584de616 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:45:56 -0400 Subject: [PATCH 10/34] test: cover getExtension, readBinaryFileOrNull, statOrNull, and asset config fields Ship-check Phase 3 coverage gap analysis found three utility-level exports and two config fields without direct tests despite being exercised indirectly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/utils/__tests__/fs.test.ts | 55 ++++++++++++++++++- src/vault-mcp/__tests__/config.test.ts | 36 ++++++++++++ .../obsidian-markdown/__tests__/links.test.ts | 28 ++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/fs.test.ts b/src/utils/__tests__/fs.test.ts index 0498e73c..25a9985c 100644 --- a/src/utils/__tests__/fs.test.ts +++ b/src/utils/__tests__/fs.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, onTestFinished } from "vitest" import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" -import { readFileOrNull, readdirOrNull, fileExists } from "../fs.js" +import { + readFileOrNull, + readBinaryFileOrNull, + readdirOrNull, + fileExists, + statOrNull, +} from "../fs.js" const makeTempDir = async (): Promise => { const dir = await mkdtemp(join(tmpdir(), "utils-fs-test-")) @@ -49,6 +55,53 @@ describe("readdirOrNull", () => { }) }) +describe("readBinaryFileOrNull", () => { + it("returns the file contents as a Buffer when the file exists", async () => { + const dir = await makeTempDir() + const path = join(dir, "image.png") + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + await writeFile(path, bytes) + const result = await readBinaryFileOrNull(path) + expect(result).not.toBeNull() + expect(result!.equals(bytes)).toBe(true) + }) + + it("returns null when the file does not exist", async () => { + const dir = await makeTempDir() + expect(await readBinaryFileOrNull(join(dir, "missing.bin"))).toBeNull() + }) + + it("rethrows a non-ENOENT error rather than swallowing it as missing", async () => { + const dir = await makeTempDir() + await expect(readBinaryFileOrNull(dir)).rejects.toThrow(/EISDIR/) + }) +}) + +describe("statOrNull", () => { + it("returns Stats when the path exists", async () => { + const dir = await makeTempDir() + const path = join(dir, "file.txt") + await writeFile(path, "12345", "utf8") + const stats = await statOrNull(path) + expect(stats).not.toBeNull() + expect(stats!.isFile()).toBe(true) + expect(stats!.size).toBe(5) + }) + + it("returns null when the path does not exist", async () => { + const dir = await makeTempDir() + expect(await statOrNull(join(dir, "ghost.txt"))).toBeNull() + }) + + it("rethrows a non-ENOENT error rather than swallowing it", async () => { + // ENOTDIR: stat a path through a file as if it were a directory + const dir = await makeTempDir() + const filePath = join(dir, "file.txt") + await writeFile(filePath, "x", "utf8") + await expect(statOrNull(join(filePath, "child"))).rejects.toThrow(/ENOTDIR/) + }) +}) + describe("fileExists", () => { it("returns true when the path exists", async () => { const dir = await makeTempDir() diff --git a/src/vault-mcp/__tests__/config.test.ts b/src/vault-mcp/__tests__/config.test.ts index f46c1a22..dac1f872 100644 --- a/src/vault-mcp/__tests__/config.test.ts +++ b/src/vault-mcp/__tests__/config.test.ts @@ -300,4 +300,40 @@ describe("loadConfig", () => { expect(config.protectedPaths).toEqual(["About Me", "Daily Notes"]) }) }) + + describe("MAX_ASSET_BYTES", () => { + it("defaults to 50 MiB (52428800) when unset", () => { + const config = loadConfig(EMPTY_ENV) + expect(config.maxAssetBytes).toBe(52_428_800) + }) + + it("accepts a custom positive integer", () => { + const config = loadConfig({ MAX_ASSET_BYTES: "10485760" }) + expect(config.maxAssetBytes).toBe(10_485_760) + }) + + it("rejects a non-integer value", () => { + expect(() => loadConfig({ MAX_ASSET_BYTES: "abc" })).toThrow( + /MAX_ASSET_BYTES/, + ) + }) + }) + + describe("MAX_IMAGE_OUTPUT_BYTES", () => { + it("defaults to 48 KiB (49152) when unset", () => { + const config = loadConfig(EMPTY_ENV) + expect(config.maxImageOutputBytes).toBe(49_152) + }) + + it("accepts a custom positive integer", () => { + const config = loadConfig({ MAX_IMAGE_OUTPUT_BYTES: "65536" }) + expect(config.maxImageOutputBytes).toBe(65_536) + }) + + it("rejects a non-integer value", () => { + expect(() => loadConfig({ MAX_IMAGE_OUTPUT_BYTES: "nope" })).toThrow( + /MAX_IMAGE_OUTPUT_BYTES/, + ) + }) + }) }) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts index fb64b37c..fc06c1a7 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts @@ -677,6 +677,34 @@ describe("stripExtension", () => { }) }) +// ── getExtension ──────────────────────────────────────────────── + +describe("getExtension", () => { + it("returns the extension including the dot for a normal filename", () => { + expect(links.getExtension("boards/Trip Route.canvas")).toBe(".canvas") + }) + + it("returns the last extension of a multi-dot filename", () => { + expect(links.getExtension("assets/photo.png.canvas")).toBe(".canvas") + }) + + it("returns empty string when the filename has no dot", () => { + expect(links.getExtension("assets/LICENSE")).toBe("") + }) + + it("treats a leading-dot file as having no extension", () => { + expect(links.getExtension("config/.hidden")).toBe("") + }) + + it("ignores dots in folder names", () => { + expect(links.getExtension("v1.2/note")).toBe("") + }) + + it("returns .png for a typical image path", () => { + expect(links.getExtension("attachments/photo.png")).toBe(".png") + }) +}) + // ── resolveAsset ───────────────────────────────────────────────── describe("resolveAsset", () => { From 6842d85560c2d0d97c6ccaaa5f1421d77d2c58cc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:48:46 -0400 Subject: [PATCH 11/34] test(assets): cover the watcher's vanished-file guard (triage of Phase 3 soft gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file-watcher test suite already spy-mocks utils/fs.js, so the race is deterministic: statOrNull forced to null for one path, real for the rest — no timing sensitivity. Without the guard, the watcher callback crashes (null.size) and vitest fails the run with the error attributed to this test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- .../search/__tests__/file-watcher.test.ts | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/vault-mcp/search/__tests__/file-watcher.test.ts b/src/vault-mcp/search/__tests__/file-watcher.test.ts index 79cd7e1c..4fdd38f6 100644 --- a/src/vault-mcp/search/__tests__/file-watcher.test.ts +++ b/src/vault-mcp/search/__tests__/file-watcher.test.ts @@ -21,7 +21,7 @@ import { join, resolve } from "node:path" import { tmpdir } from "node:os" import { watch } from "chokidar" import { createSearchIndex } from "../search-index.js" -import { readdirOrNull } from "../../../utils/fs.js" +import { readdirOrNull, statOrNull } from "../../../utils/fs.js" import type { SearchIndex } from "../search-index.js" import { startFileWatcher } from "../file-watcher.js" import { logger } from "../../../logger.js" @@ -140,6 +140,52 @@ describe("file-watcher", () => { expect(jsonResults).toHaveLength(0) }) + it( + "skips a non-md file that vanishes before its stat", + { timeout: 15000 }, + async () => { + const upsertNonMdFileSpy = vi.spyOn(index, "upsertNonMdFile") + // Force the stat miss by path — a deterministic stand-in for the file + // being deleted between the watcher event and the handler's stat. + const actualFsUtils = await vi.importActual< + typeof import("../../../utils/fs.js") + >("../../../utils/fs.js") + vi.mocked(statOrNull).mockImplementation(async (statPath) => + statPath.endsWith("vanished.png") + ? null + : actualFsUtils.statOrNull(statPath), + ) + onTestFinished(() => { + vi.mocked(statOrNull).mockRestore() + }) + + await startFileWatcher(vault, index, { + stabilityThreshold: 200, + pollInterval: 50, + }) + + await writeFile(join(vault, "vanished.png"), "gone", "utf8") + await writeFile(join(vault, "kept.png"), "here", "utf8") + + // Both non-md events have been processed once each file's stat ran. + await waitFor(() => + ["vanished.png", "kept.png"].every((fileName) => + vi + .mocked(statOrNull) + .mock.calls.some(([statPath]) => statPath.endsWith(fileName)), + ), + ) + + // Only the surviving file is indexed — the vanished one's early return + // must not upsert (without the guard, reading .size off null would throw + // inside the watcher callback). + await waitFor(() => upsertNonMdFileSpy.mock.calls.length > 0) + expect(upsertNonMdFileSpy.mock.calls.map(([path]) => path)).toEqual([ + "kept.png", + ]) + }, + ) + it("ignores hidden directories", { timeout: 15000 }, async () => { await mkdir(join(vault, ".obsidian"), { recursive: true }) From c6bd84704f62c3016490b81470fe566bfa3de5fc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:56:17 -0400 Subject: [PATCH 12/34] fix: qualify animated GIF first-frame claim in vault_read_asset description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The description unconditionally stated "Animated GIFs deliver their first frame" but the passthrough path (GIF already within budget and ≤1568px) returns the full animated buffer unchanged. Qualify to "when recompressed to fit the budget" which is accurate — sharp extracts the first frame only during the resize/encode path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/vault-mcp/mcp-core/tools/asset-tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index 194af8b8..a93f65fb 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -97,7 +97,7 @@ Example: vault_read_asset({ path: "Boards/Roadmap.canvas" }) — a readable outl Example: vault_read_asset({ path: "exports/data.json" }) — the file content as text What each type returns: -- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — automatically downscaled and recompressed server-side to fit client response limits — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs deliver their first frame. +- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — automatically downscaled and recompressed server-side to fit client response limits — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed to fit the budget. - Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. - Text formats (.svg/.json/.txt/.csv/.xml/.log/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source. - PDFs (.pdf): not yet readable — returns an error that confirms the file exists and its size; text extraction is planned. From fc86fff27ae5753cc2f8e6d5c867b6743f05f4fc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:02:19 -0400 Subject: [PATCH 13/34] =?UTF-8?q?fix(assets):=20review-thread=20fixes=20?= =?UTF-8?q?=E2=80=94=20canvas=20group=20tiebreak,=20UTF-8=20strictness,=20?= =?UTF-8?q?O(n)=20file=20filter,=20doc=20direction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - canvas.ts: identical-rect groups each claimed the other as parent, so neither was top-level and both vanished from the rendition; containment now gets one deterministic direction (higher id contains lower) for group pairs. Content nodes exempt (never parents). - asset-tools.ts: text passthrough and canvas decode UTF-8 strictly — invalid bytes now error instead of silently becoming U+FFFD (the description promises verbatim content). New Errors entry + test. - search-index.ts: visibleFilesOfKind spread-reduce → filter/map chain (O(n), no per-entry array copies). - Review direction: search-coverage phrasing stated positively in both tool descriptions + README bullet; new README Assets section (bullet anchors to it); cli/README feature line reworded; WINDOWS_MODE added to server.json environmentVariables; DOCKERHUB regenerated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- DOCKERHUB.md | 13 ++++++- README.md | 13 ++++++- cli/README.md | 6 ++-- server.json | 5 +++ .../__tests__/tool-definitions.test.ts | 12 +++++++ src/vault-mcp/mcp-core/tools/asset-tools.ts | 25 ++++++++++--- .../__tests__/canvas.test.ts | 35 +++++++++++++++++++ src/vault-mcp/obsidian-markdown/canvas.ts | 15 ++++++-- src/vault-mcp/search/search-index.ts | 33 +++++++++-------- 9 files changed, 131 insertions(+), 26 deletions(-) diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 403c8922..fde6e234 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -48,7 +48,7 @@ - **[Structured memory](https://github.com/aliasunder/vault-cortex#memory)** — dated, append-only entries accumulate into a personal knowledge layer, auto-initialized for AI personalization. Topic recall answers "what do I think about X?" with the current take and the dated history behind it — evolution included. - **[Tasks](https://github.com/aliasunder/vault-cortex#tasks)** — Kanban-aware task queries and updates: triage by status, dates, or priority, then complete, reprioritize, or move tasks between lanes in one call. Parses both [Tasks plugin](https://publish.obsidian.md/tasks/) emoji and [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) inline-field formats. - **[Link graph](https://github.com/aliasunder/vault-cortex#tools)** — backlinks, outgoing links, and orphan detection across the vault -- **[Assets](https://github.com/aliasunder/vault-cortex#tools)** — read the vault's non-markdown files too: images arrive as actual images (downscaled to fit), canvases as readable outlines, data files as text. Assets are readable and browsable, not yet searchable +- **[Assets](https://github.com/aliasunder/vault-cortex#assets)** — read the vault's non-markdown files too: images arrive as actual images (downscaled to fit), canvases as readable outlines, data files as text - **[Obsidian-native](https://github.com/aliasunder/vault-cortex#properties)** — understands frontmatter, wikilinks, tags, headings, and daily notes - **[Guided workflows](https://github.com/aliasunder/vault-cortex#prompts)** — built-in prompts for vault health, memory review, and daily reconciliation — assembled from live vault data each time @@ -59,6 +59,17 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quick-start) for local setup (2 minutes with Docker), remote deployment with Obsidian Sync, and MCP client configuration. +## Assets + +A vault isn't only markdown — it holds the diagrams your notes embed, the canvases that map your projects, the data files your workflows produce. The asset layer makes those readable too, each in the form an agent can actually use: + +- **Images** — delivered as actual images, automatically downscaled and recompressed server-side to fit client response limits. A screenshot or architecture diagram embedded in a note becomes something the agent can look at, not just a filename +- **Canvases** — `.canvas` boards arrive as a readable outline: groups, card content in reading order, and the connections between them +- **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return their content as-is +- **Browse and size** — list any folder's assets with per-type counts and file sizes, and every asset a note links to carries its size in the link graph + +See [ARCHITECTURE.md → Assets](https://github.com/aliasunder/vault-cortex/blob/main/ARCHITECTURE.md#assets) for the image pipeline and dispatch model. + ## Tools | Category | Tool | Description | diff --git a/README.md b/README.md index bc5bf7ed..5b9d0985 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - **[Structured memory](#memory)** — dated, append-only entries accumulate into a personal knowledge layer, auto-initialized for AI personalization. Topic recall answers "what do I think about X?" with the current take and the dated history behind it — evolution included. - **[Tasks](#tasks)** — Kanban-aware task queries and updates: triage by status, dates, or priority, then complete, reprioritize, or move tasks between lanes in one call. Parses both [Tasks plugin](https://publish.obsidian.md/tasks/) emoji and [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) inline-field formats. - **[Link graph](#tools)** — backlinks, outgoing links, and orphan detection across the vault -- **[Assets](#tools)** — read the vault's non-markdown files too: images arrive as actual images (downscaled to fit), canvases as readable outlines, data files as text. Assets are readable and browsable, not yet searchable +- **[Assets](#assets)** — read the vault's non-markdown files too: images arrive as actual images (downscaled to fit), canvases as readable outlines, data files as text - **[Obsidian-native](#properties)** — understands frontmatter, wikilinks, tags, headings, and daily notes - **[Guided workflows](#prompts)** — built-in prompts for vault health, memory review, and daily reconciliation — assembled from live vault data each time @@ -213,6 +213,17 @@ The task layer handles this so agents don't have to: See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing model, date cascade sorting, and Kanban lane detection. +## Assets + +A vault isn't only markdown — it holds the diagrams your notes embed, the canvases that map your projects, the data files your workflows produce. The asset layer makes those readable too, each in the form an agent can actually use: + +- **Images** — delivered as actual images, automatically downscaled and recompressed server-side to fit client response limits. A screenshot or architecture diagram embedded in a note becomes something the agent can look at, not just a filename +- **Canvases** — `.canvas` boards arrive as a readable outline: groups, card content in reading order, and the connections between them +- **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return their content as-is +- **Browse and size** — list any folder's assets with per-type counts and file sizes, and every asset a note links to carries its size in the link graph + +See [ARCHITECTURE.md → Assets](./ARCHITECTURE.md#assets) for the image pipeline and dispatch model. + ## Tools | Category | Tool | Description | diff --git a/cli/README.md b/cli/README.md index ca894df5..668c7181 100644 --- a/cli/README.md +++ b/cli/README.md @@ -8,9 +8,9 @@ npx vault-cortex@latest init ``` Vault Cortex is a standalone, remote-capable MCP server that gives any AI -agent hybrid search, task management, structured memory, asset reading -(images, canvases, data files), and read/write access to your Obsidian -vault — see the +agent hybrid search, task management, structured memory, and read/write +access to your Obsidian vault — including its images, canvases, and data +files — see the [full feature overview](https://github.com/aliasunder/vault-cortex#what-you-get). The server runs as a Docker container; this CLI scaffolds the config and manages the container so you don't have to. diff --git a/server.json b/server.json index 69bd231b..3e2f5051 100644 --- a/server.json +++ b/server.json @@ -86,6 +86,11 @@ "default": "blended", "choices": ["blended", "none"] }, + { + "name": "WINDOWS_MODE", + "description": "Windows bind-mount mode: enables filesystem polling for the file watcher and rename-based moves across the Docker Desktop/WSL2 bridge. Set to true when the vault lives on a Windows drive.", + "default": "false" + }, { "name": "MEMORY_ENABLED", "description": "Enable or disable the structured memory layer. When false, memory tools are hidden, bootstrap is skipped, and server metadata omits memory references.", diff --git a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 030527ad..7bf44b65 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -1098,6 +1098,18 @@ describe("asset tool handlers", () => { ) }) + it("rejects a non-UTF-8 text asset instead of corrupting it", async () => { + const { vault, readAsset } = await setupAssetHarness() + // 0xFF is never valid in UTF-8 — the default decoder would silently + // substitute U+FFFD; the tool must refuse instead. + await writeFile(join(vault, "latin1.txt"), Buffer.from([0x68, 0x69, 0xff])) + const result = await readAsset({ path: "latin1.txt" }) + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toBe( + '[Error]: not valid UTF-8: "latin1.txt" cannot be returned as text', + ) + }) + it("rejects an oversized text asset instead of truncating it", async () => { const { vault, readAsset } = await setupAssetHarness() const oversized = "x".repeat(102_401) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index a93f65fb..c573b969 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -73,6 +73,20 @@ const assertTextWithinCap = (params: { text: string; path: string }): void => { ) } +/** Decodes an asset buffer as UTF-8, rejecting invalid byte sequences — the + * tool promises text content verbatim, and the default decoder would + * silently substitute U+FFFD for every undecodable byte instead. */ +const decodeUtf8Strict = (params: { buffer: Buffer; path: string }): string => { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(params.buffer) + } catch (error) { + throw new Error( + `not valid UTF-8: "${params.path}" cannot be returned as text`, + { cause: error }, + ) + } +} + /** Normalizes a user-supplied extension filter entry: lowercased, leading dot * ensured — so "PNG", "png", and ".png" all match ".png". */ const normalizeExtension = (extension: string): string => { @@ -109,12 +123,13 @@ Errors: - "asset not found" — nothing exists at that path; discover valid paths via vault_list_assets - "asset too large" — the file exceeds the server's read cap (MAX_ASSET_BYTES, default 50 MiB) - "text output too large" — a text asset renders past the output cap; only smaller files can be returned whole +- "not valid UTF-8" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content - "image cannot be fitted" — the image could not be compressed under the output budget (MAX_IMAGE_OUTPUT_BYTES) - unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size Returns: for images, an image content block plus a one-line metadata text block; for every other supported type, a single text content block. -Limitation: assets are readable and listable but not searchable — vault_search indexes markdown notes only.`, +Search coverage: vault_search indexes markdown notes; find assets by browsing (vault_list_assets) or through a note's links (vault_get_outgoing_links).`, inputSchema: { path: z .string() @@ -151,12 +166,14 @@ Limitation: assets are readable and listable but not searchable — vault_search return { kind: "image", fitted, originalBytes: asset.bytes, path } } if (asset.extension === ".canvas") { - const rendition = linearizeCanvas(asset.buffer.toString("utf8")) + const rendition = linearizeCanvas( + decodeUtf8Strict({ buffer: asset.buffer, path }), + ) assertTextWithinCap({ text: rendition, path }) return { kind: "text", text: rendition } } if (TEXT_PASSTHROUGH_EXTENSIONS.has(asset.extension)) { - const text = asset.buffer.toString("utf8") + const text = decodeUtf8Strict({ buffer: asset.buffer, path }) assertTextWithinCap({ text, path }) return { kind: "text", text } } @@ -221,7 +238,7 @@ Errors: - A folder containing no assets — or a folder that doesn't exist — returns an empty listing, not an error. - A folder path escaping the vault (e.g. "../elsewhere") is rejected with a path-traversal error. -Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). Listed assets are readable via vault_read_asset; their contents are not searchable — vault_search indexes markdown notes only.`, +Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). Listed assets are readable via vault_read_asset; vault_search covers markdown notes.`, inputSchema: { folder: z .string() diff --git a/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts index a959daa6..97bbd28f 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts @@ -212,6 +212,41 @@ describe("linearizeCanvas", () => { ) }) + it("renders both groups when two groups share an identical rectangle", () => { + // Identical rects contain each other; without a deterministic tiebreak + // each would claim the other as parent and both would vanish from the + // output (neither top-level). The higher id must contain the lower. + const json = canvasJson([ + { + id: "alpha", + type: "group", + label: "Alpha", + x: 0, + y: 0, + width: 400, + height: 400, + }, + { + id: "beta", + type: "group", + label: "Beta", + x: 0, + y: 0, + width: 400, + height: 400, + }, + textNode({ id: "member", x: 10, y: 10, text: "inside both" }), + ]) + expect(linearizeCanvas(json)).toBe( + [ + "# Canvas: 3 nodes, 0 edges", + "## Group: Beta", + "### Group: Alpha", + "[text]\ninside both", + ].join("\n\n"), + ) + }) + it("renders an empty canvas as the overview line alone", () => { expect(linearizeCanvas("{}")).toBe("# Canvas: 0 nodes, 0 edges") }) diff --git a/src/vault-mcp/obsidian-markdown/canvas.ts b/src/vault-mcp/obsidian-markdown/canvas.ts index d8fbb74f..d5052f9b 100644 --- a/src/vault-mcp/obsidian-markdown/canvas.ts +++ b/src/vault-mcp/obsidian-markdown/canvas.ts @@ -91,9 +91,18 @@ const smallestContainingGroup = ( node: CanvasNode, groups: readonly CanvasNode[], ): CanvasNode | undefined => { - const containing = groups.filter( - (group) => group.id !== node.id && isContainedIn(node, group), - ) + const containing = groups.filter((group) => { + if (group.id === node.id || !isContainedIn(node, group)) return false + // Two groups with identical rectangles contain each other; without a + // tiebreak each would claim the other as parent, neither would be + // top-level, and both would drop out of the render entirely. Give the + // containment one deterministic direction (higher id contains lower) + // so one nests under the other. Content nodes are exempt — they never + // become parents, so mutual containment is harmless there. + const mutuallyContained = + node.type === "group" && isContainedIn(group, node) + return !mutuallyContained || group.id > node.id + }) if (containing.length === 0) return undefined return containing.reduce((smallest, candidate) => candidate.width * candidate.height < smallest.width * smallest.height diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index f319a1c9..615a355c 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -1366,29 +1366,34 @@ export const createSearchIndex = ( // Filter directory entries to visible files of one kind (.md notes or // non-md assets) — shared by the notes pass and the non-md stat pass. + // filter → map → filter keeps each pass O(n); a spread-accumulating + // reduce here would re-copy the array per entry (O(n²) on large vaults). const visibleFilesOfKind = ( fileKind: "note" | "asset", ): { relativePath: string; absolutePath: string }[] => - entries.reduce<{ relativePath: string; absolutePath: string }[]>( - (filteredFiles, directoryEntry) => { + entries + .filter((directoryEntry) => { if (!directoryEntry.isFile() && !directoryEntry.isSymbolicLink()) - return filteredFiles + return false const isNoteFile = directoryEntry.name.endsWith(".md") - const matchesKind = fileKind === "note" ? isNoteFile : !isNoteFile - if (!matchesKind) return filteredFiles + return fileKind === "note" ? isNoteFile : !isNoteFile + }) + .map((directoryEntry) => { const absolutePath = join( directoryEntry.parentPath, directoryEntry.name, ) - const relativePath = relative(normalizedVault, absolutePath) - if ( - relativePath.split("/").some((segment) => segment.startsWith(".")) - ) - return filteredFiles - return [...filteredFiles, { relativePath, absolutePath }] - }, - [], - ) + return { + relativePath: relative(normalizedVault, absolutePath), + absolutePath, + } + }) + .filter( + (file) => + !file.relativePath + .split("/") + .some((segment) => segment.startsWith(".")), + ) const markdownFiles = visibleFilesOfKind("note") From 2b69f79f0eba96f9eca55f2b0ba1ad14c1292aef Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:26:55 -0400 Subject: [PATCH 14/34] docs(assets): state that listed bytes are on-disk size, not delivery cost vault_list_assets and vault_get_outgoing_links report the on-disk file size; vault_read_asset downscales images to fit response limits, so a large listed image is still cheap to read. Both Returns sections now say so explicitly (text formats deliver verbatim, so there the listed size is the read cost). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/vault-mcp/mcp-core/tools/asset-tools.ts | 2 +- src/vault-mcp/mcp-core/tools/search-tools.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index c573b969..bbdbb408 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -238,7 +238,7 @@ Errors: - A folder containing no assets — or a folder that doesn't exist — returns an empty listing, not an error. - A folder path escaping the vault (e.g. "../elsewhere") is rejected with a path-traversal error. -Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). Listed assets are readable via vault_read_asset; vault_search covers markdown notes.`, +Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_asset returns a downscaled copy that can be far smaller than the listed size, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers. Listed assets are readable via vault_read_asset; vault_search covers markdown notes.`, inputSchema: { folder: z .string() diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts index dc105668..13b756cb 100644 --- a/src/vault-mcp/mcp-core/tools/search-tools.ts +++ b/src/vault-mcp/mcp-core/tools/search-tools.ts @@ -618,7 +618,7 @@ For incoming links (what links TO a note), use vault_get_backlinks. Parameters: - path is matched against the search index, so the note must be indexed (file watcher processes new/moved files within seconds). A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links. -Returns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes } sorted by target path), and count. Each link carries exists (boolean) and kind ("note"|"asset"): exists+note = readable via vault_read_note; exists+asset = non-markdown file (.canvas, image, PDF) readable via vault_read_asset; !exists+note = broken link. bytes is the file's size for notes and assets alike; null for broken links.`, +Returns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes } sorted by target path), and count. Each link carries exists (boolean) and kind ("note"|"asset"): exists+note = readable via vault_read_note; exists+asset = non-markdown file (.canvas, image, PDF) readable via vault_read_asset; !exists+note = broken link. bytes is the on-disk file size for notes and assets alike (null for broken links) — not the delivery cost: vault_read_asset downscales images to fit response limits, so a large image asset is still cheap to read.`, inputSchema: { path: z .string() From 9859214d09d8723dc73c2a6ac461c84b8d016a03 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:47:25 -0400 Subject: [PATCH 15/34] =?UTF-8?q?fix(assets):=20CodeRabbit=20review=20roun?= =?UTF-8?q?d=20=E2=80=94=20descent=20floor,=20tag-safe=20display=20names,?= =?UTF-8?q?=20capped=20read,=20zero-cap=20rejection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fit-image-to-byte-budget: descent now clamps to and encodes at the documented 64px floor before giving up (previously broke without ever trying it). - canvas: displayName strips only ATX headings (hashes + whitespace) so Obsidian tags keep their hash; equal-area group ties break on lower id so node ownership is JSON-order-independent (regression test with reversed declaration order). - readAsset: reads through a file handle into a stat-bounded buffer with a one-byte sentinel — a file growing between stat and read is rejected instead of ballooning memory; readBinaryFileOrNull removed as dead code. - config: reject 0 for MAX_ASSET_BYTES / MAX_IMAGE_OUTPUT_BYTES (env-var's asIntPositive admits 0, which would fail every read at runtime) — real gap surfaced by the suggested boundary tests. - vault_list_assets: limit schema int ≥ 1; Returns no longer overclaims that every listed asset is readable; README per-extension wording; cli/README read-only asset phrasing; fs.test.ts non-null assertions removed; extension-filter test covers .PNG spelling. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- AGENTS.md | 2 +- DOCKERHUB.md | 4 +- README.md | 4 +- cli/README.md | 4 +- src/utils/__tests__/fs.test.ts | 35 +--------- src/utils/fit-image-to-byte-budget.ts | 11 +++- src/utils/fs.ts | 13 ---- src/vault-mcp/__tests__/config.test.ts | 12 ++++ src/vault-mcp/config.ts | 31 ++++++--- .../__tests__/tool-definitions.test.ts | 27 ++++---- src/vault-mcp/mcp-core/tools/asset-tools.ts | 4 +- .../__tests__/canvas.test.ts | 54 ++++++++++++++++ src/vault-mcp/obsidian-markdown/canvas.ts | 20 ++++-- .../vault-operations/vault-filesystem.ts | 64 +++++++++++++++---- 14 files changed, 187 insertions(+), 98 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e950e90a..fbf4f864 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ src/ file-write-lock.ts # Per-file write locks — serializing, fail-fast, and multi-file fail-fast modes (TOCTOU prevention) map-with-concurrency.ts # Bounded-concurrency async map (batch-based) describe-error.ts # describeError — message from an unknown throw - fs.ts # readFileOrNull / readBinaryFileOrNull / readdirOrNull / fileExists / statOrNull (ENOENT-safe) + fs.ts # readFileOrNull / readdirOrNull / fileExists / statOrNull (ENOENT-safe) assert-path-has-extension.ts # Generic path extension assertion (used by note-path validation) filter-valid-symlinks.ts # Filters out broken symlinks from directory listings fit-image-to-byte-budget.ts # Downscale/recompress an image buffer to fit a byte budget (sharp) diff --git a/DOCKERHUB.md b/DOCKERHUB.md index fde6e234..dee2a85e 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -66,7 +66,7 @@ A vault isn't only markdown — it holds the diagrams your notes embed, the canv - **Images** — delivered as actual images, automatically downscaled and recompressed server-side to fit client response limits. A screenshot or architecture diagram embedded in a note becomes something the agent can look at, not just a filename - **Canvases** — `.canvas` boards arrive as a readable outline: groups, card content in reading order, and the connections between them - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return their content as-is -- **Browse and size** — list any folder's assets with per-type counts and file sizes, and every asset a note links to carries its size in the link graph +- **Browse and size** — list any folder's assets with per-extension counts and file sizes, and every asset a note links to carries its size in the link graph See [ARCHITECTURE.md → Assets](https://github.com/aliasunder/vault-cortex/blob/main/ARCHITECTURE.md#assets) for the image pipeline and dispatch model. @@ -102,7 +102,7 @@ See [ARCHITECTURE.md → Assets](https://github.com/aliasunder/vault-cortex/blob | | `vault_get_outgoing_links` | Links from a given note | | | `vault_find_orphans` | Notes with no incoming links | | **Assets** | `vault_read_asset` | Read a non-markdown file — images delivered as images, canvases as readable outlines | -| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-type counts | +| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-extension counts | | **Daily Notes** | `vault_get_daily_note` | Today's (or any date's) daily note | ## Prompts diff --git a/README.md b/README.md index 5b9d0985..495ea364 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ A vault isn't only markdown — it holds the diagrams your notes embed, the canv - **Images** — delivered as actual images, automatically downscaled and recompressed server-side to fit client response limits. A screenshot or architecture diagram embedded in a note becomes something the agent can look at, not just a filename - **Canvases** — `.canvas` boards arrive as a readable outline: groups, card content in reading order, and the connections between them - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return their content as-is -- **Browse and size** — list any folder's assets with per-type counts and file sizes, and every asset a note links to carries its size in the link graph +- **Browse and size** — list any folder's assets with per-extension counts and file sizes, and every asset a note links to carries its size in the link graph See [ARCHITECTURE.md → Assets](./ARCHITECTURE.md#assets) for the image pipeline and dispatch model. @@ -256,7 +256,7 @@ See [ARCHITECTURE.md → Assets](./ARCHITECTURE.md#assets) for the image pipelin | | `vault_get_outgoing_links` | Links from a given note | | | `vault_find_orphans` | Notes with no incoming links | | **Assets** | `vault_read_asset` | Read a non-markdown file — images delivered as images, canvases as readable outlines | -| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-type counts | +| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-extension counts | | **Daily Notes** | `vault_get_daily_note` | Today's (or any date's) daily note | ## Prompts diff --git a/cli/README.md b/cli/README.md index 668c7181..139aa4c8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -9,8 +9,8 @@ npx vault-cortex@latest init Vault Cortex is a standalone, remote-capable MCP server that gives any AI agent hybrid search, task management, structured memory, and read/write -access to your Obsidian vault — including its images, canvases, and data -files — see the +access to your Obsidian vault — plus read-only access to its images, +canvases, and data files — see the [full feature overview](https://github.com/aliasunder/vault-cortex#what-you-get). The server runs as a Docker container; this CLI scaffolds the config and manages the container so you don't have to. diff --git a/src/utils/__tests__/fs.test.ts b/src/utils/__tests__/fs.test.ts index 25a9985c..2585722d 100644 --- a/src/utils/__tests__/fs.test.ts +++ b/src/utils/__tests__/fs.test.ts @@ -2,13 +2,7 @@ import { describe, it, expect, onTestFinished } from "vitest" import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" -import { - readFileOrNull, - readBinaryFileOrNull, - readdirOrNull, - fileExists, - statOrNull, -} from "../fs.js" +import { readFileOrNull, readdirOrNull, fileExists, statOrNull } from "../fs.js" const makeTempDir = async (): Promise => { const dir = await mkdtemp(join(tmpdir(), "utils-fs-test-")) @@ -55,37 +49,14 @@ describe("readdirOrNull", () => { }) }) -describe("readBinaryFileOrNull", () => { - it("returns the file contents as a Buffer when the file exists", async () => { - const dir = await makeTempDir() - const path = join(dir, "image.png") - const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]) - await writeFile(path, bytes) - const result = await readBinaryFileOrNull(path) - expect(result).not.toBeNull() - expect(result!.equals(bytes)).toBe(true) - }) - - it("returns null when the file does not exist", async () => { - const dir = await makeTempDir() - expect(await readBinaryFileOrNull(join(dir, "missing.bin"))).toBeNull() - }) - - it("rethrows a non-ENOENT error rather than swallowing it as missing", async () => { - const dir = await makeTempDir() - await expect(readBinaryFileOrNull(dir)).rejects.toThrow(/EISDIR/) - }) -}) - describe("statOrNull", () => { it("returns Stats when the path exists", async () => { const dir = await makeTempDir() const path = join(dir, "file.txt") await writeFile(path, "12345", "utf8") const stats = await statOrNull(path) - expect(stats).not.toBeNull() - expect(stats!.isFile()).toBe(true) - expect(stats!.size).toBe(5) + expect(stats?.isFile()).toBe(true) + expect(stats?.size).toBe(5) }) it("returns null when the path does not exist", async () => { diff --git a/src/utils/fit-image-to-byte-budget.ts b/src/utils/fit-image-to-byte-budget.ts index 208dc8f9..b10533d9 100644 --- a/src/utils/fit-image-to-byte-budget.ts +++ b/src/utils/fit-image-to-byte-budget.ts @@ -140,11 +140,16 @@ export const fitImageToByteBudget = async (params: { } // Ladder floor still over budget — shrink dimensions. sqrt because encoded // size scales roughly with pixel area; the 0.7 clamp guarantees each step - // is a real reduction even when the overshoot is marginal. + // is a real reduction even when the overshoot is marginal. Descent clamps + // to the 64px floor and encodes there before giving up — breaking only + // when no further reduction is possible. qualityLadderIndex = QUALITY_LADDER.length const areaScale = Math.sqrt(params.budgetBytes / lastEncodedBytes) - const nextLongEdgePx = Math.floor(longEdgePx * Math.min(areaScale, 0.7)) - if (nextLongEdgePx < MIN_LONG_EDGE_PX) break + const nextLongEdgePx = Math.max( + MIN_LONG_EDGE_PX, + Math.floor(longEdgePx * Math.min(areaScale, 0.7)), + ) + if (nextLongEdgePx >= longEdgePx) break longEdgePx = nextLongEdgePx } diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 68e2a3b0..00154525 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -13,19 +13,6 @@ export const readFileOrNull = async (path: string): Promise => { } } -/** Reads a file as raw bytes (no encoding), returning null instead of throwing - * when it does not exist (ENOENT). Any other error propagates. */ -export const readBinaryFileOrNull = async ( - path: string, -): Promise => { - try { - return await readFile(path) - } catch (error) { - if (isErrnoException(error, "ENOENT")) return null - throw error - } -} - /** Stats a path, returning null instead of throwing when nothing exists there * (ENOENT). Any other error propagates. */ export const statOrNull = async (path: string): Promise => { diff --git a/src/vault-mcp/__tests__/config.test.ts b/src/vault-mcp/__tests__/config.test.ts index dac1f872..3a4968fd 100644 --- a/src/vault-mcp/__tests__/config.test.ts +++ b/src/vault-mcp/__tests__/config.test.ts @@ -317,6 +317,12 @@ describe("loadConfig", () => { /MAX_ASSET_BYTES/, ) }) + + it.each(["0", "-1", "1.5"])("rejects non-positive-integer %s", (value) => { + expect(() => loadConfig({ MAX_ASSET_BYTES: value })).toThrow( + /MAX_ASSET_BYTES/, + ) + }) }) describe("MAX_IMAGE_OUTPUT_BYTES", () => { @@ -335,5 +341,11 @@ describe("loadConfig", () => { /MAX_IMAGE_OUTPUT_BYTES/, ) }) + + it.each(["0", "-1", "1.5"])("rejects non-positive-integer %s", (value) => { + expect(() => loadConfig({ MAX_IMAGE_OUTPUT_BYTES: value })).toThrow( + /MAX_IMAGE_OUTPUT_BYTES/, + ) + }) }) }) diff --git a/src/vault-mcp/config.ts b/src/vault-mcp/config.ts index 3f153ed9..1e97412a 100644 --- a/src/vault-mcp/config.ts +++ b/src/vault-mcp/config.ts @@ -119,20 +119,31 @@ export const loadConfig = ( .default("false") .asBool() + // env-var's asIntPositive admits 0, but a zero byte cap would make every + // asset read fail at runtime — reject it at startup instead. + const requireNonZeroBytes = (name: string, value: number): number => { + if (value === 0) { + throw new Error(`env-var: "${name}" must be greater than 0`) + } + return value + } + // 50 MiB — matches the most permissive prior art for MCP attachment reads. - const maxAssetBytes = envVar - .from(env) - .get("MAX_ASSET_BYTES") - .default("52428800") - .asIntPositive() + const maxAssetBytes = requireNonZeroBytes( + "MAX_ASSET_BYTES", + envVar.from(env).get("MAX_ASSET_BYTES").default("52428800").asIntPositive(), + ) // 48 KiB binary ≈ 64 KiB base64 ≈ ~21k tokens — under Claude Code's 25k-token // MCP output cap with headroom for the metadata text block. - const maxImageOutputBytes = envVar - .from(env) - .get("MAX_IMAGE_OUTPUT_BYTES") - .default("49152") - .asIntPositive() + const maxImageOutputBytes = requireNonZeroBytes( + "MAX_IMAGE_OUTPUT_BYTES", + envVar + .from(env) + .get("MAX_IMAGE_OUTPUT_BYTES") + .default("49152") + .asIntPositive(), + ) return Object.freeze({ memoryEnabled, diff --git a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 7bf44b65..70ed8c08 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -1142,18 +1142,21 @@ describe("asset tool handlers", () => { }) }) - it("filters by extension case-insensitively with or without the leading dot", async () => { - const { vault, listAssets } = await setupAssetHarness() - await writeFile(join(vault, "a.png"), "12345", "utf8") - await writeFile(join(vault, "b.jpg"), "12", "utf8") - const result = await listAssets({ extensions: ["PNG"] }) - expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({ - assets: [{ path: "a.png", extension: ".png", bytes: 5 }], - extension_counts: { ".png": 1 }, - total: 1, - truncated: false, - }) - }) + it.each(["PNG", ".PNG"])( + "filters by extension case-insensitively for %s", + async (extensionSpelling) => { + const { vault, listAssets } = await setupAssetHarness() + await writeFile(join(vault, "a.png"), "12345", "utf8") + await writeFile(join(vault, "b.jpg"), "12", "utf8") + const result = await listAssets({ extensions: [extensionSpelling] }) + expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({ + assets: [{ path: "a.png", extension: ".png", bytes: 5 }], + extension_counts: { ".png": 1 }, + total: 1, + truncated: false, + }) + }, + ) it("pages with limit while counts and total cover the full filtered set", async () => { const { vault, listAssets } = await setupAssetHarness() diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index bbdbb408..d6e0451a 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -238,7 +238,7 @@ Errors: - A folder containing no assets — or a folder that doesn't exist — returns an empty listing, not an error. - A folder path escaping the vault (e.g. "../elsewhere") is rejected with a path-traversal error. -Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_asset returns a downscaled copy that can be far smaller than the listed size, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers. Listed assets are readable via vault_read_asset; vault_search covers markdown notes.`, +Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_asset returns a downscaled copy that can be far smaller than the listed size, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers. Assets of supported types are readable via vault_read_asset (unsupported types like .pdf return a descriptive error there); vault_search covers markdown notes.`, inputSchema: { folder: z .string() @@ -255,6 +255,8 @@ Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), ), limit: z .number() + .int() + .min(1) .optional() .describe("Max entries returned (default 50)."), }, diff --git a/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts index 97bbd28f..e2071392 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts @@ -247,6 +247,60 @@ describe("linearizeCanvas", () => { ) }) + it("renders the same ownership when the identical-rect groups are declared in reverse order", () => { + // Ownership must be a property of the canvas content (lower id wins + // equal-area ties), not of JSON array order. + const json = canvasJson([ + { + id: "beta", + type: "group", + label: "Beta", + x: 0, + y: 0, + width: 400, + height: 400, + }, + { + id: "alpha", + type: "group", + label: "Alpha", + x: 0, + y: 0, + width: 400, + height: 400, + }, + textNode({ id: "member", x: 10, y: 10, text: "inside both" }), + ]) + expect(linearizeCanvas(json)).toBe( + [ + "# Canvas: 3 nodes, 0 edges", + "## Group: Beta", + "### Group: Alpha", + "[text]\ninside both", + ].join("\n\n"), + ) + }) + + it("keeps an Obsidian tag's hash in edge display names", () => { + // Only ATX headings (hashes followed by whitespace) lose their hashes; + // "#project" is a tag, not a heading. + const json = canvasJson( + [ + textNode({ id: "a", text: "#project tracking" }), + textNode({ id: "b", x: 300, text: "## Roadmap" }), + ], + [{ id: "e1", fromNode: "a", toNode: "b" }], + ) + expect(linearizeCanvas(json)).toBe( + [ + "# Canvas: 2 nodes, 1 edge", + "[text]\n#project tracking", + "[text]\n## Roadmap", + "## Connections\n\n#project tracking → Roadmap", + ].join("\n\n"), + ) + }) + it("renders an empty canvas as the overview line alone", () => { expect(linearizeCanvas("{}")).toBe("# Canvas: 0 nodes, 0 edges") }) diff --git a/src/vault-mcp/obsidian-markdown/canvas.ts b/src/vault-mcp/obsidian-markdown/canvas.ts index d5052f9b..94121310 100644 --- a/src/vault-mcp/obsidian-markdown/canvas.ts +++ b/src/vault-mcp/obsidian-markdown/canvas.ts @@ -104,11 +104,16 @@ const smallestContainingGroup = ( return !mutuallyContained || group.id > node.id }) if (containing.length === 0) return undefined - return containing.reduce((smallest, candidate) => - candidate.width * candidate.height < smallest.width * smallest.height - ? candidate - : smallest, - ) + // Equal-area ties break on the lower id so ownership is a property of the + // canvas content, not of JSON array order. + return containing.reduce((smallest, candidate) => { + const candidateArea = candidate.width * candidate.height + const smallestArea = smallest.width * smallest.height + if (candidateArea < smallestArea) return candidate + if (candidateArea === smallestArea && candidate.id < smallest.id) + return candidate + return smallest + }) } /** Spatial reading order: top-to-bottom, then left-to-right. */ @@ -124,8 +129,9 @@ const displayName = (node: CanvasNode): string => { .map((line) => line.trim()) .find((line) => line.length > 0) // Text nodes often open with a markdown heading — "# Title" reads as - // "Title" in an edge list. - return firstLine ? firstLine.replace(/^#+\s*/, "") : "(empty text node)" + // "Title" in an edge list. Only ATX headings (hashes + whitespace) are + // stripped; an Obsidian tag like "#project" keeps its hash. + return firstLine ? firstLine.replace(/^#+\s+/, "") : "(empty text node)" } if (node.type === "file") { return node.file ? posix.basename(node.file) : "(file node)" diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 0f85edca..ec2d2312 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -2,6 +2,7 @@ import { writeFile, readdir, mkdir, + open, unlink, rename, link, @@ -16,10 +17,10 @@ import { filterValidSymlinks } from "../../utils/filter-valid-symlinks.js" import { fileExists, readFileOrNull, - readBinaryFileOrNull, readdirOrNull, statOrNull, } from "../../utils/fs.js" +import { isErrnoException } from "../../utils/is-errno-exception.js" import { mapWithConcurrency } from "../../utils/map-with-concurrency.js" import { withExclusiveFileLock } from "../../utils/file-write-lock.js" import { links } from "../obsidian-markdown/links.js" @@ -520,9 +521,10 @@ const listAssets = async ( /** Reads a non-.md vault file (an asset) as raw bytes, with a size cap. * Markdown notes are rejected — .md reads go through readNote, which treats - * files as notes, not bytes. The stat-before-read cap guards memory: assets - * return through tool responses whole, so an unbounded read of a huge file - * would balloon the process. */ + * files as notes, not bytes. The stat-before-read cap guards memory, and the + * read itself goes through a handle with a buffer bounded by that stat plus + * one sentinel byte — a file that grows between stat and read (a sync race) + * is rejected instead of ballooning memory or serving torn content. */ const readAsset = async ( params: { vaultPath: string; path: string; maxBytes: number }, logger: Logger, @@ -541,15 +543,51 @@ const readAsset = async ( `(cap ${params.maxBytes} bytes — raise MAX_ASSET_BYTES to read larger files)`, ) } - const buffer = await readBinaryFileOrNull(fullPath) - if (buffer === null) { - throw new Error(`asset not found: "${params.path}"`) - } - logger.info("read asset", { path: params.path, bytes: buffer.length }) - return { - buffer, - bytes: buffer.length, - extension: links.getExtension(params.path).toLowerCase(), + + const fileHandle = await (async () => { + try { + return await open(fullPath, "r") + } catch (error) { + if (isErrnoException(error, "ENOENT")) { + throw new Error(`asset not found: "${params.path}"`, { cause: error }) + } + throw error + } + })() + try { + // One sentinel byte past the statted size: if the file grew after the + // stat, the sentinel fills and the read is rejected as unstable. + const readBuffer = Buffer.alloc( + Math.min(fileStats.size, params.maxBytes) + 1, + ) + // Sequential fill loop — a single read() may return short on some + // platforms, so accumulate until EOF or the buffer is full. + let totalBytesRead = 0 + while (totalBytesRead < readBuffer.length) { + const { bytesRead } = await fileHandle.read( + readBuffer, + totalBytesRead, + readBuffer.length - totalBytesRead, + totalBytesRead, + ) + if (bytesRead === 0) break + totalBytesRead += bytesRead + } + if (totalBytesRead === readBuffer.length) { + throw new Error( + `asset changed while reading: "${params.path}" grew past its ` + + `measured size — retry the read`, + ) + } + const buffer = readBuffer.subarray(0, totalBytesRead) + logger.info("read asset", { path: params.path, bytes: buffer.length }) + return { + buffer, + bytes: buffer.length, + extension: links.getExtension(params.path).toLowerCase(), + } + } finally { + await fileHandle.close() } } From 5e349551667ab6d695fe3709d2d40f59e138544d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:14:19 -0400 Subject: [PATCH 16/34] docs(assets): reframe README Assets section on the sibling problem-first arc Opens with the agent's pain (an inert embed filename it can see but never look at) and pivots to the layer as resolution, matching Hybrid Search/Memory/Tasks. Ties the image bullet to the README's phone/remote through-line, verb-led Browse label, and adds the missing Assets entry to the Contents nav. DOCKERHUB regenerated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- DOCKERHUB.md | 12 ++++++------ README.md | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/DOCKERHUB.md b/DOCKERHUB.md index dee2a85e..3e9cbb84 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -61,12 +61,12 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quic ## Assets -A vault isn't only markdown — it holds the diagrams your notes embed, the canvases that map your projects, the data files your workflows produce. The asset layer makes those readable too, each in the form an agent can actually use: +Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is an inert filename. It can see the asset exists, follow the link, even learn its size, and still has no way to look at it. The asset layer closes that gap, returning each file in the form an agent can actually use: -- **Images** — delivered as actual images, automatically downscaled and recompressed server-side to fit client response limits. A screenshot or architecture diagram embedded in a note becomes something the agent can look at, not just a filename -- **Canvases** — `.canvas` boards arrive as a readable outline: groups, card content in reading order, and the connections between them -- **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return their content as-is -- **Browse and size** — list any folder's assets with per-extension counts and file sizes, and every asset a note links to carries its size in the link graph +- **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side to fit what MCP clients accept, so even a phone session can look at a 5MB architecture diagram +- **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them +- **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written +- **Browse** — list any folder's assets with per-extension counts and file sizes, and every asset a note links to carries its size in the link graph See [ARCHITECTURE.md → Assets](https://github.com/aliasunder/vault-cortex/blob/main/ARCHITECTURE.md#assets) for the image pipeline and dispatch model. @@ -102,7 +102,7 @@ See [ARCHITECTURE.md → Assets](https://github.com/aliasunder/vault-cortex/blob | | `vault_get_outgoing_links` | Links from a given note | | | `vault_find_orphans` | Notes with no incoming links | | **Assets** | `vault_read_asset` | Read a non-markdown file — images delivered as images, canvases as readable outlines | -| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-extension counts | +| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-extension counts | | **Daily Notes** | `vault_get_daily_note` | Today's (or any date's) daily note | ## Prompts diff --git a/README.md b/README.md index 495ea364..d85bfeb1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ **Vault Cortex** is a standalone MCP server that gives any AI agent **hybrid search, task management, structured memory, and read/write access** to your [Obsidian](https://obsidian.md) vault. No plugins, no running Obsidian, no separate bridge. One Docker container, your vault folder, a full tool suite + guided prompts. Deploy on a VPS with Obsidian Sync and the same vault is accessible from your phone, claude.ai, or any remote MCP client, secured with OAuth 2.1. -**Contents** — [What you get](#what-you-get) · [Quick Start](#quick-start) · [How It Works](#how-it-works) · [Hybrid Search](#hybrid-search) · [Memory](#memory) · [Tasks](#tasks) · [Tools](#tools) · [Prompts](#prompts) · [Config](#configuration) · [Data Integrity](#data-integrity) · [Auth](#authentication) · [Deployment](#deployment-options) +**Contents** — [What you get](#what-you-get) · [Quick Start](#quick-start) · [How It Works](#how-it-works) · [Hybrid Search](#hybrid-search) · [Memory](#memory) · [Tasks](#tasks) · [Assets](#assets) · [Tools](#tools) · [Prompts](#prompts) · [Config](#configuration) · [Data Integrity](#data-integrity) · [Auth](#authentication) · [Deployment](#deployment-options) ## What you get @@ -215,12 +215,12 @@ See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing mod ## Assets -A vault isn't only markdown — it holds the diagrams your notes embed, the canvases that map your projects, the data files your workflows produce. The asset layer makes those readable too, each in the form an agent can actually use: +Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is an inert filename. It can see the asset exists, follow the link, even learn its size, and still has no way to look at it. The asset layer closes that gap, returning each file in the form an agent can actually use: -- **Images** — delivered as actual images, automatically downscaled and recompressed server-side to fit client response limits. A screenshot or architecture diagram embedded in a note becomes something the agent can look at, not just a filename -- **Canvases** — `.canvas` boards arrive as a readable outline: groups, card content in reading order, and the connections between them -- **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return their content as-is -- **Browse and size** — list any folder's assets with per-extension counts and file sizes, and every asset a note links to carries its size in the link graph +- **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side to fit what MCP clients accept, so even a phone session can look at a 5MB architecture diagram +- **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them +- **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written +- **Browse** — list any folder's assets with per-extension counts and file sizes, and every asset a note links to carries its size in the link graph See [ARCHITECTURE.md → Assets](./ARCHITECTURE.md#assets) for the image pipeline and dispatch model. From b90482bed04d31732c63ce2e92693917e6da1f8d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:31:39 -0400 Subject: [PATCH 17/34] docs(assets): credit the see-and-size baseline to vault-cortex's own link graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Assets opening implied agents can generally see assets exist and learn sizes — those are vault-cortex link-graph capabilities, not an ecosystem baseline (verified against 11 Obsidian MCP servers: none resolve assets inside a link graph). Reframed as the integrated stack — link graph resolves and sizes, asset layer reads — stating capability without exclusivity claims (which the research also shows would be partly false for image blocks: obsidian-mcp-pro ships them). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- DOCKERHUB.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 3e9cbb84..c6b78cf3 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -61,12 +61,12 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quic ## Assets -Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is an inert filename. It can see the asset exists, follow the link, even learn its size, and still has no way to look at it. The asset layer closes that gap, returning each file in the form an agent can actually use: +Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: - **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side to fit what MCP clients accept, so even a phone session can look at a 5MB architecture diagram - **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written -- **Browse** — list any folder's assets with per-extension counts and file sizes, and every asset a note links to carries its size in the link graph +- **Browse** — list any folder's assets with per-extension counts and file sizes See [ARCHITECTURE.md → Assets](https://github.com/aliasunder/vault-cortex/blob/main/ARCHITECTURE.md#assets) for the image pipeline and dispatch model. diff --git a/README.md b/README.md index d85bfeb1..ae808e4a 100644 --- a/README.md +++ b/README.md @@ -215,12 +215,12 @@ See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing mod ## Assets -Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is an inert filename. It can see the asset exists, follow the link, even learn its size, and still has no way to look at it. The asset layer closes that gap, returning each file in the form an agent can actually use: +Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: - **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side to fit what MCP clients accept, so even a phone session can look at a 5MB architecture diagram - **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written -- **Browse** — list any folder's assets with per-extension counts and file sizes, and every asset a note links to carries its size in the link graph +- **Browse** — list any folder's assets with per-extension counts and file sizes See [ARCHITECTURE.md → Assets](./ARCHITECTURE.md#assets) for the image pipeline and dispatch model. From 3b13cc5206993e187cc4139e6d461af71233b7f1 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:45:19 -0400 Subject: [PATCH 18/34] feat(assets): raw option on vault_read_asset for exact canvas source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit raw: true returns the file's text source instead of any rendered form — for .canvas that's the JSON Canvas source (geometry, ids, colors); text formats already return source so raw is a no-op there; images error (binary has no text source). Restores source fidelity for canvases while keeping the readable outline as the default. Mirrors vault_read_note's representation-mode flags. 3 tests (mutation-checked), README/ ARCHITECTURE/DOCKERHUB updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- ARCHITECTURE.md | 2 +- DOCKERHUB.md | 2 +- README.md | 2 +- .../__tests__/tool-definitions.test.ts | 50 +++++++++++++++++++ src/vault-mcp/mcp-core/tools/asset-tools.ts | 32 +++++++++--- 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d680e258..8bdef52c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -294,7 +294,7 @@ Link queries use a `links` table populated during indexing: `vault_read_asset` reads non-markdown vault files, dispatching on extension to the most useful representation per type: 1. **Images** (`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`) return an MCP `image` content block plus a one-line metadata text block. A shared fit-to-byte-budget pipeline (`utils/fit-image-to-byte-budget.ts`, built on sharp) makes oversized images deliverable: EXIF auto-orient → resize long edge to ≤1568px → walk a fixed quality ladder (JPEG via mozjpeg for opaque images, WebP for alpha — PNG has no quality knob) → shrink dimensions by √(budget/actual) if the ladder floor still exceeds the budget. Deterministic and terminating (bounded attempts, 64px floor); sharp's default `limitInputPixels` stays active as the decompression-bomb guard. The budget (`MAX_IMAGE_OUTPUT_BYTES`, default 48 KiB binary) is sized for the tightest mainstream client cap. -2. **Canvas** (`.canvas`) linearizes to markdown via the pure `obsidian-markdown/canvas.ts` parser (JSON Canvas 1.0): group membership by spatial rect containment (innermost group wins), nodes in reading order (y, then x), and an edge list with node ids resolved to display names. Lenient parsing — unknown properties ignored, malformed entries skipped. +2. **Canvas** (`.canvas`) linearizes to markdown via the pure `obsidian-markdown/canvas.ts` parser (JSON Canvas 1.0): group membership by spatial rect containment (innermost group wins; equal rects tiebreak deterministically by id), nodes in reading order (y, then x), and an edge list with node ids resolved to display names. Lenient parsing — unknown properties ignored, malformed entries skipped. `raw: true` skips the linearizer and returns the JSON source verbatim for full structural fidelity. 3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.base`) pass through verbatim as text, capped at a fixed 100 KiB output size (explicit error over silent truncation). 4. **PDFs** return a structured not-yet-supported error carrying the file's existence and size (text extraction is a planned follow-up); unknown types return an error naming the readable set. diff --git a/DOCKERHUB.md b/DOCKERHUB.md index c6b78cf3..fb7303ad 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -64,7 +64,7 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quic Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: - **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side to fit what MCP clients accept, so even a phone session can look at a 5MB architecture diagram -- **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them +- **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written - **Browse** — list any folder's assets with per-extension counts and file sizes diff --git a/README.md b/README.md index ae808e4a..09d59351 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing mod Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: - **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side to fit what MCP clients accept, so even a phone session can look at a 5MB architecture diagram -- **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them +- **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written - **Browse** — list any folder's assets with per-extension counts and file sizes diff --git a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 70ed8c08..6aac6409 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -1059,6 +1059,56 @@ describe("asset tool handlers", () => { ]) }) + it("returns the exact canvas JSON source when raw is set", async () => { + const { vault, readAsset } = await setupAssetHarness() + const canvasSource = JSON.stringify({ + nodes: [ + { + id: "a", + type: "text", + x: 0, + y: 0, + width: 200, + height: 100, + text: "hello", + }, + ], + edges: [], + }) + await writeFile(join(vault, "Board.canvas"), canvasSource, "utf8") + const result = await readAsset({ path: "Board.canvas", raw: true }) + expect(result.isError).toBeUndefined() + expect(result.content).toEqual([{ type: "text", text: canvasSource }]) + }) + + it("rejects raw for an image", async () => { + const { vault, readAsset } = await setupAssetHarness() + const png = await sharp({ + create: { + width: 4, + height: 4, + channels: 3, + background: { r: 255, g: 0, b: 255 }, + }, + }) + .png() + .toBuffer() + await writeFile(join(vault, "tiny.png"), png) + const result = await readAsset({ path: "tiny.png", raw: true }) + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toBe( + '[Error]: raw source is not available for images: "tiny.png" is binary — its image block is the delivered form', + ) + }) + + it("returns a text format's source unchanged when raw is set", async () => { + const { vault, readAsset } = await setupAssetHarness() + await writeFile(join(vault, "data.json"), '{"key": "value"}', "utf8") + const result = await readAsset({ path: "data.json", raw: true }) + expect(result.isError).toBeUndefined() + expect(result.content).toEqual([{ type: "text", text: '{"key": "value"}' }]) + }) + it("rejects a .pdf with a not-yet-supported error carrying the file size", async () => { const { vault, readAsset } = await setupAssetHarness() await writeFile(join(vault, "doc.pdf"), "0123456789", "utf8") diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index d6e0451a..dbb93c6a 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -108,11 +108,12 @@ export const registerAssetTools = ({ Example: vault_read_asset({ path: "attachments/diagram.png" }) — the image itself, downscaled to fit response limits Example: vault_read_asset({ path: "Boards/Roadmap.canvas" }) — a readable outline of the canvas +Example: vault_read_asset({ path: "Boards/Roadmap.canvas", raw: true }) — the canvas's exact JSON source Example: vault_read_asset({ path: "exports/data.json" }) — the file content as text What each type returns: - Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — automatically downscaled and recompressed server-side to fit client response limits — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed to fit the budget. -- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. +- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity). - Text formats (.svg/.json/.txt/.csv/.xml/.log/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source. - PDFs (.pdf): not yet readable — returns an error that confirms the file exists and its size; text extraction is planned. @@ -125,6 +126,7 @@ Errors: - "text output too large" — a text asset renders past the output cap; only smaller files can be returned whole - "not valid UTF-8" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content - "image cannot be fitted" — the image could not be compressed under the output budget (MAX_IMAGE_OUTPUT_BYTES) +- "raw source is not available for images" — raw applies to text-representable files; an image's delivered form is its image block - unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size Returns: for images, an image content block plus a one-line metadata text block; for every other supported type, a single text content block. @@ -137,6 +139,12 @@ Search coverage: vault_search indexes markdown notes; find assets by browsing (v .describe( 'Vault-relative path to the asset, including its extension (e.g. "attachments/photo.png", "Boards/Roadmap.canvas"). Must NOT end in ".md" — notes are read with vault_read_note.', ), + raw: z + .boolean() + .optional() + .describe( + "Return the file's exact text source instead of any rendered form. For .canvas this is the JSON Canvas source (geometry, ids, colors); text formats already return their source, so raw changes nothing there. Images have no text source — raw returns an error.", + ), }, annotations: { readOnlyHint: true, @@ -145,12 +153,12 @@ Search coverage: vault_search indexes markdown notes; find assets by browsing (v openWorldHint: false, }, }, - async ({ path }, extra) => { + async ({ path, raw }, extra) => { const reqLogger = sessionLogger.child({ requestId: extra.requestId, tool: TOOL_NAMES.VAULT_READ_ASSET, }) - reqLogger.info("tool_call", { path }) + reqLogger.info("tool_call", { path, raw }) return safeHandlerContent( reqLogger, async (): Promise => { @@ -159,6 +167,12 @@ Search coverage: vault_search indexes markdown notes; find assets by browsing (v reqLogger, ) if (IMAGE_EXTENSIONS.has(asset.extension)) { + if (raw) { + throw new Error( + `raw source is not available for images: "${path}" is ` + + `binary — its image block is the delivered form`, + ) + } const fitted = await fitImageToByteBudget({ buffer: asset.buffer, budgetBytes: config.maxImageOutputBytes, @@ -166,11 +180,13 @@ Search coverage: vault_search indexes markdown notes; find assets by browsing (v return { kind: "image", fitted, originalBytes: asset.bytes, path } } if (asset.extension === ".canvas") { - const rendition = linearizeCanvas( - decodeUtf8Strict({ buffer: asset.buffer, path }), - ) - assertTextWithinCap({ text: rendition, path }) - return { kind: "text", text: rendition } + const canvasSource = decodeUtf8Strict({ + buffer: asset.buffer, + path, + }) + const text = raw ? canvasSource : linearizeCanvas(canvasSource) + assertTextWithinCap({ text, path }) + return { kind: "text", text } } if (TEXT_PASSTHROUGH_EXTENSIONS.has(asset.extension)) { const text = decodeUtf8Strict({ buffer: asset.buffer, path }) From ffbddc8ff165aa5740a23d76ef11c854480c3bc5 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:57:58 -0400 Subject: [PATCH 19/34] docs(assets): colocate why-comments on the image-fit tuning constants Each constant states its own rationale (model downscale ceiling, legibility floor, deterministic ladder, mid-ladder handoff, provable termination) so the file reads top-to-bottom without hopping back to the module docstring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/utils/fit-image-to-byte-budget.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/utils/fit-image-to-byte-budget.ts b/src/utils/fit-image-to-byte-budget.ts index b10533d9..34c6c4e0 100644 --- a/src/utils/fit-image-to-byte-budget.ts +++ b/src/utils/fit-image-to-byte-budget.ts @@ -24,10 +24,27 @@ import type { OutputInfo } from "sharp" * decompression-bomb guard; `failOn: "none"` tolerates slightly-corrupt files. */ +/** Models downscale images beyond ~1568px on the long edge anyway, so pixels + * past this are bytes spent on detail the model never sees. */ const MAX_LONG_EDGE_PX = 1568 + +/** Dimension floor for the descent — below this an image stops being legible, + * so the fit gives up (throws) rather than delivering unreadable thumbnails. */ const MIN_LONG_EDGE_PX = 64 + +/** Fixed quality descent, tried in order at full dimensions: 75 is near + * visually lossless, 30 the legibility floor. A fixed ladder (vs adaptive + * search) keeps output deterministic for identical inputs. */ const QUALITY_LADDER = [75, 60, 45, 30] + +/** Quality used once dimension-shrinking takes over from the ladder — the + * ladder's midpoint, since dimensions are now doing the size work and the + * floor qualities would degrade legibility for little gain. */ const MID_LADDER_QUALITY = 45 + +/** Hard bound on total encodes so termination is provable: the full ladder + * plus a handful of dimension-shrink attempts, after which the image is + * reported unfittable. */ const MAX_ENCODE_ATTEMPTS = 8 /** Formats the Claude API accepts as image input; anything else must be From 28677048fa7b02cd46f40940298ec5fd4dd86f49 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:05:50 -0400 Subject: [PATCH 20/34] feat(assets): log image dimensions and original bytes in tool_result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image tool_result line now carries originalBytes plus delivered and original dimensions alongside deliveredBytes/recompressed — one self-contained line for tuning MAX_IMAGE_OUTPUT_BYTES from logs alone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/vault-mcp/mcp-core/tools/asset-tools.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index dbb93c6a..f686a4fb 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -212,6 +212,11 @@ Search coverage: vault_search indexes markdown notes; find assets by browsing (v path, mimeType: result.fitted.mimeType, deliveredBytes: result.fitted.data.length, + originalBytes: result.originalBytes, + width: result.fitted.width, + height: result.fitted.height, + originalWidth: result.fitted.originalWidth, + originalHeight: result.fitted.originalHeight, recompressed: result.fitted.recompressed, }) return [ From 9c038a593d40cdef3baf4da4f47cce96ab6e00d1 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:13:20 -0400 Subject: [PATCH 21/34] docs(assets): make image fitting conditional across every surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '(downscaled to fit)' implied every image is downscaled — the pipeline passes small images through untouched and sometimes only recompresses. All six surfaces now state the conditionality: README marquee bullet (shrunk to fit when needed) + Assets section (when they exceed), tool description (delivered untouched otherwise), list_assets bytes note, server.json, both deploy .env.examples (+ CLI blocks resync), DOCKERHUB regenerated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- DOCKERHUB.md | 4 ++-- README.md | 4 ++-- cli/src/env.ts | 4 ++-- deploy/local/.env.example | 2 +- deploy/remote/.env.example | 2 +- server.json | 2 +- src/vault-mcp/mcp-core/tools/asset-tools.ts | 6 +++--- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/DOCKERHUB.md b/DOCKERHUB.md index fb7303ad..3b1bcb27 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -48,7 +48,7 @@ - **[Structured memory](https://github.com/aliasunder/vault-cortex#memory)** — dated, append-only entries accumulate into a personal knowledge layer, auto-initialized for AI personalization. Topic recall answers "what do I think about X?" with the current take and the dated history behind it — evolution included. - **[Tasks](https://github.com/aliasunder/vault-cortex#tasks)** — Kanban-aware task queries and updates: triage by status, dates, or priority, then complete, reprioritize, or move tasks between lanes in one call. Parses both [Tasks plugin](https://publish.obsidian.md/tasks/) emoji and [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) inline-field formats. - **[Link graph](https://github.com/aliasunder/vault-cortex#tools)** — backlinks, outgoing links, and orphan detection across the vault -- **[Assets](https://github.com/aliasunder/vault-cortex#assets)** — read the vault's non-markdown files too: images arrive as actual images (downscaled to fit), canvases as readable outlines, data files as text +- **[Assets](https://github.com/aliasunder/vault-cortex#assets)** — read the vault's non-markdown files too: images arrive as actual images (shrunk to fit when needed), canvases as readable outlines, data files as text - **[Obsidian-native](https://github.com/aliasunder/vault-cortex#properties)** — understands frontmatter, wikilinks, tags, headings, and daily notes - **[Guided workflows](https://github.com/aliasunder/vault-cortex#prompts)** — built-in prompts for vault health, memory review, and daily reconciliation — assembled from live vault data each time @@ -63,7 +63,7 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quic Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: -- **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side to fit what MCP clients accept, so even a phone session can look at a 5MB architecture diagram +- **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side when they exceed what MCP clients accept, so even a phone session can look at a 5MB architecture diagram - **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written - **Browse** — list any folder's assets with per-extension counts and file sizes diff --git a/README.md b/README.md index 09d59351..6ddb257f 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - **[Structured memory](#memory)** — dated, append-only entries accumulate into a personal knowledge layer, auto-initialized for AI personalization. Topic recall answers "what do I think about X?" with the current take and the dated history behind it — evolution included. - **[Tasks](#tasks)** — Kanban-aware task queries and updates: triage by status, dates, or priority, then complete, reprioritize, or move tasks between lanes in one call. Parses both [Tasks plugin](https://publish.obsidian.md/tasks/) emoji and [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) inline-field formats. - **[Link graph](#tools)** — backlinks, outgoing links, and orphan detection across the vault -- **[Assets](#assets)** — read the vault's non-markdown files too: images arrive as actual images (downscaled to fit), canvases as readable outlines, data files as text +- **[Assets](#assets)** — read the vault's non-markdown files too: images arrive as actual images (shrunk to fit when needed), canvases as readable outlines, data files as text - **[Obsidian-native](#properties)** — understands frontmatter, wikilinks, tags, headings, and daily notes - **[Guided workflows](#prompts)** — built-in prompts for vault health, memory review, and daily reconciliation — assembled from live vault data each time @@ -217,7 +217,7 @@ See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing mod Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: -- **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side to fit what MCP clients accept, so even a phone session can look at a 5MB architecture diagram +- **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side when they exceed what MCP clients accept, so even a phone session can look at a 5MB architecture diagram - **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written - **Browse** — list any folder's assets with per-extension counts and file sizes diff --git a/cli/src/env.ts b/cli/src/env.ts index ed0d74f8..bf52788f 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -40,7 +40,7 @@ MAX_ASSET_BYTES=52428800 # Byte budget for images returned by vault_read_asset, in binary bytes before # base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap). -# Images are downscaled/recompressed server-side to fit. Raise it for clients +# Images exceeding the budget are downscaled/recompressed to fit. Raise it for clients # that accept larger tool responses. MAX_IMAGE_OUTPUT_BYTES=49152 @@ -144,7 +144,7 @@ MAX_ASSET_BYTES=52428800 # Byte budget for images returned by vault_read_asset, in binary bytes before # base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap). -# Images are downscaled/recompressed server-side to fit. Raise it for clients +# Images exceeding the budget are downscaled/recompressed to fit. Raise it for clients # that accept larger tool responses. MAX_IMAGE_OUTPUT_BYTES=49152 diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 163e2955..500fe76c 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -23,7 +23,7 @@ MAX_ASSET_BYTES=52428800 # Byte budget for images returned by vault_read_asset, in binary bytes before # base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap). -# Images are downscaled/recompressed server-side to fit. Raise it for clients +# Images exceeding the budget are downscaled/recompressed to fit. Raise it for clients # that accept larger tool responses. MAX_IMAGE_OUTPUT_BYTES=49152 diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index 1f4fb9e5..a16c75f5 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -55,7 +55,7 @@ MAX_ASSET_BYTES=52428800 # Byte budget for images returned by vault_read_asset, in binary bytes before # base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap). -# Images are downscaled/recompressed server-side to fit. Raise it for clients +# Images exceeding the budget are downscaled/recompressed to fit. Raise it for clients # that accept larger tool responses. MAX_IMAGE_OUTPUT_BYTES=49152 diff --git a/server.json b/server.json index 3e2f5051..c2e3fcc7 100644 --- a/server.json +++ b/server.json @@ -144,7 +144,7 @@ }, { "name": "MAX_IMAGE_OUTPUT_BYTES", - "description": "Byte budget for images returned by vault_read_asset, in binary bytes before base64 encoding. Images are downscaled/recompressed server-side to fit; raise for clients that accept larger tool responses.", + "description": "Byte budget for images returned by vault_read_asset, in binary bytes before base64 encoding. Images exceeding the budget are downscaled/recompressed server-side to fit; raise for clients that accept larger tool responses.", "default": "49152", "format": "number" } diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index f686a4fb..d6fc8c11 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -106,13 +106,13 @@ export const registerAssetTools = ({ title: "Read Asset", description: `Read a non-markdown vault file (an asset) in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note. -Example: vault_read_asset({ path: "attachments/diagram.png" }) — the image itself, downscaled to fit response limits +Example: vault_read_asset({ path: "attachments/diagram.png" }) — the image itself, shrunk to fit response limits when needed Example: vault_read_asset({ path: "Boards/Roadmap.canvas" }) — a readable outline of the canvas Example: vault_read_asset({ path: "Boards/Roadmap.canvas", raw: true }) — the canvas's exact JSON source Example: vault_read_asset({ path: "exports/data.json" }) — the file content as text What each type returns: -- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — automatically downscaled and recompressed server-side to fit client response limits — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed to fit the budget. +- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds client response limits, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed to fit the budget. - Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity). - Text formats (.svg/.json/.txt/.csv/.xml/.log/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source. - PDFs (.pdf): not yet readable — returns an error that confirms the file exists and its size; text extraction is planned. @@ -259,7 +259,7 @@ Errors: - A folder containing no assets — or a folder that doesn't exist — returns an empty listing, not an error. - A folder path escaping the vault (e.g. "../elsewhere") is rejected with a path-traversal error. -Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_asset returns a downscaled copy that can be far smaller than the listed size, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers. Assets of supported types are readable via vault_read_asset (unsupported types like .pdf return a descriptive error there); vault_search covers markdown notes.`, +Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_asset returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers. Assets of supported types are readable via vault_read_asset (unsupported types like .pdf return a descriptive error there); vault_search covers markdown notes.`, inputSchema: { folder: z .string() From d1dcba2b4a82ed91b23a8df2863f269eac126589 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:21:21 -0400 Subject: [PATCH 22/34] docs(assets): link Canvas docs alongside Bases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bases bullet linked its docs; Canvas didn't — README's canvas bullet now links Obsidian's Canvas help, and ARCHITECTURE links the JSON Canvas 1.0 spec (jsoncanvas.org). Both URLs verified live. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- ARCHITECTURE.md | 2 +- DOCKERHUB.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8bdef52c..e58950b1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -294,7 +294,7 @@ Link queries use a `links` table populated during indexing: `vault_read_asset` reads non-markdown vault files, dispatching on extension to the most useful representation per type: 1. **Images** (`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`) return an MCP `image` content block plus a one-line metadata text block. A shared fit-to-byte-budget pipeline (`utils/fit-image-to-byte-budget.ts`, built on sharp) makes oversized images deliverable: EXIF auto-orient → resize long edge to ≤1568px → walk a fixed quality ladder (JPEG via mozjpeg for opaque images, WebP for alpha — PNG has no quality knob) → shrink dimensions by √(budget/actual) if the ladder floor still exceeds the budget. Deterministic and terminating (bounded attempts, 64px floor); sharp's default `limitInputPixels` stays active as the decompression-bomb guard. The budget (`MAX_IMAGE_OUTPUT_BYTES`, default 48 KiB binary) is sized for the tightest mainstream client cap. -2. **Canvas** (`.canvas`) linearizes to markdown via the pure `obsidian-markdown/canvas.ts` parser (JSON Canvas 1.0): group membership by spatial rect containment (innermost group wins; equal rects tiebreak deterministically by id), nodes in reading order (y, then x), and an edge list with node ids resolved to display names. Lenient parsing — unknown properties ignored, malformed entries skipped. `raw: true` skips the linearizer and returns the JSON source verbatim for full structural fidelity. +2. **Canvas** (`.canvas`) linearizes to markdown via the pure `obsidian-markdown/canvas.ts` parser ([JSON Canvas 1.0](https://jsoncanvas.org)): group membership by spatial rect containment (innermost group wins; equal rects tiebreak deterministically by id), nodes in reading order (y, then x), and an edge list with node ids resolved to display names. Lenient parsing — unknown properties ignored, malformed entries skipped. `raw: true` skips the linearizer and returns the JSON source verbatim for full structural fidelity. 3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.base`) pass through verbatim as text, capped at a fixed 100 KiB output size (explicit error over silent truncation). 4. **PDFs** return a structured not-yet-supported error carrying the file's existence and size (text extraction is a planned follow-up); unknown types return an error naming the readable set. diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 3b1bcb27..95d636a1 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -64,7 +64,7 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quic Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: - **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side when they exceed what MCP clients accept, so even a phone session can look at a 5MB architecture diagram -- **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters +- **Canvases** — a [Canvas](https://help.obsidian.md/canvas) board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written - **Browse** — list any folder's assets with per-extension counts and file sizes diff --git a/README.md b/README.md index 6ddb257f..1068833f 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing mod Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: - **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side when they exceed what MCP clients accept, so even a phone session can look at a 5MB architecture diagram -- **Canvases** — a `.canvas` board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters +- **Canvases** — a [Canvas](https://help.obsidian.md/canvas) board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written - **Browse** — list any folder's assets with per-extension counts and file sizes From d82230b8e0dddc16f830fd475eb3567d55d5796f Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:20:33 -0400 Subject: [PATCH 23/34] refactor(assets): truthy checks over explicit comparisons in the image fit util Boolean(metadata.hasAlpha) over === true, and !quality over === undefined for the ladder guard (0 is not a legitimate sharp quality, so the falsy set is safe). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/utils/fit-image-to-byte-budget.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/fit-image-to-byte-budget.ts b/src/utils/fit-image-to-byte-budget.ts index 34c6c4e0..78e6b445 100644 --- a/src/utils/fit-image-to-byte-budget.ts +++ b/src/utils/fit-image-to-byte-budget.ts @@ -118,7 +118,7 @@ export const fitImageToByteBudget = async (params: { } } - const keepAlpha = metadata.hasAlpha === true + const keepAlpha = Boolean(metadata.hasAlpha) // Mutable descent state: each attempt either succeeds (returns) or tightens // quality/dimensions for the next — inherently sequential. let longEdgePx = Math.min(longEdge, MAX_LONG_EDGE_PX) @@ -131,7 +131,7 @@ export const fitImageToByteBudget = async (params: { qualityLadderIndex < QUALITY_LADDER.length ? QUALITY_LADDER[qualityLadderIndex] : MID_LADDER_QUALITY - if (quality === undefined) break + if (!quality) break const { data, info } = await encodeAttempt({ buffer: params.buffer, longEdgePx, From 3e248492ca1aac207dd2235d059856a5cb9728ed Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:20:35 -0400 Subject: [PATCH 24/34] docs(assets): tighten the Assets opener to the linked-sized-readable triad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The '— with its size —' interruption fragmented the positioning sentence; the triad carries link graph + sizes + read layer in one stride, and the Browse bullet reabsorbs the link-graph sizes detail. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- DOCKERHUB.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 95d636a1..e10730b1 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -61,12 +61,12 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quic ## Assets -Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: +Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it — linked, sized, and readable, each in the form an agent can actually use: - **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side when they exceed what MCP clients accept, so even a phone session can look at a 5MB architecture diagram - **Canvases** — a [Canvas](https://help.obsidian.md/canvas) board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written -- **Browse** — list any folder's assets with per-extension counts and file sizes +- **Browse** — list any folder's assets with per-extension counts and file sizes; assets a note links to report their size in the link graph too See [ARCHITECTURE.md → Assets](https://github.com/aliasunder/vault-cortex/blob/main/ARCHITECTURE.md#assets) for the image pipeline and dispatch model. diff --git a/README.md b/README.md index 1068833f..75d96fd3 100644 --- a/README.md +++ b/README.md @@ -215,12 +215,12 @@ See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing mod ## Assets -Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it: the link graph resolves every asset a note references — with its size — and the asset layer makes them readable, each in the form an agent can actually use: +Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. vault-cortex treats assets as part of the vault rather than clutter around it — linked, sized, and readable, each in the form an agent can actually use: - **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side when they exceed what MCP clients accept, so even a phone session can look at a 5MB architecture diagram - **Canvases** — a [Canvas](https://help.obsidian.md/canvas) board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. The exact JSON source is one flag away when full fidelity matters - **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written -- **Browse** — list any folder's assets with per-extension counts and file sizes +- **Browse** — list any folder's assets with per-extension counts and file sizes; assets a note links to report their size in the link graph too See [ARCHITECTURE.md → Assets](./ARCHITECTURE.md#assets) for the image pipeline and dispatch model. From e297fb3176818a3093ea3fe7dcd610187abbe178 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:44:25 -0400 Subject: [PATCH 25/34] refactor(assets): flatten review-flagged logic for top-to-bottom reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search-index visibleFilesOfKind: the three anonymous chain callbacks become named stages — entries.filter(matchesKind).map(toFilePaths) .filter(isVisiblePath) reads as a sentence. - asset-tools image dispatch: the nested raw guard hoists to a flat isImage && raw rejection before the branch, so error paths read first and the pipeline flows unnested. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/vault-mcp/mcp-core/tools/asset-tools.ts | 15 +++--- src/vault-mcp/search/search-index.ts | 51 +++++++++++---------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index d6fc8c11..9d3d0123 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -166,13 +166,14 @@ Search coverage: vault_search indexes markdown notes; find assets by browsing (v { vaultPath, path, maxBytes: config.maxAssetBytes }, reqLogger, ) - if (IMAGE_EXTENSIONS.has(asset.extension)) { - if (raw) { - throw new Error( - `raw source is not available for images: "${path}" is ` + - `binary — its image block is the delivered form`, - ) - } + const isImage = IMAGE_EXTENSIONS.has(asset.extension) + if (isImage && raw) { + throw new Error( + `raw source is not available for images: "${path}" is ` + + `binary — its image block is the delivered form`, + ) + } + if (isImage) { const fitted = await fitImageToByteBudget({ buffer: asset.buffer, budgetBytes: config.maxImageOutputBytes, diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index 615a355c..a7714544 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -2,6 +2,7 @@ import Database from "better-sqlite3" import { DateTime } from "luxon" import * as sqliteVec from "sqlite-vec" import { readFile, readdir, stat } from "node:fs/promises" +import type { Dirent } from "node:fs" import { join, basename, posix, relative, resolve } from "node:path" import type { Logger } from "../../logger.js" import { parseNote } from "../obsidian-markdown/frontmatter.js" @@ -1366,34 +1367,34 @@ export const createSearchIndex = ( // Filter directory entries to visible files of one kind (.md notes or // non-md assets) — shared by the notes pass and the non-md stat pass. - // filter → map → filter keeps each pass O(n); a spread-accumulating - // reduce here would re-copy the array per entry (O(n²) on large vaults). + // Named stages keep each pass O(n) (a spread-accumulating reduce would + // re-copy the array per entry) and let the chain read top-to-bottom. const visibleFilesOfKind = ( fileKind: "note" | "asset", - ): { relativePath: string; absolutePath: string }[] => - entries - .filter((directoryEntry) => { - if (!directoryEntry.isFile() && !directoryEntry.isSymbolicLink()) - return false - const isNoteFile = directoryEntry.name.endsWith(".md") - return fileKind === "note" ? isNoteFile : !isNoteFile - }) - .map((directoryEntry) => { - const absolutePath = join( - directoryEntry.parentPath, - directoryEntry.name, - ) - return { - relativePath: relative(normalizedVault, absolutePath), - absolutePath, - } - }) - .filter( - (file) => - !file.relativePath - .split("/") - .some((segment) => segment.startsWith(".")), + ): { relativePath: string; absolutePath: string }[] => { + const matchesKind = (directoryEntry: Dirent): boolean => { + if (!directoryEntry.isFile() && !directoryEntry.isSymbolicLink()) + return false + const isNoteFile = directoryEntry.name.endsWith(".md") + return fileKind === "note" ? isNoteFile : !isNoteFile + } + const toFilePaths = ( + directoryEntry: Dirent, + ): { relativePath: string; absolutePath: string } => { + const absolutePath = join( + directoryEntry.parentPath, + directoryEntry.name, ) + return { + relativePath: relative(normalizedVault, absolutePath), + absolutePath, + } + } + const isVisiblePath = (file: { relativePath: string }): boolean => + !file.relativePath.split("/").some((segment) => segment.startsWith(".")) + + return entries.filter(matchesKind).map(toFilePaths).filter(isVisiblePath) + } const markdownFiles = visibleFilesOfKind("note") From 3cd3676f0bbddef34883fd30df12a85a1374a695 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:59:20 -0400 Subject: [PATCH 26/34] refactor(assets): name the per-attempt encode quality for its role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attemptQuality states what the value is in the loop (this attempt's encode quality — ladder rung or mid-ladder handoff) instead of naming its type; pairs with attemptCount. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/utils/fit-image-to-byte-budget.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/utils/fit-image-to-byte-budget.ts b/src/utils/fit-image-to-byte-budget.ts index 78e6b445..01215688 100644 --- a/src/utils/fit-image-to-byte-budget.ts +++ b/src/utils/fit-image-to-byte-budget.ts @@ -127,15 +127,17 @@ export const fitImageToByteBudget = async (params: { let lastEncodedBytes = params.buffer.length while (attemptCount < MAX_ENCODE_ATTEMPTS) { - const quality = + // The quality this attempt encodes at: the next ladder rung while the + // ladder descends, mid-ladder once dimension-shrinking takes over. + const attemptQuality = qualityLadderIndex < QUALITY_LADDER.length ? QUALITY_LADDER[qualityLadderIndex] : MID_LADDER_QUALITY - if (!quality) break + if (!attemptQuality) break const { data, info } = await encodeAttempt({ buffer: params.buffer, longEdgePx, - quality, + quality: attemptQuality, keepAlpha, }) attemptCount += 1 From f3db2a1ed29828302c39d4f3e7ff52f1c7a7083d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:31:16 -0400 Subject: [PATCH 27/34] refactor(assets): extract asset-reader and asset-listing use-cases from the tool layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two asset handlers were the only handlers in the codebase orchestrating multi-step pipelines inline (importing a parser into mcp-core to do it). The dispatch and listing compositions move to vault-operations use-case modules — the note-mover/vault-patcher pattern — leaving the handlers the sibling silhouette: schema, wire mapping, one data-layer call, formatting. Along the way, per review: extension filter is a Set (O(1) membership), extensionCounts is an honest immutable reduce (bounded accumulator width — distinct extensions — so the O(n·k) concern that killed the file-list reduce doesn't apply), and the misleading pagination vocabulary is gone (pagePaths/stattedPage → returnedPaths/stattedAssets; there is no pagination — limit caps the slice and counts/total tell the caller to narrow). AGENTS.md codifies the thin-handler rule with these modules as worked examples; structure tree, wiki.json, and ARCHITECTURE updated. Behavior and wire contract unchanged — the tool-layer suite covers every arm through the new modules. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- .devin/wiki.json | 2 +- AGENTS.md | 22 ++- ARCHITECTURE.md | 4 +- src/vault-mcp/mcp-core/tools/asset-tools.ts | 168 ++---------------- .../vault-operations/asset-listing.ts | 89 ++++++++++ .../vault-operations/asset-reader.ts | 130 ++++++++++++++ 6 files changed, 256 insertions(+), 159 deletions(-) create mode 100644 src/vault-mcp/vault-operations/asset-listing.ts create mode 100644 src/vault-mcp/vault-operations/asset-reader.ts diff --git a/.devin/wiki.json b/.devin/wiki.json index 061cf2cc..25a7fee2 100644 --- a/.devin/wiki.json +++ b/.devin/wiki.json @@ -50,7 +50,7 @@ }, { "title": "Vault Operations", - "purpose": "The filesystem I/O layer in vault-operations/ that reads, writes, and transforms vault content. Asset reads: vaultFs.readAsset (binary read with .md rejection and stat-first size cap), listAssets (folder-scoped non-md walk), statAssets (bounded-concurrency page stat) back the vault_read_asset/vault_list_assets tools. Safety primitives: atomic writes (atomicWriteFile temp-then-rename, atomicWriteFileExclusive link-based no-clobber for TOCTOU-safe creates), per-file write locking (file-write-lock.ts — serializing, fail-fast, and multi-file fail-fast modes), path traversal prevention (resolveSafePath resolve + prefix check, toVaultRelativePath normalization), and protected-path enforcement (PROTECTED_PATHS checked after normalization). Covers: vault-filesystem.ts (read/write/list/delete .md files, outline and section reads, protected-path validation); vault-patcher.ts (surgical edits — heading-targeted patch with prepend/append/replace operations, and find-and-replace with optional replace-all); note-mover.ts (move/rename a note and rewrite every vault-wide link that pointed to it, using the link resolution engine from obsidian-markdown/links.ts — preflight-then-commit design with multi-file exclusive locking); memory-store.ts (About Me/ heading-aware read/append/delete with shrink guard, idempotency guard, and ambiguity guard — see Memory Layer page); daily-notes.ts (daily note config reader and path resolver — see Memory Layer page). All functions follow the (params, logger) two-argument pattern.", + "purpose": "The filesystem I/O layer in vault-operations/ that reads, writes, and transforms vault content. Asset use-cases: asset-reader.ts (per-type read dispatch — image fitting, canvas linearize/raw, strict-UTF-8 text passthrough, structured errors) and asset-listing.ts (extension filter, per-extension counts, capped statted slice) compose the vaultFs primitives readAsset (binary read with .md rejection and stat-first size cap), listAssets (folder-scoped non-md walk), and statAssets (bounded-concurrency stat) behind the vault_read_asset/vault_list_assets tools. Safety primitives: atomic writes (atomicWriteFile temp-then-rename, atomicWriteFileExclusive link-based no-clobber for TOCTOU-safe creates), per-file write locking (file-write-lock.ts — serializing, fail-fast, and multi-file fail-fast modes), path traversal prevention (resolveSafePath resolve + prefix check, toVaultRelativePath normalization), and protected-path enforcement (PROTECTED_PATHS checked after normalization). Covers: vault-filesystem.ts (read/write/list/delete .md files, outline and section reads, protected-path validation); vault-patcher.ts (surgical edits — heading-targeted patch with prepend/append/replace operations, and find-and-replace with optional replace-all); note-mover.ts (move/rename a note and rewrite every vault-wide link that pointed to it, using the link resolution engine from obsidian-markdown/links.ts — preflight-then-commit design with multi-file exclusive locking); memory-store.ts (About Me/ heading-aware read/append/delete with shrink guard, idempotency guard, and ambiguity guard — see Memory Layer page); daily-notes.ts (daily note config reader and path resolver — see Memory Layer page). All functions follow the (params, logger) two-argument pattern.", "parent": "Architecture" }, { diff --git a/AGENTS.md b/AGENTS.md index fbf4f864..b97f2f92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,8 @@ src/ daily-notes.ts # Daily note config reader + path resolver task-updater.ts # Task state mutations (status, priority, lane moves) task-format-config.ts # Tasks-plugin format config reader (emoji vs Dataview) + asset-reader.ts # Asset read dispatch (image fit, canvas linearize/raw, text passthrough) + asset-listing.ts # Asset browsing (extension filter, counts, capped statted slice) mcp-core/ # MCP protocol surface mcp-router.ts # /mcp session routes + transport lifecycle tool-definitions.ts # Tool orchestrator — TOOL_NAMES + conditional group registration @@ -197,12 +199,20 @@ on**, not just its topic: any file) isn't enough to demote something to `utils/` if it's load-bearing vault-I/O policy. - **`mcp-core/`** — the MCP protocol surface. `tool-definitions.ts` is the - orchestrator that composes `TOOL_NAMES` from four domain group modules under - `mcp-core/tools/` (vault-crud, search, memory, daily-note) and calls each - register function — conditionally skipping memory tools when `MEMORY_ENABLED` - is `false`. Each group module is self-contained: its own tool name constants, - register function, and data-layer imports. Shared helpers (`safeHandler`, - `formatNoteMetadata`, `ToolRegistrationContext` type) live in `tool-helpers.ts`. + orchestrator that composes `TOOL_NAMES` from the domain group modules under + `mcp-core/tools/` (vault-crud, search, memory, daily-note, task, asset) and + calls each register function — conditionally skipping memory tools when + `MEMORY_ENABLED` is `false`. Each group module is self-contained: its own tool + name constants, register function, and data-layer imports. Shared helpers + (`safeHandler`, `formatNoteMetadata`, `ToolRegistrationContext` type) live in + `tool-helpers.ts`. + **Tool handlers stay thin**: schema, wire mapping (snake_case ↔ camelCase), + one data-layer call, and content-block/JSON formatting. Multi-step + composition — filtering, counting, pagination, dispatching across parsers + and I/O — is a _use-case_ and belongs in `vault-operations/` + (`asset-reader.ts` and `asset-listing.ts` are the worked examples). The + smell: a handler importing a parser to orchestrate between two data-layer + calls; the fix is a use-case module, not a bigger handler. `prompt-definitions.ts` is the orchestrator that composes `PROMPT_NAMES` from three group modules under `mcp-core/prompts/` (vault-orientation, memory-review, daily-review) — mirroring the `tools/` pattern. Shared helpers diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e58950b1..1d56eaf8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -298,9 +298,9 @@ Link queries use a `links` table populated during indexing: 3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.base`) pass through verbatim as text, capped at a fixed 100 KiB output size (explicit error over silent truncation). 4. **PDFs** return a structured not-yet-supported error carrying the file's existence and size (text extraction is a planned follow-up); unknown types return an error naming the readable set. -Reads go through `vaultFs.readAsset`: the same `resolveSafePath` traversal guard as notes, a `.md` rejection (notes belong to `vault_read_note`), and a stat-before-read size cap (`MAX_ASSET_BYTES`, default 50 MiB). +The dispatch lives in the `vault-operations/asset-reader.ts` use-case; reads go through `vaultFs.readAsset` beneath it: the same `resolveSafePath` traversal guard as notes, a `.md` rejection (notes belong to `vault_read_note`), and a stat-before-read size cap (`MAX_ASSET_BYTES`, default 50 MiB). -`vault_list_assets` is the discovery surface: a filesystem walk (`vaultFs.listAssets` — filesystem truth, deliberately not the index), folder and case-insensitive extension filters, per-extension counts computed over the full filtered set, and byte sizes statted only for the returned page. Assets are readable and browsable but not yet searchable — content indexing is a possible future tier. +`vault_list_assets` is the discovery surface (the `vault-operations/asset-listing.ts` use-case): a filesystem walk (`vaultFs.listAssets` — filesystem truth, deliberately not the index), folder and case-insensitive extension filters, per-extension counts computed over the full filtered set, and byte sizes statted only for the returned slice. Assets are readable and browsable but not yet searchable — content indexing is a possible future tier. ### Tasks (R9) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index 9d3d0123..c65432cf 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -1,10 +1,8 @@ /** Asset tool registration — reading and discovering non-markdown vault files. */ import { z } from "zod" -import { vaultFs } from "../../vault-operations/vault-filesystem.js" -import { linearizeCanvas } from "../../obsidian-markdown/canvas.js" -import { links } from "../../obsidian-markdown/links.js" -import { fitImageToByteBudget } from "../../../utils/fit-image-to-byte-budget.js" +import { readAssetContent } from "../../vault-operations/asset-reader.js" +import { buildAssetListing } from "../../vault-operations/asset-listing.js" import type { FittedImage } from "../../../utils/fit-image-to-byte-budget.js" import type { ToolRegistrationContext } from "./tool-helpers.js" import { safeHandler, safeHandlerContent } from "./tool-helpers.js" @@ -16,38 +14,6 @@ const TOOL_NAMES = { export { TOOL_NAMES as ASSET_TOOL_NAMES } -/** Extensions dispatched to the image pipeline (model-visible image blocks). */ -const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]) - -/** Extensions returned verbatim as text — plain-text formats an agent can - * read directly. .svg is XML source; .base is Obsidian Bases YAML. */ -const TEXT_PASSTHROUGH_EXTENSIONS = new Set([ - ".svg", - ".json", - ".txt", - ".csv", - ".xml", - ".log", - ".base", -]) - -/** Fixed cap on text output (passthrough files and canvas renditions) so a - * huge text asset can't blow a client's response limit. Deliberately not an - * env var — the image budget is the tunable surface; text past this size - * needs paging, not a bigger blob. */ -const MAX_TEXT_OUTPUT_BYTES = 102_400 - -/** The computed result of one vault_read_asset call, before content-block - * formatting: an image (fitted to the byte budget) or a text rendition. */ -type AssetReadResult = - | Readonly<{ - kind: "image" - fitted: FittedImage - originalBytes: number - path: string - }> - | Readonly<{ kind: "text"; text: string }> - /** One-line, model-facing summary accompanying an image block: what file it * is, what was delivered, and whether/how it was shrunk to fit. */ const describeDeliveredImage = (result: { @@ -62,38 +28,6 @@ const describeDeliveredImage = (result: { return `${delivered} (recompressed from ${fitted.originalWidth}×${fitted.originalHeight}, ${originalBytes} bytes)` } -/** Rejects text output past the fixed cap — an explicit error beats silent - * truncation, and states the actual size so the caller knows what exists. */ -const assertTextWithinCap = (params: { text: string; path: string }): void => { - const textBytes = Buffer.byteLength(params.text, "utf8") - if (textBytes <= MAX_TEXT_OUTPUT_BYTES) return - throw new Error( - `text output too large: "${params.path}" renders to ${textBytes} bytes ` + - `(cap ${MAX_TEXT_OUTPUT_BYTES} bytes)`, - ) -} - -/** Decodes an asset buffer as UTF-8, rejecting invalid byte sequences — the - * tool promises text content verbatim, and the default decoder would - * silently substitute U+FFFD for every undecodable byte instead. */ -const decodeUtf8Strict = (params: { buffer: Buffer; path: string }): string => { - try { - return new TextDecoder("utf-8", { fatal: true }).decode(params.buffer) - } catch (error) { - throw new Error( - `not valid UTF-8: "${params.path}" cannot be returned as text`, - { cause: error }, - ) - } -} - -/** Normalizes a user-supplied extension filter entry: lowercased, leading dot - * ensured — so "PNG", "png", and ".png" all match ".png". */ -const normalizeExtension = (extension: string): string => { - const lowered = extension.toLowerCase() - return lowered.startsWith(".") ? lowered : `.${lowered}` -} - export const registerAssetTools = ({ server, vaultPath, @@ -161,52 +95,17 @@ Search coverage: vault_search indexes markdown notes; find assets by browsing (v reqLogger.info("tool_call", { path, raw }) return safeHandlerContent( reqLogger, - async (): Promise => { - const asset = await vaultFs.readAsset( - { vaultPath, path, maxBytes: config.maxAssetBytes }, - reqLogger, - ) - const isImage = IMAGE_EXTENSIONS.has(asset.extension) - if (isImage && raw) { - throw new Error( - `raw source is not available for images: "${path}" is ` + - `binary — its image block is the delivered form`, - ) - } - if (isImage) { - const fitted = await fitImageToByteBudget({ - buffer: asset.buffer, - budgetBytes: config.maxImageOutputBytes, - }) - return { kind: "image", fitted, originalBytes: asset.bytes, path } - } - if (asset.extension === ".canvas") { - const canvasSource = decodeUtf8Strict({ - buffer: asset.buffer, + () => + readAssetContent( + { + vaultPath, path, - }) - const text = raw ? canvasSource : linearizeCanvas(canvasSource) - assertTextWithinCap({ text, path }) - return { kind: "text", text } - } - if (TEXT_PASSTHROUGH_EXTENSIONS.has(asset.extension)) { - const text = decodeUtf8Strict({ buffer: asset.buffer, path }) - assertTextWithinCap({ text, path }) - return { kind: "text", text } - } - if (asset.extension === ".pdf") { - throw new Error( - `PDF reading is not yet supported: "${path}" exists ` + - `(${asset.bytes} bytes) but text extraction is not available yet`, - ) - } - throw new Error( - `unsupported asset type "${asset.extension}": "${path}" exists ` + - `(${asset.bytes} bytes). Readable types: images ` + - `(.png/.jpg/.jpeg/.gif/.webp), .canvas, and text formats ` + - `(.svg/.json/.txt/.csv/.xml/.log/.base)`, - ) - }, + raw, + maxAssetBytes: config.maxAssetBytes, + maxImageOutputBytes: config.maxImageOutputBytes, + }, + reqLogger, + ), (result) => { if (result.kind === "image") { reqLogger.info("tool_result", { @@ -298,46 +197,15 @@ Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), return safeHandler( reqLogger, async () => { - const assetPaths = await vaultFs.listAssets( - { vaultPath, folder }, - reqLogger, - ) - const extensionFilter = extensions?.map(normalizeExtension) - const filteredPaths = extensionFilter - ? assetPaths.filter((assetPath) => - extensionFilter.includes( - links.getExtension(assetPath).toLowerCase(), - ), - ) - : assetPaths - - const extensionOf = (assetPath: string): string => - links.getExtension(assetPath).toLowerCase() || "(none)" - // Mutable accumulator: building frequency counts is the textbook - // case for a plain loop — no step depends on the prior step's shape. - const extensionCounts: Record = {} - for (const assetPath of filteredPaths) { - const ext = extensionOf(assetPath) - extensionCounts[ext] = (extensionCounts[ext] ?? 0) + 1 - } - - const pageLimit = limit ?? 50 - const pagePaths = filteredPaths.slice(0, pageLimit) - // Stat only the returned page — counts and total come from the path - // list, so unpaged files are never statted. - const stattedPage = await vaultFs.statAssets( - { vaultPath, paths: pagePaths }, + const listing = await buildAssetListing( + { vaultPath, folder, extensions, limit: limit ?? 50 }, reqLogger, ) return { - assets: stattedPage.map((entry) => ({ - path: entry.path, - extension: extensionOf(entry.path), - bytes: entry.bytes, - })), - extension_counts: extensionCounts, - total: filteredPaths.length, - truncated: filteredPaths.length > pageLimit, + assets: listing.assets, + extension_counts: listing.extensionCounts, + total: listing.total, + truncated: listing.truncated, } }, (result) => { diff --git a/src/vault-mcp/vault-operations/asset-listing.ts b/src/vault-mcp/vault-operations/asset-listing.ts new file mode 100644 index 00000000..69ddb18c --- /dev/null +++ b/src/vault-mcp/vault-operations/asset-listing.ts @@ -0,0 +1,89 @@ +import { vaultFs } from "./vault-filesystem.js" +import { links } from "../obsidian-markdown/links.js" +import type { Logger } from "../../logger.js" + +/** + * Asset listing use-case — composes the vault filesystem's asset walk and + * stat primitives with extension-filter semantics into the browsing surface + * behind vault_list_assets: filter, per-extension counts over the full + * filtered set, and a size-capped result slice. There is no pagination — + * `limit` caps the returned entries, and counts/total over the full set tell + * the caller to narrow with folder/extensions when the cap is hit. + */ + +/** Display extension for listings: lowercased with its dot, or "(none)" for + * extensionless files. */ +const extensionOf = (assetPath: string): string => + links.getExtension(assetPath).toLowerCase() || "(none)" + +/** Normalizes a caller-supplied extension filter entry: lowercased, leading + * dot ensured — so "PNG", "png", and ".png" all match ".png". */ +const normalizeExtension = (extension: string): string => { + const lowered = extension.toLowerCase() + return lowered.startsWith(".") ? lowered : `.${lowered}` +} + +export type AssetListing = Readonly<{ + assets: readonly Readonly<{ + path: string + extension: string + bytes: number + }>[] + extensionCounts: Readonly> + total: number + truncated: boolean +}> + +/** + * Lists a folder's (or the vault's) assets: extension-filtered, counted per + * extension over the full filtered set, and capped to `limit` entries with + * byte sizes statted for the returned slice only — entries beyond the cap + * are never statted. + */ +export const buildAssetListing = async ( + params: { + vaultPath: string + folder?: string | undefined + extensions?: readonly string[] | undefined + limit: number + }, + logger: Logger, +): Promise => { + const assetPaths = await vaultFs.listAssets( + { vaultPath: params.vaultPath, folder: params.folder }, + logger, + ) + const extensionFilter = params.extensions + ? new Set(params.extensions.map(normalizeExtension)) + : undefined + const filteredPaths = extensionFilter + ? assetPaths.filter((assetPath) => + extensionFilter.has(links.getExtension(assetPath).toLowerCase()), + ) + : assetPaths + + const filteredExtensions = filteredPaths.map(extensionOf) + const extensionCounts = filteredExtensions.reduce>( + (counts, extension) => ({ + ...counts, + [extension]: (counts[extension] ?? 0) + 1, + }), + {}, + ) + + const returnedPaths = filteredPaths.slice(0, params.limit) + const stattedAssets = await vaultFs.statAssets( + { vaultPath: params.vaultPath, paths: returnedPaths }, + logger, + ) + return { + assets: stattedAssets.map((entry) => ({ + path: entry.path, + extension: extensionOf(entry.path), + bytes: entry.bytes, + })), + extensionCounts, + total: filteredPaths.length, + truncated: filteredPaths.length > params.limit, + } +} diff --git a/src/vault-mcp/vault-operations/asset-reader.ts b/src/vault-mcp/vault-operations/asset-reader.ts new file mode 100644 index 00000000..e13ac72f --- /dev/null +++ b/src/vault-mcp/vault-operations/asset-reader.ts @@ -0,0 +1,130 @@ +import { vaultFs } from "./vault-filesystem.js" +import { linearizeCanvas } from "../obsidian-markdown/canvas.js" +import { fitImageToByteBudget } from "../../utils/fit-image-to-byte-budget.js" +import type { FittedImage } from "../../utils/fit-image-to-byte-budget.js" +import type { Logger } from "../../logger.js" + +/** + * Asset reading use-case — dispatches a non-markdown vault file to its most + * useful representation: images fitted to a byte budget, canvases linearized + * (or their raw JSON source), text formats decoded verbatim, and structured + * errors for everything else. Composes vaultFs.readAsset with the parsers and + * the image pipeline; the tool layer maps the result to MCP content blocks. + */ + +/** Extensions dispatched to the image pipeline (model-visible image blocks). */ +const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]) + +/** Extensions returned verbatim as text — plain-text formats an agent can + * read directly. .svg is XML source; .base is Obsidian Bases YAML. */ +const TEXT_PASSTHROUGH_EXTENSIONS = new Set([ + ".svg", + ".json", + ".txt", + ".csv", + ".xml", + ".log", + ".base", +]) + +/** Fixed cap on text output (passthrough files and canvas renditions) so a + * huge text asset can't blow a client's response limit. Deliberately not an + * env var — the image budget is the tunable surface; text past this size + * needs paging, not a bigger blob. */ +const MAX_TEXT_OUTPUT_BYTES = 102_400 + +/** The computed result of one asset read, before content-block formatting: + * an image (fitted to the byte budget) or a text rendition. */ +export type AssetReadResult = + | Readonly<{ + kind: "image" + fitted: FittedImage + originalBytes: number + path: string + }> + | Readonly<{ kind: "text"; text: string }> + +/** Rejects text output past the fixed cap — an explicit error beats silent + * truncation, and states the actual size so the caller knows what exists. */ +const assertTextWithinCap = (params: { text: string; path: string }): void => { + const textBytes = Buffer.byteLength(params.text, "utf8") + if (textBytes <= MAX_TEXT_OUTPUT_BYTES) return + throw new Error( + `text output too large: "${params.path}" renders to ${textBytes} bytes ` + + `(cap ${MAX_TEXT_OUTPUT_BYTES} bytes)`, + ) +} + +/** Decodes an asset buffer as UTF-8, rejecting invalid byte sequences — text + * is promised verbatim, and the default decoder would silently substitute + * U+FFFD for every undecodable byte instead. */ +const decodeUtf8Strict = (params: { buffer: Buffer; path: string }): string => { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(params.buffer) + } catch (error) { + throw new Error( + `not valid UTF-8: "${params.path}" cannot be returned as text`, + { cause: error }, + ) + } +} + +/** + * Reads a non-markdown vault file and returns its most useful representation + * per type; `raw` skips the canvas rendition for the exact JSON source. + * Throws structured errors for images with `raw`, PDFs, and unsupported + * types — each stating the file's existence and size. + */ +export const readAssetContent = async ( + params: { + vaultPath: string + path: string + raw?: boolean | undefined + maxAssetBytes: number + maxImageOutputBytes: number + }, + logger: Logger, +): Promise => { + const { path, raw } = params + const asset = await vaultFs.readAsset( + { vaultPath: params.vaultPath, path, maxBytes: params.maxAssetBytes }, + logger, + ) + const isImage = IMAGE_EXTENSIONS.has(asset.extension) + if (isImage && raw) { + throw new Error( + `raw source is not available for images: "${path}" is ` + + `binary — its image block is the delivered form`, + ) + } + if (isImage) { + const fitted = await fitImageToByteBudget({ + buffer: asset.buffer, + budgetBytes: params.maxImageOutputBytes, + }) + return { kind: "image", fitted, originalBytes: asset.bytes, path } + } + if (asset.extension === ".canvas") { + const canvasSource = decodeUtf8Strict({ buffer: asset.buffer, path }) + const text = raw ? canvasSource : linearizeCanvas(canvasSource) + assertTextWithinCap({ text, path }) + return { kind: "text", text } + } + if (TEXT_PASSTHROUGH_EXTENSIONS.has(asset.extension)) { + const text = decodeUtf8Strict({ buffer: asset.buffer, path }) + assertTextWithinCap({ text, path }) + return { kind: "text", text } + } + if (asset.extension === ".pdf") { + throw new Error( + `PDF reading is not yet supported: "${path}" exists ` + + `(${asset.bytes} bytes) but text extraction is not available yet`, + ) + } + throw new Error( + `unsupported asset type "${asset.extension}": "${path}" exists ` + + `(${asset.bytes} bytes). Readable types: images ` + + `(.png/.jpg/.jpeg/.gif/.webp), .canvas, and text formats ` + + `(.svg/.json/.txt/.csv/.xml/.log/.base)`, + ) +} From 938a35b0ca0538af251a7689f3ffc13b58c12dfc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:36:34 -0400 Subject: [PATCH 28/34] docs(assets): say what asset-reader implements instead of 'the dispatch' Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1d56eaf8..bf63f91b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -298,7 +298,7 @@ Link queries use a `links` table populated during indexing: 3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.base`) pass through verbatim as text, capped at a fixed 100 KiB output size (explicit error over silent truncation). 4. **PDFs** return a structured not-yet-supported error carrying the file's existence and size (text extraction is a planned follow-up); unknown types return an error naming the readable set. -The dispatch lives in the `vault-operations/asset-reader.ts` use-case; reads go through `vaultFs.readAsset` beneath it: the same `resolveSafePath` traversal guard as notes, a `.md` rejection (notes belong to `vault_read_note`), and a stat-before-read size cap (`MAX_ASSET_BYTES`, default 50 MiB). +The extension-to-representation routing above is implemented by the `vault-operations/asset-reader.ts` use-case. Beneath it, every read goes through `vaultFs.readAsset`, which applies the same `resolveSafePath` traversal guard as notes, rejects `.md` paths (notes belong to `vault_read_note`), and enforces a stat-before-read size cap (`MAX_ASSET_BYTES`, default 50 MiB). `vault_list_assets` is the discovery surface (the `vault-operations/asset-listing.ts` use-case): a filesystem walk (`vaultFs.listAssets` — filesystem truth, deliberately not the index), folder and case-insensitive extension filters, per-extension counts computed over the full filtered set, and byte sizes statted only for the returned slice. Assets are readable and browsable but not yet searchable — content indexing is a possible future tier. From 4be8d4c828c29826c6c933e6da703afdb0e54e65 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:50:14 -0400 Subject: [PATCH 29/34] =?UTF-8?q?refactor(assets):=20drop=20filter=20predi?= =?UTF-8?q?cate=20annotations=20=E2=80=94=20TS=206=20infers=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (entry) => entry !== null narrows (T | null)[] to T[] on its own now; the explicit (entry): entry is {...} annotations were clutter. Verified empirically: bare comparison and !!x infer, filter(Boolean) still doesn't narrow. Four sites: statAssets, the rebuild's non-md stat filter, and canvas's node/edge parses. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/vault-mcp/obsidian-markdown/canvas.ts | 6 ++---- src/vault-mcp/search/search-index.ts | 5 +---- src/vault-mcp/vault-operations/vault-filesystem.ts | 4 +--- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/canvas.ts b/src/vault-mcp/obsidian-markdown/canvas.ts index 94121310..09d4225e 100644 --- a/src/vault-mcp/obsidian-markdown/canvas.ts +++ b/src/vault-mcp/obsidian-markdown/canvas.ts @@ -214,11 +214,9 @@ export const linearizeCanvas = (canvasJson: string): string => { isRecord(parsed) && Array.isArray(parsed.edges) ? parsed.edges : [] const nodes = rawNodes .map(parseNode) - .filter((node): node is CanvasNode => node !== null) + .filter((node) => node !== null) .sort(byReadingOrder) - const edges = rawEdges - .map(parseEdge) - .filter((edge): edge is CanvasEdge => edge !== null) + const edges = rawEdges.map(parseEdge).filter((edge) => edge !== null) const groups = nodes.filter((node) => node.type === "group") const contentNodes = nodes.filter((node) => node.type !== "group") diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index a7714544..09f94fa7 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -1409,10 +1409,7 @@ export const createSearchIndex = ( return { relativePath: file.relativePath, bytes: fileStat.size } }), ) - ).filter( - (entry): entry is { relativePath: string; bytes: number } => - entry !== null, - ) + ).filter((entry) => entry !== null) const noteContents = await Promise.all( markdownFiles.map(async (file) => { diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index ec2d2312..3ce6fd21 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -609,9 +609,7 @@ const statAssets = async ( return { path: assetPath, bytes: fileStats.size } }, }) - const existingEntries = stattedEntries.filter( - (entry): entry is { path: string; bytes: number } => entry !== null, - ) + const existingEntries = stattedEntries.filter((entry) => entry !== null) logger.info("statted assets", { count: existingEntries.length }) return existingEntries } From b5066ea7234fec8928d3e52caea2c56cc23a991b Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:53:59 -0400 Subject: [PATCH 30/34] refactor(assets): namespace-export the asset use-cases per folder convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder's actual line: operation modules export namespaces (noteMover — even single-op — vaultPatcher, taskUpdater, vaultFs); named exports are the config-reader carve-out (daily-notes, task-format-config). The asset use-cases are operations, so assetReader.readAssetContent and assetListing.buildAssetListing now match their siblings. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/vault-mcp/mcp-core/tools/asset-tools.ts | 8 ++++---- src/vault-mcp/vault-operations/asset-listing.ts | 9 ++++++++- src/vault-mcp/vault-operations/asset-reader.ts | 9 ++++++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index c65432cf..0d675393 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -1,8 +1,8 @@ /** Asset tool registration — reading and discovering non-markdown vault files. */ import { z } from "zod" -import { readAssetContent } from "../../vault-operations/asset-reader.js" -import { buildAssetListing } from "../../vault-operations/asset-listing.js" +import { assetReader } from "../../vault-operations/asset-reader.js" +import { assetListing } from "../../vault-operations/asset-listing.js" import type { FittedImage } from "../../../utils/fit-image-to-byte-budget.js" import type { ToolRegistrationContext } from "./tool-helpers.js" import { safeHandler, safeHandlerContent } from "./tool-helpers.js" @@ -96,7 +96,7 @@ Search coverage: vault_search indexes markdown notes; find assets by browsing (v return safeHandlerContent( reqLogger, () => - readAssetContent( + assetReader.readAssetContent( { vaultPath, path, @@ -197,7 +197,7 @@ Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), return safeHandler( reqLogger, async () => { - const listing = await buildAssetListing( + const listing = await assetListing.buildAssetListing( { vaultPath, folder, extensions, limit: limit ?? 50 }, reqLogger, ) diff --git a/src/vault-mcp/vault-operations/asset-listing.ts b/src/vault-mcp/vault-operations/asset-listing.ts index 69ddb18c..c8947e44 100644 --- a/src/vault-mcp/vault-operations/asset-listing.ts +++ b/src/vault-mcp/vault-operations/asset-listing.ts @@ -40,7 +40,7 @@ export type AssetListing = Readonly<{ * byte sizes statted for the returned slice only — entries beyond the cap * are never statted. */ -export const buildAssetListing = async ( +const buildAssetListing = async ( params: { vaultPath: string folder?: string | undefined @@ -87,3 +87,10 @@ export const buildAssetListing = async ( truncated: filteredPaths.length > params.limit, } } + +/** The asset-listing use-case surface — namespace export so call sites read + * `assetListing.buildAssetListing(...)`, matching the folder's operation + * modules (noteMover, vaultPatcher, taskUpdater). */ +export const assetListing = { + buildAssetListing, +} diff --git a/src/vault-mcp/vault-operations/asset-reader.ts b/src/vault-mcp/vault-operations/asset-reader.ts index e13ac72f..6b9c97d8 100644 --- a/src/vault-mcp/vault-operations/asset-reader.ts +++ b/src/vault-mcp/vault-operations/asset-reader.ts @@ -75,7 +75,7 @@ const decodeUtf8Strict = (params: { buffer: Buffer; path: string }): string => { * Throws structured errors for images with `raw`, PDFs, and unsupported * types — each stating the file's existence and size. */ -export const readAssetContent = async ( +const readAssetContent = async ( params: { vaultPath: string path: string @@ -128,3 +128,10 @@ export const readAssetContent = async ( `(.svg/.json/.txt/.csv/.xml/.log/.base)`, ) } + +/** The asset-reading use-case surface — namespace export so call sites read + * `assetReader.readAssetContent(...)`, matching the folder's operation + * modules (noteMover, vaultPatcher, taskUpdater). */ +export const assetReader = { + readAssetContent, +} From 35e8d0fa203d7679aeef419f29605a7ebed622c1 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:54:30 -0400 Subject: [PATCH 31/34] =?UTF-8?q?docs(agents):=20sharpen=20the=20export-st?= =?UTF-8?q?yle=20rule=20=E2=80=94=20operations=20vs=20config=20readers,=20?= =?UTF-8?q?not=20function=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old 'cohesive service surface vs loose set of functions' framing invited a function-count reading (which produced named exports on the asset use-cases). The actual line the folder demonstrates: modules that perform operations export namespaces regardless of how few operations they hold; named exports are for parsers and config readers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- AGENTS.md | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b97f2f92..e0fd072c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -257,20 +257,24 @@ goes in `obsidian-markdown/`, never `utils/`. **Export style** depends on what kind of module it is: -- **Service / data-layer modules** — those that wrap a cohesive set of operations - over a resource (the vault, the index) — export a **single namespace object** - so call sites self-document which module an operation belongs to: - `vaultFs.readNote(…)`, `vaultPatcher.patchNote(…)`, `noteMover.moveNote(…)`. - Stateful ones use a **factory-closure** returning that object - (`createSearchIndex`, `createMemoryStore`), so prepared statements / caches - live in the closure. -- **Parser and small-helper modules** — the `obsidian-markdown/` parsers - (`frontmatter`, `headings`, `callouts`, `lines`), `utils/`, and `daily-notes` — - export **named functions**. The shape tracks whether a module is a _cohesive - service surface_ (→ namespace) or just a loose set of functions (→ named), - **not** whether it does I/O: the parsers are pure, while `daily-notes` does - light I/O (reads and caches daily-note config), yet both use named exports - because neither is a grouped service API. +- **Operation / data-layer modules** — anything that performs vault or index + operations — export a **single namespace object** so call sites self-document + which module an operation belongs to: `vaultFs.readNote(…)`, + `vaultPatcher.patchNote(…)`, `noteMover.moveNote(…)`, + `assetReader.readAssetContent(…)`, `assetListing.buildAssetListing(…)`. + **Function count is irrelevant** — `noteMover` and both asset use-cases are + essentially single-operation modules and still export namespaces; "it only + has one function" is not the named-export test. Stateful ones use a + **factory-closure** returning that object (`createSearchIndex`, + `createMemoryStore`), so prepared statements / caches live in the closure. +- **Parser, small-helper, and config-reader modules** — the + `obsidian-markdown/` parsers (`frontmatter`, `headings`, `callouts`, + `lines`), `utils/`, and the config readers (`daily-notes`, + `task-format-config`) — export **named functions**. The shape tracks whether + a module _performs operations_ (→ namespace) or _parses/reads + configuration_ (→ named), **not** whether it does I/O: the parsers are pure, + while the config readers do light I/O, yet both use named exports because + neither is an operation surface. - **`links.ts` is the deliberate edge case** — a pure parser that nonetheless exports a single `links` namespace, _not_ for the service-grouping reason above but to wall off its `/g` grammar regexes (shared `lastIndex` footgun) behind From be5847c2c249f28f1d11c370f1332d9480386808 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:57:48 -0400 Subject: [PATCH 32/34] refactor(assets): merge the asset use-cases into one asset-operations module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read and browse are grouped functionality over one domain — the vaultPatcher precedent (two related operations, one file, one namespace). assetOperations = { readAssetContent, buildAssetListing }; doc surfaces (AGENTS tree + worked-example mentions, ARCHITECTURE, wiki.json) follow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- .devin/wiki.json | 2 +- AGENTS.md | 11 +- ARCHITECTURE.md | 4 +- src/vault-mcp/mcp-core/tools/asset-tools.ts | 7 +- .../vault-operations/asset-listing.ts | 96 --------------- .../{asset-reader.ts => asset-operations.ts} | 110 ++++++++++++++++-- 6 files changed, 112 insertions(+), 118 deletions(-) delete mode 100644 src/vault-mcp/vault-operations/asset-listing.ts rename src/vault-mcp/vault-operations/{asset-reader.ts => asset-operations.ts} (53%) diff --git a/.devin/wiki.json b/.devin/wiki.json index 25a7fee2..31d484bc 100644 --- a/.devin/wiki.json +++ b/.devin/wiki.json @@ -50,7 +50,7 @@ }, { "title": "Vault Operations", - "purpose": "The filesystem I/O layer in vault-operations/ that reads, writes, and transforms vault content. Asset use-cases: asset-reader.ts (per-type read dispatch — image fitting, canvas linearize/raw, strict-UTF-8 text passthrough, structured errors) and asset-listing.ts (extension filter, per-extension counts, capped statted slice) compose the vaultFs primitives readAsset (binary read with .md rejection and stat-first size cap), listAssets (folder-scoped non-md walk), and statAssets (bounded-concurrency stat) behind the vault_read_asset/vault_list_assets tools. Safety primitives: atomic writes (atomicWriteFile temp-then-rename, atomicWriteFileExclusive link-based no-clobber for TOCTOU-safe creates), per-file write locking (file-write-lock.ts — serializing, fail-fast, and multi-file fail-fast modes), path traversal prevention (resolveSafePath resolve + prefix check, toVaultRelativePath normalization), and protected-path enforcement (PROTECTED_PATHS checked after normalization). Covers: vault-filesystem.ts (read/write/list/delete .md files, outline and section reads, protected-path validation); vault-patcher.ts (surgical edits — heading-targeted patch with prepend/append/replace operations, and find-and-replace with optional replace-all); note-mover.ts (move/rename a note and rewrite every vault-wide link that pointed to it, using the link resolution engine from obsidian-markdown/links.ts — preflight-then-commit design with multi-file exclusive locking); memory-store.ts (About Me/ heading-aware read/append/delete with shrink guard, idempotency guard, and ambiguity guard — see Memory Layer page); daily-notes.ts (daily note config reader and path resolver — see Memory Layer page). All functions follow the (params, logger) two-argument pattern.", + "purpose": "The filesystem I/O layer in vault-operations/ that reads, writes, and transforms vault content. Asset use-case: asset-operations.ts (per-type read dispatch — image fitting, canvas linearize/raw, strict-UTF-8 text passthrough, structured errors — plus browsing: extension filter, per-extension counts, capped statted slice) composes the vaultFs primitives readAsset (binary read with .md rejection and stat-first size cap), listAssets (folder-scoped non-md walk), and statAssets (bounded-concurrency stat) behind the vault_read_asset/vault_list_assets tools. Safety primitives: atomic writes (atomicWriteFile temp-then-rename, atomicWriteFileExclusive link-based no-clobber for TOCTOU-safe creates), per-file write locking (file-write-lock.ts — serializing, fail-fast, and multi-file fail-fast modes), path traversal prevention (resolveSafePath resolve + prefix check, toVaultRelativePath normalization), and protected-path enforcement (PROTECTED_PATHS checked after normalization). Covers: vault-filesystem.ts (read/write/list/delete .md files, outline and section reads, protected-path validation); vault-patcher.ts (surgical edits — heading-targeted patch with prepend/append/replace operations, and find-and-replace with optional replace-all); note-mover.ts (move/rename a note and rewrite every vault-wide link that pointed to it, using the link resolution engine from obsidian-markdown/links.ts — preflight-then-commit design with multi-file exclusive locking); memory-store.ts (About Me/ heading-aware read/append/delete with shrink guard, idempotency guard, and ambiguity guard — see Memory Layer page); daily-notes.ts (daily note config reader and path resolver — see Memory Layer page). All functions follow the (params, logger) two-argument pattern.", "parent": "Architecture" }, { diff --git a/AGENTS.md b/AGENTS.md index e0fd072c..9f8b89a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,8 +127,7 @@ src/ daily-notes.ts # Daily note config reader + path resolver task-updater.ts # Task state mutations (status, priority, lane moves) task-format-config.ts # Tasks-plugin format config reader (emoji vs Dataview) - asset-reader.ts # Asset read dispatch (image fit, canvas linearize/raw, text passthrough) - asset-listing.ts # Asset browsing (extension filter, counts, capped statted slice) + asset-operations.ts # Asset read dispatch + browsing (image fit, canvas linearize/raw, extension filter, statted slice) mcp-core/ # MCP protocol surface mcp-router.ts # /mcp session routes + transport lifecycle tool-definitions.ts # Tool orchestrator — TOOL_NAMES + conditional group registration @@ -261,10 +260,10 @@ goes in `obsidian-markdown/`, never `utils/`. operations — export a **single namespace object** so call sites self-document which module an operation belongs to: `vaultFs.readNote(…)`, `vaultPatcher.patchNote(…)`, `noteMover.moveNote(…)`, - `assetReader.readAssetContent(…)`, `assetListing.buildAssetListing(…)`. - **Function count is irrelevant** — `noteMover` and both asset use-cases are - essentially single-operation modules and still export namespaces; "it only - has one function" is not the named-export test. Stateful ones use a + `assetOperations.readAssetContent(…)`. + **Function count is irrelevant** — `noteMover` is essentially a + single-operation module and still exports a namespace; "it only has one + function" is not the named-export test. Stateful ones use a **factory-closure** returning that object (`createSearchIndex`, `createMemoryStore`), so prepared statements / caches live in the closure. - **Parser, small-helper, and config-reader modules** — the diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf63f91b..90cac7ff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -298,9 +298,9 @@ Link queries use a `links` table populated during indexing: 3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.base`) pass through verbatim as text, capped at a fixed 100 KiB output size (explicit error over silent truncation). 4. **PDFs** return a structured not-yet-supported error carrying the file's existence and size (text extraction is a planned follow-up); unknown types return an error naming the readable set. -The extension-to-representation routing above is implemented by the `vault-operations/asset-reader.ts` use-case. Beneath it, every read goes through `vaultFs.readAsset`, which applies the same `resolveSafePath` traversal guard as notes, rejects `.md` paths (notes belong to `vault_read_note`), and enforces a stat-before-read size cap (`MAX_ASSET_BYTES`, default 50 MiB). +The extension-to-representation routing above is implemented by the `vault-operations/asset-operations.ts` use-case. Beneath it, every read goes through `vaultFs.readAsset`, which applies the same `resolveSafePath` traversal guard as notes, rejects `.md` paths (notes belong to `vault_read_note`), and enforces a stat-before-read size cap (`MAX_ASSET_BYTES`, default 50 MiB). -`vault_list_assets` is the discovery surface (the `vault-operations/asset-listing.ts` use-case): a filesystem walk (`vaultFs.listAssets` — filesystem truth, deliberately not the index), folder and case-insensitive extension filters, per-extension counts computed over the full filtered set, and byte sizes statted only for the returned slice. Assets are readable and browsable but not yet searchable — content indexing is a possible future tier. +`vault_list_assets` is the discovery surface (also `vault-operations/asset-operations.ts`): a filesystem walk (`vaultFs.listAssets` — filesystem truth, deliberately not the index), folder and case-insensitive extension filters, per-extension counts computed over the full filtered set, and byte sizes statted only for the returned slice. Assets are readable and browsable but not yet searchable — content indexing is a possible future tier. ### Tasks (R9) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index 0d675393..5ec4bb1d 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -1,8 +1,7 @@ /** Asset tool registration — reading and discovering non-markdown vault files. */ import { z } from "zod" -import { assetReader } from "../../vault-operations/asset-reader.js" -import { assetListing } from "../../vault-operations/asset-listing.js" +import { assetOperations } from "../../vault-operations/asset-operations.js" import type { FittedImage } from "../../../utils/fit-image-to-byte-budget.js" import type { ToolRegistrationContext } from "./tool-helpers.js" import { safeHandler, safeHandlerContent } from "./tool-helpers.js" @@ -96,7 +95,7 @@ Search coverage: vault_search indexes markdown notes; find assets by browsing (v return safeHandlerContent( reqLogger, () => - assetReader.readAssetContent( + assetOperations.readAssetContent( { vaultPath, path, @@ -197,7 +196,7 @@ Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), return safeHandler( reqLogger, async () => { - const listing = await assetListing.buildAssetListing( + const listing = await assetOperations.buildAssetListing( { vaultPath, folder, extensions, limit: limit ?? 50 }, reqLogger, ) diff --git a/src/vault-mcp/vault-operations/asset-listing.ts b/src/vault-mcp/vault-operations/asset-listing.ts deleted file mode 100644 index c8947e44..00000000 --- a/src/vault-mcp/vault-operations/asset-listing.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { vaultFs } from "./vault-filesystem.js" -import { links } from "../obsidian-markdown/links.js" -import type { Logger } from "../../logger.js" - -/** - * Asset listing use-case — composes the vault filesystem's asset walk and - * stat primitives with extension-filter semantics into the browsing surface - * behind vault_list_assets: filter, per-extension counts over the full - * filtered set, and a size-capped result slice. There is no pagination — - * `limit` caps the returned entries, and counts/total over the full set tell - * the caller to narrow with folder/extensions when the cap is hit. - */ - -/** Display extension for listings: lowercased with its dot, or "(none)" for - * extensionless files. */ -const extensionOf = (assetPath: string): string => - links.getExtension(assetPath).toLowerCase() || "(none)" - -/** Normalizes a caller-supplied extension filter entry: lowercased, leading - * dot ensured — so "PNG", "png", and ".png" all match ".png". */ -const normalizeExtension = (extension: string): string => { - const lowered = extension.toLowerCase() - return lowered.startsWith(".") ? lowered : `.${lowered}` -} - -export type AssetListing = Readonly<{ - assets: readonly Readonly<{ - path: string - extension: string - bytes: number - }>[] - extensionCounts: Readonly> - total: number - truncated: boolean -}> - -/** - * Lists a folder's (or the vault's) assets: extension-filtered, counted per - * extension over the full filtered set, and capped to `limit` entries with - * byte sizes statted for the returned slice only — entries beyond the cap - * are never statted. - */ -const buildAssetListing = async ( - params: { - vaultPath: string - folder?: string | undefined - extensions?: readonly string[] | undefined - limit: number - }, - logger: Logger, -): Promise => { - const assetPaths = await vaultFs.listAssets( - { vaultPath: params.vaultPath, folder: params.folder }, - logger, - ) - const extensionFilter = params.extensions - ? new Set(params.extensions.map(normalizeExtension)) - : undefined - const filteredPaths = extensionFilter - ? assetPaths.filter((assetPath) => - extensionFilter.has(links.getExtension(assetPath).toLowerCase()), - ) - : assetPaths - - const filteredExtensions = filteredPaths.map(extensionOf) - const extensionCounts = filteredExtensions.reduce>( - (counts, extension) => ({ - ...counts, - [extension]: (counts[extension] ?? 0) + 1, - }), - {}, - ) - - const returnedPaths = filteredPaths.slice(0, params.limit) - const stattedAssets = await vaultFs.statAssets( - { vaultPath: params.vaultPath, paths: returnedPaths }, - logger, - ) - return { - assets: stattedAssets.map((entry) => ({ - path: entry.path, - extension: extensionOf(entry.path), - bytes: entry.bytes, - })), - extensionCounts, - total: filteredPaths.length, - truncated: filteredPaths.length > params.limit, - } -} - -/** The asset-listing use-case surface — namespace export so call sites read - * `assetListing.buildAssetListing(...)`, matching the folder's operation - * modules (noteMover, vaultPatcher, taskUpdater). */ -export const assetListing = { - buildAssetListing, -} diff --git a/src/vault-mcp/vault-operations/asset-reader.ts b/src/vault-mcp/vault-operations/asset-operations.ts similarity index 53% rename from src/vault-mcp/vault-operations/asset-reader.ts rename to src/vault-mcp/vault-operations/asset-operations.ts index 6b9c97d8..044b8b97 100644 --- a/src/vault-mcp/vault-operations/asset-reader.ts +++ b/src/vault-mcp/vault-operations/asset-operations.ts @@ -1,17 +1,29 @@ import { vaultFs } from "./vault-filesystem.js" import { linearizeCanvas } from "../obsidian-markdown/canvas.js" +import { links } from "../obsidian-markdown/links.js" import { fitImageToByteBudget } from "../../utils/fit-image-to-byte-budget.js" import type { FittedImage } from "../../utils/fit-image-to-byte-budget.js" import type { Logger } from "../../logger.js" /** - * Asset reading use-case — dispatches a non-markdown vault file to its most - * useful representation: images fitted to a byte budget, canvases linearized - * (or their raw JSON source), text formats decoded verbatim, and structured - * errors for everything else. Composes vaultFs.readAsset with the parsers and - * the image pipeline; the tool layer maps the result to MCP content blocks. + * Asset operations use-case — the grouped read/browse surface over the + * vault's non-markdown files, composing vaultFs primitives with the parsers + * and the image pipeline. Two operations share the domain: + * + * - `readAssetContent` dispatches one file to its most useful representation: + * images fitted to a byte budget, canvases linearized (or their raw JSON + * source), text formats decoded verbatim, structured errors for the rest. + * - `buildAssetListing` browses many: extension-filtered, counted per + * extension over the full filtered set, and capped to a statted slice. + * There is no pagination — `limit` caps the returned entries, and + * counts/total over the full set tell the caller to narrow with + * folder/extensions when the cap is hit. + * + * The tool layer maps results to MCP content blocks / wire JSON. */ +// ── Reading ───────────────────────────────────────────────────── + /** Extensions dispatched to the image pipeline (model-visible image blocks). */ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]) @@ -129,9 +141,89 @@ const readAssetContent = async ( ) } -/** The asset-reading use-case surface — namespace export so call sites read - * `assetReader.readAssetContent(...)`, matching the folder's operation - * modules (noteMover, vaultPatcher, taskUpdater). */ -export const assetReader = { +// ── Browsing ──────────────────────────────────────────────────── + +/** Display extension for listings: lowercased with its dot, or "(none)" for + * extensionless files. */ +const extensionOf = (assetPath: string): string => + links.getExtension(assetPath).toLowerCase() || "(none)" + +/** Normalizes a caller-supplied extension filter entry: lowercased, leading + * dot ensured — so "PNG", "png", and ".png" all match ".png". */ +const normalizeExtension = (extension: string): string => { + const lowered = extension.toLowerCase() + return lowered.startsWith(".") ? lowered : `.${lowered}` +} + +export type AssetListing = Readonly<{ + assets: readonly Readonly<{ + path: string + extension: string + bytes: number + }>[] + extensionCounts: Readonly> + total: number + truncated: boolean +}> + +/** + * Lists a folder's (or the vault's) assets: extension-filtered, counted per + * extension over the full filtered set, and capped to `limit` entries with + * byte sizes statted for the returned slice only — entries beyond the cap + * are never statted. + */ +const buildAssetListing = async ( + params: { + vaultPath: string + folder?: string | undefined + extensions?: readonly string[] | undefined + limit: number + }, + logger: Logger, +): Promise => { + const assetPaths = await vaultFs.listAssets( + { vaultPath: params.vaultPath, folder: params.folder }, + logger, + ) + const extensionFilter = params.extensions + ? new Set(params.extensions.map(normalizeExtension)) + : undefined + const filteredPaths = extensionFilter + ? assetPaths.filter((assetPath) => + extensionFilter.has(links.getExtension(assetPath).toLowerCase()), + ) + : assetPaths + + const filteredExtensions = filteredPaths.map(extensionOf) + const extensionCounts = filteredExtensions.reduce>( + (counts, extension) => ({ + ...counts, + [extension]: (counts[extension] ?? 0) + 1, + }), + {}, + ) + + const returnedPaths = filteredPaths.slice(0, params.limit) + const stattedAssets = await vaultFs.statAssets( + { vaultPath: params.vaultPath, paths: returnedPaths }, + logger, + ) + return { + assets: stattedAssets.map((entry) => ({ + path: entry.path, + extension: extensionOf(entry.path), + bytes: entry.bytes, + })), + extensionCounts, + total: filteredPaths.length, + truncated: filteredPaths.length > params.limit, + } +} + +/** The asset operations surface — one namespace for the grouped read/browse + * functionality, matching the folder's operation modules (noteMover, + * vaultPatcher, taskUpdater). */ +export const assetOperations = { readAssetContent, + buildAssetListing, } From 4ae8b0216c518fc92e4f1f703d8d1afe76634b0c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:01:38 -0400 Subject: [PATCH 33/34] docs(agents): codify group-by-dependency-layer-not-topic in module layering Makes the responsibility split legible as intentional: asset read+browse share the filesystem layer so asset-operations.ts holds both; task list (SQL query, search/) and task update (file mutation, task-updater.ts) stay apart, matching notes. Topic-symmetric one-module-per-domain grouping across layers is named as the smell. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9f8b89a1..3c0e32f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,6 +229,14 @@ Two rules keep this honest: `mcp-core/` and the top-level wiring depend on everything. A _search_ module importing a _parser_ should read as "uses the shared parser," never as reaching sideways into `vault-operations/`. +- **Group operations by shared dependency layer, not by topic.** A domain's + operations live together only when they share a layer: asset read + browse + are both filesystem work, so `asset-operations.ts` holds both. Task list + (a SQL query — lives with the queries in `search/`) and task update (a file + mutation — `task-updater.ts`) stay apart, and so do note search and note + mutations. A topic-symmetric "one module per domain" grouping that crosses + layers is the smell, not the goal — each file answers for one layer's view + of its domain. - **Top level is wiring only.** Folders are domains; the only loose files at `vault-mcp/` are the entry point (`server.ts`) and its `config.ts`. From 772bc6109e43feff122b2b1a396a4376f5ab13e2 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:10:53 -0400 Subject: [PATCH 34/34] docs(assets): state the canvas linearizer's why, spec, and prior art in its docstring A cold reader saw what it does but not why it exists: canvas JSON is meaning buried in presentation (content + relationships scattered through coordinates and id-joins); the rendition keeps the meaning as document structure and drops geometry. Links the JSON Canvas 1.0 spec and the Canvas2Document precedent (human long-form conversion) and states this one is purpose-built for model reading. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- src/vault-mcp/obsidian-markdown/canvas.ts | 26 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/canvas.ts b/src/vault-mcp/obsidian-markdown/canvas.ts index 09d4225e..66c3b8d0 100644 --- a/src/vault-mcp/obsidian-markdown/canvas.ts +++ b/src/vault-mcp/obsidian-markdown/canvas.ts @@ -1,11 +1,27 @@ import { posix } from "node:path" /** - * Linearizes an Obsidian .canvas file (JSON Canvas 1.0) into a readable - * markdown rendition: node content in spatial reading order, grouped under - * the group that visually contains it, followed by an edge list with node - * ids resolved to display names. The rendition drops geometry (x/y/width/ - * height) — it is a reading surface, not a round-trippable format. + * Linearizes an Obsidian .canvas file (JSON Canvas 1.0 — + * https://jsoncanvas.org) into a readable markdown rendition. + * + * Why this exists: a canvas file is meaning buried in presentation. Its + * content (card text, file references) and relationships (grouping, + * labeled edges) are scattered through coordinate records and joined by + * opaque node ids — a reader consuming the raw JSON spends most of its + * attention on `x`/`y`/`width`/`height` noise and mental id-joins. This + * transform keeps exactly what carries meaning and re-expresses it as + * document structure: spatial grouping becomes heading nesting, canvas + * position becomes reading order (top-to-bottom, left-to-right), and + * edges become an `A → B (label)` list with ids resolved to display + * names. Geometry is dropped entirely — it is presentation, and the + * rendition is a reading surface, not a round-trippable format (callers + * wanting fidelity request the raw source instead). + * + * The canvas-to-document idea has community precedent — e.g. the + * Canvas2Document plugin (https://github.com/slnsys/obsidian-canvas2document) + * converts canvases to long-form documents for humans; this rendition is + * purpose-built for model reading, where token economy and explicit + * relationships matter more than layout. * * Lenient by design: real Obsidian canvases carry extra properties and * occasionally partial entries, so unknown props are ignored and entries