pi-advisor-flow 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/CHANGELOG.md +16 -0
- package/README.md +1 -0
- package/dist/index.js +1816 -154
- package/package.json +11 -9
- package/src/commands/manual-consultation.ts +16 -2
- package/src/commands/settings-persistence.ts +48 -0
- package/src/commands/types.ts +1 -0
- package/src/config/defaults.ts +75 -0
- package/src/config/schema.ts +112 -0
- package/src/config/state.ts +69 -0
- package/src/config/storage.ts +7 -2
- package/src/config/types.ts +29 -0
- package/src/jev/client.ts +348 -0
- package/src/jev/key-store.ts +252 -0
- package/src/jev/ledger.ts +234 -0
- package/src/jev/questions.ts +119 -0
- package/src/jev/state.ts +47 -0
- package/src/jev/transport.ts +58 -0
- package/src/outcomes.ts +5 -1
- package/src/session-state.ts +99 -5
- package/src/tools/jev-filter.ts +202 -0
- package/src/tools/jev-turn-gate.ts +183 -0
- package/src/tools/loop-gate.ts +1 -0
- package/src/tools/outage-notifier.ts +30 -0
- package/src/tools/register-ask-advisor.ts +37 -1
- package/src/tools/register-renderers.ts +58 -0
- package/src/tools/registration.ts +17 -0
- package/src/tools/render-advisor-result.ts +43 -14
- package/src/tools/types.ts +10 -0
- package/src/ui/jev-setup-submenu.ts +344 -0
- package/src/ui/masked-input.ts +68 -0
- package/src/ui/settings-items.ts +162 -1
- package/src/ui/settings-mutations.ts +35 -0
- package/src/ui/types.ts +11 -0
- package/src/usage.ts +3 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Fetch } from "@typesafe-ai/sdk";
|
|
3
|
+
import {
|
|
4
|
+
advisorJevFilterEnabledRef,
|
|
5
|
+
advisorJevFilterNoulMarginRef,
|
|
6
|
+
advisorJevFilterOverrideWindowRef,
|
|
7
|
+
advisorJevFilterSkipConfidenceRef,
|
|
8
|
+
isSimpleMode,
|
|
9
|
+
} from "../config/state.ts";
|
|
10
|
+
import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
|
|
11
|
+
import { consumePlaintextKeyWarning } from "../jev/key-store.ts";
|
|
12
|
+
import {
|
|
13
|
+
composeScreeningVerdict,
|
|
14
|
+
screeningQuestions,
|
|
15
|
+
} from "../jev/questions.ts";
|
|
16
|
+
import { buildJevState } from "../jev/state.ts";
|
|
17
|
+
import { type JevCredentials, resolveJevTransport } from "../jev/transport.ts";
|
|
18
|
+
import type { AdvisorSessionState } from "../session-state.ts";
|
|
19
|
+
import { createOutageNotifier } from "./outage-notifier.ts";
|
|
20
|
+
|
|
21
|
+
export type JevSkipKind = "screened" | "repeat";
|
|
22
|
+
|
|
23
|
+
export type ScreeningOutcome =
|
|
24
|
+
| { decision: "allow" }
|
|
25
|
+
| {
|
|
26
|
+
decision: "skip";
|
|
27
|
+
kind: JevSkipKind;
|
|
28
|
+
reason: string;
|
|
29
|
+
reattachedAdvice?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface ScreenConsultationOptions {
|
|
33
|
+
draft?: string;
|
|
34
|
+
force?: boolean;
|
|
35
|
+
question?: string;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ScreeningDeps {
|
|
40
|
+
fetch?: Fetch;
|
|
41
|
+
resolveTransport?: () => Promise<JevCredentials | undefined>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const normalizeScreeningQuestion = (question: string | undefined) =>
|
|
45
|
+
question?.trim().toLowerCase().replace(/\s+/g, " ") || undefined;
|
|
46
|
+
|
|
47
|
+
const REATTACHED_ADVICE_CAP_BYTES = 4 * 1024;
|
|
48
|
+
|
|
49
|
+
const SCREENED_SKIP_TEXT =
|
|
50
|
+
"Advisor consultation skipped (screened out): the stakes are low and you can resolve this yourself with available tools and context. Proceed on your own judgment with what you already have.";
|
|
51
|
+
|
|
52
|
+
const repeatSkipText = (advice: string) =>
|
|
53
|
+
`Advisor consultation skipped (already answered): this question was answered earlier in this session; the earlier advice is reattached below. Consult again only if the situation has materially changed.\n\n${advice}`;
|
|
54
|
+
|
|
55
|
+
const outageNotifier = createOutageNotifier(
|
|
56
|
+
(category, message) =>
|
|
57
|
+
`Advisor Jev filter failed (${category}); allowing consultations. ${message}`
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const notifyOutageOnce = outageNotifier.notify;
|
|
61
|
+
|
|
62
|
+
/** Test-only: re-arms the once-per-outage notification. */
|
|
63
|
+
export const resetJevOutageNotification = outageNotifier.reset;
|
|
64
|
+
|
|
65
|
+
const allow = (): ScreeningOutcome => ({ decision: "allow" });
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fail-open screening: decides run/skip for one ask_advisor call. Disabled or
|
|
69
|
+
* simple mode, missing credentials, and every Jev failure allow the
|
|
70
|
+
* consultation; only the hard conjunction (negligible-mass ≥ skip confidence
|
|
71
|
+
* AND confidently self-answerable) or an exact repeat skips.
|
|
72
|
+
*/
|
|
73
|
+
export const screenConsultation = (
|
|
74
|
+
ctx: ExtensionContext,
|
|
75
|
+
session: AdvisorSessionState,
|
|
76
|
+
options: ScreenConsultationOptions,
|
|
77
|
+
deps: ScreeningDeps = {}
|
|
78
|
+
): Promise<ScreeningOutcome> => {
|
|
79
|
+
if (isSimpleMode()) {
|
|
80
|
+
return Promise.resolve(allow());
|
|
81
|
+
}
|
|
82
|
+
// Repeat reattachment and its force/passthrough escape hatch are code-side
|
|
83
|
+
// and free: they run regardless of the Jev enable flag, which only gates
|
|
84
|
+
// the (paid, network) Jev screening call.
|
|
85
|
+
const normalizedQuestion = normalizeScreeningQuestion(options.question);
|
|
86
|
+
const bypass = bypassOutcome(session, options, normalizedQuestion);
|
|
87
|
+
if (bypass) {
|
|
88
|
+
return Promise.resolve(bypass);
|
|
89
|
+
}
|
|
90
|
+
const reattached = session.reattachedAdviceFor(normalizedQuestion);
|
|
91
|
+
if (reattached) {
|
|
92
|
+
session.recordJevFilterSkipped(true, normalizedQuestion);
|
|
93
|
+
return Promise.resolve({
|
|
94
|
+
decision: "skip",
|
|
95
|
+
kind: "repeat",
|
|
96
|
+
reason: "already answered earlier in this session",
|
|
97
|
+
reattachedAdvice: reattached.slice(0, REATTACHED_ADVICE_CAP_BYTES),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (!advisorJevFilterEnabledRef) {
|
|
101
|
+
return Promise.resolve(allow());
|
|
102
|
+
}
|
|
103
|
+
return screenWithJev(ctx, session, options, deps, normalizedQuestion);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const bypassOutcome = (
|
|
107
|
+
session: AdvisorSessionState,
|
|
108
|
+
options: ScreenConsultationOptions,
|
|
109
|
+
normalizedQuestion: string | undefined
|
|
110
|
+
): ScreeningOutcome | undefined => {
|
|
111
|
+
const lastSkip = session.lastJevSkip;
|
|
112
|
+
if (options.force) {
|
|
113
|
+
if (
|
|
114
|
+
lastSkip?.normalizedQuestion !== undefined &&
|
|
115
|
+
lastSkip.normalizedQuestion === normalizedQuestion
|
|
116
|
+
) {
|
|
117
|
+
session.recordJevFilterOverride();
|
|
118
|
+
}
|
|
119
|
+
return allow();
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
normalizedQuestion !== undefined &&
|
|
123
|
+
lastSkip?.normalizedQuestion === normalizedQuestion &&
|
|
124
|
+
session.sessionTurnOrdinal - lastSkip.turn <=
|
|
125
|
+
advisorJevFilterOverrideWindowRef
|
|
126
|
+
) {
|
|
127
|
+
session.recordJevFilterOverride();
|
|
128
|
+
return allow();
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const screenWithJev = async (
|
|
134
|
+
ctx: ExtensionContext,
|
|
135
|
+
session: AdvisorSessionState,
|
|
136
|
+
options: ScreenConsultationOptions,
|
|
137
|
+
deps: ScreeningDeps,
|
|
138
|
+
normalizedQuestion: string | undefined
|
|
139
|
+
): Promise<ScreeningOutcome> => {
|
|
140
|
+
const credentials = await (deps.resolveTransport ?? resolveJevTransport)(ctx);
|
|
141
|
+
if (!credentials) {
|
|
142
|
+
session.recordJevFilterFailure();
|
|
143
|
+
notifyOutageOnce(
|
|
144
|
+
ctx,
|
|
145
|
+
"missing-key",
|
|
146
|
+
"No Jev credentials resolved (no TypeSafe key and no OpenRouter login)."
|
|
147
|
+
);
|
|
148
|
+
return allow();
|
|
149
|
+
}
|
|
150
|
+
if (credentials.source === "advisor-json") {
|
|
151
|
+
const warning = consumePlaintextKeyWarning();
|
|
152
|
+
if (warning && ctx.hasUI) {
|
|
153
|
+
ctx.ui.notify(warning, "warning");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const client = jevClientFromCredentials(credentials, deps.fetch);
|
|
158
|
+
try {
|
|
159
|
+
const result = await client.ask(
|
|
160
|
+
buildJevState(ctx, options),
|
|
161
|
+
screeningQuestions,
|
|
162
|
+
options.signal
|
|
163
|
+
);
|
|
164
|
+
session.recordJevFilterUsage(result.usage);
|
|
165
|
+
const verdict = composeScreeningVerdict(result.answers, {
|
|
166
|
+
noulMargin: advisorJevFilterNoulMarginRef,
|
|
167
|
+
skipConfidence: advisorJevFilterSkipConfidenceRef,
|
|
168
|
+
});
|
|
169
|
+
if (verdict.skip) {
|
|
170
|
+
session.recordJevFilterSkipped(false, normalizedQuestion);
|
|
171
|
+
return {
|
|
172
|
+
decision: "skip",
|
|
173
|
+
kind: "screened",
|
|
174
|
+
reason: "low stakes and resolvable without a consultation",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
session.recordJevFilterAllowed();
|
|
178
|
+
return allow();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
session.recordJevFilterFailure();
|
|
181
|
+
if (error instanceof JevFailure) {
|
|
182
|
+
notifyOutageOnce(ctx, error.category, error.message);
|
|
183
|
+
} else if (options.signal?.aborted) {
|
|
184
|
+
throw error;
|
|
185
|
+
} else {
|
|
186
|
+
notifyOutageOnce(
|
|
187
|
+
ctx,
|
|
188
|
+
"error",
|
|
189
|
+
error instanceof Error ? error.message : String(error)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return allow();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export const screeningSkipText = (outcome: {
|
|
197
|
+
kind: JevSkipKind;
|
|
198
|
+
reattachedAdvice?: string;
|
|
199
|
+
}) =>
|
|
200
|
+
outcome.kind === "repeat" && outcome.reattachedAdvice
|
|
201
|
+
? repeatSkipText(outcome.reattachedAdvice)
|
|
202
|
+
: SCREENED_SKIP_TEXT;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Fetch } from "@typesafe-ai/sdk";
|
|
3
|
+
import {
|
|
4
|
+
advisorJevTurnGateEveryTurnsRef,
|
|
5
|
+
advisorJevTurnGateNoulThresholdRef,
|
|
6
|
+
getAdvisorMaxCallsPerSession,
|
|
7
|
+
isSimpleMode,
|
|
8
|
+
} from "../config/state.ts";
|
|
9
|
+
import { herdrAdvisorActivity } from "../herdr.ts";
|
|
10
|
+
import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
|
|
11
|
+
import { composeTurnGateVerdict } from "../jev/questions.ts";
|
|
12
|
+
import { buildJevState } from "../jev/state.ts";
|
|
13
|
+
import { type JevCredentials, resolveJevTransport } from "../jev/transport.ts";
|
|
14
|
+
import type { AdvisorSessionState } from "../session-state.ts";
|
|
15
|
+
import { advisorUsageCost } from "../usage.ts";
|
|
16
|
+
import type { consultAdvisor } from "./consultation.ts";
|
|
17
|
+
import { updateAdvisorUsageStatus } from "./gate-policy.ts";
|
|
18
|
+
import { createOutageNotifier } from "./outage-notifier.ts";
|
|
19
|
+
|
|
20
|
+
export const turnGateQuestion = {
|
|
21
|
+
criteria: {
|
|
22
|
+
false:
|
|
23
|
+
"The executor is progressing soundly on work that matches the user's intent; interrupting would add nothing material.",
|
|
24
|
+
true: "The executor is approaching a material decision, repeating a failure, about to claim success without validation, or drifting from the user's intent; a second opinion now would change what happens next.",
|
|
25
|
+
},
|
|
26
|
+
instructions:
|
|
27
|
+
"Should a senior engineering advisor be consulted right now, before the executor continues? Judge from `recent_conversation`.",
|
|
28
|
+
type: "noul",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export interface JevTurnGateDeps {
|
|
32
|
+
consult?: typeof consultAdvisor;
|
|
33
|
+
fetch?: Fetch;
|
|
34
|
+
resolveTransport?: () => Promise<JevCredentials | undefined>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface JevTurnGateRegistration {
|
|
38
|
+
activeTools: () => string[];
|
|
39
|
+
consult: typeof consultAdvisor;
|
|
40
|
+
deps?: JevTurnGateDeps;
|
|
41
|
+
send: (message: {
|
|
42
|
+
content: string;
|
|
43
|
+
customType: string;
|
|
44
|
+
details: Record<string, unknown>;
|
|
45
|
+
display: boolean;
|
|
46
|
+
}) => void;
|
|
47
|
+
session: AdvisorSessionState;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const outageNotifier = createOutageNotifier(
|
|
51
|
+
(category, message) =>
|
|
52
|
+
`Advisor Jev turn gate failed (${category}); continuing without a proactive consultation. ${message}`
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const notifyFailureOnce = outageNotifier.notify;
|
|
56
|
+
|
|
57
|
+
/** Test-only: re-arms the once-per-outage notification. */
|
|
58
|
+
export const resetJevTurnGateNotification = outageNotifier.reset;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* turn_end handler: counts turns without a consultation and, every Nth such
|
|
62
|
+
* turn, asks Jev whether the Advisor should proactively weigh in. Confident
|
|
63
|
+
* true runs a real consultation delivered as a steer message; everything else
|
|
64
|
+
* keeps the status quo.
|
|
65
|
+
*/
|
|
66
|
+
export const handleJevTurnEnd = async (
|
|
67
|
+
registration: JevTurnGateRegistration,
|
|
68
|
+
ctx: ExtensionContext
|
|
69
|
+
): Promise<void> => {
|
|
70
|
+
const { session } = registration;
|
|
71
|
+
session.recordCompletedTurn();
|
|
72
|
+
const interval = advisorJevTurnGateEveryTurnsRef;
|
|
73
|
+
if (
|
|
74
|
+
interval <= 0 ||
|
|
75
|
+
session.turnsSinceConsultation <= 0 ||
|
|
76
|
+
session.turnsSinceConsultation % interval !== 0
|
|
77
|
+
) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
isSimpleMode() ||
|
|
82
|
+
session.blocked ||
|
|
83
|
+
!registration.activeTools().includes("ask_advisor") ||
|
|
84
|
+
!session.canConsult(getAdvisorMaxCallsPerSession())
|
|
85
|
+
) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const consult = registration.deps?.consult ?? registration.consult;
|
|
90
|
+
const deps = registration.deps ?? {};
|
|
91
|
+
try {
|
|
92
|
+
const credentials = await (deps.resolveTransport ?? resolveJevTransport)(
|
|
93
|
+
ctx
|
|
94
|
+
);
|
|
95
|
+
if (!credentials) {
|
|
96
|
+
session.recordJevGateFailure();
|
|
97
|
+
notifyFailureOnce(
|
|
98
|
+
ctx,
|
|
99
|
+
"missing-key",
|
|
100
|
+
"No Jev credentials resolved (no TypeSafe key and no OpenRouter login)."
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const client = jevClientFromCredentials(credentials, deps.fetch);
|
|
105
|
+
const result = await client.ask(buildJevState(ctx, {}), {
|
|
106
|
+
should_consult: turnGateQuestion,
|
|
107
|
+
});
|
|
108
|
+
session.recordJevGateCheck(result.usage);
|
|
109
|
+
const shouldConsult = composeTurnGateVerdict(
|
|
110
|
+
result.answers,
|
|
111
|
+
advisorJevTurnGateNoulThresholdRef
|
|
112
|
+
);
|
|
113
|
+
if (!shouldConsult) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
session.consumeCall();
|
|
118
|
+
herdrAdvisorActivity.start();
|
|
119
|
+
registration.send({
|
|
120
|
+
content: "Proactive Advisor turn review",
|
|
121
|
+
customType: "advisor-turn-gate-call",
|
|
122
|
+
details: {
|
|
123
|
+
question: `Turn gate: ${session.turnsSinceConsultation} turns without a consultation`,
|
|
124
|
+
turn: session.sessionTurnOrdinal,
|
|
125
|
+
},
|
|
126
|
+
display: true,
|
|
127
|
+
});
|
|
128
|
+
try {
|
|
129
|
+
const consulted = await consult(
|
|
130
|
+
ctx,
|
|
131
|
+
undefined,
|
|
132
|
+
ctx.signal,
|
|
133
|
+
undefined,
|
|
134
|
+
"turn-gate"
|
|
135
|
+
);
|
|
136
|
+
session.recordJevGateConsultation();
|
|
137
|
+
session.resetTurnsSinceConsultation();
|
|
138
|
+
session.recordInvocation({
|
|
139
|
+
cost: advisorUsageCost(consulted.usage),
|
|
140
|
+
executionEffect: "continued",
|
|
141
|
+
kind: "markdown",
|
|
142
|
+
model: consulted.model,
|
|
143
|
+
trigger: "turn-gate",
|
|
144
|
+
usage: consulted.usage,
|
|
145
|
+
});
|
|
146
|
+
updateAdvisorUsageStatus(ctx, session);
|
|
147
|
+
registration.send({
|
|
148
|
+
content: consulted.markdown,
|
|
149
|
+
customType: "advisor-turn-gate-result",
|
|
150
|
+
details: {
|
|
151
|
+
advisor: consulted.model,
|
|
152
|
+
text: consulted.markdown,
|
|
153
|
+
usage: consulted.usage,
|
|
154
|
+
},
|
|
155
|
+
display: true,
|
|
156
|
+
});
|
|
157
|
+
} finally {
|
|
158
|
+
herdrAdvisorActivity.finish();
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
session.recordJevGateFailure();
|
|
162
|
+
if (error instanceof JevFailure) {
|
|
163
|
+
notifyFailureOnce(ctx, error.category, error.message);
|
|
164
|
+
} else if (!ctx.signal?.aborted) {
|
|
165
|
+
notifyFailureOnce(
|
|
166
|
+
ctx,
|
|
167
|
+
"error",
|
|
168
|
+
error instanceof Error ? error.message : String(error)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Wires the turn_end handler on an ExtensionAPI-like surface. */
|
|
175
|
+
export const registerJevTurnGate = <T extends string>(
|
|
176
|
+
on: (
|
|
177
|
+
event: T,
|
|
178
|
+
handler: (event: unknown, ctx: ExtensionContext) => unknown
|
|
179
|
+
) => void,
|
|
180
|
+
registration: JevTurnGateRegistration
|
|
181
|
+
): void => {
|
|
182
|
+
on("turn_end" as T, (_event, ctx) => handleJevTurnEnd(registration, ctx));
|
|
183
|
+
};
|
package/src/tools/loop-gate.ts
CHANGED
|
@@ -180,6 +180,7 @@ export const handleAutomaticGate = async (
|
|
|
180
180
|
return failure.block ? { block: true, reason: failure.reason } : undefined;
|
|
181
181
|
}
|
|
182
182
|
session.consumeCall();
|
|
183
|
+
session.resetTurnsSinceConsultation();
|
|
183
184
|
herdrAdvisorActivity.start();
|
|
184
185
|
let scoutDetails: ScoutToolDetails | undefined;
|
|
185
186
|
const scoutStatusToken = Symbol("automatic-gate-scout");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface OutageNotifier {
|
|
4
|
+
/** Notifies once per distinct category+message; records the key even headless. */
|
|
5
|
+
notify: (ctx: ExtensionContext, category: string, message: string) => void;
|
|
6
|
+
/** Test-only: re-arms the once-per-outage notification. */
|
|
7
|
+
reset: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Creates an independently stateful once-per-outage notifier. */
|
|
11
|
+
export const createOutageNotifier = (
|
|
12
|
+
format: (category: string, message: string) => string
|
|
13
|
+
): OutageNotifier => {
|
|
14
|
+
let lastKey: string | undefined;
|
|
15
|
+
return {
|
|
16
|
+
notify: (ctx, category, message) => {
|
|
17
|
+
const key = `${category}:${message}`;
|
|
18
|
+
if (key === lastKey) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
lastKey = key;
|
|
22
|
+
if (ctx.hasUI) {
|
|
23
|
+
ctx.ui.notify(format(category, message), "warning");
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
reset: () => {
|
|
27
|
+
lastKey = undefined;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
};
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
snapshotAdvisorUsage,
|
|
18
18
|
} from "../usage.ts";
|
|
19
19
|
import { notifyLocalFailure, updateAdvisorUsageStatus } from "./gate-policy.ts";
|
|
20
|
+
import { normalizeScreeningQuestion, screeningSkipText } from "./jev-filter.ts";
|
|
20
21
|
import { renderAdvisorResult } from "./render-advisor-result.ts";
|
|
21
22
|
import {
|
|
22
23
|
renderAdvisorCallBox,
|
|
@@ -52,6 +53,7 @@ export const registerAskAdvisorTool = ({
|
|
|
52
53
|
consult: requestAdvisor,
|
|
53
54
|
pi,
|
|
54
55
|
reservedCalls,
|
|
56
|
+
screen,
|
|
55
57
|
session,
|
|
56
58
|
}: ToolRegistrationContext): void => {
|
|
57
59
|
pi.registerTool({
|
|
@@ -66,9 +68,36 @@ export const registerAskAdvisorTool = ({
|
|
|
66
68
|
) {
|
|
67
69
|
throw new Error("Advisor call budget exhausted for this session.");
|
|
68
70
|
}
|
|
71
|
+
// The Jev screening seam sits after the budget re-check and before the
|
|
72
|
+
// handoff claim: a skipped call consumes neither the one-shot handoff
|
|
73
|
+
// nor the budget.
|
|
74
|
+
const normalizedQuestion = normalizeScreeningQuestion(
|
|
75
|
+
resolveAdvisorRequest(params.question)
|
|
76
|
+
);
|
|
77
|
+
const screening = await screen(ctx, session, {
|
|
78
|
+
draft: params.draft,
|
|
79
|
+
force: params.force,
|
|
80
|
+
question: resolveAdvisorRequest(params.question),
|
|
81
|
+
signal,
|
|
82
|
+
});
|
|
83
|
+
if (screening.decision === "skip") {
|
|
84
|
+
const skipText = screeningSkipText(screening);
|
|
85
|
+
return {
|
|
86
|
+
content: [{ text: skipText, type: "text" }],
|
|
87
|
+
details: {
|
|
88
|
+
jev: {
|
|
89
|
+
kind: screening.kind,
|
|
90
|
+
reason: screening.reason,
|
|
91
|
+
skipped: true,
|
|
92
|
+
},
|
|
93
|
+
text: skipText,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
69
97
|
claimTrackedHandoff(session, params.includeTrackedFiles);
|
|
70
98
|
if (!isSimpleMode()) {
|
|
71
99
|
session.consumeCall();
|
|
100
|
+
session.resetTurnsSinceConsultation();
|
|
72
101
|
}
|
|
73
102
|
herdrAdvisorActivity.start();
|
|
74
103
|
let scoutDetails: AdvisorToolDetails["scout"];
|
|
@@ -123,7 +152,8 @@ export const registerAskAdvisorTool = ({
|
|
|
123
152
|
result.adviceId,
|
|
124
153
|
result.markdown,
|
|
125
154
|
result.trigger,
|
|
126
|
-
Boolean(result.draftBytes)
|
|
155
|
+
Boolean(result.draftBytes),
|
|
156
|
+
normalizedQuestion
|
|
127
157
|
);
|
|
128
158
|
session.recordInvocation({
|
|
129
159
|
cost: advisorUsageCost(result.usage),
|
|
@@ -189,6 +219,12 @@ export const registerAskAdvisorTool = ({
|
|
|
189
219
|
"Concise untrusted draft for plan or completion review; claims are not verification evidence.",
|
|
190
220
|
})
|
|
191
221
|
),
|
|
222
|
+
force: Type.Optional(
|
|
223
|
+
Type.Boolean({
|
|
224
|
+
description:
|
|
225
|
+
"Set true only when you judge a decision genuinely material after a consultation was screened out; bypasses screening.",
|
|
226
|
+
})
|
|
227
|
+
),
|
|
192
228
|
gitContext: Type.Optional(
|
|
193
229
|
Type.Union(
|
|
194
230
|
[Type.Literal("none"), Type.Literal("summary"), Type.Literal("full")],
|
|
@@ -20,6 +20,64 @@ export const registerToolRenderers = (pi: ExtensionAPI): void => {
|
|
|
20
20
|
}
|
|
21
21
|
);
|
|
22
22
|
|
|
23
|
+
pi.registerMessageRenderer?.(
|
|
24
|
+
"advisor-turn-gate-call",
|
|
25
|
+
(message, _options, theme) => {
|
|
26
|
+
const details = message.details as { question?: string } | undefined;
|
|
27
|
+
return renderAdvisorCallBox(details?.question, theme);
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
pi.registerMessageRenderer?.(
|
|
32
|
+
"advisor-turn-gate-result",
|
|
33
|
+
(message, { expanded }, theme) => {
|
|
34
|
+
const details = message.details as
|
|
35
|
+
| { advisor?: string; text?: string; usage?: unknown }
|
|
36
|
+
| undefined;
|
|
37
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
38
|
+
box.addChild(
|
|
39
|
+
new Text(
|
|
40
|
+
theme.fg("warning", theme.bold("◆ ADVISOR · TURN REVIEW")),
|
|
41
|
+
0,
|
|
42
|
+
0
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
if (details?.advisor) {
|
|
46
|
+
box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0));
|
|
47
|
+
}
|
|
48
|
+
if (getAdvisorSettings().showUsageDetails) {
|
|
49
|
+
const usage = formatAdvisorUsage(details?.usage);
|
|
50
|
+
if (usage) {
|
|
51
|
+
box.addChild(new Text(theme.fg("dim", ` Usage: ${usage}`), 0, 0));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (details?.text) {
|
|
55
|
+
box.addChild(
|
|
56
|
+
new Markdown(
|
|
57
|
+
adviceForDisplay(details.text, Boolean(expanded)),
|
|
58
|
+
0,
|
|
59
|
+
0,
|
|
60
|
+
getMarkdownTheme()
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
} else {
|
|
64
|
+
box.addChild(
|
|
65
|
+
new Text(
|
|
66
|
+
theme.fg(
|
|
67
|
+
"error",
|
|
68
|
+
typeof message.content === "string"
|
|
69
|
+
? message.content
|
|
70
|
+
: "Advisor turn review failed."
|
|
71
|
+
),
|
|
72
|
+
0,
|
|
73
|
+
0
|
|
74
|
+
)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return box;
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
23
81
|
pi.registerMessageRenderer?.(
|
|
24
82
|
"advisor-loop-call",
|
|
25
83
|
(message, _options, theme) => {
|
|
@@ -2,6 +2,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { appendOutcome } from "../outcomes.ts";
|
|
3
3
|
import type { AdvisorSessionState } from "../session-state.ts";
|
|
4
4
|
import { consultAdvisor, runAdvisorGate } from "./consultation.ts";
|
|
5
|
+
import { screenConsultation } from "./jev-filter.ts";
|
|
6
|
+
import { registerJevTurnGate } from "./jev-turn-gate.ts";
|
|
5
7
|
import { registerAskAdvisorTool } from "./register-ask-advisor.ts";
|
|
6
8
|
import { registerToolLifecycle } from "./register-lifecycle.ts";
|
|
7
9
|
import { registerOutcomeTool } from "./register-outcome.ts";
|
|
@@ -25,6 +27,7 @@ export const registerAdvisorTool = (
|
|
|
25
27
|
reservedCalls: new Set<string>(),
|
|
26
28
|
runGate: dependencies.runGate ?? runAdvisorGate,
|
|
27
29
|
scoutStatus: dependencies.statusManager ?? new ScoutStatusManager(),
|
|
30
|
+
screen: dependencies.screen ?? screenConsultation,
|
|
28
31
|
session,
|
|
29
32
|
};
|
|
30
33
|
|
|
@@ -32,4 +35,18 @@ export const registerAdvisorTool = (
|
|
|
32
35
|
registerToolLifecycle(registration);
|
|
33
36
|
registerAskAdvisorTool(registration);
|
|
34
37
|
registerOutcomeTool(registration);
|
|
38
|
+
registerJevTurnGate(
|
|
39
|
+
(
|
|
40
|
+
event: Parameters<ExtensionAPI["on"]>[0],
|
|
41
|
+
handler: (event: unknown, ctx: never) => unknown
|
|
42
|
+
) => pi.on(event, handler as never),
|
|
43
|
+
{
|
|
44
|
+
activeTools: () => pi.getActiveTools(),
|
|
45
|
+
consult: registration.consult,
|
|
46
|
+
...(dependencies.turnGateDeps ? { deps: dependencies.turnGateDeps } : {}),
|
|
47
|
+
send: (message) =>
|
|
48
|
+
pi.sendMessage(message as never, { deliverAs: "steer" } as never),
|
|
49
|
+
session,
|
|
50
|
+
}
|
|
51
|
+
);
|
|
35
52
|
};
|
|
@@ -29,6 +29,44 @@ const syncRenderPhase = (context: AdvisorToolContext, phase: string) => {
|
|
|
29
29
|
context.state.phase = phase;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
const attachmentLabels = (details: AdvisorToolDetails | undefined) =>
|
|
33
|
+
[
|
|
34
|
+
details?.draftBytes
|
|
35
|
+
? `Draft attached · ${details.draftBytes} B`
|
|
36
|
+
: undefined,
|
|
37
|
+
details?.preferenceBytes
|
|
38
|
+
? `Project preferences attached · ${details.preferenceBytes} B`
|
|
39
|
+
: undefined,
|
|
40
|
+
details?.trackedBytes
|
|
41
|
+
? `Tracked files attached · ${details.trackedBytes} B`
|
|
42
|
+
: undefined,
|
|
43
|
+
details?.untrackedBytes
|
|
44
|
+
? `Untracked files attached · ${details.untrackedBytes} B`
|
|
45
|
+
: undefined,
|
|
46
|
+
].filter((label): label is string => label !== undefined);
|
|
47
|
+
|
|
48
|
+
const renderJevSkipBox = (
|
|
49
|
+
box: Box,
|
|
50
|
+
result: AgentToolResult<AdvisorToolDetails>,
|
|
51
|
+
expanded: boolean,
|
|
52
|
+
theme: Theme
|
|
53
|
+
) => {
|
|
54
|
+
const details = advisorResultDetails(result);
|
|
55
|
+
const lines = [
|
|
56
|
+
theme.fg("dim", theme.bold("◆ ADVISOR · SKIPPED")),
|
|
57
|
+
theme.fg("dim", ` ${details?.jev?.reason ?? ""}`),
|
|
58
|
+
];
|
|
59
|
+
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
60
|
+
box.addChild(
|
|
61
|
+
new Markdown(
|
|
62
|
+
adviceForDisplay(textFrom(result.content), expanded),
|
|
63
|
+
0,
|
|
64
|
+
0,
|
|
65
|
+
getMarkdownTheme()
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
32
70
|
const renderPartialAdvisorResult = (
|
|
33
71
|
box: Box,
|
|
34
72
|
result: AgentToolResult<AdvisorToolDetails>,
|
|
@@ -91,6 +129,10 @@ const renderFinalAdvisorResult = (
|
|
|
91
129
|
context.state.timerId = undefined;
|
|
92
130
|
}
|
|
93
131
|
const details = advisorResultDetails(result);
|
|
132
|
+
if (details?.jev?.skipped) {
|
|
133
|
+
renderJevSkipBox(box, result, expanded, theme);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
94
136
|
if (details?.scout) {
|
|
95
137
|
context.state.scout = details.scout;
|
|
96
138
|
}
|
|
@@ -112,20 +154,7 @@ const renderFinalAdvisorResult = (
|
|
|
112
154
|
lines.push(theme.fg("dim", ` Usage: ${usage}`));
|
|
113
155
|
}
|
|
114
156
|
}
|
|
115
|
-
const attachments =
|
|
116
|
-
details?.draftBytes
|
|
117
|
-
? `Draft attached · ${details.draftBytes} B`
|
|
118
|
-
: undefined,
|
|
119
|
-
details?.preferenceBytes
|
|
120
|
-
? `Project preferences attached · ${details.preferenceBytes} B`
|
|
121
|
-
: undefined,
|
|
122
|
-
details?.trackedBytes
|
|
123
|
-
? `Tracked files attached · ${details.trackedBytes} B`
|
|
124
|
-
: undefined,
|
|
125
|
-
details?.untrackedBytes
|
|
126
|
-
? `Untracked files attached · ${details.untrackedBytes} B`
|
|
127
|
-
: undefined,
|
|
128
|
-
].filter(Boolean);
|
|
157
|
+
const attachments = attachmentLabels(details);
|
|
129
158
|
if (attachments.length) {
|
|
130
159
|
lines.push(theme.fg("dim", ` ${attachments.join(" · ")}`));
|
|
131
160
|
}
|