pi-openai-toolkit 0.15.1 → 0.17.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,7 +1,7 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
2
  import type {
3
3
  BeforeProviderRequestEvent,
4
- ContextEvent,
4
+ ContextWithSystemEvent,
5
5
  CompactionResult,
6
6
  ExtensionAPI,
7
7
  ExtensionContext,
@@ -19,6 +19,7 @@ import {
19
19
  import { routeContextNamespaceToolMessage } from "./context-management/namespace-tools";
20
20
  import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
21
21
  import { CodexContextWindowManager } from "./context-management/window-manager";
22
+ import { ManagedManualCompact } from "./context-management/manual-compact";
22
23
  import {
23
24
  BULK_CLIFF_RATIO,
24
25
  decideBulkCliffAction,
@@ -32,6 +33,7 @@ import {
32
33
  import { writeDebugArtifact, writeReplayFailureArtifact } from "./debug";
33
34
  import {
34
35
  COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE,
36
+ COMPACTION_CHECKPOINT_CONTEXT_EDITED,
35
37
  COMPACTION_PROJECTION_UNAVAILABLE,
36
38
  COMPACTION_SESSION_CONTEXT_UNAVAILABLE,
37
39
  COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE,
@@ -49,6 +51,7 @@ import {
49
51
  import { runNativeFallbackCompaction } from "./native-fallback";
50
52
  import {
51
53
  rewriteResponsesPayloadWithNativeReplay,
54
+ hasInvalidatedCompactionContext,
52
55
  removeNativeCompactionRetainedMessages,
53
56
  serializeLiveTailToResponsesInput,
54
57
  } from "./payload-rewrite";
@@ -110,6 +113,7 @@ type CompactionDependencies = {
110
113
  remoteCompact: typeof executeRemoteV2Compaction;
111
114
  nativeFallback: typeof runNativeFallbackCompaction;
112
115
  contextWindows: CodexContextWindowManager;
116
+ manualCompact?: ManagedManualCompact;
113
117
  /** Test seam for the same ordered projection exposed by the Pi host patch. */
114
118
  projectCompactionContext?: CompactionContextProjection;
115
119
  piContextHookPatch: PiContextHookPatchResult;
@@ -258,22 +262,11 @@ function findLatestProjectedCompactionSummaryIndex(
258
262
  return -1;
259
263
  }
260
264
 
261
- type SessionManagerWithOptionalContext = ExtensionContext["sessionManager"] & {
262
- buildSessionContext?: () => { messages?: readonly AgentMessage[] };
263
- };
264
-
265
265
  function readSessionContextMessages(ctx: ExtensionContext): AgentMessage[] | undefined {
266
- const sessionManager = ctx.sessionManager as SessionManagerWithOptionalContext;
267
- if (typeof sessionManager.buildSessionContext !== "function") {
268
- return undefined;
269
- }
270
-
271
266
  try {
272
- const sessionContext = sessionManager.buildSessionContext();
273
- if (!sessionContext || !Array.isArray(sessionContext.messages)) {
274
- return undefined;
275
- }
276
- return structuredClone([...sessionContext.messages]);
267
+ const projection = ctx.sessionManager.buildSessionProjection();
268
+ if (!projection || !Array.isArray(projection.messages)) return undefined;
269
+ return structuredClone(projection.messages);
277
270
  } catch {
278
271
  return undefined;
279
272
  }
@@ -337,6 +330,9 @@ async function runResponsesNativeCompact(
337
330
  let requestSource: "session-context" | "non-native-session-context" | "latest-native-replay";
338
331
  let request: NativeCompactionRequestBody;
339
332
  if (latestNativeCompaction.ok) {
333
+ if (hasInvalidatedCompactionContext(branchEntries, latestNativeCompaction.entry.id)) {
334
+ return { outcome: "unprojected-input", reason: COMPACTION_CHECKPOINT_CONTEXT_EDITED };
335
+ }
340
336
  const details = latestNativeCompaction.entry.details;
341
337
  if (!details) {
342
338
  return { outcome: "failed" };
@@ -599,6 +595,10 @@ async function handleSessionBeforeCompact(
599
595
  if (await remoteContextActive(ctx, config)) {
600
596
  try {
601
597
  dependencies.contextWindows.synchronize(ctx);
598
+ if (event.reason === "manual" && dependencies.manualCompact && !dependencies.manualCompact.isMaintenance) {
599
+ await dependencies.manualCompact.request(event, ctx, resolved);
600
+ return { cancel: true };
601
+ }
602
602
  return dependencies.contextWindows.prepareCompaction(event);
603
603
  } catch {
604
604
  notifyRemoteContextFailure(ctx, "malformed-window-state");
@@ -652,7 +652,9 @@ async function handleSessionBeforeCompact(
652
652
  return { cancel: true };
653
653
  }
654
654
  if (responsesOutcome.outcome === "unprojected-input") {
655
- const message = responsesOutcome.reason === COMPACTION_PROJECTION_UNAVAILABLE
655
+ const message = responsesOutcome.reason === COMPACTION_CHECKPOINT_CONTEXT_EDITED
656
+ ? "A context edit changed history sealed in the opaque checkpoint. Start a new session or navigate before that checkpoint; its encrypted content cannot be selectively rewritten."
657
+ : responsesOutcome.reason === COMPACTION_PROJECTION_UNAVAILABLE
656
658
  ? "Pi's ordered context-hook projection is unavailable; Remote V2 was not sent raw session history."
657
659
  : responsesOutcome.reason === COMPACTION_SESSION_CONTEXT_UNAVAILABLE
658
660
  ? "Pi did not provide the current session context required by its context-hook projection."
@@ -674,7 +676,7 @@ async function handleSessionBeforeCompact(
674
676
  );
675
677
  notifyWarning(
676
678
  ctx,
677
- `Remote V2 compaction cancelled (${responsesOutcome.reason}); ${message} Keep the context-source setting aligned with the checkpoint or choose legacy mode explicitly.`,
679
+ `Remote V2 compaction cancelled (${responsesOutcome.reason}); ${message} The existing checkpoint and session history were left unchanged.`,
678
680
  );
679
681
  return { cancel: true };
680
682
  }
@@ -783,6 +785,7 @@ async function handleWindowBulk(
783
785
  loadConfig: typeof loadToolkitConfig,
784
786
  trigger: "model-switch" | "turn-end",
785
787
  resolved = contextOperation(loadConfig, ctx),
788
+ beginMaintenance?: () => () => void,
786
789
  ): Promise<void> {
787
790
  requireContextPolicy(resolved, ctx);
788
791
  const compaction = resolved.config.compaction;
@@ -830,15 +833,20 @@ async function handleWindowBulk(
830
833
 
831
834
  notifyWarning(ctx, `${gap} to ${modelKey}, past ${Math.round(BULK_CLIFF_RATIO * 100)}% of its window; compacting the retired windows first.`);
832
835
  if (typeof ctx.compact !== "function") return;
833
- ctx.compact({
834
- onError: (error: Error) => {
835
- notifyWarning(ctx, `automatic boundary compaction failed: ${error.message}`);
836
- },
837
- });
836
+ const done = beginMaintenance?.();
837
+ try {
838
+ ctx.compact({
839
+ onComplete: () => done?.(),
840
+ onError: (error: Error) => {
841
+ done?.();
842
+ notifyWarning(ctx, `automatic boundary compaction failed: ${error.message}`);
843
+ },
844
+ });
845
+ } catch (error) { done?.(); throw error; }
838
846
  }
839
847
 
840
848
  async function handleContextInternal(
841
- event: ContextEvent,
849
+ event: ContextWithSystemEvent,
842
850
  ctx: ExtensionContext,
843
851
  pi: ExtensionAPI,
844
852
  loadConfig: typeof loadToolkitConfig,
@@ -941,7 +949,7 @@ async function handleContextInternal(
941
949
  * caught exception for a successful projection.
942
950
  */
943
951
  async function handleContext(
944
- event: ContextEvent,
952
+ event: ContextWithSystemEvent,
945
953
  ctx: ExtensionContext,
946
954
  pi: ExtensionAPI,
947
955
  loadConfig: typeof loadToolkitConfig,
@@ -1169,12 +1177,14 @@ export default function registerCompactionExtension(
1169
1177
  const contextWindows = overrides.contextWindows ?? new CodexContextWindowManager((ctx, signal, gatewayModels) =>
1170
1178
  loadHistoryNotesThreadHint(ctx, signal, gatewayModels)
1171
1179
  );
1180
+ const manualCompact = new ManagedManualCompact(pi, contextWindows);
1172
1181
  const dependencies: CompactionDependencies = {
1173
- loadConfig,
1174
1182
  remoteCompact: executeRemoteV2Compaction,
1175
1183
  nativeFallback: runNativeFallbackCompaction,
1176
1184
  contextWindows,
1177
1185
  ...overrides,
1186
+ manualCompact,
1187
+ loadConfig: () => manualCompact.snapshot ?? loadConfig(),
1178
1188
  piContextHookPatch: overrides.piContextHookPatch ?? piContextHookPatch,
1179
1189
  };
1180
1190
  let tools!: ReturnType<typeof registerContextManagementTools>;
@@ -1193,6 +1203,8 @@ export default function registerCompactionExtension(
1193
1203
  assertConfigValid(resolved, "context", "compatibility");
1194
1204
  return { active: await isContextRuntimeActive(ctx, resolved.config.compaction), gatewayModels: resolved.gatewayModelKeys };
1195
1205
  },
1206
+ undefined,
1207
+ manualCompact,
1196
1208
  );
1197
1209
  const remoteContextActive: RemoteContextActive = isContextRuntimeActive;
1198
1210
  const syncTools = async (
@@ -1233,6 +1245,7 @@ export default function registerCompactionExtension(
1233
1245
  pi.on("session_start", async (_event, ctx) => {
1234
1246
  const resolved = contextOperation(dependencies.loadConfig, ctx);
1235
1247
  const syncOutcome = await syncTools(ctx, ctx.model, { notifyWindowFailure: true }, resolved);
1248
+ await manualCompact.recover(ctx);
1236
1249
  const active = syncOutcome.eligible && syncOutcome.toolsSynced && syncOutcome.windowInitialized;
1237
1250
  const { source } = resolved;
1238
1251
  const warnings = resolved.issues.map((issue) => `${issue.path}: ${issue.code}`);
@@ -1291,10 +1304,17 @@ export default function registerCompactionExtension(
1291
1304
  }
1292
1305
  });
1293
1306
 
1294
- pi.on("context", (event, ctx) => handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive));
1307
+ pi.on("context_with_system", (event, ctx) => {
1308
+ manualCompact.guardRequest(ctx);
1309
+ return handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive);
1310
+ });
1295
1311
  pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive));
1296
1312
  pi.on("session_compact", (event, _ctx) => contextWindows.recordCompaction(event.compactionEntry.details));
1297
- pi.on("session_shutdown", () => {
1313
+ pi.on("session_compact_failed", async (event, ctx) => {
1314
+ if (event.reason === "manual" && !manualCompact.isMaintenance) await manualCompact.compactFailed(ctx);
1315
+ });
1316
+ pi.on("session_shutdown", async (_event, ctx) => {
1317
+ await manualCompact.shutdown(ctx);
1298
1318
  contextWindowReady = false;
1299
1319
  contextWindows.reset();
1300
1320
  tools.reset();
@@ -1302,9 +1322,11 @@ export default function registerCompactionExtension(
1302
1322
  pi.on("agent_settled", async (_event, ctx) => {
1303
1323
  // Idle: no retry, compaction, or queued continuation is left running, so this is the
1304
1324
  // only safe place to spend a model call on the user's behalf.
1305
- await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "turn-end");
1325
+ if (await manualCompact.settled(ctx)) return;
1326
+ await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "turn-end", undefined, () => manualCompact.beginMaintenance());
1306
1327
  });
1307
1328
  pi.on("model_select", async (event, ctx) => {
1329
+ manualCompact.modelSelected(event.model, ctx);
1308
1330
  // Switching into a covered model mid-session must open the window
1309
1331
  // lifecycle immediately: without an identity the request rewrite skips
1310
1332
  // window metadata, the backend never ingests those turns, and the first
@@ -1314,12 +1336,24 @@ export default function registerCompactionExtension(
1314
1336
  await syncTools(ctx, event.model, { notifyWindowFailure: true }, resolved);
1315
1337
  // The switch itself is the moment the durable transcript changes consumer, and the
1316
1338
  // session is idle here, so the close-out must happen now rather than mid-turn.
1317
- await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "model-switch", resolved);
1339
+ if (!manualCompact.busy) await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "model-switch", resolved, () => manualCompact.beginMaintenance());
1318
1340
  });
1319
1341
  pi.on("before_agent_start", async (_event, ctx) => {
1320
1342
  await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
1321
1343
  });
1322
- pi.on("before_provider_request", (event, ctx) => handleBeforeProviderRequest(event, ctx, dependencies.loadConfig, contextWindows, remoteContextActive));
1344
+ pi.on("thinking_level_select", (event, ctx) => manualCompact.thinkingSelected(event.level, ctx));
1345
+ pi.on("message_start", (event) => manualCompact.messageStarted(event.message));
1346
+ pi.on("turn_end", (_event, ctx) => manualCompact.turnEnded(ctx));
1347
+ pi.on("tool_call", (event) => manualCompact.guardTool(event.toolName));
1348
+ pi.on("session_tree", async (_event, ctx) => {
1349
+ contextWindows.synchronize(ctx);
1350
+ await manualCompact.recover(ctx);
1351
+ await syncTools(ctx);
1352
+ });
1353
+ pi.on("before_provider_request", (event, ctx) => {
1354
+ manualCompact.guardRequest(ctx);
1355
+ return handleBeforeProviderRequest(event, ctx, dependencies.loadConfig, contextWindows, remoteContextActive);
1356
+ });
1323
1357
  pi.on("before_provider_headers", async (event, ctx) => {
1324
1358
  const resolved = contextOperation(dependencies.loadConfig, ctx);
1325
1359
  requireContextPolicy(resolved, ctx);
@@ -1,12 +1,7 @@
1
- import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
1
+ import { buildSessionProjection, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import type { Api, Model } from "@earendil-works/pi-ai";
4
- import type {
5
- BranchSummaryEntry,
6
- CustomMessageEntry,
7
- SessionEntry,
8
- SessionMessageEntry,
9
- } from "@earendil-works/pi-coding-agent";
4
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
10
5
  import type { ResponsesCompatibleRequestPayload } from "./runtime";
11
6
  import { hasVerifiedCompactionInputProvenance } from "./compaction-projection";
12
7
  import { rewritePayloadWithDeferredToolCarryover } from "./deferred-tool-carryover";
@@ -40,7 +35,8 @@ export type NativeReplayPayloadRewriteFailureReason =
40
35
  | "unexpected-compaction-after-boundary"
41
36
  | "compaction-summary-not-found"
42
37
  | "retained-context-mismatch"
43
- | "unverified-compaction-input";
38
+ | "unverified-compaction-input"
39
+ | "checkpoint-context-edited";
44
40
 
45
41
  export type NativeReplayPayloadRewriteFailure = {
46
42
  ok: false;
@@ -200,46 +196,6 @@ function areEquivalentValues(left: unknown, right: unknown): boolean {
200
196
  return false;
201
197
  }
202
198
 
203
- function toBranchSummaryMessage(entry: BranchSummaryEntry): AgentMessage {
204
- return {
205
- role: "branchSummary",
206
- summary: entry.summary,
207
- fromId: entry.fromId,
208
- timestamp: new Date(entry.timestamp).getTime(),
209
- } as AgentMessage;
210
- }
211
-
212
- function toCustomMessage(entry: CustomMessageEntry): AgentMessage {
213
- return {
214
- role: "custom",
215
- customType: entry.customType,
216
- content: entry.content,
217
- display: entry.display,
218
- details: entry.details,
219
- timestamp: new Date(entry.timestamp).getTime(),
220
- } as AgentMessage;
221
- }
222
-
223
- function toSessionMessage(entry: SessionMessageEntry): AgentMessage {
224
- return entry.message;
225
- }
226
-
227
- function toReplayAgentMessage(entry: SessionEntry): AgentMessage | undefined {
228
- if (entry.type === "message") {
229
- return toSessionMessage(entry);
230
- }
231
-
232
- if (entry.type === "custom_message") {
233
- return toCustomMessage(entry);
234
- }
235
-
236
- if (entry.type === "branch_summary") {
237
- return toBranchSummaryMessage(entry);
238
- }
239
-
240
- return undefined;
241
- }
242
-
243
199
  function extractUserItemText(item: Record<string, unknown>): unknown {
244
200
  const { content } = item;
245
201
  if (typeof content === "string") {
@@ -273,23 +229,29 @@ function findCompactionSummaryIndex(input: readonly unknown[], summaryMarker: st
273
229
  }
274
230
 
275
231
  function collectReplayMessages(entries: readonly SessionEntry[]): AgentMessage[] {
276
- const messages: AgentMessage[] = [];
277
-
278
- for (const entry of entries) {
279
- const message = toReplayAgentMessage(entry);
280
- if (message) {
281
- messages.push(message);
282
- }
283
- }
232
+ return buildSessionProjection([...entries]).messages;
233
+ }
284
234
 
285
- return messages;
235
+ /** An opaque checkpoint cannot be selectively edited after it was produced. */
236
+ export function hasInvalidatedCompactionContext(
237
+ entries: readonly SessionEntry[],
238
+ compactionEntryId: string,
239
+ ): boolean {
240
+ const boundary = findCompactionBoundaryIndex(entries, compactionEntryId);
241
+ if (boundary === undefined) return false;
242
+ const coveredIds = new Set(entries.slice(0, boundary).map((entry) => entry.id));
243
+ return entries.slice(boundary + 1).some((entry) =>
244
+ entry.type === "context_edit" && coveredIds.has(entry.targetId),
245
+ );
286
246
  }
287
247
 
288
- function findEntryIndexByIdBeforeBoundary(
248
+ function findKeptEntryIndex(
289
249
  entries: readonly SessionEntry[],
290
250
  entryId: string,
291
251
  boundaryIndex: number,
292
252
  ): number | undefined {
253
+ // Pi 0.87 uses the compaction itself as the retained boundary for retain-none.
254
+ if (entries[boundaryIndex]?.id === entryId) return boundaryIndex;
293
255
  const index = entries.findIndex((entry, candidateIndex) => candidateIndex < boundaryIndex && entry.id === entryId);
294
256
  return index >= 0 ? index : undefined;
295
257
  }
@@ -319,7 +281,7 @@ export function collectLiveTailMessages(entries: readonly SessionEntry[]): Agent
319
281
  }
320
282
 
321
283
  /**
322
- * Remote V2 covers the complete pre-compaction context. Pi 0.85.1 also retains
284
+ * Remote V2 covers the complete pre-compaction context. Pi also retains
323
285
  * recent messages, so remove those verified copies before provider conversion
324
286
  * (where IDs/fields may change across models). Persisted session entries are
325
287
  * untouched. Preserve transient additions and all post-compaction messages.
@@ -335,7 +297,10 @@ export function removeNativeCompactionRetainedMessages(args: {
335
297
  if (!hasVerifiedCompactionInputProvenance(args.compactionEntry.details, args.expectedInputProvenance)) {
336
298
  return { ok: false, reason: "unverified-compaction-input" };
337
299
  }
338
- const firstKept = findEntryIndexByIdBeforeBoundary(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
300
+ if (hasInvalidatedCompactionContext(args.branchEntries, args.compactionEntry.id)) {
301
+ return { ok: false, reason: "checkpoint-context-edited" };
302
+ }
303
+ const firstKept = findKeptEntryIndex(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
339
304
  if (firstKept === undefined) return { ok: false, reason: "first-kept-entry-not-found" };
340
305
  const summaryMessage = sessionEntryToContextMessages(args.compactionEntry).find(
341
306
  (message) => message.role === "compactionSummary",
@@ -344,9 +309,10 @@ export function removeNativeCompactionRetainedMessages(args: {
344
309
  const summary = structuredClone(summaryMessage);
345
310
  const summaryIndex = args.messages.findIndex((message) => areEquivalentValues(message, summary));
346
311
  if (summaryIndex < 0) return { ok: false, reason: "compaction-summary-not-found" };
347
- const retained = args.branchEntries
348
- .slice(firstKept, boundary)
349
- .flatMap(sessionEntryToContextMessages)
312
+ const retainedIds = new Set(args.branchEntries.slice(firstKept, boundary).map((entry) => entry.id));
313
+ const retained = buildSessionProjection([...args.branchEntries]).entries
314
+ .filter((entry) => retainedIds.has(entry.sourceEntry.id))
315
+ .flatMap((entry) => entry.messages)
350
316
  .map((message) => structuredClone(message));
351
317
  // Custom messages are optional context-hook entries: a hook may filter or
352
318
  // rewrite them before provider serialization. An exact custom copy is still
@@ -418,7 +384,11 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
418
384
  };
419
385
  }
420
386
 
421
- const firstKeptEntryIndex = findEntryIndexByIdBeforeBoundary(
387
+ if (hasInvalidatedCompactionContext(args.branchEntries, args.compactionEntry.id)) {
388
+ return { ok: false, reason: "checkpoint-context-edited" };
389
+ }
390
+
391
+ const firstKeptEntryIndex = findKeptEntryIndex(
422
392
  args.branchEntries,
423
393
  args.compactionEntry.firstKeptEntryId,
424
394
  boundaryIndex,
@@ -1,116 +1,117 @@
1
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import { ExtensionRunner, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
-
4
- /** Context property added by the toolkit's host patch. */
5
- export const PI_CONTEXT_PROJECTOR_FIELD = "projectContextForCompaction" as const;
6
- const PI_CONTEXT_PROJECTION_REPORTER_FIELD = "__piOpenAiToolkitReportContextProjectionFailure" as const;
7
- const PATCH_MARKER_FIELD = "__piOpenAiToolkitContextHookPatch" as const;
8
-
9
- type ProjectionState = {
10
- failure?: string;
11
- };
12
-
13
- const activeProjectionByRunner = new WeakMap<ExtensionRunner, ProjectionState>();
14
-
15
- type PatchedExtensionContext = ExtensionContext & {
16
- [PI_CONTEXT_PROJECTOR_FIELD]?: PiContextHookProjector;
17
- [PI_CONTEXT_PROJECTION_REPORTER_FIELD]?: (reason: string) => void;
18
- };
19
-
20
- type MarkedCreateContext = typeof ExtensionRunner.prototype.createContext & {
21
- [PATCH_MARKER_FIELD]?: true;
22
- };
23
-
24
- /** The ordered context-hook chain, applied to a cloned compaction context. */
25
- export type PiContextHookProjector = (
26
- messages: readonly AgentMessage[],
27
- ) => Promise<readonly AgentMessage[]>;
28
-
29
- export type PiContextHookPatchResult =
30
- | { ok: true; installed: boolean; reason?: "already-installed" }
31
- | { ok: false; reason: "emit-context-unavailable" | "create-context-unavailable" };
32
-
33
- /**
34
- * Add a narrow projection method to every ExtensionContext created by Pi.
35
- *
36
- * Pi 0.85.1 already has the ordered `ExtensionRunner.emitContext()` method,
37
- * but the event-level ExtensionContext does not expose the runner. The patch
38
- * bridges only the method needed by Remote V2; it does not expose the runner
39
- * or alter Pi's scheduler.
40
- */
41
- export function installPiContextHookPatch(): PiContextHookPatchResult {
42
- const prototype = ExtensionRunner.prototype;
43
- if (typeof prototype.emitContext !== "function") {
44
- return { ok: false, reason: "emit-context-unavailable" };
45
- }
46
-
47
- const original = prototype.createContext;
48
- if (typeof original !== "function") {
49
- return { ok: false, reason: "create-context-unavailable" };
50
- }
51
-
52
- const markedOriginal = original as MarkedCreateContext;
53
- if (markedOriginal[PATCH_MARKER_FIELD] === true) {
54
- return { ok: true, installed: false, reason: "already-installed" };
55
- }
56
-
57
- const patched = function patchedCreateContext(this: ExtensionRunner): ExtensionContext {
58
- const context = original.call(this) as PatchedExtensionContext;
59
- if (!(PI_CONTEXT_PROJECTOR_FIELD in context)) {
60
- Object.defineProperty(context, PI_CONTEXT_PROJECTOR_FIELD, {
61
- configurable: false,
62
- enumerable: false,
63
- writable: false,
64
- value: async (messages: readonly AgentMessage[]) => {
65
- const state: ProjectionState = {};
66
- activeProjectionByRunner.set(this, state);
67
- try {
68
- const projected = await this.emitContext([...messages]);
69
- if (state.failure) {
70
- throw new Error(`context projection failed: ${state.failure}`);
71
- }
72
- return projected;
73
- } finally {
74
- if (activeProjectionByRunner.get(this) === state) {
75
- activeProjectionByRunner.delete(this);
76
- }
77
- }
78
- },
79
- });
80
- }
81
- if (!(PI_CONTEXT_PROJECTION_REPORTER_FIELD in context)) {
82
- Object.defineProperty(context, PI_CONTEXT_PROJECTION_REPORTER_FIELD, {
83
- configurable: false,
84
- enumerable: false,
85
- writable: false,
86
- value: (reason: string) => {
87
- const state = activeProjectionByRunner.get(this);
88
- if (state) state.failure = reason;
89
- },
90
- });
91
- }
92
- return context;
93
- };
94
-
95
- Object.defineProperty(patched, PATCH_MARKER_FIELD, {
96
- configurable: false,
97
- enumerable: false,
98
- writable: false,
99
- value: true,
100
- });
101
- prototype.createContext = patched;
102
- return { ok: true, installed: true };
103
- }
104
-
105
- /** Return the patched projection method, if this Pi process supports it. */
106
- export function getPiContextHookProjector(
107
- ctx: ExtensionContext,
108
- ): PiContextHookProjector | undefined {
109
- const candidate = (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTOR_FIELD];
110
- return typeof candidate === "function" ? candidate : undefined;
111
- }
112
-
113
- /** Preserve fail-closed context-hook errors across Pi's emitContext boundary. */
114
- export function reportPiContextHookFailure(ctx: ExtensionContext, reason: string): void {
115
- (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTION_REPORTER_FIELD]?.(reason);
116
- }
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { ExtensionRunner, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** Context property added by the toolkit's host patch. */
5
+ export const PI_CONTEXT_PROJECTOR_FIELD = "projectContextForCompaction" as const;
6
+ const PI_CONTEXT_PROJECTION_REPORTER_FIELD = "__piOpenAiToolkitReportContextProjectionFailure" as const;
7
+ const PATCH_MARKER_FIELD = "__piOpenAiToolkitContextHookPatch" as const;
8
+
9
+ type ProjectionState = {
10
+ failure?: string;
11
+ };
12
+
13
+ const activeProjectionByRunner = new WeakMap<ExtensionRunner, ProjectionState>();
14
+
15
+ type PatchedExtensionContext = ExtensionContext & {
16
+ [PI_CONTEXT_PROJECTOR_FIELD]?: PiContextHookProjector;
17
+ [PI_CONTEXT_PROJECTION_REPORTER_FIELD]?: (reason: string) => void;
18
+ };
19
+
20
+ type MarkedCreateContext = typeof ExtensionRunner.prototype.createContext & {
21
+ [PATCH_MARKER_FIELD]?: true;
22
+ };
23
+
24
+ /** The ordered context-hook chain, applied to a cloned compaction context. */
25
+ export type PiContextHookProjector = (
26
+ messages: readonly AgentMessage[],
27
+ ) => Promise<readonly AgentMessage[]>;
28
+
29
+ export type PiContextHookPatchResult =
30
+ | { ok: true; installed: boolean; reason?: "already-installed" }
31
+ | { ok: false; reason: "emit-context-unavailable" | "create-context-unavailable" };
32
+
33
+ /**
34
+ * Add a narrow projection method to every ExtensionContext created by Pi.
35
+ *
36
+ * Pi 0.87's ordered `ExtensionRunner.emitContext()` runs both `context` and
37
+ * `context_with_system` phases,
38
+ * but the event-level ExtensionContext does not expose the runner. The patch
39
+ * bridges only the method needed by Remote V2; it does not expose the runner
40
+ * or alter Pi's scheduler.
41
+ */
42
+ export function installPiContextHookPatch(): PiContextHookPatchResult {
43
+ const prototype = ExtensionRunner.prototype;
44
+ if (typeof prototype.emitContext !== "function") {
45
+ return { ok: false, reason: "emit-context-unavailable" };
46
+ }
47
+
48
+ const original = prototype.createContext;
49
+ if (typeof original !== "function") {
50
+ return { ok: false, reason: "create-context-unavailable" };
51
+ }
52
+
53
+ const markedOriginal = original as MarkedCreateContext;
54
+ if (markedOriginal[PATCH_MARKER_FIELD] === true) {
55
+ return { ok: true, installed: false, reason: "already-installed" };
56
+ }
57
+
58
+ const patched = function patchedCreateContext(this: ExtensionRunner): ExtensionContext {
59
+ const context = original.call(this) as PatchedExtensionContext;
60
+ if (!(PI_CONTEXT_PROJECTOR_FIELD in context)) {
61
+ Object.defineProperty(context, PI_CONTEXT_PROJECTOR_FIELD, {
62
+ configurable: false,
63
+ enumerable: false,
64
+ writable: false,
65
+ value: async (messages: readonly AgentMessage[]) => {
66
+ const state: ProjectionState = {};
67
+ activeProjectionByRunner.set(this, state);
68
+ try {
69
+ const projected = await this.emitContext([...messages]);
70
+ if (state.failure) {
71
+ throw new Error(`context projection failed: ${state.failure}`);
72
+ }
73
+ return projected;
74
+ } finally {
75
+ if (activeProjectionByRunner.get(this) === state) {
76
+ activeProjectionByRunner.delete(this);
77
+ }
78
+ }
79
+ },
80
+ });
81
+ }
82
+ if (!(PI_CONTEXT_PROJECTION_REPORTER_FIELD in context)) {
83
+ Object.defineProperty(context, PI_CONTEXT_PROJECTION_REPORTER_FIELD, {
84
+ configurable: false,
85
+ enumerable: false,
86
+ writable: false,
87
+ value: (reason: string) => {
88
+ const state = activeProjectionByRunner.get(this);
89
+ if (state) state.failure = reason;
90
+ },
91
+ });
92
+ }
93
+ return context;
94
+ };
95
+
96
+ Object.defineProperty(patched, PATCH_MARKER_FIELD, {
97
+ configurable: false,
98
+ enumerable: false,
99
+ writable: false,
100
+ value: true,
101
+ });
102
+ prototype.createContext = patched;
103
+ return { ok: true, installed: true };
104
+ }
105
+
106
+ /** Return the patched projection method, if this Pi process supports it. */
107
+ export function getPiContextHookProjector(
108
+ ctx: ExtensionContext,
109
+ ): PiContextHookProjector | undefined {
110
+ const candidate = (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTOR_FIELD];
111
+ return typeof candidate === "function" ? candidate : undefined;
112
+ }
113
+
114
+ /** Preserve fail-closed context-hook errors across Pi's emitContext boundary. */
115
+ export function reportPiContextHookFailure(ctx: ExtensionContext, reason: string): void {
116
+ (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTION_REPORTER_FIELD]?.(reason);
117
+ }