@narumitw/pi-codex-compact 0.47.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 +108 -0
- package/package.json +52 -0
- package/src/checkpoint.ts +257 -0
- package/src/codex-compact.ts +283 -0
- package/src/index.ts +1 -0
- package/src/protocol.ts +224 -0
- package/src/remote.ts +117 -0
- package/src/settings-menu.ts +232 -0
- package/src/settings.ts +227 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Api, Context, Model, Tool } from "@earendil-works/pi-ai";
|
|
3
|
+
import { hasApi } from "@earendil-works/pi-ai";
|
|
4
|
+
import {
|
|
5
|
+
buildContextEntries,
|
|
6
|
+
buildSessionContext,
|
|
7
|
+
convertToLlm,
|
|
8
|
+
type ExtensionAPI,
|
|
9
|
+
type ExtensionContext,
|
|
10
|
+
type SessionBeforeCompactEvent,
|
|
11
|
+
sessionEntryToContextMessages,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
buildReplacementHistory,
|
|
15
|
+
type CodexCheckpointDetails,
|
|
16
|
+
checkpointMarker,
|
|
17
|
+
createCheckpointDetails,
|
|
18
|
+
fallbackSummary,
|
|
19
|
+
latestCheckpoint,
|
|
20
|
+
projectCheckpointContext,
|
|
21
|
+
} from "./checkpoint.js";
|
|
22
|
+
import { hasCheckpointMarker, rewriteCheckpointMarker } from "./protocol.js";
|
|
23
|
+
import { requestRemoteCompaction } from "./remote.js";
|
|
24
|
+
import {
|
|
25
|
+
type CodexCompactSettings,
|
|
26
|
+
type CodexCompactSettingsRuntime,
|
|
27
|
+
type CodexCompactSettingsState,
|
|
28
|
+
createCodexCompactSettingsRuntime,
|
|
29
|
+
} from "./settings.js";
|
|
30
|
+
import { showCodexCompactMenu } from "./settings-menu.js";
|
|
31
|
+
|
|
32
|
+
const STATUS_KEY = "codex-compact";
|
|
33
|
+
const EXPERIMENTAL_WARNING =
|
|
34
|
+
"Experimental: Codex Remote Compaction V2 uses an opaque, provider-specific checkpoint. Sessions require this extension and openai-codex for full replay.";
|
|
35
|
+
|
|
36
|
+
function isSupportedModel(model: Model<Api> | undefined): model is Model<"openai-codex-responses"> {
|
|
37
|
+
return model?.provider === "openai-codex" && hasApi(model, "openai-codex-responses");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function activeCheckpoint(ctx: ExtensionContext) {
|
|
41
|
+
return latestCheckpoint(ctx.sessionManager.getBranch());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isCheckpointCompatible(
|
|
45
|
+
details: CodexCheckpointDetails,
|
|
46
|
+
model: Model<Api> | undefined,
|
|
47
|
+
): model is Model<"openai-codex-responses"> {
|
|
48
|
+
return isSupportedModel(model) && model.id === details.modelId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function keptMessages(event: SessionBeforeCompactEvent): AgentMessage[] {
|
|
52
|
+
const leafId = event.branchEntries.at(-1)?.id ?? null;
|
|
53
|
+
const contextEntries = buildContextEntries(event.branchEntries, leafId);
|
|
54
|
+
const keptIndex = contextEntries.findIndex(
|
|
55
|
+
(entry) => entry.id === event.preparation.firstKeptEntryId,
|
|
56
|
+
);
|
|
57
|
+
if (keptIndex < 0) {
|
|
58
|
+
throw new Error("Pi compaction cut point is not present in the active context");
|
|
59
|
+
}
|
|
60
|
+
return contextEntries.slice(keptIndex).flatMap(sessionEntryToContextMessages);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function activeTools(pi: ExtensionAPI): Tool[] {
|
|
64
|
+
const enabled = new Set(pi.getActiveTools());
|
|
65
|
+
return pi
|
|
66
|
+
.getAllTools()
|
|
67
|
+
.filter((tool) => enabled.has(tool.name))
|
|
68
|
+
.map((tool) => ({
|
|
69
|
+
name: tool.name,
|
|
70
|
+
description: tool.description,
|
|
71
|
+
parameters: tool.parameters,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function projectedCurrentMessages(
|
|
76
|
+
event: SessionBeforeCompactEvent,
|
|
77
|
+
model: Model<"openai-codex-responses">,
|
|
78
|
+
): { messages: AgentMessage[]; prior?: CodexCheckpointDetails } {
|
|
79
|
+
const leafId = event.branchEntries.at(-1)?.id ?? null;
|
|
80
|
+
const session = buildSessionContext(event.branchEntries, leafId);
|
|
81
|
+
const prior = latestCheckpoint(event.branchEntries)?.details;
|
|
82
|
+
if (!prior) return { messages: session.messages };
|
|
83
|
+
if (prior.modelId !== model.id) {
|
|
84
|
+
throw new Error("The active opaque checkpoint belongs to a different Codex model");
|
|
85
|
+
}
|
|
86
|
+
const projected = projectCheckpointContext(session.messages, prior);
|
|
87
|
+
if (!projected) {
|
|
88
|
+
throw new Error("The previous opaque checkpoint could not be projected safely");
|
|
89
|
+
}
|
|
90
|
+
return { messages: projected, prior };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function notifyFailure(
|
|
94
|
+
ctx: ExtensionContext,
|
|
95
|
+
error: unknown,
|
|
96
|
+
settings: CodexCompactSettings,
|
|
97
|
+
): void {
|
|
98
|
+
if (!ctx.hasUI || !settings.notifyOnFallback) return;
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
ctx.ui.notify(`Codex remote compaction failed; using Pi compaction. ${message}`, "warning");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sessionStillOwned(ctx: ExtensionContext, sessionId: string, signal: AbortSignal): boolean {
|
|
104
|
+
return !signal.aborted && ctx.sessionManager.getSessionId() === sessionId;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function compactRemotely(
|
|
108
|
+
pi: ExtensionAPI,
|
|
109
|
+
event: SessionBeforeCompactEvent,
|
|
110
|
+
ctx: ExtensionContext,
|
|
111
|
+
settings: CodexCompactSettings,
|
|
112
|
+
fetch?: typeof globalThis.fetch,
|
|
113
|
+
) {
|
|
114
|
+
const model = ctx.model;
|
|
115
|
+
if (!settings.enabled || !isSupportedModel(model)) return undefined;
|
|
116
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
117
|
+
ctx.ui.setStatus(STATUS_KEY, "Codex remote compaction…");
|
|
118
|
+
try {
|
|
119
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
120
|
+
if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
|
|
121
|
+
if (!auth.ok || !auth.apiKey) {
|
|
122
|
+
throw new Error(auth.ok ? "OpenAI Codex OAuth token is unavailable" : auth.error);
|
|
123
|
+
}
|
|
124
|
+
const provider = ctx.modelRegistry.getProvider(model.provider);
|
|
125
|
+
if (!provider) throw new Error("OpenAI Codex provider is unavailable");
|
|
126
|
+
const current = projectedCurrentMessages(event, model);
|
|
127
|
+
const context: Context = {
|
|
128
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
129
|
+
messages: convertToLlm(current.messages),
|
|
130
|
+
tools: activeTools(pi),
|
|
131
|
+
};
|
|
132
|
+
const response = await requestRemoteCompaction({
|
|
133
|
+
provider,
|
|
134
|
+
model,
|
|
135
|
+
context,
|
|
136
|
+
apiKey: auth.apiKey,
|
|
137
|
+
headers: auth.headers,
|
|
138
|
+
env: auth.env,
|
|
139
|
+
signal: event.signal,
|
|
140
|
+
priorCheckpoint: current.prior
|
|
141
|
+
? {
|
|
142
|
+
marker: checkpointMarker(current.prior.checkpointId),
|
|
143
|
+
replacementHistory: current.prior.replacementHistory,
|
|
144
|
+
}
|
|
145
|
+
: undefined,
|
|
146
|
+
requestTimeoutMs: settings.requestTimeoutMs,
|
|
147
|
+
maxRetries: settings.maxRetries,
|
|
148
|
+
fetch,
|
|
149
|
+
});
|
|
150
|
+
if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
|
|
151
|
+
const replacementHistory = buildReplacementHistory(response.promptInput, response.item, {
|
|
152
|
+
tokenBudget: settings.replacementTokenBudget,
|
|
153
|
+
});
|
|
154
|
+
const details = createCheckpointDetails({
|
|
155
|
+
modelId: model.id,
|
|
156
|
+
replacementHistory,
|
|
157
|
+
keptMessages: keptMessages(event),
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
compaction: {
|
|
161
|
+
summary: fallbackSummary(details.checkpointId),
|
|
162
|
+
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
163
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
164
|
+
usage: response.usage,
|
|
165
|
+
details,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (event.signal.aborted || ctx.sessionManager.getSessionId() !== sessionId) {
|
|
170
|
+
return { cancel: true };
|
|
171
|
+
}
|
|
172
|
+
notifyFailure(ctx, error, settings);
|
|
173
|
+
return undefined;
|
|
174
|
+
} finally {
|
|
175
|
+
if (ctx.sessionManager.getSessionId() === sessionId) ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createCodexCompactExtension(
|
|
180
|
+
options: { fetch?: typeof globalThis.fetch; settingsRuntime?: CodexCompactSettingsRuntime } = {},
|
|
181
|
+
): (pi: ExtensionAPI) => void {
|
|
182
|
+
return (pi) => {
|
|
183
|
+
const providerWarnings = new Set<string>();
|
|
184
|
+
const settingsRuntime = options.settingsRuntime ?? createCodexCompactSettingsRuntime();
|
|
185
|
+
let sessionController = new AbortController();
|
|
186
|
+
let generation = 0;
|
|
187
|
+
|
|
188
|
+
pi.registerCommand("codex-compact", {
|
|
189
|
+
description: "Compact now or configure experimental Codex Remote Compaction V2",
|
|
190
|
+
handler: async (_args, ctx) => {
|
|
191
|
+
const ownerGeneration = generation;
|
|
192
|
+
await showCodexCompactMenu(settingsRuntime, ctx, {
|
|
193
|
+
signal: sessionController.signal,
|
|
194
|
+
isCurrent: () => ownerGeneration === generation && !sessionController.signal.aborted,
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
200
|
+
sessionController.abort();
|
|
201
|
+
sessionController = new AbortController();
|
|
202
|
+
generation += 1;
|
|
203
|
+
const ownerGeneration = generation;
|
|
204
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
205
|
+
providerWarnings.clear();
|
|
206
|
+
let state: Readonly<CodexCompactSettingsState>;
|
|
207
|
+
try {
|
|
208
|
+
state = await settingsRuntime.reload(sessionController.signal);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (sessionController.signal.aborted || ownerGeneration !== generation) return;
|
|
211
|
+
if (ctx.hasUI) {
|
|
212
|
+
ctx.ui.notify(
|
|
213
|
+
`Could not load pi-codex-compact.json; using defaults. ${error instanceof Error ? error.message : String(error)}`,
|
|
214
|
+
"warning",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
sessionController.signal.aborted ||
|
|
221
|
+
ownerGeneration !== generation ||
|
|
222
|
+
ctx.sessionManager.getSessionId() !== sessionId
|
|
223
|
+
) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (ctx.hasUI) {
|
|
227
|
+
ctx.ui.notify(EXPERIMENTAL_WARNING, "warning");
|
|
228
|
+
if (state.kind === "invalid") {
|
|
229
|
+
ctx.ui.notify(
|
|
230
|
+
`Invalid pi-codex-compact.json; using defaults without overwriting it. ${state.issue}`,
|
|
231
|
+
"warning",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
pi.on("session_before_compact", (event, ctx) =>
|
|
238
|
+
compactRemotely(pi, event, ctx, settingsRuntime.get().settings, options.fetch),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
pi.on("context", (event, ctx) => {
|
|
242
|
+
if (!settingsRuntime.get().settings.enabled) return undefined;
|
|
243
|
+
const checkpoint = activeCheckpoint(ctx);
|
|
244
|
+
if (!checkpoint || !isCheckpointCompatible(checkpoint.details, ctx.model)) return undefined;
|
|
245
|
+
const messages = projectCheckpointContext(event.messages, checkpoint.details);
|
|
246
|
+
return messages ? { messages } : undefined;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
250
|
+
if (!settingsRuntime.get().settings.enabled) return undefined;
|
|
251
|
+
const checkpoint = activeCheckpoint(ctx);
|
|
252
|
+
if (!checkpoint || !isCheckpointCompatible(checkpoint.details, ctx.model)) return undefined;
|
|
253
|
+
const marker = checkpointMarker(checkpoint.details.checkpointId);
|
|
254
|
+
if (!hasCheckpointMarker(event.payload, marker)) return undefined;
|
|
255
|
+
return rewriteCheckpointMarker(event.payload, marker, checkpoint.details.replacementHistory);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
pi.on("model_select", (event, ctx) => {
|
|
259
|
+
if (!settingsRuntime.get().settings.enabled) return;
|
|
260
|
+
const checkpoint = activeCheckpoint(ctx);
|
|
261
|
+
if (!checkpoint || isCheckpointCompatible(checkpoint.details, event.model)) return;
|
|
262
|
+
const key = `${ctx.sessionManager.getSessionId()}:${event.model.provider}:${event.model.id}`;
|
|
263
|
+
if (providerWarnings.has(key)) return;
|
|
264
|
+
providerWarnings.add(key);
|
|
265
|
+
if (ctx.hasUI) {
|
|
266
|
+
ctx.ui.notify(
|
|
267
|
+
"The active Codex checkpoint cannot replay on this model; Pi will expose only its fallback marker and retained recent messages.",
|
|
268
|
+
"warning",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
274
|
+
generation += 1;
|
|
275
|
+
sessionController.abort();
|
|
276
|
+
providerWarnings.clear();
|
|
277
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
278
|
+
await settingsRuntime.flush();
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export default createCodexCompactExtension();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./codex-compact.js";
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
export const MAX_SSE_BYTES = 8 * 1024 * 1024;
|
|
2
|
+
export const MAX_COMPACTION_ITEM_BYTES = 2 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
export type JsonObject = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export class CodexCompactionProtocolError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "CodexCompactionProtocolError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isObject(value: unknown): value is JsonObject {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function byteLength(value: unknown): number {
|
|
18
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isCompactionItem(value: unknown): value is JsonObject {
|
|
22
|
+
return (
|
|
23
|
+
isObject(value) &&
|
|
24
|
+
value.type === "compaction" &&
|
|
25
|
+
typeof value.encrypted_content === "string" &&
|
|
26
|
+
value.encrypted_content.length > 0
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validateCompactionItem(
|
|
31
|
+
value: unknown,
|
|
32
|
+
maxBytes = MAX_COMPACTION_ITEM_BYTES,
|
|
33
|
+
): JsonObject {
|
|
34
|
+
if (!isCompactionItem(value)) {
|
|
35
|
+
throw new CodexCompactionProtocolError(
|
|
36
|
+
"Remote response did not contain a valid compaction item",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (byteLength(value) > maxBytes) {
|
|
40
|
+
throw new CodexCompactionProtocolError("Remote compaction item exceeded the size limit");
|
|
41
|
+
}
|
|
42
|
+
return structuredClone(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CollectedCompaction {
|
|
46
|
+
item: JsonObject;
|
|
47
|
+
completedResponse?: JsonObject;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function compactionItemsFromEvent(event: JsonObject): unknown[] {
|
|
51
|
+
const items: unknown[] = [];
|
|
52
|
+
if (event.type === "response.output_item.done" && isObject(event.item)) {
|
|
53
|
+
items.push(event.item);
|
|
54
|
+
}
|
|
55
|
+
if (event.type === "response.completed" && isObject(event.response)) {
|
|
56
|
+
const output = event.response.output;
|
|
57
|
+
if (Array.isArray(output)) items.push(...output);
|
|
58
|
+
}
|
|
59
|
+
return items.filter((item) => isObject(item) && item.type === "compaction");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function collectCompactionSse(
|
|
63
|
+
stream: ReadableStream<Uint8Array>,
|
|
64
|
+
options: {
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
maxBytes?: number;
|
|
67
|
+
maxItemBytes?: number;
|
|
68
|
+
} = {},
|
|
69
|
+
): Promise<CollectedCompaction> {
|
|
70
|
+
const maxBytes = options.maxBytes ?? MAX_SSE_BYTES;
|
|
71
|
+
const reader = stream.getReader();
|
|
72
|
+
const onAbort = () => {
|
|
73
|
+
void reader.cancel(new DOMException("Compaction aborted", "AbortError")).catch(() => undefined);
|
|
74
|
+
};
|
|
75
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
76
|
+
const decoder = new TextDecoder();
|
|
77
|
+
let bytes = 0;
|
|
78
|
+
let pending = "";
|
|
79
|
+
let dataLines: string[] = [];
|
|
80
|
+
let completedResponse: JsonObject | undefined;
|
|
81
|
+
const items = new Map<string, JsonObject>();
|
|
82
|
+
|
|
83
|
+
const checkAbort = () => {
|
|
84
|
+
if (options.signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
|
|
85
|
+
};
|
|
86
|
+
const dispatch = () => {
|
|
87
|
+
if (dataLines.length === 0) return;
|
|
88
|
+
const data = dataLines.join("\n");
|
|
89
|
+
dataLines = [];
|
|
90
|
+
if (data === "[DONE]") return;
|
|
91
|
+
let parsed: unknown;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(data);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new CodexCompactionProtocolError("Remote compaction returned malformed SSE JSON");
|
|
96
|
+
}
|
|
97
|
+
if (!isObject(parsed)) return;
|
|
98
|
+
if (parsed.type === "response.completed") {
|
|
99
|
+
completedResponse = isObject(parsed.response) ? parsed.response : {};
|
|
100
|
+
}
|
|
101
|
+
for (const candidate of compactionItemsFromEvent(parsed)) {
|
|
102
|
+
const item = validateCompactionItem(candidate, options.maxItemBytes);
|
|
103
|
+
items.set(JSON.stringify(item), item);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const processLine = (line: string) => {
|
|
107
|
+
if (line === "") {
|
|
108
|
+
dispatch();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (line.startsWith(":")) return;
|
|
112
|
+
if (line === "data") dataLines.push("");
|
|
113
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
while (true) {
|
|
118
|
+
checkAbort();
|
|
119
|
+
const { done, value } = await reader.read();
|
|
120
|
+
if (done) break;
|
|
121
|
+
bytes += value.byteLength;
|
|
122
|
+
if (bytes > maxBytes) {
|
|
123
|
+
throw new CodexCompactionProtocolError("Remote compaction stream exceeded the size limit");
|
|
124
|
+
}
|
|
125
|
+
pending += decoder.decode(value, { stream: true });
|
|
126
|
+
let newline = pending.indexOf("\n");
|
|
127
|
+
while (newline !== -1) {
|
|
128
|
+
const rawLine = pending.slice(0, newline);
|
|
129
|
+
pending = pending.slice(newline + 1);
|
|
130
|
+
processLine(rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine);
|
|
131
|
+
newline = pending.indexOf("\n");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
pending += decoder.decode();
|
|
135
|
+
if (pending.length > 0) processLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
|
|
136
|
+
dispatch();
|
|
137
|
+
checkAbort();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
await reader.cancel(error).catch(() => undefined);
|
|
140
|
+
throw error;
|
|
141
|
+
} finally {
|
|
142
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
143
|
+
reader.releaseLock();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!completedResponse) {
|
|
147
|
+
throw new CodexCompactionProtocolError(
|
|
148
|
+
"Remote compaction stream ended without response.completed",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (items.size !== 1) {
|
|
152
|
+
throw new CodexCompactionProtocolError(
|
|
153
|
+
`Remote compaction returned ${items.size} distinct compaction items; expected exactly one`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return { item: [...items.values()][0], completedResponse };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function markerTextFromItem(item: unknown): string | undefined {
|
|
160
|
+
if (!isObject(item) || item.role !== "user" || !Array.isArray(item.content)) return undefined;
|
|
161
|
+
if (item.content.length !== 1) return undefined;
|
|
162
|
+
const content = item.content[0];
|
|
163
|
+
if (!isObject(content) || content.type !== "input_text" || typeof content.text !== "string") {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
return content.text;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function rewriteCheckpointMarker(
|
|
170
|
+
payload: unknown,
|
|
171
|
+
marker: string,
|
|
172
|
+
replacementHistory: readonly unknown[],
|
|
173
|
+
): JsonObject {
|
|
174
|
+
if (!isObject(payload) || !Array.isArray(payload.input)) {
|
|
175
|
+
throw new CodexCompactionProtocolError("OpenAI Codex payload is missing an input array");
|
|
176
|
+
}
|
|
177
|
+
const matches = payload.input
|
|
178
|
+
.map((item, index) => (markerTextFromItem(item) === marker ? index : -1))
|
|
179
|
+
.filter((index) => index >= 0);
|
|
180
|
+
if (matches.length !== 1) {
|
|
181
|
+
throw new CodexCompactionProtocolError(
|
|
182
|
+
`Provider payload contained ${matches.length} checkpoint markers; expected exactly one`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const index = matches[0];
|
|
186
|
+
return {
|
|
187
|
+
...payload,
|
|
188
|
+
input: [
|
|
189
|
+
...payload.input.slice(0, index),
|
|
190
|
+
...structuredClone(replacementHistory),
|
|
191
|
+
...payload.input.slice(index + 1),
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function appendCompactionTrigger(payload: unknown): JsonObject {
|
|
197
|
+
if (!isObject(payload) || !Array.isArray(payload.input)) {
|
|
198
|
+
throw new CodexCompactionProtocolError("OpenAI Codex payload is missing an input array");
|
|
199
|
+
}
|
|
200
|
+
if (payload.input.some((item) => isObject(item) && item.type === "compaction_trigger")) {
|
|
201
|
+
throw new CodexCompactionProtocolError(
|
|
202
|
+
"Provider payload already contains a compaction trigger",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return { ...payload, input: [...payload.input, { type: "compaction_trigger" }] };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function prepareRemoteCompactionPayload(
|
|
209
|
+
payload: unknown,
|
|
210
|
+
checkpoint?: { marker: string; replacementHistory: readonly unknown[] },
|
|
211
|
+
): JsonObject {
|
|
212
|
+
const expanded = checkpoint
|
|
213
|
+
? rewriteCheckpointMarker(payload, checkpoint.marker, checkpoint.replacementHistory)
|
|
214
|
+
: payload;
|
|
215
|
+
return appendCompactionTrigger(expanded);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function hasCheckpointMarker(payload: unknown, marker: string): boolean {
|
|
219
|
+
return (
|
|
220
|
+
isObject(payload) &&
|
|
221
|
+
Array.isArray(payload.input) &&
|
|
222
|
+
payload.input.some((item) => markerTextFromItem(item) === marker)
|
|
223
|
+
);
|
|
224
|
+
}
|
package/src/remote.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Context, Model, Provider, Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
CodexCompactionProtocolError,
|
|
4
|
+
type CollectedCompaction,
|
|
5
|
+
collectCompactionSse,
|
|
6
|
+
type JsonObject,
|
|
7
|
+
prepareRemoteCompactionPayload,
|
|
8
|
+
} from "./protocol.js";
|
|
9
|
+
|
|
10
|
+
interface PriorCheckpointPayload {
|
|
11
|
+
marker: string;
|
|
12
|
+
replacementHistory: readonly unknown[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RemoteCompactionRequest {
|
|
16
|
+
provider: Provider;
|
|
17
|
+
model: Model<"openai-codex-responses">;
|
|
18
|
+
context: Context;
|
|
19
|
+
apiKey?: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
env?: Record<string, string>;
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
priorCheckpoint?: PriorCheckpointPayload;
|
|
24
|
+
requestTimeoutMs?: number;
|
|
25
|
+
maxRetries?: number;
|
|
26
|
+
fetch?: typeof globalThis.fetch;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RemoteCompactionResponse {
|
|
30
|
+
item: JsonObject;
|
|
31
|
+
promptInput: JsonObject[];
|
|
32
|
+
usage: Usage;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const EMPTY_USAGE: Usage = {
|
|
36
|
+
input: 0,
|
|
37
|
+
output: 0,
|
|
38
|
+
cacheRead: 0,
|
|
39
|
+
cacheWrite: 0,
|
|
40
|
+
totalTokens: 0,
|
|
41
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function isObject(value: unknown): value is JsonObject {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function abortError(): DOMException {
|
|
49
|
+
return new DOMException("Compaction aborted", "AbortError");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function requestRemoteCompaction(
|
|
53
|
+
request: RemoteCompactionRequest,
|
|
54
|
+
): Promise<RemoteCompactionResponse> {
|
|
55
|
+
if (request.signal.aborted) throw abortError();
|
|
56
|
+
let sentInput: JsonObject[] | undefined;
|
|
57
|
+
const inspections: Promise<
|
|
58
|
+
{ ok: true; value: CollectedCompaction } | { ok: false; error: unknown }
|
|
59
|
+
>[] = [];
|
|
60
|
+
const baseFetch = request.fetch ?? globalThis.fetch;
|
|
61
|
+
const inspectedFetch: typeof globalThis.fetch = async (input, init) => {
|
|
62
|
+
const response = await baseFetch(input, init);
|
|
63
|
+
if (!response.ok || !response.body) return response;
|
|
64
|
+
const [providerBody, inspectionBody] = response.body.tee();
|
|
65
|
+
const inspection = collectCompactionSse(inspectionBody, { signal: request.signal }).then(
|
|
66
|
+
(value) => ({ ok: true as const, value }),
|
|
67
|
+
(error: unknown) => ({ ok: false as const, error }),
|
|
68
|
+
);
|
|
69
|
+
inspections.push(inspection);
|
|
70
|
+
return new Response(providerBody, {
|
|
71
|
+
status: response.status,
|
|
72
|
+
statusText: response.statusText,
|
|
73
|
+
headers: response.headers,
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const stream = request.provider.stream(request.model, request.context, {
|
|
78
|
+
apiKey: request.apiKey,
|
|
79
|
+
headers: request.headers,
|
|
80
|
+
env: request.env,
|
|
81
|
+
signal: request.signal,
|
|
82
|
+
transport: "sse",
|
|
83
|
+
cacheRetention: "none",
|
|
84
|
+
timeoutMs: request.requestTimeoutMs ?? 5 * 60 * 1000,
|
|
85
|
+
maxRetries: request.maxRetries ?? 2,
|
|
86
|
+
fetch: inspectedFetch,
|
|
87
|
+
onPayload: (payload) => {
|
|
88
|
+
const prepared = prepareRemoteCompactionPayload(payload, request.priorCheckpoint);
|
|
89
|
+
if (!Array.isArray(prepared.input) || !prepared.input.every(isObject)) {
|
|
90
|
+
throw new CodexCompactionProtocolError(
|
|
91
|
+
"Prepared compaction payload has invalid input items",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
sentInput = structuredClone(prepared.input.slice(0, -1)) as JsonObject[];
|
|
95
|
+
return prepared;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
let usage = EMPTY_USAGE;
|
|
100
|
+
for await (const event of stream) {
|
|
101
|
+
if (request.signal.aborted) throw abortError();
|
|
102
|
+
if (event.type === "error") {
|
|
103
|
+
throw new Error(event.error.errorMessage ?? "OpenAI Codex compaction request failed");
|
|
104
|
+
}
|
|
105
|
+
if (event.type === "done") usage = event.message.usage;
|
|
106
|
+
}
|
|
107
|
+
if (request.signal.aborted) throw abortError();
|
|
108
|
+
if (!sentInput)
|
|
109
|
+
throw new CodexCompactionProtocolError("Provider did not expose a request payload");
|
|
110
|
+
if (inspections.length === 0) {
|
|
111
|
+
throw new CodexCompactionProtocolError("Provider response did not expose an SSE body");
|
|
112
|
+
}
|
|
113
|
+
const inspection = await inspections.at(-1);
|
|
114
|
+
if (request.signal.aborted) throw abortError();
|
|
115
|
+
if (!inspection?.ok) throw inspection?.error ?? new Error("Remote compaction inspection failed");
|
|
116
|
+
return { item: inspection.value.item, promptInput: sentInput, usage };
|
|
117
|
+
}
|