@ian-pascoe/pi-advisor 0.6.0 → 0.7.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
  }
@@ -3,8 +3,12 @@ import type {
3
3
  SessionManager,
4
4
  SettingsManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ defineLayeredSettings,
8
+ type LayeredSettingChange,
9
+ type LayeredSettingsLayers,
10
+ } from "@ian-pascoe/pi-utils/layered-settings";
6
11
  import { Type, type Static } from "typebox";
7
- import { Value } from "typebox/value";
8
12
 
9
13
  const positiveInteger = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
10
14
  /** Authored Advisor options; every key is optional so absent values inherit. */
@@ -45,8 +49,6 @@ export const advisorOptionsSchema = Type.Object(
45
49
  { additionalProperties: false },
46
50
  );
47
51
 
48
- export const advisorOptionKeys = Object.keys(advisorOptionsSchema.properties).map(advisorOptionKey);
49
-
50
52
  /** Authored options; absent values inherit rather than disabling their setting. */
51
53
  export type AdvisorOptions = Static<typeof advisorOptionsSchema>;
52
54
  /** Fully defaulted options; absent model/thinking follows the Observed Agent. */
@@ -66,7 +68,7 @@ export const advisorSettingSourceSchema = Type.Union([
66
68
  ]);
67
69
  export type AdvisorSettingSource = Static<typeof advisorSettingSourceSchema>;
68
70
 
69
- const defaults = {
71
+ const defaults: AdvisorConfig = {
70
72
  enabled: false,
71
73
  includeSubagents: false,
72
74
  prompt:
@@ -124,49 +126,35 @@ export function sessionTokenLimit(
124
126
  return Math.min(Math.floor((contextWindow || fallbackWindow) / 2), autoSessionCeiling);
125
127
  }
126
128
 
127
- const sessionSchema = Type.Object(
128
- {
129
- version: Type.Literal(1),
130
- overrides: advisorOptionsSchema,
131
- },
132
- { additionalProperties: false },
133
- );
129
+ const layered = defineLayeredSettings({
130
+ namespace: "advisor",
131
+ label: "Advisor",
132
+ schema: advisorOptionsSchema,
133
+ defaults,
134
+ sessionEntryType: "pi-advisor-settings",
135
+ });
134
136
 
135
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- SAFETY: Native settings contain arbitrary authored JSON; this schema validates it before use. SDK command tests exercise the boundary.
136
- export function parseAdvisorOptions(value: unknown, source: AdvisorSettingSource): AdvisorOptions {
137
- if (value === undefined) return {};
138
- if (!Value.Check(advisorOptionsSchema, value)) {
139
- const issue = Value.Errors(advisorOptionsSchema, value)[0];
140
- throw new Error(
141
- `Invalid ${source} Advisor settings${issue?.instancePath ?? ""}: ${issue?.message ?? "invalid options"}`,
142
- );
143
- }
144
- return structuredClone(value);
137
+ export const advisorOptionKeys = layered.optionKeys;
138
+
139
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- SAFETY: Native settings contain arbitrary authored JSON; the shared layered-settings schema check validates it before use. SDK command tests exercise the boundary.
140
+ export function parseAdvisorOptions(value: unknown, source: AdvisorSettingScope): AdvisorOptions {
141
+ return layered.parseOptions(value, source);
145
142
  }
146
143
 
147
144
  /** Reject inherited or unknown property names before applying an authored change. */
148
145
  export function advisorOptionKey(input: string): keyof AdvisorOptions {
149
- if (!Value.Check(Type.KeyOf(advisorOptionsSchema), input))
150
- throw new Error(`Unknown Advisor option: ${input}`);
151
- return input;
146
+ return layered.optionKey(input);
152
147
  }
153
148
 
154
149
  /** Replay only the selected branch's last complete override snapshot. */
155
150
  export function readAdvisorOverrides(manager: Pick<SessionManager, "getBranch">): AdvisorOptions {
156
- const entry = manager
157
- .getBranch()
158
- .findLast((item) => item.type === "custom" && item.customType === "pi-advisor-settings");
159
- if (!entry || entry.type !== "custom") return {};
160
- if (!Value.Check(sessionSchema, entry.data)) throw new Error("Invalid Advisor session settings");
161
- return structuredClone(entry.data.overrides);
151
+ return layered.readOverrides(manager);
162
152
  }
163
153
 
164
154
  /** Authored scopes cached at startup/reload and updated after this extension's own writes. */
165
- export type AdvisorLayers = { global: AdvisorOptions | Error; project: AdvisorOptions | Error };
155
+ export type AdvisorLayers = LayeredSettingsLayers<AdvisorOptions>;
166
156
  /** A validated authored mutation, independent of its persistence scope. */
167
- export type AdvisorChange =
168
- | { action: "set"; key: keyof AdvisorOptions; patch: AdvisorOptions }
169
- | { action: "inherit"; key: keyof AdvisorOptions };
157
+ export type AdvisorChange = LayeredSettingChange<AdvisorOptions>;
170
158
  /** An applied change as recorded for confirmation; `options` is empty when the key inherits. */
171
159
  export const advisorAppliedChangeSchema = Type.Object({
172
160
  scope: advisorSettingScopeSchema,
@@ -177,21 +165,7 @@ export type AdvisorAppliedChange = Static<typeof advisorAppliedChangeSchema>;
177
165
 
178
166
  /** Read Pi's stored layers, preserving configuration failures until corrected. */
179
167
  export function readAdvisorLayers(manager: SettingsManager): AdvisorLayers {
180
- const layers: AdvisorLayers = { global: {}, project: {} };
181
- for (const scope of ["global", "project"] as const) {
182
- try {
183
- if (scope === "project" && !manager.isProjectTrusted()) continue;
184
- const document =
185
- scope === "global" ? manager.getGlobalSettings() : manager.getProjectSettings();
186
- layers[scope] = parseAdvisorOptions(
187
- "advisor" in document ? document.advisor : undefined,
188
- scope,
189
- );
190
- } catch (cause) {
191
- layers[scope] = cause instanceof Error ? cause : new Error(String(cause));
192
- }
193
- }
194
- return layers;
168
+ return layered.readLayers(manager);
195
169
  }
196
170
 
197
171
  /** Resolve stored scopes without changing Pi's effective runtime overrides. */
@@ -199,77 +173,15 @@ export function readAdvisorSettings(
199
173
  session: Pick<AgentSession, "settingsManager" | "sessionManager">,
200
174
  authored = readAdvisorLayers(session.settingsManager),
201
175
  ) {
202
- if (authored.global instanceof Error) throw authored.global;
203
- if (session.settingsManager.isProjectTrusted() && authored.project instanceof Error)
204
- throw authored.project;
205
- const layers: Array<[AdvisorSettingSource, AdvisorOptions]> = [
206
- ["global", authored.global],
207
- [
208
- "project",
209
- session.settingsManager.isProjectTrusted() && !(authored.project instanceof Error)
210
- ? authored.project
211
- : {},
212
- ],
213
- ["session", readAdvisorOverrides(session.sessionManager)],
214
- ];
215
- const settings: AdvisorConfig = structuredClone(defaults);
216
- const sources: Record<string, AdvisorSettingSource> = Object.fromEntries(
217
- advisorOptionKeys.map((key): [string, AdvisorSettingSource] => [key, "default"]),
218
- );
219
- for (const [source, layer] of layers) {
220
- Object.assign(settings, layer);
221
- for (const key of Object.keys(layer)) sources[key] = source;
222
- }
223
- return { settings, sources };
176
+ return layered.readSettings(session, authored);
224
177
  }
225
178
 
226
- const storedDocumentSchema = Type.Object(
227
- {
228
- advisor: Type.Optional(Type.Object({}, { additionalProperties: true })),
229
- },
230
- { additionalProperties: true },
231
- );
232
- const optionalText = Type.Union([Type.String(), Type.Undefined()]);
233
- const storageSchema = Type.Object({
234
- withLock: Type.Function(
235
- [
236
- Type.Union([Type.Literal("global"), Type.Literal("project")]),
237
- Type.Function([optionalText], optionalText),
238
- ],
239
- Type.Void(),
240
- ),
241
- });
242
-
243
179
  /** Use Pi's actual backend: its generic storage API is not exported. */
244
- export async function writeAdvisorSettings(
180
+ export function writeAdvisorSettings(
245
181
  manager: SettingsManager,
246
182
  scope: "global" | "project",
247
183
  change: AdvisorChange,
248
184
  isCurrent: () => boolean,
249
185
  ): Promise<AdvisorOptions | undefined> {
250
- if (scope === "project" && !manager.isProjectTrusted())
251
- throw new Error("Advisor project settings require a trusted project");
252
- const candidate: unknown = Object.getOwnPropertyDescriptor(manager, "storage")?.value;
253
- if (!Value.Check(storageSchema, candidate)) {
254
- throw new Error("Unsupported Pi settings backend: Advisor cannot safely write scoped settings");
255
- }
256
- const storage: Parameters<typeof SettingsManager.fromStorage>[0] = candidate;
257
- await manager.flush();
258
- if (!isCurrent()) return undefined;
259
- if (scope === "project" && !manager.isProjectTrusted())
260
- throw new Error("Advisor project settings require a trusted project");
261
- let updated: AdvisorOptions | undefined;
262
- storage.withLock(scope, (current) => {
263
- const document: unknown = JSON.parse((current ?? "{}").replace(/^\uFEFF/, ""));
264
- if (!Value.Check(storedDocumentSchema, document))
265
- throw new Error(`Invalid ${scope} settings document; refusing to overwrite it`);
266
- const next = { ...document.advisor };
267
- if (change.action === "inherit") Reflect.deleteProperty(next, change.key);
268
- else Object.assign(next, change.patch);
269
- updated = parseAdvisorOptions(next, scope);
270
- if (Object.keys(updated).length === 0) delete document.advisor;
271
- else document.advisor = updated;
272
- return `${JSON.stringify(document, null, 2)}\n`;
273
- });
274
- return updated;
186
+ return layered.writeSettings(manager, scope, change, isCurrent);
275
187
  }