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
10 changes: 10 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ into this AGPL distribution become part of the combined AGPL work.
For the standalone MIT-licensed version, install
`context-compress-algorithms` directly.

This distribution also bundles acp-kernel
(https://github.com/ranxianglei/acp-kernel), the framework-agnostic
compression engine, originally published under the MIT License. It is
inline-bundled into dist/index.js so npm consumers install no extra
dependency. The MIT-licensed source retains its MIT status when consumed
directly; the bytes inlined into this AGPL distribution become part of the
combined AGPL work.

For the standalone MIT-licensed version, install `acp-kernel` directly.

MIT License (context-compress-algorithms):

Copyright (c) 2026 ranxianglei
Expand Down
180 changes: 180 additions & 0 deletions devlog/2026-08-05_acp-kernel/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# DESIGN — opencode-acp → acp-kernel migration

Issue: dog/opencode-acp#42 · Branch: `2026-08-05_acp-kernel`

> **⚠️ SUPERSEDED (2026-08-05)**: The phased migration described in §9 below was
> **rejected** by @dog on issue #42 in favour of a **one-shot fresh rewrite**.
> See `WORKLOG.md` for the as-built design. This document is retained as the
> original design rationale; the §9 phase plan was **not** executed.

## 1. Why a phased migration

The current engine (`lib/compress`, `lib/messages`, `lib/state`, `lib/gc`) is
load-bearing: ~70 files, persisted-state format, 900+ tests, `dcp-` XML tags in
production state files, and a `/dcp` command alias for backward compat
(AGENTS.md §2.6). A big-bang replacement would break the shipped plugin, the
persisted state of live sessions, and be unreviewable. So:

- **Phase 1 (this PR)**: land `acp-kernel` + an additive `lib/kernel/` adapter.
Zero behavior change. Builds + tests stay green.
- **Phase 2+ (follow-ups)**: switch the hot paths to the kernel, migrate state,
delete the old engine.

## 2. The two state shapes (and why they differ)

### acp-kernel `CompressionState` (`acp-kernel/src/types.ts`)

```ts
interface CompressionState {
blocks: CompressionBlock[]; // blockId: string ("b0"…), tier: 1|2|3
messageRefs: { byRaw: Record; byRef: Record };
nudge: NudgeState; // flat: lastPerMessageNudgeTokens, lastShownByTier{}, …
stats: CompressionStats; // tokensCompressed, compressionCount
nextBlockId: number;
nextRunId: number;
}
```

### opencode-acp `SessionState` (`lib/state/types.ts`)

Richer / older format:

```ts
interface SessionState {
prune: { messages: { byMessageId: Map; blocksById: Map<number, CompressionBlock>;
activeBlockIds: Set<number>; activeByAnchorMessageId: Map; nextBlockId; nextRunId; markedForCleanup } }
nudges: { contextLimitAnchors: Set; turnNudgeAnchors: Set; …; lastTier2/3NudgeTokens; compressBaselineSet; … }
messageIds: { byRawId: Map; byRef: Map; nextRef }
stats; compressionTiming; toolParameters; toolIdList; modelContextLimit; systemPromptTokens; …
}
// blockId: number (NOT string); block has startId/endId (m-refs), anchorMessageId,
// compressMessageId, includedBlockIds, consumedBlockIds, parentBlockIds,
// directToolIds, effectiveToolIds, effectiveCompressedTokens, summaryTokens, …
```

**Implication**: the formats are incompatible. Phase 3 must ship a converter
(old → kernel) and a one-time migration on load. Phase 1 only ships a
*detector* + writes the kernel state to a **new** path
(`plugin/acp-kernel/{sessionId}.json`) so the old `plugin/acp/{sessionId}.json`
is never touched until the converter exists.

## 3. Config mapping (`lib/kernel/config.ts`)

`resolveKernelConfig(plugin: PluginConfig, modelContextLimit: number): Config`

| kernel `Config` field | source |
|----------------------------------|--------|
| `modelContextLimit` | runtime `ctx.input.model.limit.context` (hooks), fallback 150000 |
| `protectedTools` | `plugin.compress.protectedTools` (FORCE_COMPRESS_PROTECTED appended) + `plugin.commands.protectedTools` |
| `preserveRecentMessages` | `plugin.compress.preserveRecentMessages ?? 5` |
| `preserveRecentTokens` | `plugin.compress.preserveRecentTokens ?? 5000` |
| `promotionThreshold` | `plugin.gc.promotionThreshold` |
| `truncate.threshold` | `plugin.gc.majorGcThresholdPercent` parsed → fraction (default 1.0) |
| `nudge.{max,min}ContextLimitPct` | `plugin.compress.{max,min}ContextLimit` percent parsed |
| `nudge.frequency` | `plugin.compress.nudgeFrequency` |
| `nudge.iterationThreshold` | `plugin.compress.iterationNudgeThreshold` |
| `nudge.force` | `plugin.compress.nudgeForce` |
| `nudge.growthRatio/Floor/Cap` | defaults (0.05 / 6000 / 50000); `nudgeGrowthTokens` override → ratio |
| `nudge.minGrowthFloor/Ratio` | `plugin.compress.minNudgeGrowthFloor / minNudgeGrowthRatio` |
| `nudge.emergencyThresholdPct` | `plugin.compress.emergencyThresholdPercent` parsed |
| `compress.{min,max}…` | `plugin.compress.minCompressRange / maxSummaryLengthHard` |
| `tiers.enabled` | `true` (kernel always supports tiers) |
| `messageFilters` | `plugin.messageFilters` (shape-compatible passthrough) |

Percentage parsing reused from existing `lib/config.ts` helpers where possible.

## 4. Message projection (`lib/kernel/messages.ts`)

OpenCode `WithParts = { info: Message; parts: Part[] }`. Part kinds (from
existing `lib/message-ids.ts`, `lib/messages/utils.ts`):

- `part.type === "text"` → `{ text, ignored? }`
- `part.type === "tool"` → `{ tool, callID, state: { status, input, output } }`
- `part.type === "reasoning"` → reasoning text

`withPartsToCoreMessages(messages: WithParts[]): CoreMessage[]` maps each
message → one or more `CoreMessage`:

- role `user` text → `{ role:"user", contentType:"text", text }`
- assistant with N tool parts → N `{ role:"assistant", contentType:"tool-call",
toolName, toolCallId: callID, text: JSON(input)+text }` (split by callID, ids
`${id}#${callID}` — same id-splitting convention as pai-acp)
- assistant text-only → `{ role:"assistant", contentType:"text", text }`
- tool result: OpenCode models tool results as tool parts with `state.status`
on the assistant/tool message. The converter emits
`{ role:"tool", contentType:"tool-result", toolCallId, toolName, text }`
from completed tool parts.
- `reasoning` parts are dropped from `CoreMessage` (kernel is reasoning-blind);
a follow-up can add a `contentType:"reasoning"` if needed.

The inverse (`coreToWithParts`) reconstructs the OpenCode message list: for
non-split ids, patch ref tag onto the original; for split (`id#callID`) ids,
rebuild the assistant message keeping only surviving callIDs (pai-acp pattern).
`acp_summary_*` synthetic ids are skipped (compress-as-anchor: summaries live
inside the model's own `compress` calls, not synthetic messages).

## 5. Runtime (`lib/kernel/runtime.ts`)

Mirrors `pai-acp/src/runtime.ts`:

```ts
export interface AcpCoreRuntime {
core: CompressionCore
configFor(plugin: PluginConfig, modelContextLimit: number): Config
stateFor(sessionId: string): Promise<{ state: CompressionState; coreMessages: CoreMessage[] }>
save(state: CompressionState, sessionId: string): Promise<void>
acquireLock(sessionId: string): Promise<() => void>
invalidate(sessionId: string): void
}
export function createCoreRuntime(): AcpCoreRuntime
```

- `createCore({ countTokens })` once; `countTokens` from existing
`lib/token-utils.ts` (BPE) so token counts match the rest of the plugin.
- per-session in-memory cache + async lock (no concurrent processTurn for the
same session — pai-acp uses a promise-chain lock; we copy it).
- `stateFor` loads kernel state (or fresh) and projects current messages; the
actual message list is passed in by the caller (hooks) so the runtime stays
free of the OpenCode client SDK.

## 6. State persistence (`lib/kernel/state.ts`)

- Path: `<storage>/plugin/acp-kernel/{sessionId}.json`
(`<storage>` = existing `~/.local/share/opencode/storage`).
- Atomic write (tmp + rename), same pattern as `lib/state/persistence.ts`.
- Load merges missing top-level fields from `createInitialState()`
(forward-compat, pai-acp `mergeInitialState`).
- `detectLegacyState(sessionId)` returns the parsed legacy `SessionState` if
`plugin/acp/{sessionId}.json` exists and looks like one (has
`prune.blocksById`). Phase 3 will consume this; Phase 1 only logs it.

## 7. tsup / packaging

`tsup.config.ts` `noExternal` gains `"acp-kernel"` so the published
`dist/index.js` is self-contained (npm consumers install no extra dep). Same
treatment as `context-compress-algorithms`. `NOTICE` gains the acp-kernel MIT
attribution.

## 8. What does NOT change in Phase 1

- `index.ts` entry, `lib/hooks.ts`, all tools, `/acp` commands, prompts,
notifications, the old engine — untouched.
- `plugin/acp/{sessionId}.json` legacy state — untouched.
- `dcp-` XML tags, `/dcp` alias, config schema — untouched.

## 9. Phasing summary

| Phase | PR scope | Risk |
|-------|----------|------|
| **1 (this)** | acp-kernel dep + `lib/kernel/` adapter (additive) | none — nothing wired |
| 2 | rewire hooks message-transform + tools to kernel runtime | high — hot path |
| 3 | legacy-state migration converter + delete old engine | high — persisted state |
| 4 | retire `dcp-` tags (needs persisted-state migration plan) | medium |

## 10. Backward-compat guardrails

- Never write to `plugin/acp/{sessionId}.json` from kernel code.
- Never change `dcp-` tag names without a migration (AGENTS.md §2.6).
- Keep `compress.protectedTools` force-protect of `"compress"`
(`FORCE_COMPRESS_PROTECTED`) in the config mapping — losing a compress
summary is irreversible (Bug: sequential-compress summary loss).
80 changes: 80 additions & 0 deletions devlog/2026-08-05_acp-kernel/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# REQ — opencode-acp 内核换成 acp kernel

Issue: dog/opencode-acp#42
Branch: `2026-08-05_acp-kernel` (worktree `/home/dog/projects/opencode-acp-kernel`)

## Goal

Replace opencode-acp's in-tree compression engine with the external
**`acp-kernel`** library, following the **`pai-acp`** adapter pattern. Both
reference projects live under `~/projects`; per the issue ("参考 pai acp … 可以搞新的
worktree 工作") the work is done in a new git worktree.

## Background

`opencode-acp` currently ships its own copy of the compression engine in `lib/`
(`lib/compress/`, `lib/messages/`, `lib/state/`, `lib/gc/`, … — ~70 files).
`acp-kernel` (npm `acp-kernel@0.0.16`, MIT, zero runtime deps) is the same
engine extracted as a framework-agnostic library with a clean host adapter
surface:

- `createCore(ports?)` → `{ processTurn, applyCompression, defaultNodes, decompress, search, status }`
- `createInitialState()`, `defaultConfig(modelContextLimit, overrides?)`, `validateConfig()`
- Stateless re: storage — the host owns persistence; state is passed in/out each call.

`pai-acp` (`v0.1.20`) is the reference adapter: it wraps `acp-kernel` with a
small `AcpRuntime` (per-session state store + lock + config/message projection)
and is the model to follow for the OpenCode port.

## Non-goals (this PR)

- Do **not** delete the existing `lib/` engine in this PR. This PR lands the
kernel as a dependency plus the adapter layer (`lib/kernel/`) as **additive**
code that builds and typechecks alongside the old engine. Rewiring
`hooks.ts`/tools and deleting the old engine happens in follow-up PRs (see
DESIGN → Phasing). This keeps the change reviewable and never breaks the
shipped plugin.

## Scope of this PR (Phase 1 — Foundation)

1. Add `acp-kernel` as a dependency (exact pin `0.0.16`) and inline-bundle it
via `tsup` `noExternal` (published tarball must stay self-contained, matching
the `context-compress-algorithms` precedent).
2. Add `NOTICE` attribution for the bundled MIT `acp-kernel`.
3. Add `lib/kernel/` adapter package:
- `runtime.ts` — `AcpCoreRuntime`: owns `createCore`, a per-session
`CompressionState` store (with async lock), config resolver, message
projection entry points (`stateFor` / `save`).
- `config.ts` — `resolveKernelConfig(PluginConfig, modelContextLimit)`:
maps the existing 3-layer `PluginConfig` onto the kernel `Config`
(incl. nudge thresholds, protectedTools, preserve-recent, tiers, message
filters).
- `messages.ts` — `withPartsToCoreMessages` (OpenCode `WithParts[]` →
`CoreMessage[]`) and the inverse reconstruction helper.
- `state.ts` — persist the kernel `CompressionState` under
`plugin/acp-kernel/{sessionId}.json`, with a forward-compatible load
(merge missing fields from `createInitialState()`), plus a **detector**
that recognizes the legacy `plugin/acp/{sessionId}.json` SessionState so a
later migration PR can convert it.
- `index.ts` — barrel.
4. Verify `npm run typecheck`, `npm run build`, and `npm run test` all pass
(existing suite must remain green — nothing in the old engine is touched).

## Acceptance criteria

- [ ] `acp-kernel@0.0.16` is a dependency and is bundled into `dist/index.js`.
- [ ] `lib/kernel/` exists, exports the adapter API, and `tsc --noEmit` passes.
- [ ] Existing test suite stays green (no behavior change to the running plugin).
- [ ] `devlog/2026-08-05_acp-kernel/{REQ,DESIGN,WORKLOG}.md` present.

## Follow-up PRs (tracked here, NOT done in this PR)

- **Phase 2**: rewire `lib/hooks.ts` message-transform to call
`runtime.core.processTurn` and convert its output back to OpenCode messages;
port the compress/decompress/search/status tools to use
`runtime.core.applyCompression` / `decompress` / `search` / `status`.
- **Phase 3**: legacy-state migration (old `SessionState` → kernel
`CompressionState`), then delete `lib/compress/`, `lib/messages/`,
`lib/state/`, `lib/gc/` engine code.
- **Phase 4**: retire `dcp-` internal tags in favor of kernel tags where the
migration plan permits (AGENTS.md §2.6 — needs a persisted-state migration).
Loading
Loading