@ian-pascoe/pi-advisor 0.6.0 → 0.8.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.
@@ -1,11 +1,26 @@
1
1
  import {
2
2
  getMarkdownTheme,
3
- keyHint,
4
3
  type MessageRenderOptions,
5
4
  type Theme,
6
5
  type ThemeColor,
7
6
  } from "@earendil-works/pi-coding-agent";
8
- import { Box, Container, Markdown, Text, type Component } from "@earendil-works/pi-tui";
7
+ import { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
8
+ import {
9
+ callDurationFooter,
10
+ COLLAPSED_LINES,
11
+ appendDurationFooter,
12
+ CollapsedPreview,
13
+ customMessageBox,
14
+ footerStatus,
15
+ joinInline,
16
+ previewBody,
17
+ statusMark,
18
+ summaryExpandHint,
19
+ toolHeader,
20
+ treePrefix,
21
+ type DurationContext,
22
+ type StatusKind,
23
+ } from "@ian-pascoe/pi-utils/ui";
9
24
  import { Type, type Static } from "typebox";
10
25
  import { Value } from "typebox/value";
11
26
  import {
@@ -29,12 +44,15 @@ import {
29
44
  /** Theme operations used by Advisor transcript and footer renderers. */
30
45
  export type AdvisorRenderTheme = Pick<Theme, "fg" | "bg" | "bold">;
31
46
 
32
- const severityStyle = {
33
- nit: { symbol: "·", color: "muted" },
34
- concern: { symbol: "▲", color: "warning" },
35
- blocker: { symbol: "✖", color: "error" },
36
- } as const satisfies Record<AdvisorSeverity, { symbol: string; color: ThemeColor }>;
37
- const collapsedNitLines = 4;
47
+ /** The registered name of the consultation tool. */
48
+ export const advisorAskToolName = "advisor_ask";
49
+
50
+ /** Severity Labels: a coloured word, never a Status Mark. */
51
+ const severityColor = {
52
+ nit: "dim",
53
+ concern: "warning",
54
+ blocker: "error",
55
+ } as const satisfies Record<AdvisorSeverity, ThemeColor>;
38
56
 
39
57
  const nullableString = Type.Union([Type.String(), Type.Null()]);
40
58
  /** The minimum every historical status entry recorded. */
@@ -74,37 +92,25 @@ const statusSchema = Type.Object({
74
92
  });
75
93
  /** Data recorded in a `pi-advisor-status` entry. */
76
94
  export type AdvisorStatusEntry = Static<typeof statusSchema>;
77
- const stateBadge = {
78
- disabled: { symbol: "○", color: "dim" },
79
- private: { symbol: "○", color: "dim" },
80
- armed: { symbol: "●", color: "success" },
81
- reviewing: { symbol: "●", color: "accent" },
82
- consulting: { symbol: "●", color: "accent" },
83
- paused: { symbol: "●", color: "error" },
84
- } as const satisfies Record<AdvisorState, { symbol: string; color: ThemeColor }>;
95
+ const stateMarkKind = {
96
+ disabled: "idle",
97
+ private: "idle",
98
+ armed: "active",
99
+ reviewing: "active",
100
+ consulting: "active",
101
+ paused: "warning",
102
+ } as const satisfies Record<AdvisorState, StatusKind>;
85
103
  const promptPreviewWidth = 40;
86
104
 
87
- function expandHint(theme: Pick<Theme, "fg">): string {
88
- return theme.fg("dim", `… ${keyHint("app.tools.expand", "to expand")}`);
89
- }
105
+ /** Styles a plain piece of text; messages use `customMessageText`, menus leave it as is. */
106
+ type Paint = (text: string) => string;
107
+ const unpainted: Paint = (text) => text;
108
+ const messagePaint =
109
+ (theme: Pick<Theme, "fg">): Paint =>
110
+ (text) =>
111
+ theme.fg("customMessageText", text);
90
112
 
91
- /** Render at most `limit` lines of a child, then an expansion hint. */
92
- class Clipped implements Component {
93
- constructor(
94
- private readonly child: Component,
95
- private readonly limit: number,
96
- private readonly hint: string,
97
- ) {}
98
- render(width: number): string[] {
99
- const lines = this.child.render(width);
100
- return lines.length <= this.limit ? lines : [...lines.slice(0, this.limit), this.hint];
101
- }
102
- invalidate(): void {
103
- this.child.invalidate();
104
- }
105
- }
106
-
107
- /** Severity-styled, attributed Intervention; invalid details fall back to Pi's renderer. */
113
+ /** Severity-labelled, attributed Intervention; invalid details fall back to Pi's renderer. */
108
114
  export function renderAdvisorIntervention(
109
115
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled custom-message details are validated by the finding schema below.
110
116
  details: unknown,
@@ -113,25 +119,21 @@ export function renderAdvisorIntervention(
113
119
  agentId?: string,
114
120
  ): Component | undefined {
115
121
  if (!Value.Check(advisorFindingSchema, details)) return undefined;
116
- const style = severityStyle[details.severity];
117
- const heading = joinDefined(
118
- [
119
- theme.fg(style.color, theme.bold(`${style.symbol} Advisor ${details.severity}`)),
120
- agentId ? theme.fg("accent", `↳ ${agentId}`) : undefined,
121
- ],
122
- " ",
123
- );
124
- const body: Component = new Markdown(details.message, 0, 0, getMarkdownTheme());
125
- const container = new Container();
126
- container.addChild(new Text(heading, 0, 0));
127
- container.addChild(
128
- details.severity === "nit" && !options.expanded
129
- ? new Clipped(body, collapsedNitLines, expandHint(theme))
122
+ const heading = joinInline(theme, [
123
+ theme.fg(severityColor[details.severity], details.severity),
124
+ agentId ? theme.fg("accent", agentId) : undefined,
125
+ ]);
126
+ const body = new Markdown(details.message, 0, 0, getMarkdownTheme(), {
127
+ color: messagePaint(theme),
128
+ });
129
+ return customMessageBox(theme, { outputPad: options.outputPad, source: "advisor", heading }, [
130
+ details.severity === "nit"
131
+ ? new CollapsedPreview(theme, body, {
132
+ limit: COLLAPSED_LINES.fallback,
133
+ expanded: options.expanded,
134
+ })
130
135
  : body,
131
- );
132
- const box = new Box(options.outputPad, 1, (text) => theme.bg("customMessageBg", text));
133
- box.addChild(container);
134
- return box;
136
+ ]);
135
137
  }
136
138
 
137
139
  const childFindingSchema = Type.Object({
@@ -145,52 +147,89 @@ const childStateSchema = Type.Object({
145
147
  error: Type.Optional(Type.String()),
146
148
  });
147
149
 
150
+ /** Pi's entry renderers get no `outputPad`, so entries use Pi's default of one column. */
151
+ const entryOutputPad = 1;
152
+
148
153
  /** A Child Agent's Intervention or pause, attributed to that child. */
149
154
  export function renderAdvisorChildEntry(
150
155
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled entry data is validated below; anything else renders raw.
151
156
  data: unknown,
152
157
  expanded: boolean,
153
158
  theme: AdvisorRenderTheme,
159
+ outputPad = entryOutputPad,
154
160
  ): Component {
155
161
  if (Value.Check(childFindingSchema, data)) {
156
162
  const finding = { severity: data.severity, message: data.message };
157
163
  const rendered = renderAdvisorIntervention(
158
164
  finding,
159
- { expanded, outputPad: 0 },
165
+ { expanded, outputPad },
160
166
  theme,
161
167
  data.agentId,
162
168
  );
163
169
  if (rendered) return rendered;
164
170
  }
165
171
  if (Value.Check(childStateSchema, data)) {
166
- const line = `Advisor ↳ ${data.agentId} ${data.state}${data.error ? `: ${data.error}` : ""}`;
167
- return new Text(data.error ? theme.fg("error", `✖ ${line}`) : theme.fg("dim", line), 0, 0);
172
+ const heading = joinInline(theme, [
173
+ theme.fg("accent", data.agentId),
174
+ badge(data.state, theme, messagePaint(theme)),
175
+ ]);
176
+ return customMessageBox(
177
+ theme,
178
+ { outputPad, source: "advisor", heading },
179
+ data.error ? [new Text(theme.fg("error", data.error), 0, 0)] : [],
180
+ );
168
181
  }
169
182
  return new Text(`Advisor for Child Agent\n${JSON.stringify(data, null, 2)}`, 0, 0);
170
183
  }
171
184
 
172
- const collapsedAnswerLines = 8;
173
-
174
185
  /** `advisor_ask` call: the question, first line only while collapsed. */
175
186
  export function renderAdvisorAskCall(
176
187
  args: { message: string },
177
188
  expanded: boolean,
178
189
  theme: AdvisorRenderTheme,
190
+ context: DurationContext,
179
191
  ): Component {
180
- const question = expanded ? args.message : (args.message.split("\n", 1)[0] ?? "");
181
- return new Text(`${theme.fg("toolTitle", theme.bold("Ask Advisor"))} ${question}`, 0, 0);
192
+ const [first = "", ...rest] = args.message.split("\n");
193
+ const hidden = rest.length > 0 && !expanded;
194
+ const heading = toolHeader(theme, advisorAskToolName, expanded ? undefined : first);
195
+ const container = new Container();
196
+ container.addChild(new Text(hidden ? `${heading}${summaryExpandHint(theme)}` : heading, 0, 0));
197
+ if (expanded) container.addChild(new Text(theme.fg("muted", `message: ${args.message}`), 0, 0));
198
+ container.addChild(callDurationFooter(theme, context));
199
+ return container;
182
200
  }
183
201
 
184
- /** `advisor_ask` result: a Markdown answer, previewed while collapsed. */
202
+ /** `advisor_ask` result: a Markdown answer, previewed while collapsed, then Pi's duration footer. */
185
203
  export function renderAdvisorAskResult(
186
204
  answer: string,
187
205
  options: { expanded: boolean; isPartial: boolean; isError: boolean },
188
206
  theme: AdvisorRenderTheme,
207
+ context: DurationContext,
189
208
  ): Component {
190
- if (options.isPartial) return new Text(theme.fg("dim", "Consulting…"), 0, 0);
191
- if (options.isError) return new Text(theme.fg("error", answer), 0, 0);
192
- const body = new Markdown(answer, 0, 0, getMarkdownTheme());
193
- return options.expanded ? body : new Clipped(body, collapsedAnswerLines, expandHint(theme));
209
+ const container = new Container();
210
+ if (answer) {
211
+ container.addChild(new Spacer(1));
212
+ if (options.isError) {
213
+ const lines = previewBody(theme, answer.split("\n"), {
214
+ limit: COLLAPSED_LINES.fallback,
215
+ expanded: options.expanded,
216
+ color: "error",
217
+ });
218
+ container.addChild(new Text(lines.join("\n"), 0, 0));
219
+ } else {
220
+ const body = new Markdown(answer, 0, 0, getMarkdownTheme(), {
221
+ color: (text) => theme.fg("toolOutput", text),
222
+ });
223
+ container.addChild(
224
+ new CollapsedPreview(theme, body, {
225
+ limit: COLLAPSED_LINES.fallback,
226
+ expanded: options.expanded,
227
+ }),
228
+ );
229
+ }
230
+ }
231
+ appendDurationFooter(container, theme, context, { isPartial: options.isPartial });
232
+ return container;
194
233
  }
195
234
 
196
235
  function formatTokens(total: number): string {
@@ -243,7 +282,7 @@ export function formatAdvisorOption(options: AdvisorOptions, key: keyof AdvisorO
243
282
  if (options.prompt === undefined) return "inherit";
244
283
  const first = options.prompt.split("\n", 1)[0] ?? "";
245
284
  const preview =
246
- first.length > promptPreviewWidth ? `${first.slice(0, promptPreviewWidth - 1)}…` : first;
285
+ first.length > promptPreviewWidth ? `${first.slice(0, promptPreviewWidth - 3)}...` : first;
247
286
  return `${preview} (${options.prompt.length} chars)`;
248
287
  }
249
288
  case "allowedTools":
@@ -261,28 +300,39 @@ export function formatAdvisorOption(options: AdvisorOptions, key: keyof AdvisorO
261
300
  }
262
301
  }
263
302
 
264
- function badge(state: AdvisorState, theme: AdvisorRenderTheme): string {
265
- const style = stateBadge[state];
266
- return theme.fg(style.color, `${style.symbol} ${state}`);
303
+ /** The word shown for a state. Guardian says `on` when idle, so an armed Advisor does too; `armed` stays the stored state. */
304
+ function stateWord(state: AdvisorState): string {
305
+ return state === "armed" ? "on" : state;
306
+ }
307
+
308
+ function badge(state: AdvisorState, theme: AdvisorRenderTheme, paint: Paint): string {
309
+ return `${statusMark(theme, stateMarkKind[state])} ${paint(stateWord(state))}`;
267
310
  }
268
311
 
269
312
  function joinDefined(parts: ReadonlyArray<string | undefined>, separator: string): string {
270
313
  return parts.filter((part) => part !== undefined).join(separator);
271
314
  }
272
315
 
273
- function stateLine(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string {
316
+ function stateLine(
317
+ entry: AdvisorStatusEntry,
318
+ theme: AdvisorRenderTheme,
319
+ label: string | undefined,
320
+ paint: Paint,
321
+ ): string {
274
322
  const inherited = entry.settings && entry.settings.model === undefined;
275
- return joinDefined(
276
- [
277
- `${theme.bold("Advisor")} ${badge(entry.state, theme)}`,
278
- entry.effectiveModel
279
- ? `${entry.effectiveModel}${inherited ? theme.fg("dim", " (inherited)") : ""}`
280
- : undefined,
281
- entry.effectiveThinkingLevel ?? undefined,
282
- entry.backlog ? `backlog ${entry.backlog}` : undefined,
283
- ],
284
- theme.fg("dim", " · "),
285
- );
323
+ const state = badge(entry.state, theme, paint);
324
+ return joinInline(theme, [
325
+ label ? `${label} ${state}` : state,
326
+ entry.effectiveModel
327
+ ? `${paint(entry.effectiveModel)}${inherited ? theme.fg("dim", " (inherited)") : ""}`
328
+ : undefined,
329
+ entry.effectiveThinkingLevel ? paint(entry.effectiveThinkingLevel) : undefined,
330
+ entry.backlog ? paint(`backlog ${entry.backlog}`) : undefined,
331
+ ]);
332
+ }
333
+
334
+ function errorLine(theme: AdvisorRenderTheme, error: string): string {
335
+ return `${statusMark(theme, "failed")} ${theme.fg("error", error)}`;
286
336
  }
287
337
 
288
338
  /** Live state line plus any error, as shown at the top of the settings menu. */
@@ -291,17 +341,21 @@ export function advisorStatusHeadline(
291
341
  theme: AdvisorRenderTheme,
292
342
  ): string[] {
293
343
  const error = entry.error ?? entry.lastError;
294
- return [stateLine(entry, theme), ...(error ? [theme.fg("error", `✖ ${error}`)] : [])];
344
+ return [
345
+ stateLine(entry, theme, theme.bold("Advisor"), unpainted),
346
+ ...(error ? [errorLine(theme, error)] : []),
347
+ ];
295
348
  }
296
349
 
350
+ /** The status entry's body below its `[advisor] ● on · model` label line. */
297
351
  function summaryLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string[] {
352
+ const paint = messagePaint(theme);
298
353
  const lines: string[] = [];
299
354
  for (const { scope, key, options } of entry.changes ?? []) {
300
355
  lines.push(
301
- `${theme.fg("success", "✓")} ${key} → ${formatAdvisorOption(options, key)} ${theme.fg("dim", `[${scope}]`)}`,
356
+ `${statusMark(theme, "done")} ${paint(`${key} → ${formatAdvisorOption(options, key)}`)} ${theme.fg("dim", `[${scope}]`)}`,
302
357
  );
303
358
  }
304
- lines.push(stateLine(entry, theme));
305
359
  const children = entry.children?.length ?? 0;
306
360
  const activity = joinDefined(
307
361
  [
@@ -316,13 +370,16 @@ function summaryLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): str
316
370
  const counts = findingCounts(entry);
317
371
  if (counts.length) lines.push(theme.fg("muted", counts.join(" · ")));
318
372
  if (entry.unavailableTools?.length)
319
- lines.push(theme.fg("warning", `⚠ unavailable tools: ${entry.unavailableTools.join(", ")}`));
373
+ lines.push(
374
+ `${statusMark(theme, "warning")} ${theme.fg("warning", `unavailable tools: ${entry.unavailableTools.join(", ")}`)}`,
375
+ );
320
376
  const error = entry.error ?? entry.lastError;
321
- if (error) lines.push(theme.fg("error", `✖ ${error}`));
377
+ if (error) lines.push(errorLine(theme, error));
322
378
  return lines;
323
379
  }
324
380
 
325
381
  function detailLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string[] {
382
+ const paint = messagePaint(theme);
326
383
  const lines: string[] = [];
327
384
  if (entry.settings) {
328
385
  const settings = entry.settings;
@@ -331,22 +388,23 @@ function detailLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): stri
331
388
  for (const key of advisorOptionKeys) {
332
389
  const source = entry.sources?.[key] ?? "default";
333
390
  lines.push(
334
- ` ${key.padEnd(width)} ${formatAdvisorOption(settings, key)} ${theme.fg(source === "default" ? "dim" : "accent", `[${source}]`)}`,
391
+ ` ${paint(key.padEnd(width))} ${paint(formatAdvisorOption(settings, key))} ${theme.fg(source === "default" ? "dim" : "accent", `[${source}]`)}`,
335
392
  );
336
393
  }
337
394
  }
338
- if (entry.children?.length) lines.push("");
339
- for (const child of entry.children ?? [])
395
+ const children = entry.children ?? [];
396
+ if (children.length) lines.push("");
397
+ for (const [index, child] of children.entries())
340
398
  lines.push(
341
- joinDefined(
399
+ `${theme.fg("dim", treePrefix([], index === children.length - 1))}${joinDefined(
342
400
  [
343
- ` ${theme.fg("accent", `↳ ${child.agentId}`)}`,
344
- child.state ? badge(child.state, theme) : undefined,
345
- child.backlog ? `backlog ${child.backlog}` : undefined,
346
- child.reviewCost?.reviews ? formatReviewCost(child.reviewCost) : undefined,
401
+ theme.fg("accent", child.agentId),
402
+ child.state ? badge(child.state, theme, paint) : undefined,
403
+ child.backlog ? paint(`backlog ${child.backlog}`) : undefined,
404
+ child.reviewCost?.reviews ? paint(formatReviewCost(child.reviewCost)) : undefined,
347
405
  ],
348
406
  " ",
349
- ),
407
+ )}`,
350
408
  );
351
409
  return lines;
352
410
  }
@@ -364,21 +422,34 @@ function salvageStatus(data: Static<typeof minimalStatusSchema>): AdvisorStatusE
364
422
  : { state: data.state, error: data.error ?? null };
365
423
  }
366
424
 
367
- /** Status snapshot: a compact summary, with every setting and child when expanded. */
425
+ /**
426
+ * Status snapshot in Pi's custom-message look: a ten-line summary, with every setting and child
427
+ * when expanded.
428
+ */
368
429
  export function renderAdvisorStatus(
369
430
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled entry data is validated below; older shapes keep their state and error, anything else renders raw.
370
431
  data: unknown,
371
432
  expanded: boolean,
372
433
  theme: AdvisorRenderTheme,
434
+ outputPad = entryOutputPad,
373
435
  ): Component {
374
436
  if (!Value.Check(minimalStatusSchema, data))
375
- return new Text(`Advisor\n${JSON.stringify(data, null, 2)}`, 0, 0);
437
+ return customMessageBox(theme, { outputPad, source: "advisor" }, [
438
+ new Text(JSON.stringify(data, null, 2), 0, 0),
439
+ ]);
376
440
  const entry = salvageStatus(data);
377
- const lines = summaryLines(entry, theme);
441
+ const summary = summaryLines(entry, theme);
378
442
  const details = detailLines(entry, theme);
379
- if (expanded) lines.push(...details);
380
- else if (details.length) lines.push(expandHint(theme));
381
- return new Text(lines.join("\n"), 0, 0);
443
+ const heading = stateLine(entry, theme, undefined, messagePaint(theme));
444
+ const rows = [...summary, ...details];
445
+ const body = new Text(rows.join("\n"), 0, 0);
446
+ return customMessageBox(
447
+ theme,
448
+ { outputPad, source: "advisor", heading },
449
+ rows.length > 0
450
+ ? [new CollapsedPreview(theme, body, { limit: COLLAPSED_LINES.fallback, expanded })]
451
+ : [],
452
+ );
382
453
  }
383
454
 
384
455
  /** The live fields the footer summarizes for one watched agent. */
@@ -391,24 +462,26 @@ function count(amount: number, what: string): string {
391
462
  return `${amount} ${amount === 1 ? "child" : "children"} ${what}`;
392
463
  }
393
464
 
394
- /** Compact footer status; `undefined` clears it. Pause reasons stay in `/advisor status`. */
465
+ /** Footer status entry; `undefined` clears it. Pause reasons stay in `/advisor status`. */
395
466
  export function advisorFooterText(
396
467
  root: AdvisorActivity | undefined,
397
468
  children: readonly AdvisorActivity[],
398
469
  theme: Pick<Theme, "fg">,
399
470
  ): string | undefined {
400
471
  if (!root || root.state === "disabled" || root.state === "private") return undefined;
401
- if (root.state === "paused") return theme.fg("error", "advisor: paused");
402
- const parts: string[] = [];
403
- if (root.state === "reviewing" || root.state === "consulting") {
404
- parts.push(root.state);
405
- if (root.backlog > 0) parts.push(`backlog ${root.backlog}`);
406
- }
472
+ if (root.state === "paused")
473
+ return footerStatus(theme, {
474
+ mark: "warning",
475
+ name: "advisor",
476
+ value: theme.fg("warning", "paused"),
477
+ });
478
+ const parts: string[] = [stateWord(root.state)];
479
+ if ((root.state === "reviewing" || root.state === "consulting") && root.backlog > 0)
480
+ parts.push(`backlog ${root.backlog}`);
407
481
  // Consultations come only from the main agent, so a child segment shows Reviews and pauses.
408
482
  const busy = children.filter((child) => child.state === "reviewing").length;
409
483
  const paused = children.filter((child) => child.state === "paused").length;
410
484
  if (busy) parts.push(count(busy, "reviewing"));
411
- if (paused) parts.push(theme.fg("error", count(paused, "paused")));
412
- if (!parts.length) return theme.fg("dim", "advisor");
413
- return theme.fg("accent", `advisor: ${parts.join(" · ")}`);
485
+ if (paused) parts.push(theme.fg("warning", count(paused, "paused")));
486
+ return footerStatus(theme, { mark: "active", name: "advisor", value: joinInline(theme, parts) });
414
487
  }
@@ -4,6 +4,7 @@ import { isDeepStrictEqual } from "node:util";
4
4
  import { Type } from "typebox";
5
5
  import { Value } from "typebox/value";
6
6
  import * as piAi from "@earendil-works/pi-ai";
7
+ import type { Api, CacheRetention, Model } from "@earendil-works/pi-ai";
7
8
  import * as piSdk from "@earendil-works/pi-coding-agent";
8
9
  import type {
9
10
  SourceInfo,
@@ -70,6 +71,23 @@ export function providesContextManagement(tools: { has(name: string): boolean })
70
71
  return contextManagementTools.every((name) => tools.has(name));
71
72
  }
72
73
 
74
+ /**
75
+ * Prompt-cache retention the Advisor asks for on its own requests. Pi's adapters map `long` to
76
+ * what each API accepts: OpenAI `prompt_cache_retention: "24h"` (`prompt_cache_options` on
77
+ * models with explicit cache modes), which costs nothing extra, and Anthropic's `ttl: "1h"`,
78
+ * whose writes cost 2× instead of 1.25×, so it needs the `anthropicLongCache` opt-in. Other
79
+ * providers keep their default: the Codex adapter sends no retention field and ChatGPT
80
+ * sign-in rejects the OpenAI one.
81
+ */
82
+ export function advisorCacheRetention(
83
+ model: Pick<Model<Api>, "provider">,
84
+ config: Pick<AdvisorConfig, "anthropicLongCache">,
85
+ ): CacheRetention | undefined {
86
+ if (model.provider === "openai") return "long";
87
+ if (model.provider === "anthropic" && config.anthropicLongCache) return "long";
88
+ return undefined;
89
+ }
90
+
73
91
  /** Private role is native journal state and is installed before session_start. */
74
92
  export function isAdvisorSession(manager: Pick<SessionManager, "getBranch">): boolean {
75
93
  return manager
@@ -452,7 +470,7 @@ async function buildAdvisorSession(
452
470
  services,
453
471
  sessionManager,
454
472
  model,
455
- thinkingLevel: config.thinkingLevel ?? observed.thinkingLevel,
473
+ thinkingLevel: config.thinkingLevel,
456
474
  tools: [...config.allowedTools, options.adviceTool.name],
457
475
  customTools: [options.adviceTool],
458
476
  };
@@ -470,6 +488,19 @@ async function buildAdvisorSession(
470
488
  created.session.setActiveToolsByName(
471
489
  created.session.getActiveToolNames().filter((name) => !scriptOnly.has(name)),
472
490
  );
491
+ // Retention is a per-request stream option on the Advisor's own agent, never a process-wide
492
+ // setting, so the observed agent's requests are untouched. Pi's native compaction of this
493
+ // session reuses this stream function and sets `cacheRetention: "none"` on its summary
494
+ // call on purpose, so a retention the caller chose is never overridden.
495
+ const advisorStream = created.session.agent.streamFunction;
496
+ created.session.agent.streamFunction = (model, context, streamOptions) => {
497
+ const cacheRetention = streamOptions?.cacheRetention ?? advisorCacheRetention(model, config);
498
+ return advisorStream(
499
+ model,
500
+ context,
501
+ cacheRetention ? { ...streamOptions, cacheRetention } : streamOptions,
502
+ );
503
+ };
473
504
  return { ...created, services, diagnostics: services.diagnostics };
474
505
  };
475
506
  const runtime = await piSdk.createAgentSessionRuntime(factory, {