@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 +13 -5
- package/extensions/auto-compact.ts +197 -18
- package/package.json +2 -2
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
|
|
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;
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
102
|
+
function writeConfig(config: Config): void {
|
|
54
103
|
const file = configPath();
|
|
55
104
|
mkdirSync(dirname(file), { recursive: true });
|
|
56
|
-
writeFileSync(file, `${JSON.stringify(
|
|
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: "
|
|
213
|
-
handler: async (
|
|
214
|
-
|
|
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
|
-
|
|
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: ${
|
|
224
|
-
"Enter a number
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|