A memory tool it removed

fx

A coding agent in Zig whose durable memory was a flat JSON array of strings, with a tool description that forbade writing anything the user did not ask to persist — until the tool was removed on 31 August 2026, ten days after its loader was changed to fail closed on a corrupt store.

Carries 0 of 7 rubric mechanisms. Most systems here carry none or one (41%), and a dash means the mechanism was not found at this commit — not that the system needed it. Each mark is one LLM reviewer's reading of the code at this commit rather than a run of it — known limits.

  • Tombstone
  • Trust state
  • Bi-temporal
  • Scope enforced
  • Mutation audit
  • Human review
  • Negative evals

1. Executive Summary

At this commit fx has no memory tool. On 31 August 2026 commit ae38593d… removed src/tools/memory/memory.zig and the ~/.fx/memories.json path, leaving existing files untouched and rejecting stale memory calls through generic dispatch; a registry test asserts lookup("memory") == null. Ten days earlier, 715ced44… had fixed the data-loss path this report was written about: loadMemories returned named errors for a malformed, oversized or unreadable store, save and clear refused with a message saying the file was not modified, the truncate-and-rewrite fallback was dropped, and a unit test and an end-to-end test asserted a corrupt store's bytes survive a save. No lock was added. The session layer described below remains. What follows is the memory tool as it stood at a0f73b4d…, and every memory path refers to that tree. It stays because the write policy in its description is reusable whatever store it guards.

fx is a coding agent harness and CLI in Zig from vercel-labs — Apache-2.0, roughly 677,000 lines under src/, 394 commits and 6 contributors since 11 August 2026, shipping as a 7.8 MiB binary and labelled Experimental in its own README. It is deliberately minimal, closer to a Unix shell than a terminal IDE.

It has two durable stores and they are built to completely different standards.

The session layer is serious infrastructure: an events.jsonl append-only log, a two-phase authority.pending.json / authority.json protocol, a separate commit.pending.json publication intent, lock files with a 2-second deadline, 0700/0600 permissions, log generations, replay and projections, and a compaction that rewrites the log to a snapshot while refusing — with a named error, ImmutableSessionIdentity — any replacement that would alter the session's id, creation time or workspace roots.

The memory tool is a JSON array of strings in ~/.fx/memories.json with three verbs: save, list, clear.

The gap between those two is the report. The memory tool is not badly written — it is 350 lines, it dedupes exactly, it writes atomically, and its clear is declared irreversible to the harness so the UI can treat it accordingly. It is simply built as though nothing could go wrong with a file, and one path through it loses data silently.

loadMemories returns an empty list when the file is missing, when the read fails, when the JSON does not parse, and when the root is not an array — four different conditions, one answer, no error surfaced to anyone. save then appends the new fact to that empty list and rewrites the file. So a memories.json that has been hand-edited into invalid JSON, truncated by an interrupted write, or grown past the 1 MiB read cap does not produce a warning: it produces "remembered", and a file containing exactly one memory where there used to be many. The tool's own error message for a failed clear names the path and invites the user to look at it, which is the most likely way a file becomes unparseable in the first place.

Against that, fx got one thing right that is usually left to chance. The description the model reads says what must not be written:

When NOT to use: store task notes, secrets, project facts, temporary context, or anything the user did not ask to persist.

That is a consent rule for memory writes, placed where the writer will see it, and it is the direct answer to the self-reinforcement problem — a model that decides on its own what is worth remembering.

2. Mental Model

Hold the two stores apart, because their designers clearly did.

A session is machinery. It is event-sourced, crash-safe, multi-process-aware, and reduced by compaction into a snapshot when it grows. It answers "what happened in this run, and can I resume it".

A memory is a sentence the user asked to keep. It has no id, no date, no origin and no shape. It answers "what did the user tell me about how they like to work", and it survives every session because it lives outside all of them.

That second store is the smallest thing that could be called agent memory, and the design is coherent at its own scale: with ten preferences, returning all of them costs nothing, exact-match dedup is sufficient, and clearing everything is a reasonable correction. The failure modes are all failures of growth — of the file, of the count, of the number of machines writing it.

Diagram — The memory tool's three verbs over one JSON array. Four different read failures collapse into the same empty list, and because save appends to whatever load returned, the next save writes a one-element array over a file that could not be read.
Diagram source
%% caption: The memory tool's three verbs over one JSON array. Four different read failures collapse into the same empty list, and because save appends to whatever load returned, the next save writes a one-element array over a file that could not be read.
flowchart TD
    M["model calls memory tool<br/>{action, fact}"] --> A{action}
    A -->|save| L["loadMemories(~/.fx/memories.json)"]
    A -->|list| L2["loadMemories"] --> P["print every entry<br/><i>no query, no ranking, no cap</i>"]
    A -->|clear| D["deleteFileAbsolute<br/><i>isIrreversible = true</i>"]
    L --> F{"file opens?<br/>read under 1 MiB?<br/>parses as JSON?<br/>root is an array?"}
    F -->|"any one fails"| E["return EMPTY list<br/><b>no error surfaced</b>"]
    F -->|"all pass"| K["existing facts"]
    E --> S["append fact → saveMemories<br/><b>one-element array overwrites the file</b>"]
    K --> DE{"byte-equal to an existing fact?"}
    DE -->|yes| R["return 'remembered', write nothing"]
    DE -->|no| S2["append → rewrite whole array"]
    S --> OK["returns 'remembered'"]

3. Architecture

src/core holds the agent runtime, session store, permissions, skills, hooks, MCP client, subagents and terminal integration. src/tools holds the tool implementations — filesystem, shell, terminal, session, skills, web, and memory. src/builtins/tools.zig is the registry that binds each tool's decode, validate, call, presentation and permission behaviour into one ToolSpec.

The memory tool is registered there like any other, with permission_target_kind = .none — no approval gate — and an isIrreversible predicate returning true for clear.

4. Essential Implementation Paths

src/tools/memory/memory.zig is the whole memory system in 350 lines, and runMemory is the function to read.

save resolves $HOME, loads the array, walks it comparing with std.mem.eql — exact bytes, no normalisation, so "prefers tabs" and "Prefers tabs" are two memories — appends when nothing matches, and rewrites the file. A duplicate returns "remembered" without writing, so the tool reports success identically whether or not it stored anything.

list returns every entry as - {s}\n lines. There is no argument to filter or limit it.

clear deletes the file, treating FileNotFound as success, so it is idempotent.

loadMemories is where the risk is. Four catch return list paths and one if (parsed.value != .array) return list all produce the same empty result, and the 1 MiB ceiling in readFileToEnd is one of them.

5. Memory Data Model

A JSON array of strings. That is the entire schema.

No identifier means a memory cannot be referenced, so it cannot be updated or individually removed. No timestamp means nothing can age or be ordered by recency. No origin means a preference the user stated and one the model inferred are indistinguishable once written — which is exactly what the tool description tries to prevent by policy, having no way to enforce it by structure.

6. Retrieval Mechanics

list returns the whole store, and this report's retrieval stack is empty because there is nothing to describe. At the intended scale that is the right answer: a query planner over eleven preferences would be worse than useless.

The cost is that the store's size and the context cost of consulting it are the same number, and nothing bounds either. There is no cap on entries, no cap on the length of a fact, and no way for the model to ask for a subset — so the only mechanism that keeps the memory affordable is the tool description telling the model not to save much.

7. Write Mechanics

Synchronous, whole-file, on every save. saveMemories creates the parent directory, serialises the array with two-space indentation, and calls writeFileAtomic — a proper temp-file-plus-rename that preserves existing permissions and refuses a read-only target. If that fails it falls back to createFileAbsolute with .truncate = true and a streaming write, which is the one non-atomic path in the tool and the one that can leave a partial file behind.

A partial file is unparseable JSON. Unparseable JSON reads as an empty store. An empty store plus one save is a one-element file. The three steps are individually reasonable and compose into silent loss.

8. Agent Integration

A builtin tool with a gateway schema, offered to the model alongside the filesystem and shell tools, with no permission prompt.

The tool descriptions across this repository follow a consistent "When to use / When NOT to use" template, and it is worth reading a few together. semantic_search says of itself: "This is not embedding or true semantic search." memory rules out secrets, project facts, task notes and "anything the user did not ask to persist." Writing the negative half of a tool's contract into the description is cheap, and it is where a harness gets to state a memory-write policy at all when the store cannot enforce one.

9. Reliability, Safety, and Trust

No epistemic state, no provenance, no scope. One file in $HOME is shared by every workspace, so a preference saved while working on one project is in context for all of them — defensible for "durable user preferences", which is what the description says the tool is for, and a mismatch with fx being otherwise strictly per-project.

Two concurrent fx processes will lose writes: save is read-modify-write over the whole file with no lock, while the session layer three directories away has lock files, a two-phase intent protocol and a 2-second acquisition deadline. The same repository demonstrates the technique it does not apply here.

clear being declared irreversible to the harness is the right instinct, and it is the only place in the memory path where the possibility of regret is represented at all.

No capability mark is carried, and that is a real answer rather than an omission: there is no rejected-value record, no status field, no scope key, no mutation audit of the memory store, no human gate, and no committed case asserting that anything must not be returned.

10. Tests, Evals, and Benchmarks

8,286 test blocks across src/ — an unusually high density, and the session layer, the tool registry and the UI runtime are all covered in depth.

The memory tool is tested through the tool runtime: saving the same fact twice yields one entry, clear is idempotent, list after clear reports no memories, and a missing $HOME produces "memory unavailable: HOME not set". Those are the right cases for the happy path.

None of them covers a memories.json that exists and cannot be read. There is no test for malformed JSON, for a root that is not an array, or for a file past the 1 MiB cap — which is to say the four conditions that share an answer are the four the suite does not distinguish either. A test that writes {"oops":1} to the path, calls save, and asserts the prior facts survive would have failed at that commit; 715ced44… added that test, as "memory corrupt store fails closed and preserves original bytes", beside one that distinguishes missing, oversized and unreadable stores.

11. Patterns Worth Stealing

Write the negative half of the tool contract. "When NOT to use: … anything the user did not ask to persist" is a memory-write policy delivered to the only component that can honour it, and it costs one sentence.

Declare irreversibility to the harness. isIrreversible returning true for clear lets every layer above the tool treat that call differently without knowing what the tool does.

Say what your search is not. semantic_search's description states plainly that it is not embedding search — a name that overclaims, corrected where the model reads it.

And three to avoid

One value for "empty" and "unreadable". A loader that returns the same result for nothing stored and cannot read what is stored hands its caller a decision it cannot make correctly, and here the caller's next action overwrites the file.

A read cap with no error. Silently truncating to empty at 1 MiB converts a capacity limit into data loss.

Two standards in one repository. Locks, two-phase commit and an immutability invariant for session bookkeeping; read-modify-write with no lock for the thing the user explicitly asked to keep forever.

12. Open Questions

  • Was the 1 MiB cap chosen for memories or inherited? It is a general readFileToEnd argument, and at roughly a hundred bytes a preference it allows about ten thousand of them — plausibly beyond any intended use, which would make it a latent limit rather than a live one.
  • What replaces durable user preferences now that the tool is gone? The removal commit names no successor, and nothing at this pin persists a fact across sessions outside the session log.

Appendix: File Index

Path What it carries
src/tools/memory/memory.zig The entire memory system: save, list, clear, and loadMemories
src/builtins/tools.zig Tool registration, the memory description, and the irreversibility hook
src/core/shared/profile_paths.zig ~/.fx/memories.json
src/core/shared/io.zig writeFileAtomic and the read cap
src/core/session/session_log.zig The event log, locks, two-phase commit and log compaction
src/core/session/session_event.zig ImmutableSessionIdentity and the replacement guards
src/core/tooling/tool_runtime.zig The memory tool's happy-path tests

History

2026-09-15e45d7809… — 1,692 commits on, 2026-09-15. Screened before reading: no auto-run surface, two unpinned surfaces, nine dependency surfaces inside the cooldown, and an AGENTS.md addressed to reading agents, recorded as data; nothing was installed or run. The memory tool is gone: ae38593d… (2026-08-31) removed memory.zig, its profile path and its tool registration, and left existing memories.json files in place. Before that, 715ced44… (2026-08-21) made the loader fail closed on a malformed, oversized or unreadable store and added the test this report said was missing; save stayed lock-free. The session layer's authority protocol, commit.pending.json intent and ImmutableSessionIdentity guard are still present. The body keeps the design as it was at the previous pin, framed as removed. No marks, before or after.

2026-08-19a0f73b4d… — first reading, at an experimental stage the README labels as such. Screened before reading: no auto-run surface, one dependency manifest inside the seven-day cooldown, two unpinned ranges in test packages, and an AGENTS.md carrying instructions addressed to a reading agent, which this atlas records as data rather than following. Nothing was installed and no test was run; the corrupt-file path was established by reading loadMemories and its caller, not by executing them.