Files
pi-dynamic-context-pruning/compress-tool.ts
T
Greg Harvell 60b4249501 Initial Commit
2026-03-27 17:47:33 -04:00

209 lines
7.6 KiB
TypeScript

// ---------------------------------------------------------------------------
// 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,
},
}
},
})
}