pi-advisor-flow 0.5.6 → 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 +29 -0
- package/README.md +1 -0
- package/dist/index.js +3160 -1255
- package/extensions/index.ts +9 -9
- package/package.json +12 -8
- package/src/{untracked.ts → attachments.ts} +2 -5
- package/src/commands/activation-preparation.ts +6 -6
- package/src/commands/activation.ts +7 -7
- package/src/commands/lifecycle.ts +5 -5
- package/src/commands/manual-command.ts +13 -9
- package/src/commands/manual-consultation.ts +24 -10
- package/src/commands/manual-progress.ts +3 -3
- package/src/commands/model-commands.ts +6 -6
- package/src/commands/model-options.ts +4 -4
- package/src/commands/model-picker.ts +2 -2
- package/src/commands/registration.ts +8 -8
- package/src/commands/renderers.ts +5 -5
- package/src/commands/runtime.ts +8 -8
- package/src/commands/settings-commands.ts +8 -8
- package/src/commands/settings-persistence.ts +52 -4
- package/src/commands/types.ts +6 -13
- package/src/commands.ts +2 -2
- package/src/config/args.ts +3 -3
- package/src/config/defaults.ts +78 -3
- package/src/config/schema.ts +434 -0
- package/src/config/state.ts +71 -2
- package/src/config/storage.ts +27 -119
- package/src/config/types.ts +30 -1
- package/src/config/validation.ts +61 -181
- package/src/config.ts +7 -7
- package/src/content-utils.ts +30 -0
- package/src/conversation.ts +13 -168
- package/src/git.ts +4 -18
- package/src/herdr.ts +6 -8
- 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/model-stream.ts +5 -8
- package/src/outcomes.ts +3 -2
- package/src/pi-settings.ts +59 -0
- package/src/preferences.ts +2 -2
- package/src/redaction.ts +50 -0
- package/src/scout-context.ts +121 -485
- package/src/scout-curation.ts +84 -0
- package/src/scout-groups.ts +485 -0
- package/src/scout-protocol.ts +122 -0
- package/src/scout-reconstruct.ts +44 -0
- package/src/scout-types.ts +66 -0
- package/src/scout.ts +53 -63
- package/src/session-state.ts +215 -78
- package/src/tool-result-cap.ts +88 -0
- package/src/tools/consult-context.ts +155 -0
- package/src/tools/consultation.ts +71 -220
- package/src/tools/gate-policy.ts +5 -5
- package/src/tools/gate-protocol.ts +2 -2
- package/src/tools/jev-filter.ts +202 -0
- package/src/tools/jev-turn-gate.ts +183 -0
- package/src/tools/loop-gate.ts +71 -61
- package/src/tools/outage-notifier.ts +30 -0
- package/src/tools/prompts.ts +4 -7
- package/src/tools/register-ask-advisor.ts +46 -15
- package/src/tools/register-lifecycle.ts +7 -7
- package/src/tools/register-outcome.ts +5 -5
- package/src/tools/register-renderers.ts +63 -5
- package/src/tools/registration.ts +27 -10
- package/src/tools/render-advisor-result.ts +54 -22
- package/src/tools/render-common.ts +25 -13
- package/src/tools/scout-status.ts +5 -5
- package/src/tools/session.ts +1 -1
- package/src/tools/types.ts +17 -7
- package/src/tools.ts +9 -9
- package/src/ui/jev-setup-submenu.ts +344 -0
- package/src/ui/manual-dialog-render.ts +2 -2
- package/src/ui/manual-dialog.ts +4 -9
- package/src/ui/masked-input.ts +68 -0
- package/src/ui/model-selector.ts +1 -1
- package/src/ui/settings-formatting.ts +2 -2
- package/src/ui/settings-items.ts +166 -5
- package/src/ui/settings-mutations.ts +37 -2
- package/src/ui/settings-selector.ts +5 -5
- package/src/ui/text-setting-submenu.ts +1 -1
- package/src/ui/types.ts +13 -2
- package/src/ui.ts +4 -4
- package/src/usage.ts +4 -7
package/src/conversation.ts
CHANGED
|
@@ -4,24 +4,11 @@ import {
|
|
|
4
4
|
advisorToolPoliciesRef,
|
|
5
5
|
advisorToolResultMaxBytesRef,
|
|
6
6
|
advisorToolResultMaxLinesRef,
|
|
7
|
-
} from "./config/state.
|
|
8
|
-
import type { AdvisorToolPolicies } from "./config/types.
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "./config/types.js";
|
|
13
|
-
|
|
14
|
-
type RecordValue = Record<string, unknown>;
|
|
15
|
-
|
|
16
|
-
const isRecord = (value: unknown): value is RecordValue =>
|
|
17
|
-
Boolean(value) && typeof value === "object";
|
|
18
|
-
|
|
19
|
-
const contentParts = (content: unknown): unknown[] => {
|
|
20
|
-
if (typeof content === "string") {
|
|
21
|
-
return [content];
|
|
22
|
-
}
|
|
23
|
-
return Array.isArray(content) ? content : [];
|
|
24
|
-
};
|
|
7
|
+
} from "./config/state.ts";
|
|
8
|
+
import type { AdvisorToolPolicies } from "./config/types.ts";
|
|
9
|
+
import { contentParts, isRecord, type RecordValue } from "./content-utils.ts";
|
|
10
|
+
import { redactSecrets } from "./redaction.ts";
|
|
11
|
+
import { capToolResult } from "./tool-result-cap.ts";
|
|
25
12
|
|
|
26
13
|
const textFromPart = (part: unknown): string => {
|
|
27
14
|
if (typeof part === "string") {
|
|
@@ -36,153 +23,6 @@ const textFromPart = (part: unknown): string => {
|
|
|
36
23
|
export const textFrom = (content: unknown): string =>
|
|
37
24
|
contentParts(content).map(textFromPart).join("\n").trim();
|
|
38
25
|
|
|
39
|
-
const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
|
|
40
|
-
|
|
41
|
-
const REDACTION_MARKER = "[REDACTED SECRET]";
|
|
42
|
-
const PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/gi;
|
|
43
|
-
const PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/i;
|
|
44
|
-
const SECRET_PATTERNS = [
|
|
45
|
-
/-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/gi,
|
|
46
|
-
/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
47
|
-
/\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'&,;)}\]]+)/gi,
|
|
48
|
-
/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi,
|
|
49
|
-
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
|
50
|
-
/\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/gi,
|
|
51
|
-
] as const;
|
|
52
|
-
|
|
53
|
-
const redactUnterminatedPem = (value: string): string => {
|
|
54
|
-
const begins = [...value.matchAll(PEM_BEGIN_PATTERN)];
|
|
55
|
-
const lastBegin = begins.at(-1);
|
|
56
|
-
if (lastBegin?.index === undefined) {
|
|
57
|
-
return value;
|
|
58
|
-
}
|
|
59
|
-
const hasEnd = PEM_END_PATTERN.test(
|
|
60
|
-
value.slice(lastBegin.index + lastBegin[0].length)
|
|
61
|
-
);
|
|
62
|
-
return hasEnd
|
|
63
|
-
? value
|
|
64
|
-
: `${value.slice(0, lastBegin.index)}${REDACTION_MARKER}`;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/** Redacts common credential forms locally; it is not a data-classification system. */
|
|
68
|
-
export const redactSecrets = (value: string): string => {
|
|
69
|
-
let redacted = redactUnterminatedPem(value);
|
|
70
|
-
for (const pattern of SECRET_PATTERNS) {
|
|
71
|
-
redacted = redacted.replace(pattern, (_match, scheme) =>
|
|
72
|
-
typeof scheme === "string"
|
|
73
|
-
? `${scheme}${REDACTION_MARKER}@`
|
|
74
|
-
: REDACTION_MARKER
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
return redacted;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
/** Redacts before a byte-safe cap so no secret fragment survives truncation. */
|
|
81
|
-
export const redactAndCapText = (
|
|
82
|
-
value: string,
|
|
83
|
-
maxBytes: number,
|
|
84
|
-
redact = true
|
|
85
|
-
): string => {
|
|
86
|
-
const source = redact ? redactSecrets(value) : value;
|
|
87
|
-
let result = "";
|
|
88
|
-
for (const character of source) {
|
|
89
|
-
if (byteLength(result + character) > maxBytes) {
|
|
90
|
-
break;
|
|
91
|
-
}
|
|
92
|
-
result += character;
|
|
93
|
-
}
|
|
94
|
-
return result;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
export interface ToolResultTruncation {
|
|
98
|
-
content: string;
|
|
99
|
-
omittedLines: number;
|
|
100
|
-
totalBytes: number;
|
|
101
|
-
totalLines: number;
|
|
102
|
-
truncated: boolean;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export const capToolResult = (
|
|
106
|
-
value: string,
|
|
107
|
-
maxLines = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
|
|
108
|
-
maxBytes = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES
|
|
109
|
-
): ToolResultTruncation => {
|
|
110
|
-
const lines = value.split("\n");
|
|
111
|
-
const totalLines = lines.length;
|
|
112
|
-
const totalBytes = byteLength(value);
|
|
113
|
-
if ((maxLines === 0 || maxBytes === 0) && value.length > 0) {
|
|
114
|
-
return {
|
|
115
|
-
content: "[Tool result omitted: configured limit is zero]",
|
|
116
|
-
omittedLines: totalLines,
|
|
117
|
-
totalBytes,
|
|
118
|
-
totalLines,
|
|
119
|
-
truncated: true,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
|
123
|
-
return {
|
|
124
|
-
content: value,
|
|
125
|
-
omittedLines: 0,
|
|
126
|
-
totalBytes,
|
|
127
|
-
totalLines,
|
|
128
|
-
truncated: false,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const marker = "[... omitted tool-result section ...]";
|
|
133
|
-
const markerBytes = byteLength(marker);
|
|
134
|
-
if (maxBytes < markerBytes || maxLines === 1) {
|
|
135
|
-
const content = [...marker].reduce(
|
|
136
|
-
(result, character) =>
|
|
137
|
-
byteLength(result + character) <= maxBytes
|
|
138
|
-
? result + character
|
|
139
|
-
: result,
|
|
140
|
-
""
|
|
141
|
-
);
|
|
142
|
-
return {
|
|
143
|
-
content,
|
|
144
|
-
omittedLines: totalLines,
|
|
145
|
-
totalBytes,
|
|
146
|
-
totalLines,
|
|
147
|
-
truncated: true,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
const headCount = Math.floor((maxLines - 1) / 2);
|
|
151
|
-
const tailCount = maxLines - 1 - headCount;
|
|
152
|
-
const collect = (
|
|
153
|
-
candidates: string[],
|
|
154
|
-
maxEntries: number,
|
|
155
|
-
maxContentBytes: number
|
|
156
|
-
) => {
|
|
157
|
-
const selected: string[] = [];
|
|
158
|
-
let used = 0;
|
|
159
|
-
for (const line of candidates.slice(0, maxEntries)) {
|
|
160
|
-
const next = used + byteLength(line) + (selected.length ? 1 : 0);
|
|
161
|
-
if (next > maxContentBytes) {
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
164
|
-
selected.push(line);
|
|
165
|
-
used = next;
|
|
166
|
-
}
|
|
167
|
-
return selected;
|
|
168
|
-
};
|
|
169
|
-
const availableBytes = maxBytes - markerBytes - 2;
|
|
170
|
-
const head = collect(lines, headCount, Math.floor(availableBytes / 2));
|
|
171
|
-
const tail = collect(
|
|
172
|
-
lines.slice(Math.max(head.length, lines.length - tailCount)),
|
|
173
|
-
tailCount,
|
|
174
|
-
availableBytes - byteLength(head.join("\n"))
|
|
175
|
-
);
|
|
176
|
-
const content = [...head, marker, ...tail].join("\n");
|
|
177
|
-
return {
|
|
178
|
-
content,
|
|
179
|
-
omittedLines: Math.max(0, totalLines - head.length - tail.length),
|
|
180
|
-
totalBytes,
|
|
181
|
-
totalLines,
|
|
182
|
-
truncated: true,
|
|
183
|
-
};
|
|
184
|
-
};
|
|
185
|
-
|
|
186
26
|
const assistantEntry = (
|
|
187
27
|
message: RecordValue,
|
|
188
28
|
policies: AdvisorToolPolicies,
|
|
@@ -284,6 +124,9 @@ export const conversationEntry = (
|
|
|
284
124
|
}
|
|
285
125
|
};
|
|
286
126
|
|
|
127
|
+
const omissionMarker = (omitted: number) =>
|
|
128
|
+
`[Older context omitted: ${omitted} complete entr${omitted === 1 ? "y" : "ies"}]`;
|
|
129
|
+
|
|
287
130
|
const selectRecentEntries = (entries: string[], maxChars: number): string => {
|
|
288
131
|
const separator = "\n\n";
|
|
289
132
|
const joined = entries.join(separator);
|
|
@@ -304,19 +147,21 @@ const selectRecentEntries = (entries: string[], maxChars: number): string => {
|
|
|
304
147
|
const entry = entries[index];
|
|
305
148
|
const candidateCount = selected.length + 1;
|
|
306
149
|
const omitted = entries.length - candidateCount;
|
|
307
|
-
const marker = `[Older context omitted: ${omitted} complete entr${omitted === 1 ? "y" : "ies"}]`;
|
|
308
150
|
const candidateLength =
|
|
309
151
|
selectedLength +
|
|
310
152
|
entry.length +
|
|
311
153
|
(selected.length > 0 ? separator.length : 0);
|
|
312
|
-
if (
|
|
154
|
+
if (
|
|
155
|
+
omissionMarker(omitted).length + separator.length + candidateLength >
|
|
156
|
+
maxChars
|
|
157
|
+
) {
|
|
313
158
|
break;
|
|
314
159
|
}
|
|
315
160
|
selected.unshift(entry);
|
|
316
161
|
selectedLength = candidateLength;
|
|
317
162
|
}
|
|
318
163
|
const omitted = entries.length - Math.max(1, selected.length);
|
|
319
|
-
const marker =
|
|
164
|
+
const marker = omissionMarker(omitted);
|
|
320
165
|
if (selected.length > 0) {
|
|
321
166
|
return `${marker}${separator}${selected.join(separator)}`;
|
|
322
167
|
}
|
package/src/git.ts
CHANGED
|
@@ -16,17 +16,14 @@ const LEVEL_RANK: Record<GitContextLevel, number> = {
|
|
|
16
16
|
summary: 1,
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* The Executor may request no more repository context than the user configured.
|
|
21
|
-
* A model cannot widen its own disclosure allowance.
|
|
22
|
-
*/
|
|
19
|
+
/** The Executor may narrow but never widen the user's disclosure allowance. */
|
|
23
20
|
export const clampGitContextLevel = (
|
|
24
21
|
requested: GitContextLevel,
|
|
25
22
|
allowed: GitContextLevel
|
|
26
23
|
): GitContextLevel =>
|
|
27
24
|
LEVEL_RANK[requested] <= LEVEL_RANK[allowed] ? requested : allowed;
|
|
28
25
|
|
|
29
|
-
|
|
26
|
+
type GitContextStatus =
|
|
30
27
|
| "disabled"
|
|
31
28
|
| "no-changes"
|
|
32
29
|
| "collected"
|
|
@@ -77,11 +74,7 @@ const GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
|
77
74
|
|
|
78
75
|
export type GitRunner = (args: string[], cwd: string) => string;
|
|
79
76
|
|
|
80
|
-
/**
|
|
81
|
-
* Collection runs several git commands. A per-command timeout alone would let a
|
|
82
|
-
* pathological repository block for the sum of them, so the budget is shared:
|
|
83
|
-
* each command may use only the time remaining before the overall deadline.
|
|
84
|
-
*/
|
|
77
|
+
/** Shares one deadline across commands so a pathological repository cannot block for the sum of per-command timeouts. */
|
|
85
78
|
const deadlineRunner = (): GitRunner => {
|
|
86
79
|
const expiresAt = Date.now() + GIT_TOTAL_TIMEOUT_MS;
|
|
87
80
|
return (args, cwd) => {
|
|
@@ -112,14 +105,7 @@ const diffBase = (run: GitRunner, cwd: string): string => {
|
|
|
112
105
|
}
|
|
113
106
|
};
|
|
114
107
|
|
|
115
|
-
/**
|
|
116
|
-
* Collects working-tree changes relative to HEAD, covering staged and unstaged
|
|
117
|
-
* work. Untracked files are reported by name only and never by content.
|
|
118
|
-
*
|
|
119
|
-
* `summary` discloses file names, change status, and line counts. It must not
|
|
120
|
-
* include diff hunk headers: git derives those from surrounding file content,
|
|
121
|
-
* so they can reproduce a secret from a line the change never touched.
|
|
122
|
-
*/
|
|
108
|
+
/** Collects staged and unstaged changes vs HEAD; summary omits diff hunk headers because git derives them from untouched lines that can reproduce secrets. */
|
|
123
109
|
export const collectGitContext = (
|
|
124
110
|
cwd: string,
|
|
125
111
|
level: GitContextLevel,
|
package/src/herdr.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
|
-
import { getAdvisorSettings } from "./config/state.
|
|
3
|
-
import { redactSecrets } from "./
|
|
2
|
+
import { getAdvisorSettings } from "./config/state.ts";
|
|
3
|
+
import { redactSecrets } from "./redaction.ts";
|
|
4
4
|
|
|
5
5
|
// Keep the JSON-RPC method components separate from Socket's URL-string heuristic.
|
|
6
|
-
const HERDR_NOTIFICATION_METHOD =
|
|
6
|
+
const HERDR_NOTIFICATION_METHOD = "notification.show";
|
|
7
7
|
|
|
8
8
|
const SOURCE = "pi-advisor:advisor-activity";
|
|
9
9
|
const BLOCK_SOURCE = "pi-advisor:advisor-block";
|
|
@@ -38,13 +38,11 @@ export interface HerdrNotificationRequest {
|
|
|
38
38
|
sound: "request";
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
type HerdrRequest = HerdrMetadataRequest | HerdrNotificationRequest;
|
|
42
42
|
type Report = (request: HerdrRequest) => void;
|
|
43
43
|
|
|
44
|
-
// Herdr
|
|
45
|
-
//
|
|
46
|
-
// reports from any other source are dropped, so the blocked state has to be
|
|
47
|
-
// signalled through the in-process pi event bus that integration listens on.
|
|
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.
|
|
48
46
|
type BlockedEmitter = (active: boolean, label: string) => void;
|
|
49
47
|
let emitBlocked: BlockedEmitter | undefined;
|
|
50
48
|
|
|
@@ -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
|
+
});
|