pi-openai-toolkit 0.3.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/LICENSE +21 -0
- package/NOTICE +33 -0
- package/README.md +175 -0
- package/README_zh.md +172 -0
- package/extensions/compaction.ts +1 -0
- package/extensions/web-search.ts +1 -0
- package/package.json +79 -0
- package/src/compact-client.ts +426 -0
- package/src/config.ts +273 -0
- package/src/debug.ts +168 -0
- package/src/details-store.ts +151 -0
- package/src/extension-runtime.ts +537 -0
- package/src/native-fallback.ts +151 -0
- package/src/payload-rewrite.ts +556 -0
- package/src/provider-headers.ts +23 -0
- package/src/remote-v2-client.ts +526 -0
- package/src/request-context-cache.ts +106 -0
- package/src/runtime.ts +283 -0
- package/src/serializer.ts +554 -0
- package/src/supported-environment.ts +17 -0
- package/src/types.ts +333 -0
- package/src/web-search/extension.ts +80 -0
- package/src/web-search/payload.ts +96 -0
- package/src/web-search/prompt.ts +20 -0
- package/src/web-search/types.ts +47 -0
- package/tsconfig.check.json +36 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { writeDebugArtifact } from "./debug";
|
|
2
|
+
import { mergeProviderHeaders } from "./provider-headers";
|
|
3
|
+
import type { NativeCompactionRuntime } from "./runtime";
|
|
4
|
+
import type { NativeCompactionRequestBody } from "./serializer";
|
|
5
|
+
import type { ArtifactContext, CompactionConfig } from "./types";
|
|
6
|
+
|
|
7
|
+
const JSON_CONTENT_TYPE = "application/json";
|
|
8
|
+
|
|
9
|
+
type CompactResponseEnvelope = {
|
|
10
|
+
id?: string;
|
|
11
|
+
created_at?: number | string;
|
|
12
|
+
output: unknown[];
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type NativeCompactionClientFailureReason =
|
|
17
|
+
| "aborted"
|
|
18
|
+
| "network-error"
|
|
19
|
+
| "non-2xx"
|
|
20
|
+
| "empty-body"
|
|
21
|
+
| "invalid-json"
|
|
22
|
+
| "malformed-response"
|
|
23
|
+
| "empty-output";
|
|
24
|
+
|
|
25
|
+
export type NativeCompactionClientSuccess = {
|
|
26
|
+
ok: true;
|
|
27
|
+
status: number;
|
|
28
|
+
compactedWindow: unknown[];
|
|
29
|
+
compactResponseId?: string;
|
|
30
|
+
createdAt?: string;
|
|
31
|
+
/** Assistant summary text extracted from the compact output, for CompactionEntry.summary. */
|
|
32
|
+
summaryText?: string;
|
|
33
|
+
response: CompactResponseEnvelope;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type NativeCompactionClientFailure = {
|
|
37
|
+
ok: false;
|
|
38
|
+
reason: NativeCompactionClientFailureReason;
|
|
39
|
+
status?: number;
|
|
40
|
+
errorMessage?: string;
|
|
41
|
+
responseText?: string;
|
|
42
|
+
responseJson?: unknown;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type NativeCompactionClientResult = NativeCompactionClientSuccess | NativeCompactionClientFailure;
|
|
46
|
+
|
|
47
|
+
export type ExecuteNativeCompactionOptions = {
|
|
48
|
+
runtime: NativeCompactionRuntime;
|
|
49
|
+
request: NativeCompactionRequestBody;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
settings?: CompactionConfig;
|
|
52
|
+
context?: ArtifactContext;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isAbortError(error: unknown): boolean {
|
|
60
|
+
return (
|
|
61
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
62
|
+
(error instanceof Error && (error.name === "AbortError" || error.name === "ABORT_ERR"))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeResponseTimestamp(value: unknown): string | undefined {
|
|
67
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
68
|
+
const milliseconds = value > 1_000_000_000_000 ? value : value * 1000;
|
|
69
|
+
return new Date(milliseconds).toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof value !== "string") {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const trimmed = value.trim();
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parsed = Date.parse(trimmed);
|
|
82
|
+
return Number.isNaN(parsed) ? trimmed : new Date(parsed).toISOString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isCompactOutputItem(value: unknown): value is Record<string, unknown> {
|
|
86
|
+
return isRecord(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isCompactResponseEnvelope(value: unknown): value is CompactResponseEnvelope {
|
|
90
|
+
return isRecord(value) && Array.isArray(value.output) && value.output.every(isCompactOutputItem);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extract the assistant-authored summary text from the compacted window so the
|
|
95
|
+
* persisted CompactionEntry.summary carries real context. Without this, switching
|
|
96
|
+
* to a non-Responses model later would replay a meaningless placeholder.
|
|
97
|
+
*/
|
|
98
|
+
export function extractCompactedSummaryText(output: readonly unknown[]): string | undefined {
|
|
99
|
+
const texts: string[] = [];
|
|
100
|
+
for (const item of output) {
|
|
101
|
+
if (!isRecord(item) || item.type !== "message" || item.role !== "assistant" || !Array.isArray(item.content)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (const block of item.content) {
|
|
105
|
+
if (isRecord(block) && block.type === "output_text" && typeof block.text === "string" && block.text.trim()) {
|
|
106
|
+
texts.push(block.text.trim());
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const joined = texts.join("\n\n").trim();
|
|
112
|
+
return joined.length > 0 ? joined : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
116
|
+
const parts = token.split(".");
|
|
117
|
+
if (parts.length !== 3) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const payloadText = Buffer.from(parts[1]!, "base64url").toString("utf8");
|
|
123
|
+
const payload = JSON.parse(payloadText);
|
|
124
|
+
return isRecord(payload) ? payload : undefined;
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractCodexAccountId(token: string): string | undefined {
|
|
131
|
+
const payload = decodeJwtPayload(token);
|
|
132
|
+
const authClaims = payload?.["https://api.openai.com/auth"];
|
|
133
|
+
if (!isRecord(authClaims)) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const accountId = authClaims.chatgpt_account_id;
|
|
138
|
+
return typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildCodexUserAgent(): string {
|
|
142
|
+
const platform = typeof process !== "undefined" ? process.platform : "browser";
|
|
143
|
+
const arch = typeof process !== "undefined" ? process.arch : "unknown";
|
|
144
|
+
return `pi (${platform}; ${arch})`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function toHeaders(runtime: NativeCompactionRuntime): Record<string, string> {
|
|
148
|
+
const headers = new Headers(mergeProviderHeaders(runtime.currentModel.headers, runtime.headers));
|
|
149
|
+
headers.set("accept", JSON_CONTENT_TYPE);
|
|
150
|
+
headers.set("content-type", JSON_CONTENT_TYPE);
|
|
151
|
+
if (!headers.has("authorization")) {
|
|
152
|
+
headers.set("authorization", `Bearer ${runtime.apiKey}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (runtime.api === "openai-codex-responses") {
|
|
156
|
+
const accountId = extractCodexAccountId(runtime.apiKey);
|
|
157
|
+
if (accountId) {
|
|
158
|
+
headers.set("chatgpt-account-id", accountId);
|
|
159
|
+
}
|
|
160
|
+
headers.set("originator", "pi");
|
|
161
|
+
headers.set("user-agent", buildCodexUserAgent());
|
|
162
|
+
headers.set("openai-beta", "responses=experimental");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Object.fromEntries(headers.entries());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function writeCompactArtifact(
|
|
169
|
+
data: unknown,
|
|
170
|
+
settings: CompactionConfig | undefined,
|
|
171
|
+
context: ArtifactContext | undefined,
|
|
172
|
+
): void {
|
|
173
|
+
if (!settings || !context) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
writeDebugArtifact("compact-response", data, settings, context);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function executeNativeCompaction(
|
|
181
|
+
options: ExecuteNativeCompactionOptions,
|
|
182
|
+
): Promise<NativeCompactionClientResult> {
|
|
183
|
+
const { runtime, request, signal, settings, context } = options;
|
|
184
|
+
const headers = toHeaders(runtime);
|
|
185
|
+
|
|
186
|
+
if (signal?.aborted) {
|
|
187
|
+
const aborted: NativeCompactionClientFailure = {
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: "aborted",
|
|
190
|
+
};
|
|
191
|
+
writeCompactArtifact(
|
|
192
|
+
{
|
|
193
|
+
request: {
|
|
194
|
+
url: runtime.compactUrl,
|
|
195
|
+
headers,
|
|
196
|
+
body: request,
|
|
197
|
+
},
|
|
198
|
+
outcome: aborted,
|
|
199
|
+
},
|
|
200
|
+
settings,
|
|
201
|
+
context,
|
|
202
|
+
);
|
|
203
|
+
return aborted;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const response = await fetch(runtime.compactUrl, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers,
|
|
210
|
+
body: JSON.stringify(request),
|
|
211
|
+
signal,
|
|
212
|
+
});
|
|
213
|
+
const responseText = await response.text();
|
|
214
|
+
const responseHeaders: Record<string, string> = {};
|
|
215
|
+
response.headers.forEach((value, key) => {
|
|
216
|
+
responseHeaders[key] = value;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
let responseJson: unknown;
|
|
221
|
+
if (responseText.trim().length > 0) {
|
|
222
|
+
try {
|
|
223
|
+
responseJson = JSON.parse(responseText);
|
|
224
|
+
} catch {
|
|
225
|
+
responseJson = undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const failure: NativeCompactionClientFailure = {
|
|
230
|
+
ok: false,
|
|
231
|
+
reason: "non-2xx",
|
|
232
|
+
status: response.status,
|
|
233
|
+
responseText: responseText || undefined,
|
|
234
|
+
responseJson,
|
|
235
|
+
};
|
|
236
|
+
writeCompactArtifact(
|
|
237
|
+
{
|
|
238
|
+
request: {
|
|
239
|
+
url: runtime.compactUrl,
|
|
240
|
+
headers,
|
|
241
|
+
body: request,
|
|
242
|
+
},
|
|
243
|
+
response: {
|
|
244
|
+
status: response.status,
|
|
245
|
+
headers: responseHeaders,
|
|
246
|
+
body: responseJson ?? responseText,
|
|
247
|
+
},
|
|
248
|
+
outcome: failure,
|
|
249
|
+
},
|
|
250
|
+
settings,
|
|
251
|
+
context,
|
|
252
|
+
);
|
|
253
|
+
return failure;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!responseText.trim()) {
|
|
257
|
+
const failure: NativeCompactionClientFailure = {
|
|
258
|
+
ok: false,
|
|
259
|
+
reason: "empty-body",
|
|
260
|
+
status: response.status,
|
|
261
|
+
};
|
|
262
|
+
writeCompactArtifact(
|
|
263
|
+
{
|
|
264
|
+
request: {
|
|
265
|
+
url: runtime.compactUrl,
|
|
266
|
+
headers,
|
|
267
|
+
body: request,
|
|
268
|
+
},
|
|
269
|
+
response: {
|
|
270
|
+
status: response.status,
|
|
271
|
+
headers: responseHeaders,
|
|
272
|
+
body: responseText,
|
|
273
|
+
},
|
|
274
|
+
outcome: failure,
|
|
275
|
+
},
|
|
276
|
+
settings,
|
|
277
|
+
context,
|
|
278
|
+
);
|
|
279
|
+
return failure;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let parsed: unknown;
|
|
283
|
+
try {
|
|
284
|
+
parsed = JSON.parse(responseText);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
const failure: NativeCompactionClientFailure = {
|
|
287
|
+
ok: false,
|
|
288
|
+
reason: "invalid-json",
|
|
289
|
+
status: response.status,
|
|
290
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
291
|
+
responseText,
|
|
292
|
+
};
|
|
293
|
+
writeCompactArtifact(
|
|
294
|
+
{
|
|
295
|
+
request: {
|
|
296
|
+
url: runtime.compactUrl,
|
|
297
|
+
headers,
|
|
298
|
+
body: request,
|
|
299
|
+
},
|
|
300
|
+
response: {
|
|
301
|
+
status: response.status,
|
|
302
|
+
headers: responseHeaders,
|
|
303
|
+
body: responseText,
|
|
304
|
+
},
|
|
305
|
+
outcome: failure,
|
|
306
|
+
},
|
|
307
|
+
settings,
|
|
308
|
+
context,
|
|
309
|
+
);
|
|
310
|
+
return failure;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!isCompactResponseEnvelope(parsed)) {
|
|
314
|
+
const failure: NativeCompactionClientFailure = {
|
|
315
|
+
ok: false,
|
|
316
|
+
reason: "malformed-response",
|
|
317
|
+
status: response.status,
|
|
318
|
+
responseJson: parsed,
|
|
319
|
+
};
|
|
320
|
+
writeCompactArtifact(
|
|
321
|
+
{
|
|
322
|
+
request: {
|
|
323
|
+
url: runtime.compactUrl,
|
|
324
|
+
headers,
|
|
325
|
+
body: request,
|
|
326
|
+
},
|
|
327
|
+
response: {
|
|
328
|
+
status: response.status,
|
|
329
|
+
headers: responseHeaders,
|
|
330
|
+
body: parsed,
|
|
331
|
+
},
|
|
332
|
+
outcome: failure,
|
|
333
|
+
},
|
|
334
|
+
settings,
|
|
335
|
+
context,
|
|
336
|
+
);
|
|
337
|
+
return failure;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (parsed.output.length === 0) {
|
|
341
|
+
const failure: NativeCompactionClientFailure = {
|
|
342
|
+
ok: false,
|
|
343
|
+
reason: "empty-output",
|
|
344
|
+
status: response.status,
|
|
345
|
+
responseJson: parsed,
|
|
346
|
+
};
|
|
347
|
+
writeCompactArtifact(
|
|
348
|
+
{
|
|
349
|
+
request: {
|
|
350
|
+
url: runtime.compactUrl,
|
|
351
|
+
headers,
|
|
352
|
+
body: request,
|
|
353
|
+
},
|
|
354
|
+
response: {
|
|
355
|
+
status: response.status,
|
|
356
|
+
headers: responseHeaders,
|
|
357
|
+
body: parsed,
|
|
358
|
+
},
|
|
359
|
+
outcome: failure,
|
|
360
|
+
},
|
|
361
|
+
settings,
|
|
362
|
+
context,
|
|
363
|
+
);
|
|
364
|
+
return failure;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const success: NativeCompactionClientSuccess = {
|
|
368
|
+
ok: true,
|
|
369
|
+
status: response.status,
|
|
370
|
+
compactedWindow: [...parsed.output],
|
|
371
|
+
compactResponseId: typeof parsed.id === "string" && parsed.id.trim() ? parsed.id.trim() : undefined,
|
|
372
|
+
createdAt: normalizeResponseTimestamp(parsed.created_at),
|
|
373
|
+
summaryText: extractCompactedSummaryText(parsed.output),
|
|
374
|
+
response: parsed,
|
|
375
|
+
};
|
|
376
|
+
writeCompactArtifact(
|
|
377
|
+
{
|
|
378
|
+
request: {
|
|
379
|
+
url: runtime.compactUrl,
|
|
380
|
+
headers,
|
|
381
|
+
body: request,
|
|
382
|
+
},
|
|
383
|
+
response: {
|
|
384
|
+
status: response.status,
|
|
385
|
+
headers: responseHeaders,
|
|
386
|
+
body: parsed,
|
|
387
|
+
},
|
|
388
|
+
outcome: {
|
|
389
|
+
ok: true,
|
|
390
|
+
status: success.status,
|
|
391
|
+
compactResponseId: success.compactResponseId,
|
|
392
|
+
createdAt: success.createdAt,
|
|
393
|
+
compactedItems: success.compactedWindow.length,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
settings,
|
|
397
|
+
context,
|
|
398
|
+
);
|
|
399
|
+
return success;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const failure: NativeCompactionClientFailure = isAbortError(error)
|
|
402
|
+
? {
|
|
403
|
+
ok: false,
|
|
404
|
+
reason: "aborted",
|
|
405
|
+
}
|
|
406
|
+
: {
|
|
407
|
+
ok: false,
|
|
408
|
+
reason: "network-error",
|
|
409
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
writeCompactArtifact(
|
|
413
|
+
{
|
|
414
|
+
request: {
|
|
415
|
+
url: runtime.compactUrl,
|
|
416
|
+
headers,
|
|
417
|
+
body: request,
|
|
418
|
+
},
|
|
419
|
+
outcome: failure,
|
|
420
|
+
},
|
|
421
|
+
settings,
|
|
422
|
+
context,
|
|
423
|
+
);
|
|
424
|
+
return failure;
|
|
425
|
+
}
|
|
426
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_COMPACTION_CONFIG,
|
|
7
|
+
DEFAULT_TOOLKIT_CONFIG,
|
|
8
|
+
DEFAULT_WEB_SEARCH_CONFIG,
|
|
9
|
+
RESPONSES_COMPACT_CAPABLE_APIS,
|
|
10
|
+
THINKING_LEVELS,
|
|
11
|
+
TOOLKIT_ID,
|
|
12
|
+
type CompactionConfig,
|
|
13
|
+
type LoadedToolkitConfig,
|
|
14
|
+
type ToolkitConfig,
|
|
15
|
+
type WebSearchConfig,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
|
|
19
|
+
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
20
|
+
|
|
21
|
+
const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch"]);
|
|
22
|
+
const COMPACTION_FIELDS = new Set([
|
|
23
|
+
"enabled",
|
|
24
|
+
"allowCompactionContinuityBreak",
|
|
25
|
+
"model",
|
|
26
|
+
"thinkingLevel",
|
|
27
|
+
"responsesApis",
|
|
28
|
+
"notifyOnLoad",
|
|
29
|
+
"debug",
|
|
30
|
+
"logProviderPayloads",
|
|
31
|
+
"logCompactResponses",
|
|
32
|
+
"redactSensitiveData",
|
|
33
|
+
"artifactRoot",
|
|
34
|
+
]);
|
|
35
|
+
const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
|
|
36
|
+
|
|
37
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isFile(filePath: string): boolean {
|
|
42
|
+
try {
|
|
43
|
+
return fs.statSync(filePath).isFile();
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readJsonObject(filePath: string, warnings: string[]): Record<string, unknown> | undefined {
|
|
50
|
+
if (!isFile(filePath)) return undefined;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
54
|
+
if (isRecord(parsed)) return parsed;
|
|
55
|
+
warnings.push(`Ignoring ${filePath}: expected a JSON object at the top level.`);
|
|
56
|
+
return undefined;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
59
|
+
warnings.push(`Ignoring ${filePath}: ${message}`);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveConfiguredPath(rawPath: string, baseDir: string): string {
|
|
65
|
+
if (rawPath.startsWith("~/")) {
|
|
66
|
+
return path.join(os.homedir(), rawPath.slice(2));
|
|
67
|
+
}
|
|
68
|
+
if (path.isAbsolute(rawPath)) {
|
|
69
|
+
return path.resolve(rawPath);
|
|
70
|
+
}
|
|
71
|
+
return path.resolve(baseDir, rawPath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function warnUnknownFields(
|
|
75
|
+
value: Record<string, unknown>,
|
|
76
|
+
knownFields: ReadonlySet<string>,
|
|
77
|
+
fieldPath: string,
|
|
78
|
+
warnings: string[],
|
|
79
|
+
): void {
|
|
80
|
+
for (const key of Object.keys(value)) {
|
|
81
|
+
if (!knownFields.has(key)) {
|
|
82
|
+
warnings.push(`Ignoring ${fieldPath ? `${fieldPath}.` : ""}${key}: unknown field.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toBoolean(value: unknown, fieldPath: string, warnings: string[]): boolean | undefined {
|
|
88
|
+
if (value === undefined) return undefined;
|
|
89
|
+
if (typeof value === "boolean") return value;
|
|
90
|
+
warnings.push(`Ignoring ${fieldPath}: expected a boolean.`);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toModelSpec(value: unknown, fieldPath: string, warnings: string[]): string | null | undefined {
|
|
95
|
+
if (value === undefined) return undefined;
|
|
96
|
+
if (value === null) return null;
|
|
97
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
98
|
+
return value.trim();
|
|
99
|
+
}
|
|
100
|
+
warnings.push(`Ignoring ${fieldPath}: expected "provider/model-id" or null.`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toThinkingLevel(value: unknown, fieldPath: string, warnings: string[]): ThinkingLevel | undefined {
|
|
105
|
+
if (value === undefined) return undefined;
|
|
106
|
+
if (typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value)) {
|
|
107
|
+
return value as ThinkingLevel;
|
|
108
|
+
}
|
|
109
|
+
warnings.push(`Ignoring ${fieldPath}: expected one of ${THINKING_LEVELS.join(", ")}.`);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toSupportedApis(
|
|
114
|
+
value: unknown,
|
|
115
|
+
fieldPath: string,
|
|
116
|
+
capableApis: readonly string[],
|
|
117
|
+
warnings: string[],
|
|
118
|
+
): string[] | undefined {
|
|
119
|
+
if (value === undefined) return undefined;
|
|
120
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
121
|
+
warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const capable = new Set(capableApis);
|
|
126
|
+
const accepted: string[] = [];
|
|
127
|
+
for (const item of new Set(value.map((entry) => entry.trim()).filter(Boolean))) {
|
|
128
|
+
if (capable.has(item)) {
|
|
129
|
+
accepted.push(item);
|
|
130
|
+
} else {
|
|
131
|
+
warnings.push(
|
|
132
|
+
`Ignoring ${fieldPath} entry "${item}": only ${capableApis.join(", ")} are supported.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return accepted;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function toModelAllowlist(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
|
|
141
|
+
if (value === undefined) return undefined;
|
|
142
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
143
|
+
warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cloneDefaults(): ToolkitConfig {
|
|
151
|
+
return {
|
|
152
|
+
compaction: {
|
|
153
|
+
...DEFAULT_COMPACTION_CONFIG,
|
|
154
|
+
responsesApis: [...DEFAULT_COMPACTION_CONFIG.responsesApis],
|
|
155
|
+
},
|
|
156
|
+
webSearch: {
|
|
157
|
+
...DEFAULT_WEB_SEARCH_CONFIG,
|
|
158
|
+
models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function applyCompactionConfig(
|
|
164
|
+
raw: Record<string, unknown>,
|
|
165
|
+
resolved: CompactionConfig,
|
|
166
|
+
warnings: string[],
|
|
167
|
+
): void {
|
|
168
|
+
warnUnknownFields(raw, COMPACTION_FIELDS, "compaction", warnings);
|
|
169
|
+
|
|
170
|
+
resolved.enabled = toBoolean(raw.enabled, "compaction.enabled", warnings) ?? resolved.enabled;
|
|
171
|
+
resolved.allowCompactionContinuityBreak =
|
|
172
|
+
toBoolean(
|
|
173
|
+
raw.allowCompactionContinuityBreak,
|
|
174
|
+
"compaction.allowCompactionContinuityBreak",
|
|
175
|
+
warnings,
|
|
176
|
+
) ?? resolved.allowCompactionContinuityBreak;
|
|
177
|
+
resolved.notifyOnLoad =
|
|
178
|
+
toBoolean(raw.notifyOnLoad, "compaction.notifyOnLoad", warnings) ?? resolved.notifyOnLoad;
|
|
179
|
+
resolved.debug = toBoolean(raw.debug, "compaction.debug", warnings) ?? resolved.debug;
|
|
180
|
+
resolved.logProviderPayloads =
|
|
181
|
+
toBoolean(raw.logProviderPayloads, "compaction.logProviderPayloads", warnings) ??
|
|
182
|
+
resolved.logProviderPayloads;
|
|
183
|
+
resolved.logCompactResponses =
|
|
184
|
+
toBoolean(raw.logCompactResponses, "compaction.logCompactResponses", warnings) ??
|
|
185
|
+
resolved.logCompactResponses;
|
|
186
|
+
resolved.redactSensitiveData =
|
|
187
|
+
toBoolean(raw.redactSensitiveData, "compaction.redactSensitiveData", warnings) ??
|
|
188
|
+
resolved.redactSensitiveData;
|
|
189
|
+
|
|
190
|
+
const modelSpec = toModelSpec(raw.model, "compaction.model", warnings);
|
|
191
|
+
if (modelSpec !== undefined) {
|
|
192
|
+
resolved.model = modelSpec === null ? undefined : modelSpec;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
resolved.thinkingLevel =
|
|
196
|
+
toThinkingLevel(raw.thinkingLevel, "compaction.thinkingLevel", warnings) ?? resolved.thinkingLevel;
|
|
197
|
+
|
|
198
|
+
const apis = toSupportedApis(
|
|
199
|
+
raw.responsesApis,
|
|
200
|
+
"compaction.responsesApis",
|
|
201
|
+
RESPONSES_COMPACT_CAPABLE_APIS,
|
|
202
|
+
warnings,
|
|
203
|
+
);
|
|
204
|
+
if (apis !== undefined) {
|
|
205
|
+
resolved.responsesApis = apis;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (typeof raw.artifactRoot === "string" && raw.artifactRoot.trim().length > 0) {
|
|
209
|
+
resolved.artifactRoot = raw.artifactRoot.trim();
|
|
210
|
+
} else if (raw.artifactRoot !== undefined) {
|
|
211
|
+
warnings.push("Ignoring compaction.artifactRoot: expected a non-empty string.");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function applyWebSearchConfig(
|
|
216
|
+
raw: Record<string, unknown>,
|
|
217
|
+
resolved: WebSearchConfig,
|
|
218
|
+
warnings: string[],
|
|
219
|
+
): void {
|
|
220
|
+
warnUnknownFields(raw, WEB_SEARCH_FIELDS, "webSearch", warnings);
|
|
221
|
+
resolved.enabled = toBoolean(raw.enabled, "webSearch.enabled", warnings) ?? resolved.enabled;
|
|
222
|
+
|
|
223
|
+
const models = toModelAllowlist(raw.models, "webSearch.models", warnings);
|
|
224
|
+
if (models !== undefined) {
|
|
225
|
+
resolved.models = models;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Load the canonical toolkit config from
|
|
231
|
+
* `~/.pi/agent/extensions/pi-openai-toolkit/config.json`.
|
|
232
|
+
* A missing file silently yields defaults; legacy branded paths are never read.
|
|
233
|
+
*/
|
|
234
|
+
export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolkitConfig {
|
|
235
|
+
const warnings: string[] = [];
|
|
236
|
+
const resolved = cloneDefaults();
|
|
237
|
+
let source: string | undefined;
|
|
238
|
+
|
|
239
|
+
const raw = readJsonObject(configPath, warnings);
|
|
240
|
+
if (raw) {
|
|
241
|
+
source = configPath;
|
|
242
|
+
warnUnknownFields(raw, TOP_LEVEL_FIELDS, "", warnings);
|
|
243
|
+
|
|
244
|
+
if (raw.compaction !== undefined) {
|
|
245
|
+
if (isRecord(raw.compaction)) {
|
|
246
|
+
applyCompactionConfig(raw.compaction, resolved.compaction, warnings);
|
|
247
|
+
} else {
|
|
248
|
+
warnings.push("Ignoring compaction: expected a JSON object.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (raw.webSearch !== undefined) {
|
|
253
|
+
if (isRecord(raw.webSearch)) {
|
|
254
|
+
applyWebSearchConfig(raw.webSearch, resolved.webSearch, warnings);
|
|
255
|
+
} else {
|
|
256
|
+
warnings.push("Ignoring webSearch: expected a JSON object.");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
resolved.compaction.artifactRoot = resolveConfiguredPath(
|
|
262
|
+
resolved.compaction.artifactRoot,
|
|
263
|
+
path.dirname(configPath),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
config: resolved,
|
|
268
|
+
source,
|
|
269
|
+
warnings,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export { DEFAULT_TOOLKIT_CONFIG };
|