@ian-pascoe/pi-advisor 0.3.5 → 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 +12 -3
- package/package.json +6 -6
- 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 +52 -39
- package/src/advisor-rendering.ts +368 -0
- package/src/advisor-session.ts +12 -11
- package/src/advisor-settings.ts +29 -13
- package/src/index.ts +222 -55
- package/src/advisor-runtime.ts +0 -97
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
|
}),
|
|
@@ -466,6 +470,12 @@ export class AdvisorObserver {
|
|
|
466
470
|
label: "Advisor report",
|
|
467
471
|
description: `Finish the Review with up to ${this.config.maxFindingsPerReview} concise, actionable findings. Prioritize blockers, then concerns, then worthwhile nits.`,
|
|
468
472
|
parameters: reportSchema,
|
|
473
|
+
annotations: {
|
|
474
|
+
readOnlyHint: false,
|
|
475
|
+
destructiveHint: false,
|
|
476
|
+
idempotentHint: false,
|
|
477
|
+
openWorldHint: false,
|
|
478
|
+
},
|
|
469
479
|
prepareArguments: (arguments_) => {
|
|
470
480
|
if (Value.Check(reportSchema, arguments_)) return arguments_;
|
|
471
481
|
if (!Value.Check(legacyReportSchema, arguments_))
|
|
@@ -645,6 +655,7 @@ export class AdvisorObserver {
|
|
|
645
655
|
};
|
|
646
656
|
this.active = consultation;
|
|
647
657
|
this.consulting = true;
|
|
658
|
+
this.changed();
|
|
648
659
|
let callerCancelled = false;
|
|
649
660
|
const cancelFromCaller = () => {
|
|
650
661
|
callerCancelled = true;
|
|
@@ -691,6 +702,7 @@ export class AdvisorObserver {
|
|
|
691
702
|
}
|
|
692
703
|
if (this.active === consultation) this.active = undefined;
|
|
693
704
|
this.consulting = false;
|
|
705
|
+
this.changed();
|
|
694
706
|
}
|
|
695
707
|
}
|
|
696
708
|
|
|
@@ -731,7 +743,7 @@ export class AdvisorObserver {
|
|
|
731
743
|
}
|
|
732
744
|
}
|
|
733
745
|
|
|
734
|
-
private async deliver(findings: readonly
|
|
746
|
+
private async deliver(findings: readonly AdvisorFinding[], review: Review): Promise<void> {
|
|
735
747
|
const fresh = findings.filter((finding) => {
|
|
736
748
|
const deliveredRank = this.delivered.get(normalized(finding.message)) ?? 0;
|
|
737
749
|
return severityRank[finding.severity] > deliveredRank;
|
|
@@ -900,6 +912,7 @@ export class AdvisorObserver {
|
|
|
900
912
|
this.runtime = undefined;
|
|
901
913
|
this.closeRuntime(runtime);
|
|
902
914
|
}
|
|
915
|
+
this.changed();
|
|
903
916
|
}
|
|
904
917
|
/** Owner cancellation invalidates private work without touching observed execution. */
|
|
905
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-session.ts
CHANGED
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
ToolDefinition,
|
|
20
20
|
} from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import type { AdvisorConfig } from "./advisor-settings.js";
|
|
22
|
-
import { advisorRuntimeWarning, isCallable } from "./advisor-runtime.js";
|
|
23
22
|
|
|
24
23
|
/** Explicit file-selection recipe supplied by the resource owner, never inferred from opaque loaders. */
|
|
25
24
|
export interface AdvisorResourceInputs {
|
|
@@ -149,8 +148,6 @@ export async function createAdvisorSession(
|
|
|
149
148
|
observed: AgentSession,
|
|
150
149
|
options: AdvisorSessionOptions,
|
|
151
150
|
): Promise<AgentSessionRuntime> {
|
|
152
|
-
const unsupported = advisorRuntimeWarning();
|
|
153
|
-
if (unsupported) throw new Error(unsupported);
|
|
154
151
|
options.signal?.throwIfAborted();
|
|
155
152
|
const cancelled = Promise.withResolvers<never>();
|
|
156
153
|
const abort = () =>
|
|
@@ -225,10 +222,6 @@ async function buildAdvisorSession(
|
|
|
225
222
|
const factoryExport = builtinFactories.get(name);
|
|
226
223
|
if (builtin && factoryExport) {
|
|
227
224
|
const create = piSdk[factoryExport];
|
|
228
|
-
if (!isCallable(create))
|
|
229
|
-
throw new Error(
|
|
230
|
-
`Unsupported Advisor resources: this Pi runtime does not export ${factoryExport} to recreate ${extension.path}`,
|
|
231
|
-
);
|
|
232
225
|
recreated.add(index);
|
|
233
226
|
const codemodeModels = options.resourceInputs?.codemodeModels;
|
|
234
227
|
builtins.push({
|
|
@@ -244,10 +237,6 @@ async function buildAdvisorSession(
|
|
|
244
237
|
}
|
|
245
238
|
// Pi ships a fresh file-backed llama.cpp factory but does not export it.
|
|
246
239
|
if (builtin && name === "llama.cpp") {
|
|
247
|
-
if (!isCallable(piSdk.getPackageDir))
|
|
248
|
-
throw new Error(
|
|
249
|
-
"Unsupported Advisor resources: this Pi runtime does not export getPackageDir to locate llama.cpp",
|
|
250
|
-
);
|
|
251
240
|
const path = join(piSdk.getPackageDir(), "dist", "extensions", "llama", "index.js");
|
|
252
241
|
if (!existsSync(path))
|
|
253
242
|
throw new Error(
|
|
@@ -458,6 +447,18 @@ async function buildAdvisorSession(
|
|
|
458
447
|
};
|
|
459
448
|
if (sessionStartEvent) sessionOptions.sessionStartEvent = sessionStartEvent;
|
|
460
449
|
const created = await piSdk.createAgentSessionFromServices(sessionOptions);
|
|
450
|
+
// Pi's `tools` option is the ceiling and also activates (declares) every named tool. Keep
|
|
451
|
+
// `codemode` and `deferred` grants callable but undeclared, as Pi's own defaults do;
|
|
452
|
+
// later registrations follow the same rule, and extensions can still declare them.
|
|
453
|
+
const scriptOnly = new Set(
|
|
454
|
+
created.session
|
|
455
|
+
.getAllTools()
|
|
456
|
+
.filter(({ exposure }) => exposure === "codemode" || exposure === "deferred")
|
|
457
|
+
.map(({ name }) => name),
|
|
458
|
+
);
|
|
459
|
+
created.session.setActiveToolsByName(
|
|
460
|
+
created.session.getActiveToolNames().filter((name) => !scriptOnly.has(name)),
|
|
461
|
+
);
|
|
461
462
|
return { ...created, services, diagnostics: services.diagnostics };
|
|
462
463
|
};
|
|
463
464
|
const runtime = await piSdk.createAgentSessionRuntime(factory, {
|