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".
Read commit 7850d7c on GitHub · chuyeowego/not-your-slave
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.
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.
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.
| Option | Why not |
|---|---|
| SQLite | Real 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 file | Every append rewrites the whole file, and a crash mid-write loses everything rather than one line. |
| A file per entry | Ordering becomes filename convention, and reading the tail becomes a directory listing. |
| JSONL | Append is one O(1) syscall. A torn line costs one entry. tail -f works. Chosen. |
The mindlog is a single file with one entry per line, and every entry answers three questions: when, what kind, and what.
{"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?"}
kind exists at allIt 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.
One small decision shapes how the agent behaves more than its size suggests: read() returns the last N entries.
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.
.data/ works with no setup step.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.
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.
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");
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.
eve ships a built-in memory provider. Why was a hand-rolled file used instead?
Why stamp the timestamp inside append() rather than at the call sites?
read() silently skips a line that fails to parse. Why is that the right behaviour rather than a thrown error?
What does the kind field buy that a plain text log would not?
mindlog_append runs in the app process, outside the sandbox, with the same filesystem access as you. Why is that not a hole?
Next: commit 3 stops relying on the model to record its own life, and captures the timeline automatically.