# Your MEMORY.md is a file nobody writes to. Render it instead.

> An always-injected memory file got one bullet in four months while the store grew to 42k chunks. Rendering it per session from the DB, and what valid_at fixed.

- Author: Chad Priest
- Published: 2026-08-27
- Canonical URL: https://blog.vodou.ai/render-memory-md-from-database/
- Tags: ai-agents, memory, llm, architecture, retrieval

---

Every agent harness injects a memory file at session start. Claude Code has `MEMORY.md`. OpenClaw has a workspace `MEMORY.md`. Starpod keeps one per user. The file is the one thing the model reads on every turn, so it is the highest-leverage artifact in the whole memory stack. And in most systems it is hand-edited, which means it is maintained by whoever remembers to maintain it, which means nobody.

I found this out by counting. My memory store held 42,000 chunks. The machine-managed zone of the injected file had received one bullet since 2026-04-08. Four months. One bullet. The retrieval pipeline was working; the artifact the model actually saw was frozen.

## One bullet in four months against 42k chunks in the store

The fix was to stop treating the file as a source and start treating it as a view. `MEMORY.md` is now rendered from the database at session start, per session and per project, by a deterministic renderer.

The rendering has three tiers. Pinned chunks go first, under Identity, Preferences, Decisions and Notes, and they appear in every project. Then the memories tagged with the project the session is running in, resolved from the working directory by the same longest-root-path rule the prompt hook already used. Then global memories fill the remainder of a 6 KB budget (later raised to 8 KB), restricted to durable tags or importance 7 and above. Imports and raw captures are excluded from the render entirely; more on why below.

**Diagram: The file is a view, not a source**

Session start asks the daemon to render MEMORY.md from memory.db; if the daemon is down a 60-second-old snapshot on disk stands in

```text
  [memory.db] --> [renderer]
  [renderer] --> [daemon]
  [daemon] --atomic write--> [.vodou/workspace/MEMORY.md]
  [SessionStart hook (fixed)] --render for this cwd--> [daemon]
  [daemon] --per-project text--> [SessionStart hook (fixed)]
  [.vodou/workspace/MEMORY.md] --fallback if daemon down--> [SessionStart hook (fixed)]
  [SessionStart hook (fixed)] --> [model context]

  notes:
    memory.db: 42k chunks, pins, project_id, valid_at
    renderer: pinned, then project, then global fill to budget
    daemon: rewrites the global snapshot every 60s
    .vodou/workspace/MEMORY.md: snapshot; edits are lost

  Nothing a human edits is what the model reads.
```

The file on disk still exists, because two readers (the workspace loader and the gateway) read it without talking to the daemon. The daemon rewrites it every 60 seconds, atomically, and the write is a no-op modulo the timestamp line if nothing changed. If the daemon is down at session start, the snapshot stands. If it is up, the hook gets a per-project rendering and splices it into the injected section.

For a person using it, the change is one rule: never edit the file. To change what every session sees, pin a chunk or pin a sentence into a section. Everything else in the file is earned by importance, tag and recency, and it moves.

## 74 of 42k chunks carried a project id, so the project tier was empty

The renderer worked on day one and the project tier was thin to the point of uselessness. The reason was not the renderer. Only 74 of 42,000 chunks had a `project_id`. The tier had nothing to select.

The evidence to fix that had been on disk the whole time. Every Claude Code transcript line carries `cwd`. Nothing had ever read it. So the IDE capture path now resolves a transcript's dominant `cwd` to a project and stamps the conversation, and the extractor, which already wrote `project:<id>` on bullets from tagged conversations, needed no change. A backfill pass over 489 transcripts on disk matched 341; three were tagged to a real project and 338 resolved to the install root, which is global by design. Historical chunks were not retagged, because there is no chunk-to-conversation link, and I rejected keyword tagging outright: a wrong project tag hides a memory from every other project's recall, which is worse than no tag.

Then the stamp itself went wrong. `extract_project` trusted any `project:` token found in text, so a plan document that merely mentioned the token got stamped with whatever followed it. Seven live chunks carried ids like `<id>`, `changed` and `\S+\s+)?`. A junk stamp is strictly worse than none: a non-NULL `project_id` is neither global nor in any real project, so the chunk falls out of the rendered file, out of the project boost in search, and out of every vault carrying a project rule. My first guard pinned the id to exactly eight hex characters, and three existing tests failed because they use `proj_abc` fixtures. That was the useful failure. An over-strict validator drops a legitimate stamp, which does the identical harm to the junk it stops. The shipped guard checks shape (the `proj_` prefix), which all seven junk values fail on their own.

## Zero of 42,798 chunks had a valid_at, and imports outranked native facts

The bigger dead end was time. The renderer and the search ranker both sort on recency. Recency was `created_at`. `created_at` answers when a row was written, not when the fact was true, and my temporal-memory plan had specified a `valid_at` column for exactly that distinction. The column shipped. The writer never did. I audited on 2026-08-16: 0 of 42,798 chunks had one.

Two defects had been hiding inside that zero. First, import collapse: an archive is written all at once, so all 3,403 ChatGPT import chunks, three and a half years of history, landed inside one 24-hour window. Second, re-sync re-dating: re-indexing a file deletes and reinserts its chunks, and `created_at` was not in the preserve map, so every chunk of `memory/2026-05-01.md` read 2026-05-02 03:56, the following night's pass. No chunk's date matched its own filename.

**Diagram: What the audit found on 2026-08-16**

Chunks with a valid_at before the writer existed: 0 of 42,798; ChatGPT import chunks collapsed into one 24-hour window: 3,403

```text
  chunks with valid_at            : 0 of 42,798  # (problem)
  import chunks in one 24h window : 3403 chunks  ################################ (problem)
  chunks with a project_id        :   74 of 42k  # (problem)
```

The live cost was measurable: 87 rows where an import chunk held canonical over a native one, because the inject tie-break ("yesterday's correction beats last year's stale fact") was reading last year's fact with yesterday's timestamp. This is why imports and captures are excluded from the rendered file. They are not less true; their clocks were lying.

Writing `valid_at` was not enough on its own. The reconciler, which decides whether a new fact supersedes an old one, already had a deterministic guard that refuses to move memory backwards in time. The guard stopped the damage but taught the model nothing: the LLM kept proposing updates the guard silently refused, and both facts stayed standing side by side. The prompt showed no dates, so "NEW" read as "newer" when it only meant "newly ingested". Each fact in the prompt now renders a `[YYYY-MM-DD]` from `valid_at` (else `created_at`), an explicit `[undated]` where there is none rather than an empty bracket the model could read as a parse failure, and the instruction says to choose UPDATE only when the new fact's date is the later one.

**Diagram (timeline)**

Renderer shipped, then the empty project tier, then the junk project stamps, then the valid_at audit, then the dated reconcile prompt

```text
  08-16  ->  renderer ships; project tier nearly empty (problem)
  08-16  ->  74 of 42k chunks have a project_id; cwd was never read (problem)
  08-16  ->  audit: 0 of 42,798 chunks have valid_at (problem)
  08-16  ->  valid_at writer + backfill; reconcile prompt shows dates (fixed)
  08-17  ->  seven junk project stamps; guard checks shape, not length (fixed)
```

## The invariant: every column a ranker sorts on has exactly one writer that means it

Here is the class, stated so you can check it. For every column that participates in ranking, selection or supersession, there must be a writer whose semantics match the column's name, and the writer must run on every ingest path. `valid_at` failed the first half (no writer). `project_id` failed the second half (one path wrote it, one path wrote garbage, most paths wrote nothing). In both cases the ranker was correct and the column was hollow, and a hollow column does not error. It sorts. It just sorts on the wrong thing, and the artifact the model reads looks fine.

The corollary is that the injected file cannot be a source of truth, because a file has exactly one writer (a human) and that writer's semantics are "when I remembered to". Make it a view and the columns become the only thing you have to get right.

## Three queries to run on your own store

You need nothing from Vodou for this. Point them at whatever SQLite or Postgres table holds your memories.

First, does your ranking column have a writer? Substitute your event-time column for `valid_at`.

```sql
SELECT COUNT(*) AS total,
       SUM(CASE WHEN valid_at IS NULL THEN 1 ELSE 0 END) AS missing
FROM chunks;
```

Passing looks like `total=42798, missing=120`, a small residue you can explain. Failing looks like `missing` equal to `total`. If it does, every "recent" boost in your ranker is a write-time boost, and anything you bulk-imported is winning ties it should lose.

Second, are your creation timestamps clustered where they should be spread?

```sql
SELECT date(created_at) AS day, COUNT(*) AS n
FROM chunks
WHERE source LIKE 'import:%'
GROUP BY day ORDER BY n DESC LIMIT 5;
```

Passing is a spread across many days. Failing is one row holding most of the count. For me it was 3,403 chunks on one day.

Third, is your scoping column populated, and populated with values that resolve?

```sql
SELECT project_id, COUNT(*) FROM chunks
WHERE project_id IS NOT NULL
  AND project_id NOT IN (SELECT id FROM projects)
GROUP BY project_id;
```

Passing is zero rows. Failing is any row: those chunks are in no scope at all, invisible to global and to every project. Then check the ratio of non-NULL to total; mine was 74 over 42,000, which is not a scoping feature, it is a scoping placebo.

And one check for the file itself, if you inject one. Diff its machine-managed zone against a copy from a month ago. If the diff is empty and your store grew, the file is not a memory. It is a fossil.

## What the memory architecture guides skip: the file is the read side

The published patterns are good about layers and poor about the artifact the model actually reads. The [agent memory pattern spec](https://geodocs.dev/ai-agents/agent-memory-pattern-spec) says to score retrieval by recency, frequency and relevance, and never says which timestamp recency means; mine was the wrong one for a year. The [engineering playbook](https://engineering-playbook.vercel.app/agentic/agent-memory) splits long-term memory into episodic, semantic and procedural, which is a fine taxonomy for storage and silent on how any of it reaches the context window on turn one. [INOSX's five-layer design](https://github.com/INOSX/agent-memory/blob/HEAD/docs/memory-system.md) is the closest, with an explicit "read-time assembly" plane, which is the right framing: the injected file is an assembly, and assemblies are rendered.

The tools that do keep a `MEMORY.md` mostly index it. [Gigabrain](https://github.com/vibeputin/gigabrain) syncs the workspace `MEMORY.md` alongside its registry for unified recall, and [sae4u-memory](https://github.com/Simple4uhq/simple4u-memory) searches SQLite facts and the markdown auto-memory dirs in one call. Both treat the file as a second corpus to read from. I went the other way: the file is an output of the database, never an input. [hermes-memory](https://pypi.org/project/hermes-memory/) is nearest in spirit, keeping the hot injection under about 180 tokens and migrating accumulated facts out of the file into the DB, which is the same instinct with a smaller budget. My hot tier is 6 to 8 KB because the pinned identity and preference facts are the part that must never be lost to retrieval scoring.

## Still open: historical chunks keep their project blindness

The backfill stamped conversations, not chunks. The 42,000 chunks that existed before 2026-08-16 have no link back to the conversation they came from, so they will never move into a project tier unless they are re-extracted. I chose that over keyword tagging because a wrong tag is a hidden memory, but the honest state is that the project tier is accurate only for memory captured after the change. Whether the store's older half is worth re-extracting against the corrected paths is a measurement I have not taken.

---

Source: [Your MEMORY.md is a file nobody writes to. Render it instead.](https://blog.vodou.ai/render-memory-md-from-database/) by Chad Priest, from Building Vodou in Public.
