@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.
@@ -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`, so this replicates only the session half of
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
- * 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.
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
- details,
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
- // `auth.baseUrl` (OAuth/credential-derived endpoint, e.g.
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(ctx.model, stripNullHeaders(auth.headers)),
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
- ...(resolveProviderStreamFn(ctx.modelRegistry, ctx.model.provider) ??
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.1.3",
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"