@cad0p/pi-tree-navigator
agent-callable session tree navigation for pi: anchor named milestones, rewind work into a branch summary, free up context for long autonomous sessions
Package details
Install @cad0p/pi-tree-navigator from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@cad0p/pi-tree-navigator- Package
@cad0p/pi-tree-navigator- Version
0.2.0- Published
- Sep 15, 2026
- Downloads
- 888/mo · 23/wk
- Author
- cad0p
- License
- MIT
- Types
- extension
- Size
- 139.8 KB
- Dependencies
- 0 dependencies · 4 peers
Pi manifest JSON
{
"extensions": [
"extensions/navigate-tree"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-tree-navigator
🌳 Agent-callable session tree navigation for pi.
Lets a pi agent anchor named milestones in its own conversation, then collapse work between them into a model-generated branch_summary to free up context — without tripping Anthropic's tool_use ↔ tool_result validation, and with the freed context immediately available to the next assistant turn (even within the same prompt() call).
This way you can have a session that looks like this :)
Install
Stable npm release:
pi install npm:@cad0p/pi-tree-navigator
Pre-release npm snapshots from main are published with the next dist-tag:
pi install npm:@cad0p/pi-tree-navigator@next
You can also install directly from the git source when testing unreleased branches:
pi install git:github.com/cad0p/pi-tree-navigator
Publishing
This repo uses cad0p/semver-calver-release's npm-package workflow:
- Pushes to
maincompute the next hybrid SemVer + CalVer version, tag a GitHub prerelease, and publish to npm with thenextdist-tag. - Curated release PRs from
release/from-v*branches bump the basepackage.jsonversion and publish stable npm releases. - npm publishing uses GitHub OIDC / npm trusted publishing via
.github/workflows/release.yml(id-token: write) andpublishConfig.access: public.
Requirements
- pi 0.81+ (node ≥22.19, which pi 0.81+ itself requires) with at least one model provider configured.
- Peer dependencies (the source of truth is
package.jsonpeerDependencies):@earendil-works/pi-coding-agent >=0.81.0@earendil-works/pi-agent-core >=0.81.0@earendil-works/pi-tui >=0.81.0(used by therenderResultcache-notice transcript line:Container,Spacer,Text).typebox ^1.0.0(used to declare the tool's parameter schema; bundled with pi but listed explicitly so a standalone install resolves correctly).
- The reflection bootstrap depends on plain (not
#-private) internal pi/agent fields:AgentSession.prototype.prompt(patched for session capture) andagent.state.messages(refreshed after a rewind so the next prompt snapshots the rewound chain). Since #33 the cache-preserving summary request also readsagent.state.tools,agent.state.systemPrompt, andagent.thinkingBudgetsoff the same captured session (read-only;ctx.getSystemPrompt()is the public primary for the prompt), plussettingsManager.getShowCacheMissNotices()to gate the cache-notice transcript line. Per-turn in-loop context refresh runs through the publiccontextextension event (see “In-loop context refresh” below) — noagent.prepareNextTurn*reflection. Verified against pi 0.81.0 / 0.83.0 / 0.84.2.
What you get
A single agent-callable tool, navigate_tree, with three actions:
| action | params | effect |
|---|---|---|
anchor |
name |
Label the current point in the conversation as a milestone. |
rewind |
labelStart, labelEnd, summaryFocus |
Collapse work between labelStart and the current leaf into a branch_summary entry. The summary is itself labeled with labelEnd, so you can chain rewinds. summaryFocus is required (non-trivial focus required; floor enforced at runtime by MIN_SUMMARY_FOCUS_LENGTH). Despite the verb, rewind does not restore prior state — it forks a sibling branch from labelStart and continues forward from a model-generated summary; the original subtree is preserved on disk but no longer on the active path. |
list |
— | Show all anchors on the active branch with cumulative context %. |
name (written by anchor) and labelEnd (written by rewind) both share the reserved anchor: label prefix; labelStart resolves against that same namespace. Every label written by anchor and every labelEnd written by rewind is referenceable by any subsequent rewind's labelStart, and list shows all of them.
Anchoring is mandated, not suggested. On every agent start the extension appends a one-line mandate to the end of the system prompt (before_agent_start), gated on the tool being active: navigate_tree: gather all context, then anchor \context-gathered`; list anchors and rewind after every milestone or rabbit hole / dead end.The append lands after project context and skills, is re-applied on every prompt, and survives compaction — unlike thepromptGuidelines` bullet it replaced. ~35 tokens, constant for prompt caching.
How it works
A typical autonomous-loop pattern:
agent: navigate_tree(action="anchor", name="impl-start")
→ [anchor 'impl-start'] set at 1.9% of 1.0M (after: “implement the parser”)
agent: ...does work, runs tools, accumulates context to 30%...
agent: navigate_tree(action="rewind", labelStart="impl-start", labelEnd="impl-end",
summaryFocus="record only the public API of the parser
and the open issue with edge case X")
→ [rewind 'impl-start' → 'impl-end'] · context 30.4% → 4.1% of 1.0M
→ A branch_summary recording the work just collapsed has been appended
to your context. Items under '### Done' are complete. ...
agent: ...continues with the freed context, the next API call is back at ~4%...
The freed context is available to the next assistant turn within the same prompt() call, not just on the next user prompt. This is the key feature — autonomous agents don't have to wait for a user round-trip to benefit from a rewind.
Implementation notes
Why this is more involved than just calling pi's branchWithSummary:
Anthropic's tool_use ↔ tool_result pairing. When a tool call rewinds the session tree, the tool's own
tool_uselives in the assistant message that issued it — whichbranchWithSummaryputs on the abandoned branch. Pi unconditionally writes the tool'stool_resultto the new branch, leaving the result orphaned. Anthropic 400s the next API call withImproperly formed request. The fix is to inject a synthetic assistant message whose singletool_callhas the same id as the in-flight call, afterbranchWithSummarybut before the tool returns. Pi then writes the realtool_resultas a child of that synthetic assistant — and the chain stays structurally valid.In-loop context refresh. Pi's
Agentclass snapshotsstate.messagesonce at the start ofprompt()and pushes new messages onto its own array. A rewind issued mid-loop wouldn't reduce the next API call's size until the user sent a fresh prompt. We register a handler on pi's publiccontextextension event — fired viaAgent.transformContext→runner.emitContextbefore every LLM call, including the turn right after a mid-loop rewind — that replaces the wire messages with the session-tree projection (sessionManager.buildContextEntries()→sessionEntryToContextMessages). After a rewind, the very next assistant turn within the sameprompt()sees the rewound chain. (This replaced the earlieragent.prepareNextTurnWithContextreflection wrapper.)Reflection bootstrap. Pi's slash-command
navigateTreehas access tocommandCtx.navigateTree, which mutatesagent.state.messages. Tool executes don't get that ctx, so we capture everyAgentSessioninstance via the prompt patch and replicate the mutation manually. Without it, the on-disk leaf moves butagent.state.messagesstays stale.summaryFocusis mandatory. The summary is the only thing the agent will see of the collapsed work. The first time the agent usesrewind, blanket prompts produce vague summaries; subsequent rewinds are weaker. Forcing the agent to articulatesummaryFocus(passed to pi'sgenerateBranchSummaryascustomInstructions) measurably improves what survives.
Cache-preserving summary request (#33)
Upstream generateBranchSummary builds a cold standalone request: a generic summarization system prompt, the conversation serialized into a text blob, no tools, cacheRetention: "none", a fresh session id, and no reasoning forwarding. The live turns the summary collapses were just prompt-cache-served, so the summary re-billed the whole branch input (measured ~77k tokens cold vs a few hundred warm on opencode-go). The upstream fork fix (cad0p/pi PR #3) cannot be imported — pi's extension loader aliases @earendil-works/* to the host process's own modules — so the extension rewrites the request at the streamFn seam it already injects. That seam receives the fully-built (model, context, options) triple after completeSummarization applied its cold choices, and before the wire call.
The wrapper (cache-summary.ts) replaces that triple with the live request shape:
- system prompt —
ctx.getSystemPrompt()when available, else the reflectedagent.state.systemPrompt. - tools — the same live tool array (
agent.state.tools), by reference. - messages — the live projection (
buildContextEntries) as structuredMessages, minus the in-flight assistant (it was never in a cached prefix, and an unpairedtool_usefollowed by a user message is rejected by Anthropic). Pre-branch background is included so the bytes prefix-match the previous live request; boundary-orphantool_results are stripped and the{first}scope number is adjusted to the payload actually sent. - params —
cacheRetention(resolved fromPI_CACHE_RETENTION, never hardcoded), the livesessionId(also used for the opencode routing header),reasoningfrompi.getThinkingLevel()("off"omitted), andthinkingBudgetswhen the host exposes them. The caller'smaxTokenscap is stripped: live turns let pi-ai clampmodel.maxTokensto the context, and gateways that key the cache on params must see the same value.
The history walks newest→oldest against contextWindow − 16384 tokens, dropping the oldest background first; compaction / branch_summary entries get upstream's 0.9-slack retry so they survive truncation. A truncated request no longer prefix-matches live turns (system + tools still do) — same as the fork.
The summary instruction uses the eval-approved r5d prompt and must not drift: it is pinned byte-for-byte in cache-summary.test.ts. The fallback (cold) path keeps upstream's older branch prompt — intentional divergence for a degraded path.
Every live input is read defensively. When any is unavailable (no provider streamSimple, no captured session, no live tools, no system prompt) or when the kill switch is set, the wrapper delegates today's cold request — and that path is not special-cased for notices: the summary response is measured by the fork's miss detector either way (the legacy cold request reports cacheRead≈0, so it misses exactly when the numbers say so). Hits are silent — the session totals/footer already cover them and there is no read/fresh notice. A miss is shown only when it clears the display floor (≥20k tokens or ≥$0.10), as a TUI transcript line appended by the tool's renderResult — the same mechanism upstream pi uses for its other cache notices (a Spacer(1) plus a warning-text line after the tool-result body) — gated by pi's own showCacheMissNotices setting (default off). The copy is Cache miss: <n> tokens re-billed[ (~$<n>)], prefixed Cache miss after <n>m idle once the gap spans the 5-minute cache TTL (the fork's Cache miss after model switch branch is retained for parity, but summary misses after a model switch are suppressed as expected re-billing). Cache text is never in the rewind tool-result content the model sees. When the setting is off, or in a headless run (e.g. -p / RPC without UI, where the renderer never runs), nothing is rendered; the same data is available via details.summaryCache, which carries mode, fallbackReason, branchStartRetained, used, cacheRead, input, cacheWrite, hit, missedTokens, missedCost, idleMs, modelChanged, and notice (the rendered notice string, or null). mode records whether a cache-preserving request was built; used records whether the wrapper actually delegated it — a built-but-undelegated request (stub summarizer, upstream "No content to summarize" before streamFn, or an early abort) has mode: "live-prefix" / used: false and no measurable usage. Kill switch: PI_NAVIGATE_TREE_SUMMARY_CACHE=0.
Two of those fallback reasons are evidence guards, not param mirroring: they deliberately refuse the cache path so the summary keeps full-fidelity evidence (correctness over a cache hit), and they are the only known cases where the extension's request would silently diverge from the legacy path:
branch-crosses-compaction—buildContextEntries()applies the compaction cut: entries before the latest compaction'sfirstKeptEntryIdare dropped from the live projection. When a rewind segment reaches older than that cut (anchor/target older thanfirstKeptEntryId), the cache payload would summarize the lossy compacted projection while the legacy path summarizes the raw segment, so the request falls back. The predicate is the id difference between the collapsed entries and the live projection — not "the segment contains a compaction": a segment that contains the compaction entry but whose target sits at/afterfirstKeptEntryIdloses no evidence and still takes the cache path.branch-start-not-retained—buildLiveSummaryMessagesfound no collapsed-segment message in the payload (a labels-only segment) or the newest message alone exceeded the token budget. The cache payload would be background-only; the legacy path either summarizes the raw evidence or returns "No content to summarize" before any wire call.
details.summaryCache.branchStartRetained is kept for diagnostics; it is now always true on a live-prefix request, because false is treated as a real fallback.
Synthetic assistant token bias
The synthetic assistant we inject after each rewind carries the post-rewind chain estimate in usage.totalTokens (so estimateContextTokens reads a sensible baseline immediately after the move). The synthetic itself adds a ~50-token toolCall block re-emitted on every subsequent turn until the next rewind — that overhead is not reflected in any usage.* field, so future estimateContextTokens calls understate the chain by ~50 tokens until the next assistant turn writes a fresh usage block. Negligible at typical anchor cadence; mention if you're benchmarking exact token deltas, ignore otherwise.
Limitations
Brittle to pi version bumps. The fix uses two independent reflection points on internals that aren't part of pi's public API:
AgentSession.prototype.prompt(session capture) andagent.state.messages(refreshed after a rewind). A third reflection point (agent.prepareNextTurn*) was eliminated in v0.2.0 via the publiccontextextension event; the per-turn systemPrompt/tools/model/thinkingLevel refreshes come from pi's own_installAgentNextTurnRefresh(construction-installed since 0.80.3). If a future pi release renames the two remaining fields, switches them to private (#) fields, or restructures the class hierarchy, this breaks. The extension fails loudly:anchorstill works,rewindreports⚠ reflection bootstrap missing — the rewind landed on disk but the next assistant turn may still see the pre-rewind context. Run \/reload` (or restart pi) to recover.`, and you'd see context corruption return on the next prompt.Audited against pi 0.81.0 / 0.83.0 / 0.84.2 (2026-08; #33 re-audit 2026-09): two of the original five reflection points stay eliminated —
agent.prepareNextTurnWithContext(replaced by the publiccontextextension event, which fires viatransformContextbefore every LLM call) and theprepareNextTurndouble-wrap. The remaining surface isAgentSession.prototype.prompt(session capture) andagent.state.messages(post-rewind refresh); both are NOT eliminable for the tool-based design: there is no public per-prompt hook for tool executes, and pi's ownnavigateTreerefresh lives onExtensionCommandContext, not thetool.executectx. #33 adds four read-only reads on the same captured session —agent.state.tools,agent.state.systemPrompt(publicctx.getSystemPrompt()is the primary; the reflected field is the backstop),agent.thinkingBudgets, andsettingsManager.getShowCacheMissNotices()(the AgentSession-level gate for the cache-notice transcript line) — all plain fields/accessors on pi-agent-core'sAgent/AgentSession, all covered byscripts/pi-upstream-probe.mjs. Their failure mode is a cache miss or a suppressed notice, never a hard failure: the request falls back to the cold path with the transcript notice below.Anchor early in the turn. Whatever's in
agent.state.messagesbefore theanchortool call stays in the kept chain. Everything after gets summarized. Anchor at the start of a stage for maximum context savings.Tiny rewinds are rejected by a minimum-savings floor. A
rewindwhose measured savings falls below an internal floor (~4k tokens of apparent context freed) is refused with guidance listing the active anchors instead of executing — collapsing a near-empty segment burns a summarizer LLM call and can even grow live context once the summary and its synthetic assistant land on the kept chain. This pairs with anchoring early: anchor at the start of a stage, then rewind only once real work has accumulated above the anchor.rewindmust be a solo tool call. A batched rewind is refused before any mutation because the post-rewind projection is[everything up to the anchor] + [the new summary], so a sibling result would be orphaned (generated pre-collapse, written post-collapse, no declaring call in context); re-issue the rewind alone.Abandoned branches grow the JSONL forever. Each rewind preserves the abandoned subtree on disk. Session files get bigger over time even as live context shrinks. For very long autonomous runs (days), session files can hit hundreds of MB.
Tested against Anthropic and Kiro providers. The synthetic-tool_use trick is specifically for Anthropic's strict tool_use/tool_result pairing; the synthetic's
stopReason: "toolUse"survives Kiro'snormalizeMessagesfilter. Other providers may have different validation rules — untested.Cache-preserving summary request (#33).
rewindmirrors the live request (system prompt, tool array, session id, cache retention, reasoning effort, thinking budgets) so the summary can be served from the same prompt-cache prefix as the turns it collapses. Any param the live loop sends that this extension does not mirror is a silent miss — the summary still runs, just cold, and a cold structured request can bill more than branch-only evidence. The response surfaces misses as TUI transcript lines (gated byshowCacheMissNotices, ≥20k tokens / ≥$0.10 display floor, hits silent) and indetails.summaryCache— never in the model-visible tool-result content (see “Cache-preserving summary request” above) — andPI_NAVIGATE_TREE_SUMMARY_CACHE=0forces the pre-#33 cold path. A segment whose raw evidence is not in the live projection (it crosses the latest compaction'sfirstKeptEntryId) or whose branch evidence is entirely dropped (labels-only / oversized newest message) deliberately refuses the cache path and re-bills cold — raw evidence beats a cache hit when the two disagree.Cache path bypasses the SDK live request hooks. The wrapper builds the summary request itself rather than routing through the SDK live path, so
onPayload/before_provider_requestandtransformHeadershooks registered by other extensions do not run for the summary request. Any extension that mutates the live request through those hooks is an additional silent-miss surface: the summary still runs, just cold.images.blockImagesdivergence (cache path). The summary shapes its history with the publicconvertToLlm, message by message. Pi's live loop wraps that inconvertToLlmWithBlockImageswhen theimages.blockImagessetting is on, which filters image blocks out of the wire payload — so with that setting enabled and images in the collapsed context, the summary payload diverges and misses the cache (correct summary, cold bill). The live gate runs without images.Summary trailer role alternation is untested on Kiro/Bedrock. When the last retained message is user-role (a
toolResultor user text), the summarization instruction lands as a consecutive user turn. Anthropic merges consecutive user turns; pi's Kiro/Bedrock adapters are untested on this exact shape. Unit tests pin the sequence; validate manually there before relying on the cache path.Loading the extension monkey-patches
AgentSession.prototype.promptglobally. Every session in the host pi process picks up the patch on import, including sessions that never callnavigate_tree. The patch is install-on-import and not reversible within a running pi process; restart pi to fully unload it.anchor:is a reserved label prefix. Any label written via pi's/labelcommand or by another extension that begins withanchor:will be picked up bylistand addressable byrewind'slabelStart/labelEnd. Avoid the prefix in manually-set labels.Disk-fault during
rewind(rare). Pi'sbranchWithSummaryadvances the in-memory leaf before persisting the new entry to disk. If pi's session-write fails mid-call (full disk, FS error on a persisted session), the in-memory leaf has already moved past the original assistant turn but the synthetic-assistant injection in this extension never runs — pi's tool-result then lands without a matching tool_use, surfacing as the samecontext_length_exceeded400 the synthetic exists to prevent. Production risk: low (in-memory tests don't reach this case; pi's session-write is robust on POSIX disk). Tracked for an additional salvage layer wrappingbranchWithSummaryitself in v0.2.0.
Development
pnpm install
pnpm test # helpers + dispatch / reflection bootstrap / salvage path
pnpm run lint # biome check extensions/
pnpm run typecheck # tsc --noEmit
Tests cover extensions/navigate-tree/helpers.ts (pure helpers in helpers.test.ts), extensions/navigate-tree/index.ts (action dispatch, schema shape, synthetic-assistant injection, context-event projection, reflection bootstrap, salvage path, and the #33 cache-request call site — in index.test.ts), and extensions/navigate-tree/cache-summary.ts (payload shaping, {first} numbering + budget truncation, the r5d prompt pin, the notice matrix, and the wrapper contract driven through the real upstream generateBranchSummary — in cache-summary.test.ts). The summarize factory option injects a stub for generateBranchSummary so no real LLM call fires during rewind tests, and the wrapper tests use a fake capturing streamFn; the suite is fully offline. Additional manual e2e validation against the current pi release (0.84.x at time of writing) is recommended for any pi version bump (the reflection bootstrap depends on AgentSession.prototype.prompt / agent.state.messages field shapes; the cache path additionally reads agent.state.tools / agent.state.systemPrompt / agent.thinkingBudgets).
Live summary verification
The unit suite is fully offline; it cannot prove that a real provider serves the summary from cache or that the model's output is scope-clean. For any change to cache-summary.ts or the rewind call site, run both halves against a configured provider:
- Cache gate — in a fresh session with only this extension loaded (
pi --no-extensions -e <repo>/extensions/navigate-tree/index.ts), anchor at the start of a stage, accumulate real work (e.g. two file reads), thenrewind. ConfirmcacheRead > 0/hit: trueindetails.summaryCachein the session JSONL (the authoritative, always-present surface); with pi'sshowCacheMissNoticessetting enabled you will also see a cache-notice transcript line on a miss (≥20k tokens / ≥$0.10) and nothing at all on a hit. Cache text never appears in the tool-result content the model sees. A miss notice means a mirrored request param diverged — bisect in this order: callermaxTokens(must be stripped),reasoning,cacheRetention, session headers. - Quality smoke —
node scripts/summary-quality-check.mjs ~/.pi/agent/sessions/<dir>/<file>.jsonlchecks the r5d headings/length/preamble (and prints the newestdetails.summaryCacheblock); eyeball the printed summary for scope: branch only, pre-branch background excluded, unresolved work preserved, no continuation of the collapsed work.
License
MIT — see LICENSE.