@cad0p/pi-tree-navigator 0.1.0-20260731.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-05-25
6
+
7
+ <!-- USER-EDITABLE SECTION START -->
8
+
9
+ Initial release.
10
+
11
+ `navigate_tree` is an agent-callable pi tool with three actions:
12
+
13
+ - `anchor` — label the current point in the conversation as a milestone.
14
+ - `rewind` — collapse work between an anchor and the current leaf into a model-generated `branch_summary`, freeing context.
15
+ - `list` — show all anchors on the active branch with cumulative context %.
16
+
17
+ Designed for long autonomous sessions where the agent itself decides when to summarize. Survives mid-loop rewinds (the next assistant turn within the same `prompt()` call sees the reduced context) and produces structurally valid Anthropic chains by injecting a synthetic `tool_use` to pair with the rewind's `tool_result`.
18
+
19
+ User-visible specifics worth knowing on day one:
20
+
21
+ - Anchor names are kebab-case (lowercase alphanumeric segments separated by single hyphens; max 40 chars). Re-anchoring with a name already on the active branch moves the prior label to the new leaf rather than duplicating it; the same move-on-collision applies to `rewind`'s `labelEnd`.
22
+ - `rewind` requires a `summaryFocus` of ≥20 chars after trim; the rejection message lists what the focus should preserve so the agent can self-correct without user intervention.
23
+ - The `branch_summary` boilerplate strip in `list` hints is sentinel-anchored — a user-authored doc whose first H2 happens to be `## Goal` is preserved untouched.
24
+ - If the `AgentSession.prototype` patch isn't installed (typically only after a pi internals shape change), `list` and `rewind` surface a `⚠ reflection bootstrap missing` warning. The hint suggests `/reload` first (lighter — re-runs the prototype patch on the current process) and `Restart pi` as the heavier-handed alternative.
25
+
26
+ <!-- USER-EDITABLE SECTION END -->
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pier Carlo Cadoppi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # pi-tree-navigator
2
+
3
+ 🌳 Agent-callable session tree navigation for [pi](https://github.com/badlogic/pi-mono).
4
+
5
+ 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).
6
+
7
+ ## Install
8
+
9
+ Stable npm release:
10
+
11
+ ```bash
12
+ pi install npm:@cad0p/pi-tree-navigator
13
+ ```
14
+
15
+ Pre-release npm snapshots from `main` are published with the `next` dist-tag:
16
+
17
+ ```bash
18
+ pi install npm:@cad0p/pi-tree-navigator@next
19
+ ```
20
+
21
+ You can also install directly from the git source when testing unreleased branches:
22
+
23
+ ```bash
24
+ pi install git:github.com/cad0p/pi-tree-navigator
25
+ ```
26
+
27
+ ## Publishing
28
+
29
+ This repo uses [`cad0p/semver-calver-release`](https://github.com/cad0p/semver-calver-release)'s npm-package workflow:
30
+
31
+ - Pushes to `main` compute the next hybrid SemVer + CalVer version, tag a GitHub prerelease, and publish to npm with the `next` dist-tag.
32
+ - Curated release PRs from `release/from-v*` branches bump the base `package.json` version and publish stable npm releases.
33
+ - npm publishing uses GitHub OIDC / npm trusted publishing via `.github/workflows/release.yml` (`id-token: write`) and `publishConfig.access: public`.
34
+
35
+ ### Requirements
36
+
37
+ - **pi 0.74+** with at least one model provider configured.
38
+ - Peer dependencies (the source of truth is `package.json` `peerDependencies`):
39
+ - `@earendil-works/pi-coding-agent >=0.74.0`
40
+ - `@earendil-works/pi-agent-core >=0.74.0`
41
+ - `typebox ^1.0.0` (used to declare the tool's parameter schema; bundled with pi but listed explicitly so a standalone install resolves correctly).
42
+ - The reflection bootstrap depends on five plain (not `#`-private) internal pi/agent fields: `AgentSession.prototype.prompt`, `agent.state.messages`, `agent.state.systemPrompt`, `agent.state.tools`, and `agent.prepareNextTurn`. Verified against pi 0.75.x.
43
+
44
+ ## What you get
45
+
46
+ A single agent-callable tool, `navigate_tree`, with three actions:
47
+
48
+ | action | params | effect |
49
+ |---|---|---|
50
+ | `anchor` | `name` | Label the current point in the conversation as a milestone. |
51
+ | `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. |
52
+ | `list` | — | Show all anchors on the active branch with cumulative context %. |
53
+
54
+ `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.
55
+
56
+ ## How it works
57
+
58
+ A typical autonomous-loop pattern:
59
+
60
+ ```
61
+ agent: navigate_tree(action="anchor", name="impl-start")
62
+ → [anchor 'impl-start'] set at 1.9% of 1.0M (after: “implement the parser”)
63
+
64
+ agent: ...does work, runs tools, accumulates context to 30%...
65
+
66
+ agent: navigate_tree(action="rewind", labelStart="impl-start", labelEnd="impl-end",
67
+ summaryFocus="record only the public API of the parser
68
+ and the open issue with edge case X")
69
+ → [rewind 'impl-start' → 'impl-end'] · context 30.4% → 4.1% of 1.0M
70
+ → A branch_summary recording the work just collapsed has been appended
71
+ to your context. Items under '### Done' are complete. ...
72
+
73
+ agent: ...continues with the freed context, the next API call is back at ~4%...
74
+ ```
75
+
76
+ 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.
77
+
78
+ ## Implementation notes
79
+
80
+ Why this is more involved than just calling pi's `branchWithSummary`:
81
+
82
+ 1. **Anthropic's tool_use ↔ tool_result pairing.** When a tool call rewinds the session tree, the tool's own `tool_use` lives in the assistant message that issued it — which `branchWithSummary` puts on the abandoned branch. Pi unconditionally writes the tool's `tool_result` to the new branch, leaving the result orphaned. Anthropic 400s the next API call with `Improperly formed request`. The fix is to inject a synthetic assistant message whose single `tool_call` has the same id as the in-flight call, *after* `branchWithSummary` but *before* the tool returns. Pi then writes the real `tool_result` as a child of that synthetic assistant — and the chain stays structurally valid.
83
+
84
+ 2. **In-loop context refresh.** Pi's `Agent` class snapshots `state.messages` once at the start of `prompt()` 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 wire `agent.prepareNextTurn` from a prototype patch on `AgentSession.prototype.prompt`, returning a fresh context built from `sessionManager.buildSessionContext()` between every turn boundary. After a rewind, the very next assistant turn within the same `prompt()` sees the rewound chain.
85
+
86
+ 3. **Reflection bootstrap.** Pi's slash-command `navigateTree` has access to `commandCtx.navigateTree`, which mutates `agent.state.messages`. Tool executes don't get that ctx, so we capture every `AgentSession` instance via the prompt patch and replicate the mutation manually. Without it, the on-disk leaf moves but `agent.state.messages` stays stale.
87
+
88
+ 4. **`summaryFocus` is mandatory.** The summary is the only thing the agent will see of the collapsed work. The first time the agent uses `rewind`, blanket prompts produce vague summaries; subsequent rewinds are weaker. Forcing the agent to articulate `summaryFocus` (passed to pi's `generateBranchSummary` as `customInstructions`) measurably improves what survives.
89
+
90
+ ### Synthetic assistant token bias
91
+
92
+ 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.
93
+
94
+ ## Limitations
95
+
96
+ - **Brittle to pi version bumps.** The fix uses five independent reflection points on internals that aren't part of pi's public API: `AgentSession.prototype.prompt`, `agent.state.messages`, `agent.state.systemPrompt`, `agent.state.tools`, and `agent.prepareNextTurn`. If a future pi release renames any of these, switches them to private (`#`) fields, or restructures the class hierarchy, this breaks. The extension fails loudly: `anchor` still works, `rewind` reports `⚠ 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.
97
+
98
+ - **Anchor early in the turn.** Whatever's in `agent.state.messages` *before* the `anchor` tool call stays in the kept chain. Everything after gets summarized. Anchor at the *start* of a stage for maximum context savings.
99
+
100
+ - **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.
101
+
102
+ - **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's `normalizeMessages` filter. Other providers may have different validation rules — untested.
103
+
104
+ - **Loading the extension monkey-patches `AgentSession.prototype.prompt` globally.** Every session in the host pi process picks up the patch on import, including sessions that never call `navigate_tree`. The patch is install-on-import and not reversible within a running pi process; restart pi to fully unload it.
105
+
106
+ - **`anchor:` is a reserved label prefix.** Any label written via pi's `/label` command or by another extension that begins with `anchor:` will be picked up by `list` and addressable by `rewind`'s `labelStart` / `labelEnd`. Avoid the prefix in manually-set labels.
107
+
108
+ - **Disk-fault during `rewind` (rare).** Pi's `branchWithSummary` advances 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 same `context_length_exceeded` 400 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 wrapping `branchWithSummary` itself in v0.2.0.
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ bun install
114
+ bun test # helpers + dispatch / reflection bootstrap / salvage path
115
+ bunx biome check extensions/
116
+ bunx tsc --noEmit
117
+ ```
118
+
119
+ Tests cover `extensions/navigate-tree/helpers.ts` (pure helpers in `helpers.test.ts`) and `extensions/navigate-tree/index.ts` (action dispatch, schema shape, synthetic-assistant injection, reflection bootstrap, salvage path — in `index.test.ts`). The `summarize` factory option injects a stub for `generateBranchSummary` so no real LLM call fires during rewind tests. Additional manual e2e validation against pi 0.75.x is recommended for any pi version bump (the reflection bootstrap depends on internal field shapes).
120
+
121
+ ## License
122
+
123
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pure helpers for the navigate-tree extension.
3
+ *
4
+ * Imported by `./index.ts` and `./helpers.test.ts`. Pi's extension loader
5
+ * loads `./index.ts` and ignores everything else in this directory unless
6
+ * referenced from there — so this file isn't loaded as a separate extension.
7
+ *
8
+ * No pi runtime imports — these are pure functions over plain JS values.
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Exported boundary constants below (MAX_NAME_LENGTH, MAX_BOILERPLATE_LEAD_IN).
13
+ //
14
+ // Stability: these are internal tunables. Exported only so the test suite
15
+ // can pin boundary cases by constant rather than literal. Re-tuning is
16
+ // NOT a semver-breaking change for this package — production callers
17
+ // should rely on the registered `navigate_tree` tool surface, not import
18
+ // these constants directly.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ // Hard cap on label-name length. 40 chars accommodates descriptive names
22
+ // (e.g. 'parser-edge-case-investigation', 31 chars) while keeping list
23
+ // output column-friendly under common terminal widths and preventing a
24
+ // runaway label string from poisoning the JSONL on disk.
25
+ export const MAX_NAME_LENGTH = 40;
26
+ /**
27
+ * Kebab-case anchor name: lowercase alphanumeric segments separated by
28
+ * single hyphens. No leading / trailing / double hyphens (the `isValidName`
29
+ * test suite enforces these rejections). Mirrors the pattern documented in
30
+ * the tool description and README — if this regex relaxes, both surfaces
31
+ * need the same update.
32
+ */
33
+ const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
34
+
35
+ // Maximum lead-in distance for pi's branch-summary boilerplate marker.
36
+ // Pi's standard prelude ("The user explored a different conversation
37
+ // branch...") fits in the first ~150 chars; 200 is a generous upper bound.
38
+ // A "## Goal" found later than this is treated as in-content prose, not
39
+ // the boilerplate marker, and the strip is a no-op.
40
+ export const MAX_BOILERPLATE_LEAD_IN = 200;
41
+
42
+ // Sentinel that pi's branch-summary prelude always starts with. Gating
43
+ // the strip on this prefix makes the helper a no-op for any non-
44
+ // boilerplate text that happens to contain `## Goal` early (e.g. a
45
+ // user-authored markdown doc whose first H2 is "Goal"). Verified
46
+ // against pi 0.75.5's `dist/core/compaction/branch-summarization.js`;
47
+ // if pi reworks the prelude wording, the strip falls back to a no-op
48
+ // (`findLabelHint` shows the unmodified prelude). Verify when bumping
49
+ // the peer-dep floor.
50
+ const BRANCH_SUMMARY_SENTINEL =
51
+ "The user explored a different conversation branch";
52
+
53
+ // Pi 0.75.5's branch-summary boilerplate places `## Goal` after the prelude
54
+ // (verified against `dist/core/compaction/branch-summarization.js`). Used to
55
+ // anchor the strip cut-point in `stripBranchSummaryBoilerplate` — a single
56
+ // constant so the indexOf probe and the slice-length advance can't drift.
57
+ const GOAL_HEADER = "## Goal";
58
+
59
+ /** Validate a kebab-case name suitable for use as a navigate-tree label. */
60
+ export function isValidName(s: unknown): s is string {
61
+ return (
62
+ typeof s === "string" &&
63
+ s.length > 0 &&
64
+ s.length <= MAX_NAME_LENGTH &&
65
+ NAME_RE.test(s)
66
+ );
67
+ }
68
+
69
+ /** Truncate text to a one-line preview, collapsing whitespace. */
70
+ export function toOneLine(text: string, maxLen: number): string | null {
71
+ const t = text.replace(/\s+/g, " ").trim();
72
+ if (t.length === 0) return null;
73
+ if (maxLen <= 1) return t.length > 0 ? "…" : null;
74
+ return t.length > maxLen ? `${t.slice(0, maxLen - 1)}…` : t;
75
+ }
76
+
77
+ /**
78
+ * Format token count as a percentage with one decimal, or as `Nk` if no window.
79
+ */
80
+ export function formatPct1(tokens: number, contextWindow: number): string {
81
+ if (contextWindow <= 0) return `${(tokens / 1000).toFixed(1)}k`;
82
+ return `${((tokens / contextWindow) * 100).toFixed(1)}%`;
83
+ }
84
+
85
+ /** Format a context window size as `1.0M` or `200k`. Empty string when unknown. */
86
+ export function formatWindow(contextWindow: number): string {
87
+ if (contextWindow <= 0) return "";
88
+ if (contextWindow >= 1_000_000)
89
+ return `${(contextWindow / 1_000_000).toFixed(1)}M`;
90
+ return `${(contextWindow / 1000).toFixed(0)}k`;
91
+ }
92
+
93
+ export function formatContextDelta(
94
+ beforeTokens: number,
95
+ afterTokens: number,
96
+ contextWindow: number,
97
+ ): string {
98
+ if (contextWindow > 0) {
99
+ return `context ${formatPct1(beforeTokens, contextWindow)} → ${formatPct1(afterTokens, contextWindow)} of ${formatWindow(contextWindow)}`;
100
+ }
101
+ return `tokens ${beforeTokens} → ${afterTokens}`;
102
+ }
103
+
104
+ /**
105
+ * Strip pi's standard branch-summary boilerplate ("The user explored a
106
+ * different conversation branch...") so the hint shows the actual content.
107
+ */
108
+ export function stripBranchSummaryBoilerplate(text: string): string {
109
+ if (!text.startsWith(BRANCH_SUMMARY_SENTINEL)) return text;
110
+ const goalIdx = text.indexOf(GOAL_HEADER);
111
+ if (goalIdx > 0 && goalIdx < MAX_BOILERPLATE_LEAD_IN) {
112
+ return text.slice(goalIdx + GOAL_HEADER.length);
113
+ }
114
+ return text;
115
+ }
116
+
117
+ /**
118
+ * Extract a string from a message-like content field. Handles both the
119
+ * legacy string shape and the modern array-of-blocks shape, joining all
120
+ * text blocks with spaces.
121
+ */
122
+ export function extractTextContent(content: unknown): string {
123
+ if (typeof content === "string") return content;
124
+ if (!Array.isArray(content)) return "";
125
+ return content
126
+ .filter(
127
+ (c): c is { type: "text"; text: string } =>
128
+ typeof c === "object" &&
129
+ c !== null &&
130
+ (c as { type?: string }).type === "text" &&
131
+ typeof (c as { text?: unknown }).text === "string",
132
+ )
133
+ .map((c) => c.text)
134
+ .join(" ");
135
+ }