The transcript is the memory
The model remembers nothing between calls. The useful engineering is in the log: atomic positions, decoded reads, non-destructive folds, and transactional branches.
On this page
- A model call has no memory
- So keep the whole history — as an append-only log
- What a message is, and how a row becomes one
- Resume is just: load the log and keep going
- list vs. listActive: two read paths, one truth
- A checkpoint is a fold, not a delete
- One invariant, three payoffs
- A branch is a database operation
- What it costs
- The memory is a list
I closed the laptop mid-task on Tuesday — the agent halfway through a refactor, a constraint I’d handed it an hour earlier (“don’t touch the public API”) still in force. Wednesday morning I reopened it, typed keep going, and it kept going: the same files in mind, the same constraint honored, the thread picked up as if the night hadn’t happened.
That looks like memory. It isn’t — not in the model. The model that produced Wednesday’s first token had no recollection of Tuesday; it had never seen any of it. What carried the constraint across the night was a list of messages, written to disk, read back, and handed to a brand-new stateless call. The interesting part is what follows once that list becomes infrastructure: positions must be assigned atomically, corrupt rows must not brick valid history, folds must not rewrite the record, and a branch must copy one coherent point in time. The examples come from efferent’s current engine and providers packages.
A model call has no memory
Start from the uncomfortable fact the whole design rests on: a model call is stateless. You send a request, you get a completion, and the provider keeps nothing. The next request starts from absolute zero — no session, no scrollback, no “as we discussed.” Whatever isn’t inside the bytes you send this turn didn’t happen, as far as the model is concerned.
So an agent that appears to remember is an agent that re-tells the model everything, every single turn. One turn’s prompt is the whole world, rebuilt from scratch:
// every turn: the entire world the model sees, reassembled from nothing
const prompt = [systemPrompt, ...history, newUserMessage]
The model is a pure function from that array to the next message. systemPrompt is fixed and newUserMessage is whatever just got typed — but history is the interesting one, because it has to come from somewhere durable. It is the only place “what happened so far” can live. The model won’t hold it for you; the provider won’t; the process won’t survive a restart. The memory lives entirely on your side of the wire, and it is exactly as good as your discipline about keeping it.
So keep the whole history — as an append-only log
The discipline is the simplest one available: write every message down, in order, and never touch it again. Append-only. Messages are inserted once and are thereafter immutable — never updated, never deleted, never reordered. One conversation is one growing sequence of rows:
-- one row per message; written once, never updated, never deleted
CREATE TABLE messages (
conversation_id TEXT NOT NULL,
position INTEGER NOT NULL, -- 0, 1, 2, … monotonic within a conversation
content TEXT NOT NULL, -- the schema-encoded whole message
created_at INTEGER NOT NULL,
PRIMARY KEY (conversation_id, position)
)
The two load-bearing details are position and that composite primary key. The store computes the next position itself, atomically, at the moment of insert — the caller never passes one:
INSERT INTO messages (conversation_id, position, content, created_at)
SELECT ?1, COALESCE(MAX(position) + 1, 0), ?2, ?3
FROM messages WHERE conversation_id = ?1
RETURNING position
COALESCE(MAX(position) + 1, 0) means the first message lands at 0 and every later one takes the next slot. The calculation and insert are one statement, and the composite primary key makes duplicate positions impossible. The caller gets the durable position back; it never predicts one. The sequence is the store’s invariant, not the caller’s hope.
It sounds almost too plain to write a post about. But this one rule — the prefix never changes — is what three completely different systems quietly depend on. Hold that thought; it’s the payoff at the end.
What a message is, and how a row becomes one
A message isn’t free-form text. It’s a tagged union — the model’s turns, the user’s, and tool results all have different shapes:
export const AgentMessage = Schema.Union(UserMessage, AssistantMessage, ToolMessage)
export type AgentMessage = typeof AgentMessage.Type
The store serializes that whole value into content. On the way up it is decoded, not merely parsed:
const decodeMessage = Schema.decodeUnknownEither(Schema.parseJson(AgentMessage))
const decoded = rows.map((row) => decodeMessage(row.content))
That distinction matters. A row is bytes until the schema says it is an AgentMessage. The current store also makes a less obvious durability choice: one undecodable row is logged and skipped instead of bricking the conversation. Strict decoding still protects the type boundary; salvage happens at row granularity. Corruption loses the corrupt turn, not every valid turn around it. The ConversationId threaded through all of this is branded, not a bare string, so the compiler cannot confuse it with another id-shaped value.
Resume is just: load the log and keep going
Here’s the part that felt like magic on Wednesday morning, with the magic removed. There is no “restore session” routine. Resuming is the same three moves as starting fresh — read the log, send it, keep appending:
const promptPosition = yield* store.append(conversationId, {
role: "user",
content: prompt,
})
const fold = yield* store.latestCheckpoint(conversationId)
const active = yield* store.listActive(conversationId)
const messages = Option.match(fold, {
onNone: () => active,
onSome: (checkpoint) => [handoffToMessage(checkpoint.summary), ...active],
})
const onTail = (tail: ReadonlyArray<AgentMessage>) =>
Effect.forEach(tail, (message) => store.append(conversationId, message))
Append the new prompt, load the active suffix, prepend the latest summary if one exists, then persist each completed loop tail through onTail. A first run and a thousandth resume are the same path — the only difference is how many rows listActive hands back.
The placement of onTail matters more than the syntax. Persistence happens after each settled model/tool step, not after the entire user turn. If the process dies on step nine, steps one through eight remain. The loop owns the exact tail; the store never infers it by slicing a buffer that compaction may have reshaped.
list vs. listActive: two read paths, one truth
The store exposes the history two ways, and the difference is the whole game:
readonly list: (id: ConversationId) => Effect.Effect<ReadonlyArray<AgentMessage>, …> // the permanent record
readonly listActive: (id: ConversationId) => Effect.Effect<ReadonlyArray<AgentMessage>, …> // only what's after the latest fold
list returns everything that ever happened — the immutable record, for browsing, audit, export. listActive returns only the suffix the loop actually needs to load. Same rows underneath; one read returns all of them, the other returns the tail after the most recent fold. The record is immutable; the view narrows. Keeping those two ideas separate is what lets an agent both “remember everything” and “not re-send everything” — which is the next section.
A checkpoint is a fold, not a delete
A million-token window fills. You can’t re-send a forty-thousand-line history every turn forever. The instinct is to delete or rewrite old messages — and that instinct breaks the append-only rule and everything built on it. The escape is a checkpoint: a single row that says a summary stands in for everything up to position N.
export const Checkpoint = Schema.Struct({
conversationId: ConversationId,
messagePosition: Schema.Number, // "everything up to here is covered by the summary"
summary: Schema.String,
createdAt: Schema.Number,
})
checkpointAt(id, summary, position) writes one of these at the last row covered by a fold. It deletes nothing. Afterward, listActive returns the rows after that position; runAgent synthesizes the summary back in as one handoff message. The loop sees “summary of the first 200 turns, then the last 12 verbatim” while list still returns all 212. A fold moves a read boundary; the originals stay put.
One invariant, three payoffs
“Append-only” reads like tidiness. It is doing three different jobs:
- Prompt caching. Providers bill a repeated prompt prefix at a steep discount, keyed on it being byte-stable. An append-only log’s prefix only ever grows; every turn after the first re-sends bytes the provider has already seen, unchanged, so all but the new tail is a cache hit. Edit one message in the middle and you invalidate the cache from that byte to the end — the mechanics are a post of its own.
- Non-destructive compaction. A checkpoint narrows the active view without mutating old messages. One cold prefix rebuild pays for the fold; later turns warm against the new stable prefix.
- Branching.
forkcopies messages and the latest applicable checkpoint in one SQLite transaction. A crash cannot leave a child conversation with half a history, and the parent remains immutable.
Three features, one invariant. Break “never rewrite the prefix” and you lose cache stability, explainable folds, and reproducible branches at once.
A branch is a database operation
A branch is another conversation whose prefix was copied from this one. fork(id, upToPosition) creates the child row, copies the bounded message prefix, and copies the latest checkpoint that still falls inside that prefix — all inside one transaction. That detail makes “branch from here” deterministic. It is not a prompt trick and not a second memory system; it is a snapshot operation over the same log.
What it costs
Honesty section. An append-only active record has bills attached.
- A live conversation only grows. The store now has an explicit age-based
prune; deleting a whole old conversation also checkpoints and truncates the WAL so disk space is actually reclaimed. What it still never does is edit the middle of a retained conversation. - You re-send the active history every turn. Stateless calls mean the per-turn token bill scales with the loaded history. Caching softens the steady state and checkpoints cap the growth, but a cold resume re-pays for the whole active prefix once, before the cache warms.
- You can’t edit a message in place. A correction is a new row the model reads in sequence, not a clean redaction of the mistake. That’s auditable truth over a tidy buffer — usually the right trade, occasionally the annoying one.
- Salvage trades completeness for availability. An undecodable row is warned and skipped. That keeps one bad row from bricking a session, but the resumed model now has a hole. The alternative — fail the whole load — is stricter and much less recoverable; the code chooses availability deliberately.
The memory is a list
The agent-memory discourse always reaches for something clever — vector stores, embeddings, a “memory layer,” retrieval over a knowledge base. Those have their place. But for the conversation you are actually in, the memory is something far dumber and far more reliable: an append-only list of messages, re-sent in full, because the model on the other end remembers nothing and never will.
Statelessness isn’t the limitation you engineer around. It’s the property that makes the transcript the single source of truth — there is no other copy of what happened, so the list of messages is the agent’s mind for the length of the task. Write it down, in order, and never take it back, and everything sophisticated you build on top — caching, folding, curation, the whole window lifecycle — has solid ground to stand on. Lose the discipline and none of it can be trusted. The whole trick is a table you only ever append to.