@cad0p/pi-tree-navigator 0.1.3 → 0.2.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 +19 -0
- package/README.md +50 -3
- package/extensions/navigate-tree/cache-summary.ts +767 -0
- package/extensions/navigate-tree/index.ts +468 -21
- package/package.json +2 -1
|
@@ -38,8 +38,10 @@
|
|
|
38
38
|
|
|
39
39
|
import { randomUUID } from "node:crypto";
|
|
40
40
|
import {
|
|
41
|
+
type AgentTool,
|
|
41
42
|
estimateContextTokens,
|
|
42
43
|
type StreamFn,
|
|
44
|
+
type ThinkingLevel,
|
|
43
45
|
} from "@earendil-works/pi-agent-core";
|
|
44
46
|
import {
|
|
45
47
|
AgentSession,
|
|
@@ -47,12 +49,23 @@ import {
|
|
|
47
49
|
collectEntriesForBranchSummary,
|
|
48
50
|
type ExtensionAPI,
|
|
49
51
|
generateBranchSummary,
|
|
52
|
+
keyHint,
|
|
50
53
|
type ModelRegistry,
|
|
51
54
|
type SessionEntry,
|
|
52
55
|
type SessionManager,
|
|
53
56
|
sessionEntryToContextMessages,
|
|
54
57
|
} from "@earendil-works/pi-coding-agent";
|
|
58
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
55
59
|
import { Type } from "typebox";
|
|
60
|
+
import {
|
|
61
|
+
buildLiveSummaryMessages,
|
|
62
|
+
type CacheRequest,
|
|
63
|
+
createCachePreservingStreamFn,
|
|
64
|
+
detectBranchSummaryCacheMiss,
|
|
65
|
+
formatBranchSummaryCacheMissNotice,
|
|
66
|
+
measureSummaryCache,
|
|
67
|
+
resolveSummaryCacheRetention,
|
|
68
|
+
} from "./cache-summary.ts";
|
|
56
69
|
import {
|
|
57
70
|
extractTextContent,
|
|
58
71
|
formatContextDelta,
|
|
@@ -170,7 +183,21 @@ interface PiInternals {
|
|
|
170
183
|
agent: {
|
|
171
184
|
state: {
|
|
172
185
|
messages: unknown[];
|
|
186
|
+
/** Live system prompt (cache-preserving request mirror; #33). */
|
|
187
|
+
systemPrompt: string;
|
|
188
|
+
/** Live tool instances (cache-preserving request mirror; #33). */
|
|
189
|
+
tools: unknown[];
|
|
173
190
|
};
|
|
191
|
+
/** Live thinking token budgets (plain field on pi-agent-core's Agent). */
|
|
192
|
+
thinkingBudgets?: unknown;
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Plain field on `AgentSession`; the extension ctx does not expose
|
|
196
|
+
* settings. Used only to gate the TUI cache notice on pi's own
|
|
197
|
+
* `showCacheMissNotices` setting (default off).
|
|
198
|
+
*/
|
|
199
|
+
settingsManager?: {
|
|
200
|
+
getShowCacheMissNotices?: () => boolean;
|
|
174
201
|
};
|
|
175
202
|
sessionManager: SessionManager;
|
|
176
203
|
}
|
|
@@ -285,19 +312,25 @@ function stripNullHeaders(
|
|
|
285
312
|
* native `/tree` summary fails deterministically).
|
|
286
313
|
*
|
|
287
314
|
* The tool-execute ctx exposes neither `settingsManager` (telemetry attribution)
|
|
288
|
-
* nor the live routing `sessionId
|
|
289
|
-
* pi's merge (`getSessionHeaders` in `provider-attribution.ts`): for
|
|
315
|
+
* nor the live routing `sessionId` on 0.81.x, so this replicates only the session
|
|
316
|
+
* half of pi's merge (`getSessionHeaders` in `provider-attribution.ts`): for
|
|
290
317
|
* opencode-family providers, inject `x-opencode-session` + `x-opencode-client`.
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* the
|
|
295
|
-
*
|
|
296
|
-
*
|
|
318
|
+
*
|
|
319
|
+
* `sessionId` is the LIVE session id when the caller can supply one
|
|
320
|
+
* (`sm.getSessionId()`), so the summarization request routes to the same
|
|
321
|
+
* replica/affinity bucket as the turns it summarizes. A fresh UUID is only the
|
|
322
|
+
* fallback for callers that cannot supply one (upstream's `completeSummarization`
|
|
323
|
+
* does the same via `uuidv7()`). The header is routing-only — on
|
|
324
|
+
* openai-responses the *cache key* is `prompt_cache_key`, derived from the
|
|
325
|
+
* forwarded `sessionId` option, not from this header (pi-ai sets the header from
|
|
326
|
+
* `sessionId` only when the provider opts into session affinity).
|
|
327
|
+
*
|
|
328
|
+
* Never overrides a header the auth layer already set.
|
|
297
329
|
*/
|
|
298
330
|
function withSessionHeaders(
|
|
299
331
|
model: { provider?: string; baseUrl?: string } | undefined,
|
|
300
332
|
headers: Record<string, string> | undefined,
|
|
333
|
+
sessionId?: string,
|
|
301
334
|
): Record<string, string> | undefined {
|
|
302
335
|
const provider = model?.provider ?? "";
|
|
303
336
|
let host = "";
|
|
@@ -313,7 +346,7 @@ function withSessionHeaders(
|
|
|
313
346
|
if (!needsSession) return headers;
|
|
314
347
|
const merged = { ...(headers ?? {}) };
|
|
315
348
|
if (!merged["x-opencode-session"]) {
|
|
316
|
-
merged["x-opencode-session"] = randomUUID();
|
|
349
|
+
merged["x-opencode-session"] = sessionId || randomUUID();
|
|
317
350
|
}
|
|
318
351
|
if (!merged["x-opencode-client"]) {
|
|
319
352
|
merged["x-opencode-client"] = "pi";
|
|
@@ -386,6 +419,108 @@ function findLabeledEntry(
|
|
|
386
419
|
return null;
|
|
387
420
|
}
|
|
388
421
|
|
|
422
|
+
/** Shape of a `toolCall` content block on an assistant message. */
|
|
423
|
+
interface ToolCallBlock {
|
|
424
|
+
id: string;
|
|
425
|
+
name?: unknown;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Find the assistant message that declared the in-flight tool call and
|
|
430
|
+
* return its full tool-call batch as emitted (emission order), or `null`
|
|
431
|
+
* when neither the active branch nor the captured session's live messages
|
|
432
|
+
* carry it.
|
|
433
|
+
*
|
|
434
|
+
* `rewind` is only structurally safe as a solo call (#37): it forks the
|
|
435
|
+
* tree and re-declares ONLY its own tool call in a synthetic assistant, so
|
|
436
|
+
* a sibling call's result — appended by pi's sequential loop after the
|
|
437
|
+
* fork — would land on the new branch with no declaring assistant and
|
|
438
|
+
* brick every subsequent request. This helper is the detection seam for
|
|
439
|
+
* the pre-mutation refusal guard.
|
|
440
|
+
*
|
|
441
|
+
* Primary source is `sm.getBranch()`: pi persists the assistant
|
|
442
|
+
* SessionEntry on `message_end` before `tool_execution_start`, so the
|
|
443
|
+
* in-flight assistant is already on the active branch when `execute` runs
|
|
444
|
+
* (asserted by `scripts/pi-upstream-probe.mjs`). The walk matches by
|
|
445
|
+
* tool-call id, not by tail position, because preceding sibling results
|
|
446
|
+
* may already trail the assistant. `getBranch()` reads internal session
|
|
447
|
+
* state and can throw, and tree-write lag can leave it stale; both cases
|
|
448
|
+
* fall back to the same scan over the captured session's
|
|
449
|
+
* `agent.state.messages`. A miss returns `null` so callers fall through to
|
|
450
|
+
* the pre-guard behavior — never hard-fail on undetectable state.
|
|
451
|
+
*
|
|
452
|
+
* Detection fails open on shape, not count: every `toolCall` block with a
|
|
453
|
+
* string `id` counts toward the returned batch, even if its `name` is
|
|
454
|
+
* missing or malformed (`name` falls back to `"unknown"`), so a sibling
|
|
455
|
+
* call can't be silently dropped from the count by an unexpected shape.
|
|
456
|
+
*/
|
|
457
|
+
function findInFlightAssistantToolCalls(
|
|
458
|
+
sm: SessionManager,
|
|
459
|
+
toolCallId: string,
|
|
460
|
+
): Array<{ id: string; name: string }> | null {
|
|
461
|
+
const scan = (
|
|
462
|
+
items: ReadonlyArray<unknown>,
|
|
463
|
+
): Array<{ id: string; name: string }> | null => {
|
|
464
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
465
|
+
const item = items[i] as
|
|
466
|
+
| { type?: unknown; message?: unknown }
|
|
467
|
+
| null
|
|
468
|
+
| undefined;
|
|
469
|
+
if (!item || typeof item !== "object") continue;
|
|
470
|
+
// SessionEntry: `{ type: "message", message: {...} }`. Raw
|
|
471
|
+
// `agent.state.messages` entries are the message itself.
|
|
472
|
+
const candidate = (item.type === "message" ? item.message : item) as
|
|
473
|
+
| { role?: unknown; content?: unknown }
|
|
474
|
+
| null
|
|
475
|
+
| undefined;
|
|
476
|
+
if (
|
|
477
|
+
!candidate ||
|
|
478
|
+
typeof candidate !== "object" ||
|
|
479
|
+
candidate.role !== "assistant" ||
|
|
480
|
+
!Array.isArray(candidate.content)
|
|
481
|
+
) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const toolCalls = candidate.content.filter(
|
|
485
|
+
(block): block is ToolCallBlock =>
|
|
486
|
+
!!block &&
|
|
487
|
+
typeof block === "object" &&
|
|
488
|
+
(block as { type?: unknown }).type === "toolCall" &&
|
|
489
|
+
typeof (block as { id?: unknown }).id === "string",
|
|
490
|
+
);
|
|
491
|
+
if (toolCalls.some((block) => block.id === toolCallId)) {
|
|
492
|
+
return toolCalls.map((block) => ({
|
|
493
|
+
id: block.id,
|
|
494
|
+
name: typeof block.name === "string" ? block.name : "unknown",
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
let branch: SessionEntry[] | undefined;
|
|
502
|
+
try {
|
|
503
|
+
branch = sm.getBranch();
|
|
504
|
+
} catch {
|
|
505
|
+
// getBranch() reads internal session state; fall through to the
|
|
506
|
+
// captured-session scan instead of aborting the rewind.
|
|
507
|
+
}
|
|
508
|
+
if (branch) {
|
|
509
|
+
const onBranch = scan(branch);
|
|
510
|
+
if (onBranch) return onBranch;
|
|
511
|
+
}
|
|
512
|
+
const session = findOwningSession(sm);
|
|
513
|
+
const liveMessages = session
|
|
514
|
+
? asInternals(session).agent?.state?.messages
|
|
515
|
+
: undefined;
|
|
516
|
+
if (!Array.isArray(liveMessages)) return null;
|
|
517
|
+
try {
|
|
518
|
+
return scan(liveMessages);
|
|
519
|
+
} catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
389
524
|
function estimateActiveBranchTokens(sm: SessionManager): number {
|
|
390
525
|
return estimateContextTokens(sm.buildSessionContext().messages).tokens;
|
|
391
526
|
}
|
|
@@ -541,7 +676,13 @@ function toolError(
|
|
|
541
676
|
): ToolResult {
|
|
542
677
|
return {
|
|
543
678
|
content: [{ type: "text", text }],
|
|
544
|
-
|
|
679
|
+
// `isError` is a statement of intent only: pi-agent-core ignores the flag
|
|
680
|
+
// on a *returned* result (only a thrown `execute()` is finalized as
|
|
681
|
+
// failed), so without the `refusal` marker below the host records this
|
|
682
|
+
// as a successful call and the TUI paints the row with `toolSuccessBg`.
|
|
683
|
+
// The `tool_result` handler in the factory promotes the marker into the
|
|
684
|
+
// host's real error channel.
|
|
685
|
+
details: { ...details, refusal: true },
|
|
545
686
|
isError: true,
|
|
546
687
|
};
|
|
547
688
|
}
|
|
@@ -579,6 +720,20 @@ export default function (
|
|
|
579
720
|
return { systemPrompt: `${event.systemPrompt}\n\n${ANCHOR_MANDATE}` };
|
|
580
721
|
});
|
|
581
722
|
|
|
723
|
+
// Refusals must render (and be recorded) as failed tool calls. A returned
|
|
724
|
+
// `isError: true` never reaches the host's error channel — pi-agent-core
|
|
725
|
+
// only finalizes a call as failed when `execute()` throws, overwriting the
|
|
726
|
+
// flag with its own boolean — so `toolError` tags its details with
|
|
727
|
+
// `refusal: true` and this handler flips the host's `isError`, which drives
|
|
728
|
+
// the TUI's `toolErrorBg` shell, the transcript entry, and any other
|
|
729
|
+
// extension's `tool_result` view. Content is left untouched, so the model
|
|
730
|
+
// still reads the refusal copy.
|
|
731
|
+
pi.on("tool_result", (event) => {
|
|
732
|
+
if (event.toolName !== TOOL_NAME) return undefined;
|
|
733
|
+
const details = event.details as { refusal?: boolean } | undefined;
|
|
734
|
+
return details?.refusal === true ? { isError: true } : undefined;
|
|
735
|
+
});
|
|
736
|
+
|
|
582
737
|
pi.registerTool({
|
|
583
738
|
name: TOOL_NAME,
|
|
584
739
|
label: "Navigate Tree",
|
|
@@ -761,6 +916,27 @@ Operations (set \`action\`):
|
|
|
761
916
|
);
|
|
762
917
|
}
|
|
763
918
|
|
|
919
|
+
// Solo-batch guard (#37): a rewind must be the only tool call in its
|
|
920
|
+
// assistant batch. Pi runs a batch containing this sequential-mode tool
|
|
921
|
+
// sequentially; `branchWithSummary` then puts the declaring assistant
|
|
922
|
+
// on the abandoned branch, and the synthetic assistant re-declares
|
|
923
|
+
// ONLY this call — so any sibling result pi appends afterwards would
|
|
924
|
+
// land on the new branch with no declaring `tool_calls`, 400-ing every
|
|
925
|
+
// subsequent request. Refuse before any mutation or LLM call. A missed
|
|
926
|
+
// detection returns null and falls through to today's behavior. The
|
|
927
|
+
// validations above ran first so the copy can interpolate a valid
|
|
928
|
+
// labelStart.
|
|
929
|
+
const inFlightBatch = findInFlightAssistantToolCalls(sm, toolCallId);
|
|
930
|
+
if (inFlightBatch && inFlightBatch.length > 1) {
|
|
931
|
+
return toolError(
|
|
932
|
+
`rewind must be the only tool call in its batch — after it, the next context is everything up to '${p.labelStart}' plus the new summary, so any other call's result would be orphaned — never read by anyone. Those other calls already ran; re-issue only the rewind, alone.`,
|
|
933
|
+
{
|
|
934
|
+
rejected: "batched-rewind",
|
|
935
|
+
batchedToolCalls: inFlightBatch.map((call) => call.name),
|
|
936
|
+
},
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
764
940
|
const target = findLabeledEntry(sm, LABEL_PREFIX + p.labelStart);
|
|
765
941
|
if (!target) {
|
|
766
942
|
return toolError(
|
|
@@ -879,16 +1055,174 @@ Operations (set \`action\`):
|
|
|
879
1055
|
return toolError(`Auth resolution failed: ${auth.error}`);
|
|
880
1056
|
}
|
|
881
1057
|
|
|
1058
|
+
// -----------------------------------------------------------------
|
|
1059
|
+
// Cache-preserving summary request (#33)
|
|
1060
|
+
//
|
|
1061
|
+
// `generateBranchSummary` builds a cold, standalone request (generic
|
|
1062
|
+
// summarization prompt, serialized blob, no tools, cacheRetention
|
|
1063
|
+
// "none", fresh session id). The live turns being collapsed were just
|
|
1064
|
+
// cache-served, so the summary re-bills the whole input. We cannot
|
|
1065
|
+
// patch upstream (the extension loader aliases @earendil-works/* to the
|
|
1066
|
+
// host process), but the `streamFn` seam we already inject is the exact
|
|
1067
|
+
// point where upstream's cold context/options exist — wrap it and
|
|
1068
|
+
// rewrite them to the live shape. Every live input is read
|
|
1069
|
+
// defensively: on any miss we leave `summaryCacheRequest` null and the
|
|
1070
|
+
// wrapper delegates today's cold request (with a user-visible warning).
|
|
1071
|
+
//
|
|
1072
|
+
// Two evidence guards also refuse the cache path and fall back to the
|
|
1073
|
+
// cold request, because raw branch evidence must beat a cache hit when
|
|
1074
|
+
// the two disagree:
|
|
1075
|
+
//
|
|
1076
|
+
// - "branch-crosses-compaction": `buildContextEntries()` applies the
|
|
1077
|
+
// compaction cut — entries before the latest compaction's
|
|
1078
|
+
// `firstKeptEntryId` are dropped from the live projection. When a
|
|
1079
|
+
// rewind segment reaches older than that cut, the cache payload
|
|
1080
|
+
// would summarize the lossy compacted projection while the legacy
|
|
1081
|
+
// path summarizes the raw segment. The compaction entry itself is
|
|
1082
|
+
// retained, so `branchStartRetained` cannot catch this; the
|
|
1083
|
+
// id-difference check below does (every other entry type — labels,
|
|
1084
|
+
// model changes, thinking-level changes — stays in the projection,
|
|
1085
|
+
// so only compaction-dropped entries are missing).
|
|
1086
|
+
// - "branch-start-not-retained": no message from the collapsed
|
|
1087
|
+
// segment survived into the payload (labels-only segment) or the
|
|
1088
|
+
// newest message alone exceeded the token budget. The legacy path
|
|
1089
|
+
// then either summarizes the raw evidence or returns "No content to
|
|
1090
|
+
// summarize" before any wire call.
|
|
1091
|
+
// -----------------------------------------------------------------
|
|
1092
|
+
const providerStream = resolveProviderStreamFn(
|
|
1093
|
+
ctx.modelRegistry,
|
|
1094
|
+
ctx.model.provider,
|
|
1095
|
+
);
|
|
1096
|
+
let summaryCacheRequest: CacheRequest | null = null;
|
|
1097
|
+
let summaryCacheFallbackReason: string | undefined;
|
|
1098
|
+
let branchStartRetained = true;
|
|
1099
|
+
|
|
1100
|
+
if (process.env.PI_NAVIGATE_TREE_SUMMARY_CACHE === "0") {
|
|
1101
|
+
// Kill switch: an env-var escape hatch to force the pre-#33 cold
|
|
1102
|
+
// request without unloading the extension.
|
|
1103
|
+
summaryCacheFallbackReason = "disabled";
|
|
1104
|
+
} else if (!providerStream) {
|
|
1105
|
+
summaryCacheFallbackReason = "no-provider-stream";
|
|
1106
|
+
} else {
|
|
1107
|
+
const owning = findOwningSession(sm);
|
|
1108
|
+
const internals = owning ? asInternals(owning) : undefined;
|
|
1109
|
+
const liveTools = internals?.agent?.state?.tools;
|
|
1110
|
+
if (!internals) {
|
|
1111
|
+
summaryCacheFallbackReason = "reflection-missing";
|
|
1112
|
+
} else if (!Array.isArray(liveTools)) {
|
|
1113
|
+
summaryCacheFallbackReason = "no-live-tools";
|
|
1114
|
+
} else {
|
|
1115
|
+
// Public accessor first (0.81+), reflected field as backstop. Both
|
|
1116
|
+
// are read here AND in refreshAgentMessages; the reflection probe
|
|
1117
|
+
// covers the plain-field shape.
|
|
1118
|
+
let systemPrompt: string | undefined;
|
|
1119
|
+
if (typeof ctx.getSystemPrompt === "function") {
|
|
1120
|
+
try {
|
|
1121
|
+
systemPrompt = ctx.getSystemPrompt();
|
|
1122
|
+
} catch {
|
|
1123
|
+
systemPrompt = undefined;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (typeof systemPrompt !== "string" || systemPrompt.length === 0) {
|
|
1127
|
+
const reflected = internals.agent?.state?.systemPrompt;
|
|
1128
|
+
systemPrompt =
|
|
1129
|
+
typeof reflected === "string" ? reflected : undefined;
|
|
1130
|
+
}
|
|
1131
|
+
if (typeof systemPrompt !== "string" || systemPrompt.length === 0) {
|
|
1132
|
+
summaryCacheFallbackReason = "no-system-prompt";
|
|
1133
|
+
} else {
|
|
1134
|
+
const contextEntries = sm.buildContextEntries();
|
|
1135
|
+
const contextIds = new Set(contextEntries.map((e) => e.id));
|
|
1136
|
+
// Evidence-loss guard: any segment entry missing from the live
|
|
1137
|
+
// projection means the compaction cut dropped raw evidence, so
|
|
1138
|
+
// the cache payload would diverge from the legacy raw segment.
|
|
1139
|
+
// Use the id-difference predicate — NOT a "segment contains a
|
|
1140
|
+
// compaction" check: a segment that contains the compaction
|
|
1141
|
+
// entry but whose target sits at/after `firstKeptEntryId` loses
|
|
1142
|
+
// no evidence and must still take the cache path.
|
|
1143
|
+
if (entries.some((e) => !contextIds.has(e.id))) {
|
|
1144
|
+
summaryCacheFallbackReason = "branch-crosses-compaction";
|
|
1145
|
+
} else {
|
|
1146
|
+
const branchEntryIds = new Set(entries.map((e) => e.id));
|
|
1147
|
+
// Upstream's default `reserveTokens`; the fallback path will
|
|
1148
|
+
// recompute the same budget inside generateBranchSummary.
|
|
1149
|
+
const tokenBudget = (ctx.model.contextWindow || 128000) - 16384;
|
|
1150
|
+
const built = buildLiveSummaryMessages({
|
|
1151
|
+
contextEntries,
|
|
1152
|
+
branchEntryIds,
|
|
1153
|
+
inFlightToolCallId: toolCallId,
|
|
1154
|
+
tokenBudget,
|
|
1155
|
+
focus: p.summaryFocus,
|
|
1156
|
+
});
|
|
1157
|
+
branchStartRetained = built.branchStartRetained;
|
|
1158
|
+
if (!built.branchStartRetained) {
|
|
1159
|
+
summaryCacheFallbackReason = "branch-start-not-retained";
|
|
1160
|
+
} else {
|
|
1161
|
+
const sessionId = sm.getSessionId();
|
|
1162
|
+
// Public since 0.81.0; `ctx.thinkingLevel` only exists >=
|
|
1163
|
+
// 0.84.2, so never read it directly. "off" is omitted,
|
|
1164
|
+
// mirroring the live loop's request shape.
|
|
1165
|
+
const reasoning =
|
|
1166
|
+
typeof pi.getThinkingLevel === "function"
|
|
1167
|
+
? pi.getThinkingLevel()
|
|
1168
|
+
: undefined;
|
|
1169
|
+
summaryCacheRequest = {
|
|
1170
|
+
context: {
|
|
1171
|
+
systemPrompt,
|
|
1172
|
+
messages: built.messages,
|
|
1173
|
+
tools: liveTools as AgentTool[],
|
|
1174
|
+
},
|
|
1175
|
+
// Provider-scoped `PI_CACHE_RETENTION` (auth.env) must win
|
|
1176
|
+
// over `process.env`, mirroring pi-ai's
|
|
1177
|
+
// `getProviderEnvValue`: the live turns resolve retention
|
|
1178
|
+
// from the provider env, so a provider-scoped "long" with
|
|
1179
|
+
// a process-level "short" would make live=long /
|
|
1180
|
+
// summary=short and silently miss.
|
|
1181
|
+
cacheRetention: resolveSummaryCacheRetention(auth.env),
|
|
1182
|
+
...(sessionId ? { sessionId } : {}),
|
|
1183
|
+
...(typeof reasoning === "string" && reasoning !== "off"
|
|
1184
|
+
? { reasoning: reasoning as ThinkingLevel }
|
|
1185
|
+
: {}),
|
|
1186
|
+
...(internals.agent?.thinkingBudgets
|
|
1187
|
+
? { thinkingBudgets: internals.agent.thinkingBudgets }
|
|
1188
|
+
: {}),
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Always wrap when a provider stream exists: with a request it rewrites
|
|
1197
|
+
// the live shape, without one it delegates today's cold request
|
|
1198
|
+
// byte-for-byte (the `used` flag records which happened).
|
|
1199
|
+
const cacheWrapped = providerStream
|
|
1200
|
+
? createCachePreservingStreamFn({
|
|
1201
|
+
realStreamFn: providerStream.streamFn,
|
|
1202
|
+
request: summaryCacheRequest,
|
|
1203
|
+
})
|
|
1204
|
+
: undefined;
|
|
1205
|
+
|
|
1206
|
+
// `auth.baseUrl` (OAuth/credential-derived endpoint, e.g.
|
|
1207
|
+
// githubCopilotOAuth) must be applied to the model, mirroring pi's
|
|
1208
|
+
// own `_getSummarizationRequestAuth` (`result.auth.baseUrl ?
|
|
1209
|
+
// { ...model, baseUrl: result.auth.baseUrl } : model`). Use this one
|
|
1210
|
+
// resolved model for BOTH the summarization call and the session
|
|
1211
|
+
// headers: the routing header is derived from the endpoint actually
|
|
1212
|
+
// contacted, so reading the un-overridden `ctx.model` there would
|
|
1213
|
+
// diverge.
|
|
1214
|
+
const requestModel = auth.baseUrl
|
|
1215
|
+
? { ...ctx.model, baseUrl: auth.baseUrl }
|
|
1216
|
+
: ctx.model;
|
|
1217
|
+
|
|
882
1218
|
const result = await summarize(entries, {
|
|
883
|
-
|
|
884
|
-
// githubCopilotOAuth) must be applied to the model, mirroring pi's
|
|
885
|
-
// own `_getSummarizationRequestAuth` (`result.auth.baseUrl ?
|
|
886
|
-
// { ...model, baseUrl: result.auth.baseUrl } : model`).
|
|
887
|
-
model: auth.baseUrl
|
|
888
|
-
? { ...ctx.model, baseUrl: auth.baseUrl }
|
|
889
|
-
: ctx.model,
|
|
1219
|
+
model: requestModel,
|
|
890
1220
|
apiKey: auth.apiKey ?? "",
|
|
891
|
-
headers: withSessionHeaders(
|
|
1221
|
+
headers: withSessionHeaders(
|
|
1222
|
+
requestModel,
|
|
1223
|
+
stripNullHeaders(auth.headers),
|
|
1224
|
+
sm.getSessionId(),
|
|
1225
|
+
),
|
|
892
1226
|
...(auth.env ? { env: auth.env } : {}),
|
|
893
1227
|
signal: signal ?? new AbortController().signal,
|
|
894
1228
|
customInstructions: p.summaryFocus,
|
|
@@ -899,9 +1233,9 @@ Operations (set \`action\`):
|
|
|
899
1233
|
// compat registry (which only knows builtin apis) — without this,
|
|
900
1234
|
// rewind fails with "No API provider registered for api:
|
|
901
1235
|
// <custom-id>" for any custom-api provider (e.g. commandcode 0.5.x
|
|
902
|
-
// with api "commandcode-custom").
|
|
903
|
-
|
|
904
|
-
|
|
1236
|
+
// with api "commandcode-custom"). The wrapper delegates to it in
|
|
1237
|
+
// both the live-prefix and fallback cases.
|
|
1238
|
+
...(cacheWrapped ? { streamFn: cacheWrapped.streamFn } : {}),
|
|
905
1239
|
});
|
|
906
1240
|
if (result.aborted) {
|
|
907
1241
|
return toolError("Summarization aborted.");
|
|
@@ -912,6 +1246,68 @@ Operations (set \`action\`):
|
|
|
912
1246
|
);
|
|
913
1247
|
}
|
|
914
1248
|
|
|
1249
|
+
// Cache outcome measured from provider usage. Mode reflects whether a
|
|
1250
|
+
// cache-preserving request was BUILT; `used` reflects whether the
|
|
1251
|
+
// wrapper actually delegated it (false when the summarizer is stubbed,
|
|
1252
|
+
// aborted before the wire call, or the fallback path ran).
|
|
1253
|
+
const summaryCacheMode = summaryCacheRequest ? "live-prefix" : "fallback";
|
|
1254
|
+
const summaryCacheStats = measureSummaryCache(
|
|
1255
|
+
result.usage ?? { input: 0, cacheRead: 0, cacheWrite: 0 },
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
// Fork-faithful miss accounting (cad0p/pi@eval/branch-summary-prompt):
|
|
1259
|
+
// scan the entries as they stand (the branch_summary is not appended
|
|
1260
|
+
// yet) and compare the summary's measured usage against the previous
|
|
1261
|
+
// request's baseline — the baseline survives branch_summary entries
|
|
1262
|
+
// (the summary request reuses the live prefix), while a summary miss
|
|
1263
|
+
// after a model switch is suppressed as expected re-billing. HITS ARE
|
|
1264
|
+
// SILENT — the footer/session totals already cover them; only an
|
|
1265
|
+
// actionable miss is worth a transcript line. The fallback path (cache
|
|
1266
|
+
// request not built) is NOT special-cased: the legacy cold request
|
|
1267
|
+
// measures as a miss exactly when the numbers say so, and
|
|
1268
|
+
// `fallbackReason` lives in `details.summaryCache` only. Upstream pi
|
|
1269
|
+
// renders cache notices as transcript lines gated by
|
|
1270
|
+
// `showCacheMissNotices`; the extension does the same by storing the
|
|
1271
|
+
// notice string and rendering it from the tool's `renderResult`. The
|
|
1272
|
+
// model-visible tool-result content stays cache-free (the model was
|
|
1273
|
+
// proven to echo the line verbatim). The setting is read reflectively
|
|
1274
|
+
// off the captured session — the extension ctx does not expose
|
|
1275
|
+
// `settingsManager`. `Date.now()` is the summary timestamp, as the
|
|
1276
|
+
// fork's `navigateTree` call site uses. Headless runs and setting-off
|
|
1277
|
+
// runs surface the same numbers via `details.summaryCache`.
|
|
1278
|
+
const summaryCacheMiss = result.usage
|
|
1279
|
+
? detectBranchSummaryCacheMiss(
|
|
1280
|
+
allEntries,
|
|
1281
|
+
result.usage,
|
|
1282
|
+
requestModel.provider,
|
|
1283
|
+
requestModel.id,
|
|
1284
|
+
Date.now(),
|
|
1285
|
+
{
|
|
1286
|
+
getModel: (provider, model) =>
|
|
1287
|
+
ctx.modelRegistry.find(provider, model),
|
|
1288
|
+
},
|
|
1289
|
+
)
|
|
1290
|
+
: undefined;
|
|
1291
|
+
let summaryCacheNotice: string | null = null;
|
|
1292
|
+
if (summaryCacheMiss) {
|
|
1293
|
+
const notice = formatBranchSummaryCacheMissNotice(summaryCacheMiss);
|
|
1294
|
+
if (notice !== null) {
|
|
1295
|
+
let showCacheNotices = false;
|
|
1296
|
+
try {
|
|
1297
|
+
const owning = findOwningSession(sm);
|
|
1298
|
+
showCacheNotices =
|
|
1299
|
+
(owning
|
|
1300
|
+
? asInternals(owning).settingsManager
|
|
1301
|
+
: undefined
|
|
1302
|
+
)?.getShowCacheMissNotices?.() ?? false;
|
|
1303
|
+
} catch {
|
|
1304
|
+
// Unreadable settings -> stay silent (mirror pi's default off).
|
|
1305
|
+
showCacheNotices = false;
|
|
1306
|
+
}
|
|
1307
|
+
if (showCacheNotices) summaryCacheNotice = notice;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
915
1311
|
// Move the tree.
|
|
916
1312
|
const summaryId = sm.branchWithSummary(target, result.summary, {
|
|
917
1313
|
readFiles: result.readFiles ?? [],
|
|
@@ -1064,11 +1460,61 @@ Operations (set \`action\`):
|
|
|
1064
1460
|
contextAfter: afterTokens,
|
|
1065
1461
|
contextWindow,
|
|
1066
1462
|
agentMessagesRefreshed: refreshed,
|
|
1463
|
+
summaryCache: {
|
|
1464
|
+
mode: summaryCacheMode,
|
|
1465
|
+
fallbackReason: summaryCacheFallbackReason ?? null,
|
|
1466
|
+
branchStartRetained,
|
|
1467
|
+
used: cacheWrapped?.used.value ?? false,
|
|
1468
|
+
cacheRead: summaryCacheStats.cacheRead,
|
|
1469
|
+
input: summaryCacheStats.fresh,
|
|
1470
|
+
cacheWrite: summaryCacheStats.cacheWrite,
|
|
1471
|
+
hit: summaryCacheStats.hit,
|
|
1472
|
+
missedTokens: summaryCacheMiss?.missedTokens ?? 0,
|
|
1473
|
+
missedCost: summaryCacheMiss?.missedCost ?? 0,
|
|
1474
|
+
idleMs: summaryCacheMiss?.idleMs ?? 0,
|
|
1475
|
+
modelChanged: summaryCacheMiss?.modelChanged ?? false,
|
|
1476
|
+
notice: summaryCacheNotice,
|
|
1477
|
+
},
|
|
1067
1478
|
readFiles: result.readFiles ?? [],
|
|
1068
1479
|
modifiedFiles: result.modifiedFiles ?? [],
|
|
1069
1480
|
},
|
|
1070
1481
|
};
|
|
1071
1482
|
},
|
|
1483
|
+
// Upstream pi renders cache notices as transcript lines (its
|
|
1484
|
+
// `maybeShowCacheMissNotice` + `createResultFallback`), not toasts.
|
|
1485
|
+
// Reproduce the default tool-result rendering and append the notice that
|
|
1486
|
+
// `execute` stored. `showCacheMissNotices` gating already happened in
|
|
1487
|
+
// `execute`; this renderer only runs in the TUI.
|
|
1488
|
+
renderResult(result, { expanded }, theme) {
|
|
1489
|
+
const output = result.content
|
|
1490
|
+
.filter((block) => block.type === "text")
|
|
1491
|
+
.map((block) => (block.type === "text" ? block.text : ""))
|
|
1492
|
+
.join("\n");
|
|
1493
|
+
const lines = output.split("\n");
|
|
1494
|
+
const displayLines = expanded ? lines : lines.slice(0, 10);
|
|
1495
|
+
const remaining = lines.length - displayLines.length;
|
|
1496
|
+
let text = displayLines
|
|
1497
|
+
.map((line) => theme.fg("toolOutput", line))
|
|
1498
|
+
.join("\n");
|
|
1499
|
+
if (remaining > 0) {
|
|
1500
|
+
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint(
|
|
1501
|
+
"app.tools.expand",
|
|
1502
|
+
"to expand",
|
|
1503
|
+
)}${theme.fg("muted", ")")}`;
|
|
1504
|
+
}
|
|
1505
|
+
const container = new Container();
|
|
1506
|
+
container.addChild(new Text(text, 0, 0));
|
|
1507
|
+
const notice = (
|
|
1508
|
+
result.details as
|
|
1509
|
+
| { summaryCache?: { notice?: string | null } }
|
|
1510
|
+
| undefined
|
|
1511
|
+
)?.summaryCache?.notice;
|
|
1512
|
+
if (typeof notice === "string" && notice.length > 0) {
|
|
1513
|
+
container.addChild(new Spacer(1));
|
|
1514
|
+
container.addChild(new Text(theme.fg("warning", notice), 1, 0));
|
|
1515
|
+
}
|
|
1516
|
+
return container;
|
|
1517
|
+
},
|
|
1072
1518
|
});
|
|
1073
1519
|
}
|
|
1074
1520
|
|
|
@@ -1112,6 +1558,7 @@ export const __testHooks = {
|
|
|
1112
1558
|
buildSyntheticAssistant,
|
|
1113
1559
|
findLabelHint,
|
|
1114
1560
|
findLabeledEntry,
|
|
1561
|
+
findInFlightAssistantToolCalls,
|
|
1115
1562
|
buildContextMessages,
|
|
1116
1563
|
refreshAgentMessages,
|
|
1117
1564
|
captureSession,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cad0p/pi-tree-navigator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@earendil-works/pi-agent-core": ">=0.81.0",
|
|
51
51
|
"@earendil-works/pi-coding-agent": ">=0.81.0",
|
|
52
|
+
"@earendil-works/pi-tui": ">=0.81.0",
|
|
52
53
|
"typebox": "^1.0.0"
|
|
53
54
|
},
|
|
54
55
|
"license": "MIT"
|