Skip to content

fix(tools): save through a short hidden temporary file and stop memory names ending in .md - #710

Merged
aliasunder merged 19 commits into
mainfrom
worktree-hidden-temp-save
Oct 25, 2026
Merged

aliasunder merged 19 commits into
mainfrom
worktree-hidden-temp-save

Conversation

@aliasunder

@aliasunder aliasunder commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

Saves now stage their content in a short hidden temporary file, and the memory tools refuse a file name ending in .md.

  • Hidden temporary file. A save wrote <note>.<uuid>.tmp beside the note, then renamed it over the note. Two problems came from that name:

    • A note named within 41 bytes of the filesystem's 255-byte limit could be read but not saved.
    • Obsidian Sync logged an ENOENT error whenever it opened the temporary file after the rename had moved it, which happened on about one save in four.

    The temporary file is now .<uuid>.tmp in the note's folder. Its name is always 41 bytes, and it is hidden, so Sync and the file watcher skip it. It stays in the note's folder, so the rename or hard link stays on one filesystem. The temporary-name remedy and its tool-description advice are removed.

  • Memory file names ending in .md. The memory tools add .md themselves, so file: "Opinions.md" created Opinions.md.md beside Opinions.md. vault_get_memory, vault_update_memory and vault_delete_memory now refuse such a name in any letter case: memory file must be named without .md: "Opinions.md". A file like Opinions.md.md that the old behaviour created is left out of vault_list_memory_files, the memory-review autocomplete, the all-files vault_get_memory read and recall, since no memory tool takes the name Opinions.md. vault_read_note still reads it, and renaming it in Obsidian makes it a memory file again.

  • Hand-edited entry dates. 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 only the date's shape (date must be YYYY-MM-DD (e.g. 2026-07-02)). vault_update_memory still refuses an impossible date for a new entry.

  • Broken memory-folder symlink. A MEMORY_DIR that is a symlink pointing nowhere now gets the cannot create memory folder message, whose Errors entry sends the vault's owner to fix it, instead of a raw ENOENT with a "retry" remedy that can never succeed.

Tool text

Tool Change Characters (default list)
vault_move_note "move aborted: could not write to …" loses the 41-byte advice 6,050 → 5,918
vault_update_memory filesystem entry loses the too-long-name rename; name entry adds .md 4,749 → 4,734
vault_delete_memory same two changes, and the date entry shortens 3,844 → 3,677
vault_get_memory name entry adds .md 3,211 → 3,261
vault_list_memory_files Behavior names the skipped .md.md names 2,087 → 2,117

The default tool list goes from 136,873 to 136,639 characters, and its Errors: text from 34,471 to 34,207. CHARS_PER_TOOL_ALLOWANCE stays at 4,198.

Tool-definition review: run on the four first-round tools in the default list and on vault_move_note in the disabled-tools list, then re-run on vault_list_memory_files after its Behavior change. No round found a defect in the text this change edits; the cold reads listed gaps elsewhere in these tools for a later description pass.

Tests

  • New tests cover:
    • saves, exclusive creates (both strategies) and moves at a name one byte under the limit;
    • the temp file's exact path;
    • a save through the hidden temp file still reaching the search index;
    • the .md refusal on all three memory tools, and .md.md files left out of the listings, the all-files read and recall;
    • deleting a hand-edited impossible date, through the entry-line test the parser uses;
    • a memory folder that is a broken symlink.
  • The near-limit tests fail on the old temporary name.
  • The contract tests for the removed temporary-name remedy are deleted.
  • npm run lint, npm run build:server and npm run knip pass.
  • npx vitest run src/vault-mcp src/__tests__/integration src/utils scripts/__tests__ passes, apart from macOS watcher timing tests that also fail on main under the same full local run.

Live Test Results

Local images with a scratch vault, MEMORY_DIR="Meta/About Me" and no Sync. Before is ghcr.io/aliasunder/vault-cortex:0.55.3 (revision 8e2ce992ed143476f9957197e90fc21c0c2a0f63). After is this branch built with --target local at 390a7bd9.

Probe Before (v0.55.3) After
vault_update_memory file: "Probe.md" Added entry to Meta/About Me/Probe.md.md; file created memory file must be named without .md: "Probe.md"; nothing written
vault_get_memory file: "Probe.md" returned Probe.md.md same refusal
vault_delete_memory on a 2026-02-30 entry date must be a real ISO calendar date … Deleted entry from Meta/About Me/Hand.md → ## Notes
vault_delete_memory date: "2026-1-1" date must be a real ISO calendar date … date must be YYYY-MM-DD (e.g. 2026-07-02)
vault_update_memory on a memory file with a 254-byte name failed at the temp file (Unknown system error -115 on Docker Desktop's bind mount) entry added
vault_write_note to a 254-byte name failed at the temp file (same error) written
vault_move_note to a 254-byte name move aborted: could not write to … moved

No .tmp file was left in either vault, and neither container restarted.

Sync check against real Obsidian Sync (production, the same window length counted from each container's start):

Before (v0.55.3, 8e2ce992) After (Test Deploy of 9a690497)
Server writes 18 25, including 10 fixture notes created and then edited
Fixture uploads Sync completed 4 of 4 20 of 20
ENOENT … .tmp Sync errors 5 0
Other Sync errors 0 0

The test build was confirmed by the container's image revision label and by symbols only this branch has in its dist/. The fixtures were deleted afterwards. The commits after 9a690497 change memory listings, the all-files read, recall and the delete guard's internals, which the unit and integration tests cover, plus comments and docs.

This PR is releasable on its own.

🤖 Generated with Claude Code

aliasunder and others added 3 commits October 24, 2026 20:50
A save staged its content in `<note>.<uuid>.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 `.<uuid>.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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…date ARCHITECTURE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/vault-mcp/vault-operations/memory-store.ts
Comment thread src/vault-mcp/vault-operations/memory-store.ts
@umm-actually

umm-actually Bot commented Oct 25, 2026

Copy link
Copy Markdown

Make vault_list_memory_files skip names the .md refusal now rejects
Low severity · correctness · medium confidence

src/vault-mcp/vault-operations/memory-store.ts:457 — beyond the diff's line ranges, in code the changes touch or depend on.

The PR adds a refusal for memory file names ending in .md, but the lister still derives a tool-usable name from any visible .md file via basename(filename, '.md'), so a doubled-extension file (e.g. one created by the old bug this PR fixes, or by hand) is listed as file: 'Opinions.md'. The listing's own description promises 'file is the name the other memory tools take as file (no .md)', and every tool that name feeds now rejects it, leaving the listed file operable through no memory tool and only discoverable through the refusal error.

Failure scenario: A vault holds About Me/Opinions.md.md left over from the pre-fix bug (file: 'Opinions.md' created it). vault_list_memory_files returns file: 'Opinions.md'; the caller passes that exact name to vault_get_memory, vault_update_memory, or vault_delete_memory and gets 'memory file must be named without .md: “Opinions.md”' on every call — the file the listing recommended is unusable through any memory tool.

Suggested fix
In listVisibleMemoryFilenames (or the outline/name mappers), skip names whose basename-minus-.md still ends in .md (case-insensitive), so the listing only offers names the tools accept — or surface such files with a flag the descriptions can document.

umm-actually · z-ai/glm-5.3-flash

@umm-actually

umm-actually Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

umm-actually re-reviewed at 6220a7b

1 new finding(s) posted (8 tracked finding(s) across all runs).

Context notes
  • Priority docs already in context: ARCHITECTURE.md
  • Priority docs not included: deploy/railway/README.md, deploy/render/README.md, deploy/local/README.md, deploy/remote/README.md (missing, unreadable, or over budget)
  • 14 changed file(s) excluded from review: src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json (diff_exclude_paths input)

umm-actually · z-ai/glm-5.3-flash

aliasunder and others added 8 commits October 24, 2026 21:58
- 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 <noreply@anthropic.com>
Ship-Check: triage · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ship-Check: triage · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…folder listings after saves

- 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 <noreply@anthropic.com>
…p the stale path key from the remedy docs

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 <noreply@anthropic.com>
…ack's remedy join

- 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 <noreply@anthropic.com>
…ory store

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 <noreply@anthropic.com>
Ship-Check: triage · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/vault-mcp/mcp-core/tools/vault-crud-tools.ts
…tings and recall

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 <noreply@anthropic.com>
@aliasunder

Copy link
Copy Markdown
Owner Author

Re the PR-level finding "Make vault_list_memory_files skip names the .md refusal now rejects" (umm-actually, comment 6391962464): Fixed in d06030e. A file whose name ends in .md twice (any letter case), such as Opinions.md.md, is now left out of vault_list_memory_files, the memory-review autocomplete, the no-file vault_get_memory read and recall, so every name those surfaces offer is one the memory tools take. vault_read_note still reads such a file. One helper, isDoubledMarkdownFileName in memory-entries.ts, holds the rule for both the memory store and the search index.


🔍 ship-check · pr-monitor · claude-opus-5-5

Comment thread src/vault-mcp/vault-operations/memory-store.ts
Comment thread src/vault-mcp/vault-operations/vault-filesystem.ts
@umm-actually

umm-actually Bot commented Oct 25, 2026

Copy link
Copy Markdown

Update vault_list_memory_files' Behavior text for the doubled-name exclusion
Low severity · subtle bugs · high confidence

src/vault-mcp/mcp-core/tools/memory-tools.ts:184 — beyond the diff's line ranges, in code the changes touch or depend on.

The description still promises 'Lists every .md file directly inside' the memory folder, but this PR's listing filter now also drops files whose name ends in .md twice, so a legacy doubled file is silently absent from the enumeration the text states. The PR description carries the same stale claim ('still shows in memory listings as Opinions.md'), so both surfaces now disagree with the shipped behavior the tests pin.

Failure scenario: A vault holds About Me/Opinions.md.md left over from the pre-fix bug this PR describes. An agent reading vault_list_memory_files' description trusts 'Lists every .md file directly inside About Me/' to enumerate the folder, never learns the doubled file exists, and a cleanup pass built on that enumeration never touches it — the file's only discoverable trace is a refusal error if something happens to pass file: 'Opinions.md'.

Suggested fix
Amend the Behavior sentence to state the second exclusion, e.g.: 'Behavior: 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 (which no memory tool can address) are skipped.'

umm-actually · z-ai/glm-5.3-flash

Ship-Check: pr-monitor · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aliasunder

Copy link
Copy Markdown
Owner Author

Re the PR-level finding "Update vault_list_memory_files' Behavior text for the doubled-name exclusion" (umm-actually, comment 6393517297): Fixed in abc25ee. The Behavior sentence now reads "Subfolders are not read, and hidden (dot-prefixed) files and names ending in .md twice are skipped." The PR description states the same.


🔍 ship-check · pr-monitor · claude-opus-5-5

aliasunder and others added 6 commits October 25, 2026 01:06
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 <noreply@anthropic.com>
Ship-Check: triage · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck, untangle the delete date comment

- 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 <noreply@anthropic.com>
…e entry line's colon

- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@umm-actually

umm-actually Bot commented Oct 25, 2026

Copy link
Copy Markdown

Wrap bootstrapMemoryDir's mkdir failure like updateMemory's
Low severity · correctness · medium confidence

src/vault-mcp/vault-operations/memory-store.ts:1238 — beyond the diff's line ranges, in code the changes touch or depend on.

Pre-existing: bootstrapMemoryDir creates the memory folder with a bare mkdir, so a MEMORY_DIR that is a dangling symlink throws a raw ENOENT out of server startup — while this PR's own fix routes the identical state through the wrapped, owner-facing 'cannot create memory folder' error on tool writes, and reads already degrade gracefully (the folder listing returns empty). The boot path is the one surface left treating an owner-fixable memory-folder obstruction as a raw failure.

Failure scenario: MEMORY_DIR points at a symlink whose target was deleted (e.g. the linked folder was moved). On every container start, bootstrapMemoryDir's access() sees ENOENT and falls through to mkdir(dirPath, { recursive: true }), which throws the raw ENOENT the PR's own new test shows this exact call produces for a dangling symlink; startup fails or logs the container's absolute path instead of the owner-fix message the tool path now gives, and the restart policy loops on a state nothing in the boot output explains.

Suggested fix
Apply the same folderPathBlocked wrap (EEXIST/ENOTDIR/ENOENT → 'cannot create memory folder "…"' with the cause logged) around bootstrapMemoryDir's mkdir, mirroring updateMemory's.

umm-actually · z-ai/glm-5.3-flash

@aliasunder

Copy link
Copy Markdown
Owner Author

Wrap bootstrapMemoryDir's mkdir failure like updateMemory's (comment 6394424161)

The maintainer decided not to change this. When MEMORY_DIR is a broken symlink, or a file sits where one of its parent folders belongs, startup already logs failed to start server with the raw error code and the full path, then exits 1. Wrapping the error would change only that log line's wording, and the server would still refuse to start, which is the intended behaviour when required configuration is broken. The setup is rare and non-default. Unlike the tool path, startup has no caller-facing remedy for the wrap to correct.


🔍 ship-check · pr-monitor · claude-opus-5-5

@aliasunder
aliasunder merged commit e28c802 into main Oct 25, 2026
18 checks passed
@aliasunder
aliasunder deleted the worktree-hidden-temp-save branch October 25, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant