pi-compaction-control

Pi Coding Agent extension: granular per-model context-window hard cap + configurable compaction summariser model, all from settings.json.

Packages

Package details

extension

Install pi-compaction-control from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-compaction-control
Package
pi-compaction-control
Version
0.4.5
Published
Sep 5, 2026
Downloads
404/mo · 39/wk
Author
aalexren
License
MIT
Types
extension
Size
49.4 KB
Dependencies
0 dependencies · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-compaction-control

npm version npm license npm downloads GitHub Actions GitHub release GitHub code size GitHub top language

A single Pi Coding Agent extension that gives you full control over conversation compaction: a granular per-model hard cap on context windows, and a configurable compaction model. Both read from Pi's own settings.json, so everything lives in one place — no extra config files, no separate package install.

Zero token overhead. The extension never makes LLM calls of its own: contextCap is a pure in-memory mutation of model.contextWindow, and compactionModel only re-routes the summariser that Pi runs anyway. No extra prompts, no extra requests, no hidden API calls.

What you get:

  • Caps oversized context windows so auto-compaction fires earlier — at cap − reserveTokens instead of the model's native (often huge) window.
  • Optionally picks a cheaper/faster model to run the compaction summary.
  • Works out of the box with sensible defaults (256k cap, all models) — nothing to set if that's all you want.

🤔 Why

Bigger context windows are not better. Even 2026 frontier models degrade sharply past ~200K tokens: RULER and MRCR v2 benchmarks show them losing 30–60 percentage points of multi-fact retrieval accuracy between 32K and 500K, on models that advertise a 1M-token window.

Pi's built-in compaction settings (compaction.reserveTokens, compaction.keepRecentTokens) control how much to keep and how much room to leave, but they can't cap the model's context window itself. On long-context models (1M-token Claude, 500K Grok, etc.) compaction only fires at contextWindow - reserveTokens, which is rarely what you want for day-to-day work.

This extension adds the missing piece: a client-side cap on model.contextWindow. It also lets you pick which model runs the compaction summariser, all configurable per-model.

What it does Without extension With extension Config
Reserve tokens for the reply compaction.reserveTokens
Keep recent tokens verbatim compaction.keepRecentTokens
Cap the context window contextCap
Pick the summariser model compactionModel

Note: reserveTokens and keepRecentTokens cannot be overridden by an extension: Pi's prepareCompaction() runs before the session_before_compact event and bakes them into the preparation. They must stay in settings.json.


⬇️ Install

Option A — via your agent (recommended)

Paste this into your Pi agent — it runs the install for you:

install npm:pi-compaction-control

Option B — from npm (manual)

pi install npm:pi-compaction-control

Option C — global (manual)

Copy the package directory into Pi's global extensions directory:

cp -r pi-compaction-control ~/.pi/agent/extensions/

Pi auto-discovers ~/.pi/agent/extensions/*/index.ts. No pi install needed.

Option D — from this repo as a pi package

pi install git:github.com/aalexren/pi-compaction-control

Option E — project-local

cp -r pi-compaction-control .pi/extensions/

Project-local extensions load after project trust is granted.

After installing, restart Pi (or run /reload).


⚙️ Configure

All config lives in ~/.pi/agent/settings.json (global) or <project>/.pi/settings.json (project overrides global, per top-level key).

1. Built-in compaction (Pi core)

{
  "compaction": {
    "enabled": true,
    "reserveTokens": 32768,
    "keepRecentTokens": 30000
  }
}

2. contextCap — granular per-model hard cap

Caps every matching model's effective contextWindow so auto-compaction fires at cap - reserveTokens instead of the model's native window.

No implicit defaults — if cap, matchPatterns, and models are all unset, the extension does nothing. You must explicitly configure at least one to cap anything. Only notify defaults to true (cosmetic).

{
  "contextCap": {
    "cap": 256000,                       // optional: target contextWindow for pattern-matched models (no default)
    "matchPatterns": ["*"],              // optional: id-substring matchers (case-insensitive); ["*"] = all (no default)
    "models": {                          // optional: per-model-id granular overrides (wins over patterns)
      "gpt-6-astra": 200000,
      "grok-4-6": 180000
    },
    "notify": true                       // notify on each cap applied (default: true)
  }
}
Field Default Description
cap (unset) Target contextWindow for pattern-matched models. Unset = no pattern-based cap
matchPatterns [] id-substring matchers; "*" matches all. Empty = no pattern matching
models {} Per-model-id granular caps. Always wins over pattern matching
notify true Show a notification when a model is capped

How matching works (per model):

  1. If models[model.id] is set → cap to that value (always wins).
  2. Else if cap is set AND model.id matches any matchPatterns → cap to cap.
  3. Else → leave unchanged.

Models already at or below their target are skipped (idempotent). If the configured cap exceeds the model's native window, the effective cap is clamped down to native — see Cap clamped to native window below.

Examples

Cap everything at 256k:

{ "contextCap": { "cap": 256000, "matchPatterns": ["*"] } }

Only cap Anthropic models, leave others alone:

{
  "contextCap": {
    "cap": 200000,
    "matchPatterns": ["anthropic", "claude"]
  }
}

Cap one specific model, leave everything else:

{
  "contextCap": {
    "models": { "gpt-6-astra": 200000 }
  }
}

3. compactionModel — which model runs the summariser

Follows Pi's { model, thinkingLevel } convention.

{
  "compactionModel": {
    "model": "current",                  // "current"/"default"/unset → active conversation model
    "thinkingLevel": "low"               // optional: minimal|low|medium|high|xhigh|max
  }
}
Value Behavior
"current", "default", "active", "", or unset Pi's default — compact with the active conversation model. Extension only logs visibility.
"provider/modelId" Resolve that model and run Pi's native compact() with it. Pi's own prompts, cut-point logic, file-operation tracking, and iterative summary updates are all preserved.

When a specific model is configured, the extension:

  1. Resolves it via modelRegistry.find(provider, modelId).
  2. Pulls auth via modelRegistry.getApiKeyAndHeaders(model).
  3. Calls Pi's exported compact() with the preparation, model, auth, and optional thinkingLevel.
  4. Returns the CompactionResult from session_before_compact.

On any failure (model not found, auth error, compact() throws), control falls back to Pi's default (active model) — compaction never breaks.

Example — use a cheaper/faster model for summaries:

{
  "compactionModel": {
    "model": "google/gemini-2.5-flash",
    "thinkingLevel": "low"
  }
}

📦 Built-in context settings (out of the box)

Pi already ships context/compaction controls in settings.json — you may not need this extension at all if these are enough. They work with zero install:

{
  "compaction": {
    "enabled": true,            // default: true — enable auto-compaction
    "reserveTokens": 32768,    // default: 16384 — tokens reserved for the LLM response
    "keepRecentTokens": 30000  // default: 20000 — recent tokens kept (not summarized)
  }
}
Built-in key Default What it does
compaction.enabled true Enable auto-compaction (disable with false; /compact still works manually)
compaction.reserveTokens 16384 Tokens to reserve for the LLM response — auto-compaction fires when contextTokens > contextWindow - reserveTokens
compaction.keepRecentTokens 20000 Recent tokens to keep verbatim (not summarized)

Auto-compaction trigger (Pi core):

contextTokens > contextWindow - reserveTokens

What the built-in keys cannot do (and why this extension exists):

  • ❌ Cap the model's contextWindow itself — Pi uses the model's native window (e.g. 1,000,000 for long-context Claude), so compaction only fires near that native limit.
  • ❌ Choose a different model for the compaction summariser — Pi always uses the active conversation model.

This extension adds exactly those two missing pieces. The built-in compaction.* keys continue to work alongside it (and are required — the extension can't override reserveTokens/keepRecentTokens).


🧮 How compaction now behaves

Four numbers decide when compaction fires, how much it keeps, and how big the summary can be:

trigger        =  contextWindow − reserveTokens     ← when compaction fires
summaryBudget  =  min(0.8 × reserveTokens, maxOutput)  ← how big the summary can be
keepRecent     =  keepRecentTokens                   ← verbatim context kept

after compaction:  context ≈ summary + keepRecent   (summary REPLACES old context)

0.8 is pi's factor (it reserves 20% of reserveTokens for the compaction prompt overhead). maxOutput is the summariser model's max output (provider-side, e.g. 8,192 for a small-output model, 192,000 for a big-output one).

With the defaults (cap = 256000, reserve = 32768, keepRecent = 30000, small-output summariser maxOutput = 8192):

trigger       = 256000 − 32768   = 223232
summaryBudget = min(26214, 8192) = 8192   ← output is the bottleneck, not reserve
keepRecent    = 30000
after compact ≈ 8192 + 30000   = 38192   ← below 223232 ✓ (185K of headroom)

The no-loop constraint

Compaction must leave context below the trigger, or it re-fires immediately in a loop:

summary + keepRecent  <  trigger
summary + keepRecent  <  cap − reserve

Substituting the worst case (full summary budget):

keepRecent  <  cap − reserve − summaryBudget
keepRecent  <  cap − reserve − min(0.8 × reserve, maxOutput)

This is a strict inequality — there is no fixed "safe margin". The headroom is whatever is left over; you just need the sum strictly below the trigger.

Two regimes

Which term bottlenecks the summary depends on the model's maxOutput:

Regime When summaryBudget keepRecent must be <
Small-output model maxOutput ≤ 0.8 × reserve maxOutput cap − reserve − maxOutput
Big-output model maxOutput > 0.8 × reserve 0.8 × reserve cap − 1.8 × reserve

Small-output model (e.g. maxOutput = 8192): the output cap bottlenecks the summary, so reserve only affects the trigger — you can raise it freely without growing the summary. Lots of room for keepRecent.

Big-output model (e.g. 192K-output model): reserve bottlenecks the summary, so raising reserve is doubly expensive — it shrinks the trigger and grows the summary. keepRecent gets squeezed from both sides.

Big-output model, cap = 200000, reserve = 100000

trigger       = 200000 − 100000  = 100000
summaryBudget = min(80000, 192000) = 80000   ← reserve is the bottleneck
keepRecent    must be <  100000 − 80000 = 20000

You can keep at most 20K verbatim — the summary ate the rest. Raise reserve to 110000 and it gets worse:

trigger       = 90000
summaryBudget = min(88000, 192000) = 88000
keepRecent    <  90000 − 88000 = 2000   ← almost nothing kept verbatim

The hard ceiling on reserve

With a big-output model, reserve has a hard ceiling — past it, the summary alone exceeds the trigger and no keepRecent works (not even 0):

1.8 × reserve  <  cap          (big-output regime, keepRecent = 0)
reserve        <  cap ÷ 1.8

For cap = 200000: reserve < 111111. At reserve = 112000, summaryBudget = 89600 but trigger = 88000 — the summary alone re-triggers compaction in an infinite loop.

What to do when the summary is too big

If you're hitting the ceiling (summary approaches the trigger), you have three options — pick based on what you can spare:

Option Effect Tradeoff
Lower reserve Smaller summary, bigger trigger, more keepRecent room Less detail preserved in the summary
Raise cap (up to native window) More total room for everything Compaction fires later; longer context before summarising
Accept tiny keepRecent Keeps the big summary Almost nothing verbatim; the model relies on the summary alone

You cannot have all three of big summaries, big keepRecent, and a low cap. The extension can't relax this — it's arithmetic.

Choosing values

Goal Tune Effect
Fire sooner (compact more often) Lower cap or raise reserve Less working context between compactions
Keep more recent context Raise keepRecent (lower reserve on big-output models) More verbatim turns preserved
Bigger summaries Raise reserve (only helps if maxOutput > 0.8 × reserve) Up to 0.8 × reserve; squeezes keepRecent
Later compaction (more working room) Raise cap More context before summarising

Sanity check before shipping — verify the no-loop constraint holds:

summaryBudget = min(0.8 × reserve, maxOutput)
keepRecent    < cap − reserve − summaryBudget   (must be > 0, ideally with headroom)

Cap clamped to native window

If a configured cap exceeds the compaction model's native context window, the effective cap is silently clamped down to that native window (the model cannot use room it does not have). The extension warns you when this happens so you know the effective cap is the native window, not your configured value:

compaction-control: provider/model compaction-model configured cap 256,000 > native 200,000 — effective cap clamped down to 200,000

This is informational — the clamp is the correct behavior (the model was already running at native). The warning just makes the effective cap visible so you can lower the configured cap to match, or accept that the model runs at native.

The warning fires only for the compaction model (the model that will actually summarise your context), not for every model in the registry. It fires on session start, on model switch, and when you set a compaction model via /compaction-model — once per model per session (no spam on /reload).


🔧 What it does (mechanism)

Event Action
session_start Read config, cap all matching models in the registry
resources_discover Re-cap (covers /reload and late model loads)
model_select Cap the newly selected model + re-sweep the registry
session_before_compact If a specific compactionModel.model is set, run pi's native compact() with it; otherwise notify and defer to pi's default

The cap works by mutating model.contextWindow in Pi's in-memory model registry — the same mechanism pi-context-cap uses. Everything else in Pi's compaction machinery (summariser model unless overridden, prompt, recovery flow, /compact, session_before_compact hooks) is unchanged.


✅ Verify it works

Run Pi in non-interactive print mode — if the extension loads cleanly, it exits 0:

pi --no-tools --print "reply with exactly: OK"
# → OK

On startup (with notify: true) you'll see notifications like:

compaction-control: active openai/gpt-6-astra 1,050,000 -> 256,000
compaction-control: capped 1 model(s)
compaction-control: OK on pi 0.85.0 — cap 200,000, summariser current@high (all capability probes passed)

The status line shows your effective cap and compaction summariser alongside the capability check. Run /compaction-control-doctor any time for a full breakdown (pi version, effective config, each probe ✓/✗).

Check the effective window at any time:

pi --list-models | grep -i context

🌐 Project-local overrides

Drop a <project>/.pi/settings.json with only the keys you want to override:

{
  "contextCap": { "cap": 180000 }
}

Project config merges per top-level key over global — so you can tighten the cap for a specific repo without touching the global file.


📋 Requirements

  • Pi Coding Agent >= 0.85.0 (uses the compact() export and modelRegistry.getApiKeyAndHeaders())

License

MIT