@lll9p/pi-better-compaction 0.2.1 → 0.5.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.
@@ -0,0 +1,428 @@
1
+ /**
2
+ * V2 compaction streaming client.
3
+ *
4
+ * Sends a Responses API request with a `compaction_trigger` input item appended,
5
+ * streams the SSE response, and collects the encrypted `compaction` output blob.
6
+ *
7
+ * This is the pi extension equivalent of codex-rs `compact_remote_v2.rs`.
8
+ */
9
+
10
+ import { writeDebugArtifact } from "./debug";
11
+ import type { NativeCompactionRuntime } from "./runtime";
12
+ import type { NativeCompactionRequestBody } from "./serializer";
13
+ import { isAbortError, toHeaders } from "./shared-headers";
14
+ import type { ArtifactContext, ExtensionConfig } from "./types";
15
+
16
+ // ── Types ──────────────────────────────────────────────────────────────
17
+
18
+ export type CompactionItem = {
19
+ type: "compaction";
20
+ id?: string;
21
+ encrypted_content: string;
22
+ };
23
+
24
+ export type V2CompactionUsage = {
25
+ input_tokens?: number;
26
+ output_tokens?: number;
27
+ total_tokens?: number;
28
+ [key: string]: unknown;
29
+ };
30
+
31
+ export type V2CompactionSuccess = {
32
+ ok: true;
33
+ compactionItem: CompactionItem;
34
+ responseId?: string;
35
+ createdAt?: string;
36
+ usage?: V2CompactionUsage;
37
+ };
38
+
39
+ export type V2CompactionFailureReason =
40
+ | "aborted"
41
+ | "network-error"
42
+ | "non-2xx"
43
+ | "no-compaction-output"
44
+ | "multiple-compaction-outputs"
45
+ | "stream-parse-error"
46
+ | "retries-exhausted";
47
+
48
+ export type V2CompactionFailure = {
49
+ ok: false;
50
+ reason: V2CompactionFailureReason;
51
+ status?: number;
52
+ errorMessage?: string;
53
+ };
54
+
55
+ export type V2CompactionResult = V2CompactionSuccess | V2CompactionFailure;
56
+
57
+ export type ExecuteV2CompactionOptions = {
58
+ runtime: NativeCompactionRuntime;
59
+ request: NativeCompactionRequestBody;
60
+ signal?: AbortSignal;
61
+ maxRetries?: number;
62
+ settings?: ExtensionConfig;
63
+ context?: ArtifactContext;
64
+ };
65
+
66
+ // ── Constants ──────────────────────────────────────────────────────────
67
+
68
+ const DEFAULT_MAX_RETRIES = 2;
69
+ const SSE_ACCEPT = "text/event-stream";
70
+
71
+ // ── Helpers ────────────────────────────────────────────────────────────
72
+
73
+ function isRecord(value: unknown): value is Record<string, unknown> {
74
+ return !!value && typeof value === "object" && !Array.isArray(value);
75
+ }
76
+
77
+ function isCompactionItem(item: unknown): item is CompactionItem {
78
+ return (
79
+ isRecord(item) &&
80
+ (item.type === "compaction" || item.type === "compaction_summary") &&
81
+ typeof item.encrypted_content === "string" &&
82
+ item.encrypted_content.length > 0
83
+ );
84
+ }
85
+
86
+ function writeV2Artifact(
87
+ data: unknown,
88
+ settings: ExtensionConfig | undefined,
89
+ context: ArtifactContext | undefined,
90
+ ): void {
91
+ if (!settings || !context) return;
92
+ writeDebugArtifact("compact-response", data, settings, context);
93
+ }
94
+
95
+ // ── SSE stream processing ──────────────────────────────────────────────
96
+
97
+ type StreamCollectionResult =
98
+ | { ok: true; compactionItems: CompactionItem[]; responseId?: string; createdAt?: string; usage?: V2CompactionUsage }
99
+ | { ok: false; reason: V2CompactionFailureReason; errorMessage?: string };
100
+
101
+ /**
102
+ * Read an SSE stream from a fetch Response and collect compaction output items.
103
+ *
104
+ * Expected SSE events:
105
+ * - `response.output_item.done` with a compaction item in `item`
106
+ * - `response.completed` with `response.id`, `response.usage`, `response.created_at`
107
+ * - `response.failed` / `error` for server-side errors
108
+ */
109
+ async function collectStreamOutput(response: Response, signal?: AbortSignal): Promise<StreamCollectionResult> {
110
+ const body = response.body;
111
+ if (!body) {
112
+ return { ok: false, reason: "stream-parse-error", errorMessage: "Response body is null" };
113
+ }
114
+
115
+ const compactionItems: CompactionItem[] = [];
116
+ let responseId: string | undefined;
117
+ let createdAt: string | undefined;
118
+ let usage: V2CompactionUsage | undefined;
119
+ let serverError: string | undefined;
120
+
121
+ const reader = body.getReader();
122
+ const decoder = new TextDecoder();
123
+ let buffer = "";
124
+
125
+ try {
126
+ while (true) {
127
+ if (signal?.aborted) {
128
+ reader.cancel();
129
+ return { ok: false, reason: "aborted" as const };
130
+ }
131
+
132
+ const { done, value } = await reader.read();
133
+ if (done) break;
134
+
135
+ buffer += decoder.decode(value, { stream: true });
136
+ const lines = buffer.split("\n");
137
+ // Keep the last incomplete line in the buffer.
138
+ buffer = lines.pop() ?? "";
139
+
140
+ for (const line of lines) {
141
+ const trimmed = line.trim();
142
+ if (!trimmed || trimmed.startsWith(":")) continue;
143
+ if (!trimmed.startsWith("data: ")) continue;
144
+
145
+ const jsonStr = trimmed.slice(6);
146
+ if (jsonStr === "[DONE]") continue;
147
+
148
+ let event: Record<string, unknown>;
149
+ try {
150
+ event = JSON.parse(jsonStr);
151
+ if (!isRecord(event)) continue;
152
+ } catch {
153
+ continue;
154
+ }
155
+
156
+ const eventType = event.type;
157
+
158
+ if (eventType === "response.output_item.done") {
159
+ const item = event.item;
160
+ if (isCompactionItem(item)) {
161
+ compactionItems.push({
162
+ type: "compaction",
163
+ id: typeof item.id === "string" ? item.id : undefined,
164
+ encrypted_content: item.encrypted_content,
165
+ });
166
+ }
167
+ continue;
168
+ }
169
+
170
+ if (eventType === "response.completed") {
171
+ const resp = event.response;
172
+ if (isRecord(resp)) {
173
+ responseId = typeof resp.id === "string" ? resp.id : undefined;
174
+ createdAt = normalizeTimestamp(resp.created_at);
175
+ if (isRecord(resp.usage)) {
176
+ usage = resp.usage as V2CompactionUsage;
177
+ }
178
+ }
179
+ continue;
180
+ }
181
+
182
+ if (eventType === "response.failed" || eventType === "error") {
183
+ const errorObj = event.error ?? event;
184
+ serverError = isRecord(errorObj)
185
+ ? (typeof errorObj.message === "string" ? errorObj.message : JSON.stringify(errorObj))
186
+ : String(errorObj);
187
+ continue;
188
+ }
189
+ }
190
+ }
191
+ } catch (error) {
192
+ if (isAbortError(error)) {
193
+ return { ok: false, reason: "aborted" as const };
194
+ }
195
+ return { ok: false, reason: "stream-parse-error", errorMessage: error instanceof Error ? error.message : String(error) };
196
+ } finally {
197
+ try { reader.releaseLock(); } catch { /* noop */ }
198
+ }
199
+
200
+ if (serverError) {
201
+ return { ok: false, reason: "stream-parse-error", errorMessage: serverError };
202
+ }
203
+
204
+ return { ok: true, compactionItems, responseId, createdAt, usage };
205
+ }
206
+
207
+ function normalizeTimestamp(value: unknown): string | undefined {
208
+ if (typeof value === "number" && Number.isFinite(value)) {
209
+ const ms = value > 1_000_000_000_000 ? value : value * 1000;
210
+ return new Date(ms).toISOString();
211
+ }
212
+ if (typeof value === "string" && value.trim()) {
213
+ const parsed = Date.parse(value.trim());
214
+ return Number.isNaN(parsed) ? value.trim() : new Date(parsed).toISOString();
215
+ }
216
+ return undefined;
217
+ }
218
+
219
+ // ── Single attempt ─────────────────────────────────────────────────────
220
+
221
+ async function executeV2Attempt(
222
+ url: string,
223
+ requestBody: unknown,
224
+ headers: Record<string, string>,
225
+ signal?: AbortSignal,
226
+ ): Promise<{ response?: Response; result?: StreamCollectionResult; failure?: V2CompactionFailure }> {
227
+ if (signal?.aborted) {
228
+ return { failure: { ok: false, reason: "aborted" } };
229
+ }
230
+
231
+ let response: Response;
232
+ try {
233
+ response = await fetch(url, {
234
+ method: "POST",
235
+ headers,
236
+ body: JSON.stringify(requestBody),
237
+ signal,
238
+ });
239
+ } catch (error) {
240
+ if (isAbortError(error)) {
241
+ return { failure: { ok: false, reason: "aborted" } };
242
+ }
243
+ return {
244
+ failure: {
245
+ ok: false,
246
+ reason: "network-error",
247
+ errorMessage: error instanceof Error ? error.message : String(error),
248
+ },
249
+ };
250
+ }
251
+
252
+ if (!response.ok) {
253
+ let errorMessage: string | undefined;
254
+ try {
255
+ const text = await response.text();
256
+ if (text.trim()) {
257
+ try {
258
+ const json = JSON.parse(text);
259
+ errorMessage = isRecord(json) && isRecord(json.error) && typeof json.error.message === "string"
260
+ ? json.error.message
261
+ : text;
262
+ } catch {
263
+ errorMessage = text;
264
+ }
265
+ }
266
+ } catch { /* swallow */ }
267
+ return {
268
+ response,
269
+ failure: {
270
+ ok: false,
271
+ reason: "non-2xx",
272
+ status: response.status,
273
+ errorMessage,
274
+ },
275
+ };
276
+ }
277
+
278
+ const result = await collectStreamOutput(response, signal);
279
+ return { response, result };
280
+ }
281
+
282
+ // ── Retryable errors ───────────────────────────────────────────────────
283
+
284
+ function isRetryable(result: V2CompactionFailure): boolean {
285
+ return result.reason === "network-error" || result.reason === "stream-parse-error";
286
+ }
287
+
288
+ // ── Public API ─────────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Execute a V2 compaction request.
292
+ *
293
+ * Builds a Responses API streaming request with a `compaction_trigger` appended
294
+ * to the input, streams the SSE response, and collects the compaction blob.
295
+ *
296
+ * Retries recoverable failures up to `maxRetries` times (default 2).
297
+ */
298
+ export async function executeV2Compaction(
299
+ options: ExecuteV2CompactionOptions,
300
+ ): Promise<V2CompactionResult> {
301
+ const { runtime, request, signal, settings, context } = options;
302
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
303
+
304
+ const headers = toHeaders(runtime, SSE_ACCEPT);
305
+ const url = runtime.responsesUrl;
306
+
307
+ // Build request body: input + compaction_trigger, stream=true.
308
+ const requestBody = {
309
+ ...request,
310
+ input: [...request.input, { type: "compaction_trigger" }],
311
+ stream: true,
312
+ };
313
+
314
+ let lastFailure: V2CompactionFailure | undefined;
315
+
316
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
317
+ if (signal?.aborted) {
318
+ const aborted: V2CompactionFailure = { ok: false, reason: "aborted" };
319
+ writeV2Artifact(
320
+ { request: { url, headers, body: requestBody }, attempt, outcome: aborted },
321
+ settings,
322
+ context,
323
+ );
324
+ return aborted;
325
+ }
326
+
327
+ const { result, failure } = await executeV2Attempt(url, requestBody, headers, signal);
328
+
329
+ if (failure) {
330
+ lastFailure = failure;
331
+ if (!isRetryable(failure) || attempt >= maxRetries) {
332
+ writeV2Artifact(
333
+ { request: { url, headers, body: requestBody }, attempt, outcome: failure },
334
+ settings,
335
+ context,
336
+ );
337
+ return failure;
338
+ }
339
+ continue;
340
+ }
341
+
342
+ if (!result) {
343
+ // Should not happen, but guard.
344
+ lastFailure = { ok: false, reason: "stream-parse-error", errorMessage: "No result from attempt" };
345
+ continue;
346
+ }
347
+
348
+ if (!result.ok) {
349
+ lastFailure = { ok: false, reason: result.reason, errorMessage: result.errorMessage };
350
+ if (result.reason === "aborted" || !isRetryable(lastFailure) || attempt >= maxRetries) {
351
+ writeV2Artifact(
352
+ { request: { url, headers, body: requestBody }, attempt, outcome: lastFailure },
353
+ settings,
354
+ context,
355
+ );
356
+ return lastFailure;
357
+ }
358
+ continue;
359
+ }
360
+
361
+ // Stream collected successfully. Validate compaction output.
362
+ if (result.compactionItems.length === 0) {
363
+ const noOutput: V2CompactionFailure = {
364
+ ok: false,
365
+ reason: "no-compaction-output",
366
+ };
367
+ writeV2Artifact(
368
+ { request: { url, headers, body: requestBody }, attempt, outcome: noOutput },
369
+ settings,
370
+ context,
371
+ );
372
+ return noOutput;
373
+ }
374
+
375
+ if (result.compactionItems.length > 1) {
376
+ const multiOutput: V2CompactionFailure = {
377
+ ok: false,
378
+ reason: "multiple-compaction-outputs",
379
+ errorMessage: `Expected 1 compaction item, got ${result.compactionItems.length}`,
380
+ };
381
+ writeV2Artifact(
382
+ { request: { url, headers, body: requestBody }, attempt, outcome: multiOutput },
383
+ settings,
384
+ context,
385
+ );
386
+ return multiOutput;
387
+ }
388
+
389
+ const success: V2CompactionSuccess = {
390
+ ok: true,
391
+ compactionItem: result.compactionItems[0]!,
392
+ responseId: result.responseId,
393
+ createdAt: result.createdAt,
394
+ usage: result.usage,
395
+ };
396
+
397
+ writeV2Artifact(
398
+ {
399
+ request: { url, headers, body: requestBody },
400
+ attempt,
401
+ outcome: {
402
+ ok: true,
403
+ responseId: success.responseId,
404
+ createdAt: success.createdAt,
405
+ compactionItemId: success.compactionItem.id,
406
+ usage: success.usage,
407
+ },
408
+ },
409
+ settings,
410
+ context,
411
+ );
412
+
413
+ return success;
414
+ }
415
+
416
+ // All retries exhausted.
417
+ const exhausted: V2CompactionFailure = {
418
+ ok: false,
419
+ reason: "retries-exhausted",
420
+ errorMessage: lastFailure?.errorMessage ?? "All retry attempts failed",
421
+ };
422
+ writeV2Artifact(
423
+ { request: { url, headers, body: requestBody }, maxRetries, outcome: exhausted },
424
+ settings,
425
+ context,
426
+ );
427
+ return exhausted;
428
+ }
@@ -1,6 +1,7 @@
1
1
  import { writeDebugArtifact } from "./debug";
2
2
  import type { NativeCompactionRuntime } from "./runtime";
3
3
  import type { NativeCompactionRequestBody } from "./serializer";
4
+ import { isAbortError, toHeaders } from "./shared-headers";
4
5
  import type { ArtifactContext, ExtensionConfig } from "./types";
5
6
 
6
7
  const JSON_CONTENT_TYPE = "application/json";
@@ -55,13 +56,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
55
56
  return !!value && typeof value === "object" && !Array.isArray(value);
56
57
  }
57
58
 
58
- function isAbortError(error: unknown): boolean {
59
- return (
60
- (error instanceof DOMException && error.name === "AbortError") ||
61
- (error instanceof Error && (error.name === "AbortError" || error.name === "ABORT_ERR"))
62
- );
63
- }
64
-
65
59
  function normalizeResponseTimestamp(value: unknown): string | undefined {
66
60
  if (typeof value === "number" && Number.isFinite(value)) {
67
61
  const milliseconds = value > 1_000_000_000_000 ? value : value * 1000;
@@ -111,62 +105,6 @@ export function extractCompactedSummaryText(output: readonly unknown[]): string
111
105
  return joined.length > 0 ? joined : undefined;
112
106
  }
113
107
 
114
- function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
115
- const parts = token.split(".");
116
- if (parts.length !== 3) {
117
- return undefined;
118
- }
119
-
120
- try {
121
- const payloadText = Buffer.from(parts[1]!, "base64url").toString("utf8");
122
- const payload = JSON.parse(payloadText);
123
- return isRecord(payload) ? payload : undefined;
124
- } catch {
125
- return undefined;
126
- }
127
- }
128
-
129
- function extractCodexAccountId(token: string): string | undefined {
130
- const payload = decodeJwtPayload(token);
131
- const authClaims = payload?.["https://api.openai.com/auth"];
132
- if (!isRecord(authClaims)) {
133
- return undefined;
134
- }
135
-
136
- const accountId = authClaims.chatgpt_account_id;
137
- return typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : undefined;
138
- }
139
-
140
- function buildCodexUserAgent(): string {
141
- const platform = typeof process !== "undefined" ? process.platform : "browser";
142
- const arch = typeof process !== "undefined" ? process.arch : "unknown";
143
- return `pi (${platform}; ${arch})`;
144
- }
145
-
146
- function toHeaders(runtime: NativeCompactionRuntime): Record<string, string> {
147
- const headers = new Headers(runtime.currentModel.headers ?? {});
148
- for (const [key, value] of Object.entries(runtime.headers ?? {})) {
149
- headers.set(key, value);
150
- }
151
- headers.set("accept", JSON_CONTENT_TYPE);
152
- headers.set("content-type", JSON_CONTENT_TYPE);
153
- if (!headers.has("authorization")) {
154
- headers.set("authorization", `Bearer ${runtime.apiKey}`);
155
- }
156
-
157
- if (runtime.api === "openai-codex-responses") {
158
- const accountId = extractCodexAccountId(runtime.apiKey);
159
- if (accountId) {
160
- headers.set("chatgpt-account-id", accountId);
161
- }
162
- headers.set("originator", "pi");
163
- headers.set("user-agent", buildCodexUserAgent());
164
- headers.set("openai-beta", "responses=experimental");
165
- }
166
-
167
- return Object.fromEntries(headers.entries());
168
- }
169
-
170
108
  function writeCompactArtifact(
171
109
  data: unknown,
172
110
  settings: ExtensionConfig | undefined,
package/src/config.ts CHANGED
@@ -3,10 +3,12 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
5
  import {
6
+ COMPACTION_VERSIONS,
6
7
  DEFAULT_EXTENSION_CONFIG,
7
8
  EXTENSION_ID,
8
9
  RESPONSES_COMPACT_CAPABLE_APIS,
9
10
  THINKING_LEVELS,
11
+ type CompactionVersion,
10
12
  type ExtensionConfig,
11
13
  type LoadedExtensionConfig,
12
14
  } from "./types";
@@ -58,6 +60,15 @@ function toBoolean(value: unknown, fieldPath: string, warnings: string[]): boole
58
60
  return undefined;
59
61
  }
60
62
 
63
+ function toThresholdPercent(value: unknown, fieldPath: string, warnings: string[]): number | undefined {
64
+ if (value === undefined) return undefined;
65
+ if (typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 100) {
66
+ return value;
67
+ }
68
+ warnings.push(`Ignoring ${fieldPath}: expected a number greater than 0 and at most 100.`);
69
+ return undefined;
70
+ }
71
+
61
72
  function toModelSpec(value: unknown, fieldPath: string, warnings: string[]): string | null | undefined {
62
73
  if (value === undefined) return undefined;
63
74
  // Explicit null clears a spec, matching the documented "unset = current model" behavior.
@@ -78,6 +89,15 @@ function toThinkingLevel(value: unknown, fieldPath: string, warnings: string[]):
78
89
  return undefined;
79
90
  }
80
91
 
92
+ function toCompactionVersion(value: unknown, fieldPath: string, warnings: string[]): CompactionVersion | undefined {
93
+ if (value === undefined) return undefined;
94
+ if (typeof value === "string" && (COMPACTION_VERSIONS as readonly string[]).includes(value)) {
95
+ return value as CompactionVersion;
96
+ }
97
+ warnings.push(`Ignoring ${fieldPath}: expected one of ${COMPACTION_VERSIONS.join(", ")}.`);
98
+ return undefined;
99
+ }
100
+
81
101
  function toResponsesCompactApis(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
82
102
  if (value === undefined) return undefined;
83
103
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
@@ -108,6 +128,7 @@ export function loadExtensionConfig(configPath: string = CONFIG_PATH): LoadedExt
108
128
  const warnings: string[] = [];
109
129
  const resolved: ExtensionConfig = {
110
130
  ...DEFAULT_EXTENSION_CONFIG,
131
+ midRun: { ...DEFAULT_EXTENSION_CONFIG.midRun },
111
132
  responsesCompactApis: [...DEFAULT_EXTENSION_CONFIG.responsesCompactApis],
112
133
  };
113
134
  let source: string | undefined;
@@ -117,6 +138,19 @@ export function loadExtensionConfig(configPath: string = CONFIG_PATH): LoadedExt
117
138
  source = configPath;
118
139
 
119
140
  resolved.enabled = toBoolean(raw.enabled, "enabled", warnings) ?? resolved.enabled;
141
+
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
+
120
154
  resolved.allowCompactionContinuityBreak =
121
155
  toBoolean(raw.allowCompactionContinuityBreak, "allowCompactionContinuityBreak", warnings) ??
122
156
  resolved.allowCompactionContinuityBreak;
@@ -143,6 +177,10 @@ export function loadExtensionConfig(configPath: string = CONFIG_PATH): LoadedExt
143
177
  resolved.responsesCompactApis = apis;
144
178
  }
145
179
 
180
+ resolved.compactionVersion =
181
+ toCompactionVersion(raw.compactionVersion, "compactionVersion", warnings) ??
182
+ resolved.compactionVersion;
183
+
146
184
  if (typeof raw.artifactRoot === "string" && raw.artifactRoot.trim().length > 0) {
147
185
  resolved.artifactRoot = raw.artifactRoot.trim();
148
186
  } else if (raw.artifactRoot !== undefined) {