@henryqw/pi-auto-compact 0.1.2 → 0.2.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
@@ -26,14 +26,26 @@ Remove with:
26
26
  pi remove npm:@henryqw/pi-auto-compact
27
27
  ```
28
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
+
29
41
  ## Behavior
30
42
 
31
43
  - Refuses activation with an error when Pi's effective `compaction.enabled` setting is not `false`; competing automatic compactors can start duplicate summaries.
32
- - Checks `turn_start`, tool-call `turn_end`, `context`, and resumed/forked `session_start`.
44
+ - Checks `turn_start`, tool-call `turn_end`, `agent_end`, `context`, and resumed/forked `session_start`.
33
45
  - Uses Pi's default `ctx.compact()` summary and session persistence.
34
46
  - Keeps newest 15% as temporary emergency context while compaction runs.
35
- - Sends a follow-up message after compaction so task execution continues.
36
- - Uses fixed 50% threshold; no extension-specific configuration.
47
+ - Sends a follow-up message after mid-task compaction so task execution continues; final-answer compaction stays idle.
48
+ - Compacts above configured `autoCompactThreshold` percentage (50% by default).
37
49
 
38
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.
39
51
 
@@ -1,3 +1,5 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
1
3
  import {
2
4
  estimateTokens,
3
5
  getAgentDir,
@@ -11,15 +13,48 @@ import type {
11
13
  type AgentMessage = Parameters<typeof estimateTokens>[0];
12
14
 
13
15
  /**
14
- * Proactive compaction runs at three points:
16
+ * Proactive compaction runs at four points:
15
17
  * - turn_start: catch sessions already over threshold before next request.
16
18
  * - turn_end: catch growth caused by tool results before next LLM turn.
19
+ * - agent_end: catch growth from the final provider turn.
17
20
  * - context: last-resort guard with a temporary keep-recent context.
18
21
  *
19
- * Pi's ctx.compact() aborts active low-level run internally. Its completion
20
- * callback sends follow-up user message, which resumes task after summary.
22
+ * Pi's ctx.compact() aborts active low-level run internally. Mid-task
23
+ * compaction sends a follow-up user message to resume work after summary.
21
24
  */
22
- const COMPACT_THRESHOLD_PERCENT = 50;
25
+ const DEFAULT_COMPACT_THRESHOLD_PERCENT = 50;
26
+ const MIN_COMPACT_THRESHOLD_PERCENT = 25;
27
+ const configPath = () => join(getAgentDir(), "config", "pi-auto-compact.json");
28
+
29
+ function isValidThreshold(value: unknown): value is number {
30
+ return typeof value === "number" && Number.isFinite(value) && value >= MIN_COMPACT_THRESHOLD_PERCENT && value < 100;
31
+ }
32
+
33
+ function readConfig(): { autoCompactThreshold: number } {
34
+ let value: unknown;
35
+ try {
36
+ value = JSON.parse(readFileSync(configPath(), "utf8"));
37
+ } catch (error) {
38
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
39
+ return { autoCompactThreshold: DEFAULT_COMPACT_THRESHOLD_PERCENT };
40
+ }
41
+ throw error;
42
+ }
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
44
+ throw new Error("Config must be an object.");
45
+ }
46
+ const threshold = (value as Record<string, unknown>).autoCompactThreshold ?? DEFAULT_COMPACT_THRESHOLD_PERCENT;
47
+ if (!isValidThreshold(threshold)) {
48
+ throw new Error(`autoCompactThreshold must be at least ${MIN_COMPACT_THRESHOLD_PERCENT} and below 100.`);
49
+ }
50
+ return { autoCompactThreshold: threshold };
51
+ }
52
+
53
+ function writeConfig(autoCompactThreshold: number): void {
54
+ const file = configPath();
55
+ mkdirSync(dirname(file), { recursive: true });
56
+ writeFileSync(file, `${JSON.stringify({ autoCompactThreshold }, null, 2)}\n`);
57
+ }
23
58
 
24
59
  // Emergency context guard keeps recent messages while default compaction runs.
25
60
  const KEEP_RECENT_PERCENT = 15;
@@ -84,16 +119,18 @@ function hasToolCall(message: AgentMessage): boolean {
84
119
 
85
120
  export default function (pi: ExtensionAPI) {
86
121
  let active = false;
87
- // Prevent turn_start, turn_end, and context from starting duplicate summaries.
122
+ let autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
123
+ // Prevent lifecycle hooks from starting duplicate summaries.
88
124
  let compactionPending = false;
89
125
  let compactionAbortExpected = false;
90
126
 
91
- const runCompaction = (ctx: ExtensionContext) => {
127
+ const runCompaction = (ctx: ExtensionContext, resumeTask = true) => {
92
128
  compactionAbortExpected = Boolean(ctx.signal && !ctx.signal.aborted);
93
129
  ctx.compact({
94
130
  onComplete: () => {
95
131
  compactionPending = false;
96
132
  compactionAbortExpected = false;
133
+ if (!resumeTask) return;
97
134
  // Pi may flush queued input during compaction_end. Wait one macrotask
98
135
  // before checking idle, otherwise follow-up can race that flush.
99
136
  setImmediate(() => {
@@ -107,14 +144,14 @@ export default function (pi: ExtensionAPI) {
107
144
  });
108
145
  };
109
146
 
110
- const compactIfNeeded = (ctx: ExtensionContext) => {
147
+ const compactIfNeeded = (ctx: ExtensionContext, resumeTask = true) => {
111
148
  if (!active || compactionPending) return;
112
149
 
113
150
  const usage = ctx.getContextUsage();
114
- if (usage?.percent == null || usage.percent <= COMPACT_THRESHOLD_PERCENT) return;
151
+ if (usage?.percent == null || usage.percent <= autoCompactThreshold) return;
115
152
 
116
153
  compactionPending = true;
117
- runCompaction(ctx);
154
+ runCompaction(ctx, resumeTask);
118
155
  };
119
156
 
120
157
  // Hide only empty abort produced when ctx.compact() cancels active run.
@@ -141,12 +178,14 @@ export default function (pi: ExtensionAPI) {
141
178
  // Pre-turn catches resumed/queued work before provider request starts.
142
179
  pi.on("turn_start", (_event, ctx) => compactIfNeeded(ctx));
143
180
 
144
- // Only tool-call turns need mid-run compaction. Final answers should not
145
- // receive an unsolicited continuation message.
181
+ // Only tool-call turns need mid-run compaction.
146
182
  pi.on("turn_end", (event, ctx) => {
147
183
  if (hasToolCall(event.message)) compactIfNeeded(ctx);
148
184
  });
149
185
 
186
+ // Catch threshold crossings caused by the final provider turn.
187
+ pi.on("agent_end", (_event, ctx) => compactIfNeeded(ctx, false));
188
+
150
189
  // Runs before every provider request. Temporary truncation protects request
151
190
  // size while asynchronous default compaction summarizes persisted history.
152
191
  pi.on("context", (event, ctx) => {
@@ -154,7 +193,7 @@ export default function (pi: ExtensionAPI) {
154
193
 
155
194
  const contextWindow = ctx.getContextUsage()?.contextWindow ?? ctx.model?.contextWindow ?? 0;
156
195
  const estimatedTokens = estimateTotalTokens(event.messages);
157
- if (contextWindow <= 0 || estimatedTokens <= contextWindow * COMPACT_THRESHOLD_PERCENT / 100) return;
196
+ if (contextWindow <= 0 || estimatedTokens <= contextWindow * autoCompactThreshold / 100) return;
158
197
 
159
198
  const truncated = keepRecent(
160
199
  event.messages,
@@ -169,9 +208,54 @@ export default function (pi: ExtensionAPI) {
169
208
  return { messages: truncated };
170
209
  });
171
210
 
211
+ pi.registerCommand("auto-compact", {
212
+ description: "set automatic compaction threshold",
213
+ handler: async (_args, ctx) => {
214
+ let current: number;
215
+ try {
216
+ current = readConfig().autoCompactThreshold;
217
+ } catch {
218
+ ctx.ui.notify("Couldn't read pi-auto-compact config.", "error");
219
+ return;
220
+ }
221
+
222
+ const input = await ctx.ui.input(
223
+ `Auto-compact threshold (%) · current: ${current}`,
224
+ "Enter a number above 0 and below 100",
225
+ );
226
+ if (input === undefined) return;
227
+
228
+ const threshold = Number(input.trim());
229
+ if (Number.isFinite(threshold) && threshold < MIN_COMPACT_THRESHOLD_PERCENT) {
230
+ ctx.ui.notify("Auto-compact threshold below 25% is not meaningful.", "error");
231
+ return;
232
+ }
233
+ if (!isValidThreshold(threshold)) {
234
+ ctx.ui.notify("Threshold must be at least 25% and below 100%.", "error");
235
+ return;
236
+ }
237
+
238
+ try {
239
+ writeConfig(threshold);
240
+ } catch {
241
+ ctx.ui.notify("Couldn't save pi-auto-compact config.", "error");
242
+ return;
243
+ }
244
+ autoCompactThreshold = threshold;
245
+ ctx.ui.notify(`Auto-compact threshold set to ${threshold}%.`, "info");
246
+ },
247
+ });
248
+
172
249
  // Pi's built-in automatic compaction competes with this extension. Refuse
173
250
  // activation unless effective global/project settings disable it.
174
251
  pi.on("session_start", (event, ctx) => {
252
+ try {
253
+ autoCompactThreshold = readConfig().autoCompactThreshold;
254
+ } catch {
255
+ autoCompactThreshold = DEFAULT_COMPACT_THRESHOLD_PERCENT;
256
+ ctx.ui.notify("Couldn't read pi-auto-compact config; using 50%.", "error");
257
+ }
258
+
175
259
  active = !SettingsManager.create(ctx.cwd, getAgentDir(), {
176
260
  projectTrusted: ctx.isProjectTrusted(),
177
261
  }).getCompactionEnabled();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@henryqw/pi-auto-compact",
3
- "version": "0.1.2",
4
- "description": "Proactively compact Pi context at 50% and resume the current task.",
3
+ "version": "0.2.2",
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",