@thunstack/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 Ashish Dhiman
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,116 @@
1
+ # Pi Auto Compact
2
+
3
+ Pi Auto Compact adds early, configurable context compaction to Pi and safely resumes unfinished tool-driven work after compaction.
4
+
5
+ It uses Pi's native compaction API and summary format. It does not replace Pi's summarizer, rewrite session history, or make a second model call outside Pi's compaction flow.
6
+
7
+ ## What is missing in native Pi
8
+
9
+ Pi already protects sessions from context overflow. Native automatic compaction runs when estimated context usage exceeds `contextWindow - reserveTokens`, and Pi lets users configure the response reserve and the amount of recent context to keep.
10
+
11
+ That native behavior does not provide an early percentage trigger, a session-only toggle, an interactive settings panel, or an extension-managed continuation after compaction interrupts unfinished tool work. Pi Auto Compact fills those gaps while leaving native compaction available as a final safety net.
12
+
13
+ | Capability | Native Pi | Pi Auto Compact |
14
+ |---|---|---|
15
+ | Automatic trigger | Near the model limit, based on reserved response tokens | At a configurable percentage of context usage |
16
+ | Manual compaction | `/compact` | Unchanged |
17
+ | Summary generation | Pi's native compaction prompt and session format | Uses the same native flow with optional supplemental guidance |
18
+ | Session-only control | No dedicated toggle | `/auto-compact` |
19
+ | Interactive configuration | Settings file | `/auto-compact-config` TUI panel |
20
+ | Resume interrupted tool work | Native retry handles overflow recovery | Queues one configurable follow-up after successful early compaction |
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pi install npm:@thunstack/auto-compact
26
+ ```
27
+
28
+ Try it for one Pi run without installing:
29
+
30
+ ```bash
31
+ pi -e npm:@thunstack/auto-compact
32
+ ```
33
+
34
+ Install directly from GitHub:
35
+
36
+ ```bash
37
+ pi install git:github.com/aashishd/pi-auto-compact
38
+ ```
39
+
40
+ Pi packages execute with your full system permissions. Review third-party extension source before installing it.
41
+
42
+ ## Commands
43
+
44
+ | Command | Purpose |
45
+ |---|---|
46
+ | `/auto-compact` | Toggle auto-compaction for the current session without changing the global startup default |
47
+ | `/auto-compact-config` | Open the settings panel in Pi's interactive terminal UI |
48
+
49
+ The settings command requires TUI mode. The compaction behavior itself also works in non-interactive modes.
50
+
51
+ ## Settings and defaults
52
+
53
+ Global settings are stored in `$PI_CODING_AGENT_DIR/auto-compact.json`, or `~/.pi/agent/auto-compact.json` when that environment variable is unset. The file is created only after a setting is saved. Until then, in-memory defaults are used.
54
+
55
+ | Setting | Default | Scope | Accepted values | Purpose |
56
+ |---|---:|---|---|---|
57
+ | `enabledAtSessionStart` | `true` | Global startup default | `true` or `false` | Sets whether new and forked sessions start with the extension active |
58
+ | Active in current session | Startup default | Current session | `true` or `false` | Controlled by `/auto-compact`; restored on reload or resume and never written to global config |
59
+ | `thresholdPercent` | `60` | Global | Integer `1` through `99` | Compacts only when context usage is strictly greater than this percentage |
60
+ | `autoResume` | `true` | Global | `true` or `false` | Resumes only when compaction interrupted a non-terminating tool-driven turn |
61
+ | `resumptionInstruction` | `Continue the unfinished work from the compaction summary. Preserve prior decisions, avoid repeating completed work, and proceed with the next pending step.` | Global | Any string | Follow-up user message queued once after successful interrupted compaction; blank disables the follow-up |
62
+ | `waitForTurnEnd` | `true` | Fixed safety setting | `true` only | Defers compaction to Pi's `turn_end` boundary; `false` is rejected because Pi does not expose a safe reliable live boundary |
63
+ | `additionalCompactionInstruction` | `Preserve unfinished work and exact details required to resume safely.` | Global | Any string | Appends guidance to Pi's native compaction prompt; blank uses only the native prompt |
64
+
65
+ Equivalent config file:
66
+
67
+ ```json
68
+ {
69
+ "version": 1,
70
+ "enabledAtSessionStart": true,
71
+ "thresholdPercent": 60,
72
+ "autoResume": true,
73
+ "resumptionInstruction": "Continue the unfinished work from the compaction summary. Preserve prior decisions, avoid repeating completed work, and proceed with the next pending step.",
74
+ "waitForTurnEnd": true,
75
+ "additionalCompactionInstruction": "Preserve unfinished work and exact details required to resume safely."
76
+ }
77
+ ```
78
+
79
+ Use `/auto-compact-config` instead of editing the file when TUI mode is available. Saves are written atomically with owner-only permissions.
80
+
81
+ ## How it behaves
82
+
83
+ 1. After each completed Pi turn, the extension reads the active model's context usage.
84
+ 2. When usage crosses above the configured threshold, it requests Pi's native compaction flow.
85
+ 3. The detector disarms until usage returns to or below the threshold, preventing repeated compactions at the same usage level.
86
+ 4. If that compaction interrupted a non-terminating tool-driven turn, the extension queues the resumption instruction exactly once.
87
+ 5. Session replacement or reload invalidates stale completion callbacks, so old compactions cannot resume a new runtime.
88
+
89
+ A tool result that explicitly requests termination is respected, including mixed tool batches. Final assistant responses are never auto-resumed.
90
+
91
+ ### Interaction with native auto-compaction
92
+
93
+ This extension does not disable Pi's built-in threshold or overflow recovery. With the default 60% trigger, it normally compacts before Pi reaches its native reserve boundary. If native compaction runs first, Pi handles that event normally and this extension does not add its auto-resume message.
94
+
95
+ Pi's native compaction settings still control how much recent context is kept and how much response space is reserved.
96
+
97
+ ## Uninstall
98
+
99
+ ```bash
100
+ pi remove npm:@thunstack/auto-compact
101
+ ```
102
+
103
+ Removing the package does not delete `~/.pi/agent/auto-compact.json`. Delete that optional settings file separately if you no longer want it.
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ npm install
109
+ npm run check
110
+ ```
111
+
112
+ `npm run check` type-checks the extension, runs the behavior tests, and previews the npm tarball. Publishing also runs the same checks through `prepublishOnly`.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,786 @@
1
+ import {
2
+ closeSync,
3
+ fsyncSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readFileSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { randomUUID } from "node:crypto";
12
+ import { homedir } from "node:os";
13
+ import { basename, dirname, join } from "node:path";
14
+ import type {
15
+ ExtensionAPI,
16
+ ExtensionCommandContext,
17
+ ExtensionContext,
18
+ ExtensionEvent,
19
+ SessionStartEvent,
20
+ Theme,
21
+ TurnEndEvent,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import {
24
+ Container,
25
+ Input,
26
+ SettingsList,
27
+ Text,
28
+ type Component,
29
+ type Focusable,
30
+ type SettingItem,
31
+ type SettingsListTheme,
32
+ } from "@earendil-works/pi-tui";
33
+
34
+ type ToolExecutionEndEvent = Extract<
35
+ ExtensionEvent,
36
+ { type: "tool_execution_end" }
37
+ >;
38
+
39
+ export const AUTO_COMPACT_CONFIG_VERSION = 1;
40
+ export const AUTO_COMPACT_SESSION_ENTRY = "auto-compact-state";
41
+
42
+ export interface AutoCompactConfig {
43
+ version: 1;
44
+ enabledAtSessionStart: boolean;
45
+ thresholdPercent: number;
46
+ autoResume: boolean;
47
+ resumptionInstruction: string;
48
+ waitForTurnEnd: true;
49
+ additionalCompactionInstruction: string;
50
+ }
51
+
52
+ interface AutoCompactSessionState {
53
+ version: 1;
54
+ isActive: boolean;
55
+ }
56
+
57
+ export const DEFAULT_AUTO_COMPACT_CONFIG: Readonly<AutoCompactConfig> = {
58
+ version: AUTO_COMPACT_CONFIG_VERSION,
59
+ enabledAtSessionStart: true,
60
+ thresholdPercent: 60,
61
+ autoResume: true,
62
+ resumptionInstruction:
63
+ "Continue the unfinished work from the compaction summary. Preserve prior decisions, avoid repeating completed work, and proceed with the next pending step.",
64
+ waitForTurnEnd: true,
65
+ additionalCompactionInstruction:
66
+ "Preserve unfinished work and exact details required to resume safely.",
67
+ };
68
+
69
+ function defaultConfig(): AutoCompactConfig {
70
+ return { ...DEFAULT_AUTO_COMPACT_CONFIG };
71
+ }
72
+
73
+ function isRecord(value: unknown): value is Record<string, unknown> {
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ }
76
+
77
+ export function normalizeAutoCompactConfig(value: unknown): AutoCompactConfig {
78
+ if (!isRecord(value)) return defaultConfig();
79
+ if (
80
+ Object.prototype.hasOwnProperty.call(value, "version") &&
81
+ value.version !== AUTO_COMPACT_CONFIG_VERSION
82
+ ) {
83
+ return defaultConfig();
84
+ }
85
+ return {
86
+ version: AUTO_COMPACT_CONFIG_VERSION,
87
+ enabledAtSessionStart:
88
+ typeof value.enabledAtSessionStart === "boolean"
89
+ ? value.enabledAtSessionStart
90
+ : DEFAULT_AUTO_COMPACT_CONFIG.enabledAtSessionStart,
91
+ thresholdPercent:
92
+ Number.isInteger(value.thresholdPercent) &&
93
+ typeof value.thresholdPercent === "number" &&
94
+ value.thresholdPercent >= 1 &&
95
+ value.thresholdPercent <= 99
96
+ ? value.thresholdPercent
97
+ : DEFAULT_AUTO_COMPACT_CONFIG.thresholdPercent,
98
+ autoResume:
99
+ typeof value.autoResume === "boolean"
100
+ ? value.autoResume
101
+ : DEFAULT_AUTO_COMPACT_CONFIG.autoResume,
102
+ resumptionInstruction:
103
+ typeof value.resumptionInstruction === "string"
104
+ ? value.resumptionInstruction
105
+ : DEFAULT_AUTO_COMPACT_CONFIG.resumptionInstruction,
106
+ waitForTurnEnd: true,
107
+ additionalCompactionInstruction:
108
+ typeof value.additionalCompactionInstruction === "string"
109
+ ? value.additionalCompactionInstruction
110
+ : DEFAULT_AUTO_COMPACT_CONFIG.additionalCompactionInstruction,
111
+ };
112
+ }
113
+
114
+ export function getAutoCompactConfigPath(): string {
115
+ const agentDirectory =
116
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
117
+ return join(agentDirectory, "auto-compact.json");
118
+ }
119
+
120
+ export function readAutoCompactConfig(path = getAutoCompactConfigPath()): AutoCompactConfig {
121
+ try {
122
+ return normalizeAutoCompactConfig(JSON.parse(readFileSync(path, "utf8")));
123
+ } catch {
124
+ return defaultConfig();
125
+ }
126
+ }
127
+
128
+ export type RenameConfigFile = (temporaryPath: string, targetPath: string) => void;
129
+
130
+ export function writeAutoCompactConfig(
131
+ path: string,
132
+ config: AutoCompactConfig,
133
+ renameFile: RenameConfigFile = renameSync,
134
+ ): void {
135
+ const directory = dirname(path);
136
+ mkdirSync(directory, { recursive: true });
137
+ const temporaryPath = join(
138
+ directory,
139
+ `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`,
140
+ );
141
+ let descriptor: number | undefined;
142
+ try {
143
+ descriptor = openSync(temporaryPath, "wx", 0o600);
144
+ writeFileSync(
145
+ descriptor,
146
+ `${JSON.stringify(normalizeAutoCompactConfig(config), null, 2)}\n`,
147
+ "utf8",
148
+ );
149
+ fsyncSync(descriptor);
150
+ closeSync(descriptor);
151
+ descriptor = undefined;
152
+ renameFile(temporaryPath, path);
153
+ } catch (error) {
154
+ if (descriptor !== undefined) {
155
+ try {
156
+ closeSync(descriptor);
157
+ } catch {
158
+ // Preserve the original write error.
159
+ }
160
+ }
161
+ try {
162
+ unlinkSync(temporaryPath);
163
+ } catch {
164
+ // The temporary file may not have been created or may already be renamed.
165
+ }
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ function errorMessage(error: unknown): string {
171
+ return error instanceof Error ? error.message : String(error);
172
+ }
173
+
174
+ function notify(
175
+ ctx: ExtensionContext,
176
+ message: string,
177
+ level: "info" | "warning" | "error",
178
+ ): void {
179
+ if (ctx.hasUI) ctx.ui.notify(message, level);
180
+ }
181
+
182
+ function validPercent(value: unknown): value is number {
183
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
184
+ }
185
+
186
+ function restoreSessionActivation(ctx: ExtensionContext): boolean | undefined {
187
+ let restored: boolean | undefined;
188
+ for (const entry of ctx.sessionManager.getEntries()) {
189
+ if (entry.type !== "custom" || entry.customType !== AUTO_COMPACT_SESSION_ENTRY) {
190
+ continue;
191
+ }
192
+ const state = entry.data as Partial<AutoCompactSessionState> | undefined;
193
+ if (typeof state?.isActive === "boolean") restored = state.isActive;
194
+ }
195
+ return restored;
196
+ }
197
+
198
+ function toolCallIds(event: TurnEndEvent): string[] {
199
+ if (event.message.role !== "assistant") return [];
200
+ const ids: string[] = [];
201
+ for (const content of event.message.content) {
202
+ if (content.type === "toolCall") ids.push(content.id);
203
+ }
204
+ return ids;
205
+ }
206
+
207
+ export function isInterruptedToolTurn(
208
+ event: TurnEndEvent,
209
+ terminatedToolCalls: ReadonlySet<string>,
210
+ ): boolean {
211
+ const ids = toolCallIds(event);
212
+ return ids.length > 0 && !ids.some((id) => terminatedToolCalls.has(id));
213
+ }
214
+
215
+ export type PersistAutoCompactConfig = (
216
+ path: string,
217
+ config: AutoCompactConfig,
218
+ ) => void;
219
+
220
+ export class AutoCompactRuntime {
221
+ private config: AutoCompactConfig;
222
+ private isActive: boolean;
223
+ private detectorArmed = true;
224
+ private inFlightToken: symbol | undefined;
225
+ private generation = 0;
226
+ private readonly terminatedToolCalls = new Set<string>();
227
+
228
+ constructor(
229
+ private readonly pi: ExtensionAPI,
230
+ private readonly configPath: string,
231
+ initialConfig: AutoCompactConfig,
232
+ private readonly persistConfig: PersistAutoCompactConfig = writeAutoCompactConfig,
233
+ ) {
234
+ this.config = normalizeAutoCompactConfig(initialConfig);
235
+ this.isActive = this.config.enabledAtSessionStart;
236
+ }
237
+
238
+ getConfig(): AutoCompactConfig {
239
+ return { ...this.config };
240
+ }
241
+
242
+ getIsActive(): boolean {
243
+ return this.isActive;
244
+ }
245
+
246
+ getDetectorState(): { armed: boolean; inFlight: boolean; generation: number } {
247
+ return {
248
+ armed: this.detectorArmed,
249
+ inFlight: this.inFlightToken !== undefined,
250
+ generation: this.generation,
251
+ };
252
+ }
253
+
254
+ setSessionActive(active: boolean): void {
255
+ this.isActive = active;
256
+ this.pi.appendEntry<AutoCompactSessionState>(AUTO_COMPACT_SESSION_ENTRY, {
257
+ version: AUTO_COMPACT_CONFIG_VERSION,
258
+ isActive: active,
259
+ });
260
+ }
261
+
262
+ updateGlobalConfig(config: AutoCompactConfig, ctx: ExtensionContext): boolean {
263
+ const candidate = normalizeAutoCompactConfig(config);
264
+ try {
265
+ this.persistConfig(this.configPath, candidate);
266
+ } catch (error) {
267
+ notify(
268
+ ctx,
269
+ `Auto-compact config was not saved: ${errorMessage(error)}`,
270
+ "error",
271
+ );
272
+ return false;
273
+ }
274
+ this.config = candidate;
275
+ return true;
276
+ }
277
+
278
+ setWaitForTurnEnd(wait: boolean, ctx: ExtensionContext): boolean {
279
+ if (!wait) {
280
+ notify(
281
+ ctx,
282
+ "Wait for turn end must remain yes. Pi lacks a safe reliable live boundary, so eager compaction is deferred.",
283
+ "warning",
284
+ );
285
+ return false;
286
+ }
287
+ return true;
288
+ }
289
+
290
+ resetDefaults(ctx: ExtensionContext): boolean {
291
+ if (!this.updateGlobalConfig(defaultConfig(), ctx)) return false;
292
+ this.setSessionActive(DEFAULT_AUTO_COMPACT_CONFIG.enabledAtSessionStart);
293
+ return true;
294
+ }
295
+
296
+ onSessionStart(event: SessionStartEvent, ctx: ExtensionContext): void {
297
+ this.generation++;
298
+ this.inFlightToken = undefined;
299
+ this.detectorArmed = true;
300
+ this.terminatedToolCalls.clear();
301
+
302
+ const mustUseStartupDefault = event.reason === "new" || event.reason === "fork";
303
+ const restored = mustUseStartupDefault ? undefined : restoreSessionActivation(ctx);
304
+ this.isActive = restored ?? this.config.enabledAtSessionStart;
305
+ if (mustUseStartupDefault || restored === undefined) {
306
+ this.pi.appendEntry<AutoCompactSessionState>(AUTO_COMPACT_SESSION_ENTRY, {
307
+ version: AUTO_COMPACT_CONFIG_VERSION,
308
+ isActive: this.isActive,
309
+ });
310
+ }
311
+ }
312
+
313
+ onSessionShutdown(): void {
314
+ this.generation++;
315
+ this.inFlightToken = undefined;
316
+ this.terminatedToolCalls.clear();
317
+ }
318
+
319
+ onToolExecutionEnd(event: ToolExecutionEndEvent): void {
320
+ if (isRecord(event.result) && event.result.terminate === true) {
321
+ this.terminatedToolCalls.add(event.toolCallId);
322
+ } else {
323
+ this.terminatedToolCalls.delete(event.toolCallId);
324
+ }
325
+ }
326
+
327
+ onTurnEnd(event: TurnEndEvent, ctx: ExtensionContext): void {
328
+ const interrupted = isInterruptedToolTurn(event, this.terminatedToolCalls);
329
+ this.terminatedToolCalls.clear();
330
+
331
+ const percent = ctx.getContextUsage()?.percent;
332
+ if (!validPercent(percent)) return;
333
+ if (percent <= this.config.thresholdPercent) {
334
+ this.detectorArmed = true;
335
+ return;
336
+ }
337
+ if (!this.isActive || !this.detectorArmed || this.inFlightToken !== undefined) {
338
+ return;
339
+ }
340
+
341
+ this.detectorArmed = false;
342
+ const token = Symbol("auto-compact");
343
+ const callbackGeneration = this.generation;
344
+ this.inFlightToken = token;
345
+ const additionalInstruction = this.config.additionalCompactionInstruction;
346
+ const customInstructions = additionalInstruction.trim()
347
+ ? additionalInstruction
348
+ : undefined;
349
+ notify(
350
+ ctx,
351
+ `Auto-compacting at ${percent.toFixed(1)}% context usage.`,
352
+ "info",
353
+ );
354
+
355
+ const settle = (error?: Error) => {
356
+ if (
357
+ callbackGeneration !== this.generation ||
358
+ this.inFlightToken !== token
359
+ ) {
360
+ return;
361
+ }
362
+ this.inFlightToken = undefined;
363
+ if (error) {
364
+ this.detectorArmed = true;
365
+ notify(ctx, `Auto-compaction failed: ${error.message}`, "error");
366
+ return;
367
+ }
368
+
369
+ notify(ctx, "Auto-compaction completed.", "info");
370
+ if (!interrupted || !this.isActive || !this.config.autoResume) return;
371
+ const instruction = this.config.resumptionInstruction;
372
+ if (!instruction.trim()) return;
373
+ try {
374
+ this.pi.sendUserMessage(instruction, { deliverAs: "followUp" });
375
+ } catch (resumeError) {
376
+ notify(
377
+ ctx,
378
+ `Auto-compact could not resume: ${errorMessage(resumeError)}`,
379
+ "error",
380
+ );
381
+ }
382
+ };
383
+
384
+ try {
385
+ ctx.compact({
386
+ customInstructions,
387
+ onComplete: () => settle(),
388
+ onError: (error) => settle(error),
389
+ });
390
+ } catch (error) {
391
+ settle(error instanceof Error ? error : new Error(String(error)));
392
+ }
393
+ }
394
+ }
395
+
396
+ type AutoCompactSettingId =
397
+ | "enabledAtSessionStart"
398
+ | "isActive"
399
+ | "thresholdPercent"
400
+ | "autoResume"
401
+ | "resumptionInstruction"
402
+ | "waitForTurnEnd"
403
+ | "additionalCompactionInstruction"
404
+ | "resetDefaults"
405
+ | "close";
406
+
407
+ function yesNo(value: boolean): string {
408
+ return value ? "yes" : "no";
409
+ }
410
+
411
+ function settingsListTheme(theme: Theme): SettingsListTheme {
412
+ return {
413
+ label: (text, selected) =>
414
+ selected ? theme.fg("accent", theme.bold(text)) : text,
415
+ value: (text, selected) =>
416
+ selected ? theme.fg("accent", text) : theme.fg("dim", text),
417
+ description: (text) => theme.fg("dim", text),
418
+ cursor: theme.fg("accent", "›"),
419
+ hint: (text) => theme.fg("dim", text),
420
+ };
421
+ }
422
+
423
+ function valueInput(
424
+ title: string,
425
+ currentValue: string,
426
+ theme: Theme,
427
+ done: (selectedValue?: string) => void,
428
+ ): Component & Focusable {
429
+ const input = new Input();
430
+ input.onSubmit = (value) => done(value);
431
+ input.onEscape = () => done(undefined);
432
+ return {
433
+ get focused() {
434
+ return input.focused;
435
+ },
436
+ set focused(value: boolean) {
437
+ input.focused = value;
438
+ },
439
+ render: (width: number) => [
440
+ theme.fg("accent", theme.bold(title)),
441
+ theme.fg("dim", `Current: ${currentValue || "(empty)"}`),
442
+ theme.fg("dim", "type replacement • enter save • esc back"),
443
+ ...input.render(width),
444
+ ],
445
+ invalidate: () => input.invalidate(),
446
+ handleInput: (data: string) => input.handleInput(data),
447
+ };
448
+ }
449
+
450
+ function resetConfirmation(
451
+ theme: Theme,
452
+ done: (selectedValue?: string) => void,
453
+ ): Component {
454
+ const container = new Container();
455
+ container.addChild(
456
+ new Text(theme.fg("warning", theme.bold("Reset auto-compact defaults?")), 1, 1),
457
+ );
458
+ const list = new SettingsList(
459
+ [
460
+ {
461
+ id: "confirm",
462
+ label: "Confirm reset",
463
+ currentValue: "no",
464
+ values: ["no", "yes"],
465
+ description:
466
+ "Resets every machine-global value and activates auto-compact in this session.",
467
+ },
468
+ {
469
+ id: "cancel",
470
+ label: "Cancel",
471
+ currentValue: "back",
472
+ values: ["back"],
473
+ },
474
+ ],
475
+ 4,
476
+ settingsListTheme(theme),
477
+ (id, value) => {
478
+ if (id === "confirm" && value === "yes") done("confirmed");
479
+ if (id === "cancel") done(undefined);
480
+ },
481
+ () => done(undefined),
482
+ );
483
+ container.addChild(list);
484
+ return {
485
+ render: (width: number) => container.render(width),
486
+ invalidate: () => container.invalidate(),
487
+ handleInput: (data: string) => list.handleInput(data),
488
+ };
489
+ }
490
+
491
+ function settingsItems(
492
+ runtime: AutoCompactRuntime,
493
+ theme: Theme,
494
+ ): SettingItem[] {
495
+ const config = runtime.getConfig();
496
+ return [
497
+ {
498
+ id: "enabledAtSessionStart",
499
+ label: "Enabled at session start",
500
+ currentValue: yesNo(config.enabledAtSessionStart),
501
+ values: ["yes", "no"],
502
+ description:
503
+ "Machine-global startup default. Changes affect future sessions only.",
504
+ },
505
+ {
506
+ id: "isActive",
507
+ label: "Active in current session",
508
+ currentValue: yesNo(runtime.getIsActive()),
509
+ values: ["yes", "no"],
510
+ description:
511
+ "New and forked sessions initialize from Enabled at session start; reload/resume of this session restores its stored state.",
512
+ },
513
+ {
514
+ id: "thresholdPercent",
515
+ label: "Threshold",
516
+ currentValue: `${config.thresholdPercent}%`,
517
+ description: "Integer context percentage from 1 through 99.",
518
+ submenu: (_currentValue, done) =>
519
+ valueInput(
520
+ "Threshold percent (1..99)",
521
+ String(runtime.getConfig().thresholdPercent),
522
+ theme,
523
+ done,
524
+ ),
525
+ },
526
+ {
527
+ id: "autoResume",
528
+ label: "Auto-resume",
529
+ currentValue: yesNo(config.autoResume),
530
+ values: ["yes", "no"],
531
+ description: "Resume only when auto-compaction interrupted a tool-driven turn.",
532
+ },
533
+ {
534
+ id: "resumptionInstruction",
535
+ label: "Resumption instruction",
536
+ currentValue: config.resumptionInstruction || "(empty)",
537
+ description: "User message queued once after successful interrupted compaction.",
538
+ submenu: (_currentValue, done) =>
539
+ valueInput(
540
+ "Resumption instruction",
541
+ runtime.getConfig().resumptionInstruction,
542
+ theme,
543
+ done,
544
+ ),
545
+ },
546
+ {
547
+ id: "waitForTurnEnd",
548
+ label: "Wait for turn end",
549
+ currentValue: "yes",
550
+ values: ["yes", "no"],
551
+ description:
552
+ "Safe-only in this version. No is deferred because Pi lacks a safe reliable live boundary.",
553
+ },
554
+ {
555
+ id: "additionalCompactionInstruction",
556
+ label: "Additional compaction instruction",
557
+ currentValue: config.additionalCompactionInstruction || "(native prompt only)",
558
+ description:
559
+ "Pi's native /compact instruction is always used. This text is appended only as additional guidance and never edits or replaces the native instruction. " +
560
+ "Changing it may affect compaction quality. Empty means native prompt only.",
561
+ submenu: (_currentValue, done) =>
562
+ valueInput(
563
+ "Additional compaction instruction",
564
+ runtime.getConfig().additionalCompactionInstruction,
565
+ theme,
566
+ done,
567
+ ),
568
+ },
569
+ {
570
+ id: "resetDefaults",
571
+ label: "Reset defaults",
572
+ currentValue: "open",
573
+ description: "Requires confirmation before resetting global and session values.",
574
+ submenu: (_currentValue, done) => resetConfirmation(theme, done),
575
+ },
576
+ {
577
+ id: "close",
578
+ label: "Close",
579
+ currentValue: "close",
580
+ values: ["close"],
581
+ },
582
+ ];
583
+ }
584
+
585
+ async function openAutoCompactSettings(
586
+ ctx: ExtensionCommandContext,
587
+ runtime: AutoCompactRuntime,
588
+ ): Promise<void> {
589
+ await ctx.ui.custom(
590
+ (tui, theme, _keybindings, done) => {
591
+ const container = new Container();
592
+ container.addChild(
593
+ new Text(theme.fg("accent", theme.bold("Auto-compact Settings")), 1, 1),
594
+ );
595
+ let settingsList: SettingsList;
596
+ const refresh = () => {
597
+ for (const item of settingsItems(runtime, theme)) {
598
+ settingsList.updateValue(item.id, item.currentValue);
599
+ }
600
+ tui.requestRender();
601
+ };
602
+ const saveGlobal = (
603
+ patch: Partial<Omit<AutoCompactConfig, "version" | "waitForTurnEnd">>,
604
+ message: string,
605
+ ) => {
606
+ const saved = runtime.updateGlobalConfig(
607
+ { ...runtime.getConfig(), ...patch },
608
+ ctx,
609
+ );
610
+ if (saved) notify(ctx, message, "info");
611
+ refresh();
612
+ };
613
+
614
+ settingsList = new SettingsList(
615
+ settingsItems(runtime, theme),
616
+ 10,
617
+ settingsListTheme(theme),
618
+ (id, newValue) => {
619
+ const action = id as AutoCompactSettingId;
620
+ switch (action) {
621
+ case "enabledAtSessionStart":
622
+ saveGlobal(
623
+ { enabledAtSessionStart: newValue === "yes" },
624
+ "Auto-compact startup default updated for future sessions.",
625
+ );
626
+ return;
627
+ case "isActive":
628
+ runtime.setSessionActive(newValue === "yes");
629
+ notify(
630
+ ctx,
631
+ `Auto-compact ${newValue === "yes" ? "enabled" : "disabled"} for this session.`,
632
+ "info",
633
+ );
634
+ refresh();
635
+ return;
636
+ case "thresholdPercent": {
637
+ const threshold = Number(newValue);
638
+ if (
639
+ !Number.isInteger(threshold) ||
640
+ threshold < 1 ||
641
+ threshold > 99
642
+ ) {
643
+ notify(
644
+ ctx,
645
+ "Threshold must be an integer from 1 through 99.",
646
+ "warning",
647
+ );
648
+ refresh();
649
+ return;
650
+ }
651
+ saveGlobal(
652
+ { thresholdPercent: threshold },
653
+ `Auto-compact threshold set to ${threshold}%.`,
654
+ );
655
+ return;
656
+ }
657
+ case "autoResume":
658
+ saveGlobal(
659
+ { autoResume: newValue === "yes" },
660
+ `Auto-resume ${newValue === "yes" ? "enabled" : "disabled"}.`,
661
+ );
662
+ return;
663
+ case "resumptionInstruction":
664
+ saveGlobal(
665
+ { resumptionInstruction: newValue },
666
+ "Auto-compact resumption instruction updated.",
667
+ );
668
+ return;
669
+ case "waitForTurnEnd":
670
+ runtime.setWaitForTurnEnd(newValue === "yes", ctx);
671
+ refresh();
672
+ return;
673
+ case "additionalCompactionInstruction":
674
+ saveGlobal(
675
+ { additionalCompactionInstruction: newValue },
676
+ "Additional compaction guidance updated.",
677
+ );
678
+ return;
679
+ case "resetDefaults":
680
+ if (newValue === "confirmed" && runtime.resetDefaults(ctx)) {
681
+ notify(
682
+ ctx,
683
+ "Auto-compact defaults restored and enabled for this session.",
684
+ "info",
685
+ );
686
+ }
687
+ refresh();
688
+ return;
689
+ case "close":
690
+ done(undefined);
691
+ }
692
+ },
693
+ () => done(undefined),
694
+ );
695
+ container.addChild(settingsList);
696
+ container.addChild(
697
+ new Text(
698
+ theme.fg("dim", "j/k scroll • enter edit/save • esc close"),
699
+ 1,
700
+ 0,
701
+ ),
702
+ );
703
+ return {
704
+ render: (width: number) => container.render(width),
705
+ invalidate: () => container.invalidate(),
706
+ handleInput: (data: string) => {
707
+ settingsList.handleInput(data);
708
+ tui.requestRender();
709
+ },
710
+ };
711
+ },
712
+ {
713
+ overlay: true,
714
+ overlayOptions: {
715
+ anchor: "bottom-left",
716
+ width: 92,
717
+ maxHeight: "85%",
718
+ margin: { bottom: 1 },
719
+ },
720
+ },
721
+ );
722
+ }
723
+
724
+ export interface AutoCompactExtensionOptions {
725
+ configPath?: string;
726
+ initialConfig?: AutoCompactConfig;
727
+ persistConfig?: PersistAutoCompactConfig;
728
+ onRuntime?: (runtime: AutoCompactRuntime) => void;
729
+ }
730
+
731
+ export function registerAutoCompactExtension(
732
+ pi: ExtensionAPI,
733
+ options: AutoCompactExtensionOptions = {},
734
+ ): AutoCompactRuntime {
735
+ const configPath = options.configPath ?? getAutoCompactConfigPath();
736
+ const runtime = new AutoCompactRuntime(
737
+ pi,
738
+ configPath,
739
+ options.initialConfig ?? readAutoCompactConfig(configPath),
740
+ options.persistConfig,
741
+ );
742
+ options.onRuntime?.(runtime);
743
+
744
+ pi.registerCommand("auto-compact", {
745
+ description: "Toggle auto-compaction for the current session",
746
+ handler: async (_args, ctx) => {
747
+ const active = !runtime.getIsActive();
748
+ runtime.setSessionActive(active);
749
+ notify(
750
+ ctx,
751
+ `Auto-compact ${active ? "enabled" : "disabled"} for this session.`,
752
+ "info",
753
+ );
754
+ },
755
+ });
756
+
757
+ pi.registerCommand("auto-compact-config", {
758
+ description: "Configure auto-compaction",
759
+ handler: async (_args, ctx) => {
760
+ if (!ctx.hasUI || ctx.mode !== "tui") {
761
+ notify(ctx, "/auto-compact-config requires TUI mode.", "warning");
762
+ return;
763
+ }
764
+ try {
765
+ await openAutoCompactSettings(ctx, runtime);
766
+ } catch (error) {
767
+ notify(
768
+ ctx,
769
+ `Could not open auto-compact settings: ${errorMessage(error)}`,
770
+ "error",
771
+ );
772
+ }
773
+ },
774
+ });
775
+
776
+ pi.on("session_start", (event, ctx) => runtime.onSessionStart(event, ctx));
777
+ pi.on("session_shutdown", () => runtime.onSessionShutdown());
778
+ pi.on("tool_execution_end", (event) => runtime.onToolExecutionEnd(event));
779
+ pi.on("turn_end", (event, ctx) => runtime.onTurnEnd(event, ctx));
780
+
781
+ return runtime;
782
+ }
783
+
784
+ export default function autoCompactExtension(pi: ExtensionAPI): void {
785
+ registerAutoCompactExtension(pi);
786
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@thunstack/auto-compact",
3
+ "version": "0.1.0",
4
+ "description": "Early, configurable context auto-compaction for Pi with safe tool-turn resumption.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi",
10
+ "auto-compact",
11
+ "compaction",
12
+ "context-window",
13
+ "context-management",
14
+ "ai-agent"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Ashish Dhiman",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/aashishd/pi-auto-compact.git"
21
+ },
22
+ "homepage": "https://github.com/aashishd/pi-auto-compact#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/aashishd/pi-auto-compact/issues"
25
+ },
26
+ "files": [
27
+ "extensions",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./extensions/auto-compact/index.ts"
34
+ ]
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-coding-agent": "0.80.7",
42
+ "@earendil-works/pi-tui": "0.80.7",
43
+ "@types/node": "^24.0.0",
44
+ "tsx": "^4.20.0",
45
+ "typescript": "^5.9.0"
46
+ },
47
+ "scripts": {
48
+ "test": "tsx --test tests/*.test.ts",
49
+ "typecheck": "tsc --noEmit",
50
+ "pack:dry-run": "npm pack --dry-run",
51
+ "check": "npm run typecheck && npm test && npm run pack:dry-run",
52
+ "prepublishOnly": "npm run check"
53
+ },
54
+ "engines": {
55
+ "node": ">=22.19.0"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }