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/herdr.ts
CHANGED
|
@@ -1,108 +1,37 @@
|
|
|
1
|
-
import net from "node:net";
|
|
2
1
|
import { getAdvisorSettings } from "./config/state.ts";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
agent: "pi";
|
|
25
|
-
applies_to_source: string;
|
|
26
|
-
state_labels?: { working?: string; blocked?: string };
|
|
27
|
-
clear_state_labels?: true;
|
|
28
|
-
seq: number;
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
export interface HerdrNotificationRequest {
|
|
32
|
-
id: string;
|
|
33
|
-
method: typeof HERDR_NOTIFICATION_METHOD;
|
|
34
|
-
params: {
|
|
35
|
-
title: string;
|
|
36
|
-
body: string;
|
|
37
|
-
position: "top-left";
|
|
38
|
-
sound: "request";
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
type HerdrRequest = HerdrMetadataRequest | HerdrNotificationRequest;
|
|
42
|
-
type Report = (request: HerdrRequest) => void;
|
|
43
|
-
|
|
44
|
-
// Herdr drops socket reports from other sources, so the blocked state must be
|
|
45
|
-
// signalled through the in-process pi event bus its integration listens on.
|
|
46
|
-
type BlockedEmitter = (active: boolean, label: string) => void;
|
|
47
|
-
let emitBlocked: BlockedEmitter | undefined;
|
|
48
|
-
|
|
49
|
-
export const setHerdrBlockedEmitter = (emitter: BlockedEmitter | undefined) => {
|
|
50
|
-
emitBlocked = emitter;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const safeEmitBlocked = (active: boolean, label = "Advisor blocked") => {
|
|
54
|
-
try {
|
|
55
|
-
emitBlocked?.(active, label);
|
|
56
|
-
} catch {
|
|
57
|
-
/* Herdr is optional. */
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const isControlCharacter = (character: string) =>
|
|
62
|
-
character <= "\u001f" || character === "\u007f";
|
|
63
|
-
|
|
64
|
-
const cleanNotification = (value: string, max: number) =>
|
|
65
|
-
[...redactSecrets(value)]
|
|
66
|
-
.map((character) => (isControlCharacter(character) ? " " : character))
|
|
67
|
-
.join("")
|
|
68
|
-
.replace(/\s+/g, " ")
|
|
69
|
-
.trim()
|
|
70
|
-
.slice(0, max);
|
|
71
|
-
|
|
72
|
-
export const createHerdrNotificationRequest = (
|
|
73
|
-
title: string,
|
|
74
|
-
body: string
|
|
75
|
-
): HerdrNotificationRequest => ({
|
|
76
|
-
id: `${NOTIFICATION_SOURCE}:${nextSequence()}`,
|
|
77
|
-
method: HERDR_NOTIFICATION_METHOD,
|
|
2
|
+
import { HerdrAdvisorBlock } from "./herdr-block.ts";
|
|
3
|
+
import type { HerdrMetadataRequest, Report } from "./herdr-shared.ts";
|
|
4
|
+
import {
|
|
5
|
+
createHerdrNotificationRequest,
|
|
6
|
+
HERDR_PI_SOURCE,
|
|
7
|
+
nextSequence,
|
|
8
|
+
sendToHerdr,
|
|
9
|
+
SOURCE,
|
|
10
|
+
} from "./herdr-shared.ts";
|
|
11
|
+
|
|
12
|
+
export { HerdrAdvisorBlock } from "./herdr-block.ts";
|
|
13
|
+
export {
|
|
14
|
+
createHerdrNotificationRequest,
|
|
15
|
+
type HerdrMetadataRequest,
|
|
16
|
+
type HerdrNotificationRequest,
|
|
17
|
+
setHerdrBlockedEmitter,
|
|
18
|
+
} from "./herdr-shared.ts";
|
|
19
|
+
|
|
20
|
+
const metadataRequest = (clear: boolean): HerdrMetadataRequest => ({
|
|
21
|
+
id: `${SOURCE}:${nextSequence()}`,
|
|
22
|
+
method: "pane.report_metadata",
|
|
78
23
|
params: {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
24
|
+
agent: "pi",
|
|
25
|
+
applies_to_source: HERDR_PI_SOURCE,
|
|
26
|
+
pane_id: process.env.HERDR_PANE_ID ?? "",
|
|
27
|
+
source: SOURCE,
|
|
28
|
+
...(clear
|
|
29
|
+
? { clear_state_labels: true }
|
|
30
|
+
: { state_labels: { working: "seeking advice" } }),
|
|
31
|
+
seq: nextSequence(),
|
|
83
32
|
},
|
|
84
33
|
});
|
|
85
34
|
|
|
86
|
-
const sendToHerdr: Report = (request) => {
|
|
87
|
-
if (process.env.HERDR_ENV !== "1") {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
const paneId = process.env.HERDR_PANE_ID;
|
|
91
|
-
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
92
|
-
if (!(paneId && socketPath)) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
const endpoint =
|
|
96
|
-
process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
|
97
|
-
const socket = net.createConnection(endpoint);
|
|
98
|
-
const timeout = setTimeout(() => socket.destroy(), 500);
|
|
99
|
-
timeout.unref?.();
|
|
100
|
-
socket.once("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
|
101
|
-
socket.once("data", () => socket.destroy());
|
|
102
|
-
socket.once("error", () => socket.destroy());
|
|
103
|
-
socket.once("close", () => clearTimeout(timeout));
|
|
104
|
-
};
|
|
105
|
-
|
|
106
35
|
export class HerdrAdvisorActivity {
|
|
107
36
|
#activeConsultations = 0;
|
|
108
37
|
private readonly report: Report;
|
|
@@ -146,98 +75,7 @@ export class HerdrAdvisorActivity {
|
|
|
146
75
|
|
|
147
76
|
private safeReport(clear: boolean) {
|
|
148
77
|
try {
|
|
149
|
-
this.report(
|
|
150
|
-
} catch {
|
|
151
|
-
/* Herdr is optional. */
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private request(clear: boolean): HerdrMetadataRequest {
|
|
156
|
-
return {
|
|
157
|
-
id: `${SOURCE}:${nextSequence()}`,
|
|
158
|
-
method: "pane.report_metadata",
|
|
159
|
-
params: {
|
|
160
|
-
agent: "pi",
|
|
161
|
-
applies_to_source: HERDR_PI_SOURCE,
|
|
162
|
-
pane_id: process.env.HERDR_PANE_ID ?? "",
|
|
163
|
-
source: SOURCE,
|
|
164
|
-
...(clear
|
|
165
|
-
? { clear_state_labels: true }
|
|
166
|
-
: { state_labels: { working: "seeking advice" } }),
|
|
167
|
-
seq: nextSequence(),
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export class HerdrAdvisorBlock {
|
|
174
|
-
#blocked = false;
|
|
175
|
-
private readonly report: Report;
|
|
176
|
-
private readonly enabled: () => boolean;
|
|
177
|
-
|
|
178
|
-
constructor(
|
|
179
|
-
report: Report = sendToHerdr,
|
|
180
|
-
enabled: () => boolean = () => true
|
|
181
|
-
) {
|
|
182
|
-
this.report = report;
|
|
183
|
-
this.enabled = enabled;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
set(reason: string) {
|
|
187
|
-
if (!this.enabled()) {
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
const label = cleanNotification(reason, 200);
|
|
191
|
-
const wasBlocked: boolean = this.#blocked;
|
|
192
|
-
if (!wasBlocked) {
|
|
193
|
-
// The listener refcounts, so only the false → true edge may emit.
|
|
194
|
-
safeEmitBlocked(true, label);
|
|
195
|
-
}
|
|
196
|
-
this.#blocked = true;
|
|
197
|
-
this.safeReport({ blocked: label });
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
clear() {
|
|
201
|
-
const wasBlocked: boolean = this.#blocked;
|
|
202
|
-
this.#blocked = false;
|
|
203
|
-
if (!wasBlocked) {
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
// Clearing previously reported state is a de-escalation and must still be
|
|
207
|
-
// delivered if integration was disabled after the block was reported.
|
|
208
|
-
safeEmitBlocked(false);
|
|
209
|
-
try {
|
|
210
|
-
this.report({
|
|
211
|
-
id: `${BLOCK_SOURCE}:${nextSequence()}`,
|
|
212
|
-
method: "pane.report_metadata",
|
|
213
|
-
params: {
|
|
214
|
-
agent: "pi",
|
|
215
|
-
applies_to_source: HERDR_PI_SOURCE,
|
|
216
|
-
clear_state_labels: true,
|
|
217
|
-
pane_id: process.env.HERDR_PANE_ID ?? "",
|
|
218
|
-
seq: nextSequence(),
|
|
219
|
-
source: BLOCK_SOURCE,
|
|
220
|
-
},
|
|
221
|
-
});
|
|
222
|
-
} catch {
|
|
223
|
-
/* Herdr is optional. */
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private safeReport(labels: { blocked: string }) {
|
|
228
|
-
try {
|
|
229
|
-
this.report({
|
|
230
|
-
id: `${BLOCK_SOURCE}:${nextSequence()}`,
|
|
231
|
-
method: "pane.report_metadata",
|
|
232
|
-
params: {
|
|
233
|
-
agent: "pi",
|
|
234
|
-
applies_to_source: HERDR_PI_SOURCE,
|
|
235
|
-
pane_id: process.env.HERDR_PANE_ID ?? "",
|
|
236
|
-
seq: nextSequence(),
|
|
237
|
-
source: BLOCK_SOURCE,
|
|
238
|
-
state_labels: labels,
|
|
239
|
-
},
|
|
240
|
-
});
|
|
78
|
+
this.report(metadataRequest(clear));
|
|
241
79
|
} catch {
|
|
242
80
|
/* Herdr is optional. */
|
|
243
81
|
}
|
package/src/jev/client.ts
CHANGED
|
@@ -1,29 +1,19 @@
|
|
|
1
1
|
import type { EntryType, Fetch, Questions } from "@typesafe-ai/sdk";
|
|
2
|
+
|
|
2
3
|
import {
|
|
3
4
|
advisorJevModelRef,
|
|
4
5
|
advisorJevPricePerMtokRef,
|
|
5
6
|
advisorJevTimeoutMsRef,
|
|
6
7
|
} from "../config/state.ts";
|
|
8
|
+
import { isNumber, isRecord, isRecordOf, isString } from "../content-utils.ts";
|
|
9
|
+
import type { JsonValue, RecordValue } from "../content-utils.ts";
|
|
7
10
|
import { redactSecrets } from "../redaction.ts";
|
|
11
|
+
import { JevFailureError } from "./failure.ts";
|
|
12
|
+
import type { JevErrorCategory } from "./failure.ts";
|
|
8
13
|
import type { JevCredentials, JevTransportKind } from "./transport.ts";
|
|
9
14
|
|
|
10
|
-
export type JevErrorCategory
|
|
11
|
-
|
|
12
|
-
| "timeout"
|
|
13
|
-
| "network"
|
|
14
|
-
| "malformed"
|
|
15
|
-
| "error";
|
|
16
|
-
|
|
17
|
-
/** A classified, redacted Jev failure; never carries key material. */
|
|
18
|
-
export class JevFailure extends Error {
|
|
19
|
-
readonly category: JevErrorCategory;
|
|
20
|
-
|
|
21
|
-
constructor(category: JevErrorCategory, message: string) {
|
|
22
|
-
super(message);
|
|
23
|
-
this.name = "JevFailure";
|
|
24
|
-
this.category = category;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
15
|
+
export type { JevErrorCategory } from "./failure.ts";
|
|
16
|
+
export { JevFailureError as JevFailure } from "./failure.ts";
|
|
27
17
|
|
|
28
18
|
export interface JevUsage {
|
|
29
19
|
cost: number;
|
|
@@ -32,7 +22,7 @@ export interface JevUsage {
|
|
|
32
22
|
}
|
|
33
23
|
|
|
34
24
|
export interface JevAskResult {
|
|
35
|
-
answers:
|
|
25
|
+
answers: RecordValue;
|
|
36
26
|
model: string;
|
|
37
27
|
usage: JevUsage;
|
|
38
28
|
}
|
|
@@ -51,23 +41,22 @@ const ENDPOINTS: Record<JevTransportKind, string> = {
|
|
|
51
41
|
typesafe: "https://api.typesafe.ai/v1/systemone",
|
|
52
42
|
};
|
|
53
43
|
|
|
44
|
+
const range = (from: number, to: number): number[] =>
|
|
45
|
+
Array.from({ length: to - from + 1 }, (_, index) => from + index);
|
|
46
|
+
|
|
54
47
|
const RETRYABLE_STATUSES = new Set([408, 429, ...range(500, 599)]);
|
|
55
48
|
const RETRY_BACKOFF_MS = 250;
|
|
56
49
|
const MAX_ATTEMPTS = 2;
|
|
57
50
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const finiteTokens = (value: unknown): number =>
|
|
63
|
-
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
51
|
+
const finiteTokens = (value: JsonValue | undefined): number =>
|
|
52
|
+
isNumber(value) && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
64
53
|
|
|
65
|
-
const errorDetail = (error:
|
|
66
|
-
if (
|
|
54
|
+
const errorDetail = (error: JsonValue | undefined): string => {
|
|
55
|
+
if (isString(error)) {
|
|
67
56
|
return error;
|
|
68
57
|
}
|
|
69
|
-
if (error &&
|
|
70
|
-
return String(
|
|
58
|
+
if (isRecordOf(error) && "message" in error) {
|
|
59
|
+
return String(error.message);
|
|
71
60
|
}
|
|
72
61
|
return "";
|
|
73
62
|
};
|
|
@@ -82,17 +71,47 @@ const statusCategory = (status: number): JevErrorCategory => {
|
|
|
82
71
|
return "error";
|
|
83
72
|
};
|
|
84
73
|
|
|
74
|
+
const isObjectLike = <Value>(value: Value): value is Value & object =>
|
|
75
|
+
typeof value === "object";
|
|
76
|
+
|
|
85
77
|
const openRouterModelId = (model: string) =>
|
|
86
78
|
model.includes("/") ? model : `~typesafe/${model}`;
|
|
87
79
|
|
|
88
80
|
interface AttemptOutcome {
|
|
89
|
-
answers?:
|
|
90
|
-
failure?:
|
|
81
|
+
answers?: RecordValue;
|
|
82
|
+
failure?: JevFailureError;
|
|
91
83
|
model?: string;
|
|
92
84
|
retryable?: boolean;
|
|
93
|
-
usage?: { input_tokens?:
|
|
85
|
+
usage?: { input_tokens?: JsonValue; output_tokens?: JsonValue };
|
|
94
86
|
}
|
|
95
87
|
|
|
88
|
+
const connectionFailure = <E>(error: E) => {
|
|
89
|
+
const message = redactSecrets(
|
|
90
|
+
error instanceof Error ? error.message : String(error)
|
|
91
|
+
);
|
|
92
|
+
return {
|
|
93
|
+
failure: new JevFailureError(
|
|
94
|
+
"network",
|
|
95
|
+
`Jev connection failed: ${message}`
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const sleepWithAbort = (signal: AbortSignal, ms: number): Promise<void> =>
|
|
101
|
+
// oxlint-disable-next-line promise/avoid-new -- unref-ed abortable retry timer cannot be expressed with async/await.
|
|
102
|
+
new Promise((resolve) => {
|
|
103
|
+
const timer = setTimeout(resolve, ms);
|
|
104
|
+
timer.unref?.();
|
|
105
|
+
signal.addEventListener(
|
|
106
|
+
"abort",
|
|
107
|
+
() => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolve();
|
|
110
|
+
},
|
|
111
|
+
{ once: true }
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
96
115
|
/** One systemone client for both transports with a total wall deadline so
|
|
97
116
|
* retries can never stall a tool call; errors are classified and redacted on
|
|
98
117
|
* every path. */
|
|
@@ -114,6 +133,7 @@ export class JevClient {
|
|
|
114
133
|
}: JevClientOptions) {
|
|
115
134
|
this.#apiKey = apiKey;
|
|
116
135
|
this.#endpoint = ENDPOINTS[transport];
|
|
136
|
+
// SAFETY: the bound global fetch satisfies the SDK Fetch signature; binding keeps the receiver correct.
|
|
117
137
|
this.#fetch = fetch ?? (globalThis.fetch.bind(globalThis) as Fetch);
|
|
118
138
|
this.#model = transport === "openrouter" ? openRouterModelId(model) : model;
|
|
119
139
|
this.#pricePerMtok = pricePerMtok;
|
|
@@ -145,12 +165,12 @@ export class JevClient {
|
|
|
145
165
|
try {
|
|
146
166
|
let outcome: AttemptOutcome = {};
|
|
147
167
|
for (let attempt = 1; ; attempt += 1) {
|
|
148
|
-
//
|
|
168
|
+
// The retry loop is bounded to one backoff by the deadline controller.
|
|
149
169
|
outcome = await this.#attempt(body, deadline.signal);
|
|
150
170
|
if (!outcome.retryable || attempt >= MAX_ATTEMPTS) {
|
|
151
171
|
break;
|
|
152
172
|
}
|
|
153
|
-
await
|
|
173
|
+
await sleepWithAbort(deadline.signal, RETRY_BACKOFF_MS);
|
|
154
174
|
if (deadline.signal.aborted) {
|
|
155
175
|
break;
|
|
156
176
|
}
|
|
@@ -160,18 +180,18 @@ export class JevClient {
|
|
|
160
180
|
if (signal?.aborted && !deadlineHit) {
|
|
161
181
|
throw error;
|
|
162
182
|
}
|
|
163
|
-
if (error instanceof
|
|
183
|
+
if (error instanceof JevFailureError) {
|
|
164
184
|
throw error;
|
|
165
185
|
}
|
|
166
186
|
const message = redactSecrets(
|
|
167
187
|
error instanceof Error ? error.message : String(error)
|
|
168
188
|
);
|
|
169
189
|
throw deadlineHit
|
|
170
|
-
? new
|
|
190
|
+
? new JevFailureError(
|
|
171
191
|
"timeout",
|
|
172
192
|
`Jev call exceeded its ${this.#timeoutMs} ms wall-time budget.`
|
|
173
193
|
)
|
|
174
|
-
: new
|
|
194
|
+
: new JevFailureError("error", message);
|
|
175
195
|
} finally {
|
|
176
196
|
clearTimeout(timer);
|
|
177
197
|
signal?.removeEventListener("abort", abortFromCaller);
|
|
@@ -187,7 +207,7 @@ export class JevClient {
|
|
|
187
207
|
return this.#result(outcome);
|
|
188
208
|
}
|
|
189
209
|
if (deadlineHit && outcome.failure?.category !== "auth") {
|
|
190
|
-
throw new
|
|
210
|
+
throw new JevFailureError(
|
|
191
211
|
"timeout",
|
|
192
212
|
`Jev call exceeded its ${this.#timeoutMs} ms wall-time budget.`
|
|
193
213
|
);
|
|
@@ -198,22 +218,7 @@ export class JevClient {
|
|
|
198
218
|
if (signal?.aborted) {
|
|
199
219
|
throw new Error("Jev call aborted by the caller.");
|
|
200
220
|
}
|
|
201
|
-
throw new
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async #backoff(signal: AbortSignal): Promise<void> {
|
|
205
|
-
if (signal.aborted) {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
await new Promise<void>((resolve) => {
|
|
209
|
-
const timer = setTimeout(resolve, RETRY_BACKOFF_MS);
|
|
210
|
-
timer.unref?.();
|
|
211
|
-
const onAbort = () => {
|
|
212
|
-
clearTimeout(timer);
|
|
213
|
-
resolve();
|
|
214
|
-
};
|
|
215
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
216
|
-
});
|
|
221
|
+
throw new JevFailureError("error", "Jev call failed.");
|
|
217
222
|
}
|
|
218
223
|
|
|
219
224
|
async #attempt(body: string, signal: AbortSignal): Promise<AttemptOutcome> {
|
|
@@ -237,7 +242,7 @@ export class JevClient {
|
|
|
237
242
|
}
|
|
238
243
|
return {
|
|
239
244
|
retryable: true,
|
|
240
|
-
...
|
|
245
|
+
...connectionFailure(error),
|
|
241
246
|
};
|
|
242
247
|
}
|
|
243
248
|
if (response.ok) {
|
|
@@ -250,20 +255,11 @@ export class JevClient {
|
|
|
250
255
|
};
|
|
251
256
|
}
|
|
252
257
|
|
|
253
|
-
#
|
|
254
|
-
const message = redactSecrets(
|
|
255
|
-
error instanceof Error ? error.message : String(error)
|
|
256
|
-
);
|
|
257
|
-
return {
|
|
258
|
-
failure: new JevFailure("network", `Jev connection failed: ${message}`),
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
async #failureFromStatus(response: Response): Promise<JevFailure> {
|
|
258
|
+
async #failureFromStatus(response: Response): Promise<JevFailureError> {
|
|
263
259
|
let detail = "";
|
|
264
260
|
try {
|
|
265
261
|
const parsed: unknown = await response.json();
|
|
266
|
-
const error = (parsed
|
|
262
|
+
const error = isRecord(parsed) ? parsed.error : undefined;
|
|
267
263
|
detail = errorDetail(error);
|
|
268
264
|
} catch {
|
|
269
265
|
detail = "";
|
|
@@ -271,7 +267,7 @@ export class JevClient {
|
|
|
271
267
|
const message = redactSecrets(
|
|
272
268
|
`Jev ${this.#transportLabel()} request failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}.`
|
|
273
269
|
);
|
|
274
|
-
return new
|
|
270
|
+
return new JevFailureError(statusCategory(response.status), message);
|
|
275
271
|
}
|
|
276
272
|
|
|
277
273
|
#transportLabel(): string {
|
|
@@ -284,41 +280,35 @@ export class JevClient {
|
|
|
284
280
|
parsed = await response.json();
|
|
285
281
|
} catch (error) {
|
|
286
282
|
return {
|
|
287
|
-
failure: new
|
|
283
|
+
failure: new JevFailureError(
|
|
288
284
|
"malformed",
|
|
289
285
|
`Jev response was not JSON: ${redactSecrets(error instanceof Error ? error.message : String(error))}`
|
|
290
286
|
),
|
|
291
287
|
};
|
|
292
288
|
}
|
|
293
|
-
|
|
294
|
-
answers?: unknown;
|
|
295
|
-
model?: unknown;
|
|
296
|
-
usage?: unknown;
|
|
297
|
-
} | null;
|
|
298
|
-
if (
|
|
299
|
-
!record ||
|
|
300
|
-
typeof record !== "object" ||
|
|
301
|
-
!record.answers ||
|
|
302
|
-
typeof record.answers !== "object"
|
|
303
|
-
) {
|
|
289
|
+
if (!isRecord(parsed) || !parsed.answers || !isObjectLike(parsed.answers)) {
|
|
304
290
|
return {
|
|
305
|
-
failure: new
|
|
291
|
+
failure: new JevFailureError(
|
|
306
292
|
"malformed",
|
|
307
293
|
"Jev response did not include an answers object."
|
|
308
294
|
),
|
|
309
295
|
};
|
|
310
296
|
}
|
|
311
297
|
return {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
298
|
+
// SAFETY: the response contract guarantees an answers object; the shape is re-validated per key by consumers.
|
|
299
|
+
answers: parsed.answers as RecordValue,
|
|
300
|
+
model: isString(parsed.model) ? parsed.model : this.#model,
|
|
301
|
+
usage: isRecordOf(parsed.usage)
|
|
302
|
+
? {
|
|
303
|
+
input_tokens: parsed.usage.input_tokens,
|
|
304
|
+
output_tokens: parsed.usage.output_tokens,
|
|
305
|
+
}
|
|
306
|
+
: undefined,
|
|
315
307
|
};
|
|
316
308
|
}
|
|
317
309
|
|
|
318
310
|
#result(outcome: AttemptOutcome): JevAskResult {
|
|
319
|
-
const usage = outcome
|
|
320
|
-
| { input_tokens?: unknown; output_tokens?: unknown }
|
|
321
|
-
| undefined;
|
|
311
|
+
const { usage } = outcome;
|
|
322
312
|
const inputTokens = finiteTokens(usage?.input_tokens);
|
|
323
313
|
const outputTokens = finiteTokens(usage?.output_tokens);
|
|
324
314
|
const price = this.#pricePerMtok ?? advisorJevPricePerMtokRef;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type JevErrorCategory =
|
|
2
|
+
| "auth"
|
|
3
|
+
| "timeout"
|
|
4
|
+
| "network"
|
|
5
|
+
| "malformed"
|
|
6
|
+
| "error";
|
|
7
|
+
|
|
8
|
+
/** A classified, redacted Jev failure; never carries key material. */
|
|
9
|
+
export class JevFailureError extends Error {
|
|
10
|
+
readonly category: JevErrorCategory;
|
|
11
|
+
|
|
12
|
+
constructor(category: JevErrorCategory, message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "JevFailureError";
|
|
15
|
+
this.category = category;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { JevFailureError as JevFailure };
|
package/src/jev/key-store.ts
CHANGED
|
@@ -6,8 +6,12 @@ import {
|
|
|
6
6
|
writeFileSync,
|
|
7
7
|
} from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
|
|
9
10
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
10
12
|
import { readExistingConfig, resetConfigCache } from "../config/storage.ts";
|
|
13
|
+
import { isString } from "../content-utils.ts";
|
|
14
|
+
import type { RecordValue } from "../content-utils.ts";
|
|
11
15
|
import { redactSecrets } from "../redaction.ts";
|
|
12
16
|
|
|
13
17
|
export type JevKeySource = "bun-secrets" | "env" | "file" | "advisor-json";
|
|
@@ -29,7 +33,7 @@ export interface JevSecretEntry {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export interface JevSecretsLike {
|
|
32
|
-
delete: (options: JevSecretEntry) => Promise<
|
|
36
|
+
delete: (options: JevSecretEntry) => Promise<boolean | undefined>;
|
|
33
37
|
get: (options: JevSecretEntry) => Promise<string | null | undefined>;
|
|
34
38
|
set: (options: JevSecretEntry & { value: string }) => Promise<void>;
|
|
35
39
|
}
|
|
@@ -37,8 +41,8 @@ export interface JevSecretsLike {
|
|
|
37
41
|
export interface JevKeyStoreDeps {
|
|
38
42
|
/** Deletes the extension-managed key file; injectable for tests. */
|
|
39
43
|
deleteFileStore?: () => void;
|
|
40
|
-
env?:
|
|
41
|
-
readAdvisorJson?: () =>
|
|
44
|
+
env?: NodeJS.ProcessEnv;
|
|
45
|
+
readAdvisorJson?: () => RecordValue;
|
|
42
46
|
/** Reads the extension-managed 0600 key file; injectable for tests. */
|
|
43
47
|
readFileStore?: () => string | undefined;
|
|
44
48
|
/** Inject `null` to simulate a runtime without a secret store. */
|
|
@@ -55,8 +59,15 @@ const KEY_FILE_MODE = 0o600;
|
|
|
55
59
|
|
|
56
60
|
const keyFilePath = () => join(getAgentDir(), "typesafe_api_key");
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
interface BunGlobal {
|
|
63
|
+
Bun?: { secrets?: JevSecretsLike };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const runtimeSecrets = (): JevSecretsLike | undefined => {
|
|
67
|
+
// SAFETY: only Bun runtimes expose Bun.secrets on globalThis; others leave it undefined.
|
|
68
|
+
const bun = globalThis as BunGlobal;
|
|
69
|
+
return bun.Bun?.secrets;
|
|
70
|
+
};
|
|
60
71
|
|
|
61
72
|
/** Whether the current runtime offers a Bun.secrets store. */
|
|
62
73
|
export const hasRuntimeSecretStore = () => runtimeSecrets() !== undefined;
|
|
@@ -64,12 +75,12 @@ export const hasRuntimeSecretStore = () => runtimeSecrets() !== undefined;
|
|
|
64
75
|
const normalizeKey = (value: string | null | undefined): string | undefined =>
|
|
65
76
|
value?.trim() || undefined;
|
|
66
77
|
|
|
67
|
-
const readAdvisorJsonConfig = ():
|
|
78
|
+
const readAdvisorJsonConfig = (): RecordValue =>
|
|
68
79
|
readExistingConfig(join(getAgentDir(), "advisor.json"));
|
|
69
80
|
|
|
70
81
|
const defaultReadFileStore = (): string | undefined => {
|
|
71
82
|
try {
|
|
72
|
-
return normalizeKey(readFileSync(keyFilePath(), "
|
|
83
|
+
return normalizeKey(readFileSync(keyFilePath(), "utf-8"));
|
|
73
84
|
} catch {
|
|
74
85
|
return undefined;
|
|
75
86
|
}
|
|
@@ -86,7 +97,7 @@ const defaultDeleteFileStore = () => {
|
|
|
86
97
|
rmSync(keyFilePath(), { force: true });
|
|
87
98
|
};
|
|
88
99
|
|
|
89
|
-
const messageOf = (error:
|
|
100
|
+
const messageOf = <E>(error: E) =>
|
|
90
101
|
redactSecrets(error instanceof Error ? error.message : String(error));
|
|
91
102
|
|
|
92
103
|
/** Resolves the TypeSafe API key: Bun.secrets → env var → the extension's
|
|
@@ -123,7 +134,7 @@ export const resolveTypeSafeKey = async (
|
|
|
123
134
|
}
|
|
124
135
|
const config = (deps.readAdvisorJson ?? readAdvisorJsonConfig)();
|
|
125
136
|
const staged = config[TYPESAFE_KEY_CONFIG_FIELD];
|
|
126
|
-
if (
|
|
137
|
+
if (isString(staged)) {
|
|
127
138
|
const fromConfig = normalizeKey(staged);
|
|
128
139
|
if (fromConfig) {
|
|
129
140
|
return { key: fromConfig, source: "advisor-json" };
|
|
@@ -223,8 +234,12 @@ export const removeTypeSafeKeyFromAdvisorJson = (): JevKeyStoreResult => {
|
|
|
223
234
|
if (!(TYPESAFE_KEY_CONFIG_FIELD in existing)) {
|
|
224
235
|
return { message: "No plaintext key in advisor.json.", ok: true };
|
|
225
236
|
}
|
|
226
|
-
|
|
227
|
-
|
|
237
|
+
const retained = Object.fromEntries(
|
|
238
|
+
Object.entries(existing).filter(
|
|
239
|
+
([key]) => key !== TYPESAFE_KEY_CONFIG_FIELD
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
writeFileSync(path, `${JSON.stringify(retained, null, 2)}\n`);
|
|
228
243
|
resetConfigCache();
|
|
229
244
|
return { message: "Plaintext key removed from advisor.json.", ok: true };
|
|
230
245
|
} catch (error) {
|