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

> Discover how the earendil pi agent runtime manages state using immutable session trees and append-only storage for deterministic branching and persistent conversation history.

- Repository: [Earendil Works/pi](https://github.com/earendil-works/pi)
- Tags: internals
- Published: 2026-05-25

---

**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`](https://github.com/earendil-works/pi/blob/main/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`](https://github.com/earendil-works/pi/blob/main/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:

- **`InMemorySessionStorage`** ([`packages/agent/src/harness/session/memory-storage.ts`](https://github.com/earendil-works/pi/blob/main/packages/agent/src/harness/session/memory-storage.ts)): Stores the entire tree in RAM, ideal for testing or short-lived sessions.
- **`JsonlSessionStorage`** ([`packages/agent/src/harness/session/jsonl-storage.ts`](https://github.com/earendil-works/pi/blob/main/packages/agent/src/harness/session/jsonl-storage.ts)): Persists entries as line-delimited JSON, enabling session resumption after process termination.

## 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`](https://github.com/earendil-works/pi/blob/main/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`](https://github.com/earendil-works/pi/blob/main/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

```typescript
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

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

```

### Switching Models and Thinking Levels

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

```

### Adding Conversation Compaction

```typescript
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

```typescript
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

```typescript
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`.