How the Earendil Pi Agent Runtime Manages State: Session Trees and Append-Only Storage

The Earendil Pi agent runtime stores all interaction data in an immutable append-only session tree that records every user message, model change, thinking-level adjustment, compaction, and branch summary, enabling deterministic branching and persistent conversation history.

The state management system in the earendil-works/pi repository treats conversation history as a versioned tree rather than a flat message array. This architecture supports advanced features like non-linear branching, model switching mid-conversation, and crash-resistant persistence through multiple storage backends.

Core State Management Components

The Session Class

The Session class in packages/agent/src/harness/session/session.ts serves as the primary interface for state manipulation. It exposes high-level methods including appendMessage, appendModelChange, appendThinkingLevelChange, and appendCompaction, while internally coordinating with the storage layer. When the agent needs to send a request to the LLM, the class calls buildContext(), which invokes buildSessionContext (lines 21-75) to traverse the tree and compile the active conversation path into a SessionContext object.

Storage Abstractions

All persistence logic is abstracted behind the SessionStorage interface defined in packages/agent/src/types.ts. This decoupling allows the runtime to swap between volatile and durable storage without changing business logic. The system provides two concrete implementations:

How Session State Evolves

Creating and Initializing Sessions

Session initialization depends on the chosen storage backend. Ephemeral sessions use new InMemorySessionStorage(), while persistent sessions call await JsonlSessionStorage.open(fs, path) to load and replay existing conversation history. The toSession helper from packages/agent/src/harness/session/repo-utils.ts wraps the storage instance with the public API.

Appending Entries to the Session Tree

Every state modification creates a new node rather than mutating existing data. When methods like appendMessage execute, the runtime performs the following steps:

  1. Generates a unique entry ID using uuidv7().slice(0,8) (from repo-utils.ts)
  2. Records the current leaf ID as parentId to maintain tree structure
  3. Stamps the entry with new Date().toISOString()
  4. Calls storage.appendEntry(entry) to persist the update

The storage layer maintains internal maps (byId, labelsById) and updates the leaf pointer (leafIdAfterEntry) to reflect the new active state.

Branch Navigation and Undo/Redo

The session.moveTo(entryId, summary?) method enables non-linear conversation flows by repositioning the active leaf to any previous entry (or null for the root). When a summary is provided, the system inserts a branch_summary entry at the diversion point, creating a new timeline while preserving the original conversation path for potential future revisiting.

Building Context for LLM Calls

Before each inference request, the runtime constructs the prompt context by calling buildSessionContext. This function:

  • Retrieves the path from the current leaf to the root via storage.getPathToRoot
  • Extracts the active thinking level, model configuration, and any compaction boundaries
  • Flattens the entries into an ordered AgentMessage[] array that respects compaction limits

The resulting context contains { messages, thinkingLevel, model }, where messages represents the compiled conversation history ready for the AI provider.

Persistence with JSON-Lines

The JsonlSessionStorage implementation writes each entry as a separate line using fs.appendFile, ensuring that crashes never corrupt existing data. The file structure consists of:

  • Line 1: A session header containing metadata (id, timestamp, cwd)
  • Lines 2-N: Individual JSON objects representing entries (messages, model changes, compactions)

When reopening a session, the implementation replays the entire file to reconstruct the in-memory tree structure, leaf pointers, and label mappings.

Code Examples

Starting a New In-Memory Session

import { InMemorySessionStorage } from "./harness/session/memory-storage.ts";
import { toSession } from "./harness/session/repo-utils.ts";

const storage = new InMemorySessionStorage();
const session = toSession(storage);

Recording a User Message

await session.appendMessage({
  role: "user",
  content: "Explain how state works",
  provider: "openai",
  model: "gpt-4",
});

Switching Models and Thinking Levels

await session.appendModelChange("anthropic", "claude-2");
await session.appendThinkingLevelChange("on");

Adding Conversation Compaction

await session.appendCompaction(
  "Summarized previous 20 messages",
  firstKeptEntryId, // ID of the first entry retained after compaction
  1500,             // Token count before compaction occurred
);

Building Context for the Next LLM Call

const ctx = await session.buildContext();
// ctx.messages => ordered AgentMessage[]
// ctx.thinkingLevel => "on"
// ctx.model => { provider: "anthropic", modelId: "claude-2" }

Persisting to a JSON-Lines File

import { JsonlSessionStorage } from "./harness/session/jsonl-storage.ts";

const fs = /* a FileSystem implementation */;
const filePath = "/tmp/pi-session.jsonl";

const jsonl = await JsonlSessionStorage.create(fs, filePath, {
  cwd: process.cwd(),
  sessionId: "my-session",
});

const persisted = toSession(jsonl);
await persisted.appendMessage({ role: "user", content: "Hello" });

Summary

  • The Earendil Pi agent uses an immutable append-only session tree where every interaction creates a new entry rather than modifying history.
  • Branch navigation via moveTo enables undo/redo and parallel conversation timelines without data loss.
  • The SessionStorage interface abstracts persistence, supporting both in-memory (InMemorySessionStorage) and durable JSON-Lines (JsonlSessionStorage) backends.
  • Context building walks the tree from leaf to root to compile the active message list, model configuration, and thinking level for LLM requests.
  • All state transitions are pure data operations that generate unique IDs via uuidv7 and timestamp entries with ISO 8601 strings.

Frequently Asked Questions

What is the difference between InMemorySessionStorage and JsonlSessionStorage?

InMemorySessionStorage keeps the entire session tree in RAM and loses data when the process exits, making it suitable for testing or transient interactions. JsonlSessionStorage writes each entry as a newline-delimited JSON record to disk, allowing sessions to survive crashes and be resumed later by replaying the file.

How does the Pi agent handle conversation branching or undo operations?

The runtime implements branching through the session.moveTo(entryId, summary?) method, which changes the active leaf pointer to any previous entry in the tree. This creates a new timeline from that point forward while preserving the original branch, effectively providing unlimited undo and parallel conversation paths.

What happens to old messages during compaction?

When appendCompaction is called, the system inserts a special compaction entry that marks a boundary in the tree. The buildSessionContext function recognizes this boundary and omits messages prior to the compaction point from the LLM context, while the full history remains in storage for audit purposes.

Is the session data format human-readable?

Yes, the JSON-Lines format used by JsonlSessionStorage produces plain text files where each line is a valid JSON object. The first line contains session metadata, and subsequent lines contain typed entries (messages, model changes, compactions) that can be inspected with standard text editors or UNIX tools like jq.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →