pi-advisor-flow 0.7.0 → 0.8.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +19 -18
  2. package/README.md +9 -8
  3. package/dist/index.js +1390 -1068
  4. package/extensions/index.ts +2 -1
  5. package/package.json +48 -35
  6. package/src/attachments.ts +11 -10
  7. package/src/commands/activation-preparation.ts +1 -0
  8. package/src/commands/activation.ts +2 -0
  9. package/src/commands/lifecycle.ts +1 -0
  10. package/src/commands/manual-command.ts +7 -1
  11. package/src/commands/manual-consultation.ts +102 -92
  12. package/src/commands/manual-progress.ts +7 -5
  13. package/src/commands/model-commands.ts +1 -1
  14. package/src/commands/model-options.ts +14 -3
  15. package/src/commands/model-picker.ts +1 -0
  16. package/src/commands/registration.ts +1 -0
  17. package/src/commands/renderers.ts +67 -55
  18. package/src/commands/runtime.ts +16 -14
  19. package/src/commands/settings-commands.ts +10 -4
  20. package/src/commands/settings-persistence.ts +16 -2
  21. package/src/commands/types.ts +1 -0
  22. package/src/commands.ts +0 -1
  23. package/src/config/args.ts +1 -1
  24. package/src/config/defaults.ts +8 -1
  25. package/src/config/schema.ts +42 -17
  26. package/src/config/state.ts +12 -3
  27. package/src/config/storage.ts +34 -23
  28. package/src/config/types.ts +16 -3
  29. package/src/config/validation.ts +33 -21
  30. package/src/config.ts +3 -0
  31. package/src/content-utils.ts +24 -4
  32. package/src/conversation.ts +16 -15
  33. package/src/git.ts +10 -3
  34. package/src/herdr-block.ts +83 -0
  35. package/src/herdr-shared.ts +108 -0
  36. package/src/herdr.ts +30 -192
  37. package/src/jev/client.ts +75 -85
  38. package/src/jev/failure.ts +19 -0
  39. package/src/jev/key-store.ts +26 -11
  40. package/src/jev/ledger.ts +58 -64
  41. package/src/jev/questions.ts +26 -23
  42. package/src/jev/state.ts +15 -12
  43. package/src/jev/transport.ts +8 -3
  44. package/src/model-stream.ts +35 -37
  45. package/src/outcomes.ts +15 -11
  46. package/src/pi-settings.ts +4 -9
  47. package/src/preferences.ts +4 -2
  48. package/src/redaction.ts +10 -12
  49. package/src/scout-context.ts +59 -60
  50. package/src/scout-curation.ts +4 -13
  51. package/src/scout-groups.ts +178 -175
  52. package/src/scout-protocol.ts +74 -70
  53. package/src/scout-reconstruct.ts +1 -1
  54. package/src/scout.ts +115 -73
  55. package/src/session-state.ts +64 -49
  56. package/src/tool-result-cap.ts +18 -17
  57. package/src/tools/consult-context.ts +7 -15
  58. package/src/tools/consultation.ts +14 -4
  59. package/src/tools/gate-policy.ts +1 -0
  60. package/src/tools/gate-protocol.ts +36 -23
  61. package/src/tools/jev-filter.ts +43 -41
  62. package/src/tools/jev-turn-gate.ts +29 -9
  63. package/src/tools/loop-gate.ts +31 -20
  64. package/src/tools/model-access.ts +46 -0
  65. package/src/tools/prompts.ts +20 -25
  66. package/src/tools/register-ask-advisor.ts +36 -16
  67. package/src/tools/register-lifecycle.ts +22 -0
  68. package/src/tools/register-outcome.ts +2 -0
  69. package/src/tools/register-renderers.ts +146 -128
  70. package/src/tools/registration.ts +12 -14
  71. package/src/tools/render-advisor-result.ts +35 -22
  72. package/src/tools/render-common.ts +16 -19
  73. package/src/tools/scout-status.ts +42 -27
  74. package/src/tools/types.ts +1 -0
  75. package/src/tools.ts +0 -1
  76. package/src/ui/jev-setup-submenu.ts +65 -59
  77. package/src/ui/manual-dialog-render.ts +1 -0
  78. package/src/ui/manual-dialog.ts +39 -43
  79. package/src/ui/masked-input.ts +5 -11
  80. package/src/ui/model-multi-selector.ts +9 -0
  81. package/src/ui/model-selector-adapter.ts +31 -0
  82. package/src/ui/model-selector.ts +14 -161
  83. package/src/ui/searchable-model-list.ts +230 -0
  84. package/src/ui/settings-formatting.ts +24 -14
  85. package/src/ui/settings-items.ts +38 -1
  86. package/src/ui/settings-list-adapter.ts +15 -12
  87. package/src/ui/settings-mutations.ts +157 -70
  88. package/src/ui/settings-selector.ts +18 -9
  89. package/src/ui/text-setting-submenu.ts +3 -6
  90. package/src/ui/types.ts +14 -0
  91. package/src/ui.ts +0 -1
  92. 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 { redactSecrets } from "./redaction.ts";
4
-
5
- // Keep the JSON-RPC method components separate from Socket's URL-string heuristic.
6
- const HERDR_NOTIFICATION_METHOD = "notification.show";
7
-
8
- const SOURCE = "pi-advisor:advisor-activity";
9
- const BLOCK_SOURCE = "pi-advisor:advisor-block";
10
- const NOTIFICATION_SOURCE = "pi-advisor:advisor-notification";
11
- const HERDR_PI_SOURCE = "herdr:pi";
12
- let sequence = Date.now() * 1000;
13
- const nextSequence = () => {
14
- sequence += 1;
15
- return sequence;
16
- };
17
-
18
- export interface HerdrMetadataRequest {
19
- id: string;
20
- method: "pane.report_metadata";
21
- params: {
22
- pane_id: string;
23
- source: string;
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
- body: cleanNotification(body, 240),
80
- position: "top-left",
81
- sound: "request",
82
- title: cleanNotification(title, 80),
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(this.request(clear));
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
- | "auth"
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: Record<string, unknown>;
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
- function range(from: number, to: number): number[] {
59
- return Array.from({ length: to - from + 1 }, (_, index) => from + index);
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: unknown): string => {
66
- if (typeof error === "string") {
54
+ const errorDetail = (error: JsonValue | undefined): string => {
55
+ if (isString(error)) {
67
56
  return error;
68
57
  }
69
- if (error && typeof error === "object" && "message" in error) {
70
- return String((error as { message: unknown }).message);
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?: Record<string, unknown>;
90
- failure?: JevFailure;
81
+ answers?: RecordValue;
82
+ failure?: JevFailureError;
91
83
  model?: string;
92
84
  retryable?: boolean;
93
- usage?: { input_tokens?: unknown; output_tokens?: unknown };
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
- // biome-ignore lint/performance/noAwaitInLoops: the retry loop is bounded to one backoff by the deadline controller.
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 this.#backoff(deadline.signal);
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 JevFailure) {
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 JevFailure(
190
+ ? new JevFailureError(
171
191
  "timeout",
172
192
  `Jev call exceeded its ${this.#timeoutMs} ms wall-time budget.`
173
193
  )
174
- : new JevFailure("error", message);
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 JevFailure(
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 JevFailure("error", "Jev call failed.");
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
- ...this.#connectionFailure(error),
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
- #connectionFailure(error: unknown): { failure: JevFailure } {
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 as { error?: unknown } | null)?.error;
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 JevFailure(statusCategory(response.status), message);
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 JevFailure(
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
- const record = parsed as {
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 JevFailure(
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
- answers: record.answers as Record<string, unknown>,
313
- model: typeof record.model === "string" ? record.model : this.#model,
314
- usage: record.usage as AttemptOutcome["usage"],
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.usage as
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 };
@@ -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<unknown>;
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?: Record<string, string | undefined>;
41
- readAdvisorJson?: () => Record<string, unknown>;
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
- const runtimeSecrets = (): JevSecretsLike | undefined =>
59
- (globalThis as { Bun?: { secrets?: JevSecretsLike } }).Bun?.secrets;
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 = (): Record<string, unknown> =>
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(), "utf8"));
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: unknown) =>
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 (typeof staged === "string") {
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
- delete existing[TYPESAFE_KEY_CONFIG_FIELD];
227
- writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`);
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) {