@henryqw/pi-auto-compact 0.1.1 → 0.2.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
@@ -8,21 +8,46 @@ Pi extension that compacts context before it reaches 50% of current model contex
8
8
  pi install npm:@henryqw/pi-auto-compact
9
9
  ```
10
10
 
11
- Restart Pi after installation. Remove with:
11
+ Disable Pi's built-in auto-compaction in `~/.pi/agent/settings.json`:
12
+
13
+ ```json
14
+ {
15
+ "compaction": {
16
+ "enabled": false
17
+ }
18
+ }
19
+ ```
20
+
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:
12
24
 
13
25
  ```bash
14
26
  pi remove npm:@henryqw/pi-auto-compact
15
27
  ```
16
28
 
29
+ ## Configure
30
+
31
+ Run `/auto-compact`, then enter threshold percentage. Config lives in `~/.pi/agent/config/pi-auto-compact.json`:
32
+
33
+ ```json
34
+ {
35
+ "autoCompactThreshold": 50
36
+ }
37
+ ```
38
+
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.
40
+
17
41
  ## Behavior
18
42
 
43
+ - Refuses activation with an error when Pi's effective `compaction.enabled` setting is not `false`; competing automatic compactors can start duplicate summaries.
19
44
  - Checks `turn_start`, tool-call `turn_end`, `context`, and resumed/forked `session_start`.
20
45
  - Uses Pi's default `ctx.compact()` summary and session persistence.
21
46
  - Keeps newest 15% as temporary emergency context while compaction runs.
22
47
  - Sends a follow-up message after compaction so task execution continues.
23
- - Uses fixed 50% threshold; no configuration needed.
48
+ - Compacts above configured `autoCompactThreshold` percentage (50% by default).
24
49
 
25
- `ctx.compact()` aborts current low-level run. Extension starts new run after compaction with the current task resume message.
50
+ `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.
26
51
 
27
52
  ## Development
28
53
 
@@ -32,4 +57,4 @@ npm run pack:check
32
57
  npm run test:live
33
58
  ```
34
59
 
35
- `test:live` uses real Pi plus authenticated model access. It sets a temporary 12K context window, sends a large prompt, and verifies compaction, automatic resume, persisted resume message, and assistant response. Set `PI_AUTO_COMPACT_AUTH_FILE` when auth is not at `~/.pi/agent/auth.json`.
60
+ `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`.
@@ -1,4 +1,10 @@
1
- import { estimateTokens } from "@earendil-works/pi-coding-agent";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import {
4
+ estimateTokens,
5
+ getAgentDir,
6
+ SettingsManager,
7
+ } from "@earendil-works/pi-coding-agent";
2
8
  import type {
3
9
  ExtensionAPI,
4
10
  ExtensionContext,
@@ -15,11 +21,47 @@ type AgentMessage = Parameters<typeof estimateTokens>[0];
15
21
  * Pi's ctx.compact() aborts active low-level run internally. Its completion
16
22
  * callback sends follow-up user message, which resumes task after summary.
17
23
  */
18
- const COMPACT_THRESHOLD_PERCENT = 50;
24
+ const DEFAULT_COMPACT_THRESHOLD_PERCENT = 50;
25
+ const MIN_COMPACT_THRESHOLD_PERCENT = 25;
26
+ const configPath = () => join(getAgentDir(), "config", "pi-auto-compact.json");
27
+
28
+ function isValidThreshold(value: unknown): value is number {
29
+ return typeof value === "number" && Number.isFinite(value) && value >= MIN_COMPACT_THRESHOLD_PERCENT && value < 100;
30
+ }
31
+
32
+ function readConfig(): { autoCompactThreshold: number } {
33
+ let value: unknown;
34
+ try {
35
+ value = JSON.parse(readFileSync(configPath(), "utf8"));
36
+ } catch (error) {
37
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
38
+ return { autoCompactThreshold: DEFAULT_COMPACT_THRESHOLD_PERCENT };
39
+ }
40
+ throw error;
41
+ }
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
43
+ throw new Error("Config must be an object.");
44
+ }
45
+ const threshold = (value as Record<string, unknown>).autoCompactThreshold ?? DEFAULT_COMPACT_THRESHOLD_PERCENT;
46
+ if (!isValidThreshold(threshold)) {
47
+ throw new Error(`autoCompactThreshold must be at least ${MIN_COMPACT_THRESHOLD_PERCENT} and below 100.`);
48
+ }
49
+ return { autoCompactThreshold: threshold };
50
+ }
51
+
52
+ function writeConfig(autoCompactThreshold: number): void {
53
+ const file = configPath();
54
+ mkdirSync(dirname(file), { recursive: true });
55
+ writeFileSync(file, `${JSON.stringify({ autoCompactThreshold }, null, 2)}\n`);
56
+ }
19
57
 
20
58
  // Emergency context guard keeps recent messages while default compaction runs.
21
59
  const KEEP_RECENT_PERCENT = 15;
22
60
  const RESUME_MESSAGE = "Auto-compact ran. Continue the current task.";
61
+ const COMPACTION_ABORT_ERROR = "This operation was aborted";
62
+ const ACTIVATION_ERROR =
63
+ "pi-auto-compact failed to activate: Pi built-in auto-compaction is enabled. " +
64
+ "Set compaction.enabled to false in Pi settings, then restart Pi.";
23
65
 
24
66
  /** Estimate current request size using same estimator Pi uses. */
25
67
  function estimateTotalTokens(messages: AgentMessage[]): number {
@@ -75,13 +117,18 @@ function hasToolCall(message: AgentMessage): boolean {
75
117
  }
76
118
 
77
119
  export default function (pi: ExtensionAPI) {
120
+ let active = false;
121
+ let autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
78
122
  // Prevent turn_start, turn_end, and context from starting duplicate summaries.
79
123
  let compactionPending = false;
124
+ let compactionAbortExpected = false;
80
125
 
81
126
  const runCompaction = (ctx: ExtensionContext) => {
127
+ compactionAbortExpected = Boolean(ctx.signal && !ctx.signal.aborted);
82
128
  ctx.compact({
83
129
  onComplete: () => {
84
130
  compactionPending = false;
131
+ compactionAbortExpected = false;
85
132
  // Pi may flush queued input during compaction_end. Wait one macrotask
86
133
  // before checking idle, otherwise follow-up can race that flush.
87
134
  setImmediate(() => {
@@ -90,20 +137,40 @@ export default function (pi: ExtensionAPI) {
90
137
  },
91
138
  onError: () => {
92
139
  compactionPending = false;
140
+ compactionAbortExpected = false;
93
141
  },
94
142
  });
95
143
  };
96
144
 
97
145
  const compactIfNeeded = (ctx: ExtensionContext) => {
98
- if (compactionPending) return;
146
+ if (!active || compactionPending) return;
99
147
 
100
148
  const usage = ctx.getContextUsage();
101
- if (usage?.percent == null || usage.percent <= COMPACT_THRESHOLD_PERCENT) return;
149
+ if (usage?.percent == null || usage.percent <= autoCompactThreshold) return;
102
150
 
103
151
  compactionPending = true;
104
152
  runCompaction(ctx);
105
153
  };
106
154
 
155
+ // Hide only empty abort produced when ctx.compact() cancels active run.
156
+ pi.on("message_end", (event, ctx) => {
157
+ const message = event.message;
158
+ if (
159
+ !compactionPending ||
160
+ !compactionAbortExpected ||
161
+ !ctx.signal?.aborted ||
162
+ message.role !== "assistant" ||
163
+ message.stopReason !== "error" ||
164
+ message.errorMessage !== COMPACTION_ABORT_ERROR ||
165
+ message.content.some((part) => part.type !== "text" || part.text !== "")
166
+ ) return;
167
+
168
+ compactionAbortExpected = false;
169
+ return {
170
+ message: { ...message, stopReason: "stop", errorMessage: undefined },
171
+ };
172
+ });
173
+
107
174
  // Do not use agent_settled here: long tool loops may cross threshold before
108
175
  // the full run settles. These hooks inspect every provider-turn boundary.
109
176
  // Pre-turn catches resumed/queued work before provider request starts.
@@ -118,11 +185,11 @@ export default function (pi: ExtensionAPI) {
118
185
  // Runs before every provider request. Temporary truncation protects request
119
186
  // size while asynchronous default compaction summarizes persisted history.
120
187
  pi.on("context", (event, ctx) => {
121
- if (compactionPending) return;
188
+ if (!active || compactionPending) return;
122
189
 
123
190
  const contextWindow = ctx.getContextUsage()?.contextWindow ?? ctx.model?.contextWindow ?? 0;
124
191
  const estimatedTokens = estimateTotalTokens(event.messages);
125
- if (contextWindow <= 0 || estimatedTokens <= contextWindow * COMPACT_THRESHOLD_PERCENT / 100) return;
192
+ if (contextWindow <= 0 || estimatedTokens <= contextWindow * autoCompactThreshold / 100) return;
126
193
 
127
194
  const truncated = keepRecent(
128
195
  event.messages,
@@ -137,10 +204,60 @@ export default function (pi: ExtensionAPI) {
137
204
  return { messages: truncated };
138
205
  });
139
206
 
140
- // Resume/fork can load an already-large session before first turn.
207
+ pi.registerCommand("auto-compact", {
208
+ description: "set automatic compaction threshold",
209
+ handler: async (_args, ctx) => {
210
+ let current: number;
211
+ try {
212
+ current = readConfig().autoCompactThreshold;
213
+ } catch {
214
+ ctx.ui.notify("Couldn't read pi-auto-compact config.", "error");
215
+ return;
216
+ }
217
+
218
+ const input = await ctx.ui.input(
219
+ `Auto-compact threshold (%) · current: ${current}`,
220
+ "Enter a number above 0 and below 100",
221
+ );
222
+ if (input === undefined) return;
223
+
224
+ const threshold = Number(input.trim());
225
+ if (Number.isFinite(threshold) && threshold < MIN_COMPACT_THRESHOLD_PERCENT) {
226
+ ctx.ui.notify("Auto-compact threshold below 25% is not meaningful.", "error");
227
+ return;
228
+ }
229
+ if (!isValidThreshold(threshold)) {
230
+ ctx.ui.notify("Threshold must be at least 25% and below 100%.", "error");
231
+ return;
232
+ }
233
+
234
+ try {
235
+ writeConfig(threshold);
236
+ } catch {
237
+ ctx.ui.notify("Couldn't save pi-auto-compact config.", "error");
238
+ return;
239
+ }
240
+ autoCompactThreshold = threshold;
241
+ ctx.ui.notify(`Auto-compact threshold set to ${threshold}%.`, "info");
242
+ },
243
+ });
244
+
245
+ // Pi's built-in automatic compaction competes with this extension. Refuse
246
+ // activation unless effective global/project settings disable it.
141
247
  pi.on("session_start", (event, ctx) => {
142
- if (event.reason === "resume" || event.reason === "fork") {
143
- compactIfNeeded(ctx);
248
+ try {
249
+ autoCompactThreshold = readConfig().autoCompactThreshold;
250
+ } catch {
251
+ autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
252
+ ctx.ui.notify("Couldn't read pi-auto-compact config; using 50%.", "error");
144
253
  }
254
+
255
+ active = !SettingsManager.create(ctx.cwd, getAgentDir(), {
256
+ projectTrusted: ctx.isProjectTrusted(),
257
+ }).getCompactionEnabled();
258
+ if (!active) throw new Error(ACTIVATION_ERROR);
259
+
260
+ // Resume/fork can load an already-large session before first turn.
261
+ if (event.reason === "resume" || event.reason === "fork") compactIfNeeded(ctx);
145
262
  });
146
263
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@henryqw/pi-auto-compact",
3
- "version": "0.1.1",
4
- "description": "Proactively compact Pi context at 50% and resume the current task.",
3
+ "version": "0.2.0",
4
+ "description": "Proactively compact Pi context at a configurable threshold and resume the current task.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "test": "node --test test/*.test.ts",
23
23
  "test:live": "PI_AUTO_COMPACT_LIVE=1 node --test test/live.test.ts",
24
- "typecheck": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/auto-compact.ts test/live.test.ts",
24
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/auto-compact.ts test/*.test.ts",
25
25
  "pack:check": "npm pack --dry-run"
26
26
  },
27
27
  "peerDependencies": {