diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e1b2cae2a..8773bd7cd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1196,12 +1196,19 @@ Docker hardening, and durability seatbelts above. - **Atomic writes** (`atomicWriteFile` in `vault-filesystem.ts`): write-to-temp-then-rename. The target is never truncated — the Obsidian Sync process sees either old content or new content, never a - 0-byte or partial write. The temp file is named after the note plus - `..tmp`, 41 bytes longer, so a note whose name sits fewer than - 41 bytes under the filesystem's limit reads fine but cannot be saved; the - failed save's error carries a remedy (`coded-error-remedy.ts`) asking for - a name at least 41 bytes shorter. On failure the temp file is removed, by a `catch` in - `atomicWriteFile` and by a `finally` in `atomicWriteFileExclusive`. + 0-byte or partial write. The temp file is `..tmp` in the note's + folder: + - hidden: Obsidian Sync and the file watcher skip dot-prefixed names, + so neither opens it; + - 41 bytes whatever the note's name, so a note named near the + filesystem's limit still saves; + - on the note's filesystem, so the rename (or the hard link of an + exclusive create, below) stays atomic. + + A `catch` in `atomicWriteFile` removes the temp file after a failure. A + `finally` in `atomicWriteFileExclusive` removes it after every call, + because after a link it is a second name for the note. + - **Exclusive atomic creates** (`atomicWriteFileExclusive`): uses `link()` for POSIX no-clobber semantics — fails atomically with `EEXIST` if the target already exists, closing the TOCTOU race on @@ -1246,8 +1253,10 @@ Docker hardening, and durability seatbelts above. source is deleted last — a failure at any step leaves both copies rather than losing content. - **Content-hash gating** (`search-index.ts`): SHA-256 per chunk. Only - changed content re-embeds on both incremental updates and full rebuilds - — a correctness guarantee (not just performance). + changed content re-embeds on both incremental updates and full rebuilds. + Each chunk's hash is saved in the same transaction as its vector, so a + crash can never mark a chunk as embedded while its vector is missing; the + gating is a correctness guarantee, not just a performance one. - **Symlink safety** (`filterValidSymlinks` in `utils/filter-valid-symlinks.ts`): broken symlinks and symlinks to non-file targets are filtered from directory listings before indexing @@ -1321,7 +1330,7 @@ Docker hardening, and durability seatbelts above. `tool-helpers.ts`): wraps every MCP tool handler with try/catch. Errors return a structured `isError` response with the error's name and message, plus one remedy sentence for a raw filesystem or search-index error - (`coded-error-remedy.ts`, keyed on the error's code, syscall and path) or + (`coded-error-remedy.ts`, keyed on the error's code and syscall) or how to fix a properties-block refusal — no stack traces. A buggy tool never crashes the server. The prompts' fallback messages carry the same remedy sentence. diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index 80eb28863..d08d283a9 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -705,8 +705,6 @@ const FOLDER_IS_FILE_REMEDY = "A folder in the path is an existing file; choose a path whose folders are folders." const PATH_TOO_LONG_REMEDY = "The path is too long for the filesystem, so nothing can exist at it; check the path, or shorten it for a new file." -const TEMP_SAVE_REMEDY = - "The name leaves no room for the temporary file a save writes beside it; save it under a name at least 41 bytes shorter (rename an existing note with vault_move_note)." const LINK_LOOP_REMEDY = "The path goes through a symbolic-link loop; ask the vault's owner to fix the link." @@ -803,75 +801,6 @@ describe("filesystem errors name the path vault-relative and carry their remedy" }, ) - // A name of 233 bytes reads, but the temp file a save writes beside it - // adds 41 bytes and goes over the filesystem's 255-byte limit - it.each([ - { name: "vault_replace_in_note", args: { old_text: "line one", new_text: "line 1" } }, - { name: "vault_delete_span", args: { start_anchor: "line one" } }, - { name: "vault_replace_span", args: { start_anchor: "line one", content: "line 1" } }, - { name: "vault_create_task", args: { description: "another task", block_id: "another" } }, - ])("$name on a note whose name is too long for the save's temp file", async ({ name, args }) => { - const nameTooLongForTempFile = `${"b".repeat(230)}.md` - await plantFileWithMode({ filePath: nameTooLongForTempFile, mode: 0o644 }) - - const result = await callTool({ client, name, args: { path: nameTooLongForTempFile, ...args } }) - expect(result).toEqual({ - content: [ - { - type: "text", - // The temp file's name ends in a random UUID - text: expect.stringMatching( - new RegExp( - `^\\[Error\\]: ENAMETOOLONG: name too long, open '${RegExp.escape(nameTooLongForTempFile)}\\.[0-9a-f-]{36}\\.tmp'\\. ${RegExp.escape(TEMP_SAVE_REMEDY)}$`, - ), - ), - }, - ], - isError: true, - }) - // The note opens under its own name and is unchanged, so only the save's - // temp file name was too long - await expect(readFile(join(serverVaultPath, nameTooLongForTempFile), "utf8")).resolves.toBe( - "line one\n- [ ] a task\n", - ) - }) - - const memoryFileContent = "## Notes (newest first)\n- **2026-01-01**: kept\n" - - it.each([ - { name: "vault_update_memory", args: { section: "Notes", entry: "added" } }, - { name: "vault_delete_memory", args: { section: "Notes", date: "2026-01-01", entry: "kept" } }, - ])( - "$name on a memory file whose name is too long for the save's temp file", - async ({ name, args }) => { - const fileTooLongForTempFile = "m".repeat(230) - const memoryFilePath = join(serverVaultPath, "About Me", `${fileTooLongForTempFile}.md`) - await writeFile(memoryFilePath, memoryFileContent) - onTestFinished(() => rm(memoryFilePath)) - - const result = await callTool({ - client, - name, - args: { file: fileTooLongForTempFile, ...args }, - }) - expect(result).toEqual({ - content: [ - { - type: "text", - // The temp file's name ends in a random UUID - text: expect.stringMatching( - new RegExp( - `^\\[Error\\]: ENAMETOOLONG: name too long, open 'About Me/${fileTooLongForTempFile}\\.md\\.[0-9a-f-]{36}\\.tmp'\\. ${RegExp.escape(TEMP_SAVE_REMEDY)}$`, - ), - ), - }, - ], - isError: true, - }) - await expect(readFile(memoryFilePath, "utf8")).resolves.toBe(memoryFileContent) - }, - ) - it.skipIf(runsAsRoot)( "vault_write_note into a read-only folder names the temp file vault-relative", async () => { @@ -886,10 +815,10 @@ describe("filesystem errors name the path vault-relative and carry their remedy" content: [ { type: "text", - // The temp file's name ends in a random UUID + // The temp file's hidden name is a random UUID text: expect.stringMatching( new RegExp( - `^\\[Error\\]: EACCES: permission denied, open 'Sealed/new\\.md\\.[0-9a-f-]{36}\\.tmp'\\. ${RegExp.escape(PERMISSIONS_REMEDY)}$`, + `^\\[Error\\]: EACCES: permission denied, open 'Sealed/\\.[0-9a-f-]{36}\\.tmp'\\. ${RegExp.escape(PERMISSIONS_REMEDY)}$`, ), ), }, @@ -1762,6 +1691,32 @@ describe("memory errors", () => { 'memory file must be a bare name without path separators: "Nested/Preferences"', ) }) + + it.each([ + { name: "vault_get_memory", args: {} }, + { name: "vault_update_memory", args: { section: "Editor settings", entry: "never written" } }, + { + name: "vault_delete_memory", + args: { section: "Editor settings", date: "2026-01-01", entry: "anything" }, + }, + ])("$name with .md in the file name", async ({ name, args }) => { + const result = await callTool({ client, name, args: { file: "Preferences.md", ...args } }) + expectToolError(result, 'memory file must be named without .md: "Preferences.md"') + }) + + it("vault_delete_memory with a date that is not YYYY-MM-DD", async () => { + const result = await callTool({ + client, + name: "vault_delete_memory", + args: { + file: "Preferences", + section: "Editor settings", + date: "2026-1-1", + entry: "anything", + }, + }) + expectToolError(result, "date must be YYYY-MM-DD (e.g. 2026-07-02)") + }) }) // ── Parameter combinations ─────────────────────────────────── diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json index c0e9dbd61..0b9c0cbae 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json @@ -149,7 +149,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", + "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md.\n- \"date must be YYYY-MM-DD\" — pass the entry's date exactly as the file shows it, zero-padded.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", "inputSchema": { "type": "object", "properties": { @@ -366,7 +366,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -528,7 +528,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -973,7 +973,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1588,7 +1588,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", + "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — use a bare file name: no folder or slash, no leading dot, and no .md.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json index 8226d8706..031c78276 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json @@ -151,7 +151,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", + "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md.\n- \"date must be YYYY-MM-DD\" — pass the entry's date exactly as the file shows it, zero-padded.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", "inputSchema": { "type": "object", "properties": { @@ -368,7 +368,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -530,7 +530,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -975,7 +975,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's properties, use vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's properties, use vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1529,7 +1529,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", + "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — use a bare file name: no folder or slash, no leading dot, and no .md.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json index 15ac7b9c8..c85afa09b 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json @@ -151,7 +151,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", + "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md.\n- \"date must be YYYY-MM-DD\" — pass the entry's date exactly as the file shows it, zero-padded.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", "inputSchema": { "type": "object", "properties": { @@ -368,7 +368,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -530,7 +530,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -975,7 +975,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1590,7 +1590,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", + "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — use a bare file name: no folder or slash, no leading dot, and no .md.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json index 4d32af145..026997622 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json @@ -152,7 +152,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", + "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md.\n- \"date must be YYYY-MM-DD\" — pass the entry's date exactly as the file shows it, zero-padded.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", "inputSchema": { "type": "object", "properties": { @@ -369,7 +369,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -489,7 +489,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -934,7 +934,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1504,7 +1504,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", + "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — use a bare file name: no folder or slash, no leading dot, and no .md.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json index 6eb2ff150..00266ec36 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json @@ -151,7 +151,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", + "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md.\n- \"date must be YYYY-MM-DD\" — pass the entry's date exactly as the file shows it, zero-padded.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", "inputSchema": { "type": "object", "properties": { @@ -368,7 +368,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -488,7 +488,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -933,7 +933,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1503,7 +1503,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", + "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — use a bare file name: no folder or slash, no leading dot, and no .md.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json index bded2b274..5cd32631b 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json @@ -833,7 +833,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json index 9560a9cd7..38fd9a022 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json @@ -792,7 +792,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json index 4805c7e2a..5938f2a34 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json @@ -791,7 +791,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json index 20d03e614..034c0c367 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json @@ -832,7 +832,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json index 9f3eb3ad9..c09b15f2a 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json @@ -151,7 +151,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", + "description": "Delete a single dated entry from a memory file in About Me/.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"Tools and workflows (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\nExample: vault_delete_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", date: \"2026-03-10\", entry: \"Gym Tue/Thu at 7am\" }) — prune an expired entry from an entry-policy: living file\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together pick the bullet line within the given section; identical copies make the match ambiguous.\n- section scopes the match — an identical entry under a different heading is not found.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md.\n- \"date must be YYYY-MM-DD\" — pass the entry's date exactly as the file shows it, zero-padded.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section. Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Deleted entry from About Me/.md → ##
\".", "inputSchema": { "type": "object", "properties": { @@ -368,7 +368,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -530,7 +530,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -975,7 +975,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths cannot be moved: About Me/ and the daily notes folder (Daily Notes/ by default; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders keeps a folder that holds any file, even a hidden .DS_Store. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify path with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder (listed under When to use); pick a new_path outside it, or leave a protected note where it is.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry.\n- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources; retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- \"move incomplete: …\" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — \"move aborted: could not rename to …\" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. \"move incomplete: …\" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; nothing was written.\n- \"[SqliteError]: …\" — the search index failed, not the call; the message says what to do; nothing was written.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes and Canvas files that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1590,7 +1590,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do; rename an existing memory file with a too-long name in Obsidian\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", + "description": "Append a dated entry to a section of a memory file in About Me/. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\nExample: vault_update_memory({ file: \"Routines\", section: \"Daily/weekly rhythm (newest first)\", entry: \"Gym Mon/Wed at 6am\", options: { date: \"2026-10-01\", position: \"bottom\" } }) — a backdated entry placed below the existing ones\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note).\n\nParameters:\n- options.date only sets the bullet's date; placement follows options.position, never the date.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — use a bare file name: no folder or slash, no leading dot, and no .md.\n- \"section not created: … is nearly identical to existing section …\" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. \"2025\" vs \"2026\") count as different.\n- \"cannot read memory file …\" — the file can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise its permissions need the vault's owner\n- \"cannot write memory file …: that path is not a file\" — a folder already has this name; choose another file name\n- \"ENAMETOOLONG: …\", \"EACCES: …\" or another filesystem error code — the message says what to do\n- \"cannot create memory folder …\" — a file sits where the memory folder belongs; ask the vault's owner to move it\n- \"memory file …: the note would open with a properties block …\" — saving would turn the file's opening lines into a properties block the server can't keep; the error says how to avoid it\n\nReturns: \"Added entry to About Me/.md → ##
\", plus a note to fill in the placeholder scope callout when the file is new; an exact duplicate returns \"Entry already exists in About Me/.md → ##
— nothing was written.\"", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json index 90a82942b..60b085d26 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json @@ -100,7 +100,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -207,7 +207,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json index 7d21ec2e7..7627a6527 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json @@ -101,7 +101,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -166,7 +166,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json index 785a23595..fca6d9ca8 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json @@ -100,7 +100,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -165,7 +165,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json index 76c40d303..1c95ea3e5 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json @@ -99,7 +99,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: that file's content, frontmatter stripped. With file+section: just that H2 section's entries. No args: every file's content, frontmatter stripped, joined by --- lines — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, their default position). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution.\n\nExample: vault_get_memory({ file: \"Principles\" }) — the whole file\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward.\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" / \"memory file must be named without .md\" — pass the file's bare name: no folder or slash, no leading dot, and no .md\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n- \"cannot read memory file …\" / \"cannot list memory folder …\" — a memory file, or the About Me/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact). An empty match returns { entries: [], total: 0, on_or_after }.", "inputSchema": { "type": "object", "properties": { @@ -206,7 +206,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n- \"cannot list memory folder …\" — a file sits where the memory folder belongs, or its permissions need the vault's owner\n- \"cannot read memory file …\" — one listed file can't be read, which fails the whole listing; retry once, then ask the vault's owner to check that file\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, diff --git a/src/vault-mcp/mcp-core/__tests__/coded-error-remedy.test.ts b/src/vault-mcp/mcp-core/__tests__/coded-error-remedy.test.ts index c459db1f8..1d271e9b2 100644 --- a/src/vault-mcp/mcp-core/__tests__/coded-error-remedy.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/coded-error-remedy.test.ts @@ -1,10 +1,7 @@ import { describe, it, expect } from "vitest" -import { appendCodedErrorRemedy, remedyForCodedError } from "../coded-error-remedy.js" +import { appendRemedy, remedyForCodedError } from "../coded-error-remedy.js" import { IndexIntegrityError } from "../../search/search-helpers.js" -const everyToolServed = (): boolean => true -const noToolServed = (): boolean => false - /** A Node system error as fs produces it: the message, plus the structured * code, syscall and path fields the remedy table keys on. */ const systemError = (fields: { @@ -16,153 +13,120 @@ const systemError = (fields: { return Object.assign(new Error(`${fields.code}: failed, ${fields.syscall}${quotedPath}`), fields) } -const TEMP_SAVE_PATH = "/vault/Notes/a.md.123e4567-e89b-12d3-a456-426614174000.tmp" - const INDEX_REMEDY = "The server's search index could not be read; the call was not at fault. Retry once; if it fails again, tell the server's owner." describe("remedyForCodedError", () => { it.each([ - { - label: "a too-long name on the save's temp file, rename tool served", - error: systemError({ code: "ENAMETOOLONG", syscall: "open", path: TEMP_SAVE_PATH }), - isToolEnabled: everyToolServed, - remedy: - "The name leaves no room for the temporary file a save writes beside it; save it under a name at least 41 bytes shorter (rename an existing note with vault_move_note).", - }, - { - label: "a too-long name on the save's temp file, rename tool not served", - error: systemError({ code: "ENAMETOOLONG", syscall: "open", path: TEMP_SAVE_PATH }), - isToolEnabled: noToolServed, - remedy: - "The name leaves no room for the temporary file a save writes beside it; save it under a name at least 41 bytes shorter (rename an existing note).", - }, { label: "a too-long name on the note itself", error: systemError({ code: "ENAMETOOLONG", syscall: "open", path: "/vault/Notes/a.md" }), - isToolEnabled: everyToolServed, remedy: "The path is too long for the filesystem, so nothing can exist at it; check the path, or shorten it for a new file.", }, { label: "a too-long name with no path on the error", error: systemError({ code: "ENAMETOOLONG", syscall: "open" }), - isToolEnabled: everyToolServed, remedy: "The path is too long for the filesystem, so nothing can exist at it; check the path, or shorten it for a new file.", }, { label: "a folder that is a file, from mkdir (EEXIST)", error: systemError({ code: "EEXIST", syscall: "mkdir", path: "/vault/Projects/alpha.md" }), - isToolEnabled: everyToolServed, remedy: "A folder in the path is an existing file; choose a path whose folders are folders.", }, { label: "a folder that is a file, from mkdir (ENOTDIR)", error: systemError({ code: "ENOTDIR", syscall: "mkdir", path: "/vault/Projects/alpha.md" }), - isToolEnabled: everyToolServed, remedy: "A folder in the path is an existing file; choose a path whose folders are folders.", }, { label: "a folder that changed during a listing (race)", error: systemError({ code: "ENOTDIR", syscall: "scandir", path: "/vault/Projects" }), - isToolEnabled: everyToolServed, remedy: "The folder changed while it was being listed; retry.", }, { label: "a folder in the path that became a file between two steps (race, ENOTDIR)", error: systemError({ code: "ENOTDIR", syscall: "rename", path: "/vault/Projects/a.md" }), - isToolEnabled: everyToolServed, remedy: "The file or folder changed while the call ran; retry.", }, { label: "an EEXIST outside mkdir, which the exclusive save's callers did not catch (race)", error: systemError({ code: "EEXIST", syscall: "link", path: "/vault/Projects/a.md" }), - isToolEnabled: everyToolServed, remedy: "The file or folder changed while the call ran; retry.", }, { label: "permission denied", error: systemError({ code: "EACCES", syscall: "open", path: "/vault/Locked.md" }), - isToolEnabled: everyToolServed, remedy: "Ask the vault's owner to fix the file's or folder's permissions.", }, { label: "operation not permitted", error: systemError({ code: "EPERM", syscall: "unlink", path: "/vault/Locked.md" }), - isToolEnabled: everyToolServed, remedy: "Ask the vault's owner to fix the file's or folder's permissions.", }, { label: "a read-only disk", error: systemError({ code: "EROFS", syscall: "open", path: "/vault/a.md" }), - isToolEnabled: everyToolServed, remedy: "The disk is read-only; ask the vault's owner.", }, { label: "a full disk, with no path on the error", error: systemError({ code: "ENOSPC", syscall: "write" }), - isToolEnabled: everyToolServed, remedy: "The disk is full; ask the vault's owner to free space.", }, { label: "a quota exceeded", error: systemError({ code: "EDQUOT", syscall: "write" }), - isToolEnabled: everyToolServed, remedy: "The disk is full; ask the vault's owner to free space.", }, { label: "a symbolic-link loop", error: systemError({ code: "ELOOP", syscall: "open", path: "/vault/Loop.md" }), - isToolEnabled: everyToolServed, remedy: "The path goes through a symbolic-link loop; ask the vault's owner to fix the link.", }, { label: "a disk I/O failure", error: systemError({ code: "EIO", syscall: "read" }), - isToolEnabled: everyToolServed, remedy: "The disk failed to read or write; ask the vault's owner.", }, { label: "too many open files", error: systemError({ code: "EMFILE", syscall: "open", path: "/vault/a.md" }), - isToolEnabled: everyToolServed, remedy: "The server is busy; retry.", }, { label: "too many open files system-wide", error: systemError({ code: "ENFILE", syscall: "open", path: "/vault/a.md" }), - isToolEnabled: everyToolServed, remedy: "The server is busy; retry.", }, { label: "a resource temporarily unavailable", error: systemError({ code: "EAGAIN", syscall: "read" }), - isToolEnabled: everyToolServed, remedy: "The server is busy; retry.", }, { label: "a busy file", error: systemError({ code: "EBUSY", syscall: "rename", path: "/vault/a.md" }), - isToolEnabled: everyToolServed, remedy: "The server is busy; retry.", }, { label: "a file that vanished between two steps (race)", - error: systemError({ code: "ENOENT", syscall: "rename", path: TEMP_SAVE_PATH }), - isToolEnabled: everyToolServed, + error: systemError({ + code: "ENOENT", + syscall: "rename", + path: "/vault/Notes/.123e4567-e89b-12d3-a456-426614174000.tmp", + }), remedy: "The file or folder changed while the call ran; retry.", }, { label: "a path that became a folder between two steps (race)", error: systemError({ code: "EISDIR", syscall: "open", path: "/vault/a.md" }), - isToolEnabled: everyToolServed, remedy: "The file or folder changed while the call ran; retry.", }, { label: "a system error with a code the table does not know", error: systemError({ code: "EXDEV", syscall: "rename", path: "/vault/a.md" }), - isToolEnabled: everyToolServed, remedy: "Report the error to the vault's owner.", }, { @@ -171,17 +135,15 @@ describe("remedyForCodedError", () => { name: "SqliteError", code: "SQLITE_ERROR", }), - isToolEnabled: everyToolServed, remedy: INDEX_REMEDY, }, { label: "a damaged index row", error: new IndexIntegrityError("expected string[] from JSON column, got: {}"), - isToolEnabled: everyToolServed, remedy: INDEX_REMEDY, }, - ])("names the remedy for $label", ({ error, isToolEnabled, remedy }) => { - expect(remedyForCodedError({ error, isToolEnabled })).toBe(remedy) + ])("names the remedy for $label", ({ error, remedy }) => { + expect(remedyForCodedError(error)).toBe(remedy) }) it.each([ @@ -201,19 +163,16 @@ describe("remedyForCodedError", () => { error: "boom", }, ])("returns undefined for $label", ({ error }) => { - expect(remedyForCodedError({ error, isToolEnabled: everyToolServed })).toBeUndefined() + expect(remedyForCodedError(error)).toBeUndefined() }) }) -describe("appendCodedErrorRemedy", () => { +describe("appendRemedy", () => { it("appends the remedy after a period and a space when the message ends in a quoted path", () => { - const error = systemError({ code: "EACCES", syscall: "open", path: "/vault/Locked.md" }) - expect( - appendCodedErrorRemedy({ + appendRemedy({ message: "[Error]: EACCES: permission denied, open 'Locked.md'", - error, - isToolEnabled: everyToolServed, + remedy: "Ask the vault's owner to fix the file's or folder's permissions.", }), ).toBe( "[Error]: EACCES: permission denied, open 'Locked.md'. Ask the vault's owner to fix the file's or folder's permissions.", @@ -221,24 +180,17 @@ describe("appendCodedErrorRemedy", () => { }) it("appends the remedy after one space when the message already ends with a period", () => { - const error = systemError({ code: "EACCES", syscall: "open", path: "/vault/Locked.md" }) - expect( - appendCodedErrorRemedy({ + appendRemedy({ message: "Nothing was written.", - error, - isToolEnabled: everyToolServed, + remedy: "Ask the vault's owner to fix the file's or folder's permissions.", }), ).toBe("Nothing was written. Ask the vault's owner to fix the file's or folder's permissions.") }) - it("returns the message unchanged for an error the table does not know", () => { - expect( - appendCodedErrorRemedy({ - message: "[Error]: note not found", - error: new Error("note not found"), - isToolEnabled: everyToolServed, - }), - ).toBe("[Error]: note not found") + it("returns the message unchanged when there is no remedy", () => { + expect(appendRemedy({ message: "[Error]: note not found", remedy: undefined })).toBe( + "[Error]: note not found", + ) }) }) 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 2a4714674..efbd49469 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -3207,15 +3207,13 @@ describe("DISABLED_TOOLS", () => { it.each([ { toolName: TOOL_NAMES.VAULT_UPDATE_MEMORY }, { toolName: TOOL_NAMES.VAULT_DELETE_MEMORY }, - ])("$toolName's filesystem Errors entry sends a rename to Obsidian", ({ toolName }) => { + ])("$toolName carries the shared filesystem Errors entry", ({ toolName }) => { const filesystemEntry = findDescriptionLine({ registeredCalls: registerWithConfig({}), toolName, linePrefix: '- "ENAMETOOLONG: …", "EACCES: …"', }) - expect(filesystemEntry).toBe( - `${SHARED_FILESYSTEM_ENTRY}; rename an existing memory file with a too-long name in Obsidian`, - ) + expect(filesystemEntry).toBe(SHARED_FILESYSTEM_ENTRY) }) it("vault_update_memory lists the memory folder's own failure with the owner's remedy", () => { diff --git a/src/vault-mcp/mcp-core/coded-error-remedy.ts b/src/vault-mcp/mcp-core/coded-error-remedy.ts index 80b3a4e00..0ebb77331 100644 --- a/src/vault-mcp/mcp-core/coded-error-remedy.ts +++ b/src/vault-mcp/mcp-core/coded-error-remedy.ts @@ -5,8 +5,6 @@ import { isErrnoException } from "../../utils/is-errno-exception.js" import { IndexIntegrityError } from "../search/search-helpers.js" -import { TEMP_SAVE_SUFFIX_BYTES, isTempSavePath } from "../vault-operations/vault-filesystem.js" -import type { ToolName } from "./tool-registry.js" const INDEX_REMEDY = "The server's search index could not be read; the call was not at fault. Retry once; if it fails again, tell the server's owner." @@ -21,28 +19,17 @@ const isSystemError = (error: NodeJS.ErrnoException): error is SystemError => { return typeof error.syscall === "string" } -const describeTempSaveRemedy = (isToolEnabled: (name: ToolName) => boolean): string => { - const renameToolClause = isToolEnabled("vault_move_note") ? " with vault_move_note" : "" - - return `The name leaves no room for the temporary file a save writes beside it; save it under a name at least ${TEMP_SAVE_SUFFIX_BYTES} bytes shorter (rename an existing note${renameToolClause}).` -} - -const describeSystemErrorRemedy = ( - error: SystemError, - isToolEnabled: (name: ToolName) => boolean, -): string => { +const describeSystemErrorRemedy = (error: SystemError): string => { switch (error.code) { - // The temp file is always the failing path: a save writes it first, and - // the rename or link that follows targets the shorter final name - case "ENAMETOOLONG": { - if (error.path && isTempSavePath(error.path)) return describeTempSaveRemedy(isToolEnabled) + case "ENAMETOOLONG": return "The path is too long for the filesystem, so nothing can exist at it; check the path, or shorten it for a new file." - } case "ENOTDIR": case "EEXIST": { if (error.syscall === "mkdir") { return "A folder in the path is an existing file; choose a path whose folders are folders." } + // readdirOrNull (utils/fs.ts) returns null when the listed path is not + // a folder, so a raw scandir failure means it changed mid-listing if (error.syscall === "scandir") { return "The folder changed while it was being listed; retry." } @@ -50,7 +37,8 @@ const describeSystemErrorRemedy = ( // two steps of the call: // - reads treat ENOTDIR as no file (utils/fs.ts); // - both exclusive creates catch their EEXIST: note-mover.ts around - // atomicWriteFileExclusive, and claimTrashTarget. + // atomicWriteFileExclusive, and claimTrashTarget in + // vault-filesystem.ts. return CHANGED_DURING_CALL_REMEDY } case "EACCES": @@ -70,13 +58,14 @@ const describeSystemErrorRemedy = ( case "EAGAIN": case "EBUSY": return "The server is busy; retry." - // The helpers in utils/fs.ts absorb the ordinary cases of these, so a raw - // one means the file changed between two steps of the call + // Reads absorb the ordinary cases of these (utils/fs.ts), and each write + // creates its folder and checks or reads its target first, so a raw one + // means the file changed between two steps of the call case "ENOENT": case "EISDIR": return CHANGED_DURING_CALL_REMEDY - // A code outside the table (EXDEV, ESTALE) comes from the host's disks or - // mounts, which only the owner can change + // A code no case above names (EXDEV, ESTALE) comes from the host's disks + // or mounts, which only the owner can change default: return "Report the error to the vault's owner." } @@ -85,37 +74,24 @@ const describeSystemErrorRemedy = ( /** The remedy sentence for a raw filesystem error (a Node error with a * syscall) or a search-index failure (a SqliteError or an * IndexIntegrityError); undefined for any other error, whose own text stands. */ -export const remedyForCodedError = (params: { - error: unknown - isToolEnabled: (name: ToolName) => boolean -}): string | undefined => { - const { error, isToolEnabled } = params - +export const remedyForCodedError = (error: unknown): string | undefined => { if (error instanceof IndexIntegrityError) return INDEX_REMEDY if (!isErrnoException(error)) return undefined // isErrnoException admits any Error with a string code, so better-sqlite3's // SqliteError (a SQLITE_* code and no syscall) passes it if (error.code.startsWith("SQLITE_")) return INDEX_REMEDY if (!isSystemError(error)) return undefined - return describeSystemErrorRemedy(error, isToolEnabled) + return describeSystemErrorRemedy(error) } /** A message followed by its remedy, or the message alone when there is none. * A message that already ends with a period gets the remedy after a space (a * move abort's properties-block message ends with "Nothing was written."); - * Node's messages end with a quoted path, so they get ". ". Every client text - * that carries a remedy joins it here, so the two cannot disagree. */ + * Node's messages end with a quoted path, so they get ". ". Tool errors and + * prompt fallbacks both join their remedy here, so the two cannot disagree. */ export const appendRemedy = (params: { message: string; remedy: string | undefined }): string => { if (!params.remedy) return params.message const separator = params.message.endsWith(".") ? " " : ". " return `${params.message}${separator}${params.remedy}` } - -/** The client text for an error: its message followed by the remedy when the - * error has one. */ -export const appendCodedErrorRemedy = (params: { - message: string - error: unknown - isToolEnabled: (name: ToolName) => boolean -}): string => appendRemedy({ message: params.message, remedy: remedyForCodedError(params) }) diff --git a/src/vault-mcp/mcp-core/prompts/__tests__/prompt-helpers.test.ts b/src/vault-mcp/mcp-core/prompts/__tests__/prompt-helpers.test.ts index 7fbf01dff..e8b3fac6b 100644 --- a/src/vault-mcp/mcp-core/prompts/__tests__/prompt-helpers.test.ts +++ b/src/vault-mcp/mcp-core/prompts/__tests__/prompt-helpers.test.ts @@ -168,51 +168,22 @@ describe("wrapWithDataMarkers", () => { }) describe("describeErrorForPromptFallback", () => { - const everyToolServed = (): boolean => true - const noToolServed = (): boolean => false - it("makes a filesystem error's path vault-relative and appends its remedy without the closing period", () => { const error = Object.assign( new Error("EACCES: permission denied, open '/srv/vault/Projects/plan.md'"), { code: "EACCES", syscall: "open", path: "/srv/vault/Projects/plan.md" }, ) - expect( - describeErrorForPromptFallback({ - error, - vaultPath: "/srv/vault", - isToolEnabled: everyToolServed, - }), - ).toBe( + expect(describeErrorForPromptFallback({ error, vaultPath: "/srv/vault" })).toBe( "[Error]: EACCES: permission denied, open 'Projects/plan.md'. Ask the vault's owner to fix the file's or folder's permissions", ) }) - it("passes the enabled tool set to the remedy, so an unserved tool goes unnamed", () => { - const tempSavePath = "/srv/vault/a.md.123e4567-e89b-12d3-a456-426614174000.tmp" - const error = Object.assign(new Error(`ENAMETOOLONG: name too long, open '${tempSavePath}'`), { - code: "ENAMETOOLONG", - syscall: "open", - path: tempSavePath, - }) - - expect( - describeErrorForPromptFallback({ - error, - vaultPath: "/srv/vault", - isToolEnabled: noToolServed, - }), - ).toBe( - "[Error]: ENAMETOOLONG: name too long, open 'a.md.123e4567-e89b-12d3-a456-426614174000.tmp'. The name leaves no room for the temporary file a save writes beside it; save it under a name at least 41 bytes shorter (rename an existing note)", - ) - }) - it("drops the closing period of an error that has no remedy", () => { expect( describeErrorForPromptFallback({ error: new Error("daily notes config is not valid JSON."), vaultPath: "/srv/vault", - isToolEnabled: everyToolServed, }), ).toBe("[Error]: daily notes config is not valid JSON") }) @@ -222,7 +193,6 @@ describe("describeErrorForPromptFallback", () => { describeErrorForPromptFallback({ error: new Error("note not found: Projects/plan.md"), vaultPath: "/srv/vault", - isToolEnabled: everyToolServed, }), ).toBe("[Error]: note not found: Projects/plan.md") }) diff --git a/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts b/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts index 11c62999b..a3534ddf6 100644 --- a/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts @@ -441,7 +441,7 @@ export const registerDailyReviewPrompt = ({ // the exception: it passes those checks, but the task filter refuses // the day after it reqLogger.error("prompt_error", describeErrorForLog(error)) - const clientMessage = describeErrorForPromptFallback({ error, vaultPath, isToolEnabled }) + const clientMessage = describeErrorForPromptFallback({ error, vaultPath }) const dailyFallbackHint = whenToolEnabledText( "vault_get_daily_note", " Try vault_get_daily_note to fetch the note directly.", diff --git a/src/vault-mcp/mcp-core/prompts/memory-review-prompt.ts b/src/vault-mcp/mcp-core/prompts/memory-review-prompt.ts index 936fb9985..78a649612 100644 --- a/src/vault-mcp/mcp-core/prompts/memory-review-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/memory-review-prompt.ts @@ -228,7 +228,7 @@ export const registerMemoryReviewPrompt = ({ // An unknown file returns earlier in the try, so a failure caught here // is the server's own and logs at error, not warn reqLogger.error("prompt_error", describeErrorForLog(error)) - const clientMessage = describeErrorForPromptFallback({ error, vaultPath, isToolEnabled }) + const clientMessage = describeErrorForPromptFallback({ error, vaultPath }) const fallbackTools = formatEnabledToolList(["vault_list_memory_files", "vault_get_memory"]) const fallbackHint = fallbackTools ? ` Try ${fallbackTools} to inspect the ${config.memoryDir}/ layer directly.` diff --git a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts index 11988aac8..f73adf1d6 100644 --- a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts +++ b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts @@ -9,7 +9,7 @@ import type { Logger } from "../../../logger.js" import type { ToolAvailability } from "../tool-availability.js" import { truncateToCodePointLimit } from "../../../utils/truncate-to-code-point-limit.js" import { describeErrorRelativeTo } from "../../../utils/describe-error.js" -import { appendCodedErrorRemedy } from "../coded-error-remedy.js" +import { appendRemedy, remedyForCodedError } from "../coded-error-remedy.js" export type PromptRegistrationContext = ToolAvailability & { server: McpServer @@ -53,13 +53,14 @@ export const textResult = (text: string): GetPromptResult => ({ export const describeErrorForPromptFallback = (params: { error: unknown vaultPath: string - isToolEnabled: ToolAvailability["isToolEnabled"] }): string => { - const { error, vaultPath, isToolEnabled } = params - const errorText = appendCodedErrorRemedy({ + const { error, vaultPath } = params + // Prompts write nothing and read through paths that never refuse a + // properties block, so only coded remedies apply, never the tool boundary's + // repair steps + const errorText = appendRemedy({ message: describeErrorRelativeTo({ error, directory: vaultPath }), - error, - isToolEnabled, + remedy: remedyForCodedError(error), }) return errorText.endsWith(".") ? errorText.slice(0, -1) : errorText @@ -72,7 +73,10 @@ export const exceedsCharCap = (text: string, maxChars: number | undefined): bool // within the cap settles it without walking the text. if (!maxChars || text.length <= maxChars) return false - return !text[Symbol.iterator]().drop(maxChars).next().done + // A string's iterator yields code points, not UTF-16 units + const codePointsPastCap = text[Symbol.iterator]().drop(maxChars) + + return !codePointsPastCap.next().done } /** Opt-in safety cap for live content embedded in a prompt. When the caller @@ -84,6 +88,8 @@ export const capContent = ( maxChars: number | undefined, toolName: string | undefined, ): string => { + // exceedsCharCap also checks !maxChars, but only this check narrows + // maxChars to a number for the truncation below if (!maxChars || !exceedsCharCap(text, maxChars)) return text const keptText = truncateToCodePointLimit(text, maxChars) 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 3ca0a2d0d..14c5b3dc0 100644 --- a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts @@ -342,7 +342,7 @@ export const registerVaultOrientationPrompt = ({ // The prompt takes no arguments, so a failure caught here is the // server's own and logs at error, not warn reqLogger.error("prompt_error", describeErrorForLog(error)) - const clientMessage = describeErrorForPromptFallback({ error, vaultPath, isToolEnabled }) + const clientMessage = describeErrorForPromptFallback({ error, vaultPath }) const fallbackTools = formatEnabledToolList([ "vault_list_tags", "vault_list_property_keys", diff --git a/src/vault-mcp/mcp-core/tools/memory-tools.ts b/src/vault-mcp/mcp-core/tools/memory-tools.ts index 99c21ac43..2839e06f7 100644 --- a/src/vault-mcp/mcp-core/tools/memory-tools.ts +++ b/src/vault-mcp/mcp-core/tools/memory-tools.ts @@ -11,14 +11,9 @@ import { toolInputErrorResult, } from "./tool-helpers.js" -/** The memory tools' filesystem-error entry: the shared one plus where an - * existing file gets a shorter name. The remedy the tool boundary appends to - * a too-long name (coded-error-remedy.ts) renames with vault_move_note, and - * the memory folder is protected from moves by default. */ -const MEMORY_FILESYSTEM_ERROR_ENTRY = `${FILESYSTEM_ERROR_ENTRY}; rename an existing memory file with a too-long name in Obsidian` - -/** The memory store names a folder standing where the file would go, since a - * folder in the memory folder is the vault owner's. */ +/** The memory store names a folder standing where the file would go. That + * folder is the vault owner's, so the caller picks another name instead of + * removing it. */ const MEMORY_FILE_IS_FOLDER_ERROR_ENTRY = '- "cannot write memory file …: that path is not a file" — a folder already has this name; choose another file name' @@ -94,7 +89,7 @@ Prefer vault_read_note for reading non-memory notes. Errors: - "section requires a file" / "on_or_after requires a file" — section or on_or_after was passed without file; add file - "memory file not found" — file does not exist in ${config.memoryDir}/; call vault_list_memory_files to discover valid names -- "memory file must not start with a dot" / "memory file must be a bare name without path separators" — pass the file's bare name: no folder or slash, and no leading dot +- "memory file must not start with a dot" / "memory file must be a bare name without path separators" / "memory file must be named without .md" — pass the file's bare name: no folder or slash, no leading dot, and no .md - "section not found: …" — no H2 heading matches; the error lists the file's available sections - "date must be a real ISO calendar date" — on_or_after must be a valid YYYY-MM-DD date - "cannot read memory file …" / "cannot list memory folder …" — a memory file, or the ${config.memoryDir}/ folder itself on a call without file, can't be read: check file against vault_list_memory_files (a name longer than the filesystem allows fails here); otherwise ask the vault's owner to check the folder @@ -186,7 +181,7 @@ Example: vault_list_memory_files() returns [{ file: "Principles", title: "Princi When to use: ${discoveryPurpose} Always call this first to get valid file and section names${whenToolEnabledText("vault_delete_memory", ", and to check a file's entry_policy before pruning entries")}. -Behavior: Lists every .md file directly inside ${config.memoryDir}/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped. +Behavior: Lists every .md file directly inside ${config.memoryDir}/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped. Errors: - An empty or nonexistent memory folder returns an empty array, not an error. @@ -320,11 +315,11 @@ Errors: - "section must be a single line" — section names become H2 headings; remove line breaks. - "date must be a real ISO calendar date" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. "2026-07-02"), not a timestamp. - "entry contains a control character" / "section contains a control character" — the value includes a non-printable control byte; remove it before writing. -- "memory file must not start with a dot" / "memory file must be a bare name without path separators" — use a bare file name: no folder or slash, and no leading dot. +- "memory file must not start with a dot" / "memory file must be a bare name without path separators" / "memory file must be named without .md" — use a bare file name: no folder or slash, no leading dot, and no .md. - "section not created: … is nearly identical to existing section …" — the name differs from an existing heading only by an HTML-entity slip, a typo, or spacing; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Names differing only in digits (e.g. "2025" vs "2026") count as different. ${MEMORY_FILE_READ_ERROR_ENTRY} ${MEMORY_FILE_IS_FOLDER_ERROR_ENTRY} -${MEMORY_FILESYSTEM_ERROR_ENTRY} +${FILESYSTEM_ERROR_ENTRY} ${MEMORY_FOLDER_ERROR_ENTRY} ${MEMORY_OPENING_BLOCK_ERROR_ENTRY} @@ -423,14 +418,14 @@ Parameters: Errors: - "memory file not found" — file does not exist in ${config.memoryDir}/; call vault_list_memory_files to discover valid names. -- "memory file must not start with a dot" / "memory file must be a bare name without path separators" — pass the file's bare name: no folder or slash, and no leading dot. -- "date must be a real ISO calendar date" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with ${whenToolEnabledText("vault_delete_span", "vault_delete_span or ")}a manual edit. +- "memory file must not start with a dot" / "memory file must be a bare name without path separators" / "memory file must be named without .md" — pass the file's bare name: no folder or slash, no leading dot, and no .md. +- "date must be YYYY-MM-DD" — pass the entry's date exactly as the file shows it, zero-padded. - "section not found: …" — no H2 heading matches; the error lists the file's available sections - "no entry matching …" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section). - "ambiguous: N entries match …" — more than one identical bullet exists in the section. Remove the extra copy with ${whenToolEnabledText("vault_delete_span", "vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or ")}a manual edit, then retry. - "refusing memory write: … would shrink content" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs ${whenToolEnabledText("vault_delete_span", "vault_delete_span or ")}a manual edit. ${MEMORY_FILE_READ_ERROR_ENTRY} -${MEMORY_FILESYSTEM_ERROR_ENTRY} +${FILESYSTEM_ERROR_ENTRY} ${MEMORY_OPENING_BLOCK_ERROR_ENTRY} Returns: "Deleted entry from ${config.memoryDir}/.md → ##
".`, diff --git a/src/vault-mcp/mcp-core/tools/tool-helpers.ts b/src/vault-mcp/mcp-core/tools/tool-helpers.ts index a65132964..2010f8615 100644 --- a/src/vault-mcp/mcp-core/tools/tool-helpers.ts +++ b/src/vault-mcp/mcp-core/tools/tool-helpers.ts @@ -302,7 +302,7 @@ export const createSafeHandlers = (params: { if (error instanceof UnsupportedPropertiesBlockError) { return describePropertiesBlockRepair({ kind: error.kind, isToolEnabled }) } - return remedyForCodedError({ error, isToolEnabled }) + return remedyForCodedError(error) } /** The error's text with a Node error's paths made vault-relative, then its 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 0663bd814..0a35696bb 100644 --- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts +++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts @@ -1236,7 +1236,7 @@ Errors: - "move incomplete: …" — an ordinary move failed partway (rare: a permission or disk error); no data is lost, and the error names what failed and the resulting state. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish. - A case-only rename that fails partway — "move aborted: could not rename to …" means nothing was written; retry the move, and if it fails again ask the vault's owner to fix the folder's permissions. "move incomplete: …" means the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move. - "move aborted: could not read …" / "move aborted: could not rewrite …" — the note, or a note linking to it, could not be read, or could not be saved with its links rewritten (the error says why and how to fix it); nothing was written; fix that note, then retry. -- "move aborted: could not write to …" — new_path could not be created; nothing was written. A name fewer than 41 bytes under the filesystem's length limit fails here; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it. +- "move aborted: could not write to …" — new_path could not be created; nothing was written. The folder or disk can't be written; ask the vault's owner to fix it. ${FILESYSTEM_ERROR_ENTRY}; nothing was written. ${INDEX_ENGINE_ERROR_ENTRY}; nothing was written. diff --git a/src/vault-mcp/obsidian-markdown/__tests__/memory-entries.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/memory-entries.test.ts index c61eb0330..bfd1daff1 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/memory-entries.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/memory-entries.test.ts @@ -1,5 +1,61 @@ import { describe, it, expect } from "vitest" -import { parseMemoryEntries } from "../memory-entries.js" +import { + isDoubledMarkdownFileName, + isMemoryEntryLine, + memoryEntryLineDate, + parseMemoryEntries, +} from "../memory-entries.js" + +describe("isDoubledMarkdownFileName", () => { + it.each([ + { label: "a name ending in .md twice", fileName: "Opinions.md.md", doubled: true }, + { label: "an uppercase first extension", fileName: "Opinions.MD.md", doubled: true }, + { label: "a path ending in .md twice", fileName: "About Me/Opinions.md.md", doubled: true }, + { label: "a name ending in .md once", fileName: "Opinions.md", doubled: false }, + { label: "a name with .md inside it", fileName: "Notes.md backup.md", doubled: false }, + ])("returns $doubled for $label", ({ fileName, doubled }) => { + expect(isDoubledMarkdownFileName(fileName)).toBe(doubled) + }) +}) + +describe("isMemoryEntryLine", () => { + it.each([ + { label: "a dated bullet", line: "- **2026-07-02**: an entry", opensEntry: true }, + { + label: "an impossible date of the right shape", + line: "- **2026-02-30**: typed by hand", + opensEntry: true, + }, + { + label: "a date that is not zero-padded", + line: "- **2026-7-2**: an entry", + opensEntry: false, + }, + { label: "a bullet with no bold date", line: "- 2026-07-02: an entry", opensEntry: false }, + { + label: "a bold date with no colon after it", + line: "- **2026-07-02** an entry", + opensEntry: false, + }, + { label: "an indented bullet", line: " - **2026-07-02**: an entry", opensEntry: false }, + ])("returns $opensEntry for $label", ({ line, opensEntry }) => { + expect(isMemoryEntryLine(line)).toBe(opensEntry) + }) +}) + +describe("memoryEntryLineDate", () => { + it.each([ + { label: "a dated bullet", line: "- **2026-07-02**: an entry", date: "2026-07-02" }, + { + label: "entry text that opens with a bold lead", + line: "- **2026-07-02**: **Tip**: rest", + date: "2026-07-02", + }, + { label: "a line that opens no entry", line: "- 2026-07-02: an entry", date: undefined }, + ])("returns $date for $label", ({ line, date }) => { + expect(memoryEntryLineDate(line)).toBe(date) + }) +}) describe("parseMemoryEntries", () => { it("parses a single dated entry with exact section, date, text, and index", () => { diff --git a/src/vault-mcp/obsidian-markdown/memory-entries.ts b/src/vault-mcp/obsidian-markdown/memory-entries.ts index ed1fbb1c9..1cb387e43 100644 --- a/src/vault-mcp/obsidian-markdown/memory-entries.ts +++ b/src/vault-mcp/obsidian-markdown/memory-entries.ts @@ -37,10 +37,27 @@ export type MemoryEntry = Readonly<{ /** Matches the first line of a dated memory entry (`- **YYYY-MM-DD**: …`) and * captures the date. The bold date is the reliable anchor — entry text after - * the colon may contain its own `**bold**`. Same shape as memory-store's - * ENTRY_PATTERN, with a capture group added. */ + * the colon may contain its own `**bold**`. */ const ENTRY_START_PATTERN = /^- \*\*(\d{4}-\d{2}-\d{2})\*\*:/ +/** The date of the memory entry the line opens, as written, or undefined + * when the line opens no entry. The date is checked for shape only. */ +export const memoryEntryLineDate = (line: string): string | undefined => { + return ENTRY_START_PATTERN.exec(line)?.[1] +} + +/** True when the line opens a dated memory entry, the same test the parser + * uses, so the memory store's writes agree with it on what an entry is. */ +export const isMemoryEntryLine = (line: string): boolean => Boolean(memoryEntryLineDate(line)) + +/** True for a file name that ends in .md twice, in any letter case, such as + * "Opinions.md.md". The memory tools add .md to a bare name and refuse a name + * that already ends in .md, so no memory tool can reach such a file, and the + * memory listings and recall leave it out instead of offering its name. */ +export const isDoubledMarkdownFileName = (fileName: string): boolean => { + return fileName.toLowerCase().endsWith(".md.md") +} + /** Matches any ATX heading line (H1–H6) per CommonMark §4.2: 0-3 leading * spaces, hashes, then a space/tab separator or end of line (empty headings * are valid). Inside an H2 span this can only be a deeper heading (H3+) — @@ -125,8 +142,7 @@ export const parseMemoryEntries = (lines: readonly string[]): MemoryEntry[] => { continue } - const entryStartMatch = ENTRY_START_PATTERN.exec(line) - const entryDate = entryStartMatch?.[1] + const entryDate = memoryEntryLineDate(line) if (entryDate) { if (openEntry !== null) { diff --git a/src/vault-mcp/search/__tests__/file-watcher.test.ts b/src/vault-mcp/search/__tests__/file-watcher.test.ts index e83f5d1b3..787575aae 100644 --- a/src/vault-mcp/search/__tests__/file-watcher.test.ts +++ b/src/vault-mcp/search/__tests__/file-watcher.test.ts @@ -23,6 +23,7 @@ import { extractPdfText } from "../../obsidian-markdown/pdf.js" import { readdirOrNull, statOrNull } from "../../../utils/fs.js" import type { SearchIndex } from "../search-index.js" import { watchVaultAndBuildIndex } from "../file-watcher.js" +import { atomicWriteFile } from "../../vault-operations/vault-filesystem.js" import { logger } from "../../../logger.js" // Auto-spy chokidar: spy: true keeps the real implementation, so the @@ -111,6 +112,28 @@ describe("file-watcher", REAL_WATCHER_RETRY, () => { expect(results.map((result) => result.path)).toEqual(["test.md"]) }) + // The server saves through a hidden temp file, which the watcher ignores, + // so only the rename onto the note can report it + it( + "indexes a note the server saves through its hidden temp file", + { timeout: 15000 }, + async () => { + await startWatcherWithoutRebuild( + { vaultPath: vault, search: index }, + { + stabilityThreshold: 200, + pollInterval: 50, + }, + ) + + await atomicWriteFile({ filePath: join(vault, "saved.md"), content: "savedopal\n" }, logger) + + await waitFor(() => index.fullTextSearch({ query: "savedopal" }, logger).length > 0) + const results = index.fullTextSearch({ query: "savedopal" }, logger) + expect(results.map((result) => result.path)).toEqual(["saved.md"]) + }, + ) + it("re-indexes a modified file", { timeout: 15000 }, async () => { await startWatcherWithoutRebuild( { vaultPath: vault, search: index }, diff --git a/src/vault-mcp/search/__tests__/memory-index.test.ts b/src/vault-mcp/search/__tests__/memory-index.test.ts index 9a9c998af..4471e7e8c 100644 --- a/src/vault-mcp/search/__tests__/memory-index.test.ts +++ b/src/vault-mcp/search/__tests__/memory-index.test.ts @@ -149,6 +149,24 @@ describe("memory entry indexing", () => { expect(selectEntryRows(inspect)).toEqual([]) }) + // No memory tool takes the name "Opinions.md", so recall must not offer it + it("does not index entries for a memory file whose name ends in .md twice", async () => { + const { index, inspect } = await createInspectableMemoryIndex() + index.upsertNote( + { + filePath: "About Me/Opinions.md.md", + rawContent: OPINIONS_V1, + fileStat: testStat(1000), + }, + logger, + ) + // The note itself was indexed (the trigger ran) — only the entry + // extraction leaves it out. + const noteResults = index.fullTextSearch({ query: "immutable", filters: {} }, logger) + expect(noteResults.map((result) => result.path)).toEqual(["About Me/Opinions.md.md"]) + expect(selectEntryRows(inspect)).toEqual([]) + }) + it("does not index entries for a note nested below the memory dir", async () => { const { index, inspect } = await createInspectableMemoryIndex() index.upsertNote( @@ -685,6 +703,44 @@ title: Agents expect(countVectors(inspect)).toBe(3) }) + // An index built before such names were left out holds their entries under + // file "Opinions.md"; the rebuild is what stops recall offering that name + it("removes entries an earlier index held for a memory file whose name ends in .md twice", async () => { + const { dir, memoryDirPath, index, inspect } = await createRebuiltVault() + await writeFile(join(memoryDirPath, "Opinions.md.md"), OPINIONS_V1) + const firstBuild = await index.rebuildFromVault({ vaultPath: dir }, logger) + await firstBuild.embedding + const writer = new Database(join(dir, "index.db")) + onTestFinished(() => { + writer.close() + }) + writer + .prepare( + `INSERT INTO memory_entries (file, section, entry_date, entry_text, entry_index, content_hash) + VALUES ('Opinions.md', 'Process (newest first)', '2026-06-25', + '- **2026-06-25**: Sequential over parallel review.', 0, 'stale')`, + ) + .run() + // The stale row is in place, so the rebuild below has something to remove + expect(selectEntryRows(inspect).map((row) => row.file)).toEqual([ + "Agents", + "Opinions", + "Opinions", + "Opinions", + "Opinions.md", + ]) + + const secondBuild = await index.rebuildFromVault({ vaultPath: dir }, logger) + await secondBuild.embedding + + expect(selectEntryRows(inspect).map((row) => row.file)).toEqual([ + "Agents", + "Opinions", + "Opinions", + "Opinions", + ]) + }) + it("re-indexes a memory note's entries from its body when its block is not valid YAML", async () => { const { dir, memoryDirPath, index, inspect } = await createRebuiltVault() const firstBuild = await index.rebuildFromVault({ vaultPath: dir }, logger) diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index 79a609972..555c90b9a 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -12,7 +12,11 @@ import { linearizeCanvas, extractCanvasFileLinks } from "../obsidian-markdown/ca import { links } from "../obsidian-markdown/links.js" import { splitIntoLines } from "../obsidian-markdown/lines.js" import { parseHeadings } from "../obsidian-markdown/headings.js" -import { parseMemoryEntries, type MemoryEntry } from "../obsidian-markdown/memory-entries.js" +import { + isDoubledMarkdownFileName, + parseMemoryEntries, + type MemoryEntry, +} from "../obsidian-markdown/memory-entries.js" import { parseFrontmatterTags } from "../obsidian-markdown/tags.js" import { DEFAULT_STATUS_REGISTRY, tasks } from "../obsidian-markdown/tasks.js" import type { StatusClassification, TaskPriority, TaskStatus } from "../obsidian-markdown/tasks.js" @@ -1353,10 +1357,12 @@ export const createSearchIndex = ( /** Bare memory file name ("Principles") for notes that are DIRECT children * of the memory dir — the memory tool family's flat namespace, so recall * output feeds straight back into vault_get_memory / vault_delete_memory. - * Null for every other path (nested subfolders included). */ + * Null for every other path (nested subfolders included), and for a name + * ending in .md twice, which no memory tool takes. */ const memoryFileNameFromPath = (filePath: string): string | null => { if (memoryDir === undefined) return null if (!filePath.endsWith(".md")) return null + if (isDoubledMarkdownFileName(basename(filePath))) return null if (posix.dirname(filePath) !== memoryDir) return null return basename(filePath, ".md") } diff --git a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts index d3b8529df..79973f257 100644 --- a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts @@ -161,6 +161,12 @@ describe("getMemory", () => { expect(result).toContain("# Principles") }) + it("leaves a file whose name ends in .md twice out of the all-files read", async () => { + const withoutDoubledName = await getMemory({ vaultPath: vault }, logger) + await writeFile(join(vault, "About Me", "Opinions.MD.md"), "# Doubled\n", "utf8") + expect(await getMemory({ vaultPath: vault }, logger)).toBe(withoutDoubledName) + }) + it("skips a folder whose name ends in .md in the all-files read", async () => { const withoutFolder = await getMemory({ vaultPath: vault }, logger) await mkdir(join(vault, "About Me", "Archive.md"), { recursive: true }) @@ -336,6 +342,20 @@ describe("getMemory", () => { 'memory file must not start with a dot: ".secret" would be a hidden file', ) }) + + // The store adds .md itself, so "Opinions.md" names "Opinions.md.md". A real + // file of that name on disk makes the name check, not a missing file, the + // only possible rejection. + it.each([ + { label: "lowercase", file: "Opinions.md" }, + { label: "uppercase", file: "Opinions.MD" }, + ])("rejects a file name ending in .md ($label) even when that file exists", async ({ file }) => { + await writeFile(join(vault, "About Me", `${file}.md`), "## Notes (newest first)\n", "utf8") + + await expect(getMemory({ vaultPath: vault, file }, logger)).rejects.toThrow( + new Error(`memory file must be named without .md: "${file}"`), + ) + }) }) describe("updateMemory", () => { @@ -361,6 +381,29 @@ describe("updateMemory", () => { expect(memoryDirEntries).not.toContain(".secret.md") }) + it.each([ + { label: "lowercase", file: "Opinions.md" }, + { label: "uppercase", file: "Opinions.MD" }, + ])("rejects a file name ending in .md ($label) and writes nothing", async ({ file }) => { + await expect( + updateMemory( + { + vaultPath: vault, + file, + section: "Notes (newest first)", + entry: "never written", + date: "2026-05-08", + }, + logger, + ), + ).rejects.toThrow(new Error(`memory file must be named without .md: "${file}"`)) + expect((await readdir(join(vault, "About Me"))).toSorted()).toEqual([ + "Opinions.md", + "Principles.md", + ]) + expect(await readFile(join(vault, "About Me/Opinions.md"), "utf8")).toBe(OPINIONS_MD) + }) + it("inserts entry at top of section by default", async () => { await updateMemory( { @@ -651,6 +694,41 @@ created: 2026-01-01T00:00:00-05:00 }) }) + it("wraps the mkdir failure when the memory folder is a symlink that points nowhere", async () => { + // mkdir reports ENOENT here, and a retry cannot fix a broken link, so the + // failure is the vault owner's like a file at the folder's path + const memoryFolder = join(vault, "About Me") + await rm(memoryFolder, { recursive: true }) + await symlink(join(vault, "missing-folder"), memoryFolder) + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}) + onTestFinished(() => warnSpy.mockRestore()) + + await expect( + updateMemory( + { + vaultPath: vault, + file: "Archive", + section: "Notes", + entry: "never written", + date: "2026-05-15", + }, + logger, + ), + ).rejects.toThrow( + new Error('cannot create memory folder "About Me"', { + cause: Object.assign( + new Error(`ENOENT: no such file or directory, mkdir '${memoryFolder}'`), + { errno: -2, code: "ENOENT", syscall: "mkdir", path: memoryFolder }, + ), + }), + ) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { + folder: "About Me", + error: `[Error]: ENOENT: no such file or directory, mkdir '${memoryFolder}'`, + }) + }) + it("wraps the mkdir failure when a file sits where a parent of the memory folder belongs", async () => { // mkdir reports ENOTDIR, not EEXIST, when the file is a parent of the // folder it creates @@ -691,8 +769,9 @@ created: 2026-01-01T00:00:00-05:00 it.skipIf(runsAsRoot)( "lets a permission failure creating the memory folder through with its code", async () => { - // Only a file at the folder's path is wrapped; a permission failure - // keeps Node's coded error so the tool boundary can name its remedy + // Only a file or broken symlink in the folder's way is wrapped; a + // permission failure keeps Node's coded error so the tool boundary can + // name its remedy const readOnlyVault = await mkdtemp(join(tmpdir(), "read-only-vault-")) const memoryFolder = join(readOnlyVault, "About Me") onTestFinished(() => rm(readOnlyVault, { recursive: true })) @@ -909,7 +988,7 @@ created: 2026-01-01T00:00:00-05:00 const section = await getMemory({ vaultPath: vault, file: "Fenced", section: "Notes" }, logger) // The new entry is inserted before the first real entry, not before - // the column-0 fenced bullet that matches ENTRY_PATTERN. + // the column-0 fenced bullet that looks like an entry. expect(section).toBe( [ "- **2026-06-16**: New entry appended at top", @@ -993,6 +1072,7 @@ created: 2026-01-01T00:00:00-05:00 { dateKind: "a timestamp", date: "2026-07-02T10:00:00" }, { dateKind: "free text with a line break", date: "today\n" }, { dateKind: "a calendar-impossible date", date: "2026-13-40" }, + { dateKind: "a day past the end of its month", date: "2026-02-30" }, ])("rejects $dateKind as the entry date", async ({ date }) => { await expect( updateMemory( @@ -1839,28 +1919,92 @@ describe("deleteMemory", () => { ) }) - // Server-written bullets only carry real calendar dates, so a malformed - // date can never match — the guard turns a guaranteed "no entry matching" - // miss into an actionable format error before any file read. - it("rejects a calendar-impossible date and leaves the file unchanged", async () => { + // An entry's date is read by its shape alone, so a date of another shape + // can never match — the guard turns a guaranteed "no entry matching" miss + // into a format error before any file read. + it("rejects a date that is not YYYY-MM-DD and leaves the file unchanged", async () => { await expect( deleteMemory( { vaultPath: vault, file: "Principles", section: "Decision heuristics (newest first)", - date: "2026-13-40", + date: "2026-5-5", entry: "Least-privilege for AI agents", }, logger, ), - ).rejects.toThrow( - new Error("date must be a real ISO calendar date (YYYY-MM-DD, e.g. 2026-07-02)"), - ) + ).rejects.toThrow(new Error("date must be YYYY-MM-DD (e.g. 2026-07-02)")) const fileContent = await readFile(join(vault, "About Me/Principles.md"), "utf8") expect(fileContent).toBe(PRINCIPLES_MD) }) + // The built bullet "- **2026-01-01**: **Tip**: rest" is a line in the file, + // so only the date check stops the delete + it("rejects a date with entry text after it and leaves the file unchanged", async () => { + const boldLeadPath = join(vault, "About Me/BoldLead.md") + const boldLeadContent = "## Notes (newest first)\n- **2026-01-01**: **Tip**: rest\n" + await writeFile(boldLeadPath, boldLeadContent, "utf8") + + await expect( + deleteMemory( + { + vaultPath: vault, + file: "BoldLead", + section: "Notes", + date: "2026-01-01**: **Tip", + entry: "rest", + }, + logger, + ), + ).rejects.toThrow(new Error("date must be YYYY-MM-DD (e.g. 2026-07-02)")) + expect(await readFile(boldLeadPath, "utf8")).toBe(boldLeadContent) + }) + + it("deletes a hand-edited entry whose date is not a real calendar day", async () => { + const handEditedPath = join(vault, "About Me/Hand.md") + await writeFile( + handEditedPath, + "## Notes (newest first)\n- **2026-02-30**: typed by hand\n- **2026-01-01**: kept\n", + "utf8", + ) + + await deleteMemory( + { + vaultPath: vault, + file: "Hand", + section: "Notes", + date: "2026-02-30", + entry: "typed by hand", + }, + logger, + ) + + expect(await readFile(handEditedPath, "utf8")).toBe( + "## Notes (newest first)\n- **2026-01-01**: kept\n", + ) + }) + + it("rejects a file name ending in .md and leaves a file of that name unchanged", async () => { + const doubledExtensionPath = join(vault, "About Me", "Opinions.md.md") + const doubledExtensionContent = "## Notes (newest first)\n- **2026-01-01**: old\n" + await writeFile(doubledExtensionPath, doubledExtensionContent, "utf8") + + await expect( + deleteMemory( + { + vaultPath: vault, + file: "Opinions.md", + section: "Notes", + date: "2026-01-01", + entry: "old", + }, + logger, + ), + ).rejects.toThrow(new Error('memory file must be named without .md: "Opinions.md"')) + expect(await readFile(doubledExtensionPath, "utf8")).toBe(doubledExtensionContent) + }) + it("throws on ambiguous match", async () => { const dupeContent = `--- title: Dupe @@ -2050,6 +2194,13 @@ describe("listMemoryFiles", () => { expect(outlines.map((outline) => outline.file)).toEqual(["Opinions", "Principles"]) }) + // Listed, it would show as file "Opinions.md", which every memory tool refuses + it("leaves a file whose name ends in .md twice out of the outlines", async () => { + await writeFile(join(vault, "About Me", "Opinions.md.md"), "# Doubled\n", "utf8") + const outlines = await listMemoryFiles({ vaultPath: vault }, logger) + expect(outlines.map((outline) => outline.file)).toEqual(["Opinions", "Principles"]) + }) + it("skips a folder whose name ends in .md and still lists the files", async () => { await mkdir(join(vault, "About Me", "Archive.md"), { recursive: true }) const outlines = await listMemoryFiles({ vaultPath: vault }, logger) @@ -2346,6 +2497,12 @@ describe("listMemoryFileNames", () => { expect(names).toEqual(["Opinions", "Principles"]) }) + it("leaves out a file whose name ends in .md twice", async () => { + await writeFile(join(vault, "About Me/Opinions.md.md"), "# Doubled\n", "utf8") + const names = await listMemoryFileNames({ vaultPath: vault }, logger) + expect(names).toEqual(["Opinions", "Principles"]) + }) + it("skips a folder whose name ends in .md", async () => { await mkdir(join(vault, "About Me", "Archive.md"), { recursive: true }) const names = await listMemoryFileNames({ vaultPath: vault }, logger) diff --git a/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts b/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts index bd457c214..1cef3f302 100644 --- a/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, onTestFinished } from "vitest" -import { rm, writeFile, mkdir, readFile, stat, rename, unlink } from "node:fs/promises" +import { rm, writeFile, mkdir, readFile, readdir, stat, rename, unlink } from "node:fs/promises" import { mkdtempSync } from "node:fs" import { basename, join, dirname } from "node:path" import { tmpdir } from "node:os" @@ -24,6 +24,9 @@ vi.mock("../vault-filesystem.js", { spy: true }) const PROTECTED = ["About Me", "Daily Notes"] as const +/** A name one byte under the 255-byte name limit of APFS and ext4. */ +const NAME_ONE_BYTE_UNDER_LIMIT = `${"n".repeat(251)}.md` + /** A Logger whose methods are spies, so a test can assert on its log calls * (vi.mocked(logger.info)…) without touching the shared logger singleton. * child() returns the same mock so any childed logger is asserted on too. */ @@ -143,6 +146,17 @@ describe("moveNote — file relocation", () => { pruned_empty_folders: 0, }) }) + + it("moves a note to a name one byte under the filesystem's name limit", async () => { + const { vault, writeFixture, moveNote, noteExists, readNote } = setupVault() + await writeFixture("Draft.md", "Body.\n") + + await moveNote({ oldPath: "Draft.md", newPath: NAME_ONE_BYTE_UNDER_LIMIT }) + + expect(await noteExists("Draft.md")).toBe(false) + expect(await readNote(NAME_ONE_BYTE_UNDER_LIMIT)).toBe("Body.\n") + expect(await readdir(vault)).toEqual([NAME_ONE_BYTE_UNDER_LIMIT]) + }) }) describe("moveNote — link rewriting forms", () => { @@ -2447,6 +2461,21 @@ describe("moveNote — Windows mode (rename-based exclusive write)", () => { expect(await readNote("Bar.md")).toBe("occupied\n") expect(await readNote("Foo.md")).toBe("content\n") }) + + it("moves a note to a name one byte under the filesystem's name limit in Windows mode", async () => { + const { vault, writeFixture, moveNote, noteExists, readNote } = setupVault() + await writeFixture("Foo.md", "content\n") + + await moveNote({ + oldPath: "Foo.md", + newPath: NAME_ONE_BYTE_UNDER_LIMIT, + windowsBindMount: true, + }) + + expect(await noteExists("Foo.md")).toBe(false) + expect(await readNote(NAME_ONE_BYTE_UNDER_LIMIT)).toBe("content\n") + expect(await readdir(vault)).toEqual([NAME_ONE_BYTE_UNDER_LIMIT]) + }) }) describe("moveNote — concurrent write locking", () => { 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 062faea96..c7eca56f3 100644 --- a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts @@ -13,6 +13,7 @@ import { rename, symlink, } from "node:fs/promises" +import { randomUUID } from "node:crypto" import { basename, join } from "node:path" import { tmpdir } from "node:os" import { DateTime } from "luxon" @@ -22,12 +23,13 @@ import { DateTime } from "luxon" // exact call the production code makes (the forced-collision and // rename-failure trash tests). vi.mock("node:fs/promises", { spy: true }) +// A pass-through spy too, so a test can fix the UUID a save's temp name uses +// and assert the exact path +vi.mock("node:crypto", { spy: true }) import { vaultFs, atomicWriteFile, atomicWriteFileExclusive, - isTempSavePath, - TEMP_SAVE_SUFFIX_BYTES, pruneEmptyParents, readTrashFileState, resolveSafePath, @@ -127,6 +129,11 @@ const describeOutputRefusal = ( } } +const FIXED_UUID = "123e4567-e89b-12d3-a456-426614174000" + +/** A name one byte under the 255-byte name limit of APFS and ext4. */ +const NAME_ONE_BYTE_UNDER_LIMIT = `${"n".repeat(251)}.md` + let vault: string beforeEach(async () => { @@ -146,8 +153,7 @@ describe("atomicWriteFile", () => { it("leaves no .tmp staging file behind on success", async () => { await atomicWriteFile({ filePath: join(vault, "clean.md"), content: "body\n" }, logger) - const entries = await readdir(vault) - expect(entries.filter((name) => name.endsWith(".tmp"))).toEqual([]) + expect(await readdir(vault)).toEqual(["clean.md"]) }) it("cleans up the staging file and rethrows when the rename fails", async () => { @@ -159,8 +165,7 @@ describe("atomicWriteFile", () => { await expect(atomicWriteFile({ filePath: target, content: "body\n" }, logger)).rejects.toThrow( /EISDIR/, ) - const entries = await readdir(vault) - expect(entries.filter((name) => name.endsWith(".tmp"))).toEqual([]) + expect(await readdir(vault)).toEqual(["occupied"]) }) it("logs the temp-file cleanup failure when both the rename and the cleanup fail", async () => { @@ -168,6 +173,7 @@ describe("atomicWriteFile", () => { await mkdir(target) const warnSpy = vi.spyOn(logger, "warn") onTestFinished(() => warnSpy.mockRestore()) + vi.mocked(randomUUID).mockReturnValueOnce(FIXED_UUID) // The next rm call is the temp-file cleanup after the EISDIR rename vi.mocked(rm).mockImplementationOnce(async () => { throw new Error("EPERM: injected cleanup failure") @@ -177,41 +183,36 @@ describe("atomicWriteFile", () => { /EISDIR/, ) - // The temp path carries a random UUID, so only its shape is assertable expect(warnSpy).toHaveBeenCalledTimes(1) expect(warnSpy).toHaveBeenCalledWith("failed to remove temp file", { - path: expect.stringMatching(/occupied\..+\.tmp$/), + path: join(vault, `.${FIXED_UUID}.tmp`), error: "[Error]: EPERM: injected cleanup failure", }) }) -}) -describe("isTempSavePath", () => { - it("recognises the temp file a save writes beside its target", () => { - expect(isTempSavePath("/vault/Notes/a.md.123e4567-e89b-12d3-a456-426614174000.tmp")).toBe(true) - }) + it("saves a note whose name is one byte under the filesystem's name limit", async () => { + const target = join(vault, NAME_ONE_BYTE_UNDER_LIMIT) + + await atomicWriteFile({ filePath: target, content: "near the limit\n" }, logger) - // The literal paths in this block pin the regex; this row pins it to the - // generator, so changing either one cannot silently leave a real temp-save - // failure with the generic remedy instead of the temp-save one. - it("recognises the path tempSavePath actually writes", () => { - expect(isTempSavePath(tempSavePath("/vault/Notes/a.md"))).toBe(true) + expect(await readFile(target, "utf8")).toBe("near the limit\n") + expect(await readdir(vault)).toEqual([NAME_ONE_BYTE_UNDER_LIMIT]) }) +}) - it.each([ - { label: "the target itself", path: "/vault/Notes/a.md" }, - { label: "a note whose own name ends in .tmp", path: "/vault/Notes/draft.tmp" }, - { - label: "a UUID that is not followed by .tmp", - path: "/vault/a.md.123e4567-e89b-12d3-a456-426614174000", - }, - { label: "a short hex run before .tmp", path: "/vault/a.md.123e4567.tmp" }, - ])("rejects $label", ({ path }) => { - expect(isTempSavePath(path)).toBe(false) +describe("tempSavePath", () => { + it("names a hidden file in the target's folder: a dot, the UUID and .tmp", () => { + vi.mocked(randomUUID).mockReturnValueOnce(FIXED_UUID) + + expect(tempSavePath("/vault/Notes/a.md")).toBe(`/vault/Notes/.${FIXED_UUID}.tmp`) }) - it("adds 41 bytes to the target's name: a dot, a 36-byte UUID and .tmp", () => { - expect(TEMP_SAVE_SUFFIX_BYTES).toBe(41) + // Sync and the file watcher skip a hidden name, and a fixed length means a + // note's own name length never decides whether its save fits + it("gives a long name the same 41-byte temp name as a short one", () => { + vi.mocked(randomUUID).mockReturnValueOnce(FIXED_UUID) + + expect(tempSavePath(`/vault/${NAME_ONE_BYTE_UNDER_LIMIT}`)).toBe(`/vault/.${FIXED_UUID}.tmp`) }) }) @@ -237,8 +238,7 @@ describe("atomicWriteFileExclusive", () => { it("leaves no .tmp staging file behind on success", async () => { await atomicWriteFileExclusive({ filePath: join(vault, "clean.md"), content: "body\n" }, logger) - const entries = await readdir(vault) - expect(entries.filter((name) => name.endsWith(".tmp"))).toEqual([]) + expect(await readdir(vault)).toEqual(["clean.md"]) }) it("leaves no .tmp staging file behind when the target already exists", async () => { @@ -249,8 +249,7 @@ describe("atomicWriteFileExclusive", () => { ).rejects.toMatchObject({ code: "EEXIST", }) - const entries = await readdir(vault) - expect(entries.filter((name) => name.endsWith(".tmp"))).toEqual([]) + expect(await readdir(vault)).toEqual(["exists.md"]) }) it("logs the temp-file cleanup failure when the claim fails and the cleanup fails", async () => { @@ -258,6 +257,7 @@ describe("atomicWriteFileExclusive", () => { await atomicWriteFile({ filePath: target, content: "original\n" }, logger) const warnSpy = vi.spyOn(logger, "warn") onTestFinished(() => warnSpy.mockRestore()) + vi.mocked(randomUUID).mockReturnValueOnce(FIXED_UUID) // The next rm call is the finally block's temp-file cleanup, reached // after link throws EEXIST on the occupied target vi.mocked(rm).mockImplementationOnce(async () => { @@ -272,16 +272,24 @@ describe("atomicWriteFileExclusive", () => { expect(warnSpy).toHaveBeenCalledTimes(1) expect(warnSpy).toHaveBeenCalledWith("failed to remove temp file", { - path: expect.stringMatching(/taken\.md\..+\.tmp$/), + path: join(vault, `.${FIXED_UUID}.tmp`), error: "[Error]: EPERM: injected cleanup failure", }) // The intercepted cleanup genuinely left the staging file on disk, and // the occupied target survives untouched - const entries = await readdir(vault) - expect(entries.filter((name) => name.endsWith(".tmp"))).toHaveLength(1) + expect((await readdir(vault)).toSorted()).toEqual([`.${FIXED_UUID}.tmp`, "taken.md"]) expect(await readFile(target, "utf8")).toBe("original\n") }) + it("creates a note whose name is one byte under the filesystem's name limit", async () => { + const target = join(vault, NAME_ONE_BYTE_UNDER_LIMIT) + + await atomicWriteFileExclusive({ filePath: target, content: "near the limit\n" }, logger) + + expect(await readFile(target, "utf8")).toBe("near the limit\n") + expect(await readdir(vault)).toEqual([NAME_ONE_BYTE_UNDER_LIMIT]) + }) + // Covers the rename fallback path taken on filesystems without hard-link // support (e.g. a Windows-drive Docker bind mount). describe("hardLinksSupported: false (rename strategy)", () => { @@ -325,8 +333,19 @@ describe("atomicWriteFileExclusive", () => { }, logger, ) - const entries = await readdir(vault) - expect(entries.filter((name) => name.endsWith(".tmp"))).toEqual([]) + expect(await readdir(vault)).toEqual(["clean.md"]) + }) + + it("creates a note whose name is one byte under the filesystem's name limit", async () => { + const target = join(vault, NAME_ONE_BYTE_UNDER_LIMIT) + + await atomicWriteFileExclusive( + { filePath: target, content: "near the limit\n", hardLinksSupported: false }, + logger, + ) + + expect(await readFile(target, "utf8")).toBe("near the limit\n") + expect(await readdir(vault)).toEqual([NAME_ONE_BYTE_UNDER_LIMIT]) }) it("logs the placeholder cleanup failure when both the swap and the cleanup fail", async () => { diff --git a/src/vault-mcp/vault-operations/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index dea33ae7f..bf6ec4eba 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -20,7 +20,13 @@ import { isErrnoException } from "../../utils/is-errno-exception.js" import { describeError } from "../../utils/describe-error.js" import { assertNoControlCharacters } from "../../utils/assert-no-control-characters.js" import { withFileLock } from "../../utils/file-write-lock.js" -import { parseMemoryEntries, type MemoryEntry } from "../obsidian-markdown/memory-entries.js" +import { + isDoubledMarkdownFileName, + isMemoryEntryLine, + memoryEntryLineDate, + parseMemoryEntries, + type MemoryEntry, +} from "../obsidian-markdown/memory-entries.js" import { parseLeadingCallout } from "../obsidian-markdown/callouts.js" import type { LeadingCallout } from "../obsidian-markdown/callouts.js" import { parseHeadings } from "../obsidian-markdown/headings.js" @@ -53,10 +59,6 @@ const guardAgainstShrink = (beforeBytes: number, afterBytes: number, context: st } } -// Matches dated bullet entries: `- **YYYY-MM-DD**: ...` -// The date portion is the reliable anchor — entry text after `: ` may contain its own **bold** -const ENTRY_PATTERN = /^- \*\*\d{4}-\d{2}-\d{2}\*\*:/ - /** Matches CR/LF anywhere in a string — memory entries are single-line dated * bullets, so any line break in an input would corrupt the format. */ const MEMORY_ENTRY_LINE_BREAK_PATTERN = /[\r\n]/ @@ -72,7 +74,7 @@ const isValidMemoryEntryDate = (dateText: string | undefined): boolean => { return DateTime.fromFormat(dateText, "yyyy-MM-dd").isValid } -/** Thrown by updateMemory and deleteMemory when a supplied date fails +/** Thrown by updateMemory and getMemoryEntries when a supplied date fails * isValidMemoryEntryDate — one string so the two sites can't drift. */ const INVALID_MEMORY_ENTRY_DATE_MESSAGE = "date must be a real ISO calendar date (YYYY-MM-DD, e.g. 2026-07-02)" @@ -154,8 +156,8 @@ type ParsedSection = Readonly<{ // ── Internal helpers ──────────────────────────────────────────── /** Walks section body lines with fence/comment awareness and returns the - * 0-based offsets of genuine entry bullets (lines matching ENTRY_PATTERN that - * are not inside a code fence or %% comment). Used by updateMemory's insertion + * 0-based offsets of genuine entry bullets (lines isMemoryEntryLine accepts + * that are not inside a code fence or %% comment). Used by updateMemory's insertion * scan, deleteMemory's bullet match, and parseSections' entry count — all three * must agree on what counts as a real entry. */ const scanGenuineEntryOffsets = (bodyLines: readonly string[]): number[] => { @@ -185,7 +187,7 @@ const scanGenuineEntryOffsets = (bodyLines: readonly string[]): number[] => { if (insideCodeOrComment) continue - if (ENTRY_PATTERN.test(bodyLine)) { + if (isMemoryEntryLine(bodyLine)) { offsets.push(offsetIndex) } } @@ -361,12 +363,15 @@ export const createMemoryStore = (options: { memoryDir: string }) => { /** Large memory folders must not exhaust file handles by reading every file at once. */ const memoryReadConcurrency = 16 - /** True for the .md entries the memory layer serves — excludes dot-prefixed - * (hidden) filenames so a pre-existing hidden file on disk never leaks - * through the no-file read or the list surfaces, mirroring the write-side - * rejection in memoryFilePath. */ + /** True for the .md entries the memory layer serves. It leaves out two + * kinds of file that memoryFilePath refuses to name, so the no-file read + * and the list surfaces never offer them: + * - a dot-prefixed (hidden) name; + * - a name ending in .md twice. */ const isVisibleMemoryFile = (filename: string): boolean => { - return filename.endsWith(".md") && !filename.startsWith(".") + return ( + filename.endsWith(".md") && !filename.startsWith(".") && !isDoubledMarkdownFileName(filename) + ) } /** Reads the memory folder's direct entries. @@ -460,12 +465,16 @@ export const createMemoryStore = (options: { memoryDir: string }) => { } } - // A memory file is a bare name, never a path — a separator would let a - // name like "../../outside" escape the memory directory (and the vault) - // entirely, so reject it at the single point every memory path goes through. - // A dot-prefixed name would create a hidden file (invisible in Obsidian, - // excluded from listings and the index), so it is rejected the same way — - // memory paths bypass resolveSafePath's hidden-path guard via direct join. + // A memory file is a bare name, never a path, and every memory path built + // from a caller's name goes through this one check: + // - a separator would let a name like "../../outside" escape the memory + // directory (and the vault); + // - a dot-prefixed name would create a hidden file, invisible in Obsidian + // and left out of listings and the index (memory paths are joined + // directly, so resolveSafePath's hidden-path guard never sees them); + // - a name ending in .md, in any letter case, already carries the + // extension and would get a second one, creating "Opinions.md.md" beside + // "Opinions.md". const memoryFilePath = (vaultPath: string, file: string): string => { if (file.includes("/") || file.includes("\\")) { throw new Error(`memory file must be a bare name without path separators: "${file}"`) @@ -473,6 +482,9 @@ export const createMemoryStore = (options: { memoryDir: string }) => { if (file.startsWith(".")) { throw new Error(`memory file must not start with a dot: "${file}" would be a hidden file`) } + if (file.toLowerCase().endsWith(".md")) { + throw new Error(`memory file must be named without .md: "${file}"`) + } return join(vaultPath, memoryDir, `${file}.md`) } @@ -882,16 +894,23 @@ export const createMemoryStore = (options: { memoryDir: string }) => { try { await mkdir(dirname(filePath), { recursive: true }) } catch (error) { - // The folder is server configuration (MEMORY_DIR), so a file sitting - // at its path is the vault owner's to move, not the caller's: - // - that failure is wrapped without its code, and the memory tools' - // Errors entry names its fix; + // The folder is server configuration (MEMORY_DIR), so anything in + // its way is the vault owner's to fix, not the caller's. mkdir + // reports a file at the folder's own path as EEXIST, a file at a + // folder above it as ENOTDIR, and a broken symlink at its path as + // ENOENT: + // - those failures are wrapped without their code, because the tool + // boundary's remedies for them tell the caller to choose another + // path or to retry; the memory tools' Errors entry names the + // owner's fix instead; // - other failures keep their code, so the tool boundary appends // its remedy. - const fileAtFolderPath = - isErrnoException(error, "EEXIST") || isErrnoException(error, "ENOTDIR") + const folderPathBlocked = + isErrnoException(error, "EEXIST") || + isErrnoException(error, "ENOTDIR") || + isErrnoException(error, "ENOENT") - if (!fileAtFolderPath) throw error + if (!folderPathBlocked) throw error logger.warn("cannot create memory folder", { folder: memoryDir, @@ -1103,12 +1122,18 @@ export const createMemoryStore = (options: { memoryDir: string }) => { }, logger: Logger, ): Promise => { - // Server-written bullets only ever carry real calendar dates, so a - // malformed date can never match — reject it up front with remediation - // instead of a guaranteed "no entry matching" miss. - if (!isValidMemoryEntryDate(params.date)) { - throw new Error(INVALID_MEMORY_ENTRY_DATE_MESSAGE) + const targetBullet = `- **${params.date}**: ${params.entry}` + + // Refused up front unless the entry parser reads the bullet as an entry + // dated exactly params.date. That test refuses: + // - a date of another shape, which can never match an entry; + // - text after the date ("2026-07-02**: x"), which would carry part of the entry. + // It passes an impossible date that a hand edit can leave in a file, such + // as 2026-02-30, because the parser checks the date's shape only. + if (memoryEntryLineDate(targetBullet) !== params.date) { + throw new Error("date must be YYYY-MM-DD (e.g. 2026-07-02)") } + // Serialize with concurrent updates/deletes to the same file so the // read-modify-write can't be interleaved and lose a write. return withFileLock(memoryFilePath(params.vaultPath, params.file), async () => { @@ -1118,7 +1143,6 @@ export const createMemoryStore = (options: { memoryDir: string }) => { const match = resolveSection({ lines, section: params.section, file: params.file }) // Find the target bullet among genuine (non-fenced) entry lines. - const targetBullet = `- **${params.date}**: ${params.entry}` const sectionBodyLines = lines.slice(match.bodyStartLine, match.bodyEndLine) const genuineEntryOffsets = scanGenuineEntryOffsets(sectionBodyLines) const matchingOffsets = genuineEntryOffsets.filter( diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 367fb8d0a..858b3dd39 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -170,28 +170,40 @@ export const pruneEmptyParents = async ( return pruneFrom(start, 0) } -/** The temp file a save writes beside its target: the target's path plus a - * dot, a UUID and ".tmp". That suffix makes the temp name longer than the - * target's, so a name near the filesystem's length limit can read fine and - * still fail to save. */ -export const tempSavePath = (filePath: string): string => `${filePath}.${randomUUID()}.tmp` - -/** How many bytes tempSavePath adds to a target's name. */ -export const TEMP_SAVE_SUFFIX_BYTES = Buffer.byteLength(tempSavePath(""), "utf8") - -/** Matches the suffix tempSavePath appends, at the end of a path. It matches that - * shape, not only paths a save wrote, so a note named the same way counts too. */ -const TEMP_SAVE_SUFFIX_REGEX = - /\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/ +/** The temp file a save writes beside its target: a dot, a UUID and ".tmp". + * - The leading dot hides it, so Obsidian Sync and the file watcher never + * open a file the save is about to rename away; file-watcher.ts ignores + * every dot-prefixed path. + * - Its name is 41 bytes (a 36-character UUID plus the dot and ".tmp") + * whatever the target's name, so a target named near the filesystem's + * limit still saves. + * - It stays in the target's folder, so the rename or link that follows + * never crosses filesystems. */ +export const tempSavePath = (filePath: string): string => { + return join(dirname(filePath), `.${randomUUID()}.tmp`) +} -export const isTempSavePath = (path: string): boolean => TEMP_SAVE_SUFFIX_REGEX.test(path) +/** Removes a file a write left behind. A failed removal is logged, not thrown, + * so the caller's own failure is the one that propagates. */ +const removeLeftoverFile = async ( + params: { fullPath: string; loggedPath: string; failureLogMessage: string }, + logger: Logger, +): Promise => { + try { + await rm(params.fullPath, { force: true }) + } catch (cleanupError) { + logger.warn(params.failureLogMessage, { + path: params.loggedPath, + error: describeError(cleanupError), + }) + } +} /** * Stages content to a unique temp file, then renames over the target. * `rename` is atomic on the same filesystem, so the target is never * truncated — readers (notably the obsidian-sync container) see either the old - * content or the new content, never a 0-byte or partial write. This is the - * core defense against the partial-write clobber class of bug. + * content or the new content, never a 0-byte or partial write. * * Overwrites an existing target; use `atomicWriteFileExclusive` when the file * must not already exist. @@ -205,16 +217,11 @@ export const atomicWriteFile = async ( await writeFile(tmpPath, params.content, "utf8") await rename(tmpPath, params.filePath) } catch (err) { - // Best-effort cleanup so a failed write never strands a temp file. A - // failed cleanup is logged, not thrown, so the write failure propagates. - try { - await rm(tmpPath, { force: true }) - } catch (cleanupError) { - logger.warn("failed to remove temp file", { - path: tmpPath, - error: describeError(cleanupError), - }) - } + // A failed write never strands its temp file + await removeLeftoverFile( + { fullPath: tmpPath, loggedPath: tmpPath, failureLogMessage: "failed to remove temp file" }, + logger, + ) throw err } } @@ -265,30 +272,25 @@ export const atomicWriteFileExclusive = async ( await rename(tmpPath, params.filePath) } catch (renameError) { // The reservation took but the swap failed — drop the placeholder so a - // failed write never strands a 0-byte note at the destination. A failed - // cleanup is logged, not thrown, so the swap failure propagates. - try { - await rm(params.filePath, { force: true }) - } catch (cleanupError) { - logger.warn("failed to remove reservation placeholder", { - path: params.filePath, - error: describeError(cleanupError), - }) - } + // failed write never strands a 0-byte note at the destination. + await removeLeftoverFile( + { + fullPath: params.filePath, + loggedPath: params.filePath, + failureLogMessage: "failed to remove reservation placeholder", + }, + logger, + ) throw renameError } } finally { - // Always drop the temp file — renamed away on success, redundant otherwise. - // A failed cleanup is logged, not thrown, so the original failure (e.g. - // EEXIST) propagates. - try { - await rm(tmpPath, { force: true }) - } catch (cleanupError) { - logger.warn("failed to remove temp file", { - path: tmpPath, - error: describeError(cleanupError), - }) - } + // Always drop the temp file. After a link it is a second name for the + // target, so removing it leaves the target alone; after the rename it is + // already gone. + await removeLeftoverFile( + { fullPath: tmpPath, loggedPath: tmpPath, failureLogMessage: "failed to remove temp file" }, + logger, + ) } } @@ -708,16 +710,15 @@ const moveNoteToTrash = async ( await rename(params.fullPath, candidateFullPath) } catch (renameError) { // The claim took but the move failed — drop our placeholder so it - // doesn't strand a 0-byte file occupying a suffix. A failed cleanup is - // logged, not thrown, so the rename failure propagates as the cause. - try { - await rm(candidateFullPath, { force: true }) - } catch (cleanupError) { - logger.warn("failed to remove claim placeholder", { - path: candidateRelativePath, - error: describeError(cleanupError), - }) - } + // doesn't strand a 0-byte file occupying a suffix. + await removeLeftoverFile( + { + fullPath: candidateFullPath, + loggedPath: candidateRelativePath, + failureLogMessage: "failed to remove claim placeholder", + }, + logger, + ) throw renameError }