pi-openai-codex-compat 0.0.14 → 0.0.15

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.0.15 - 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - Preserve earlier message omissions and replacements during overflow-recovery
10
+ compaction. Recover only the latest attempt Pi omitted for recovery, keeping
11
+ its committed progress without reviving unrelated content. Stored native
12
+ responses no longer override later assistant-message replacements.
13
+ - Rebuild the compaction request's tool declarations when a tool's parameter
14
+ schema changes under an unchanged name and description, instead of reusing a
15
+ stale schema from the previous turn.
16
+
5
17
  ## 0.0.14 - 2026-09-22
6
18
 
7
19
  ### Changed
package/README.md CHANGED
@@ -328,9 +328,21 @@ The extension handles native compaction for `openai-codex`. It follows the Codex
328
328
  1. Send normal Responses history followed by `{ "type": "compaction_trigger" }`.
329
329
  2. Validate the returned opaque `compaction` item.
330
330
  3. Retain approximately 64,000 tokens of recent user messages, plus every developer and
331
- system message outside that budget.
331
+ system message outside that budget. Repeated prompt updates (for example several
332
+ `/reload` runs or prompt-section changes) each remain in place as their own inline
333
+ developer item; they accumulate across checkpoints and are never dropped, merged, or
334
+ deduplicated, because each one is a distinct instruction the model already saw.
332
335
  4. Persist the opaque checkpoint in the Pi session and replay it on later requests.
333
336
 
337
+ Overflow and length recovery follow Pi 0.87's `context_edit` semantics. Every request
338
+ reads Pi's session projection, so earlier omissions and replacements (an elided tool
339
+ output, an omitted reply) apply exactly as they do for Pi's own adapters. Before an
340
+ overflow-recovery compaction, Pi omits the latest failed or truncated attempt and its tool
341
+ results; the checkpoint request projects only those entries as they were before that
342
+ omission, so a truncated response and its tool results survive as committed progress while
343
+ every unrelated earlier edit stays in force. A failed attempt contributes only the native
344
+ output committed before the overflowing subrequest.
345
+
334
346
  Ordinary responses and compaction use the same extension-managed SSE/WebSocket transport. Before the first WebSocket turn for a session and model, the provider performs a best-effort v2 `generate: false` prewarm of the static instruction/tool prefix, then generates the dynamic conversation input from its continuation. With `responsesLite: false`, supported models use the ordinary Responses envelope and receive their own ordinary-prefix prewarm.
335
347
 
336
348
  Healthy session WebSockets remain available until the server closes them or Pi tears down the session. Retryable WebSocket failures before model-visible output receive up to five fresh-connection retries before the session switches to sticky SSE. Retryable SSE HTTP failures and dropped streams receive up to five same-request resampling attempts before model-visible output. Both transports use Codex-style exponential backoff with ±10% jitter and preserve the same prompt-cache, session, account, installation, and window identities. Server metadata is not considered model-visible output, so a routing-state-only response can still be retried safely. Transport failures after model-visible output fail closed rather than risk duplicate text or tool calls. Explicit retryable `response.failed`/`response.incomplete` protocol terminals instead return to the provider-owned sampling loop, which preserves completed output items as the next request's history.
@@ -576,6 +588,15 @@ mise run check
576
588
  npm run pack:dry
577
589
  ```
578
590
 
591
+ `mise run check` runs the linters, formatters, type checks, and the offline
592
+ test suite; `mise run test` runs only the offline suite. Both tasks bind
593
+ `PI_PACKAGE_DIR` to `node_modules/@earendil-works/pi-coding-agent`, so tests
594
+ that load Pi in-process read this repository's Pi 0.87.0 resources even when a
595
+ global `PI_PACKAGE_DIR` points at another installation. Ordinary `pi` launches
596
+ outside these tasks are unaffected. Offline tests also exercise
597
+ `scripts/release.ts` with every child process mocked: they never run Git, npm,
598
+ Mise, or provider calls.
599
+
579
600
  Run the credentialed Pi/Codex integration tests separately. They load the real
580
601
  extension into headless Pi sessions, use the real WebSocket service, and ask
581
602
  the model to report all prior history markers after each text and tool
@@ -593,6 +614,21 @@ prompt reload, native compaction, and persisted-session resume against Codex.
593
614
  Test credentials stay in memory and child-process environments. Test sessions
594
615
  and configuration are isolated from the user's agent directory.
595
616
 
617
+ The packaged-CLI test selects its archive and executable as follows:
618
+
619
+ - Without `PI_CODEX_PACKAGE_ARCHIVE`, it packs the current worktree into a
620
+ temporary directory. With `PI_CODEX_PACKAGE_ARCHIVE`, it loads exactly that
621
+ archive: the value resolves against the working directory, must be an
622
+ existing regular file that `tar` can list, and an empty, missing, directory,
623
+ or invalid value fails the test instead of falling back to a fresh pack.
624
+ - `PI_CODEX_CLI_PATH` selects another Pi `cli.js`; the default is this
625
+ repository's Pi 0.87.0 dependency. The test asserts that the selected
626
+ executable reports version 0.87.0, the only Pi version the packaged CLI test
627
+ is run against. The supported range is `>=0.87.0 <0.88.0`.
628
+ - Each Pi child process receives `PI_PACKAGE_DIR` bound to the selected
629
+ executable's package directory, so the runtime under test reads its own
630
+ metadata.
631
+
596
632
  The public package entrypoint is `extensions/index.ts`; implementation modules remain under
597
633
  `extensions/openai-codex-compat/`. The focused Pi AI serializer copy lives under
598
634
  `extensions/openai-codex-compat/vendor/pi-ai/`. The custom Codex provider transport and stream
@@ -606,10 +642,74 @@ search, and compaction continuation.
606
642
  1. Run `npm run release -- X.Y.Z` from a clean, synchronized `main`.
607
643
  2. The command builds the exact package locally and runs live CLI tests against that archive. It also runs the existing live SDK tests against checkout source. Both suites must pass before it records the archive's SHA-256 in an SSH-signed release commit, proves a clean rebuild is reproducible, and creates a lightweight tag. Missing credentials or failing live tests stop the release.
608
644
  3. Inspect the result, then push atomically with `git push --atomic origin main vX.Y.Z`.
609
- 4. A read-only GitHub Actions job validates and packs the package. After approval in the tag-restricted `npm-publish` environment, a separate GitHub-owned job verifies the signature and signed digest before attesting and staging that exact archive through npm trusted publishing.
610
- 5. A final job creates the immutable GitHub release for the tag from the same verified archive, its
611
- checksum, and the version's `CHANGELOG.md` section (`Unreleased` for prereleases).
612
- 6. Approve the staged package on npmjs.com, or with `npm stage approve <stage-id>`.
645
+ 4. A read-only GitHub Actions job validates and packs the package. After approval
646
+ in the tag-restricted `npm-publish` environment, a separate GitHub-owned job
647
+ verifies the signature and signed digest before attesting and staging that
648
+ exact archive through npm trusted publishing.
649
+ 5. A final job creates the immutable GitHub release from the verified archive,
650
+ its checksum, and the version's changelog section (`Unreleased` for prereleases).
651
+ 6. Approve the staged package on npmjs.com or with `npm stage approve <stage-id>`.
652
+
653
+ The release command runs these steps in order:
654
+
655
+ 1. Refuse to start unless the branch is `main`, `git status --porcelain` is
656
+ empty, `CHANGELOG.md` has a section for the version, `HEAD` equals
657
+ `origin/main` after a fetch, and tag `vX.Y.Z` does not exist. Nothing has
658
+ changed when one of these checks fails.
659
+ 2. Write the version into `package.json` and `package-lock.json` and stage
660
+ only those two files.
661
+ 3. Check out the staged index into a temporary directory, run
662
+ `npm ci --ignore-scripts` and `npm pack` there, validate the package
663
+ identity, file list, and manifest, then run `mise run test:live:codex` in
664
+ the repository with `PI_CODEX_PACKAGE_ARCHIVE` set to that archive. This is
665
+ the exact archive release gate: the live CLI tests load that archive and
666
+ nothing else. A live process that cannot start, a nonzero exit, or a failed
667
+ package validation stops the release here.
668
+ 4. Create the SSH-signed `release: vX.Y.Z` commit with the archive's SHA-256
669
+ as its `Npm-Artifact-SHA256` trailer and verify the signature and trailer.
670
+ 5. Rebuild the package from the committed tree without repeating the live
671
+ tests and require the same digest.
672
+ 6. Create the lightweight tag and verify that it points at the release commit.
673
+
674
+ ### Recovering from a failed release command
675
+
676
+ Do not run any blanket `git restore`, `git reset`, `git checkout --`, or
677
+ `git clean`. Inspect first, then undo only what the failed command produced.
678
+
679
+ If the command fails during the version update, version changes can remain
680
+ in the worktree. Once the update and `git add` succeed, package, live-test,
681
+ and signing failures leave those changes staged. No release commit or tag
682
+ was created by that attempt. Inspect the state:
683
+
684
+ ```sh
685
+ git status --short
686
+ git diff -- package.json package-lock.json
687
+ git diff --cached -- package.json package-lock.json
688
+ ```
689
+
690
+ Undo only this attempt's version edits in the worktree and index. Preserve
691
+ concurrent changes, including edits in those same files. Do not stage whole
692
+ files containing unrelated edits. Fix the cause and confirm that `main` is
693
+ clean and synchronized before rerunning the release command.
694
+
695
+ If the command fails after the release commit (steps 4 to 6), a local signed
696
+ `release: vX.Y.Z` commit exists on `main`, and a local tag may exist. Do not rerun
697
+ the release command: it would refuse the unsynchronized `HEAD`, and a second
698
+ run on top of the existing commit would produce a duplicate release commit.
699
+ Do not push the commit or tag until signature verification, the reproducibility
700
+ check, and tag verification are complete. Inspect the commit and the digest:
701
+
702
+ ```sh
703
+ git log -1 --format='%H%n%s%n%(trailers:key=Npm-Artifact-SHA256,valueonly)' HEAD
704
+ git diff origin/main..HEAD -- package.json package-lock.json
705
+ git tag --list 'vX.Y.Z'
706
+ ```
707
+
708
+ Removing the local release commit or tag changes local refs. Confirm that
709
+ they were never pushed, record their hashes and a recovery path, and obtain
710
+ explicit approval before changing them. Never replace a published tag.
711
+ Fix the underlying cause before starting a new release from clean,
712
+ synchronized `main`.
613
713
 
614
714
  Stable releases use `latest`; prereleases derive their npm dist-tag from the first prerelease identifier.
615
715
 
@@ -47,6 +47,8 @@ export type RuntimeScope = {
47
47
  export type RequestTemplate = {
48
48
  modelId: string;
49
49
  payload: JsonRecord;
50
+ /** Fingerprint of the raw tool definitions that produced `payload.tools`. */
51
+ toolFingerprint: string;
50
52
  grammarToolInputProperties: GrammarToolInputProperties;
51
53
  requestOptions: OpenAICodexResponsesOptions;
52
54
  };
@@ -32,6 +32,7 @@ import {
32
32
  resolveRequestTranscript,
33
33
  responsesCompatibility,
34
34
  searchCheckpoint,
35
+ toolDefinitionFingerprint,
35
36
  type CheckpointData,
36
37
  type CompactionDecision,
37
38
  type GrammarToolInputProperties,
@@ -871,6 +872,7 @@ export class CodexProviderRuntime {
871
872
  this.templates.set(runtimeSessionId, {
872
873
  modelId: model.id,
873
874
  payload: replacementValues ? {} : withoutConversationInput(body),
875
+ toolFingerprint: toolDefinitionFingerprint(getCurrentTools(context.messages)),
874
876
  grammarToolInputProperties,
875
877
  requestOptions: { ...requestOptions },
876
878
  });
@@ -1,10 +1,11 @@
1
1
  import { isBoolean, isString } from "./value-contracts.ts";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import {
4
4
  buildSessionContext,
5
5
  buildSessionProjection,
6
6
  convertToLlm,
7
7
  sessionEntryToContextMessages,
8
+ type ContextEditEntry,
8
9
  type SessionEntry,
9
10
  type ToolInfo,
10
11
  } from "@earendil-works/pi-coding-agent";
@@ -31,6 +32,7 @@ import {
31
32
  type JsonRecord,
32
33
  } from "./codex-protocol.ts";
33
34
  import { nativeCommittedPrefixBeforeOverflow, nativeResponseOverrides } from "./native-history.ts";
35
+ import { stableResponsesJson } from "./responses-replay.ts";
34
36
  import {
35
37
  CODEX_NAMESPACED_TOOL_NAMES,
36
38
  CODEX_TEXT_CONTENT_ITEM_TOOL_RESULT_NAMES,
@@ -181,9 +183,45 @@ export function activeResponsesTools(
181
183
  : undefined;
182
184
  }
183
185
 
186
+ /** The raw tool definition fields that both Pi AI's `Tool` and Pi's `ToolInfo` carry. */
187
+ export type ToolDeclarationSource = Pick<ToolInfo, "name" | "description" | "parameters">;
188
+
184
189
  /**
185
- * Whether a serialized Responses `tools` list declares exactly the active tools,
186
- * by name and description. Namespace members count as `namespace.member`.
190
+ * Fingerprint raw tool definitions by name, description, and parameter schema,
191
+ * independent of declaration order. Both the transcript tools a turn request
192
+ * declared and the registry's active `ToolInfo` list produce the same value for
193
+ * the same definitions, so a cached turn template can be compared with the
194
+ * current tools without reversing the strict schema conversion applied on the
195
+ * wire.
196
+ */
197
+ export function toolDefinitionFingerprint(tools: readonly ToolDeclarationSource[]): string {
198
+ const declarations = tools
199
+ .map((tool) => {
200
+ // The JSON round-trip drops typebox symbol keys and undefined fields, as
201
+ // Pi AI's own declaration comparison does.
202
+ const parameters: unknown = JSON.parse(JSON.stringify(tool.parameters));
203
+ return { name: tool.name, description: tool.description, parameters };
204
+ })
205
+ .sort((left, right) => left.name.localeCompare(right.name));
206
+ return createHash("sha256").update(stableResponsesJson(declarations)).digest("hex");
207
+ }
208
+
209
+ /** Fingerprint of the registry tools that are currently active. */
210
+ export function activeToolFingerprint(
211
+ allTools: readonly ToolInfo[],
212
+ activeNames: readonly string[],
213
+ ): string {
214
+ const enabled = new Set(activeNames);
215
+ return toolDefinitionFingerprint(allTools.filter((tool) => enabled.has(tool.name)));
216
+ }
217
+
218
+ /**
219
+ * Whether a cached turn template still declares exactly the active tools.
220
+ *
221
+ * The serialized `tools` list must name the active set (namespace members
222
+ * count as `namespace.member`), and the raw definitions that produced it must
223
+ * fingerprint identically to the active registry tools. Comparing names and
224
+ * descriptions alone missed parameter-schema changes under an unchanged name.
187
225
  *
188
226
  * A cached turn template carries the exact declarations the turn requests sent,
189
227
  * including strict schema conversion that `ToolInfo` cannot reproduce. Reusing
@@ -191,9 +229,11 @@ export function activeResponsesTools(
191
229
  */
192
230
  export function declaresActiveTools(
193
231
  serialized: readonly JsonRecord[],
232
+ cachedToolFingerprint: string,
194
233
  allTools: readonly ToolInfo[],
195
234
  activeNames: readonly string[],
196
235
  ): boolean {
236
+ if (cachedToolFingerprint !== activeToolFingerprint(allTools, activeNames)) return false;
197
237
  const declared = new Map<string, string | undefined>();
198
238
  for (const item of serialized) {
199
239
  if (item.type === "namespace" && isString(item.name) && Array.isArray(item.tools)) {
@@ -415,18 +455,69 @@ export function checkpointData(
415
455
  return checkpoint;
416
456
  }
417
457
 
458
+ /**
459
+ * The entries Pi 0.87 omits for overflow or length recovery: the latest
460
+ * assistant attempt and the tool results of that turn, each targeted by a
461
+ * `context_edit` omission appended after the attempt. `leafId` is the parent of
462
+ * the first such omission, so a projection built up to it reflects every edit
463
+ * that existed before Pi omitted the attempt.
464
+ *
465
+ * Pi only selects a recovery attempt while it is still projected, so an
466
+ * omission of the latest assistant found here is Pi's recovery omission and
467
+ * not an unrelated edit.
468
+ */
469
+ function recoveryOmissions(
470
+ branch: readonly SessionEntry[],
471
+ ): { entryIds: ReadonlySet<string>; leafId: string } | undefined {
472
+ const attemptIndex = branch.findLastIndex(
473
+ (entry) => entry.type === "message" && entry.message.role === "assistant",
474
+ );
475
+ const attempt = branch[attemptIndex];
476
+ if (attempt?.type !== "message" || attempt.message.role !== "assistant") return undefined;
477
+
478
+ const toolCallIds = new Set(
479
+ attempt.message.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : [])),
480
+ );
481
+ const candidates = new Set([attempt.id]);
482
+ for (const entry of branch.slice(attemptIndex + 1)) {
483
+ if (entry.type !== "message") continue;
484
+ if (entry.message.role !== "toolResult" || !toolCallIds.has(entry.message.toolCallId)) break;
485
+ candidates.add(entry.id);
486
+ }
487
+
488
+ const latestEdits = new Map<string, ContextEditEntry>();
489
+ for (const entry of branch.slice(attemptIndex + 1)) {
490
+ if (entry.type === "context_edit" && candidates.has(entry.targetId)) {
491
+ latestEdits.set(entry.targetId, entry);
492
+ }
493
+ }
494
+ if (latestEdits.get(attempt.id)?.replacement !== null) return undefined;
495
+ const omissions = new Set(
496
+ [...latestEdits.values()].filter((entry) => entry.replacement === null),
497
+ );
498
+ const first = branch.find((entry) => entry.type === "context_edit" && omissions.has(entry));
499
+ if (first?.type !== "context_edit" || first.parentId === null) return undefined;
500
+ return {
501
+ entryIds: new Set([...omissions].map((entry) => entry.targetId)),
502
+ leafId: first.parentId,
503
+ };
504
+ }
505
+
418
506
  /**
419
507
  * Materialize provider history for the active branch. Checkpoint history
420
508
  * replaces everything before its entry; later branch entries form the tail.
421
509
  *
422
- * Ordinary requests read the session projection, so `context_edit` omissions
423
- * and replacements apply exactly as they do for Pi's own adapters. Recovery
424
- * compaction reads the raw entries instead: Pi 0.87 omits the failed or
425
- * truncated attempt through a `context_edit` child of that entry before it
426
- * runs `session_before_compact`. The serializer already skips error and
427
- * aborted assistants, and a truncated boundary is committed progress that the
428
- * checkpoint must keep. Entries are never removed from the branch copy because
429
- * the omission entry's parent chain would break and the history would vanish.
510
+ * Every request reads the session projection, so `context_edit` omissions and
511
+ * replacements apply exactly as they do for Pi's own adapters. Recovery
512
+ * compaction differs only for the latest attempt: Pi 0.87 omits the failed or
513
+ * truncated assistant and its tool results through `context_edit` entries
514
+ * before it runs `session_before_compact`. Those entries are projected as they
515
+ * were before Pi's omission, so a truncated boundary stays as committed
516
+ * progress while every earlier omission or replacement remains in force. The
517
+ * serializer skips error and aborted assistants; a failed attempt contributes
518
+ * only the native prefix committed before the overflowing subrequest. Entries
519
+ * are never removed from the branch copy because the omission entry's parent
520
+ * chain would break and the history would vanish.
430
521
  */
431
522
  export function providerHistory(options: {
432
523
  branch: readonly SessionEntry[];
@@ -459,13 +550,30 @@ export function providerHistory(options: {
459
550
  const projection = buildSessionProjection(branch);
460
551
  const projected = new Map<string, AgentMessages>();
461
552
  for (const entry of projection.entries) projected.set(entry.sourceEntry.id, entry.messages);
462
- const entryMessages = (entry: SessionEntry): AgentMessages =>
463
- options.recoverLatestOverflowPrefix
464
- ? sessionEntryToContextMessages(entry)
465
- : (projected.get(entry.id) ?? []);
553
+ const recovery = options.recoverLatestOverflowPrefix ? recoveryOmissions(branch) : undefined;
554
+ if (recovery) {
555
+ for (const entry of buildSessionProjection(branch, recovery.leafId).entries) {
556
+ if (recovery.entryIds.has(entry.sourceEntry.id)) {
557
+ projected.set(entry.sourceEntry.id, entry.messages);
558
+ }
559
+ }
560
+ }
561
+ const entryMessages = (entry: SessionEntry): AgentMessages => projected.get(entry.id) ?? [];
466
562
 
467
563
  const checkpoint = searchCheckpoint(branch);
468
- const nativeAssistantItems = nativeResponseOverrides(branch, options.wireModel.id);
564
+ const nativeAssistantItems = new Map(nativeResponseOverrides(branch, options.wireModel.id));
565
+ for (const entry of branch) {
566
+ if (
567
+ entry.type === "message" &&
568
+ entry.message.role === "assistant" &&
569
+ entry.message.responseId &&
570
+ entryMessages(entry).some((message) => message !== entry.message)
571
+ ) {
572
+ // A replacement keeps the response ID but changes the model-visible content.
573
+ // Its original native output must not override the canonical replacement.
574
+ nativeAssistantItems.delete(entry.message.responseId);
575
+ }
576
+ }
469
577
  if (checkpoint.kind === "corrupt") {
470
578
  throw new Error("The latest Codex compaction checkpoint is corrupt.");
471
579
  }
@@ -258,15 +258,18 @@ export default function registerRemoteCompaction(
258
258
  });
259
259
  // Reuse the last turn's exact declarations when they still describe the
260
260
  // active tools, so the compaction request shares the turn requests'
261
- // prompt-cache prefix. Rebuild from the registry only when the active
262
- // set changed since that turn or no turn template exists.
261
+ // prompt-cache prefix. Rebuild from the registry when the active set or
262
+ // any definition (including a parameter schema under an unchanged name)
263
+ // changed since that turn, or when no turn template exists.
263
264
  const template: JsonRecord = { ...matching?.payload };
264
265
  const activeNames = pi.getActiveTools();
265
266
  const cachedTools = Array.isArray(matching?.payload.tools)
266
267
  ? matching.payload.tools.filter(isObject)
267
268
  : undefined;
268
269
  template.tools =
269
- cachedTools && declaresActiveTools(cachedTools, allTools, activeNames)
270
+ cachedTools &&
271
+ matching &&
272
+ declaresActiveTools(cachedTools, matching.toolFingerprint, allTools, activeNames)
270
273
  ? cachedTools
271
274
  : (activeResponsesTools(allTools, activeNames, grammarToolInputProperties) ?? []);
272
275
  const requestOptions: OpenAICodexResponsesOptions = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-codex-compat",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "OpenAI Codex compatibility for Pi with native compaction, fast mode, and Codex-optimized capabilities",
5
5
  "keywords": [
6
6
  "pi-package"