pi-advisor-flow 0.7.0 → 0.8.1-dev.1
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 +27 -18
- package/README.md +9 -8
- package/dist/index.js +1968 -1081
- package/extensions/index.ts +2 -1
- package/package.json +48 -35
- package/src/attachments.ts +11 -10
- package/src/child-session.ts +15 -0
- package/src/commands/activation-preparation.ts +1 -0
- package/src/commands/activation.ts +12 -3
- package/src/commands/lifecycle.ts +7 -1
- package/src/commands/manual-command.ts +7 -1
- package/src/commands/manual-consultation.ts +102 -92
- package/src/commands/manual-progress.ts +7 -5
- package/src/commands/model-commands.ts +1 -1
- package/src/commands/model-options.ts +14 -3
- package/src/commands/model-picker.ts +1 -0
- package/src/commands/registration.ts +1 -0
- package/src/commands/renderers.ts +67 -55
- package/src/commands/runtime.ts +16 -14
- package/src/commands/settings-commands.ts +10 -4
- package/src/commands/settings-persistence.ts +16 -2
- package/src/commands/types.ts +1 -0
- package/src/commands.ts +0 -1
- package/src/config/args.ts +1 -1
- package/src/config/defaults.ts +8 -1
- package/src/config/schema.ts +42 -17
- package/src/config/state.ts +12 -3
- package/src/config/storage.ts +34 -23
- package/src/config/types.ts +16 -3
- package/src/config/validation.ts +33 -21
- package/src/config.ts +3 -0
- package/src/content-utils.ts +24 -4
- package/src/conversation.ts +16 -15
- package/src/git.ts +10 -3
- package/src/herdr-block.ts +83 -0
- package/src/herdr-shared.ts +108 -0
- package/src/herdr.ts +30 -192
- package/src/jev/client.ts +75 -85
- package/src/jev/failure.ts +19 -0
- package/src/jev/key-store.ts +26 -11
- package/src/jev/ledger.ts +58 -64
- package/src/jev/questions.ts +26 -23
- package/src/jev/state.ts +15 -12
- package/src/jev/transport.ts +8 -3
- package/src/model-stream.ts +35 -37
- package/src/outcomes.ts +15 -11
- package/src/pi-settings.ts +4 -9
- package/src/preferences.ts +4 -2
- package/src/redaction.ts +10 -12
- package/src/scout-context.ts +59 -60
- package/src/scout-curation.ts +4 -13
- package/src/scout-groups.ts +178 -175
- package/src/scout-protocol.ts +74 -70
- package/src/scout-reconstruct.ts +1 -1
- package/src/scout.ts +128 -77
- package/src/session-state.ts +64 -49
- package/src/tool-result-cap.ts +18 -17
- package/src/tools/consult-context.ts +7 -15
- package/src/tools/consultation.ts +14 -4
- package/src/tools/gate-policy.ts +1 -0
- package/src/tools/gate-protocol.ts +36 -23
- package/src/tools/jev-filter.ts +43 -41
- package/src/tools/jev-turn-gate.ts +29 -9
- package/src/tools/loop-gate.ts +31 -20
- package/src/tools/model-access.ts +46 -0
- package/src/tools/prompts.ts +20 -25
- package/src/tools/register-ask-advisor.ts +36 -16
- package/src/tools/register-lifecycle.ts +22 -0
- package/src/tools/register-outcome.ts +2 -0
- package/src/tools/register-renderers.ts +146 -128
- package/src/tools/registration.ts +12 -14
- package/src/tools/render-advisor-result.ts +35 -22
- package/src/tools/render-common.ts +16 -19
- package/src/tools/scout-status.ts +42 -27
- package/src/tools/types.ts +1 -0
- package/src/tools.ts +0 -1
- package/src/ui/jev-setup-submenu.ts +65 -59
- package/src/ui/manual-dialog-render.ts +1 -0
- package/src/ui/manual-dialog.ts +39 -43
- package/src/ui/masked-input.ts +5 -11
- package/src/ui/model-multi-selector.ts +9 -0
- package/src/ui/model-selector-adapter.ts +31 -0
- package/src/ui/model-selector.ts +14 -161
- package/src/ui/searchable-model-list.ts +230 -0
- package/src/ui/settings-formatting.ts +24 -14
- package/src/ui/settings-items.ts +38 -1
- package/src/ui/settings-list-adapter.ts +15 -12
- package/src/ui/settings-mutations.ts +157 -70
- package/src/ui/settings-selector.ts +18 -9
- package/src/ui/text-setting-submenu.ts +3 -6
- package/src/ui/types.ts +14 -0
- package/src/ui.ts +0 -1
- package/src/usage.ts +26 -19
package/src/jev/ledger.ts
CHANGED
|
@@ -71,6 +71,53 @@ const addJevUsage = (totals: AdvisorJevUsageTotals, usage: AdvisorJevUsage) => {
|
|
|
71
71
|
totals.outputTokens += usage.outputTokens;
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
+
const savingsLine = (costs: number[], skipped: number) => {
|
|
75
|
+
if (costs.length === 0) {
|
|
76
|
+
return "Estimated saving from skips: unavailable — no observed consultation cost this session";
|
|
77
|
+
}
|
|
78
|
+
const mean = costs.reduce((sum, cost) => sum + cost, 0) / costs.length;
|
|
79
|
+
return `Estimated saving from skips: ≤ $${(mean * skipped).toFixed(4)} — upper bound; assumes each skipped consultation would have cost this session's mean allowed-consultation cost ($${mean.toFixed(4)}), which the skipped calls would likely have undercut`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const formatJevTokens = (usage: AdvisorJevUsageTotals) =>
|
|
83
|
+
`↑${formatTokenCount(usage.inputTokens + usage.outputTokens)}`;
|
|
84
|
+
|
|
85
|
+
const markdownCosts = (invocations: readonly JevInvocationView[]): number[] =>
|
|
86
|
+
invocations
|
|
87
|
+
.filter(
|
|
88
|
+
(item): item is JevInvocationView & { cost: number } =>
|
|
89
|
+
item.kind === "markdown" && typeof item.cost === "number"
|
|
90
|
+
)
|
|
91
|
+
.map((item) => item.cost);
|
|
92
|
+
|
|
93
|
+
const filterLine = (filter: AdvisorJevFilterLedger) => {
|
|
94
|
+
const head = `${filter.screened} screened (${filter.allowed} allowed, ${filter.skipped} skipped${filter.repeatSkipped > 0 ? ` [${filter.repeatSkipped} repeat]` : ""})`;
|
|
95
|
+
const parts = [head];
|
|
96
|
+
if (filter.overrides > 0) {
|
|
97
|
+
parts.push(
|
|
98
|
+
`${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (filter.failures > 0) {
|
|
102
|
+
parts.push(`${filter.failures} failure${filter.failures === 1 ? "" : "s"}`);
|
|
103
|
+
}
|
|
104
|
+
return `Jev filter: ${parts.join(", ")}`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const gateLine = (
|
|
108
|
+
gate: AdvisorJevGateLedger,
|
|
109
|
+
invocations: readonly JevInvocationView[]
|
|
110
|
+
) => {
|
|
111
|
+
const consultationCosts = invocations
|
|
112
|
+
.filter(
|
|
113
|
+
(item): item is JevInvocationView & { cost: number } =>
|
|
114
|
+
item.trigger === "turn-gate" && typeof item.cost === "number"
|
|
115
|
+
)
|
|
116
|
+
.map((item) => item.cost);
|
|
117
|
+
const gateSpend = consultationCosts.reduce((sum, cost) => sum + cost, 0);
|
|
118
|
+
return `Turn gate: ${gate.checks} check${gate.checks === 1 ? "" : "s"} (Jev ${formatJevTokens(gate.usage)} · $${gate.usage.cost.toFixed(4)}), ${gate.consultations} consultation${gate.consultations === 1 ? "" : "s"} ($${gateSpend.toFixed(4)})`;
|
|
119
|
+
};
|
|
120
|
+
|
|
74
121
|
/** One session's Jev screening and turn-gate accounting plus summary lines. */
|
|
75
122
|
export class AdvisorJevLedgerState {
|
|
76
123
|
#ledger = freshJevLedger();
|
|
@@ -100,10 +147,11 @@ export class AdvisorJevLedgerState {
|
|
|
100
147
|
if (repeat) {
|
|
101
148
|
this.#ledger.filter.repeatSkipped += 1;
|
|
102
149
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
150
|
+
const skip: JevSkipRecord = { turn };
|
|
151
|
+
if (normalizedQuestion) {
|
|
152
|
+
skip.normalizedQuestion = normalizedQuestion;
|
|
153
|
+
}
|
|
154
|
+
this.#lastSkip = skip;
|
|
107
155
|
}
|
|
108
156
|
|
|
109
157
|
recordFilterOverride() {
|
|
@@ -151,26 +199,24 @@ export class AdvisorJevLedgerState {
|
|
|
151
199
|
`${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
|
|
152
200
|
);
|
|
153
201
|
}
|
|
154
|
-
lines.push(`Consultation dedup: ${parts.join(", ")}`);
|
|
155
202
|
lines.push(
|
|
156
|
-
|
|
203
|
+
`Consultation dedup: ${parts.join(", ")}`,
|
|
204
|
+
savingsLine(markdownCosts(invocations), filter.skipped)
|
|
157
205
|
);
|
|
158
206
|
} else if (this.#filterActive()) {
|
|
159
|
-
lines.push(
|
|
207
|
+
lines.push(filterLine(filter));
|
|
160
208
|
const jevTokens = usage.inputTokens + usage.outputTokens;
|
|
161
209
|
if (jevTokens > 0) {
|
|
162
210
|
lines.push(
|
|
163
|
-
`Jev cost: ${
|
|
211
|
+
`Jev cost: ${formatJevTokens(usage)} tokens · $${usage.cost.toFixed(4)} (input only; output free)`
|
|
164
212
|
);
|
|
165
213
|
}
|
|
166
214
|
if (filter.skipped > 0) {
|
|
167
|
-
lines.push(
|
|
168
|
-
this.#savingsLine(this.#markdownCosts(invocations), filter.skipped)
|
|
169
|
-
);
|
|
215
|
+
lines.push(savingsLine(markdownCosts(invocations), filter.skipped));
|
|
170
216
|
}
|
|
171
217
|
}
|
|
172
218
|
if (gate.checks > 0 || gate.consultations > 0) {
|
|
173
|
-
lines.push(
|
|
219
|
+
lines.push(gateLine(gate, invocations));
|
|
174
220
|
}
|
|
175
221
|
return lines;
|
|
176
222
|
}
|
|
@@ -179,56 +225,4 @@ export class AdvisorJevLedgerState {
|
|
|
179
225
|
const { filter } = this.#ledger;
|
|
180
226
|
return filter.screened > 0 || filter.overrides > 0 || filter.failures > 0;
|
|
181
227
|
}
|
|
182
|
-
|
|
183
|
-
#savingsLine(markdownCosts: number[], skipped: number) {
|
|
184
|
-
if (markdownCosts.length === 0) {
|
|
185
|
-
return "Estimated saving from skips: unavailable — no observed consultation cost this session";
|
|
186
|
-
}
|
|
187
|
-
const mean =
|
|
188
|
-
markdownCosts.reduce((sum, cost) => sum + cost, 0) / markdownCosts.length;
|
|
189
|
-
return `Estimated saving from skips: ≤ $${(mean * skipped).toFixed(4)} — upper bound; assumes each skipped consultation would have cost this session's mean allowed-consultation cost ($${mean.toFixed(4)}), which the skipped calls would likely have undercut`;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
#gateLine(
|
|
193
|
-
gate: AdvisorJevGateLedger,
|
|
194
|
-
invocations: readonly JevInvocationView[]
|
|
195
|
-
) {
|
|
196
|
-
const consultationCosts = invocations
|
|
197
|
-
.filter(
|
|
198
|
-
(item): item is JevInvocationView & { cost: number } =>
|
|
199
|
-
item.trigger === "turn-gate" && typeof item.cost === "number"
|
|
200
|
-
)
|
|
201
|
-
.map((item) => item.cost);
|
|
202
|
-
const gateSpend = consultationCosts.reduce((sum, cost) => sum + cost, 0);
|
|
203
|
-
return `Turn gate: ${gate.checks} check${gate.checks === 1 ? "" : "s"} (Jev ${this.#formatJevTokens(gate.usage)} · $${gate.usage.cost.toFixed(4)}), ${gate.consultations} consultation${gate.consultations === 1 ? "" : "s"} ($${gateSpend.toFixed(4)})`;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
#formatJevTokens(usage: AdvisorJevUsageTotals) {
|
|
207
|
-
return `↑${formatTokenCount(usage.inputTokens + usage.outputTokens)}`;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
#markdownCosts(invocations: readonly JevInvocationView[]): number[] {
|
|
211
|
-
return invocations
|
|
212
|
-
.filter(
|
|
213
|
-
(item): item is JevInvocationView & { cost: number } =>
|
|
214
|
-
item.kind === "markdown" && typeof item.cost === "number"
|
|
215
|
-
)
|
|
216
|
-
.map((item) => item.cost);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
#filterLine(filter: AdvisorJevFilterLedger) {
|
|
220
|
-
const head = `${filter.screened} screened (${filter.allowed} allowed, ${filter.skipped} skipped${filter.repeatSkipped > 0 ? ` [${filter.repeatSkipped} repeat]` : ""})`;
|
|
221
|
-
const parts = [head];
|
|
222
|
-
if (filter.overrides > 0) {
|
|
223
|
-
parts.push(
|
|
224
|
-
`${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
if (filter.failures > 0) {
|
|
228
|
-
parts.push(
|
|
229
|
-
`${filter.failures} failure${filter.failures === 1 ? "" : "s"}`
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
return `Jev filter: ${parts.join(", ")}`;
|
|
233
|
-
}
|
|
234
228
|
}
|
package/src/jev/questions.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Questions } from "@typesafe-ai/sdk";
|
|
2
2
|
|
|
3
|
+
import { isNumber, isRecordOf } from "../content-utils.ts";
|
|
4
|
+
import type { JsonValue } from "../content-utils.ts";
|
|
5
|
+
|
|
3
6
|
/** Exact rubric text for the stakes Score question; level 0 is the skip level. */
|
|
4
7
|
export const STAKES_RUBRIC = [
|
|
5
8
|
"Negligible: routine, low-risk, mechanical, or reversible; a wrong call costs little and is easy to undo.",
|
|
@@ -37,13 +40,10 @@ export interface ScreeningCriteria {
|
|
|
37
40
|
|
|
38
41
|
export type ScreeningVerdict = { skip: false } | { skip: true };
|
|
39
42
|
|
|
40
|
-
const NUMERIC_KEY_PATTERN = /^\d
|
|
41
|
-
|
|
42
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
43
|
-
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
const NUMERIC_KEY_PATTERN = /^\d+$/u;
|
|
44
44
|
|
|
45
|
-
const finiteNumber = (value:
|
|
46
|
-
|
|
45
|
+
const finiteNumber = (value: JsonValue | undefined): number | undefined =>
|
|
46
|
+
isNumber(value) && Number.isFinite(value) ? value : undefined;
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Probability mass on the lowest stakes level, resolved through the answer's
|
|
@@ -51,16 +51,16 @@ const finiteNumber = (value: unknown): number | undefined =>
|
|
|
51
51
|
* smallest numeric index key only when no legend resolves. Shape-independent:
|
|
52
52
|
* never hardcodes a level key and never uses `ceil(score)`.
|
|
53
53
|
*/
|
|
54
|
-
export const lowestStakesProbability = (
|
|
55
|
-
answer:
|
|
54
|
+
export const lowestStakesProbability = <Answer>(
|
|
55
|
+
answer: Answer
|
|
56
56
|
): number | undefined => {
|
|
57
|
-
if (!
|
|
57
|
+
if (!isRecordOf(answer)) {
|
|
58
58
|
return undefined;
|
|
59
59
|
}
|
|
60
|
-
const probabilities =
|
|
60
|
+
const probabilities = isRecordOf(answer.probabilities)
|
|
61
61
|
? answer.probabilities
|
|
62
62
|
: {};
|
|
63
|
-
const legend =
|
|
63
|
+
const legend = isRecordOf(answer.legend) ? answer.legend : undefined;
|
|
64
64
|
if (legend) {
|
|
65
65
|
const exact = Object.keys(legend).find(
|
|
66
66
|
(key) => legend[key] === STAKES_RUBRIC[0]
|
|
@@ -75,23 +75,26 @@ export const lowestStakesProbability = (
|
|
|
75
75
|
if (numericKeys.length === 0) {
|
|
76
76
|
return undefined;
|
|
77
77
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
let [lowest] = numericKeys;
|
|
79
|
+
for (const key of numericKeys) {
|
|
80
|
+
if (Number(key) < Number(lowest)) {
|
|
81
|
+
lowest = key;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
81
84
|
return finiteNumber(probabilities[lowest]);
|
|
82
85
|
};
|
|
83
86
|
|
|
84
|
-
const selfAnswerableNoul = (answer:
|
|
85
|
-
|
|
87
|
+
const selfAnswerableNoul = <Answer>(answer: Answer): number | undefined =>
|
|
88
|
+
isRecordOf(answer) ? finiteNumber(answer.noul) : undefined;
|
|
86
89
|
|
|
87
90
|
/** Pure composition: skip requires the hard conjunction of confident
|
|
88
91
|
* negligible stakes AND confident self-answerability. Any missing, NaN, or
|
|
89
92
|
* malformed input allows. Never a weighted sum. */
|
|
90
|
-
export const composeScreeningVerdict = (
|
|
91
|
-
answers:
|
|
93
|
+
export const composeScreeningVerdict = <Answers>(
|
|
94
|
+
answers: Answers,
|
|
92
95
|
{ noulMargin, skipConfidence }: ScreeningCriteria
|
|
93
96
|
): ScreeningVerdict => {
|
|
94
|
-
if (!
|
|
97
|
+
if (!isRecordOf(answers)) {
|
|
95
98
|
return { skip: false };
|
|
96
99
|
}
|
|
97
100
|
const negligibleMass = lowestStakesProbability(answers.stakes);
|
|
@@ -106,14 +109,14 @@ export const composeScreeningVerdict = (
|
|
|
106
109
|
};
|
|
107
110
|
|
|
108
111
|
/** Confident-true only; any uncertainty means no invocation. */
|
|
109
|
-
export const composeTurnGateVerdict = (
|
|
110
|
-
answers:
|
|
112
|
+
export const composeTurnGateVerdict = <Answers>(
|
|
113
|
+
answers: Answers,
|
|
111
114
|
threshold: number
|
|
112
115
|
): boolean => {
|
|
113
|
-
if (!
|
|
116
|
+
if (!isRecordOf(answers)) {
|
|
114
117
|
return false;
|
|
115
118
|
}
|
|
116
119
|
const answer = answers.should_consult;
|
|
117
|
-
const noul =
|
|
120
|
+
const noul = isRecordOf(answer) ? finiteNumber(answer.noul) : undefined;
|
|
118
121
|
return noul !== undefined && noul >= threshold;
|
|
119
122
|
};
|
package/src/jev/state.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { JsonValue } from "@typesafe-ai/sdk";
|
|
3
|
+
|
|
3
4
|
import {
|
|
4
5
|
advisorJevDigestMaxCharsRef,
|
|
5
6
|
advisorRedactSecretsRef,
|
|
@@ -23,25 +24,27 @@ export type JevState = Record<string, JsonValue>;
|
|
|
23
24
|
export const buildJevState = (
|
|
24
25
|
ctx: ExtensionContext,
|
|
25
26
|
input: JevStateInput = {}
|
|
26
|
-
)
|
|
27
|
-
const
|
|
27
|
+
) => {
|
|
28
|
+
const fields = new Map<string, string>([["role", "executor"]]);
|
|
28
29
|
if (input.question) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
fields.set(
|
|
31
|
+
"executor_question",
|
|
32
|
+
redactAndCapText(
|
|
33
|
+
input.question,
|
|
34
|
+
JEV_TEXT_CAP_BYTES,
|
|
35
|
+
advisorRedactSecretsRef
|
|
36
|
+
)
|
|
33
37
|
);
|
|
34
38
|
}
|
|
35
39
|
if (input.draft) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
JEV_TEXT_CAP_BYTES,
|
|
39
|
-
advisorRedactSecretsRef
|
|
40
|
+
fields.set(
|
|
41
|
+
"executor_draft",
|
|
42
|
+
redactAndCapText(input.draft, JEV_TEXT_CAP_BYTES, advisorRedactSecretsRef)
|
|
40
43
|
);
|
|
41
44
|
}
|
|
42
45
|
const digest = recentConversation(ctx, advisorJevDigestMaxCharsRef);
|
|
43
46
|
if (digest) {
|
|
44
|
-
|
|
47
|
+
fields.set("recent_conversation", digest);
|
|
45
48
|
}
|
|
46
|
-
return
|
|
49
|
+
return Object.fromEntries(fields);
|
|
47
50
|
};
|
package/src/jev/transport.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
2
3
|
import { advisorJevTransportRef } from "../config/state.ts";
|
|
3
|
-
import {
|
|
4
|
+
import { resolveTypeSafeKey } from "./key-store.ts";
|
|
5
|
+
import type { JevKeySource } from "./key-store.ts";
|
|
4
6
|
|
|
5
7
|
export type JevTransportKind = "typesafe" | "openrouter";
|
|
6
8
|
|
|
@@ -41,11 +43,14 @@ export const resolveJevTransport = async (
|
|
|
41
43
|
const resolveTypesafe = deps.resolveTypesafe ?? resolveTypeSafeKey;
|
|
42
44
|
const resolution = await resolveTypesafe();
|
|
43
45
|
if (resolution.key) {
|
|
44
|
-
|
|
46
|
+
const credentials: JevCredentials = {
|
|
45
47
|
apiKey: resolution.key,
|
|
46
|
-
...(resolution.source ? { source: resolution.source } : {}),
|
|
47
48
|
transport: "typesafe",
|
|
48
49
|
};
|
|
50
|
+
if (resolution.source) {
|
|
51
|
+
credentials.source = resolution.source;
|
|
52
|
+
}
|
|
53
|
+
return credentials;
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
if (preference === "typesafe") {
|
package/src/model-stream.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { stream } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import type {
|
|
3
|
+
Api,
|
|
4
|
+
AssistantMessage,
|
|
5
|
+
Message,
|
|
6
|
+
Model,
|
|
7
7
|
} from "@earendil-works/pi-ai/compat";
|
|
8
8
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
9
10
|
import { splitRef } from "./config/state.ts";
|
|
10
11
|
|
|
11
12
|
export interface ResolvedConfiguredModel {
|
|
@@ -25,13 +26,14 @@ export const resolveConfiguredModel = async (
|
|
|
25
26
|
throw new Error(`${label} model not configured`);
|
|
26
27
|
}
|
|
27
28
|
const [provider, modelId] = splitRef(ref);
|
|
28
|
-
const
|
|
29
|
+
const lookup: [string, string] = [provider, modelId];
|
|
30
|
+
const model = ctx.modelRegistry.find(...lookup);
|
|
29
31
|
if (!model) {
|
|
30
32
|
throw new Error(`${label} model not found: ${ref}`);
|
|
31
33
|
}
|
|
32
34
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
33
35
|
if (!auth.ok) {
|
|
34
|
-
throw new Error(
|
|
36
|
+
throw new Error(auth.error);
|
|
35
37
|
}
|
|
36
38
|
if (!auth.apiKey) {
|
|
37
39
|
throw new Error(`No API key for ${ref}`);
|
|
@@ -61,6 +63,12 @@ export interface CollectedTextStream {
|
|
|
61
63
|
|
|
62
64
|
export const ADVISOR_STREAM_UPDATE_INTERVAL_MS = 90;
|
|
63
65
|
|
|
66
|
+
const defaultScheduler: CoalescedUpdateScheduler = {
|
|
67
|
+
clearTimeout,
|
|
68
|
+
now: Date.now,
|
|
69
|
+
setTimeout: (callback, delay) => setTimeout(callback, delay),
|
|
70
|
+
};
|
|
71
|
+
|
|
64
72
|
interface CoalescedUpdateResult {
|
|
65
73
|
error?: unknown;
|
|
66
74
|
failed: boolean;
|
|
@@ -85,19 +93,14 @@ export interface CoalescedUpdateScheduler {
|
|
|
85
93
|
export const createCoalescedUpdate = <T>(
|
|
86
94
|
publish: (value: T) => void,
|
|
87
95
|
intervalMs = ADVISOR_STREAM_UPDATE_INTERVAL_MS,
|
|
88
|
-
scheduler: CoalescedUpdateScheduler =
|
|
89
|
-
clearTimeout,
|
|
90
|
-
now: Date.now,
|
|
91
|
-
setTimeout: (callback, delay) => setTimeout(callback, delay),
|
|
92
|
-
}
|
|
96
|
+
scheduler: CoalescedUpdateScheduler | undefined = defaultScheduler
|
|
93
97
|
): CoalescedUpdate<T> => {
|
|
94
98
|
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
95
99
|
throw new Error("Coalesced update interval must be positive and finite.");
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
let closed = false;
|
|
99
|
-
let
|
|
100
|
-
let pending: T | undefined;
|
|
103
|
+
let pending: { value: T } | undefined;
|
|
101
104
|
let lastPublishedAt: number | undefined;
|
|
102
105
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
103
106
|
let publishError: unknown;
|
|
@@ -112,12 +115,11 @@ export const createCoalescedUpdate = <T>(
|
|
|
112
115
|
|
|
113
116
|
const publishPending = () => {
|
|
114
117
|
timer = undefined;
|
|
115
|
-
if (!
|
|
118
|
+
if (!pending) {
|
|
116
119
|
return;
|
|
117
120
|
}
|
|
118
|
-
const value = pending
|
|
121
|
+
const { value } = pending;
|
|
119
122
|
pending = undefined;
|
|
120
|
-
hasPending = false;
|
|
121
123
|
lastPublishedAt = scheduler.now();
|
|
122
124
|
try {
|
|
123
125
|
publish(value);
|
|
@@ -150,7 +152,6 @@ export const createCoalescedUpdate = <T>(
|
|
|
150
152
|
closed = true;
|
|
151
153
|
clearTimer();
|
|
152
154
|
pending = undefined;
|
|
153
|
-
hasPending = false;
|
|
154
155
|
},
|
|
155
156
|
flush: () => {
|
|
156
157
|
if (!closed) {
|
|
@@ -167,8 +168,7 @@ export const createCoalescedUpdate = <T>(
|
|
|
167
168
|
if (publishFailed) {
|
|
168
169
|
throw publishError;
|
|
169
170
|
}
|
|
170
|
-
pending = value;
|
|
171
|
-
hasPending = true;
|
|
171
|
+
pending = { value };
|
|
172
172
|
if (timer === undefined) {
|
|
173
173
|
schedule();
|
|
174
174
|
}
|
|
@@ -186,22 +186,22 @@ export const collectTextStream = async (
|
|
|
186
186
|
): Promise<CollectedTextStream> => {
|
|
187
187
|
let thinking = "";
|
|
188
188
|
let text = "";
|
|
189
|
+
const streamOptions: Parameters<typeof streamModel>[2] = {
|
|
190
|
+
apiKey: resolved.apiKey,
|
|
191
|
+
env: resolved.env,
|
|
192
|
+
headers: resolved.headers,
|
|
193
|
+
// SAFETY: stream() models the provider-facing reasoning field as never; options.reasoning is the Pi-facing effort string.
|
|
194
|
+
reasoning: options.reasoning as never,
|
|
195
|
+
signal: options.signal,
|
|
196
|
+
};
|
|
197
|
+
if (options.reasoning !== undefined) {
|
|
198
|
+
// SAFETY: reasoningEffort takes the same effort string; kept absent when reasoning is unset.
|
|
199
|
+
streamOptions.reasoningEffort = options.reasoning as never;
|
|
200
|
+
}
|
|
189
201
|
const eventStream = streamModel(
|
|
190
202
|
resolved.model,
|
|
191
203
|
{ messages: options.messages, systemPrompt: options.systemPrompt },
|
|
192
|
-
|
|
193
|
-
apiKey: resolved.apiKey,
|
|
194
|
-
env: resolved.env,
|
|
195
|
-
headers: resolved.headers,
|
|
196
|
-
// `stream()` uses the provider-facing name while the extension's public
|
|
197
|
-
// option keeps the Pi-facing `reasoning` name. Preserve both so the
|
|
198
|
-
// configured effort reaches providers that serialize reasoning_effort.
|
|
199
|
-
reasoning: options.reasoning as never,
|
|
200
|
-
...(options.reasoning === undefined
|
|
201
|
-
? {}
|
|
202
|
-
: { reasoningEffort: options.reasoning as never }),
|
|
203
|
-
signal: options.signal,
|
|
204
|
-
}
|
|
204
|
+
streamOptions
|
|
205
205
|
);
|
|
206
206
|
|
|
207
207
|
for await (const event of eventStream) {
|
|
@@ -242,8 +242,6 @@ export const collectTextStream = async (
|
|
|
242
242
|
return {
|
|
243
243
|
text: finalText,
|
|
244
244
|
thinking,
|
|
245
|
-
usage:
|
|
246
|
-
lastAssistant as (AssistantMessage & { usage?: unknown }) | undefined
|
|
247
|
-
)?.usage,
|
|
245
|
+
usage: lastAssistant?.usage,
|
|
248
246
|
};
|
|
249
247
|
};
|
package/src/outcomes.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
writeFile,
|
|
12
12
|
} from "node:fs/promises";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
15
|
+
|
|
14
16
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
17
|
|
|
16
18
|
export type OutcomeAdoption = "followed" | "not-followed" | "unknown";
|
|
@@ -44,12 +46,15 @@ const statePath = () => join(getAgentDir(), "advisor-outcomes-salt");
|
|
|
44
46
|
export const outcomeLogPath = () =>
|
|
45
47
|
join(getAgentDir(), "advisor-outcomes.jsonl");
|
|
46
48
|
|
|
49
|
+
const isErrnoException = (error: unknown): error is NodeJS.ErrnoException =>
|
|
50
|
+
error instanceof Error && "code" in error;
|
|
51
|
+
|
|
47
52
|
const salt = async () => {
|
|
48
53
|
const path = statePath();
|
|
49
54
|
await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
|
|
50
55
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
51
56
|
try {
|
|
52
|
-
//
|
|
57
|
+
// Contenders retry sequentially until one salt is atomically published.
|
|
53
58
|
const existing = await readFile(path);
|
|
54
59
|
if (existing.length === 32) {
|
|
55
60
|
return existing;
|
|
@@ -57,7 +62,7 @@ const salt = async () => {
|
|
|
57
62
|
// Recover a salt file left incomplete by an interrupted older writer.
|
|
58
63
|
await unlink(path);
|
|
59
64
|
} catch (error) {
|
|
60
|
-
if ((error
|
|
65
|
+
if (!isErrnoException(error) || error.code !== "ENOENT") {
|
|
61
66
|
throw error;
|
|
62
67
|
}
|
|
63
68
|
}
|
|
@@ -68,7 +73,7 @@ const salt = async () => {
|
|
|
68
73
|
await link(temporary, path);
|
|
69
74
|
return value;
|
|
70
75
|
} catch (error) {
|
|
71
|
-
if ((error
|
|
76
|
+
if (!isErrnoException(error) || error.code !== "EEXIST") {
|
|
72
77
|
throw error;
|
|
73
78
|
}
|
|
74
79
|
} finally {
|
|
@@ -86,7 +91,7 @@ const withOutcomeLock = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
|
86
91
|
const lockPath = `${outcomeLogPath()}.lock`;
|
|
87
92
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
88
93
|
try {
|
|
89
|
-
//
|
|
94
|
+
// Lock acquisition must retry sequentially across processes.
|
|
90
95
|
const lock = await open(lockPath, "wx", 0o600);
|
|
91
96
|
const identity = await lock.stat();
|
|
92
97
|
try {
|
|
@@ -99,7 +104,7 @@ const withOutcomeLock = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
} catch (error) {
|
|
102
|
-
if ((error
|
|
107
|
+
if (!isErrnoException(error) || error.code !== "EEXIST") {
|
|
103
108
|
throw error;
|
|
104
109
|
}
|
|
105
110
|
const observed = await stat(lockPath).catch(() => undefined);
|
|
@@ -110,7 +115,7 @@ const withOutcomeLock = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
|
110
115
|
}
|
|
111
116
|
continue;
|
|
112
117
|
}
|
|
113
|
-
await
|
|
118
|
+
await sleep(5);
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
throw new Error("Timed out waiting to append an Advisor outcome.");
|
|
@@ -145,11 +150,10 @@ export const appendOutcome = async (
|
|
|
145
150
|
}
|
|
146
151
|
throw error;
|
|
147
152
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
+
const overflow = currentBytes + Buffer.byteLength(line) > MAX_LOG_BYTES;
|
|
154
|
+
await (overflow
|
|
155
|
+
? writeFile(path, line, { encoding: "utf-8", mode: 0o600 })
|
|
156
|
+
: appendFile(path, line, { encoding: "utf-8", mode: 0o600 }));
|
|
153
157
|
await chmod(path, 0o600);
|
|
154
158
|
return next;
|
|
155
159
|
});
|
package/src/pi-settings.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
|
|
3
4
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
hideThinkingBlock?: boolean;
|
|
7
|
-
}
|
|
6
|
+
import { isRecord } from "./content-utils.ts";
|
|
8
7
|
|
|
9
8
|
interface CachedSetting {
|
|
10
9
|
hidden: boolean;
|
|
@@ -27,12 +26,8 @@ const settingsIdentity = (path: string): string => {
|
|
|
27
26
|
|
|
28
27
|
const readHideThinking = (path: string): boolean => {
|
|
29
28
|
try {
|
|
30
|
-
const parsed: unknown = JSON.parse(readFileSync(path, "
|
|
31
|
-
return (
|
|
32
|
-
typeof parsed === "object" &&
|
|
33
|
-
parsed !== null &&
|
|
34
|
-
(parsed as PiGlobalSettings).hideThinkingBlock === true
|
|
35
|
-
);
|
|
29
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
30
|
+
return isRecord(parsed) && parsed.hideThinkingBlock === true;
|
|
36
31
|
} catch {
|
|
37
32
|
return false;
|
|
38
33
|
}
|
package/src/preferences.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { lstat, open, realpath } from "node:fs/promises";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
|
+
|
|
3
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
4
6
|
import { redactAndCapText } from "./redaction.ts";
|
|
5
7
|
|
|
6
8
|
const PREFERENCES_MAX_BYTES = 8 * 1024;
|
|
@@ -40,9 +42,9 @@ export const readProjectPreferences = async (
|
|
|
40
42
|
try {
|
|
41
43
|
const buffer = Buffer.alloc(maxBytes + 1);
|
|
42
44
|
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
43
|
-
const source = buffer.subarray(0, bytesRead).toString("
|
|
45
|
+
const source = buffer.subarray(0, bytesRead).toString("utf-8");
|
|
44
46
|
const capped = redactAndCapText(source, maxBytes, redact);
|
|
45
|
-
return { bytes: Buffer.byteLength(capped, "
|
|
47
|
+
return { bytes: Buffer.byteLength(capped, "utf-8"), text: capped };
|
|
46
48
|
} finally {
|
|
47
49
|
await file.close();
|
|
48
50
|
}
|
package/src/redaction.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { capUtf8Bytes } from "./content-utils.ts";
|
|
1
|
+
import { capUtf8Bytes, isString } from "./content-utils.ts";
|
|
2
2
|
|
|
3
3
|
const REDACTION_MARKER = "[REDACTED SECRET]";
|
|
4
|
-
const PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/
|
|
5
|
-
const PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/
|
|
4
|
+
const PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/giu;
|
|
5
|
+
const PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/iu;
|
|
6
6
|
const SECRET_PATTERNS = [
|
|
7
|
-
/-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/
|
|
8
|
-
/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/
|
|
9
|
-
/\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'&,;)}\]]+)/
|
|
10
|
-
/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/
|
|
11
|
-
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/
|
|
12
|
-
/\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/
|
|
7
|
+
/-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/giu,
|
|
8
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/giu,
|
|
9
|
+
/\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'&,;)}\]]+)/giu,
|
|
10
|
+
/(?<scheme>[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/giu,
|
|
11
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu,
|
|
12
|
+
/\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/giu,
|
|
13
13
|
] as const;
|
|
14
14
|
|
|
15
15
|
const redactUnterminatedPem = (value: string): string => {
|
|
@@ -31,9 +31,7 @@ export const redactSecrets = (value: string): string => {
|
|
|
31
31
|
let redacted = redactUnterminatedPem(value);
|
|
32
32
|
for (const pattern of SECRET_PATTERNS) {
|
|
33
33
|
redacted = redacted.replace(pattern, (_match, scheme) =>
|
|
34
|
-
|
|
35
|
-
? `${scheme}${REDACTION_MARKER}@`
|
|
36
|
-
: REDACTION_MARKER
|
|
34
|
+
isString(scheme) ? `${scheme}${REDACTION_MARKER}@` : REDACTION_MARKER
|
|
37
35
|
);
|
|
38
36
|
}
|
|
39
37
|
return redacted;
|