Skip to content

Sessions, branching & compaction

The tree-structured session store, resuming, forking a branch, and token-aware compaction.


On this page

Everything is a tree

An arcturn session isn't a flat transcript — it's a tree. Every SessionEntry carries a parentId, so appending after an older entry starts a new branch instead of overwriting what came after it. This is what makes "resume from three turns ago and try a different approach" a normal operation instead of something that destroys history.

type SessionEntry =
  | { kind: "message"; id; parentId; timestamp; message }
  | { kind: "compaction"; id; parentId; timestamp; summary; upToId; tokensBefore; tokensAfter }
  | { kind: "label"; id; parentId; timestamp; label }
  | { kind: "state"; id; parentId; timestamp; todos?; plan?; model? };

Four kinds of entry: a conversation message, a compaction (a summary that folds everything up to upToId into itself), a label (a bookmark), and state (todos, plan, or model changes — anything that isn't a message but needs to survive a resume).

Every entry an Agent appends gets a fresh id and a parentId set to whatever the previous append's id was — so the tree isn't something a caller has to construct, it falls out of the fact that every write records where it came from.

label is honest about being aspirational: the type exists, materializeBranch (below) skips over it safely, and a test fixture can construct one — but nothing in the current CLI ever appends one. There's no /label command yet. If you see kind: "label" in a session file, something wrote it by hand or with the SDK directly, not arcturn itself.

Storage

JsonlSessionStore from @arcturn/core persists each session as one .jsonl file: a header line, then one JSON line per entry, appended in order. Writes to a single session are serialized through an internal queue so concurrent appends never interleave — important once background tasks and sub-agents can all be emitting events for the same session at once.

import { JsonlSessionStore } from "@arcturn/core";
 
const store = new JsonlSessionStore({ dir: ".arcturn/sessions" });

createAgent({ ..., sessionDir: ".arcturn/sessions" }) builds one of these for you. MemorySessionStore is the same interface backed by memory, for tests or fully ephemeral runs.

On disk, the CLI keeps this under ~/.arcturn/sessions/<cwdHash>/<sessionId>.jsonl — one directory per working directory (hashed, not the literal path), so arcturn --continue in one repo never lists sessions started in another. JsonlSessionStore.list() returns every session header in that directory, newest first; a missing or partially-written file is skipped rather than failing the whole listing. setTitle rewrites the header line (through a temp file plus rename, so a crash mid-write can't corrupt it) — that's how a session picks up a human-readable title.

A crash mid-append leaves a torn last line. entries() recovers from that by dropping an unparsable final line and returning everything before it; a parse failure anywhere earlier in the file is treated as real corruption and throws.

Branching

const entries = await store.entries(sessionId);       // everything, append order
const leaf = latestEntryId(entries);                    // the newest entry — default tip
const branch = pathToLeaf(entries, someOlderEntryId);   // root-first path to any entry

Resuming from the newest entry continues the conversation you'd expect. Resuming from an older entry — passing leafId to Agent.resume — starts a new branch from that point: everything after it on the original path is left alone, untouched, still walkable by anyone resuming from its own leaf.

const agent = await Agent.resume({
  llm, model, systemPrompt, tools, cwd,
  sessionStore: store,
  sessionId,
  leafId: someOlderEntryId, // omit to resume the latest branch tip
});

materializeBranch replays a root-first entry list back into the conversation and state an Agent needs to resume: messages (with compactions folded into a single synthetic summary message), the current todos, the current plan, and the model in effect at that point.

Resuming from the CLI

arcturn -c                    # --continue: resume the newest session for this cwd
arcturn -r <sessionId>        # --resume: resume a specific session by id

--resume and --continue are mutually exclusive — passing both is a parse error. Both resume the session's latest branch tip; there's no --leaf <id> flag today, so jumping to an older branch from the command line means going through /rewind once you're already in the session (see Checkpoints & /rewind), which forks the conversation at a chosen turn rather than continuing from the newest one.

Inside a running session, /sessions lists up to 50 stored sessions for the current directory (newest first, each row showing its creation time and title) and resumes whichever one you pick — the interactive equivalent of --resume.

Exporting a transcript

/export [html] [--thinking] writes the current conversation to arcturn-session-<yyyy-MM-dd-HHmm>.md (or .html with the html argument) in the working directory. exportMarkdown/exportHtml in export.ts are pure functions over the message list — no session-store or filesystem access — so what gets written is exactly what's in memory for the live agent, not a re-read of the JSONL file. Tool results are capped at 200 lines each before a truncation marker, and thinking content blocks are included only when --thinking is passed.

Compaction

Long sessions eventually threaten to overflow the model's context window. Before that happens, Arcturn folds the oldest part of the conversation into a structured markdown summary and keeps going — automatically, or on demand via agent.compact().

The summarizer is prompted to preserve everything a fresh agent would need to continue: file paths, identifiers, decisions made and why, outstanding problems — in five fixed sections (Goal, Progress, Key decisions, Next steps, Critical context), not a vague paraphrase.

Tuning knobs (AgentOptions.compaction, or CompactionOptions directly):

Option Default Meaning
enabled true Set false to disable automatic compaction entirely.
reserveTokens 16384 Headroom kept free in the context window.
keepRecentTokens 20000 Recent conversation preserved verbatim, uncompacted.
model agent's current model Model used for the summarization call itself.
maxOutputTokens 4096 Output budget for the summary.
buildPrompt built-in Replace the summarization prompt entirely.

The cut point always lands on a user-message boundary — never between an assistant's tool call and its result — so a compaction can never produce a conversation the model would see as malformed. Compaction is recorded as its own compaction entry in the session tree (with tokensBefore / tokensAfter for observability), so replaying the branch later reconstructs the exact same folded state.

agent.subscribe((event) => {
  if (event.type === "compactionEnd") {
    console.log(`Compacted ${event.tokensBefore} → ${event.tokensAfter} tokens`);
  }
});
 
const compacted = await agent.compact(); // force it now, regardless of the threshold
  • Checkpoints & /rewind — the file-restore side of branching: what actually changes on disk when you fork the conversation at an older turn.
  • Provenance & arcturn blame — attributes each line of a file to the turn (and prompt) that wrote it, built on the same "everything traces to a turn" idea.
  • Replay & bisect — re-runs a session's prompts (extractPrompts walks the same entry list this page describes) against a live model or a cassette.
  • Audit & cost — the accountability trail and spend accounting that ride alongside a session rather than living inside it.
  • The accountability feature page has a diagram of the tree shape and the same commands in context.