@henryqw/pi-auto-compact 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henry Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # `@henryqw/pi-auto-compact`
2
+
3
+ Pi extension that compacts context before it reaches 50% of current model context, then resumes current task.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@henryqw/pi-auto-compact
9
+ ```
10
+
11
+ Restart Pi after installation. Remove with:
12
+
13
+ ```bash
14
+ pi remove npm:@henryqw/pi-auto-compact
15
+ ```
16
+
17
+ ## Behavior
18
+
19
+ - Checks `turn_start`, tool-call `turn_end`, `context`, and resumed/forked `session_start`.
20
+ - Uses Pi's default `ctx.compact()` summary and session persistence.
21
+ - Keeps newest 15% as temporary emergency context while compaction runs.
22
+ - Sends a follow-up message after compaction so task execution continues.
23
+ - Uses fixed 50% threshold; no configuration needed.
24
+
25
+ `ctx.compact()` aborts current low-level run. Extension starts new run after compaction with the current task resume message.
26
+
27
+ ## Development
28
+
29
+ ```bash
30
+ npm test
31
+ npm run pack:check
32
+ npm run test:live
33
+ ```
34
+
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`.
@@ -0,0 +1,146 @@
1
+ import { estimateTokens } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ type AgentMessage = Parameters<typeof estimateTokens>[0];
8
+
9
+ /**
10
+ * Proactive compaction runs at three points:
11
+ * - turn_start: catch sessions already over threshold before next request.
12
+ * - turn_end: catch growth caused by tool results before next LLM turn.
13
+ * - context: last-resort guard with a temporary keep-recent context.
14
+ *
15
+ * Pi's ctx.compact() aborts active low-level run internally. Its completion
16
+ * callback sends follow-up user message, which resumes task after summary.
17
+ */
18
+ const COMPACT_THRESHOLD_PERCENT = 50;
19
+
20
+ // Emergency context guard keeps recent messages while default compaction runs.
21
+ const KEEP_RECENT_PERCENT = 15;
22
+ const RESUME_MESSAGE = "Auto-compact ran. Continue the current task.";
23
+
24
+ /** Estimate current request size using same estimator Pi uses. */
25
+ function estimateTotalTokens(messages: AgentMessage[]): number {
26
+ return messages.reduce((total, message) => total + estimateTokens(message), 0);
27
+ }
28
+
29
+ /**
30
+ * Do not cut inside assistant/toolResult history. A user boundary is safe:
31
+ * tool calls and their results belong to preceding turn.
32
+ */
33
+ function snapToUserBoundary(messages: AgentMessage[], index: number): number {
34
+ while (index < messages.length && messages[index].role !== "user") index++;
35
+ return index;
36
+ }
37
+
38
+ /**
39
+ * Return temporary context containing newest messages plus notice.
40
+ * This changes only request context; session history remains intact.
41
+ */
42
+ function keepRecent(messages: AgentMessage[], keepTokens: number): AgentMessage[] | null {
43
+ let tokens = 0;
44
+ let cutIndex = 0;
45
+
46
+ for (let i = messages.length - 1; i >= 0; i--) {
47
+ const messageTokens = estimateTokens(messages[i]);
48
+ if (tokens + messageTokens > keepTokens) {
49
+ cutIndex = snapToUserBoundary(messages, i + 1);
50
+ break;
51
+ }
52
+ tokens += messageTokens;
53
+ }
54
+
55
+ if (cutIndex <= 0) return null;
56
+
57
+ const removed = messages.slice(0, cutIndex);
58
+ return [
59
+ {
60
+ role: "user",
61
+ content: `[Context compacted: ${removed.length} earlier messages (~${Math.round(estimateTotalTokens(removed) / 1000)}K tokens) were summarized. Continue with the current task.]`,
62
+ timestamp: Date.now(),
63
+ },
64
+ ...messages.slice(cutIndex),
65
+ ];
66
+ }
67
+
68
+ /** Final assistant turns need no automatic follow-up; tool turns do. */
69
+ function hasToolCall(message: AgentMessage): boolean {
70
+ return (
71
+ message.role === "assistant" &&
72
+ Array.isArray(message.content) &&
73
+ message.content.some((part) => part.type === "toolCall")
74
+ );
75
+ }
76
+
77
+ export default function (pi: ExtensionAPI) {
78
+ // Prevent turn_start, turn_end, and context from starting duplicate summaries.
79
+ let compactionPending = false;
80
+
81
+ const runCompaction = (ctx: ExtensionContext) => {
82
+ ctx.compact({
83
+ onComplete: () => {
84
+ compactionPending = false;
85
+ // Pi may flush queued input during compaction_end. Wait one macrotask
86
+ // before checking idle, otherwise follow-up can race that flush.
87
+ setImmediate(() => {
88
+ if (ctx.isIdle()) pi.sendUserMessage(RESUME_MESSAGE);
89
+ });
90
+ },
91
+ onError: () => {
92
+ compactionPending = false;
93
+ },
94
+ });
95
+ };
96
+
97
+ const compactIfNeeded = (ctx: ExtensionContext) => {
98
+ if (compactionPending) return;
99
+
100
+ const usage = ctx.getContextUsage();
101
+ if (usage?.percent == null || usage.percent <= COMPACT_THRESHOLD_PERCENT) return;
102
+
103
+ compactionPending = true;
104
+ runCompaction(ctx);
105
+ };
106
+
107
+ // Do not use agent_settled here: long tool loops may cross threshold before
108
+ // the full run settles. These hooks inspect every provider-turn boundary.
109
+ // Pre-turn catches resumed/queued work before provider request starts.
110
+ pi.on("turn_start", (_event, ctx) => compactIfNeeded(ctx));
111
+
112
+ // Only tool-call turns need mid-run compaction. Final answers should not
113
+ // receive an unsolicited continuation message.
114
+ pi.on("turn_end", (event, ctx) => {
115
+ if (hasToolCall(event.message)) compactIfNeeded(ctx);
116
+ });
117
+
118
+ // Runs before every provider request. Temporary truncation protects request
119
+ // size while asynchronous default compaction summarizes persisted history.
120
+ pi.on("context", (event, ctx) => {
121
+ if (compactionPending) return;
122
+
123
+ const contextWindow = ctx.getContextUsage()?.contextWindow ?? ctx.model?.contextWindow ?? 0;
124
+ const estimatedTokens = estimateTotalTokens(event.messages);
125
+ if (contextWindow <= 0 || estimatedTokens <= contextWindow * COMPACT_THRESHOLD_PERCENT / 100) return;
126
+
127
+ const truncated = keepRecent(
128
+ event.messages,
129
+ Math.floor(contextWindow * KEEP_RECENT_PERCENT / 100),
130
+ );
131
+ if (!truncated) return;
132
+
133
+ // Mark pending before deferring. Another context event can fire before
134
+ // setImmediate runs, and must not schedule a second compaction.
135
+ compactionPending = true;
136
+ setImmediate(() => runCompaction(ctx));
137
+ return { messages: truncated };
138
+ });
139
+
140
+ // Resume/fork can load an already-large session before first turn.
141
+ pi.on("session_start", (event, ctx) => {
142
+ if (event.reason === "resume" || event.reason === "fork") {
143
+ compactIfNeeded(ctx);
144
+ }
145
+ });
146
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@henryqw/pi-auto-compact",
3
+ "version": "0.1.0",
4
+ "description": "Proactively compact Pi context at 50% and resume the current task.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "compaction",
9
+ "context"
10
+ ],
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=22.19.0"
14
+ },
15
+ "license": "MIT",
16
+ "files": [
17
+ "extensions",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test test/*.test.ts",
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",
25
+ "pack:check": "npm pack --dry-run"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-coding-agent": "*"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/HenryQW/pi-packages.git",
33
+ "directory": "packages/pi-auto-compact"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/HenryQW/pi-packages/issues"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "pi": {
42
+ "extensions": [
43
+ "./extensions"
44
+ ]
45
+ }
46
+ }