pi-context-whisperer 1.0.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/README.md +61 -0
- package/index.ts +304 -0
- package/package.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# pi-context-whisperer 🦜
|
|
2
|
+
|
|
3
|
+
Smart gradual auto-compaction for Pi — warns you and auto-compacts before context limits hit, preserving your flow.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
Context Whisperer monitors your context window usage after every turn and takes action before you hit limits:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Context: 45% → 🦜 ███████░░░ 58k/128k ✓ Healthy
|
|
11
|
+
Context: 72% → 🦜 █████████░░ 92k/128k ⚡ Warning (notifies you)
|
|
12
|
+
Context: 82% → 🦜 ██████████░ 105k/128k ⚠COMPACT NOW (auto-compacts)
|
|
13
|
+
Context: 35% → 🦜 █████░░░░░ 45k/128k ✓ Healthy (freed up!)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- **Context health bar** — live in footer: ████░░░░ with percentage
|
|
19
|
+
- **Warning at customizable threshold** — nudges you when context gets tight
|
|
20
|
+
- **Auto-compaction** — automatically compacts at critical threshold so you don't crash
|
|
21
|
+
- **Prevents wasted calls** — no more "context limit exceeded" mid-task
|
|
22
|
+
- **Compaction counter** — see how many times it's saved you this session
|
|
23
|
+
- **Fully configurable** — set your own warn/auto thresholds
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# npm
|
|
29
|
+
pi install npm:pi-context-whisperer
|
|
30
|
+
|
|
31
|
+
# GitHub
|
|
32
|
+
pi install git:github.com/Jaraxxxx/pi-context-whisperer
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
| Command | Description |
|
|
38
|
+
|---------|-------------|
|
|
39
|
+
| `/whisper-enable` | Turn on auto-compaction |
|
|
40
|
+
| `/whisper-disable` | Turn off (manual `/compact` only) |
|
|
41
|
+
| `/whisper-stats` | Show current stats and thresholds |
|
|
42
|
+
| `/whisper-config 60 80` | Set warn at 60%, auto at 80% |
|
|
43
|
+
| `Ctrl+Shift+C` | Force compact now |
|
|
44
|
+
|
|
45
|
+
## LLM Tools
|
|
46
|
+
|
|
47
|
+
The `context_health` tool lets the agent check context status before making large requests.
|
|
48
|
+
|
|
49
|
+
## Default thresholds
|
|
50
|
+
|
|
51
|
+
| Threshold | Default | Behavior |
|
|
52
|
+
|-----------|---------|----------|
|
|
53
|
+
| Warn | 70% | Notification only |
|
|
54
|
+
| Auto-compact | 80% | Triggers compaction automatically |
|
|
55
|
+
|
|
56
|
+
Change with: `/whisper-config <warnPct> <autoPct>`
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- Pi coding agent
|
|
61
|
+
- Auto-compaction enabled in settings (default: on)
|
package/index.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// ---- State ----
|
|
4
|
+
|
|
5
|
+
interface WhispererState {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
warnThreshold: number; // % of context at which to warn (default 70)
|
|
8
|
+
autoThreshold: number; // % of context at which to compact (default 80)
|
|
9
|
+
compactedCount: number; // how many times compacted this session
|
|
10
|
+
lastWarningAt: number; // last warning % to avoid spam
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const STORAGE_KEY = "pi-context-whisperer-state";
|
|
14
|
+
|
|
15
|
+
let state: WhispererState = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
warnThreshold: 70,
|
|
18
|
+
autoThreshold: 80,
|
|
19
|
+
compactedCount: 0,
|
|
20
|
+
lastWarningAt: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let tuiRef: any = null;
|
|
24
|
+
let inContextLock = false;
|
|
25
|
+
|
|
26
|
+
// ---- Helpers ----
|
|
27
|
+
|
|
28
|
+
function fmtTokens(n: number | null | undefined): string {
|
|
29
|
+
if (n == null) return "?k";
|
|
30
|
+
if (n > 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
31
|
+
if (n > 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
32
|
+
return `${n}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pct(n: number): string {
|
|
36
|
+
return `${n.toFixed(0)}%`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- Extension ----
|
|
40
|
+
|
|
41
|
+
export default function (pi: ExtensionAPI) {
|
|
42
|
+
// ---- Restore state ----
|
|
43
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
44
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
45
|
+
if (entry.type === "custom" && entry.customType === STORAGE_KEY && entry.data) {
|
|
46
|
+
state = { ...state, ...(entry.data as Partial<WhispererState>) };
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Replace/supplement footer with context health indicator
|
|
52
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
53
|
+
tuiRef = tui;
|
|
54
|
+
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
dispose: unsub,
|
|
58
|
+
invalidate() {},
|
|
59
|
+
render(width: number): string[] {
|
|
60
|
+
const A = (s: string) => theme.fg("accent", s);
|
|
61
|
+
const D = (s: string) => theme.fg("dim", s);
|
|
62
|
+
const W = (s: string) => theme.fg("warning", s);
|
|
63
|
+
const E = (s: string) => theme.fg("error", s);
|
|
64
|
+
const S = (s: string) => theme.fg("success", s);
|
|
65
|
+
|
|
66
|
+
const lines: string[] = [];
|
|
67
|
+
|
|
68
|
+
if (!state.enabled) {
|
|
69
|
+
lines.push(D("🦜 Context Whisperer: off (/whisper-enable to turn on)"));
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Get current context
|
|
74
|
+
const cu = (ctx as any).getContextUsage?.();
|
|
75
|
+
let pctVal: number | null = null;
|
|
76
|
+
let tokens: number | null = null;
|
|
77
|
+
let window: number | null = (ctx as any).model?.contextWindow ?? null;
|
|
78
|
+
|
|
79
|
+
if (cu && cu.percent != null) {
|
|
80
|
+
pctVal = cu.percent;
|
|
81
|
+
tokens = cu.tokens ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (pctVal === null) {
|
|
85
|
+
lines.push(D("🦜 Context Whisperer: waiting for context data..."));
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const w = Math.min(20, Math.max(8, Math.floor(width / 5)));
|
|
90
|
+
const filled = Math.min(w, Math.max(0, Math.round((pctVal / 100) * w)));
|
|
91
|
+
const empty = w - filled;
|
|
92
|
+
const barColor =
|
|
93
|
+
pctVal >= state.autoThreshold ? "error" :
|
|
94
|
+
pctVal >= state.warnThreshold ? "warning" : "success";
|
|
95
|
+
const bar = theme.fg(barColor, "â–ˆ".repeat(filled)) + D("â–‘".repeat(empty));
|
|
96
|
+
|
|
97
|
+
const tokenStr = tokens != null ? fmtTokens(tokens) : "?k";
|
|
98
|
+
const windowStr = window != null ? fmtTokens(window) : "?k";
|
|
99
|
+
|
|
100
|
+
const health = pctVal >= state.autoThreshold
|
|
101
|
+
? E("âš COMPACT NOW")
|
|
102
|
+
: pctVal >= state.warnThreshold
|
|
103
|
+
? W("âš¡ Warning")
|
|
104
|
+
: S("✓ Healthy");
|
|
105
|
+
|
|
106
|
+
const compacted = state.compactedCount > 0
|
|
107
|
+
? D(` | ${state.compactedCount} compacted`)
|
|
108
|
+
: "";
|
|
109
|
+
|
|
110
|
+
lines.push(
|
|
111
|
+
`🦜 ${bar} ${tokenStr}/${windowStr} ${health}${compacted} ${D("warn:")}${state.warnThreshold}% ${D("auto:")}${state.autoThreshold}%`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return lines;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---- Detect context pressure ----
|
|
121
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
122
|
+
if (!state.enabled || inContextLock) return;
|
|
123
|
+
|
|
124
|
+
const cu = (ctx as any).getContextUsage?.();
|
|
125
|
+
if (!cu || cu.percent == null) return;
|
|
126
|
+
|
|
127
|
+
const pct = cu.percent;
|
|
128
|
+
const tokens = cu.tokens;
|
|
129
|
+
|
|
130
|
+
// Warning at warnThreshold — only warn once per crossing
|
|
131
|
+
if (pct >= state.warnThreshold && pct < state.autoThreshold && state.lastWarningAt < state.warnThreshold) {
|
|
132
|
+
ctx.ui.notify(
|
|
133
|
+
`🦜 Context at ${pct(pct)} — ${fmtTokens(tokens)}/${fmtTokens(cu.contextWindow)}. Consider /compact soon.`,
|
|
134
|
+
"warning",
|
|
135
|
+
);
|
|
136
|
+
state.lastWarningAt = pct;
|
|
137
|
+
tuiRef?.requestRender();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Auto-compact at autoThreshold
|
|
141
|
+
if (pct >= state.autoThreshold && !inContextLock) {
|
|
142
|
+
inContextLock = true;
|
|
143
|
+
ctx.ui.notify(
|
|
144
|
+
`🦜 Context at ${pct(pct)} — auto-compacting to preserve history...`,
|
|
145
|
+
"info",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
let done = false;
|
|
150
|
+
let errorMsg = "";
|
|
151
|
+
|
|
152
|
+
ctx.compact({
|
|
153
|
+
customInstructions:
|
|
154
|
+
"Summarize the conversation so far, preserving all key decisions, file changes, and the user's current goal. Be concise but complete.",
|
|
155
|
+
onComplete: () => {
|
|
156
|
+
done = true;
|
|
157
|
+
state.compactedCount++;
|
|
158
|
+
state.lastWarningAt = 0;
|
|
159
|
+
persistState(ctx);
|
|
160
|
+
tuiRef?.requestRender();
|
|
161
|
+
},
|
|
162
|
+
onError: (err: Error) => {
|
|
163
|
+
errorMsg = err.message;
|
|
164
|
+
done = true;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Wait for compaction to complete
|
|
169
|
+
let waited = 0;
|
|
170
|
+
while (!done && waited < 30000) {
|
|
171
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
172
|
+
waited += 200;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (errorMsg) {
|
|
176
|
+
ctx.ui.notify(`🦜 Compaction failed: ${errorMsg.slice(0, 100)}`, "error");
|
|
177
|
+
} else if (done) {
|
|
178
|
+
ctx.ui.notify(
|
|
179
|
+
`🦜 Compaction complete (${state.compactedCount} total). Context freed.`,
|
|
180
|
+
"success",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
inContextLock = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---- Commands ----
|
|
190
|
+
|
|
191
|
+
pi.registerCommand({
|
|
192
|
+
name: "whisper-enable",
|
|
193
|
+
description: "Enable the Context Whisperer auto-compaction",
|
|
194
|
+
async handler(_args: string[], ctx: any) {
|
|
195
|
+
state.enabled = true;
|
|
196
|
+
persistState(ctx);
|
|
197
|
+
tuiRef?.requestRender();
|
|
198
|
+
ctx.ui.notify("🦜 Context Whisperer enabled", "success");
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
pi.registerCommand({
|
|
203
|
+
name: "whisper-disable",
|
|
204
|
+
description: "Disable the Context Whisperer (manual compaction only)",
|
|
205
|
+
async handler(_args: string[], ctx: any) {
|
|
206
|
+
state.enabled = false;
|
|
207
|
+
persistState(ctx);
|
|
208
|
+
tuiRef?.requestRender();
|
|
209
|
+
ctx.ui.notify("🦜 Context Whisperer disabled", "info");
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
pi.registerCommand({
|
|
214
|
+
name: "whisper-stats",
|
|
215
|
+
description: "Show Context Whisperer statistics",
|
|
216
|
+
async handler(_args: string[], ctx: any) {
|
|
217
|
+
const cu = (ctx as any).getContextUsage?.();
|
|
218
|
+
const pct = cu?.percent ?? null;
|
|
219
|
+
ctx.ui.notify(
|
|
220
|
+
`🦜 Whisperer: ${state.enabled ? "on" : "off"} | Compactions: ${state.compactedCount} | Context: ${pct != null ? pct(pct) : "?"} | Warn: ${state.warnThreshold}% | Auto: ${state.autoThreshold}%`,
|
|
221
|
+
"info",
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
pi.registerCommand({
|
|
227
|
+
name: "whisper-config",
|
|
228
|
+
description: "Set Context Whisperer thresholds. Usage: /whisper-config <warnPct> <autoPct>",
|
|
229
|
+
async handler(_args: string[], ctx: any) {
|
|
230
|
+
const warnPct = parseInt(_args[0]);
|
|
231
|
+
const autoPct = parseInt(_args[1]);
|
|
232
|
+
|
|
233
|
+
if (isNaN(warnPct) || warnPct < 30 || warnPct > 95) {
|
|
234
|
+
ctx.ui.notify("Warn threshold must be between 30 and 95", "error");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (isNaN(autoPct) || autoPct < 40 || autoPct > 98) {
|
|
238
|
+
ctx.ui.notify("Auto threshold must be between 40 and 98", "error");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (autoPct <= warnPct) {
|
|
242
|
+
ctx.ui.notify("Auto threshold must be higher than warn threshold", "error");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
state.warnThreshold = warnPct;
|
|
247
|
+
state.autoThreshold = autoPct;
|
|
248
|
+
persistState(ctx);
|
|
249
|
+
tuiRef?.requestRender();
|
|
250
|
+
ctx.ui.notify(`🦜 Whisperer: warn at ${warnPct}%, auto-compact at ${autoPct}%`, "success");
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---- LLM Tools ----
|
|
255
|
+
|
|
256
|
+
pi.registerTool({
|
|
257
|
+
name: "context_health",
|
|
258
|
+
description: "Check current context window usage percentage and token counts. Use when concerned about approaching limits.",
|
|
259
|
+
parameters: { type: "object", properties: {} },
|
|
260
|
+
async execute(_toolCallId: any, _args: any, _signal: any, _onUpdate: any, ctx: any) {
|
|
261
|
+
const cu = (ctx as any).getContextUsage?.();
|
|
262
|
+
if (!cu || cu.percent == null) return "Context usage data not available yet.";
|
|
263
|
+
const status =
|
|
264
|
+
cu.percent >= state.autoThreshold ? "CRITICAL — compaction recommended" :
|
|
265
|
+
cu.percent >= state.warnThreshold ? "WARNING — approaching limit" :
|
|
266
|
+
"HEALTHY";
|
|
267
|
+
return `Context: ${fmtTokens(cu.tokens)}/${fmtTokens(cu.contextWindow)} (${pct(cu.percent)}) — ${status}. Whisperer: ${state.enabled ? "on" : "off"} (${state.compactedCount} compactions this session).`;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---- Keyboard shortcut ----
|
|
272
|
+
// Ctrl+Shift+C: force compact now
|
|
273
|
+
pi.registerShortcut("c", { ctrl: true, shift: true }, (_event: any, ctx: any) => {
|
|
274
|
+
if (!ctx.hasUI || !state.enabled || inContextLock) return;
|
|
275
|
+
inContextLock = true;
|
|
276
|
+
ctx.compact({
|
|
277
|
+
customInstructions: "Summarize the conversation so far concisely.",
|
|
278
|
+
onComplete: () => {
|
|
279
|
+
state.compactedCount++;
|
|
280
|
+
state.lastWarningAt = 0;
|
|
281
|
+
persistState(ctx);
|
|
282
|
+
inContextLock = false;
|
|
283
|
+
tuiRef?.requestRender();
|
|
284
|
+
},
|
|
285
|
+
onError: () => {
|
|
286
|
+
inContextLock = false;
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- Persistence ----
|
|
293
|
+
|
|
294
|
+
function persistState(ctx: any) {
|
|
295
|
+
try {
|
|
296
|
+
ctx.sessionManager.appendEntry?.({
|
|
297
|
+
type: "custom",
|
|
298
|
+
customType: STORAGE_KEY,
|
|
299
|
+
data: { ...state },
|
|
300
|
+
});
|
|
301
|
+
} catch {
|
|
302
|
+
// Ignore persistence errors
|
|
303
|
+
}
|
|
304
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-context-whisperer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Smart gradual auto-compaction for Pi — summarizes conversation mid-session before context limits hit, preserving key decisions",
|
|
5
|
+
"keywords": ["pi-package", "pi-extension", "context", "compaction", "auto-summarize", "token-management"],
|
|
6
|
+
"author": { "name": "Jay Rathod", "url": "https://github.com/Jaraxxxx" },
|
|
7
|
+
"repository": { "type": "git", "url": "https://github.com/Jaraxxxx/pi-context-whisperer" },
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"pi": { "extensions": ["./index.ts"] },
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"@earendil-works/pi-ai": "*",
|
|
13
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
14
|
+
"@earendil-works/pi-tui": "*"
|
|
15
|
+
}
|
|
16
|
+
}
|