@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 +32 -30
- package/extensions/auto-compact.ts +69 -142
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
# `@henryqw/pi-auto-compact`
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
26
|
-
pi remove npm:@henryqw/pi-auto-compact
|
|
27
|
-
```
|
|
32
|
+
## Use
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
| Surface | Type | Purpose |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `/auto-compact` | command | Set the compaction threshold. |
|
|
30
37
|
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
- `Threshold`: set compaction percentage.
|
|
40
|
+
## Config
|
|
35
41
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
52
|
+
## Remove
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
65
|
-
npm run
|
|
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`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
+
"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-
|
|
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
|
}
|