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
1 change: 1 addition & 0 deletions .opencode/plugins/contextpilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../../opencode-plugin/src/index.js"
87 changes: 87 additions & 0 deletions docs/guides/opencode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# ContextPilot + OpenCode Integration Guide

## Overview

ContextPilot integrates with [OpenCode](https://github.com/opencode-ai/opencode) as a native plugin. It intercepts every LLM call via the `experimental.chat.messages.transform` hook, deduplicates repeated tool results across turns, and removes shared content blocks within tool outputs — all lossless, zero extra LLM calls.

Typical savings: **40-50% input tokens** on agentic workloads with repeated file reads.

## Prerequisites

- OpenCode installed and working
- Node.js 18+ (or Bun)

## Installation

Add `@contextpilot-ai/opencode` to your project's `opencode.json`:

```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@contextpilot-ai/opencode"]
}
```

Restart OpenCode. The plugin is installed automatically via Bun at startup and cached in `~/.cache/opencode/node_modules/`.

## How it works

ContextPilot registers on the `experimental.chat.messages.transform` hook. Before every LLM call, OpenCode passes the full message array to the plugin. ContextPilot runs a two-stage pipeline and mutates messages in-place:

| Stage | Operation | Benefit |
|-------|-----------|---------|
| 1 | Single-doc cross-turn dedup | Identical tool outputs (by SHA-256) replaced with a short pointer to the earlier result |
| 2 | Block-level dedup | Shared content blocks across different tool outputs are deduplicated via content-defined chunking |

Because the plugin mutates the message array directly, OpenCode sends the optimized (shorter) context to the LLM. The LLM sees less redundant content, and inference backends with KV cache or prompt caching benefit from the reduced input.

## Monitoring

### Log output

ContextPilot writes to its own log file alongside OpenCode's logs:

```bash
tail -f ~/.local/share/opencode/log/contextpilot.log
```

Example output:

```
[ContextPilot] Turn 8: saved 3212 chars (~803 tokens) | docs deduped: 2 | tracked: 5 | cumulative: 12840 chars (~3210 tokens)
```

### Status tool

During a session, call the `contextpilot_status` tool to see cumulative statistics:

```
ContextPilot Status:
Turns optimized: 8
Chars saved: 12,840
Tokens saved: ~3,210
Docs deduped: 2
Tracked hashes: 5
Reorder: dedup-only
```

## What gets optimized

**Single-doc cross-turn dedup.** When a tool returns output identical to a previous tool result (e.g. reading the same file twice), the duplicate is replaced with a short hint: `[Duplicate — identical to previous tool result (...). Refer to the earlier result above.]`. The original stays intact.

**Block-level dedup.** When different tool outputs share large content blocks (e.g. overlapping file sections from different reads), the shared blocks are deduplicated using content-defined chunking. This catches partial overlaps that single-doc dedup misses.

Tool outputs shorter than 100 characters are skipped — the overhead of hashing and tracking outweighs any savings.

## Troubleshooting

**Plugin not loading.** Check OpenCode's logs at `~/.local/share/opencode/log/` for plugin discovery errors:

```bash
grep -i "failed.*plugin" ~/.local/share/opencode/log/*.log
cat ~/.local/share/opencode/log/contextpilot.log
```

**0 chars saved.** Normal for the first few turns. Dedup only fires when the same content appears more than once in the conversation. Once an agent re-reads a file or tool results overlap, savings appear.

**Reorder shows "dedup-only".** The reorder stage requires the ContextPilot engine. If it fails to initialize, the plugin falls back gracefully to dedup-only mode. This is the expected default for most setups.
73 changes: 73 additions & 0 deletions opencode-plugin/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions opencode-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@contextpilot-ai/opencode",
"version": "0.1.0",
"description": "ContextPilot plugin for OpenCode — per-turn context optimization via deduplication and reordering for KV cache sharing.",
"type": "module",
"license": "Apache-2.0",
"author": "ContextPilot Contributors",
"repository": {
"type": "git",
"url": "https://github.com/EfficientContext/ContextPilot.git",
"directory": "opencode-plugin"
},
"keywords": [
"opencode",
"opencode-plugin",
"contextpilot",
"kv-cache",
"context-reuse",
"prompt-cache",
"dedup",
"llm"
],
"main": "./src/index.ts",
"publishConfig": {
"access": "public"
},
"files": [
"src/"
],
"peerDependencies": {
"@opencode-ai/plugin": "*"
}
}
177 changes: 177 additions & 0 deletions opencode-plugin/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, expect, it, vi } from "vitest"

// Mock the external dependencies before importing the plugin
vi.mock("../../openclaw-plugin/src/engine/dedup.js", () => ({
dedupChatCompletions: vi.fn(() => ({ charsSaved: 0, blocksDeduped: 0, blocksTotal: 0, systemBlocksMatched: 0 })),
}))

vi.mock("../../openclaw-plugin/src/engine/live-index.js", () => ({
ContextPilot: vi.fn(() => { throw new Error("no reorder in test") }),
}))

vi.mock("node:fs", () => ({ appendFileSync: vi.fn() }))


import pluginDefault, { ContextPilotPlugin } from "./index.js"
import { dedupChatCompletions } from "../../openclaw-plugin/src/engine/dedup.js"

// ── Helpers ─────────────────────────────────────────────────────────────

interface OpenCodeMessage {
info: { id: string; role: string; sessionID: string }
parts: Array<{
id: string; sessionID: string; messageID: string; type: string;
callID?: string; tool?: string;
state?: { status: string; output?: string };
text?: string;
}>
}

function makeToolMessage(id: string, parts: Array<{ partId: string; callID: string; tool: string; output: string }>): OpenCodeMessage {
return {
info: { id, role: "assistant", sessionID: "s1" },
parts: parts.map((p) => ({
id: p.partId,
sessionID: "s1",
messageID: id,
type: "tool",
callID: p.callID,
tool: p.tool,
state: { status: "completed", output: p.output },
})),
}
}

function makeTextMessage(id: string, role: string, text: string): OpenCodeMessage {
return {
info: { id, role, sessionID: "s1" },
parts: [{ id: `${id}-p1`, sessionID: "s1", messageID: id, type: "text", text }],
}
}

const LONG_OUTPUT = "x".repeat(200)

// ── Tests ───────────────────────────────────────────────────────────────

describe("plugin export format", () => {
it("default export has id 'contextpilot' and server function", () => {
expect(pluginDefault.id).toBe("contextpilot")
expect(typeof pluginDefault.server).toBe("function")
})
})

describe("plugin initialization", () => {
it("server() returns hooks with transform and contextpilot_status tool", async () => {
const hooks = await ContextPilotPlugin()
expect(hooks["experimental.chat.messages.transform"]).toBeDefined()
expect(typeof hooks["experimental.chat.messages.transform"]).toBe("function")
expect(hooks.tool).toBeDefined()
expect(hooks.tool!.contextpilot_status).toBeDefined()
})
})

describe("single-doc cross-turn dedup", () => {
it("replaces duplicate tool output with a hint on second occurrence", async () => {
const hooks = await ContextPilotPlugin()
const transform = hooks["experimental.chat.messages.transform"]!

const msg1 = makeToolMessage("m1", [{ partId: "p1", callID: "c1", tool: "read_file", output: LONG_OUTPUT }])
const msg2 = makeToolMessage("m2", [{ partId: "p2", callID: "c2", tool: "read_file", output: LONG_OUTPUT }])
const messages = [msg1, msg2] as any

await transform({} as any, { messages })

expect(msg1.parts[0]!.state!.output).toBe(LONG_OUTPUT)
expect(msg2.parts[0]!.state!.output).toContain("Duplicate")
expect(msg2.parts[0]!.state!.output).toContain("c1")
})
})

describe("no dedup for short outputs", () => {
it("outputs under 100 chars are not deduped", async () => {
const hooks = await ContextPilotPlugin()
const transform = hooks["experimental.chat.messages.transform"]!

const shortOutput = "short"
const msg1 = makeToolMessage("m1", [{ partId: "p1", callID: "c1", tool: "read_file", output: shortOutput }])
const msg2 = makeToolMessage("m2", [{ partId: "p2", callID: "c2", tool: "read_file", output: shortOutput }])
const messages = [msg1, msg2] as any

await transform({} as any, { messages })

expect(msg1.parts[0]!.state!.output).toBe(shortOutput)
expect(msg2.parts[0]!.state!.output).toBe(shortOutput)
})
})

describe("no dedup on first occurrence", () => {
it("first time seeing content, output is unchanged", async () => {
const hooks = await ContextPilotPlugin()
const transform = hooks["experimental.chat.messages.transform"]!

const msg = makeToolMessage("m1", [{ partId: "p1", callID: "c1", tool: "read_file", output: LONG_OUTPUT }])
const messages = [msg] as any

await transform({} as any, { messages })

expect(msg.parts[0]!.state!.output).toBe(LONG_OUTPUT)
})
})

describe("block-level dedup", () => {
it("fires dedupChatCompletions and saves chars when blocks are shared", async () => {
const mockDedup = vi.mocked(dedupChatCompletions)
mockDedup.mockReturnValueOnce({ charsSaved: 500, blocksDeduped: 2, blocksTotal: 4, systemBlocksMatched: 0 } as any)

const hooks = await ContextPilotPlugin()
const transform = hooks["experimental.chat.messages.transform"]!

const msg1 = makeToolMessage("m1", [{ partId: "p1", callID: "c1", tool: "read_file", output: "unique-a-" + "z".repeat(200) }])
const msg2 = makeToolMessage("m2", [{ partId: "p2", callID: "c2", tool: "read_file", output: "unique-b-" + "z".repeat(200) }])
const messages = [msg1, msg2] as any

await transform({} as any, { messages })

expect(mockDedup).toHaveBeenCalled()
})
})

describe("stats tracking", () => {
it("contextpilot_status returns correct cumulative stats after optimization", async () => {
const mockDedup = vi.mocked(dedupChatCompletions)
mockDedup.mockReturnValue({ charsSaved: 0, blocksDeduped: 0, blocksTotal: 0, systemBlocksMatched: 0 } as any)

const hooks = await ContextPilotPlugin()
const transform = hooks["experimental.chat.messages.transform"]!
const statusTool = hooks.tool!.contextpilot_status

// Run a transform with a duplicate to accumulate stats
const msg1 = makeToolMessage("m1", [{ partId: "p1", callID: "c1", tool: "read_file", output: LONG_OUTPUT }])
const msg2 = makeToolMessage("m2", [{ partId: "p2", callID: "c2", tool: "read_file", output: LONG_OUTPUT }])
const messages = [msg1, msg2] as any

await transform({} as any, { messages })

const result = await (statusTool as any).execute({})
expect(result).toContain("Turns optimized: 1")
expect(result).toContain("Docs deduped: 1")
expect(result).toContain("Tracked hashes: 1")
expect(result).toContain("Reorder: dedup-only")
})
})

describe("transform hook error handling", () => {
it("bad input does not crash the transform", async () => {
const hooks = await ContextPilotPlugin()
const transform = hooks["experimental.chat.messages.transform"]!

// null messages
await expect(transform({} as any, { messages: null } as any)).resolves.toBeUndefined()

// messages with missing parts
await expect(transform({} as any, { messages: [{ info: { id: "x", role: "user", sessionID: "s" } }] } as any)).resolves.toBeUndefined()

// completely invalid input
await expect(transform({} as any, {} as any)).resolves.toBeUndefined()
})
})
Loading
Loading