@pi-unipi/compactor 2.0.3 → 2.0.4

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/src/index.ts CHANGED
@@ -3,9 +3,16 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
- import { MODULES, UNIPI_EVENTS, COMPACTOR_COMMANDS, COMPACTOR_TOOLS, emitEvent } from "@pi-unipi/core";
6
+ import { MODULES, UNIPI_EVENTS, COMPACTOR_COMMANDS, COMPACTOR_TOOLS, COMPACTOR_INSTRUCTION, emitEvent } from "@pi-unipi/core";
7
7
  import { scaffoldConfig, loadConfig } from "./config/manager.js";
8
8
  import { registerCompactionHooks } from "./compaction/hooks.js";
9
+ import {
10
+ createAutoCompactionState,
11
+ decideAutoCompaction,
12
+ markAutoCompactionComplete,
13
+ markAutoCompactionError,
14
+ type AutoCompactionState,
15
+ } from "./compaction/auto-trigger.js";
9
16
  import { SessionDB, getWorktreeSuffix } from "./session/db.js";
10
17
  import { extractEventsFromToolResult } from "./session/extract.js";
11
18
  import { injectResumeSnapshot } from "./session/resume-inject.js";
@@ -26,6 +33,12 @@ function createDebugLogger(getConfig: () => { debug: boolean }) {
26
33
  };
27
34
  }
28
35
 
36
+ const formatTokenCount = (n: number): string => {
37
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
38
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
39
+ return String(n);
40
+ };
41
+
29
42
  /** Measure byte size of a tool_result event's response content. */
30
43
  function measureResponseBytes(event: any): number {
31
44
  try {
@@ -59,6 +72,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
59
72
  let sessionDB: SessionDB | null = null;
60
73
  let executor: PolyglotExecutor | null = null;
61
74
  let config = loadConfig();
75
+ let autoCompactionState: AutoCompactionState = createAutoCompactionState();
62
76
  let cachedBlocks: NormalizedBlock[] = [];
63
77
  let currentSessionId = "default";
64
78
  const counters: RuntimeCounters = {
@@ -148,6 +162,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
148
162
  runtimeStats.sessionStart = Date.now();
149
163
  runtimeStats.cacheHits = 0;
150
164
  runtimeStats.cacheBytesSaved = 0;
165
+ autoCompactionState = createAutoCompactionState();
151
166
 
152
167
  sessionDB?.ensureSession(fullSessionId, projectDir);
153
168
 
@@ -284,6 +299,64 @@ export default function compactorExtension(pi: ExtensionAPI): void {
284
299
  }
285
300
  });
286
301
 
302
+ pi.on("turn_end", async (_event, ctx) => {
303
+ const cwd = (ctx as any).cwd ?? process.cwd();
304
+ config = loadConfig(cwd);
305
+
306
+ const decision = decideAutoCompaction({
307
+ config: config.autoCompaction,
308
+ usage: ctx.getContextUsage?.(),
309
+ state: autoCompactionState,
310
+ nowMs: Date.now(),
311
+ });
312
+ autoCompactionState = decision.state;
313
+
314
+ debug("auto_compaction_decision", {
315
+ enabled: config.autoCompaction.enabled,
316
+ reason: decision.reason,
317
+ shouldTrigger: decision.shouldTrigger,
318
+ percent: decision.usage?.percent,
319
+ tokens: decision.usage?.tokens,
320
+ thresholdPercent: decision.thresholdPercent,
321
+ cooldownRemainingMs: decision.cooldownRemainingMs,
322
+ tokenGrowth: decision.tokenGrowth,
323
+ });
324
+
325
+ if (!decision.shouldTrigger) return;
326
+
327
+ const notify = config.autoCompaction.notify;
328
+ if (notify && decision.usage) {
329
+ ctx.ui.notify(
330
+ `Auto-compacting at ${decision.usage.percent.toFixed(1)}% context (~${formatTokenCount(decision.usage.tokens)} tokens; threshold ${decision.thresholdPercent}%).`,
331
+ "info",
332
+ );
333
+ }
334
+
335
+ try {
336
+ ctx.compact({
337
+ customInstructions: COMPACTOR_INSTRUCTION,
338
+ onComplete: () => {
339
+ autoCompactionState = markAutoCompactionComplete(autoCompactionState);
340
+ if (notify) {
341
+ ctx.ui.notify("Auto-compaction completed.", "info");
342
+ }
343
+ },
344
+ onError: (err: Error) => {
345
+ autoCompactionState = markAutoCompactionError(autoCompactionState, Date.now());
346
+ const benign = err.message === "Compaction cancelled" || err.message === "Already compacted";
347
+ if (notify && !benign) {
348
+ ctx.ui.notify(`Auto-compaction failed: ${err.message}`, "warning");
349
+ }
350
+ },
351
+ });
352
+ } catch (err) {
353
+ autoCompactionState = markAutoCompactionError(autoCompactionState, Date.now());
354
+ if (notify) {
355
+ ctx.ui.notify(`Auto-compaction failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
356
+ }
357
+ }
358
+ });
359
+
287
360
  pi.on("session_before_compact", async (event, _ctx) => {
288
361
  if (sessionDB) {
289
362
  // Use closure currentSessionId — Pi's session_before_compact event
@@ -2,6 +2,8 @@
2
2
  * context_budget tool — estimate remaining context window
3
3
  */
4
4
 
5
+ import type { AutoCompactionConfig } from "../types.js";
6
+
5
7
  export interface ContextBudgetResult {
6
8
  percentFull: number;
7
9
  remainingTokens: number;
@@ -13,6 +15,7 @@ export interface ContextBudgetResult {
13
15
  export function estimateContextBudget(
14
16
  tokensBefore?: number,
15
17
  contextWindowSize?: number,
18
+ autoCompaction?: Pick<AutoCompactionConfig, "enabled" | "thresholdPercent">,
16
19
  ): ContextBudgetResult | null {
17
20
  const windowSize = contextWindowSize ?? 200000; // Default 200K context
18
21
  const used = tokensBefore ?? 0;
@@ -33,6 +36,15 @@ export function estimateContextBudget(
33
36
  advice = "Context has plenty of room. No compaction needed yet.";
34
37
  }
35
38
 
39
+ if (autoCompaction?.enabled) {
40
+ const threshold = autoCompaction.thresholdPercent;
41
+ if (percentFull >= threshold) {
42
+ advice += ` UniPi percentage auto-compaction is enabled at ${threshold}% and usage is above that threshold, subject to cooldown/repeat safeguards.`;
43
+ } else {
44
+ advice += ` UniPi percentage auto-compaction is enabled at ${threshold}%.`;
45
+ }
46
+ }
47
+
36
48
  const message = `Context: ~${percentFull}% full (estimated ${remaining.toLocaleString()} tokens remaining)`;
37
49
 
38
50
  return { percentFull, remainingTokens: remaining, totalTokens: windowSize, message, advice };
@@ -42,8 +54,12 @@ export function estimateContextBudget(
42
54
  * The context_budget tool handler.
43
55
  * Called from the tool registration — receives tokensBefore from Pi context.
44
56
  */
45
- export function contextBudgetTool(tokensBefore?: number): string {
46
- const budget = estimateContextBudget(tokensBefore);
57
+ export function contextBudgetTool(
58
+ tokensBefore?: number,
59
+ contextWindowSize?: number,
60
+ autoCompaction?: Pick<AutoCompactionConfig, "enabled" | "thresholdPercent">,
61
+ ): string {
62
+ const budget = estimateContextBudget(tokensBefore, contextWindowSize, autoCompaction);
47
63
  if (!budget) return "Context budget: Unknown (no token data available from session).";
48
64
 
49
65
  return `${budget.message}\nAdvice: ${budget.advice}`;
@@ -291,17 +291,25 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
291
291
  label: "Context Budget",
292
292
  description: "Estimate remaining context window (% full, tokens left) and get advice on whether to compact.",
293
293
  parameters: Type.Object({}),
294
- async execute(): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<unknown>> {
295
- const blocks = deps.getBlocks();
296
- const estimatedTokens = blocks.reduce((sum, b) => {
297
- const text = b.kind === "tool_call"
298
- ? `${b.name} ${JSON.stringify((b as any).args ?? {})}`
299
- : b.kind === "tool_result"
300
- ? `${b.name} ${(b as any).text ?? ""}`
301
- : (b as any).text ?? "";
302
- return sum + Math.ceil(text.length / 4);
303
- }, 0);
304
- const message = contextBudgetTool(estimatedTokens);
294
+ async execute(_toolCallId: string, _params: any, _signal?: AbortSignal, _onUpdate?: unknown, ctx?: ExtensionContext): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<unknown>> {
295
+ const config = loadConfig(ctx?.cwd ?? process.cwd());
296
+ const liveUsage = ctx?.getContextUsage?.();
297
+ let estimatedTokens: number | undefined = liveUsage?.tokens ?? undefined;
298
+ let contextWindow = liveUsage?.contextWindow;
299
+
300
+ if (estimatedTokens === undefined) {
301
+ const blocks = deps.getBlocks();
302
+ estimatedTokens = blocks.reduce((sum, b) => {
303
+ const text = b.kind === "tool_call"
304
+ ? `${b.name} ${JSON.stringify((b as any).args ?? {})}`
305
+ : b.kind === "tool_result"
306
+ ? `${b.name} ${(b as any).text ?? ""}`
307
+ : (b as any).text ?? "";
308
+ return sum + Math.ceil(text.length / 4);
309
+ }, 0);
310
+ }
311
+
312
+ const message = contextBudgetTool(estimatedTokens, contextWindow, config.autoCompaction);
305
313
  return textResult(message);
306
314
  },
307
315
  } as any));
@@ -18,8 +18,8 @@ import { existsSync, unlinkSync } from "node:fs";
18
18
 
19
19
  // ─── Section types ─────────────────────────────────────────────────────
20
20
 
21
- type Section = "presets" | "strategies" | "pipeline";
22
- const SECTIONS: Section[] = ["presets", "strategies", "pipeline"];
21
+ type Section = "presets" | "strategies" | "auto" | "pipeline";
22
+ const SECTIONS: Section[] = ["presets", "strategies", "auto", "pipeline"];
23
23
 
24
24
  // ─── Strategy item definition ──────────────────────────────────────────
25
25
 
@@ -160,6 +160,10 @@ const PIPELINE_ITEMS: PipelineDef[] = [
160
160
 
161
161
  const PRESETS: CompactorPreset[] = ["precise", "balanced", "thorough", "lean"];
162
162
 
163
+ const THRESHOLD_VALUES = ["60%", "70%", "75%", "80%", "85%", "90%", "95%"];
164
+ const COOLDOWN_VALUES = ["0s", "30s", "60s", "5m", "10m"];
165
+ const REPEAT_GROWTH_VALUES = ["0", "1k", "4k", "8k", "16k", "32k"];
166
+
163
167
  const PRESET_DESCRIPTIONS: Record<string, { summary: string; detail: string }> = {
164
168
  precise: {
165
169
  summary: "Code-heavy, minimal waste — compaction: full, sandbox: safe-only",
@@ -207,6 +211,40 @@ function borderLine(innerWidth: number, edge: "top" | "bottom"): string {
207
211
  return `\x1b[90m${left}${"─".repeat(innerWidth)}${right}\x1b[0m`;
208
212
  }
209
213
 
214
+ function uniqueValues(values: string[]): string[] {
215
+ return [...new Set(values)];
216
+ }
217
+
218
+ function formatPercent(value: number): string {
219
+ return `${Math.round(value)}%`;
220
+ }
221
+
222
+ function parsePercent(value: string): number {
223
+ return Number(value.replace("%", ""));
224
+ }
225
+
226
+ function formatCooldown(ms: number): string {
227
+ if (ms === 0) return "0s";
228
+ if (ms % 60_000 === 0) return `${ms / 60_000}m`;
229
+ return `${Math.round(ms / 1000)}s`;
230
+ }
231
+
232
+ function parseCooldown(value: string): number {
233
+ if (value.endsWith("m")) return Number(value.slice(0, -1)) * 60_000;
234
+ if (value.endsWith("s")) return Number(value.slice(0, -1)) * 1000;
235
+ return Number(value);
236
+ }
237
+
238
+ function formatGrowthTokens(tokens: number): string {
239
+ if (tokens >= 1000 && tokens % 1000 === 0) return `${tokens / 1000}k`;
240
+ return String(tokens);
241
+ }
242
+
243
+ function parseGrowthTokens(value: string): number {
244
+ if (value.endsWith("k")) return Number(value.slice(0, -1)) * 1000;
245
+ return Number(value);
246
+ }
247
+
210
248
  // ─── Main component ────────────────────────────────────────────────────
211
249
 
212
250
  /**
@@ -224,6 +262,7 @@ export class CompactorSettingsOverlay implements Component {
224
262
  // Per-section SettingsList instances
225
263
  private presetList!: SettingsList;
226
264
  private strategyList!: SettingsList;
265
+ private autoList!: SettingsList;
227
266
  private pipelineList!: SettingsList;
228
267
 
229
268
  constructor(opts?: { cwd?: string }) {
@@ -290,6 +329,53 @@ export class CompactorSettingsOverlay implements Component {
290
329
  { enableSearch: true },
291
330
  );
292
331
 
332
+ // ── Auto-compaction trigger list ──────────────────────────────────
333
+ const autoItems: SettingItem[] = [
334
+ {
335
+ id: "auto:enabled",
336
+ label: "Percentage Trigger",
337
+ description: "UniPi-managed auto-compaction based on context percentage",
338
+ currentValue: this.config.autoCompaction.enabled ? "on" : "off",
339
+ values: ["on", "off"],
340
+ },
341
+ {
342
+ id: "auto:thresholdPercent",
343
+ label: "Threshold",
344
+ description: "Trigger when Pi reports context usage at or above this percent",
345
+ currentValue: formatPercent(this.config.autoCompaction.thresholdPercent),
346
+ values: uniqueValues([...THRESHOLD_VALUES, formatPercent(this.config.autoCompaction.thresholdPercent)]),
347
+ },
348
+ {
349
+ id: "auto:cooldownMs",
350
+ label: "Cooldown",
351
+ description: "Minimum delay between UniPi-triggered compaction attempts",
352
+ currentValue: formatCooldown(this.config.autoCompaction.cooldownMs),
353
+ values: uniqueValues([...COOLDOWN_VALUES, formatCooldown(this.config.autoCompaction.cooldownMs)]),
354
+ },
355
+ {
356
+ id: "auto:repeatMinGrowthTokens",
357
+ label: "Repeat Growth",
358
+ description: "If still above threshold after compaction, require this many new tokens",
359
+ currentValue: formatGrowthTokens(this.config.autoCompaction.repeatMinGrowthTokens),
360
+ values: uniqueValues([...REPEAT_GROWTH_VALUES, formatGrowthTokens(this.config.autoCompaction.repeatMinGrowthTokens)]),
361
+ },
362
+ {
363
+ id: "auto:notify",
364
+ label: "Notifications",
365
+ description: "Notify when UniPi auto-compaction triggers or fails",
366
+ currentValue: this.config.autoCompaction.notify ? "on" : "off",
367
+ values: ["on", "off"],
368
+ },
369
+ ];
370
+
371
+ this.autoList = new SettingsList(
372
+ autoItems,
373
+ 8,
374
+ THEME,
375
+ (id, newValue) => this.onAutoChange(id, newValue),
376
+ () => this.onCancel(),
377
+ );
378
+
293
379
  // ── Pipeline list ─────────────────────────────────────────────────
294
380
  const pipelineItems: SettingItem[] = PIPELINE_ITEMS.map((p) => ({
295
381
  id: `pipeline:${p.key}`,
@@ -312,6 +398,7 @@ export class CompactorSettingsOverlay implements Component {
312
398
 
313
399
  private get currentList(): SettingsList {
314
400
  if (this.section === "strategies") return this.strategyList;
401
+ if (this.section === "auto") return this.autoList;
315
402
  if (this.section === "pipeline") return this.pipelineList;
316
403
  return this.presetList;
317
404
  }
@@ -332,8 +419,9 @@ export class CompactorSettingsOverlay implements Component {
332
419
  const presetName = id.replace("preset:", "") as CompactorPreset;
333
420
  if (PRESETS.includes(presetName)) {
334
421
  this.config = applyPreset(presetName);
335
- // Update all strategy/pipeline items to reflect new config
422
+ // Update all strategy/auto/pipeline items to reflect new config
336
423
  this.refreshStrategyValues();
424
+ this.refreshAutoValues();
337
425
  this.refreshPipelineValues();
338
426
  // Update preset indicators
339
427
  for (const name of PRESETS) {
@@ -366,6 +454,39 @@ export class CompactorSettingsOverlay implements Component {
366
454
  }
367
455
  }
368
456
 
457
+ private onAutoChange(id: string, newValue: string): void {
458
+ const key = id.replace("auto:", "");
459
+ switch (key) {
460
+ case "enabled":
461
+ this.config.autoCompaction.enabled = newValue === "on";
462
+ break;
463
+ case "thresholdPercent":
464
+ this.config.autoCompaction.thresholdPercent = parsePercent(newValue);
465
+ break;
466
+ case "cooldownMs":
467
+ this.config.autoCompaction.cooldownMs = parseCooldown(newValue);
468
+ break;
469
+ case "repeatMinGrowthTokens":
470
+ this.config.autoCompaction.repeatMinGrowthTokens = parseGrowthTokens(newValue);
471
+ break;
472
+ case "notify":
473
+ this.config.autoCompaction.notify = newValue === "on";
474
+ break;
475
+ default:
476
+ return;
477
+ }
478
+
479
+ this.autoList.updateValue(id, this.formatAutoValue(key));
480
+
481
+ // Update preset indicators
482
+ for (const name of PRESETS) {
483
+ this.presetList.updateValue(
484
+ `preset:${name}`,
485
+ detectPreset(this.config) === name ? "✓ active" : "",
486
+ );
487
+ }
488
+ }
489
+
369
490
  private onPipelineChange(id: string, newValue: string): void {
370
491
  const key = id.replace("pipeline:", "");
371
492
  const item = PIPELINE_ITEMS.find((p) => p.key === key);
@@ -395,12 +516,30 @@ export class CompactorSettingsOverlay implements Component {
395
516
  return mode;
396
517
  }
397
518
 
519
+ private formatAutoValue(key: string): string {
520
+ const auto = this.config.autoCompaction;
521
+ switch (key) {
522
+ case "enabled": return auto.enabled ? "on" : "off";
523
+ case "thresholdPercent": return formatPercent(auto.thresholdPercent);
524
+ case "cooldownMs": return formatCooldown(auto.cooldownMs);
525
+ case "repeatMinGrowthTokens": return formatGrowthTokens(auto.repeatMinGrowthTokens);
526
+ case "notify": return auto.notify ? "on" : "off";
527
+ default: return "";
528
+ }
529
+ }
530
+
398
531
  private refreshStrategyValues(): void {
399
532
  for (const s of STRATEGIES) {
400
533
  this.strategyList.updateValue(`strategy:${s.key}`, this.formatStrategyValue(s));
401
534
  }
402
535
  }
403
536
 
537
+ private refreshAutoValues(): void {
538
+ for (const key of ["enabled", "thresholdPercent", "cooldownMs", "repeatMinGrowthTokens", "notify"]) {
539
+ this.autoList.updateValue(`auto:${key}`, this.formatAutoValue(key));
540
+ }
541
+ }
542
+
404
543
  private refreshPipelineValues(): void {
405
544
  for (const p of PIPELINE_ITEMS) {
406
545
  this.pipelineList.updateValue(`pipeline:${p.key}`, p.getValue(this.config) ? "on" : "off");
package/src/types.ts CHANGED
@@ -88,6 +88,20 @@ export interface CompactorStrategyConfig {
88
88
  autoDetect?: "git" | null;
89
89
  }
90
90
 
91
+ /** UniPi-managed percentage auto-compaction trigger settings. */
92
+ export interface AutoCompactionConfig {
93
+ /** Enable the extension-managed percentage trigger. Disabled by default for backward compatibility. */
94
+ enabled: boolean;
95
+ /** Trigger when Pi reports context usage at or above this percent (0-100 scale). */
96
+ thresholdPercent: number;
97
+ /** Minimum delay between UniPi-triggered compaction attempts. */
98
+ cooldownMs: number;
99
+ /** When usage stays above threshold after compaction, require this many new tokens before repeating. */
100
+ repeatMinGrowthTokens: number;
101
+ /** Show user notifications for UniPi-triggered compaction attempts/results. */
102
+ notify: boolean;
103
+ }
104
+
91
105
  export interface CompactorConfig {
92
106
  // Compaction strategies
93
107
  sessionGoals: CompactorStrategyConfig & { mode: "full" | "brief" | "off" };
@@ -112,6 +126,9 @@ export interface CompactorConfig {
112
126
  customNoisePatterns: string[];
113
127
  };
114
128
 
129
+ // Auto compaction trigger
130
+ autoCompaction: AutoCompactionConfig;
131
+
115
132
  // Global settings
116
133
  overrideDefaultCompaction: boolean;
117
134
  debug: boolean;