pi-firecode 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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/config.example.jsonc +100 -0
- package/config.ts +474 -0
- package/deliver.ts +32 -0
- package/flame-frames.ts +460 -0
- package/format.ts +80 -0
- package/header.ts +92 -0
- package/herdr-client.ts +60 -0
- package/index.ts +62 -0
- package/jsonc.ts +34 -0
- package/master/event-card.ts +106 -0
- package/master/event-format.ts +52 -0
- package/master/index.ts +1200 -0
- package/master/prompt.ts +26 -0
- package/master/prompts/master.zh.md +17 -0
- package/master/prompts/worker.zh.md +1 -0
- package/master/role.ts +13 -0
- package/master/spawn.ts +193 -0
- package/master/state.ts +201 -0
- package/package.json +59 -0
- package/provider/claude-sub.ts +129 -0
- package/provider/openai-native/index.ts +8 -0
- package/provider/openai-native/src/compact-client.ts +362 -0
- package/provider/openai-native/src/config.ts +297 -0
- package/provider/openai-native/src/extension.ts +93 -0
- package/provider/openai-native/src/native-compaction.ts +151 -0
- package/provider/openai-native/src/native-details.ts +157 -0
- package/provider/openai-native/src/native-replay.ts +253 -0
- package/provider/openai-native/src/native-runtime.ts +144 -0
- package/provider/openai-native/src/options.ts +85 -0
- package/provider/openai-native/src/request-pipeline.ts +21 -0
- package/provider/openai-native/src/responses-input.ts +433 -0
- package/review/advisor.ts +112 -0
- package/review/card.ts +385 -0
- package/review/checkpoint.ts +384 -0
- package/review/evidence.ts +232 -0
- package/review/index.ts +1246 -0
- package/review/outcome.ts +74 -0
- package/review/progress.ts +324 -0
- package/review/prompt.ts +210 -0
- package/review/prompts/advisor.en.md +41 -0
- package/review/prompts/advisor.zh.md +42 -0
- package/review/prompts/review.en.md +75 -0
- package/review/prompts/review.zh.md +75 -0
- package/review/reviewer.ts +356 -0
- package/review/session.ts +129 -0
- package/review/state.ts +905 -0
- package/review/ui.ts +528 -0
- package/session/bark.ts +157 -0
- package/session/herdr-display.ts +87 -0
- package/session/presets.ts +257 -0
- package/session/rename.ts +46 -0
- package/session/stats.ts +310 -0
- package/session/working-flame.ts +116 -0
- package/statusbar/index.ts +118 -0
- package/statusbar/quota-cache.ts +60 -0
- package/statusbar/quota-parse.ts +85 -0
- package/statusbar/quota.ts +203 -0
- package/statusbar/render.ts +181 -0
- package/statusbar/tps.ts +87 -0
- package/theme.ts +84 -0
- package/tools/grouping.ts +233 -0
- package/tools/index.ts +183 -0
- package/tools/line.ts +194 -0
- package/tools/parts.ts +143 -0
- package/tools/timing.ts +22 -0
- package/watcher/card.ts +85 -0
- package/watcher/index.ts +204 -0
- package/watcher/observer.ts +75 -0
- package/watcher/prompts/watch.zh.md +46 -0
- package/watcher/transcript.ts +64 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { NativeCompactionRuntime } from "./native-runtime";
|
|
2
|
+
import type { NativeCompactionRequest } from "./responses-input";
|
|
3
|
+
|
|
4
|
+
const JSON_CONTENT_TYPE = "application/json";
|
|
5
|
+
const COMPACTION_TRIGGER = { type: "compaction_trigger" } as const;
|
|
6
|
+
const RETAINED_MESSAGE_TOKEN_BUDGET = 64_000;
|
|
7
|
+
|
|
8
|
+
type ParsedOutput = {
|
|
9
|
+
output: Record<string, unknown>[];
|
|
10
|
+
createdAt?: unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ParsedFailure = {
|
|
14
|
+
reason: "input-too-large" | "response-failed";
|
|
15
|
+
detail?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type NativeCompactionFailureReason =
|
|
19
|
+
| "aborted"
|
|
20
|
+
| "network-error"
|
|
21
|
+
| "non-2xx"
|
|
22
|
+
| "empty-body"
|
|
23
|
+
| "invalid-json"
|
|
24
|
+
| "malformed-response"
|
|
25
|
+
| "missing-compaction"
|
|
26
|
+
| "input-too-large"
|
|
27
|
+
| "response-failed";
|
|
28
|
+
|
|
29
|
+
export type NativeCompactionResult =
|
|
30
|
+
| {
|
|
31
|
+
ok: true;
|
|
32
|
+
compactedWindow: unknown[];
|
|
33
|
+
createdAt?: string;
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
ok: false;
|
|
37
|
+
reason: NativeCompactionFailureReason;
|
|
38
|
+
status?: number;
|
|
39
|
+
detail?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isAbortError(error: unknown): boolean {
|
|
47
|
+
return (
|
|
48
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
49
|
+
(error instanceof Error && (error.name === "AbortError" || error.name === "ABORT_ERR"))
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeResponseTimestamp(value: unknown): string | undefined {
|
|
54
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
55
|
+
const milliseconds = value > 1_000_000_000_000 ? value : value * 1000;
|
|
56
|
+
return new Date(milliseconds).toISOString();
|
|
57
|
+
}
|
|
58
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const parsed = Date.parse(value);
|
|
62
|
+
return Number.isNaN(parsed) ? value.trim() : new Date(parsed).toISOString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function errorMessage(value: unknown): string | undefined {
|
|
66
|
+
if (!isRecord(value)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value.message === "string" && value.message.trim()) {
|
|
70
|
+
return value.message.trim();
|
|
71
|
+
}
|
|
72
|
+
if (isRecord(value.error)) {
|
|
73
|
+
return errorMessage(value.error);
|
|
74
|
+
}
|
|
75
|
+
if (isRecord(value.response)) {
|
|
76
|
+
return errorMessage(value.response);
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function providerError(value: unknown): Record<string, unknown> | undefined {
|
|
82
|
+
if (!isRecord(value)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
if (isRecord(value.error)) {
|
|
86
|
+
return value.error;
|
|
87
|
+
}
|
|
88
|
+
return providerError(value.response);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseFailedResponse(value: unknown): ParsedFailure | undefined {
|
|
92
|
+
if (!isRecord(value)) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const response = isRecord(value.response) ? value.response : value;
|
|
96
|
+
if (value.type !== "response.failed" && value.type !== "error" && response.status !== "failed") {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const error = providerError(value);
|
|
100
|
+
const code = typeof error?.code === "string" ? error.code : undefined;
|
|
101
|
+
const detail = error ? JSON.stringify(error) : errorMessage(value);
|
|
102
|
+
return {
|
|
103
|
+
reason: code === "context_length_exceeded" ? "input-too-large" : "response-failed",
|
|
104
|
+
...(detail ? { detail } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseErrorDetail(responseText: string): string | undefined {
|
|
109
|
+
try {
|
|
110
|
+
return errorMessage(JSON.parse(responseText));
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isOutputArray(value: unknown): value is Record<string, unknown>[] {
|
|
117
|
+
return Array.isArray(value) && value.every(isRecord);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseJsonOutput(value: unknown): ParsedOutput | undefined {
|
|
121
|
+
if (!isRecord(value) || !isOutputArray(value.output)) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
return { output: value.output, createdAt: value.created_at };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseSseOutput(responseText: string): ParsedOutput | ParsedFailure | undefined {
|
|
128
|
+
let completed: ParsedOutput | undefined;
|
|
129
|
+
const streamedItems: Record<string, unknown>[] = [];
|
|
130
|
+
let failure: ParsedFailure | undefined;
|
|
131
|
+
for (const block of responseText.split(/\r?\n\r?\n/)) {
|
|
132
|
+
const data = block
|
|
133
|
+
.split(/\r?\n/)
|
|
134
|
+
.filter((line) => line.startsWith("data:"))
|
|
135
|
+
.map((line) => line.slice(5).trimStart())
|
|
136
|
+
.join("\n");
|
|
137
|
+
if (!data || data === "[DONE]") {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
let event: unknown;
|
|
141
|
+
try {
|
|
142
|
+
event = JSON.parse(data);
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!isRecord(event)) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (event.type === "response.output_item.done" && isRecord(event.item)) {
|
|
150
|
+
streamedItems.push(event.item);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (event.type === "response.completed") {
|
|
154
|
+
completed = parseJsonOutput(event.response) ?? {
|
|
155
|
+
output: [],
|
|
156
|
+
createdAt: isRecord(event.response) ? event.response.created_at : undefined,
|
|
157
|
+
};
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
failure = parseFailedResponse(event) ?? failure;
|
|
161
|
+
}
|
|
162
|
+
if (completed?.output.length) {
|
|
163
|
+
return completed;
|
|
164
|
+
}
|
|
165
|
+
if (completed && streamedItems.length > 0) {
|
|
166
|
+
return { output: streamedItems, createdAt: completed.createdAt };
|
|
167
|
+
}
|
|
168
|
+
return failure;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseResponseOutput(responseText: string):
|
|
172
|
+
| { ok: true; parsed: ParsedOutput }
|
|
173
|
+
| { ok: false; reason: "invalid-json" | "malformed-response" | ParsedFailure["reason"]; detail?: string } {
|
|
174
|
+
const trimmed = responseText.trim();
|
|
175
|
+
if (trimmed.startsWith("{")) {
|
|
176
|
+
try {
|
|
177
|
+
const value = JSON.parse(trimmed);
|
|
178
|
+
const failure = parseFailedResponse(value);
|
|
179
|
+
if (failure) {
|
|
180
|
+
return { ok: false, ...failure };
|
|
181
|
+
}
|
|
182
|
+
const parsed = parseJsonOutput(value);
|
|
183
|
+
if (parsed) {
|
|
184
|
+
return { ok: true, parsed };
|
|
185
|
+
}
|
|
186
|
+
const detail = errorMessage(value);
|
|
187
|
+
return { ok: false, reason: "malformed-response", ...(detail ? { detail } : {}) };
|
|
188
|
+
} catch {
|
|
189
|
+
return { ok: false, reason: "invalid-json" };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const sse = parseSseOutput(trimmed);
|
|
194
|
+
if (sse && "output" in sse) {
|
|
195
|
+
return { ok: true, parsed: sse };
|
|
196
|
+
}
|
|
197
|
+
return sse
|
|
198
|
+
? { ok: false, ...sse }
|
|
199
|
+
: { ok: false, reason: "malformed-response" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function estimateSerializedTokens(value: unknown): number {
|
|
203
|
+
return Math.ceil(JSON.stringify(value).length / 4);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isRetainedInputItem(value: unknown): value is Record<string, unknown> {
|
|
207
|
+
return isRecord(value) && (value.role === "user" || value.role === "developer" || value.role === "system");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function itemText(item: Record<string, unknown>): string {
|
|
211
|
+
if (typeof item.content === "string") {
|
|
212
|
+
return item.content;
|
|
213
|
+
}
|
|
214
|
+
if (!Array.isArray(item.content)) {
|
|
215
|
+
return "";
|
|
216
|
+
}
|
|
217
|
+
return item.content
|
|
218
|
+
.filter(isRecord)
|
|
219
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
220
|
+
.join("");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function estimateTokens(item: Record<string, unknown>): number {
|
|
224
|
+
return Math.max(1, Math.ceil(itemText(item).length / 4));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildCompactedWindow(input: unknown[], compactionItem: Record<string, unknown>): unknown[] {
|
|
228
|
+
const retained: Record<string, unknown>[] = [];
|
|
229
|
+
let remaining = RETAINED_MESSAGE_TOKEN_BUDGET;
|
|
230
|
+
for (let index = input.length - 1; index >= 0 && remaining > 0; index--) {
|
|
231
|
+
const item = input[index];
|
|
232
|
+
if (!isRetainedInputItem(item)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const tokens = estimateTokens(item);
|
|
236
|
+
if (tokens > remaining) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
retained.push(structuredClone(item));
|
|
240
|
+
remaining -= tokens;
|
|
241
|
+
}
|
|
242
|
+
retained.reverse();
|
|
243
|
+
return [...retained, structuredClone(compactionItem)];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
247
|
+
const parts = token.split(".");
|
|
248
|
+
if (parts.length !== 3) {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
|
|
253
|
+
return isRecord(payload) ? payload : undefined;
|
|
254
|
+
} catch {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function codexAccountId(token: string): string | undefined {
|
|
260
|
+
const authClaims = decodeJwtPayload(token)?.["https://api.openai.com/auth"];
|
|
261
|
+
if (!isRecord(authClaims) || typeof authClaims.chatgpt_account_id !== "string") {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
return authClaims.chatgpt_account_id.trim() || undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildHeaders(runtime: NativeCompactionRuntime): Record<string, string> {
|
|
268
|
+
const headers = new Headers(runtime.currentModel.headers ?? {});
|
|
269
|
+
for (const [key, value] of Object.entries(runtime.headers ?? {})) {
|
|
270
|
+
if (value === null) headers.delete(key);
|
|
271
|
+
else headers.set(key, value);
|
|
272
|
+
}
|
|
273
|
+
headers.set("accept", "text/event-stream, application/json");
|
|
274
|
+
headers.set("content-type", JSON_CONTENT_TYPE);
|
|
275
|
+
if (!headers.has("authorization")) {
|
|
276
|
+
headers.set("authorization", `Bearer ${runtime.apiKey}`);
|
|
277
|
+
}
|
|
278
|
+
if (runtime.provider === "openai-codex") {
|
|
279
|
+
const accountId = codexAccountId(runtime.apiKey);
|
|
280
|
+
if (accountId) {
|
|
281
|
+
headers.set("chatgpt-account-id", accountId);
|
|
282
|
+
}
|
|
283
|
+
headers.set("originator", "pi");
|
|
284
|
+
headers.set("user-agent", `pi (${process.platform}; ${process.arch})`);
|
|
285
|
+
headers.set("openai-beta", "responses=experimental");
|
|
286
|
+
}
|
|
287
|
+
return Object.fromEntries(headers.entries());
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function executeNativeCompaction(args: {
|
|
291
|
+
runtime: NativeCompactionRuntime;
|
|
292
|
+
request: NativeCompactionRequest;
|
|
293
|
+
signal?: AbortSignal;
|
|
294
|
+
}): Promise<NativeCompactionResult> {
|
|
295
|
+
const { runtime, request, signal } = args;
|
|
296
|
+
if (signal?.aborted) {
|
|
297
|
+
return { ok: false, reason: "aborted" };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const input = [...request.input, COMPACTION_TRIGGER];
|
|
302
|
+
const inputTokens = estimateSerializedTokens(request.instructions) + estimateSerializedTokens(input);
|
|
303
|
+
const outputReserve = runtime.currentModel.maxTokens;
|
|
304
|
+
const contextWindow = runtime.currentModel.contextWindow;
|
|
305
|
+
if (inputTokens + outputReserve > contextWindow) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
reason: "input-too-large",
|
|
309
|
+
detail: `estimated input exceeds the model context window (${inputTokens} input + ${outputReserve} output reserve > ${contextWindow} tokens)`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const response = await fetch(runtime.responsesUrl, {
|
|
313
|
+
method: "POST",
|
|
314
|
+
headers: buildHeaders(runtime),
|
|
315
|
+
body: JSON.stringify({
|
|
316
|
+
model: request.model,
|
|
317
|
+
instructions: request.instructions,
|
|
318
|
+
input,
|
|
319
|
+
store: false,
|
|
320
|
+
stream: true,
|
|
321
|
+
}),
|
|
322
|
+
signal,
|
|
323
|
+
});
|
|
324
|
+
const responseText = await response.text();
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
const detail = parseErrorDetail(responseText);
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
reason: "non-2xx",
|
|
330
|
+
status: response.status,
|
|
331
|
+
...(detail ? { detail } : {}),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (!responseText.trim()) {
|
|
335
|
+
return { ok: false, reason: "empty-body", status: response.status };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const parsed = parseResponseOutput(responseText);
|
|
339
|
+
if (!parsed.ok) {
|
|
340
|
+
return { ...parsed, status: response.status };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const compactionItems = parsed.parsed.output.filter((item) => item.type === "compaction");
|
|
344
|
+
if (compactionItems.length !== 1) {
|
|
345
|
+
return { ok: false, reason: "missing-compaction", status: response.status };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
compactedWindow: buildCompactedWindow(request.input, compactionItems[0]!),
|
|
351
|
+
createdAt: normalizeResponseTimestamp(parsed.parsed.createdAt),
|
|
352
|
+
};
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return isAbortError(error)
|
|
355
|
+
? { ok: false, reason: "aborted" }
|
|
356
|
+
: {
|
|
357
|
+
ok: false,
|
|
358
|
+
reason: "network-error",
|
|
359
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { parseJsonc } from "../../../jsonc.js";
|
|
3
|
+
|
|
4
|
+
const TEXT_VERBOSITIES = ["low", "medium", "high"] as const;
|
|
5
|
+
|
|
6
|
+
export type TextVerbosity = (typeof TEXT_VERBOSITIES)[number];
|
|
7
|
+
|
|
8
|
+
export type OpenAIProviderSettings = {
|
|
9
|
+
textVerbosity?: TextVerbosity;
|
|
10
|
+
priority?: true;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type OpenAINativeSettings = {
|
|
14
|
+
nativeCompaction: boolean;
|
|
15
|
+
providers: Record<string, OpenAIProviderSettings>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type LoadedOpenAINativeSettings = {
|
|
19
|
+
settings: OpenAINativeSettings;
|
|
20
|
+
warnings: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTextVerbosity(value: unknown): value is TextVerbosity {
|
|
28
|
+
return typeof value === "string" && TEXT_VERBOSITIES.includes(value as TextVerbosity);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createDefaultSettings(): OpenAINativeSettings {
|
|
32
|
+
return {
|
|
33
|
+
nativeCompaction: false,
|
|
34
|
+
providers: {},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readConfig(configPath: string): Record<string, unknown> {
|
|
39
|
+
const parsed = parseJsonc(readFileSync(configPath, "utf8"));
|
|
40
|
+
if (!isRecord(parsed)) {
|
|
41
|
+
throw new Error("expected a JSON object");
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 只替换名为 key 的对象,文件其余部分(含注释)原样保留。 */
|
|
47
|
+
function replaceKeyedObject(text: string, key: string, value: unknown): string | undefined {
|
|
48
|
+
const marker = new RegExp(`"${key}"\\s*:\\s*\\{`);
|
|
49
|
+
const match = marker.exec(text);
|
|
50
|
+
if (!match) return undefined;
|
|
51
|
+
const brace = text.indexOf("{", match.index + match[0].length - 1);
|
|
52
|
+
if (brace < 0) return undefined;
|
|
53
|
+
|
|
54
|
+
let depth = 0;
|
|
55
|
+
let inString = false;
|
|
56
|
+
let escape = false;
|
|
57
|
+
for (let index = brace; index < text.length; index++) {
|
|
58
|
+
const char = text[index];
|
|
59
|
+
if (inString) {
|
|
60
|
+
if (escape) escape = false;
|
|
61
|
+
else if (char === "\\") escape = true;
|
|
62
|
+
else if (char === '"') inString = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (char === '"') {
|
|
66
|
+
inString = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (char === "{") depth += 1;
|
|
70
|
+
else if (char === "}") {
|
|
71
|
+
depth -= 1;
|
|
72
|
+
if (depth !== 0) continue;
|
|
73
|
+
const lineStart = text.lastIndexOf("\n", match.index) + 1;
|
|
74
|
+
const indent = text.slice(lineStart, match.index);
|
|
75
|
+
const pretty = JSON.stringify(value, null, "\t")
|
|
76
|
+
.split("\n")
|
|
77
|
+
.map((line, lineIndex) => (lineIndex === 0 ? line : indent + line))
|
|
78
|
+
.join("\n");
|
|
79
|
+
return text.slice(0, brace) + pretty + text.slice(index + 1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** firecode/config.jsonc 用 `openai` 节;单测仍可用独立的 native 文件。 */
|
|
86
|
+
function openaiRoot(root: Record<string, unknown>): Record<string, unknown> {
|
|
87
|
+
return isRecord(root.openai) ? root.openai : root;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isFirecodeConfig(root: Record<string, unknown>): boolean {
|
|
91
|
+
return "openai" in root || "presets" in root || "keys" in root || "features" in root;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseProviderSettings(
|
|
95
|
+
provider: string,
|
|
96
|
+
value: unknown,
|
|
97
|
+
warnings: string[],
|
|
98
|
+
): OpenAIProviderSettings | undefined {
|
|
99
|
+
if (!isRecord(value)) {
|
|
100
|
+
warnings.push(`providers.${provider}: expected an object.`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const settings: OpenAIProviderSettings = {};
|
|
105
|
+
if (value.textVerbosity !== undefined) {
|
|
106
|
+
if (isTextVerbosity(value.textVerbosity)) {
|
|
107
|
+
settings.textVerbosity = value.textVerbosity;
|
|
108
|
+
} else {
|
|
109
|
+
warnings.push(`providers.${provider}.textVerbosity: expected low, medium, or high.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (value.priority !== undefined) {
|
|
113
|
+
if (typeof value.priority === "boolean") {
|
|
114
|
+
if (value.priority) {
|
|
115
|
+
settings.priority = true;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
warnings.push(`providers.${provider}.priority: expected a boolean.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return Object.keys(settings).length > 0 ? settings : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseSettings(root: Record<string, unknown>): LoadedOpenAINativeSettings {
|
|
126
|
+
const warnings: string[] = [];
|
|
127
|
+
const settings = createDefaultSettings();
|
|
128
|
+
if (root.nativeCompaction !== undefined) {
|
|
129
|
+
if (typeof root.nativeCompaction === "boolean") {
|
|
130
|
+
settings.nativeCompaction = root.nativeCompaction;
|
|
131
|
+
} else {
|
|
132
|
+
warnings.push("nativeCompaction: expected a boolean.");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (root.providers === undefined) {
|
|
136
|
+
return { settings, warnings };
|
|
137
|
+
}
|
|
138
|
+
if (!isRecord(root.providers)) {
|
|
139
|
+
warnings.push("providers: expected an object.");
|
|
140
|
+
return { settings, warnings };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const [provider, value] of Object.entries(root.providers)) {
|
|
144
|
+
if (!provider.trim()) {
|
|
145
|
+
warnings.push("providers: provider name cannot be empty.");
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const providerSettings = parseProviderSettings(provider, value, warnings);
|
|
149
|
+
if (providerSettings) {
|
|
150
|
+
settings.providers[provider] = providerSettings;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { settings, warnings };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function serializeSettings(settings: OpenAINativeSettings): Record<string, unknown> {
|
|
158
|
+
const providers: Record<string, OpenAIProviderSettings> = {};
|
|
159
|
+
for (const [provider, providerSettings] of Object.entries(settings.providers)) {
|
|
160
|
+
providers[provider] = {
|
|
161
|
+
...(providerSettings.textVerbosity ? { textVerbosity: providerSettings.textVerbosity } : {}),
|
|
162
|
+
...(providerSettings.priority ? { priority: true } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
nativeCompaction: settings.nativeCompaction,
|
|
168
|
+
providers,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isProcessRunning(processId: number): boolean {
|
|
173
|
+
if (!Number.isSafeInteger(processId) || processId <= 0) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
process.kill(processId, 0);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function acquireConfigLock(lockPath: string): number {
|
|
186
|
+
try {
|
|
187
|
+
return openSync(lockPath, "wx", 0o600);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const processId = Number.parseInt(readFileSync(lockPath, "utf8"), 10);
|
|
195
|
+
if (!Number.isSafeInteger(processId) || isProcessRunning(processId)) {
|
|
196
|
+
throw new Error("config is being updated by another Pi process");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
rmSync(lockPath, { force: true });
|
|
200
|
+
return openSync(lockPath, "wx", 0o600);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function writeConfig(
|
|
204
|
+
configPath: string,
|
|
205
|
+
settings: OpenAINativeSettings,
|
|
206
|
+
firecodeRoot?: Record<string, unknown>,
|
|
207
|
+
): void {
|
|
208
|
+
const serialized = serializeSettings(settings);
|
|
209
|
+
let text: string;
|
|
210
|
+
if (firecodeRoot) {
|
|
211
|
+
const original = readFileSync(configPath, "utf8");
|
|
212
|
+
text = replaceKeyedObject(original, "openai", serialized)
|
|
213
|
+
?? `${JSON.stringify({ ...firecodeRoot, openai: serialized }, null, "\t")}\n`;
|
|
214
|
+
} else {
|
|
215
|
+
text = `${JSON.stringify(serialized, null, 2)}\n`;
|
|
216
|
+
}
|
|
217
|
+
const temporaryPath = `${configPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
218
|
+
try {
|
|
219
|
+
writeFileSync(temporaryPath, text, {
|
|
220
|
+
encoding: "utf8",
|
|
221
|
+
mode: 0o600,
|
|
222
|
+
});
|
|
223
|
+
renameSync(temporaryPath, configPath);
|
|
224
|
+
} finally {
|
|
225
|
+
rmSync(temporaryPath, { force: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function withConfigLock<T>(configPath: string, action: () => T): T {
|
|
230
|
+
const lockPath = `${configPath}.lock`;
|
|
231
|
+
const lockDescriptor = acquireConfigLock(lockPath);
|
|
232
|
+
writeFileSync(lockDescriptor, `${process.pid}\n`, "utf8");
|
|
233
|
+
try {
|
|
234
|
+
return action();
|
|
235
|
+
} finally {
|
|
236
|
+
closeSync(lockDescriptor);
|
|
237
|
+
rmSync(lockPath, { force: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function loadOpenAINativeSettings(configPath: string): LoadedOpenAINativeSettings {
|
|
242
|
+
try {
|
|
243
|
+
return parseSettings(openaiRoot(readConfig(configPath)));
|
|
244
|
+
} catch (error) {
|
|
245
|
+
return {
|
|
246
|
+
settings: createDefaultSettings(),
|
|
247
|
+
warnings: [`config.json: ${error instanceof Error ? error.message : String(error)}`],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function togglePriority(
|
|
253
|
+
provider: string,
|
|
254
|
+
configPath: string,
|
|
255
|
+
): { enabled: boolean; loaded: LoadedOpenAINativeSettings } {
|
|
256
|
+
if (!provider.trim()) {
|
|
257
|
+
throw new Error("provider name cannot be empty");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return withConfigLock(configPath, () => {
|
|
261
|
+
const root = readConfig(configPath);
|
|
262
|
+
const loaded = parseSettings(openaiRoot(root));
|
|
263
|
+
const providerSettings = loaded.settings.providers[provider] ?? {};
|
|
264
|
+
const enabled = providerSettings.priority !== true;
|
|
265
|
+
const providers = {
|
|
266
|
+
...loaded.settings.providers,
|
|
267
|
+
[provider]: {
|
|
268
|
+
...providerSettings,
|
|
269
|
+
...(enabled ? { priority: true as const } : {}),
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
if (!enabled) {
|
|
274
|
+
delete providers[provider].priority;
|
|
275
|
+
if (Object.keys(providers[provider]).length === 0) {
|
|
276
|
+
delete providers[provider];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const settings: OpenAINativeSettings = {
|
|
281
|
+
nativeCompaction: loaded.settings.nativeCompaction,
|
|
282
|
+
providers,
|
|
283
|
+
};
|
|
284
|
+
writeConfig(configPath, settings, isFirecodeConfig(root) ? root : undefined);
|
|
285
|
+
return {
|
|
286
|
+
enabled,
|
|
287
|
+
loaded: { settings, warnings: loaded.warnings },
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function providerSettings(
|
|
293
|
+
settings: OpenAINativeSettings,
|
|
294
|
+
provider: string | undefined,
|
|
295
|
+
): OpenAIProviderSettings | undefined {
|
|
296
|
+
return provider ? settings.providers[provider] : undefined;
|
|
297
|
+
}
|