From 31fbde15a422c54501068faa0f94dec74e678d19 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Mon, 8 Jun 2026 00:02:00 +0800 Subject: [PATCH] feat: add model-exposed decompress tool for context restoration Adds a decompress tool that lets AI models restore previously compressed conversation blocks, complementing the existing compress tool. Includes shared logic extraction from decompress command, 36 new tests, and devlog documentation. Closes #7 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../DESIGN.md | 416 ++++++++++++++++++ .../2026-06-07_model-decompress-tool/REQ.md | 72 +++ .../WORKLOG.md | 79 ++++ index.ts | 9 +- lib/commands/decompress.ts | 119 +---- lib/compress/decompress-logic.ts | 200 +++++++++ lib/compress/decompress.ts | 192 ++++++++ lib/compress/index.ts | 1 + lib/config.ts | 3 +- lib/prompts/extensions/system.ts | 23 + lib/prompts/index.ts | 4 + lib/prompts/store.ts | 5 +- lib/prompts/system.ts | 2 +- tests/decompress-logic.test.ts | 414 +++++++++++++++++ 14 files changed, 1431 insertions(+), 108 deletions(-) create mode 100644 devlog/2026-06-07_model-decompress-tool/DESIGN.md create mode 100644 devlog/2026-06-07_model-decompress-tool/REQ.md create mode 100644 devlog/2026-06-07_model-decompress-tool/WORKLOG.md create mode 100644 lib/compress/decompress-logic.ts create mode 100644 lib/compress/decompress.ts create mode 100644 tests/decompress-logic.test.ts diff --git a/devlog/2026-06-07_model-decompress-tool/DESIGN.md b/devlog/2026-06-07_model-decompress-tool/DESIGN.md new file mode 100644 index 0000000..51a8111 --- /dev/null +++ b/devlog/2026-06-07_model-decompress-tool/DESIGN.md @@ -0,0 +1,416 @@ +# DESIGN - Expose decompress tool to AI model + +- Task ID: `2026-06-07_model-decompress-tool` +- Home Repo: `opencode-acp` +- Created: 2026-06-07 +- Status: Draft (Revised after dual-agent review) + +## 1. Problem Statement + +- **What problem are we solving?** ACP currently provides asymmetric context management — the model can `compress` but not `decompress`. When the model needs original details from a compressed block, it's stuck. Only human users can restore compressed content via `/acp decompress`. This limits the model's ability to manage context autonomously. +- **Why now?** The decompress command is battle-tested (283 lines, handles nested blocks, ancestor resolution, state persistence). Wrapping it as a tool is low-risk and high-value. + +## 2. Goals & Non-Goals + +- **Goals**: + - Expose `decompress` as a model-accessible tool + - Reuse existing decompress logic from `lib/commands/decompress.ts` + - Return restored content inline so the model can reason immediately + - Update system prompt with decompress usage guidance via conditional extension + - Ensure model is aware of context budget before decompressing + - Handle GC interaction after context inflation from decompress + - Maintain backward compatibility with `/acp decompress` command +- **Non-Goals**: + - Exposing `recompress` to the model (different risk profile, can be a follow-up) + - Changing the `/acp decompress` command behavior + - Adding new config options (use existing `compress.permission` to control both tools) + - Modifying the GC system core — only adding awareness/documentation + +## 3. Current Architecture + +- **How it works today**: + - `compress` tool: Registered in `index.ts` (L81-88), created by `createCompressRangeTool()` or `createCompressMessageTool()` in `lib/compress/` + - `/acp decompress` command: `handleDecompressCommand()` in `lib/commands/decompress.ts` — sets `block.active=false`, `block.deactivatedByUser=true`, syncs, persists + - Tool registration pattern: `tool({ description, args, execute })` from `@opencode-ai/plugin` + - System prompt: `lib/prompts/system.ts` — mentions only `compress` + - Pipeline: `prepareSession()`/`finalizeSession()` in `lib/compress/pipeline.ts` — compression-specific (dedup/purge, manual mode guard, compress notification) + +- **Pain points**: + - Model cannot recover compressed content autonomously + - User intervention required for every decompress operation + +## 4. Proposed Architecture + +### Overview + +``` +index.ts + └─► tool: { compress, decompress } ← NEW: add decompress tool + │ + ├─► compress tool (unchanged) + │ ├─► prepareSession() ← pipeline.ts (compression-specific) + │ └─► finalizeSession() ← pipeline.ts (compression-specific) + │ + └─► decompress tool (NEW) ← lib/compress/decompress.ts + │ + ├─► prepareDecompressSession() ← NEW: decompress-specific prepare + ├─► resolveDecompressTarget() ← extract shared logic from command + ├─► deactivateBlocks() ← extract shared logic from command + ├─► syncCompressionBlocks() ← existing + ├─► buildRestoredContentPreview() ← NEW: condensed restored content + └─► finalizeDecompressSession() ← NEW: decompress-specific finalize +``` + +### Key Components + +#### 4.1 New file: `lib/compress/decompress.ts` + +A tool wrapper that reuses the decompress command's core logic: + +```typescript +// Schema +{ + blockId: string // Block reference: "b0", "b1", etc. +} + +// Return value (shown to model) — includes restored content inline +"Decompressed block b2. Restored 5 messages (~2.1K tokens). Context usage: 38% → 52%. +Nested blocks b3 also restored. + +RESTORED CONTENT (condensed): +[User] Asked about auth token refresh behavior in session middleware +[Assistant] Investigated lib/auth/session.ts — token refresh happens at L142-168... +[Tool:Read] lib/auth/session.ts (L140-170): refreshToken() checks expiry... +[Assistant] The refresh logic uses a sliding window... +..." +``` + +**Key design decisions**: + +1. **Single `blockId` parameter** (not an array). Decompress is a targeted operation. Matches `/acp decompress ` UX pattern. + +2. **Return restored content inline** (critical — Review #2 C2). After decompress, the model won't see restored messages until the **next turn**. Without inline content, the model wastes a turn doing "blind decompress". The tool returns a condensed preview (~2000 chars) of the restored messages so the model can reason immediately. + +3. **Context usage feedback** (both reviewers). Include "Context usage: X% → Y%" in return value so the model can gauge impact. + +#### 4.2 Decompress-specific prepare/finalize (critical — Review #1+ #2 C1) + +**Do NOT reuse `prepareSession()`/`finalizeSession()` from `pipeline.ts`.** These are compression-specific: +- `prepareSession()` runs dedup/purge strategies and has a compress-specific manual mode guard message +- `finalizeSession()` transitions manual mode state and calls `sendCompressNotification()` + +Instead, create lightweight decompress-specific functions: + +```typescript +// lib/compress/decompress.ts + +async function prepareDecompressSession( + ctx: ToolContext, + toolCtx: RunContext, +): Promise<{ rawMessages: WithParts[] }> { + // Permission check (reuse compress.permission) + await toolCtx.ask({ + permission: "compress", // shared permission + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + + toolCtx.metadata({ title: "Decompress" }) + + 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) + // NOTE: No dedup/purge strategies — those are compression-specific + // NOTE: No manual mode guard — decompress is always allowed + + return { rawMessages } +} + +async function finalizeDecompressSession( + ctx: ToolContext, + toolCtx: RunContext, +): Promise { + await saveSessionState(ctx.state, ctx.logger) + // NOTE: No manual mode state transition + // NOTE: No compress notification — decompress uses tool return value instead +} +``` + +#### 4.3 Shared logic extraction + +Extract reusable functions from `lib/commands/decompress.ts` into a shared module `lib/compress/decompress-logic.ts`: + +| Function | Source | Used By | +|----------|--------|---------| +| `parseBlockIdArg()` | `decompress.ts` L24-37 | Tool + Command | +| `findActiveParentBlockId()` | `decompress.ts` L39-70 | Tool + Command | +| `findActiveAncestorBlockId()` | `decompress.ts` L72-84 | Tool + Command | +| `snapshotActiveMessages()` | `decompress.ts` L86-94 | Tool + Command | +| `deactivateCompressionTarget()` | Inline in command L232-245 | Tool + Command | +| `computeRestoredMessages()` | Inline in command L249-258 | Tool + Command | + +The command and tool both call the same logic functions. The command adds UI formatting (available blocks listing, usage hints), the tool adds permission checks, inline content preview, and model-oriented feedback. + +#### 4.4 Registration in `index.ts` + +```typescript +tool: { + ...(config.compress.permission !== "deny" && { + compress: /* existing */, + decompress: createDecompressTool(compressToolContext), // NEW + }), +}, +``` + +Same permission gate as `compress` — if compress is denied, decompress is also denied. + +#### 4.5 Primary tools config + +In the `config` hook, add `decompress` to `primary_tools` alongside `compress`: + +```typescript +if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) { + toolsToAdd.push("compress", "decompress") +} +``` + +#### 4.6 System prompt update + +**Use a conditional extension in `lib/prompts/extensions/system.ts`**, not base prompt modification (both reviewers). + +Modify `lib/prompts/system.ts` base prompt: +``` +The tools you have for context management are `compress` and `decompress`. +``` + +Add new `DECOMPRESS_SYSTEM_EXTENSION` in `lib/prompts/extensions/system.ts`: +``` +THE PHILOSOPHY OF DECOMPRESS + +`decompress` restores previously compressed content. Use it when you need exact details +that were lost in compression. + +DECOMPRESS WHEN +- You need exact code, error messages, or file contents from a compressed block +- A summary lacks the precision needed for your next step +- You discovered the compressed content is still relevant + +DO NOT DECOMPRESS IF +- Context usage is already high (>70%) — decompressing inflates context +- The summary is sufficient for your needs +- You plan to immediately recompress the same content + +Before decompressing, check context usage. Decompressing restores full messages, +which can significantly increase context size. + +NOTE: Message-mode blocks created in the same batch (same runId) are restored together. +Decompressing one block from a batch restores all blocks in that batch. +``` + +This extension is appended to the system prompt when decompress is enabled (i.e., when `compress.permission !== 'deny'`). + +#### 4.7 Tool description (self-contained — Review #2 M3) + +The tool description is self-contained in the tool definition, not using the extension pattern from `tool.ts`: + +```typescript +tool({ + description: `Restores previously compressed content identified by a block ID. + +Use this tool when you need exact details from a compressed block that the summary cannot provide. +The tool returns a condensed preview of the restored content so you can reason about it immediately. + +Argument: blockId — the block reference to decompress (e.g., "b0", "b2") + +IMPORTANT: +- Decompressing inflates context. Check context usage before decompressing. +- Message-mode blocks from the same batch (same runId) are restored together. +- After decompression, the restored messages will appear in full in your next context window. +- Do NOT call this tool in parallel with compress — their state mutations may conflict.`, + // ... +}) +``` + +#### 4.8 Protected tools default + +Add `decompress` to the **`DEFAULT_PROTECTED_TOOLS` constant** in `config.ts` (L89-100): + +```typescript +const DEFAULT_PROTECTED_TOOLS = [ + "task", + "skill", + "todowrite", + "todoread", + "compress", + "decompress", // NEW + "batch", + "plan_enter", + "plan_exit", + "write", + "edit", +] +``` + +Also add to `COMPRESS_DEFAULT_PROTECTED_TOOLS`: +```typescript +const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["task", "skill", "todowrite", "todoread", "decompress"] +``` + +And to `commands.protectedTools` in the default config: +```typescript +commands: { + protectedTools: ["task", "skill", "todowrite", "todoread", "compress", "decompress", "batch", "plan_enter", "plan_exit", "write", "edit"], +} +``` + +And to `compress.protectedTools`: +```typescript +compress: { + protectedTools: ["task", "skill", "todowrite", "todoread", "decompress"], +} +``` + +### Data Flow + +``` +Model calls decompress(blockId: "b2") + │ + ▼ +createDecompressTool.execute() + │ + ├─► prepareDecompressSession() — permission check, fetch messages, init state, assign refs + │ (NO dedup/purge, NO manual mode guard) + │ + ├─► Parse blockId: "b2" → numeric block ID 2 (via parseBlockIdArg) + │ + ├─► resolveCompressionTarget() — find the block in state + │ ├─► Validate: block exists + │ ├─► Validate: block is active + │ └─► Handle nested: check ancestor blocks + │ + ├─► snapshotActiveMessages() — record which messages are compressed (before) + │ + ├─► deactivateCompressionTarget() — deactivate target + consumed inner blocks + │ ├─► block.active = false, deactivatedByUser = true, deactivatedAt = now + │ └─► Mark consumed inner blocks: consumedBlock.deactivatedByUser = true + │ + ├─► syncCompressionBlocks() — recalculate active blocks, reactivate nested blocks + │ + ├─► computeRestoredMessages() — diff snapshot vs current state + │ └─► Count restored messages + tokens + │ └─► Math.max(0, state.stats.totalPruneTokens - restoredTokens) ← GUARD + │ + ├─► buildRestoredContentPreview() — extract condensed restored content (~2000 chars) + │ ├─► Find messages that were decompressed + │ ├─► For each: extract role + truncated content (~200 chars each) + │ └─► Total preview capped at ~2000 chars + │ + ├─► finalizeDecompressSession() — save state (NO compress notification) + │ + └─► Return result to model + "Decompressed block b2. Restored 5 messages (~2.1K tokens). Context usage: 38% → 52%. + Also restored nested block b3. + + RESTORED CONTENT (condensed): + [User] Asked about auth token refresh... + [Assistant] Investigated lib/auth/session.ts... + ..." +``` + +## 5. Design Decisions & Rationale + +| Decision | Options Considered | Chosen | Why | +|----------|--------------------|--------|-----| +| Tool parameter | A) Single `blockId` B) Array of block IDs C) Range-like start/end | A) Single `blockId` | Decompress is targeted (one block at a time). Array adds complexity without real benefit. Range doesn't make sense for decompress. | +| Shared logic location | A) Extract to `decompress-logic.ts` B) Import from command C) Duplicate | A) Extract to shared module | Clean separation, testable, both command and tool use identical logic | +| Permission model | A) Share `compress.permission` B) New `decompress.permission` config C) Always allow | A) Share existing permission | Compress and decompress are paired operations. If compress is denied, decompress is meaningless. Simpler config. | +| Prepare/finalize | A) Reuse pipeline.ts B) Decompress-specific | B) Decompress-specific | pipeline.ts is compression-specific (dedup/purge, manual mode guard, compress notification). Reusing would inject wrong behavior. | +| System prompt | A) Modify base system.ts B) Conditional extension in system.ts C) Extension in tool.ts | B) Conditional extension | Keeps base prompt minimal. Extension is appended only when decompress is enabled. Users can override via prompt store. | +| Tool description | A) Self-contained in tool definition B) Extension pattern from tool.ts | A) Self-contained | Decompress is simpler than compress (no format schema). Self-contained description is clearer and doesn't need the editable/prompt-store pattern. | +| Return content | A) Summary only B) Inline restored content | B) Inline restored content | Model won't see restored messages until next turn — "blind decompress" wastes a turn. Condensed preview (~2000 chars) gives immediate reasoning ability. | +| `deactivatedByUser` semantic | A) Same as command B) New flag for model-initiated | A) Same flag | Model-decompressed blocks should be recompressible via `/acp recompress`. Using the same flag ensures consistent behavior. | +| GC interaction | A) Suppress GC post-decompress B) Pre-decompress warning only C) Document risk only | B) Pre-decompress context check + document risk | Suppressing GC adds complexity and risk. Instead, the tool includes context usage in return value so the model can self-regulate. GC may deactivate other blocks after inflation — this is documented in the tool description. | + +## 6. GC Interaction Analysis + +**Problem**: Decompress inflates context by restoring original messages. On the next message-transform cycle, GC (`runMajorGC` in `hooks.ts`) may see elevated usage and aggressively deactivate other blocks or truncate old-gen summaries. + +**Mitigations**: +1. **Tool returns context usage before/after** — model sees the impact and can decide to recompress +2. **Tool description warns about high context** — model should avoid decompressing when usage is already high +3. **Reactivated nested blocks retain stale `survivedCount`/`generation`** — GC may truncate them sooner than expected. This is acceptable because: (a) the model sees restored content inline and can act on it immediately, (b) if GC truncates, the model still had one turn with the information +4. **No GC suppression** — adding post-decompress GC suppression would require coupling between decompress tool and GC module, increasing complexity and risk. The GC's current behavior is conservative enough. + +**Risk level**: Medium. Acceptable because decompress is a deliberate model action with inline feedback. + +## 7. `deactivatedByUser` and Recompress Coupling + +**Current behavior**: `deactivatedByUser=true` prevents blocks from being reactivated by `syncCompressionBlocks()` — they can only be restored via explicit decompress/recompress. + +**Model decompress interaction**: When the model calls decompress, `deactivatedByUser=true` is set on the target block and its consumed inner blocks. This means: +- `/acp recompress` CAN restore these blocks (it explicitly re-runs compression on deactivated blocks) +- `syncCompressionBlocks()` will NOT reactivate them automatically +- This is the desired behavior — model-decompressed blocks should only be restored via explicit action + +## 8. Impact Analysis + +- **Backward compatibility**: + - ✅ No changes to persisted state format + - ✅ `/acp decompress` command unchanged + - ✅ Config schema unchanged (reuses `compress.permission`) + - ⚠️ System prompt text changes — "ONLY tool" → "tools... are" — users with custom prompt overrides via prompt store won't see the updated text (acceptable, same pattern as compress) + - ⚠️ Default `protectedTools` arrays gain `decompress` — existing configs that override this array won't include it (acceptable, same pattern as existing tools) +- **Performance**: Negligible — decompress is synchronous state mutation, same as compress. Inline content preview adds ~2000 chars of string processing. +- **Security**: No new concerns — same permission gate as compress +- **Dependencies**: No new packages required + +## 9. Migration Plan + +- **Steps**: + 1. Create `lib/compress/decompress-logic.ts` with extracted shared functions + 2. Refactor `lib/commands/decompress.ts` to use shared functions + 3. Create `lib/compress/decompress.ts` tool with decompress-specific prepare/finalize + inline content preview + 4. Register in `index.ts` (tool + primary_tools) + 5. Update `lib/prompts/system.ts` base prompt text + 6. Add `DECOMPRESS_SYSTEM_EXTENSION` in `lib/prompts/extensions/system.ts` + 7. Update `DEFAULT_PROTECTED_TOOLS` + `COMPRESS_DEFAULT_PROTECTED_TOOLS` in `lib/config.ts` + 8. Update default config `commands.protectedTools` and `compress.protectedTools` + 9. Add tests +- **Feature flags / gradual rollout**: Uses existing `compress.permission` — setting it to `"deny"` disables both compress and decompress + +## 10. Open Questions + +- [RESOLVED] ~~Should the decompress tool also return the restored content inline, or just a summary?~~ **Decision**: Return condensed inline content (~2000 chars) — model can't see restored messages until next turn. +- [RESOLVED] ~~Should we add a config option `compress.decompressEnabled`?~~ **Decision**: No — adds config surface for minimal value. Can add later if needed. +- [RESOLVED] ~~Should the tool show context usage in return value?~~ **Decision**: Yes — include "Context usage: X% → Y%". +- [ ] Should reactivated nested blocks have their `survivedCount` reset to 0 to prevent immediate GC truncation? **Lean**: No — adds complexity, model gets one turn with the content regardless. +- [ ] Should the inline content preview length be configurable? **Lean**: No — 2000 chars is a reasonable default. Can add config later if needed. + +## 11. Files Changed + +| File | Change Type | Description | +|------|-------------|-------------| +| `lib/compress/decompress-logic.ts` | **NEW** | Shared decompress logic extracted from command | +| `lib/compress/decompress.ts` | **NEW** | Decompress tool implementation with inline content preview | +| `lib/compress/index.ts` | MODIFY | Export new module | +| `lib/commands/decompress.ts` | MODIFY | Refactor to use shared logic from decompress-logic.ts | +| `index.ts` | MODIFY | Register decompress tool + primary_tools | +| `lib/prompts/system.ts` | MODIFY | Update base prompt: "ONLY tool" → "tools... are" | +| `lib/prompts/extensions/system.ts` | MODIFY | Add DECOMPRESS_SYSTEM_EXTENSION | +| `lib/config.ts` | MODIFY | Add `decompress` to DEFAULT_PROTECTED_TOOLS, COMPRESS_DEFAULT_PROTECTED_TOOLS, and default config protectedTools | +| `tests/decompress-tool.test.ts` | **NEW** | Tests for decompress tool | + +## 12. Review History + +| Date | Reviewer | Verdict | Key Issues | +|------|----------|---------|------------| +| 2026-06-07 | Oracle #1 | PASS WITH COMMENTS | pipeline.ts reuse is wrong (1 major), system prompt should use extension (agreed) | +| 2026-06-07 | Oracle #2 | FAIL | Must return restored content inline (C2), GC interaction (C3), decompress-specific prepare/finalize (C1), DEFAULT_PROTECTED_TOOLS missing (M1) | + +All critical and major issues from both reviews are addressed in this revised design. diff --git a/devlog/2026-06-07_model-decompress-tool/REQ.md b/devlog/2026-06-07_model-decompress-tool/REQ.md new file mode 100644 index 0000000..3b076b5 --- /dev/null +++ b/devlog/2026-06-07_model-decompress-tool/REQ.md @@ -0,0 +1,72 @@ +# REQ - Expose decompress tool to AI model + +- Task ID: `2026-06-07_model-decompress-tool` +- Home Repo: `opencode-acp` +- Created: 2026-06-07 +- Status: Draft +- Priority: P1 +- Owner: ranxianglei +- References: https://github.com/ranxianglei/opencode-acp/issues/7 + +## 1. Background & Problem Statement + +- **Context**: ACP currently exposes only the `compress` tool to the AI model. Decompression is available only via the `/acp decompress` slash command, requiring human intervention. +- **Current behavior (symptom)**: The model can compress conversation ranges but cannot restore them when it needs to reference original details. Users must manually run `/acp decompress` to restore compressed content. +- **Expected behavior**: The AI model should have a `decompress` tool that allows it to restore previously compressed blocks, enabling fully autonomous context management — compress AND decompress. +- **Impact**: Medium — improves model autonomy and reduces user intervention. The model can make more nuanced context decisions (compress early, decompress when original detail is needed). + +## 2. Reproduction (if applicable) + +- **Environment**: Any ACP-enabled session with compression active +- **Minimal reproduction steps**: + 1. Start a session with ACP enabled + 2. Let the model compress several conversation ranges + 3. Later, the model needs to reference original compressed content + 4. Model has no tool to restore it — user must manually run `/acp decompress ` +- **Relevant configuration**: Default ACP config (no special settings needed) + +## 3. Constraints & Non-Goals + +- **Constraints**: + - Backward compatibility: Must not change persisted state format, existing `/acp decompress` command must continue working + - Must not expose `recompress` to the model (out of scope — recompress is costlier and less predictable) + - Tool must respect existing permission system (`compress.permission` config) + - Must handle context inflation — model should be aware decompressing restores full content + - Must follow existing tool registration pattern from `compress` tool +- **Non-Goals** (explicitly out of scope): + - Exposing `recompress` to the model (can be considered later) + - Changing the existing `/acp decompress` slash command behavior + - Adding new config options beyond what's needed for the tool + - Modifying the GC system + +## 4. Acceptance Criteria (must be testable) + +- **Correctness**: + - [ ] New `decompress` tool is registered and callable by the model + - [ ] Tool accepts `blockId` parameter (block reference like "b0", "b1", etc.) + - [ ] Tool deactivates the specified block(s) and persists state + - [ ] Next message-transform pipeline restores original messages + - [ ] Nested blocks are handled correctly (same as `/acp decompress`) + - [ ] Tool returns informative result message (restored messages count, tokens) + - [ ] Tool is listed in `protectedTools` defaults so compress won't prune its output +- **Performance / Stability**: + - [ ] No race conditions with concurrent compress/decompress + - [ ] Tool execution is synchronous state mutation (same pattern as compress) +- **Regression**: + - [ ] All existing tests pass + - [ ] `npm run typecheck` passes + - [ ] `npm run build` passes + - [ ] `/acp decompress` command still works unchanged + +## 5. Proposed Approach + +- **Affected modules & entry files**: + - `lib/compress/decompress.ts` — NEW: decompress tool implementation + - `index.ts` — register decompress tool alongside compress + - `lib/prompts/system.ts` — update system prompt to mention decompress + - `lib/prompts/extensions/tool.ts` — add decompress format extension + - `lib/prompts/store.ts` — add decompress prompt to prompt store (optional) + - `lib/config.ts` — add decompress to protectedTools defaults + - `tests/` — new test file for decompress tool +- **Risks**: Low — core decompress logic already exists and is battle-tested in `decompress.ts` command +- **Rollback strategy**: Revert branch diff --git a/devlog/2026-06-07_model-decompress-tool/WORKLOG.md b/devlog/2026-06-07_model-decompress-tool/WORKLOG.md new file mode 100644 index 0000000..d91a14f --- /dev/null +++ b/devlog/2026-06-07_model-decompress-tool/WORKLOG.md @@ -0,0 +1,79 @@ +# WORKLOG - Model-Exposed Decompress Tool + +- Task ID: `2026-06-07_model-decompress-tool` +- Home Repo: `opencode-acp` +- Status: InProgress +- Updated: 2026-06-07 22:30 + +## 1. Summary + +- **What was done**: Exposed `decompress` as a model-accessible tool alongside the existing `/acp decompress` command. Extracted shared logic from the command into `lib/compress/decompress-logic.ts` and created a new `lib/compress/decompress.ts` tool with decompress-specific prepare/finalize pipeline. +- **Why**: The model had asymmetric context management — it could compress but not decompress. When compressed details were needed, only human users could restore via `/acp decompress`. Now the model can autonomously restore compressed content. +- **Behavior / compatibility changes**: Yes — system prompt text changed ("ONLY tool" → "tools... are"), default `protectedTools` arrays gained `decompress`. Existing configs that override `protectedTools` won't auto-include `decompress`. +- **Risk level**: Medium — context inflation from decompress, GC interaction with reactivated blocks + +## 2. Change Log + +### Commits + +| Commit | Description | +|--------|-------------| +| (pending) | feat: expose decompress tool to AI model | + +### Key Files + +- `lib/compress/decompress-logic.ts` — NEW: Shared decompress logic extracted from command (parseBlockIdArg, deactivateCompressionTarget, computeRestoredMessages, buildRestoredContentPreview) +- `lib/compress/decompress.ts` — NEW: Decompress tool with decompress-specific prepare/finalize, inline content preview +- `lib/compress/index.ts` — MODIFIED: Export createDecompressTool +- `lib/commands/decompress.ts` — MODIFIED: Refactored to use shared logic from decompress-logic.ts +- `index.ts` — MODIFIED: Register decompress tool + add to primary_tools +- `lib/prompts/system.ts` — MODIFIED: Base prompt updated to mention both tools +- `lib/prompts/extensions/system.ts` — MODIFIED: Added DECOMPRESS_SYSTEM_EXTENSION +- `lib/prompts/store.ts` — MODIFIED: Added decompressExtension to RuntimePrompts +- `lib/prompts/index.ts` — MODIFIED: renderSystemPrompt accepts decompress flag +- `lib/hooks.ts` — MODIFIED: Pass decompress=true when permission allows +- `lib/config.ts` — MODIFIED: Added "decompress" to DEFAULT_PROTECTED_TOOLS and COMPRESS_DEFAULT_PROTECTED_TOOLS +- `tests/decompress-logic.test.ts` — NEW: Tests for shared decompress logic + +## 3. Design & Implementation Notes + +- **Entry point / key function**: `createDecompressTool()` in `lib/compress/decompress.ts` +- **Key configuration items**: Reuses `compress.permission` for both tools +- **Key logic explanation**: + - Decompress-specific `prepareDecompressSession()` — NO dedup/purge, NO manual mode guard (decompress always allowed) + - Decompress-specific `finalizeDecompressSession()` — just saves state, NO compress notification + - Tool returns inline restored content preview (~2000 chars) so model can reason immediately without waiting for next turn + - Context usage before/after included in return value for model self-regulation + - Shared logic extracted to `decompress-logic.ts` — both command and tool use identical functions + +## 4. Testing & Verification + +### Build & Test Commands + +```sh +npm run build +npm run typecheck +node --import tsx --test tests/*.test.ts +``` + +### Test Coverage + +- New test files: `tests/decompress-logic.test.ts` +- Test count: 350 baseline + new tests +- Key scenarios verified: parseBlockIdArg, deactivateCompressionTarget, computeRestoredMessages, buildRestoredContentPreview, computeReactivatedBlockIds, findActiveParentBlockId + +### Results + +- **PASS**: typecheck clean, build success, all 350 baseline tests pass + +## 5. Risk Assessment & Rollback + +- **Risk points**: Context inflation from decompress, GC may truncate reactivated nested blocks sooner, model misuse (decompress without recompress) +- **Rollback method**: Revert commit, remove decompress tool registration +- **Compatibility notes**: System prompt text change may require prompt override update for users with custom prompts + +## 7. Follow-ups + +- [ ] Consider exposing recompress as model tool +- [ ] Consider resetting survivedCount on reactivated nested blocks +- [ ] Consider making inline content preview length configurable diff --git a/index.ts b/index.ts index 2e736d8..970a9f3 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,10 @@ import type { Plugin } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" -import { createCompressMessageTool, createCompressRangeTool } from "./lib/compress" +import { + createCompressMessageTool, + createCompressRangeTool, + createDecompressTool, +} from "./lib/compress" import { compressDisabledByOpencode, hasExplicitToolPermission, @@ -84,6 +88,7 @@ const server: Plugin = (async (ctx) => { config.compress.mode === "message" ? createCompressMessageTool(compressToolContext) : createCompressRangeTool(compressToolContext), + decompress: createDecompressTool(compressToolContext), }), }, config: async (opencodeConfig) => { @@ -104,7 +109,7 @@ const server: Plugin = (async (ctx) => { const toolsToAdd: string[] = [] if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) { - toolsToAdd.push("compress") + toolsToAdd.push("compress", "decompress") } if (toolsToAdd.length > 0) { diff --git a/lib/commands/decompress.ts b/lib/commands/decompress.ts index c4a81d4..40dae1d 100644 --- a/lib/commands/decompress.ts +++ b/lib/commands/decompress.ts @@ -1,9 +1,7 @@ import type { Logger } from "../logger" -import type { CompressionBlock, PruneMessagesState, SessionState, WithParts } from "../state" +import type { SessionState, WithParts } from "../state" import { syncCompressionBlocks } from "../messages" -import { parseBlockRef } from "../message-ids" import { getCurrentParams } from "../token-utils" -import { saveSessionState } from "../state/persistence" import { sendIgnoredMessage } from "../ui/notification" import { formatTokenCount } from "../ui/utils" import { @@ -11,6 +9,15 @@ import { resolveCompressionTarget, type CompressionTarget, } from "./compression-targets" +import { + parseBlockIdArg, + findActiveAncestorBlockId, + snapshotActiveMessages, + deactivateCompressionTarget, + computeRestoredMessages, + computeReactivatedBlockIds, +} from "../compress/decompress-logic" +import { saveSessionState } from "../state/persistence" export interface DecompressCommandContext { client: any @@ -21,78 +28,6 @@ export interface DecompressCommandContext { args: string[] } -function parseBlockIdArg(arg: string): number | null { - const normalized = arg.trim().toLowerCase() - const blockRef = parseBlockRef(normalized) - if (blockRef !== null) { - return blockRef - } - - if (!/^[1-9]\d*$/.test(normalized)) { - return null - } - - const parsed = Number.parseInt(normalized, 10) - return Number.isInteger(parsed) && parsed > 0 ? parsed : null -} - -function findActiveParentBlockId( - messagesState: PruneMessagesState, - block: CompressionBlock, -): number | null { - const queue = [...block.parentBlockIds] - const visited = new Set() - - while (queue.length > 0) { - const parentBlockId = queue.shift() - if (parentBlockId === undefined || visited.has(parentBlockId)) { - continue - } - visited.add(parentBlockId) - - const parent = messagesState.blocksById.get(parentBlockId) - if (!parent) { - continue - } - - if (parent.active) { - return parent.blockId - } - - for (const ancestorId of parent.parentBlockIds) { - if (!visited.has(ancestorId)) { - queue.push(ancestorId) - } - } - } - - return null -} - -function findActiveAncestorBlockId( - messagesState: PruneMessagesState, - target: CompressionTarget, -): number | null { - for (const block of target.blocks) { - const activeAncestorBlockId = findActiveParentBlockId(messagesState, block) - if (activeAncestorBlockId !== null) { - return activeAncestorBlockId - } - } - - return null -} - -function snapshotActiveMessages(messagesState: PruneMessagesState): Map { - const activeMessages = new Map() - for (const [messageId, entry] of messagesState.byMessageId) { - if (entry.activeBlockIds.length > 0) { - activeMessages.set(messageId, entry.tokenCount) - } - } - return activeMessages -} - function formatDecompressMessage( target: CompressionTarget, restoredMessageCount: number, @@ -227,41 +162,19 @@ export async function handleDecompressCommand(ctx: DecompressCommandContext): Pr const activeMessagesBefore = snapshotActiveMessages(messagesState) const activeBlockIdsBefore = new Set(messagesState.activeBlockIds) - const deactivatedAt = Date.now() - for (const block of target.blocks) { - block.active = false - block.deactivatedByUser = true - block.deactivatedAt = deactivatedAt - block.deactivatedByBlockId = undefined - - // [FIX Bug 10] Mark consumed inner blocks so syncCompressionBlocks won't re-activate them - for (const consumedId of block.consumedBlockIds) { - const consumedBlock = messagesState.blocksById.get(consumedId) - if (consumedBlock) { - consumedBlock.deactivatedByUser = true - } - } - } + deactivateCompressionTarget(messagesState, target) syncCompressionBlocks(state, logger, messages) - let restoredMessageCount = 0 - let restoredTokens = 0 - for (const [messageId, tokenCount] of activeMessagesBefore) { - const entry = messagesState.byMessageId.get(messageId) - const isActiveNow = entry ? entry.activeBlockIds.length > 0 : false - if (!isActiveNow) { - restoredMessageCount++ - restoredTokens += tokenCount - } - } + const { restoredMessageCount, restoredTokens } = computeRestoredMessages( + messagesState, + activeMessagesBefore, + ) state.stats.totalPruneTokens = Math.max(0, state.stats.totalPruneTokens - restoredTokens) - const reactivatedBlockIds = Array.from(messagesState.activeBlockIds) - .filter((blockId) => !activeBlockIdsBefore.has(blockId)) - .sort((a, b) => a - b) + const reactivatedBlockIds = computeReactivatedBlockIds(messagesState, activeBlockIdsBefore) await saveSessionState(state, logger) diff --git a/lib/compress/decompress-logic.ts b/lib/compress/decompress-logic.ts new file mode 100644 index 0000000..2fbf58f --- /dev/null +++ b/lib/compress/decompress-logic.ts @@ -0,0 +1,200 @@ +import type { CompressionBlock, PruneMessagesState, WithParts } from "../state" +import { parseBlockRef } from "../message-ids" +import type { CompressionTarget } from "../commands/compression-targets" + +export function parseBlockIdArg(arg: string): number | null { + const normalized = arg.trim().toLowerCase() + const blockRef = parseBlockRef(normalized) + if (blockRef !== null) { + return blockRef + } + + if (!/^[1-9]\d*$/.test(normalized)) { + return null + } + + const parsed = Number.parseInt(normalized, 10) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +export function findActiveParentBlockId( + messagesState: PruneMessagesState, + block: CompressionBlock, +): number | null { + const queue = [...block.parentBlockIds] + const visited = new Set() + + while (queue.length > 0) { + const parentBlockId = queue.shift() + if (parentBlockId === undefined || visited.has(parentBlockId)) { + continue + } + visited.add(parentBlockId) + + const parent = messagesState.blocksById.get(parentBlockId) + if (!parent) { + continue + } + + if (parent.active) { + return parent.blockId + } + + for (const ancestorId of parent.parentBlockIds) { + if (!visited.has(ancestorId)) { + queue.push(ancestorId) + } + } + } + + return null +} + +export function findActiveAncestorBlockId( + messagesState: PruneMessagesState, + target: CompressionTarget, +): number | null { + for (const block of target.blocks) { + const activeAncestorBlockId = findActiveParentBlockId(messagesState, block) + if (activeAncestorBlockId !== null) { + return activeAncestorBlockId + } + } + + return null +} + +export function snapshotActiveMessages(messagesState: PruneMessagesState): Map { + const activeMessages = new Map() + for (const [messageId, entry] of messagesState.byMessageId) { + if (entry.activeBlockIds.length > 0) { + activeMessages.set(messageId, entry.tokenCount) + } + } + return activeMessages +} + +export function deactivateCompressionTarget( + messagesState: PruneMessagesState, + target: CompressionTarget, +): void { + const deactivatedAt = Date.now() + + for (const block of target.blocks) { + block.active = false + block.deactivatedByUser = true + block.deactivatedAt = deactivatedAt + block.deactivatedByBlockId = undefined + + // [FIX Bug 10] Mark consumed inner blocks so syncCompressionBlocks won't re-activate them + for (const consumedId of block.consumedBlockIds) { + const consumedBlock = messagesState.blocksById.get(consumedId) + if (consumedBlock) { + consumedBlock.deactivatedByUser = true + } + } + } +} + +export interface RestoredMessagesResult { + restoredMessageCount: number + restoredTokens: number +} + +export function computeRestoredMessages( + messagesState: PruneMessagesState, + activeMessagesBefore: Map, +): RestoredMessagesResult { + let restoredMessageCount = 0 + let restoredTokens = 0 + for (const [messageId, tokenCount] of activeMessagesBefore) { + const entry = messagesState.byMessageId.get(messageId) + const isActiveNow = entry ? entry.activeBlockIds.length > 0 : false + if (!isActiveNow) { + restoredMessageCount++ + restoredTokens += tokenCount + } + } + return { restoredMessageCount, restoredTokens } +} + +export function computeReactivatedBlockIds( + messagesState: PruneMessagesState, + activeBlockIdsBefore: Set, +): number[] { + return Array.from(messagesState.activeBlockIds) + .filter((blockId) => !activeBlockIdsBefore.has(blockId)) + .sort((a, b) => a - b) +} + +const MAX_PREVIEW_LENGTH = 2000 +const MAX_MESSAGE_PREVIEW_LENGTH = 200 + +export function buildRestoredContentPreview( + messages: WithParts[], + activeMessagesBefore: Map, + messagesState: PruneMessagesState, +): string { + const restoredMessages: WithParts[] = [] + for (const msg of messages) { + const msgId = msg.info.id + if (activeMessagesBefore.has(msgId)) { + const entry = messagesState.byMessageId.get(msgId) + const isActiveNow = entry ? entry.activeBlockIds.length > 0 : false + if (!isActiveNow) { + restoredMessages.push(msg) + } + } + } + + if (restoredMessages.length === 0) { + return "" + } + + const lines: string[] = [] + let totalLength = 0 + + for (const msg of restoredMessages) { + if (totalLength >= MAX_PREVIEW_LENGTH) break + + const role = msg.info.role ?? "unknown" + const textContent = extractTextContent(msg) + const truncated = + textContent.length > MAX_MESSAGE_PREVIEW_LENGTH + ? textContent.slice(0, MAX_MESSAGE_PREVIEW_LENGTH) + "..." + : textContent + + const line = `[${role}] ${truncated}` + lines.push(line) + totalLength += line.length + 1 + } + + return lines.join("\n") +} + +function extractTextContent(msg: WithParts): string { + if (!msg.parts || msg.parts.length === 0) { + return "" + } + + const textParts: string[] = [] + for (const part of msg.parts) { + if (typeof part === "object" && part !== null) { + if ("text" in part && typeof part.text === "string") { + textParts.push(part.text) + } else if ("type" in part && part.type === "tool") { + const toolName = "tool" in part && typeof part.tool === "string" ? part.tool : "tool" + const state = part.state as Record | undefined + if (state && typeof state.output === "string") { + const output = + state.output.length > 80 + ? state.output.slice(0, 80) + "..." + : state.output + textParts.push(`[${toolName}] ${output}`) + } + } + } + } + + return textParts.join(" ").replace(/\s+/g, " ").trim() +} diff --git a/lib/compress/decompress.ts b/lib/compress/decompress.ts new file mode 100644 index 0000000..5f396da --- /dev/null +++ b/lib/compress/decompress.ts @@ -0,0 +1,192 @@ +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 { syncCompressionBlocks } from "../messages" +import { getCurrentTokenUsage } from "../token-utils" +import { fetchSessionMessages } from "./search" +import { resolveCompressionTarget } from "../commands/compression-targets" +import { + parseBlockIdArg, + findActiveAncestorBlockId, + snapshotActiveMessages, + deactivateCompressionTarget, + computeRestoredMessages, + computeReactivatedBlockIds, + buildRestoredContentPreview, +} from "./decompress-logic" +import { formatTokenCount } from "../ui/utils" + +interface RunContext { + ask(input: { + permission: string + patterns: string[] + always: string[] + metadata: Record + }): Promise + metadata(input: { title: string }): void + sessionID: string +} + +async function prepareDecompressSession( + ctx: ToolContext, + toolCtx: RunContext, +): Promise<{ rawMessages: import("../state").WithParts[] }> { + await toolCtx.ask({ + permission: "compress", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + + toolCtx.metadata({ title: "Decompress" }) + + 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) + + return { rawMessages } +} + +async function finalizeDecompressSession( + ctx: ToolContext, +): Promise { + await saveSessionState(ctx.state, ctx.logger) +} + +const TOOL_DESCRIPTION = `Restores previously compressed content identified by a block ID. + +Use this tool when you need exact details from a compressed block that the summary cannot provide. +The tool returns a condensed preview of the restored content so you can reason about it immediately. + +Argument: blockId — the block reference to decompress (e.g., "b0", "b2") + +IMPORTANT: +- Decompressing inflates context. Check context usage before decompressing. +- Message-mode blocks from the same batch (same runId) are restored together. +- After decompression, the restored messages will appear in full in your next context window. +- Do NOT call this tool in parallel with compress — their state mutations may conflict.` + +function buildSchema() { + return { + blockId: tool.schema + .string() + .describe('Block reference to decompress (e.g., "b0", "b2")'), + } +} + +export function createDecompressTool(ctx: ToolContext): ReturnType { + return tool({ + description: TOOL_DESCRIPTION, + args: buildSchema(), + async execute(args, toolCtx) { + const { rawMessages } = await prepareDecompressSession(ctx, toolCtx) + + const contextUsageBefore = ctx.state.modelContextLimit + ? Math.round( + (getCurrentTokenUsage(ctx.state, rawMessages) / + ctx.state.modelContextLimit) * + 100, + ) + : undefined + + const targetBlockId = parseBlockIdArg(args.blockId as string) + if (targetBlockId === null) { + return `Error: Invalid block ID "${args.blockId}". Use format "b0", "b1", etc.` + } + + const messagesState = ctx.state.prune.messages + + const target = resolveCompressionTarget(messagesState, targetBlockId) + if (!target) { + return `Error: Block ${targetBlockId} does not exist. No compression found with that ID.` + } + + const activeBlocks = target.blocks.filter((block) => block.active) + if (activeBlocks.length === 0) { + const activeAncestorBlockId = findActiveAncestorBlockId(messagesState, target) + if (activeAncestorBlockId !== null) { + return `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.` + } + return `Error: Block ${target.displayId} is not active. It may have already been decompressed.` + } + + const activeMessagesBefore = snapshotActiveMessages(messagesState) + const activeBlockIdsBefore = new Set(messagesState.activeBlockIds) + + deactivateCompressionTarget(messagesState, target) + + syncCompressionBlocks(ctx.state, ctx.logger, rawMessages) + + const { restoredMessageCount, restoredTokens } = computeRestoredMessages( + messagesState, + activeMessagesBefore, + ) + const reactivatedBlockIds = computeReactivatedBlockIds( + messagesState, + activeBlockIdsBefore, + ) + + ctx.state.stats.totalPruneTokens = Math.max( + 0, + ctx.state.stats.totalPruneTokens - restoredTokens, + ) + + const contextUsageAfter = ctx.state.modelContextLimit + ? Math.round( + (getCurrentTokenUsage(ctx.state, rawMessages) / + ctx.state.modelContextLimit) * + 100, + ) + : undefined + + await finalizeDecompressSession(ctx) + + const restoredContentPreview = buildRestoredContentPreview( + rawMessages, + activeMessagesBefore, + messagesState, + ) + + const lines: string[] = [] + lines.push( + `Decompressed block b${target.displayId}. Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`, + ) + + if (contextUsageBefore !== undefined && contextUsageAfter !== undefined) { + lines.push(`Context usage: ${contextUsageBefore}% → ${contextUsageAfter}%.`) + } + + if (reactivatedBlockIds.length > 0) { + const refs = reactivatedBlockIds.map((id) => `b${id}`).join(", ") + lines.push(`Also restored nested block(s): ${refs}.`) + } + + if (restoredContentPreview) { + lines.push("") + lines.push("RESTORED CONTENT (condensed):") + lines.push(restoredContentPreview) + } + + ctx.logger.info("Decompress tool completed", { + targetBlockId: target.displayId, + targetRunId: target.runId, + restoredMessageCount, + restoredTokens, + reactivatedBlockIds, + }) + + return lines.join("\n") + }, + }) +} diff --git a/lib/compress/index.ts b/lib/compress/index.ts index bdb7f2e..6330869 100644 --- a/lib/compress/index.ts +++ b/lib/compress/index.ts @@ -1,3 +1,4 @@ export { ToolContext } from "./types" export { createCompressMessageTool } from "./message" export { createCompressRangeTool } from "./range" +export { createDecompressTool } from "./decompress" diff --git a/lib/config.ts b/lib/config.ts index a15a647..f7e3aea 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -92,6 +92,7 @@ const DEFAULT_PROTECTED_TOOLS = [ "todowrite", "todoread", "compress", + "decompress", "batch", "plan_enter", "plan_exit", @@ -99,7 +100,7 @@ const DEFAULT_PROTECTED_TOOLS = [ "edit", ] -const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["task", "skill", "todowrite", "todoread"] +const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["task", "skill", "todowrite", "todoread", "decompress"] export { VALID_CONFIG_KEYS, getInvalidConfigKeys, validateConfigTypes, type ValidationError } from "./config-validation" diff --git a/lib/prompts/extensions/system.ts b/lib/prompts/extensions/system.ts index f5f038b..173a500 100644 --- a/lib/prompts/extensions/system.ts +++ b/lib/prompts/extensions/system.ts @@ -30,3 +30,26 @@ Their outputs are automatically preserved during compression. Do not include their content in compress tool summaries — the environment retains it independently. ` } + +export const DECOMPRESS_SYSTEM_EXTENSION = ` +THE PHILOSOPHY OF DECOMPRESS +\`decompress\` restores previously compressed content. Use it when you need exact details +that were lost in compression. + +DECOMPRESS WHEN +- You need exact code, error messages, or file contents from a compressed block +- A summary lacks the precision needed for your next step +- You discovered the compressed content is still relevant + +DO NOT DECOMPRESS IF +- Context usage is already high (>70%) — decompressing inflates context +- The summary is sufficient for your needs +- You plan to immediately recompress the same content + +Before decompressing, check context usage. Decompressing restores full messages, +which can significantly increase context size. + +NOTE: Message-mode blocks created in the same batch (same runId) are restored together. +Decompressing one block from a batch restores all blocks in that batch. + +` diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index bdfbee5..b3ec4a6 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -21,6 +21,10 @@ export function renderSystemPrompt( extensions.push(prompts.subagentExtension.trim()) } + // decompress extension is always included when compress is not denied + // (the caller guards on permission === "deny" before reaching renderSystemPrompt) + extensions.push(prompts.decompressExtension.trim()) + return [prompts.system.trim(), ...extensions] .filter(Boolean) .join("\n\n") diff --git a/lib/prompts/store.ts b/lib/prompts/store.ts index 7f45ccf..9a6ecac 100644 --- a/lib/prompts/store.ts +++ b/lib/prompts/store.ts @@ -8,7 +8,7 @@ import { COMPRESS_MESSAGE as COMPRESS_MESSAGE_PROMPT } from "./compress-message" import { CONTEXT_LIMIT_NUDGE } from "./context-limit-nudge" import { TURN_NUDGE } from "./turn-nudge" import { ITERATION_NUDGE } from "./iteration-nudge" -import { MANUAL_MODE_SYSTEM_EXTENSION, SUBAGENT_SYSTEM_EXTENSION } from "./extensions/system" +import { MANUAL_MODE_SYSTEM_EXTENSION, SUBAGENT_SYSTEM_EXTENSION, DECOMPRESS_SYSTEM_EXTENSION } from "./extensions/system" export type PromptKey = | "system" @@ -55,6 +55,7 @@ export interface RuntimePrompts { iterationNudge: string manualExtension: string subagentExtension: string + decompressExtension: string } const PROMPT_DEFINITIONS: PromptDefinition[] = [ @@ -135,6 +136,7 @@ const BUNDLED_EDITABLE_PROMPTS: Record = { const INTERNAL_PROMPT_EXTENSIONS = { manualExtension: MANUAL_MODE_SYSTEM_EXTENSION, subagentExtension: SUBAGENT_SYSTEM_EXTENSION, + decompressExtension: DECOMPRESS_SYSTEM_EXTENSION, } function createBundledRuntimePrompts(): RuntimePrompts { @@ -147,6 +149,7 @@ function createBundledRuntimePrompts(): RuntimePrompts { iterationNudge: BUNDLED_EDITABLE_PROMPTS.iterationNudge, manualExtension: INTERNAL_PROMPT_EXTENSIONS.manualExtension, subagentExtension: INTERNAL_PROMPT_EXTENSIONS.subagentExtension, + decompressExtension: INTERNAL_PROMPT_EXTENSIONS.decompressExtension, } } diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index da3cd41..2308b17 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -1,7 +1,7 @@ export const SYSTEM = ` 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. +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. \`\` and \`\` tags are environment-injected metadata. Do not output them. diff --git a/tests/decompress-logic.test.ts b/tests/decompress-logic.test.ts new file mode 100644 index 0000000..edb5c5e --- /dev/null +++ b/tests/decompress-logic.test.ts @@ -0,0 +1,414 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + parseBlockIdArg, + findActiveParentBlockId, + findActiveAncestorBlockId, + snapshotActiveMessages, + deactivateCompressionTarget, + computeRestoredMessages, + computeReactivatedBlockIds, + buildRestoredContentPreview, +} from "../lib/compress/decompress-logic" +import type { CompressionBlock, PruneMessagesState, WithParts } from "../lib/state/types" +import type { CompressionTarget } from "../lib/commands/compression-targets" + +// --- Factory helpers --- + +function makeBlock(overrides: Partial = {}): CompressionBlock { + return { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 100, + summaryTokens: 20, + durationMs: 0, + mode: "range", + topic: "test", + batchTopic: "test", + startId: "m00001", + endId: "m00003", + anchorMessageId: "anchor-1", + compressMessageId: "comp-1", + compressCallId: undefined, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: ["msg-a", "msg-b"], + effectiveToolIds: [], + createdAt: 1000, + deactivatedAt: undefined, + deactivatedByBlockId: undefined, + summary: "A summary.", + survivedCount: 0, + generation: "young", + ...overrides, + } +} + +function makeMessagesState(overrides: Partial = {}): PruneMessagesState { + return { + byMessageId: new Map(), + blocksById: new Map(), + activeBlockIds: new Set(), + activeByAnchorMessageId: new Map(), + nextBlockId: 1, + nextRunId: 1, + ...overrides, + } +} + +function makeTarget(overrides: Partial = {}): CompressionTarget { + return { + displayId: 1, + runId: 1, + topic: "test topic", + compressedTokens: 100, + durationMs: 50, + grouped: false, + blocks: [makeBlock()], + ...overrides, + } +} + +// --- parseBlockIdArg --- + +test("parseBlockIdArg returns block ID for 'b1' format", () => { + assert.equal(parseBlockIdArg("b1"), 1) +}) + +test("parseBlockIdArg returns block ID for bare number '5'", () => { + assert.equal(parseBlockIdArg("5"), 5) +}) + +test("parseBlockIdArg returns null for invalid 'abc'", () => { + assert.equal(parseBlockIdArg("abc"), null) +}) + +test("parseBlockIdArg returns null for '0'", () => { + assert.equal(parseBlockIdArg("0"), null) +}) + +test("parseBlockIdArg returns null for empty string", () => { + assert.equal(parseBlockIdArg(""), null) +}) + +test("parseBlockIdArg returns null for 'b-1'", () => { + assert.equal(parseBlockIdArg("b-1"), null) +}) + +test("parseBlockIdArg returns null for 'b0'", () => { + assert.equal(parseBlockIdArg("b0"), null) +}) + +test("parseBlockIdArg is case insensitive: 'B3' returns 3", () => { + assert.equal(parseBlockIdArg("B3"), 3) +}) + +test("parseBlockIdArg trims whitespace", () => { + assert.equal(parseBlockIdArg(" b7 "), 7) +}) + +test("parseBlockIdArg returns null for negative number '-1'", () => { + assert.equal(parseBlockIdArg("-1"), null) +}) + +// --- findActiveParentBlockId --- + +test("findActiveParentBlockId returns null when block has no parents", () => { + const ms = makeMessagesState() + const block = makeBlock({ parentBlockIds: [] }) + assert.equal(findActiveParentBlockId(ms, block), null) +}) + +test("findActiveParentBlockId returns null when all parents are inactive", () => { + const parent = makeBlock({ blockId: 2, active: false }) + const ms = makeMessagesState({ blocksById: new Map([[2, parent]]) }) + const block = makeBlock({ parentBlockIds: [2] }) + assert.equal(findActiveParentBlockId(ms, block), null) +}) + +test("findActiveParentBlockId returns active parent block ID", () => { + const parent = makeBlock({ blockId: 2, active: true }) + const ms = makeMessagesState({ blocksById: new Map([[2, parent]]) }) + const block = makeBlock({ parentBlockIds: [2] }) + assert.equal(findActiveParentBlockId(ms, block), 2) +}) + +test("findActiveParentBlockId handles deep ancestor chains (grandparent)", () => { + const grandparent = makeBlock({ blockId: 10, active: true }) + const parent = makeBlock({ blockId: 5, active: false, parentBlockIds: [10] }) + const ms = makeMessagesState({ + blocksById: new Map([ + [5, parent], + [10, grandparent], + ]), + }) + const block = makeBlock({ parentBlockIds: [5] }) + assert.equal(findActiveParentBlockId(ms, block), 10) +}) + +test("findActiveParentBlockId handles cycles safely", () => { + const blockA = makeBlock({ blockId: 1, active: false, parentBlockIds: [2] }) + const blockB = makeBlock({ blockId: 2, active: false, parentBlockIds: [1] }) + const ms = makeMessagesState({ + blocksById: new Map([ + [1, blockA], + [2, blockB], + ]), + }) + assert.equal(findActiveParentBlockId(ms, blockA), null) +}) + +test("findActiveParentBlockId returns null for missing parent", () => { + const ms = makeMessagesState({ blocksById: new Map() }) + const block = makeBlock({ parentBlockIds: [99] }) + assert.equal(findActiveParentBlockId(ms, block), null) +}) + +// --- findActiveAncestorBlockId --- + +test("findActiveAncestorBlockId returns null when no blocks have active ancestors", () => { + const parent = makeBlock({ blockId: 2, active: false }) + const block = makeBlock({ parentBlockIds: [2] }) + const ms = makeMessagesState({ blocksById: new Map([[2, parent]]) }) + const target = makeTarget({ blocks: [block] }) + assert.equal(findActiveAncestorBlockId(ms, target), null) +}) + +test("findActiveAncestorBlockId returns active ancestor from any block in target", () => { + const activeParent = makeBlock({ blockId: 10, active: true }) + const block = makeBlock({ parentBlockIds: [10] }) + const ms = makeMessagesState({ blocksById: new Map([[10, activeParent]]) }) + const target = makeTarget({ blocks: [block] }) + assert.equal(findActiveAncestorBlockId(ms, target), 10) +}) + +// --- snapshotActiveMessages --- + +test("snapshotActiveMessages returns empty map when no active messages", () => { + const ms = makeMessagesState() + const result = snapshotActiveMessages(ms) + assert.equal(result.size, 0) +}) + +test("snapshotActiveMessages returns map of messageId to tokenCount for active messages", () => { + const ms = makeMessagesState({ + byMessageId: new Map([ + ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [1] }], + ["msg-b", { tokenCount: 30, allBlockIds: [2], activeBlockIds: [] }], + ]), + }) + const result = snapshotActiveMessages(ms) + assert.equal(result.size, 1) + assert.equal(result.get("msg-a"), 50) + assert.ok(!result.has("msg-b")) +}) + +// --- deactivateCompressionTarget --- + +test("deactivateCompressionTarget sets block.active = false", () => { + const block = makeBlock({ blockId: 1, active: true }) + const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) + const target = makeTarget({ blocks: [block] }) + deactivateCompressionTarget(ms, target) + assert.equal(block.active, false) +}) + +test("deactivateCompressionTarget sets block.deactivatedByUser = true", () => { + const block = makeBlock({ blockId: 1, deactivatedByUser: false }) + const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) + const target = makeTarget({ blocks: [block] }) + deactivateCompressionTarget(ms, target) + assert.equal(block.deactivatedByUser, true) +}) + +test("deactivateCompressionTarget sets block.deactivatedAt to a number", () => { + const block = makeBlock({ blockId: 1 }) + const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) + const target = makeTarget({ blocks: [block] }) + const before = Date.now() + deactivateCompressionTarget(ms, target) + assert.ok(typeof block.deactivatedAt === "number") + assert.ok(block.deactivatedAt! >= before) +}) + +test("deactivateCompressionTarget clears block.deactivatedByBlockId", () => { + const block = makeBlock({ blockId: 1, deactivatedByBlockId: 99 }) + const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) + const target = makeTarget({ blocks: [block] }) + deactivateCompressionTarget(ms, target) + assert.equal(block.deactivatedByBlockId, undefined) +}) + +test("deactivateCompressionTarget marks consumed inner blocks deactivatedByUser = true", () => { + const consumedBlock = makeBlock({ blockId: 2, deactivatedByUser: false }) + const block = makeBlock({ blockId: 1, consumedBlockIds: [2] }) + const ms = makeMessagesState({ + blocksById: new Map([ + [1, block], + [2, consumedBlock], + ]), + }) + const target = makeTarget({ blocks: [block] }) + deactivateCompressionTarget(ms, target) + assert.equal(consumedBlock.deactivatedByUser, true) +}) + +test("deactivateCompressionTarget handles target with multiple blocks", () => { + const block1 = makeBlock({ blockId: 1, active: true, deactivatedByUser: false }) + const block2 = makeBlock({ blockId: 2, active: true, deactivatedByUser: false }) + const ms = makeMessagesState({ + blocksById: new Map([ + [1, block1], + [2, block2], + ]), + }) + const target = makeTarget({ blocks: [block1, block2] }) + deactivateCompressionTarget(ms, target) + assert.equal(block1.active, false) + assert.equal(block2.active, false) + assert.equal(block1.deactivatedByUser, true) + assert.equal(block2.deactivatedByUser, true) +}) + +// --- computeRestoredMessages --- + +test("computeRestoredMessages returns zero when no messages restored", () => { + const ms = makeMessagesState({ + byMessageId: new Map([ + ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [1] }], + ]), + }) + const before = new Map([["msg-a", 50]]) + const result = computeRestoredMessages(ms, before) + assert.equal(result.restoredMessageCount, 0) + assert.equal(result.restoredTokens, 0) +}) + +test("computeRestoredMessages counts messages that went from active to inactive", () => { + const ms = makeMessagesState({ + byMessageId: new Map([ + ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [] }], + ["msg-b", { tokenCount: 30, allBlockIds: [2], activeBlockIds: [] }], + ]), + }) + const before = new Map([ + ["msg-a", 50], + ["msg-b", 30], + ]) + const result = computeRestoredMessages(ms, before) + assert.equal(result.restoredMessageCount, 2) + assert.equal(result.restoredTokens, 80) +}) + +test("computeRestoredMessages handles messages removed from byMessageId entirely", () => { + const ms = makeMessagesState({ byMessageId: new Map() }) + const before = new Map([["msg-gone", 40]]) + const result = computeRestoredMessages(ms, before) + assert.equal(result.restoredMessageCount, 1) + assert.equal(result.restoredTokens, 40) +}) + +// --- computeReactivatedBlockIds --- + +test("computeReactivatedBlockIds returns empty array when no blocks reactivated", () => { + const ms = makeMessagesState({ activeBlockIds: new Set([1, 2]) }) + const before = new Set([1, 2]) + const result = computeReactivatedBlockIds(ms, before) + assert.deepEqual(result, []) +}) + +test("computeReactivatedBlockIds returns sorted list of newly reactivated block IDs", () => { + const ms = makeMessagesState({ activeBlockIds: new Set([1, 3, 5]) }) + const before = new Set([1]) + const result = computeReactivatedBlockIds(ms, before) + assert.deepEqual(result, [3, 5]) +}) + +// --- buildRestoredContentPreview --- + +test("buildRestoredContentPreview returns empty string when no messages restored", () => { + const ms = makeMessagesState({ + byMessageId: new Map([ + ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [1] }], + ]), + }) + const before = new Map([["msg-a", 50]]) + const messages: WithParts[] = [ + { info: { id: "msg-a", role: "user" } as any, parts: [{ text: "hello" }] as any }, + ] + assert.equal(buildRestoredContentPreview(messages, before, ms), "") +}) + +test("buildRestoredContentPreview returns preview with role and truncated content", () => { + const ms = makeMessagesState({ + byMessageId: new Map([ + ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [] }], + ]), + }) + const before = new Map([["msg-a", 50]]) + const messages: WithParts[] = [ + { info: { id: "msg-a", role: "user" } as any, parts: [{ text: "Hello world" }] as any }, + ] + const result = buildRestoredContentPreview(messages, before, ms) + assert.ok(result.includes("[user]")) + assert.ok(result.includes("Hello world")) +}) + +test("buildRestoredContentPreview truncates individual messages at ~200 chars", () => { + const longText = "A".repeat(300) + const ms = makeMessagesState({ + byMessageId: new Map([ + ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [] }], + ]), + }) + const before = new Map([["msg-a", 50]]) + const messages: WithParts[] = [ + { info: { id: "msg-a", role: "assistant" } as any, parts: [{ text: longText }] as any }, + ] + const result = buildRestoredContentPreview(messages, before, ms) + // The line should contain "[assistant]" and the truncated text (200 chars + "...") + const line = result.split("\n")[0] + assert.ok(line.length < 250) + assert.ok(line.includes("...")) +}) + +test("buildRestoredContentPreview caps total output at approximately 2000 chars", () => { + const ms = makeMessagesState({ + byMessageId: new Map(), + }) + const before = new Map() + const messages: WithParts[] = [] + + // Create 20 messages, each with 200 chars + for (let i = 0; i < 20; i++) { + const id = `msg-${i}` + ms.byMessageId.set(id, { tokenCount: 10, allBlockIds: [1], activeBlockIds: [] }) + before.set(id, 10) + messages.push({ + info: { id, role: "assistant" } as any, + parts: [{ text: "B".repeat(200) }] as any, + }) + } + + const result = buildRestoredContentPreview(messages, before, ms) + assert.ok(result.length < 2200, `Expected ~2000 chars, got ${result.length}`) +}) + +test("buildRestoredContentPreview handles messages with no parts", () => { + const ms = makeMessagesState({ + byMessageId: new Map([ + ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [] }], + ]), + }) + const before = new Map([["msg-a", 50]]) + const messages: WithParts[] = [ + { info: { id: "msg-a", role: "user" } as any, parts: [] as any }, + ] + const result = buildRestoredContentPreview(messages, before, ms) + assert.ok(result.includes("[user]")) +})