Skip to content

Commit abfa596

Browse files
baudbot-agentBaudbotbenvinegar
authored
extensions: add idle-compact for context compaction during idle periods (#107)
Co-authored-by: Baudbot <hornet@agentmail.to> Co-authored-by: Ben Vinegar <ben@benv.ca>
1 parent ff9c90a commit abfa596

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

β€ŽAGENTS.mdβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pi/
4343
heartbeat.ts periodic health check loop
4444
auto-name.ts session naming
4545
control.ts inter-session communication
46+
idle-compact.ts compact context during idle periods (40% threshold)
4647
...
4748
skills/ source of truth for agent skill templates
4849
control-agent/ orchestration agent

β€ŽCONFIGURATION.mdβ€Ž

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ Set during `setup.sh` / `baudbot install` via env vars:
164164
| `HEARTBEAT_FILE` | Path to heartbeat checklist file | `~/.pi/agent/HEARTBEAT.md` |
165165
| `HEARTBEAT_ENABLED` | Set to `0` or `false` to disable heartbeats | enabled |
166166

167+
### Idle Compaction
168+
169+
| Variable | Description | Default |
170+
|----------|-------------|---------|
171+
| `IDLE_COMPACT_DELAY_MS` | Idle time before checking for compaction (milliseconds, min 60000) | `300000` (5 min) |
172+
| `IDLE_COMPACT_THRESHOLD_PCT` | Context usage % to trigger compaction (10–90) | `25` |
173+
| `IDLE_COMPACT_ENABLED` | Set to `0`, `false`, or `no` to disable idle compaction | enabled |
174+
167175
### Bridge
168176

169177
| Variable | Description | Default |
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/**
2+
* Idle Compaction Extension
3+
*
4+
* Compacts the conversation context when the agent is truly idle β€” not just
5+
* "no recent turns" but "no active work at all". This prevents compacting
6+
* in the middle of a long-running dev agent task where we'd lose critical
7+
* context (which repo, which todo, which Slack thread to reply to).
8+
*
9+
* Compaction triggers when ALL of these are true:
10+
* 1. No turns for IDLE_DELAY_MS (default 5 min)
11+
* 2. No active dev-agent-* sessions (checked via session-control sockets)
12+
* 3. No in-progress todos (checked via todo files)
13+
* 4. Context usage exceeds COMPACT_THRESHOLD_PCT of the context window
14+
*
15+
* Configuration (env vars):
16+
* IDLE_COMPACT_DELAY_MS β€” idle time before checking (default: 300000 = 5 min)
17+
* IDLE_COMPACT_THRESHOLD_PCT β€” context % to trigger (default: 25)
18+
* IDLE_COMPACT_ENABLED β€” set to "0" or "false" to disable
19+
*/
20+
21+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
22+
import { readdirSync, readFileSync, readlinkSync, existsSync } from "node:fs";
23+
import { join } from "node:path";
24+
import { homedir } from "node:os";
25+
import * as net from "node:net";
26+
27+
const DEFAULT_IDLE_DELAY_MS = 5 * 60 * 1000; // 5 minutes
28+
const DEFAULT_THRESHOLD_PCT = 25;
29+
const MIN_IDLE_DELAY_MS = 60 * 1000; // 1 minute minimum
30+
31+
const CONTROL_DIR = join(homedir(), ".pi", "session-control");
32+
const TODO_DIR = process.env.PI_TODO_PATH || join(".pi", "todos");
33+
34+
function getConfig() {
35+
const envDelay = parseInt(process.env.IDLE_COMPACT_DELAY_MS || "", 10);
36+
const idleDelayMs = Math.max(
37+
MIN_IDLE_DELAY_MS,
38+
Number.isFinite(envDelay) ? envDelay : DEFAULT_IDLE_DELAY_MS
39+
);
40+
41+
const envThreshold = parseInt(process.env.IDLE_COMPACT_THRESHOLD_PCT || "", 10);
42+
const thresholdPct = Number.isFinite(envThreshold)
43+
? Math.max(10, Math.min(90, envThreshold))
44+
: DEFAULT_THRESHOLD_PCT;
45+
46+
const envEnabled = process.env.IDLE_COMPACT_ENABLED?.trim().toLowerCase();
47+
const enabled = envEnabled !== "0" && envEnabled !== "false" && envEnabled !== "no";
48+
49+
return { idleDelayMs, thresholdPct, enabled };
50+
}
51+
52+
// ---------------------------------------------------------------------------
53+
// Check for active dev agents by probing session-control sockets
54+
// ---------------------------------------------------------------------------
55+
56+
function isSocketAlive(socketPath: string): Promise<boolean> {
57+
return new Promise((resolve) => {
58+
const socket = net.createConnection(socketPath);
59+
let settled = false;
60+
61+
const cleanup = (alive: boolean) => {
62+
if (settled) return;
63+
settled = true;
64+
clearTimeout(timeout);
65+
socket.removeAllListeners();
66+
if (alive) socket.end();
67+
else socket.destroy();
68+
resolve(alive);
69+
};
70+
71+
const timeout = setTimeout(() => cleanup(false), 300);
72+
73+
socket.once("connect", () => cleanup(true));
74+
socket.once("error", () => cleanup(false));
75+
});
76+
}
77+
78+
async function hasActiveDevAgents(): Promise<boolean> {
79+
try {
80+
const entries = readdirSync(CONTROL_DIR, { withFileTypes: true });
81+
82+
for (const entry of entries) {
83+
if (!entry.name.endsWith(".alias")) continue;
84+
const aliasName = entry.name.slice(0, -".alias".length);
85+
if (!aliasName.startsWith("dev-agent-")) continue;
86+
87+
// Resolve the symlink to find the socket file
88+
try {
89+
const target = readlinkSync(join(CONTROL_DIR, entry.name));
90+
const socketPath = join(CONTROL_DIR, target);
91+
if (await isSocketAlive(socketPath)) {
92+
return true;
93+
}
94+
} catch {
95+
// Broken symlink β€” skip
96+
}
97+
}
98+
99+
return false;
100+
} catch {
101+
// If we can't read the directory, assume no active agents
102+
return false;
103+
}
104+
}
105+
106+
// ---------------------------------------------------------------------------
107+
// Check for in-progress todos
108+
// ---------------------------------------------------------------------------
109+
110+
function hasInProgressTodos(): boolean {
111+
try {
112+
const todoPath = TODO_DIR.startsWith("/") ? TODO_DIR : join(process.cwd(), TODO_DIR);
113+
if (!existsSync(todoPath)) return false;
114+
115+
const files = readdirSync(todoPath).filter((f) => f.endsWith(".md"));
116+
117+
for (const file of files) {
118+
try {
119+
const content = readFileSync(join(todoPath, file), "utf-8");
120+
// Front matter is a JSON block at the start of the file
121+
const jsonEnd = content.indexOf("\n\n");
122+
const jsonStr = jsonEnd > 0 ? content.slice(0, jsonEnd) : content;
123+
const frontMatter = JSON.parse(jsonStr.trim());
124+
if (
125+
frontMatter.status === "in-progress" ||
126+
frontMatter.status === "in_progress" ||
127+
(typeof frontMatter.assigned_to_session === "string" &&
128+
frontMatter.assigned_to_session.trim().length > 0)
129+
) {
130+
return true;
131+
}
132+
} catch {
133+
// Skip files we can't parse
134+
}
135+
}
136+
137+
return false;
138+
} catch {
139+
return false;
140+
}
141+
}
142+
143+
// ---------------------------------------------------------------------------
144+
// Extension
145+
// ---------------------------------------------------------------------------
146+
147+
export default function idleCompactExtension(pi: ExtensionAPI): void {
148+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
149+
let lastCtx: ExtensionContext | null = null;
150+
let compacting = false;
151+
let enabled = true;
152+
let idleDelayMs = DEFAULT_IDLE_DELAY_MS;
153+
let thresholdPct = DEFAULT_THRESHOLD_PCT;
154+
155+
function cancelTimer() {
156+
if (idleTimer) {
157+
clearTimeout(idleTimer);
158+
idleTimer = null;
159+
}
160+
}
161+
162+
function armTimer() {
163+
cancelTimer();
164+
if (!enabled || !lastCtx) return;
165+
166+
idleTimer = setTimeout(() => {
167+
idleTimer = null;
168+
void checkAndCompact();
169+
}, idleDelayMs);
170+
}
171+
172+
async function checkAndCompact() {
173+
if (!lastCtx || compacting) return;
174+
175+
// Check 1: context usage above threshold?
176+
const usage = lastCtx.getContextUsage();
177+
if (!usage || usage.tokens === null || usage.contextWindow === null || usage.contextWindow <= 0)
178+
return;
179+
180+
const pctUsed = (usage.tokens / usage.contextWindow) * 100;
181+
if (pctUsed < thresholdPct) {
182+
return; // Not worth compacting yet
183+
}
184+
185+
// Check 2: any active dev agents?
186+
if (await hasActiveDevAgents()) {
187+
// Dev agent running β€” don't compact, re-arm and check again later
188+
armTimer();
189+
return;
190+
}
191+
192+
// Check 3: any in-progress todos?
193+
if (hasInProgressTodos()) {
194+
// Work in progress β€” don't compact, re-arm and check again later
195+
armTimer();
196+
return;
197+
}
198+
199+
// All clear β€” compact
200+
compacting = true;
201+
lastCtx.compact({
202+
customInstructions:
203+
"Prioritize recency: the most recent conversations, task results, and decisions " +
204+
"are more important than older history. Preserve active Slack thread references " +
205+
"(channel + thread_ts), in-progress work context, recent user requests, and " +
206+
"current operational state. Older completed tasks can be summarized briefly.",
207+
onComplete: () => {
208+
compacting = false;
209+
},
210+
onError: () => {
211+
compacting = false;
212+
// Re-arm to try again later
213+
armTimer();
214+
},
215+
});
216+
}
217+
218+
// ── Events ────────────────────────────────────────────────────────────────
219+
220+
pi.on("session_start", async () => {
221+
const config = getConfig();
222+
enabled = config.enabled;
223+
idleDelayMs = config.idleDelayMs;
224+
thresholdPct = config.thresholdPct;
225+
});
226+
227+
// When a turn starts, cancel any pending idle compaction β€” we're active
228+
pi.on("turn_start", async () => {
229+
cancelTimer();
230+
});
231+
232+
// When a turn ends, start the idle countdown
233+
pi.on("turn_end", async (_event, ctx) => {
234+
lastCtx = ctx;
235+
if (enabled) {
236+
armTimer();
237+
}
238+
});
239+
240+
pi.on("session_shutdown", async () => {
241+
cancelTimer();
242+
});
243+
}

0 commit comments

Comments
Β (0)