@cad0p/pi-tree-navigator 0.1.1 → 0.1.2-20260820.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 CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.2] - 2026-08-20
6
+
7
+ <!-- USER-EDITABLE SECTION START -->
8
+ Patch release: fixes rewind summarization for custom-api providers and eliminates 3 of the 5 reflection points via public APIs.
9
+
10
+ **Rewind summarization for custom providers (#13):** `rewind` failed with `No API provider registered for api: <custom-id>` for any provider registered via `pi.registerProvider(name, { api: <custom-id>, streamSimple })` (e.g. pi-commandcode-provider 0.5.x with `api: "commandcode-custom"`). The summarizer now routes through the composed provider's `streamSimple`, obtained via the public `ctx.modelRegistry.getProvider(providerId)` API (pi ≥0.81.0) — the same routing pi's own `branchWithSummary` uses. Peer floor bumped to `>=0.81.0`; `null` header-deletion markers stripped (pi 0.84+ `ProviderHeaders`).
11
+
12
+ **Reflection surface five → two (#14):** the per-turn in-loop context refresh no longer reads `agent.state.systemPrompt` / `agent.state.tools` or wraps `agent.prepareNextTurnWithContext` / `agent.prepareNextTurn`. Instead a `pi.on("context")` handler (the public `context` extension event, fired via `Agent.transformContext` before every LLM call) returns the session-tree projection `sessionManager.buildContextEntries().flatMap(sessionEntryToContextMessages)`. Pi's own `_installAgentNextTurnRefresh` keeps systemPrompt/tools/model/thinkingLevel fresh per turn. Remaining reflection points: `AgentSession.prototype.prompt` (session capture) and `agent.state.messages` (post-rewind refresh so the next prompt snapshots the rewound chain) — neither is eliminable via public APIs.
13
+
14
+ Verified live on pi 0.84.2 (tmux TUI, commandcode provider): a 580KB file read (22.6% of 400k) rewound mid-loop to 1.0% of 400k with the very next LLM call at the rewound cache prefix (cacheRead 90,368 → 2,944). 125 tests pass; CI green.
15
+ <!-- USER-EDITABLE SECTION END -->
16
+
17
+ ### 🐛 Bug Fixes
18
+
19
+ - *(rewind)* Route summarization through provider streamSimple (public API) (closes #13)
20
+
21
+ ### 🚜 Refactor
22
+
23
+ - Eliminate 3 of 5 reflection points via public context event (closes #14)
24
+
25
+ ### 📚 Documentation
26
+
27
+ - Update reflection field count/versions and dev commands for v0.1.1 reality ([#9](https://github.com/cad0p/pi-tree-navigator/pull/9))
28
+ - Port AGENTS.md bootstrap instructions from pi-napkin ([#11](https://github.com/cad0p/pi-tree-navigator/pull/11))
29
+
30
+
5
31
  ## [0.1.1] - 2026-07-31
6
32
 
7
33
  <!-- USER-EDITABLE SECTION START -->
package/README.md CHANGED
@@ -34,12 +34,12 @@ This repo uses [`cad0p/semver-calver-release`](https://github.com/cad0p/semver-c
34
34
 
35
35
  ### Requirements
36
36
 
37
- - **pi 0.74+** with at least one model provider configured.
37
+ - **pi 0.81+** (node ≥22.19, which pi 0.81+ itself requires) with at least one model provider configured.
38
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`
39
+ - `@earendil-works/pi-coding-agent >=0.81.0`
40
+ - `@earendil-works/pi-agent-core >=0.81.0`
41
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.
42
+ - The reflection bootstrap depends on two plain (not `#`-private) internal pi/agent fields: `AgentSession.prototype.prompt` (patched for session capture) and `agent.state.messages` (refreshed after a rewind so the next prompt snapshots the rewound chain). Per-turn in-loop context refresh runs through the **public** `context` extension event (see “In-loop context refresh” below) — no `agent.prepareNextTurn*` reflection. Verified against pi 0.81.0 / 0.83.0 / 0.84.2.
43
43
 
44
44
  ## What you get
45
45
 
@@ -81,7 +81,7 @@ Why this is more involved than just calling pi's `branchWithSummary`:
81
81
 
82
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
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.
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 register a handler on pi's **public `context` extension event** — fired via `Agent.transformContext` → `runner.emitContext` before *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 same `prompt()` sees the rewound chain. (This replaced the earlier `agent.prepareNextTurnWithContext` reflection wrapper.)
85
85
 
86
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
87
 
@@ -93,7 +93,9 @@ The synthetic assistant we inject after each rewind carries the **post-rewind ch
93
93
 
94
94
  ## Limitations
95
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.
96
+ - **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) and `agent.state.messages` (refreshed after a rewind). A third reflection point (`agent.prepareNextTurn*`) was eliminated in v0.2.0 via the public `context` extension 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: `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
+ **Audited against pi 0.81.0 / 0.83.0 / 0.84.2 (2026-08):** three of the five reflection points have been eliminated — `agent.state.systemPrompt` and `agent.state.tools` reads (deleted with the `prepareNextTurn` double-wrap; no longer needed since pi's own per-turn wrapper and the public `ctx.getSystemPrompt()` / `pi.getAllTools()` cover them) and `agent.prepareNextTurnWithContext` (replaced by the public `context` extension event, which fires via `transformContext` before **every** LLM call). The remaining two are NOT eliminable for the tool-based design: `AgentSession.prototype.prompt` (no public per-prompt hook for tool executes — the `context` event fires too late to install hooks before `createLoopConfig`) and `agent.state.messages` (pi's own `navigateTree` refresh is only reachable via `ctx.navigateTree()`, which exists solely on `ExtensionCommandContext`, not the `ExtensionContext` a `tool.execute` receives).
97
99
 
98
100
  - **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
101
 
@@ -110,13 +112,13 @@ The synthetic assistant we inject after each rewind carries the **post-rewind ch
110
112
  ## Development
111
113
 
112
114
  ```bash
113
- bun install
114
- bun test # helpers + dispatch / reflection bootstrap / salvage path
115
- bunx biome check extensions/
116
- bunx tsc --noEmit
115
+ pnpm install
116
+ pnpm test # helpers + dispatch / reflection bootstrap / salvage path
117
+ pnpm run lint # biome check extensions/
118
+ pnpm run typecheck # tsc --noEmit
117
119
  ```
118
120
 
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).
121
+ 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, context-event projection, 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 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).
120
122
 
121
123
  ## License
122
124
 
@@ -22,10 +22,8 @@
22
22
  * this.agent.state.messages = this.sessionManager.buildSessionContext().messages;
23
23
  *
24
24
  * Risks of the reflection approach:
25
- * • If pi switches any of the six fields this extension reads —
26
- * `AgentSession.prototype.prompt`, `agent.state.messages`,
27
- * `agent.state.systemPrompt`, `agent.state.tools`,
28
- * `agent.prepareNextTurn`, or `agent.prepareNextTurnWithContext` —
25
+ * • If pi switches either of the two fields this extension reads —
26
+ * `AgentSession.prototype.prompt` or `agent.state.messages` —
29
27
  * to ES `#` private fields, this breaks fundamentally.
30
28
  * • If pi renames or restructures any of these fields, this breaks.
31
29
  * • Patches `AgentSession.prototype.prompt` globally on import; not
@@ -33,20 +31,25 @@
33
31
  * the pi process, including sessions that never call
34
32
  * `navigate_tree`.
35
33
  *
36
- * Verified against pi 0.75.5 (`prepareNextTurn` path) and pi 0.83.0
37
- * (`prepareNextTurnWithContext` path — required since pi 0.80.3, see
38
- * `installPrepareNextTurn`).
34
+ * Verified against pi 0.81.0 / 0.83.0 / 0.84.2. In-loop context
35
+ * refresh runs through the public `context` extension event (see
36
+ * `buildContextMessages`), not `agent.prepareNextTurn*` reflection.
39
37
  */
40
38
 
41
- import { estimateContextTokens } from "@earendil-works/pi-agent-core";
39
+ import {
40
+ estimateContextTokens,
41
+ type StreamFn,
42
+ } from "@earendil-works/pi-agent-core";
42
43
  import {
43
44
  AgentSession,
44
45
  buildSessionContext,
45
46
  collectEntriesForBranchSummary,
46
47
  type ExtensionAPI,
47
48
  generateBranchSummary,
49
+ type ModelRegistry,
48
50
  type SessionEntry,
49
51
  type SessionManager,
52
+ sessionEntryToContextMessages,
50
53
  } from "@earendil-works/pi-coding-agent";
51
54
  import { Type } from "typebox";
52
55
  import {
@@ -117,8 +120,6 @@ const LIST_PCT_COL_WIDTH = 5;
117
120
  // is 40, but the typical kebab-case name is 8–20 chars; 28 keeps the
118
121
  // hint column visible without truncating common names.
119
122
  const LIST_LABEL_COL_WIDTH = 28;
120
- const PNT_MARKER = Symbol.for("navigate-tree.pnt-installed");
121
- const PNTWC_MARKER = Symbol.for("navigate-tree.pntwc-installed");
122
123
  const ORIG_PROMPT_KEY = Symbol.for("navigate-tree.orig-prompt");
123
124
 
124
125
  // Two warnings: list-site (read-only path; warns about the next turn's
@@ -142,15 +143,8 @@ const REFLECTION_BOOTSTRAP_WARNING_REWIND =
142
143
  interface PiInternals {
143
144
  agent: {
144
145
  state: {
145
- systemPrompt: string;
146
146
  messages: unknown[];
147
- tools: unknown[];
148
147
  };
149
- prepareNextTurn?: unknown;
150
- // Preferred over `prepareNextTurn` by `Agent.createLoopConfig`
151
- // when set; pi's own AgentSession always sets it (via
152
- // `_installAgentNextTurnRefresh`) since pi-coding-agent 0.80.3.
153
- prepareNextTurnWithContext?: unknown;
154
148
  };
155
149
  sessionManager: SessionManager;
156
150
  }
@@ -159,34 +153,6 @@ function asInternals(session: AgentSession): PiInternals {
159
153
  return session as unknown as PiInternals;
160
154
  }
161
155
 
162
- type PntResult = {
163
- context?: {
164
- systemPrompt?: unknown;
165
- messages?: unknown[];
166
- tools?: unknown[];
167
- [k: string]: unknown;
168
- };
169
- model?: unknown;
170
- thinkingLevel?: unknown;
171
- };
172
- // pi 0.75.5 invokes `agent.prepareNextTurn(signal)` from
173
- // `Agent.createLoopConfig` — a single AbortSignal argument. Since pi
174
- // 0.80.3 the loop instead invokes
175
- // `agent.prepareNextTurnWithContext(nextTurnContext, signal)` (and only
176
- // falls back to `prepareNextTurn` when the WithContext field is unset).
177
- // Both differ from the documented
178
- // `AgentLoopConfig.prepareNextTurn(context: PrepareNextTurnContext)`
179
- // shape, which `Agent` is bridging. We accept whatever pi passes and
180
- // forward it verbatim to the prior wrapper so we don't fight a future
181
- // signature alignment. Verified against pi-coding-agent 0.75.5 and
182
- // 0.83.0; revisit if the call site changes.
183
- type PntFn = (...args: unknown[]) => Promise<PntResult> | PntResult;
184
- type MarkedPntFn = PntFn & {
185
- [PNT_MARKER]?: boolean;
186
- [PNTWC_MARKER]?: boolean;
187
- __prior?: PntFn;
188
- };
189
-
190
156
  // =============================================================================
191
157
  // Reflection bootstrap & in-loop refresh
192
158
  // =============================================================================
@@ -226,126 +192,11 @@ function patchAgentSessionPrototype(): void {
226
192
  // module and reflection finds nothing.
227
193
  const patched = function (this: AgentSession, ...args: unknown[]) {
228
194
  captureSession(this);
229
- installPrepareNextTurn(this);
230
195
  return orig.apply(this, args);
231
196
  };
232
197
  proto.prompt = patched;
233
198
  }
234
199
 
235
- /**
236
- * Wire the in-flight agent loop to refresh its context from sessionManager
237
- * between turns within the same prompt() call. Without this, the loop
238
- * snapshots agent.state.messages once at prompt start and pushes new
239
- * messages onto its own array — a rewind issued mid-loop doesn't reduce
240
- * the next API call's size until the user sends a new prompt.
241
- *
242
- * Two hook fields, by pi version:
243
- * • pi ≤0.80.2: `Agent.createLoopConfig` reads `agent.prepareNextTurn`.
244
- * • pi ≥0.80.3: AgentSession's constructor installs its own
245
- * `agent.prepareNextTurnWithContext` (`_installAgentNextTurnRefresh`),
246
- * and `createLoopConfig` prefers that field over `prepareNextTurn`.
247
- * Pi's wrapper refreshes systemPrompt/tools/model/thinkingLevel per
248
- * turn but leaves `messages` stale — so wrapping only
249
- * `prepareNextTurn` silently dead-ends (footer % and the wire context
250
- * stay pre-rewind for the rest of the loop).
251
- *
252
- * We install on BOTH fields, each with the same marker/__prior chaining
253
- * discipline: on new pi the WithContext wrapper chains pi's own (keeping
254
- * its per-turn refreshes) and overrides only `messages`; on old pi the
255
- * WithContext field is never read and `prepareNextTurn` does the work.
256
- *
257
- * The Agent class's `createLoopConfig` dereferences both fields at the
258
- * closure call site, so the values here are read at every turn boundary.
259
- * But it gates the closure on either field being truthy at config
260
- * creation — so we have to set this BEFORE prompt() runs, hence wiring
261
- * it from inside the prompt patch.
262
- */
263
- function installPrepareNextTurn(session: AgentSession): void {
264
- const internals = asInternals(session);
265
- const agent = internals.agent;
266
- if (!agent) return;
267
-
268
- const sm = internals.sessionManager;
269
-
270
- // If the existing hooks were installed by a previous load of THIS
271
- // extension, recover the chains they captured (their `__prior`) so we
272
- // don't strand other extensions' closures across /reload. Preserve any
273
- // other extension's (or pi's own) hooks so we compose with them.
274
- const existing = agent.prepareNextTurn as MarkedPntFn | undefined;
275
- const prior: PntFn | undefined =
276
- typeof existing === "function" && existing[PNT_MARKER]
277
- ? existing.__prior
278
- : (existing as PntFn | undefined);
279
- const existingWc = agent.prepareNextTurnWithContext as
280
- | MarkedPntFn
281
- | undefined;
282
- const priorWc: PntFn | undefined =
283
- typeof existingWc === "function" && existingWc[PNTWC_MARKER]
284
- ? existingWc.__prior
285
- : (existingWc as PntFn | undefined);
286
-
287
- const next: MarkedPntFn = async (...args: unknown[]) => {
288
- let priorResult: PntResult | undefined;
289
- if (typeof prior === "function") {
290
- priorResult = await prior(...args);
291
- }
292
- // Pi's loop replaces context wholesale (`currentContext = ctx ??
293
- // currentContext`), not field-merges — a prior wrapper that returns
294
- // a partial context (e.g. only systemPrompt) would silently drop
295
- // tools. Spread the prior first so its fields survive, then fall
296
- // back to `agent.state` for any field the prior left undefined.
297
- // `messages` is owned by this wrapper.
298
- const priorContext = priorResult?.context;
299
- return {
300
- context: {
301
- ...priorContext,
302
- systemPrompt: priorContext?.systemPrompt ?? agent.state.systemPrompt,
303
- tools: priorContext?.tools ?? agent.state.tools,
304
- messages: sm.buildSessionContext().messages,
305
- },
306
- model: priorResult?.model,
307
- thinkingLevel: priorResult?.thinkingLevel,
308
- };
309
- };
310
- next[PNT_MARKER] = true;
311
- next.__prior = prior;
312
- agent.prepareNextTurn = next;
313
-
314
- const nextWc: MarkedPntFn = async (...args: unknown[]) => {
315
- // On pi ≥0.80.3 the loop calls this as (nextTurnContext, signal),
316
- // where nextTurnContext = { message, toolResults, context,
317
- // newMessages }. Forward args verbatim to the prior (pi's own
318
- // wrapper expects exactly this shape).
319
- const turn = args[0] as { context?: PntResult["context"] } | undefined;
320
- let priorResult: PntResult | undefined;
321
- if (typeof priorWc === "function") {
322
- priorResult = await priorWc(...args);
323
- }
324
- // Same wholesale-replacement rationale as the prepareNextTurn
325
- // wrapper above. When no prior result exists, fall back to the
326
- // turn's live context (mirrors pi's own wrapper, which does
327
- // `previousSnapshot?.context ?? turn.context`) so systemPrompt and
328
- // tools are never dropped. `messages` is owned by this wrapper —
329
- // this override is the entire point of the hook on pi ≥0.80.3, whose
330
- // own wrapper refreshes every other field but leaves messages stale.
331
- const priorContext = priorResult?.context ?? turn?.context;
332
- return {
333
- ...priorResult,
334
- context: {
335
- ...priorContext,
336
- systemPrompt: priorContext?.systemPrompt ?? agent.state.systemPrompt,
337
- tools: priorContext?.tools ?? agent.state.tools,
338
- messages: sm.buildSessionContext().messages,
339
- },
340
- model: priorResult?.model,
341
- thinkingLevel: priorResult?.thinkingLevel,
342
- };
343
- };
344
- nextWc[PNTWC_MARKER] = true;
345
- nextWc.__prior = priorWc;
346
- agent.prepareNextTurnWithContext = nextWc;
347
- }
348
-
349
200
  function findOwningSession(sm: SessionManager): AgentSession | null {
350
201
  for (const ref of sessionInstances) {
351
202
  const s = ref.deref();
@@ -356,6 +207,78 @@ function findOwningSession(sm: SessionManager): AgentSession | null {
356
207
  return null;
357
208
  }
358
209
 
210
+ /**
211
+ * Resolve the provider's `streamSimple` for summarization routing via the
212
+ * PUBLIC modelRegistry API (no reflection).
213
+ *
214
+ * Custom providers registered via `pi.registerProvider(name, { api:
215
+ * <custom-id>, streamSimple })` are composed into the ModelRuntime provider
216
+ * returned by `ctx.modelRegistry.getProvider(...)` — its `streamSimple`
217
+ * dispatches to the extension handler (provider-composer `streamWith`).
218
+ * Without passing it as `streamFn` to `generateBranchSummary`,
219
+ * completeSummarization falls back to the pi-ai compat registry (builtin
220
+ * apis only) and throws "No API provider registered for api: <custom-id>".
221
+ */
222
+ function resolveProviderStreamFn(
223
+ modelRegistry: ModelRegistry,
224
+ providerId: string,
225
+ ): { streamFn: StreamFn } | undefined {
226
+ try {
227
+ const provider = modelRegistry.getProvider(providerId);
228
+ return provider?.streamSimple
229
+ ? { streamFn: provider.streamSimple as StreamFn }
230
+ : undefined;
231
+ } catch {
232
+ return undefined;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Drop `null` header-deletion markers (pi 0.84+ `ProviderHeaders` can carry
238
+ * `string | null` values). Mirrors pi's own `withoutDeletedHeaders` in
239
+ * agent-session.js — `generateBranchSummary` expects `Record<string, string>`.
240
+ */
241
+ function stripNullHeaders(
242
+ headers: Record<string, string | null> | undefined,
243
+ ): Record<string, string> | undefined {
244
+ if (!headers) return undefined;
245
+ const entries = Object.entries(headers).filter((entry) => entry[1] !== null);
246
+ return entries.length > 0
247
+ ? (Object.fromEntries(entries) as Record<string, string>)
248
+ : undefined;
249
+ }
250
+
251
+ /**
252
+ * Project the session tree's active branch into the messages the next LLM
253
+ * call should see — the public-API replacement for the deleted
254
+ * `prepareNextTurn` double-wrap (which rebuilt `messages` from
255
+ * `sessionManager.buildSessionContext()` between every turn).
256
+ *
257
+ * Registered as a `context` extension handler: pi fires the `context` event
258
+ * inside `streamAssistantResponse` before EVERY LLM call (wired through
259
+ * `Agent.transformContext` → `runner.emitContext`), so this projection is
260
+ * applied at the wire boundary itself rather than at a turn-boundary
261
+ * snapshot. The loop's own `currentContext.messages` is never mutated — the
262
+ * handler only replaces the clone that `convertToLlm` consumes.
263
+ *
264
+ * We always replace (no leaf-gating): every `appendMessage` advances the
265
+ * session leaf, so a "leaf changed since last turn" heuristic would fire on
266
+ * essentially every call anyway — and always-replace is byte-for-byte the
267
+ * behavior of the old wrapper (per-turn rebuild from the tree).
268
+ *
269
+ * The parameter is typed structurally because `ReadonlySessionManager` is
270
+ * NOT re-exported from the package barrel (only `SessionManager` and
271
+ * `sessionEntryToContextMessages` are public); `ctx.sessionManager` at the
272
+ * event site is `ReadonlySessionManager`, which has `buildContextEntries()`.
273
+ */
274
+ export function buildContextMessages(sm: {
275
+ buildContextEntries(): SessionEntry[];
276
+ }): ReturnType<typeof sessionEntryToContextMessages> {
277
+ return sm
278
+ .buildContextEntries()
279
+ .flatMap((entry) => sessionEntryToContextMessages(entry));
280
+ }
281
+
359
282
  function refreshAgentMessages(sm: SessionManager): boolean {
360
283
  // Manually replicate the agent-state refresh that pi's
361
284
  // commandCtx.navigateTree does after branchWithSummary. Returns true on
@@ -518,6 +441,17 @@ export default function (
518
441
  const summarize = opts?.summarize ?? generateBranchSummary;
519
442
  patchAgentSessionPrototype();
520
443
 
444
+ // Public context event: replace the wire messages with the session-tree
445
+ // projection before every LLM call. This is the public-API replacement for
446
+ // the deleted `agent.prepareNextTurnWithContext` per-turn refresh — it
447
+ // fires inside `streamAssistantResponse` (via `Agent.transformContext` →
448
+ // `runner.emitContext`), before EVERY LLM call including the turn right
449
+ // after a mid-loop rewind, so the rewound chain (branch_summary +
450
+ // synthetic assistant + tool_result) reaches the next API call.
451
+ pi.on("context", (_event, ctx) => ({
452
+ messages: buildContextMessages(ctx.sessionManager),
453
+ }));
454
+
521
455
  pi.registerTool({
522
456
  name: "navigate_tree",
523
457
  label: "Navigate Tree",
@@ -787,11 +721,28 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
787
721
  }
788
722
 
789
723
  const result = await summarize(entries, {
790
- model: ctx.model,
724
+ // `auth.baseUrl` (OAuth/credential-derived endpoint, e.g.
725
+ // githubCopilotOAuth) must be applied to the model, mirroring pi's
726
+ // own `_getSummarizationRequestAuth` (`result.auth.baseUrl ?
727
+ // { ...model, baseUrl: result.auth.baseUrl } : model`).
728
+ model: auth.baseUrl
729
+ ? { ...ctx.model, baseUrl: auth.baseUrl }
730
+ : ctx.model,
791
731
  apiKey: auth.apiKey ?? "",
792
- headers: auth.headers,
732
+ headers: stripNullHeaders(auth.headers),
733
+ ...(auth.env ? { env: auth.env } : {}),
793
734
  signal: signal ?? new AbortController().signal,
794
735
  customInstructions: p.summaryFocus,
736
+ // Route through the composed provider's `streamSimple` (public
737
+ // modelRegistry API) instead of the pi-ai compat registry.
738
+ // Custom providers registered via `pi.registerProvider(name,
739
+ // { api: <custom-id>, streamSimple })` are NOT visible to the
740
+ // compat registry (which only knows builtin apis) — without this,
741
+ // rewind fails with "No API provider registered for api:
742
+ // <custom-id>" for any custom-api provider (e.g. commandcode 0.5.x
743
+ // with api "commandcode-custom").
744
+ ...(resolveProviderStreamFn(ctx.modelRegistry, ctx.model.provider) ??
745
+ {}),
795
746
  });
796
747
  if (result.aborted) {
797
748
  return toolError("Summarization aborted.");
@@ -1002,14 +953,9 @@ export const __testHooks = {
1002
953
  buildSyntheticAssistant,
1003
954
  findLabelHint,
1004
955
  findLabeledEntry,
1005
- installPrepareNextTurn,
956
+ buildContextMessages,
1006
957
  refreshAgentMessages,
1007
958
  captureSession,
1008
- /** Symbols used to mark the wrappers installed by `installPrepareNextTurn`
1009
- * (`PNT_MARKER` on `agent.prepareNextTurn`, `PNTWC_MARKER` on
1010
- * `agent.prepareNextTurnWithContext` — the field pi ≥0.80.3 prefers). */
1011
- PNT_MARKER,
1012
- PNTWC_MARKER,
1013
959
  /** Read-only view of captured-session ref count for reaping assertions. */
1014
960
  sessionRefCount(): number {
1015
961
  return sessionInstances.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cad0p/pi-tree-navigator",
3
- "version": "0.1.1",
3
+ "version": "0.1.2-20260820.0",
4
4
  "description": "agent-callable session tree navigation for pi: anchor named milestones, rewind work into a branch summary, free up context for long autonomous sessions",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -47,8 +47,8 @@
47
47
  "typescript": "^5.8.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@earendil-works/pi-agent-core": ">=0.74.0",
51
- "@earendil-works/pi-coding-agent": ">=0.74.0",
50
+ "@earendil-works/pi-agent-core": ">=0.81.0",
51
+ "@earendil-works/pi-coding-agent": ">=0.81.0",
52
52
  "typebox": "^1.0.0"
53
53
  },
54
54
  "license": "MIT"