@lll9p/pi-better-compaction 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  English | [中文](README.zh-CN.md)
4
4
 
5
- A [pi](https://github.com/nicepkg/pi) extension that upgrades context compaction with two coordinated strategies:
5
+ A [pi](https://github.com/nicepkg/pi) extension that upgrades context compaction with three coordinated strategies:
6
6
 
7
- 1. **OpenAI Responses APIs** use the provider's native compaction endpoint, preserving opaque context that plain text summaries lose.
8
- 2. **All other APIs** (Anthropic, Gemini, etc.) can run pi's built-in compaction with a **dedicated cheaper/faster model**, so summarization doesn't consume quota on your primary model.
7
+ 1. An optional **mid-run guard** aborts an oversized tool loop, waits for `agent_settled`, compacts once, then resumes with a hidden custom message.
8
+ 2. **OpenAI Responses APIs** use the provider's native compaction endpoint, preserving opaque context that plain text summaries lose.
9
+ 3. **All other APIs** (Anthropic, Gemini, etc.) can run pi's built-in compaction with a **dedicated cheaper/faster model**, so summarization doesn't consume quota on your primary model.
9
10
 
10
11
  Everything fails open — if any step cannot proceed, pi's default compaction takes over.
11
12
 
@@ -44,6 +45,10 @@ If the file doesn't exist, all defaults apply. The extension never creates this
44
45
  ```jsonc
45
46
  {
46
47
  "enabled": true,
48
+ "midRun": {
49
+ "enabled": false,
50
+ "thresholdPercent": 80
51
+ },
47
52
  "compactionVersion": "v2",
48
53
  "compactionModel": null,
49
54
  "compactionThinkingLevel": "off",
@@ -65,6 +70,8 @@ If the file doesn't exist, all defaults apply. The extension never creates this
65
70
  | Option | Type | Default | Description |
66
71
  |--------|------|---------|-------------|
67
72
  | `enabled` | `boolean` | `true` | Master switch. Set `false` to disable the extension entirely. |
73
+ | `midRun.enabled` | `boolean` | `false` | Enable the mid-run guard. It may abort a long tool loop once context reaches the configured threshold. |
74
+ | `midRun.thresholdPercent` | `number` | `80` | Context usage percentage that triggers the mid-run guard after a tool-bearing turn. Must be greater than 0 and at most 100. |
68
75
  | `compactionVersion` | `"v1" \| "v2"` | `"v2"` | Protocol for Responses-family APIs. **V2** (streaming, encrypted blob) is the current OpenAI default. **V1** uses the legacy `/responses/compact` endpoint. |
69
76
  | `compactionModel` | `string \| null` | `null` | Model for fallback compaction (non-Responses APIs, or when native compact fails). Format: `"provider/model-id"`, e.g. `"openai/gpt-5.1-mini"`. `null` = let pi use the current chat model. |
70
77
  | `compactionThinkingLevel` | `string` | `"off"` | Thinking level for the fallback compaction model. One of: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. |
@@ -77,6 +84,17 @@ If the file doesn't exist, all defaults apply. The extension never creates this
77
84
  | `redactSensitiveData` | `boolean` | `true` | Redact secrets in debug artifacts. |
78
85
  | `artifactRoot` | `string` | `"~/.pi/agent/artifacts/pi-better-compaction"` | Root directory for debug artifacts. Supports `~/` and relative paths (resolved against config dir). |
79
86
 
87
+ ### Example: enable mid-run compaction
88
+
89
+ ```json
90
+ {
91
+ "midRun": {
92
+ "enabled": true,
93
+ "thresholdPercent": 80
94
+ }
95
+ }
96
+ ```
97
+
80
98
  ### Example: use a cheap model for fallback compaction
81
99
 
82
100
  ```json
@@ -96,6 +114,8 @@ If the file doesn't exist, all defaults apply. The extension never creates this
96
114
 
97
115
  ## How it works
98
116
 
117
+ With `midRun.enabled`, a completed tool-bearing `turn_end` above the threshold only calls `ctx.abort()`. After Pi emits `agent_settled`, the guard reuses any compaction Pi already completed during abort handling; otherwise it calls `ctx.compact()` exactly once. A successful or coalesced compaction triggers the next turn with a hidden custom message. Failed compaction does not resume, preventing a compact-fail-resume loop.
118
+
99
119
  When pi triggers compaction (`session_before_compact`):
100
120
 
101
121
  1. **Responses API detected** → run native compaction (V2 or V1 per config):
package/README.zh-CN.md CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
- 一个 [pi](https://github.com/nicepkg/pi) 扩展,通过两条策略提升上下文压缩效果:
5
+ 一个 [pi](https://github.com/nicepkg/pi) 扩展,通过三条协同策略提升上下文压缩效果:
6
6
 
7
- 1. **OpenAI Responses 系列 API** 使用提供商原生压缩端点,保留纯文本摘要无法留存的不透明上下文。
8
- 2. **其他所有 API**(Anthropic、Gemini 等)可用一个**独立的低成本模型**执行 pi 内置压缩,避免在主模型上消耗额度。
7
+ 1. 可选的 **mid-run guard** 在工具循环占满上下文时先中止运行,等待 `agent_settled` 后只压缩一次,再通过隐藏消息继续任务。
8
+ 2. **OpenAI Responses 系列 API** 使用提供商原生压缩端点,保留纯文本摘要无法留存的不透明上下文。
9
+ 3. **其他所有 API**(Anthropic、Gemini 等)可用一个**独立的低成本模型**执行 pi 内置压缩,避免在主模型上消耗额度。
9
10
 
10
11
  所有环节都安全降级——任何步骤无法执行时,pi 的默认压缩自动接管。
11
12
 
@@ -44,6 +45,10 @@ cd pi-better-compaction && pi install .
44
45
  ```jsonc
45
46
  {
46
47
  "enabled": true,
48
+ "midRun": {
49
+ "enabled": false,
50
+ "thresholdPercent": 80
51
+ },
47
52
  "compactionVersion": "v2",
48
53
  "compactionModel": null,
49
54
  "compactionThinkingLevel": "off",
@@ -65,6 +70,8 @@ cd pi-better-compaction && pi install .
65
70
  | 选项 | 类型 | 默认值 | 说明 |
66
71
  |------|------|--------|------|
67
72
  | `enabled` | `boolean` | `true` | 总开关。设为 `false` 完全禁用扩展。 |
73
+ | `midRun.enabled` | `boolean` | `false` | 启用 mid-run guard。上下文达到阈值后,它可能主动中止长工具循环。 |
74
+ | `midRun.thresholdPercent` | `number` | `80` | 工具型 turn 完成后触发 mid-run guard 的上下文占用百分比。必须大于 0 且不超过 100。 |
68
75
  | `compactionVersion` | `"v1" \| "v2"` | `"v2"` | Responses 系列 API 的压缩协议。**V2**(流式,加密 blob)是 OpenAI 当前默认协议;**V1** 使用旧版 `/responses/compact` 端点。 |
69
76
  | `compactionModel` | `string \| null` | `null` | 回退压缩使用的模型(用于非 Responses API,或原生压缩失败时)。格式:`"provider/model-id"`,如 `"openai/gpt-5.1-mini"`。`null` = 由 pi 使用当前对话模型。 |
70
77
  | `compactionThinkingLevel` | `string` | `"off"` | 回退压缩模型的思考级别。可选:`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`。 |
@@ -77,6 +84,17 @@ cd pi-better-compaction && pi install .
77
84
  | `redactSensitiveData` | `boolean` | `true` | 在调试文件中脱敏。 |
78
85
  | `artifactRoot` | `string` | `"~/.pi/agent/artifacts/pi-better-compaction"` | 调试文件根目录。支持 `~/` 和相对路径(相对于配置文件目录解析)。 |
79
86
 
87
+ ### 示例:启用 mid-run 压缩
88
+
89
+ ```json
90
+ {
91
+ "midRun": {
92
+ "enabled": true,
93
+ "thresholdPercent": 80
94
+ }
95
+ }
96
+ ```
97
+
80
98
  ### 示例:使用低成本模型做回退压缩
81
99
 
82
100
  ```json
@@ -96,6 +114,8 @@ cd pi-better-compaction && pi install .
96
114
 
97
115
  ## 工作原理
98
116
 
117
+ 启用 `midRun.enabled` 后,超过阈值的工具型 `turn_end` 只调用 `ctx.abort()`。Pi 发出 `agent_settled` 后,guard 会复用 abort 收尾阶段已经完成的压缩;如果没有,则只调用一次 `ctx.compact()`。压缩成功或竞争合并后,通过隐藏 custom message 触发下一轮。压缩失败时不会自动继续,避免进入“压缩失败—继续—再次失败”的循环。
118
+
99
119
  pi 触发压缩时(`session_before_compact`):
100
120
 
101
121
  1. **检测到 Responses API** → 执行原生压缩(根据配置选择 V2 或 V1):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lll9p/pi-better-compaction",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Better compaction for pi: native /responses/compact replay for OpenAI Responses APIs, plus a configurable compaction model driving pi's native summarization everywhere else.",
6
6
  "author": "Lilin Lao",
@@ -38,6 +38,7 @@
38
38
  "src/debug.ts",
39
39
  "src/details-store.ts",
40
40
  "src/extension-runtime.ts",
41
+ "src/midrun.ts",
41
42
  "src/native-fallback.ts",
42
43
  "src/payload-rewrite.ts",
43
44
  "src/request-context-cache.ts",
package/src/config.ts CHANGED
@@ -60,6 +60,15 @@ function toBoolean(value: unknown, fieldPath: string, warnings: string[]): boole
60
60
  return undefined;
61
61
  }
62
62
 
63
+ function toThresholdPercent(value: unknown, fieldPath: string, warnings: string[]): number | undefined {
64
+ if (value === undefined) return undefined;
65
+ if (typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 100) {
66
+ return value;
67
+ }
68
+ warnings.push(`Ignoring ${fieldPath}: expected a number greater than 0 and at most 100.`);
69
+ return undefined;
70
+ }
71
+
63
72
  function toModelSpec(value: unknown, fieldPath: string, warnings: string[]): string | null | undefined {
64
73
  if (value === undefined) return undefined;
65
74
  // Explicit null clears a spec, matching the documented "unset = current model" behavior.
@@ -119,6 +128,7 @@ export function loadExtensionConfig(configPath: string = CONFIG_PATH): LoadedExt
119
128
  const warnings: string[] = [];
120
129
  const resolved: ExtensionConfig = {
121
130
  ...DEFAULT_EXTENSION_CONFIG,
131
+ midRun: { ...DEFAULT_EXTENSION_CONFIG.midRun },
122
132
  responsesCompactApis: [...DEFAULT_EXTENSION_CONFIG.responsesCompactApis],
123
133
  };
124
134
  let source: string | undefined;
@@ -128,6 +138,19 @@ export function loadExtensionConfig(configPath: string = CONFIG_PATH): LoadedExt
128
138
  source = configPath;
129
139
 
130
140
  resolved.enabled = toBoolean(raw.enabled, "enabled", warnings) ?? resolved.enabled;
141
+
142
+ if (raw.midRun === undefined) {
143
+ // Keep defaults.
144
+ } else if (isRecord(raw.midRun)) {
145
+ resolved.midRun.enabled =
146
+ toBoolean(raw.midRun.enabled, "midRun.enabled", warnings) ?? resolved.midRun.enabled;
147
+ resolved.midRun.thresholdPercent =
148
+ toThresholdPercent(raw.midRun.thresholdPercent, "midRun.thresholdPercent", warnings) ??
149
+ resolved.midRun.thresholdPercent;
150
+ } else {
151
+ warnings.push("Ignoring midRun: expected a JSON object.");
152
+ }
153
+
131
154
  resolved.allowCompactionContinuityBreak =
132
155
  toBoolean(raw.allowCompactionContinuityBreak, "allowCompactionContinuityBreak", warnings) ??
133
156
  resolved.allowCompactionContinuityBreak;
@@ -10,6 +10,7 @@ import { executeV2Compaction } from "./compact-client-v2";
10
10
  import { loadExtensionConfig } from "./config";
11
11
  import { writeDebugArtifact } from "./debug";
12
12
  import { resolveLatestNativeCompactionEntry } from "./details-store";
13
+ import { registerMidRunGuard } from "./midrun";
13
14
  import { runNativeFallbackCompaction } from "./native-fallback";
14
15
  import {
15
16
  rewriteResponsesPayloadWithNativeReplay,
@@ -40,6 +41,20 @@ type ResponsesCompactOutcome =
40
41
  | { outcome: "aborted" }
41
42
  | { outcome: "failed" };
42
43
 
44
+ export type ExtensionRuntimeDependencies = {
45
+ loadExtensionConfig: typeof loadExtensionConfig;
46
+ executeNativeCompaction: typeof executeNativeCompaction;
47
+ executeV2Compaction: typeof executeV2Compaction;
48
+ runNativeFallbackCompaction: typeof runNativeFallbackCompaction;
49
+ };
50
+
51
+ const DEFAULT_DEPENDENCIES: ExtensionRuntimeDependencies = {
52
+ loadExtensionConfig,
53
+ executeNativeCompaction,
54
+ executeV2Compaction,
55
+ runNativeFallbackCompaction,
56
+ };
57
+
43
58
  function buildCompactionRequestMeta(event: SessionBeforeCompactEvent): NativeCompactionRequestMeta {
44
59
  return {
45
60
  tokensBefore: event.preparation.tokensBefore,
@@ -91,7 +106,7 @@ function buildCompactionInstructions(systemPrompt: string, customInstructions?:
91
106
  return systemPrompt;
92
107
  }
93
108
 
94
- return `${systemPrompt}\n\nAdditional user guidance for this manual /compact request:\n${guidance}`;
109
+ return `${systemPrompt}\n\nAdditional compaction guidance:\n${guidance}`;
95
110
  }
96
111
 
97
112
  async function runResponsesV1Compact(
@@ -99,6 +114,7 @@ async function runResponsesV1Compact(
99
114
  ctx: ExtensionContext,
100
115
  config: ExtensionConfig,
101
116
  runtime: NativeCompactionRuntime,
117
+ dependencies: ExtensionRuntimeDependencies,
102
118
  ): Promise<ResponsesCompactOutcome> {
103
119
  const instructions = buildCompactionInstructions(ctx.getSystemPrompt(), event.customInstructions);
104
120
  const branchEntries = ctx.sessionManager.getBranch();
@@ -161,7 +177,7 @@ async function runResponsesV1Compact(
161
177
  request = { ...request, ...extras };
162
178
  }
163
179
 
164
- const compactResult = await executeNativeCompaction({
180
+ const compactResult = await dependencies.executeNativeCompaction({
165
181
  runtime,
166
182
  request,
167
183
  signal: event.signal,
@@ -251,6 +267,7 @@ async function runResponsesV2Compact(
251
267
  ctx: ExtensionContext,
252
268
  config: ExtensionConfig,
253
269
  runtime: NativeCompactionRuntime,
270
+ dependencies: ExtensionRuntimeDependencies,
254
271
  ): Promise<ResponsesCompactOutcome> {
255
272
  const instructions = buildCompactionInstructions(ctx.getSystemPrompt(), event.customInstructions);
256
273
  const branchEntries = ctx.sessionManager.getBranch();
@@ -311,7 +328,7 @@ async function runResponsesV2Compact(
311
328
  request = { ...request, ...extras };
312
329
  }
313
330
 
314
- const v2Result = await executeV2Compaction({
331
+ const v2Result = await dependencies.executeV2Compaction({
315
332
  runtime,
316
333
  request,
317
334
  signal: event.signal,
@@ -400,8 +417,12 @@ async function runResponsesV2Compact(
400
417
  return { outcome: "success", compaction };
401
418
  }
402
419
 
403
- async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx: ExtensionContext) {
404
- const { config } = loadExtensionConfig();
420
+ async function handleSessionBeforeCompact(
421
+ event: SessionBeforeCompactEvent,
422
+ ctx: ExtensionContext,
423
+ dependencies: ExtensionRuntimeDependencies,
424
+ ) {
425
+ const { config } = dependencies.loadExtensionConfig();
405
426
  if (!config.enabled) {
406
427
  return undefined;
407
428
  }
@@ -436,9 +457,9 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
436
457
  let responsesOutcome: ResponsesCompactOutcome;
437
458
 
438
459
  if (config.compactionVersion === "v2") {
439
- responsesOutcome = await runResponsesV2Compact(event, ctx, config, resolution.runtime);
460
+ responsesOutcome = await runResponsesV2Compact(event, ctx, config, resolution.runtime, dependencies);
440
461
  } else {
441
- responsesOutcome = await runResponsesV1Compact(event, ctx, config, resolution.runtime);
462
+ responsesOutcome = await runResponsesV1Compact(event, ctx, config, resolution.runtime, dependencies);
442
463
  }
443
464
 
444
465
  if (responsesOutcome.outcome === "success") {
@@ -465,7 +486,12 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
465
486
  }
466
487
 
467
488
  // Branch 2: run pi's native compaction method with the configured model.
468
- const fallback = await runNativeFallbackCompaction({ ctx, event, config, sessionId: getSessionId(ctx) });
489
+ const fallback = await dependencies.runNativeFallbackCompaction({
490
+ ctx,
491
+ event,
492
+ config,
493
+ sessionId: getSessionId(ctx),
494
+ });
469
495
  if (fallback.ok) {
470
496
  if (ctx.hasUI) {
471
497
  ctx.ui.notify(
@@ -514,8 +540,12 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
514
540
  return undefined;
515
541
  }
516
542
 
517
- async function handleBeforeProviderRequest(event: BeforeProviderRequestEvent, ctx: ExtensionContext) {
518
- const { config } = loadExtensionConfig();
543
+ async function handleBeforeProviderRequest(
544
+ event: BeforeProviderRequestEvent,
545
+ ctx: ExtensionContext,
546
+ dependencies: ExtensionRuntimeDependencies,
547
+ ) {
548
+ const { config } = dependencies.loadExtensionConfig();
519
549
  if (!config.enabled) {
520
550
  return undefined;
521
551
  }
@@ -638,9 +668,14 @@ async function handleBeforeProviderRequest(event: BeforeProviderRequestEvent, ct
638
668
  return rewrite.rewrittenPayload;
639
669
  }
640
670
 
641
- export default function (pi: ExtensionAPI) {
671
+ export function registerExtensionRuntime(
672
+ pi: ExtensionAPI,
673
+ dependencies: ExtensionRuntimeDependencies = DEFAULT_DEPENDENCIES,
674
+ ): void {
675
+ registerMidRunGuard(pi, dependencies.loadExtensionConfig);
676
+
642
677
  pi.on("session_start", (_event, ctx) => {
643
- const { config, source, warnings } = loadExtensionConfig();
678
+ const { config, source, warnings } = dependencies.loadExtensionConfig();
644
679
  if (!config.enabled) return;
645
680
 
646
681
  if (warnings.length > 0 && ctx.hasUI && config.debug) {
@@ -669,11 +704,15 @@ export default function (pi: ExtensionAPI) {
669
704
  }
670
705
  });
671
706
 
672
- pi.on("session_before_compact", handleSessionBeforeCompact);
673
- pi.on("before_provider_request", handleBeforeProviderRequest);
707
+ pi.on("session_before_compact", (event, ctx) =>
708
+ handleSessionBeforeCompact(event, ctx, dependencies),
709
+ );
710
+ pi.on("before_provider_request", (event, ctx) =>
711
+ handleBeforeProviderRequest(event, ctx, dependencies),
712
+ );
674
713
 
675
714
  pi.on("session_compact_failed", (event, ctx) => {
676
- const { config } = loadExtensionConfig();
715
+ const { config } = dependencies.loadExtensionConfig();
677
716
  if (!config.enabled) return;
678
717
 
679
718
  writeDebugArtifact(
@@ -691,3 +730,5 @@ export default function (pi: ExtensionAPI) {
691
730
  );
692
731
  });
693
732
  }
733
+
734
+ export default registerExtensionRuntime;
package/src/midrun.ts ADDED
@@ -0,0 +1,229 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadExtensionConfig } from "./config";
3
+ import { writeDebugArtifact } from "./debug";
4
+ import { EXTENSION_ID, type LoadedExtensionConfig } from "./types";
5
+
6
+ type MidRunPhase = "idle" | "abort-pending" | "compacting" | "resume-pending" | "failed";
7
+
8
+ type MidRunState = {
9
+ phase: MidRunPhase;
10
+ generation: number;
11
+ sessionId?: string;
12
+ baselineCompactionId?: string;
13
+ triggerTokens?: number;
14
+ triggerPercent?: number;
15
+ triggerContextWindow?: number;
16
+ };
17
+
18
+ type ConfigLoader = () => LoadedExtensionConfig;
19
+
20
+ const RESUME_CUSTOM_TYPE = "pi-better-compaction-midrun-resume";
21
+ const RESUME_PROMPT = `[pi-better-compaction/midrun]
22
+ Context compaction completed. Continue the interrupted task from the exact point where execution stopped.`;
23
+ const MIDRUN_COMPACTION_INSTRUCTIONS =
24
+ "Preserve the active task, completed work, decisions, changed files, failures, current tool-loop state, and exact next steps so execution can resume immediately after compaction.";
25
+
26
+ function getSessionId(ctx: ExtensionContext): string | undefined {
27
+ try {
28
+ return ctx.sessionManager.getSessionId();
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ function getLatestCompactionId(ctx: ExtensionContext): string | undefined {
35
+ const branch = ctx.sessionManager.getBranch();
36
+ for (let index = branch.length - 1; index >= 0; index--) {
37
+ const entry = branch[index];
38
+ if (entry?.type === "compaction") return entry.id;
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ function resetState(state: MidRunState): void {
44
+ state.phase = "idle";
45
+ state.generation += 1;
46
+ state.sessionId = undefined;
47
+ state.baselineCompactionId = undefined;
48
+ state.triggerTokens = undefined;
49
+ state.triggerPercent = undefined;
50
+ state.triggerContextWindow = undefined;
51
+ }
52
+
53
+ function sameSession(state: MidRunState, ctx: ExtensionContext): boolean {
54
+ return state.sessionId !== undefined && state.sessionId === getSessionId(ctx);
55
+ }
56
+
57
+ function notifyFailure(ctx: ExtensionContext, message: string): void {
58
+ if (ctx.hasUI) {
59
+ ctx.ui.notify(`${EXTENSION_ID}: ${message}`, "error");
60
+ }
61
+ }
62
+
63
+ function scheduleResume(
64
+ pi: ExtensionAPI,
65
+ ctx: ExtensionContext,
66
+ state: MidRunState,
67
+ generation: number,
68
+ ): void {
69
+ state.phase = "resume-pending";
70
+
71
+ setImmediate(() => {
72
+ if (
73
+ state.generation !== generation ||
74
+ state.phase !== "resume-pending" ||
75
+ !sameSession(state, ctx)
76
+ ) {
77
+ return;
78
+ }
79
+
80
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
81
+ resetState(state);
82
+ return;
83
+ }
84
+
85
+ resetState(state);
86
+ pi.sendMessage(
87
+ {
88
+ customType: RESUME_CUSTOM_TYPE,
89
+ content: RESUME_PROMPT,
90
+ display: false,
91
+ details: { source: "midrun-compaction" },
92
+ },
93
+ { triggerTurn: true },
94
+ );
95
+ });
96
+ }
97
+
98
+ export function registerMidRunGuard(
99
+ pi: ExtensionAPI,
100
+ loadConfig: ConfigLoader = loadExtensionConfig,
101
+ ): void {
102
+ const state: MidRunState = { phase: "idle", generation: 0 };
103
+
104
+ pi.on("turn_end", (event, ctx) => {
105
+ const { config } = loadConfig();
106
+ if (!config.enabled || !config.midRun.enabled || state.phase !== "idle") return;
107
+ if (event.toolResults.length === 0 || ctx.hasPendingMessages()) return;
108
+
109
+ const usage = ctx.getContextUsage();
110
+ if (!usage || usage.tokens == null || usage.percent == null) return;
111
+ if (usage.percent < config.midRun.thresholdPercent) return;
112
+
113
+ const sessionId = getSessionId(ctx);
114
+ if (!sessionId) return;
115
+
116
+ state.phase = "abort-pending";
117
+ state.generation += 1;
118
+ state.sessionId = sessionId;
119
+ state.baselineCompactionId = getLatestCompactionId(ctx);
120
+ state.triggerTokens = usage.tokens;
121
+ state.triggerPercent = usage.percent;
122
+ state.triggerContextWindow = usage.contextWindow;
123
+
124
+ writeDebugArtifact(
125
+ "lifecycle",
126
+ {
127
+ event: "midrun.threshold",
128
+ turnIndex: event.turnIndex,
129
+ tokens: usage.tokens,
130
+ contextWindow: usage.contextWindow,
131
+ percent: usage.percent,
132
+ thresholdPercent: config.midRun.thresholdPercent,
133
+ baselineCompactionId: state.baselineCompactionId,
134
+ },
135
+ config,
136
+ ctx,
137
+ );
138
+
139
+ // Never compact while the agent run is active; let Pi settle first.
140
+ ctx.abort();
141
+ });
142
+
143
+ pi.on("agent_settled", (_event, ctx) => {
144
+ if (state.phase !== "abort-pending") return;
145
+ if (!sameSession(state, ctx)) {
146
+ resetState(state);
147
+ return;
148
+ }
149
+
150
+ // An earlier agent_settled handler may already have started or queued another run.
151
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
152
+ resetState(state);
153
+ return;
154
+ }
155
+
156
+ const generation = state.generation;
157
+ const latestCompactionId = getLatestCompactionId(ctx);
158
+ if (latestCompactionId !== state.baselineCompactionId) {
159
+ const { config } = loadConfig();
160
+ writeDebugArtifact(
161
+ "lifecycle",
162
+ {
163
+ event: "midrun.coalesced",
164
+ reason: "compaction-already-occurred-during-abort",
165
+ baselineCompactionId: state.baselineCompactionId,
166
+ latestCompactionId,
167
+ },
168
+ config,
169
+ ctx,
170
+ );
171
+ scheduleResume(pi, ctx, state, generation);
172
+ return;
173
+ }
174
+
175
+ const { config } = loadConfig();
176
+ if (!config.enabled || !config.midRun.enabled) {
177
+ scheduleResume(pi, ctx, state, generation);
178
+ return;
179
+ }
180
+
181
+ const usage = ctx.getContextUsage();
182
+ if (usage?.percent != null && usage.percent < config.midRun.thresholdPercent) {
183
+ scheduleResume(pi, ctx, state, generation);
184
+ return;
185
+ }
186
+
187
+ state.phase = "compacting";
188
+ ctx.compact({
189
+ customInstructions: MIDRUN_COMPACTION_INSTRUCTIONS,
190
+ onComplete: () => {
191
+ if (
192
+ state.generation !== generation ||
193
+ state.phase !== "compacting" ||
194
+ !sameSession(state, ctx)
195
+ ) {
196
+ return;
197
+ }
198
+ scheduleResume(pi, ctx, state, generation);
199
+ },
200
+ onError: (error) => {
201
+ if (state.generation !== generation || !sameSession(state, ctx)) return;
202
+
203
+ const latest = getLatestCompactionId(ctx);
204
+ if (/Already compacted/i.test(error.message) && latest !== state.baselineCompactionId) {
205
+ scheduleResume(pi, ctx, state, generation);
206
+ return;
207
+ }
208
+
209
+ state.phase = "failed";
210
+ writeDebugArtifact(
211
+ "lifecycle",
212
+ {
213
+ event: "midrun.failed",
214
+ errorMessage: error.message,
215
+ triggerTokens: state.triggerTokens,
216
+ triggerPercent: state.triggerPercent,
217
+ triggerContextWindow: state.triggerContextWindow,
218
+ },
219
+ config,
220
+ ctx,
221
+ );
222
+ notifyFailure(ctx, `mid-run compaction failed: ${error.message}`);
223
+ },
224
+ });
225
+ });
226
+
227
+ pi.on("session_start", () => resetState(state));
228
+ pi.on("session_shutdown", () => resetState(state));
229
+ }
package/src/types.ts CHANGED
@@ -33,8 +33,14 @@ export type DebugArtifactKind =
33
33
  | "compaction-event"
34
34
  | "lifecycle";
35
35
 
36
+ export type MidRunConfig = {
37
+ enabled: boolean;
38
+ thresholdPercent: number;
39
+ };
40
+
36
41
  export type ExtensionConfig = {
37
42
  enabled: boolean;
43
+ midRun: MidRunConfig;
38
44
  /**
39
45
  * Allow a Responses session whose latest compaction was not created by this extension
40
46
  * to restart native compaction from Pi's current serialized session context.
@@ -304,6 +310,10 @@ export function createNativeCompactionResult(
304
310
 
305
311
  export const DEFAULT_EXTENSION_CONFIG: ExtensionConfig = {
306
312
  enabled: true,
313
+ midRun: {
314
+ enabled: false,
315
+ thresholdPercent: 80,
316
+ },
307
317
  allowCompactionContinuityBreak: false,
308
318
  compactionModel: undefined,
309
319
  compactionThinkingLevel: "off",