mirror of
https://github.com/wassname/pi-dynamic-context-pruning.git
synced 2026-06-27 15:16:10 +08:00
Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/*
|
||||
@@ -0,0 +1,146 @@
|
||||
# Dynamic Context Pruning (DCP) for Pi
|
||||
|
||||
Automatically reduces token usage in Pi coding agent sessions by managing conversation context through compression, deduplication, and smart nudges.
|
||||
|
||||
## Features
|
||||
|
||||
- **Compress tool** — LLM-callable tool that replaces stale conversation ranges with exhaustive technical summaries, preserving full context fidelity at a fraction of the token cost
|
||||
- **Deduplication** — automatically removes duplicate tool call outputs (same tool, same args) keeping only the most recent result
|
||||
- **Error purging** — cleans up failed tool inputs after a configurable number of user turns
|
||||
- **Context nudges** — injects compression reminders into the context at configurable thresholds: soft housekeeping notices, strong emergency warnings, and iteration reminders after long tool-call chains
|
||||
- **Manual mode** — disable autonomous compression nudges; trigger compression only via `/dcp compress` or explicit user request
|
||||
- **Session persistence** — compression blocks and pruning state survive session restarts
|
||||
- **`/dcp` commands** — inspect context usage, view stats, sweep tool outputs, and manage compression blocks interactively
|
||||
|
||||
## Installation
|
||||
|
||||
### Global (applies to all pi sessions)
|
||||
|
||||
```bash
|
||||
pi install npm:@complexthings/pi-dynamic-context-pruning
|
||||
```
|
||||
|
||||
### Install globally from GitHub
|
||||
|
||||
```bash
|
||||
pi install https://github.com/complexthings/pi-dynamic-context-pruning
|
||||
```
|
||||
|
||||
### Try it without installing
|
||||
|
||||
```bash
|
||||
pi -e https://github.com/complexthings/pi-dynamic-context-pruning
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
DCP uses a layered configuration system (later layers override earlier ones):
|
||||
|
||||
1. Built-in defaults
|
||||
2. `~/.config/pi/dcp.jsonc` — global user config (auto-created with defaults on first run)
|
||||
3. `$PI_CONFIG_DIR/dcp.jsonc` — if the env var is set
|
||||
4. `<project>/.pi/dcp.jsonc` — project-local overrides (walk up from cwd)
|
||||
|
||||
### Example: `~/.config/pi/dcp.jsonc`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Disable the extension entirely
|
||||
// "enabled": false,
|
||||
|
||||
// Start every session in manual mode
|
||||
// "manualMode": { "enabled": true, "automaticStrategies": true },
|
||||
|
||||
"compress": {
|
||||
// Above 80 % context: fire a nudge (every nudgeFrequency context events)
|
||||
"maxContextPercent": 0.8,
|
||||
// Below 40 % context: no nudges
|
||||
"minContextPercent": 0.4,
|
||||
// How many context events between nudges
|
||||
"nudgeFrequency": 5,
|
||||
// Nudge after this many tool calls since the last user message
|
||||
"iterationNudgeThreshold": 15,
|
||||
// "strong" = emergency tone, "soft" = housekeeping tone
|
||||
"nudgeForce": "soft",
|
||||
// These tool outputs are never auto-pruned
|
||||
"protectedTools": ["compress", "write", "edit"]
|
||||
},
|
||||
"strategies": {
|
||||
"deduplication": {
|
||||
"enabled": true,
|
||||
// Additional tools to exclude from dedup
|
||||
"protectedTools": []
|
||||
},
|
||||
"purgeErrors": {
|
||||
"enabled": true,
|
||||
// Purge failed tool inputs after N user turns
|
||||
"turns": 4,
|
||||
"protectedTools": []
|
||||
}
|
||||
},
|
||||
// Glob patterns — matching file paths are never pruned
|
||||
"protectedFilePatterns": [],
|
||||
// "off" | "minimal" | "detailed"
|
||||
"pruneNotification": "detailed"
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
All commands are available in the pi TUI via `/dcp <subcommand>`:
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/dcp` or `/dcp help` | Show command reference |
|
||||
| `/dcp context` | Show context window usage and session stats |
|
||||
| `/dcp stats` | Show pruning statistics (tokens saved, blocks, operations) |
|
||||
| `/dcp sweep [N]` | Mark last N tool outputs for pruning (default: all since last user message) |
|
||||
| `/dcp manual` | Show current manual mode status |
|
||||
| `/dcp manual on` | Enable manual mode — autonomous nudges disabled |
|
||||
| `/dcp manual off` | Disable manual mode — autonomous nudges re-enabled |
|
||||
| `/dcp compress` | Trigger LLM compression immediately (sends a followUp message) |
|
||||
| `/dcp decompress` | List all active compression blocks |
|
||||
| `/dcp decompress N` | Restore compression block `bN` (re-expands it in context) |
|
||||
|
||||
## How It Works
|
||||
|
||||
### Compression blocks
|
||||
|
||||
When the LLM calls the `compress` tool it provides one or more `{startId, endId, summary}` ranges. DCP:
|
||||
|
||||
1. Records the range as a `CompressionBlock` with start/end timestamps
|
||||
2. On every `context` event, splices out the raw messages in that range
|
||||
3. Injects a synthetic `[Compressed section: …]` user message containing the summary
|
||||
4. Keeps the block state in the session so it survives restarts
|
||||
|
||||
Message IDs (`m001`, `m042`, etc.) and block IDs (`b1`, `b3`) are injected into every message in the context so the LLM can reference exact boundaries.
|
||||
|
||||
### Nudge types
|
||||
|
||||
| Nudge | Condition |
|
||||
|---|---|
|
||||
| **context-strong** | Above `maxContextPercent`, nudge counter ≥ `nudgeFrequency`, `nudgeForce = "strong"` |
|
||||
| **context-soft** | Same as above with `nudgeForce = "soft"` |
|
||||
| **iteration** | Between min/max percent AND ≥ `iterationNudgeThreshold` tool calls since last user message |
|
||||
| **turn** | Between min/max percent, standard cadence |
|
||||
|
||||
### Deduplication
|
||||
|
||||
Two tool results share the same fingerprint (`toolName::JSON(sorted-args)`) if they were called with identical arguments. All but the last occurrence are replaced with a tombstone message.
|
||||
|
||||
### Error purging
|
||||
|
||||
Tool results that were errors are replaced with a tombstone after `purgeErrors.turns` user turns have passed, keeping the context clean of long-dead failure traces.
|
||||
|
||||
## Status indicator
|
||||
|
||||
A `DCP` badge is shown in the pi status bar. In manual mode it displays `DCP [manual]`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx tsc --noEmit # type-check without emitting
|
||||
```
|
||||
|
||||
The extension is loaded by pi via [jiti](https://github.com/unjs/jiti) so TypeScript is executed directly — no build step required for normal use.
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"
|
||||
import type { DcpState } from "./state.js"
|
||||
import type { DcpConfig } from "./config.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Tools whose outputs are always protected from sweep regardless of config. */
|
||||
const ALWAYS_PROTECTED_TOOLS = ["compress", "write", "edit"] as const
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Help
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HELP_TEXT = `DCP — Dynamic Context Pruning
|
||||
|
||||
Commands:
|
||||
/dcp context — Show context window usage breakdown
|
||||
/dcp stats — Show pruning statistics for this session
|
||||
/dcp sweep [N] — Prune last N tool outputs (default: all since last user msg)
|
||||
/dcp manual — Show manual mode status
|
||||
/dcp manual on — Enable manual mode (disable autonomous compression)
|
||||
/dcp manual off — Disable manual mode (enable autonomous compression)
|
||||
/dcp decompress — List active compression blocks
|
||||
/dcp decompress N — Restore compression block N
|
||||
/dcp compress — Trigger compression (sends compress tool invocation to LLM)`
|
||||
|
||||
function handleHelp(ctx: ExtensionCommandContext): void {
|
||||
ctx.ui.notify(HELP_TEXT, "info")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context usage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleContext(ctx: ExtensionCommandContext, state: DcpState): void {
|
||||
const usage = ctx.getContextUsage()
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
if (usage) {
|
||||
if (usage.tokens !== null) {
|
||||
const pct = ((usage.tokens / usage.contextWindow) * 100).toFixed(1)
|
||||
lines.push(
|
||||
`Context Usage: ${pct}% (${fmt(usage.tokens)} / ${fmt(usage.contextWindow)} tokens)`,
|
||||
)
|
||||
} else {
|
||||
lines.push(`Context Usage: unknown / ${fmt(usage.contextWindow)} tokens`)
|
||||
}
|
||||
} else {
|
||||
lines.push("Context Usage: unavailable")
|
||||
}
|
||||
|
||||
lines.push("")
|
||||
lines.push("Session Stats:")
|
||||
lines.push(` Tool calls tracked: ${fmt(state.toolCalls.size)}`)
|
||||
lines.push(` Pruned tools: ${fmt(state.prunedToolIds.size)}`)
|
||||
lines.push(` Compression blocks: ${state.compressionBlocks.filter((b) => b.active).length}`)
|
||||
lines.push(` Tokens saved (estimated): ${fmt(state.tokensSaved)}`)
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleStats(ctx: ExtensionCommandContext, state: DcpState): void {
|
||||
const activeBlocks = state.compressionBlocks.filter((b) => b.active).length
|
||||
const totalBlocks = state.compressionBlocks.length
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push("DCP Session Statistics:")
|
||||
lines.push(` Tokens saved (estimated): ${fmt(state.tokensSaved)}`)
|
||||
lines.push(` Total pruning operations: ${fmt(state.totalPruneCount)}`)
|
||||
lines.push(` Compression blocks active: ${activeBlocks} / ${totalBlocks} total`)
|
||||
lines.push(` Manual mode: ${state.manualMode ? "on" : "off"}`)
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sweep
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleSweep(
|
||||
ctx: ExtensionCommandContext,
|
||||
state: DcpState,
|
||||
config: DcpConfig,
|
||||
n: number,
|
||||
): Promise<void> {
|
||||
await ctx.waitForIdle()
|
||||
|
||||
const branch = ctx.sessionManager.getBranch()
|
||||
|
||||
// Build the full set of protected tool names.
|
||||
const protectedTools = new Set<string>([
|
||||
...ALWAYS_PROTECTED_TOOLS,
|
||||
...config.strategies.deduplication.protectedTools,
|
||||
])
|
||||
|
||||
// Walk the branch (root → leaf) collecting toolCallIds in encounter order,
|
||||
// and tracking where the last real user message was.
|
||||
const allToolCallIds: string[] = []
|
||||
const toolCallIdsSinceLastUser: string[] = []
|
||||
let lastUserMsgBranchIndex = -1
|
||||
|
||||
// First pass: find the last user message index.
|
||||
for (let i = 0; i < branch.length; i++) {
|
||||
const entry = branch[i]
|
||||
if (entry.type !== "message") continue
|
||||
const msg = (entry as any).message
|
||||
if (msg.role === "user") {
|
||||
lastUserMsgBranchIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: collect tool result IDs in encounter order.
|
||||
for (let i = 0; i < branch.length; i++) {
|
||||
const entry = branch[i]
|
||||
if (entry.type !== "message") continue
|
||||
const msg = (entry as any).message
|
||||
if (msg.role !== "toolResult") continue
|
||||
|
||||
const toolCallId = msg.toolCallId as string
|
||||
allToolCallIds.push(toolCallId)
|
||||
|
||||
if (lastUserMsgBranchIndex >= 0 && i > lastUserMsgBranchIndex) {
|
||||
toolCallIdsSinceLastUser.push(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the candidate set based on the N argument.
|
||||
let candidates: string[]
|
||||
if (n > 0) {
|
||||
// Last N tool results from the full session branch.
|
||||
candidates = allToolCallIds.slice(-n)
|
||||
} else {
|
||||
// All tool results since the last user message (or everything if no user
|
||||
// message exists yet — e.g. in a purely agentic session).
|
||||
candidates =
|
||||
lastUserMsgBranchIndex >= 0 ? toolCallIdsSinceLastUser : allToolCallIds
|
||||
}
|
||||
|
||||
// Filter: skip already-pruned IDs and protected tool names.
|
||||
const toAdd = candidates.filter((toolCallId) => {
|
||||
if (state.prunedToolIds.has(toolCallId)) return false
|
||||
|
||||
// Tool name lookup: prefer the DCP tool-call record if tracked; fall back
|
||||
// to the AgentMessage itself (msg.toolName is present on ToolResultMessage).
|
||||
const record = state.toolCalls.get(toolCallId)
|
||||
const toolName = record?.toolName
|
||||
|
||||
if (toolName !== undefined && protectedTools.has(toolName)) return false
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
for (const toolCallId of toAdd) {
|
||||
state.prunedToolIds.add(toolCallId)
|
||||
}
|
||||
|
||||
const count = toAdd.length
|
||||
ctx.ui.notify(`Swept ${count} tool output${count === 1 ? "" : "s"}`, "info")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleManual(
|
||||
ctx: ExtensionCommandContext,
|
||||
state: DcpState,
|
||||
subArg: string | undefined,
|
||||
): void {
|
||||
if (subArg === "on") {
|
||||
state.manualMode = true
|
||||
ctx.ui.notify(
|
||||
"Manual mode: on\nAutonomous compression is disabled. Use /dcp compress to trigger manually.",
|
||||
"info",
|
||||
)
|
||||
} else if (subArg === "off") {
|
||||
state.manualMode = false
|
||||
ctx.ui.notify("Manual mode: off\nAutonomous compression is enabled.", "info")
|
||||
} else {
|
||||
// Status display (no argument).
|
||||
const status = state.manualMode ? "on" : "off"
|
||||
ctx.ui.notify(
|
||||
`Manual mode: ${status}\nWhen on: compress tool only fires when you explicitly request it.`,
|
||||
"info",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decompress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleDecompress(
|
||||
ctx: ExtensionCommandContext,
|
||||
state: DcpState,
|
||||
nArg: string | undefined,
|
||||
): void {
|
||||
if (nArg === undefined) {
|
||||
// List all active compression blocks.
|
||||
const activeBlocks = state.compressionBlocks.filter((b) => b.active)
|
||||
|
||||
if (activeBlocks.length === 0) {
|
||||
ctx.ui.notify("No active compression blocks.", "info")
|
||||
return
|
||||
}
|
||||
|
||||
const lines: string[] = ["Active compression blocks:"]
|
||||
for (const block of activeBlocks) {
|
||||
lines.push(
|
||||
` b${block.id} — "${block.topic}" (est. ${fmt(block.summaryTokenEstimate)} tokens)`,
|
||||
)
|
||||
}
|
||||
lines.push("")
|
||||
lines.push("Run /dcp decompress N to restore a block.")
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info")
|
||||
} else {
|
||||
// Restore block N.
|
||||
const id = parseInt(nArg, 10)
|
||||
|
||||
if (isNaN(id)) {
|
||||
ctx.ui.notify(
|
||||
`Invalid block ID: "${nArg}". Usage: /dcp decompress N`,
|
||||
"error",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const block = state.compressionBlocks.find((b) => b.id === id)
|
||||
|
||||
if (!block) {
|
||||
ctx.ui.notify(`No compression block found with id ${id}.`, "error")
|
||||
return
|
||||
}
|
||||
|
||||
if (!block.active) {
|
||||
ctx.ui.notify(`Compression block b${id} is already decompressed.`, "info")
|
||||
return
|
||||
}
|
||||
|
||||
block.active = false
|
||||
ctx.ui.notify(`Decompressed block b${id}: "${block.topic}"`, "info")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compress (trigger)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleCompress(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
||||
await ctx.waitForIdle()
|
||||
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "dcp-compress-trigger",
|
||||
content:
|
||||
"Please compress stale conversation sections using the compress tool now.",
|
||||
display: false,
|
||||
},
|
||||
{ triggerTurn: true, deliverAs: "followUp" },
|
||||
)
|
||||
|
||||
ctx.ui.notify("Triggered compression", "info")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function registerCommands(
|
||||
pi: ExtensionAPI,
|
||||
state: DcpState,
|
||||
config: DcpConfig,
|
||||
): void {
|
||||
pi.registerCommand("dcp", {
|
||||
description: "Dynamic Context Pruning — manage context window usage",
|
||||
getArgumentCompletions(prefix: string) {
|
||||
const subcommands = [
|
||||
{ label: "context", description: "Show context window usage breakdown" },
|
||||
{ label: "stats", description: "Show pruning statistics" },
|
||||
{ label: "sweep", description: "Prune tool outputs" },
|
||||
{ label: "manual", description: "Toggle manual mode" },
|
||||
{ label: "decompress", description: "List or restore compression blocks" },
|
||||
{ label: "compress", description: "Trigger LLM compression" },
|
||||
{ label: "help", description: "Show help" },
|
||||
]
|
||||
const matched = subcommands.filter((s) => s.label.startsWith(prefix))
|
||||
return matched.length > 0 ? matched : null
|
||||
},
|
||||
|
||||
async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean)
|
||||
const sub = parts[0] ?? ""
|
||||
|
||||
switch (sub) {
|
||||
case "":
|
||||
case "help":
|
||||
handleHelp(ctx)
|
||||
break
|
||||
|
||||
case "context":
|
||||
handleContext(ctx, state)
|
||||
break
|
||||
|
||||
case "stats":
|
||||
handleStats(ctx, state)
|
||||
break
|
||||
|
||||
case "sweep": {
|
||||
const rawN = parts[1] !== undefined ? parseInt(parts[1], 10) : 0
|
||||
const n = isNaN(rawN) || rawN < 0 ? 0 : rawN
|
||||
await handleSweep(ctx, state, config, n)
|
||||
break
|
||||
}
|
||||
|
||||
case "manual":
|
||||
handleManual(ctx, state, parts[1])
|
||||
break
|
||||
|
||||
case "decompress":
|
||||
handleDecompress(ctx, state, parts[1])
|
||||
break
|
||||
|
||||
case "compress":
|
||||
await handleCompress(pi, ctx)
|
||||
break
|
||||
|
||||
default:
|
||||
ctx.ui.notify(
|
||||
`Unknown DCP command: "${sub}". Run /dcp help for available commands.`,
|
||||
"error",
|
||||
)
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic Context Pruning (DCP) — compress tool registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { Type } from "@sinclair/typebox"
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
||||
import type { CompressionBlock, DcpState } from "./state.js"
|
||||
import type { DcpConfig } from "./config.js"
|
||||
import { COMPRESS_RANGE_DESCRIPTION } from "./prompts.js"
|
||||
import { estimateTokens } from "./pruner.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace `(bN)` placeholders in a summary with the stored content of the
|
||||
* referenced compression block. Unrecognised placeholders are left as-is.
|
||||
*/
|
||||
function expandBlockPlaceholders(summary: string, state: DcpState): string {
|
||||
return summary.replace(/\(b(\d+)\)/g, (match, idStr) => {
|
||||
const id = parseInt(idStr, 10)
|
||||
const block = state.compressionBlocks.find((b) => b.id === id && b.active)
|
||||
return block
|
||||
? `[Previously compressed: ${block.topic}]\n${block.summary}`
|
||||
: match
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied ID string (e.g. "m001" or "b3") to an actual
|
||||
* message timestamp.
|
||||
*
|
||||
* - `mNNN` ids → looked up directly in `state.messageIdSnapshot`
|
||||
* - `bN` ids → matched against `state.compressionBlocks` by integer id;
|
||||
* `field` selects whether we return the block's start or end
|
||||
* timestamp depending on whether the id is used as a range
|
||||
* start or end boundary.
|
||||
*
|
||||
* Throws `Error("Unknown message ID: <id>")` when the id cannot be resolved.
|
||||
*/
|
||||
function resolveIdToTimestamp(
|
||||
rawId: string,
|
||||
field: "startTimestamp" | "endTimestamp",
|
||||
state: DcpState,
|
||||
): number {
|
||||
const id = rawId.trim()
|
||||
|
||||
// Block ID: b1, b2, b10, …
|
||||
const blockMatch = id.match(/^b(\d+)$/i)
|
||||
if (blockMatch) {
|
||||
const blockId = parseInt(blockMatch[1]!, 10)
|
||||
const block = state.compressionBlocks.find((b) => b.id === blockId && b.active)
|
||||
if (!block) throw new Error(`Unknown message ID: ${id}`)
|
||||
return block[field]
|
||||
}
|
||||
|
||||
// Message ID: m001, m042, …
|
||||
const ts = state.messageIdSnapshot.get(id)
|
||||
if (ts === undefined) throw new Error(`Unknown message ID: ${id}`)
|
||||
return ts
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the anchor timestamp for a compression block — the timestamp of
|
||||
* the first raw message that appears strictly after `endTimestamp`.
|
||||
*
|
||||
* Returns `Infinity` when the range extends to the very end of the visible
|
||||
* conversation (nothing comes after it).
|
||||
*/
|
||||
function resolveAnchorTimestamp(endTimestamp: number, state: DcpState): number {
|
||||
let anchor = Infinity
|
||||
for (const ts of state.messageIdSnapshot.values()) {
|
||||
if (ts > endTimestamp && ts < anchor) {
|
||||
anchor = ts
|
||||
}
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function registerCompressTool(
|
||||
pi: ExtensionAPI,
|
||||
state: DcpState,
|
||||
config: DcpConfig,
|
||||
): void {
|
||||
pi.registerTool({
|
||||
name: "compress",
|
||||
label: "Compress Context",
|
||||
description: COMPRESS_RANGE_DESCRIPTION,
|
||||
promptSnippet: "Compress ranges of conversation into summaries to manage context",
|
||||
parameters: Type.Object({
|
||||
topic: Type.String({
|
||||
description:
|
||||
"Short label (3-5 words) for display - e.g., 'Auth System Exploration'",
|
||||
}),
|
||||
ranges: Type.Array(
|
||||
Type.Object({
|
||||
startId: Type.String({
|
||||
description:
|
||||
"Message ID marking start of range (e.g. m001, b2)",
|
||||
}),
|
||||
endId: Type.String({
|
||||
description:
|
||||
"Message ID marking end of range (e.g. m042, b5)",
|
||||
}),
|
||||
summary: Type.String({
|
||||
description:
|
||||
"Complete technical summary replacing all content in range",
|
||||
}),
|
||||
}),
|
||||
{ description: "One or more ranges to compress" },
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const newBlockIds: number[] = []
|
||||
|
||||
for (const range of params.ranges) {
|
||||
const { startId, endId, summary } = range
|
||||
|
||||
// ── Resolve boundary timestamps ──────────────────────────────────
|
||||
const startTimestamp = resolveIdToTimestamp(startId, "startTimestamp", state)
|
||||
const endTimestamp = resolveIdToTimestamp(endId, "endTimestamp", state)
|
||||
|
||||
if (startTimestamp > endTimestamp) {
|
||||
throw new Error(
|
||||
`Range start "${startId}" must appear before end "${endId}" in the conversation`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Overlap check against existing active blocks ─────────────────
|
||||
for (const existing of state.compressionBlocks) {
|
||||
if (!existing.active) continue
|
||||
const overlaps =
|
||||
startTimestamp <= existing.endTimestamp &&
|
||||
existing.startTimestamp <= endTimestamp
|
||||
if (overlaps) {
|
||||
throw new Error(
|
||||
`Overlapping compression ranges are not supported. ` +
|
||||
`New range (${startId}..${endId}) overlaps existing block ` +
|
||||
`b${existing.id} "${existing.topic}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Anchor: first raw message after the range ────────────────────
|
||||
const anchorTimestamp = resolveAnchorTimestamp(endTimestamp, state)
|
||||
|
||||
// ── Expand any (bN) placeholders in the summary ──────────────────
|
||||
const expandedSummary = expandBlockPlaceholders(summary, state)
|
||||
|
||||
// ── Create and store the compression block ───────────────────────
|
||||
const block: CompressionBlock = {
|
||||
id: state.nextBlockId++,
|
||||
topic: params.topic,
|
||||
summary: expandedSummary,
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
anchorTimestamp,
|
||||
active: true,
|
||||
summaryTokenEstimate: estimateTokens(expandedSummary),
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
state.compressionBlocks.push(block)
|
||||
newBlockIds.push(block.id)
|
||||
}
|
||||
|
||||
// ── Notification ────────────────────────────────────────────────────
|
||||
if (config.pruneNotification !== "off") {
|
||||
const count = params.ranges.length
|
||||
const rangeWord = count === 1 ? "range" : "ranges"
|
||||
|
||||
if (config.pruneNotification === "detailed") {
|
||||
const totalTokens = newBlockIds.reduce((sum, id) => {
|
||||
const b = state.compressionBlocks.find((block) => block.id === id)
|
||||
return sum + (b?.summaryTokenEstimate ?? 0)
|
||||
}, 0)
|
||||
ctx.ui.notify(
|
||||
`Compressed: ${params.topic} (${count} ${rangeWord}, ~${totalTokens} tokens in summaries)`,
|
||||
"info",
|
||||
)
|
||||
} else {
|
||||
// "minimal"
|
||||
ctx.ui.notify(`Compressed: ${params.topic}`, "info")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Return result ───────────────────────────────────────────────────
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Compressed ${params.ranges.length} range(s): ${params.topic}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
blockIds: newBlockIds,
|
||||
topic: params.topic,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import { parse as parseJsonc } from "jsonc-parser"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DcpConfig {
|
||||
enabled: boolean
|
||||
debug: boolean
|
||||
manualMode: {
|
||||
enabled: boolean
|
||||
automaticStrategies: boolean // run dedup/purge even in manual mode
|
||||
}
|
||||
compress: {
|
||||
maxContextPercent: number // 0-1, e.g. 0.8 — above this, aggressive nudges
|
||||
minContextPercent: number // 0-1, e.g. 0.4 — below this, no nudges
|
||||
nudgeFrequency: number // inject nudge every N context events (default: 5)
|
||||
iterationNudgeThreshold: number // nudge after N tool calls since last user msg (default: 15)
|
||||
nudgeForce: "strong" | "soft"
|
||||
protectedTools: string[] // these tool outputs always protected from pruning
|
||||
protectUserMessages: boolean
|
||||
}
|
||||
strategies: {
|
||||
deduplication: {
|
||||
enabled: boolean
|
||||
protectedTools: string[]
|
||||
}
|
||||
purgeErrors: {
|
||||
enabled: boolean
|
||||
turns: number // prune error inputs after N user turns (default: 4)
|
||||
protectedTools: string[]
|
||||
}
|
||||
}
|
||||
protectedFilePatterns: string[]
|
||||
pruneNotification: "off" | "minimal" | "detailed"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_CONFIG: DcpConfig = {
|
||||
enabled: true,
|
||||
debug: false,
|
||||
manualMode: {
|
||||
enabled: false,
|
||||
automaticStrategies: true,
|
||||
},
|
||||
compress: {
|
||||
maxContextPercent: 0.8,
|
||||
minContextPercent: 0.4,
|
||||
nudgeFrequency: 5,
|
||||
iterationNudgeThreshold: 15,
|
||||
nudgeForce: "soft",
|
||||
protectedTools: ["compress", "write", "edit"],
|
||||
protectUserMessages: false,
|
||||
},
|
||||
strategies: {
|
||||
deduplication: {
|
||||
enabled: true,
|
||||
protectedTools: [],
|
||||
},
|
||||
purgeErrors: {
|
||||
enabled: true,
|
||||
turns: 4,
|
||||
protectedTools: [],
|
||||
},
|
||||
},
|
||||
protectedFilePatterns: [],
|
||||
pruneNotification: "detailed",
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_FILE_CONTENT = `{
|
||||
// Dynamic Context Pruning (DCP) configuration
|
||||
// Full schema reference: https://github.com/your-org/pi-dynamic-context-pruning
|
||||
//
|
||||
// "$schema": "...",
|
||||
//
|
||||
// Uncomment and edit properties you want to override:
|
||||
//
|
||||
// "enabled": true,
|
||||
// "debug": false,
|
||||
// "manualMode": {
|
||||
// "enabled": false,
|
||||
// "automaticStrategies": true
|
||||
// },
|
||||
// "compress": {
|
||||
// "maxContextPercent": 0.8,
|
||||
// "minContextPercent": 0.4,
|
||||
// "nudgeFrequency": 5,
|
||||
// "iterationNudgeThreshold": 15,
|
||||
// "nudgeForce": "soft",
|
||||
// "protectedTools": ["compress", "write", "edit"],
|
||||
// "protectUserMessages": false
|
||||
// },
|
||||
// "strategies": {
|
||||
// "deduplication": { "enabled": true, "protectedTools": [] },
|
||||
// "purgeErrors": { "enabled": true, "turns": 4, "protectedTools": [] }
|
||||
// },
|
||||
// "protectedFilePatterns": [],
|
||||
// "pruneNotification": "detailed"
|
||||
}
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Recursively merge `override` into `base`. Arrays are union-merged (deduped).
|
||||
* Returns a new object; does not mutate inputs.
|
||||
*/
|
||||
function deepMerge<T>(base: T, override: Partial<T>): T {
|
||||
if (override === null || override === undefined) return base
|
||||
if (typeof base !== "object" || typeof override !== "object") {
|
||||
return override as T
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) }
|
||||
|
||||
for (const key of Object.keys(override as Record<string, unknown>)) {
|
||||
const baseVal = (base as Record<string, unknown>)[key]
|
||||
const overVal = (override as Record<string, unknown>)[key]
|
||||
|
||||
if (Array.isArray(baseVal) && Array.isArray(overVal)) {
|
||||
// Union merge: combine and deduplicate by value
|
||||
const combined = [...baseVal, ...overVal]
|
||||
result[key] = [...new Set(combined)]
|
||||
} else if (
|
||||
overVal !== null &&
|
||||
typeof overVal === "object" &&
|
||||
!Array.isArray(overVal) &&
|
||||
baseVal !== null &&
|
||||
typeof baseVal === "object" &&
|
||||
!Array.isArray(baseVal)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
baseVal as Record<string, unknown>,
|
||||
overVal as Record<string, unknown>,
|
||||
)
|
||||
} else if (overVal !== undefined) {
|
||||
result[key] = overVal
|
||||
}
|
||||
}
|
||||
|
||||
return result as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSONC file and return a plain object.
|
||||
* Returns `{}` on any error (missing file, parse error).
|
||||
*/
|
||||
function readJsoncFile(filePath: string): Record<string, unknown> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, "utf8")
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
|
||||
const errors: unknown[] = []
|
||||
const parsed = parseJsonc(raw, errors)
|
||||
if (errors.length > 0) {
|
||||
// Non-fatal: return whatever was parsed (jsonc-parser is lenient)
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {}
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the global config file exists, creating it with defaults if missing.
|
||||
*/
|
||||
function ensureGlobalConfig(filePath: string): void {
|
||||
const dir = path.dirname(filePath)
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, DEFAULT_CONFIG_FILE_CONTENT, "utf8")
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; do not crash if we cannot write
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from `startDir` looking for `.pi/dcp.jsonc`.
|
||||
* Returns the path if found, otherwise null.
|
||||
*/
|
||||
function findProjectConfig(startDir: string): string | null {
|
||||
let dir = path.resolve(startDir)
|
||||
const root = path.parse(dir).root
|
||||
|
||||
while (true) {
|
||||
const candidate = path.join(dir, ".pi", "dcp.jsonc")
|
||||
if (fs.existsSync(candidate)) return candidate
|
||||
if (dir === root) return null
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) return null
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load the DCP configuration by merging (in order):
|
||||
* 1. Built-in defaults
|
||||
* 2. ~/.config/pi/dcp.jsonc (global; auto-created if missing)
|
||||
* 3. $PI_CONFIG_DIR/dcp.jsonc (if env var is set)
|
||||
* 4. <project>/.pi/dcp.jsonc (walked up from projectDir)
|
||||
*/
|
||||
export function loadConfig(projectDir: string): DcpConfig {
|
||||
// Layer 1: defaults (deep clone so we never mutate the constant)
|
||||
let config: DcpConfig = deepMerge(DEFAULT_CONFIG, {})
|
||||
|
||||
// Layer 2: global config
|
||||
const globalConfigPath = path.join(os.homedir(), ".config", "pi", "dcp.jsonc")
|
||||
ensureGlobalConfig(globalConfigPath)
|
||||
const globalRaw = readJsoncFile(globalConfigPath)
|
||||
if (Object.keys(globalRaw).length > 0) {
|
||||
config = deepMerge(config, globalRaw as Partial<DcpConfig>)
|
||||
}
|
||||
|
||||
// Layer 3: $PI_CONFIG_DIR/dcp.jsonc
|
||||
const piConfigDir = process.env["PI_CONFIG_DIR"]
|
||||
if (piConfigDir) {
|
||||
const envConfigPath = path.join(piConfigDir, "dcp.jsonc")
|
||||
const envRaw = readJsoncFile(envConfigPath)
|
||||
if (Object.keys(envRaw).length > 0) {
|
||||
config = deepMerge(config, envRaw as Partial<DcpConfig>)
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 4: project-local config (walk up from projectDir)
|
||||
const projectConfigPath = findProjectConfig(projectDir)
|
||||
if (projectConfigPath) {
|
||||
const projectRaw = readJsoncFile(projectConfigPath)
|
||||
if (Object.keys(projectRaw).length > 0) {
|
||||
config = deepMerge(config, projectRaw as Partial<DcpConfig>)
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) {
|
||||
state.compressionBlocks = data.compressionBlocks
|
||||
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)
|
||||
})
|
||||
}
|
||||
Generated
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "pi-dynamic-context-pruning",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-dynamic-context-pruning",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"jsonc-parser": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@complexthings/pi-dynamic-context-pruning",
|
||||
"version": "1.0.0",
|
||||
"description": "PI coding agent extension — Dynamic Context Pruning (DCP)",
|
||||
"type": "module",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"author": {
|
||||
"name": "Greg",
|
||||
"email": "greg.harvell@complexthings.com",
|
||||
"url": "https://github.com/complexthings"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": ""
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonc-parser": "^3.3.1"
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic Context Pruning (DCP) — PI extension prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
// All prompt text is exported as plain strings so the extension index can
|
||||
// reference them by name without executing any logic here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Appended to the existing system prompt when DCP is enabled (automatic mode).
|
||||
*/
|
||||
export const SYSTEM_PROMPT = `
|
||||
You operate in a context-constrained environment. Manage context continuously to avoid buildup and preserve retrieval quality. Efficient context management is paramount for your agentic performance.
|
||||
|
||||
The ONLY tool you have for context management is \`compress\`. It replaces older conversation content with technical summaries you produce.
|
||||
|
||||
\`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
|
||||
|
||||
THE PHILOSOPHY OF COMPRESS
|
||||
\`compress\` transforms conversation content into dense, high-fidelity summaries. This is not cleanup — it is crystallization. Your summary becomes the authoritative record of what transpired.
|
||||
|
||||
Think of compression as phase transitions: raw exploration becomes refined understanding. The original context served its purpose; your summary now carries that understanding forward.
|
||||
|
||||
OPERATING STANCE
|
||||
Prefer short, closed, summary-safe compressions.
|
||||
When multiple independent stale sections exist, prefer several focused compressions (in parallel when possible) over one broad compression.
|
||||
|
||||
Use \`compress\` as steady housekeeping while you work.
|
||||
|
||||
CADENCE, SIGNALS, AND LATENCY
|
||||
|
||||
- No fixed threshold mandates compression
|
||||
- Prioritize closedness and independence over raw size
|
||||
- Prefer smaller, regular compressions over infrequent massive compressions for better latency and summary quality
|
||||
- When multiple independent stale sections are ready, batch compressions in parallel
|
||||
|
||||
COMPRESS WHEN
|
||||
|
||||
A section is genuinely closed and the raw conversation has served its purpose:
|
||||
|
||||
- Research concluded and findings are clear
|
||||
- Implementation finished and verified
|
||||
- Exploration exhausted and patterns understood
|
||||
- Dead-end noise can be discarded without waiting for a whole chapter to close
|
||||
|
||||
DO NOT COMPRESS IF
|
||||
|
||||
- Raw context is still relevant and needed for edits or precise references
|
||||
- The target content is still actively in progress
|
||||
- You may need exact code, error messages, or file contents in the immediate next steps
|
||||
|
||||
Before compressing, ask: _"Is this section closed enough to become summary-only right now?"_
|
||||
|
||||
Evaluate conversation signal-to-noise REGULARLY. Use \`compress\` deliberately with quality-first summaries. Prioritize stale content intelligently to maintain a high-signal context window that supports your agency.
|
||||
|
||||
It is your responsibility to keep a sharp, high-quality context window for optimal performance.
|
||||
`.trim()
|
||||
|
||||
/**
|
||||
* Used as the \`description\` field when registering the \`compress\` tool.
|
||||
*
|
||||
* Tool signature:
|
||||
* {
|
||||
* topic: string // 3-5 word label for this compression
|
||||
* ranges: Array<{
|
||||
* startId: string // mNNN or bN
|
||||
* endId: string // mNNN or bN
|
||||
* summary: string // exhaustive technical summary
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
export const COMPRESS_RANGE_DESCRIPTION = `Collapse one or more ranges of the conversation into detailed summaries.
|
||||
|
||||
THE SUMMARY
|
||||
Your summary must be EXHAUSTIVE. Capture file paths, function signatures, decisions made, constraints discovered, key findings... EVERYTHING that maintains context integrity. This is not a brief note — it is an authoritative record so faithful that the original conversation adds no value.
|
||||
|
||||
USER INTENT FIDELITY
|
||||
When the compressed range includes user messages, preserve the user's intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes.
|
||||
Directly quote user messages when they are short enough to include safely. Direct quotes are preferred when they best preserve exact meaning.
|
||||
|
||||
Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool outputs, back-and-forth exploration. What remains should be pure signal — golden nuggets of detail that preserve full understanding with zero ambiguity.
|
||||
|
||||
COMPRESSED BLOCK PLACEHOLDERS
|
||||
When the selected range includes previously compressed blocks, use this exact placeholder format when referencing one:
|
||||
|
||||
- \`(bN)\`
|
||||
|
||||
Compressed block sections in context are clearly marked with a header:
|
||||
|
||||
- \`[Compressed conversation section]\`
|
||||
|
||||
Compressed block IDs always use the \`bN\` form (never \`mNNN\`) and are represented in the same XML metadata tag format.
|
||||
|
||||
Rules:
|
||||
|
||||
- Include every required block placeholder exactly once.
|
||||
- Do not invent placeholders for blocks outside the selected range.
|
||||
- Treat \`(bN)\` placeholders as RESERVED TOKENS. Do not emit \`(bN)\` text anywhere except intentional placeholders.
|
||||
- If you need to mention a block in prose, use plain text like \`compressed bN\` (not as a placeholder).
|
||||
- Preflight check before finalizing: the set of \`(bN)\` placeholders in your summary must exactly match the required set, with no duplicates.
|
||||
|
||||
These placeholders are semantic references. They will be replaced with the full stored compressed block content when the tool processes your output.
|
||||
|
||||
FLOW PRESERVATION WITH PLACEHOLDERS
|
||||
When you use compressed block placeholders, write the surrounding summary text so it still reads correctly AFTER placeholder expansion.
|
||||
|
||||
- Treat each placeholder as a stand-in for a full conversation segment, not as a short label.
|
||||
- Ensure transitions before and after each placeholder preserve chronology and causality.
|
||||
- Do not write text that depends on the placeholder staying literal (for example, "as noted in \`(b2)\`").
|
||||
- Your final meaning must be coherent once each placeholder is replaced with its full compressed block content.
|
||||
|
||||
BOUNDARY IDS
|
||||
You specify boundaries by ID using the injected IDs visible in the conversation:
|
||||
|
||||
- \`mNNN\` IDs identify raw messages (3 digits, zero-padded, e.g. \`m001\`, \`m042\`)
|
||||
- \`bN\` IDs identify previously compressed blocks
|
||||
|
||||
Each message has an ID inside XML metadata tags like \`<dcp-message-id>...</dcp-message-id>\`.
|
||||
The ID tag appears at the end of the message it belongs to — it identifies the message above it, not the one below it.
|
||||
Treat these tags as boundary metadata only, not as tool result content.
|
||||
|
||||
Rules:
|
||||
|
||||
- Pick \`startId\` and \`endId\` directly from injected IDs in context.
|
||||
- IDs must exist in the current visible context.
|
||||
- \`startId\` must appear before \`endId\`.
|
||||
- Do not invent IDs. Use only IDs that are present in context.
|
||||
|
||||
BATCHING
|
||||
When multiple independent ranges are ready and their boundaries do not overlap, include all of them as separate entries in the \`ranges\` array of a single tool call. Each entry must have its own \`startId\`, \`endId\`, and \`summary\`.`
|
||||
|
||||
/**
|
||||
* Injected into messages when context usage exceeds maxContextPercent.
|
||||
* nudgeForce = "strong" — emergency recovery tone.
|
||||
*/
|
||||
export const CONTEXT_LIMIT_NUDGE_STRONG = `<dcp-system-reminder>
|
||||
CRITICAL WARNING: MAX CONTEXT LIMIT REACHED
|
||||
|
||||
You are at or beyond the configured max context threshold. This is an emergency context-recovery moment.
|
||||
|
||||
You MUST use the \`compress\` tool now. Do not continue normal exploration until compression is handled.
|
||||
|
||||
If you are in the middle of a critical atomic operation, finish that atomic step first, then compress immediately.
|
||||
|
||||
RANGE STRATEGY (MANDATORY)
|
||||
Prioritize one large, closed, high-yield compression range first.
|
||||
This overrides the normal preference for many small compressions.
|
||||
Only split into multiple compressions if one large range would reduce summary quality or make boundary selection unsafe.
|
||||
|
||||
RANGE SELECTION
|
||||
Start from older, resolved history and capture as much stale context as safely possible in one pass.
|
||||
Avoid the newest active working slice unless it is clearly closed.
|
||||
Use visible injected boundary IDs for compression (\`mNNN\` for messages, \`bN\` for compressed blocks), and ensure \`startId\` appears before \`endId\`.
|
||||
|
||||
SUMMARY REQUIREMENTS
|
||||
Your summary must cover all essential details from the selected range so work can continue without reopening raw messages.
|
||||
If the compressed range includes user messages, preserve user intent exactly. Prefer direct quotes for short user messages to avoid semantic drift.
|
||||
</dcp-system-reminder>`
|
||||
|
||||
/**
|
||||
* Injected into messages when context usage exceeds maxContextPercent.
|
||||
* nudgeForce = "soft" — steady housekeeping tone.
|
||||
*/
|
||||
export const CONTEXT_LIMIT_NUDGE_SOFT = `<dcp-system-reminder>
|
||||
NOTICE: Context usage is high.
|
||||
|
||||
Look for a closed, self-contained range that no longer needs to stay raw and compress it now.
|
||||
|
||||
RANGE SELECTION
|
||||
Prefer older, resolved history. Avoid the newest active working slice unless it is clearly done.
|
||||
Use visible boundary IDs (\`mNNN\` for messages, \`bN\` for compressed blocks) and ensure \`startId\` appears before \`endId\`.
|
||||
|
||||
If multiple independent ranges are ready, batch them in a single \`compress\` call.
|
||||
If nothing is cleanly closed yet, continue — but compress at the earliest opportunity.
|
||||
</dcp-system-reminder>`
|
||||
|
||||
/**
|
||||
* Injected as a lightweight reminder between minContextPercent and maxContextPercent
|
||||
* at the configured nudgeFrequency cadence.
|
||||
*/
|
||||
export const TURN_NUDGE = `<dcp-system-reminder>
|
||||
Evaluate the conversation for compressible ranges.
|
||||
|
||||
If any range is cleanly closed and unlikely to be needed again, use the compress tool on it.
|
||||
If direction has shifted, compress earlier ranges that are now less relevant.
|
||||
|
||||
Prefer small, closed-range compressions over one broad compression.
|
||||
The goal is to filter noise and distill key information so context accumulation stays under control.
|
||||
Keep active context uncompressed.
|
||||
</dcp-system-reminder>`
|
||||
|
||||
/**
|
||||
* Injected after iterationNudgeThreshold tool calls since the last user message.
|
||||
*/
|
||||
export const ITERATION_NUDGE = `<dcp-system-reminder>
|
||||
You've been iterating for a while after the last user message.
|
||||
|
||||
If there is a closed portion that is unlikely to be referenced immediately (for example, finished research before implementation), use the compress tool on it now.
|
||||
|
||||
Prefer multiple short, closed ranges over one large range when several independent slices are ready.
|
||||
</dcp-system-reminder>`
|
||||
|
||||
/**
|
||||
* Replaces SYSTEM_PROMPT when manualMode.enabled = true.
|
||||
* The agent should NOT proactively compress — only compress when explicitly
|
||||
* requested by the user or when a context-limit nudge fires.
|
||||
*/
|
||||
export const MANUAL_MODE_SYSTEM_PROMPT = `
|
||||
You are operating in DCP manual mode for context management.
|
||||
|
||||
\`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
|
||||
|
||||
In manual mode you do NOT proactively compress conversation content. Compression is a deliberate, user-directed action.
|
||||
|
||||
WHEN TO COMPRESS
|
||||
- Only when the user explicitly asks you to compress
|
||||
- Only when a \`<dcp-system-reminder>\` nudge instructs you to (context-limit emergency)
|
||||
- Never as background housekeeping or on your own initiative
|
||||
|
||||
WHEN YOU DO COMPRESS
|
||||
Apply the same quality standards as always:
|
||||
|
||||
- Summaries must be EXHAUSTIVE — file paths, decisions, findings, exact constraints
|
||||
- Preserve user intent precisely; prefer direct quotes for short user messages
|
||||
- Use only boundary IDs visible in context (\`mNNN\` for messages, \`bN\` for compressed blocks)
|
||||
- Batch independent ranges in a single \`compress\` call when possible
|
||||
|
||||
Do not compress active, still-needed context. Only compress ranges that are genuinely closed and whose raw form is no longer required.
|
||||
`.trim()
|
||||
@@ -0,0 +1,321 @@
|
||||
import type { DcpState } from "./state.js";
|
||||
import type { DcpConfig } from "./config.js";
|
||||
|
||||
// Always-protected tool names for deduplication
|
||||
const ALWAYS_PROTECTED_DEDUP = new Set(["compress", "write", "edit"]);
|
||||
|
||||
// Roles that get message IDs injected
|
||||
const ID_ELIGIBLE_ROLES = new Set(["user", "assistant", "toolResult", "bashExecution"]);
|
||||
|
||||
// Roles that are PI-internal and should pass through unchanged
|
||||
const PASSTHROUGH_ROLES = new Set(["compaction", "branch_summary", "custom_message"]);
|
||||
|
||||
/**
|
||||
* Simple token estimator: chars / 4, rounded.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.round(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate tokens from a message's content, whatever shape it takes.
|
||||
*/
|
||||
function estimateMessageTokens(msg: any): number {
|
||||
if (!msg) return 0;
|
||||
const content = msg.content;
|
||||
if (!content) return 0;
|
||||
if (typeof content === "string") return estimateTokens(content);
|
||||
if (Array.isArray(content)) {
|
||||
let total = 0;
|
||||
for (const part of content) {
|
||||
if (part && typeof part === "object") {
|
||||
if (typeof part.text === "string") total += estimateTokens(part.text);
|
||||
else if (typeof part.thinking === "string") total += estimateTokens(part.thinking);
|
||||
else if (part.type === "image") total += 500; // rough estimate for images
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply active compression blocks to the message array.
|
||||
* Mutates messages in place (via splice/sort) and returns it.
|
||||
*/
|
||||
function applyCompressionBlocks(messages: any[], state: DcpState): any[] {
|
||||
const activeBlocks = state.compressionBlocks.filter((b) => b.active);
|
||||
if (activeBlocks.length === 0) return messages;
|
||||
|
||||
for (const block of activeBlocks) {
|
||||
// Find start and end indices by timestamp
|
||||
const startIdx = messages.findIndex((m) => m.timestamp === block.startTimestamp);
|
||||
const endIdx = messages.findIndex((m) => m.timestamp === block.endTimestamp);
|
||||
|
||||
if (startIdx === -1 || endIdx === -1) continue;
|
||||
|
||||
const lo = Math.min(startIdx, endIdx);
|
||||
const hi = Math.max(startIdx, endIdx);
|
||||
|
||||
// Estimate tokens removed
|
||||
let removedTokens = 0;
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
removedTokens += estimateMessageTokens(messages[i]);
|
||||
}
|
||||
|
||||
// Remove the range (inclusive)
|
||||
messages.splice(lo, hi - lo + 1);
|
||||
|
||||
// Build synthetic user message for the compressed block
|
||||
const syntheticMsg = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"[Compressed section: " +
|
||||
block.topic +
|
||||
"]\n\n" +
|
||||
block.summary +
|
||||
"\n\n<dcp-block-id>b" +
|
||||
block.id +
|
||||
"</dcp-block-id>",
|
||||
},
|
||||
],
|
||||
timestamp: block.anchorTimestamp - 0.5,
|
||||
};
|
||||
|
||||
// Estimate tokens added by the summary
|
||||
const addedTokens = estimateMessageTokens(syntheticMsg);
|
||||
|
||||
// Insert the synthetic message
|
||||
messages.push(syntheticMsg);
|
||||
|
||||
// Re-sort by timestamp
|
||||
messages.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
|
||||
|
||||
// Update tokens saved
|
||||
const saved = removedTokens - addedTokens;
|
||||
if (saved > 0) state.tokensSaved += saved;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply deduplication: mark redundant tool outputs for pruning.
|
||||
* Mutates state.prunedToolIds.
|
||||
*/
|
||||
function applyDeduplication(messages: any[], state: DcpState, config: DcpConfig): void {
|
||||
if (!config.strategies.deduplication.enabled) return;
|
||||
if (state.manualMode && !config.manualMode.automaticStrategies) return;
|
||||
|
||||
const protectedTools = new Set([
|
||||
...ALWAYS_PROTECTED_DEDUP,
|
||||
...(config.strategies.deduplication.protectedTools ?? []),
|
||||
]);
|
||||
|
||||
// fingerprint → array of toolCallIds in timestamp order
|
||||
const fingerprintMap = new Map<string, string[]>();
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "toolResult") continue;
|
||||
const toolName: string = msg.toolName ?? "";
|
||||
if (protectedTools.has(toolName)) continue;
|
||||
|
||||
// Look up the fingerprint from the recorded tool call
|
||||
const record = state.toolCalls.get(msg.toolCallId);
|
||||
if (!record) continue;
|
||||
|
||||
const fp = record.inputFingerprint;
|
||||
if (!fingerprintMap.has(fp)) {
|
||||
fingerprintMap.set(fp, []);
|
||||
}
|
||||
fingerprintMap.get(fp)!.push(msg.toolCallId);
|
||||
}
|
||||
|
||||
// For each fingerprint with duplicates, prune all but the last
|
||||
for (const [, ids] of fingerprintMap) {
|
||||
if (ids.length <= 1) continue;
|
||||
// Keep the last one; prune the rest
|
||||
for (let i = 0; i < ids.length - 1; i++) {
|
||||
state.prunedToolIds.add(ids[i]);
|
||||
state.totalPruneCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply error purging: mark old error tool outputs for pruning.
|
||||
* Mutates state.prunedToolIds.
|
||||
*/
|
||||
function applyErrorPurging(messages: any[], state: DcpState, config: DcpConfig): void {
|
||||
if (!config.strategies.purgeErrors.enabled) return;
|
||||
if (state.manualMode && !config.manualMode.automaticStrategies) return;
|
||||
|
||||
const protectedTools = new Set(config.strategies.purgeErrors.protectedTools ?? []);
|
||||
const turnsThreshold = config.strategies.purgeErrors.turns ?? 3;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "toolResult") continue;
|
||||
if (!msg.isError) continue;
|
||||
|
||||
const toolName: string = msg.toolName ?? "";
|
||||
if (protectedTools.has(toolName)) continue;
|
||||
|
||||
const record = state.toolCalls.get(msg.toolCallId);
|
||||
if (!record) continue;
|
||||
|
||||
if (state.currentTurn - record.turnIndex >= turnsThreshold) {
|
||||
state.prunedToolIds.add(msg.toolCallId);
|
||||
state.totalPruneCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply explicit tool output pruning from state.prunedToolIds.
|
||||
* Replaces content of matching toolResult messages in place.
|
||||
*/
|
||||
function applyToolOutputPruning(messages: any[], state: DcpState): void {
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "toolResult") continue;
|
||||
if (!state.prunedToolIds.has(msg.toolCallId)) continue;
|
||||
|
||||
if (msg.isError) {
|
||||
msg.content = [
|
||||
{
|
||||
type: "text",
|
||||
text: "[Error output removed - tool failed more than N turns ago]",
|
||||
},
|
||||
];
|
||||
} else {
|
||||
msg.content = [
|
||||
{
|
||||
type: "text",
|
||||
text: "[Output removed to save context - information superseded or no longer needed]",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject sequential message IDs into eligible messages.
|
||||
* Updates state.messageIdSnapshot.
|
||||
*/
|
||||
function injectMessageIds(messages: any[], state: DcpState): void {
|
||||
// Clear the snapshot and rebuild
|
||||
state.messageIdSnapshot.clear();
|
||||
|
||||
let counter = 1;
|
||||
|
||||
for (const msg of messages) {
|
||||
const role: string = msg.role ?? "";
|
||||
|
||||
// Skip PI-internal passthrough messages
|
||||
if (PASSTHROUGH_ROLES.has(role)) continue;
|
||||
// Skip non-eligible roles
|
||||
if (!ID_ELIGIBLE_ROLES.has(role)) continue;
|
||||
|
||||
const id = "m" + String(counter).padStart(3, "0");
|
||||
counter++;
|
||||
|
||||
const idTag = `\n<dcp-id>${id}</dcp-id>`;
|
||||
|
||||
if (role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = msg.content + `\n\n<dcp-id>${id}</dcp-id>`;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
msg.content = [...msg.content, { type: "text", text: idTag }];
|
||||
}
|
||||
} else if (role === "assistant" || role === "toolResult" || role === "bashExecution") {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content = [...msg.content, { type: "text", text: idTag }];
|
||||
} else if (typeof msg.content === "string") {
|
||||
msg.content = msg.content + idTag;
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.timestamp !== undefined) {
|
||||
state.messageIdSnapshot.set(id, msg.timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main transform: applies all pruning and returns modified message array.
|
||||
* Called from the `context` event handler.
|
||||
*/
|
||||
export function applyPruning(
|
||||
messages: any[],
|
||||
state: DcpState,
|
||||
config: DcpConfig
|
||||
): any[] {
|
||||
// Work on a shallow copy of the array (individual message objects may be mutated)
|
||||
const msgs: any[] = [...messages];
|
||||
|
||||
// 1. Count user turns → update state.currentTurn
|
||||
state.currentTurn = msgs.filter((m) => m.role === "user").length;
|
||||
|
||||
// 2. Apply active compression blocks
|
||||
applyCompressionBlocks(msgs, state);
|
||||
|
||||
// 3. Apply deduplication
|
||||
applyDeduplication(msgs, state, config);
|
||||
|
||||
// 4. Apply error purging
|
||||
applyErrorPurging(msgs, state, config);
|
||||
|
||||
// 5. Apply explicit tool output pruning (prunedToolIds)
|
||||
applyToolOutputPruning(msgs, state);
|
||||
|
||||
// 6. Inject message IDs into visible messages
|
||||
injectMessageIds(msgs, state);
|
||||
|
||||
// 7. state.messageIdSnapshot is already updated by injectMessageIds
|
||||
|
||||
return msgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject context limit nudge as a synthetic user message at the end of messages.
|
||||
* Mutates messages in place.
|
||||
*/
|
||||
export function injectNudge(messages: any[], nudgeText: string): void {
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: nudgeText,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a nudge should fire and return the nudge type, or null.
|
||||
*/
|
||||
export function getNudgeType(
|
||||
contextPercent: number,
|
||||
state: DcpState,
|
||||
config: DcpConfig,
|
||||
toolCallsSinceLastUser: number
|
||||
): "context-strong" | "context-soft" | "turn" | "iteration" | null {
|
||||
const { maxContextPercent, minContextPercent, nudgeFrequency, nudgeForce, iterationNudgeThreshold } =
|
||||
config.compress;
|
||||
|
||||
if (contextPercent > maxContextPercent) {
|
||||
// Only fire if nudge counter has reached frequency threshold
|
||||
if (state.nudgeCounter >= nudgeFrequency) {
|
||||
return nudgeForce === "strong" ? "context-strong" : "context-soft";
|
||||
}
|
||||
// Still above max but haven't hit frequency yet — fall through to lower checks
|
||||
}
|
||||
|
||||
if (contextPercent > minContextPercent && contextPercent <= maxContextPercent) {
|
||||
if (toolCallsSinceLastUser >= iterationNudgeThreshold) {
|
||||
return "iteration";
|
||||
}
|
||||
return "turn";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A record of a single tool call, keyed by toolCallId in DcpState.toolCalls.
|
||||
*/
|
||||
export interface ToolRecord {
|
||||
/** Matches ToolResultMessage.toolCallId */
|
||||
toolCallId: string
|
||||
/** Matches ToolResultMessage.toolName */
|
||||
toolName: string
|
||||
/** The arguments passed to the tool (from the corresponding ToolCall) */
|
||||
inputArgs: Record<string, unknown>
|
||||
/**
|
||||
* Deduplication fingerprint: `toolName::JSON(sortedArgs)`
|
||||
* Two calls with the same name + identical args share the same fingerprint.
|
||||
*/
|
||||
inputFingerprint: string
|
||||
/** Whether the tool result was an error */
|
||||
isError: boolean
|
||||
/**
|
||||
* Zero-based index of the user turn during which this tool was called.
|
||||
* Incremented each time a user message is encountered in the context stream.
|
||||
*/
|
||||
turnIndex: number
|
||||
/** message.timestamp from the ToolResultMessage */
|
||||
timestamp: number
|
||||
/** Rough token estimate: sum of result text content lengths divided by 4 */
|
||||
tokenEstimate: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A compression block created by the `compress` tool.
|
||||
* Tracks the range of messages that were summarised and where to inject the
|
||||
* summary back into the context.
|
||||
*/
|
||||
export interface CompressionBlock {
|
||||
/** Auto-incrementing integer ID */
|
||||
id: number
|
||||
/** Short human-readable topic label */
|
||||
topic: string
|
||||
/** LLM-generated summary text */
|
||||
summary: string
|
||||
/** Timestamp of the first message in the compressed range */
|
||||
startTimestamp: number
|
||||
/** Timestamp of the last message in the compressed range */
|
||||
endTimestamp: number
|
||||
/**
|
||||
* Timestamp of the first message *after* the range — the summary is injected
|
||||
* immediately before this message. Set to `Infinity` when the range extends
|
||||
* to the end of the conversation.
|
||||
*/
|
||||
anchorTimestamp: number
|
||||
/** Whether this block is still being applied (false = soft-deleted) */
|
||||
active: boolean
|
||||
/** Token estimate for the summary text itself */
|
||||
summaryTokenEstimate: number
|
||||
/** Wall-clock time the block was created (Date.now()) */
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Full runtime state for the DCP extension.
|
||||
*/
|
||||
export interface DcpState {
|
||||
// ── Tool tracking ──────────────────────────────────────────────────────────
|
||||
/** toolCallId → ToolRecord, populated when a tool_result event fires */
|
||||
toolCalls: Map<string, ToolRecord>
|
||||
/** Set of toolCallIds whose result messages should be suppressed in context */
|
||||
prunedToolIds: Set<string>
|
||||
|
||||
// ── Compression ────────────────────────────────────────────────────────────
|
||||
/** All compression blocks (both active and soft-deleted) */
|
||||
compressionBlocks: CompressionBlock[]
|
||||
/** Monotonically increasing counter used to assign CompressionBlock.id */
|
||||
nextBlockId: number
|
||||
|
||||
// ── Message ID snapshot ────────────────────────────────────────────────────
|
||||
/**
|
||||
* Maps the short LLM-visible message IDs (e.g. "m001") to the actual
|
||||
* `timestamp` of that message as seen in the last `context` event.
|
||||
*
|
||||
* The `compress` tool receives ID strings from the LLM; this map lets us
|
||||
* translate them back to real timestamps so compression blocks can reference
|
||||
* message positions by timestamp (which is stable across pruning passes).
|
||||
*/
|
||||
messageIdSnapshot: Map<string, number>
|
||||
|
||||
// ── Turn tracking ──────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Zero-based index of the current user turn.
|
||||
* Incremented each time a user message is encountered while processing the
|
||||
* context array in the `context` event handler.
|
||||
*/
|
||||
currentTurn: number
|
||||
|
||||
// ── Statistics ─────────────────────────────────────────────────────────────
|
||||
/** Running total of tokens estimated to have been saved by pruning/compression */
|
||||
tokensSaved: number
|
||||
/** Number of discrete pruning operations performed */
|
||||
totalPruneCount: number
|
||||
|
||||
// ── Mode ───────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* When true, the extension will not autonomously emit compress nudges.
|
||||
* Automatic deduplication/error-purge strategies may still run depending on
|
||||
* the `manualMode.automaticStrategies` config flag.
|
||||
*/
|
||||
manualMode: boolean
|
||||
|
||||
// ── Nudge state ────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* How many `context` events have fired since the last compress nudge was
|
||||
* emitted. Reset to 0 after each nudge.
|
||||
*/
|
||||
nudgeCounter: number
|
||||
/**
|
||||
* The value of `currentTurn` at the time the last nudge was emitted.
|
||||
* Used to avoid nudging more than once per user turn when nudgeFrequency is
|
||||
* satisfied within the same turn.
|
||||
*/
|
||||
lastNudgeTurn: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a fresh, zeroed DcpState instance. */
|
||||
export function createState(): DcpState {
|
||||
return {
|
||||
toolCalls: new Map(),
|
||||
prunedToolIds: new Set(),
|
||||
compressionBlocks: [],
|
||||
nextBlockId: 1,
|
||||
messageIdSnapshot: new Map(),
|
||||
currentTurn: 0,
|
||||
tokensSaved: 0,
|
||||
totalPruneCount: 0,
|
||||
manualMode: false,
|
||||
nudgeCounter: 0,
|
||||
lastNudgeTurn: -1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset `state` back to its initial values **in-place**.
|
||||
* Preserves the object reference so other modules holding a reference see the
|
||||
* reset immediately.
|
||||
*/
|
||||
export function resetState(state: DcpState): void {
|
||||
state.toolCalls.clear()
|
||||
state.prunedToolIds.clear()
|
||||
state.compressionBlocks = []
|
||||
state.nextBlockId = 1
|
||||
state.messageIdSnapshot.clear()
|
||||
state.currentTurn = 0
|
||||
state.tokensSaved = 0
|
||||
state.totalPruneCount = 0
|
||||
state.manualMode = false
|
||||
state.nudgeCounter = 0
|
||||
state.lastNudgeTurn = -1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fingerprinting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Recursively sort the keys of a plain object so that two argument objects
|
||||
* with the same entries in different key-insertion order produce the same JSON.
|
||||
*/
|
||||
function sortObjectKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortObjectKeys)
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>
|
||||
const sorted: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(obj).sort()) {
|
||||
sorted[key] = sortObjectKeys(obj[key])
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stable deduplication fingerprint for a tool call.
|
||||
*
|
||||
* Two calls with the same `toolName` and semantically identical `args`
|
||||
* (regardless of key ordering) will produce the same fingerprint.
|
||||
*
|
||||
* Format: `<toolName>::<JSON of recursively key-sorted args>`
|
||||
*/
|
||||
export function createInputFingerprint(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): string {
|
||||
const sorted = sortObjectKeys(args)
|
||||
return `${toolName}::${JSON.stringify(sorted)}`
|
||||
}
|
||||
Reference in New Issue
Block a user