Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
160 changes: 160 additions & 0 deletions devlog/2026-06-26_mark-block-batch-cleanup/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# DESIGN: mark_block + Batch Cleanup

## Architecture

### New state field

`PruneMessagesState` in `lib/state/types.ts`:
```ts
markedForCleanup: Set<number> // 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<number>` 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)
31 changes: 31 additions & 0 deletions devlog/2026-06-26_mark-block-batch-cleanup/REQ.md
Original file line number Diff line number Diff line change
@@ -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)
81 changes: 81 additions & 0 deletions devlog/2026-06-26_mark-block-batch-cleanup/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -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<number>` 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.
6 changes: 5 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
createCompressMessageTool,
createCompressRangeTool,
createDecompressTool,
createMarkBlockTool,
createUnmarkBlockTool,
} from "./lib/compress"
import {
compressDisabledByOpencode,
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions lib/compress/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading