@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 +15 -3
- package/extensions/auto-compact.ts +96 -12
- package/package.json +2 -2
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
|
-
-
|
|
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
|
|
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.
|
|
20
|
-
*
|
|
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
|
|
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
|
-
|
|
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 <=
|
|
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.
|
|
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 *
|
|
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.
|
|
4
|
-
"description": "Proactively compact Pi context at
|
|
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",
|