@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.
- package/README.md +2 -2
- package/package.json +6 -6
- package/src/advisor-command.ts +26 -47
- package/src/advisor-evidence.ts +38 -270
- package/src/advisor-menu.ts +34 -253
- package/src/advisor-rendering.ts +185 -112
- package/src/advisor-settings.ts +26 -114
- package/src/index.ts +23 -11
package/src/advisor-rendering.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
78
|
-
disabled:
|
|
79
|
-
private:
|
|
80
|
-
armed:
|
|
81
|
-
reviewing:
|
|
82
|
-
consulting:
|
|
83
|
-
paused:
|
|
84
|
-
} as const satisfies Record<AdvisorState,
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
/**
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
167
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
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 -
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
return
|
|
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(
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 [
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
441
|
+
const summary = summaryLines(entry, theme);
|
|
378
442
|
const details = detailLines(entry, theme);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
/**
|
|
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")
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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("
|
|
412
|
-
|
|
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
|
}
|
package/src/advisor-settings.ts
CHANGED
|
@@ -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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|