@cad0p/pi-tree-navigator 0.1.2 β 0.1.3
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 +22 -0
- package/README.md +4 -0
- package/extensions/navigate-tree/index.ts +192 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.1.3] - 2026-09-14
|
|
6
|
+
|
|
7
|
+
<!-- USER-EDITABLE SECTION START -->
|
|
8
|
+
Last version before adopting the branch summarization cache hit mechanism to save everyone money -> https://github.com/earendil-works/pi/issues/9411
|
|
9
|
+
<!-- USER-EDITABLE SECTION END -->
|
|
10
|
+
|
|
11
|
+
### π Features
|
|
12
|
+
|
|
13
|
+
- *(ci)* Daily pi-upstream reflection probe (closes #17)
|
|
14
|
+
- *(guidelines)* Steer agents to anchor early at context-gathered (closes #29)
|
|
15
|
+
- Mandate anchoring via before_agent_start system prompt (closes #31)
|
|
16
|
+
|
|
17
|
+
### π Bug Fixes
|
|
18
|
+
|
|
19
|
+
- *(rewind)* Reject degenerate rewinds below a min-savings floor (closes #21, closes #20)
|
|
20
|
+
- *(rewind)* Inject opencode session headers for summarization (closes #26)
|
|
21
|
+
|
|
22
|
+
### βοΈ Miscellaneous Tasks
|
|
23
|
+
|
|
24
|
+
- *(tooldef)* Trim navigate_tree tool definition (~848 β ~470 tok/request) (closes #22)
|
|
25
|
+
|
|
26
|
+
|
|
5
27
|
## [0.1.2] - 2026-08-20
|
|
6
28
|
|
|
7
29
|
<!-- USER-EDITABLE SECTION START -->
|
package/README.md
CHANGED
|
@@ -53,6 +53,8 @@ A single agent-callable tool, `navigate_tree`, with three actions:
|
|
|
53
53
|
|
|
54
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
55
|
|
|
56
|
+
**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 the `promptGuidelines` bullet it replaced. ~35 tokens, constant for prompt caching.
|
|
57
|
+
|
|
56
58
|
## How it works
|
|
57
59
|
|
|
58
60
|
A typical autonomous-loop pattern:
|
|
@@ -99,6 +101,8 @@ The synthetic assistant we inject after each rewind carries the **post-rewind ch
|
|
|
99
101
|
|
|
100
102
|
- **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.
|
|
101
103
|
|
|
104
|
+
- **Tiny rewinds are rejected by a minimum-savings floor.** A `rewind` whose 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.
|
|
105
|
+
|
|
102
106
|
- **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.
|
|
103
107
|
|
|
104
108
|
- **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.
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* `buildContextMessages`), not `agent.prepareNextTurn*` reflection.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import { randomUUID } from "node:crypto";
|
|
39
40
|
import {
|
|
40
41
|
estimateContextTokens,
|
|
41
42
|
type StreamFn,
|
|
@@ -64,10 +65,22 @@ import {
|
|
|
64
65
|
} from "./helpers.ts";
|
|
65
66
|
|
|
66
67
|
const LABEL_PREFIX = "anchor:";
|
|
68
|
+
const TOOL_NAME = "navigate_tree";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Always-on anchor mandate appended to the system prompt on every agent
|
|
72
|
+
* start (gated on the tool being active). Same lever as pi-napkin's vault
|
|
73
|
+
* mandate: `promptGuidelines` bullets land mid-prompt; this append lands
|
|
74
|
+
* at the very end of the system prompt, after <project_context> and
|
|
75
|
+
* skills, and is re-applied on every prompt (never compacted). Byte-stable
|
|
76
|
+
* (module constant, no per-session interpolation) for provider caching.
|
|
77
|
+
*/
|
|
78
|
+
export const ANCHOR_MANDATE = `${TOOL_NAME}: gather all context, then anchor \`context-gathered\`; list anchors and rewind after every milestone or rabbit hole / dead end.`;
|
|
67
79
|
|
|
68
80
|
// ---------------------------------------------------------------------------
|
|
69
81
|
// Exported boundary constants below (MAX_SESSION_REFS, MAX_HINT_WALK_DEPTH,
|
|
70
|
-
// MIN_SUMMARY_FOCUS_LENGTH,
|
|
82
|
+
// MIN_SUMMARY_FOCUS_LENGTH, MIN_REWIND_SAVINGS_TOKENS,
|
|
83
|
+
// MAX_SYNTHETIC_FOCUS_LENGTH).
|
|
71
84
|
//
|
|
72
85
|
// Stability: these are internal tunables. Exported only so the test suite
|
|
73
86
|
// can pin boundary cases by constant rather than literal. Re-tuning is
|
|
@@ -95,6 +108,19 @@ export const MAX_HINT_WALK_DEPTH = 50;
|
|
|
95
108
|
// reliably loses continuity β raising it forces more useful focus text
|
|
96
109
|
// without inviting verbosity.
|
|
97
110
|
export const MIN_SUMMARY_FOCUS_LENGTH = 20;
|
|
111
|
+
// Floor on the *apparent* token savings (`beforeTokens β tokensAtTarget`) a
|
|
112
|
+
// `rewind` must clear to execute. Absolute threshold, deliberately NOT
|
|
113
|
+
// window-relative: % semantics drift with window size, so a fixed token bar
|
|
114
|
+
// is the only stable shape. Every rewind carries β350β900 tokens of fixed
|
|
115
|
+
// overhead (the branch_summary adds ~300β800 permanent tokens on the kept
|
|
116
|
+
// chain; the synthetic assistant re-emits ~50 on every subsequent turn), so
|
|
117
|
+
// apparent savings OVERSTATES true freed context. Empirically the degenerate
|
|
118
|
+
// anchorβimmediate-rewind case measures ~100 apparent tokens while a healthy
|
|
119
|
+
// stage measures ~20k β 4k separates them cleanly (~200Γ) and roughly equals
|
|
120
|
+
// one 300-line source-file read (~5.9k tokens measured). Below the floor a
|
|
121
|
+
// rewind burns a summarizer LLM call, forks a junk branch_summary, consumes
|
|
122
|
+
// a milestone label, and GROWS live context.
|
|
123
|
+
export const MIN_REWIND_SAVINGS_TOKENS = 4000;
|
|
98
124
|
// Cap on the `summaryFocus` length stored in the synthetic assistant's
|
|
99
125
|
// arguments. The full focus is passed live to `generateBranchSummary`, so
|
|
100
126
|
// the summarizer always sees the original; we only need a trimmed copy in
|
|
@@ -248,6 +274,53 @@ function stripNullHeaders(
|
|
|
248
274
|
: undefined;
|
|
249
275
|
}
|
|
250
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Session-routing headers for the summarization request.
|
|
279
|
+
*
|
|
280
|
+
* Pi's live turns merge these in `sdk.ts` via `mergeProviderAttributionHeaders`
|
|
281
|
+
* (session headers + install-telemetry attribution). Out-of-loop summarization
|
|
282
|
+
* callers β pi's own `agent-session.navigateTree` and this extension β pass only
|
|
283
|
+
* auth headers, so providers that REQUIRE the session header 400 the request
|
|
284
|
+
* (`MissingSessionID`; observed on opencode-go 2026-09-07: every rewind and every
|
|
285
|
+
* native `/tree` summary fails deterministically).
|
|
286
|
+
*
|
|
287
|
+
* The tool-execute ctx exposes neither `settingsManager` (telemetry attribution)
|
|
288
|
+
* nor the live routing `sessionId`, so this replicates only the session half of
|
|
289
|
+
* pi's merge (`getSessionHeaders` in `provider-attribution.ts`): for
|
|
290
|
+
* opencode-family providers, inject `x-opencode-session` + `x-opencode-client`.
|
|
291
|
+
* The value is a fresh UUID per rewind β upstream does the same for routing
|
|
292
|
+
* (`completeSummarization` falls back to `uuidv7()` when the caller passes no
|
|
293
|
+
* session id), and one-off summaries have no continuation to route. Proven by
|
|
294
|
+
* the branch-summary eval harness, whose identical injection succeeds on
|
|
295
|
+
* opencode-go where the bare call 400s. Never overrides a header the auth layer
|
|
296
|
+
* already set.
|
|
297
|
+
*/
|
|
298
|
+
function withSessionHeaders(
|
|
299
|
+
model: { provider?: string; baseUrl?: string } | undefined,
|
|
300
|
+
headers: Record<string, string> | undefined,
|
|
301
|
+
): Record<string, string> | undefined {
|
|
302
|
+
const provider = model?.provider ?? "";
|
|
303
|
+
let host = "";
|
|
304
|
+
try {
|
|
305
|
+
host = model?.baseUrl ? new URL(model.baseUrl).hostname : "";
|
|
306
|
+
} catch {
|
|
307
|
+
host = "";
|
|
308
|
+
}
|
|
309
|
+
const needsSession =
|
|
310
|
+
provider === "opencode" ||
|
|
311
|
+
provider === "opencode-go" ||
|
|
312
|
+
host === "opencode.ai";
|
|
313
|
+
if (!needsSession) return headers;
|
|
314
|
+
const merged = { ...(headers ?? {}) };
|
|
315
|
+
if (!merged["x-opencode-session"]) {
|
|
316
|
+
merged["x-opencode-session"] = randomUUID();
|
|
317
|
+
}
|
|
318
|
+
if (!merged["x-opencode-client"]) {
|
|
319
|
+
merged["x-opencode-client"] = "pi";
|
|
320
|
+
}
|
|
321
|
+
return merged;
|
|
322
|
+
}
|
|
323
|
+
|
|
251
324
|
/**
|
|
252
325
|
* Project the session tree's active branch into the messages the next LLM
|
|
253
326
|
* call should see β the public-API replacement for the deleted
|
|
@@ -327,6 +400,49 @@ function estimateAtEntry(
|
|
|
327
400
|
).tokens;
|
|
328
401
|
}
|
|
329
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Rejection copy for the rewind min-savings floor (#21). Reports the
|
|
405
|
+
* apparent savings plus every active anchor β chronological rootβleaf,
|
|
406
|
+
* same walk order as `list`, including labelStart itself β with its
|
|
407
|
+
* cumulative context percentage, so the agent can pick a genuinely earlier
|
|
408
|
+
* anchor or keep working. Move-on-collision means the tool itself never
|
|
409
|
+
* creates duplicate anchor names; a manual `/label anchor:x` CAN put the
|
|
410
|
+
* same name on two entries β duplicates print twice here, exactly as
|
|
411
|
+
* `list` prints them today.
|
|
412
|
+
*
|
|
413
|
+
* Deliberately does NOT print the floor value anywhere: an agent-visible
|
|
414
|
+
* numeric bar invites grinding up to it instead of doing real work.
|
|
415
|
+
*/
|
|
416
|
+
function buildMinSavingsRejection(
|
|
417
|
+
labelStart: string,
|
|
418
|
+
labelEnd: string,
|
|
419
|
+
apparentSavings: number,
|
|
420
|
+
sm: SessionManager,
|
|
421
|
+
cw: number,
|
|
422
|
+
allEntries: SessionEntry[],
|
|
423
|
+
byId: Map<string, SessionEntry>,
|
|
424
|
+
): string {
|
|
425
|
+
const anchors: string[] = [];
|
|
426
|
+
let oldestAnchorName: string | null = null;
|
|
427
|
+
for (const e of sm.getBranch()) {
|
|
428
|
+
const lbl = sm.getLabel(e.id);
|
|
429
|
+
if (!lbl?.startsWith(LABEL_PREFIX)) continue;
|
|
430
|
+
const name = lbl.slice(LABEL_PREFIX.length);
|
|
431
|
+
if (!oldestAnchorName) oldestAnchorName = name;
|
|
432
|
+
anchors.push(
|
|
433
|
+
`'${name}' ${formatPct1(estimateAtEntry(allEntries, e.id, byId), cw)}`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
// cw=0 β percents fall back to formatPct1's k-format and the window
|
|
437
|
+
// suffix is omitted, mirroring `list`'s header behavior.
|
|
438
|
+
const suffix = cw > 0 ? ` of ${formatWindow(cw)}` : "";
|
|
439
|
+
const guidance =
|
|
440
|
+
oldestAnchorName === labelStart
|
|
441
|
+
? "No earlier anchors β keep working and rewind later once more has accumulated above it."
|
|
442
|
+
: "Rewind further back to actually free context, or keep working.";
|
|
443
|
+
return `Rewinding '${labelStart}' β '${labelEnd}' would free only ~${formatPct1(apparentSavings, 0)} tokens. ${guidance} Active anchors: ${anchors.join(" Β· ")}${suffix}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
330
446
|
/**
|
|
331
447
|
* Walk parentId chain back from `fromId` and return a one-line preview of
|
|
332
448
|
* the first entry that has meaningful text content. Branch summaries are
|
|
@@ -452,22 +568,35 @@ export default function (
|
|
|
452
568
|
messages: buildContextMessages(ctx.sessionManager),
|
|
453
569
|
}));
|
|
454
570
|
|
|
571
|
+
// Anchor mandate (#31): a before_agent_start append lands at the END of
|
|
572
|
+
// the system prompt β stronger than the mid-prompt Guidelines block, and
|
|
573
|
+
// re-applied on every prompt instead of living in the conversation. Skip
|
|
574
|
+
// only when the tool is verifiably absent from the active set; fail-open
|
|
575
|
+
// when `selectedTools` is undefined (this extension always registers it).
|
|
576
|
+
pi.on("before_agent_start", async (event) => {
|
|
577
|
+
const selected = event.systemPromptOptions?.selectedTools;
|
|
578
|
+
if (Array.isArray(selected) && !selected.includes(TOOL_NAME)) return {};
|
|
579
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${ANCHOR_MANDATE}` };
|
|
580
|
+
});
|
|
581
|
+
|
|
455
582
|
pi.registerTool({
|
|
456
|
-
name:
|
|
583
|
+
name: TOOL_NAME,
|
|
457
584
|
label: "Navigate Tree",
|
|
458
585
|
// Stateful: every action mutates SessionManager; concurrent calls would
|
|
459
586
|
// race on `leafId` / `labelsById` and produce an undefined tree.
|
|
460
587
|
executionMode: "sequential",
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
β’ action='list': show all named anchors on the active branch in chronological order, with cumulative context % at each anchor.
|
|
588
|
+
promptGuidelines: [
|
|
589
|
+
`${TOOL_NAME}: the further back you rewind, the more you free but the more collapses into the summary; pick the earliest anchor that still preserves what you need next.`,
|
|
590
|
+
],
|
|
591
|
+
description: `Long-session context management via the pi session tree. Anchor named milestones, then collapse work between them into a model-generated summary to free context.
|
|
592
|
+
\`rewind\` does not restore prior state: it forks a sibling branch from the anchor and continues forward from a model-generated summary.
|
|
467
593
|
|
|
468
|
-
|
|
594
|
+
Operations (set \`action\`):
|
|
595
|
+
β’ 'anchor', name='<milestone-name>': label the current point. Anchor at the start of a stage you'll summarize (e.g. 'impl-start').
|
|
596
|
+
β’ 'rewind', labelStart='<existing>', labelEnd='<new>': collapse work between labelStart and the current leaf into a branch_summary labeled labelEnd, so rewinds can chain.
|
|
597
|
+
β’ 'list': show all anchors on the active branch, oldest first, with cumulative context % at each.
|
|
469
598
|
|
|
470
|
-
\`
|
|
599
|
+
\`name\` (anchor) and \`labelEnd\` (rewind) write into one shared anchor namespace: re-using an existing label moves it to the new entry, and everything written there is addressable as a future \`labelStart\`. Avoid the reserved \`anchor:\` prefix.`,
|
|
471
600
|
promptSnippet:
|
|
472
601
|
"Use to anchor named milestones and rewind the conversation tree to a prior point with a model-generated summary, for token-efficient long autonomous sessions.",
|
|
473
602
|
// The schema is intentionally a flat `Type.Object` with everything-but-
|
|
@@ -481,28 +610,27 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
|
|
|
481
610
|
action: Type.Union(
|
|
482
611
|
[Type.Literal("anchor"), Type.Literal("rewind"), Type.Literal("list")],
|
|
483
612
|
{
|
|
484
|
-
description:
|
|
485
|
-
"Which operation to perform. 'anchor' labels the current point, 'rewind' collapses work between an anchor and the current leaf into a branch_summary, 'list' shows every anchor on the active branch.",
|
|
613
|
+
description: "Which operation to perform.",
|
|
486
614
|
},
|
|
487
615
|
),
|
|
488
616
|
name: Type.Optional(
|
|
489
617
|
Type.String({
|
|
490
|
-
description: `Required when action='anchor'. Kebab-case label
|
|
618
|
+
description: `Required when action='anchor'. Kebab-case milestone label, max ${MAX_NAME_LENGTH} chars.`,
|
|
491
619
|
}),
|
|
492
620
|
),
|
|
493
621
|
labelStart: Type.Optional(
|
|
494
622
|
Type.String({
|
|
495
|
-
description: `Required when action='rewind'. Kebab-case name
|
|
623
|
+
description: `Required when action='rewind'. Kebab-case name of an existing anchor on the active branch to rewind to.`,
|
|
496
624
|
}),
|
|
497
625
|
),
|
|
498
626
|
labelEnd: Type.Optional(
|
|
499
627
|
Type.String({
|
|
500
|
-
description: `Required when action='rewind'. Kebab-case
|
|
628
|
+
description: `Required when action='rewind'. Kebab-case label for the resulting branch_summary entry; reusable as a future labelStart.`,
|
|
501
629
|
}),
|
|
502
630
|
),
|
|
503
631
|
summaryFocus: Type.Optional(
|
|
504
632
|
Type.String({
|
|
505
|
-
description: `Required when action='rewind'
|
|
633
|
+
description: `Required when action='rewind'; β₯${MIN_SUMMARY_FOCUS_LENGTH} chars after trim. Encode: (1) the user's most recent instruction verbatim, (2) what's done in the collapsed segment, (3) what's left to do as a next action.`,
|
|
506
634
|
}),
|
|
507
635
|
),
|
|
508
636
|
}),
|
|
@@ -595,7 +723,7 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
|
|
|
595
723
|
type: "text",
|
|
596
724
|
text:
|
|
597
725
|
`[anchor '${p.name}'] set at ${positionLine}${hintLine}\n\n` +
|
|
598
|
-
`
|
|
726
|
+
`Once real work has accumulated after this anchor, collapse it into a summary with: ${TOOL_NAME}(action='rewind', labelStart='${p.name}', labelEnd='<milestone-name>', summaryFocus='<β₯${MIN_SUMMARY_FOCUS_LENGTH}-char focus: latest user instruction + done + remaining>').`,
|
|
599
727
|
},
|
|
600
728
|
],
|
|
601
729
|
details: {
|
|
@@ -647,15 +775,13 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
|
|
|
647
775
|
);
|
|
648
776
|
}
|
|
649
777
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
}
|
|
658
|
-
|
|
778
|
+
// Token math hoisted ABOVE the model check / auth await (#21): these
|
|
779
|
+
// are pure SessionManager reads, and the min-savings floor below must
|
|
780
|
+
// be evaluable β and able to reject β before any auth resolution.
|
|
781
|
+
// `allEntries`/`byId` are hoisted out of the parented-assistant branch
|
|
782
|
+
// because the floor rejection copy reports per-anchor percentages from
|
|
783
|
+
// the same maps.
|
|
784
|
+
//
|
|
659
785
|
// The leaf at execute time is the assistant that just streamed the
|
|
660
786
|
// rewind tool call. Its `usage.input` is the *minimum* of recent API
|
|
661
787
|
// calls in this turn, so estimating from it understates what the
|
|
@@ -663,6 +789,9 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
|
|
|
663
789
|
// assistant's usage as baseline) so beforeTokens matches the value
|
|
664
790
|
// `list` would have reported on the previous turn.
|
|
665
791
|
const oldLeafEntry = sm.getEntry(oldLeaf);
|
|
792
|
+
const allEntries = sm.getEntries();
|
|
793
|
+
const byId = new Map<string, SessionEntry>();
|
|
794
|
+
for (const e of allEntries) byId.set(e.id, e);
|
|
666
795
|
let beforeTokens: number;
|
|
667
796
|
if (
|
|
668
797
|
oldLeafEntry &&
|
|
@@ -670,14 +799,12 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
|
|
|
670
799
|
oldLeafEntry.message.role === "assistant" &&
|
|
671
800
|
oldLeafEntry.parentId
|
|
672
801
|
) {
|
|
673
|
-
const allEntries = sm.getEntries();
|
|
674
|
-
const byId = new Map<string, SessionEntry>();
|
|
675
|
-
for (const e of allEntries) byId.set(e.id, e);
|
|
676
802
|
beforeTokens = estimateAtEntry(allEntries, oldLeafEntry.parentId, byId);
|
|
677
803
|
} else {
|
|
678
804
|
beforeTokens = estimateActiveBranchTokens(sm);
|
|
679
805
|
}
|
|
680
|
-
const
|
|
806
|
+
const tokensAtTarget = estimateAtEntry(allEntries, target, byId);
|
|
807
|
+
const contextWindow = ctx.model?.contextWindow ?? 0;
|
|
681
808
|
|
|
682
809
|
const { entries } = collectEntriesForBranchSummary(sm, oldLeaf, target);
|
|
683
810
|
if (entries.length === 0) {
|
|
@@ -710,7 +837,7 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
|
|
|
710
837
|
if (
|
|
711
838
|
block &&
|
|
712
839
|
block.type === "toolCall" &&
|
|
713
|
-
block.name ===
|
|
840
|
+
block.name === TOOL_NAME &&
|
|
714
841
|
isSyntheticShape
|
|
715
842
|
) {
|
|
716
843
|
return toolError(
|
|
@@ -720,6 +847,38 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
|
|
|
720
847
|
}
|
|
721
848
|
}
|
|
722
849
|
|
|
850
|
+
// Minimum-savings floor (#21): refuse rewinds whose apparent savings
|
|
851
|
+
// is below MIN_REWIND_SAVINGS_TOKENS β collapsing a near-empty segment
|
|
852
|
+
// burns a summarizer LLM call, forks a junk branch_summary, consumes
|
|
853
|
+
// a milestone label, and grows live context. Placed AFTER the
|
|
854
|
+
// synthetic-boundary guard so the chained rewind-with-no-turns case
|
|
855
|
+
// keeps its specific diagnostic, and BEFORE the model/auth checks so
|
|
856
|
+
// the rejection fires without touching provider config.
|
|
857
|
+
const apparentSavings = beforeTokens - tokensAtTarget;
|
|
858
|
+
if (apparentSavings < MIN_REWIND_SAVINGS_TOKENS) {
|
|
859
|
+
return toolError(
|
|
860
|
+
buildMinSavingsRejection(
|
|
861
|
+
p.labelStart,
|
|
862
|
+
p.labelEnd,
|
|
863
|
+
apparentSavings,
|
|
864
|
+
sm,
|
|
865
|
+
contextWindow,
|
|
866
|
+
allEntries,
|
|
867
|
+
byId,
|
|
868
|
+
),
|
|
869
|
+
{ rejected: "min-savings", apparentSavings },
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (!ctx.model) {
|
|
874
|
+
return toolError("No model configured for summarization.");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
878
|
+
if (!auth.ok) {
|
|
879
|
+
return toolError(`Auth resolution failed: ${auth.error}`);
|
|
880
|
+
}
|
|
881
|
+
|
|
723
882
|
const result = await summarize(entries, {
|
|
724
883
|
// `auth.baseUrl` (OAuth/credential-derived endpoint, e.g.
|
|
725
884
|
// githubCopilotOAuth) must be applied to the model, mirroring pi's
|
|
@@ -729,7 +888,7 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
|
|
|
729
888
|
? { ...ctx.model, baseUrl: auth.baseUrl }
|
|
730
889
|
: ctx.model,
|
|
731
890
|
apiKey: auth.apiKey ?? "",
|
|
732
|
-
headers: stripNullHeaders(auth.headers),
|
|
891
|
+
headers: withSessionHeaders(ctx.model, stripNullHeaders(auth.headers)),
|
|
733
892
|
...(auth.env ? { env: auth.env } : {}),
|
|
734
893
|
signal: signal ?? new AbortController().signal,
|
|
735
894
|
customInstructions: p.summaryFocus,
|
|
@@ -848,7 +1007,7 @@ Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor name
|
|
|
848
1007
|
}
|
|
849
1008
|
const syntheticMsg = buildSyntheticAssistant(
|
|
850
1009
|
toolCallId,
|
|
851
|
-
|
|
1010
|
+
TOOL_NAME,
|
|
852
1011
|
syntheticArgs,
|
|
853
1012
|
ctx.model as
|
|
854
1013
|
| { api?: string; provider?: string; id?: string }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cad0p/pi-tree-navigator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|