pi-openai-toolkit 0.5.0 → 0.7.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.
@@ -0,0 +1,1264 @@
1
+ import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ AgentLoopTurnUpdate,
4
+ AgentMessage,
5
+ PrepareNextTurnContext,
6
+ ShouldStopAfterTurnContext,
7
+ } from "@earendil-works/pi-agent-core";
8
+ import type { ToolResultMessage } from "@earendil-works/pi-ai";
9
+ import type {
10
+ ExtensionAPI,
11
+ ExtensionContext,
12
+ SessionEntry,
13
+ TurnEndEvent,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import type {
16
+ CompactionContinuationMode,
17
+ CompactionConfig,
18
+ LoadedToolkitConfig,
19
+ } from "./types";
20
+
21
+ export const SUPPORTED_OFFICIAL_PI_VERSION = "0.84.3";
22
+ export const INLINE_COMPACTION_FOLLOW_UP_TYPE = "pi-openai-toolkit.inline-compaction-follow-up.v1";
23
+ const DEFAULT_RESERVE_TOKENS = 16_384;
24
+ const ADAPTER_MARKER = Symbol.for("pi-openai-toolkit.inline-compaction.adapter.v1");
25
+
26
+ type ConfigLoader = () => LoadedToolkitConfig;
27
+ type UnknownRecord = Record<string, unknown>;
28
+ type PrepareNextTurnHandler = (
29
+ context: PrepareNextTurnContext,
30
+ signal?: AbortSignal,
31
+ ) => AgentLoopTurnUpdate | undefined | Promise<AgentLoopTurnUpdate | undefined>;
32
+ type ShouldStopAfterTurnHandler = (
33
+ context: ShouldStopAfterTurnContext,
34
+ signal?: AbortSignal,
35
+ ) => boolean | Promise<boolean>;
36
+ type EmitExtensionEventHandler = (this: object, event: unknown) => Promise<void>;
37
+ type CompactHandler = (customInstructions?: string) => Promise<unknown>;
38
+ type AbortHandler = () => Promise<void>;
39
+ type CreateExtensionContextHandler = () => ExtensionContext;
40
+
41
+ type HostAgent = {
42
+ state: {
43
+ messages: AgentMessage[];
44
+ pendingToolCalls?: ReadonlySet<string>;
45
+ };
46
+ prepareNextTurnWithContext?: PrepareNextTurnHandler;
47
+ shouldStopAfterTurn?: ShouldStopAfterTurnHandler;
48
+ signal?: AbortSignal;
49
+ };
50
+
51
+ type HostSession = {
52
+ raw: UnknownRecord;
53
+ agent: HostAgent;
54
+ compact: CompactHandler;
55
+ abort: AbortHandler;
56
+ abortCompaction: () => void;
57
+ extensionRunner: {
58
+ createContext: CreateExtensionContextHandler;
59
+ };
60
+ sessionManager: {
61
+ getBranch: () => readonly unknown[];
62
+ };
63
+ settingsManager: {
64
+ getCompactionSettings: () => {
65
+ enabled: boolean;
66
+ reserveTokens: number;
67
+ keepRecentTokens: number;
68
+ };
69
+ };
70
+ };
71
+
72
+ type HostConstructor = {
73
+ prototype: object;
74
+ };
75
+
76
+ type HostTurnEndEvent = {
77
+ type: "turn_end";
78
+ message: AgentMessage;
79
+ toolResults: ToolResultMessage[];
80
+ };
81
+
82
+ type PendingInline = {
83
+ turnId: string;
84
+ message: AgentMessage;
85
+ beforeMessages: AgentMessage[];
86
+ compactedMessages?: AgentMessage[];
87
+ compactionIdBefore?: string;
88
+ compactionIdAfter?: string;
89
+ contextTokens: number;
90
+ };
91
+
92
+ type PublicPending = {
93
+ turnId: string;
94
+ };
95
+
96
+ type PublicSessionState = {
97
+ pending?: PublicPending;
98
+ lastHandledTurnId?: string;
99
+ };
100
+
101
+ type GlobalAdapterMarker = {
102
+ version: string;
103
+ originalEmitExtensionEvent: EmitExtensionEventHandler;
104
+ runtime?: InlineCompactionRuntime;
105
+ adapters: WeakMap<object, {
106
+ runtime: InlineCompactionRuntime;
107
+ adapter: InlineCompactionCoordinator;
108
+ }>;
109
+ };
110
+
111
+ export type HostPreflightResult = {
112
+ ok: boolean;
113
+ version: string;
114
+ supportsInline: boolean;
115
+ supportsFollowUp: boolean;
116
+ reason?: string;
117
+ };
118
+
119
+ export type TurnBoundaryInput = {
120
+ enabled: boolean;
121
+ continuation: CompactionContinuationMode;
122
+ message: unknown;
123
+ toolResults: readonly unknown[];
124
+ contextUsage?: {
125
+ tokens: number | null;
126
+ contextWindow: number;
127
+ };
128
+ reserveTokens: number;
129
+ toolResultsIncluded?: boolean;
130
+ pendingToolCalls?: number;
131
+ alreadyCompacted?: boolean;
132
+ externalAborted?: boolean;
133
+ };
134
+
135
+ export type TurnBoundaryDecision = {
136
+ compact: boolean;
137
+ reason:
138
+ | "disabled"
139
+ | "continuation-off"
140
+ | "aborted"
141
+ | "not-assistant"
142
+ | "terminal-turn"
143
+ | "no-tool-calls"
144
+ | "incomplete-tool-pairing"
145
+ | "pending-tool-calls"
146
+ | "unknown-usage"
147
+ | "invalid-context-window"
148
+ | "already-compacted"
149
+ | "below-threshold"
150
+ | "threshold-exceeded";
151
+ contextTokens?: number;
152
+ threshold?: number;
153
+ };
154
+
155
+ export type CompactabilityDecision = {
156
+ compactable: boolean;
157
+ reason:
158
+ | "compactable"
159
+ | "already-compacted"
160
+ | "invalid-branch"
161
+ | "missing-first-kept-entry"
162
+ | "nothing-to-summarize";
163
+ };
164
+
165
+ export type InlineCompactionState = {
166
+ installed: boolean;
167
+ inFlight: boolean;
168
+ pendingTurnId?: string;
169
+ lastCompactedBaseline?: number;
170
+ lastFailureReason?: string;
171
+ lastSkipReason?: CompactabilityDecision["reason"];
172
+ adapterStatus: "supported" | "unsupported" | "failed";
173
+ };
174
+
175
+ function isRecord(value: unknown): value is UnknownRecord {
176
+ return !!value && typeof value === "object" && !Array.isArray(value);
177
+ }
178
+
179
+ function isCallable(value: unknown): value is (...args: never[]) => unknown {
180
+ return typeof value === "function";
181
+ }
182
+
183
+ function getString(value: unknown): string | undefined {
184
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
185
+ }
186
+
187
+ function toErrorMessage(error: unknown): string {
188
+ return error instanceof Error ? error.message : String(error);
189
+ }
190
+
191
+ function getTurnToolCallIds(message: unknown): string[] {
192
+ if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
193
+ return [];
194
+ }
195
+
196
+ const ids: string[] = [];
197
+ for (const block of message.content) {
198
+ if (!isRecord(block) || block.type !== "toolCall") continue;
199
+ const id = getString(block.id);
200
+ if (id) ids.push(id);
201
+ }
202
+ return ids;
203
+ }
204
+
205
+ function getToolResultCallIds(toolResults: readonly unknown[]): string[] {
206
+ const ids: string[] = [];
207
+ for (const result of toolResults) {
208
+ if (!isRecord(result) || result.role !== "toolResult") continue;
209
+ const id = getString(result.toolCallId);
210
+ if (id) ids.push(id);
211
+ }
212
+ return ids;
213
+ }
214
+
215
+ function hasCompleteToolPairing(message: unknown, toolResults: readonly unknown[]): boolean {
216
+ const callIds = getTurnToolCallIds(message);
217
+ const resultIds = getToolResultCallIds(toolResults);
218
+ if (callIds.length === 0 || callIds.length !== resultIds.length) return false;
219
+
220
+ const calls = new Set(callIds);
221
+ if (calls.size !== callIds.length) return false;
222
+ const results = new Set(resultIds);
223
+ return (
224
+ results.size === resultIds.length &&
225
+ resultIds.every((id, index) => id === callIds[index] && calls.has(id))
226
+ );
227
+ }
228
+
229
+ function hasPairedResultsInMessages(
230
+ messages: readonly AgentMessage[],
231
+ message: AgentMessage,
232
+ toolResults: readonly ToolResultMessage[],
233
+ ): boolean {
234
+ if (!hasCompleteToolPairing(message, toolResults)) return false;
235
+ const expected = new Set(getToolResultCallIds(toolResults));
236
+ const found = new Set<string>();
237
+ for (const candidate of messages) {
238
+ if (!isRecord(candidate) || candidate.role !== "toolResult") continue;
239
+ const callId = getString(candidate.toolCallId);
240
+ if (callId && expected.has(callId)) found.add(callId);
241
+ }
242
+ return found.size === expected.size;
243
+ }
244
+
245
+ function getTurnId(message: unknown): string {
246
+ if (!isRecord(message)) return "unknown";
247
+ const role = getString(message.role) ?? "unknown";
248
+ const timestamp = typeof message.timestamp === "number" ? String(message.timestamp) : "unknown";
249
+ const model = getString(message.model) ?? "unknown";
250
+ const stopReason = getString(message.stopReason) ?? "unknown";
251
+ const callIds = getTurnToolCallIds(message).join(",");
252
+ return `${role}:${timestamp}:${model}:${stopReason}:${callIds}`;
253
+ }
254
+
255
+ function isSameTurn(left: AgentMessage, right: AgentMessage, turnId: string): boolean {
256
+ return left === right || getTurnId(left) === turnId || getTurnId(right) === turnId;
257
+ }
258
+
259
+ function isTerminalAssistantMessage(message: unknown): boolean {
260
+ if (!isRecord(message) || message.role !== "assistant") return true;
261
+ return (
262
+ message.stopReason === "aborted" ||
263
+ message.stopReason === "error" ||
264
+ message.stopReason === "length" ||
265
+ message.stopReason === "deferred" ||
266
+ message.stopReason === "pending"
267
+ );
268
+ }
269
+
270
+ function estimateToolResultTokens(toolResults: readonly unknown[]): number {
271
+ const estimator = (PiCodingAgent as unknown as UnknownRecord).estimateTokens;
272
+ let total = 0;
273
+ for (const result of toolResults) {
274
+ if (typeof estimator === "function") {
275
+ try {
276
+ total += (estimator as (message: AgentMessage) => number)(result as AgentMessage);
277
+ continue;
278
+ } catch {
279
+ // Use the conservative structural estimate below.
280
+ }
281
+ }
282
+ try {
283
+ total += Math.ceil(JSON.stringify(result).length / 4);
284
+ } catch {
285
+ return Number.POSITIVE_INFINITY;
286
+ }
287
+ }
288
+ return total;
289
+ }
290
+
291
+ /** Validate a completed tool batch before attempting automatic compaction. */
292
+ export function evaluateTurnBoundary(input: TurnBoundaryInput): TurnBoundaryDecision {
293
+ if (!input.enabled) return { compact: false, reason: "disabled" };
294
+ if (input.continuation === "off") return { compact: false, reason: "continuation-off" };
295
+ if (input.externalAborted) return { compact: false, reason: "aborted" };
296
+ if (!isRecord(input.message) || input.message.role !== "assistant") {
297
+ return { compact: false, reason: "not-assistant" };
298
+ }
299
+ if (isTerminalAssistantMessage(input.message)) return { compact: false, reason: "terminal-turn" };
300
+ if (getTurnToolCallIds(input.message).length === 0) return { compact: false, reason: "no-tool-calls" };
301
+ if (!hasCompleteToolPairing(input.message, input.toolResults)) {
302
+ return { compact: false, reason: "incomplete-tool-pairing" };
303
+ }
304
+ if ((input.pendingToolCalls ?? 0) > 0) return { compact: false, reason: "pending-tool-calls" };
305
+ if (input.alreadyCompacted) return { compact: false, reason: "already-compacted" };
306
+ if (!input.contextUsage || input.contextUsage.tokens === null || !Number.isFinite(input.contextUsage.tokens)) {
307
+ return { compact: false, reason: "unknown-usage" };
308
+ }
309
+ if (!Number.isFinite(input.contextUsage.contextWindow) || input.contextUsage.contextWindow <= 0) {
310
+ return { compact: false, reason: "invalid-context-window" };
311
+ }
312
+
313
+ const missingToolResultTokens = input.toolResultsIncluded === false
314
+ ? estimateToolResultTokens(input.toolResults)
315
+ : 0;
316
+ const contextTokens = input.contextUsage.tokens + missingToolResultTokens;
317
+ if (!Number.isFinite(contextTokens)) return { compact: false, reason: "unknown-usage" };
318
+
319
+ const reserveTokens = Number.isFinite(input.reserveTokens) && input.reserveTokens >= 0
320
+ ? input.reserveTokens
321
+ : DEFAULT_RESERVE_TOKENS;
322
+ const threshold = input.contextUsage.contextWindow - reserveTokens;
323
+ if (contextTokens <= threshold) {
324
+ return { compact: false, reason: "below-threshold", contextTokens, threshold };
325
+ }
326
+ return { compact: true, reason: "threshold-exceeded", contextTokens, threshold };
327
+ }
328
+
329
+ function toSessionEntries(branch: readonly unknown[]): SessionEntry[] | undefined {
330
+ if (
331
+ branch.some(
332
+ (entry) =>
333
+ !isRecord(entry) ||
334
+ typeof entry.type !== "string" ||
335
+ typeof entry.id !== "string",
336
+ )
337
+ ) {
338
+ return undefined;
339
+ }
340
+ return branch.slice() as SessionEntry[];
341
+ }
342
+
343
+ function rangeHasContextMessages(entries: SessionEntry[], start: number, end: number): boolean {
344
+ for (let index = start; index < end; index += 1) {
345
+ const entry = entries[index];
346
+ if (entry?.type === "compaction") continue;
347
+ if (PiCodingAgent.sessionEntryToContextMessages(entry).length > 0) return true;
348
+ }
349
+ return false;
350
+ }
351
+
352
+ /**
353
+ * Mirror official Pi 0.84.3's `prepareCompaction()` eligibility test using its
354
+ * exported cut-point and session-entry helpers. This is a gate only; the actual
355
+ * compaction still runs through AgentSession.compact() and its normal hooks.
356
+ */
357
+ export function evaluateCompactionCompactability(
358
+ branch: readonly unknown[],
359
+ keepRecentTokens: number,
360
+ ): CompactabilityDecision {
361
+ const entries = toSessionEntries(branch);
362
+ if (!entries) return { compactable: false, reason: "invalid-branch" };
363
+ if (entries.at(-1)?.type === "compaction") {
364
+ return { compactable: false, reason: "already-compacted" };
365
+ }
366
+
367
+ let previousCompactionIndex = -1;
368
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
369
+ if (entries[index]?.type === "compaction") {
370
+ previousCompactionIndex = index;
371
+ break;
372
+ }
373
+ }
374
+
375
+ let boundaryStart = 0;
376
+ if (previousCompactionIndex >= 0) {
377
+ const previousCompaction = entries[previousCompactionIndex];
378
+ if (previousCompaction?.type !== "compaction") {
379
+ return { compactable: false, reason: "invalid-branch" };
380
+ }
381
+ const firstKeptEntryIndex = entries.findIndex(
382
+ (entry) => entry.id === previousCompaction.firstKeptEntryId,
383
+ );
384
+ boundaryStart = firstKeptEntryIndex >= 0
385
+ ? firstKeptEntryIndex
386
+ : previousCompactionIndex + 1;
387
+ }
388
+
389
+ const effectiveKeepRecentTokens =
390
+ Number.isFinite(keepRecentTokens) && keepRecentTokens >= 0
391
+ ? keepRecentTokens
392
+ : PiCodingAgent.DEFAULT_COMPACTION_SETTINGS.keepRecentTokens;
393
+ try {
394
+ const cutPoint = PiCodingAgent.findCutPoint(
395
+ entries,
396
+ boundaryStart,
397
+ entries.length,
398
+ effectiveKeepRecentTokens,
399
+ );
400
+ const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex];
401
+ if (!firstKeptEntry?.id) {
402
+ return { compactable: false, reason: "missing-first-kept-entry" };
403
+ }
404
+
405
+ const historyEnd = cutPoint.isSplitTurn
406
+ ? cutPoint.turnStartIndex
407
+ : cutPoint.firstKeptEntryIndex;
408
+ const hasHistory = rangeHasContextMessages(entries, boundaryStart, historyEnd);
409
+ const hasTurnPrefix =
410
+ cutPoint.isSplitTurn &&
411
+ cutPoint.turnStartIndex >= boundaryStart &&
412
+ rangeHasContextMessages(entries, cutPoint.turnStartIndex, cutPoint.firstKeptEntryIndex);
413
+ return hasHistory || hasTurnPrefix
414
+ ? { compactable: true, reason: "compactable" }
415
+ : { compactable: false, reason: "nothing-to-summarize" };
416
+ } catch {
417
+ return { compactable: false, reason: "invalid-branch" };
418
+ }
419
+ }
420
+
421
+ function getOfficialRuntimeVersion(): string {
422
+ return getString((PiCodingAgent as unknown as UnknownRecord).VERSION) ?? "unknown";
423
+ }
424
+
425
+ function getHostConstructor(): HostConstructor | undefined {
426
+ const candidate = (PiCodingAgent as unknown as UnknownRecord).AgentSession;
427
+ if (typeof candidate !== "function") return undefined;
428
+ const prototype = (candidate as { prototype?: unknown }).prototype;
429
+ if (!isRecord(prototype)) return undefined;
430
+ return candidate as unknown as HostConstructor;
431
+ }
432
+
433
+ function canAssign(object: object, property: string): boolean {
434
+ if (!Object.isExtensible(object)) return false;
435
+ let current: object | null = object;
436
+ while (current) {
437
+ const descriptor = Object.getOwnPropertyDescriptor(current, property);
438
+ if (descriptor) return descriptor.writable === true || descriptor.set !== undefined;
439
+ current = Object.getPrototypeOf(current);
440
+ }
441
+ return true;
442
+ }
443
+
444
+ function toHostSession(value: unknown): HostSession | undefined {
445
+ if (!isRecord(value) || !isRecord(value.agent) || !isRecord(value.agent.state)) return undefined;
446
+ if (!Array.isArray(value.agent.state.messages)) return undefined;
447
+ if (!isCallable(value.compact) || !isCallable(value.abort) || !isCallable(value.abortCompaction)) return undefined;
448
+ if (!isRecord(value.extensionRunner) || !isCallable(value.extensionRunner.createContext)) return undefined;
449
+ if (!isRecord(value.sessionManager) || !isCallable(value.sessionManager.getBranch)) return undefined;
450
+ if (!isRecord(value.settingsManager) || !isCallable(value.settingsManager.getCompactionSettings)) return undefined;
451
+
452
+ const rawRunner = value.extensionRunner as UnknownRecord;
453
+ return {
454
+ raw: value,
455
+ agent: value.agent as unknown as HostAgent,
456
+ compact: (value.compact as CompactHandler).bind(value),
457
+ abort: (value.abort as AbortHandler).bind(value),
458
+ abortCompaction: (value.abortCompaction as () => void).bind(value),
459
+ extensionRunner: {
460
+ createContext: (rawRunner.createContext as CreateExtensionContextHandler).bind(value.extensionRunner),
461
+ },
462
+ sessionManager: {
463
+ getBranch: (value.sessionManager.getBranch as () => readonly unknown[]).bind(value.sessionManager),
464
+ },
465
+ settingsManager: {
466
+ getCompactionSettings: (value.settingsManager.getCompactionSettings as HostSession["settingsManager"]["getCompactionSettings"]).bind(value.settingsManager),
467
+ },
468
+ };
469
+ }
470
+
471
+ function createContextFromHost(value: unknown): ExtensionContext | undefined {
472
+ if (!isRecord(value) || !isRecord(value.extensionRunner) || !isCallable(value.extensionRunner.createContext)) {
473
+ return undefined;
474
+ }
475
+ try {
476
+ return (value.extensionRunner.createContext as CreateExtensionContextHandler).call(value.extensionRunner);
477
+ } catch {
478
+ return undefined;
479
+ }
480
+ }
481
+
482
+ /** Preflight the complete official 0.84.3 host shape before changing any host method. */
483
+ export function preflightInlineCompactionHost(
484
+ host: unknown,
485
+ version: string = getOfficialRuntimeVersion(),
486
+ ): HostPreflightResult {
487
+ const session = toHostSession(host);
488
+ if (!session) {
489
+ return {
490
+ ok: false,
491
+ version,
492
+ supportsInline: false,
493
+ supportsFollowUp: true,
494
+ reason: "missing-agent-session-shape",
495
+ };
496
+ }
497
+ if (version !== SUPPORTED_OFFICIAL_PI_VERSION) {
498
+ return {
499
+ ok: false,
500
+ version,
501
+ supportsInline: false,
502
+ supportsFollowUp: true,
503
+ reason: "unsupported-official-pi-version",
504
+ };
505
+ }
506
+
507
+ const agentObject = session.raw.agent;
508
+ if (
509
+ !isRecord(agentObject) ||
510
+ typeof session.agent.prepareNextTurnWithContext !== "function" ||
511
+ !canAssign(agentObject, "prepareNextTurnWithContext") ||
512
+ !canAssign(agentObject, "shouldStopAfterTurn") ||
513
+ !canAssign(session.raw, "abort")
514
+ ) {
515
+ return {
516
+ ok: false,
517
+ version,
518
+ supportsInline: false,
519
+ supportsFollowUp: true,
520
+ reason: "missing-inline-seam",
521
+ };
522
+ }
523
+
524
+ return {
525
+ ok: true,
526
+ version,
527
+ supportsInline: true,
528
+ supportsFollowUp: true,
529
+ };
530
+ }
531
+
532
+ function getLatestCompactionId(host: HostSession): string | undefined {
533
+ try {
534
+ const branch = host.sessionManager.getBranch();
535
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
536
+ const entry = branch[index];
537
+ if (isRecord(entry) && entry.type === "compaction") return getString(entry.id);
538
+ }
539
+ } catch {
540
+ return undefined;
541
+ }
542
+ return undefined;
543
+ }
544
+
545
+ function getEffectiveCompactionSettings(
546
+ host: HostSession,
547
+ config: CompactionConfig,
548
+ ): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } {
549
+ try {
550
+ const official = host.settingsManager.getCompactionSettings();
551
+ return {
552
+ enabled: official.enabled,
553
+ reserveTokens: config.autoCompaction.reserveTokens ?? official.reserveTokens,
554
+ keepRecentTokens: official.keepRecentTokens,
555
+ };
556
+ } catch {
557
+ return {
558
+ enabled: true,
559
+ reserveTokens: config.autoCompaction.reserveTokens ?? DEFAULT_RESERVE_TOKENS,
560
+ keepRecentTokens: PiCodingAgent.DEFAULT_COMPACTION_SETTINGS.keepRecentTokens,
561
+ };
562
+ }
563
+ }
564
+
565
+ function toHostTurnEndEvent(event: unknown): HostTurnEndEvent | undefined {
566
+ if (!isRecord(event) || event.type !== "turn_end" || !Array.isArray(event.toolResults)) return undefined;
567
+ if (!isRecord(event.message)) return undefined;
568
+ return event as unknown as HostTurnEndEvent;
569
+ }
570
+
571
+ export class InlineCompactionCoordinator {
572
+ private readonly host: HostSession;
573
+ private readonly originalPrepareNextTurnWithContext: PrepareNextTurnHandler;
574
+ private readonly originalShouldStopAfterTurn?: ShouldStopAfterTurnHandler;
575
+ private readonly wrappedPrepareNextTurnWithContext: PrepareNextTurnHandler;
576
+ private readonly wrappedShouldStopAfterTurn: ShouldStopAfterTurnHandler;
577
+ private pending: PendingInline | undefined;
578
+ private forceStopTurnId: string | undefined;
579
+ private lastCompactedTurnId: string | undefined;
580
+ private lastSkippedTurnId: string | undefined;
581
+ private disposed = false;
582
+ private readonly mutableState: InlineCompactionState;
583
+
584
+ constructor(hostValue: unknown, version = getOfficialRuntimeVersion()) {
585
+ const preflight = preflightInlineCompactionHost(hostValue, version);
586
+ const host = toHostSession(hostValue);
587
+ if (!host || !preflight.supportsInline || !host.agent.prepareNextTurnWithContext) {
588
+ throw new Error(preflight.reason ?? "Cannot install inline compaction host adapter");
589
+ }
590
+ this.host = host;
591
+ this.originalPrepareNextTurnWithContext = host.agent.prepareNextTurnWithContext;
592
+ this.originalShouldStopAfterTurn = host.agent.shouldStopAfterTurn;
593
+ this.mutableState = {
594
+ installed: false,
595
+ inFlight: false,
596
+ adapterStatus: "supported",
597
+ };
598
+
599
+ this.wrappedPrepareNextTurnWithContext = async (context, signal) => {
600
+ const pending = this.pending;
601
+ if (!pending) {
602
+ return await this.originalPrepareNextTurnWithContext(context, signal);
603
+ }
604
+ if (signal?.aborted) {
605
+ this.failClosed(getTurnId(context.message), "agent-run-aborted-before-next-turn");
606
+ return undefined;
607
+ }
608
+
609
+ let update: AgentLoopTurnUpdate | undefined;
610
+ try {
611
+ update = await this.originalPrepareNextTurnWithContext(context, signal);
612
+ } catch (error) {
613
+ this.failClosed(getTurnId(context.message), `prepare-next-turn: ${toErrorMessage(error)}`);
614
+ return undefined;
615
+ }
616
+ if (!isSameTurn(context.message, pending.message, pending.turnId)) {
617
+ this.failClosed(getTurnId(context.message), "stale-next-turn-context");
618
+ return update;
619
+ }
620
+
621
+ const messages = this.host.agent.state.messages;
622
+ const latestCompactionId = getLatestCompactionId(this.host);
623
+ if (
624
+ !pending.compactedMessages ||
625
+ messages !== pending.compactedMessages ||
626
+ latestCompactionId === undefined ||
627
+ latestCompactionId !== pending.compactionIdAfter
628
+ ) {
629
+ this.failClosed(pending.turnId, "compacted-host-state-changed-before-next-turn");
630
+ return update;
631
+ }
632
+
633
+ const baseContext = update?.context ?? context.context;
634
+ this.mutableState.lastCompactedBaseline = pending.contextTokens;
635
+ this.lastCompactedTurnId = pending.turnId;
636
+ this.pending = undefined;
637
+ this.mutableState.inFlight = false;
638
+ this.mutableState.pendingTurnId = undefined;
639
+ return {
640
+ ...update,
641
+ context: {
642
+ ...baseContext,
643
+ messages: messages.slice(),
644
+ },
645
+ };
646
+ };
647
+
648
+ this.wrappedShouldStopAfterTurn = async (context, signal) => {
649
+ const turnId = getTurnId(context.message);
650
+ if (this.forceStopTurnId && (this.forceStopTurnId === turnId || this.forceStopTurnId === "unknown")) {
651
+ this.forceStopTurnId = undefined;
652
+ return true;
653
+ }
654
+ return this.originalShouldStopAfterTurn
655
+ ? await this.originalShouldStopAfterTurn(context, signal)
656
+ : false;
657
+ };
658
+
659
+ const agent = host.raw.agent as UnknownRecord;
660
+ try {
661
+ agent.prepareNextTurnWithContext = this.wrappedPrepareNextTurnWithContext;
662
+ agent.shouldStopAfterTurn = this.wrappedShouldStopAfterTurn;
663
+ this.mutableState.installed = true;
664
+ } catch (error) {
665
+ if (agent.prepareNextTurnWithContext === this.wrappedPrepareNextTurnWithContext) {
666
+ agent.prepareNextTurnWithContext = this.originalPrepareNextTurnWithContext;
667
+ }
668
+ if (agent.shouldStopAfterTurn === this.wrappedShouldStopAfterTurn) {
669
+ agent.shouldStopAfterTurn = this.originalShouldStopAfterTurn;
670
+ }
671
+ throw error;
672
+ }
673
+ }
674
+
675
+ get state(): InlineCompactionState {
676
+ return { ...this.mutableState };
677
+ }
678
+
679
+ matchesSessionManager(sessionManager: unknown): boolean {
680
+ return sessionManager === this.host.raw.sessionManager;
681
+ }
682
+
683
+ /** Clear a successful continuation that had no next turn (for example, a terminal tool batch). */
684
+ handleAgentEnd(): void {
685
+ this.pending = undefined;
686
+ this.forceStopTurnId = undefined;
687
+ this.mutableState.inFlight = false;
688
+ this.mutableState.pendingTurnId = undefined;
689
+ }
690
+
691
+ getPublicFallbackEnvironment(
692
+ config: CompactionConfig,
693
+ ): {
694
+ context: ExtensionContext;
695
+ enabled: boolean;
696
+ reserveTokens: number;
697
+ keepRecentTokens: number;
698
+ abortCompaction: () => void;
699
+ } | undefined {
700
+ try {
701
+ const settings = getEffectiveCompactionSettings(this.host, config);
702
+ return {
703
+ context: this.host.extensionRunner.createContext(),
704
+ enabled: settings.enabled,
705
+ reserveTokens: settings.reserveTokens,
706
+ keepRecentTokens: settings.keepRecentTokens,
707
+ abortCompaction: this.host.abortCompaction,
708
+ };
709
+ } catch {
710
+ return undefined;
711
+ }
712
+ }
713
+
714
+ async handleTurnEnd(event: HostTurnEndEvent, config: CompactionConfig): Promise<void> {
715
+ if (this.disposed || this.pending || this.mutableState.inFlight) return;
716
+ let context: ExtensionContext;
717
+ try {
718
+ context = this.host.extensionRunner.createContext();
719
+ } catch {
720
+ return;
721
+ }
722
+
723
+ const officialSettings = getEffectiveCompactionSettings(this.host, config);
724
+ const turnId = getTurnId(event.message);
725
+ const decision = evaluateTurnBoundary({
726
+ enabled: config.enabled && config.autoCompaction.enabled && officialSettings.enabled,
727
+ continuation: config.autoCompaction.continuation,
728
+ message: event.message,
729
+ toolResults: event.toolResults,
730
+ contextUsage: context.getContextUsage(),
731
+ reserveTokens: officialSettings.reserveTokens,
732
+ toolResultsIncluded: true,
733
+ pendingToolCalls: this.host.agent.state.pendingToolCalls?.size ?? 0,
734
+ alreadyCompacted:
735
+ this.lastCompactedTurnId === turnId || this.lastSkippedTurnId === turnId,
736
+ externalAborted: this.host.agent.signal?.aborted,
737
+ });
738
+ if (!decision.compact) return;
739
+
740
+ const compactability = evaluateCompactionCompactability(
741
+ this.host.sessionManager.getBranch(),
742
+ officialSettings.keepRecentTokens,
743
+ );
744
+ if (!compactability.compactable) {
745
+ this.skipInline(turnId, compactability.reason);
746
+ return;
747
+ }
748
+
749
+ await this.runInline(
750
+ event.message,
751
+ event.toolResults,
752
+ turnId,
753
+ decision.contextTokens ?? 0,
754
+ this.host.agent.signal,
755
+ );
756
+ }
757
+
758
+ dispose(): void {
759
+ if (this.disposed) return;
760
+ this.disposed = true;
761
+ this.pending = undefined;
762
+ this.forceStopTurnId = undefined;
763
+ this.lastSkippedTurnId = undefined;
764
+ this.mutableState.inFlight = false;
765
+ this.mutableState.pendingTurnId = undefined;
766
+ const agent = this.host.raw.agent as UnknownRecord;
767
+ if (agent.prepareNextTurnWithContext === this.wrappedPrepareNextTurnWithContext) {
768
+ agent.prepareNextTurnWithContext = this.originalPrepareNextTurnWithContext;
769
+ }
770
+ if (agent.shouldStopAfterTurn === this.wrappedShouldStopAfterTurn) {
771
+ agent.shouldStopAfterTurn = this.originalShouldStopAfterTurn;
772
+ }
773
+ }
774
+
775
+ private async runInline(
776
+ message: AgentMessage,
777
+ toolResults: ToolResultMessage[],
778
+ turnId: string,
779
+ contextTokens: number,
780
+ runSignal: AbortSignal | undefined,
781
+ ): Promise<void> {
782
+ const beforeMessages = this.host.agent.state.messages;
783
+ const pending: PendingInline = {
784
+ turnId,
785
+ message,
786
+ beforeMessages,
787
+ compactionIdBefore: getLatestCompactionId(this.host),
788
+ contextTokens,
789
+ };
790
+ this.pending = pending;
791
+ this.mutableState.inFlight = true;
792
+ this.mutableState.pendingTurnId = turnId;
793
+ const removeAbortListener = this.bindExternalAbort(runSignal);
794
+ let abortPatch: { restore: () => void; suppressedCalls: () => number };
795
+ try {
796
+ abortPatch = this.suppressCompactOwnedAbort();
797
+ } catch (error) {
798
+ removeAbortListener();
799
+ this.failClosed(turnId, `install-abort-seam: ${toErrorMessage(error)}`);
800
+ return;
801
+ }
802
+ try {
803
+ await this.host.compact();
804
+ if (runSignal?.aborted || abortPatch.suppressedCalls() !== 1) {
805
+ this.failClosed(
806
+ turnId,
807
+ runSignal?.aborted
808
+ ? "agent-run-aborted-during-compaction"
809
+ : `unexpected-compact-abort-count:${abortPatch.suppressedCalls()}`,
810
+ );
811
+ return;
812
+ }
813
+ const afterMessages = this.host.agent.state.messages;
814
+ const compactionIdAfter = getLatestCompactionId(this.host);
815
+ if (
816
+ !Array.isArray(afterMessages) ||
817
+ afterMessages === beforeMessages ||
818
+ compactionIdAfter === undefined ||
819
+ compactionIdAfter === pending.compactionIdBefore ||
820
+ !hasPairedResultsInMessages(afterMessages, message, toolResults)
821
+ ) {
822
+ this.failClosed(turnId, "compacted-host-state-validation-failed");
823
+ return;
824
+ }
825
+ pending.compactedMessages = afterMessages;
826
+ pending.compactionIdAfter = compactionIdAfter;
827
+ this.mutableState.inFlight = false;
828
+ this.mutableState.lastFailureReason = undefined;
829
+ this.mutableState.lastSkipReason = undefined;
830
+ } catch (error) {
831
+ const message = toErrorMessage(error);
832
+ if (message === "Nothing to compact (session too small)") {
833
+ this.skipInline(turnId, "nothing-to-summarize");
834
+ } else {
835
+ this.failClosed(turnId, `compact: ${message}`);
836
+ }
837
+ } finally {
838
+ removeAbortListener();
839
+ abortPatch.restore();
840
+ }
841
+ }
842
+
843
+ private bindExternalAbort(signal: AbortSignal | undefined): () => void {
844
+ if (!signal) return () => {};
845
+ const listener = () => this.host.abortCompaction();
846
+ signal.addEventListener("abort", listener, { once: true });
847
+ return () => signal.removeEventListener("abort", listener);
848
+ }
849
+
850
+ private suppressCompactOwnedAbort(): { restore: () => void; suppressedCalls: () => number } {
851
+ const hostObject = this.host.raw;
852
+ const originalAbort = this.host.abort;
853
+ let suppressedCalls = 0;
854
+ let expectingCompactAbort = true;
855
+ const replacement: AbortHandler = async () => {
856
+ if (expectingCompactAbort) {
857
+ expectingCompactAbort = false;
858
+ suppressedCalls += 1;
859
+ return;
860
+ }
861
+ await originalAbort();
862
+ };
863
+ const previous = Object.getOwnPropertyDescriptor(hostObject, "abort");
864
+ Object.defineProperty(hostObject, "abort", {
865
+ configurable: true,
866
+ enumerable: previous?.enumerable ?? false,
867
+ writable: true,
868
+ value: replacement,
869
+ });
870
+ return {
871
+ suppressedCalls: () => suppressedCalls,
872
+ restore: () => {
873
+ try {
874
+ if (previous) Object.defineProperty(hostObject, "abort", previous);
875
+ else delete hostObject.abort;
876
+ } catch {
877
+ this.mutableState.adapterStatus = "failed";
878
+ }
879
+ },
880
+ };
881
+ }
882
+
883
+ private skipInline(turnId: string, reason: CompactabilityDecision["reason"]): void {
884
+ this.pending = undefined;
885
+ this.lastSkippedTurnId = turnId;
886
+ this.mutableState.inFlight = false;
887
+ this.mutableState.pendingTurnId = undefined;
888
+ this.mutableState.lastFailureReason = undefined;
889
+ this.mutableState.lastSkipReason = reason;
890
+ }
891
+
892
+ private failClosed(turnId: string, reason: string): void {
893
+ this.pending = undefined;
894
+ this.forceStopTurnId = turnId;
895
+ this.mutableState.inFlight = false;
896
+ this.mutableState.pendingTurnId = undefined;
897
+ this.mutableState.lastFailureReason = reason;
898
+ this.mutableState.adapterStatus = "failed";
899
+ }
900
+ }
901
+
902
+ function getMarker(prototype: object): GlobalAdapterMarker | undefined {
903
+ const value = (prototype as unknown as Record<PropertyKey, unknown>)[ADAPTER_MARKER];
904
+ return isRecord(value) &&
905
+ value.version === SUPPORTED_OFFICIAL_PI_VERSION &&
906
+ isCallable(value.originalEmitExtensionEvent) &&
907
+ value.adapters instanceof WeakMap
908
+ ? value as unknown as GlobalAdapterMarker
909
+ : undefined;
910
+ }
911
+
912
+ function installHostBridge(runtime: InlineCompactionRuntime): HostPreflightResult {
913
+ const version = getOfficialRuntimeVersion();
914
+ if (version !== SUPPORTED_OFFICIAL_PI_VERSION) {
915
+ return {
916
+ ok: false,
917
+ version,
918
+ supportsInline: false,
919
+ supportsFollowUp: true,
920
+ reason: "unsupported-official-pi-version",
921
+ };
922
+ }
923
+ const constructor = getHostConstructor();
924
+ if (!constructor) {
925
+ return {
926
+ ok: false,
927
+ version,
928
+ supportsInline: false,
929
+ supportsFollowUp: true,
930
+ reason: "official-agent-session-not-exported",
931
+ };
932
+ }
933
+ const existing = getMarker(constructor.prototype);
934
+ if (existing) {
935
+ existing.runtime = runtime;
936
+ return {
937
+ ok: true,
938
+ version: existing.version,
939
+ supportsInline: true,
940
+ supportsFollowUp: true,
941
+ };
942
+ }
943
+
944
+ const prototype = constructor.prototype as unknown as UnknownRecord;
945
+ const original = prototype._emitExtensionEvent;
946
+ if (!isCallable(original) || !canAssign(constructor.prototype, "_emitExtensionEvent")) {
947
+ return {
948
+ ok: false,
949
+ version,
950
+ supportsInline: false,
951
+ supportsFollowUp: true,
952
+ reason: "missing-extension-event-seam",
953
+ };
954
+ }
955
+
956
+ const marker: GlobalAdapterMarker = {
957
+ version,
958
+ originalEmitExtensionEvent: original as EmitExtensionEventHandler,
959
+ runtime,
960
+ adapters: new WeakMap(),
961
+ };
962
+ const wrapped: EmitExtensionEventHandler = async function (this: object, event: unknown): Promise<void> {
963
+ await marker.originalEmitExtensionEvent.call(this, event);
964
+ const currentRuntime = marker.runtime;
965
+ const turnEnd = toHostTurnEndEvent(event);
966
+ const agentEnd = isRecord(event) && event.type === "agent_end";
967
+ if (!currentRuntime || (!turnEnd && !agentEnd)) return;
968
+
969
+ let entry = marker.adapters.get(this);
970
+ if (agentEnd) {
971
+ entry?.adapter.handleAgentEnd();
972
+ return;
973
+ }
974
+ if (!turnEnd) return;
975
+ if (entry && entry.runtime !== currentRuntime) {
976
+ entry.adapter.dispose();
977
+ marker.adapters.delete(this);
978
+ entry = undefined;
979
+ }
980
+ if (!entry) {
981
+ try {
982
+ const adapter = new InlineCompactionCoordinator(this, marker.version);
983
+ entry = { runtime: currentRuntime, adapter };
984
+ marker.adapters.set(this, entry);
985
+ currentRuntime.trackAdapter(this, adapter);
986
+ } catch {
987
+ const context = createContextFromHost(this);
988
+ if (context) await currentRuntime.handleUnsupportedHostTurn(turnEnd, context);
989
+ return;
990
+ }
991
+ }
992
+ await currentRuntime.handlePrivateTurnEnd(entry.adapter, turnEnd);
993
+ };
994
+
995
+ const previousEmitDescriptor = Object.getOwnPropertyDescriptor(constructor.prototype, "_emitExtensionEvent");
996
+ try {
997
+ Object.defineProperty(constructor.prototype, "_emitExtensionEvent", {
998
+ configurable: true,
999
+ enumerable: previousEmitDescriptor?.enumerable ?? false,
1000
+ writable: true,
1001
+ value: wrapped,
1002
+ });
1003
+ Object.defineProperty(constructor.prototype, ADAPTER_MARKER, {
1004
+ configurable: true,
1005
+ enumerable: false,
1006
+ writable: false,
1007
+ value: marker,
1008
+ });
1009
+ } catch {
1010
+ try {
1011
+ if (previousEmitDescriptor) {
1012
+ Object.defineProperty(constructor.prototype, "_emitExtensionEvent", previousEmitDescriptor);
1013
+ } else {
1014
+ prototype._emitExtensionEvent = original;
1015
+ }
1016
+ } catch {
1017
+ // The caller receives a failed status and will use only public fallback APIs.
1018
+ }
1019
+ return {
1020
+ ok: false,
1021
+ version,
1022
+ supportsInline: false,
1023
+ supportsFollowUp: true,
1024
+ reason: "adapter-install-failed",
1025
+ };
1026
+ }
1027
+
1028
+ return { ok: true, version, supportsInline: true, supportsFollowUp: true };
1029
+ }
1030
+
1031
+ export class InlineCompactionRuntime {
1032
+ readonly installation: HostPreflightResult;
1033
+ private readonly pi: Pick<ExtensionAPI, "sendMessage">;
1034
+ private readonly loadConfig: ConfigLoader;
1035
+ private publicSessionStates = new WeakMap<object, PublicSessionState>();
1036
+ private readonly adapters = new Set<{ host: object; adapter: InlineCompactionCoordinator }>();
1037
+ private disposed = false;
1038
+ private unsupportedNotified = false;
1039
+
1040
+ constructor(pi: Pick<ExtensionAPI, "sendMessage">, loadConfig: ConfigLoader) {
1041
+ this.pi = pi;
1042
+ this.loadConfig = loadConfig;
1043
+ this.installation = installHostBridge(this);
1044
+ }
1045
+
1046
+ async handlePublicTurnEnd(event: TurnEndEvent, context: ExtensionContext): Promise<void> {
1047
+ if (this.disposed) return;
1048
+ const config = this.getConfig();
1049
+ if (!config) return;
1050
+ if (this.installation.supportsInline) return;
1051
+ if (
1052
+ config.autoCompaction.continuation === "inline" &&
1053
+ config.autoCompaction.unsupportedFallback === "off"
1054
+ ) {
1055
+ this.notifyUnsupported(context, config, this.installation.reason ?? "unsupported host adapter");
1056
+ return;
1057
+ }
1058
+ await this.startPublicFallback(event, context, config);
1059
+ }
1060
+
1061
+ async handlePrivateTurnEnd(
1062
+ adapter: InlineCompactionCoordinator,
1063
+ event: HostTurnEndEvent,
1064
+ ): Promise<void> {
1065
+ if (this.disposed) return;
1066
+ const config = this.getConfig();
1067
+ if (!config || config.autoCompaction.continuation === "off") return;
1068
+ if (config.autoCompaction.continuation === "inline") {
1069
+ await adapter.handleTurnEnd(event, config);
1070
+ return;
1071
+ }
1072
+
1073
+ const fallback = adapter.getPublicFallbackEnvironment(config);
1074
+ if (!fallback) return;
1075
+ await this.startPublicFallback(event, fallback.context, config, {
1076
+ enabled: fallback.enabled,
1077
+ reserveTokens: fallback.reserveTokens,
1078
+ keepRecentTokens: fallback.keepRecentTokens,
1079
+ abortCompaction: fallback.abortCompaction,
1080
+ });
1081
+ }
1082
+
1083
+ async handleUnsupportedHostTurn(event: HostTurnEndEvent, context: ExtensionContext): Promise<void> {
1084
+ if (this.disposed) return;
1085
+ const config = this.getConfig();
1086
+ if (!config || config.autoCompaction.continuation === "off") return;
1087
+ if (
1088
+ config.autoCompaction.continuation === "inline" &&
1089
+ config.autoCompaction.unsupportedFallback === "off"
1090
+ ) {
1091
+ this.notifyUnsupported(context, config, "unsupported AgentSession shape");
1092
+ return;
1093
+ }
1094
+ await this.startPublicFallback(event, context, config);
1095
+ }
1096
+
1097
+ trackAdapter(host: object, adapter: InlineCompactionCoordinator): void {
1098
+ this.adapters.add({ host, adapter });
1099
+ }
1100
+
1101
+ dispose(context?: ExtensionContext): void {
1102
+ if (context) {
1103
+ for (const entry of [...this.adapters]) {
1104
+ if (!entry.adapter.matchesSessionManager(context.sessionManager)) continue;
1105
+ entry.adapter.dispose();
1106
+ this.adapters.delete(entry);
1107
+ }
1108
+ const sessionState = this.publicSessionStates.get(context.sessionManager);
1109
+ if (sessionState) sessionState.pending = undefined;
1110
+ this.publicSessionStates.delete(context.sessionManager);
1111
+ return;
1112
+ }
1113
+ if (this.disposed) return;
1114
+ this.disposed = true;
1115
+ this.publicSessionStates = new WeakMap();
1116
+ for (const entry of this.adapters) entry.adapter.dispose();
1117
+ this.adapters.clear();
1118
+ const constructor = getHostConstructor();
1119
+ const marker = constructor ? getMarker(constructor.prototype) : undefined;
1120
+ if (marker?.runtime === this) marker.runtime = undefined;
1121
+ }
1122
+
1123
+ private getConfig(): CompactionConfig | undefined {
1124
+ try {
1125
+ return this.loadConfig().config.compaction;
1126
+ } catch {
1127
+ return undefined;
1128
+ }
1129
+ }
1130
+
1131
+ private async startPublicFallback(
1132
+ event: Pick<TurnEndEvent, "message" | "toolResults">,
1133
+ context: ExtensionContext,
1134
+ config: CompactionConfig,
1135
+ settings?: {
1136
+ enabled: boolean;
1137
+ reserveTokens: number;
1138
+ keepRecentTokens: number;
1139
+ abortCompaction?: () => void;
1140
+ },
1141
+ ): Promise<void> {
1142
+ const sessionKey = context.sessionManager;
1143
+ let sessionState = this.publicSessionStates.get(sessionKey);
1144
+ if (!sessionState) {
1145
+ sessionState = {};
1146
+ this.publicSessionStates.set(sessionKey, sessionState);
1147
+ }
1148
+ if (sessionState.pending) return;
1149
+ const turnId = getTurnId(event.message);
1150
+ const decision = evaluateTurnBoundary({
1151
+ enabled: config.enabled && config.autoCompaction.enabled && (settings?.enabled ?? true),
1152
+ continuation: config.autoCompaction.continuation,
1153
+ message: event.message,
1154
+ toolResults: event.toolResults,
1155
+ contextUsage: context.getContextUsage(),
1156
+ reserveTokens:
1157
+ settings?.reserveTokens ??
1158
+ config.autoCompaction.reserveTokens ??
1159
+ DEFAULT_RESERVE_TOKENS,
1160
+ toolResultsIncluded: true,
1161
+ pendingToolCalls: 0,
1162
+ alreadyCompacted: sessionState.lastHandledTurnId === turnId,
1163
+ externalAborted: context.signal?.aborted,
1164
+ });
1165
+ if (!decision.compact) return;
1166
+
1167
+ const compactability = evaluateCompactionCompactability(
1168
+ context.sessionManager.getBranch(),
1169
+ settings?.keepRecentTokens ?? PiCodingAgent.DEFAULT_COMPACTION_SETTINGS.keepRecentTokens,
1170
+ );
1171
+ if (!compactability.compactable) {
1172
+ sessionState.lastHandledTurnId = turnId;
1173
+ return;
1174
+ }
1175
+
1176
+ const pending = { turnId };
1177
+ sessionState.pending = pending;
1178
+ let compactInvocationActive = true;
1179
+ let externallyAborted = false;
1180
+ let removeAbortListener = () => {};
1181
+ const onAbort = () => {
1182
+ // Official Pi's public compact() first aborts the active agent run. That
1183
+ // synchronous abort belongs to this fallback operation, not the user.
1184
+ if (compactInvocationActive) return;
1185
+ externallyAborted = true;
1186
+ if (sessionState.pending !== pending) return;
1187
+ sessionState.lastHandledTurnId = turnId;
1188
+ sessionState.pending = undefined;
1189
+ settings?.abortCompaction?.();
1190
+ };
1191
+ if (context.signal) {
1192
+ context.signal.addEventListener("abort", onAbort, { once: true });
1193
+ removeAbortListener = () => context.signal?.removeEventListener("abort", onAbort);
1194
+ }
1195
+ const cleanup = () => {
1196
+ removeAbortListener();
1197
+ removeAbortListener = () => {};
1198
+ };
1199
+ try {
1200
+ context.compact({
1201
+ onComplete: () => {
1202
+ cleanup();
1203
+ if (this.disposed || sessionState.pending !== pending || externallyAborted) return;
1204
+ sessionState.lastHandledTurnId = turnId;
1205
+ sessionState.pending = undefined;
1206
+ this.sendHiddenFollowUp(turnId);
1207
+ },
1208
+ onError: () => {
1209
+ cleanup();
1210
+ if (this.disposed || sessionState.pending !== pending) return;
1211
+ sessionState.lastHandledTurnId = turnId;
1212
+ sessionState.pending = undefined;
1213
+ // Compaction failures fail closed. A hidden follow-up is only
1214
+ // valid after a successful host compaction.
1215
+ },
1216
+ });
1217
+ } catch {
1218
+ cleanup();
1219
+ if (sessionState.pending === pending) {
1220
+ sessionState.lastHandledTurnId = turnId;
1221
+ sessionState.pending = undefined;
1222
+ }
1223
+ } finally {
1224
+ compactInvocationActive = false;
1225
+ }
1226
+ }
1227
+
1228
+ private sendHiddenFollowUp(turnId: string): void {
1229
+ try {
1230
+ this.pi.sendMessage(
1231
+ {
1232
+ customType: INLINE_COMPACTION_FOLLOW_UP_TYPE,
1233
+ content: "Continue the unfinished task from the completed tool batch. Do not treat this as new user intent.",
1234
+ display: false,
1235
+ details: {
1236
+ owner: "pi-openai-toolkit",
1237
+ turnId,
1238
+ mode: "followUp",
1239
+ },
1240
+ },
1241
+ { triggerTurn: true, deliverAs: "followUp" },
1242
+ );
1243
+ } catch {
1244
+ // Extension reload/session replacement can stale the captured API. Pending
1245
+ // state was cleared before this call, so the failure cannot loop.
1246
+ }
1247
+ }
1248
+
1249
+ private notifyUnsupported(context: ExtensionContext, config: CompactionConfig, reason: string): void {
1250
+ if (this.unsupportedNotified || !config.debug || !context.hasUI) return;
1251
+ this.unsupportedNotified = true;
1252
+ context.ui.notify(
1253
+ `pi-openai-toolkit: inline compaction unavailable (${reason}); automatic continuation is off`,
1254
+ "warning",
1255
+ );
1256
+ }
1257
+ }
1258
+
1259
+ export function registerInlineCompactionRuntime(
1260
+ pi: Pick<ExtensionAPI, "sendMessage">,
1261
+ loadConfig: ConfigLoader,
1262
+ ): InlineCompactionRuntime {
1263
+ return new InlineCompactionRuntime(pi, loadConfig);
1264
+ }