@lll9p/pi-better-compaction 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -7
- package/README.zh-CN.md +0 -6
- package/package.json +1 -2
- package/src/compact-client-v2.ts +26 -7
- package/src/compact-client.ts +1 -1
- package/src/config.ts +0 -13
- package/src/extension-runtime.ts +0 -1
- package/src/payload-rewrite.ts +45 -11
- package/src/runtime.ts +25 -30
- package/src/shared-headers.ts +23 -17
- package/src/types.ts +0 -10
- package/src/midrun.ts +0 -229
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ English | [中文](README.zh-CN.md)
|
|
|
4
4
|
|
|
5
5
|
A [pi](https://github.com/nicepkg/pi) extension that upgrades context compaction with two coordinated strategies:
|
|
6
6
|
|
|
7
|
-
1. **OpenAI Responses APIs
|
|
7
|
+
1. **OpenAI Responses APIs**, including supported GitHub Copilot models, use the provider's native compaction endpoint, preserving opaque context that plain text summaries lose.
|
|
8
8
|
2. **All other APIs** (Anthropic, Gemini, etc.) can run pi's built-in compaction with a **dedicated cheaper/faster model**, so summarization doesn't consume quota on your primary model.
|
|
9
9
|
|
|
10
10
|
Everything fails open — if any step cannot proceed, pi's default compaction takes over.
|
|
@@ -44,10 +44,6 @@ If the file doesn't exist, all defaults apply. The extension never creates this
|
|
|
44
44
|
```jsonc
|
|
45
45
|
{
|
|
46
46
|
"enabled": true,
|
|
47
|
-
"midRun": {
|
|
48
|
-
"enabled": false,
|
|
49
|
-
"thresholdPercent": 80
|
|
50
|
-
},
|
|
51
47
|
"compactionVersion": "v2",
|
|
52
48
|
"compactionModel": null,
|
|
53
49
|
"compactionThinkingLevel": "off",
|
|
@@ -69,8 +65,6 @@ If the file doesn't exist, all defaults apply. The extension never creates this
|
|
|
69
65
|
| Option | Type | Default | Description |
|
|
70
66
|
|--------|------|---------|-------------|
|
|
71
67
|
| `enabled` | `boolean` | `true` | Master switch. Set `false` to disable the extension entirely. |
|
|
72
|
-
| `midRun.enabled` | `boolean` | `false` | Reserved and currently ignored. The mid-run guard remains disabled even when set to `true`. |
|
|
73
|
-
| `midRun.thresholdPercent` | `number` | `80` | Reserved threshold for the disabled mid-run guard. Must be greater than 0 and at most 100. |
|
|
74
68
|
| `compactionVersion` | `"v1" \| "v2"` | `"v2"` | Protocol for Responses-family APIs. **V2** (streaming, encrypted blob) is the current OpenAI default. **V1** uses the legacy `/responses/compact` endpoint. |
|
|
75
69
|
| `compactionModel` | `string \| null` | `null` | Model for fallback compaction (non-Responses APIs, or when native compact fails). Format: `"provider/model-id"`, e.g. `"openai/gpt-5.1-mini"`. `null` = let pi use the current chat model. |
|
|
76
70
|
| `compactionThinkingLevel` | `string` | `"off"` | Thinking level for the fallback compaction model. One of: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. |
|
package/README.zh-CN.md
CHANGED
|
@@ -44,10 +44,6 @@ cd pi-better-compaction && pi install .
|
|
|
44
44
|
```jsonc
|
|
45
45
|
{
|
|
46
46
|
"enabled": true,
|
|
47
|
-
"midRun": {
|
|
48
|
-
"enabled": false,
|
|
49
|
-
"thresholdPercent": 80
|
|
50
|
-
},
|
|
51
47
|
"compactionVersion": "v2",
|
|
52
48
|
"compactionModel": null,
|
|
53
49
|
"compactionThinkingLevel": "off",
|
|
@@ -69,8 +65,6 @@ cd pi-better-compaction && pi install .
|
|
|
69
65
|
| 选项 | 类型 | 默认值 | 说明 |
|
|
70
66
|
|------|------|--------|------|
|
|
71
67
|
| `enabled` | `boolean` | `true` | 总开关。设为 `false` 完全禁用扩展。 |
|
|
72
|
-
| `midRun.enabled` | `boolean` | `false` | 保留配置,当前会被忽略。即使设为 `true`,mid-run guard 仍保持禁用。 |
|
|
73
|
-
| `midRun.thresholdPercent` | `number` | `80` | 已禁用的 mid-run guard 的保留阈值。必须大于 0 且不超过 100。 |
|
|
74
68
|
| `compactionVersion` | `"v1" \| "v2"` | `"v2"` | Responses 系列 API 的压缩协议。**V2**(流式,加密 blob)是 OpenAI 当前默认协议;**V1** 使用旧版 `/responses/compact` 端点。 |
|
|
75
69
|
| `compactionModel` | `string \| null` | `null` | 回退压缩使用的模型(用于非 Responses API,或原生压缩失败时)。格式:`"provider/model-id"`,如 `"openai/gpt-5.1-mini"`。`null` = 由 pi 使用当前对话模型。 |
|
|
76
70
|
| `compactionThinkingLevel` | `string` | `"off"` | 回退压缩模型的思考级别。可选:`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`。 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lll9p/pi-better-compaction",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Better compaction for pi: native /responses/compact replay for OpenAI Responses APIs, plus a configurable compaction model driving pi's native summarization everywhere else.",
|
|
6
6
|
"author": "Lilin Lao",
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
"src/debug.ts",
|
|
39
39
|
"src/details-store.ts",
|
|
40
40
|
"src/extension-runtime.ts",
|
|
41
|
-
"src/midrun.ts",
|
|
42
41
|
"src/native-fallback.ts",
|
|
43
42
|
"src/payload-rewrite.ts",
|
|
44
43
|
"src/request-context-cache.ts",
|
package/src/compact-client-v2.ts
CHANGED
|
@@ -67,6 +67,8 @@ export type ExecuteV2CompactionOptions = {
|
|
|
67
67
|
|
|
68
68
|
const DEFAULT_MAX_RETRIES = 2;
|
|
69
69
|
const SSE_ACCEPT = "text/event-stream";
|
|
70
|
+
// Copilot rejects compaction_trigger with a smaller explicit output ceiling.
|
|
71
|
+
const COPILOT_COMPACTION_OUTPUT_CEILING = 20_000;
|
|
70
72
|
|
|
71
73
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
72
74
|
|
|
@@ -123,7 +125,7 @@ async function collectStreamOutput(response: Response, signal?: AbortSignal): Pr
|
|
|
123
125
|
let buffer = "";
|
|
124
126
|
|
|
125
127
|
try {
|
|
126
|
-
while (true) {
|
|
128
|
+
stream: while (true) {
|
|
127
129
|
if (signal?.aborted) {
|
|
128
130
|
reader.cancel();
|
|
129
131
|
return { ok: false, reason: "aborted" as const };
|
|
@@ -176,7 +178,7 @@ async function collectStreamOutput(response: Response, signal?: AbortSignal): Pr
|
|
|
176
178
|
usage = resp.usage as V2CompactionUsage;
|
|
177
179
|
}
|
|
178
180
|
}
|
|
179
|
-
|
|
181
|
+
break stream;
|
|
180
182
|
}
|
|
181
183
|
|
|
182
184
|
if (eventType === "response.failed" || eventType === "error") {
|
|
@@ -184,16 +186,18 @@ async function collectStreamOutput(response: Response, signal?: AbortSignal): Pr
|
|
|
184
186
|
serverError = isRecord(errorObj)
|
|
185
187
|
? (typeof errorObj.message === "string" ? errorObj.message : JSON.stringify(errorObj))
|
|
186
188
|
: String(errorObj);
|
|
187
|
-
|
|
189
|
+
break stream;
|
|
188
190
|
}
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
} catch (error) {
|
|
192
|
-
if (isAbortError(error)) {
|
|
194
|
+
if (signal?.aborted || isAbortError(error)) {
|
|
193
195
|
return { ok: false, reason: "aborted" as const };
|
|
194
196
|
}
|
|
195
197
|
return { ok: false, reason: "stream-parse-error", errorMessage: error instanceof Error ? error.message : String(error) };
|
|
196
198
|
} finally {
|
|
199
|
+
// Completion is terminal even when a gateway keeps the HTTP body open.
|
|
200
|
+
try { await reader.cancel(); } catch { /* noop */ }
|
|
197
201
|
try { reader.releaseLock(); } catch { /* noop */ }
|
|
198
202
|
}
|
|
199
203
|
|
|
@@ -237,7 +241,7 @@ async function executeV2Attempt(
|
|
|
237
241
|
signal,
|
|
238
242
|
});
|
|
239
243
|
} catch (error) {
|
|
240
|
-
if (isAbortError(error)) {
|
|
244
|
+
if (signal?.aborted || isAbortError(error)) {
|
|
241
245
|
return { failure: { ok: false, reason: "aborted" } };
|
|
242
246
|
}
|
|
243
247
|
return {
|
|
@@ -294,21 +298,36 @@ function isRetryable(result: V2CompactionFailure): boolean {
|
|
|
294
298
|
* to the input, streams the SSE response, and collects the compaction blob.
|
|
295
299
|
*
|
|
296
300
|
* Retries recoverable failures up to `maxRetries` times (default 2).
|
|
301
|
+
*
|
|
302
|
+
* Copilot routes in Pi 0.84.4; tested means live native compaction and replay.
|
|
303
|
+
*
|
|
304
|
+
* | Provider (via Copilot) | API | Tested |
|
|
305
|
+
* | --- | --- | --- |
|
|
306
|
+
* | OpenAI (Astra) | Responses | Yes |
|
|
307
|
+
* | xAI (Grok 4.6) | Responses | Failed (HTTP 422) |
|
|
308
|
+
* | Google (Gemini) | Chat Completions | No; outside this path |
|
|
309
|
+
* | Anthropic (Opus) | Messages | No; outside this path |
|
|
297
310
|
*/
|
|
298
311
|
export async function executeV2Compaction(
|
|
299
312
|
options: ExecuteV2CompactionOptions,
|
|
300
313
|
): Promise<V2CompactionResult> {
|
|
301
|
-
const { runtime, request,
|
|
314
|
+
const { runtime, request, settings, context } = options;
|
|
315
|
+
const deadline = AbortSignal.timeout(600_000);
|
|
316
|
+
const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
|
|
302
317
|
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
303
318
|
|
|
304
|
-
const headers = toHeaders(runtime, SSE_ACCEPT);
|
|
319
|
+
const headers = toHeaders(runtime, SSE_ACCEPT, request.input);
|
|
305
320
|
const url = runtime.responsesUrl;
|
|
306
321
|
|
|
307
322
|
// Build request body: input + compaction_trigger, stream=true.
|
|
308
323
|
const requestBody = {
|
|
309
324
|
...request,
|
|
310
325
|
input: [...request.input, { type: "compaction_trigger" }],
|
|
326
|
+
store: false,
|
|
311
327
|
stream: true,
|
|
328
|
+
...(runtime.provider === "github-copilot" ? {
|
|
329
|
+
max_output_tokens: COPILOT_COMPACTION_OUTPUT_CEILING,
|
|
330
|
+
} : {}),
|
|
312
331
|
};
|
|
313
332
|
|
|
314
333
|
let lastFailure: V2CompactionFailure | undefined;
|
package/src/compact-client.ts
CHANGED
|
@@ -121,7 +121,7 @@ export async function executeNativeCompaction(
|
|
|
121
121
|
options: ExecuteNativeCompactionOptions,
|
|
122
122
|
): Promise<NativeCompactionClientResult> {
|
|
123
123
|
const { runtime, request, signal, settings, context } = options;
|
|
124
|
-
const headers = toHeaders(runtime);
|
|
124
|
+
const headers = toHeaders(runtime, JSON_CONTENT_TYPE, request.input);
|
|
125
125
|
|
|
126
126
|
if (signal?.aborted) {
|
|
127
127
|
const aborted: NativeCompactionClientFailure = {
|
package/src/config.ts
CHANGED
|
@@ -128,7 +128,6 @@ export function loadExtensionConfig(configPath: string = CONFIG_PATH): LoadedExt
|
|
|
128
128
|
const warnings: string[] = [];
|
|
129
129
|
const resolved: ExtensionConfig = {
|
|
130
130
|
...DEFAULT_EXTENSION_CONFIG,
|
|
131
|
-
midRun: { ...DEFAULT_EXTENSION_CONFIG.midRun },
|
|
132
131
|
responsesCompactApis: [...DEFAULT_EXTENSION_CONFIG.responsesCompactApis],
|
|
133
132
|
};
|
|
134
133
|
let source: string | undefined;
|
|
@@ -139,18 +138,6 @@ export function loadExtensionConfig(configPath: string = CONFIG_PATH): LoadedExt
|
|
|
139
138
|
|
|
140
139
|
resolved.enabled = toBoolean(raw.enabled, "enabled", warnings) ?? resolved.enabled;
|
|
141
140
|
|
|
142
|
-
if (raw.midRun === undefined) {
|
|
143
|
-
// Keep defaults.
|
|
144
|
-
} else if (isRecord(raw.midRun)) {
|
|
145
|
-
resolved.midRun.enabled =
|
|
146
|
-
toBoolean(raw.midRun.enabled, "midRun.enabled", warnings) ?? resolved.midRun.enabled;
|
|
147
|
-
resolved.midRun.thresholdPercent =
|
|
148
|
-
toThresholdPercent(raw.midRun.thresholdPercent, "midRun.thresholdPercent", warnings) ??
|
|
149
|
-
resolved.midRun.thresholdPercent;
|
|
150
|
-
} else {
|
|
151
|
-
warnings.push("Ignoring midRun: expected a JSON object.");
|
|
152
|
-
}
|
|
153
|
-
|
|
154
141
|
resolved.allowCompactionContinuityBreak =
|
|
155
142
|
toBoolean(raw.allowCompactionContinuityBreak, "allowCompactionContinuityBreak", warnings) ??
|
|
156
143
|
resolved.allowCompactionContinuityBreak;
|
package/src/extension-runtime.ts
CHANGED
|
@@ -671,7 +671,6 @@ export function registerExtensionRuntime(
|
|
|
671
671
|
pi: ExtensionAPI,
|
|
672
672
|
dependencies: ExtensionRuntimeDependencies = DEFAULT_DEPENDENCIES,
|
|
673
673
|
): void {
|
|
674
|
-
// Mid-run compaction is intentionally disabled regardless of config.
|
|
675
674
|
pi.on("session_start", (_event, ctx) => {
|
|
676
675
|
const { config, source, warnings } = dependencies.loadExtensionConfig();
|
|
677
676
|
if (!config.enabled) return;
|
package/src/payload-rewrite.ts
CHANGED
|
@@ -227,6 +227,43 @@ function areEquivalentValues(left: unknown, right: unknown): boolean {
|
|
|
227
227
|
return false;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// Match pruned output runs by call ID; all other items must stay unchanged. -- PI/gpt-6-astra
|
|
231
|
+
/** @internal — exported for unit testing only */
|
|
232
|
+
export function alignPrunedInput(actual: readonly unknown[], expected: readonly unknown[]): number[] | undefined {
|
|
233
|
+
const indices: number[] = [];
|
|
234
|
+
let actualIndex = 0;
|
|
235
|
+
for (let expectedIndex = 0; expectedIndex < expected.length;) {
|
|
236
|
+
const item = expected[expectedIndex];
|
|
237
|
+
if (isRecord(item) && item.type === "function_call_output") {
|
|
238
|
+
const outputs = new Map<string, number>();
|
|
239
|
+
while (expectedIndex < expected.length) {
|
|
240
|
+
const output = expected[expectedIndex];
|
|
241
|
+
if (!isRecord(output) || output.type !== "function_call_output") break;
|
|
242
|
+
if (typeof output.call_id !== "string" || outputs.has(output.call_id)) return undefined;
|
|
243
|
+
outputs.set(output.call_id, expectedIndex++);
|
|
244
|
+
}
|
|
245
|
+
while (actualIndex < actual.length) {
|
|
246
|
+
const output = actual[actualIndex];
|
|
247
|
+
if (!isRecord(output) || output.type !== "function_call_output") break;
|
|
248
|
+
const index = typeof output.call_id === "string" ? outputs.get(output.call_id) : undefined;
|
|
249
|
+
if (index === undefined || !areEquivalentValues(
|
|
250
|
+
{ ...output, output: undefined },
|
|
251
|
+
{ ...(expected[index] as Record<string, unknown>), output: undefined },
|
|
252
|
+
)) return undefined;
|
|
253
|
+
outputs.delete(output.call_id as string);
|
|
254
|
+
indices.push(index);
|
|
255
|
+
actualIndex++;
|
|
256
|
+
}
|
|
257
|
+
if (outputs.size > 0) return undefined;
|
|
258
|
+
} else {
|
|
259
|
+
if (!areEquivalentValues(actual[actualIndex], item)) return undefined;
|
|
260
|
+
indices.push(expectedIndex++);
|
|
261
|
+
actualIndex++;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return actualIndex === actual.length ? indices : undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
230
267
|
function toBranchSummaryMessage(entry: BranchSummaryEntry): AgentMessage {
|
|
231
268
|
return {
|
|
232
269
|
role: "branchSummary",
|
|
@@ -449,7 +486,8 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
|
|
|
449
486
|
...freshPreamble.trailingInput,
|
|
450
487
|
];
|
|
451
488
|
|
|
452
|
-
|
|
489
|
+
const originalIndices = alignPrunedInput(args.payload.input, originalPiReplayInput);
|
|
490
|
+
if (!originalIndices) {
|
|
453
491
|
const parity = compareResponsesInputParity(args.payload.input, originalPiReplayInput);
|
|
454
492
|
return {
|
|
455
493
|
ok: false,
|
|
@@ -467,17 +505,13 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
|
|
|
467
505
|
const compactionSummaryCount = serializeMessagesToResponsesInput(args.model, [compactionSummaryMessage]).length;
|
|
468
506
|
const preCompactionKeptCount = serializeMessagesToResponsesInput(args.model, preCompactionKeptMessages).length;
|
|
469
507
|
const tailStartIndex = freshPreambleCount + compactionSummaryCount + preCompactionKeptCount;
|
|
470
|
-
const tailEndIndex =
|
|
471
|
-
const
|
|
472
|
-
args.payload.input.
|
|
473
|
-
);
|
|
474
|
-
const actualPreCompactionKeptWindow = cloneResponsesInputSlice(
|
|
475
|
-
args.payload.input.slice(
|
|
476
|
-
freshPreambleCount + compactionSummaryCount,
|
|
477
|
-
freshPreambleCount + compactionSummaryCount + preCompactionKeptCount,
|
|
478
|
-
),
|
|
508
|
+
const tailEndIndex = originalPiReplayInput.length - trailingPreambleCount;
|
|
509
|
+
const actualSlice = (start: number, end: number) => cloneResponsesInputSlice(
|
|
510
|
+
args.payload.input.filter((_, index) => originalIndices[index] >= start && originalIndices[index] < end),
|
|
479
511
|
);
|
|
480
|
-
const
|
|
512
|
+
const actualCompactionSummary = actualSlice(freshPreambleCount, freshPreambleCount + compactionSummaryCount);
|
|
513
|
+
const actualPreCompactionKeptWindow = actualSlice(freshPreambleCount + compactionSummaryCount, tailStartIndex);
|
|
514
|
+
const actualPostCompactionTail = actualSlice(tailStartIndex, tailEndIndex);
|
|
481
515
|
if (!actualCompactionSummary || !actualPreCompactionKeptWindow || !actualPostCompactionTail) {
|
|
482
516
|
return {
|
|
483
517
|
ok: false,
|
package/src/runtime.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type NativeCompactionRuntime = {
|
|
|
39
39
|
model: string;
|
|
40
40
|
baseUrl: string;
|
|
41
41
|
apiKey: string;
|
|
42
|
-
headers?: Record<string, string>;
|
|
42
|
+
headers?: Record<string, string | null>;
|
|
43
43
|
compactPath: string;
|
|
44
44
|
compactUrl: string;
|
|
45
45
|
responsesUrl: string;
|
|
@@ -127,25 +127,13 @@ export function buildCompactPath(api: ResponsesCompactApi): string {
|
|
|
127
127
|
return api === "openai-codex-responses" ? CODEX_COMPACT_PATH : OPENAI_COMPACT_PATH;
|
|
128
128
|
}
|
|
129
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
|
-
|
|
142
130
|
async function resolveRequestAuth(
|
|
143
131
|
ctx: ExtensionContext,
|
|
144
132
|
model: RuntimeModel,
|
|
145
|
-
): Promise<{ apiKey?: string; headers?: Record<string, string
|
|
133
|
+
): Promise<{ apiKey?: string; headers?: Record<string, string | null>; baseUrl?: string }> {
|
|
146
134
|
const modelRegistry = ctx.modelRegistry as {
|
|
147
135
|
getApiKeyAndHeaders?: (currentModel: RuntimeModel) => Promise<
|
|
148
|
-
| { ok: true; apiKey?: string; headers?: Record<string, string | null
|
|
136
|
+
| { ok: true; apiKey?: string; headers?: Record<string, string | null>; baseUrl?: string }
|
|
149
137
|
| { ok: false; error: string }
|
|
150
138
|
>;
|
|
151
139
|
};
|
|
@@ -155,7 +143,7 @@ async function resolveRequestAuth(
|
|
|
155
143
|
}
|
|
156
144
|
|
|
157
145
|
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
158
|
-
return auth.ok ? { apiKey: auth.apiKey, headers:
|
|
146
|
+
return auth.ok ? { apiKey: auth.apiKey, headers: auth.headers, baseUrl: auth.baseUrl } : {};
|
|
159
147
|
}
|
|
160
148
|
|
|
161
149
|
export function isSupportedApi(api: string): api is ResponsesCompactApi {
|
|
@@ -201,7 +189,16 @@ export async function resolveNativeCompactionEnvironment(
|
|
|
201
189
|
};
|
|
202
190
|
}
|
|
203
191
|
|
|
204
|
-
|
|
192
|
+
let sessionModel: RuntimeModel | undefined;
|
|
193
|
+
const branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
194
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
195
|
+
const entry = branch[index];
|
|
196
|
+
if (entry?.type === "model_change") {
|
|
197
|
+
sessionModel = ctx.modelRegistry.find(entry.provider, entry.modelId);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const currentModel = ctx.model ?? sessionModel;
|
|
205
202
|
const descriptor = getRuntimeModelDescriptor(currentModel);
|
|
206
203
|
if (!currentModel || !descriptor.provider || !descriptor.api || !descriptor.model) {
|
|
207
204
|
return {
|
|
@@ -223,14 +220,6 @@ export async function resolveNativeCompactionEnvironment(
|
|
|
223
220
|
};
|
|
224
221
|
}
|
|
225
222
|
|
|
226
|
-
if (!descriptor.baseUrl) {
|
|
227
|
-
return {
|
|
228
|
-
ok: false,
|
|
229
|
-
reason: "missing-base-url",
|
|
230
|
-
...descriptor,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
223
|
let requestPayload: ResponsesCompatibleRequestPayload | undefined;
|
|
235
224
|
if (payload !== undefined) {
|
|
236
225
|
if (!isResponsesCompatiblePayload(payload)) {
|
|
@@ -252,7 +241,13 @@ export async function resolveNativeCompactionEnvironment(
|
|
|
252
241
|
requestPayload = payload;
|
|
253
242
|
}
|
|
254
243
|
|
|
255
|
-
const { apiKey, headers } = await resolveRequestAuth(ctx, currentModel);
|
|
244
|
+
const { apiKey, headers, baseUrl: authBaseUrl } = await resolveRequestAuth(ctx, currentModel);
|
|
245
|
+
// OAuth can route a configured Individual model to an Enterprise endpoint.
|
|
246
|
+
// Use the same resolved endpoint for transport AND persisted replay identity.
|
|
247
|
+
const baseUrl = normalizeBaseUrl(authBaseUrl) ?? descriptor.baseUrl;
|
|
248
|
+
if (!baseUrl) {
|
|
249
|
+
return { ok: false, reason: "missing-base-url", ...descriptor };
|
|
250
|
+
}
|
|
256
251
|
if (!apiKey) {
|
|
257
252
|
return {
|
|
258
253
|
ok: false,
|
|
@@ -267,14 +262,14 @@ export async function resolveNativeCompactionEnvironment(
|
|
|
267
262
|
provider: descriptor.provider,
|
|
268
263
|
api: descriptor.api,
|
|
269
264
|
model: descriptor.model,
|
|
270
|
-
baseUrl
|
|
265
|
+
baseUrl,
|
|
271
266
|
apiKey,
|
|
272
267
|
headers,
|
|
273
268
|
compactPath: buildCompactPath(descriptor.api),
|
|
274
|
-
compactUrl: buildCompactUrl(
|
|
275
|
-
responsesUrl: buildResponsesUrl(
|
|
269
|
+
compactUrl: buildCompactUrl(baseUrl, descriptor.api),
|
|
270
|
+
responsesUrl: buildResponsesUrl(baseUrl, descriptor.api),
|
|
276
271
|
payload: requestPayload,
|
|
277
|
-
currentModel,
|
|
272
|
+
currentModel: { ...currentModel, baseUrl },
|
|
278
273
|
},
|
|
279
274
|
};
|
|
280
275
|
}
|
package/src/shared-headers.ts
CHANGED
|
@@ -55,31 +55,25 @@ function buildCodexUserAgent(): string {
|
|
|
55
55
|
* and Codex-specific headers (account ID, originator, user-agent, beta flag).
|
|
56
56
|
*
|
|
57
57
|
* @param accept - The Accept header value. Defaults to `application/json`.
|
|
58
|
+
* @param input - The request input items, used for Copilot vision detection.
|
|
58
59
|
*/
|
|
59
60
|
export function toHeaders(
|
|
60
61
|
runtime: NativeCompactionRuntime,
|
|
61
62
|
accept: string = JSON_CONTENT_TYPE,
|
|
63
|
+
input: readonly unknown[] = [],
|
|
62
64
|
): Record<string, string> {
|
|
63
65
|
const headers = new Headers();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
headers.set("authorization", `Bearer ${runtime.apiKey}`);
|
|
67
|
+
if (runtime.provider === "github-copilot") {
|
|
68
|
+
// Compaction is an agent-initiated request, even when history ends with a user.
|
|
69
|
+
headers.set("x-initiator", "agent");
|
|
70
|
+
headers.set("openai-intent", "conversation-edits");
|
|
71
|
+
if (input.some((item) => isRecord(item) && [item.content, item.output].some((blocks) =>
|
|
72
|
+
Array.isArray(blocks) && blocks.some((block) => isRecord(block) && block.type === "input_image"),
|
|
73
|
+
))) {
|
|
74
|
+
headers.set("copilot-vision-request", "true");
|
|
68
75
|
}
|
|
69
76
|
}
|
|
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
77
|
|
|
84
78
|
if (runtime.api === "openai-codex-responses") {
|
|
85
79
|
const accountId = extractCodexAccountId(runtime.apiKey);
|
|
@@ -91,6 +85,18 @@ export function toHeaders(
|
|
|
91
85
|
headers.set("openai-beta", "responses=experimental");
|
|
92
86
|
}
|
|
93
87
|
|
|
88
|
+
// Defaults first, then model headers, then resolved request auth. Null means
|
|
89
|
+
// remove, even for Authorization and Codex-specific headers.
|
|
90
|
+
for (const source of [runtime.currentModel.headers, runtime.headers]) {
|
|
91
|
+
for (const [key, value] of Object.entries(source ?? {})) {
|
|
92
|
+
if (value == null) headers.delete(key);
|
|
93
|
+
else headers.set(key, value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// These two are mandatory transport invariants, not overridable auth defaults.
|
|
97
|
+
headers.set("accept", accept);
|
|
98
|
+
headers.set("content-type", JSON_CONTENT_TYPE);
|
|
99
|
+
|
|
94
100
|
return Object.fromEntries(headers.entries());
|
|
95
101
|
}
|
|
96
102
|
|
package/src/types.ts
CHANGED
|
@@ -33,14 +33,8 @@ export type DebugArtifactKind =
|
|
|
33
33
|
| "compaction-event"
|
|
34
34
|
| "lifecycle";
|
|
35
35
|
|
|
36
|
-
export type MidRunConfig = {
|
|
37
|
-
enabled: boolean;
|
|
38
|
-
thresholdPercent: number;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
36
|
export type ExtensionConfig = {
|
|
42
37
|
enabled: boolean;
|
|
43
|
-
midRun: MidRunConfig;
|
|
44
38
|
/**
|
|
45
39
|
* Allow a Responses session whose latest compaction was not created by this extension
|
|
46
40
|
* to restart native compaction from Pi's current serialized session context.
|
|
@@ -310,10 +304,6 @@ export function createNativeCompactionResult(
|
|
|
310
304
|
|
|
311
305
|
export const DEFAULT_EXTENSION_CONFIG: ExtensionConfig = {
|
|
312
306
|
enabled: true,
|
|
313
|
-
midRun: {
|
|
314
|
-
enabled: false,
|
|
315
|
-
thresholdPercent: 80,
|
|
316
|
-
},
|
|
317
307
|
allowCompactionContinuityBreak: false,
|
|
318
308
|
compactionModel: undefined,
|
|
319
309
|
compactionThinkingLevel: "off",
|
package/src/midrun.ts
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { loadExtensionConfig } from "./config";
|
|
3
|
-
import { writeDebugArtifact } from "./debug";
|
|
4
|
-
import { EXTENSION_ID, type LoadedExtensionConfig } from "./types";
|
|
5
|
-
|
|
6
|
-
type MidRunPhase = "idle" | "abort-pending" | "compacting" | "resume-pending" | "failed";
|
|
7
|
-
|
|
8
|
-
type MidRunState = {
|
|
9
|
-
phase: MidRunPhase;
|
|
10
|
-
generation: number;
|
|
11
|
-
sessionId?: string;
|
|
12
|
-
baselineCompactionId?: string;
|
|
13
|
-
triggerTokens?: number;
|
|
14
|
-
triggerPercent?: number;
|
|
15
|
-
triggerContextWindow?: number;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
type ConfigLoader = () => LoadedExtensionConfig;
|
|
19
|
-
|
|
20
|
-
const RESUME_CUSTOM_TYPE = "pi-better-compaction-midrun-resume";
|
|
21
|
-
const RESUME_PROMPT = `[pi-better-compaction/midrun]
|
|
22
|
-
Context compaction completed. Continue the interrupted task from the exact point where execution stopped.`;
|
|
23
|
-
const MIDRUN_COMPACTION_INSTRUCTIONS =
|
|
24
|
-
"Preserve the active task, completed work, decisions, changed files, failures, current tool-loop state, and exact next steps so execution can resume immediately after compaction.";
|
|
25
|
-
|
|
26
|
-
function getSessionId(ctx: ExtensionContext): string | undefined {
|
|
27
|
-
try {
|
|
28
|
-
return ctx.sessionManager.getSessionId();
|
|
29
|
-
} catch {
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function getLatestCompactionId(ctx: ExtensionContext): string | undefined {
|
|
35
|
-
const branch = ctx.sessionManager.getBranch();
|
|
36
|
-
for (let index = branch.length - 1; index >= 0; index--) {
|
|
37
|
-
const entry = branch[index];
|
|
38
|
-
if (entry?.type === "compaction") return entry.id;
|
|
39
|
-
}
|
|
40
|
-
return undefined;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function resetState(state: MidRunState): void {
|
|
44
|
-
state.phase = "idle";
|
|
45
|
-
state.generation += 1;
|
|
46
|
-
state.sessionId = undefined;
|
|
47
|
-
state.baselineCompactionId = undefined;
|
|
48
|
-
state.triggerTokens = undefined;
|
|
49
|
-
state.triggerPercent = undefined;
|
|
50
|
-
state.triggerContextWindow = undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function sameSession(state: MidRunState, ctx: ExtensionContext): boolean {
|
|
54
|
-
return state.sessionId !== undefined && state.sessionId === getSessionId(ctx);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function notifyFailure(ctx: ExtensionContext, message: string): void {
|
|
58
|
-
if (ctx.hasUI) {
|
|
59
|
-
ctx.ui.notify(`${EXTENSION_ID}: ${message}`, "error");
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function scheduleResume(
|
|
64
|
-
pi: ExtensionAPI,
|
|
65
|
-
ctx: ExtensionContext,
|
|
66
|
-
state: MidRunState,
|
|
67
|
-
generation: number,
|
|
68
|
-
): void {
|
|
69
|
-
state.phase = "resume-pending";
|
|
70
|
-
|
|
71
|
-
setImmediate(() => {
|
|
72
|
-
if (
|
|
73
|
-
state.generation !== generation ||
|
|
74
|
-
state.phase !== "resume-pending" ||
|
|
75
|
-
!sameSession(state, ctx)
|
|
76
|
-
) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
81
|
-
resetState(state);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
resetState(state);
|
|
86
|
-
pi.sendMessage(
|
|
87
|
-
{
|
|
88
|
-
customType: RESUME_CUSTOM_TYPE,
|
|
89
|
-
content: RESUME_PROMPT,
|
|
90
|
-
display: false,
|
|
91
|
-
details: { source: "midrun-compaction" },
|
|
92
|
-
},
|
|
93
|
-
{ triggerTurn: true },
|
|
94
|
-
);
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function registerMidRunGuard(
|
|
99
|
-
pi: ExtensionAPI,
|
|
100
|
-
loadConfig: ConfigLoader = loadExtensionConfig,
|
|
101
|
-
): void {
|
|
102
|
-
const state: MidRunState = { phase: "idle", generation: 0 };
|
|
103
|
-
|
|
104
|
-
pi.on("turn_end", (event, ctx) => {
|
|
105
|
-
const { config } = loadConfig();
|
|
106
|
-
if (!config.enabled || !config.midRun.enabled || state.phase !== "idle") return;
|
|
107
|
-
if (event.toolResults.length === 0 || ctx.hasPendingMessages()) return;
|
|
108
|
-
|
|
109
|
-
const usage = ctx.getContextUsage();
|
|
110
|
-
if (!usage || usage.tokens == null || usage.percent == null) return;
|
|
111
|
-
if (usage.percent < config.midRun.thresholdPercent) return;
|
|
112
|
-
|
|
113
|
-
const sessionId = getSessionId(ctx);
|
|
114
|
-
if (!sessionId) return;
|
|
115
|
-
|
|
116
|
-
state.phase = "abort-pending";
|
|
117
|
-
state.generation += 1;
|
|
118
|
-
state.sessionId = sessionId;
|
|
119
|
-
state.baselineCompactionId = getLatestCompactionId(ctx);
|
|
120
|
-
state.triggerTokens = usage.tokens;
|
|
121
|
-
state.triggerPercent = usage.percent;
|
|
122
|
-
state.triggerContextWindow = usage.contextWindow;
|
|
123
|
-
|
|
124
|
-
writeDebugArtifact(
|
|
125
|
-
"lifecycle",
|
|
126
|
-
{
|
|
127
|
-
event: "midrun.threshold",
|
|
128
|
-
turnIndex: event.turnIndex,
|
|
129
|
-
tokens: usage.tokens,
|
|
130
|
-
contextWindow: usage.contextWindow,
|
|
131
|
-
percent: usage.percent,
|
|
132
|
-
thresholdPercent: config.midRun.thresholdPercent,
|
|
133
|
-
baselineCompactionId: state.baselineCompactionId,
|
|
134
|
-
},
|
|
135
|
-
config,
|
|
136
|
-
ctx,
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
// Never compact while the agent run is active; let Pi settle first.
|
|
140
|
-
ctx.abort();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
144
|
-
if (state.phase !== "abort-pending") return;
|
|
145
|
-
if (!sameSession(state, ctx)) {
|
|
146
|
-
resetState(state);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// An earlier agent_settled handler may already have started or queued another run.
|
|
151
|
-
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
152
|
-
resetState(state);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const generation = state.generation;
|
|
157
|
-
const latestCompactionId = getLatestCompactionId(ctx);
|
|
158
|
-
if (latestCompactionId !== state.baselineCompactionId) {
|
|
159
|
-
const { config } = loadConfig();
|
|
160
|
-
writeDebugArtifact(
|
|
161
|
-
"lifecycle",
|
|
162
|
-
{
|
|
163
|
-
event: "midrun.coalesced",
|
|
164
|
-
reason: "compaction-already-occurred-during-abort",
|
|
165
|
-
baselineCompactionId: state.baselineCompactionId,
|
|
166
|
-
latestCompactionId,
|
|
167
|
-
},
|
|
168
|
-
config,
|
|
169
|
-
ctx,
|
|
170
|
-
);
|
|
171
|
-
scheduleResume(pi, ctx, state, generation);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const { config } = loadConfig();
|
|
176
|
-
if (!config.enabled || !config.midRun.enabled) {
|
|
177
|
-
scheduleResume(pi, ctx, state, generation);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const usage = ctx.getContextUsage();
|
|
182
|
-
if (usage?.percent != null && usage.percent < config.midRun.thresholdPercent) {
|
|
183
|
-
scheduleResume(pi, ctx, state, generation);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
state.phase = "compacting";
|
|
188
|
-
ctx.compact({
|
|
189
|
-
customInstructions: MIDRUN_COMPACTION_INSTRUCTIONS,
|
|
190
|
-
onComplete: () => {
|
|
191
|
-
if (
|
|
192
|
-
state.generation !== generation ||
|
|
193
|
-
state.phase !== "compacting" ||
|
|
194
|
-
!sameSession(state, ctx)
|
|
195
|
-
) {
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
scheduleResume(pi, ctx, state, generation);
|
|
199
|
-
},
|
|
200
|
-
onError: (error) => {
|
|
201
|
-
if (state.generation !== generation || !sameSession(state, ctx)) return;
|
|
202
|
-
|
|
203
|
-
const latest = getLatestCompactionId(ctx);
|
|
204
|
-
if (/Already compacted/i.test(error.message) && latest !== state.baselineCompactionId) {
|
|
205
|
-
scheduleResume(pi, ctx, state, generation);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
state.phase = "failed";
|
|
210
|
-
writeDebugArtifact(
|
|
211
|
-
"lifecycle",
|
|
212
|
-
{
|
|
213
|
-
event: "midrun.failed",
|
|
214
|
-
errorMessage: error.message,
|
|
215
|
-
triggerTokens: state.triggerTokens,
|
|
216
|
-
triggerPercent: state.triggerPercent,
|
|
217
|
-
triggerContextWindow: state.triggerContextWindow,
|
|
218
|
-
},
|
|
219
|
-
config,
|
|
220
|
-
ctx,
|
|
221
|
-
);
|
|
222
|
-
notifyFailure(ctx, `mid-run compaction failed: ${error.message}`);
|
|
223
|
-
},
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
pi.on("session_start", () => resetState(state));
|
|
228
|
-
pi.on("session_shutdown", () => resetState(state));
|
|
229
|
-
}
|