@henryqw/pi-auto-compact 0.3.0 → 1.0.2

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,13 +1,22 @@
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. Requires Pi Coding Agent 0.80.7+.
3
+ Compact context before it hits the configured threshold, then resume the current task.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
+ pi install npm:@henryqw/pi-task-models
8
9
  pi install npm:@henryqw/pi-auto-compact
9
10
  ```
10
11
 
12
+ Requires Pi Coding Agent 0.84.2+.
13
+
14
+ ## With
15
+
16
+ | Package | Why |
17
+ | --- | --- |
18
+ | `@henryqw/pi-task-models` | Required. Shared model profiles for compaction routes. |
19
+
11
20
  Disable Pi's built-in auto-compaction in `~/.pi/agent/settings.json`:
12
21
 
13
22
  ```json
@@ -18,51 +27,44 @@ Disable Pi's built-in auto-compaction in `~/.pi/agent/settings.json`:
18
27
  }
19
28
  ```
20
29
 
21
- Restart Pi after installation or settings changes. Trusted project settings in `.pi/settings.json` must not override `compaction.enabled` back to `true`. Manual `/compact` remains available.
22
-
23
- Remove with:
30
+ Restart Pi after install or settings changes. Trusted project settings in `.pi/settings.json` must not set `compaction.enabled` back to `true`.
24
31
 
25
- ```bash
26
- pi remove npm:@henryqw/pi-auto-compact
27
- ```
32
+ ## Use
28
33
 
29
- ## Configure
34
+ | Surface | Type | Purpose |
35
+ | --- | --- | --- |
36
+ | `/auto-compact` | command | Set the compaction threshold. |
30
37
 
31
- Run `/auto-compact`, then choose:
38
+ Refuses to activate unless effective `compaction.enabled` is `false`. Checks `turn_start`, tool-call `turn_end`, `agent_end`, `context`, and resumed or forked `session_start`. Tries the assigned profile primary, then fallback; if neither route works, the current session model still compacts. After mid-task compaction, a follow-up message continues the current task.
32
39
 
33
- - `Model`: use current session model or select an available text model and its supported thinking level.
34
- - `Threshold`: set compaction percentage.
40
+ ## Config
35
41
 
36
- Config lives in `~/.pi/agent/config/pi-auto-compact.json`:
42
+ `~/.pi/agent/config/pi-auto-compact.json`
37
43
 
38
44
  ```json
39
45
  {
40
- "autoCompactThreshold": 50,
41
- "compactionModel": "openai-codex/gpt-5.6-terra",
42
- "compactionThinkingLevel": "max"
46
+ "autoCompactThreshold": 50
43
47
  }
44
48
  ```
45
49
 
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.
50
+ Threshold must be at least 25 and below 100. Missing config defaults to 50. Model routes live in `~/.pi/agent/config/pi-task-models.json`. Malformed shared task-model config is reported and left unchanged.
47
51
 
48
- ## Behavior
52
+ ## Remove
49
53
 
50
- - Refuses activation with an error when Pi's effective `compaction.enabled` setting is not `false`; competing automatic compactors can start duplicate summaries.
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.
54
- - Keeps newest 15% as temporary emergency context while compaction runs.
55
- - Sends a follow-up message after mid-task compaction so task execution continues; final-answer compaction stays idle.
56
- - Compacts above configured `autoCompactThreshold` percentage (50% by default).
57
-
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.
54
+ ```bash
55
+ pi remove npm:@henryqw/pi-auto-compact
56
+ ```
59
57
 
60
58
  ## Development
61
59
 
62
60
  ```bash
63
- npm test
64
- npm run pack:check
65
- npm run test:live
61
+ npm test --workspace @henryqw/pi-auto-compact
62
+ npm run typecheck --workspace @henryqw/pi-auto-compact
63
+ npm run pack:check --workspace @henryqw/pi-auto-compact
66
64
  ```
67
65
 
68
- `test:live` uses real Pi plus authenticated model access. It disables Pi's built-in auto-compaction, sets a temporary 12K context window, sends a large prompt, and verifies extension compaction, automatic resume, persisted resume message, and assistant response. Set `PI_AUTO_COMPACT_AUTH_FILE` when auth is not at `~/.pi/agent/auth.json`.
66
+ `test:live` needs real Pi plus authenticated model access. Set `PI_AUTO_COMPACT_AUTH_FILE` when auth is not at `~/.pi/agent/auth.json`.
67
+
68
+ ```bash
69
+ npm run test:live --workspace @henryqw/pi-auto-compact
70
+ ```
@@ -10,10 +10,14 @@ import type {
10
10
  ExtensionAPI,
11
11
  ExtensionContext,
12
12
  } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ orderedProfileRoutes,
15
+ readTaskModelsConfig,
16
+ resolveTaskModelRoute,
17
+ type ResolvedTaskRoute,
18
+ } from "@henryqw/pi-task-models";
13
19
 
14
20
  type AgentMessage = Parameters<typeof estimateTokens>[0];
15
- type ThinkingLevel = NonNullable<Parameters<typeof compact>[6]>;
16
- type TextModel = ReturnType<ExtensionContext["modelRegistry"]["getAvailable"]>[number];
17
21
 
18
22
  /**
19
23
  * Proactive compaction runs at four points:
@@ -27,43 +31,18 @@ type TextModel = ReturnType<ExtensionContext["modelRegistry"]["getAvailable"]>[n
27
31
  */
28
32
  const DEFAULT_COMPACT_THRESHOLD_PERCENT = 50;
29
33
  const MIN_COMPACT_THRESHOLD_PERCENT = 25;
30
- const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
34
+ const AUTO_COMPACT_TASK = "pi-auto-compact/autoCompact";
35
+ const DEFAULT_AUTO_COMPACT_PROFILE = "balanced" as const;
31
36
  const configPath = () => join(getAgentDir(), "config", "pi-auto-compact.json");
32
37
 
33
38
  type Config = {
34
39
  autoCompactThreshold: number;
35
- compactionModel?: string;
36
- compactionThinkingLevel?: ThinkingLevel;
37
40
  };
38
41
 
39
- type ModelReference = { provider: string; modelId: string };
40
-
41
42
  function isValidThreshold(value: unknown): value is number {
42
43
  return typeof value === "number" && Number.isFinite(value) && value >= MIN_COMPACT_THRESHOLD_PERCENT && value < 100;
43
44
  }
44
45
 
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
46
  function readConfig(): Config {
68
47
  let value: unknown;
69
48
  try {
@@ -77,26 +56,11 @@ function readConfig(): Config {
77
56
  if (!value || typeof value !== "object" || Array.isArray(value)) {
78
57
  throw new Error("Config must be an object.");
79
58
  }
80
- const config = value as Record<string, unknown>;
81
- const threshold = config.autoCompactThreshold ?? DEFAULT_COMPACT_THRESHOLD_PERCENT;
59
+ const threshold = (value as Record<string, unknown>).autoCompactThreshold ?? DEFAULT_COMPACT_THRESHOLD_PERCENT;
82
60
  if (!isValidThreshold(threshold)) {
83
61
  throw new Error(`autoCompactThreshold must be at least ${MIN_COMPACT_THRESHOLD_PERCENT} and below 100.`);
84
62
  }
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
- };
63
+ return { autoCompactThreshold: threshold };
100
64
  }
101
65
 
102
66
  function writeConfig(config: Config): void {
@@ -105,6 +69,31 @@ function writeConfig(config: Config): void {
105
69
  writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
106
70
  }
107
71
 
72
+ function configuredTaskRoutes(ctx: ExtensionContext): ResolvedTaskRoute[] | undefined {
73
+ let config;
74
+ try {
75
+ config = readTaskModelsConfig();
76
+ } catch {
77
+ ctx.ui.notify("Couldn't read task model config; using current session model.", "error");
78
+ return undefined;
79
+ }
80
+
81
+ const profileName = config.tasks[AUTO_COMPACT_TASK] ?? DEFAULT_AUTO_COMPACT_PROFILE;
82
+ const profile = config.profiles[profileName];
83
+ if (!profile) {
84
+ ctx.ui.notify(`Task model profile ${profileName} is not configured; using current session model.`, "error");
85
+ return [];
86
+ }
87
+
88
+ const routes = orderedProfileRoutes(profile)
89
+ .map((route) => resolveTaskModelRoute(ctx, route))
90
+ .filter((route): route is ResolvedTaskRoute => route !== undefined);
91
+ if (!routes.length) {
92
+ ctx.ui.notify(`No usable ${profileName} task model route; using current session model.`, "error");
93
+ }
94
+ return routes;
95
+ }
96
+
108
97
  function withoutDeletedHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
109
98
  return headers
110
99
  ? Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null))
@@ -176,8 +165,6 @@ function hasToolCall(message: AgentMessage): boolean {
176
165
  export default function (pi: ExtensionAPI) {
177
166
  let active = false;
178
167
  let autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
179
- let compactionModel: string | undefined;
180
- let compactionThinkingLevel: ThinkingLevel | undefined;
181
168
  // Prevent lifecycle hooks from starting duplicate summaries.
182
169
  let compactionPending = false;
183
170
  let compactionAbortExpected = false;
@@ -268,7 +255,7 @@ export default function (pi: ExtensionAPI) {
268
255
  });
269
256
 
270
257
  pi.registerCommand("auto-compact", {
271
- description: "configure automatic compaction",
258
+ description: "configure automatic compaction threshold",
272
259
  handler: async (args, ctx) => {
273
260
  if (args.trim()) {
274
261
  ctx.ui.notify("Usage: /auto-compact", "error");
@@ -282,57 +269,6 @@ export default function (pi: ExtensionAPI) {
282
269
  ctx.ui.notify("Couldn't read pi-auto-compact config.", "error");
283
270
  return;
284
271
  }
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
- }
336
272
 
337
273
  const input = await ctx.ui.input(
338
274
  `Auto-compact threshold (%) · current: ${config.autoCompactThreshold}`,
@@ -350,7 +286,12 @@ export default function (pi: ExtensionAPI) {
350
286
  return;
351
287
  }
352
288
 
353
- if (!save({ ...config, autoCompactThreshold: threshold })) return;
289
+ try {
290
+ writeConfig({ autoCompactThreshold: threshold });
291
+ } catch {
292
+ ctx.ui.notify("Couldn't save pi-auto-compact config.", "error");
293
+ return;
294
+ }
354
295
  autoCompactThreshold = threshold;
355
296
  ctx.ui.notify(`Auto-compact threshold set to ${threshold}%.`, "info");
356
297
  },
@@ -360,14 +301,9 @@ export default function (pi: ExtensionAPI) {
360
301
  // activation unless effective global/project settings disable it.
361
302
  pi.on("session_start", (event, ctx) => {
362
303
  try {
363
- const config = readConfig();
364
- autoCompactThreshold = config.autoCompactThreshold;
365
- compactionModel = config.compactionModel;
366
- compactionThinkingLevel = config.compactionThinkingLevel;
304
+ autoCompactThreshold = readConfig().autoCompactThreshold;
367
305
  } catch {
368
306
  autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
369
- compactionModel = undefined;
370
- compactionThinkingLevel = undefined;
371
307
  ctx.ui.notify("Couldn't read pi-auto-compact config; using 50%.", "error");
372
308
  }
373
309
 
@@ -403,44 +339,35 @@ export default function (pi: ExtensionAPI) {
403
339
  }
404
340
  }
405
341
 
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
- }
342
+ const routes = configuredTaskRoutes(ctx);
343
+ if (!routes?.length) return;
418
344
 
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;
345
+ for (const route of routes) {
346
+ try {
347
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(route.model);
348
+ if (!auth.ok) continue;
349
+
350
+ const requestModel = auth.baseUrl ? { ...route.model, baseUrl: auth.baseUrl } : route.model;
351
+ return {
352
+ compaction: await compact(
353
+ event.preparation,
354
+ requestModel,
355
+ auth.apiKey,
356
+ withoutDeletedHeaders(auth.headers),
357
+ event.customInstructions,
358
+ event.signal,
359
+ route.thinkingLevel,
360
+ undefined,
361
+ auth.env,
362
+ ),
363
+ };
364
+ } catch {
365
+ if (event.signal.aborted) return;
424
366
  }
367
+ }
425
368
 
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
- }
369
+ if (!event.signal.aborted) {
370
+ ctx.ui.notify("Configured task model routes failed; using current session model.", "error");
444
371
  }
445
372
  });
446
373
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-auto-compact",
3
- "version": "0.3.0",
3
+ "version": "1.0.2",
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,8 @@
25
25
  "pack:check": "npm pack --dry-run"
26
26
  },
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-coding-agent": ">=0.80.7"
28
+ "@earendil-works/pi-ai": "^0.84.2",
29
+ "@earendil-works/pi-coding-agent": "^0.84.2"
29
30
  },
30
31
  "repository": {
31
32
  "type": "git",
@@ -42,5 +43,8 @@
42
43
  "extensions": [
43
44
  "./extensions"
44
45
  ]
46
+ },
47
+ "dependencies": {
48
+ "@henryqw/pi-task-models": "^0.1.0"
45
49
  }
46
50
  }