@ian-pascoe/pi-advisor 0.4.0 → 0.5.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 +7 -0
- package/package.json +1 -1
- package/src/advisor-command.ts +14 -8
- package/src/advisor-contract.ts +33 -0
- package/src/advisor-menu.ts +629 -0
- package/src/advisor-observer.ts +46 -39
- package/src/advisor-rendering.ts +368 -0
- package/src/advisor-settings.ts +29 -13
- package/src/index.ts +216 -45
package/src/advisor-observer.ts
CHANGED
|
@@ -3,8 +3,14 @@ import * as piAi from "@earendil-works/pi-ai";
|
|
|
3
3
|
import type { Context, ImageContent } from "@earendil-works/pi-ai";
|
|
4
4
|
import * as piSdk from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { AgentSession, AgentSessionRuntime } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Type
|
|
6
|
+
import { Type } from "typebox";
|
|
7
7
|
import { Value } from "typebox/value";
|
|
8
|
+
import {
|
|
9
|
+
advisorFindingSchema,
|
|
10
|
+
type AdvisorFinding,
|
|
11
|
+
type AdvisorObserverState,
|
|
12
|
+
type AdvisorSeverity,
|
|
13
|
+
} from "./advisor-contract.js";
|
|
8
14
|
import type { AdvisorConfig } from "./advisor-settings.js";
|
|
9
15
|
import {
|
|
10
16
|
createAdvisorSession,
|
|
@@ -15,29 +21,14 @@ import {
|
|
|
15
21
|
|
|
16
22
|
/** Delivery authority supplied by the native session owner. */
|
|
17
23
|
export type AdvisorMode = "interactive" | "headless-root" | "owned-child";
|
|
18
|
-
export type AdvisorSeverity = "nit" | "concern" | "blocker";
|
|
19
24
|
/** Owner-supplied recreation inputs and native UI/delivery surfaces. */
|
|
20
25
|
export interface AdvisorObserverOptions {
|
|
21
26
|
resourceInputs?: AdvisorResourceInputs;
|
|
22
|
-
onIntervention?: (finding:
|
|
23
|
-
severity: AdvisorSeverity;
|
|
24
|
-
message: string;
|
|
25
|
-
}) => void | Promise<void>;
|
|
27
|
+
onIntervention?: (finding: AdvisorFinding) => void | Promise<void>;
|
|
26
28
|
onError?: (message: string) => void;
|
|
29
|
+
/** Called after `status` state or backlog may have changed. */
|
|
30
|
+
onStateChange?: () => void;
|
|
27
31
|
}
|
|
28
|
-
const severitySchema = Type.Union([
|
|
29
|
-
Type.Literal("nit"),
|
|
30
|
-
Type.Literal("concern"),
|
|
31
|
-
Type.Literal("blocker"),
|
|
32
|
-
]);
|
|
33
|
-
const findingSchema = Type.Object(
|
|
34
|
-
{
|
|
35
|
-
severity: severitySchema,
|
|
36
|
-
message: Type.String({ minLength: 1, maxLength: 4000 }),
|
|
37
|
-
},
|
|
38
|
-
{ additionalProperties: false },
|
|
39
|
-
);
|
|
40
|
-
type Finding = Static<typeof findingSchema>;
|
|
41
32
|
const legacyReportSchema = Type.Object(
|
|
42
33
|
{
|
|
43
34
|
severity: Type.Union([Type.Literal("none"), Type.Literal("concern"), Type.Literal("blocker")]),
|
|
@@ -48,7 +39,7 @@ const legacyReportSchema = Type.Object(
|
|
|
48
39
|
const adviceMessageSchema = Type.Object({
|
|
49
40
|
role: Type.Literal("custom"),
|
|
50
41
|
customType: Type.Literal("pi-advisor"),
|
|
51
|
-
details:
|
|
42
|
+
details: advisorFindingSchema,
|
|
52
43
|
});
|
|
53
44
|
const pendingQueuesSchema = Type.Object({
|
|
54
45
|
agent: Type.Object({ steeringQueue: Type.Object({ messages: Type.Array(Type.Unknown()) }) }),
|
|
@@ -56,7 +47,7 @@ const pendingQueuesSchema = Type.Object({
|
|
|
56
47
|
});
|
|
57
48
|
|
|
58
49
|
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- SAFETY: Pi has no selective queue-removal API. Validate its native queue data and remove only exact owned finding identities, never unrelated messages or journal entries.
|
|
59
|
-
function retractFindings(session: unknown, findings: ReadonlySet<
|
|
50
|
+
function retractFindings(session: unknown, findings: ReadonlySet<AdvisorFinding>): void {
|
|
60
51
|
if (!findings.size) return;
|
|
61
52
|
if (!Value.Check(pendingQueuesSchema, session))
|
|
62
53
|
throw new Error("Unsupported Advisor SDK: native pending-message queues are unavailable");
|
|
@@ -86,7 +77,7 @@ interface OperationBase {
|
|
|
86
77
|
}
|
|
87
78
|
interface Review extends OperationBase {
|
|
88
79
|
kind: "review";
|
|
89
|
-
findings?:
|
|
80
|
+
findings?: AdvisorFinding[];
|
|
90
81
|
}
|
|
91
82
|
interface Consultation extends OperationBase {
|
|
92
83
|
kind: "consultation";
|
|
@@ -114,8 +105,8 @@ const severityRank = { nit: 1, concern: 2, blocker: 3 } as const satisfies Recor
|
|
|
114
105
|
>;
|
|
115
106
|
|
|
116
107
|
/** Keep report order while replacing same-message findings with their highest severity. */
|
|
117
|
-
function selectFindings(findings: readonly
|
|
118
|
-
const distinct = new Map<string,
|
|
108
|
+
function selectFindings(findings: readonly AdvisorFinding[], limit: number): AdvisorFinding[] {
|
|
109
|
+
const distinct = new Map<string, AdvisorFinding>();
|
|
119
110
|
for (const finding of findings) {
|
|
120
111
|
const key = normalized(finding.message);
|
|
121
112
|
const previous = distinct.get(key);
|
|
@@ -168,7 +159,7 @@ export class AdvisorObserver {
|
|
|
168
159
|
private snapshot: Context | undefined;
|
|
169
160
|
private supplied: Context | undefined;
|
|
170
161
|
private suppliedBoundary: ReturnType<typeof observationBoundary> | undefined;
|
|
171
|
-
private readonly pendingFindings = new Map<
|
|
162
|
+
private readonly pendingFindings = new Map<AdvisorFinding, Review>();
|
|
172
163
|
private completed = 0;
|
|
173
164
|
private reviewed = 0;
|
|
174
165
|
private running: Promise<void> | undefined;
|
|
@@ -181,7 +172,7 @@ export class AdvisorObserver {
|
|
|
181
172
|
private error: string | undefined;
|
|
182
173
|
private closed = false;
|
|
183
174
|
private disposing: Promise<void> | undefined;
|
|
184
|
-
private deferred:
|
|
175
|
+
private deferred: AdvisorFinding[] = [];
|
|
185
176
|
private readonly delivered = new Map<string, number>();
|
|
186
177
|
private lastConcern = -3;
|
|
187
178
|
private unsafeEnding = false;
|
|
@@ -252,6 +243,7 @@ export class AdvisorObserver {
|
|
|
252
243
|
this.unsafeEnding = true;
|
|
253
244
|
this.completed++;
|
|
254
245
|
this.start();
|
|
246
|
+
this.changed();
|
|
255
247
|
if (this.config.catchUpThreshold !== "off")
|
|
256
248
|
await this.wait(this.config.catchUpThreshold, signal);
|
|
257
249
|
// Selection/branch changes may occur during that awaited barrier.
|
|
@@ -276,16 +268,17 @@ export class AdvisorObserver {
|
|
|
276
268
|
(rate) => rate.input > 0 || rate.output > 0 || rate.cacheRead > 0 || rate.cacheWrite > 0,
|
|
277
269
|
);
|
|
278
270
|
const knownCost = stats && (stats.cost > 0 || (stats.tokens.total > 0 && priced));
|
|
271
|
+
const state: AdvisorObserverState = !this.config.enabled
|
|
272
|
+
? "disabled"
|
|
273
|
+
: this.error
|
|
274
|
+
? "paused"
|
|
275
|
+
: this.consulting
|
|
276
|
+
? "consulting"
|
|
277
|
+
: this.running
|
|
278
|
+
? "reviewing"
|
|
279
|
+
: "armed";
|
|
279
280
|
return {
|
|
280
|
-
state
|
|
281
|
-
? "disabled"
|
|
282
|
-
: this.error
|
|
283
|
-
? "paused"
|
|
284
|
-
: this.consulting
|
|
285
|
-
? "consulting"
|
|
286
|
-
: this.running
|
|
287
|
-
? "reviewing"
|
|
288
|
-
: "armed",
|
|
281
|
+
state,
|
|
289
282
|
backlog: this.completed - this.reviewed,
|
|
290
283
|
effectiveModel,
|
|
291
284
|
effectiveThinkingLevel:
|
|
@@ -303,8 +296,17 @@ export class AdvisorObserver {
|
|
|
303
296
|
};
|
|
304
297
|
}
|
|
305
298
|
|
|
299
|
+
/** Status observers are UI; their failures must not disturb review work. */
|
|
300
|
+
private changed(): void {
|
|
301
|
+
try {
|
|
302
|
+
this.options.onStateChange?.();
|
|
303
|
+
} catch {
|
|
304
|
+
// A stale UI cannot be updated; `/advisor status` remains authoritative.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
306
307
|
private fail(message: string): void {
|
|
307
308
|
this.error = message;
|
|
309
|
+
this.changed();
|
|
308
310
|
try {
|
|
309
311
|
this.options.onError?.(message);
|
|
310
312
|
} catch (cause) {
|
|
@@ -324,7 +326,7 @@ export class AdvisorObserver {
|
|
|
324
326
|
if (
|
|
325
327
|
entry.type === "custom_message" &&
|
|
326
328
|
entry.customType === "pi-advisor" &&
|
|
327
|
-
Value.Check(
|
|
329
|
+
Value.Check(advisorFindingSchema, entry.details)
|
|
328
330
|
) {
|
|
329
331
|
const key = normalized(entry.details.message);
|
|
330
332
|
this.delivered.set(
|
|
@@ -339,7 +341,7 @@ export class AdvisorObserver {
|
|
|
339
341
|
this.lastConcern = this.completed - (turnsSinceConcern ?? 3);
|
|
340
342
|
}
|
|
341
343
|
private retractInvalidFindings(): void {
|
|
342
|
-
const stale = new Set<
|
|
344
|
+
const stale = new Set<AdvisorFinding>();
|
|
343
345
|
for (const [finding, review] of this.pendingFindings) {
|
|
344
346
|
if (!this.current(review)) {
|
|
345
347
|
stale.add(finding);
|
|
@@ -400,8 +402,10 @@ export class AdvisorObserver {
|
|
|
400
402
|
this.active = undefined;
|
|
401
403
|
if (!this.error) this.start();
|
|
402
404
|
this.scheduleCorrection();
|
|
405
|
+
this.changed();
|
|
403
406
|
});
|
|
404
407
|
this.running = operation;
|
|
408
|
+
this.changed();
|
|
405
409
|
}
|
|
406
410
|
|
|
407
411
|
private async review(review: Review): Promise<void> {
|
|
@@ -454,7 +458,7 @@ export class AdvisorObserver {
|
|
|
454
458
|
const runtimeEpoch = operation.epoch;
|
|
455
459
|
const reportSchema = Type.Object(
|
|
456
460
|
{
|
|
457
|
-
findings: Type.Array(
|
|
461
|
+
findings: Type.Array(advisorFindingSchema, {
|
|
458
462
|
maxItems: 32,
|
|
459
463
|
description: `Up to ${this.config.maxFindingsPerReview} distinct findings in priority order; use an empty array when there are none`,
|
|
460
464
|
}),
|
|
@@ -651,6 +655,7 @@ export class AdvisorObserver {
|
|
|
651
655
|
};
|
|
652
656
|
this.active = consultation;
|
|
653
657
|
this.consulting = true;
|
|
658
|
+
this.changed();
|
|
654
659
|
let callerCancelled = false;
|
|
655
660
|
const cancelFromCaller = () => {
|
|
656
661
|
callerCancelled = true;
|
|
@@ -697,6 +702,7 @@ export class AdvisorObserver {
|
|
|
697
702
|
}
|
|
698
703
|
if (this.active === consultation) this.active = undefined;
|
|
699
704
|
this.consulting = false;
|
|
705
|
+
this.changed();
|
|
700
706
|
}
|
|
701
707
|
}
|
|
702
708
|
|
|
@@ -737,7 +743,7 @@ export class AdvisorObserver {
|
|
|
737
743
|
}
|
|
738
744
|
}
|
|
739
745
|
|
|
740
|
-
private async deliver(findings: readonly
|
|
746
|
+
private async deliver(findings: readonly AdvisorFinding[], review: Review): Promise<void> {
|
|
741
747
|
const fresh = findings.filter((finding) => {
|
|
742
748
|
const deliveredRank = this.delivered.get(normalized(finding.message)) ?? 0;
|
|
743
749
|
return severityRank[finding.severity] > deliveredRank;
|
|
@@ -906,6 +912,7 @@ export class AdvisorObserver {
|
|
|
906
912
|
this.runtime = undefined;
|
|
907
913
|
this.closeRuntime(runtime);
|
|
908
914
|
}
|
|
915
|
+
this.changed();
|
|
909
916
|
}
|
|
910
917
|
/** Owner cancellation invalidates private work without touching observed execution. */
|
|
911
918
|
async abort(): Promise<void> {
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getMarkdownTheme,
|
|
3
|
+
keyHint,
|
|
4
|
+
type MessageRenderOptions,
|
|
5
|
+
type Theme,
|
|
6
|
+
type ThemeColor,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Box, Container, Markdown, Text, type Component } from "@earendil-works/pi-tui";
|
|
9
|
+
import { Type, type Static } from "typebox";
|
|
10
|
+
import { Value } from "typebox/value";
|
|
11
|
+
import {
|
|
12
|
+
advisorFindingSchema,
|
|
13
|
+
advisorSeveritySchema,
|
|
14
|
+
advisorStateSchema,
|
|
15
|
+
type AdvisorSeverity,
|
|
16
|
+
type AdvisorState,
|
|
17
|
+
} from "./advisor-contract.js";
|
|
18
|
+
import {
|
|
19
|
+
advisorAppliedChangeSchema,
|
|
20
|
+
advisorOptionKeys,
|
|
21
|
+
advisorOptionsSchema,
|
|
22
|
+
advisorSettingSourceSchema,
|
|
23
|
+
type AdvisorOptions,
|
|
24
|
+
} from "./advisor-settings.js";
|
|
25
|
+
|
|
26
|
+
/** Theme operations used by Advisor transcript and footer renderers. */
|
|
27
|
+
export type AdvisorRenderTheme = Pick<Theme, "fg" | "bg" | "bold">;
|
|
28
|
+
|
|
29
|
+
const severityStyle = {
|
|
30
|
+
nit: { symbol: "·", color: "muted" },
|
|
31
|
+
concern: { symbol: "▲", color: "warning" },
|
|
32
|
+
blocker: { symbol: "✖", color: "error" },
|
|
33
|
+
} as const satisfies Record<AdvisorSeverity, { symbol: string; color: ThemeColor }>;
|
|
34
|
+
const collapsedNitLines = 4;
|
|
35
|
+
|
|
36
|
+
const nullableString = Type.Union([Type.String(), Type.Null()]);
|
|
37
|
+
/** The minimum every historical status entry recorded. */
|
|
38
|
+
const minimalStatusSchema = Type.Object({
|
|
39
|
+
state: advisorStateSchema,
|
|
40
|
+
error: Type.Optional(nullableString),
|
|
41
|
+
});
|
|
42
|
+
const statusSchema = Type.Object({
|
|
43
|
+
state: advisorStateSchema,
|
|
44
|
+
changes: Type.Optional(Type.Array(advisorAppliedChangeSchema)),
|
|
45
|
+
settings: Type.Optional(advisorOptionsSchema),
|
|
46
|
+
sources: Type.Optional(Type.Record(Type.String(), advisorSettingSourceSchema)),
|
|
47
|
+
backlog: Type.Optional(Type.Number()),
|
|
48
|
+
effectiveModel: Type.Optional(nullableString),
|
|
49
|
+
effectiveThinkingLevel: Type.Optional(nullableString),
|
|
50
|
+
usage: Type.Optional(Type.Union([Type.Object({ total: Type.Number() }), Type.Null()])),
|
|
51
|
+
cost: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
|
|
52
|
+
unavailableTools: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
|
53
|
+
children: Type.Optional(
|
|
54
|
+
Type.Array(
|
|
55
|
+
Type.Object({
|
|
56
|
+
agentId: Type.String(),
|
|
57
|
+
state: Type.Optional(advisorStateSchema),
|
|
58
|
+
backlog: Type.Optional(Type.Number()),
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
error: Type.Optional(nullableString),
|
|
63
|
+
lastError: Type.Optional(nullableString),
|
|
64
|
+
});
|
|
65
|
+
/** Data recorded in a `pi-advisor-status` entry. */
|
|
66
|
+
export type AdvisorStatusEntry = Static<typeof statusSchema>;
|
|
67
|
+
const stateBadge = {
|
|
68
|
+
disabled: { symbol: "○", color: "dim" },
|
|
69
|
+
private: { symbol: "○", color: "dim" },
|
|
70
|
+
armed: { symbol: "●", color: "success" },
|
|
71
|
+
reviewing: { symbol: "●", color: "accent" },
|
|
72
|
+
consulting: { symbol: "●", color: "accent" },
|
|
73
|
+
paused: { symbol: "●", color: "error" },
|
|
74
|
+
} as const satisfies Record<AdvisorState, { symbol: string; color: ThemeColor }>;
|
|
75
|
+
const promptPreviewWidth = 40;
|
|
76
|
+
|
|
77
|
+
function expandHint(theme: Pick<Theme, "fg">): string {
|
|
78
|
+
return theme.fg("dim", `… ${keyHint("app.tools.expand", "to expand")}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Render at most `limit` lines of a child, then an expansion hint. */
|
|
82
|
+
class Clipped implements Component {
|
|
83
|
+
constructor(
|
|
84
|
+
private readonly child: Component,
|
|
85
|
+
private readonly limit: number,
|
|
86
|
+
private readonly hint: string,
|
|
87
|
+
) {}
|
|
88
|
+
render(width: number): string[] {
|
|
89
|
+
const lines = this.child.render(width);
|
|
90
|
+
return lines.length <= this.limit ? lines : [...lines.slice(0, this.limit), this.hint];
|
|
91
|
+
}
|
|
92
|
+
invalidate(): void {
|
|
93
|
+
this.child.invalidate();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Severity-styled, attributed Intervention; invalid details fall back to Pi's renderer. */
|
|
98
|
+
export function renderAdvisorIntervention(
|
|
99
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled custom-message details are validated by the finding schema below.
|
|
100
|
+
details: unknown,
|
|
101
|
+
options: MessageRenderOptions,
|
|
102
|
+
theme: AdvisorRenderTheme,
|
|
103
|
+
agentId?: string,
|
|
104
|
+
): Component | undefined {
|
|
105
|
+
if (!Value.Check(advisorFindingSchema, details)) return undefined;
|
|
106
|
+
const style = severityStyle[details.severity];
|
|
107
|
+
const heading = joinDefined(
|
|
108
|
+
[
|
|
109
|
+
theme.fg(style.color, theme.bold(`${style.symbol} Advisor ${details.severity}`)),
|
|
110
|
+
agentId ? theme.fg("accent", `↳ ${agentId}`) : undefined,
|
|
111
|
+
],
|
|
112
|
+
" ",
|
|
113
|
+
);
|
|
114
|
+
const body: Component = new Markdown(details.message, 0, 0, getMarkdownTheme());
|
|
115
|
+
const container = new Container();
|
|
116
|
+
container.addChild(new Text(heading, 0, 0));
|
|
117
|
+
container.addChild(
|
|
118
|
+
details.severity === "nit" && !options.expanded
|
|
119
|
+
? new Clipped(body, collapsedNitLines, expandHint(theme))
|
|
120
|
+
: body,
|
|
121
|
+
);
|
|
122
|
+
const box = new Box(options.outputPad, 1, (text) => theme.bg("customMessageBg", text));
|
|
123
|
+
box.addChild(container);
|
|
124
|
+
return box;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const childFindingSchema = Type.Object({
|
|
128
|
+
agentId: Type.String(),
|
|
129
|
+
severity: advisorSeveritySchema,
|
|
130
|
+
message: Type.String(),
|
|
131
|
+
});
|
|
132
|
+
const childStateSchema = Type.Object({
|
|
133
|
+
agentId: Type.String(),
|
|
134
|
+
state: advisorStateSchema,
|
|
135
|
+
error: Type.Optional(Type.String()),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** A Child Agent's Intervention or pause, attributed to that child. */
|
|
139
|
+
export function renderAdvisorChildEntry(
|
|
140
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled entry data is validated below; anything else renders raw.
|
|
141
|
+
data: unknown,
|
|
142
|
+
expanded: boolean,
|
|
143
|
+
theme: AdvisorRenderTheme,
|
|
144
|
+
): Component {
|
|
145
|
+
if (Value.Check(childFindingSchema, data)) {
|
|
146
|
+
const finding = { severity: data.severity, message: data.message };
|
|
147
|
+
const rendered = renderAdvisorIntervention(
|
|
148
|
+
finding,
|
|
149
|
+
{ expanded, outputPad: 0 },
|
|
150
|
+
theme,
|
|
151
|
+
data.agentId,
|
|
152
|
+
);
|
|
153
|
+
if (rendered) return rendered;
|
|
154
|
+
}
|
|
155
|
+
if (Value.Check(childStateSchema, data)) {
|
|
156
|
+
const line = `Advisor ↳ ${data.agentId} ${data.state}${data.error ? `: ${data.error}` : ""}`;
|
|
157
|
+
return new Text(data.error ? theme.fg("error", `✖ ${line}`) : theme.fg("dim", line), 0, 0);
|
|
158
|
+
}
|
|
159
|
+
return new Text(`Advisor for Child Agent\n${JSON.stringify(data, null, 2)}`, 0, 0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const collapsedAnswerLines = 8;
|
|
163
|
+
|
|
164
|
+
/** `advisor_ask` call: the question, first line only while collapsed. */
|
|
165
|
+
export function renderAdvisorAskCall(
|
|
166
|
+
args: { message: string },
|
|
167
|
+
expanded: boolean,
|
|
168
|
+
theme: AdvisorRenderTheme,
|
|
169
|
+
): Component {
|
|
170
|
+
const question = expanded ? args.message : (args.message.split("\n", 1)[0] ?? "");
|
|
171
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("Ask Advisor"))} ${question}`, 0, 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `advisor_ask` result: a Markdown answer, previewed while collapsed. */
|
|
175
|
+
export function renderAdvisorAskResult(
|
|
176
|
+
answer: string,
|
|
177
|
+
options: { expanded: boolean; isPartial: boolean; isError: boolean },
|
|
178
|
+
theme: AdvisorRenderTheme,
|
|
179
|
+
): Component {
|
|
180
|
+
if (options.isPartial) return new Text(theme.fg("dim", "Consulting…"), 0, 0);
|
|
181
|
+
if (options.isError) return new Text(theme.fg("error", answer), 0, 0);
|
|
182
|
+
const body = new Markdown(answer, 0, 0, getMarkdownTheme());
|
|
183
|
+
return options.expanded ? body : new Clipped(body, collapsedAnswerLines, expandHint(theme));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function formatTokens(total: number): string {
|
|
187
|
+
if (total < 1_000) return String(total);
|
|
188
|
+
if (total < 1_000_000) return `${(total / 1_000).toFixed(1)}k`;
|
|
189
|
+
return `${(total / 1_000_000).toFixed(1)}M`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatCost(cost: number | null | undefined): string {
|
|
193
|
+
if (cost === null || cost === undefined) return "cost unknown";
|
|
194
|
+
return `cost $${cost.toFixed(cost < 0.01 ? 4 : 2)}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Human-readable option value; an absent value inherits. */
|
|
198
|
+
export function formatAdvisorOption(options: AdvisorOptions, key: keyof AdvisorOptions): string {
|
|
199
|
+
switch (key) {
|
|
200
|
+
case "prompt": {
|
|
201
|
+
if (options.prompt === undefined) return "inherit";
|
|
202
|
+
const first = options.prompt.split("\n", 1)[0] ?? "";
|
|
203
|
+
const preview =
|
|
204
|
+
first.length > promptPreviewWidth ? `${first.slice(0, promptPreviewWidth - 1)}…` : first;
|
|
205
|
+
return `${preview} (${options.prompt.length} chars)`;
|
|
206
|
+
}
|
|
207
|
+
case "allowedTools":
|
|
208
|
+
if (options.allowedTools === undefined) return "inherit";
|
|
209
|
+
return options.allowedTools.length ? options.allowedTools.join(", ") : "none";
|
|
210
|
+
case "reviewTimeoutMs":
|
|
211
|
+
if (options.reviewTimeoutMs === undefined) return "inherit";
|
|
212
|
+
return options.reviewTimeoutMs < 1_000
|
|
213
|
+
? `${options.reviewTimeoutMs}ms`
|
|
214
|
+
: `${options.reviewTimeoutMs / 1_000}s`;
|
|
215
|
+
default: {
|
|
216
|
+
const value = options[key];
|
|
217
|
+
return value === undefined ? "inherit" : String(value);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function badge(state: AdvisorState, theme: AdvisorRenderTheme): string {
|
|
223
|
+
const style = stateBadge[state];
|
|
224
|
+
return theme.fg(style.color, `${style.symbol} ${state}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function joinDefined(parts: ReadonlyArray<string | undefined>, separator: string): string {
|
|
228
|
+
return parts.filter((part) => part !== undefined).join(separator);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function stateLine(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string {
|
|
232
|
+
const inherited = entry.settings && entry.settings.model === undefined;
|
|
233
|
+
return joinDefined(
|
|
234
|
+
[
|
|
235
|
+
`${theme.bold("Advisor")} ${badge(entry.state, theme)}`,
|
|
236
|
+
entry.effectiveModel
|
|
237
|
+
? `${entry.effectiveModel}${inherited ? theme.fg("dim", " (inherited)") : ""}`
|
|
238
|
+
: undefined,
|
|
239
|
+
entry.effectiveThinkingLevel ?? undefined,
|
|
240
|
+
entry.backlog ? `backlog ${entry.backlog}` : undefined,
|
|
241
|
+
],
|
|
242
|
+
theme.fg("dim", " · "),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Live state line plus any error, as shown at the top of the settings menu. */
|
|
247
|
+
export function advisorStatusHeadline(
|
|
248
|
+
entry: AdvisorStatusEntry,
|
|
249
|
+
theme: AdvisorRenderTheme,
|
|
250
|
+
): string[] {
|
|
251
|
+
const error = entry.error ?? entry.lastError;
|
|
252
|
+
return [stateLine(entry, theme), ...(error ? [theme.fg("error", `✖ ${error}`)] : [])];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function summaryLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string[] {
|
|
256
|
+
const lines: string[] = [];
|
|
257
|
+
for (const { scope, key, options } of entry.changes ?? []) {
|
|
258
|
+
lines.push(
|
|
259
|
+
`${theme.fg("success", "✓")} ${key} → ${formatAdvisorOption(options, key)} ${theme.fg("dim", `[${scope}]`)}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
lines.push(stateLine(entry, theme));
|
|
263
|
+
const children = entry.children?.length ?? 0;
|
|
264
|
+
const activity = joinDefined(
|
|
265
|
+
[
|
|
266
|
+
entry.usage ? `tokens ${formatTokens(entry.usage.total)}` : undefined,
|
|
267
|
+
entry.usage ? formatCost(entry.cost) : undefined,
|
|
268
|
+
children ? `${children} ${children === 1 ? "child" : "children"}` : undefined,
|
|
269
|
+
],
|
|
270
|
+
" · ",
|
|
271
|
+
);
|
|
272
|
+
if (activity) lines.push(theme.fg("muted", activity));
|
|
273
|
+
if (entry.unavailableTools?.length)
|
|
274
|
+
lines.push(theme.fg("warning", `⚠ unavailable tools: ${entry.unavailableTools.join(", ")}`));
|
|
275
|
+
const error = entry.error ?? entry.lastError;
|
|
276
|
+
if (error) lines.push(theme.fg("error", `✖ ${error}`));
|
|
277
|
+
return lines;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function detailLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string[] {
|
|
281
|
+
const lines: string[] = [];
|
|
282
|
+
if (entry.settings) {
|
|
283
|
+
const settings = entry.settings;
|
|
284
|
+
const width = Math.max(...advisorOptionKeys.map((key) => key.length));
|
|
285
|
+
lines.push("");
|
|
286
|
+
for (const key of advisorOptionKeys) {
|
|
287
|
+
const source = entry.sources?.[key] ?? "default";
|
|
288
|
+
lines.push(
|
|
289
|
+
` ${key.padEnd(width)} ${formatAdvisorOption(settings, key)} ${theme.fg(source === "default" ? "dim" : "accent", `[${source}]`)}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (entry.children?.length) lines.push("");
|
|
294
|
+
for (const child of entry.children ?? [])
|
|
295
|
+
lines.push(
|
|
296
|
+
joinDefined(
|
|
297
|
+
[
|
|
298
|
+
` ${theme.fg("accent", `↳ ${child.agentId}`)}`,
|
|
299
|
+
child.state ? badge(child.state, theme) : undefined,
|
|
300
|
+
child.backlog ? `backlog ${child.backlog}` : undefined,
|
|
301
|
+
],
|
|
302
|
+
" ",
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
return lines;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Drop top-level fields that no longer match, so older entries keep what they can show. */
|
|
309
|
+
function salvageStatus(data: Static<typeof minimalStatusSchema>): AdvisorStatusEntry {
|
|
310
|
+
// Runtime copy keeps every recorded field; only mismatching ones are removed below.
|
|
311
|
+
const candidate = structuredClone(data);
|
|
312
|
+
for (const issue of Value.Errors(statusSchema, candidate)) {
|
|
313
|
+
const field = issue.instancePath.split("/")[1];
|
|
314
|
+
if (field && field !== "state") Reflect.deleteProperty(candidate, field);
|
|
315
|
+
}
|
|
316
|
+
return Value.Check(statusSchema, candidate)
|
|
317
|
+
? candidate
|
|
318
|
+
: { state: data.state, error: data.error ?? null };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Status snapshot: a compact summary, with every setting and child when expanded. */
|
|
322
|
+
export function renderAdvisorStatus(
|
|
323
|
+
// 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.
|
|
324
|
+
data: unknown,
|
|
325
|
+
expanded: boolean,
|
|
326
|
+
theme: AdvisorRenderTheme,
|
|
327
|
+
): Component {
|
|
328
|
+
if (!Value.Check(minimalStatusSchema, data))
|
|
329
|
+
return new Text(`Advisor\n${JSON.stringify(data, null, 2)}`, 0, 0);
|
|
330
|
+
const entry = salvageStatus(data);
|
|
331
|
+
const lines = summaryLines(entry, theme);
|
|
332
|
+
const details = detailLines(entry, theme);
|
|
333
|
+
if (expanded) lines.push(...details);
|
|
334
|
+
else if (details.length) lines.push(expandHint(theme));
|
|
335
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The live fields the footer summarizes for one watched agent. */
|
|
339
|
+
export interface AdvisorActivity {
|
|
340
|
+
state: AdvisorState;
|
|
341
|
+
backlog: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function count(amount: number, what: string): string {
|
|
345
|
+
return `${amount} ${amount === 1 ? "child" : "children"} ${what}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Compact footer status; `undefined` clears it. Pause reasons stay in `/advisor status`. */
|
|
349
|
+
export function advisorFooterText(
|
|
350
|
+
root: AdvisorActivity | undefined,
|
|
351
|
+
children: readonly AdvisorActivity[],
|
|
352
|
+
theme: Pick<Theme, "fg">,
|
|
353
|
+
): string | undefined {
|
|
354
|
+
if (!root || root.state === "disabled" || root.state === "private") return undefined;
|
|
355
|
+
if (root.state === "paused") return theme.fg("error", "advisor: paused");
|
|
356
|
+
const parts: string[] = [];
|
|
357
|
+
if (root.state === "reviewing" || root.state === "consulting") {
|
|
358
|
+
parts.push(root.state);
|
|
359
|
+
if (root.backlog > 0) parts.push(`backlog ${root.backlog}`);
|
|
360
|
+
}
|
|
361
|
+
// Consultations come only from the main agent, so a child segment shows Reviews and pauses.
|
|
362
|
+
const busy = children.filter((child) => child.state === "reviewing").length;
|
|
363
|
+
const paused = children.filter((child) => child.state === "paused").length;
|
|
364
|
+
if (busy) parts.push(count(busy, "reviewing"));
|
|
365
|
+
if (paused) parts.push(theme.fg("error", count(paused, "paused")));
|
|
366
|
+
if (!parts.length) return theme.fg("dim", "advisor");
|
|
367
|
+
return theme.fg("accent", `advisor: ${parts.join(" · ")}`);
|
|
368
|
+
}
|
package/src/advisor-settings.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { Type, type Static } from "typebox";
|
|
|
7
7
|
import { Value } from "typebox/value";
|
|
8
8
|
|
|
9
9
|
const positiveInteger = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
|
|
10
|
-
|
|
10
|
+
/** Authored Advisor options; every key is optional so absent values inherit. */
|
|
11
|
+
export const advisorOptionsSchema = Type.Object(
|
|
11
12
|
{
|
|
12
13
|
enabled: Type.Optional(Type.Boolean()),
|
|
13
14
|
includeSubagents: Type.Optional(Type.Boolean()),
|
|
@@ -36,15 +37,26 @@ const optionsSchema = Type.Object(
|
|
|
36
37
|
{ additionalProperties: false },
|
|
37
38
|
);
|
|
38
39
|
|
|
39
|
-
export const advisorOptionKeys = Object.keys(
|
|
40
|
+
export const advisorOptionKeys = Object.keys(advisorOptionsSchema.properties).map(advisorOptionKey);
|
|
40
41
|
|
|
41
42
|
/** Authored options; absent values inherit rather than disabling their setting. */
|
|
42
|
-
export type AdvisorOptions = Static<typeof
|
|
43
|
+
export type AdvisorOptions = Static<typeof advisorOptionsSchema>;
|
|
43
44
|
/** Fully defaulted options; absent model/thinking follows the Observed Agent. */
|
|
44
45
|
export type AdvisorConfig = Required<Omit<AdvisorOptions, "model" | "thinkingLevel">> &
|
|
45
46
|
Pick<AdvisorOptions, "model" | "thinkingLevel">;
|
|
47
|
+
/** Native settings/session layer an authored change is written to. */
|
|
48
|
+
export const advisorSettingScopeSchema = Type.Union([
|
|
49
|
+
Type.Literal("session"),
|
|
50
|
+
Type.Literal("global"),
|
|
51
|
+
Type.Literal("project"),
|
|
52
|
+
]);
|
|
53
|
+
export type AdvisorSettingScope = Static<typeof advisorSettingScopeSchema>;
|
|
46
54
|
/** Native settings/session layer supplying an effective option. */
|
|
47
|
-
export
|
|
55
|
+
export const advisorSettingSourceSchema = Type.Union([
|
|
56
|
+
Type.Literal("default"),
|
|
57
|
+
advisorSettingScopeSchema,
|
|
58
|
+
]);
|
|
59
|
+
export type AdvisorSettingSource = Static<typeof advisorSettingSourceSchema>;
|
|
48
60
|
|
|
49
61
|
const defaults = {
|
|
50
62
|
enabled: false,
|
|
@@ -62,7 +74,7 @@ const defaults = {
|
|
|
62
74
|
const sessionSchema = Type.Object(
|
|
63
75
|
{
|
|
64
76
|
version: Type.Literal(1),
|
|
65
|
-
overrides:
|
|
77
|
+
overrides: advisorOptionsSchema,
|
|
66
78
|
},
|
|
67
79
|
{ additionalProperties: false },
|
|
68
80
|
);
|
|
@@ -70,8 +82,8 @@ const sessionSchema = Type.Object(
|
|
|
70
82
|
// 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.
|
|
71
83
|
export function parseAdvisorOptions(value: unknown, source: AdvisorSettingSource): AdvisorOptions {
|
|
72
84
|
if (value === undefined) return {};
|
|
73
|
-
if (!Value.Check(
|
|
74
|
-
const issue = Value.Errors(
|
|
85
|
+
if (!Value.Check(advisorOptionsSchema, value)) {
|
|
86
|
+
const issue = Value.Errors(advisorOptionsSchema, value)[0];
|
|
75
87
|
throw new Error(
|
|
76
88
|
`Invalid ${source} Advisor settings${issue?.instancePath ?? ""}: ${issue?.message ?? "invalid options"}`,
|
|
77
89
|
);
|
|
@@ -81,7 +93,7 @@ export function parseAdvisorOptions(value: unknown, source: AdvisorSettingSource
|
|
|
81
93
|
|
|
82
94
|
/** Reject inherited or unknown property names before applying an authored change. */
|
|
83
95
|
export function advisorOptionKey(input: string): keyof AdvisorOptions {
|
|
84
|
-
if (!Value.Check(Type.KeyOf(
|
|
96
|
+
if (!Value.Check(Type.KeyOf(advisorOptionsSchema), input))
|
|
85
97
|
throw new Error(`Unknown Advisor option: ${input}`);
|
|
86
98
|
return input;
|
|
87
99
|
}
|
|
@@ -100,8 +112,15 @@ export function readAdvisorOverrides(manager: Pick<SessionManager, "getBranch">)
|
|
|
100
112
|
export type AdvisorLayers = { global: AdvisorOptions | Error; project: AdvisorOptions | Error };
|
|
101
113
|
/** A validated authored mutation, independent of its persistence scope. */
|
|
102
114
|
export type AdvisorChange =
|
|
103
|
-
| { action: "set"; patch: AdvisorOptions }
|
|
115
|
+
| { action: "set"; key: keyof AdvisorOptions; patch: AdvisorOptions }
|
|
104
116
|
| { action: "inherit"; key: keyof AdvisorOptions };
|
|
117
|
+
/** An applied change as recorded for confirmation; `options` is empty when the key inherits. */
|
|
118
|
+
export const advisorAppliedChangeSchema = Type.Object({
|
|
119
|
+
scope: advisorSettingScopeSchema,
|
|
120
|
+
key: Type.KeyOf(advisorOptionsSchema),
|
|
121
|
+
options: advisorOptionsSchema,
|
|
122
|
+
});
|
|
123
|
+
export type AdvisorAppliedChange = Static<typeof advisorAppliedChangeSchema>;
|
|
105
124
|
|
|
106
125
|
/** Read Pi's stored layers, preserving configuration failures until corrected. */
|
|
107
126
|
export function readAdvisorLayers(manager: SettingsManager): AdvisorLayers {
|
|
@@ -142,10 +161,7 @@ export function readAdvisorSettings(
|
|
|
142
161
|
];
|
|
143
162
|
const settings: AdvisorConfig = structuredClone(defaults);
|
|
144
163
|
const sources: Record<string, AdvisorSettingSource> = Object.fromEntries(
|
|
145
|
-
|
|
146
|
-
key,
|
|
147
|
-
"default",
|
|
148
|
-
]),
|
|
164
|
+
advisorOptionKeys.map((key): [string, AdvisorSettingSource] => [key, "default"]),
|
|
149
165
|
);
|
|
150
166
|
for (const [source, layer] of layers) {
|
|
151
167
|
Object.assign(settings, layer);
|