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.
Read commit 4be5d7e on GitHub · chuyeowego/not-your-slave
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.
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:
| Event | Carries |
|---|---|
session.started | A durable session was created. |
message.received | An inbound user message: data.message. |
reasoning.completed | One finished reasoning block: data.reasoning. |
message.appended | An assistant text delta, with messageDelta and messageSoFar. |
message.completed | A finalised assistant text block: data.message. |
action.result | A tool call returned: data.result, data.status. |
session.waiting | The session parked, ready for the next message. |
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.
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.
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.
Four events, four kinds of memory, one line each:
message.received – a person said something to it.reasoning.completed – its own reasoning, verbatim.message.completed – what it said back.action.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…
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.
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.
// 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.
ctx.session.idA 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 onceA 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 payloadTool 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.
message.completedThe 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.
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.
What is the argument for capturing the timeline in a hook rather than instructing the model to log as it goes?
eve hooks cannot contribute anything to the model's context. Why does that make a hook the right home for capture?
Of the four events captured, why is reasoning.completed the one the project is built around?
Why does the did entry record only the tool name and status?
A single turn produces three said entries. Is that a bug?
Next: commit 4 gives it a clock, and the log stops being a record of conversations.