@henryqw/pi-auto-compact 0.2.0 → 0.3.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
@@ -1,6 +1,6 @@
1
1
  # `@henryqw/pi-auto-compact`
2
2
 
3
- Pi extension that compacts context before it reaches 50% of current model context, then resumes current task.
3
+ Pi extension that compacts context before it reaches 50% of current model context, then resumes current task. Requires Pi Coding Agent 0.80.7+.
4
4
 
5
5
  ## Install
6
6
 
@@ -28,23 +28,31 @@ pi remove npm:@henryqw/pi-auto-compact
28
28
 
29
29
  ## Configure
30
30
 
31
- Run `/auto-compact`, then enter threshold percentage. Config lives in `~/.pi/agent/config/pi-auto-compact.json`:
31
+ Run `/auto-compact`, then choose:
32
+
33
+ - `Model`: use current session model or select an available text model and its supported thinking level.
34
+ - `Threshold`: set compaction percentage.
35
+
36
+ Config lives in `~/.pi/agent/config/pi-auto-compact.json`:
32
37
 
33
38
  ```json
34
39
  {
35
- "autoCompactThreshold": 50
40
+ "autoCompactThreshold": 50,
41
+ "compactionModel": "openai-codex/gpt-5.6-terra",
42
+ "compactionThinkingLevel": "max"
36
43
  }
37
44
  ```
38
45
 
39
- Threshold must be at least 25% and below 100%; lower values are not meaningful. Missing config defaults to 50%. Restart or `/reload` after manual edits; command changes apply immediately.
46
+ `compactionModel` is optional and must name Pi-known `provider/model`; omit it to compact with current session model. `compactionThinkingLevel` accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`, requires a dedicated model, and defaults to `off` when omitted. Threshold must be at least 25% and below 100%; lower values are not meaningful. Missing config defaults to 50%. Restart or `/reload` after manual edits; menu changes apply immediately.
40
47
 
41
48
  ## Behavior
42
49
 
43
50
  - Refuses activation with an error when Pi's effective `compaction.enabled` setting is not `false`; competing automatic compactors can start duplicate summaries.
44
- - Checks `turn_start`, tool-call `turn_end`, `context`, and resumed/forked `session_start`.
45
- - Uses Pi's default `ctx.compact()` summary and session persistence.
51
+ - Checks `turn_start`, tool-call `turn_end`, `agent_end`, `context`, and resumed/forked `session_start`.
52
+ - Uses Pi's native summary and session persistence; optional `compactionModel` runs automatic summaries with selected model and thinking level. Manual `/compact` keeps using current session model.
53
+ - Falls back to current session model when configured compaction model is unavailable, cannot authenticate, or fails.
46
54
  - Keeps newest 15% as temporary emergency context while compaction runs.
47
- - Sends a follow-up message after compaction so task execution continues.
55
+ - Sends a follow-up message after mid-task compaction so task execution continues; final-answer compaction stays idle.
48
56
  - Compacts above configured `autoCompactThreshold` percentage (50% by default).
49
57
 
50
58
  `ctx.compact()` aborts current low-level run. Extension hides that empty internal abort message, then starts new run with current task resume message. Other aborts and provider errors remain visible.
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import {
4
+ compact,
4
5
  estimateTokens,
5
6
  getAgentDir,
6
7
  SettingsManager,
@@ -11,25 +12,59 @@ import type {
11
12
  } from "@earendil-works/pi-coding-agent";
12
13
 
13
14
  type AgentMessage = Parameters<typeof estimateTokens>[0];
15
+ type ThinkingLevel = NonNullable<Parameters<typeof compact>[6]>;
16
+ type TextModel = ReturnType<ExtensionContext["modelRegistry"]["getAvailable"]>[number];
14
17
 
15
18
  /**
16
- * Proactive compaction runs at three points:
19
+ * Proactive compaction runs at four points:
17
20
  * - turn_start: catch sessions already over threshold before next request.
18
21
  * - turn_end: catch growth caused by tool results before next LLM turn.
22
+ * - agent_end: catch growth from the final provider turn.
19
23
  * - context: last-resort guard with a temporary keep-recent context.
20
24
  *
21
- * Pi's ctx.compact() aborts active low-level run internally. Its completion
22
- * callback sends follow-up user message, which resumes task after summary.
25
+ * Pi's ctx.compact() aborts active low-level run internally. Mid-task
26
+ * compaction sends a follow-up user message to resume work after summary.
23
27
  */
24
28
  const DEFAULT_COMPACT_THRESHOLD_PERCENT = 50;
25
29
  const MIN_COMPACT_THRESHOLD_PERCENT = 25;
30
+ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
26
31
  const configPath = () => join(getAgentDir(), "config", "pi-auto-compact.json");
27
32
 
33
+ type Config = {
34
+ autoCompactThreshold: number;
35
+ compactionModel?: string;
36
+ compactionThinkingLevel?: ThinkingLevel;
37
+ };
38
+
39
+ type ModelReference = { provider: string; modelId: string };
40
+
28
41
  function isValidThreshold(value: unknown): value is number {
29
42
  return typeof value === "number" && Number.isFinite(value) && value >= MIN_COMPACT_THRESHOLD_PERCENT && value < 100;
30
43
  }
31
44
 
32
- function readConfig(): { autoCompactThreshold: number } {
45
+ function isValidThinkingLevel(value: unknown): value is ThinkingLevel {
46
+ return typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel);
47
+ }
48
+
49
+ function supportedThinkingLevels(model: TextModel): ThinkingLevel[] {
50
+ if (!model.reasoning) return ["off"];
51
+ return THINKING_LEVELS.filter((level) => {
52
+ const mapped = model.thinkingLevelMap?.[level];
53
+ return mapped !== null && ((level !== "xhigh" && level !== "max") || mapped !== undefined);
54
+ });
55
+ }
56
+
57
+ function parseModelReference(value: unknown): ModelReference | undefined {
58
+ if (typeof value !== "string") return undefined;
59
+ const separator = value.indexOf("/");
60
+ if (separator <= 0 || separator === value.length - 1) return undefined;
61
+
62
+ const provider = value.slice(0, separator).trim();
63
+ const modelId = value.slice(separator + 1).trim();
64
+ return provider && modelId ? { provider, modelId } : undefined;
65
+ }
66
+
67
+ function readConfig(): Config {
33
68
  let value: unknown;
34
69
  try {
35
70
  value = JSON.parse(readFileSync(configPath(), "utf8"));
@@ -42,21 +77,43 @@ function readConfig(): { autoCompactThreshold: number } {
42
77
  if (!value || typeof value !== "object" || Array.isArray(value)) {
43
78
  throw new Error("Config must be an object.");
44
79
  }
45
- const threshold = (value as Record<string, unknown>).autoCompactThreshold ?? DEFAULT_COMPACT_THRESHOLD_PERCENT;
80
+ const config = value as Record<string, unknown>;
81
+ const threshold = config.autoCompactThreshold ?? DEFAULT_COMPACT_THRESHOLD_PERCENT;
46
82
  if (!isValidThreshold(threshold)) {
47
83
  throw new Error(`autoCompactThreshold must be at least ${MIN_COMPACT_THRESHOLD_PERCENT} and below 100.`);
48
84
  }
49
- return { autoCompactThreshold: threshold };
85
+ if (config.compactionModel !== undefined && !parseModelReference(config.compactionModel)) {
86
+ throw new Error("compactionModel must be a provider/model string.");
87
+ }
88
+ const thinkingLevel = config.compactionThinkingLevel;
89
+ if (thinkingLevel !== undefined && !isValidThinkingLevel(thinkingLevel)) {
90
+ throw new Error("compactionThinkingLevel is invalid.");
91
+ }
92
+ if (thinkingLevel !== undefined && config.compactionModel === undefined) {
93
+ throw new Error("compactionThinkingLevel requires compactionModel.");
94
+ }
95
+ return {
96
+ autoCompactThreshold: threshold,
97
+ compactionModel: typeof config.compactionModel === "string" ? config.compactionModel.trim() : undefined,
98
+ compactionThinkingLevel: thinkingLevel,
99
+ };
50
100
  }
51
101
 
52
- function writeConfig(autoCompactThreshold: number): void {
102
+ function writeConfig(config: Config): void {
53
103
  const file = configPath();
54
104
  mkdirSync(dirname(file), { recursive: true });
55
- writeFileSync(file, `${JSON.stringify({ autoCompactThreshold }, null, 2)}\n`);
105
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
106
+ }
107
+
108
+ function withoutDeletedHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
109
+ return headers
110
+ ? Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null))
111
+ : undefined;
56
112
  }
57
113
 
58
114
  // Emergency context guard keeps recent messages while default compaction runs.
59
115
  const KEEP_RECENT_PERCENT = 15;
116
+ const COMPACTION_INSTRUCTIONS = "Preserve current task to be resumed after compaction.";
60
117
  const RESUME_MESSAGE = "Auto-compact ran. Continue the current task.";
61
118
  const COMPACTION_ABORT_ERROR = "This operation was aborted";
62
119
  const ACTIVATION_ERROR =
@@ -119,16 +176,20 @@ function hasToolCall(message: AgentMessage): boolean {
119
176
  export default function (pi: ExtensionAPI) {
120
177
  let active = false;
121
178
  let autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
122
- // Prevent turn_start, turn_end, and context from starting duplicate summaries.
179
+ let compactionModel: string | undefined;
180
+ let compactionThinkingLevel: ThinkingLevel | undefined;
181
+ // Prevent lifecycle hooks from starting duplicate summaries.
123
182
  let compactionPending = false;
124
183
  let compactionAbortExpected = false;
125
184
 
126
- const runCompaction = (ctx: ExtensionContext) => {
185
+ const runCompaction = (ctx: ExtensionContext, resumeTask = true) => {
127
186
  compactionAbortExpected = Boolean(ctx.signal && !ctx.signal.aborted);
128
187
  ctx.compact({
188
+ customInstructions: COMPACTION_INSTRUCTIONS,
129
189
  onComplete: () => {
130
190
  compactionPending = false;
131
191
  compactionAbortExpected = false;
192
+ if (!resumeTask) return;
132
193
  // Pi may flush queued input during compaction_end. Wait one macrotask
133
194
  // before checking idle, otherwise follow-up can race that flush.
134
195
  setImmediate(() => {
@@ -142,14 +203,14 @@ export default function (pi: ExtensionAPI) {
142
203
  });
143
204
  };
144
205
 
145
- const compactIfNeeded = (ctx: ExtensionContext) => {
206
+ const compactIfNeeded = (ctx: ExtensionContext, resumeTask = true) => {
146
207
  if (!active || compactionPending) return;
147
208
 
148
209
  const usage = ctx.getContextUsage();
149
210
  if (usage?.percent == null || usage.percent <= autoCompactThreshold) return;
150
211
 
151
212
  compactionPending = true;
152
- runCompaction(ctx);
213
+ runCompaction(ctx, resumeTask);
153
214
  };
154
215
 
155
216
  // Hide only empty abort produced when ctx.compact() cancels active run.
@@ -176,12 +237,14 @@ export default function (pi: ExtensionAPI) {
176
237
  // Pre-turn catches resumed/queued work before provider request starts.
177
238
  pi.on("turn_start", (_event, ctx) => compactIfNeeded(ctx));
178
239
 
179
- // Only tool-call turns need mid-run compaction. Final answers should not
180
- // receive an unsolicited continuation message.
240
+ // Only tool-call turns need mid-run compaction.
181
241
  pi.on("turn_end", (event, ctx) => {
182
242
  if (hasToolCall(event.message)) compactIfNeeded(ctx);
183
243
  });
184
244
 
245
+ // Catch threshold crossings caused by the final provider turn.
246
+ pi.on("agent_end", (_event, ctx) => compactIfNeeded(ctx, false));
247
+
185
248
  // Runs before every provider request. Temporary truncation protects request
186
249
  // size while asynchronous default compaction summarizes persisted history.
187
250
  pi.on("context", (event, ctx) => {
@@ -205,19 +268,75 @@ export default function (pi: ExtensionAPI) {
205
268
  });
206
269
 
207
270
  pi.registerCommand("auto-compact", {
208
- description: "set automatic compaction threshold",
209
- handler: async (_args, ctx) => {
210
- let current: number;
271
+ description: "configure automatic compaction",
272
+ handler: async (args, ctx) => {
273
+ if (args.trim()) {
274
+ ctx.ui.notify("Usage: /auto-compact", "error");
275
+ return;
276
+ }
277
+
278
+ let config: Config;
211
279
  try {
212
- current = readConfig().autoCompactThreshold;
280
+ config = readConfig();
213
281
  } catch {
214
282
  ctx.ui.notify("Couldn't read pi-auto-compact config.", "error");
215
283
  return;
216
284
  }
285
+ const save = (next: Config) => {
286
+ try {
287
+ writeConfig(next);
288
+ return true;
289
+ } catch {
290
+ ctx.ui.notify("Couldn't save pi-auto-compact config.", "error");
291
+ return false;
292
+ }
293
+ };
294
+
295
+ const thresholdOption = `Threshold · ${config.autoCompactThreshold}%`;
296
+ const modelOption = `Model · ${config.compactionModel
297
+ ? `${config.compactionModel} (${config.compactionThinkingLevel ?? "off"})`
298
+ : "current session"}`;
299
+ const setting = await ctx.ui.select("Configure auto-compact", [modelOption, thresholdOption]);
300
+ if (!setting) return;
301
+
302
+ if (setting === modelOption) {
303
+ const models = ctx.modelRegistry
304
+ .getAvailable()
305
+ .filter((model) => model.input.includes("text"))
306
+ .sort((a, b) => `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
307
+ const currentModel = "Current session model";
308
+ const selected = await ctx.ui.select("Auto-compact model", [
309
+ currentModel,
310
+ ...models.map((model) => `${model.provider}/${model.id}`),
311
+ ]);
312
+ if (!selected) return;
313
+
314
+ if (selected === currentModel) {
315
+ if (!save({ ...config, compactionModel: undefined, compactionThinkingLevel: undefined })) return;
316
+ compactionModel = undefined;
317
+ compactionThinkingLevel = undefined;
318
+ ctx.ui.notify("Auto-compact model set to current session model.", "info");
319
+ return;
320
+ }
321
+
322
+ const model = models.find((candidate) => `${candidate.provider}/${candidate.id}` === selected);
323
+ if (!model) return;
324
+ const thinkingLevels = supportedThinkingLevels(model);
325
+ const thinkingLevel = thinkingLevels.length === 1
326
+ ? thinkingLevels[0]
327
+ : await ctx.ui.select(`Thinking level · ${selected}`, thinkingLevels);
328
+ if (!isValidThinkingLevel(thinkingLevel)) return;
329
+
330
+ if (!save({ ...config, compactionModel: selected, compactionThinkingLevel: thinkingLevel })) return;
331
+ compactionModel = selected;
332
+ compactionThinkingLevel = thinkingLevel;
333
+ ctx.ui.notify(`Auto-compact model set to ${selected} (${thinkingLevel}).`, "info");
334
+ return;
335
+ }
217
336
 
218
337
  const input = await ctx.ui.input(
219
- `Auto-compact threshold (%) · current: ${current}`,
220
- "Enter a number above 0 and below 100",
338
+ `Auto-compact threshold (%) · current: ${config.autoCompactThreshold}`,
339
+ "Enter a number at least 25 and below 100",
221
340
  );
222
341
  if (input === undefined) return;
223
342
 
@@ -231,12 +350,7 @@ export default function (pi: ExtensionAPI) {
231
350
  return;
232
351
  }
233
352
 
234
- try {
235
- writeConfig(threshold);
236
- } catch {
237
- ctx.ui.notify("Couldn't save pi-auto-compact config.", "error");
238
- return;
239
- }
353
+ if (!save({ ...config, autoCompactThreshold: threshold })) return;
240
354
  autoCompactThreshold = threshold;
241
355
  ctx.ui.notify(`Auto-compact threshold set to ${threshold}%.`, "info");
242
356
  },
@@ -246,9 +360,14 @@ export default function (pi: ExtensionAPI) {
246
360
  // activation unless effective global/project settings disable it.
247
361
  pi.on("session_start", (event, ctx) => {
248
362
  try {
249
- autoCompactThreshold = readConfig().autoCompactThreshold;
363
+ const config = readConfig();
364
+ autoCompactThreshold = config.autoCompactThreshold;
365
+ compactionModel = config.compactionModel;
366
+ compactionThinkingLevel = config.compactionThinkingLevel;
250
367
  } catch {
251
368
  autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
369
+ compactionModel = undefined;
370
+ compactionThinkingLevel = undefined;
252
371
  ctx.ui.notify("Couldn't read pi-auto-compact config; using 50%.", "error");
253
372
  }
254
373
 
@@ -260,4 +379,68 @@ export default function (pi: ExtensionAPI) {
260
379
  // Resume/fork can load an already-large session before first turn.
261
380
  if (event.reason === "resume" || event.reason === "fork") compactIfNeeded(ctx);
262
381
  });
382
+
383
+ pi.on("session_before_compact", async (event, ctx) => {
384
+ if (
385
+ !active ||
386
+ !compactionPending ||
387
+ event.customInstructions !== COMPACTION_INSTRUCTIONS
388
+ ) return;
389
+
390
+ // Pi omits details from prior extension compactions when preparing next run.
391
+ const previous = [...event.branchEntries].reverse().find((entry) => entry.type === "compaction");
392
+ if (previous?.details && typeof previous.details === "object") {
393
+ const details = previous.details as { readFiles?: unknown; modifiedFiles?: unknown };
394
+ if (Array.isArray(details.readFiles)) {
395
+ for (const path of details.readFiles) {
396
+ if (typeof path === "string") event.preparation.fileOps.read.add(path);
397
+ }
398
+ }
399
+ if (Array.isArray(details.modifiedFiles)) {
400
+ for (const path of details.modifiedFiles) {
401
+ if (typeof path === "string") event.preparation.fileOps.edited.add(path);
402
+ }
403
+ }
404
+ }
405
+
406
+ if (!compactionModel) return;
407
+ const reference = parseModelReference(compactionModel);
408
+ if (!reference) return;
409
+ const model = ctx.modelRegistry.find(reference.provider, reference.modelId);
410
+ if (!model) {
411
+ ctx.ui.notify("Configured compaction model not found; using current session model.", "error");
412
+ return;
413
+ }
414
+ if (compactionThinkingLevel && !supportedThinkingLevels(model).includes(compactionThinkingLevel)) {
415
+ ctx.ui.notify("Configured thinking level is unsupported; using current session model.", "error");
416
+ return;
417
+ }
418
+
419
+ try {
420
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
421
+ if (!auth.ok) {
422
+ ctx.ui.notify("Couldn't authenticate configured compaction model; using current session model.", "error");
423
+ return;
424
+ }
425
+
426
+ const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
427
+ return {
428
+ compaction: await compact(
429
+ event.preparation,
430
+ requestModel,
431
+ auth.apiKey,
432
+ withoutDeletedHeaders(auth.headers),
433
+ event.customInstructions,
434
+ event.signal,
435
+ compactionThinkingLevel,
436
+ undefined,
437
+ auth.env,
438
+ ),
439
+ };
440
+ } catch {
441
+ if (!event.signal.aborted) {
442
+ ctx.ui.notify("Configured compaction model failed; using current session model.", "error");
443
+ }
444
+ }
445
+ });
263
446
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-auto-compact",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Proactively compact Pi context at a configurable threshold and resume the current task.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -25,7 +25,7 @@
25
25
  "pack:check": "npm pack --dry-run"
26
26
  },
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-coding-agent": "*"
28
+ "@earendil-works/pi-coding-agent": ">=0.80.7"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",