|
#!/usr/bin/env bun |
|
/** |
|
* ContextMonitor.hook.ts — Agent Context Awareness (PostToolUse) |
|
* |
|
* Makes the agent aware of context window limits by injecting warnings |
|
* via additionalContext when usage approaches the compaction threshold. |
|
* |
|
* Architecture: |
|
* 1. statusline-command.sh writes /tmp/pai-ctx-{session_id}.json on every tick |
|
* 2. This hook reads that bridge file on every PostToolUse |
|
* 3. When thresholds are exceeded, injects additionalContext into the agent |
|
* |
|
* Thresholds (scaled to compaction at 83%): |
|
* WARNING @ 70% raw = ~84% of compaction window |
|
* CRITICAL @ 78% raw = ~94% of compaction window |
|
* |
|
* Debounce: Warns every 5 tool calls. Severity escalation bypasses debounce. |
|
* |
|
* TRIGGER: PostToolUse (matcher: .*) |
|
* PERFORMANCE: <2ms. Never blocks — outputs continue immediately. |
|
* |
|
* Inspired by: gsd-build/get-shit-done context-monitor.js |
|
*/ |
|
|
|
import { existsSync, readFileSync, writeFileSync } from 'fs'; |
|
|
|
const WARNING_PCT = 70; |
|
const CRITICAL_PCT = 78; |
|
const DEBOUNCE_CALLS = 5; |
|
|
|
type Severity = 'warning' | 'critical'; |
|
|
|
interface BridgeData { |
|
used_pct: number; |
|
remaining_pct: number; |
|
timestamp: number; |
|
} |
|
|
|
interface WarnState { |
|
callsSinceWarn: number; |
|
lastLevel: Severity | null; |
|
} |
|
|
|
function getSeverity(usedPct: number): Severity | null { |
|
if (usedPct >= CRITICAL_PCT) return 'critical'; |
|
if (usedPct >= WARNING_PCT) return 'warning'; |
|
return null; |
|
} |
|
|
|
function getWarningMessage(severity: Severity, usedPct: number, remainingPct: number): string { |
|
if (severity === 'critical') { |
|
return ( |
|
`CONTEXT MONITOR CRITICAL: Usage at ${usedPct}%. Compaction imminent (~${83 - usedPct}% remaining before autocompact). ` + |
|
`STOP new work. Do not spawn agents. Do not write long outputs. ` + |
|
`Save algorithm state. Complete current tool call and inform the user.` |
|
); |
|
} |
|
return ( |
|
`CONTEXT MONITOR WARNING: Usage at ${usedPct}%. ~${83 - usedPct}% remaining before autocompact. ` + |
|
`Begin wrapping up current task. Avoid spawning new agents or starting complex work. ` + |
|
`Prefer concise outputs.` |
|
); |
|
} |
|
|
|
async function main() { |
|
// Read stdin for hook input |
|
let input: any; |
|
try { |
|
const reader = Bun.stdin.stream().getReader(); |
|
let raw = ''; |
|
const read = (async () => { |
|
while (true) { |
|
const { done, value } = await reader.read(); |
|
if (done) break; |
|
raw += new TextDecoder().decode(value, { stream: true }); |
|
} |
|
})(); |
|
await Promise.race([read, new Promise<void>(r => setTimeout(r, 200))]); |
|
if (!raw.trim()) { |
|
console.log(JSON.stringify({ continue: true })); |
|
return; |
|
} |
|
input = JSON.parse(raw); |
|
} catch { |
|
console.log(JSON.stringify({ continue: true })); |
|
return; |
|
} |
|
|
|
const sessionId = input?.session_id; |
|
if (!sessionId) { |
|
console.log(JSON.stringify({ continue: true })); |
|
return; |
|
} |
|
|
|
// Read bridge file from statusline |
|
const bridgePath = `/tmp/pai-ctx-${sessionId}.json`; |
|
if (!existsSync(bridgePath)) { |
|
console.log(JSON.stringify({ continue: true })); |
|
return; |
|
} |
|
|
|
let bridge: BridgeData; |
|
try { |
|
bridge = JSON.parse(readFileSync(bridgePath, 'utf-8')); |
|
} catch { |
|
console.log(JSON.stringify({ continue: true })); |
|
return; |
|
} |
|
|
|
// Stale check — if bridge is older than 60s, skip |
|
const now = Math.floor(Date.now() / 1000); |
|
if (now - bridge.timestamp > 60) { |
|
console.log(JSON.stringify({ continue: true })); |
|
return; |
|
} |
|
|
|
const usedPct = Math.round(bridge.used_pct); |
|
const severity = getSeverity(usedPct); |
|
|
|
// No threshold exceeded — silent exit |
|
if (!severity) { |
|
console.log(JSON.stringify({ continue: true })); |
|
return; |
|
} |
|
|
|
// Debounce logic |
|
const warnPath = `/tmp/pai-ctx-${sessionId}-warned.json`; |
|
let warnState: WarnState = { callsSinceWarn: 0, lastLevel: null }; |
|
|
|
try { |
|
if (existsSync(warnPath)) { |
|
warnState = JSON.parse(readFileSync(warnPath, 'utf-8')); |
|
} |
|
} catch { |
|
// Corrupted state file — reset |
|
} |
|
|
|
warnState.callsSinceWarn++; |
|
|
|
const isFirstWarn = warnState.lastLevel === null; |
|
const severityEscalated = severity === 'critical' && warnState.lastLevel === 'warning'; |
|
|
|
if (!isFirstWarn && warnState.callsSinceWarn <= DEBOUNCE_CALLS && !severityEscalated) { |
|
// Debounced — update counter and exit silently |
|
writeFileSync(warnPath, JSON.stringify(warnState)); |
|
console.log(JSON.stringify({ continue: true })); |
|
return; |
|
} |
|
|
|
// Fire warning |
|
warnState.callsSinceWarn = 0; |
|
warnState.lastLevel = severity; |
|
writeFileSync(warnPath, JSON.stringify(warnState)); |
|
|
|
const message = getWarningMessage(severity, usedPct, bridge.remaining_pct); |
|
|
|
console.log(JSON.stringify({ |
|
continue: true, |
|
hookSpecificOutput: { |
|
hookEventName: 'PostToolUse', |
|
additionalContext: message, |
|
}, |
|
})); |
|
|
|
process.stderr.write(`[ContextMonitor] ${severity.toUpperCase()}: ${usedPct}% used\n`); |
|
} |
|
|
|
main().catch(() => { |
|
// Ensure we always output continue even on unhandled errors |
|
console.log(JSON.stringify({ continue: true })); |
|
}); |