@lll9p/pi-better-compaction 0.2.0 → 0.4.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/README.md +83 -124
- package/README.zh-CN.md +142 -0
- package/package.json +8 -3
- package/src/compact-client-v2.ts +428 -0
- package/src/compact-client.ts +1 -63
- package/src/config.ts +18 -0
- package/src/extension-runtime.ts +204 -10
- package/src/native-fallback.ts +21 -3
- package/src/retained-messages.ts +98 -0
- package/src/runtime.ts +41 -2
- package/src/shared-headers.ts +103 -0
- package/src/types.ts +26 -4
package/src/extension-runtime.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
SessionBeforeCompactEvent,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { executeNativeCompaction } from "./compact-client";
|
|
9
|
+
import { executeV2Compaction } from "./compact-client-v2";
|
|
9
10
|
import { loadExtensionConfig } from "./config";
|
|
10
11
|
import { writeDebugArtifact } from "./debug";
|
|
11
12
|
import { resolveLatestNativeCompactionEntry } from "./details-store";
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
serializeLiveTailToResponsesInput,
|
|
16
17
|
} from "./payload-rewrite";
|
|
17
18
|
import { getCompactionRequestExtras, rememberRequestContext } from "./request-context-cache";
|
|
19
|
+
import { buildRetainedMessages } from "./retained-messages";
|
|
18
20
|
import {
|
|
19
21
|
isResponsesCompatiblePayload,
|
|
20
22
|
resolveNativeCompactionEnvironment,
|
|
@@ -26,6 +28,8 @@ import {
|
|
|
26
28
|
createNativeCompactionResult,
|
|
27
29
|
EXTENSION_ID,
|
|
28
30
|
isNativeCompactionDetails,
|
|
31
|
+
NATIVE_COMPACTION_STRATEGY,
|
|
32
|
+
NATIVE_COMPACTION_STRATEGY_V2,
|
|
29
33
|
type ExtensionConfig,
|
|
30
34
|
type NativeCompactionDetails,
|
|
31
35
|
type NativeCompactionRequestMeta,
|
|
@@ -90,7 +94,7 @@ function buildCompactionInstructions(systemPrompt: string, customInstructions?:
|
|
|
90
94
|
return `${systemPrompt}\n\nAdditional user guidance for this manual /compact request:\n${guidance}`;
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
async function
|
|
97
|
+
async function runResponsesV1Compact(
|
|
94
98
|
event: SessionBeforeCompactEvent,
|
|
95
99
|
ctx: ExtensionContext,
|
|
96
100
|
config: ExtensionConfig,
|
|
@@ -105,7 +109,7 @@ async function runResponsesNativeCompact(
|
|
|
105
109
|
baseUrl: runtime.baseUrl,
|
|
106
110
|
});
|
|
107
111
|
|
|
108
|
-
let requestSource: "session-context" | "latest-native-replay";
|
|
112
|
+
let requestSource: "session-context" | "non-native-session-context" | "latest-native-replay";
|
|
109
113
|
let request: NativeCompactionRequestBody;
|
|
110
114
|
if (latestNativeCompaction.ok) {
|
|
111
115
|
const liveTailEntries = branchEntries.slice(latestNativeCompaction.index + 1);
|
|
@@ -119,8 +123,13 @@ async function runResponsesNativeCompact(
|
|
|
119
123
|
input,
|
|
120
124
|
instructions,
|
|
121
125
|
};
|
|
122
|
-
} else if (
|
|
123
|
-
|
|
126
|
+
} else if (
|
|
127
|
+
latestNativeCompaction.reason === "no-compaction" ||
|
|
128
|
+
(latestNativeCompaction.reason === "latest-compaction-not-native" &&
|
|
129
|
+
config.allowCompactionContinuityBreak)
|
|
130
|
+
) {
|
|
131
|
+
requestSource =
|
|
132
|
+
latestNativeCompaction.reason === "no-compaction" ? "session-context" : "non-native-session-context";
|
|
124
133
|
request = serializeMessagesToCompactRequest({
|
|
125
134
|
model: runtime.currentModel,
|
|
126
135
|
messages: ctx.sessionManager.buildSessionContext().messages,
|
|
@@ -130,7 +139,7 @@ async function runResponsesNativeCompact(
|
|
|
130
139
|
writeDebugArtifact(
|
|
131
140
|
"compaction-event",
|
|
132
141
|
{
|
|
133
|
-
event: "session_before_compact.
|
|
142
|
+
event: "session_before_compact.v1-compact-skip",
|
|
134
143
|
reason: latestNativeCompaction.reason,
|
|
135
144
|
provider: runtime.provider,
|
|
136
145
|
api: runtime.api,
|
|
@@ -164,7 +173,7 @@ async function runResponsesNativeCompact(
|
|
|
164
173
|
writeDebugArtifact(
|
|
165
174
|
"compaction-event",
|
|
166
175
|
{
|
|
167
|
-
event: "session_before_compact.
|
|
176
|
+
event: "session_before_compact.v1-compact-failure",
|
|
168
177
|
reason: compactResult.reason,
|
|
169
178
|
status: compactResult.status,
|
|
170
179
|
errorMessage: compactResult.errorMessage,
|
|
@@ -191,7 +200,7 @@ async function runResponsesNativeCompact(
|
|
|
191
200
|
writeDebugArtifact(
|
|
192
201
|
"compaction-event",
|
|
193
202
|
{
|
|
194
|
-
event: "session_before_compact.invalid-native-details",
|
|
203
|
+
event: "session_before_compact.v1-invalid-native-details",
|
|
195
204
|
reason: error instanceof Error ? error.message : String(error),
|
|
196
205
|
provider: runtime.provider,
|
|
197
206
|
api: runtime.api,
|
|
@@ -214,7 +223,7 @@ async function runResponsesNativeCompact(
|
|
|
214
223
|
writeDebugArtifact(
|
|
215
224
|
"compaction-event",
|
|
216
225
|
{
|
|
217
|
-
event: "session_before_compact.
|
|
226
|
+
event: "session_before_compact.v1-compact-success",
|
|
218
227
|
provider: runtime.provider,
|
|
219
228
|
api: runtime.api,
|
|
220
229
|
model: runtime.model,
|
|
@@ -233,6 +242,164 @@ async function runResponsesNativeCompact(
|
|
|
233
242
|
return { outcome: "success", compaction };
|
|
234
243
|
}
|
|
235
244
|
|
|
245
|
+
/**
|
|
246
|
+
* V2 compaction: stream a Responses request with compaction_trigger appended.
|
|
247
|
+
* On success, returns retained messages + encrypted compaction blob.
|
|
248
|
+
*/
|
|
249
|
+
async function runResponsesV2Compact(
|
|
250
|
+
event: SessionBeforeCompactEvent,
|
|
251
|
+
ctx: ExtensionContext,
|
|
252
|
+
config: ExtensionConfig,
|
|
253
|
+
runtime: NativeCompactionRuntime,
|
|
254
|
+
): Promise<ResponsesCompactOutcome> {
|
|
255
|
+
const instructions = buildCompactionInstructions(ctx.getSystemPrompt(), event.customInstructions);
|
|
256
|
+
const branchEntries = ctx.sessionManager.getBranch();
|
|
257
|
+
const latestNativeCompaction = resolveLatestNativeCompactionEntry(branchEntries, {
|
|
258
|
+
provider: runtime.provider,
|
|
259
|
+
api: runtime.api,
|
|
260
|
+
model: runtime.model,
|
|
261
|
+
baseUrl: runtime.baseUrl,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
let requestSource: "session-context" | "non-native-session-context" | "latest-native-replay";
|
|
265
|
+
let request: NativeCompactionRequestBody;
|
|
266
|
+
if (latestNativeCompaction.ok) {
|
|
267
|
+
const liveTailEntries = branchEntries.slice(latestNativeCompaction.index + 1);
|
|
268
|
+
requestSource = "latest-native-replay";
|
|
269
|
+
const input: ResponsesInputItem[] = [
|
|
270
|
+
...(cloneOpaqueWindow(latestNativeCompaction.entry.details.compactedWindow) as ResponsesInputItem[]),
|
|
271
|
+
...serializeLiveTailToResponsesInput({ model: runtime.currentModel, entries: liveTailEntries }),
|
|
272
|
+
];
|
|
273
|
+
request = {
|
|
274
|
+
model: runtime.currentModel.id,
|
|
275
|
+
input,
|
|
276
|
+
instructions,
|
|
277
|
+
};
|
|
278
|
+
} else if (
|
|
279
|
+
latestNativeCompaction.reason === "no-compaction" ||
|
|
280
|
+
(latestNativeCompaction.reason === "latest-compaction-not-native" &&
|
|
281
|
+
config.allowCompactionContinuityBreak)
|
|
282
|
+
) {
|
|
283
|
+
requestSource =
|
|
284
|
+
latestNativeCompaction.reason === "no-compaction" ? "session-context" : "non-native-session-context";
|
|
285
|
+
request = serializeMessagesToCompactRequest({
|
|
286
|
+
model: runtime.currentModel,
|
|
287
|
+
messages: ctx.sessionManager.buildSessionContext().messages,
|
|
288
|
+
instructions,
|
|
289
|
+
});
|
|
290
|
+
} else {
|
|
291
|
+
writeDebugArtifact(
|
|
292
|
+
"compaction-event",
|
|
293
|
+
{
|
|
294
|
+
event: "session_before_compact.v2-compact-skip",
|
|
295
|
+
reason: latestNativeCompaction.reason,
|
|
296
|
+
provider: runtime.provider,
|
|
297
|
+
api: runtime.api,
|
|
298
|
+
model: runtime.model,
|
|
299
|
+
baseUrl: runtime.baseUrl,
|
|
300
|
+
latestCompactionIndex: latestNativeCompaction.latestCompactionIndex,
|
|
301
|
+
latestCompactionIdentity: getCompactionIdentityDebugInfo(latestNativeCompaction.latestCompaction),
|
|
302
|
+
},
|
|
303
|
+
config,
|
|
304
|
+
ctx,
|
|
305
|
+
);
|
|
306
|
+
return { outcome: "failed" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const extras = getCompactionRequestExtras(runtime.model, getSessionId(ctx));
|
|
310
|
+
if (extras) {
|
|
311
|
+
request = { ...request, ...extras };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const v2Result = await executeV2Compaction({
|
|
315
|
+
runtime,
|
|
316
|
+
request,
|
|
317
|
+
signal: event.signal,
|
|
318
|
+
settings: config,
|
|
319
|
+
context: ctx,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (!v2Result.ok) {
|
|
323
|
+
writeDebugArtifact(
|
|
324
|
+
"compaction-event",
|
|
325
|
+
{
|
|
326
|
+
event: "session_before_compact.v2-compact-failure",
|
|
327
|
+
reason: v2Result.reason,
|
|
328
|
+
status: v2Result.status,
|
|
329
|
+
errorMessage: v2Result.errorMessage,
|
|
330
|
+
},
|
|
331
|
+
config,
|
|
332
|
+
ctx,
|
|
333
|
+
);
|
|
334
|
+
return v2Result.reason === "aborted" ? { outcome: "aborted" } : { outcome: "failed" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Build compacted window: retained messages + compaction blob.
|
|
338
|
+
const retainedMessages = buildRetainedMessages(request.input);
|
|
339
|
+
const compactedWindow = [...retainedMessages, v2Result.compactionItem];
|
|
340
|
+
|
|
341
|
+
let details: NativeCompactionDetails;
|
|
342
|
+
try {
|
|
343
|
+
details = createNativeCompactionDetails(
|
|
344
|
+
{
|
|
345
|
+
provider: runtime.provider,
|
|
346
|
+
api: runtime.api,
|
|
347
|
+
model: runtime.model,
|
|
348
|
+
baseUrl: runtime.baseUrl,
|
|
349
|
+
compactedWindow,
|
|
350
|
+
compactResponseId: v2Result.responseId,
|
|
351
|
+
createdAt: v2Result.createdAt,
|
|
352
|
+
requestMeta: buildCompactionRequestMeta(event),
|
|
353
|
+
},
|
|
354
|
+
NATIVE_COMPACTION_STRATEGY_V2,
|
|
355
|
+
);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
writeDebugArtifact(
|
|
358
|
+
"compaction-event",
|
|
359
|
+
{
|
|
360
|
+
event: "session_before_compact.v2-invalid-native-details",
|
|
361
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
362
|
+
provider: runtime.provider,
|
|
363
|
+
api: runtime.api,
|
|
364
|
+
model: runtime.model,
|
|
365
|
+
baseUrl: runtime.baseUrl,
|
|
366
|
+
},
|
|
367
|
+
config,
|
|
368
|
+
ctx,
|
|
369
|
+
);
|
|
370
|
+
return { outcome: "failed" };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// V2 blob is encrypted; no summary text can be extracted.
|
|
374
|
+
const compaction = createNativeCompactionResult({
|
|
375
|
+
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
376
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
377
|
+
details,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
writeDebugArtifact(
|
|
381
|
+
"compaction-event",
|
|
382
|
+
{
|
|
383
|
+
event: "session_before_compact.v2-compact-success",
|
|
384
|
+
provider: runtime.provider,
|
|
385
|
+
api: runtime.api,
|
|
386
|
+
model: runtime.model,
|
|
387
|
+
requestSource,
|
|
388
|
+
requestInputItems: request.input.length,
|
|
389
|
+
requestExtras: extras ? Object.keys(extras) : [],
|
|
390
|
+
compactResponseId: v2Result.responseId,
|
|
391
|
+
retainedMessageCount: retainedMessages.length,
|
|
392
|
+
compactedItems: compactedWindow.length,
|
|
393
|
+
usage: v2Result.usage,
|
|
394
|
+
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
395
|
+
},
|
|
396
|
+
config,
|
|
397
|
+
ctx,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return { outcome: "success", compaction };
|
|
401
|
+
}
|
|
402
|
+
|
|
236
403
|
async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx: ExtensionContext) {
|
|
237
404
|
const { config } = loadExtensionConfig();
|
|
238
405
|
if (!config.enabled) {
|
|
@@ -266,7 +433,14 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
|
|
|
266
433
|
responsesCompactApis: config.responsesCompactApis,
|
|
267
434
|
});
|
|
268
435
|
if (resolution.ok) {
|
|
269
|
-
|
|
436
|
+
let responsesOutcome: ResponsesCompactOutcome;
|
|
437
|
+
|
|
438
|
+
if (config.compactionVersion === "v2") {
|
|
439
|
+
responsesOutcome = await runResponsesV2Compact(event, ctx, config, resolution.runtime);
|
|
440
|
+
} else {
|
|
441
|
+
responsesOutcome = await runResponsesV1Compact(event, ctx, config, resolution.runtime);
|
|
442
|
+
}
|
|
443
|
+
|
|
270
444
|
if (responsesOutcome.outcome === "success") {
|
|
271
445
|
return { compaction: responsesOutcome.compaction };
|
|
272
446
|
}
|
|
@@ -291,7 +465,7 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
|
|
|
291
465
|
}
|
|
292
466
|
|
|
293
467
|
// Branch 2: run pi's native compaction method with the configured model.
|
|
294
|
-
const fallback = await runNativeFallbackCompaction({ ctx, event, config });
|
|
468
|
+
const fallback = await runNativeFallbackCompaction({ ctx, event, config, sessionId: getSessionId(ctx) });
|
|
295
469
|
if (fallback.ok) {
|
|
296
470
|
if (ctx.hasUI) {
|
|
297
471
|
ctx.ui.notify(
|
|
@@ -304,6 +478,7 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
|
|
|
304
478
|
{
|
|
305
479
|
event: "session_before_compact.fallback-success",
|
|
306
480
|
model: fallback.model,
|
|
481
|
+
usage: fallback.usage,
|
|
307
482
|
},
|
|
308
483
|
config,
|
|
309
484
|
ctx,
|
|
@@ -496,4 +671,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
496
671
|
|
|
497
672
|
pi.on("session_before_compact", handleSessionBeforeCompact);
|
|
498
673
|
pi.on("before_provider_request", handleBeforeProviderRequest);
|
|
674
|
+
|
|
675
|
+
pi.on("session_compact_failed", (event, ctx) => {
|
|
676
|
+
const { config } = loadExtensionConfig();
|
|
677
|
+
if (!config.enabled) return;
|
|
678
|
+
|
|
679
|
+
writeDebugArtifact(
|
|
680
|
+
"compaction-event",
|
|
681
|
+
{
|
|
682
|
+
event: "session_compact_failed",
|
|
683
|
+
reason: event.reason,
|
|
684
|
+
errorMessage: event.errorMessage,
|
|
685
|
+
aborted: event.aborted,
|
|
686
|
+
willRetry: event.willRetry,
|
|
687
|
+
fromExtension: event.fromExtension,
|
|
688
|
+
},
|
|
689
|
+
config,
|
|
690
|
+
ctx,
|
|
691
|
+
);
|
|
692
|
+
});
|
|
499
693
|
}
|
package/src/native-fallback.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type NativeFallbackResult =
|
|
|
26
26
|
ok: true;
|
|
27
27
|
result: CompactionResult;
|
|
28
28
|
model: { provider: string; id: string };
|
|
29
|
+
usage?: CompactionResult["usage"];
|
|
29
30
|
}
|
|
30
31
|
| {
|
|
31
32
|
ok: false;
|
|
@@ -38,9 +39,21 @@ export type NativeFallbackResult =
|
|
|
38
39
|
export type NativeCompactFn = typeof compact;
|
|
39
40
|
|
|
40
41
|
type ResolvedAuth =
|
|
41
|
-
| { ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
|
|
42
|
+
| { ok: true; apiKey?: string; headers?: Record<string, string | null>; env?: Record<string, string> }
|
|
42
43
|
| { ok: false; error: string };
|
|
43
44
|
|
|
45
|
+
/** Strip null-valued entries so downstream consumers receive a clean Record<string, string>. */
|
|
46
|
+
function filterNullHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
|
|
47
|
+
if (!headers) return undefined;
|
|
48
|
+
const filtered: Record<string, string> = {};
|
|
49
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
50
|
+
if (value !== null) {
|
|
51
|
+
filtered[key] = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return Object.keys(filtered).length > 0 ? filtered : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
/** Parse "provider/model-id" (model ids may themselves contain slashes). */
|
|
45
58
|
export function parseModelSpec(spec: string): ParsedModelSpec | undefined {
|
|
46
59
|
const trimmed = spec.trim();
|
|
@@ -82,6 +95,7 @@ export async function runNativeFallbackCompaction(args: {
|
|
|
82
95
|
event: SessionBeforeCompactEvent;
|
|
83
96
|
config: ExtensionConfig;
|
|
84
97
|
compactFn?: NativeCompactFn;
|
|
98
|
+
sessionId?: string;
|
|
85
99
|
}): Promise<NativeFallbackResult> {
|
|
86
100
|
const { ctx, event, config } = args;
|
|
87
101
|
const compactFn = args.compactFn ?? compact;
|
|
@@ -120,12 +134,15 @@ export async function runNativeFallbackCompaction(args: {
|
|
|
120
134
|
event.preparation,
|
|
121
135
|
model,
|
|
122
136
|
auth.apiKey,
|
|
123
|
-
auth.headers,
|
|
137
|
+
filterNullHeaders(auth.headers),
|
|
124
138
|
event.customInstructions,
|
|
125
139
|
event.signal,
|
|
126
140
|
config.compactionThinkingLevel,
|
|
127
|
-
undefined,
|
|
141
|
+
undefined, // streamFn
|
|
128
142
|
auth.env,
|
|
143
|
+
undefined, // retry (use pi defaults)
|
|
144
|
+
undefined, // callbacks
|
|
145
|
+
args.sessionId,
|
|
129
146
|
);
|
|
130
147
|
|
|
131
148
|
if (event.signal.aborted) {
|
|
@@ -139,6 +156,7 @@ export async function runNativeFallbackCompaction(args: {
|
|
|
139
156
|
ok: true,
|
|
140
157
|
result,
|
|
141
158
|
model: { provider: model.provider, id: model.id },
|
|
159
|
+
usage: result.usage,
|
|
142
160
|
};
|
|
143
161
|
} catch (error) {
|
|
144
162
|
if (event.signal.aborted || isAbortError(error)) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V2 compaction retained-message filtering.
|
|
3
|
+
*
|
|
4
|
+
* After the API returns an encrypted compaction blob, V2 keeps a window of recent
|
|
5
|
+
* user/developer/system messages alongside the blob so the model retains explicit
|
|
6
|
+
* user instructions and context anchors. This module mirrors the filtering and
|
|
7
|
+
* budget logic from codex-rs `compact_remote_v2.rs`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Token budget for retained messages (matches codex-rs RETAINED_MESSAGE_TOKEN_BUDGET). */
|
|
11
|
+
export const RETAINED_MESSAGE_TOKEN_BUDGET = 65_536;
|
|
12
|
+
|
|
13
|
+
/** Messages larger than this are excluded even if they are of a retained role. */
|
|
14
|
+
const MAX_SINGLE_ITEM_TOKENS = 10_000;
|
|
15
|
+
|
|
16
|
+
/** Rough chars-per-token ratio for budget estimation. */
|
|
17
|
+
const CHARS_PER_TOKEN = 4;
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Estimate the token count of an opaque input item by serializing to JSON and
|
|
25
|
+
* dividing by the chars-per-token ratio. This is intentionally rough — codex-rs
|
|
26
|
+
* uses a real tokenizer but we don't have one in the extension runtime.
|
|
27
|
+
*/
|
|
28
|
+
export function estimateItemTokens(item: unknown): number {
|
|
29
|
+
try {
|
|
30
|
+
const length = JSON.stringify(item).length;
|
|
31
|
+
return Math.ceil(length / CHARS_PER_TOKEN);
|
|
32
|
+
} catch {
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether an input item should be retained alongside the compaction blob.
|
|
39
|
+
*
|
|
40
|
+
* Retained roles (matching codex-rs `is_retained_for_remote_compaction_v2`):
|
|
41
|
+
* - `user` messages
|
|
42
|
+
* - `developer` messages
|
|
43
|
+
* - `system` messages
|
|
44
|
+
*
|
|
45
|
+
* Everything else (assistant, function_call, function_call_output, reasoning,
|
|
46
|
+
* compaction, etc.) is excluded.
|
|
47
|
+
*/
|
|
48
|
+
export function isRetainedItem(item: unknown): boolean {
|
|
49
|
+
if (!isRecord(item)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const role = item.role;
|
|
54
|
+
if (typeof role === "string") {
|
|
55
|
+
return role === "user" || role === "developer" || role === "system";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* From a list of Responses API input items, select the most recent retained
|
|
63
|
+
* messages that fit within the token budget. Items are selected newest-first
|
|
64
|
+
* (reverse order) and returned in their original chronological order.
|
|
65
|
+
*
|
|
66
|
+
* Oversized individual items (> MAX_SINGLE_ITEM_TOKENS) are skipped.
|
|
67
|
+
*/
|
|
68
|
+
export function buildRetainedMessages(
|
|
69
|
+
input: readonly unknown[],
|
|
70
|
+
budget: number = RETAINED_MESSAGE_TOKEN_BUDGET,
|
|
71
|
+
): unknown[] {
|
|
72
|
+
// Filter to retained roles first.
|
|
73
|
+
const retained: Array<{ index: number; item: unknown; tokens: number }> = [];
|
|
74
|
+
for (let i = 0; i < input.length; i++) {
|
|
75
|
+
const item = input[i];
|
|
76
|
+
if (!isRetainedItem(item)) continue;
|
|
77
|
+
|
|
78
|
+
const tokens = estimateItemTokens(item);
|
|
79
|
+
if (tokens > MAX_SINGLE_ITEM_TOKENS) continue;
|
|
80
|
+
|
|
81
|
+
retained.push({ index: i, item, tokens });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Select from newest to oldest within the budget.
|
|
85
|
+
let remaining = budget;
|
|
86
|
+
const selected: typeof retained = [];
|
|
87
|
+
|
|
88
|
+
for (let i = retained.length - 1; i >= 0; i--) {
|
|
89
|
+
const entry = retained[i]!;
|
|
90
|
+
if (entry.tokens > remaining) break;
|
|
91
|
+
remaining -= entry.tokens;
|
|
92
|
+
selected.push(entry);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Return in original chronological order.
|
|
96
|
+
selected.reverse();
|
|
97
|
+
return selected.map((entry) => structuredClone(entry.item));
|
|
98
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { RESPONSES_COMPACT_CAPABLE_APIS } from "./types";
|
|
|
4
4
|
|
|
5
5
|
const OPENAI_COMPACT_PATH = "responses/compact";
|
|
6
6
|
const CODEX_COMPACT_PATH = "codex/responses/compact";
|
|
7
|
+
const OPENAI_RESPONSES_PATH = "responses";
|
|
8
|
+
const CODEX_RESPONSES_PATH = "codex/responses";
|
|
7
9
|
|
|
8
10
|
type ResponsesCompactApi = (typeof RESPONSES_COMPACT_CAPABLE_APIS)[number];
|
|
9
11
|
|
|
@@ -40,6 +42,7 @@ export type NativeCompactionRuntime = {
|
|
|
40
42
|
headers?: Record<string, string>;
|
|
41
43
|
compactPath: string;
|
|
42
44
|
compactUrl: string;
|
|
45
|
+
responsesUrl: string;
|
|
43
46
|
payload?: ResponsesCompatibleRequestPayload;
|
|
44
47
|
currentModel: RuntimeModel;
|
|
45
48
|
};
|
|
@@ -74,6 +77,29 @@ export function normalizeBaseUrl(baseUrl: string | undefined | null): string | u
|
|
|
74
77
|
return normalized ? normalized : undefined;
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
function buildOpenAIResponsesUrl(baseUrl: string): string {
|
|
81
|
+
const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
|
|
82
|
+
if (normalized.endsWith("/responses")) {
|
|
83
|
+
return normalized;
|
|
84
|
+
}
|
|
85
|
+
return `${normalized}/${OPENAI_RESPONSES_PATH}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildCodexResponsesUrl(baseUrl: string): string {
|
|
89
|
+
const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
|
|
90
|
+
if (normalized.endsWith("/codex/responses")) {
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
if (normalized.endsWith("/codex")) {
|
|
94
|
+
return `${normalized}/responses`;
|
|
95
|
+
}
|
|
96
|
+
return `${normalized}/${CODEX_RESPONSES_PATH}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildResponsesUrl(baseUrl: string, api: ResponsesCompactApi): string {
|
|
100
|
+
return api === "openai-codex-responses" ? buildCodexResponsesUrl(baseUrl) : buildOpenAIResponsesUrl(baseUrl);
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
function buildOpenAICompactUrl(baseUrl: string): string {
|
|
78
104
|
const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
|
|
79
105
|
if (normalized.endsWith("/responses")) {
|
|
@@ -101,13 +127,25 @@ export function buildCompactPath(api: ResponsesCompactApi): string {
|
|
|
101
127
|
return api === "openai-codex-responses" ? CODEX_COMPACT_PATH : OPENAI_COMPACT_PATH;
|
|
102
128
|
}
|
|
103
129
|
|
|
130
|
+
/** Strip null-valued entries so downstream consumers receive a clean Record<string, string>. */
|
|
131
|
+
function filterNullHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
|
|
132
|
+
if (!headers) return undefined;
|
|
133
|
+
const filtered: Record<string, string> = {};
|
|
134
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
135
|
+
if (value !== null) {
|
|
136
|
+
filtered[key] = value;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return Object.keys(filtered).length > 0 ? filtered : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
104
142
|
async function resolveRequestAuth(
|
|
105
143
|
ctx: ExtensionContext,
|
|
106
144
|
model: RuntimeModel,
|
|
107
145
|
): Promise<{ apiKey?: string; headers?: Record<string, string> }> {
|
|
108
146
|
const modelRegistry = ctx.modelRegistry as {
|
|
109
147
|
getApiKeyAndHeaders?: (currentModel: RuntimeModel) => Promise<
|
|
110
|
-
| { ok: true; apiKey?: string; headers?: Record<string, string> }
|
|
148
|
+
| { ok: true; apiKey?: string; headers?: Record<string, string | null> }
|
|
111
149
|
| { ok: false; error: string }
|
|
112
150
|
>;
|
|
113
151
|
};
|
|
@@ -117,7 +155,7 @@ async function resolveRequestAuth(
|
|
|
117
155
|
}
|
|
118
156
|
|
|
119
157
|
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
120
|
-
return auth.ok ? { apiKey: auth.apiKey, headers: auth.headers } : {};
|
|
158
|
+
return auth.ok ? { apiKey: auth.apiKey, headers: filterNullHeaders(auth.headers) } : {};
|
|
121
159
|
}
|
|
122
160
|
|
|
123
161
|
export function isSupportedApi(api: string): api is ResponsesCompactApi {
|
|
@@ -234,6 +272,7 @@ export async function resolveNativeCompactionEnvironment(
|
|
|
234
272
|
headers,
|
|
235
273
|
compactPath: buildCompactPath(descriptor.api),
|
|
236
274
|
compactUrl: buildCompactUrl(descriptor.baseUrl, descriptor.api),
|
|
275
|
+
responsesUrl: buildResponsesUrl(descriptor.baseUrl, descriptor.api),
|
|
237
276
|
payload: requestPayload,
|
|
238
277
|
currentModel,
|
|
239
278
|
},
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP header construction for V1 and V2 compaction clients.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the header building logic that was previously private in
|
|
5
|
+
* compact-client.ts so both compact-client.ts and compact-client-v2.ts
|
|
6
|
+
* can share it without duplication.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { NativeCompactionRuntime } from "./runtime";
|
|
10
|
+
|
|
11
|
+
const JSON_CONTENT_TYPE = "application/json";
|
|
12
|
+
|
|
13
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
14
|
+
const parts = token.split(".");
|
|
15
|
+
if (parts.length !== 3) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const payloadText = Buffer.from(parts[1]!, "base64url").toString("utf8");
|
|
21
|
+
const payload = JSON.parse(payloadText);
|
|
22
|
+
return payload && typeof payload === "object" && !Array.isArray(payload)
|
|
23
|
+
? (payload as Record<string, unknown>)
|
|
24
|
+
: undefined;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractCodexAccountId(token: string): string | undefined {
|
|
35
|
+
const payload = decodeJwtPayload(token);
|
|
36
|
+
const authClaims = payload?.["https://api.openai.com/auth"];
|
|
37
|
+
if (!isRecord(authClaims)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const accountId = authClaims.chatgpt_account_id;
|
|
42
|
+
return typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildCodexUserAgent(): string {
|
|
46
|
+
const platform = typeof process !== "undefined" ? process.platform : "browser";
|
|
47
|
+
const arch = typeof process !== "undefined" ? process.arch : "unknown";
|
|
48
|
+
return `pi (${platform}; ${arch})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build HTTP headers for a compaction request from the resolved runtime.
|
|
53
|
+
*
|
|
54
|
+
* Handles model-level headers, extension-resolved headers, authorization,
|
|
55
|
+
* and Codex-specific headers (account ID, originator, user-agent, beta flag).
|
|
56
|
+
*
|
|
57
|
+
* @param accept - The Accept header value. Defaults to `application/json`.
|
|
58
|
+
*/
|
|
59
|
+
export function toHeaders(
|
|
60
|
+
runtime: NativeCompactionRuntime,
|
|
61
|
+
accept: string = JSON_CONTENT_TYPE,
|
|
62
|
+
): Record<string, string> {
|
|
63
|
+
const headers = new Headers();
|
|
64
|
+
// Model-level headers may contain null values (ProviderHeaders); null means "unset".
|
|
65
|
+
for (const [key, value] of Object.entries(runtime.currentModel.headers ?? {})) {
|
|
66
|
+
if (value != null) {
|
|
67
|
+
headers.set(key, String(value));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Extension-resolved headers (already filtered by resolveRequestAuth, but defensive).
|
|
71
|
+
for (const [key, value] of Object.entries(runtime.headers ?? {})) {
|
|
72
|
+
if (value == null) {
|
|
73
|
+
headers.delete(key);
|
|
74
|
+
} else {
|
|
75
|
+
headers.set(key, value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
headers.set("accept", accept);
|
|
79
|
+
headers.set("content-type", JSON_CONTENT_TYPE);
|
|
80
|
+
if (!headers.has("authorization")) {
|
|
81
|
+
headers.set("authorization", `Bearer ${runtime.apiKey}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (runtime.api === "openai-codex-responses") {
|
|
85
|
+
const accountId = extractCodexAccountId(runtime.apiKey);
|
|
86
|
+
if (accountId) {
|
|
87
|
+
headers.set("chatgpt-account-id", accountId);
|
|
88
|
+
}
|
|
89
|
+
headers.set("originator", "pi");
|
|
90
|
+
headers.set("user-agent", buildCodexUserAgent());
|
|
91
|
+
headers.set("openai-beta", "responses=experimental");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return Object.fromEntries(headers.entries());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Check whether an error represents an intentional abort (AbortController / AbortSignal). */
|
|
98
|
+
export function isAbortError(error: unknown): boolean {
|
|
99
|
+
return (
|
|
100
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
101
|
+
(error instanceof Error && (error.name === "AbortError" || error.name === "ABORT_ERR"))
|
|
102
|
+
);
|
|
103
|
+
}
|