Skip to content
RO
← All writing

The memory that depended on a folder

  • claude-code
  • windows
  • ntfs
  • python
  • backups

I asked about an article we’d written a while back. The answer came back that there was no record of it anywhere, and that the memory directory didn’t exist.

That was true. It was also completely wrong, in the way that only true things can be.

Two directories, one machine

Claude Code keeps its persistent notes as plain markdown files, one fact per file, with an index that gets loaded at the start of every session. Mine had been accumulating since June: 97 files, an index with 96 hand-written lines, warnings I’d written after things went wrong the first time.

All of it lived here:

D:\Claude\projects\C--Users-laure\memory\

That path is derived from the working directory the session started in. Sessions I’d started from C:\Users\laure got the lot. This session started in D:\Claude, so it looked in:

D:\Claude\projects\D--Claude\memory\

Which existed, and was empty. Same machine, same config root, same everything else. The only difference was which folder I happened to be sitting in when I typed the command.

Nothing was broken. Nothing had been deleted. There was no error, because from the tool’s point of view an empty memory directory is a perfectly ordinary state for a new project. It just quietly presents as amnesia.

I’ve been building things long enough to recognise the shape of this. Any state keyed by a path that varies with how you launched the program will eventually fork into two states, and you’ll find out when the two disagree. Browser profiles do it. Node’s global installs do it. Python’s user site-packages do it. It’s the same bug wearing different clothes each time, and it always looks like data loss before it turns out to be data in the wrong drawer.

The fix, which is boring

One real directory, D:\Claude\memory, and NTFS junctions at each of the project paths that point at it:

New-Item -ItemType Junction -Path 'D:\Claude\projects\D--Claude\memory' -Target 'D:\Claude\memory'

Junctions don’t need admin rights, unlike symlinks on Windows without developer mode, and everything reading or writing through them lands in the same place. I put the real folder outside projects\ deliberately: project folders hold transcripts and get cleaned up on a schedule, and I’d rather my notes weren’t sitting inside something with a retention policy.

Then I tested it in both directions, because a link you haven’t written through is a link you’re guessing about. Wrote a file through one junction, read it from the target and from the other junction, deleted it at the target, confirmed both junctions saw it go.

One thing worth knowing if you do this: don’t rm -rf a junction from Git Bash. It can follow the link and take the target’s contents with it rather than just removing the pointer. PowerShell’s Remove-Item on the junction, or cmd //c rmdir, removes the link only.

The part I nearly broke

Before merging I copied both stores to a backup folder and checksummed the copies against the originals. Good habit. Thirty minutes later it stopped being a habit and started being the thing that saved the afternoon.

I was editing one of the memory files with a small Python script, adding a paragraph. The script ended with the obvious line:

io.open(path, 'w', encoding='utf-8').write(new_text)

new_text contained an emoji I’d written as two escape sequences, '\ud83d\udd34', which is a surrogate pair. That’s how it’s spelled in UTF-16, and Python 3 strings don’t work that way: what I’d actually built was a string containing two lone surrogates, which cannot be encoded to UTF-8 at all. The correct spelling is '\U0001F534'.

So the write raised UnicodeEncodeError. Fine. Except open(path, 'w') truncates the file the moment it opens it, and it opens before anything gets encoded. The exception fired after the truncation and before a single byte was written.

21,160 bytes to zero, with a traceback that looks like nothing happened.

I only spotted it because the next command printed lines: 0 for a file I knew had 212. The backup was ten minutes old, so recovery was a cp. Without it I’d have been reconstructing a memory file from a transcript.

The rewrite is not clever:

tmp = path + '.tmp'
io.open(tmp, 'w', encoding='utf-8', newline='').write(new_text)
assert io.open(tmp, encoding='utf-8').read() == new_text
shutil.move(tmp, path)

Encode into a temp file, read it back, then move it over the original. The move is atomic on the same volume, and if anything throws, the file you cared about hasn’t been touched.

I’ve written that pattern into deploy scripts for years and never bothered for a “quick” edit to one file. That’s the actual lesson, and it isn’t about Unicode. The dangerous edits are the small ones, because small edits don’t feel like they deserve the ceremony.

What I kept

The store is merged and shared now, so it doesn’t matter which folder I start from. The index is scoped, on purpose, to the two things I actually want loaded every session, with the rest of the files sitting in the same folder unindexed rather than deleted.

And there’s a new note in there, written today, that says: D:\Claude\memory is the real directory, these three paths are junctions to it, a new project folder won’t get one automatically, and that’s how the split comes back.

Which is the only reliable fix for this class of problem. Not the junction. The note explaining why the junction is there.