A Pi session is an append-only log of entries stored as JSON Lines, where each entry points to its parent. The parent pointers turn the log into a tree; the path from the current leaf to the root is the conversation the model sees. Branching, rewinding and summarizing never rewrite history — they append new entries that change which path is active or how it is projected into model context. This chapter covers the file format, the in-memory tree, context reconstruction, compaction and branch summaries, and the layered settings that configure them.
Learning goals. Read a session file by hand; explain how a tree is stored in an append-only log; reconstruct model context from a leaf; explain where and why compaction cuts, and what the summary must preserve; describe how the session file survives crashes and how settings files survive concurrent writers.
9.1 The file
Sessions live in ~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl, grouped by working directory. The first line is a header; every other line is one entry:
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234401000}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{…},"stopReason":"stop","timestamp":1733234402000}}(Examples from docs/session-format.md.) Every entry has type, id, parentId and an ISO timestamp. The entry types, a discriminated union on type:
| Entry type | Purpose | In model context? |
|---|---|---|
message | A user, assistant, tool-result or system message | Yes |
model_change, thinking_level_change | The user switched model or reasoning level | No (configuration) |
compaction | Summary replacing older entries, plus a system checkpoint | Yes, as summary |
branch_summary | Summary of an abandoned branch | Yes |
context_edit | Omit or replace an earlier entry's content in future context | Changes others |
custom | Extension state | No |
custom_message | Extension message for the model | Yes |
label | User bookmark on an entry | No |
session_info | Display name | No |
usage | Cost not tied to a message (e.g. cache warming) | No |
The header has a version. Version 1 was a linear list, version 2 introduced the tree, version 3 renamed a role; "Existing sessions are automatically migrated to the current version (v3) when loaded" (migrateSessionEntries). Schema versioning with forward migration on read lets the format evolve without breaking old files.
Ids are short: generateId() takes the first 8 hex characters of a random UUID, retries up to 100 times on collision with an existing id, and falls back to a full UUID. Eight hex digits give about 4.3 billion values; within one session the birthday-paradox collision chance is negligible, and the check makes it zero.
9.2 A tree in an append-only log
Why a tree? Because users want to rewind: "go back to before you started refactoring and try a different approach". A linear log would need truncation (destroying history) or copying. A tree stores both attempts. Pi's documentation draws it:
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf
│
└─ [branch_summary] ─── [user msg] ← alternate branchThe leaf is the current position. Appending creates a child of the leaf and moves the leaf to it:
private _appendEntry(entry: SessionEntry): void {
this.fileEntries.push(entry);
this.byId.set(entry.id, entry);
this.leafId = entry.id;
this._persist(entry);
}Branching moves the leaf to an earlier entry; the next append creates a sibling of what came after it. Nothing is deleted; the file only grows. In memory, byId is a hash map from id to entry, so following a parent pointer is O(1), and the active path is recovered by walking up and reversing:
getBranch(fromId?: string): SessionEntry[] {
const path: SessionEntry[] = [];
const startId = fromId ?? this.leafId;
let current = startId ? this.byId.get(startId) : undefined;
while (current) {
path.push(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
path.reverse();
return path;
}This is the parent-pointer tree representation: each node stores only its parent, which is ideal for an append-only medium (a node never has to be updated when a child is added) and makes root-ward traversal trivial. Finding children requires a scan or an index, which the tree UI builds when needed. Git stores commit history the same way, and a session leaf is analogous to HEAD.
session tree · active path vs abandoned branch
After /tree navigation the old attempt stays in the file; a branch summary starts the new branch, and only the path from the leaf back to the root is projected into model context.
9.3 Writing safely
_persist() shows three durability decisions:
_persist(entry: SessionEntry): void {
if (!this.persist || !this.sessionFile) return;
if (!this.flushed) {
if (!this._hasConversation()) return;
const fd = openSync(this.sessionFile, "wx");
try {
for (const e of this.fileEntries) {
writeFileSync(fd, `${JSON.stringify(e)}\n`);
}
} finally {
closeSync(fd);
}
this.flushed = true;
} else {
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
}
}- Lazy creation. No file is written until the session contains a user or assistant message, so starting and quitting Pi does not litter the sessions folder.
- Exclusive create. The
"wx"flag fails if the file exists — an atomic "create only if absent" provided by the operating system (O_CREAT | O_EXCL), so two processes can never both believe they created the same session. - One line per entry, appended. Appends are the cheapest durable write, and JSON Lines makes each record independently parseable.
The reader is written for files damaged by a crash. loadEntriesFromFile() reads in fixed-size chunks through a StringDecoder (multi-byte characters across chunk boundaries again), parses each complete line, and skips malformed ones. If the last line has no terminating newline — the signature of a write interrupted mid-record — it appends "\n" to the file so the next append starts on a fresh line instead of being glued to the torn record. A missing or invalid header makes the file not a session at all. This is the same recovery idea that write-ahead logs use: a log is valid up to its last complete record.
Note what is not done: no fsync per entry. A power failure can lose the last few entries that sat in the operating system's page cache. For a conversation log that is an acceptable trade-off; Part VI shows a design with stronger guarantees.
9.4 From tree to model context
The model does not see the tree. It sees one projected message list, built in three layers (session-manager.ts):
buildContextEntries()walks the active path and applies the latest compaction on it: the compaction entry first, then the retained entries fromfirstKeptEntryIdup to the compaction (system messages excluded, because the compaction carries a full system checkpoint), then everything after the compaction.buildSessionProjection()appliescontext_editentries: for each target, the latest edit on the active branch either removes it from context (replacement: null) or replaces its content. Edits are branch-relative: "navigating to a point before the edit reveals the target's original contribution again."buildSessionContext()converts entries toAgentMessages (compaction→ system checkpoint + summary message,branch_summary→ branch summary message,custom_message→ custom message; configuration and metadata entries produce nothing) and extracts the current model and thinking level from the path.
This is the event sourcing pattern at full scale: the log of entries is the source of truth, and the model context is a projection (a derived read model) computed from it. The SDK documentation states the ownership rule plainly: "SessionManager is authoritative for finalized model context." Context edits show the benefit: the agent-level retry of Chapter 7 can hide a failed attempt from the model while the raw log, exports and cost totals still contain it.
9.5 Compaction
Auto-compaction triggers when
\text{contextTokens} > \text{contextWindow} - \text{reserveTokens}with reserveTokens defaulting to 16,384 (room for the answer). It is checked between turns, after tool results are appended and before the next request (inside prepareNextTurn, Chapter 6), before a new prompt, and after a run ends with an overflow error or an early length stop, in which case Pi compacts and retries once. Token counts come from the hybrid estimate of Chapter 3: the last reported usage plus a characters-per-token estimate for later messages.
The algorithm, from docs/compaction.md and compaction.ts:
- Find the cut point. Walk backwards from the newest entry, summing estimated tokens, until
keepRecentTokens(default 20,000) is reached. Snap to the nearest valid cut point at or after that entry. Valid cut points are user, assistant, bash-execution and custom messages — "Never cut at tool results (they must stay with their tool call)." - Collect what to summarize: the messages from the previous kept boundary (or session start) up to the cut.
- Summarize with an LLM call, passing the previous summary for an update rather than a fresh summary.
- Append a
CompactionEntrywith the summary,firstKeptEntryId,tokensBeforeand the system checkpoint. - Rebuild context from the new projection.
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
const messageTokens = sessionEntryToContextMessages(entry).reduce(
(sum, message) => sum + estimateTokens(message),
0,
);
if (messageTokens === 0) continue;
accumulatedTokens += messageTokens;
if (accumulatedTokens >= keepRecentTokens) {
cutIndex = cutPoints.find((candidate) => candidate >= i) ?? cutPoints[cutPoints.length - 1];
break;
}
}A subtle case is the split turn: one user request can produce dozens of tool calls whose combined size exceeds keepRecentTokens. Cutting only at user messages would keep nothing or everything. Pi then cuts inside the turn at an assistant message and produces two summaries — one of the earlier history and one of the "user-message-span prefix" — and merges them.
The summary is a structured checkpoint, not prose. The prompt demands a fixed format with sections ## Goal, ## Constraints & Preferences, ## Progress (Done / In Progress / Blocked), ## Key Decisions, ## Next Steps and ## Critical Context, ending with "Keep each section concise. Preserve exact file paths, function names, and error messages." Subsequent compactions use an update prompt: "PRESERVE all existing information from the previous summary … UPDATE the Progress section: move items from "In Progress" to "Done" when completed". Default compaction also tracks readFiles and modifiedFiles cumulatively in the entry's details, so the agent remembers what it touched even after the tool calls are gone.
Summarization is lossy compression guided by a schema of what matters for continuation. Two further safeguards: a summary whose response stopped with length is rejected ("A length stop contains partial text and must not become a session checkpoint"), and summarization requests disable prompt-cache writes "because these one-off prompts are unlikely to be reused."
9.6 Branch summaries
When the user navigates with /tree to another branch, the work on the abandoned branch would vanish from context. Pi offers to summarize it: find the lowest common ancestor of the old leaf and the target, collect the entries from the old leaf back to that ancestor, summarize the newest ones within a token budget, and append a branch_summary entry at the navigation point:
┌─ B ─ C ─ D (old leaf, being abandoned)
A ───┤
└─ E ─ F ─ [summary of B,C,D] (new leaf)With parent pointers, the lowest common ancestor is found by collecting one node's ancestors into a set and walking up from the other until a member is found — O(depth).
9.7 Layered settings
Settings come from ~/.pi/agent/settings.json (global) and <project>/.pi/settings.json (project, only if the project is trusted), merged with project values winning, then overridden by command-line flags. The merge is recursive for plain objects and replacement for everything else:
function deepMergeObjects(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
const result = { ...base };
for (const key of Object.keys(overrides)) {
const overrideValue = overrides[key];
if (overrideValue === undefined) {
continue;
}
const baseValue = base[key];
result[key] =
isMergeableObject(baseValue) && isMergeableObject(overrideValue)
? deepMergeObjects(baseValue, overrideValue)
: overrideValue;
}
return result;
}Arrays are replaced, not concatenated, except where a setting defines its own merge (mergeDefaultTools understands +name/-name modifiers). Several Pi processes may edit the same settings file, so writes go through withLock(), which takes a proper-lockfile lock and retries on ELOCKED up to ten times with a 20 ms synchronous spin ("Sleep synchronously to avoid changing callers to async") — a pragmatic choice for a rarely contended lock, though spinning blocks the event loop while it waits.
9.8 Concepts in this chapter
- Append-only logs and JSON Lines. Records are only ever added; each line is independently parseable, so corruption is local.
- Parent-pointer trees. Store each node's parent; good for append-only storage and root-ward walks.
- Event sourcing and projections. The log is the truth; views (model context, UI tree, cost totals) are derived and can be rebuilt.
- Schema versioning and migration. Version stamps plus migrate-on-read keep old data usable.
- Atomic file creation (
O_EXCL), appends, and fsync. What the OS guarantees, and what survives a crash. - Crash recovery by truncation to the last complete record. Skip or seal torn records.
- Lossy compression and summarization. Trade fidelity for space, guided by what the consumer needs.
- Lowest common ancestor. The deepest node shared by two root paths.
- Layered configuration. Ordered sources with defined merge semantics and precedence.
- Advisory locks and spin-waiting. Cooperative mutual exclusion between processes; busy-waiting trades CPU for simplicity.
Exercises
- Write a script that reads a session file and prints the tree with indentation, marking the leaf. What data structure do you need that the file does not store directly?
- Construct a session file whose last line is a truncated JSON object. Predict what
loadEntriesFromFilereturns and what it does to the file. - Given entries with token estimates [3k, 8k, 12k, 2k, 9k, 6k] (oldest first), types [user, assistant, toolResult, assistant, toolResult, user], and
keepRecentTokens = 15000, find the cut point. - Why does the compaction prompt insist on preserving exact file paths and error messages? What goes wrong if the summary paraphrases them?
- Explain why a
context_editmust be branch-relative. Construct a scenario where a global edit would surprise the user. - Implement lowest-common-ancestor for two entry ids using
byId. - A project's
.pi/settings.jsonsets"compaction": { "reserveTokens": 8000 }and the global file sets"compaction": { "enabled": false, "reserveTokens": 16384 }. What is the effective configuration?