@henryqw/pi-auto-compact 0.2.2 → 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,21 +28,29 @@ 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
51
  - Checks `turn_start`, tool-call `turn_end`, `agent_end`, `context`, and resumed/forked `session_start`.
45
- - Uses Pi's default `ctx.compact()` summary and session persistence.
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
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).
@@ -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,6 +12,8 @@ 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
19
  * Proactive compaction runs at four points:
@@ -24,13 +27,44 @@ type AgentMessage = Parameters<typeof estimateTokens>[0];
24
27
  */
25
28
  const DEFAULT_COMPACT_THRESHOLD_PERCENT = 50;
26
29
  const MIN_COMPACT_THRESHOLD_PERCENT = 25;
30
+ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
27
31
  const configPath = () => join(getAgentDir(), "config", "pi-auto-compact.json");
28
32
 
33
+ type Config = {
34
+ autoCompactThreshold: number;
35
+ compactionModel?: string;
36
+ compactionThinkingLevel?: ThinkingLevel;
37
+ };
38
+
39
+ type ModelReference = { provider: string; modelId: string };
40
+
29
41
  function isValidThreshold(value: unknown): value is number {
30
42
  return typeof value === "number" && Number.isFinite(value) && value >= MIN_COMPACT_THRESHOLD_PERCENT && value < 100;
31
43
  }
32
44
 
33
- 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 {
34
68
  let value: unknown;
35
69
  try {
36
70
  value = JSON.parse(readFileSync(configPath(), "utf8"));
@@ -43,21 +77,43 @@ function readConfig(): { autoCompactThreshold: number } {
43
77
  if (!value || typeof value !== "object" || Array.isArray(value)) {
44
78
  throw new Error("Config must be an object.");
45
79
  }
46
- 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;
47
82
  if (!isValidThreshold(threshold)) {
48
83
  throw new Error(`autoCompactThreshold must be at least ${MIN_COMPACT_THRESHOLD_PERCENT} and below 100.`);
49
84
  }
50
- 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
+ };
51
100
  }
52
101
 
53
- function writeConfig(autoCompactThreshold: number): void {
102
+ function writeConfig(config: Config): void {
54
103
  const file = configPath();
55
104
  mkdirSync(dirname(file), { recursive: true });
56
- 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;
57
112
  }
58
113
 
59
114
  // Emergency context guard keeps recent messages while default compaction runs.
60
115
  const KEEP_RECENT_PERCENT = 15;
116
+ const COMPACTION_INSTRUCTIONS = "Preserve current task to be resumed after compaction.";
61
117
  const RESUME_MESSAGE = "Auto-compact ran. Continue the current task.";
62
118
  const COMPACTION_ABORT_ERROR = "This operation was aborted";
63
119
  const ACTIVATION_ERROR =
@@ -120,6 +176,8 @@ function hasToolCall(message: AgentMessage): boolean {
120
176
  export default function (pi: ExtensionAPI) {
121
177
  let active = false;
122
178
  let autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
179
+ let compactionModel: string | undefined;
180
+ let compactionThinkingLevel: ThinkingLevel | undefined;
123
181
  // Prevent lifecycle hooks from starting duplicate summaries.
124
182
  let compactionPending = false;
125
183
  let compactionAbortExpected = false;
@@ -127,6 +185,7 @@ export default function (pi: ExtensionAPI) {
127
185
  const runCompaction = (ctx: ExtensionContext, resumeTask = true) => {
128
186
  compactionAbortExpected = Boolean(ctx.signal && !ctx.signal.aborted);
129
187
  ctx.compact({
188
+ customInstructions: COMPACTION_INSTRUCTIONS,
130
189
  onComplete: () => {
131
190
  compactionPending = false;
132
191
  compactionAbortExpected = false;
@@ -209,19 +268,75 @@ export default function (pi: ExtensionAPI) {
209
268
  });
210
269
 
211
270
  pi.registerCommand("auto-compact", {
212
- description: "set automatic compaction threshold",
213
- handler: async (_args, ctx) => {
214
- 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;
215
279
  try {
216
- current = readConfig().autoCompactThreshold;
280
+ config = readConfig();
217
281
  } catch {
218
282
  ctx.ui.notify("Couldn't read pi-auto-compact config.", "error");
219
283
  return;
220
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
+ }
221
336
 
222
337
  const input = await ctx.ui.input(
223
- `Auto-compact threshold (%) · current: ${current}`,
224
- "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",
225
340
  );
226
341
  if (input === undefined) return;
227
342
 
@@ -235,12 +350,7 @@ export default function (pi: ExtensionAPI) {
235
350
  return;
236
351
  }
237
352
 
238
- try {
239
- writeConfig(threshold);
240
- } catch {
241
- ctx.ui.notify("Couldn't save pi-auto-compact config.", "error");
242
- return;
243
- }
353
+ if (!save({ ...config, autoCompactThreshold: threshold })) return;
244
354
  autoCompactThreshold = threshold;
245
355
  ctx.ui.notify(`Auto-compact threshold set to ${threshold}%.`, "info");
246
356
  },
@@ -250,9 +360,14 @@ export default function (pi: ExtensionAPI) {
250
360
  // activation unless effective global/project settings disable it.
251
361
  pi.on("session_start", (event, ctx) => {
252
362
  try {
253
- autoCompactThreshold = readConfig().autoCompactThreshold;
363
+ const config = readConfig();
364
+ autoCompactThreshold = config.autoCompactThreshold;
365
+ compactionModel = config.compactionModel;
366
+ compactionThinkingLevel = config.compactionThinkingLevel;
254
367
  } catch {
255
368
  autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
369
+ compactionModel = undefined;
370
+ compactionThinkingLevel = undefined;
256
371
  ctx.ui.notify("Couldn't read pi-auto-compact config; using 50%.", "error");
257
372
  }
258
373
 
@@ -264,4 +379,68 @@ export default function (pi: ExtensionAPI) {
264
379
  // Resume/fork can load an already-large session before first turn.
265
380
  if (event.reason === "resume" || event.reason === "fork") compactIfNeeded(ctx);
266
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
+ });
267
446
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-auto-compact",
3
- "version": "0.2.2",
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",