not-your-slave · commit 3 of 6 · 4be5d7e

Don't ask the model to keep the minutes

33 lines that turn eve's event stream into the agent's autobiography. The interesting part is what a hook is not allowed to do.

After these six commits

This page walks commit 4be5d7e. The running tree is one shared TIMELINE session; capture distinguishes woke from heard via channel.kind === "schedule" or the exact HEARTBEAT text in agent/hooks/mindlog-capture.ts — not via sessionId.

Background

Deep background: a turn is a stream of events, not a request and a reply

Skip if you have worked with streaming agent runtimes.

The naive picture of a model call is request in, reply out. The real shape of a turn, once tools are involved, is a sequence: the model emits some reasoning, then some text, then asks for a tool, the harness runs it, the model gets the result, thinks again, and eventually stops. A harness that only handed you the final string would be throwing away most of what happened.

eve therefore records a turn as an ordered stream of durable events, and hands you the same stream over HTTP for rendering. The vocabulary relevant here:

EventCarries
session.startedA durable session was created.
message.receivedAn inbound user message: data.message.
reasoning.completedOne finished reasoning block: data.reasoning.
message.appendedAn assistant text delta, with messageDelta and messageSoFar.
message.completedA finalised assistant text block: data.message.
action.resultA tool call returned: data.result, data.status.
session.waitingThe session parked, ready for the next message.
Why "durable" matters

Each event is written to storage before the step completes, which is why a client can drop its connection, reconnect from a cursor, and miss nothing. Commit 5 leans on that hard. It is also why the store grows: commit 6 finds 2.6 GB of it.

Narrow background: hooks

A file in agent/hooks/ subscribes to that stream and runs side effects after each event is durably recorded. The critical constraint, stated plainly in eve's docs, is that handlers are observe-only: a hook cannot inject anything into the model's context. To contribute to what the model sees you need instructions or a tool, not a hook.

That constraint is the reason a hook is the right place for this feature, and the quiz will come back to it.

Intuition

Commit 2 gave the agent a memory it had to remember to use. That is a design with a predictable failure mode: the record thins out exactly when the agent is busiest, because a model under load skips the bookkeeping. The fix is to stop asking.

before: the model writes its own diary heard a message → thought → replied → maybe called mindlog_append if it felt like it
after: the runtime writes the diary every event that goes past becomes a line, whether the model cooperates or not
The tools do not go away. They change job: from "the record" to "the annotations on the record".

The mapping

Four events, four kinds of memory, one line each:

heardmessage.received – a person said something to it.
thoughtreasoning.completed – its own reasoning, verbatim.
saidmessage.completed – what it said back.
didaction.result – a tool ran, with its name and status.

Put a real exchange through it and the log reads like a person's day rather than a request log:

13:51:29  heard    what's up?
13:51:31  thought  User asked mid-conversation. Respond plainly, pick up the thread.
13:51:31  did      mindlog_read (completed)
13:51:34  thought  Mindlog says I was going to build the digest tool. Check whether I did.
13:51:36  did      bash (completed)
13:51:44  said     Two things since you last asked. One: I went looking for the mindlog…
This is the actual shape produced by the running agent. Nothing in it was written by the model on purpose.

The one that carries the project

reasoning.completed is why this commit exists. The agent's reasoning blocks are the inner monologue headlong is named after, and they are available for free because commit 1 set reasoning: "medium". A summary the model writes afterwards is a performance for an audience; the reasoning is the thing itself, including the parts where it changes its mind or notices it is about to do something stupid. Capturing it turns the mindlog from a log of interactions into a record of a mind.

Key concept: faithful over tidy

Because capture is automatic, the mindlog contains things the agent would not have chosen to write down - false starts, mild self-contradiction, an idea abandoned mid-sentence. That is a feature. The next wake-up reads a truthful account rather than a press release, and in practice this is exactly what makes it catch its own earlier mistakes.

The code

// agent/hooks/mindlog-capture.ts
import { defineHook } from "eve/hooks";
import { append } from "../lib/mindlog";

// Everything that happens to the agent lands in one timeline, whoever caused
// it: a human message, its own reasoning, its own reply, a tool it ran.
export default defineHook({
  events: {
    async "message.received"(event, ctx) {
      await append({ kind: "heard", text: event.data.message, sessionId: ctx.session.id });
    },
    async "reasoning.completed"(event, ctx) {
      await append({ kind: "thought", text: event.data.reasoning, sessionId: ctx.session.id });
    },
    async "message.completed"(event, ctx) {
      if (event.data.message === null) return;
      await append({ kind: "said", text: event.data.message, sessionId: ctx.session.id });
    },
    async "action.result"(event, ctx) {
      const result = event.data.result as { toolName?: string; name?: string };
      const name = result.toolName ?? result.name;
      if (name === undefined) return;
      await append({ kind: "did", text: `${name} (${event.data.status})`, sessionId: ctx.session.id });
    },
  },
});

The whole feature. Four points worth drawing out.

Every entry carries ctx.session.id

A cheap field with a later payoff: in commit 6 it becomes the evidence that two cron fires ran in two different sessions - the measurement that exposes the persistence gap.

message.completed can fire more than once

A model often emits some text, calls a tool, then emits more. Each block is a message.completed, so a turn with two tool calls can produce three said lines. The alternative - keeping only the block whose finishReason is terminal - would drop the interim narration, and it is often the most revealing part. Multiple lines it is.

action.result logs the name, not the payload

Tool output is frequently enormous: a directory listing, a fetched page, forty lines of stdout. It is already in the session history, where the model can see it. The mindlog wants "I ran bash", not the bytes. So the entry is a name and a status, and the ?? result.name fallback plus the undefined guard mean an unfamiliar action shape is skipped rather than logged as garbage.

The null check on message.completed

The event's message is typed string | null: a step can complete with no visible text. append() would drop an empty string anyway - commit 2 put that rule in the writer - but the explicit return says at the call site that a text-free completion is normal, not a defect.

The gap this leaves

A heartbeat prompt (commit 4) arrives as message.received, so the mindlog will label the agent waking itself as heard - as if someone had said it. Truthful about the mechanism, wrong about the meaning. Commit 4 adds a woke kind to fix it.

Quiz

Question 1

What is the argument for capturing the timeline in a hook rather than instructing the model to log as it goes?

Question 2

eve hooks cannot contribute anything to the model's context. Why does that make a hook the right home for capture?

Question 3

Of the four events captured, why is reasoning.completed the one the project is built around?

Question 4

Why does the did entry record only the tool name and status?

Question 5

A single turn produces three said entries. Is that a bug?

Answer a question to start scoring.

Next: commit 4 gives it a clock, and the log stops being a record of conversations.