diff --git a/.devin/wiki.json b/.devin/wiki.json index c6f26831..31d484bc 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 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" }, { @@ -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..e0ec46b2 100644 --- a/.env.example +++ b/.env.example @@ -101,6 +101,12 @@ 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 2c0bb897..3c0e32f7 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 / 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,26 +117,29 @@ 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 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-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 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 +167,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 @@ -190,12 +198,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 @@ -213,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`. @@ -240,20 +264,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(…)`, + `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 + `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 @@ -564,6 +592,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..90cac7ff 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](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. + +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 (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) | Tool | Input | Annotation | diff --git a/DOCKERHUB.md b/DOCKERHUB.md index d0da463a..e10730b1 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#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 @@ -58,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 + +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; 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. + ## Tools | Category | Tool | Description | @@ -89,6 +101,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-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 ed5a8650..75d96fd3 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 @@ -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](#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 @@ -212,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 + +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; 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. + ## Tools | Category | Tool | Description | @@ -243,6 +255,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-extension counts | | **Daily Notes** | `vault_get_daily_note` | Today's (or any date's) daily note | ## Prompts diff --git a/assets/social-preview.png b/assets/social-preview.png index 83eae351..18a7a315 100644 Binary files a/assets/social-preview.png and b/assets/social-preview.png differ diff --git a/assets/social-preview.svg b/assets/social-preview.svg index 050e6d60..98150049 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="19" 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..139aa4c8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -9,7 +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 — 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/cli/src/env.ts b/cli/src/env.ts index 02896943..bf52788f 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 exceeding the budget are downscaled/recompressed 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 exceeding the budget are downscaled/recompressed 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..500fe76c 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 exceeding the budget are downscaled/recompressed 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..a16c75f5 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 exceeding the budget are downscaled/recompressed 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/package-lock.json b/package-lock.json index 115d1f95..28cf0fcd 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 c2be9af5..1f68add4 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/server.json b/server.json index f838d3b4..c2e3fcc7 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.", @@ -130,6 +135,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 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/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/__tests__/fs.test.ts b/src/utils/__tests__/fs.test.ts index 0498e73c..2585722d 100644 --- a/src/utils/__tests__/fs.test.ts +++ b/src/utils/__tests__/fs.test.ts @@ -2,7 +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, readdirOrNull, fileExists } from "../fs.js" +import { readFileOrNull, readdirOrNull, fileExists, statOrNull } from "../fs.js" const makeTempDir = async (): Promise => { const dir = await mkdtemp(join(tmpdir(), "utils-fs-test-")) @@ -49,6 +49,30 @@ describe("readdirOrNull", () => { }) }) +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?.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/utils/fit-image-to-byte-budget.ts b/src/utils/fit-image-to-byte-budget.ts new file mode 100644 index 00000000..01215688 --- /dev/null +++ b/src/utils/fit-image-to-byte-budget.ts @@ -0,0 +1,179 @@ +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. + */ + +/** 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 + * 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 = 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) + let attemptCount = 0 + let qualityLadderIndex = 0 + let lastEncodedBytes = params.buffer.length + + while (attemptCount < MAX_ENCODE_ATTEMPTS) { + // 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 (!attemptQuality) break + const { data, info } = await encodeAttempt({ + buffer: params.buffer, + longEdgePx, + quality: attemptQuality, + 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. 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.max( + MIN_LONG_EDGE_PX, + Math.floor(longEdgePx * Math.min(areaScale, 0.7)), + ) + if (nextLongEdgePx >= longEdgePx) 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..00154525 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,17 @@ export const readFileOrNull = async (path: string): Promise => { } } +/** 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/__tests__/config.test.ts b/src/vault-mcp/__tests__/config.test.ts index f46c1a22..3a4968fd 100644 --- a/src/vault-mcp/__tests__/config.test.ts +++ b/src/vault-mcp/__tests__/config.test.ts @@ -300,4 +300,52 @@ 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/, + ) + }) + + 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", () => { + 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/, + ) + }) + + 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 c114dde3..1e97412a 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,32 @@ 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 = 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 = requireNonZeroBytes( + "MAX_IMAGE_OUTPUT_BYTES", + envVar + .from(env) + .get("MAX_IMAGE_OUTPUT_BYTES") + .default("49152") + .asIntPositive(), + ) + return Object.freeze({ memoryEnabled, memoryDir, @@ -120,5 +154,7 @@ export const loadConfig = ( embeddingEnabled, rerankMode, windowsBindMount, + maxAssetBytes, + maxImageOutputBytes, }) } 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__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 0182b435..6aac6409 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,284 @@ 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("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") + 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 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) + 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.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() + 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/__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"), ) }) 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") 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..5ec4bb1d --- /dev/null +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -0,0 +1,220 @@ +/** Asset tool registration — reading and discovering non-markdown vault files. */ + +import { z } from "zod" +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" + +const TOOL_NAMES = { + VAULT_READ_ASSET: "vault_read_asset", + VAULT_LIST_ASSETS: "vault_list_assets", +} as const + +export { TOOL_NAMES as ASSET_TOOL_NAMES } + +/** 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)` +} + +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, 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 — 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. + +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 +- "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. + +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() + .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.', + ), + 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, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ path, raw }, extra) => { + const reqLogger = sessionLogger.child({ + requestId: extra.requestId, + tool: TOOL_NAMES.VAULT_READ_ASSET, + }) + reqLogger.info("tool_call", { path, raw }) + return safeHandlerContent( + reqLogger, + () => + assetOperations.readAssetContent( + { + vaultPath, + path, + raw, + maxAssetBytes: config.maxAssetBytes, + maxImageOutputBytes: config.maxImageOutputBytes, + }, + reqLogger, + ), + (result) => { + if (result.kind === "image") { + reqLogger.info("tool_result", { + 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 [ + { + 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. +- 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 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() + .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() + .int() + .min(1) + .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 listing = await assetOperations.buildAssetListing( + { vaultPath, folder, extensions, limit: limit ?? 50 }, + reqLogger, + ) + return { + assets: listing.assets, + extension_counts: listing.extensionCounts, + total: listing.total, + truncated: listing.truncated, + } + }, + (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..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); !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 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() 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 5f2a8591..3bd4d000 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/obsidian-markdown/__tests__/canvas.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts new file mode 100644 index 00000000..e2071392 --- /dev/null +++ b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts @@ -0,0 +1,313 @@ +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 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 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") + }) + + it("throws on unparseable JSON", () => { + expect(() => linearizeCanvas("{not json")).toThrow( + /^invalid \.canvas JSON: /, + ) + }) +}) 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", () => { diff --git a/src/vault-mcp/obsidian-markdown/canvas.ts b/src/vault-mcp/obsidian-markdown/canvas.ts new file mode 100644 index 00000000..66c3b8d0 --- /dev/null +++ b/src/vault-mcp/obsidian-markdown/canvas.ts @@ -0,0 +1,272 @@ +import { posix } from "node:path" + +/** + * 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 + * 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) => { + 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 + // 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. */ +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. 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)" + } + 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 !== null) + .sort(byReadingOrder) + 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") + 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__/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 }) 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..09f94fa7 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" @@ -21,6 +22,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 +343,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 +457,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 +523,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 +806,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 +1365,51 @@ 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. + // 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 }[] => { + 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") + + // 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 !== null) const noteContents = await Promise.all( markdownFiles.map(async (file) => { @@ -1399,7 +1430,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/__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 }]) + }) +}) diff --git a/src/vault-mcp/vault-operations/asset-operations.ts b/src/vault-mcp/vault-operations/asset-operations.ts new file mode 100644 index 00000000..044b8b97 --- /dev/null +++ b/src/vault-mcp/vault-operations/asset-operations.ts @@ -0,0 +1,229 @@ +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 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"]) + +/** 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. + */ +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)`, + ) +} + +// ── 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, +} diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 2419cf9b..3ce6fd21 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, @@ -13,8 +14,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, + 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" import { parseNote, stringifyNote, @@ -491,24 +500,120 @@ 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, 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, +): 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 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() + } +} + +/** 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 !== null) + logger.info("statted assets", { count: existingEntries.length }) + return existingEntries +} + export const vaultFs = { readNote, readNoteOutline, @@ -519,4 +624,6 @@ export const vaultFs = { deleteNote, listNotes, listAssets, + readAsset, + statAssets, }