@criogaid/pi-codex-compaction 0.4.0 → 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.
@@ -1,13 +1,24 @@
1
1
  // Own ordinary-request prompt and context snapshots that let compaction reuse Pi's projected request prefix,
2
2
  // and build compaction's provider context the way Pi 0.99 builds an ordinary request.
3
+ import { randomUUID } from "node:crypto";
3
4
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
4
5
  import { getCurrentSystemMessage, getSystemMessageText, type Context, type Message, type Tool } from "@earendil-works/pi-ai";
5
6
  import { convertToLlm, type ExtensionAPI, type ToolInfo } from "@earendil-works/pi-coding-agent";
6
7
  import { sameBackend, sameModel, type CapableModel, type ProviderIdentity } from "./capability.js";
7
8
  import { type CodexCheckpointDetails, fingerprintMessage, projectCheckpointRequest, withoutSystemMessages } from "./checkpoint.js";
8
- import { isObject, type JsonObject } from "./protocol.js";
9
+ import { BLOCKED_IMAGE_TEXT, CodexCompactionProtocolError, isObject, type JsonObject } from "./protocol.js";
10
+ import { contextUserItems, userItemOrigins } from "./retention-input.js";
9
11
 
10
- const BLOCKED_IMAGE_TEXT = "Image reading is disabled.";
12
+ /**
13
+ * In Pi 0.99.1 and 1.0.4 these settings affect presentation, not provider inputs. Every other setting,
14
+ * including unknown future ones, stays in the reuse key, so changing it drops the wire snapshot.
15
+ */
16
+ const INTERFACE_ONLY_SETTINGS: ReadonlySet<string> = new Set([
17
+ "autocompleteMaxVisible", "collapseChangelog", "doubleEscapeAction", "editorPaddingX", "externalEditor",
18
+ "fullscreenCopyOnSelect", "fullscreenExitOutput", "fullscreenScrollbar", "fullscreenWheelScrollLines",
19
+ "hideThinkingBlock", "lastChangelogVersion", "markdown", "outputPad", "quietStartup", "showCacheMissNotices",
20
+ "showHardwareCursor", "terminal", "theme", "treeFilterMode", "tuiMode", "warnings",
21
+ ]);
11
22
 
12
23
  interface SnapshotScope {
13
24
  readonly sessionId: string;
@@ -73,10 +84,12 @@ export function promptOverrideFor(
73
84
  sessionId: string,
74
85
  target: CapableModel,
75
86
  messages: readonly AgentMessage[],
87
+ currentPrompt: string,
76
88
  ): PromptOverride | undefined {
77
89
  const scoped = snapshotFor(override, sessionId, target);
78
90
  const head = scoped && sourceHead(messages);
79
- return head && fingerprintMessage(head) === scoped.sourceFingerprint ? scoped : undefined;
91
+ return head && fingerprintMessage(head) === scoped.sourceFingerprint &&
92
+ (currentPrompt === scoped.text || currentPrompt === getSystemMessageText(head)) ? scoped : undefined;
80
93
  }
81
94
 
82
95
  export function captureContextSnapshot(
@@ -127,6 +140,9 @@ interface ProviderRequestFields {
127
140
  readonly instructions?: string;
128
141
  readonly tools?: readonly JsonObject[];
129
142
  readonly reasoning?: JsonObject;
143
+ readonly parallel_tool_calls?: boolean;
144
+ readonly text?: JsonObject;
145
+ readonly prompt_cache_options?: { readonly mode?: string; readonly ttl?: string };
130
146
  readonly prompt_cache_key?: string;
131
147
  readonly prompt_cache_retention?: string;
132
148
  readonly service_tier?: string;
@@ -134,20 +150,24 @@ interface ProviderRequestFields {
134
150
 
135
151
  export interface ProviderRequestSnapshot extends SnapshotScope {
136
152
  readonly sourceFingerprints: readonly string[];
153
+ /** The effective prompt `ctx.getSystemPrompt()` reported for the observed request. */
154
+ readonly systemPrompt: string;
137
155
  readonly inputsKey: string;
156
+ /** The same inputs after the observed run settled and Pi fell back to its base prompt options. */
157
+ readonly settledInputsKey?: string;
138
158
  readonly fields: ProviderRequestFields;
139
- readonly verbosity?: string;
140
- readonly prefix: readonly JsonObject[];
141
- }
142
-
143
- function declarationPrefix(input: readonly JsonObject[]): readonly JsonObject[] {
144
- const end = input.findIndex((item) => item.type !== "additional_tools" &&
145
- !((item.type === undefined || item.type === "message") && (item.role === "system" || item.role === "developer")));
146
- return end < 0 ? input : input.slice(0, end);
159
+ /** Absent when the observed input depends on server-side conversation state. */
160
+ readonly prefix?: {
161
+ readonly boundary: string;
162
+ readonly contextLength: number;
163
+ readonly input: readonly JsonObject[];
164
+ readonly contextual: readonly boolean[];
165
+ };
147
166
  }
148
167
 
149
168
  function requestInputsKey(target: CapableModel, inputs: ProviderRequestInputs): string {
150
- return JSON.stringify({ model: target.model, ...inputs });
169
+ const settings = Object.fromEntries(Object.entries(inputs.settings).filter(([key]) => !INTERFACE_ONLY_SETTINGS.has(key)));
170
+ return JSON.stringify({ model: target.model, ...inputs, settings });
151
171
  }
152
172
 
153
173
  function captureProviderRequest(
@@ -155,47 +175,77 @@ function captureProviderRequest(
155
175
  ): ProviderRequestSnapshot | undefined {
156
176
  if (!isObject(payload) || payload.model !== target.model.id ||
157
177
  !Array.isArray(payload.input) || !payload.input.every(isObject)) return undefined;
158
- const { instructions, tools, reasoning, prompt_cache_key, prompt_cache_retention, service_tier, text } = payload;
178
+ const { instructions, tools, reasoning, parallel_tool_calls, prompt_cache_key, prompt_cache_retention, prompt_cache_options, service_tier, text } = payload;
159
179
  const verbosity = isObject(text) ? text.verbosity : undefined;
180
+ const format = isObject(text) ? text.format : undefined;
181
+ const mode = isObject(prompt_cache_options) ? prompt_cache_options.mode : undefined;
182
+ const ttl = isObject(prompt_cache_options) ? prompt_cache_options.ttl : undefined;
160
183
  if ((instructions !== undefined && typeof instructions !== "string") ||
161
184
  (tools !== undefined && (!Array.isArray(tools) || !tools.every(isObject))) ||
162
185
  (reasoning !== undefined && !isObject(reasoning)) ||
186
+ (parallel_tool_calls !== undefined && typeof parallel_tool_calls !== "boolean") ||
187
+ (format !== undefined && !isObject(format)) ||
188
+ (prompt_cache_options !== undefined && !isObject(prompt_cache_options)) ||
189
+ (mode !== undefined && typeof mode !== "string") || (ttl !== undefined && typeof ttl !== "string") ||
163
190
  (prompt_cache_key !== undefined && typeof prompt_cache_key !== "string") ||
164
191
  (prompt_cache_retention !== undefined && typeof prompt_cache_retention !== "string") ||
165
192
  (service_tier !== undefined && typeof service_tier !== "string") ||
166
193
  (text !== undefined && !isObject(text)) || (verbosity !== undefined && typeof verbosity !== "string")) return undefined;
194
+ const contextual = contextUserItems(payload.input, userItemOrigins(context.messages));
195
+ // Copy policy only: a diagnostic response ID or prewarm operation belongs to the observed request.
196
+ const cachePolicy = mode !== undefined || ttl !== undefined ? { mode, ttl } : undefined;
197
+ const serverHistory = payload.previous_response_id != null || payload.conversation != null ||
198
+ payload.input.some((item) => item.type === "item_reference");
167
199
  return {
168
200
  sessionId: context.sessionId, identity: context.identity, sourceFingerprints: context.sourceFingerprints,
169
- inputsKey: requestInputsKey(target, inputs),
170
- fields: structuredClone({ instructions, tools, reasoning, prompt_cache_key, prompt_cache_retention, service_tier }),
171
- verbosity, prefix: structuredClone(declarationPrefix(payload.input)),
201
+ systemPrompt: inputs.systemPrompt, inputsKey: requestInputsKey(target, inputs),
202
+ fields: structuredClone({ instructions, tools, reasoning, parallel_tool_calls, text, prompt_cache_key,
203
+ prompt_cache_retention, prompt_cache_options: cachePolicy, service_tier }),
204
+ prefix: serverHistory ? undefined : {
205
+ boundary: `PI_CODEX_REQUEST_BOUNDARY_${randomUUID()}`, contextLength: context.messages.length,
206
+ input: structuredClone(payload.input), contextual: payload.input.map((item) => contextual.has(item)),
207
+ },
172
208
  };
173
209
  }
174
210
 
175
- /** Reuse wire declarations only with an unchanged source prefix and request configuration. */
211
+ /** Reuse the wire prompt and request parameters only with an unchanged source prefix and configuration. */
176
212
  export function providerRequestFor(
177
213
  snapshots: RequestSnapshots, sessionId: string, target: CapableModel, current: readonly AgentMessage[], inputs: ProviderRequestInputs,
178
214
  ): ProviderRequestSnapshot | undefined {
179
215
  const snapshot = snapshotFor(snapshots.providerRequest, sessionId, target);
216
+ const override = promptOverrideFor(snapshots.promptOverride, sessionId, target, current, inputs.systemPrompt);
217
+ const effectiveInputs = override ? { ...inputs, systemPrompt: override.text } : inputs;
218
+ const key = requestInputsKey(target, effectiveInputs);
180
219
  return snapshot && matchingSource(current, snapshot) &&
181
- snapshot.inputsKey === requestInputsKey(target, inputs) ? snapshot : undefined;
220
+ (snapshot.inputsKey === key || snapshot.settledInputsKey === key) ? snapshot : undefined;
182
221
  }
183
222
 
184
- /** Replace only declarations and cache-related fields; Pi still owns the current input, transport, and output limits. */
223
+ /** Replace the serialized prefix up to the private boundary; never let that boundary reach the provider. */
185
224
  export function applyProviderRequest(
186
225
  payload: JsonObject, target: CapableModel, snapshot: ProviderRequestSnapshot | undefined,
187
- ): JsonObject {
188
- if (!snapshot || !sameBackend(snapshot.identity, target.identity) || !sameModel(snapshot.identity, target.model) ||
189
- payload.model !== target.model.id || !Array.isArray(payload.input) || !payload.input.every(isObject)) return payload;
190
- const updated: JsonObject = { ...payload, ...structuredClone(snapshot.fields),
191
- input: [...structuredClone(snapshot.prefix), ...payload.input.slice(declarationPrefix(payload.input).length)] };
192
- if (isObject(payload.text) || snapshot.verbosity !== undefined) {
193
- const text = { ...(isObject(payload.text) ? payload.text : {}) };
194
- delete text.verbosity;
195
- if (snapshot.verbosity !== undefined) text.verbosity = snapshot.verbosity;
196
- updated.text = Object.keys(text).length ? text : undefined;
226
+ contextItems: ReadonlySet<JsonObject> = new Set(),
227
+ ): { readonly payload: JsonObject; readonly contextual: readonly boolean[] } {
228
+ const input = Array.isArray(payload.input) && payload.input.every(isObject) ? payload.input : undefined;
229
+ if (!snapshot) return { payload, contextual: input?.map((item) => contextItems.has(item)) ?? [] };
230
+ const compatible = sameBackend(snapshot.identity, target.identity) && sameModel(snapshot.identity, target.model) && payload.model === target.model.id;
231
+ const prefix = snapshot.prefix;
232
+ if (!prefix) return { payload: compatible ? { ...payload, ...structuredClone(snapshot.fields) } : payload,
233
+ contextual: input?.map((item) => contextItems.has(item)) ?? [] };
234
+ if (!input) throw new CodexCompactionProtocolError("Prepared request cannot locate the snapshot boundary");
235
+ const matches = input.flatMap((item, index) => item.role === "user" && Array.isArray(item.content) &&
236
+ item.content.length === 1 && isObject(item.content[0]) && item.content[0].type === "input_text" &&
237
+ item.content[0].text === prefix.boundary ? [index] : []);
238
+ if (matches.length !== 1) throw new CodexCompactionProtocolError("Prepared request must contain exactly one snapshot boundary");
239
+ const boundaryIndex = matches[0];
240
+ const suffix = input.slice(boundaryIndex + 1);
241
+ if (!compatible) {
242
+ const unmarked = [...input.slice(0, boundaryIndex), ...suffix];
243
+ return { payload: { ...payload, input: unmarked }, contextual: unmarked.map((item) => contextItems.has(item)) };
197
244
  }
198
- return updated;
245
+ return {
246
+ payload: { ...payload, ...structuredClone(snapshot.fields), input: [...structuredClone(prefix.input), ...suffix] },
247
+ contextual: [...prefix.contextual, ...suffix.map((item) => contextItems.has(item))],
248
+ };
199
249
  }
200
250
 
201
251
  /** The snapshots that the latest ordinary request left for compaction. */
@@ -215,7 +265,8 @@ interface TrackedSnapshots {
215
265
 
216
266
  /**
217
267
  * Follow one ordinary request through Pi's hooks: `context`, then `context_with_system`, then
218
- * `before_provider_request` for each provider attempt. State lives only as long as the Pi process.
268
+ * `before_provider_request` for each provider attempt, then `agent_settled` when its run ends.
269
+ * State lives only as long as the Pi process.
219
270
  */
220
271
  export class RequestSnapshotTracker {
221
272
  private state: TrackedSnapshots = {};
@@ -274,9 +325,23 @@ export class RequestSnapshotTracker {
274
325
  this.state.providerRequest = captureProviderRequest(context, target, payload, inputs);
275
326
  }
276
327
  }
328
+
329
+ /**
330
+ * `agent_settled`: Pi drops the run's prompt options, so `ctx.getSystemPrompt()` reports the base prompt
331
+ * until the next run. Accept that prompt for the run's last request only while every other input is unchanged;
332
+ * the request prefix still comes from the transcript the snapshot's source fingerprints bind.
333
+ */
334
+ recordSettledRun(sessionId: string, target: CapableModel | undefined, inputs: () => ProviderRequestInputs): void {
335
+ if (!target) return;
336
+ const snapshot = snapshotFor(this.state.providerRequest, sessionId, target);
337
+ if (!snapshot || snapshot.settledInputsKey !== undefined) return;
338
+ const settled = inputs();
339
+ if (requestInputsKey(target, { ...settled, systemPrompt: snapshot.systemPrompt }) !== snapshot.inputsKey) return;
340
+ this.state.providerRequest = { ...snapshot, settledInputsKey: requestInputsKey(target, settled) };
341
+ }
277
342
  }
278
343
 
279
- /** What Pi would declare for an ordinary request whose transcript carries no system messages. */
344
+ /** Pi's internal declarations let the provider serialize historical grammar tool calls correctly. */
280
345
  export interface RequestDeclarations {
281
346
  readonly blockImages: boolean;
282
347
  readonly systemPrompt: () => string;
@@ -298,8 +363,7 @@ function withoutImages(messages: Message[]): Message[] {
298
363
 
299
364
  /**
300
365
  * Build compaction's provider context from the current transcript and the snapshots bound to this
301
- * session, model, and backend. Pi 0.99 transcripts declare the prompt and tools through system
302
- * messages, as ordinary requests do; `messages` is the transcript actually sent.
366
+ * session, model, and backend. Preserve tool declarations for the request prefix and historical-call serialization.
303
367
  */
304
368
  export function compactionRequest(
305
369
  snapshots: RequestSnapshots,
@@ -307,9 +371,18 @@ export function compactionRequest(
307
371
  target: CapableModel,
308
372
  current: AgentMessage[],
309
373
  declarations: RequestDeclarations,
374
+ providerRequest?: ProviderRequestSnapshot,
310
375
  ): { readonly context: Context; readonly messages: AgentMessage[] } {
311
- const override = promptOverrideFor(snapshots.promptOverride, sessionId, target, current);
312
- const messages = reuseContextSnapshot(current, snapshotFor(snapshots.context, sessionId, target));
376
+ const override = snapshots.promptOverride && promptOverrideFor(snapshots.promptOverride, sessionId, target, current, declarations.systemPrompt());
377
+ const reused = reuseContextSnapshot(current, snapshotFor(snapshots.context, sessionId, target));
378
+ // The provider serializes the full transcript so grammar declarations and historical IDs keep their context.
379
+ // This private user item marks the join; applyProviderRequest removes it and everything before it.
380
+ const prefix = providerRequest?.prefix;
381
+ const messages: AgentMessage[] = prefix ? [
382
+ ...reused.slice(0, prefix.contextLength),
383
+ { role: "user", content: prefix.boundary, timestamp: 0 },
384
+ ...reused.slice(prefix.contextLength),
385
+ ] : reused;
313
386
  const converted = convertToLlm(messages);
314
387
  const llmMessages = declarations.blockImages ? withoutImages(converted) : converted;
315
388
  const overridden = override && applyPromptOverride(llmMessages, override);