not-your-slave · commit 2 of 6 · 7850d7c

One file, appended forever: giving the agent a memory

57 lines of storage, two tools, and a self-check. The design question is not "which database" but "what is the smallest thing that survives everything else being thrown away".

Background

Deep background: the model remembers nothing

Skip ahead if you already know why agents need a memory layer.

A model call is a pure function. Text in, text out, nothing retained. The illusion of memory in a chat product comes entirely from the harness replaying the conversation on every request: turn twelve is answered by sending turns one through eleven along with it. This works until it doesn't, and it stops working for two separate reasons.

So a persistent agent needs storage the harness does not own: something written during one session and read during the next. This is exactly what headlong calls its trajectory - an append-only record of everything the agent has heard, thought, and done, and the thing every wake-up reads first.

A road not taken

eve ships a memory slot: fileMemory() keeps a bounded, model-maintained list of durable facts, recalled automatically before each turn, with save_memory and remove_memory tools. It was the obvious choice and it got cut, for one reason: with no backend configured, eve dev stores it in process memory. Restart the dev server and the memory is gone. For a project whose entire premise is continuity, a store that forgets on restart is worse than no store, because it lies. A plain file on disk is less clever and strictly more durable.

Narrow background: why append-only, and why JSONL

Two properties make the format almost decide itself. Writes are frequent, small, and never revise history - the agent adds observations, it does not edit yesterday. Reads are overwhelmingly "the most recent N". That is a log, and the cheapest durable log is one JSON object per line.

OptionWhy not
SQLiteReal answer for search and indexing later. Today it buys nothing a file lacks, and adds a dependency and a schema.
One JSON array in a fileEvery append rewrites the whole file, and a crash mid-write loses everything rather than one line.
A file per entryOrdering becomes filename convention, and reading the tail becomes a directory listing.
JSONLAppend is one O(1) syscall. A torn line costs one entry. tail -f works. Chosen.

Intuition

The mindlog is a single file with one entry per line, and every entry answers three questions: when, what kind, and what.

.data/mindlog.jsonlone line, appended, forever
{"at":"2026-09-01T05:39:00.412Z","kind":"note","text":"Decided not to scaffold a fake project.","sessionId":"wrun_01M1D…"}
{"at":"2026-09-01T05:39:02.887Z","kind":"thought","text":"I just woke up with no context. Read the mindlog first."}
{"at":"2026-09-01T05:41:11.004Z","kind":"heard","text":"what's up?"}
Three fields, one optional. `kind` is what lets a reader tell a deliberate note from an overheard message without parsing prose.

Why kind exists at all

It would have been simpler to store text and a timestamp. The type earns its place because two different consumers need to treat entries differently, and neither should have to guess.

Two kinds exist in this commit, both written on purpose by the model: note for a deliberate memo, thought for a passing reflection. The others - heard, said, did, and later woke - arrive in commit 3, where capture becomes automatic.

The tail, not the head

One small decision shapes how the agent behaves more than its size suggests: read() returns the last N entries.

entry 1..40older history, still on disk, not returned
→read(3)
entry 41..43what it was just doing
A waking agent wants the last thing it was doing, not its childhood. The cost of that choice - anything older is invisible - is real, and commit 6 adds search to pay it off.

The code

The store

const FILE = process.env.MINDLOG_FILE ?? ".data/mindlog.jsonl";
const MAX_TEXT = 4000;

export async function append(entry: Omit<MindlogEntry, "at">): Promise<void> {
  const text = entry.text.trim();
  if (text.length === 0) return;

  const line = JSON.stringify({
    at: new Date().toISOString(),
    ...entry,
    text: text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text,
  });

  await mkdir(dirname(FILE), { recursive: true });
  await appendFile(FILE, `${line}\n`, "utf8");
}

Four decisions in twelve lines.

Key concept: one writer, one shape

Putting validation in the writer rather than in each caller is the difference between a file you can trust and a file you have to defend against.

export async function read(limit = 50): Promise<MindlogEntry[]> {
  let raw: string;
  try {
    raw = await readFile(FILE, "utf8");
  } catch {
    return [];
  }

  const entries: MindlogEntry[] = [];
  for (const line of raw.split("\n")) {
    if (line.trim().length === 0) continue;
    try {
      entries.push(JSON.parse(line) as MindlogEntry);
    } catch {
      // A torn final line is expected while another write is in flight.
    }
  }

  return entries.slice(-limit);
}

Two swallowed errors, both deliberate, both commented. A missing file is not an error: it is a new agent, and it reads as an empty history. A line that will not parse is skipped rather than thrown, because a reader can arrive while an append is half-written - and the correct behaviour for a memory is to keep remembering the other 99 lines.

Two doors for the model

An eve tool is a filename, a description, a Zod schema, and a function. The name and description are the only things the model sees before calling it, so they are prompt engineering, not documentation.

// agent/tools/mindlog_append.ts
export default defineTool({
  description:
    "Write an entry to your mindlog: the append-only record of your inner life. " +
    "Use it for a deliberate note to your future self – an intention, an open " +
    "question, something you noticed, a decision. …",
  inputSchema: z.object({
    kind: z.enum(["note", "thought"]).describe(
      "note = deliberate memo to your future self. thought = a passing reflection."),
    text: z.string().min(1).describe("What to record, in your own voice."),
  }),
  async execute({ kind, text }, ctx) {
    await append({ kind, text, sessionId: ctx.session.id });
    return { ok: true };
  },
});

Note what the schema does not offer: no path, no timestamp, no arbitrary kind. The tool is a keyhole. The model can add to its own memory and nothing else, which is exactly the property that makes it safe to run the tool in the app process with full filesystem access, an argument that gets its full airing in commit 6.

mindlog_read is the mirror image, taking only a limit. Its description does the persuading: "This is how you remember what you were doing, thinking, and waiting on before this moment. Call it whenever you wake up with no context." That last sentence is aimed squarely at the heartbeat two commits from now.

The check

const dir = await mkdtemp(join(tmpdir(), "mindlog-check-"));
process.env.MINDLOG_FILE = join(dir, "mindlog.jsonl");
const { append, read } = await import("./mindlog.ts");

assert.deepEqual(await read(), [], "a missing file reads as empty, not an error");
…
assert.equal(all.length, 2, "blank entries are dropped");
assert.equal((await read(1))[0].text, "Second Thing about BUSYWORK", "the tail is the newest");
Design note: the import extension

Node's type stripping needs the .ts extension on the import, which tsc rejects by default. One line in tsconfig.json - allowImportingTsExtensions - reconciles them, legal here because the project already compiles with noEmit.

Quiz

Question 1

eve ships a built-in memory provider. Why was a hand-rolled file used instead?

Question 2

Why stamp the timestamp inside append() rather than at the call sites?

Question 3

read() silently skips a line that fails to parse. Why is that the right behaviour rather than a thrown error?

Question 4

What does the kind field buy that a plain text log would not?

Question 5

mindlog_append runs in the app process, outside the sandbox, with the same filesystem access as you. Why is that not a hole?

Answer a question to start scoring.

Next: commit 3 stops relying on the model to record its own life, and captures the timeline automatically.