pi-openai-toolkit 0.7.0 → 0.7.2

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 CHANGED
@@ -163,6 +163,7 @@ Content-Type: application/json
163
163
  - **Validation Criteria**: Must return a valid SSE stream with `response.completed` (status `completed`) containing exactly one output item of `type: "compaction"` with non-empty `encrypted_content`.
164
164
  - **Remote Model Override**: `compaction.remoteCompactModel` can assign a same-gateway Responses model as the checkpoint producer while the active model remains the consumer. Its registered model metadata, authentication, headers, and effective base URL are used only for the synthetic compact request. Existing and recursive checkpoints remain replayable by the active model.
165
165
  - **Zero Loss Replay**: The opaque encrypted item is stored in `CompactionEntry.details.compactedWindow`. On subsequent requests, it is transparently prepended after fresh preamble instructions alongside live turns, eliminating lossy text re-summaries.
166
+ - **Fail-Closed Replay Guard**: Replay anchors on the Pi-authored compaction summary item by content and replaces it with the opaque compacted window; the post-compaction context is preserved verbatim, so transient messages injected by other extensions no longer block replay. If the summary anchor cannot be found, the toolkit aborts the provider request, shows an error notification, and always writes a content-free `*-replay-failure.json` artifact even when `logProviderPayloads` is disabled. The sentinel-only payload is never sent to the model.
166
167
  - **Graceful Multi-Tier Fallback**:
167
168
  1. Compatible Responses endpoints → Remote v2 Opaque Compaction.
168
169
  2. Gateway failure or non-Responses model → Configured `compaction.model` fallback.
package/README_zh.md CHANGED
@@ -163,6 +163,7 @@ Content-Type: application/json
163
163
  - **校验标准**:接收 SSE 流并验证 `response.completed` 事件(状态为 `completed`),且返回且仅返回一个包含非空 `encrypted_content` 密文的 `type: "compaction"` 数据块。
164
164
  - **远端压缩模型覆盖**:`compaction.remoteCompactModel` 可以让同一网关上的另一个 Responses 模型负责生成 checkpoint,而当前活动模型仍是 checkpoint 的消费者。synthetic 压缩请求会使用覆盖模型在注册表中的模型元数据、认证、请求头和实际 Base URL;旧 checkpoint、递归压缩 checkpoint 都仍由当前活动模型重放。
165
165
  - **无损重放机制**:密文数据完整存入 `CompactionEntry.details.compactedWindow`。在后续对话中,插件会自动将密文插入在最新的系统指令之后与最新几轮消息之前,实现完全服务端无损上下文还原。
166
+ - **重放失败保护(Fail-Closed)**:重放以 Pi 生成的 compaction summary 哨兵为内容锚点,将其替换为密文压缩窗口;压缩后的其余上下文一律原样保留,因此其他扩展注入的临时消息不再阻断重放。若找不到哨兵锚点,插件会中止 provider 请求、弹出错误通知,并且即使 `logProviderPayloads` 关闭也会写入一份不含内容的安全失败记录 `*-replay-failure.json`。只含哨兵占位符的请求体永远不会发送给模型。
166
167
  - **三级优雅降级**:
167
168
  1. 支持的 Responses 端点 → 触发 Remote v2 密文无损压缩;
168
169
  2. 网关不支持或报错 → 降级调用用户指定的 `compaction.model`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "OpenAI-focused Pi toolkit providing remote Responses compaction and native Web Search.",
package/src/debug.ts CHANGED
@@ -177,3 +177,61 @@ export function writeDebugArtifact(
177
177
  fs.writeFileSync(filePath, `${JSON.stringify(envelope, null, 2)}\n`, "utf8");
178
178
  return filePath;
179
179
  }
180
+
181
+ /**
182
+ * Always-written, content-free failure record for an opaque replay failure.
183
+ *
184
+ * Unlike regular payload artifacts this is not gated by logProviderPayloads:
185
+ * a replay failure is a continuity-loss incident, not optional payload logging.
186
+ * It contains only structural parity signatures and identity metadata; prompt
187
+ * text, user content, credentials, and encrypted_content are never included
188
+ * (critical redaction is applied unconditionally).
189
+ */
190
+ export function writeReplayFailureArtifact(
191
+ details: {
192
+ reason: string;
193
+ parity?: { actual: string[]; expected: string[]; mismatches: string[] };
194
+ compactionEntryId?: string;
195
+ provider?: string;
196
+ api?: string;
197
+ model?: string;
198
+ },
199
+ settings: CompactionConfig,
200
+ context: ArtifactContext,
201
+ ): string | undefined {
202
+ try {
203
+ const sessionInfo = toSessionInfo(context);
204
+ const paths = resolveArtifactPaths(settings, context);
205
+ const targetDir = selectArtifactDirectory(paths, "provider-request");
206
+ ensureDir(targetDir);
207
+
208
+ const timestamp = new Date().toISOString();
209
+ const filePath = path.join(targetDir, `${timestamp.replace(/[.:]/g, "-")}-replay-failure.json`);
210
+ const data = redactCriticalValue({
211
+ event: "before_provider_request.rewrite-failed",
212
+ reason: details.reason,
213
+ parity: details.parity,
214
+ compactionEntryId: details.compactionEntryId,
215
+ provider: details.provider,
216
+ api: details.api,
217
+ model: details.model,
218
+ });
219
+ const envelope: DebugArtifactEnvelope = {
220
+ extension: COMPACTION_EXTENSION_ID,
221
+ kind: "provider-request",
222
+ timestamp,
223
+ cwd: sessionInfo.cwd,
224
+ sessionId: sessionInfo.sessionId,
225
+ sessionFile: sessionInfo.sessionFile,
226
+ sessionDir: sessionInfo.sessionDir,
227
+ redaction: { enabled: true },
228
+ data,
229
+ };
230
+ fs.writeFileSync(filePath, `${JSON.stringify(envelope, null, 2)}\n`, "utf8");
231
+ return filePath;
232
+ } catch {
233
+ // The replay failure path itself must never be masked by an artifact write
234
+ // error; the caller still aborts the provider request.
235
+ return undefined;
236
+ }
237
+ }
@@ -6,7 +6,7 @@ import type {
6
6
  SessionBeforeCompactEvent,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { loadToolkitConfig } from "./config";
9
- import { writeDebugArtifact } from "./debug";
9
+ import { writeDebugArtifact, writeReplayFailureArtifact } from "./debug";
10
10
  import { resolveLatestNativeCompactionEntry } from "./details-store";
11
11
  import { runNativeFallbackCompaction } from "./native-fallback";
12
12
  import {
@@ -508,6 +508,30 @@ async function handleBeforeProviderRequest(event: BeforeProviderRequestEvent, ct
508
508
  config,
509
509
  ctx,
510
510
  );
511
+
512
+ // Fail loud instead of letting Pi send the sentinel-only payload: the
513
+ // compacted history would be silently lost while the request still succeeds.
514
+ // A forced redacted failure record is written even when logProviderPayloads
515
+ // is disabled so the incident is diagnosable without leaking content.
516
+ writeReplayFailureArtifact(
517
+ {
518
+ reason: rewrite.reason,
519
+ parity: rewrite.parity,
520
+ compactionEntryId: latestNativeCompactionEntry.id,
521
+ provider: runtime.provider,
522
+ api: runtime.api,
523
+ model: runtime.model,
524
+ },
525
+ config,
526
+ ctx,
527
+ );
528
+ if (ctx.hasUI) {
529
+ ctx.ui.notify(
530
+ `${COMPACTION_EXTENSION_ID}: native compaction replay failed (${rewrite.reason}); provider request aborted`,
531
+ "error",
532
+ );
533
+ }
534
+ ctx.abort();
511
535
  return undefined;
512
536
  }
513
537
 
@@ -524,12 +548,10 @@ async function handleBeforeProviderRequest(event: BeforeProviderRequestEvent, ct
524
548
  firstKeptEntryIndex: rewrite.segments.firstKeptEntryIndex,
525
549
  originalInputItems: payload.input.length,
526
550
  rewrittenInputItems: rewrite.rewrittenPayload.input.length,
527
- freshPreambleItems: rewrite.segments.freshPreamble.length,
528
- trailingPreambleItems: rewrite.segments.trailingPreamble.length,
551
+ leadingItems: rewrite.segments.leading.length,
529
552
  compactionSummaryItems: rewrite.segments.compactionSummary.length,
530
- preCompactionKeptItems: rewrite.segments.preCompactionKeptWindow.input.length,
531
553
  compactedItems: rewrite.segments.compactedWindow.length,
532
- postCompactionTailItems: rewrite.segments.postCompactionTail.input.length,
554
+ postItems: rewrite.segments.post.length,
533
555
  payload: rewrite.rewrittenPayload,
534
556
  originalPayload: payload,
535
557
  },
@@ -9,37 +9,16 @@ import type {
9
9
  import type { ResponsesCompatibleRequestPayload } from "./runtime";
10
10
  import { rewritePayloadWithDeferredToolCarryover } from "./deferred-tool-carryover";
11
11
  import type { NativeCompactionEntry } from "./types";
12
- import {
13
- compareResponsesInputParity,
14
- serializeMessagesToResponsesInput,
15
- type ResponsesInputContentItem,
16
- type ResponsesInputItem,
17
- type ResponsesInputMessageItem,
18
- } from "./serializer";
19
-
20
- export type FreshAuthoritativePreamble = {
21
- instructions?: string;
22
- leadingInput: ResponsesInputMessageItem[];
23
- trailingInput: ResponsesInputMessageItem[];
24
- };
25
-
26
- export type SerializedReplaySlice = {
27
- entries: SessionEntry[];
28
- messages: AgentMessage[];
29
- input: ResponsesInputItem[];
30
- };
12
+ import { serializeMessagesToResponsesInput, type ResponsesInputContentItem, type ResponsesInputItem, type ResponsesInputMessageItem } from "./serializer";
31
13
 
32
14
  export type NativeReplaySegments = {
33
15
  boundaryIndex: number;
34
16
  firstKeptEntryIndex: number;
35
17
  instructions?: string;
36
- freshPreamble: ResponsesInputMessageItem[];
37
- trailingPreamble: ResponsesInputMessageItem[];
18
+ leading: ResponsesInputItem[];
38
19
  compactionSummary: ResponsesInputItem[];
39
- preCompactionKeptWindow: SerializedReplaySlice;
40
20
  compactedWindow: unknown[];
41
- postCompactionTail: SerializedReplaySlice;
42
- originalPiReplayInput: ResponsesInputItem[];
21
+ post: ResponsesInputItem[];
43
22
  replayInput: unknown[];
44
23
  };
45
24
 
@@ -52,10 +31,9 @@ export type NativeReplayPayloadRewrite = {
52
31
  export type NativeReplayPayloadRewriteFailureReason =
53
32
  | "compaction-boundary-not-found"
54
33
  | "first-kept-entry-not-found"
55
- | "unsupported-instructions"
56
34
  | "invalid-compacted-window"
57
35
  | "unexpected-compaction-after-boundary"
58
- | "expected-pi-replay-mismatch";
36
+ | "compaction-summary-not-found";
59
37
 
60
38
  export type NativeReplayPayloadRewriteFailure = {
61
39
  ok: false;
@@ -95,19 +73,6 @@ function isResponsesInputMessageRole(value: unknown): value is ResponsesInputMes
95
73
  return value === "user" || value === "developer" || value === "system";
96
74
  }
97
75
 
98
- function isPreambleRole(value: ResponsesInputMessageItem["role"]): value is "developer" | "system" {
99
- return value === "developer" || value === "system";
100
- }
101
-
102
- function isResponsesInputMessageItem(value: unknown): value is ResponsesInputMessageItem {
103
- if (!isRecord(value) || !isResponsesInputMessageRole(value.role)) {
104
- return false;
105
- }
106
-
107
- const { content } = value;
108
- return typeof content === "string" || (Array.isArray(content) && content.every(isResponsesInputContentItem));
109
- }
110
-
111
76
  function cloneResponsesInputContentItem(item: ResponsesInputContentItem): ResponsesInputContentItem {
112
77
  return item.type === "input_text"
113
78
  ? {
@@ -268,44 +233,36 @@ function toReplayAgentMessage(entry: SessionEntry): AgentMessage | undefined {
268
233
  return undefined;
269
234
  }
270
235
 
271
- function isPromptEnvelopeItem(item: unknown): item is ResponsesInputMessageItem {
272
- return isResponsesInputMessageItem(item) && isPreambleRole(item.role);
273
- }
274
-
275
- export function extractFreshAuthoritativePreamble(
276
- payload: ResponsesCompatibleRequestPayload,
277
- ): FreshAuthoritativePreamble | undefined {
278
- if (payload.instructions !== undefined && typeof payload.instructions !== "string") {
279
- return undefined;
236
+ function extractUserItemText(item: Record<string, unknown>): unknown {
237
+ const { content } = item;
238
+ if (typeof content === "string") {
239
+ return content;
280
240
  }
281
241
 
282
- // Developer/system items in Pi's Responses payload are prompt-level instructions,
283
- // not transcript entries from session history. Preserve them in the same leading
284
- // or trailing position that Pi authored so provider-added suffix prompts like
285
- // GPT-5's trailing developer "# Juice: 0 !important" survive replay unchanged.
286
- let leadingBoundary = 0;
287
- while (leadingBoundary < payload.input.length && isPromptEnvelopeItem(payload.input[leadingBoundary])) {
288
- leadingBoundary += 1;
242
+ if (Array.isArray(content)) {
243
+ const first = content[0];
244
+ if (isRecord(first) && first.type === "input_text" && typeof first.text === "string") {
245
+ return first.text;
246
+ }
289
247
  }
290
248
 
291
- let trailingBoundary = payload.input.length;
292
- while (trailingBoundary > leadingBoundary && isPromptEnvelopeItem(payload.input[trailingBoundary - 1])) {
293
- trailingBoundary -= 1;
294
- }
249
+ return undefined;
250
+ }
295
251
 
296
- for (let index = leadingBoundary; index < trailingBoundary; index++) {
297
- if (isPromptEnvelopeItem(payload.input[index])) {
298
- return undefined;
252
+ /**
253
+ * Locate the Pi-authored compaction summary item. The marker text is unique to
254
+ * this compaction entry, so a content match on the user item is a reliable
255
+ * single anchor; nothing else in the payload needs to be prefixed or counted.
256
+ */
257
+ function findCompactionSummaryIndex(input: readonly unknown[], summaryMarker: string): number {
258
+ return input.findIndex((item) => {
259
+ if (!isRecord(item) || item.role !== "user") {
260
+ return false;
299
261
  }
300
- }
301
262
 
302
- return {
303
- ...(typeof payload.instructions === "string" ? { instructions: payload.instructions } : {}),
304
- leadingInput: payload.input.slice(0, leadingBoundary).map((item) => cloneResponsesInputMessageItem(item as ResponsesInputMessageItem)),
305
- trailingInput: payload.input
306
- .slice(trailingBoundary)
307
- .map((item) => cloneResponsesInputMessageItem(item as ResponsesInputMessageItem)),
308
- };
263
+ const text = extractUserItemText(item);
264
+ return typeof text === "string" && text.includes("<summary>") && text.includes(summaryMarker);
265
+ });
309
266
  }
310
267
 
311
268
  function collectReplayMessages(entries: readonly SessionEntry[]): AgentMessage[] {
@@ -321,27 +278,6 @@ function collectReplayMessages(entries: readonly SessionEntry[]): AgentMessage[]
321
278
  return messages;
322
279
  }
323
280
 
324
- function createCompactionSummaryAgentMessage(entry: NativeCompactionEntry): AgentMessage {
325
- return {
326
- role: "compactionSummary",
327
- summary: entry.summary,
328
- tokensBefore: entry.tokensBefore,
329
- timestamp: new Date(entry.timestamp).getTime(),
330
- } as AgentMessage;
331
- }
332
-
333
- function createReplaySlice(
334
- entries: readonly SessionEntry[],
335
- messages: readonly AgentMessage[],
336
- input: readonly ResponsesInputItem[],
337
- ): SerializedReplaySlice {
338
- return {
339
- entries: [...entries],
340
- messages: [...messages],
341
- input: [...input],
342
- };
343
- }
344
-
345
281
  function findEntryIndexByIdBeforeBoundary(
346
282
  entries: readonly SessionEntry[],
347
283
  entryId: string,
@@ -408,14 +344,6 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
408
344
  };
409
345
  }
410
346
 
411
- const freshPreamble = extractFreshAuthoritativePreamble(args.payload);
412
- if (!freshPreamble) {
413
- return {
414
- ok: false,
415
- reason: "unsupported-instructions",
416
- };
417
- }
418
-
419
347
  const newerCompactionEntry = args.branchEntries
420
348
  .slice(boundaryIndex + 1)
421
349
  .some((entry) => entry.type === "compaction");
@@ -444,81 +372,40 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
444
372
 
445
373
  const preCompactionEntries = args.branchEntries.slice(firstKeptEntryIndex, boundaryIndex);
446
374
  const postCompactionEntries = args.branchEntries.slice(boundaryIndex + 1);
447
- const preCompactionKeptMessages = collectReplayMessages(preCompactionEntries);
448
- const postCompactionTailMessages = collectReplayMessages(postCompactionEntries);
449
- const compactionSummaryMessage = createCompactionSummaryAgentMessage(args.compactionEntry);
450
- const serializedPiHistoryInput = serializeMessagesToResponsesInput(args.model, [
451
- compactionSummaryMessage,
452
- ...preCompactionKeptMessages,
453
- ...postCompactionTailMessages,
454
- ]);
455
- const originalPiReplayInput: ResponsesInputItem[] = [
456
- ...freshPreamble.leadingInput,
457
- ...serializedPiHistoryInput,
458
- ...freshPreamble.trailingInput,
459
- ];
460
-
461
- if (!areEquivalentValues(args.payload.input, originalPiReplayInput)) {
462
- const parity = compareResponsesInputParity(args.payload.input, originalPiReplayInput);
375
+
376
+ // Pi-authored compaction summary item. Pi 0.84.3 renders the context after a
377
+ // compaction as [leading prompts][summary item][all post-compaction messages]:
378
+ // pre-compaction messages never appear in the payload (entry.retainedTail is
379
+ // never persisted by appendCompaction), so the summary item itself is the only
380
+ // anchor and the only thing that gets replaced. Everything else is preserved
381
+ // verbatim, so transient messages injected by other extensions stay intact.
382
+ const summaryIndex = findCompactionSummaryIndex(args.payload.input, args.compactionEntry.summary);
383
+ if (summaryIndex < 0) {
463
384
  return {
464
385
  ok: false,
465
- reason: "expected-pi-replay-mismatch",
466
- parity: {
467
- actual: parity.actual,
468
- expected: parity.expected,
469
- mismatches: parity.mismatches,
470
- },
386
+ reason: "compaction-summary-not-found",
471
387
  };
472
388
  }
473
389
 
474
- const freshPreambleCount = freshPreamble.leadingInput.length;
475
- const trailingPreambleCount = freshPreamble.trailingInput.length;
476
- const compactionSummaryCount = serializeMessagesToResponsesInput(args.model, [compactionSummaryMessage]).length;
477
- const preCompactionKeptCount = serializeMessagesToResponsesInput(args.model, preCompactionKeptMessages).length;
478
- const tailStartIndex = freshPreambleCount + compactionSummaryCount + preCompactionKeptCount;
479
- const tailEndIndex = args.payload.input.length - trailingPreambleCount;
480
- const actualCompactionSummary = cloneResponsesInputSlice(
481
- args.payload.input.slice(freshPreambleCount, freshPreambleCount + compactionSummaryCount),
482
- );
483
- const actualPreCompactionKeptWindow = cloneResponsesInputSlice(
484
- args.payload.input.slice(
485
- freshPreambleCount + compactionSummaryCount,
486
- freshPreambleCount + compactionSummaryCount + preCompactionKeptCount,
487
- ),
488
- );
489
- const actualPostCompactionTail = cloneResponsesInputSlice(args.payload.input.slice(tailStartIndex, tailEndIndex));
490
- if (!actualCompactionSummary || !actualPreCompactionKeptWindow || !actualPostCompactionTail) {
390
+ const leadingInput = cloneResponsesInputSlice(args.payload.input.slice(0, summaryIndex));
391
+ const actualCompactionSummary = cloneResponsesInputSlice([args.payload.input[summaryIndex]]);
392
+ const actualPostInput = cloneResponsesInputSlice(args.payload.input.slice(summaryIndex + 1));
393
+ if (!leadingInput || !actualCompactionSummary || !actualPostInput) {
491
394
  return {
492
395
  ok: false,
493
- reason: "expected-pi-replay-mismatch",
396
+ reason: "compaction-summary-not-found",
494
397
  };
495
398
  }
496
399
 
497
- const preCompactionKeptWindow = createReplaySlice(
498
- preCompactionEntries,
499
- preCompactionKeptMessages,
500
- actualPreCompactionKeptWindow,
501
- );
502
- const postCompactionTail = createReplaySlice(
503
- postCompactionEntries,
504
- postCompactionTailMessages,
505
- actualPostCompactionTail,
506
- );
507
400
  const baseRewrittenPayload: ResponsesCompatibleRequestPayload = {
508
401
  ...args.payload,
509
- ...(freshPreamble.instructions !== undefined ? { instructions: freshPreamble.instructions } : {}),
510
- input: [
511
- ...freshPreamble.leadingInput,
512
- ...compactedWindow,
513
- ...actualPostCompactionTail,
514
- ...freshPreamble.trailingInput,
515
- ],
402
+ input: [...leadingInput, ...compactedWindow, ...actualPostInput],
516
403
  };
517
404
  const carryoverRewrite = rewritePayloadWithDeferredToolCarryover({
518
405
  payload: baseRewrittenPayload,
519
406
  carryover: details.deferredToolCarryover,
520
407
  compactionEntryId: args.compactionEntry.id,
521
- checkpointEndIndex: freshPreamble.leadingInput.length + compactedWindow.length,
408
+ checkpointEndIndex: leadingInput.length + compactedWindow.length,
522
409
  compat: args.model.compat as
523
410
  | { supportsAdditionalTools?: boolean; supportsToolSearch?: boolean }
524
411
  | undefined,
@@ -529,14 +416,11 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
529
416
  segments: {
530
417
  boundaryIndex,
531
418
  firstKeptEntryIndex,
532
- instructions: freshPreamble.instructions,
533
- freshPreamble: freshPreamble.leadingInput,
534
- trailingPreamble: freshPreamble.trailingInput,
419
+ instructions: typeof args.payload.instructions === "string" ? args.payload.instructions : undefined,
420
+ leading: leadingInput,
535
421
  compactionSummary: actualCompactionSummary,
536
- preCompactionKeptWindow,
537
422
  compactedWindow,
538
- postCompactionTail,
539
- originalPiReplayInput,
423
+ post: actualPostInput,
540
424
  replayInput: carryoverRewrite.payload.input,
541
425
  },
542
426
  rewrittenPayload: carryoverRewrite.payload,