diff --git a/dcp.schema.json b/dcp.schema.json index 39f2df5..3775acb 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -263,6 +263,68 @@ "protectUserMessages": false } }, + "gc": { + "type": "object", + "description": "Garbage collection and batch cleanup configuration", + "additionalProperties": false, + "properties": { + "algorithm": { + "type": "string", + "enum": ["truncate"], + "default": "truncate" + }, + "promotionThreshold": { + "type": "number", + "default": 5 + }, + "maxBlockAge": { + "type": "number", + "default": 15 + }, + "maxOldGenSummaryLength": { + "type": "number", + "default": 3000 + }, + "majorGcThresholdPercent": { + "default": "100%", + "oneOf": [ + { "type": "number" }, + { "type": "string", "pattern": "^\\d+(?:\\.\\d+)?%$" } + ] + }, + "batchCleanup": { + "type": "object", + "description": "Three-tier batch merge-cleanup thresholds for blocks marked via mark_block. Accepts number or \"X%\" of model context window.", + "additionalProperties": false, + "properties": { + "lowThreshold": { + "default": "60%", + "description": "Nudge tier: at/above this usage, a reminder to merge marked blocks is injected.", + "oneOf": [ + { "type": "number" }, + { "type": "string", "pattern": "^\\d+(?:\\.\\d+)?%$" } + ] + }, + "highThreshold": { + "default": "75%", + "description": "Auto merge tier: at/above this usage, all marked blocks are merge-compressed into one.", + "oneOf": [ + { "type": "number" }, + { "type": "string", "pattern": "^\\d+(?:\\.\\d+)?%$" } + ] + }, + "forceThreshold": { + "default": "90%", + "description": "Force merge tier: at/above this usage, all old-gen blocks are merged regardless of marks.", + "oneOf": [ + { "type": "number" }, + { "type": "string", "pattern": "^\\d+(?:\\.\\d+)?%$" } + ] + } + } + } + } + }, "strategies": { "type": "object", "description": "Automatic pruning strategies", diff --git a/devlog/2026-06-26_mark-block-batch-cleanup/DESIGN.md b/devlog/2026-06-26_mark-block-batch-cleanup/DESIGN.md new file mode 100644 index 0000000..09937d4 --- /dev/null +++ b/devlog/2026-06-26_mark-block-batch-cleanup/DESIGN.md @@ -0,0 +1,160 @@ +# DESIGN: mark_block + Batch Cleanup + +## Architecture + +### New state field + +`PruneMessagesState` in `lib/state/types.ts`: +```ts +markedForCleanup: Set // blockIds marked by model for batch cleanup +``` + +Persisted in session state JSON. Loaded/saved via existing persistence mechanism. + +### New tool: mark_block + +Registered in `index.ts` alongside compress/decompress. + +**Input**: `blockId: string` (e.g., "b0", "b3") +**Behavior**: +1. Resolve blockId → numeric ID +2. Validate block exists and is active +3. Add to `state.prune.messages.markedForCleanup` +4. Return confirmation: "Block bN marked for cleanup. It will be merge-compressed when context pressure rises. No immediate effect on context or cache." +**Zero cache impact**: doesn't modify any messages or block summaries. + +### New tool: unmark_block (optional) + +Allow model to unmark if it changes its mind. Same input, removes from set. + +### Batch cleanup triggers in hooks.ts + +Added to the message transform pipeline, after `runMajorGC()` and before `prune()`: + +```ts +// Three-tier batch cleanup for marked blocks +runBatchCleanup(state, config, logger, messages, currentUsagePercent) +``` + +**Tier 1 — Low threshold (configurable, default 60%)**: +- Action: inject nudge text into the last user message +- Nudge: "⚠️ N blocks marked for cleanup (b0, b1, ...). Consider merge-compressing them to free ~M tokens. Use compress with a range covering these blocks." +- No automatic action — just reminds the model + +**Tier 2 — High threshold (configurable, default 75%)**: +- Action: automatic merge-compress of all marked blocks +- Concatenate marked block summaries → create one new block with merged summary +- Deactivate old marked blocks +- One cache break, maximum cleanup + +**Tier 3 — Near-100 (configurable, default 90%)**: +- Action: force merge-compress ALL old-gen blocks (marked or not) +- Last resort before GC at 100% +- Ensures batch cleanup catches everything before GC's blind truncation + +### Merge-compress implementation + +New function in `lib/gc/truncate.ts` (or new file `lib/gc/merge.ts`): + +```ts +function mergeMarkedBlocks( + state: SessionState, + markedIds: number[], + maxMergedLength: number // e.g., maxOldGenSummaryLength or configurable +): { mergedCount: number; savedTokens: number } +``` + +1. Collect all marked blocks (sorted by blockId ascending = oldest first) +2. Concatenate their summaries with separators +3. If concatenated length > maxMergedLength: truncate (keep headers, cut middle) +4. Create new CompressionBlock with merged summary +5. Deactivate all source blocks (set active=false, set deactivatedByBlockId=newBlockId) +6. New block covers all effectiveMessageIds from source blocks +7. Clear markedForCleanup set +8. Persist state + +### Config additions + +In `lib/config.ts`, add to `gc` config section: + +```ts +gc: { + // ... existing fields ... + batchCleanup: { + lowThreshold: "60%", // nudge tier + highThreshold: "75%", // auto merge-compress tier + forceThreshold: "90%", // force merge all old blocks + }, +} +``` + +All optional with defaults. Backward compatible (missing = use defaults). + +### System prompt update + +In `lib/prompts/system.ts`, add mark_block to the tools documentation: + +``` +mark_block(blockId: "b0") — Mark a compressed block for batch cleanup. +Zero immediate effect — the block stays in context with full cache hits. +Marked blocks are merge-compressed together when context pressure rises, +minimizing cache breaks. Use this for blocks whose information you no longer +need but don't want to delete immediately (to preserve cache). + +unmark_block(blockId: "b0") — Remove the cleanup mark from a block. +``` + +## Data flow + +``` +Model calls mark_block("b0") + → state.prune.messages.markedForCleanup.add(0) + → persist state + → return "marked" + +[Several turns later, context at 75%] + +Message transform hook runs: + → runMajorGC() (existing, age-based — still active) + → runBatchCleanup(): + → currentUsage >= highThreshold (75%) + → mergeMarkedBlocks(state, [0, 1, 2]) + → concatenate b0+b1+b2 summaries + → create b3 with merged summary + → deactivate b0, b1, b2 + → clear markedForCleanup + → one cache break (same as any compression) + → prune() — injects b3 summary instead of b0+b1+b2 +``` + +## Cache impact analysis + +| Scenario | Cache breaks | When | +|----------|-------------|------| +| mark_block (any time) | 0 | Never modifies context | +| Tier 1 nudge (60%) | 0 | Only appends to last message (cache already breaking there) | +| Tier 2 auto merge (75%) | 1 | One break at merged block position | +| Tier 3 force (90%) | 1 | One break at merged block position | +| GC at 100% (existing) | 1 | One break at truncation point | + +Total: maximum 1 cache break per pressure cycle (vs multiple if discarding individually). + +## Files to modify + +| File | Change | +|------|--------| +| `lib/state/types.ts` | Add `markedForCleanup: Set` to PruneMessagesState | +| `lib/gc/truncate.ts` | Add `mergeMarkedBlocks()` + `runBatchCleanup()` functions | +| `lib/hooks.ts` | Call `runBatchCleanup()` in message transform pipeline | +| `index.ts` | Register `mark_block` (and optionally `unmark_block`) tools | +| `lib/config.ts` | Add `batchCleanup` config with 3 thresholds | +| `lib/config-validation.ts` | Validate new config fields | +| `lib/prompts/system.ts` | Document mark_block tool | +| `lib/state/persistence.ts` | Ensure markedForCleanup set is serialized/deserialized | + +## Backward compatibility + +- `markedForCleanup` defaults to empty Set — old state files without it work fine +- `batchCleanup` config is optional — old configs without it use defaults +- GC remains fully functional — no changes to existing GC behavior +- mark_block tool only registered if config allows (can be disabled) diff --git a/devlog/2026-06-26_mark-block-batch-cleanup/REQ.md b/devlog/2026-06-26_mark-block-batch-cleanup/REQ.md new file mode 100644 index 0000000..4816a97 --- /dev/null +++ b/devlog/2026-06-26_mark-block-batch-cleanup/REQ.md @@ -0,0 +1,31 @@ +# REQ: mark_block + Batch Cleanup + +## Problem + +ACP blocks are only-in-not-out: the model can create (compress) and restore (decompress) but cannot delete. The only deletion mechanism is GC (age-based deactivation at survivedCount > maxBlockAge, threshold truncation at 100% context). + +Two issues: +1. **GC age-deactivation fires at low context pressure** — blocks deleted purely by age, even at 30% context. Loses important info (task_ids, session_ids) unnecessarily. +2. **Model has no way to clean up blocks it no longer needs** — blocks accumulate until GC eventually nukes them. + +## Solution + +Separate "intent" from "execution": + +1. **`mark_block` tool** — model marks a block as "no longer needed". Zero cache impact (doesn't modify context, only sets internal flag). Block stays in context with full cache hits. + +2. **Batch cleanup at 3 context-pressure thresholds** — when pressure rises, ACP processes all marked blocks in one operation (one cache break, maximum benefit): + - **Low threshold (~60%)**: nudge the model — "N blocks marked for cleanup, consider merge-compressing them" + - **High threshold (~75%)**: automatic merge-compress of all marked blocks + - **Near-100 (~90%)**: force merge-compress ALL old blocks (marked or not) as new fallback before GC + +3. **Keep existing GC** as ultimate fallback (100%). Remove later when batch cleanup is stable. + +## Key constraint + +Prefix-cache: any change to context prefix causes cache miss from that point. Batch cleanup minimizes cache breaks by deferring all modifications to a single operation at high pressure. + +## Non-goals + +- Do NOT delete original messages from DB (too risky, may break replay/export) +- Do NOT remove GC yet (keep as fallback, remove in future PR when stable) diff --git a/devlog/2026-06-26_mark-block-batch-cleanup/WORKLOG.md b/devlog/2026-06-26_mark-block-batch-cleanup/WORKLOG.md new file mode 100644 index 0000000..2755276 --- /dev/null +++ b/devlog/2026-06-26_mark-block-batch-cleanup/WORKLOG.md @@ -0,0 +1,81 @@ +# WORKLOG - mark_block + Batch Cleanup + +- Task ID: `2026-06-26_mark-block-batch-cleanup` +- Home Repo: `opencode-acp` +- Status: Done +- Updated: 2026-06-26 + +## 1. Summary + +- **What was done**: Implemented the `mark_block` / `unmark_block` tools and a three-tier batch merge-cleanup mechanism that consolidates marked (or force-targeted old-gen) compression blocks into a single summary as context pressure rises. +- **Why**: Gives the model a zero-cache-cost way to flag blocks it no longer needs, deferring all consolidation into one cache break at high pressure instead of losing information to blind age-based GC at low pressure. +- **Behavior / compatibility changes**: Yes — additive only. New optional state field (`markedForCleanup`) and new required config sub-object (`gc.batchCleanup`, always populated from defaults). Existing GC logic is untouched and remains the ultimate fallback at 100%. Old persisted state files without `markedForCleanup` load fine (defaults to empty set). Old user configs without `gc.batchCleanup` use defaults via deep-merge. +- **Risk level**: Low + +## 2. Change Log + +### Commits + +Not committed — changes left uncommitted for review (per task instructions). + +### Key Files + +- `lib/state/types.ts` — added `markedForCleanup: Set` to `PruneMessagesState`. +- `lib/state/utils.ts` — `createPruneMessagesState` initializes the set; `serializePruneMessagesState` writes it as an array; `loadPruneMessagesState` restores it (skips marks for non-existent blocks). Both local `PersistedPruneMessagesState` shapes gained `markedForCleanup?: number[]`. +- `lib/state/persistence.ts` — added `markedForCleanup?: number[]` to the on-disk `PersistedPruneMessagesState` interface. +- `lib/config.ts` — new `BatchCleanupConfig` interface; `batchCleanup` added to `GCConfig`; defaults (`60%/75%/90%`) in `DEFAULT_CONFIG`; `deepCloneConfig` clones it; new `mergeGC` deep-merges `batchCleanup` so partial user overrides keep default thresholds; `mark_block`/`unmark_block` added to `DEFAULT_PROTECTED_TOOLS`. +- `lib/config-validation.ts` — registered `gc.batchCleanup{,.lowThreshold,.highThreshold,.forceThreshold}` keys + type validation (number | `${number}%`). +- `dcp.schema.json` — added a `gc` property section (previously absent) including `batchCleanup` with the three thresholds. +- `lib/gc/merge.ts` — **new file**: `mergeMarkedBlocks()` and `runBatchCleanup()` plus helpers (`collectActiveMarkedBlocks`, `collectActiveOldGenBlocks`, `extractSummaryBody`, `truncateMergedSummary`, `percentToTokens`, `buildNudgeText`). +- `lib/hooks.ts` — imported `runBatchCleanup`; inserted the call between `runMajorGC()` and `prune()`; tier 1 appends nudge text to the last user message via new `appendBatchCleanupNudge`; tier 2/3 persists the mutated state. +- `lib/compress/mark-block.ts` — **new file**: `createMarkBlockTool` / `createUnmarkBlockTool` (session init + state mutation + persistence, mirroring the decompress tool pattern). +- `lib/compress/index.ts` — re-exported the two new tool factories. +- `index.ts` — registered `mark_block` / `unmark_block` in the `tool:` block (gated on `compress.permission !== "deny"`) and added them to `experimental.primary_tools`. +- `lib/prompts/system.ts` — documented `mark_block` / `unmark_block` in the tools description. +- `tests/*.test.ts` — added `batchCleanup` defaults to every `buildConfig`/`makeGCConfig` gc literal (10 files) so test configs match the updated `PluginConfig`/`GCConfig` types. + +## 3. Design & Implementation Notes + +- **Entry point / key function**: `runBatchCleanup(state, config, logger, messages)` in `lib/gc/merge.ts`, invoked from the message-transform pipeline in `lib/hooks.ts`. +- **Key configuration items**: `gc.batchCleanup.{lowThreshold,highThreshold,forceThreshold}` (number or `${number}%` of model context window). Defaults 60% / 75% / 90%. +- **Key logic explanation**: + - `runBatchCleanup` computes current usage via `getCurrentTokenUsage`. If `modelContextLimit` is unknown it no-ops. Tier precedence: force(3) > high(2) > low(1) > none(0). + - **Tier 3 (>= forceThreshold)**: gathers all active old-gen blocks (same selection as `runMajorGC`: `generation === "old" | undefined` or oversized) and merges them. + - **Tier 2 (>= highThreshold)**: gathers active blocks in `markedForCleanup` and merges them. + - **Tier 1 (>= lowThreshold)**: returns a `nudgeText` (only when marks exist); hooks.ts appends it to the last user message — cache-safe because the prefix already breaks there each turn. + - `mergeMarkedBlocks` requires >= 2 valid active source blocks (a single-block "merge" is a no-op to avoid redundant work already covered by truncate-GC). It concatenates source summary bodies (header/footer stripped) with `\n---\n`, truncates to `maxOldGenSummaryLength` keeping each block's first line, wraps into a new old-gen block, deactivates all sources (`active=false`, `deactivatedByBlockId=newId`), transfers their `effectiveMessageIds`/`effectiveToolIds` union, re-points `activeByAnchorMessageId` to the new block at the oldest source's anchor, rewrites `byMessageId.activeBlockIds`, and clears `markedForCleanup`. + - **No infinite loop**: after a tier-3 merge there is only one old-gen block, so the next run's `< 2` guard no-ops; after a tier-2 merge `markedForCleanup` is cleared. + - State is **not** persisted inside `mergeMarkedBlocks`; the hooks.ts caller persists once when `mergedCount > 0`. + - `resolveBatchCleanup` falls back to a constant default set when `config.gc.batchCleanup` is absent — defensive against partial configs / older test factories. +- **Persistence / serialization**: Sets are written as arrays (`Array.from`) and read back with integer/existence validation, consistent with the existing `activeBlockIds` handling. Backward compatible: a missing field yields an empty set. +- **Backward compatibility**: internal `dcp` naming untouched; existing GC untouched; no DB message modification; only ACP state is mutated. + +## 4. Testing & Verification + +### Build & Test Commands + +```sh +cd opencode-acp +npm run typecheck # tsc --noEmit (covers index.ts + lib/**) +npm run build # clean + tsup + tsc --emitDeclarationOnly +npm run test # node --import tsx --test tests/*.test.ts +``` + +### Test Coverage + +- New/modified test files: no new test files (per scope — test review TBD). Updated 10 existing test factory `gc` literals to include `batchCleanup` defaults. +- The new code paths are exercised defensively at runtime but no existing test scenario crosses a batch-cleanup threshold (e2e tests run with `modelContextLimit` undefined or ~0.075% usage, so `runBatchCleanup` returns tier 0), guaranteeing no regression in existing assertions. +- Test count: **386 total, 386 pass, 0 fail** (unchanged from baseline). + +### Results + +- `npm run typecheck`: PASS +- `npm run build`: PASS (`dist/index.js` 319.87 KB) +- `npm run test`: PASS (386/386) +- `dcp.schema.json`: valid JSON (verified via `JSON.parse`) + +## 5. Follow-ups / Notes + +- Dedicated unit tests for `mergeMarkedBlocks` / `runBatchCleanup` and the `mark_block` tool were not added in this iteration (out of scope; flagged for a follow-up test-review pass per AGENTS.md §5.6). +- `compressMessageId` of merged blocks is set to `""` since no compress tool call produces them; downstream code that keys on `compressMessageId` for duration attachment (`attachCompressionDuration`) is unaffected because merged blocks have no matching start event. +- `gc` was missing from `dcp.schema.json` entirely; this iteration added the full `gc` section (with `batchCleanup`) so IDE autocomplete now reflects reality. diff --git a/index.ts b/index.ts index 970a9f3..87a1028 100644 --- a/index.ts +++ b/index.ts @@ -4,6 +4,8 @@ import { createCompressMessageTool, createCompressRangeTool, createDecompressTool, + createMarkBlockTool, + createUnmarkBlockTool, } from "./lib/compress" import { compressDisabledByOpencode, @@ -89,6 +91,8 @@ const server: Plugin = (async (ctx) => { ? createCompressMessageTool(compressToolContext) : createCompressRangeTool(compressToolContext), decompress: createDecompressTool(compressToolContext), + mark_block: createMarkBlockTool(compressToolContext), + unmark_block: createUnmarkBlockTool(compressToolContext), }), }, config: async (opencodeConfig) => { @@ -109,7 +113,7 @@ const server: Plugin = (async (ctx) => { const toolsToAdd: string[] = [] if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) { - toolsToAdd.push("compress", "decompress") + toolsToAdd.push("compress", "decompress", "mark_block", "unmark_block") } if (toolsToAdd.length > 0) { diff --git a/lib/compress/index.ts b/lib/compress/index.ts index 6330869..b4fe6e7 100644 --- a/lib/compress/index.ts +++ b/lib/compress/index.ts @@ -2,3 +2,4 @@ export { ToolContext } from "./types" export { createCompressMessageTool } from "./message" export { createCompressRangeTool } from "./range" export { createDecompressTool } from "./decompress" +export { createMarkBlockTool, createUnmarkBlockTool } from "./mark-block" diff --git a/lib/compress/mark-block.ts b/lib/compress/mark-block.ts new file mode 100644 index 0000000..11168ac --- /dev/null +++ b/lib/compress/mark-block.ts @@ -0,0 +1,148 @@ +import { tool } from "@opencode-ai/plugin" +import type { ToolContext } from "./types" +import { ensureSessionInitialized } from "../state" +import { saveSessionState } from "../state/persistence" +import { assignMessageRefs } from "../message-ids" +import { fetchSessionMessages } from "./search" +import { formatBlockRef, parseBlockRef } from "../message-ids" + +interface RunContext { + ask(input: { + permission: string + patterns: string[] + always: string[] + metadata: Record + }): Promise + metadata(input: { title: string }): void + sessionID: string +} + +async function prepareMarkSession( + ctx: ToolContext, + toolCtx: RunContext, +): Promise { + await toolCtx.ask({ + permission: "compress", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + + toolCtx.metadata({ title: "Mark block" }) + + const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID) + + await ensureSessionInitialized( + ctx.client, + ctx.state, + toolCtx.sessionID, + ctx.logger, + rawMessages, + ctx.config.manualMode.enabled, + ) + + assignMessageRefs(ctx.state, rawMessages) +} + +const MARK_DESCRIPTION = `Marks a compressed block for batch merge-cleanup. + +Use this for blocks whose detailed content you no longer need, but whose summaries +you want to keep in context for now (to preserve prompt cache). Marked blocks stay +fully active with zero immediate effect on context or cache. When context pressure +rises, all marked blocks are merge-compressed together into a single summary in one +cache break, instead of being handled one at a time. + +Argument: blockId — the block reference to mark (e.g., "b1", "b3") + +Use mark_block instead of compress when you want deferred cleanup: the block keeps +serving cache hits now and gets consolidated later only if context gets tight.` + +const UNMARK_DESCRIPTION = `Removes the batch cleanup mark from a compressed block. + +Reverses mark_block. The block returns to normal handling and will not be +auto-merged during batch cleanup. + +Argument: blockId — the block reference to unmark (e.g., "b1", "b3")` + +function buildSchema() { + return { + blockId: tool.schema + .string() + .describe('Block reference to mark (e.g., "b1", "b3")'), + } +} + +function buildUnmarkSchema() { + return { + blockId: tool.schema + .string() + .describe('Block reference to unmark (e.g., "b1", "b3")'), + } +} + +export function createMarkBlockTool(ctx: ToolContext): ReturnType { + return tool({ + description: MARK_DESCRIPTION, + args: buildSchema(), + async execute(args, toolCtx) { + await prepareMarkSession(ctx, toolCtx) + + const targetBlockId = parseBlockRef(String(args.blockId)) + if (targetBlockId === null) { + return `Error: Invalid block ID "${args.blockId}". Use format "b0", "b1", etc.` + } + + const messagesState = ctx.state.prune.messages + const block = messagesState.blocksById.get(targetBlockId) + if (!block) { + return `Error: Block ${formatBlockRef(targetBlockId)} does not exist.` + } + + if (!block.active) { + return `Error: Block ${formatBlockRef(targetBlockId)} is not active.` + } + + messagesState.markedForCleanup.add(targetBlockId) + await saveSessionState(ctx.state, ctx.logger) + + const ref = formatBlockRef(targetBlockId) + const markedCount = messagesState.markedForCleanup.size + + ctx.logger.info("mark_block: block marked for cleanup", { + blockId: targetBlockId, + markedCount, + }) + + return `Block ${ref} marked for cleanup. It will be merge-compressed together with other marked blocks when context pressure rises. No immediate effect on context or cache. (${markedCount} block(s) currently marked.)` + }, + }) +} + +export function createUnmarkBlockTool(ctx: ToolContext): ReturnType { + return tool({ + description: UNMARK_DESCRIPTION, + args: buildUnmarkSchema(), + async execute(args, toolCtx) { + await prepareMarkSession(ctx, toolCtx) + + const targetBlockId = parseBlockRef(String(args.blockId)) + if (targetBlockId === null) { + return `Error: Invalid block ID "${args.blockId}". Use format "b0", "b1", etc.` + } + + const messagesState = ctx.state.prune.messages + if (!messagesState.markedForCleanup.has(targetBlockId)) { + return `Block ${formatBlockRef(targetBlockId)} was not marked for cleanup.` + } + + messagesState.markedForCleanup.delete(targetBlockId) + await saveSessionState(ctx.state, ctx.logger) + + ctx.logger.info("unmark_block: block unmarked", { + blockId: targetBlockId, + }) + + return `Block ${formatBlockRef(targetBlockId)} unmarked. It will no longer be auto-merged during batch cleanup.` + }, + }) +} diff --git a/lib/config-validation.ts b/lib/config-validation.ts index 9df5ac8..baec07a 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -45,6 +45,10 @@ export const VALID_CONFIG_KEYS = new Set([ "gc.maxBlockAge", "gc.maxOldGenSummaryLength", "gc.majorGcThresholdPercent", + "gc.batchCleanup", + "gc.batchCleanup.lowThreshold", + "gc.batchCleanup.highThreshold", + "gc.batchCleanup.forceThreshold", "strategies", "strategies.deduplication", "strategies.deduplication.enabled", @@ -509,6 +513,45 @@ export function validateConfigTypes(config: Record): ValidationErro }) } } + + const validateBatchThreshold = ( + key: "gc.batchCleanup.lowThreshold" | "gc.batchCleanup.highThreshold" | "gc.batchCleanup.forceThreshold", + value: unknown, + ): void => { + const isValidNumber = typeof value === "number" + const isPercentString = typeof value === "string" && /^\d+(?:\.\d+)?%$/.test(value) + if (!isValidNumber && !isPercentString) { + errors.push({ + key, + expected: 'number | "${number}%"', + actual: JSON.stringify(value), + }) + } + } + + if (gc.batchCleanup !== undefined) { + if ( + typeof gc.batchCleanup !== "object" || + gc.batchCleanup === null || + Array.isArray(gc.batchCleanup) + ) { + errors.push({ + key: "gc.batchCleanup", + expected: "object", + actual: typeof gc.batchCleanup, + }) + } else { + if (gc.batchCleanup.lowThreshold !== undefined) { + validateBatchThreshold("gc.batchCleanup.lowThreshold", gc.batchCleanup.lowThreshold) + } + if (gc.batchCleanup.highThreshold !== undefined) { + validateBatchThreshold("gc.batchCleanup.highThreshold", gc.batchCleanup.highThreshold) + } + if (gc.batchCleanup.forceThreshold !== undefined) { + validateBatchThreshold("gc.batchCleanup.forceThreshold", gc.batchCleanup.forceThreshold) + } + } + } } } diff --git a/lib/config.ts b/lib/config.ts index f7e3aea..6b1d7b4 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -57,12 +57,19 @@ export interface ExperimentalConfig { customPrompts: boolean } +export interface BatchCleanupConfig { + lowThreshold: number | `${number}%` + highThreshold: number | `${number}%` + forceThreshold: number | `${number}%` +} + export interface GCConfig { algorithm: "truncate" promotionThreshold: number maxBlockAge: number maxOldGenSummaryLength: number majorGcThresholdPercent: number | `${number}%` + batchCleanup: BatchCleanupConfig } export interface PluginConfig { @@ -93,6 +100,8 @@ const DEFAULT_PROTECTED_TOOLS = [ "todoread", "compress", "decompress", + "mark_block", + "unmark_block", "batch", "plan_enter", "plan_exit", @@ -203,6 +212,11 @@ const defaultConfig: PluginConfig = { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { + lowThreshold: "60%", + highThreshold: "75%", + forceThreshold: "90%", + }, }, } @@ -457,7 +471,22 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { protectedTools: [...config.strategies.purgeErrors.protectedTools], }, }, - gc: { ...config.gc }, + gc: { + ...config.gc, + batchCleanup: { ...config.gc.batchCleanup }, + }, + } +} + +function mergeGC(base: GCConfig, override?: Partial): GCConfig { + if (!override) { + return base + } + + return { + ...base, + ...override, + batchCleanup: { ...base.batchCleanup, ...(override.batchCleanup ?? {}) }, } } @@ -479,7 +508,7 @@ function mergeLayer(config: PluginConfig, data: Record): PluginConf ...new Set([...config.protectedFilePatterns, ...(data.protectedFilePatterns ?? [])]), ], compress: mergeCompress(config.compress, data.compress as CompressOverride), - gc: { ...config.gc, ...(data.gc as Partial) }, + gc: mergeGC(config.gc, data.gc as Partial), strategies: mergeStrategies(config.strategies, data.strategies as any), } } diff --git a/lib/gc/merge.ts b/lib/gc/merge.ts new file mode 100644 index 0000000..391691f --- /dev/null +++ b/lib/gc/merge.ts @@ -0,0 +1,336 @@ +import type { CompressionBlock, SessionState, WithParts } from "../state" +import type { BatchCleanupConfig, GCConfig, PluginConfig } from "../config" +import type { Logger } from "../logger" +import { countTokens, getCurrentTokenUsage } from "../token-utils" +import { + COMPRESSED_BLOCK_HEADER, + allocateBlockId, + allocateRunId, + wrapCompressedSummary, +} from "../compress/state" +import { formatBlockRef } from "../message-ids" + +export interface MergeMarkedResult { + mergedCount: number + savedTokens: number +} + +export interface BatchCleanupResult { + tier: 0 | 1 | 2 | 3 + action: "none" | "nudge" | "merge" + mergedCount: number + savedTokens: number + nudgeText?: string +} + +const DEFAULT_BATCH_CLEANUP: BatchCleanupConfig = { + lowThreshold: "60%", + highThreshold: "75%", + forceThreshold: "90%", +} + +function resolveBatchCleanup(gc: GCConfig): BatchCleanupConfig { + return gc.batchCleanup ?? DEFAULT_BATCH_CLEANUP +} + +function percentToTokens( + value: number | `${number}%`, + modelContextLimit: number, +): number { + if (typeof value === "number") return value + const percent = parseFloat(value.slice(0, -1)) + if (isNaN(percent)) return modelContextLimit + const clamped = Math.max(0, Math.min(100, Math.round(percent))) + return Math.round((clamped / 100) * modelContextLimit) +} + +function collectActiveOldGenBlocks(state: SessionState, maxOldGenSummaryLength: number): CompressionBlock[] { + const blocks: CompressionBlock[] = [] + const ids = Array.from(state.prune.messages.activeBlockIds).sort((a, b) => a - b) + for (const id of ids) { + const block = state.prune.messages.blocksById.get(id) + if (!block || !block.active) continue + if ( + block.generation === "old" || + block.generation === undefined || + block.summary.length > maxOldGenSummaryLength + ) { + blocks.push(block) + } + } + return blocks +} + +function collectActiveMarkedBlocks(state: SessionState): CompressionBlock[] { + const ids = Array.from(state.prune.messages.markedForCleanup).sort((a, b) => a - b) + const blocks: CompressionBlock[] = [] + for (const id of ids) { + const block = state.prune.messages.blocksById.get(id) + if (!block || !block.active) continue + blocks.push(block) + } + return blocks +} + +function extractSummaryBody(summary: string): string { + let body = summary + const headerPrefix = COMPRESSED_BLOCK_HEADER + "\n" + if (body.startsWith(headerPrefix)) { + body = body.slice(headerPrefix.length) + } + body = body.replace(/\n]*>b\d+<\/dcp-message-id>$/, "") + return body.trim() +} + +function truncateMergedSummary(merged: string, maxLength: number): string { + if (merged.length <= maxLength) return merged + + const blocks = merged.split("\n---\n") + const headers = blocks + .map((b) => b.split("\n")[0] ?? "") + .filter((h) => h.trim().length > 0) + + const marker = "\n...\n[merged and truncated by batch cleanup]" + const budget = Math.max(0, maxLength - marker.length) + const headerJoin = headers.join("\n") + + if (headerJoin.length <= budget) { + return headerJoin + marker + } + return headerJoin.slice(0, budget) + marker +} + +export function mergeMarkedBlocks( + state: SessionState, + markedIds: number[], + maxMergedLength: number, +): MergeMarkedResult { + const sortedIds = [...new Set(markedIds)].filter( + (id) => Number.isInteger(id) && id > 0, + ).sort((a, b) => a - b) + + const sourceBlocks: CompressionBlock[] = [] + for (const id of sortedIds) { + const block = state.prune.messages.blocksById.get(id) + if (!block || !block.active) continue + if (!sourceBlocks.some((b) => b.blockId === id)) { + sourceBlocks.push(block) + } + } + + if (sourceBlocks.length < 2) { + return { mergedCount: 0, savedTokens: 0 } + } + + const messagesState = state.prune.messages + const newBlockId = allocateBlockId(state) + const newRunId = allocateRunId(state) + + const bodies = sourceBlocks.map((block) => extractSummaryBody(block.summary)) + const mergedRaw = bodies.join("\n---\n") + const mergedBody = truncateMergedSummary(mergedRaw, maxMergedLength) + const newSummary = wrapCompressedSummary(newBlockId, mergedBody) + const newSummaryTokens = countTokens(newSummary) + + const oldest = sourceBlocks[0] + const newest = sourceBlocks[sourceBlocks.length - 1] + + const effectiveMessageIds = new Set() + const effectiveToolIds = new Set() + for (const block of sourceBlocks) { + for (const id of block.effectiveMessageIds) effectiveMessageIds.add(id) + for (const id of block.effectiveToolIds) effectiveToolIds.add(id) + } + + const sourceIds = sourceBlocks.map((b) => b.blockId) + const createdAt = Date.now() + + const mergedBlock: CompressionBlock = { + blockId: newBlockId, + runId: newRunId, + active: true, + deactivatedByUser: false, + compressedTokens: 0, + summaryTokens: newSummaryTokens, + durationMs: 0, + mode: "range", + topic: "Batch merge cleanup", + batchTopic: "Batch merge cleanup", + startId: oldest.startId, + endId: newest.endId, + anchorMessageId: oldest.anchorMessageId, + compressMessageId: "", + compressCallId: undefined, + includedBlockIds: [...sourceIds], + consumedBlockIds: [...sourceIds], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: [...effectiveMessageIds], + effectiveToolIds: [...effectiveToolIds], + createdAt, + summary: newSummary, + survivedCount: 0, + generation: "old", + } + + const now = Date.now() + for (const block of sourceBlocks) { + block.active = false + block.deactivatedAt = now + block.deactivatedByBlockId = newBlockId + if (!block.parentBlockIds.includes(newBlockId)) { + block.parentBlockIds.push(newBlockId) + } + messagesState.activeBlockIds.delete(block.blockId) + const mappedId = messagesState.activeByAnchorMessageId.get(block.anchorMessageId) + if (mappedId === block.blockId) { + messagesState.activeByAnchorMessageId.delete(block.anchorMessageId) + } + } + + messagesState.blocksById.set(newBlockId, mergedBlock) + messagesState.activeBlockIds.add(newBlockId) + messagesState.activeByAnchorMessageId.set(mergedBlock.anchorMessageId, newBlockId) + + for (const messageId of effectiveMessageIds) { + const entry = messagesState.byMessageId.get(messageId) + if (!entry) continue + entry.activeBlockIds = entry.activeBlockIds.filter((id) => !sourceIds.includes(id)) + if (!entry.activeBlockIds.includes(newBlockId)) { + entry.activeBlockIds.push(newBlockId) + } + if (!entry.allBlockIds.includes(newBlockId)) { + entry.allBlockIds.push(newBlockId) + } + } + + for (const id of sourceIds) { + messagesState.markedForCleanup.delete(id) + } + + const sourceTokens = sourceBlocks.reduce( + (sum, block) => sum + (block.summaryTokens || Math.round(block.summary.length / 4)), + 0, + ) + const savedTokens = Math.max(0, sourceTokens - newSummaryTokens) + + return { mergedCount: sourceBlocks.length, savedTokens } +} + +function buildNudgeText(state: SessionState, maxMergedLength: number): string | undefined { + const blocks = collectActiveMarkedBlocks(state) + if (blocks.length < 1) return undefined + + const refs = blocks.map((b) => formatBlockRef(b.blockId)).join(", ") + const sourceTokens = blocks.reduce( + (sum, block) => sum + (block.summaryTokens || Math.round(block.summary.length / 4)), + 0, + ) + const estimatedMergedTokens = Math.round(maxMergedLength / 4) + const estimatedSavings = Math.max(0, sourceTokens - estimatedMergedTokens) + + return [ + `⚠️ ${blocks.length} block(s) marked for batch cleanup (${refs}).`, + `Merge-compressing them would free ~${estimatedSavings} tokens.`, + blocks.length >= 2 + ? "They will auto-merge when context pressure reaches the high threshold." + : "A single marked block won't auto-merge on its own — use compress to consolidate it, or unmark_block if no longer needed.", + "To act now, use compress with a range covering these blocks.", + ].join(" ") +} + +export function runBatchCleanup( + state: SessionState, + config: PluginConfig, + logger: Logger, + messages: WithParts[], +): BatchCleanupResult { + const noop: BatchCleanupResult = { + tier: 0, + action: "none", + mergedCount: 0, + savedTokens: 0, + } + + if (!state.modelContextLimit || state.modelContextLimit <= 0) { + return noop + } + + const currentTokens = getCurrentTokenUsage(state, messages) + const limit = state.modelContextLimit + const batchCleanup = resolveBatchCleanup(config.gc) + const maxMergedLength = config.gc.maxOldGenSummaryLength + + const forceTokens = percentToTokens(batchCleanup.forceThreshold, limit) + const highTokens = percentToTokens(batchCleanup.highThreshold, limit) + const lowTokens = percentToTokens(batchCleanup.lowThreshold, limit) + + if (currentTokens >= forceTokens) { + const oldGenBlocks = collectActiveOldGenBlocks(state, maxMergedLength) + if (oldGenBlocks.length < 2) { + return noop + } + const ids = oldGenBlocks.map((b) => b.blockId) + const result = mergeMarkedBlocks(state, ids, maxMergedLength) + if (result.mergedCount === 0) { + return noop + } + logger.info("Batch cleanup tier 3 (force): merged old-gen blocks", { + mergedCount: result.mergedCount, + savedTokens: result.savedTokens, + currentTokens, + forceThreshold: batchCleanup.forceThreshold, + }) + return { + tier: 3, + action: "merge", + mergedCount: result.mergedCount, + savedTokens: result.savedTokens, + } + } + + if (currentTokens >= highTokens) { + const marked = collectActiveMarkedBlocks(state) + if (marked.length < 2) { + return noop + } + const ids = marked.map((b) => b.blockId) + const result = mergeMarkedBlocks(state, ids, maxMergedLength) + if (result.mergedCount === 0) { + return noop + } + logger.info("Batch cleanup tier 2 (high): merged marked blocks", { + mergedCount: result.mergedCount, + savedTokens: result.savedTokens, + currentTokens, + highThreshold: batchCleanup.highThreshold, + }) + return { + tier: 2, + action: "merge", + mergedCount: result.mergedCount, + savedTokens: result.savedTokens, + } + } + + if (currentTokens >= lowTokens) { + const nudgeText = buildNudgeText(state, maxMergedLength) + if (!nudgeText) { + return noop + } + logger.info("Batch cleanup tier 1 (low): nudge injected", { + currentTokens, + lowThreshold: batchCleanup.lowThreshold, + }) + return { + tier: 1, + action: "nudge", + mergedCount: 0, + savedTokens: 0, + nudgeText, + } + } + + return noop +} diff --git a/lib/hooks.ts b/lib/hooks.ts index ac47ccc..9cf4576 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -40,7 +40,10 @@ import { compressPermission, syncCompressPermissionState } from "./compress-perm import { checkSession, ensureSessionInitialized, saveSessionState, syncToolCache } from "./state" import { cacheSystemPromptTokens } from "./ui/utils" import { runTruncateGC, shouldRunMajorGC, getGCParams } from "./gc/truncate" +import { runBatchCleanup } from "./gc/merge" import { getCurrentTokenUsage } from "./token-utils" +import { getLastUserMessage } from "./messages/query" +import { appendToLastTextPart } from "./messages/utils" const INTERNAL_AGENT_SIGNATURES = [ "You are a title generator", @@ -186,6 +189,12 @@ function runMajorGC( } } +function appendBatchCleanupNudge(messages: WithParts[], nudgeText: string): void { + const lastUser = getLastUserMessage(messages) + if (!lastUser) return + appendToLastTextPart(lastUser, nudgeText) +} + export function createChatMessageTransformHandler( client: any, state: SessionState, @@ -223,6 +232,13 @@ export function createChatMessageTransformHandler( syncToolCache(state, config, logger, output.messages) buildToolIdList(state, output.messages) runMajorGC(state, config, logger, output.messages) + const batchResult = runBatchCleanup(state, config, logger, output.messages) + if (batchResult.tier === 1 && batchResult.nudgeText) { + appendBatchCleanupNudge(output.messages, batchResult.nudgeText) + } + if (batchResult.mergedCount > 0) { + void saveSessionState(state, logger) + } prune(state, logger, config, output.messages) // [FIX Bug 2] assign refs to newly created synthetic messages from prune/filterCompressedRanges assignMessageRefs(state, output.messages) diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index b32bb32..00f8ee6 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -2,7 +2,7 @@ export const SYSTEM = ` You operate in a context-constrained environment. Context management helps preserve retrieval quality, but your primary goal is completing the task at hand. Do not let context management distract from the actual work. -The tools you have for context management are \`compress\` and \`decompress\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details. +The tools you have for context management are \`compress\`, \`decompress\`, \`mark_block\`, and \`unmark_block\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details. \`mark_block\` flags a compressed block for deferred batch merge-cleanup — it has zero immediate effect on context or cache, but marked blocks are merge-compressed together in a single cache break when context pressure rises. Use it for blocks you no longer need in detail but want to keep cached for now. \`unmark_block\` removes that flag. \`\` and \`\` tags are environment-injected metadata. Do not output them. diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index ea82301..d402af4 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -29,6 +29,7 @@ export interface PersistedPruneMessagesState { activeByAnchorMessageId: Record nextBlockId: number nextRunId: number + markedForCleanup?: number[] } export interface PersistedPrune { diff --git a/lib/state/types.ts b/lib/state/types.ts index 7356b68..8e62fbc 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -70,6 +70,7 @@ export interface PruneMessagesState { activeByAnchorMessageId: Map nextBlockId: number nextRunId: number + markedForCleanup: Set } export interface Prune { diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 7830c9f..ccc48b3 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -36,6 +36,7 @@ interface PersistedPruneMessagesState { activeByAnchorMessageId: Record nextBlockId: number nextRunId: number + markedForCleanup?: number[] } export function serializePruneMessagesState( @@ -53,6 +54,7 @@ export function serializePruneMessagesState( activeByAnchorMessageId: Object.fromEntries(messagesState.activeByAnchorMessageId), nextBlockId: messagesState.nextBlockId, nextRunId: messagesState.nextRunId, + markedForCleanup: Array.from(messagesState.markedForCleanup), } } @@ -117,6 +119,7 @@ export function createPruneMessagesState(): PruneMessagesState { activeByAnchorMessageId: new Map(), nextBlockId: 1, nextRunId: 1, + markedForCleanup: new Set(), } } @@ -293,6 +296,14 @@ export function loadPruneMessagesState( } } + if (Array.isArray(persisted.markedForCleanup)) { + for (const id of persisted.markedForCleanup) { + if (Number.isInteger(id) && id > 0 && state.blocksById.has(id)) { + state.markedForCleanup.add(id) + } + } + } + return state } diff --git a/tests/compress-message.test.ts b/tests/compress-message.test.ts index 33031ba..88d7f91 100644 --- a/tests/compress-message.test.ts +++ b/tests/compress-message.test.ts @@ -70,6 +70,7 @@ function buildConfig(): PluginConfig { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, }, } } diff --git a/tests/compress-range.test.ts b/tests/compress-range.test.ts index 5baf1ec..736f784 100644 --- a/tests/compress-range.test.ts +++ b/tests/compress-range.test.ts @@ -70,6 +70,7 @@ function buildConfig(): PluginConfig { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, }, } } diff --git a/tests/compress-state.test.ts b/tests/compress-state.test.ts index f838cbe..8d29c1a 100644 --- a/tests/compress-state.test.ts +++ b/tests/compress-state.test.ts @@ -312,6 +312,7 @@ test("applyCompressionState promotes old-gen blocks when survivedCount exceeds t maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, } applyCompressionState(state, input, selection, "msg-a", 1, "summary", [], gcConfig) @@ -335,6 +336,7 @@ test("applyCompressionState does not promote blocks below threshold", () => { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, } applyCompressionState(state, input, selection, "msg-a", 1, "summary", [], gcConfig) diff --git a/tests/e2e-blocks-nudges.test.ts b/tests/e2e-blocks-nudges.test.ts index a80c110..d96de89 100644 --- a/tests/e2e-blocks-nudges.test.ts +++ b/tests/e2e-blocks-nudges.test.ts @@ -61,6 +61,7 @@ function buildConfig(overrides: Partial = {}): PluginConfig { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, }, } return { ...base, ...overrides } diff --git a/tests/e2e-message-transform.test.ts b/tests/e2e-message-transform.test.ts index 7a0fe85..a691b56 100644 --- a/tests/e2e-message-transform.test.ts +++ b/tests/e2e-message-transform.test.ts @@ -64,6 +64,7 @@ function buildConfig(overrides: Partial = {}): PluginConfig { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, }, } return { ...base, ...overrides } diff --git a/tests/gc-merge.test.ts b/tests/gc-merge.test.ts new file mode 100644 index 0000000..468682a --- /dev/null +++ b/tests/gc-merge.test.ts @@ -0,0 +1,513 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mergeMarkedBlocks, runBatchCleanup } from "../lib/gc/merge" +import { createSessionState } from "../lib/state" +import { wrapCompressedSummary } from "../lib/compress/state" +import { Logger } from "../lib/logger" +import type { + CompressionBlock, + PrunedMessageEntry, + SessionState, + WithParts, +} from "../lib/state/types" +import type { GCConfig, PluginConfig } from "../lib/config" + +function makeBlock(overrides: Partial = {}): CompressionBlock { + return { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 1000, + summaryTokens: 100, + durationMs: 0, + mode: "range", + topic: "test", + batchTopic: "test", + startId: "m0", + endId: "m5", + anchorMessageId: "anchor-1", + compressMessageId: "comp-1", + compressCallId: undefined, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: [], + effectiveToolIds: [], + createdAt: 1000, + deactivatedAt: undefined, + deactivatedByBlockId: undefined, + summary: "A short summary.", + survivedCount: 5, + generation: "old", + ...overrides, + } +} + +interface MakeStateOptions { + modelContextLimit?: number + marked?: number[] +} + +function makeState(blocks: CompressionBlock[], opts: MakeStateOptions = {}): SessionState { + const state = createSessionState() + state.modelContextLimit = opts.modelContextLimit + + let maxId = 0 + for (const block of blocks) { + state.prune.messages.blocksById.set(block.blockId, block) + if (block.active) { + state.prune.messages.activeBlockIds.add(block.blockId) + if (block.anchorMessageId) { + state.prune.messages.activeByAnchorMessageId.set(block.anchorMessageId, block.blockId) + } + } + if (block.blockId > maxId) maxId = block.blockId + } + state.prune.messages.nextBlockId = Math.max(state.prune.messages.nextBlockId, maxId + 1) + state.prune.messages.nextRunId = Math.max(state.prune.messages.nextRunId, maxId + 1) + + for (const id of opts.marked ?? []) { + state.prune.messages.markedForCleanup.add(id) + } + + return state +} + +function registerMessage( + state: SessionState, + messageId: string, + blockIds: number[], + tokenCount = 100, +): PrunedMessageEntry { + const entry: PrunedMessageEntry = { + tokenCount, + allBlockIds: [...blockIds], + activeBlockIds: [...blockIds], + } + state.prune.messages.byMessageId.set(messageId, entry) + return entry +} + +function buildConfig(gcOverrides: Partial = {}): PluginConfig { + return { + enabled: true, + autoUpdate: true, + debug: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { enabled: true, protectedTools: [] }, + manualMode: { enabled: false, automaticStrategies: true }, + turnProtection: { enabled: false, turns: 4 }, + experimental: { allowSubAgents: false, customPrompts: false }, + protectedFilePatterns: [], + compress: { + mode: "range", + permission: "allow", + showCompression: false, + summaryBuffer: true, + maxContextLimit: 150000, + minContextLimit: 50000, + nudgeFrequency: 5, + iterationNudgeThreshold: 15, + nudgeForce: "soft", + protectedTools: [], + protectTags: false, + protectUserMessages: false, + }, + strategies: { + deduplication: { enabled: true, protectedTools: [] }, + purgeErrors: { enabled: true, turns: 4, protectedTools: [] }, + }, + gc: { + algorithm: "truncate", + promotionThreshold: 5, + maxBlockAge: 15, + maxOldGenSummaryLength: 3000, + majorGcThresholdPercent: "100%", + batchCleanup: { + lowThreshold: "60%", + highThreshold: "75%", + forceThreshold: "90%", + }, + ...gcOverrides, + }, + } +} + +function makeAssistantMessage(id: string, totalTokens: number, sessionId = "s1"): WithParts { + return { + info: { + id, + sessionID: sessionId, + role: "assistant", + time: { created: Date.now() }, + parentID: "parent-1", + modelID: "test-model", + providerID: "test-provider", + mode: "normal", + agent: "code", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { + input: 0, + output: totalTokens, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + parts: [ + { type: "text", text: "ok", id: `${id}-p1`, sessionID: sessionId, messageID: id }, + ], + } +} + +test("mergeMarkedBlocks: merges 2 blocks → creates new block, deactivates sources, updates indexes", () => { + const block1 = makeBlock({ + blockId: 1, + runId: 1, + anchorMessageId: "anchor-1", + summary: wrapCompressedSummary(1, "Body of block one"), + summaryTokens: 50, + effectiveMessageIds: ["m1", "m2"], + effectiveToolIds: ["t1"], + }) + const block2 = makeBlock({ + blockId: 2, + runId: 2, + anchorMessageId: "anchor-2", + summary: wrapCompressedSummary(2, "Body of block two"), + summaryTokens: 60, + effectiveMessageIds: ["m3"], + effectiveToolIds: ["t2"], + }) + const state = makeState([block1, block2], { marked: [1, 2] }) + registerMessage(state, "m1", [1]) + registerMessage(state, "m2", [1]) + registerMessage(state, "m3", [2]) + + const newId = state.prune.messages.nextBlockId + const result = mergeMarkedBlocks(state, [1, 2], 3000) + + assert.equal(result.mergedCount, 2) + assert.ok(result.savedTokens >= 0) + + const merged = state.prune.messages.blocksById.get(newId) + assert.ok(merged, "merged block created") + assert.equal(merged!.active, true) + assert.equal(merged!.generation, "old") + assert.ok(state.prune.messages.activeBlockIds.has(newId)) + + assert.equal(block1.active, false) + assert.equal(block2.active, false) + assert.equal(block1.deactivatedByBlockId, newId) + assert.equal(block2.deactivatedByBlockId, newId) + assert.equal(state.prune.messages.activeBlockIds.has(1), false) + assert.equal(state.prune.messages.activeBlockIds.has(2), false) + + assert.ok(merged!.summary.includes("Body of block one")) + assert.ok(merged!.summary.includes("Body of block two")) + + assert.deepEqual( + [...merged!.effectiveMessageIds].sort(), + ["m1", "m2", "m3"], + ) + assert.deepEqual( + [...merged!.effectiveToolIds].sort(), + ["t1", "t2"], + ) + + const entryM1 = state.prune.messages.byMessageId.get("m1")! + assert.ok(!entryM1.activeBlockIds.includes(1)) + assert.ok(entryM1.activeBlockIds.includes(newId)) + assert.ok(entryM1.allBlockIds.includes(newId)) + + assert.equal( + state.prune.messages.activeByAnchorMessageId.get("anchor-1"), + newId, + ) + assert.equal(state.prune.messages.activeByAnchorMessageId.has("anchor-2"), false) + + assert.equal(state.prune.messages.markedForCleanup.size, 0) +}) + +test("mergeMarkedBlocks: merges 3 blocks with overlapping effectiveMessageIds → union correct", () => { + const block1 = makeBlock({ + blockId: 1, + anchorMessageId: "a1", + summary: wrapCompressedSummary(1, "block one body"), + effectiveMessageIds: ["m1", "m2"], + }) + const block2 = makeBlock({ + blockId: 2, + runId: 2, + anchorMessageId: "a2", + summary: wrapCompressedSummary(2, "block two body"), + effectiveMessageIds: ["m2", "m3"], + }) + const block3 = makeBlock({ + blockId: 3, + runId: 3, + anchorMessageId: "a3", + summary: wrapCompressedSummary(3, "block three body"), + effectiveMessageIds: ["m3", "m4"], + }) + const state = makeState([block1, block2, block3]) + + const newId = state.prune.messages.nextBlockId + const result = mergeMarkedBlocks(state, [3, 1, 2], 3000) + + assert.equal(result.mergedCount, 3) + const merged = state.prune.messages.blocksById.get(newId)! + assert.equal(merged.effectiveMessageIds.length, 4) + for (const id of ["m1", "m2", "m3", "m4"]) { + assert.ok(merged.effectiveMessageIds.includes(id), `union should include ${id}`) + } +}) + +test("mergeMarkedBlocks: single block (< 2) → noop", () => { + const block1 = makeBlock({ blockId: 1 }) + const state = makeState([block1]) + const result = mergeMarkedBlocks(state, [1], 3000) + assert.equal(result.mergedCount, 0) + assert.equal(result.savedTokens, 0) + assert.equal(block1.active, true) +}) + +test("mergeMarkedBlocks: empty array → noop", () => { + const block1 = makeBlock({ blockId: 1 }) + const block2 = makeBlock({ blockId: 2, runId: 2 }) + const state = makeState([block1, block2]) + const result = mergeMarkedBlocks(state, [], 3000) + assert.equal(result.mergedCount, 0) + assert.equal(result.savedTokens, 0) +}) + +test("mergeMarkedBlocks: inactive block in input → filtered out", () => { + const block1 = makeBlock({ blockId: 1, active: true }) + const block2 = makeBlock({ blockId: 2, runId: 2, active: false }) + const state = makeState([block1, block2]) + const result = mergeMarkedBlocks(state, [1, 2], 3000) + assert.equal(result.mergedCount, 0) + assert.equal(result.savedTokens, 0) + assert.equal(block1.active, true) +}) + +test("mergeMarkedBlocks: markedForCleanup only clears merged IDs (not all)", () => { + const block1 = makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one") }) + const block2 = makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two") }) + const block3 = makeBlock({ blockId: 3, runId: 3, anchorMessageId: "a3", summary: wrapCompressedSummary(3, "three") }) + const state = makeState([block1, block2, block3], { marked: [1, 2, 3] }) + + mergeMarkedBlocks(state, [1, 2], 3000) + + assert.equal(state.prune.messages.markedForCleanup.has(1), false) + assert.equal(state.prune.messages.markedForCleanup.has(2), false) + assert.equal(state.prune.messages.markedForCleanup.has(3), true) + assert.equal(state.prune.messages.markedForCleanup.size, 1) +}) + +test("mergeMarkedBlocks: new merged block has generation old and survivedCount 0", () => { + const block1 = makeBlock({ + blockId: 1, + anchorMessageId: "a1", + summary: wrapCompressedSummary(1, "one"), + survivedCount: 9, + generation: "old", + }) + const block2 = makeBlock({ + blockId: 2, + runId: 2, + anchorMessageId: "a2", + summary: wrapCompressedSummary(2, "two"), + survivedCount: 7, + generation: "old", + }) + const state = makeState([block1, block2]) + + const newId = state.prune.messages.nextBlockId + mergeMarkedBlocks(state, [1, 2], 3000) + + const merged = state.prune.messages.blocksById.get(newId)! + assert.equal(merged.generation, "old") + assert.equal(merged.survivedCount, 0) +}) + +test("mergeMarkedBlocks: reports saved tokens as reduction from source summaries", () => { + const longBody = "x".repeat(4000) + const block1 = makeBlock({ + blockId: 1, + anchorMessageId: "a1", + summary: wrapCompressedSummary(1, longBody), + summaryTokens: 1000, + }) + const block2 = makeBlock({ + blockId: 2, + runId: 2, + anchorMessageId: "a2", + summary: wrapCompressedSummary(2, longBody), + summaryTokens: 1000, + }) + const state = makeState([block1, block2]) + + const result = mergeMarkedBlocks(state, [1, 2], 3000) + assert.equal(result.mergedCount, 2) + assert.ok(result.savedTokens > 0, "truncation should free tokens") +}) + +const logger = new Logger(false) + +test("runBatchCleanup: below low threshold (50%) → noop tier 0", () => { + const blocks = [ + makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one") }), + makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two") }), + ] + const state = makeState(blocks, { modelContextLimit: 1000, marked: [1, 2] }) + const messages: WithParts[] = [makeAssistantMessage("a1", 500)] + + const result = runBatchCleanup(state, buildConfig(), logger, messages) + assert.equal(result.tier, 0) + assert.equal(result.action, "none") + assert.equal(result.mergedCount, 0) + assert.equal(state.prune.messages.activeBlockIds.size, 2) +}) + +test("runBatchCleanup: at low threshold (60%) with marked blocks → tier 1 nudge", () => { + const blocks = [ + makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one") }), + makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two") }), + ] + const state = makeState(blocks, { modelContextLimit: 1000, marked: [1, 2] }) + const messages: WithParts[] = [makeAssistantMessage("a1", 600)] + + const result = runBatchCleanup(state, buildConfig(), logger, messages) + assert.equal(result.tier, 1) + assert.equal(result.action, "nudge") + assert.equal(result.mergedCount, 0) + assert.ok(result.nudgeText, "nudge text should be provided") + assert.ok(result.nudgeText!.includes("b1")) + assert.ok(result.nudgeText!.includes("b2")) + assert.equal(state.prune.messages.activeBlockIds.size, 2) +}) + +test("runBatchCleanup: at high threshold (75%) with >= 2 marked blocks → tier 2 merge", () => { + const blocks = [ + makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one") }), + makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two") }), + ] + const state = makeState(blocks, { modelContextLimit: 1000, marked: [1, 2] }) + const messages: WithParts[] = [makeAssistantMessage("a1", 750)] + + const result = runBatchCleanup(state, buildConfig(), logger, messages) + assert.equal(result.tier, 2) + assert.equal(result.action, "merge") + assert.equal(result.mergedCount, 2) + assert.ok(result.savedTokens >= 0) + assert.equal(state.prune.messages.markedForCleanup.size, 0) + assert.equal(state.prune.messages.activeBlockIds.size, 1) +}) + +test("runBatchCleanup: at high threshold (75%) with 1 marked block → noop (< 2)", () => { + const blocks = [ + makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one") }), + makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two") }), + ] + const state = makeState(blocks, { modelContextLimit: 1000, marked: [1] }) + const messages: WithParts[] = [makeAssistantMessage("a1", 750)] + + const result = runBatchCleanup(state, buildConfig(), logger, messages) + assert.equal(result.tier, 0) + assert.equal(result.action, "none") + assert.equal(result.mergedCount, 0) + assert.equal(state.prune.messages.activeBlockIds.size, 2) +}) + +test("runBatchCleanup: at force threshold (90%) with >= 2 old-gen blocks → tier 3 force merge", () => { + const blocks = [ + makeBlock({ + blockId: 1, + anchorMessageId: "a1", + summary: wrapCompressedSummary(1, "one"), + generation: "old", + }), + makeBlock({ + blockId: 2, + runId: 2, + anchorMessageId: "a2", + summary: wrapCompressedSummary(2, "two"), + generation: "old", + }), + ] + const state = makeState(blocks, { modelContextLimit: 1000 }) + const messages: WithParts[] = [makeAssistantMessage("a1", 900)] + + const result = runBatchCleanup(state, buildConfig(), logger, messages) + assert.equal(result.tier, 3) + assert.equal(result.action, "merge") + assert.equal(result.mergedCount, 2) + assert.equal(state.prune.messages.activeBlockIds.size, 1) +}) + +test("runBatchCleanup: modelContextLimit undefined → noop", () => { + const blocks = [ + makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one") }), + makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two") }), + ] + const state = makeState(blocks, { modelContextLimit: undefined, marked: [1, 2] }) + const messages: WithParts[] = [makeAssistantMessage("a1", 999999)] + + const result = runBatchCleanup(state, buildConfig(), logger, messages) + assert.equal(result.tier, 0) + assert.equal(result.action, "none") + assert.equal(result.mergedCount, 0) +}) + +test("runBatchCleanup: tier ordering — force takes precedence over high and low at 95%", () => { + const blocks = [ + makeBlock({ + blockId: 1, + anchorMessageId: "a1", + summary: wrapCompressedSummary(1, "one"), + generation: "old", + }), + makeBlock({ + blockId: 2, + runId: 2, + anchorMessageId: "a2", + summary: wrapCompressedSummary(2, "two"), + generation: "old", + }), + ] + const state = makeState(blocks, { modelContextLimit: 1000, marked: [1, 2] }) + const messages: WithParts[] = [makeAssistantMessage("a1", 950)] + + const result = runBatchCleanup(state, buildConfig(), logger, messages) + assert.equal(result.tier, 3, "force tier must win over high/low when usage >= 90%") + assert.equal(result.action, "merge") +}) + +test("runBatchCleanup: high tier requires marked blocks, old-gen alone does not trigger it", () => { + const blocks = [ + makeBlock({ + blockId: 1, + anchorMessageId: "a1", + summary: wrapCompressedSummary(1, "one"), + generation: "old", + }), + makeBlock({ + blockId: 2, + runId: 2, + anchorMessageId: "a2", + summary: wrapCompressedSummary(2, "two"), + generation: "old", + }), + ] + const state = makeState(blocks, { modelContextLimit: 1000 }) + const messages: WithParts[] = [makeAssistantMessage("a1", 800)] + + const result = runBatchCleanup(state, buildConfig(), logger, messages) + assert.equal(result.tier, 0, "without marks, high tier should not fire for unmarked old-gen blocks") + assert.equal(result.action, "none") +}) diff --git a/tests/gc-truncate-pure.test.ts b/tests/gc-truncate-pure.test.ts index 4c78852..8134bea 100644 --- a/tests/gc-truncate-pure.test.ts +++ b/tests/gc-truncate-pure.test.ts @@ -10,6 +10,7 @@ function makeGCConfig(overrides: Partial = {}): GCConfig { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, ...overrides, } } diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts index 502e681..422cc89 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -68,6 +68,7 @@ function buildConfig(permission: "allow" | "ask" | "deny" = "allow"): PluginConf maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, }, } } diff --git a/tests/query-mock.test.ts b/tests/query-mock.test.ts index 1d75630..44cdcdd 100644 --- a/tests/query-mock.test.ts +++ b/tests/query-mock.test.ts @@ -142,6 +142,7 @@ function makeConfig(overrides: Partial = {}): PluginConfig { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, }, strategies: { deduplication: { enabled: true, protectedTools: [] }, diff --git a/tests/strategies-dedup.test.ts b/tests/strategies-dedup.test.ts index 3240f99..a21c142 100644 --- a/tests/strategies-dedup.test.ts +++ b/tests/strategies-dedup.test.ts @@ -76,6 +76,7 @@ function makeConfig(overrides: Partial = {}): PluginConfig { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, }, strategies: { deduplication: { enabled: true, protectedTools: [] }, diff --git a/tests/strategies-purge-errors.test.ts b/tests/strategies-purge-errors.test.ts index 05661c2..8b16bf7 100644 --- a/tests/strategies-purge-errors.test.ts +++ b/tests/strategies-purge-errors.test.ts @@ -76,6 +76,7 @@ function makeConfig(overrides: Partial = {}): PluginConfig { maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, }, strategies: { deduplication: { enabled: true, protectedTools: [] },