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,348 @@
|
|
|
1
|
+
import type { EntryType, Fetch, Questions } from "@typesafe-ai/sdk";
|
|
2
|
+
import {
|
|
3
|
+
advisorJevModelRef,
|
|
4
|
+
advisorJevPricePerMtokRef,
|
|
5
|
+
advisorJevTimeoutMsRef,
|
|
6
|
+
} from "../config/state.ts";
|
|
7
|
+
import { redactSecrets } from "../redaction.ts";
|
|
8
|
+
import type { JevCredentials, JevTransportKind } from "./transport.ts";
|
|
9
|
+
|
|
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
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface JevUsage {
|
|
29
|
+
cost: number;
|
|
30
|
+
inputTokens: number;
|
|
31
|
+
outputTokens: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface JevAskResult {
|
|
35
|
+
answers: Record<string, unknown>;
|
|
36
|
+
model: string;
|
|
37
|
+
usage: JevUsage;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface JevClientOptions {
|
|
41
|
+
apiKey: string;
|
|
42
|
+
fetch?: Fetch;
|
|
43
|
+
model: string;
|
|
44
|
+
pricePerMtok?: number;
|
|
45
|
+
timeoutMs: number;
|
|
46
|
+
transport: JevTransportKind;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ENDPOINTS: Record<JevTransportKind, string> = {
|
|
50
|
+
openrouter: "https://openrouter.ai/api/alpha/decisions",
|
|
51
|
+
typesafe: "https://api.typesafe.ai/v1/systemone",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const RETRYABLE_STATUSES = new Set([408, 429, ...range(500, 599)]);
|
|
55
|
+
const RETRY_BACKOFF_MS = 250;
|
|
56
|
+
const MAX_ATTEMPTS = 2;
|
|
57
|
+
|
|
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;
|
|
64
|
+
|
|
65
|
+
const errorDetail = (error: unknown): string => {
|
|
66
|
+
if (typeof error === "string") {
|
|
67
|
+
return error;
|
|
68
|
+
}
|
|
69
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
70
|
+
return String((error as { message: unknown }).message);
|
|
71
|
+
}
|
|
72
|
+
return "";
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const statusCategory = (status: number): JevErrorCategory => {
|
|
76
|
+
if (status === 401 || status === 403) {
|
|
77
|
+
return "auth";
|
|
78
|
+
}
|
|
79
|
+
if (status === 400 || status === 422) {
|
|
80
|
+
return "malformed";
|
|
81
|
+
}
|
|
82
|
+
return "error";
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const openRouterModelId = (model: string) =>
|
|
86
|
+
model.includes("/") ? model : `~typesafe/${model}`;
|
|
87
|
+
|
|
88
|
+
interface AttemptOutcome {
|
|
89
|
+
answers?: Record<string, unknown>;
|
|
90
|
+
failure?: JevFailure;
|
|
91
|
+
model?: string;
|
|
92
|
+
retryable?: boolean;
|
|
93
|
+
usage?: { input_tokens?: unknown; output_tokens?: unknown };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One systemone client for both transports with a total wall deadline so
|
|
97
|
+
* retries can never stall a tool call; errors are classified and redacted on
|
|
98
|
+
* every path. */
|
|
99
|
+
export class JevClient {
|
|
100
|
+
readonly #apiKey: string;
|
|
101
|
+
readonly #endpoint: string;
|
|
102
|
+
readonly #fetch: Fetch;
|
|
103
|
+
readonly #model: string;
|
|
104
|
+
readonly #pricePerMtok: number | undefined;
|
|
105
|
+
readonly #timeoutMs: number;
|
|
106
|
+
|
|
107
|
+
constructor({
|
|
108
|
+
apiKey,
|
|
109
|
+
fetch,
|
|
110
|
+
model,
|
|
111
|
+
pricePerMtok,
|
|
112
|
+
timeoutMs,
|
|
113
|
+
transport,
|
|
114
|
+
}: JevClientOptions) {
|
|
115
|
+
this.#apiKey = apiKey;
|
|
116
|
+
this.#endpoint = ENDPOINTS[transport];
|
|
117
|
+
this.#fetch = fetch ?? (globalThis.fetch.bind(globalThis) as Fetch);
|
|
118
|
+
this.#model = transport === "openrouter" ? openRouterModelId(model) : model;
|
|
119
|
+
this.#pricePerMtok = pricePerMtok;
|
|
120
|
+
this.#timeoutMs = timeoutMs;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async ask(
|
|
124
|
+
state: EntryType,
|
|
125
|
+
questions: Questions,
|
|
126
|
+
signal?: AbortSignal
|
|
127
|
+
): Promise<JevAskResult> {
|
|
128
|
+
const deadline = new AbortController();
|
|
129
|
+
const abortFromCaller = () => deadline.abort(signal?.reason);
|
|
130
|
+
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
131
|
+
if (signal?.aborted) {
|
|
132
|
+
abortFromCaller();
|
|
133
|
+
}
|
|
134
|
+
let deadlineHit = false;
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
deadlineHit = true;
|
|
137
|
+
deadline.abort(new Error("Jev wall-time budget elapsed"));
|
|
138
|
+
}, this.#timeoutMs);
|
|
139
|
+
timer.unref?.();
|
|
140
|
+
const body = JSON.stringify({
|
|
141
|
+
model: this.#model,
|
|
142
|
+
questions,
|
|
143
|
+
state,
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
let outcome: AttemptOutcome = {};
|
|
147
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
148
|
+
// biome-ignore lint/performance/noAwaitInLoops: the retry loop is bounded to one backoff by the deadline controller.
|
|
149
|
+
outcome = await this.#attempt(body, deadline.signal);
|
|
150
|
+
if (!outcome.retryable || attempt >= MAX_ATTEMPTS) {
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
await this.#backoff(deadline.signal);
|
|
154
|
+
if (deadline.signal.aborted) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return this.#settle(outcome, deadlineHit, signal);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (signal?.aborted && !deadlineHit) {
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
if (error instanceof JevFailure) {
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
const message = redactSecrets(
|
|
167
|
+
error instanceof Error ? error.message : String(error)
|
|
168
|
+
);
|
|
169
|
+
throw deadlineHit
|
|
170
|
+
? new JevFailure(
|
|
171
|
+
"timeout",
|
|
172
|
+
`Jev call exceeded its ${this.#timeoutMs} ms wall-time budget.`
|
|
173
|
+
)
|
|
174
|
+
: new JevFailure("error", message);
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
signal?.removeEventListener("abort", abortFromCaller);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#settle(
|
|
182
|
+
outcome: AttemptOutcome,
|
|
183
|
+
deadlineHit: boolean,
|
|
184
|
+
signal: AbortSignal | undefined
|
|
185
|
+
): JevAskResult {
|
|
186
|
+
if (outcome.answers) {
|
|
187
|
+
return this.#result(outcome);
|
|
188
|
+
}
|
|
189
|
+
if (deadlineHit && outcome.failure?.category !== "auth") {
|
|
190
|
+
throw new JevFailure(
|
|
191
|
+
"timeout",
|
|
192
|
+
`Jev call exceeded its ${this.#timeoutMs} ms wall-time budget.`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (outcome.failure) {
|
|
196
|
+
throw outcome.failure;
|
|
197
|
+
}
|
|
198
|
+
if (signal?.aborted) {
|
|
199
|
+
throw new Error("Jev call aborted by the caller.");
|
|
200
|
+
}
|
|
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
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async #attempt(body: string, signal: AbortSignal): Promise<AttemptOutcome> {
|
|
220
|
+
if (signal.aborted) {
|
|
221
|
+
return { retryable: false };
|
|
222
|
+
}
|
|
223
|
+
let response: Response;
|
|
224
|
+
try {
|
|
225
|
+
response = await this.#fetch(this.#endpoint, {
|
|
226
|
+
body,
|
|
227
|
+
headers: {
|
|
228
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
229
|
+
"content-type": "application/json",
|
|
230
|
+
},
|
|
231
|
+
method: "POST",
|
|
232
|
+
signal,
|
|
233
|
+
});
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (signal.aborted) {
|
|
236
|
+
return { retryable: false };
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
retryable: true,
|
|
240
|
+
...this.#connectionFailure(error),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (response.ok) {
|
|
244
|
+
return this.#parseSuccess(response);
|
|
245
|
+
}
|
|
246
|
+
const failure = await this.#failureFromStatus(response);
|
|
247
|
+
return {
|
|
248
|
+
failure,
|
|
249
|
+
retryable: RETRYABLE_STATUSES.has(response.status),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
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> {
|
|
263
|
+
let detail = "";
|
|
264
|
+
try {
|
|
265
|
+
const parsed: unknown = await response.json();
|
|
266
|
+
const error = (parsed as { error?: unknown } | null)?.error;
|
|
267
|
+
detail = errorDetail(error);
|
|
268
|
+
} catch {
|
|
269
|
+
detail = "";
|
|
270
|
+
}
|
|
271
|
+
const message = redactSecrets(
|
|
272
|
+
`Jev ${this.#transportLabel()} request failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}.`
|
|
273
|
+
);
|
|
274
|
+
return new JevFailure(statusCategory(response.status), message);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#transportLabel(): string {
|
|
278
|
+
return this.#endpoint === ENDPOINTS.openrouter ? "OpenRouter" : "TypeSafe";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async #parseSuccess(response: Response): Promise<AttemptOutcome> {
|
|
282
|
+
let parsed: unknown;
|
|
283
|
+
try {
|
|
284
|
+
parsed = await response.json();
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return {
|
|
287
|
+
failure: new JevFailure(
|
|
288
|
+
"malformed",
|
|
289
|
+
`Jev response was not JSON: ${redactSecrets(error instanceof Error ? error.message : String(error))}`
|
|
290
|
+
),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
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
|
+
) {
|
|
304
|
+
return {
|
|
305
|
+
failure: new JevFailure(
|
|
306
|
+
"malformed",
|
|
307
|
+
"Jev response did not include an answers object."
|
|
308
|
+
),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
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"],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#result(outcome: AttemptOutcome): JevAskResult {
|
|
319
|
+
const usage = outcome.usage as
|
|
320
|
+
| { input_tokens?: unknown; output_tokens?: unknown }
|
|
321
|
+
| undefined;
|
|
322
|
+
const inputTokens = finiteTokens(usage?.input_tokens);
|
|
323
|
+
const outputTokens = finiteTokens(usage?.output_tokens);
|
|
324
|
+
const price = this.#pricePerMtok ?? advisorJevPricePerMtokRef;
|
|
325
|
+
return {
|
|
326
|
+
answers: outcome.answers ?? {},
|
|
327
|
+
model: outcome.model ?? this.#model,
|
|
328
|
+
usage: {
|
|
329
|
+
cost: (inputTokens / 1_000_000) * price,
|
|
330
|
+
inputTokens,
|
|
331
|
+
outputTokens,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** A client wired from resolved credentials; reads the live Jev settings per call. */
|
|
338
|
+
export const jevClientFromCredentials = (
|
|
339
|
+
credentials: JevCredentials,
|
|
340
|
+
fetch?: Fetch
|
|
341
|
+
): JevClient =>
|
|
342
|
+
new JevClient({
|
|
343
|
+
apiKey: credentials.apiKey,
|
|
344
|
+
fetch,
|
|
345
|
+
model: advisorJevModelRef,
|
|
346
|
+
timeoutMs: advisorJevTimeoutMsRef,
|
|
347
|
+
transport: credentials.transport,
|
|
348
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { readExistingConfig, resetConfigCache } from "../config/storage.ts";
|
|
11
|
+
import { redactSecrets } from "../redaction.ts";
|
|
12
|
+
|
|
13
|
+
export type JevKeySource = "bun-secrets" | "env" | "file" | "advisor-json";
|
|
14
|
+
|
|
15
|
+
export interface JevKeyResolution {
|
|
16
|
+
key?: string;
|
|
17
|
+
/** Present whenever a key resolved. */
|
|
18
|
+
source?: JevKeySource;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface JevKeyStoreResult {
|
|
22
|
+
message: string;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JevSecretEntry {
|
|
27
|
+
name: string;
|
|
28
|
+
service: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface JevSecretsLike {
|
|
32
|
+
delete: (options: JevSecretEntry) => Promise<unknown>;
|
|
33
|
+
get: (options: JevSecretEntry) => Promise<string | null | undefined>;
|
|
34
|
+
set: (options: JevSecretEntry & { value: string }) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface JevKeyStoreDeps {
|
|
38
|
+
/** Deletes the extension-managed key file; injectable for tests. */
|
|
39
|
+
deleteFileStore?: () => void;
|
|
40
|
+
env?: Record<string, string | undefined>;
|
|
41
|
+
readAdvisorJson?: () => Record<string, unknown>;
|
|
42
|
+
/** Reads the extension-managed 0600 key file; injectable for tests. */
|
|
43
|
+
readFileStore?: () => string | undefined;
|
|
44
|
+
/** Inject `null` to simulate a runtime without a secret store. */
|
|
45
|
+
secrets?: JevSecretsLike | null;
|
|
46
|
+
writeFileStore?: (key: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const TYPESAFE_KEY_ENV_VAR = "TYPESAFE_API_KEY";
|
|
50
|
+
export const TYPESAFE_KEY_SERVICE = "pi-advisor";
|
|
51
|
+
export const TYPESAFE_KEY_NAME = "typesafe-api-key";
|
|
52
|
+
export const TYPESAFE_KEY_CONFIG_FIELD = "typesafe_api_key";
|
|
53
|
+
|
|
54
|
+
const KEY_FILE_MODE = 0o600;
|
|
55
|
+
|
|
56
|
+
const keyFilePath = () => join(getAgentDir(), "typesafe_api_key");
|
|
57
|
+
|
|
58
|
+
const runtimeSecrets = (): JevSecretsLike | undefined =>
|
|
59
|
+
(globalThis as { Bun?: { secrets?: JevSecretsLike } }).Bun?.secrets;
|
|
60
|
+
|
|
61
|
+
/** Whether the current runtime offers a Bun.secrets store. */
|
|
62
|
+
export const hasRuntimeSecretStore = () => runtimeSecrets() !== undefined;
|
|
63
|
+
|
|
64
|
+
const normalizeKey = (value: string | null | undefined): string | undefined =>
|
|
65
|
+
value?.trim() || undefined;
|
|
66
|
+
|
|
67
|
+
const readAdvisorJsonConfig = (): Record<string, unknown> =>
|
|
68
|
+
readExistingConfig(join(getAgentDir(), "advisor.json"));
|
|
69
|
+
|
|
70
|
+
const defaultReadFileStore = (): string | undefined => {
|
|
71
|
+
try {
|
|
72
|
+
return normalizeKey(readFileSync(keyFilePath(), "utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const defaultWriteFileStore = (key: string) => {
|
|
79
|
+
const path = keyFilePath();
|
|
80
|
+
writeFileSync(path, `${key}\n`, { mode: KEY_FILE_MODE });
|
|
81
|
+
// writeFileSync's mode only applies at creation; re-assert it on every write.
|
|
82
|
+
chmodSync(path, KEY_FILE_MODE);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const defaultDeleteFileStore = () => {
|
|
86
|
+
rmSync(keyFilePath(), { force: true });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const messageOf = (error: unknown) =>
|
|
90
|
+
redactSecrets(error instanceof Error ? error.message : String(error));
|
|
91
|
+
|
|
92
|
+
/** Resolves the TypeSafe API key: Bun.secrets → env var → the extension's
|
|
93
|
+
* 0600 key file → a hand-placed advisor.json string (read-only). Never
|
|
94
|
+
* throws; no key means undefined. */
|
|
95
|
+
export const resolveTypeSafeKey = async (
|
|
96
|
+
deps: JevKeyStoreDeps = {}
|
|
97
|
+
): Promise<JevKeyResolution> => {
|
|
98
|
+
const secrets = deps.secrets === undefined ? runtimeSecrets() : deps.secrets;
|
|
99
|
+
if (secrets) {
|
|
100
|
+
try {
|
|
101
|
+
const stored = normalizeKey(
|
|
102
|
+
await secrets.get({
|
|
103
|
+
name: TYPESAFE_KEY_NAME,
|
|
104
|
+
service: TYPESAFE_KEY_SERVICE,
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
if (stored) {
|
|
108
|
+
return { key: stored, source: "bun-secrets" };
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// An unavailable secret store falls through to the next source.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const env = deps.env ?? process.env;
|
|
115
|
+
const fromEnv = normalizeKey(env[TYPESAFE_KEY_ENV_VAR]);
|
|
116
|
+
if (fromEnv) {
|
|
117
|
+
return { key: fromEnv, source: "env" };
|
|
118
|
+
}
|
|
119
|
+
const readFileStore = deps.readFileStore ?? defaultReadFileStore;
|
|
120
|
+
const fromFile = normalizeKey(readFileStore());
|
|
121
|
+
if (fromFile) {
|
|
122
|
+
return { key: fromFile, source: "file" };
|
|
123
|
+
}
|
|
124
|
+
const config = (deps.readAdvisorJson ?? readAdvisorJsonConfig)();
|
|
125
|
+
const staged = config[TYPESAFE_KEY_CONFIG_FIELD];
|
|
126
|
+
if (typeof staged === "string") {
|
|
127
|
+
const fromConfig = normalizeKey(staged);
|
|
128
|
+
if (fromConfig) {
|
|
129
|
+
return { key: fromConfig, source: "advisor-json" };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Stores the key securely: Bun.secrets when the runtime provides it,
|
|
136
|
+
* otherwise a dedicated 0600-mode file in the Pi agent directory. The key is
|
|
137
|
+
* never written to advisor.json. */
|
|
138
|
+
export const writeKeyTypeSafeKey = async (
|
|
139
|
+
key: string,
|
|
140
|
+
deps: JevKeyStoreDeps = {}
|
|
141
|
+
): Promise<JevKeyStoreResult> => {
|
|
142
|
+
const normalized = normalizeKey(key);
|
|
143
|
+
if (!normalized) {
|
|
144
|
+
return { message: "The key is empty.", ok: false };
|
|
145
|
+
}
|
|
146
|
+
const secrets = deps.secrets === undefined ? runtimeSecrets() : deps.secrets;
|
|
147
|
+
if (secrets) {
|
|
148
|
+
try {
|
|
149
|
+
await secrets.set({
|
|
150
|
+
name: TYPESAFE_KEY_NAME,
|
|
151
|
+
service: TYPESAFE_KEY_SERVICE,
|
|
152
|
+
value: normalized,
|
|
153
|
+
});
|
|
154
|
+
return { message: "Key stored in Bun.secrets.", ok: true };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return {
|
|
157
|
+
message: `Storing the key in Bun.secrets failed: ${messageOf(error)}. Alternatively set the ${TYPESAFE_KEY_ENV_VAR} environment variable in your shell profile.`,
|
|
158
|
+
ok: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
(deps.writeFileStore ?? defaultWriteFileStore)(normalized);
|
|
164
|
+
return {
|
|
165
|
+
message: "Key stored in ~/.pi/agent/typesafe_api_key (mode 0600).",
|
|
166
|
+
ok: true,
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return {
|
|
170
|
+
message: `Storing the key failed: ${messageOf(error)}. Alternatively set the ${TYPESAFE_KEY_ENV_VAR} environment variable in your shell profile.`,
|
|
171
|
+
ok: false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Removes every stored key (Bun.secrets entry and the 0600 file); never
|
|
177
|
+
* touches env vars or the hand-placed advisor.json entry. */
|
|
178
|
+
export const clearKeyTypeSafeKey = async (
|
|
179
|
+
deps: JevKeyStoreDeps = {}
|
|
180
|
+
): Promise<JevKeyStoreResult> => {
|
|
181
|
+
let clearedSomething = false;
|
|
182
|
+
let firstError: string | undefined;
|
|
183
|
+
const secrets = deps.secrets === undefined ? runtimeSecrets() : deps.secrets;
|
|
184
|
+
if (secrets) {
|
|
185
|
+
try {
|
|
186
|
+
await secrets.delete({
|
|
187
|
+
name: TYPESAFE_KEY_NAME,
|
|
188
|
+
service: TYPESAFE_KEY_SERVICE,
|
|
189
|
+
});
|
|
190
|
+
clearedSomething = true;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
firstError = messageOf(error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
if (deps.deleteFileStore) {
|
|
197
|
+
deps.deleteFileStore();
|
|
198
|
+
} else if (existsSync(keyFilePath())) {
|
|
199
|
+
defaultDeleteFileStore();
|
|
200
|
+
}
|
|
201
|
+
clearedSomething = true;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
firstError ??= messageOf(error);
|
|
204
|
+
}
|
|
205
|
+
if (firstError) {
|
|
206
|
+
return {
|
|
207
|
+
message: `Clearing the stored key failed: ${firstError}.`,
|
|
208
|
+
ok: false,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
message: `Stored key cleared.${clearedSomething ? "" : ` Nothing was stored; unset ${TYPESAFE_KEY_ENV_VAR} and remove ${TYPESAFE_KEY_CONFIG_FIELD} from advisor.json yourself if you use them.`}`,
|
|
213
|
+
ok: true,
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** Removes a hand-placed advisor.json key after a successful store migration.
|
|
218
|
+
* Unknown keys and all config settings are preserved verbatim. */
|
|
219
|
+
export const removeTypeSafeKeyFromAdvisorJson = (): JevKeyStoreResult => {
|
|
220
|
+
try {
|
|
221
|
+
const path = join(getAgentDir(), "advisor.json");
|
|
222
|
+
const existing = readExistingConfig(path);
|
|
223
|
+
if (!(TYPESAFE_KEY_CONFIG_FIELD in existing)) {
|
|
224
|
+
return { message: "No plaintext key in advisor.json.", ok: true };
|
|
225
|
+
}
|
|
226
|
+
delete existing[TYPESAFE_KEY_CONFIG_FIELD];
|
|
227
|
+
writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`);
|
|
228
|
+
resetConfigCache();
|
|
229
|
+
return { message: "Plaintext key removed from advisor.json.", ok: true };
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return {
|
|
232
|
+
message: `Removing the plaintext key failed: ${messageOf(error)}.`,
|
|
233
|
+
ok: false,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
let warnedPlaintextKey = false;
|
|
239
|
+
|
|
240
|
+
/** Returns the plaintext-key warning once so callers can notify without spam. */
|
|
241
|
+
export const consumePlaintextKeyWarning = (): string | undefined => {
|
|
242
|
+
if (warnedPlaintextKey) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
warnedPlaintextKey = true;
|
|
246
|
+
return `Advisor is using a plaintext ${TYPESAFE_KEY_CONFIG_FIELD} from advisor.json; this is not recommended. Open /advisor-settings → Jev consultation filter to migrate it into a secure store, or use the ${TYPESAFE_KEY_ENV_VAR} environment variable.`;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** Test-only: re-arms the one-time plaintext warning. */
|
|
250
|
+
export const resetPlaintextKeyWarning = () => {
|
|
251
|
+
warnedPlaintextKey = false;
|
|
252
|
+
};
|