mirror of
https://github.com/wassname/pi-dynamic-context-pruning.git
synced 2026-06-27 18:05:36 +08:00
a0d9945830
The root cause of the compression spiral (101 failures, 2 hours): resolveAnchorTimestamp returned Infinity when no message followed the compression range. Infinity corrupted JSON serialization (became null), and null timestamps in JS overlap checks coerced to 0, making every range appear to overlap the ghost block b7. Fixes: - resolveAnchorTimestamp returns endTimestamp + 1 instead of Infinity - Validate all timestamps are finite before creating a block (fail fast) - Skip blocks with non-finite timestamps in overlap checks and compression - Include existing block range in overlap error messages (diagnostic) - Filter out corrupted blocks on session restore - Guard synthetic message timestamp creation against non-finite values - Add regression tests for Infinity anchor and null-timestamp blocks
238 lines
8.8 KiB
TypeScript
238 lines
8.8 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// Dynamic Context Pruning (DCP) — PI extension entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
|
import { loadConfig } from "./config.js"
|
|
import {
|
|
createState,
|
|
resetState,
|
|
createInputFingerprint,
|
|
type DcpState,
|
|
} from "./state.js"
|
|
import {
|
|
SYSTEM_PROMPT,
|
|
MANUAL_MODE_SYSTEM_PROMPT,
|
|
CONTEXT_LIMIT_NUDGE_STRONG,
|
|
CONTEXT_LIMIT_NUDGE_SOFT,
|
|
TURN_NUDGE,
|
|
ITERATION_NUDGE,
|
|
} from "./prompts.js"
|
|
import { applyPruning, injectNudge, getNudgeType } from "./pruner.js"
|
|
import { registerCompressTool } from "./compress-tool.js"
|
|
import { registerCommands } from "./commands.js"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Persist the current DCP runtime state as a custom session entry so it
|
|
* survives session restarts and pi process restarts.
|
|
*/
|
|
function saveState(pi: ExtensionAPI, state: DcpState): void {
|
|
pi.appendEntry("dcp-state", {
|
|
compressionBlocks: state.compressionBlocks,
|
|
nextBlockId: state.nextBlockId,
|
|
prunedToolIds: Array.from(state.prunedToolIds),
|
|
tokensSaved: state.tokensSaved,
|
|
totalPruneCount: state.totalPruneCount,
|
|
manualMode: state.manualMode,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Extension entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
// ── 1. Load config ────────────────────────────────────────────────────────
|
|
const config = loadConfig(process.cwd())
|
|
|
|
if (!config.enabled) return
|
|
|
|
// ── 2. Create state ───────────────────────────────────────────────────────
|
|
const state = createState()
|
|
|
|
// Apply config baseline for manual mode before any session events fire.
|
|
if (config.manualMode.enabled) {
|
|
state.manualMode = true
|
|
}
|
|
|
|
// ── 3. Register compress tool ─────────────────────────────────────────────
|
|
registerCompressTool(pi, state, config)
|
|
|
|
// ── 4. Register /dcp commands ─────────────────────────────────────────────
|
|
registerCommands(pi, state, config)
|
|
|
|
// ── 5. session_start: restore state from session entries ──────────────────
|
|
pi.on("session_start", async (event, ctx) => {
|
|
// Reset to a clean slate first.
|
|
resetState(state)
|
|
|
|
// Re-apply config baseline so manual mode survives a session_start reset.
|
|
if (config.manualMode.enabled) {
|
|
state.manualMode = true
|
|
}
|
|
|
|
// Walk the branch looking for the most-recent persisted dcp-state entry.
|
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
if (entry.type === "custom" && entry.customType === "dcp-state") {
|
|
const data = entry.data as any
|
|
|
|
if (data?.compressionBlocks) {
|
|
// Filter out blocks with corrupted (null/NaN/Infinity) timestamps —
|
|
// these were caused by Infinity anchorTimestamp values that became
|
|
// null after JSON round-trip.
|
|
const validBlocks = data.compressionBlocks.filter(
|
|
(b: any) =>
|
|
Number.isFinite(b.startTimestamp) &&
|
|
Number.isFinite(b.endTimestamp) &&
|
|
Number.isFinite(b.anchorTimestamp),
|
|
)
|
|
state.compressionBlocks = validBlocks
|
|
state.nextBlockId = data.nextBlockId ?? state.compressionBlocks.length
|
|
state.tokensSaved = data.tokensSaved ?? 0
|
|
state.totalPruneCount = data.totalPruneCount ?? 0
|
|
}
|
|
|
|
if (data?.prunedToolIds) {
|
|
state.prunedToolIds = new Set(data.prunedToolIds)
|
|
}
|
|
|
|
// Saved manualMode takes precedence over config baseline so the user's
|
|
// last /dcp manual on|off choice is honoured across restarts.
|
|
if (data?.manualMode !== undefined) {
|
|
state.manualMode = data.manualMode
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show a status indicator in the pi TUI.
|
|
ctx.ui.setStatus("dcp", state.manualMode ? "DCP [manual]" : "DCP")
|
|
})
|
|
|
|
// ── 6. session_shutdown: save state ───────────────────────────────────────
|
|
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
saveState(pi, state)
|
|
})
|
|
|
|
// ── 7. before_agent_start: inject system prompt ───────────────────────────
|
|
pi.on("before_agent_start", async (event, _ctx) => {
|
|
const promptAddition = state.manualMode
|
|
? MANUAL_MODE_SYSTEM_PROMPT
|
|
: SYSTEM_PROMPT
|
|
|
|
return {
|
|
systemPrompt: event.systemPrompt + "\n\n" + promptAddition,
|
|
}
|
|
})
|
|
|
|
// ── 8. tool_call: record input args for dedup / purge fingerprinting ───────
|
|
pi.on("tool_call", async (event, _ctx) => {
|
|
// Only create a record if we haven't seen this toolCallId yet. The
|
|
// tool_result handler may also create one if the tool_call event was
|
|
// somehow missed.
|
|
if (!state.toolCalls.has(event.toolCallId)) {
|
|
state.toolCalls.set(event.toolCallId, {
|
|
toolCallId: event.toolCallId,
|
|
toolName: event.toolName,
|
|
inputArgs: event.input as Record<string, unknown>,
|
|
inputFingerprint: createInputFingerprint(
|
|
event.toolName,
|
|
event.input as Record<string, unknown>,
|
|
),
|
|
isError: false,
|
|
turnIndex: state.currentTurn,
|
|
timestamp: 0, // filled in by the tool_result handler
|
|
tokenEstimate: 0,
|
|
})
|
|
}
|
|
})
|
|
|
|
// ── 9. tool_result: finalise tool record with result info ─────────────────
|
|
pi.on("tool_result", async (event, _ctx) => {
|
|
const record = state.toolCalls.get(event.toolCallId)
|
|
|
|
const outputText = event.content
|
|
.map((c: any) => (c.type === "text" ? c.text : ""))
|
|
.join("")
|
|
const tokenEstimate = Math.round(outputText.length / 4)
|
|
|
|
if (record) {
|
|
// Update the record created in tool_call.
|
|
record.isError = event.isError
|
|
record.timestamp = Date.now()
|
|
record.tokenEstimate = tokenEstimate
|
|
} else {
|
|
// Fallback: create a record even when tool_call event was not observed.
|
|
state.toolCalls.set(event.toolCallId, {
|
|
toolCallId: event.toolCallId,
|
|
toolName: event.toolName,
|
|
inputArgs: {},
|
|
inputFingerprint: createInputFingerprint(event.toolName, {}),
|
|
isError: event.isError,
|
|
turnIndex: state.currentTurn,
|
|
timestamp: Date.now(),
|
|
tokenEstimate,
|
|
})
|
|
}
|
|
})
|
|
|
|
// ── 10. context: apply pruning and inject nudges ──────────────────────────
|
|
pi.on("context", async (event, ctx) => {
|
|
// Apply all pruning transforms (compression blocks, dedup, error purge,
|
|
// tool output replacement, message ID injection).
|
|
const prunedMessages = applyPruning(event.messages, state, config)
|
|
|
|
// In manual mode we still apply pruning strategies (if
|
|
// automaticStrategies is on) but skip autonomous nudge injection.
|
|
const usage = ctx.getContextUsage()
|
|
if (usage && usage.tokens !== null && !state.manualMode) {
|
|
const contextPercent = usage.tokens / usage.contextWindow
|
|
|
|
// Count tool calls since the last user message (used for iteration nudge).
|
|
let toolCallsSinceLastUser = 0
|
|
for (let i = prunedMessages.length - 1; i >= 0; i--) {
|
|
const msg = prunedMessages[i] as any
|
|
if (msg.role === "user") break
|
|
if (msg.role === "toolResult") toolCallsSinceLastUser++
|
|
}
|
|
|
|
const nudgeType = getNudgeType(
|
|
contextPercent,
|
|
state,
|
|
config,
|
|
toolCallsSinceLastUser,
|
|
)
|
|
|
|
if (nudgeType) {
|
|
let nudgeText: string
|
|
|
|
if (nudgeType === "context-strong") {
|
|
nudgeText = CONTEXT_LIMIT_NUDGE_STRONG
|
|
} else if (nudgeType === "context-soft") {
|
|
nudgeText = CONTEXT_LIMIT_NUDGE_SOFT
|
|
} else if (nudgeType === "iteration") {
|
|
nudgeText = ITERATION_NUDGE
|
|
} else {
|
|
// "turn"
|
|
nudgeText = TURN_NUDGE
|
|
}
|
|
|
|
injectNudge(prunedMessages, nudgeText)
|
|
state.nudgeCounter = 0
|
|
} else {
|
|
state.nudgeCounter++
|
|
}
|
|
}
|
|
|
|
return { messages: prunedMessages }
|
|
})
|
|
|
|
// ── 11. agent_end: persist state after each agent run ────────────────────
|
|
pi.on("agent_end", async (_event, _ctx) => {
|
|
saveState(pi, state)
|
|
})
|
|
}
|