pi-openai-toolkit 0.14.9 → 0.14.10

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/docs/internals.md CHANGED
@@ -52,6 +52,7 @@ sequenceDiagram
52
52
  Toolkit->>Toolkit: verify successful notes write in this window
53
53
  Toolkit-->>Model: checkpoint receipt with the successful note path
54
54
  Toolkit-->>Pi: window marker appended, previous window trim scheduled
55
+ Note over Model: Context switch is complete; read the receipt once, then resume the active task
55
56
  Model->>Backend: next request with the new x-codex-window-id
56
57
  Pi->>Toolkit: session_before_compact
57
58
  Toolkit-->>Pi: no-summary boundary consuming the scheduled trim
@@ -62,6 +63,7 @@ State invariants the lifecycle code must keep honest:
62
63
  - The checkpoint gate verifies a successful `notes` `append_to_file` or `write_file` against the persisted session branch after the latest window boundary, so it survives restarts and forks. A response with `ok: false` or `success: false` is not successful, even when HTTP returns 200.
63
64
  - The gate uses the live session/branch identity from `ctx.sessionManager`; cached manager identity must not hide a valid current-window pair, and old or foreign-session evidence must remain rejected.
64
65
  - Every rollover carries the most recent successful checkpoint path in its handoff message. The optional `thread_hint` is supplemental; a hint failure must not erase the local receipt.
66
+ - A successful rollover closes the pre-rollover checkpoint phase. The first turn in the new window reads the receipt once and resumes the active user task; it must not immediately create another checkpoint or call `new_context` as part of that handoff. A later checkpoint is only for a later rollover.
65
67
  - `new_context` has no force escape hatch: a persisted successful notes checkpoint in the current window is always required before rollover. The model must wait for the notes result to be persisted before retrying.
66
68
  - After a marker is accepted for sending, the manager keeps a session/window-anchored pending guard until that exact target marker is persisted or the session changes. A duplicate `new_context` during this persistence gap returns `started: false` and sends no second marker; projected/in-memory messages do not retire the guard.
67
69
  - Budget checks are skipped until the current window has produced its own assistant usage. Acting on the previous window's usage anchor would burn the once-per-window reminder on a false alarm.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.14.9",
3
+ "version": "0.14.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -32,20 +32,7 @@
32
32
  "codex",
33
33
  "responses"
34
34
  ],
35
- "pi": {
36
- "extensions": [
37
- "./extensions/compaction.ts",
38
- "./extensions/web-search.ts",
39
- "./extensions/image-generation.ts",
40
- "./extensions/auto-mode.ts",
41
- "./extensions/codex-astra.ts"
42
- ]
43
- },
44
35
  "files": [
45
- "extensions/compaction.ts",
46
- "extensions/web-search.ts",
47
- "extensions/image-generation.ts",
48
- "extensions/auto-mode.ts",
49
36
  "src/compact-client.ts",
50
37
  "src/compaction-projection.ts",
51
38
  "src/pi-context-hook.ts",
@@ -81,7 +68,6 @@
81
68
  "src/auto-mode/reviewer.ts",
82
69
  "src/auto-mode/classifier.ts",
83
70
  "src/auto-mode/types.ts",
84
- "extensions/codex-astra.ts",
85
71
  "src/codex-astra/effort-planner.ts",
86
72
  "src/codex-astra/extension.ts",
87
73
  "src/model-scope.ts",
@@ -120,10 +106,10 @@
120
106
  "@earendil-works/pi-tui": ">=0.85.1"
121
107
  },
122
108
  "devDependencies": {
123
- "@earendil-works/pi-agent-core": "0.85.1",
124
- "@earendil-works/pi-ai": "0.85.1",
125
- "@earendil-works/pi-coding-agent": "0.85.1",
126
- "@earendil-works/pi-tui": "0.85.1",
109
+ "@earendil-works/pi-agent-core": "0.86.0",
110
+ "@earendil-works/pi-ai": "0.86.0",
111
+ "@earendil-works/pi-coding-agent": "0.86.0",
112
+ "@earendil-works/pi-tui": "0.86.0",
127
113
  "@types/node": "24.12.4",
128
114
  "bun": "1.2.23",
129
115
  "typescript": "5.9.3"
@@ -1,4 +1,12 @@
1
- import type { Api, AssistantMessage, Context, Message, Model, Tool } from "@earendil-works/pi-ai";
1
+ import type {
2
+ Api,
3
+ AssistantMessage,
4
+ Context,
5
+ Message,
6
+ Model,
7
+ Tool,
8
+ ToolCall,
9
+ } from "@earendil-works/pi-ai";
2
10
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
11
  import { parseModelSpec } from "../runtime";
4
12
  import { backfillVerdictFields, buildReviewPrompt, reviewerSystemPrompt } from "./prompt";
@@ -93,11 +101,13 @@ function firstTextBlock(message: AssistantMessage): string {
93
101
  .trim();
94
102
  }
95
103
 
96
- function toolCallBlocks(message: AssistantMessage) {
97
- return message.content.filter(
98
- (block): block is { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> } =>
99
- block.type === "toolCall",
100
- );
104
+ /**
105
+ * Pi 0.85 typed tool-call arguments as `Record<string, any>` and Pi 0.86 tightened
106
+ * them to the public `JsonObject`. Narrow with Pi's exported `ToolCall` instead of a
107
+ * handwritten shape so the guard tracks whichever contract the installed Pi declares.
108
+ */
109
+ function toolCallBlocks(message: AssistantMessage): ToolCall[] {
110
+ return message.content.filter((block): block is ToolCall => block.type === "toolCall");
101
111
  }
102
112
 
103
113
  type CompletionAttempt = {
@@ -35,7 +35,7 @@ export const CONTEXT_WINDOW_COMPACTION_SUMMARY =
35
35
  "[Pi Codex context-window boundary; no conversation summary was generated.]";
36
36
 
37
37
  const CONTEXT_WINDOW_GUIDANCE = `<context_window_guidance>
38
- Checkpoint the active request, known history IDs, decisions, progress, learnings and next steps in notes before new_context, and wait for that notes result to be persisted; only a persisted successful result in the current window unlocks the rollover, and no summary carries over. If new_context reports that a rollover is already scheduled, do not call it again in the same window. After rollover, read the checkpoint receipt note first when present; use a thread hint only as supplemental guidance. Use history only for a missing detail.
38
+ Before calling new_context, checkpoint the active request, known history IDs, decisions, progress, learnings and next steps in notes, and wait for that result to be persisted; only a persisted successful result in the current window unlocks the rollover. If new_context reports that a rollover is already scheduled, do not call it again in the same window. When this message includes a completed context-switch handoff, follow that post-rollover section first instead of restarting the pre-rollover checkpoint steps. A thread hint is supplemental and must not replace the local checkpoint receipt. No conversation summary carries over, and history is only for missing details.
39
39
  </context_window_guidance>`;
40
40
 
41
41
  export function renderContextWindowMessage(
@@ -51,8 +51,11 @@ export function renderContextWindowMessage(
51
51
  ];
52
52
  if (identity.previousWindowId) lines.push(`Previous context window id: ${identity.previousWindowId}`);
53
53
  if (checkpoint) {
54
+ lines.push("Context switch completed. This is the first turn in the new context window.");
54
55
  lines.push("Checkpoint successfully written:");
55
56
  lines.push(` Read this note before doing anything else with notes action "read_file": ${JSON.stringify(checkpoint.path)}`);
57
+ lines.push("After reading the checkpoint receipt, resume the active user task.");
58
+ lines.push("Do not immediately create another checkpoint or call new_context as part of this handoff. Only prepare a new checkpoint when a later context rollover is actually needed.");
56
59
  }
57
60
  if (threadHint) lines.push(threadHint);
58
61
  lines.push("</context_window>");
@@ -94,7 +94,8 @@ export function createContextManagementTools(
94
94
  parameters: NEW_CONTEXT_PARAMETERS,
95
95
  promptSnippet: "Start a new remote Codex context window without summarizing history.",
96
96
  promptGuidelines: [
97
- "Checkpoint active work in notes before calling new_context; no conversation summary carries over. Only a persisted successful notes append/write in the current window unlocks the rollover.",
97
+ "Before this new_context call, checkpoint active work in notes; only a persisted successful notes append/write in the current window unlocks this rollover, and no conversation summary carries over.",
98
+ "A successful new_context completes one context switch. In the next window, read the checkpoint receipt first when present, then resume the active user task; do not immediately create another checkpoint or call new_context as part of that handoff.",
98
99
  "Wait for the notes tool result before calling new_context; an in-flight or failed write is not a checkpoint. If new_context reports that a rollover is already scheduled, do not call it again in the same window.",
99
100
  ],
100
101
  executionMode: "sequential",
@@ -116,7 +117,12 @@ export function createContextManagementTools(
116
117
  trimPreviousWindow: true,
117
118
  });
118
119
  return {
119
- content: [{ type: "text", text: started ? "A new context window will start without summarizing conversation history." : "A new context window is already scheduled." }],
120
+ content: [{
121
+ type: "text",
122
+ text: started
123
+ ? "Context switch scheduled successfully. In the next context window, read the checkpoint receipt first when present, then resume the active user task; do not immediately create another checkpoint or call new_context."
124
+ : "A new context window is already scheduled.",
125
+ }],
120
126
  details: { started },
121
127
  };
122
128
  },
@@ -161,6 +167,7 @@ export function createContextManagementTools(
161
167
  promptSnippet: "Read and checkpoint remote Codex notes across context windows.",
162
168
  promptGuidelines: [
163
169
  "Before calling new_context, checkpoint the current turn's active work (unfinished tasks, decisions, open questions, references) into notes with append_to_file or write_file so it survives the window change; wait for that result to be persisted before calling new_context.",
170
+ "After a successful new_context handoff, read the checkpoint receipt first when present and resume the active user task; do not immediately create another checkpoint or call new_context unless a later rollover is actually needed.",
164
171
  "When a large task spans multiple context windows, keep a running note per line of work and read it at the start of each new window; append new state instead of replacing it unless the note is stale.",
165
172
  "Prefer read_file or search_contents for looking things up; reserve write_file for explicit rewrite/clear and append_to_file for incremental state.",
166
173
  "Keep note text concise and self-contained: it may be read later without the rest of the conversation, so include identifiers and verbatim key decisions, not hearsay summaries.",
@@ -301,6 +301,7 @@ async function runResponsesNativeCompact(
301
301
  ...serializeMessagesToResponsesInput(
302
302
  compactor.currentModel,
303
303
  [...projection.messages.slice(summaryIndex + 1)],
304
+ { firstSystemMessageIsUpdate: true },
304
305
  ),
305
306
  ];
306
307
  request = {
@@ -337,7 +337,11 @@ export function removeNativeCompactionRetainedMessages(args: {
337
337
  }
338
338
  const firstKept = findEntryIndexByIdBeforeBoundary(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
339
339
  if (firstKept === undefined) return { ok: false, reason: "first-kept-entry-not-found" };
340
- const summary = structuredClone(sessionEntryToContextMessages(args.compactionEntry)[0]);
340
+ const summaryMessage = sessionEntryToContextMessages(args.compactionEntry).find(
341
+ (message) => message.role === "compactionSummary",
342
+ );
343
+ if (!summaryMessage) return { ok: false, reason: "compaction-summary-not-found" };
344
+ const summary = structuredClone(summaryMessage);
341
345
  const summaryIndex = args.messages.findIndex((message) => areEquivalentValues(message, summary));
342
346
  if (summaryIndex < 0) return { ok: false, reason: "compaction-summary-not-found" };
343
347
  const retained = args.branchEntries
@@ -349,7 +353,13 @@ export function removeNativeCompactionRetainedMessages(args: {
349
353
  // removed when present, while a missing or rewritten copy is left alone.
350
354
  // Required user/assistant/tool messages keep the ordered, fail-closed
351
355
  // matching contract.
352
- const requiredRetained = retained.filter((message) => message.role !== "custom");
356
+ // Pi 0.86 promotes transcript system messages to the leading prompt when it
357
+ // rebuilds context after compaction. They can therefore appear before the
358
+ // compaction summary even when their session entry lies inside the retained
359
+ // span; keep them in place instead of treating that relocation as a mismatch.
360
+ const requiredRetained = retained.filter(
361
+ (message) => message.role !== "custom" && (message.role as string) !== "system",
362
+ );
353
363
  const optionalRetained = retained.filter((message) => message.role === "custom");
354
364
  const removed = new Set<number>();
355
365
  let cursor = summaryIndex + 1;
@@ -388,7 +398,9 @@ export function serializeLiveTailToResponsesInput<TApi extends Api>(args: {
388
398
  model: Model<TApi>;
389
399
  entries: readonly SessionEntry[];
390
400
  }): ResponsesInputItem[] {
391
- return serializeMessagesToResponsesInput(args.model, collectReplayMessages(args.entries));
401
+ return serializeMessagesToResponsesInput(args.model, collectReplayMessages(args.entries), {
402
+ firstSystemMessageIsUpdate: true,
403
+ });
392
404
  }
393
405
 
394
406
  function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
package/src/serializer.ts CHANGED
@@ -89,6 +89,21 @@ export type ResponsesFunctionCallOutputItem = {
89
89
 
90
90
  export type ResponsesReasoningItem = Record<string, unknown>;
91
91
 
92
+ /**
93
+ * Pi 0.86 adds `SystemMessage` to the public `Message` union; 0.85.1 has no system
94
+ * variant and does not export that type name. Use the public shape when the installed
95
+ * Pi declares it and a structural fallback for the fields this serializer consumes
96
+ * otherwise, so one source file type-checks against both supported versions.
97
+ */
98
+ type ResponsesSystemMessage = Extract<Message, { role: "system" }> extends never
99
+ ? {
100
+ role: "system";
101
+ content: string | TextContent[];
102
+ sections?: Record<string, string | null>;
103
+ timestamp: number;
104
+ }
105
+ : Extract<Message, { role: "system" }>;
106
+
92
107
  export type ResponsesInputItem =
93
108
  | ResponsesInputMessageItem
94
109
  | ResponsesAssistantOutputItem
@@ -106,6 +121,8 @@ export type NativeCompactionRequestBody = CompactionRequestExtras & {
106
121
  export type SerializeResponsesMessagesOptions = {
107
122
  instructions?: string;
108
123
  includeInstructionsInInput?: boolean;
124
+ /** Treat a system message at the start of a sliced post-compaction tail as an update. */
125
+ firstSystemMessageIsUpdate?: boolean;
109
126
  };
110
127
 
111
128
  export type ResponsesParityReport = {
@@ -126,6 +143,13 @@ function sanitizeSurrogates(text: string): string {
126
143
  return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
127
144
  }
128
145
 
146
+ function instructionRole<TApi extends Api>(model: Model<TApi>): "developer" | "system" {
147
+ // Pi's public Model compat union is broader than the OpenAI Responses compat
148
+ // shape, while the runtime field is shared by both supported Pi versions.
149
+ const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
150
+ return model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
151
+ }
152
+
129
153
  export function collectCompactionWindowMessages(preparation: CompactionPreparation): AgentMessage[] {
130
154
  return [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
131
155
  }
@@ -159,19 +183,38 @@ export function serializeMessagesToResponsesInput<TApi extends Api>(
159
183
  messages: AgentMessage[],
160
184
  options: SerializeResponsesMessagesOptions = {},
161
185
  ): ResponsesInputItem[] {
162
- const llmMessages = convertToLlm(messages);
163
- const transformedMessages = transformMessagesForResponses(llmMessages);
186
+ return serializeLlmMessagesToResponsesInput(model, convertToLlm(messages), options);
187
+ }
188
+
189
+ /**
190
+ * Serialize Pi's normalized LLM message union without running `convertToLlm` again.
191
+ * This keeps the system-message boundary directly testable while preserving the public
192
+ * AgentMessage-facing entry point above.
193
+ */
194
+ export function serializeLlmMessagesToResponsesInput<TApi extends Api>(
195
+ model: Model<TApi>,
196
+ messages: Message[],
197
+ options: SerializeResponsesMessagesOptions = {},
198
+ ): ResponsesInputItem[] {
199
+ const transformedMessages = transformMessagesForResponses(messages);
164
200
  const input: ResponsesInputItem[] = [];
165
201
 
166
202
  if (options.includeInstructionsInInput && options.instructions) {
167
203
  input.push({
168
- role: model.reasoning ? "developer" : "system",
204
+ role: instructionRole(model),
169
205
  content: sanitizeSurrogates(options.instructions),
170
206
  });
171
207
  }
172
208
 
173
209
  let messageIndex = 0;
210
+ let sourceIndex = 0;
174
211
  for (const message of transformedMessages) {
212
+ // Pi 0.85's Message union has no system member, so compare through the
213
+ // public role string while keeping the 0.86 discriminant runtime-compatible.
214
+ const isFirstMessage = sourceIndex++ === 0;
215
+ const isLeadingSystemMessage =
216
+ !options.firstSystemMessageIsUpdate && isFirstMessage && (message.role as string) === "system";
217
+
175
218
  if (message.role === "user") {
176
219
  const item = serializeUserMessage(message, model);
177
220
  if (item) {
@@ -190,8 +233,21 @@ export function serializeMessagesToResponsesInput<TApi extends Api>(
190
233
  continue;
191
234
  }
192
235
 
193
- input.push(serializeToolResultMessage(message, model));
194
- messageIndex++;
236
+ if (message.role === "toolResult") {
237
+ input.push(serializeToolResultMessage(message, model));
238
+ messageIndex++;
239
+ continue;
240
+ }
241
+
242
+ // In Pi 0.85.1 this branch narrows to `never`; in Pi 0.86 it is a SystemMessage.
243
+ // Handle it explicitly instead of letting a new message variant become tool output.
244
+ const systemItem = serializeSystemMessage(message, model, isLeadingSystemMessage);
245
+ if (systemItem) {
246
+ input.push(systemItem);
247
+ }
248
+ if (!isLeadingSystemMessage) {
249
+ messageIndex++;
250
+ }
195
251
  }
196
252
 
197
253
  return input;
@@ -446,6 +502,72 @@ function normalizeUserContent(content: UserMessage["content"]): Array<TextConten
446
502
  return typeof content === "string" ? [{ type: "text", text: content }] : content;
447
503
  }
448
504
 
505
+ /**
506
+ * Serialize one normalized Pi system message as a Responses input message.
507
+ *
508
+ * The role follows the same reasoning-model convention as `instructions`: reasoning
509
+ * models use `developer`, everything else keeps `system`. Leading messages include
510
+ * their complete section state; later messages use Pi's update framing so section
511
+ * additions/removals are not mistaken for a new complete prompt. An empty system
512
+ * message produces no item, matching Pi.
513
+ */
514
+ function serializeSystemMessage<TApi extends Api>(
515
+ message: ResponsesSystemMessage,
516
+ model: Model<TApi>,
517
+ isLeadingSystemMessage: boolean,
518
+ ): ResponsesInputMessageItem | undefined {
519
+ const text = sanitizeSurrogates(
520
+ isLeadingSystemMessage ? getSystemMessageText(message) : renderSystemMessageUpdate(message),
521
+ );
522
+ if (text.length === 0) {
523
+ return undefined;
524
+ }
525
+
526
+ return {
527
+ role: instructionRole(model),
528
+ content: text,
529
+ };
530
+ }
531
+
532
+ /** Mirrors Pi's public getSystemMessageText() without importing a 0.86-only helper. */
533
+ function getSystemMessageText(message: ResponsesSystemMessage): string {
534
+ const parts = [contentText(message.content)];
535
+ for (const text of Object.values(message.sections ?? {})) {
536
+ if (text !== null) {
537
+ parts.push(text);
538
+ }
539
+ }
540
+ return parts.filter((part) => part.length > 0).join("\n\n");
541
+ }
542
+
543
+ /** Mirrors Pi's public renderSystemMessageUpdate() for later transcript updates. */
544
+ function renderSystemMessageUpdate(message: ResponsesSystemMessage): string {
545
+ const parts: string[] = [];
546
+ const text = contentText(message.content);
547
+ if (text.length > 0) {
548
+ parts.push(text);
549
+ }
550
+ for (const [name, value] of Object.entries(message.sections ?? {})) {
551
+ parts.push(
552
+ value === null
553
+ ? `Removed system prompt section "${name}".`
554
+ : `Updated system prompt section "${name}":\n\n${value}`,
555
+ );
556
+ }
557
+ return parts.join("\n\n");
558
+ }
559
+
560
+ function contentText(content: ResponsesSystemMessage["content"]): string {
561
+ if (typeof content === "string") {
562
+ return content;
563
+ }
564
+
565
+ return content
566
+ .filter((item): item is TextContent => item.type === "text")
567
+ .map((item) => item.text)
568
+ .join("\n");
569
+ }
570
+
449
571
  function parseReasoningItem(block: ThinkingContent): ResponsesReasoningItem | undefined {
450
572
  if (!block.thinkingSignature) {
451
573
  return undefined;
@@ -12,10 +12,6 @@
12
12
  "typeRoots": ["./node_modules/@types"]
13
13
  },
14
14
  "include": [
15
- "extensions/compaction.ts",
16
- "extensions/web-search.ts",
17
- "extensions/image-generation.ts",
18
- "extensions/auto-mode.ts",
19
15
  "src/compact-client.ts",
20
16
  "src/compaction-projection.ts",
21
17
  "src/pi-context-hook.ts",
@@ -1 +0,0 @@
1
- export { default } from "../src/auto-mode/extension.ts";
@@ -1 +0,0 @@
1
- export { default } from "../src/codex-astra/extension.ts";
@@ -1 +0,0 @@
1
- export { default } from "../src/extension-runtime.ts";
@@ -1 +0,0 @@
1
- export { default } from "../src/image-generation/extension.ts";
@@ -1 +0,0 @@
1
- export { default } from "../src/web-search/extension.ts";