Engram::MemoryFile
A single parsed memory migration file: frontmatter metadata plus markdown body.
Files live at .agents/memories/<ID>_<slug>.md. The filename is
canonical for id and slug; the frontmatter id must match it.
Constants
Matches <14-digit-id>_<slug>.md; captures the id and slug portions.
Constructors
Builds a memory file record from already-parsed fields.
Parses raw migration-file content, using path for filename validation and error context.
Class methods
Raises Engram::DuplicateIdError if any two of files share an id.
Atomically claims the next unused migration id for a memory whose
filename slug is slug under memories_dir, and publishes the block's
result as that file's content in one step. Returns {id, path}.
This is the race-free replacement for the naive "next_id then
File.write" pattern: that pattern has a TOCTOU gap where two callers
(two processes, or two near-simultaneous remember/new calls) can
both land on the same id in the same wall-clock second, and the second
File.write truncates the first one's file with no error and no
duplicate-id detection, because only one file ever ends up on disk.
The id -- not the <id>_<slug>.md pathname -- is the memory's
globally-unique key: two files sharing an id are a duplicate-id conflict
even when their slugs (titles) differ, so a candidate id is rejected if
ANY <id>_*.md already exists, not just one matching this exact slug.
We combine two guards to that end:
-
A one-time directory snapshot (like
next_id): a candidate whose id-prefix already appears is skipped. This is what makes repeated claims within the same second -- even with different slugs -- march forward to distinct ids (the common, single-process case:engram newtwice, or an MCP session firing severalremembers a second apart). -
For each surviving candidate we write the block's result (the block receives the candidate id, since the frontmatter embeds it) to a fresh same-directory temp file, then
File.linkthat temp file onto the final<id>_<slug>.mdpath.link(2)is atomic with respect to existence: if the target already exists -- because a concurrent caller already claimed that exact id/slug pair after our snapshot -- the link fails withFile::AlreadyExistsErrorand the existing file is left completely untouched (the core defect this replaces: a plainFile.writewould have silently truncated it). On that failure we discard the temp, bump the id by a second, and retry; on success the temp is unlinked (it was only ever a staging name) and{id, path}is returned.
memories_dir must already exist (same precondition as next_id;
callers already Dir.mkdir_p it first). start_time defaults to
Time.utc and exists so specs can freeze the clock to deterministically
reproduce two claims racing in the same second.
The next unused 14-digit migration id for memories_dir: the current UTC
time, bumped a second at a time until no existing <id>_*.md file already
claims it, per a one-time directory scan.
NOTE: this is a best-effort peek, not a claim -- two calls to next_id
in the same wall-clock second (from two processes, or from new/
remember racing each other) can and will return the same id, because
nothing stops either caller's subsequent plain File.write from
silently truncating whatever the other one just wrote. Anything that's
actually about to create the migration file must use claim_and_write
instead, which makes the id allocation and the file's creation a single
atomic step. next_id remains only for callers that just want to know
what id would be used next without writing anything.
The repo-relative form of path (a file living under memories_dir):
the last two path segments of memories_dir (conventionally
".agents/memories") joined with the file's basename. Keeps the file_path
schema column (docs/SPEC.md: "repo-relative source path") stable no
matter how absolute memories_dir and path happen to be for the caller.
Instance methods
SHA256 hex digest over the meaningful fields (not id/slug/path), used by
sync to detect whether a memory's content changed since it was applied.
Topics are lowercased in the canonical string to match how Store
persists them (docs/SPEC.md: "comma-joined, lowercased") — otherwise a
memory whose frontmatter uses mixed-case topics would hash differently
from the record read back from the store and sync would treat it as
perpetually changed.