From 220fdca4461cce453f22736c4a6d8c1d83b1ae58 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:50:41 -0400 Subject: [PATCH 01/19] fix(tools): save through a short hidden temporary file A save staged its content in `..tmp` beside the note, so a note named within 41 bytes of the filesystem's limit could be read but not saved, and Obsidian Sync logged an error for each save when it opened the temp file after the rename had moved it. The temp file is now `..tmp` in the note's folder: 41 bytes whatever the note's name, and hidden, so Sync and the file watcher skip it. The temp-name remedy and its tool-description text go, along with the tool-availability argument only that remedy used. Co-Authored-By: Claude Opus 5.5 --- .../server-error-contracts.test.ts | 75 +---------------- .../__snapshots__/tool-surface/default.json | 6 +- .../tool-surface/disabled-tools.json | 6 +- .../tool-surface/embedding-off.json | 6 +- .../file-tools-off+embedding-off.json | 6 +- .../tool-surface/file-tools-off.json | 6 +- .../memory-off+embedding-off.json | 2 +- ...mory-off+file-tools-off+embedding-off.json | 2 +- .../memory-off+file-tools-off.json | 2 +- .../tool-surface/memory-off.json | 2 +- .../tool-surface/obsidian-sync.json | 6 +- .../__tests__/coded-error-remedy.test.ts | 57 ++----------- .../__tests__/tool-definitions.test.ts | 6 +- src/vault-mcp/mcp-core/coded-error-remedy.ts | 36 ++------ .../prompts/__tests__/prompt-helpers.test.ts | 32 +------- .../mcp-core/prompts/daily-review-prompt.ts | 2 +- .../mcp-core/prompts/memory-review-prompt.ts | 2 +- .../mcp-core/prompts/prompt-helpers.ts | 4 +- .../prompts/vault-orientation-prompt.ts | 2 +- src/vault-mcp/mcp-core/tools/memory-tools.ts | 10 +-- src/vault-mcp/mcp-core/tools/tool-helpers.ts | 2 +- .../mcp-core/tools/vault-crud-tools.ts | 2 +- .../__tests__/note-mover.test.ts | 31 ++++++- .../__tests__/vault-filesystem.test.ts | 82 ++++++++++++------- .../vault-operations/vault-filesystem.ts | 25 +++--- 25 files changed, 143 insertions(+), 269 deletions(-) diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index 80eb28863..5b34ff7c9 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)}$`, ), ), }, 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..7b70f8eb1 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\" — 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\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": { @@ -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\" — 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\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..25d8cf34e 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\" — 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\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": { @@ -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\" — 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\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..a00a326d2 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\" — 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\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": { @@ -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\" — 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\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..5a86938ff 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\" — 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\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": { @@ -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\" — 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\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..e0eac9037 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\" — 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\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": { @@ -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\" — 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\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..a8b338e6e 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\" — 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\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": { @@ -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\" — 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\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__/coded-error-remedy.test.ts b/src/vault-mcp/mcp-core/__tests__/coded-error-remedy.test.ts index c459db1f8..3e55c0afc 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 @@ -2,9 +2,6 @@ import { describe, it, expect } from "vitest" import { appendCodedErrorRemedy, 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,7 +163,7 @@ describe("remedyForCodedError", () => { error: "boom", }, ])("returns undefined for $label", ({ error }) => { - expect(remedyForCodedError({ error, isToolEnabled: everyToolServed })).toBeUndefined() + expect(remedyForCodedError(error)).toBeUndefined() }) }) @@ -213,7 +175,6 @@ describe("appendCodedErrorRemedy", () => { appendCodedErrorRemedy({ message: "[Error]: EACCES: permission denied, open 'Locked.md'", error, - isToolEnabled: everyToolServed, }), ).toBe( "[Error]: EACCES: permission denied, open 'Locked.md'. Ask the vault's owner to fix the file's or folder's permissions.", @@ -227,7 +188,6 @@ describe("appendCodedErrorRemedy", () => { appendCodedErrorRemedy({ message: "Nothing was written.", error, - isToolEnabled: everyToolServed, }), ).toBe("Nothing was written. Ask the vault's owner to fix the file's or folder's permissions.") }) @@ -237,7 +197,6 @@ describe("appendCodedErrorRemedy", () => { appendCodedErrorRemedy({ message: "[Error]: note not found", error: new Error("note not found"), - isToolEnabled: everyToolServed, }), ).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..b60556460 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,23 +19,10 @@ 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") { @@ -85,19 +70,14 @@ 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. @@ -114,8 +94,6 @@ export const appendRemedy = (params: { message: string; remedy: string | undefin /** 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) }) +export const appendCodedErrorRemedy = (params: { message: string; error: unknown }): string => { + return appendRemedy({ message: params.message, remedy: remedyForCodedError(params.error) }) +} 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..b816b9167 100644 --- a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts +++ b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts @@ -53,13 +53,11 @@ export const textResult = (text: string): GetPromptResult => ({ export const describeErrorForPromptFallback = (params: { error: unknown vaultPath: string - isToolEnabled: ToolAvailability["isToolEnabled"] }): string => { - const { error, vaultPath, isToolEnabled } = params + const { error, vaultPath } = params const errorText = appendCodedErrorRemedy({ message: describeErrorRelativeTo({ error, directory: vaultPath }), error, - isToolEnabled, }) return errorText.endsWith(".") ? errorText.slice(0, -1) : errorText 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..eb52e3409 100644 --- a/src/vault-mcp/mcp-core/tools/memory-tools.ts +++ b/src/vault-mcp/mcp-core/tools/memory-tools.ts @@ -11,12 +11,6 @@ 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. */ const MEMORY_FILE_IS_FOLDER_ERROR_ENTRY = @@ -324,7 +318,7 @@ Errors: - "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} @@ -430,7 +424,7 @@ Errors: - "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/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..f660197bd 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 () => { @@ -168,6 +175,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 +185,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`) }) }) @@ -258,6 +261,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 +276,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)", () => { @@ -329,6 +341,18 @@ describe("atomicWriteFileExclusive", () => { expect(entries.filter((name) => name.endsWith(".tmp"))).toEqual([]) }) + 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 () => { const target = join(vault, "reserved.md") const warnSpy = vi.spyOn(logger, "warn") diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 367fb8d0a..89f2669f4 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -170,21 +170,16 @@ 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$/ - -export const isTempSavePath = (path: string): boolean => TEMP_SAVE_SUFFIX_REGEX.test(path) +/** 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. + * - Its name is 41 bytes 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`) +} /** * Stages content to a unique temp file, then renames over the target. From 136c9a8ade2d8605be2d052b08e42d21069562a7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:55:04 -0400 Subject: [PATCH 02/19] fix(tools): refuse memory names ending in .md and accept hand-edited entry dates - The memory tools add .md to the file name, so `file: "Opinions.md"` created `Opinions.md.md` beside `Opinions.md`. A name ending in .md, in any letter case, is now refused before anything is read or written. - vault_delete_memory refused an entry whose date a hand edit made impossible (2026-02-30), so only a manual edit could remove it. It now checks the date's shape only; vault_update_memory still refuses an impossible date for a new entry. - When a file blocks the memory folder, the message names the folder on the way that is not a folder, so a nested MEMORY_DIR points at the right path. Co-Authored-By: Claude Opus 5.5 --- .../server-error-contracts.test.ts | 33 ++++- .../__snapshots__/tool-surface/default.json | 6 +- .../tool-surface/disabled-tools.json | 6 +- .../tool-surface/embedding-off.json | 6 +- .../file-tools-off+embedding-off.json | 6 +- .../tool-surface/file-tools-off.json | 6 +- .../tool-surface/obsidian-sync.json | 6 +- .../tool-surface/readonly+embedding-off.json | 2 +- ...readonly+file-tools-off+embedding-off.json | 2 +- .../tool-surface/readonly+file-tools-off.json | 2 +- .../__snapshots__/tool-surface/readonly.json | 2 +- src/vault-mcp/mcp-core/tools/memory-tools.ts | 8 +- .../__tests__/memory-store.test.ts | 139 ++++++++++++++++-- .../vault-operations/memory-store.ts | 63 ++++++-- 14 files changed, 237 insertions(+), 50 deletions(-) diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index 5b34ff7c9..c07fa4501 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -885,7 +885,12 @@ describe("filesystem errors name the path vault-relative and carry their remedy" // The folder is server configuration, so the store wraps the raw mkdir // error instead of letting the boundary tell the caller to choose a path expect(result).toEqual({ - content: [{ type: "text", text: '[Error]: cannot create memory folder "About Me"' }], + content: [ + { + type: "text", + text: '[Error]: cannot create memory folder "About Me": "About Me" is not a folder', + }, + ], isError: true, }) }) @@ -1691,6 +1696,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 7b70f8eb1..840001084 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\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": { @@ -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\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 25d8cf34e..fc3013952 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\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": { @@ -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\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 a00a326d2..325f26d56 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\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": { @@ -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\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 5a86938ff..359b34704 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\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": { @@ -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\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 e0eac9037..6b0af74a4 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\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": { @@ -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\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/obsidian-sync.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json index a8b338e6e..c709491e7 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\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": { @@ -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\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..3867fdad8 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": { 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..3cbb8dffe 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": { 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..df79aac7f 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": { 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..911b9bc0d 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": { diff --git a/src/vault-mcp/mcp-core/tools/memory-tools.ts b/src/vault-mcp/mcp-core/tools/memory-tools.ts index eb52e3409..5857a7060 100644 --- a/src/vault-mcp/mcp-core/tools/memory-tools.ts +++ b/src/vault-mcp/mcp-core/tools/memory-tools.ts @@ -88,7 +88,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 @@ -314,7 +314,7 @@ 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} @@ -417,8 +417,8 @@ 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. 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..1bb2141ee 100644 --- a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts @@ -336,6 +336,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 +375,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( { @@ -635,7 +672,7 @@ created: 2026-01-01T00:00:00-05:00 logger, ), ).rejects.toThrow( - new Error('cannot create memory folder "About Me"', { + new Error('cannot create memory folder "About Me": "About Me" is not a folder', { cause: Object.assign(new Error(`EEXIST: file already exists, mkdir '${memoryFolder}'`), { errno: -17, code: "EEXIST", @@ -647,6 +684,44 @@ created: 2026-01-01T00:00:00-05:00 expect(warnSpy).toHaveBeenCalledTimes(1) expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { folder: "About Me", + blocker: "About Me", + error: `[Error]: EEXIST: file already exists, mkdir '${memoryFolder}'`, + }) + }) + + it("names no blocker when nothing on the way to the memory folder is a file any more", async () => { + // The file mkdir tripped over is gone by the time the store looks for it + // (another writer removed it, or a symlink there points nowhere) + const memoryFolder = join(vault, "About Me") + await rm(memoryFolder, { recursive: true }) + const mkdirError = Object.assign( + new Error(`EEXIST: file already exists, mkdir '${memoryFolder}'`), + { + code: "EEXIST", + syscall: "mkdir", + path: memoryFolder, + }, + ) + vi.mocked(mkdir).mockRejectedValueOnce(mkdirError) + 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: mkdirError })) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { + folder: "About Me", + blocker: undefined, error: `[Error]: EEXIST: file already exists, mkdir '${memoryFolder}'`, }) }) @@ -672,7 +747,7 @@ created: 2026-01-01T00:00:00-05:00 logger, ), ).rejects.toThrow( - new Error('cannot create memory folder "Meta/About Me"', { + new Error('cannot create memory folder "Meta/About Me": "Meta" is not a folder', { cause: Object.assign(new Error(`ENOTDIR: not a directory, mkdir '${memoryFolder}'`), { errno: -20, code: "ENOTDIR", @@ -684,6 +759,7 @@ created: 2026-01-01T00:00:00-05:00 expect(warnSpy).toHaveBeenCalledTimes(1) expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { folder: "Meta/About Me", + blocker: "Meta", error: `[Error]: ENOTDIR: not a directory, mkdir '${memoryFolder}'`, }) }) @@ -993,6 +1069,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 +1916,70 @@ 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) }) + 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 diff --git a/src/vault-mcp/vault-operations/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index dea33ae7f..c7448d5de 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -72,11 +72,16 @@ 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)" +/** The date shape ENTRY_PATTERN reads as an entry. A hand edit can leave an + * impossible date such as 2026-02-30 in it, so deleteMemory checks only + * the shape. */ +const MEMORY_ENTRY_DATE_SHAPE = /^\d{4}-\d{2}-\d{2}$/ + const isString = (value: unknown): value is string => typeof value === "string" /** Appends "(newest first)" when absent (case-insensitive match). Trailing @@ -460,12 +465,15 @@ 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 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 would get a second .md, 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,9 +481,31 @@ 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`) } + /** The first folder on the way to the memory folder, from the vault root, + * that is something other than a folder; undefined when every one is a + * folder or missing (it changed since mkdir failed, or a symlink there + * points nowhere). */ + const findMemoryFolderBlocker = async (vaultPath: string): Promise => { + // "Meta/About Me" → ["Meta", "Meta/About Me"] + const folderSegments = memoryDir.split("/") + const foldersFromRoot = folderSegments.map((_, index) => { + return folderSegments.slice(0, index + 1).join("/") + }) + + for (const folder of foldersFromRoot) { + const entry = await statOrNull(join(vaultPath, folder)) + + if (entry && !entry.isDirectory()) return folder + } + return undefined + } + type MemoryTemplateSpec = { fileName: string title: string @@ -893,11 +923,19 @@ export const createMemoryStore = (options: { memoryDir: string }) => { if (!fileAtFolderPath) throw error + // MEMORY_DIR can be nested, and mkdir does not say which folder on + // the way is a file, so the message names it for the owner + const blocker = await findMemoryFolderBlocker(params.vaultPath) + const blockerClause = blocker ? `: "${blocker}" is not a folder` : "" + logger.warn("cannot create memory folder", { folder: memoryDir, + blocker, error: describeError(error), }) - throw new Error(`cannot create memory folder "${memoryDir}"`, { cause: error }) + throw new Error(`cannot create memory folder "${memoryDir}"${blockerClause}`, { + cause: error, + }) } const content = buildNewMemoryFile({ @@ -1103,11 +1141,10 @@ 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) + // A date of any other shape can never match an entry, so it is refused + // up front instead of ending in a guaranteed "no entry matching" miss. + if (!MEMORY_ENTRY_DATE_SHAPE.test(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. From 390a7bd9e731973bf2500a96233a6d3ca6e99086 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:02:16 -0400 Subject: [PATCH 03/19] test(search): pin indexing of a save through the hidden temp file; update ARCHITECTURE.md Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 16 ++++++++----- .../search/__tests__/file-watcher.test.ts | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e1b2cae2a..ef1b46749 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1196,12 +1196,16 @@ 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, so Obsidian Sync and the file watcher never open 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 link stays atomic. + + On failure the temp file is removed, by a `catch` in `atomicWriteFile` + and by a `finally` in `atomicWriteFileExclusive`. + - **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 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 }, From a509e68739d7b337505130fdc55f6eb9301fcf88 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:58:12 -0400 Subject: [PATCH 04/19] style: clarify temp-save, memory-folder and error-remedy comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Correct the atomicWriteFileExclusive cleanup comment for the link path - Name where the watcher skips hidden paths and show the 41-byte sum - Rename findMemoryFolderBlocker's stats local and fix its docstring - Say why mkdir's EEXIST/ENOTDIR are wrapped and which case gives which - Name the files behind the coded-error remedies' race assumptions - Extract a named local in exceedsCharCap and explain capContent's guard - Align ARCHITECTURE.md's temp-file cleanup with both write paths Ship-Check: code-quality · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 11 ++++-- src/vault-mcp/mcp-core/coded-error-remedy.ts | 18 +++++---- .../mcp-core/prompts/prompt-helpers.ts | 7 +++- src/vault-mcp/mcp-core/tools/memory-tools.ts | 5 ++- .../vault-operations/memory-store.ts | 37 +++++++++++-------- .../vault-operations/vault-filesystem.ts | 18 +++++---- 6 files changed, 58 insertions(+), 38 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ef1b46749..c537d7afc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1198,13 +1198,16 @@ Docker hardening, and durability seatbelts above. Obsidian Sync process sees either old content or new content, never a 0-byte or partial write. The temp file is `..tmp` in the note's folder: - - hidden, so Obsidian Sync and the file watcher never open it; + - 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 link stays atomic. + - on the note's filesystem, so the rename (or the hard link of an + exclusive create, below) stays atomic. - On failure the temp file is removed, by a `catch` in `atomicWriteFile` - and by a `finally` in `atomicWriteFileExclusive`. + 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 diff --git a/src/vault-mcp/mcp-core/coded-error-remedy.ts b/src/vault-mcp/mcp-core/coded-error-remedy.ts index b60556460..7b228684f 100644 --- a/src/vault-mcp/mcp-core/coded-error-remedy.ts +++ b/src/vault-mcp/mcp-core/coded-error-remedy.ts @@ -28,6 +28,8 @@ const describeSystemErrorRemedy = (error: SystemError): string => { 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." } @@ -35,7 +37,8 @@ const describeSystemErrorRemedy = (error: SystemError): string => { // 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": @@ -55,13 +58,14 @@ const describeSystemErrorRemedy = (error: SystemError): string => { 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." } @@ -83,8 +87,8 @@ export const remedyForCodedError = (error: unknown): string | undefined => { /** 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 diff --git a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts index b816b9167..4a02b045f 100644 --- a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts +++ b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts @@ -70,7 +70,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 @@ -82,6 +85,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/tools/memory-tools.ts b/src/vault-mcp/mcp-core/tools/memory-tools.ts index 5857a7060..edc6a2324 100644 --- a/src/vault-mcp/mcp-core/tools/memory-tools.ts +++ b/src/vault-mcp/mcp-core/tools/memory-tools.ts @@ -11,8 +11,9 @@ import { toolInputErrorResult, } from "./tool-helpers.js" -/** 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' diff --git a/src/vault-mcp/vault-operations/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index c7448d5de..82e1eea88 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -78,8 +78,8 @@ const INVALID_MEMORY_ENTRY_DATE_MESSAGE = "date must be a real ISO calendar date (YYYY-MM-DD, e.g. 2026-07-02)" /** The date shape ENTRY_PATTERN reads as an entry. A hand edit can leave an - * impossible date such as 2026-02-30 in it, so deleteMemory checks only - * the shape. */ + * impossible date such as 2026-02-30 in a memory file, so deleteMemory + * checks only the shape. */ const MEMORY_ENTRY_DATE_SHAPE = /^\d{4}-\d{2}-\d{2}$/ const isString = (value: unknown): value is string => typeof value === "string" @@ -465,15 +465,16 @@ export const createMemoryStore = (options: { memoryDir: string }) => { } } - // A memory file is a bare name, never a path, and every memory path goes - // through this one check: + // 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 would get a second .md, creating "Opinions.md.md" - // beside "Opinions.md". + // - 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}"`) @@ -487,10 +488,10 @@ export const createMemoryStore = (options: { memoryDir: string }) => { return join(vaultPath, memoryDir, `${file}.md`) } - /** The first folder on the way to the memory folder, from the vault root, - * that is something other than a folder; undefined when every one is a - * folder or missing (it changed since mkdir failed, or a symlink there - * points nowhere). */ + /** The first path on the way to the memory folder, from the vault root, + * that exists but is not a folder. Undefined when each path is a folder or + * missing; one can be missing after mkdir failed because it changed since, + * or because a symlink there points nowhere. */ const findMemoryFolderBlocker = async (vaultPath: string): Promise => { // "Meta/About Me" → ["Meta", "Meta/About Me"] const folderSegments = memoryDir.split("/") @@ -499,9 +500,9 @@ export const createMemoryStore = (options: { memoryDir: string }) => { }) for (const folder of foldersFromRoot) { - const entry = await statOrNull(join(vaultPath, folder)) + const folderStats = await statOrNull(join(vaultPath, folder)) - if (entry && !entry.isDirectory()) return folder + if (folderStats && !folderStats.isDirectory()) return folder } return undefined } @@ -912,10 +913,14 @@ 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 a file in its + // way is the vault owner's to move, not the caller's. mkdir reports + // a file at the folder's own path as EEXIST, and one at a folder + // above it as ENOTDIR: + // - those failures are wrapped without their code, because the tool + // boundary's remedy for a mkdir EEXIST or ENOTDIR tells the caller + // to choose another path; 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 = diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 89f2669f4..7a3b4eb1c 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -172,9 +172,11 @@ export const pruneEmptyParents = async ( /** 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. - * - Its name is 41 bytes whatever the target's name, so a target named - * near the filesystem's limit still saves. + * 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 => { @@ -185,8 +187,7 @@ export const tempSavePath = (filePath: string): string => { * 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. @@ -273,9 +274,10 @@ export const atomicWriteFileExclusive = async ( 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. + // 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. A failed cleanup is logged, not thrown, so the original + // failure (e.g. EEXIST) propagates. try { await rm(tmpPath, { force: true }) } catch (cleanupError) { From b2d3176cc8c867473e5605e9bed91eeff63e49a3 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:59:48 -0400 Subject: [PATCH 05/19] docs(prompts): say why a prompt fallback carries only coded remedies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/vault-mcp/mcp-core/prompts/prompt-helpers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts index 4a02b045f..869e2bf91 100644 --- a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts +++ b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts @@ -55,6 +55,9 @@ export const describeErrorForPromptFallback = (params: { vaultPath: string }): string => { const { error, vaultPath } = params + // Only coded remedies apply: prompts write nothing and read through paths + // that never refuse a properties block, so the tool boundary's repair steps + // cannot arise here const errorText = appendCodedErrorRemedy({ message: describeErrorRelativeTo({ error, directory: vaultPath }), error, From 1b0f26eb0d10c77433539257a0eb75cd82674e91 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:00:01 -0400 Subject: [PATCH 06/19] docs(prompts): reword the prompt-fallback remedy comment as a sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/vault-mcp/mcp-core/prompts/prompt-helpers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts index 869e2bf91..43fcbcfd4 100644 --- a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts +++ b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts @@ -55,9 +55,9 @@ export const describeErrorForPromptFallback = (params: { vaultPath: string }): string => { const { error, vaultPath } = params - // Only coded remedies apply: prompts write nothing and read through paths - // that never refuse a properties block, so the tool boundary's repair steps - // cannot arise here + // 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 = appendCodedErrorRemedy({ message: describeErrorRelativeTo({ error, directory: vaultPath }), error, From e980e1b981295f2a7b974d143c287c217f67fae1 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:08:57 -0400 Subject: [PATCH 07/19] test: pin a nested memory folder that is itself a file; assert whole folder listings after saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memory-store: a nested MEMORY_DIR whose parent is a folder and whose own path is a file names "Meta/About Me" as the blocker, so the check must walk past the first segment - vault-filesystem: the no-staging-file-left tests assert the folder's exact listing instead of filtering for names ending in .tmp, which would pass if the temp name stopped ending that way Ship-Check: test-audit · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/memory-store.test.ts | 39 +++++++++++++++++++ .../__tests__/vault-filesystem.test.ts | 15 +++---- 2 files changed, 44 insertions(+), 10 deletions(-) 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 1bb2141ee..413f51869 100644 --- a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts @@ -764,6 +764,45 @@ created: 2026-01-01T00:00:00-05:00 }) }) + it("names the nested memory folder itself when its parent is a folder and it is a file", async () => { + // "Meta" is a real folder, so the check must walk past it to the file at + // "Meta/About Me" + const nestedStore = createMemoryStore({ memoryDir: "Meta/About Me" }) + const memoryFolder = join(vault, "Meta", "About Me") + await mkdir(join(vault, "Meta")) + await writeFile(memoryFolder, "not a folder") + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}) + onTestFinished(() => warnSpy.mockRestore()) + + await expect( + nestedStore.updateMemory( + { + vaultPath: vault, + file: "Archive", + section: "Notes", + entry: "never written", + date: "2026-05-15", + }, + logger, + ), + ).rejects.toThrow( + new Error('cannot create memory folder "Meta/About Me": "Meta/About Me" is not a folder', { + cause: Object.assign(new Error(`EEXIST: file already exists, mkdir '${memoryFolder}'`), { + errno: -17, + code: "EEXIST", + syscall: "mkdir", + path: memoryFolder, + }), + }), + ) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { + folder: "Meta/About Me", + blocker: "Meta/About Me", + error: `[Error]: EEXIST: file already exists, mkdir '${memoryFolder}'`, + }) + }) + it.skipIf(runsAsRoot)( "lets a permission failure creating the memory folder through with its code", async () => { 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 f660197bd..c7eca56f3 100644 --- a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts @@ -153,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 () => { @@ -166,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 () => { @@ -240,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 () => { @@ -252,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 () => { @@ -337,8 +333,7 @@ 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 () => { From 164e6bf86815713b3e2be28b8e648d7391cbad02 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:30:02 -0400 Subject: [PATCH 08/19] fix: wrap the memory folder's mkdir failure for a broken symlink; drop the stale path key from the remedy docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A broken symlink at the memory folder's path made mkdir fail with ENOENT, which reached the client with a retry remedy that cannot fix it. It is now wrapped like a file at the folder's path, so the memory tools' owner-fix entry applies. ARCHITECTURE.md said the filesystem remedy is keyed on the error's path; it is keyed on the code and syscall only. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- .../__tests__/memory-store.test.ts | 36 +++++++++++++++++++ .../vault-operations/memory-store.ts | 21 ++++++----- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c537d7afc..eccef7354 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1328,7 +1328,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/vault-mcp/vault-operations/__tests__/memory-store.test.ts b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts index 413f51869..cef428c6a 100644 --- a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts @@ -726,6 +726,42 @@ 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", + blocker: undefined, + 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 diff --git a/src/vault-mcp/vault-operations/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index 82e1eea88..afecab097 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -913,20 +913,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 in its - // way is the vault owner's to move, not the caller's. mkdir reports - // a file at the folder's own path as EEXIST, and one at a folder - // above it as ENOTDIR: + // 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 remedy for a mkdir EEXIST or ENOTDIR tells the caller - // to choose another path; the memory tools' Errors entry names the + // 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 // MEMORY_DIR can be nested, and mkdir does not say which folder on // the way is a file, so the message names it for the owner From 72839e79f1622bf4808f3c53803a93ebd036aa20 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:41:53 -0400 Subject: [PATCH 09/19] refactor: one helper for best-effort cleanup; inline the prompt fallback's remedy join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vault-filesystem.ts wrote the same log-don't-throw cleanup four times; removeLeftoverFile holds that rule once. - appendCodedErrorRemedy had one caller and only joined two calls; prompt-helpers.ts now joins them as tool-helpers.ts does. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/coded-error-remedy.test.ts | 27 +++--- src/vault-mcp/mcp-core/coded-error-remedy.ts | 6 -- .../mcp-core/prompts/prompt-helpers.ts | 6 +- .../vault-operations/vault-filesystem.ts | 84 ++++++++++--------- 4 files changed, 57 insertions(+), 66 deletions(-) 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 3e55c0afc..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,5 +1,5 @@ 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" /** A Node system error as fs produces it: the message, plus the structured @@ -167,14 +167,12 @@ describe("remedyForCodedError", () => { }) }) -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, + 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.", @@ -182,22 +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, + 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"), - }), - ).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/coded-error-remedy.ts b/src/vault-mcp/mcp-core/coded-error-remedy.ts index 7b228684f..0ebb77331 100644 --- a/src/vault-mcp/mcp-core/coded-error-remedy.ts +++ b/src/vault-mcp/mcp-core/coded-error-remedy.ts @@ -95,9 +95,3 @@ export const appendRemedy = (params: { message: string; remedy: string | undefin 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 }): string => { - return appendRemedy({ message: params.message, remedy: remedyForCodedError(params.error) }) -} diff --git a/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts b/src/vault-mcp/mcp-core/prompts/prompt-helpers.ts index 43fcbcfd4..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 @@ -58,9 +58,9 @@ export const describeErrorForPromptFallback = (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 = appendCodedErrorRemedy({ + const errorText = appendRemedy({ message: describeErrorRelativeTo({ error, directory: vaultPath }), - error, + remedy: remedyForCodedError(error), }) return errorText.endsWith(".") ? errorText.slice(0, -1) : errorText diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 7a3b4eb1c..f0ec8780b 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -183,6 +183,22 @@ export const tempSavePath = (filePath: string): string => { return join(dirname(filePath), `.${randomUUID()}.tmp`) } +/** 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; logPath: string; failureLogMessage: string }, + logger: Logger, +): Promise => { + try { + await rm(params.fullPath, { force: true }) + } catch (cleanupError) { + logger.warn(params.failureLogMessage, { + path: params.logPath, + 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 @@ -201,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, logPath: tmpPath, failureLogMessage: "failed to remove temp file" }, + logger, + ) throw err } } @@ -261,31 +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, + logPath: params.filePath, + failureLogMessage: "failed to remove reservation placeholder", + }, + logger, + ) throw renameError } } finally { // 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. 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), - }) - } + // already gone. + await removeLeftoverFile( + { fullPath: tmpPath, logPath: tmpPath, failureLogMessage: "failed to remove temp file" }, + logger, + ) } } @@ -705,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, + logPath: candidateRelativePath, + failureLogMessage: "failed to remove claim placeholder", + }, + logger, + ) throw renameError } From 9a690497f42d9840a23867aa5260f88b98866ad8 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:16:34 -0400 Subject: [PATCH 10/19] refactor(memory): one entry-line predicate for the parser and the memory store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memory-store.ts kept its own copy of the entry-line regex, and the delete guard added a third for the date's shape. memory-entries.ts now exports isMemoryEntryLine; the store's entry scan and the delete guard (testing the bullet it builds) both use it, so they agree with the parser by construction. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/memory-entries.test.ts | 22 ++++++++++++- .../obsidian-markdown/memory-entries.ts | 8 +++-- .../__tests__/memory-store.test.ts | 2 +- .../vault-operations/memory-store.ts | 32 +++++++++---------- 4 files changed, 43 insertions(+), 21 deletions(-) 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..62d502d40 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,25 @@ import { describe, it, expect } from "vitest" -import { parseMemoryEntries } from "../memory-entries.js" +import { isMemoryEntryLine, parseMemoryEntries } from "../memory-entries.js" + +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: "an indented bullet", line: " - **2026-07-02**: an entry", opensEntry: false }, + ])("returns $opensEntry for $label", ({ line, opensEntry }) => { + expect(isMemoryEntryLine(line)).toBe(opensEntry) + }) +}) 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..268c33ced 100644 --- a/src/vault-mcp/obsidian-markdown/memory-entries.ts +++ b/src/vault-mcp/obsidian-markdown/memory-entries.ts @@ -37,10 +37,14 @@ 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})\*\*:/ +/** 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. The + * date is checked for shape only. */ +export const isMemoryEntryLine = (line: string): boolean => ENTRY_START_PATTERN.test(line) + /** 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+) — 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 cef428c6a..0419baf1d 100644 --- a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts @@ -1060,7 +1060,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", diff --git a/src/vault-mcp/vault-operations/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index afecab097..3817eec18 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -20,7 +20,11 @@ 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 { + isMemoryEntryLine, + 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 +57,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]/ @@ -77,11 +77,6 @@ const isValidMemoryEntryDate = (dateText: string | undefined): boolean => { const INVALID_MEMORY_ENTRY_DATE_MESSAGE = "date must be a real ISO calendar date (YYYY-MM-DD, e.g. 2026-07-02)" -/** The date shape ENTRY_PATTERN reads as an entry. A hand edit can leave an - * impossible date such as 2026-02-30 in a memory file, so deleteMemory - * checks only the shape. */ -const MEMORY_ENTRY_DATE_SHAPE = /^\d{4}-\d{2}-\d{2}$/ - const isString = (value: unknown): value is string => typeof value === "string" /** Appends "(newest first)" when absent (case-insensitive match). Trailing @@ -159,8 +154,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[] => { @@ -190,7 +185,7 @@ const scanGenuineEntryOffsets = (bodyLines: readonly string[]): number[] => { if (insideCodeOrComment) continue - if (ENTRY_PATTERN.test(bodyLine)) { + if (isMemoryEntryLine(bodyLine)) { offsets.push(offsetIndex) } } @@ -1149,9 +1144,13 @@ export const createMemoryStore = (options: { memoryDir: string }) => { }, logger: Logger, ): Promise => { - // A date of any other shape can never match an entry, so it is refused - // up front instead of ending in a guaranteed "no entry matching" miss. - if (!MEMORY_ENTRY_DATE_SHAPE.test(params.date)) { + const targetBullet = `- **${params.date}**: ${params.entry}` + + // A bullet the entry parser would not read as an entry can never match + // one, so it is refused up front instead of ending in a guaranteed "no + // entry matching" miss. The parser checks the date's shape only, so an + // impossible date a hand edit left in a file, such as 2026-02-30, passes. + if (!isMemoryEntryLine(targetBullet)) { 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 @@ -1163,7 +1162,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( From 99842d5aaf95406493846124036534c0870a9dfd Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:20:12 -0400 Subject: [PATCH 11/19] docs: say why content-hash gating is a correctness guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eccef7354..8773bd7cd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1253,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 From d06030eca326f9596ab75e22bd1270c6e498e217 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:31:52 -0400 Subject: [PATCH 12/19] fix(tools): leave memory files named with .md twice out of memory listings and recall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file like Opinions.md.md, which the old name handling created, listed as file "Opinions.md", a name every memory tool now refuses, though the listing promises its names are ones the other memory tools take. The memory listings, the all-files read and recall now leave such files out; vault_read_note still reads them. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/memory-entries.test.ts | 18 +++++++++++++++++- .../obsidian-markdown/memory-entries.ts | 8 ++++++++ .../search/__tests__/memory-index.test.ts | 18 ++++++++++++++++++ src/vault-mcp/search/search-index.ts | 10 ++++++++-- .../__tests__/memory-store.test.ts | 19 +++++++++++++++++++ .../vault-operations/memory-store.ts | 14 +++++++++----- 6 files changed, 79 insertions(+), 8 deletions(-) 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 62d502d40..f7b048aeb 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,21 @@ import { describe, it, expect } from "vitest" -import { isMemoryEntryLine, parseMemoryEntries } from "../memory-entries.js" +import { + isDoubledMarkdownFileName, + isMemoryEntryLine, + 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([ diff --git a/src/vault-mcp/obsidian-markdown/memory-entries.ts b/src/vault-mcp/obsidian-markdown/memory-entries.ts index 268c33ced..7911cb5a4 100644 --- a/src/vault-mcp/obsidian-markdown/memory-entries.ts +++ b/src/vault-mcp/obsidian-markdown/memory-entries.ts @@ -45,6 +45,14 @@ const ENTRY_START_PATTERN = /^- \*\*(\d{4}-\d{2}-\d{2})\*\*:/ * date is checked for shape only. */ export const isMemoryEntryLine = (line: string): boolean => ENTRY_START_PATTERN.test(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+) — diff --git a/src/vault-mcp/search/__tests__/memory-index.test.ts b/src/vault-mcp/search/__tests__/memory-index.test.ts index 9a9c998af..c8412e3b5 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( diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index 79a609972..6abc960ca 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(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 0419baf1d..2713b6c4c 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 }) @@ -2244,6 +2250,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) @@ -2540,6 +2553,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/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index 3817eec18..fa8cb64a0 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -21,6 +21,7 @@ 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 { + isDoubledMarkdownFileName, isMemoryEntryLine, parseMemoryEntries, type MemoryEntry, @@ -361,12 +362,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 mirrors the + * rejections in memoryFilePath, so a file on disk that no memory tool can + * name never reaches the no-file read or the list surfaces: + * - a dot-prefixed (hidden) name; + * - a name ending in .md twice, which only the listing would offer. */ 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. From abc25ee22407617ed93b3c0639ab318e1232dac0 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:42:59 -0400 Subject: [PATCH 13/19] fix(tools): say that the memory listing skips names ending in .md twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../mcp-core/__tests__/__snapshots__/tool-surface/default.json | 2 +- .../__tests__/__snapshots__/tool-surface/disabled-tools.json | 2 +- .../__tests__/__snapshots__/tool-surface/embedding-off.json | 2 +- .../tool-surface/file-tools-off+embedding-off.json | 2 +- .../__tests__/__snapshots__/tool-surface/file-tools-off.json | 2 +- .../__tests__/__snapshots__/tool-surface/obsidian-sync.json | 2 +- .../__snapshots__/tool-surface/readonly+embedding-off.json | 2 +- .../tool-surface/readonly+file-tools-off+embedding-off.json | 2 +- .../__snapshots__/tool-surface/readonly+file-tools-off.json | 2 +- .../mcp-core/__tests__/__snapshots__/tool-surface/readonly.json | 2 +- src/vault-mcp/mcp-core/tools/memory-tools.ts | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) 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 840001084..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 @@ -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": {}, 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 fc3013952..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 @@ -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": {}, 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 325f26d56..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 @@ -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": {}, 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 359b34704..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 @@ -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": {}, 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 6b0af74a4..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 @@ -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": {}, 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 c709491e7..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 @@ -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": {}, 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 3867fdad8..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 @@ -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 3cbb8dffe..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 @@ -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 df79aac7f..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 @@ -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 911b9bc0d..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 @@ -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/tools/memory-tools.ts b/src/vault-mcp/mcp-core/tools/memory-tools.ts index edc6a2324..2839e06f7 100644 --- a/src/vault-mcp/mcp-core/tools/memory-tools.ts +++ b/src/vault-mcp/mcp-core/tools/memory-tools.ts @@ -181,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. From 94759fe450207226daeb07a7f679a1f8fc55cd9c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:06:04 -0400 Subject: [PATCH 14/19] fix(tools): refuse a vault_delete_memory date with entry text after it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete guard tested the bullet it builds against the entry pattern, which is not anchored after the date, so a date such as "2026-07-02**: x" passed and could delete an entry whose text held "**:". The guard now requires the date the parser reads from that bullet to equal the date passed. memoryEntryLineDate returns that date, and the parser and isMemoryEntryLine use it too. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/memory-entries.test.ts | 15 +++++++++++++ .../obsidian-markdown/memory-entries.ts | 11 +++++++--- .../__tests__/memory-store.test.ts | 22 +++++++++++++++++++ .../vault-operations/memory-store.ts | 13 ++++++----- 4 files changed, 53 insertions(+), 8 deletions(-) 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 f7b048aeb..725998f55 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/memory-entries.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/memory-entries.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest" import { isDoubledMarkdownFileName, isMemoryEntryLine, + memoryEntryLineDate, parseMemoryEntries, } from "../memory-entries.js" @@ -37,6 +38,20 @@ describe("isMemoryEntryLine", () => { }) }) +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", () => { const lines = [ diff --git a/src/vault-mcp/obsidian-markdown/memory-entries.ts b/src/vault-mcp/obsidian-markdown/memory-entries.ts index 7911cb5a4..904bdae33 100644 --- a/src/vault-mcp/obsidian-markdown/memory-entries.ts +++ b/src/vault-mcp/obsidian-markdown/memory-entries.ts @@ -40,10 +40,16 @@ export type MemoryEntry = Readonly<{ * 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. The * date is checked for shape only. */ -export const isMemoryEntryLine = (line: string): boolean => ENTRY_START_PATTERN.test(line) +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 @@ -137,8 +143,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/vault-operations/__tests__/memory-store.test.ts b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts index 2713b6c4c..90611780c 100644 --- a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts @@ -2017,6 +2017,28 @@ describe("deleteMemory", () => { 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( diff --git a/src/vault-mcp/vault-operations/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index fa8cb64a0..35bbbc0a3 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -23,6 +23,7 @@ import { withFileLock } from "../../utils/file-write-lock.js" import { isDoubledMarkdownFileName, isMemoryEntryLine, + memoryEntryLineDate, parseMemoryEntries, type MemoryEntry, } from "../obsidian-markdown/memory-entries.js" @@ -1150,11 +1151,13 @@ export const createMemoryStore = (options: { memoryDir: string }) => { ): Promise => { const targetBullet = `- **${params.date}**: ${params.entry}` - // A bullet the entry parser would not read as an entry can never match - // one, so it is refused up front instead of ending in a guaranteed "no - // entry matching" miss. The parser checks the date's shape only, so an - // impossible date a hand edit left in a file, such as 2026-02-30, passes. - if (!isMemoryEntryLine(targetBullet)) { + // Refused up front unless the entry parser reads the bullet as an entry + // dated exactly params.date: + // - a date of another shape can never match an entry; + // - text after the date ("2026-07-02**: x") would carry part of the entry; + // - an impossible date a hand edit left in a file, such as 2026-02-30, + // passes, 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 From 67a63282bff5ad7ef1349d5306446eb8441adbed Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:07:15 -0400 Subject: [PATCH 15/19] docs(memory): say which refused names the memory listing leaves out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/vault-mcp/vault-operations/memory-store.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vault-mcp/vault-operations/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index 35bbbc0a3..aebfe36af 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -363,11 +363,11 @@ 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. It mirrors the - * rejections in memoryFilePath, so a file on disk that no memory tool can - * name never reaches the no-file read or the list surfaces: + /** 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, which only the listing would offer. */ + * - a name ending in .md twice. */ const isVisibleMemoryFile = (filename: string): boolean => { return ( filename.endsWith(".md") && !filename.startsWith(".") && !isDoubledMarkdownFileName(filename) From 120353996e6e1289387b8b6145c3a5b919d2dbd8 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:20:13 -0400 Subject: [PATCH 16/19] style: name the logged path, pass a file name to the doubled-name check, untangle the delete date comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - removeLeftoverFile's logPath read as the path of a log file; it is the path the warning names, now loggedPath. - memoryFileNameFromPath passed a whole path to isDoubledMarkdownFileName, which takes a file name; it now passes the basename (same result, since the suffix cannot span a slash). - deleteMemory's guard comment listed a passing case under a 'refused' header; the two refused cases stay bullets and the passing case is its own sentence. A blank line now separates the guard from the locked section. - isMemoryEntryLine's doc repeated the shape-only caveat stated two lines above. Ship-Check: code-quality · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/vault-mcp/obsidian-markdown/memory-entries.ts | 3 +-- src/vault-mcp/search/search-index.ts | 2 +- src/vault-mcp/vault-operations/memory-store.ts | 11 ++++++----- src/vault-mcp/vault-operations/vault-filesystem.ts | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/memory-entries.ts b/src/vault-mcp/obsidian-markdown/memory-entries.ts index 904bdae33..1cb387e43 100644 --- a/src/vault-mcp/obsidian-markdown/memory-entries.ts +++ b/src/vault-mcp/obsidian-markdown/memory-entries.ts @@ -47,8 +47,7 @@ export const memoryEntryLineDate = (line: string): string | undefined => { } /** 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. The - * date is checked for shape only. */ + * 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 diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index 6abc960ca..555c90b9a 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -1362,7 +1362,7 @@ export const createSearchIndex = ( const memoryFileNameFromPath = (filePath: string): string | null => { if (memoryDir === undefined) return null if (!filePath.endsWith(".md")) return null - if (isDoubledMarkdownFileName(filePath)) 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/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index aebfe36af..6182d49cb 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -1152,14 +1152,15 @@ export const createMemoryStore = (options: { memoryDir: string }) => { const targetBullet = `- **${params.date}**: ${params.entry}` // Refused up front unless the entry parser reads the bullet as an entry - // dated exactly params.date: - // - a date of another shape can never match an entry; - // - text after the date ("2026-07-02**: x") would carry part of the entry; - // - an impossible date a hand edit left in a file, such as 2026-02-30, - // passes, because the parser checks the date's shape only. + // 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 () => { diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index f0ec8780b..858b3dd39 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -186,14 +186,14 @@ export const tempSavePath = (filePath: string): string => { /** 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; logPath: string; failureLogMessage: string }, + 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.logPath, + path: params.loggedPath, error: describeError(cleanupError), }) } @@ -219,7 +219,7 @@ export const atomicWriteFile = async ( } catch (err) { // A failed write never strands its temp file await removeLeftoverFile( - { fullPath: tmpPath, logPath: tmpPath, failureLogMessage: "failed to remove temp file" }, + { fullPath: tmpPath, loggedPath: tmpPath, failureLogMessage: "failed to remove temp file" }, logger, ) throw err @@ -276,7 +276,7 @@ export const atomicWriteFileExclusive = async ( await removeLeftoverFile( { fullPath: params.filePath, - logPath: params.filePath, + loggedPath: params.filePath, failureLogMessage: "failed to remove reservation placeholder", }, logger, @@ -288,7 +288,7 @@ export const atomicWriteFileExclusive = async ( // target, so removing it leaves the target alone; after the rename it is // already gone. await removeLeftoverFile( - { fullPath: tmpPath, logPath: tmpPath, failureLogMessage: "failed to remove temp file" }, + { fullPath: tmpPath, loggedPath: tmpPath, failureLogMessage: "failed to remove temp file" }, logger, ) } @@ -714,7 +714,7 @@ const moveNoteToTrash = async ( await removeLeftoverFile( { fullPath: candidateFullPath, - logPath: candidateRelativePath, + loggedPath: candidateRelativePath, failureLogMessage: "failed to remove claim placeholder", }, logger, From 1c7b29ae8ba90b072a6959f9971e63e0674f9ed9 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:35:18 -0400 Subject: [PATCH 17/19] test: pin the rebuild's removal of doubled-name memory entries and the entry line's colon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memory-index: a rebuild removes entry rows an earlier index held under file "Opinions.md" for About Me/Opinions.md.md, the only path that stops recall offering that name once a deployment upgrades. - memory-entries: isMemoryEntryLine refuses a bold date with no colon after it; no other test pinned the pattern's colon. Ship-Check: test-audit · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/memory-entries.test.ts | 5 +++ .../search/__tests__/memory-index.test.ts | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) 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 725998f55..bfd1daff1 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/memory-entries.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/memory-entries.test.ts @@ -32,6 +32,11 @@ describe("isMemoryEntryLine", () => { 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) diff --git a/src/vault-mcp/search/__tests__/memory-index.test.ts b/src/vault-mcp/search/__tests__/memory-index.test.ts index c8412e3b5..4471e7e8c 100644 --- a/src/vault-mcp/search/__tests__/memory-index.test.ts +++ b/src/vault-mcp/search/__tests__/memory-index.test.ts @@ -703,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) From 5b11a928b62ec04b1c3404591d90353422a66350 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:21:03 -0400 Subject: [PATCH 18/19] refactor(memory): drop the clause naming which folder blocks MEMORY_DIR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It removed no Errors text and prevented no failure: it sharpened a message for a rare, non-default nested MEMORY_DIR, and for the default folder it only repeated the folder's name. The message is as on main again; the broken-symlink wrap stays. Ship-Check: design-pass · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../server-error-contracts.test.ts | 7 +- .../__tests__/memory-store.test.ts | 83 +------------------ .../vault-operations/memory-store.ts | 29 +------ 3 files changed, 4 insertions(+), 115 deletions(-) diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index c07fa4501..d08d283a9 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -885,12 +885,7 @@ describe("filesystem errors name the path vault-relative and carry their remedy" // The folder is server configuration, so the store wraps the raw mkdir // error instead of letting the boundary tell the caller to choose a path expect(result).toEqual({ - content: [ - { - type: "text", - text: '[Error]: cannot create memory folder "About Me": "About Me" is not a folder', - }, - ], + content: [{ type: "text", text: '[Error]: cannot create memory folder "About Me"' }], isError: true, }) }) 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 90611780c..8ed25c13a 100644 --- a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts @@ -678,7 +678,7 @@ created: 2026-01-01T00:00:00-05:00 logger, ), ).rejects.toThrow( - new Error('cannot create memory folder "About Me": "About Me" is not a folder', { + new Error('cannot create memory folder "About Me"', { cause: Object.assign(new Error(`EEXIST: file already exists, mkdir '${memoryFolder}'`), { errno: -17, code: "EEXIST", @@ -690,44 +690,6 @@ created: 2026-01-01T00:00:00-05:00 expect(warnSpy).toHaveBeenCalledTimes(1) expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { folder: "About Me", - blocker: "About Me", - error: `[Error]: EEXIST: file already exists, mkdir '${memoryFolder}'`, - }) - }) - - it("names no blocker when nothing on the way to the memory folder is a file any more", async () => { - // The file mkdir tripped over is gone by the time the store looks for it - // (another writer removed it, or a symlink there points nowhere) - const memoryFolder = join(vault, "About Me") - await rm(memoryFolder, { recursive: true }) - const mkdirError = Object.assign( - new Error(`EEXIST: file already exists, mkdir '${memoryFolder}'`), - { - code: "EEXIST", - syscall: "mkdir", - path: memoryFolder, - }, - ) - vi.mocked(mkdir).mockRejectedValueOnce(mkdirError) - 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: mkdirError })) - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { - folder: "About Me", - blocker: undefined, error: `[Error]: EEXIST: file already exists, mkdir '${memoryFolder}'`, }) }) @@ -763,7 +725,6 @@ created: 2026-01-01T00:00:00-05:00 expect(warnSpy).toHaveBeenCalledTimes(1) expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { folder: "About Me", - blocker: undefined, error: `[Error]: ENOENT: no such file or directory, mkdir '${memoryFolder}'`, }) }) @@ -789,7 +750,7 @@ created: 2026-01-01T00:00:00-05:00 logger, ), ).rejects.toThrow( - new Error('cannot create memory folder "Meta/About Me": "Meta" is not a folder', { + new Error('cannot create memory folder "Meta/About Me"', { cause: Object.assign(new Error(`ENOTDIR: not a directory, mkdir '${memoryFolder}'`), { errno: -20, code: "ENOTDIR", @@ -801,50 +762,10 @@ created: 2026-01-01T00:00:00-05:00 expect(warnSpy).toHaveBeenCalledTimes(1) expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { folder: "Meta/About Me", - blocker: "Meta", error: `[Error]: ENOTDIR: not a directory, mkdir '${memoryFolder}'`, }) }) - it("names the nested memory folder itself when its parent is a folder and it is a file", async () => { - // "Meta" is a real folder, so the check must walk past it to the file at - // "Meta/About Me" - const nestedStore = createMemoryStore({ memoryDir: "Meta/About Me" }) - const memoryFolder = join(vault, "Meta", "About Me") - await mkdir(join(vault, "Meta")) - await writeFile(memoryFolder, "not a folder") - const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}) - onTestFinished(() => warnSpy.mockRestore()) - - await expect( - nestedStore.updateMemory( - { - vaultPath: vault, - file: "Archive", - section: "Notes", - entry: "never written", - date: "2026-05-15", - }, - logger, - ), - ).rejects.toThrow( - new Error('cannot create memory folder "Meta/About Me": "Meta/About Me" is not a folder', { - cause: Object.assign(new Error(`EEXIST: file already exists, mkdir '${memoryFolder}'`), { - errno: -17, - code: "EEXIST", - syscall: "mkdir", - path: memoryFolder, - }), - }), - ) - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith("cannot create memory folder", { - folder: "Meta/About Me", - blocker: "Meta/About Me", - error: `[Error]: EEXIST: file already exists, mkdir '${memoryFolder}'`, - }) - }) - it.skipIf(runsAsRoot)( "lets a permission failure creating the memory folder through with its code", async () => { diff --git a/src/vault-mcp/vault-operations/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index 6182d49cb..bf6ec4eba 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -488,25 +488,6 @@ export const createMemoryStore = (options: { memoryDir: string }) => { return join(vaultPath, memoryDir, `${file}.md`) } - /** The first path on the way to the memory folder, from the vault root, - * that exists but is not a folder. Undefined when each path is a folder or - * missing; one can be missing after mkdir failed because it changed since, - * or because a symlink there points nowhere. */ - const findMemoryFolderBlocker = async (vaultPath: string): Promise => { - // "Meta/About Me" → ["Meta", "Meta/About Me"] - const folderSegments = memoryDir.split("/") - const foldersFromRoot = folderSegments.map((_, index) => { - return folderSegments.slice(0, index + 1).join("/") - }) - - for (const folder of foldersFromRoot) { - const folderStats = await statOrNull(join(vaultPath, folder)) - - if (folderStats && !folderStats.isDirectory()) return folder - } - return undefined - } - type MemoryTemplateSpec = { fileName: string title: string @@ -931,19 +912,11 @@ export const createMemoryStore = (options: { memoryDir: string }) => { if (!folderPathBlocked) throw error - // MEMORY_DIR can be nested, and mkdir does not say which folder on - // the way is a file, so the message names it for the owner - const blocker = await findMemoryFolderBlocker(params.vaultPath) - const blockerClause = blocker ? `: "${blocker}" is not a folder` : "" - logger.warn("cannot create memory folder", { folder: memoryDir, - blocker, error: describeError(error), }) - throw new Error(`cannot create memory folder "${memoryDir}"${blockerClause}`, { - cause: error, - }) + throw new Error(`cannot create memory folder "${memoryDir}"`, { cause: error }) } const content = buildNewMemoryFile({ From 6220a7be81c11f0a04ae875d2863e55d4d823d15 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:25:42 -0400 Subject: [PATCH 19/19] test: say the memory folder wrap also covers a broken symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permission test's comment said only a file at the folder's path is wrapped. The store also wraps a file above the folder and a broken symlink at its path. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../vault-operations/__tests__/memory-store.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 8ed25c13a..79973f257 100644 --- a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts @@ -769,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 }))