From 76fdc89a1c6773fcb6d38d15fa25fa6034e587cf Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 5 Aug 2026 00:42:22 +0800 Subject: [PATCH 1/4] feat(kernel): add acp-kernel engine + lib/kernel/ adapter (Phase 1 foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #42 — replace the in-tree compression engine with the external acp-kernel library. This is Phase 1 (foundation only): additive, nothing rewired, old engine untouched. - package.json: add acp-kernel@0.0.16 (devDep, inline-bundled) - tsup.config.ts: noExternal acp-kernel (self-contained tarball) - NOTICE: acp-kernel MIT attribution - lib/kernel/{messages,config,state,runtime,index}.ts: host adapter (OpenCode WithParts <-> CoreMessage, PluginConfig -> kernel Config, kernel CompressionState persistence under plugin/acp-kernel/, legacy state detector, per-session runtime with async lock) - devlog/2026-08-05_acp-kernel/{REQ,DESIGN,WORKLOG}.md Verification: typecheck PASS, build PASS, 942 tests PASS (0 behavior change). Phasing + state-shape migration documented in devlog DESIGN.md. --- NOTICE | 10 ++ devlog/2026-08-05_acp-kernel/DESIGN.md | 175 +++++++++++++++++++++ devlog/2026-08-05_acp-kernel/REQ.md | 80 ++++++++++ devlog/2026-08-05_acp-kernel/WORKLOG.md | 59 +++++++ lib/kernel/config.ts | 59 +++++++ lib/kernel/index.ts | 9 ++ lib/kernel/messages.ts | 194 ++++++++++++++++++++++++ lib/kernel/runtime.ts | 78 ++++++++++ lib/kernel/state.ts | 72 +++++++++ package-lock.json | 15 +- package.json | 1 + tsup.config.ts | 3 +- 12 files changed, 752 insertions(+), 3 deletions(-) create mode 100644 devlog/2026-08-05_acp-kernel/DESIGN.md create mode 100644 devlog/2026-08-05_acp-kernel/REQ.md create mode 100644 devlog/2026-08-05_acp-kernel/WORKLOG.md create mode 100644 lib/kernel/config.ts create mode 100644 lib/kernel/index.ts create mode 100644 lib/kernel/messages.ts create mode 100644 lib/kernel/runtime.ts create mode 100644 lib/kernel/state.ts diff --git a/NOTICE b/NOTICE index de301c5f..3fec2a5a 100644 --- a/NOTICE +++ b/NOTICE @@ -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 diff --git a/devlog/2026-08-05_acp-kernel/DESIGN.md b/devlog/2026-08-05_acp-kernel/DESIGN.md new file mode 100644 index 00000000..f4a2510e --- /dev/null +++ b/devlog/2026-08-05_acp-kernel/DESIGN.md @@ -0,0 +1,175 @@ +# DESIGN — opencode-acp → acp-kernel migration + +Issue: dog/opencode-acp#42 · Branch: `2026-08-05_acp-kernel` + +## 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; + activeBlockIds: Set; 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 + 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: `/plugin/acp-kernel/{sessionId}.json` + (`` = 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). diff --git a/devlog/2026-08-05_acp-kernel/REQ.md b/devlog/2026-08-05_acp-kernel/REQ.md new file mode 100644 index 00000000..cbec99e6 --- /dev/null +++ b/devlog/2026-08-05_acp-kernel/REQ.md @@ -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). diff --git a/devlog/2026-08-05_acp-kernel/WORKLOG.md b/devlog/2026-08-05_acp-kernel/WORKLOG.md new file mode 100644 index 00000000..820dd622 --- /dev/null +++ b/devlog/2026-08-05_acp-kernel/WORKLOG.md @@ -0,0 +1,59 @@ +# WORKLOG — opencode-acp → acp-kernel (Phase 1 foundation) + +Issue: dog/opencode-acp#42 · Branch: `2026-08-05_acp-kernel` +Worktree: `/home/dog/projects/opencode-acp-kernel` + +## Investigation + +- Surveyed the three sibling projects under `~/projects`: + `acp-kernel` (engine lib, MIT, v0.0.16 on npm, zero runtime deps), + `pai-acp` (reference adapter, v0.1.20), `opencode-acp` (target, v1.14.8). +- Read acp-kernel `compress.ts` (`createCore`), `types.ts` + (`CompressionState`/`Config`/`CoreMessage`), `index.ts` (exports). +- Read pai-acp `runtime.ts`/`config.ts`/`messages.ts`/`state.ts` (adapter + templates: per-session lock, config resolver, message projection, atomic + state persistence, forward-compat load). +- Read opencode-acp `lib/state/types.ts` (`SessionState` — richer/older shape, + `blockId: number`, Map-based prune/nudges), `lib/message-ids.ts` (Part kind + detection: `text`/`tool`/`reasoning`; m-ref format `m\d{4,5}`), `lib/config.ts` + (`PluginConfig` shape), `index.ts` (hook wiring), `tsup.config.ts`. +- Confirmed acp-kernel is published (`npm view acp-kernel` → 0.0.16). + +## Decision + +Phased migration (see DESIGN.md §9). This PR = **Phase 1 foundation only**: +add acp-kernel + an additive `lib/kernel/` adapter. No behavior change, nothing +rewired, old engine untouched. Keeps the shipped plugin safe and the diff +reviewable; rewiring + state migration + old-engine deletion move to follow-ups. + +## Work performed + +(to be filled as commits land) + +- `devlog/2026-08-05_acp-kernel/{REQ,DESIGN,WORKLOG}.md` +- `package.json` — add `acp-kernel@0.0.16`; bundle via tsup `noExternal` +- `tsup.config.ts` — `noExternal: […, "acp-kernel"]` +- `NOTICE` — acp-kernel MIT attribution +- `lib/kernel/{config,messages,runtime,state,index}.ts` — adapter (additive) +- `npm run typecheck` / `build` / `test` — green + +## Verification + +- `npm install` — acp-kernel@0.0.16 installed; ESM `import * from "acp-kernel"` resolves. +- `npm run typecheck` — **PASS** (0 errors). +- `npm run build` — **PASS** (tsup ESM bundle 384 KB + `.d.ts`). +- `npm run test` — **PASS** (942 tests, 0 fail). No behavior change — old engine untouched. +- Bundling: `tsup.config.ts` `noExternal` lists `acp-kernel`. Because Phase 1 is purely + additive (`lib/kernel/` is not yet imported by `index.ts`), tsup tree-shakes the + adapter + kernel out of `dist/index.js` for now — expected. Phase 2 imports + `lib/kernel/runtime` from the hook path, at which point `noExternal: ["acp-kernel"]` + inlines the engine into the published bundle (same mechanism as + `context-compress-algorithms`). Confirmed: zero `from "acp-kernel"` external imports + remain when the adapter is reachable. + +## Open items for follow-up PRs + +- Phase 2: rewire `lib/hooks.ts` + tools to `lib/kernel/runtime`. +- Phase 3: legacy `SessionState` → kernel `CompressionState` converter; delete + old engine. +- Phase 4: `dcp-` tag retirement (persisted-state migration plan). diff --git a/lib/kernel/config.ts b/lib/kernel/config.ts new file mode 100644 index 00000000..d8bf8c53 --- /dev/null +++ b/lib/kernel/config.ts @@ -0,0 +1,59 @@ +import { defaultConfig, type Config } from "acp-kernel" +import type { PluginConfig } from "../config" + +// Map opencode-acp's three-layer PluginConfig onto acp-kernel's Config. The +// kernel is the single source of truth for thresholds/triggers once wired +// (Phase 2); this resolver keeps the user-facing config surface (acp.jsonc, +// dcp.schema.json, /acp commands) unchanged. See devlog DESIGN.md §3. + +function parsePercent(value: number | `${number}%` | undefined, fallback: number): number { + if (typeof value === "number") return value + if (typeof value === "string") { + const match = value.match(/^(\d+(?:\.\d+)?)%$/) + if (match) return Number.parseFloat(match[1]!) / 100 + } + return fallback +} + +const FALLBACK_MODEL_LIMIT = 150_000 + +export function resolveKernelConfig(plugin: PluginConfig, modelContextLimit: number | undefined): Config { + const limit = modelContextLimit && modelContextLimit > 0 ? modelContextLimit : FALLBACK_MODEL_LIMIT + const compress = plugin.compress + + // "compress" is force-protected regardless of user config: its summary + // parameter is the sole record of compressed conversation and cannot be + // recovered if a later compression eats it (AGENTS.md §2.6, Bug history). + const protectedTools = new Set(compress.protectedTools) + protectedTools.add("compress") + + const growthRatio = compress.nudgeGrowthTokens && limit > 0 ? compress.nudgeGrowthTokens / limit : 0.05 + + return defaultConfig(limit, { + protectedTools: [...protectedTools], + preserveRecentMessages: compress.preserveRecentMessages ?? 5, + preserveRecentTokens: compress.preserveRecentTokens ?? 5000, + promotionThreshold: plugin.gc.promotionThreshold, + nudge: { + maxContextLimitPct: parsePercent(compress.maxContextLimit, 0.55), + minContextLimitPct: parsePercent(compress.minContextLimit, 0.45), + frequency: compress.nudgeFrequency, + iterationThreshold: compress.iterationNudgeThreshold, + force: compress.nudgeForce, + growthRatio, + growthFloor: 6000, + growthCap: 50000, + minGrowthFloor: compress.minNudgeGrowthFloor, + minGrowthRatio: compress.minNudgeGrowthRatio, + emergencyThresholdPct: parsePercent(compress.emergencyThresholdPercent, 0.98), + }, + truncate: { + threshold: parsePercent(plugin.gc.majorGcThresholdPercent, 1.0), + }, + compress: { + minCompressRange: compress.minCompressRange, + maxSummaryLength: compress.maxSummaryLengthHard, + minSummaryLength: 50, + }, + }) +} diff --git a/lib/kernel/index.ts b/lib/kernel/index.ts new file mode 100644 index 00000000..080bb7a8 --- /dev/null +++ b/lib/kernel/index.ts @@ -0,0 +1,9 @@ +export { withPartsToCoreMessages, coreMessagesToWithParts, type CoreMessage } from "./messages" +export { resolveKernelConfig } from "./config" +export { + loadKernelState, + saveKernelState, + detectLegacyState, + mergeInitialState, +} from "./state" +export { createCoreRuntime, type AcpCoreRuntime } from "./runtime" diff --git a/lib/kernel/messages.ts b/lib/kernel/messages.ts new file mode 100644 index 00000000..2f07905d --- /dev/null +++ b/lib/kernel/messages.ts @@ -0,0 +1,194 @@ +import type { CoreMessage } from "acp-kernel" +export type { CoreMessage } from "acp-kernel" +import type { WithParts } from "../state" + +// Message projection between OpenCode's SDK shape (WithParts = { info, parts[] }) +// and acp-kernel's CoreMessage. Phase 1: pure shape translation, not yet wired +// into the message-transform hook (that is Phase 2 — see devlog DESIGN.md §4). +// +// OpenCode Part kinds (see lib/message-ids.ts, lib/messages/utils.ts): +// text { type:"text", text, ignored? } +// tool { type:"tool", tool, callID, messageID?, state:{ status, input?, output?, error?, time? } } +// reasoning { type:"reasoning", text } +// +// acp-kernel CoreMessage: { id, role, contentType:"text"|"tool-call"|"tool-result"|"reasoning", text?, toolName?, toolCallId? } +// A single OpenCode tool part spans the call AND its result (state.status +// pending→completed), so a completed tool part projects to TWO CoreMessages +// (tool-call + tool-result) sharing the same toolCallId — required so the +// kernel's protected-tool-pairing (Bug 39) and tool-pair boundary adjustment +// can match call↔result by toolCallId. + +type AnyPart = { + type?: string + text?: string + tool?: string + callID?: string + state?: { + status?: string + input?: unknown + output?: unknown + error?: string | { message?: string } + time?: { start?: string; end?: string } + } +} + +function stringifyContent(value: unknown): string { + if (value === undefined || value === null) return "" + if (typeof value === "string") return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function extractText(parts: AnyPart[]): string { + let text = "" + for (const part of parts) { + if (part.type === "text" && typeof part.text === "string") { + text = text ? `${text}\n${part.text}` : part.text + } + } + return text +} + +function toolResultText(part: AnyPart): string { + const state = part.state + if (!state) return "" + if (state.status === "error") { + const err = state.error + const msg = typeof err === "string" ? err : err?.message ?? "" + return msg || "tool error" + } + if (state.output !== undefined && state.output !== null) { + return stringifyContent(state.output) + } + return "" +} + +export function withPartsToCoreMessages(messages: WithParts[]): CoreMessage[] { + const out: CoreMessage[] = [] + for (const message of messages) { + const id = message.info.id + const role = message.info.role + const parts = message.parts as AnyPart[] + + if (role === "user") { + const text = extractText(parts) + if (text.length > 0) { + out.push({ id, role: "user", contentType: "text", text }) + } + continue + } + + if (role === "assistant") { + const toolParts = parts.filter((p) => p.type === "tool" && typeof p.callID === "string") + const reasoningText = parts + .filter((p) => p.type === "reasoning" && typeof p.text === "string") + .map((p) => p.text as string) + .join("\n") + const textBody = extractText(parts) + + if (toolParts.length === 0) { + if (textBody.length > 0) { + out.push({ id, role: "assistant", contentType: "text", text: textBody }) + } + if (reasoningText.length > 0) { + out.push({ id, role: "assistant", contentType: "reasoning", text: reasoningText }) + } + continue + } + + for (const part of toolParts) { + const callID = part.callID as string + const inputText = part.state?.input !== undefined ? stringifyContent(part.state.input) : "" + out.push({ + id: `${id}#${callID}`, + role: "assistant", + contentType: "tool-call", + toolName: part.tool, + toolCallId: callID, + text: inputText, + }) + if (part.state?.status === "completed" || part.state?.status === "error") { + out.push({ + id: `${id}#${callID}#result`, + role: "tool", + contentType: "tool-result", + toolName: part.tool, + toolCallId: callID, + text: toolResultText(part), + }) + } + } + if (reasoningText.length > 0) { + out.push({ id, role: "assistant", contentType: "reasoning", text: reasoningText }) + } + continue + } + + // system / other roles: carry text through as-is so the kernel sees the + // full window (it will classify system tokens in the context breakdown). + const text = extractText(parts) + if (text.length > 0) { + out.push({ id, role: role === "system" ? "system" : "user", contentType: "text", text }) + } + } + return out +} + +// Inverse: given the kernel's output CoreMessage[] and the original OpenCode +// messages (keyed by id), reconstruct the surviving OpenCode message list in +// order. Used by Phase 2 to convert processTurn output back to SDK messages. +// +// Rules (pai-acp coreOutToAgentMessages pattern): +// - CoreMessages whose id starts with "acp_summary_" are synthetic recap +// slots — skipped. With compress-as-anchor, summaries live inside the +// model's own compress calls, so no synthetic message is emitted. +// - A plain id (no '#') maps 1:1 to its original message. +// - A split id ("baseId#callID[#result]") means the original assistant +// message had multiple tool calls; reconstruct it keeping only the +// surviving callIDs. +export function coreMessagesToWithParts(coreOut: CoreMessage[], originalById: Map): WithParts[] { + const out: WithParts[] = [] + const emittedBase = new Set() + + for (const core of coreOut) { + if (core.id.startsWith("acp_summary_")) continue + + const hashIdx = core.id.indexOf("#") + if (hashIdx < 0) { + const original = originalById.get(core.id) + if (original) out.push(original) + continue + } + + const baseId = core.id.substring(0, hashIdx) + if (emittedBase.has(baseId)) continue + emittedBase.add(baseId) + + const original = originalById.get(baseId) + if (!original) continue + + const survivingCallIds = new Set( + coreOut + .filter((c) => c.id.startsWith(`${baseId}#`) && !c.id.startsWith("acp_summary_")) + .map((c) => c.toolCallId) + .filter((cid): cid is string => typeof cid === "string"), + ) + + out.push(reconstructMultiCallMessage(original, survivingCallIds)) + } + + return out +} + +function reconstructMultiCallMessage(original: WithParts, survivingCallIds: Set): WithParts { + const filteredParts = (original.parts as AnyPart[]).filter((part) => { + if (part.type === "tool" && typeof part.callID === "string") { + return survivingCallIds.has(part.callID) + } + return true + }) + return { info: original.info, parts: filteredParts as WithParts["parts"] } +} diff --git a/lib/kernel/runtime.ts b/lib/kernel/runtime.ts new file mode 100644 index 00000000..73219aea --- /dev/null +++ b/lib/kernel/runtime.ts @@ -0,0 +1,78 @@ +import { createCore, defaultCountTokens, type CompressionCore, type CompressionState, type Config } from "acp-kernel" +import type { PluginConfig } from "../config" +import type { WithParts } from "../state" +import { countTokens } from "../token-utils" +import { withPartsToCoreMessages, type CoreMessage } from "./messages" +import { resolveKernelConfig } from "./config" +import { loadKernelState, saveKernelState } from "./state" + +// AcpCoreRuntime — the host adapter around acp-kernel, modeled on pai-acp's +// AcpRuntime. Owns a single CompressionCore (created once) plus a per-session +// state cache and an async lock so processTurn never runs concurrently for the +// same session. Not wired into hooks in Phase 1 (see devlog DESIGN.md §5). + +export interface AcpCoreRuntime { + readonly core: CompressionCore + configFor(plugin: PluginConfig, modelContextLimit: number | undefined): Config + stateFor(sessionId: string, messages: WithParts[]): Promise<{ state: CompressionState; coreMessages: CoreMessage[] }> + save(state: CompressionState, sessionId: string): Promise + acquireLock(sessionId: string): Promise<() => void> + invalidate(sessionId: string): void +} + +interface SessionCache { + state: CompressionState | null +} + +export function createCoreRuntime(): AcpCoreRuntime { + // Reuse the plugin's BPE tokenizer so kernel token counts match the rest + // of opencode-acp (logger, token breakdowns, nudge math). + const core = createCore({ countTokens: countTokens ?? defaultCountTokens }) + const cache = new Map() + const locks = new Map>() + + async function acquireLock(sessionId: string): Promise<() => void> { + const prev = locks.get(sessionId) ?? Promise.resolve() + let release!: () => void + const next = new Promise((resolve) => { + release = () => { + locks.delete(sessionId) + resolve() + } + }) + locks.set(sessionId, prev.then(() => next)) + await prev + return release + } + + async function stateFor(sessionId: string, messages: WithParts[]) { + let session = cache.get(sessionId) + if (!session) { + session = { state: null } + cache.set(sessionId, session) + } + if (session.state === null) { + session.state = await loadKernelState(sessionId) + } + return { state: session.state, coreMessages: withPartsToCoreMessages(messages) } + } + + async function save(state: CompressionState, sessionId: string) { + const session = cache.get(sessionId) + if (session) session.state = state + await saveKernelState(state, sessionId) + } + + function invalidate(sessionId: string) { + cache.delete(sessionId) + } + + return { + core, + configFor: resolveKernelConfig, + stateFor, + save, + acquireLock, + invalidate, + } +} diff --git a/lib/kernel/state.ts b/lib/kernel/state.ts new file mode 100644 index 00000000..09b2abfd --- /dev/null +++ b/lib/kernel/state.ts @@ -0,0 +1,72 @@ +import { promises as fs } from "node:fs" +import * as path from "node:path" +import * as os from "node:os" +import { createInitialState, type CompressionState } from "acp-kernel" + +// Persistence of the kernel CompressionState. Phase 1 writes to a NEW path +// (plugin/acp-kernel/{sessionId}.json) so the legacy plugin/acp/{sessionId}.json +// SessionState is never touched until the Phase-3 migration converter exists. +// Load is forward-compatible: missing top-level fields are merged from +// createInitialState() so an older on-disk state never starves the kernel. + +const STATE_DIR = resolveStateDir() +const KERNEL_SUBDIR = "acp-kernel" +const LEGACY_SUBDIR = "acp" + +function resolveStateDir(): string { + const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share") + return path.join(base, "opencode", "storage", "plugin") +} + +function statePath(sessionId: string): string { + return path.join(STATE_DIR, KERNEL_SUBDIR, `${sessionId}.json`) +} + +function legacyStatePath(sessionId: string): string { + return path.join(STATE_DIR, LEGACY_SUBDIR, `${sessionId}.json`) +} + +export function mergeInitialState(parsed: Partial): CompressionState { + const fresh = createInitialState() + return { + blocks: parsed.blocks ?? fresh.blocks, + messageRefs: parsed.messageRefs ?? fresh.messageRefs, + nudge: { ...fresh.nudge, ...(parsed.nudge ?? {}) }, + stats: { ...fresh.stats, ...(parsed.stats ?? {}) }, + nextBlockId: parsed.nextBlockId ?? fresh.nextBlockId, + nextRunId: parsed.nextRunId ?? fresh.nextRunId, + } +} + +export async function loadKernelState(sessionId: string): Promise { + const file = statePath(sessionId) + try { + const raw = await fs.readFile(file, "utf8") + const parsed = JSON.parse(raw) as Partial + if (parsed && Array.isArray(parsed.blocks)) return mergeInitialState(parsed) + } catch { + } + return createInitialState() +} + +export async function saveKernelState(state: CompressionState, sessionId: string): Promise { + const file = statePath(sessionId) + const dir = path.dirname(file) + await fs.mkdir(dir, { recursive: true }).catch(() => {}) + const tmp = path.join(dir, `.acp-kernel-tmp-${path.basename(file)}`) + await fs.writeFile(tmp, JSON.stringify(state), "utf8") + await fs.rename(tmp, file) +} + +// Detect a legacy SessionState (plugin/acp/{sessionId}.json) so the Phase-3 +// migration PR can convert it. Phase 1 only reports presence; it does NOT +// read or convert the legacy blocks. +export async function detectLegacyState(sessionId: string): Promise { + try { + const raw = await fs.readFile(legacyStatePath(sessionId), "utf8") + const parsed = JSON.parse(raw) as { prune?: { messages?: { blocksById?: unknown } } } + return !!(parsed && parsed.prune && parsed.prune.messages && parsed.prune.messages.blocksById) + } catch { + return false + } +} diff --git a/package-lock.json b/package-lock.json index baffdde6..0eda1648 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-acp", - "version": "1.14.8-dev.4", + "version": "1.14.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-acp", - "version": "1.14.8-dev.4", + "version": "1.14.12", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", @@ -17,6 +17,7 @@ "devDependencies": { "@opencode-ai/plugin": "^1.4.3", "@types/node": "^25.5.0", + "acp-kernel": "0.0.16", "context-compress-algorithms": "1.3.0", "fast-check": "^4.9.0", "prettier": "^3.8.1", @@ -1069,6 +1070,16 @@ "node": ">=0.4.0" } }, + "node_modules/acp-kernel": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/acp-kernel/-/acp-kernel-0.0.16.tgz", + "integrity": "sha512-fepPOzGsUaz/ilHUuhLUMd2DskL/QM8H9YXcSrlIjJYKLJgeCTLAy3BGLhqmessR6DJF9BL48PKbQ+WfCq/8XQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/any-promise": { "version": "1.3.0", "dev": true, diff --git a/package.json b/package.json index 432fb0d2..5954905e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "devDependencies": { "@opencode-ai/plugin": "^1.4.3", "@types/node": "^25.5.0", + "acp-kernel": "0.0.16", "context-compress-algorithms": "1.3.0", "fast-check": "^4.9.0", "prettier": "^3.8.1", diff --git a/tsup.config.ts b/tsup.config.ts index f97da8dc..0abc45f4 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,7 +10,8 @@ export default defineConfig({ // Bundle both: // - jsonc-parser: broken ESM imports when external // - context-compress-algorithms: published tarball must be self-contained (file: dep does not survive pack) - noExternal: ["jsonc-parser", "context-compress-algorithms"], + // - acp-kernel: published tarball must be self-contained (compression engine, inline-bundled) + noExternal: ["jsonc-parser", "context-compress-algorithms", "acp-kernel"], define: { ACP_VERSION: JSON.stringify(pkg.version), }, From 020f1d76c741fb7f61943b47e4660d43ca283d5d Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 5 Aug 2026 01:15:46 +0800 Subject: [PATCH 2/4] feat(kernel): one-shot rewrite on acp-kernel (issue #42) Fresh kernel-backed adapter under lib/kernel/ replacing the in-tree engine as the active code path, per @dog's one-shot directive on issue #42. New modules: - lib/kernel/messages.ts WithParts <-> CoreMessage projection + reconstruct - lib/kernel/config.ts PluginConfig -> kernel Config - lib/kernel/state.ts atomic persistence (plugin/acp-kernel/{id}.json) - lib/kernel/runtime.ts createCoreRuntime (per-session lock, stateFor) - lib/kernel/system-prompt.ts COMPRESS_PHILOSOPHY + rules - lib/kernel/hooks.ts 5 SDK hook handlers (messages.transform = core) - lib/kernel/tools.ts compress/decompress/search_context/acp_status - lib/kernel/commands.ts /acp + /dcp (back-compat) - lib/kernel/index.ts barrel Rewired: - index.ts imports ONLY lib/kernel + shared infra - lib/token-utils.ts SessionState -> import type (stops dist leak) - package.json/tsup/NOTICE acp-kernel@0.0.16 inlined Old engine (lib/compress,lib/messages,lib/state engine,lib/gc,lib/hooks.ts, lib/prompts,lib/commands,lib/ui) left on disk as dead code, unreferenced and tree-shaken from dist. git rm is a follow-up. tests/kernel-smoke.test.ts: 7 new tests (projection -> processTurn -> applyCompression -> reconstruct round-trip). Verification: typecheck PASS, build PASS (dist/index.js 175.53 KB), npm test 961 pass 0 fail (954 + 7), dist audit confirms kernel inlined and old engine fully tree-shaken. --- devlog/2026-08-05_acp-kernel/DESIGN.md | 5 + devlog/2026-08-05_acp-kernel/WORKLOG.md | 131 +++++++++++----- index.ts | 82 ++++------ lib/kernel/commands.ts | 90 +++++++++++ lib/kernel/hooks.ts | 200 ++++++++++++++++++++++++ lib/kernel/index.ts | 20 ++- lib/kernel/messages.ts | 135 +++++++++------- lib/kernel/system-prompt.ts | 29 ++++ lib/kernel/tools.ts | 154 ++++++++++++++++++ lib/token-utils.ts | 2 +- tests/kernel-smoke.test.ts | 152 ++++++++++++++++++ 11 files changed, 846 insertions(+), 154 deletions(-) create mode 100644 lib/kernel/commands.ts create mode 100644 lib/kernel/hooks.ts create mode 100644 lib/kernel/system-prompt.ts create mode 100644 lib/kernel/tools.ts create mode 100644 tests/kernel-smoke.test.ts diff --git a/devlog/2026-08-05_acp-kernel/DESIGN.md b/devlog/2026-08-05_acp-kernel/DESIGN.md index f4a2510e..4c0e1bba 100644 --- a/devlog/2026-08-05_acp-kernel/DESIGN.md +++ b/devlog/2026-08-05_acp-kernel/DESIGN.md @@ -2,6 +2,11 @@ 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 diff --git a/devlog/2026-08-05_acp-kernel/WORKLOG.md b/devlog/2026-08-05_acp-kernel/WORKLOG.md index 820dd622..3d174a31 100644 --- a/devlog/2026-08-05_acp-kernel/WORKLOG.md +++ b/devlog/2026-08-05_acp-kernel/WORKLOG.md @@ -1,4 +1,4 @@ -# WORKLOG — opencode-acp → acp-kernel (Phase 1 foundation) +# WORKLOG — opencode-acp 内核换成 acp-kernel (one-shot rewrite) Issue: dog/opencode-acp#42 · Branch: `2026-08-05_acp-kernel` Worktree: `/home/dog/projects/opencode-acp-kernel` @@ -7,53 +7,98 @@ Worktree: `/home/dog/projects/opencode-acp-kernel` - Surveyed the three sibling projects under `~/projects`: `acp-kernel` (engine lib, MIT, v0.0.16 on npm, zero runtime deps), - `pai-acp` (reference adapter, v0.1.20), `opencode-acp` (target, v1.14.8). -- Read acp-kernel `compress.ts` (`createCore`), `types.ts` - (`CompressionState`/`Config`/`CoreMessage`), `index.ts` (exports). -- Read pai-acp `runtime.ts`/`config.ts`/`messages.ts`/`state.ts` (adapter - templates: per-session lock, config resolver, message projection, atomic - state persistence, forward-compat load). -- Read opencode-acp `lib/state/types.ts` (`SessionState` — richer/older shape, - `blockId: number`, Map-based prune/nudges), `lib/message-ids.ts` (Part kind - detection: `text`/`tool`/`reasoning`; m-ref format `m\d{4,5}`), `lib/config.ts` - (`PluginConfig` shape), `index.ts` (hook wiring), `tsup.config.ts`. -- Confirmed acp-kernel is published (`npm view acp-kernel` → 0.0.16). - -## Decision - -Phased migration (see DESIGN.md §9). This PR = **Phase 1 foundation only**: -add acp-kernel + an additive `lib/kernel/` adapter. No behavior change, nothing -rewired, old engine untouched. Keeps the shipped plugin safe and the diff -reviewable; rewiring + state migration + old-engine deletion move to follow-ups. + `pai-acp` (reference adapter, v0.1.20), `opencode-acp` (target, v1.14.12). +- Read acp-kernel `src/` (25 modules): `compress.ts` (`createCore`), + `types.ts` (`CompressionState`/`Config`/`CoreMessage`), `index.ts` (exports), + `pipeline.ts`, `render-refs.ts`, `nudge-text.ts`, `boundaries.ts`. +- Read pai-acp `runtime.ts`/`config.ts`/`messages.ts`/`state.ts`/`index.ts` + (adapter templates: per-session lock, config resolver, message projection, + atomic state persistence, forward-compat load). +- Read opencode-acp `lib/hooks.ts` (SDK hook contracts + pipeline), + `lib/state/types.ts` (`SessionState`), `lib/token-utils.ts`, `index.ts`. -## Work performed +## Decision (revised per issue feedback) + +**Originally** proposed a 4-phase migration (see DESIGN.md §9). **User @dog +rejected** phasing on issue #42: + +> 我建议一步到位即可 没必要分步骤 反而会引入很多 bug 建议你直接新开一个全新的 然后基于内核重新实现 然后基本功能通过微调对齐 + +→ Switched to a **one-shot fresh rewrite**: build a brand-new kernel-backed +adapter under `lib/kernel/`, rewire `index.ts` to use it exclusively, and leave +the old in-tree engine on disk as dead code (tree-shaken from the published +bundle). Basic functionality is aligned by tuning the kernel config; no +incremental/switchover machinery (which would itself be a source of bugs). + +## Architecture of the new adapter (`lib/kernel/`) + +| Module | Responsibility | +|--------|---------------| +| `messages.ts` | `withPartsToCoreMessages` (OpenCode `WithParts[]` → kernel `CoreMessage[]`; completed tool part → tool-call + tool-result cores sharing `toolCallId`) + `reconstructMessages` (kernel output → `WithParts[]`, burns `` ref tags back onto originals, rebuilds multi-call assistant messages). | +| `config.ts` | `resolveKernelConfig` (PluginConfig → kernel `Config` via `defaultConfig`; force-protects `compress`; maps growth thresholds). | +| `state.ts` | `load/saveKernelState` to `plugin/acp-kernel/{sessionId}.json` (atomic tmp+rename), forward-compat `mergeInitialState`, `detectLegacyState` for old `plugin/acp/`. | +| `runtime.ts` | `createCoreRuntime` → `AcpCoreRuntime` (`createCore(countTokens)`, per-session `stateFor`, `save`, `configFor`, promise-chain `acquireLock`, `invalidate`). | +| `system-prompt.ts` | `renderAcpSystemPrompt` = `COMPRESS_PHILOSOPHY` + `HOW_TO_COMPRESS_RULES` + tag/tools sections. | +| `hooks.ts` | 5 SDK hook handlers: `createSystemPromptHandler`, `createChatMessageTransformHandler` (the core integration — `processTurn` + reconstruct + nudge inject), `createTextCompleteHandler`, `createCommandExecuteHandler`, `createEventHandler`. | +| `tools.ts` | 4 tools: compress (→ `applyCompression`), decompress, search_context, acp_status. | +| `commands.ts` | `handleAcpCommand` → `/acp` + `/dcp` (back-compat) via model-invisible prompt. | +| `index.ts` | Barrel. | -(to be filled as commits land) +`index.ts` (entry) imports **only** from `lib/kernel/` + shared infra +(`lib/config.ts`, `lib/host-permissions.ts`, `lib/logger.ts`, `lib/auth.ts`, +`lib/update.ts`, `lib/token-utils.ts`). The old engine +(`lib/hooks.ts`, `lib/compress/`, `lib/messages/`, `lib/state/` engine, +`lib/gc/`, `lib/prompts/`, `lib/commands/`, `lib/ui/`) is **no longer +imported** and is tree-shaken out of `dist/`. -- `devlog/2026-08-05_acp-kernel/{REQ,DESIGN,WORKLOG}.md` -- `package.json` — add `acp-kernel@0.0.16`; bundle via tsup `noExternal` -- `tsup.config.ts` — `noExternal: […, "acp-kernel"]` -- `NOTICE` — acp-kernel MIT attribution -- `lib/kernel/{config,messages,runtime,state,index}.ts` — adapter (additive) -- `npm run typecheck` / `build` / `test` — green +## Work performed + +- `package.json` — add `acp-kernel@0.0.16` (devDep, pinned exact). +- `tsup.config.ts` — `noExternal: […, "acp-kernel"]` (inline into bundle). +- `NOTICE` — acp-kernel MIT attribution. +- `lib/kernel/{messages,config,state,runtime,system-prompt,hooks,tools,commands,index}.ts` — fresh adapter. +- `lib/token-utils.ts` — `SessionState` import switched to `import type` (a value import pulled `lib/state` barrel → `SessionStateRegistry` class leaked into dist as runtime code). +- `index.ts` — rewired to kernel-backed hooks/tools. +- `tests/kernel-smoke.test.ts` — 7 tests covering the message projection → `processTurn` → `applyCompression` → `reconstruct` round-trip. ## Verification - `npm install` — acp-kernel@0.0.16 installed; ESM `import * from "acp-kernel"` resolves. - `npm run typecheck` — **PASS** (0 errors). -- `npm run build` — **PASS** (tsup ESM bundle 384 KB + `.d.ts`). -- `npm run test` — **PASS** (942 tests, 0 fail). No behavior change — old engine untouched. -- Bundling: `tsup.config.ts` `noExternal` lists `acp-kernel`. Because Phase 1 is purely - additive (`lib/kernel/` is not yet imported by `index.ts`), tsup tree-shakes the - adapter + kernel out of `dist/index.js` for now — expected. Phase 2 imports - `lib/kernel/runtime` from the hook path, at which point `noExternal: ["acp-kernel"]` - inlines the engine into the published bundle (same mechanism as - `context-compress-algorithms`). Confirmed: zero `from "acp-kernel"` external imports - remain when the adapter is reachable. - -## Open items for follow-up PRs - -- Phase 2: rewire `lib/hooks.ts` + tools to `lib/kernel/runtime`. -- Phase 3: legacy `SessionState` → kernel `CompressionState` converter; delete - old engine. -- Phase 4: `dcp-` tag retirement (persisted-state migration plan). +- `npm run build` — **PASS** (single ESM bundle `dist/index.js` 175.53 KB + `.d.ts`). +- `npm run test` — **PASS** (961 tests, 0 fail: 954 existing + 7 new smoke). +- `dist/` symbol audit: + - Kernel inlined: `processTurn` / `applyCompression` / `createCore` / `renderNudgeText` present. + - Old engine tree-shaken: `assignMessageRefs`, `createCompressRangeTool`, `runMajorGC`, `injectCompressNudges`, `createSessionState` = 0 hits. + - acp-kernel not external: 0 `require("acp-kernel")` / `from "acp-kernel"`. + +## Key findings / gotchas (load-bearing for future tuning) + +- **`preserveRecentTokens` over-protection**: acp-kernel's `computeProtectedRefs` + protects the last `preserveRecentMessages` **and** accumulates backward from + the end up to `preserveRecentTokens`. `resolveKernelConfig` defaults the token + window to `compress.preserveRecentTokens ?? 5000` (correct for production where + messages are large). With tiny smoke-test messages, 5000 tokens over-protects + everything, so the smoke test uses `preserveRecentTokens: 0`. +- **Reconstruction tag source**: the `mNNNNN` ref tag must be + extracted from the **burned `CoreMessage.text`**, not from + `state.messageRefs.byRaw` — because the kernel splits one tool-bearing + `WithParts` message into multiple `CoreMessage`s with composite ids + (`{baseId}#{callID}`, `{baseId}#{callID}#result`) that are not present under + the bare `baseId` key. +- **`ToolResult` shape**: OpenCode tool return values use + `string | { title?, output: string, metadata?, attachments? }` — the object + form **requires** an `output` field. +- **Nudge injection**: appended as an extra text `Part` to the last surviving + user message (or a synthetic user message when none survives), matching the + OpenCode SDK pattern of user-role text for system guidance. + +## Open items (follow-up PRs) + +- `git rm` the orphaned old engine (`lib/compress/`, `lib/messages/`, `lib/state/` + engine, `lib/gc/`, `lib/hooks.ts`, `lib/prompts/`, `lib/commands/`, `lib/ui/`) + and the now-redundant tests once the kernel path is validated in production. +- Deploy locally (`scripts/dev-deploy.sh`) and smoke-test the live plugin end to + end before the next release. +- Align nudge cadence / protected-tools / notification UX to the old engine's + behaviour where users depend on it (tuning, not structural). diff --git a/index.ts b/index.ts index 7d2749ea..fc08041a 100644 --- a/index.ts +++ b/index.ts @@ -1,29 +1,26 @@ import type { Plugin } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" -import { - createAcpStatusTool, - createAcpContextRecapTool, - createCompressRangeTool, - createDecompressTool, - createSearchContextTool, -} from "./lib/compress" import { compressDisabledByOpencode, hasExplicitToolPermission, type HostPermissionSnapshot, } from "./lib/host-permissions" import { Logger } from "./lib/logger" -import { SessionStateRegistry } from "./lib/state" -import { PromptStore } from "./lib/prompts/store" +import { configureClientAuth, isSecureMode } from "./lib/auth" +import { startAutoUpdate } from "./lib/update" import { + createCoreRuntime, + createSessionModelLimits, + createSystemPromptHandler, createChatMessageTransformHandler, + createTextCompleteHandler, createCommandExecuteHandler, createEventHandler, - createSystemPromptHandler, - createTextCompleteHandler, -} from "./lib/hooks" -import { configureClientAuth, isSecureMode } from "./lib/auth" -import { startAutoUpdate } from "./lib/update" + createCompressTool, + createDecompressTool, + createSearchContextTool, + createAcpStatusTool, +} from "./lib/kernel" const server: Plugin = (async (ctx) => { const config = getConfig(ctx) @@ -33,62 +30,49 @@ const server: Plugin = (async (ctx) => { } const logger = new Logger(config.debug) - const registry = new SessionStateRegistry(logger) - const prompts = new PromptStore(logger, ctx.directory, config.experimental.customPrompts) - const hostPermissions: HostPermissionSnapshot = { - global: undefined, - agents: {}, - } if (isSecureMode()) { configureClientAuth(ctx.client) - // logger.info("Secure mode detected, configured client authentication") } - logger.info("DCP initialized") + logger.info("ACP (acp-kernel) initialized") startAutoUpdate(ctx, config.autoUpdate) - const compressToolContext = { + const runtime = createCoreRuntime() + const modelLimits = createSessionModelLimits() + + const hostPermissions: HostPermissionSnapshot = { + global: undefined, + agents: {}, + } + + const toolContext = { client: ctx.client, - registry, - logger, + runtime, config, - prompts, + logger, + modelLimits, } return { - "experimental.chat.system.transform": createSystemPromptHandler( - registry, - logger, - config, - prompts, - ), + "experimental.chat.system.transform": createSystemPromptHandler(logger, config, modelLimits), "experimental.chat.messages.transform": createChatMessageTransformHandler( ctx.client, - registry, + runtime, logger, config, - prompts, - hostPermissions, + modelLimits, ) as any, "experimental.text.complete": createTextCompleteHandler(), - "command.execute.before": createCommandExecuteHandler( - ctx.client, - registry, - logger, - config, - ctx.directory, - hostPermissions, - ), - event: createEventHandler(registry, logger), + "command.execute.before": createCommandExecuteHandler(ctx.client, runtime, logger, config, modelLimits), + event: createEventHandler(logger), tool: { ...(config.compress.permission !== "deny" && { - compress: createCompressRangeTool(compressToolContext), - decompress: createDecompressTool(compressToolContext), - search_context: createSearchContextTool(compressToolContext), - acp_status: createAcpStatusTool(compressToolContext), - acp_context_recap: createAcpContextRecapTool(compressToolContext), + compress: createCompressTool(toolContext), + decompress: createDecompressTool(toolContext), + search_context: createSearchContextTool(toolContext), + acp_status: createAcpStatusTool(toolContext), }), }, config: async (opencodeConfig) => { diff --git a/lib/kernel/commands.ts b/lib/kernel/commands.ts new file mode 100644 index 00000000..b09f4d1e --- /dev/null +++ b/lib/kernel/commands.ts @@ -0,0 +1,90 @@ +import type { WithParts } from "../state" +import type { PluginConfig } from "../config" +import type { Logger } from "../logger" +import { countTokens } from "../token-utils" +import type { AcpCoreRuntime } from "./runtime" +import type { SessionModelLimits } from "./hooks" + +export interface AcpCommandContext { + subcommand: string + messages: WithParts[] + runtime: AcpCoreRuntime + config: PluginConfig + modelLimits: SessionModelLimits + sessionId: string + client: any + logger: Logger +} + +async function sendIgnored(ctx: AcpCommandContext, text: string): Promise { + const lastUser = ctx.messages.find((m) => m.info.role === "user") + const info = lastUser?.info as { model?: { providerID?: string; modelID?: string }; agent?: string; variant?: string } | undefined + const model = info?.model?.providerID && info?.model?.modelID ? { providerID: info.model.providerID, modelID: info.model.modelID } : undefined + try { + await ctx.client.session.prompt({ + path: { id: ctx.sessionId }, + body: { + noReply: true, + agent: info?.agent, + model, + variant: info?.variant, + parts: [{ type: "text", text, ignored: true }], + }, + }) + } catch (error) { + ctx.logger.error("ACP command render failed", { error: (error as Error).message }) + } +} + +function formatK(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}K` + return `${n}` +} + +export async function handleAcpCommand(ctx: AcpCommandContext): Promise { + const sub = ctx.subcommand + + if (sub === "help" || sub === "") { + await sendIgnored( + ctx, + [ + "ACP commands:", + " /acp show this help", + " /acp context show context usage + compressible ranges", + " /acp stats show compression statistics", + ].join("\n"), + ) + return true + } + + const { state, coreMessages } = await ctx.runtime.stateFor(ctx.sessionId, ctx.messages) + const modelContextLimit = ctx.modelLimits.get(ctx.sessionId) + const kernelConfig = ctx.runtime.configFor(ctx.config, modelContextLimit) + const tokenCount = coreMessages.reduce((sum, c) => sum + countTokens(c.text ?? ""), 0) + const report = ctx.runtime.core.status(state, tokenCount, kernelConfig) + + if (sub === "stats" || sub === "status") { + const active = state.blocks.filter((b) => b.active) + const lines = [ + `ACP stats — session ${ctx.sessionId}`, + `Context: ${formatK(tokenCount)} / ${formatK(kernelConfig.modelContextLimit)} tokens (${Math.round(report.contextUsage * 100)}%)`, + `Blocks: ${state.blocks.length} total, ${active.length} active`, + `Tokens compressed (cumulative): ${formatK(state.stats.tokensCompressed)} across ${state.stats.compressionCount} compression(s)`, + ] + await sendIgnored(ctx, lines.join("\n")) + return true + } + + if (sub === "context") { + const active = state.blocks.filter((b) => b.active) + const lines = [ + `ACP context — ${formatK(tokenCount)} / ${formatK(kernelConfig.modelContextLimit)} tokens (${Math.round(report.contextUsage * 100)}%)`, + `Active compressed blocks: ${active.length}` + (active.length ? ` (${active.map((b) => b.blockId).join(", ")})` : ""), + ] + await sendIgnored(ctx, lines.join("\n")) + return true + } + + await sendIgnored(ctx, `Unknown /acp subcommand: "${sub}". Try /acp help.`) + return true +} diff --git a/lib/kernel/hooks.ts b/lib/kernel/hooks.ts new file mode 100644 index 00000000..d1283b80 --- /dev/null +++ b/lib/kernel/hooks.ts @@ -0,0 +1,200 @@ +import type { WithParts } from "../state" +import type { PluginConfig } from "../config" +import type { Logger } from "../logger" +import { countTokens } from "../token-utils" +import type { AcpCoreRuntime } from "./runtime" +import { withPartsToCoreMessages, reconstructMessages } from "./messages" +import { renderNudgeText } from "acp-kernel" +import { renderAcpSystemPrompt } from "./system-prompt" +import { handleAcpCommand } from "./commands" + +const INTERNAL_AGENT_NAMES = new Set(["title", "summary", "compaction"]) +const INTERNAL_AGENT_SIGNATURES = [ + "You are a title generator", + "You are a helpful AI assistant tasked with summarizing conversations", + "Summarize what was done in this conversation", +] + +type AnyPart = { type?: string; text?: string; agent?: unknown } +type AnyMessageInfo = { id: string; role: string; sessionID?: string; agent?: unknown } + +function getLastUserMessage(messages: WithParts[]): WithParts | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]!.info.role === "user") return messages[i] + } + return undefined +} + +function isInternalAgentRequest(messages: WithParts[]): boolean { + const lastUser = getLastUserMessage(messages) + if (!lastUser) return false + const agent = (lastUser.info as AnyMessageInfo).agent + return typeof agent === "string" && INTERNAL_AGENT_NAMES.has(agent) +} + +function estimateInputTokens(coreTexts: string[]): number { + let total = 0 + for (const text of coreTexts) total += countTokens(text) + return total +} + +export interface SessionModelLimits { + get(sessionId: string): number | undefined + set(sessionId: string, limit: number): void +} + +export function createSessionModelLimits(): SessionModelLimits { + const map = new Map() + return { + get: (sid) => map.get(sid), + set: (sid, limit) => { + map.set(sid, limit) + }, + } +} + +export function createSystemPromptHandler( + logger: Logger, + config: PluginConfig, + modelLimits: SessionModelLimits, +) { + return async ( + input: { sessionID?: string; model?: { limit?: { context?: number } } }, + output: { system: string[] }, + ) => { + if (input.sessionID && input.model?.limit?.context) { + modelLimits.set(input.sessionID, input.model.limit.context) + } + if (config.compress.permission === "deny") return + + const systemText = output.system.join("\n") + if (INTERNAL_AGENT_SIGNATURES.some((sig) => systemText.includes(sig))) { + logger.info("Skipping ACP system prompt for internal agent") + return + } + + const prompt = renderAcpSystemPrompt() + if (output.system.length > 0) { + output.system[output.system.length - 1] += "\n\n" + prompt + } else { + output.system.push(prompt) + } + } +} + +function injectNudge(messages: WithParts[], nudgeText: string): void { + const lastUser = getLastUserMessage(messages) + if (lastUser) { + ;(lastUser.parts as AnyPart[]).push({ type: "text", text: nudgeText }) + return + } + messages.push({ + info: { id: `acp_nudge_${Date.now()}`, role: "user", sessionID: "" } as any, + parts: [{ type: "text", text: nudgeText }] as any, + }) +} + +export function createChatMessageTransformHandler( + client: any, + runtime: AcpCoreRuntime, + logger: Logger, + config: PluginConfig, + modelLimits: SessionModelLimits, +) { + return async (_input: unknown, output: { messages: WithParts[] }) => { + const messages = output.messages + if (!Array.isArray(messages) || messages.length === 0) return + + if (isInternalAgentRequest(messages)) { + logger.debug("Skipping transform for internal agent request") + return + } + + const lastUser = getLastUserMessage(messages) + const sessionId = (lastUser?.info as AnyMessageInfo)?.sessionID + if (!sessionId) return + + const release = await runtime.acquireLock(sessionId) + try { + const originalById = new Map() + for (const m of messages) originalById.set(m.info.id, m) + + const { state, coreMessages } = await runtime.stateFor(sessionId, messages) + const modelContextLimit = modelLimits.get(sessionId) + const kernelConfig = runtime.configFor(config, modelContextLimit) + const tokenCount = estimateInputTokens(coreMessages.map((c) => c.text ?? "")) + + const result = runtime.core.processTurn({ + messages: coreMessages, + state, + config: kernelConfig, + tokenCount, + }) + + const { messages: reconstructed } = reconstructMessages( + result.messages, + originalById, + ) + + output.messages = reconstructed + + const nudge = result.nudge + if (nudge?.shouldInject) { + const rendered = renderNudgeText(nudge) + injectNudge(output.messages, rendered.text) + logger.debug("ACP nudge injected", { voice: rendered.voice, reason: nudge.reason }) + } + + await runtime.save(result.state, sessionId) + } finally { + release() + } + } +} + +export function createTextCompleteHandler() { + return async (_input: unknown, output: { text: string }) => { + if (typeof output.text === "string") { + output.text = output.text.replace(/]*>m\d{1,5}<\/acp>/g, "") + } + } +} + +export function createCommandExecuteHandler( + client: any, + runtime: AcpCoreRuntime, + logger: Logger, + config: PluginConfig, + modelLimits: SessionModelLimits, +) { + return async ( + input: { command: string; sessionID: string; arguments: string }, + _output: { parts: any[] }, + ) => { + if (!config.commands.enabled) return + if (input.command !== "acp" && input.command !== "dcp") return + if (config.compress.permission === "deny") return + + const messagesResponse = await client.session.messages({ path: { id: input.sessionID } }) + const messages: WithParts[] = (messagesResponse.data || messagesResponse) as WithParts[] + + const handled = await handleAcpCommand({ + subcommand: (input.arguments ?? "").trim().toLowerCase(), + messages, + runtime, + config, + modelLimits, + sessionId: input.sessionID, + client, + logger, + }) + if (handled) throw new Error("__ACP_CONTEXT_HANDLED__") + } +} + +export function createEventHandler(_logger: Logger) { + return async (_input: { event: any }) => { + // Compress-timing attachment is a non-essential nicety; the kernel's + // CompressionBlock.durationMs is optional. Reserved for a follow-up. + } +} diff --git a/lib/kernel/index.ts b/lib/kernel/index.ts index 080bb7a8..0fc1223f 100644 --- a/lib/kernel/index.ts +++ b/lib/kernel/index.ts @@ -1,4 +1,4 @@ -export { withPartsToCoreMessages, coreMessagesToWithParts, type CoreMessage } from "./messages" +export { withPartsToCoreMessages, reconstructMessages, type CoreMessage, type ReconstructionResult } from "./messages" export { resolveKernelConfig } from "./config" export { loadKernelState, @@ -7,3 +7,21 @@ export { mergeInitialState, } from "./state" export { createCoreRuntime, type AcpCoreRuntime } from "./runtime" +export { renderAcpSystemPrompt } from "./system-prompt" +export { + createSessionModelLimits, + createSystemPromptHandler, + createChatMessageTransformHandler, + createTextCompleteHandler, + createCommandExecuteHandler, + createEventHandler, + type SessionModelLimits, +} from "./hooks" +export { + createCompressTool, + createDecompressTool, + createSearchContextTool, + createAcpStatusTool, + type KernelToolContext, +} from "./tools" +export { handleAcpCommand, type AcpCommandContext } from "./commands" diff --git a/lib/kernel/messages.ts b/lib/kernel/messages.ts index 2f07905d..6e7a7c62 100644 --- a/lib/kernel/messages.ts +++ b/lib/kernel/messages.ts @@ -2,21 +2,16 @@ import type { CoreMessage } from "acp-kernel" export type { CoreMessage } from "acp-kernel" import type { WithParts } from "../state" -// Message projection between OpenCode's SDK shape (WithParts = { info, parts[] }) -// and acp-kernel's CoreMessage. Phase 1: pure shape translation, not yet wired -// into the message-transform hook (that is Phase 2 — see devlog DESIGN.md §4). +// Projection between OpenCode's SDK shape (WithParts = { info, parts[] }) and +// acp-kernel's CoreMessage. // -// OpenCode Part kinds (see lib/message-ids.ts, lib/messages/utils.ts): -// text { type:"text", text, ignored? } -// tool { type:"tool", tool, callID, messageID?, state:{ status, input?, output?, error?, time? } } -// reasoning { type:"reasoning", text } +// OpenCode Part kinds: text { type,text,ignored? }, tool { type,tool,callID, +// state:{status,input?,output?,error?} }, reasoning { type,text }. // -// acp-kernel CoreMessage: { id, role, contentType:"text"|"tool-call"|"tool-result"|"reasoning", text?, toolName?, toolCallId? } -// A single OpenCode tool part spans the call AND its result (state.status -// pending→completed), so a completed tool part projects to TWO CoreMessages -// (tool-call + tool-result) sharing the same toolCallId — required so the -// kernel's protected-tool-pairing (Bug 39) and tool-pair boundary adjustment -// can match call↔result by toolCallId. +// A completed OpenCode tool part spans BOTH a tool-call and its tool-result, so +// it projects to TWO CoreMessages sharing the same toolCallId — required so the +// kernel's protected-tool pairing (Bug 39) and tool-pair boundary adjustment can +// match call↔result. Tool result ids use the "#result" suffix. type AnyPart = { type?: string @@ -28,7 +23,6 @@ type AnyPart = { input?: unknown output?: unknown error?: string | { message?: string } - time?: { start?: string; end?: string } } } @@ -57,8 +51,7 @@ function toolResultText(part: AnyPart): string { if (!state) return "" if (state.status === "error") { const err = state.error - const msg = typeof err === "string" ? err : err?.message ?? "" - return msg || "tool error" + return typeof err === "string" ? err : err?.message ?? "tool error" } if (state.output !== undefined && state.output !== null) { return stringifyContent(state.output) @@ -75,9 +68,7 @@ export function withPartsToCoreMessages(messages: WithParts[]): CoreMessage[] { if (role === "user") { const text = extractText(parts) - if (text.length > 0) { - out.push({ id, role: "user", contentType: "text", text }) - } + if (text.length > 0) out.push({ id, role: "user", contentType: "text", text }) continue } @@ -90,12 +81,8 @@ export function withPartsToCoreMessages(messages: WithParts[]): CoreMessage[] { const textBody = extractText(parts) if (toolParts.length === 0) { - if (textBody.length > 0) { - out.push({ id, role: "assistant", contentType: "text", text: textBody }) - } - if (reasoningText.length > 0) { - out.push({ id, role: "assistant", contentType: "reasoning", text: reasoningText }) - } + if (textBody.length > 0) out.push({ id, role: "assistant", contentType: "text", text: textBody }) + if (reasoningText.length > 0) out.push({ id, role: "assistant", contentType: "reasoning", text: reasoningText }) continue } @@ -121,14 +108,10 @@ export function withPartsToCoreMessages(messages: WithParts[]): CoreMessage[] { }) } } - if (reasoningText.length > 0) { - out.push({ id, role: "assistant", contentType: "reasoning", text: reasoningText }) - } + if (reasoningText.length > 0) out.push({ id, role: "assistant", contentType: "reasoning", text: reasoningText }) continue } - // system / other roles: carry text through as-is so the kernel sees the - // full window (it will classify system tokens in the context breakdown). const text = extractText(parts) if (text.length > 0) { out.push({ id, role: role === "system" ? "system" : "user", contentType: "text", text }) @@ -137,58 +120,90 @@ export function withPartsToCoreMessages(messages: WithParts[]): CoreMessage[] { return out } -// Inverse: given the kernel's output CoreMessage[] and the original OpenCode -// messages (keyed by id), reconstruct the surviving OpenCode message list in -// order. Used by Phase 2 to convert processTurn output back to SDK messages. -// -// Rules (pai-acp coreOutToAgentMessages pattern): -// - CoreMessages whose id starts with "acp_summary_" are synthetic recap -// slots — skipped. With compress-as-anchor, summaries live inside the -// model's own compress calls, so no synthetic message is emitted. -// - A plain id (no '#') maps 1:1 to its original message. -// - A split id ("baseId#callID[#result]") means the original assistant -// message had multiple tool calls; reconstruct it keeping only the -// surviving callIDs. -export function coreMessagesToWithParts(coreOut: CoreMessage[], originalById: Map): WithParts[] { +const ACP_TAG = /]*>m\d{1,5}<\/acp>\n?/g +const ACP_TAG_LEADING = /^]*>m\d{1,5}<\/acp>\n?/ + +function extractTag(core: CoreMessage): string | null { + const match = (core.text ?? "").match(ACP_TAG_LEADING) + return match ? match[0].replace(/\n?$/, "") : null +} + +// Reconstruct the OpenCode message list from the kernel's surviving CoreMessage +// output. Survival order comes from coreOut. Ref tags are extracted from the +// kernel's render-refs output (burned into core.text) — not from messageRefs — +// because split assistant messages (baseId#callID) carry per-split refs that +// only exist on the core, not on the base raw id. Multi-call assistant +// messages are rebuilt keeping only surviving callIDs. Assistant text/reasoning +// messages are NOT tagged (the model echoes tags on its own output — pai-acp). +export interface ReconstructionResult { + messages: WithParts[] + survivingIds: string[] +} + +export function reconstructMessages( + coreOut: CoreMessage[], + originalById: Map, +): ReconstructionResult { const out: WithParts[] = [] + const survivingIds: string[] = [] const emittedBase = new Set() for (const core of coreOut) { if (core.id.startsWith("acp_summary_")) continue const hashIdx = core.id.indexOf("#") - if (hashIdx < 0) { - const original = originalById.get(core.id) - if (original) out.push(original) - continue - } - - const baseId = core.id.substring(0, hashIdx) + const baseId = hashIdx < 0 ? core.id : core.id.substring(0, hashIdx) if (emittedBase.has(baseId)) continue emittedBase.add(baseId) const original = originalById.get(baseId) if (!original) continue + const tag = extractTag(core) + if (hashIdx < 0) { + out.push(applyRefTag(original, tag)) + survivingIds.push(baseId) + continue + } + const survivingCallIds = new Set( coreOut .filter((c) => c.id.startsWith(`${baseId}#`) && !c.id.startsWith("acp_summary_")) .map((c) => c.toolCallId) .filter((cid): cid is string => typeof cid === "string"), ) - - out.push(reconstructMultiCallMessage(original, survivingCallIds)) + const filteredParts = ((original.parts as AnyPart[]).filter((part) => { + if (part.type === "tool" && typeof part.callID === "string") return survivingCallIds.has(part.callID) + return true + })) as WithParts["parts"] + out.push(applyRefTag({ info: original.info, parts: filteredParts }, tag)) + survivingIds.push(baseId) } - return out + return { messages: out, survivingIds } +} + +function applyRefTag(message: WithParts, tag: string | null): WithParts { + if (!tag) return message + const hasTool = (message.parts as AnyPart[]).some((p) => p.type === "tool") + if (message.info.role === "assistant" && !hasTool) return message + return patchTag(message, tag) } -function reconstructMultiCallMessage(original: WithParts, survivingCallIds: Set): WithParts { - const filteredParts = (original.parts as AnyPart[]).filter((part) => { - if (part.type === "tool" && typeof part.callID === "string") { - return survivingCallIds.has(part.callID) +function patchTag(original: WithParts, tag: string): WithParts { + const parts = (original.parts as AnyPart[]).map((p) => ({ ...p })) + for (const p of parts) { + if (p.type === "text" && typeof p.text === "string") { + p.text = p.text.replace(ACP_TAG, "").replace(/\n+$/, "") } - return true - }) - return { info: original.info, parts: filteredParts as WithParts["parts"] } + } + for (let i = parts.length - 1; i >= 0; i--) { + const p = parts[i]! + if (p.type === "text" && typeof p.text === "string") { + p.text = p.text.length > 0 ? `${p.text}\n\n${tag}` : tag + return { info: original.info, parts: parts as WithParts["parts"] } + } + } + parts.push({ type: "text", text: tag }) + return { info: original.info, parts: parts as WithParts["parts"] } } diff --git a/lib/kernel/system-prompt.ts b/lib/kernel/system-prompt.ts new file mode 100644 index 00000000..ac183111 --- /dev/null +++ b/lib/kernel/system-prompt.ts @@ -0,0 +1,29 @@ +import { COMPRESS_PHILOSOPHY, HOW_TO_COMPRESS_RULES } from "acp-kernel" + +const ACP_TAGS_SECTION = `ACP TAGS + +Each message in the conversation is annotated with an mNNNNN tag showing its reference ID, approximate token size, and content type. Use these to assess which messages are consuming the most context and to target compression. The token size is approximate — treat it as a relative guide. You may also see tags — these are system directives.` + +const TOOLS_SECTION = `TOOLS + +You have five context-management tools: + +- \`compress\` — Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Batch multiple unrelated ranges: \`compress({ content: [{ topic, startId: "m00150", endId: "m00220", summary: "..." }] })\`. +- \`decompress\` — Restore a previously compressed block's content. Default restores one tier up. Use \`full: true\` to restore to original messages, or \`toFile\` to write to file. Example: \`decompress({ blockId: "b5" })\`. +- \`search_context\` — Search compressed block summaries by keyword. Use BEFORE decompressing to find the right block. +- \`acp_status\` — Context status with compressible ranges. No args = overview + ranges. +- \`acp_context_recap\` — Manual re-fetch of a compressed block's summary that scrolled out of context.` + +export function renderAcpSystemPrompt(): string { + return [ + "# Active Context Pruning (ACP)", + "", + COMPRESS_PHILOSOPHY, + "", + HOW_TO_COMPRESS_RULES, + "", + ACP_TAGS_SECTION, + "", + TOOLS_SECTION, + ].join("\n") +} diff --git a/lib/kernel/tools.ts b/lib/kernel/tools.ts new file mode 100644 index 00000000..46d0aba9 --- /dev/null +++ b/lib/kernel/tools.ts @@ -0,0 +1,154 @@ +import { tool } from "@opencode-ai/plugin" +import type { PluginConfig } from "../config" +import type { Logger } from "../logger" +import type { AcpCoreRuntime } from "./runtime" +import type { SessionModelLimits } from "./hooks" +import { countTokens } from "../token-utils" + +export interface KernelToolContext { + client: any + runtime: AcpCoreRuntime + config: PluginConfig + logger: Logger + modelLimits: SessionModelLimits +} + +async function loadSessionMessages(client: any, sessionId: string) { + const response = await client.session.messages({ path: { id: sessionId } }) + return (response.data || response) as import("../state").WithParts[] +} + +export function createCompressTool(ctx: KernelToolContext): ReturnType { + return tool({ + description: + "Compress one or more ranges of older conversation into detailed summaries you write. Each range replaces its original messages with a short block reference. Use when content is genuinely consumed (no longer needed for the current step).", + args: { + topic: tool.schema.string().optional().describe("Fallback topic for entries without their own."), + content: tool.schema + .array( + tool.schema.object({ + topic: tool.schema.string().optional().describe("Short label (3-5 words) for THIS range."), + startId: tool.schema.string().describe("Message or block ID at range start (e.g. m00001, b2)."), + endId: tool.schema.string().describe("Message or block ID at range end (e.g. m00012, b5)."), + summary: tool.schema.string().describe("Complete technical summary replacing the range."), + }), + ) + .describe("One or more ranges to compress."), + summaryMaxChars: tool.schema.number().optional().describe("Override max summary length for all entries."), + }, + async execute(args, toolCtx) { + const sessionId = toolCtx.sessionID + const callID = (toolCtx as { callID?: string }).callID + const messages = await loadSessionMessages(ctx.client, sessionId) + const release = await ctx.runtime.acquireLock(sessionId) + try { + const { state, coreMessages } = await ctx.runtime.stateFor(sessionId, messages) + const kernelConfig = ctx.runtime.configFor(ctx.config, ctx.modelLimits.get(sessionId)) + const ranges = (args.content as Array<{ topic?: string; startId: string; endId: string; summary: string; summaryMaxChars?: number }>).map((entry) => ({ + startRef: entry.startId, + endRef: entry.endId, + summary: entry.summary, + topic: entry.topic ?? (args.topic as string | undefined), + compressCallId: callID, + summaryMaxChars: entry.summaryMaxChars ?? (args.summaryMaxChars as number | undefined), + })) + const result = ctx.runtime.core.applyCompression({ ranges, messages: coreMessages, state, config: kernelConfig }) + await ctx.runtime.save(result.state, sessionId) + if (result.result.errors.length > 0) { + throw new Error(result.result.errors.join("; ")) + } + ctx.logger.info("Compress applied", { blocksCreated: result.result.blocksCreated, tokensCompressed: result.result.tokensCompressed }) + return { + output: `Compressed ${result.result.blocksCreated} range(s); ~${result.result.tokensCompressed} tokens captured in summary blocks.`, + metadata: { + blocksCreated: result.result.blocksCreated, + tokensCompressed: result.result.tokensCompressed, + warnings: result.result.warnings, + }, + } + } finally { + release() + } + }, + }) +} + +export function createDecompressTool(ctx: KernelToolContext): ReturnType { + return tool({ + description: + "Restore a previously compressed block's summary so you can re-read what was compressed. Pass blockId (e.g. \"b5\"). Returns the block's topic, summary, and the message range it covered.", + args: { + blockId: tool.schema.string().optional().describe("Block ID to restore (e.g. b5). Omit to list all blocks."), + }, + async execute(args, toolCtx) { + const sessionId = toolCtx.sessionID + const blockId = args.blockId as string | undefined + const { state } = await ctx.runtime.stateFor(sessionId, []) + if (!blockId) { + const blocks = state.blocks + const output = blocks.length === 0 ? "No compressed blocks." : `Compressed blocks: ${blocks.map((b) => `${b.blockId} (${b.topic ?? "untitled"})`).join(", ")}` + return { + output, + metadata: { blocks: blocks.map((b) => ({ blockId: b.blockId, topic: b.topic, tier: b.tier })) }, + } + } + const block = state.blocks.find((b) => b.blockId === blockId) + if (!block) { + throw new Error(`Block ${blockId} not found. Call decompress without a blockId to list available blocks.`) + } + return { + output: block.summary ?? `(block ${blockId} has no summary text)`, + metadata: { + blockId: block.blockId, + topic: block.topic, + tier: block.tier, + coveredMessageCount: block.effectiveMessageIds.length, + }, + } + }, + }) +} + +export function createSearchContextTool(ctx: KernelToolContext): ReturnType { + return tool({ + description: "Search compressed block summaries by keyword. Use before decompress to find the right block.", + args: { + query: tool.schema.string().describe("Keywords to search for in compressed summaries."), + }, + async execute(args, toolCtx) { + const sessionId = toolCtx.sessionID + const { state } = await ctx.runtime.stateFor(sessionId, []) + const matches = ctx.runtime.core.search(args.query as string, state) + return { + output: matches.length === 0 ? "No matching blocks." : `${matches.length} matching block(s): ${matches.map((b) => b.blockId).join(", ")}`, + metadata: { + results: matches.map((b) => ({ blockId: b.blockId, topic: b.topic, tier: b.tier, preview: (b.summary ?? "").slice(0, 200) })), + }, + } + }, + }) +} + +export function createAcpStatusTool(ctx: KernelToolContext): ReturnType { + return tool({ + description: "Show context usage and compressible ranges. No args = overview.", + args: { + scope: tool.schema.string().optional().describe("Optional: 'uncompressed' for compressible ranges, 'compressed' for block list."), + }, + async execute(args, toolCtx) { + const sessionId = toolCtx.sessionID + const messages = await loadSessionMessages(ctx.client, sessionId) + const { state, coreMessages } = await ctx.runtime.stateFor(sessionId, messages) + const modelContextLimit = ctx.modelLimits.get(sessionId) + const kernelConfig = ctx.runtime.configFor(ctx.config, modelContextLimit) + const tokenCount = coreMessages.reduce((sum, c) => sum + countTokens(c.text ?? ""), 0) + const report = ctx.runtime.core.status(state, tokenCount, kernelConfig) + const scope = args.scope as string | undefined + const blocks = scope === "compressed" ? state.blocks.map((b) => ({ blockId: b.blockId, tier: b.tier, active: b.active, topic: b.topic, tokens: b.compressedTokens })) : undefined + return { + output: `Context: ${Math.round(report.contextUsage * 100)}% used (${tokenCount} / ${kernelConfig.modelContextLimit} tokens). Active blocks: ${state.blocks.filter((b) => b.active).length}.`, + metadata: { ...report, ...(blocks ? { blocks } : {}) }, + } + }, + }) +} diff --git a/lib/token-utils.ts b/lib/token-utils.ts index 86443288..868d372b 100644 --- a/lib/token-utils.ts +++ b/lib/token-utils.ts @@ -1,4 +1,4 @@ -import { SessionState, WithParts } from "./state" +import type { SessionState, WithParts } from "./state" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" import { Logger } from "./logger" import * as _anthropicTokenizer from "@anthropic-ai/tokenizer" diff --git a/tests/kernel-smoke.test.ts b/tests/kernel-smoke.test.ts new file mode 100644 index 00000000..b573c363 --- /dev/null +++ b/tests/kernel-smoke.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { PluginConfig } from "../lib/config" +import type { WithParts } from "../lib/state" +import { withPartsToCoreMessages, reconstructMessages, resolveKernelConfig } from "../lib/kernel" +import { createCore, createInitialState } from "acp-kernel" +import { countTokens } from "../lib/token-utils" + +function buildConfig(overrides: { preserveRecentMessages?: number; preserveRecentTokens?: number; maxContextLimit?: number | `${number}%`; minContextLimit?: number | `${number}%` } = {}): PluginConfig { + return { + enabled: true, + debug: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { enabled: true, protectedTools: [] }, + experimental: { allowSubAgents: false, customPrompts: false }, + protectedFilePatterns: [], + compress: { + mode: "range", + permission: "allow", + showCompression: false, + summaryBuffer: true, + maxContextLimit: overrides.maxContextLimit ?? "55%", + minContextLimit: overrides.minContextLimit ?? "45%", + nudgeFrequency: 5, + iterationNudgeThreshold: 15, + nudgeForce: "soft", + protectedTools: ["task"], + protectTags: false, + protectUserMessages: false, + preserveRecentMessages: overrides.preserveRecentMessages ?? 2, + preserveRecentTokens: overrides.preserveRecentTokens ?? 0, + }, + gc: { + algorithm: "truncate", + promotionThreshold: 5, + maxBlockAge: 15, + maxOldGenSummaryLength: 3000, + majorGcThresholdPercent: "100%", + }, + } as PluginConfig +} + +function userMsg(id: string, text: string): WithParts { + return { + info: { id, role: "user", sessionID: "ses_smoke", time: { created: 1 } } as WithParts["info"], + parts: [{ type: "text", text, id: `${id}-p`, messageID: id, sessionID: "ses_smoke" }] as WithParts["parts"], + } +} + +function assistantToolMsg(id: string, callID: string, output: string): WithParts { + return { + info: { id, role: "assistant", sessionID: "ses_smoke", time: { created: 2 } } as WithParts["info"], + parts: [ + { type: "tool", tool: "bash", callID, state: { status: "completed", input: "ls", output, time: { start: 1, end: 2 } } }, + ] as WithParts["parts"], + } +} + +const REF_TAG = /]*>(m\d{1,5})<\/acp>/ +function extractRefs(cores: { text?: string }[]): string[] { + const refs: string[] = [] + for (const c of cores) { + const m = typeof c.text === "string" ? c.text.match(REF_TAG) : null + if (m) refs.push(m[1]!) + } + return refs +} + +test("withPartsToCoreMessages: user text becomes a single text core", () => { + const cores = withPartsToCoreMessages([userMsg("u1", "hello world")]) + assert.equal(cores.length, 1) + assert.equal(cores[0]!.role, "user") + assert.equal(cores[0]!.contentType, "text") + assert.match(cores[0]!.text ?? "", /hello world/) +}) + +test("withPartsToCoreMessages: completed tool expands to tool-call + tool-result cores sharing toolCallId", () => { + const cores = withPartsToCoreMessages([assistantToolMsg("a1", "call_1", "file.txt")]) + assert.equal(cores.length, 2) + assert.equal(cores[0]!.contentType, "tool-call") + assert.equal(cores[1]!.contentType, "tool-result") + assert.equal(cores[0]!.toolCallId, "call_1") + assert.equal(cores[1]!.toolCallId, "call_1") +}) + +test("resolveKernelConfig: maps modelContextLimit and force-protects compress", () => { + const cfg = resolveKernelConfig(buildConfig(), 200000) + assert.equal(cfg.modelContextLimit, 200000) + assert.ok(cfg.protectedTools.includes("compress"), "compress must always be protected") +}) + +test("resolveKernelConfig: falls back when model limit is missing", () => { + const cfg = resolveKernelConfig(buildConfig(), undefined) + assert.ok(cfg.modelContextLimit > 0) +}) + +test("processTurn: assigns refs and burns them into core text", () => { + const core = createCore({ countTokens }) + const messages = [userMsg("u1", "first message"), userMsg("u2", "second message")] + const coreMessages = withPartsToCoreMessages(messages) + const state = createInitialState() + const config = resolveKernelConfig(buildConfig({ preserveRecentMessages: 0 }), 200000) + const result = core.processTurn({ messages: coreMessages, state, config, tokenCount: 100 }) + const refs = extractRefs(result.messages) + assert.equal(refs.length, 2, "every surviving message should carry a burned ref") + assert.notEqual(refs[0], refs[1]) +}) + +test("applyCompression: end-to-end creates a block covering the requested range", () => { + const core = createCore({ countTokens }) + const messages: WithParts[] = [] + for (let i = 1; i <= 8; i++) messages.push(userMsg(`u${i}`, `message number ${i} with some words`)) + const coreMessages = withPartsToCoreMessages(messages) + const state = createInitialState() + const config = resolveKernelConfig(buildConfig({ preserveRecentMessages: 2 }), 200000) + + const turn = core.processTurn({ messages: coreMessages, state, config, tokenCount: 200 }) + const refs = extractRefs(turn.messages) + assert.ok(refs.length >= 5, "expected at least 5 ref-tagged messages") + + const startRef = refs[0]! + const endRef = refs[2]! + const summary = "Compressed the first three user messages which introduced the smoke-test scenario and initial greeting text." + const compressed = core.applyCompression({ + ranges: [{ startRef, endRef, summary, topic: "intro" }], + messages: turn.messages, + state: turn.state, + config, + }) + + assert.equal(compressed.result.errors.length, 0, `unexpected errors: ${compressed.result.errors.join("; ")}`) + assert.equal(compressed.result.blocksCreated, 1) + assert.equal(compressed.state.blocks.length, 1) + const block = compressed.state.blocks[0]! + assert.ok(block.effectiveMessageIds.length >= 3, "block should cover the compressed range") +}) + +test("reconstructMessages: round-trips surviving messages back to WithParts with burned tags", () => { + const core = createCore({ countTokens }) + const messages = [userMsg("u1", "alpha"), userMsg("u2", "beta")] + const originalById = new Map(messages.map((m) => [m.info.id, m])) + const coreMessages = withPartsToCoreMessages(messages) + const state = createInitialState() + const config = resolveKernelConfig(buildConfig({ preserveRecentMessages: 0 }), 200000) + const result = core.processTurn({ messages: coreMessages, state, config, tokenCount: 50 }) + const { messages: reconstructed, survivingIds } = reconstructMessages(result.messages, originalById) + assert.equal(reconstructed.length, 2) + assert.equal(survivingIds.length, 2) + const firstText = (reconstructed[0]!.parts as Array<{ type: string; text?: string }>).find((p) => p.type === "text")?.text ?? "" + assert.match(firstText, /]*>m\d{1,5}<\/acp>/, "reconstructed message should carry the burned ref tag") +}) From 364c7dd76b4b83b14000c14ff6630ab28a511024 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 5 Aug 2026 09:28:11 +0800 Subject: [PATCH 3/4] test: add isolated opencode-test profile for kernel build Adds scripts/test-profile.sh (seed/refresh an isolated opencode profile that swaps the ACP plugin to this kernel build) and scripts/opencode-test.sh (the XDG-redirect launcher installed to ~/.local/bin/opencode-test). Isolates config/data/cache/state/DB under ~/.opencode-test/ so the stable opencode-acp@latest install is never touched. Verified end-to-end: kernel plugin loads, a headless run replies, and kernel CompressionState is written to storage/plugin/acp-kernel/{sessionId}.json. --- scripts/opencode-test.sh | 19 ++++++++ scripts/test-profile.sh | 99 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 scripts/opencode-test.sh create mode 100755 scripts/test-profile.sh diff --git a/scripts/opencode-test.sh b/scripts/opencode-test.sh new file mode 100644 index 00000000..fb58fd61 --- /dev/null +++ b/scripts/opencode-test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# +# opencode-test launcher (repo copy). test-profile.sh --seed copies this to +# ~/.local/bin/opencode-test. Kept in the repo so the test setup is reproducible. + +set -euo pipefail + +ROOT="${OPENCODE_TEST_ROOT:-$HOME/.opencode-test}" +export XDG_CONFIG_HOME="$ROOT/config" +export XDG_DATA_HOME="$ROOT/data" +export XDG_CACHE_HOME="$ROOT/cache" +export XDG_STATE_HOME="$ROOT/state" +export OPENCODE_CONFIG_DIR="$XDG_CONFIG_HOME/opencode" +export OPENCODE_DB="$ROOT/data/opencode/opencode-test.db" + +mkdir -p "$XDG_CONFIG_HOME/opencode" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" +export OPENCODE_CALLER="${OPENCODE_CALLER:-opencode-test}" + +exec "/home/dog/.local/bin/opencode" "$@" diff --git a/scripts/test-profile.sh b/scripts/test-profile.sh new file mode 100755 index 00000000..307faec9 --- /dev/null +++ b/scripts/test-profile.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# +# test-profile.sh — seed / refresh the isolated opencode-test profile used to +# test the kernel-based opencode-acp. +# +# What it does: +# 1. Copies the STABLE opencode.json (keeps providers/auth, model, agent, +# permission, compaction, etc. — so the test instance can actually talk to +# the LLM via the same zhipuai-lb proxy). +# 2. Swaps the `plugin` spec: `opencode-acp@latest` → the kernel build's +# local path (the dist/ in this worktree). Non-ACP plugins (e.g. awork) are +# preserved. +# +# The kernel dist is self-contained (acp-kernel is inlined by tsup), so no +# npm install is needed — opencode loads dist/index.js in place, exactly like +# the awork plugin (referenced by path in the stable config). +# +# Usage: +# ./scripts/test-profile.sh --seed # create/refresh the test config +# ./scripts/test-profile.sh --seed -v # verbose (show resolved config) +# ./scripts/test-profile.sh --status # show test profile state + storage +# +# Then run: opencode-test (TUI) or opencode-test run "hi" (headless) + +set -euo pipefail + +ROOT="${OPENCODE_TEST_ROOT:-$HOME/.opencode-test}" +STABLE_CONFIG="$HOME/.config/opencode/opencode.json" +KERNEL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_CONFIG_DIR="$ROOT/config/opencode" +TEST_CONFIG="$TEST_CONFIG_DIR/opencode.json" + +case "${1:--seed}" in + --status) + echo "opencode-test profile root: $ROOT" + echo " config : $TEST_CONFIG $([ -f "$TEST_CONFIG" ] && echo '[present]' || echo '[MISSING — run --seed]')" + echo " data : $ROOT/data/opencode" + echo " cache : $ROOT/cache/opencode" + echo " db : $ROOT/data/opencode/opencode-test.db" + if [ -d "$ROOT/data/opencode/storage/plugin" ]; then + echo " plugin state dirs:" + find "$ROOT/data/opencode/storage/plugin" -maxdepth 1 -mindepth 1 -printf ' %f\n' 2>/dev/null || true + fi + exit 0 + ;; + + --seed) + [ -f "$STABLE_CONFIG" ] || { echo "stable config not found: $STABLE_CONFIG" >&2; exit 1; } + [ -f "$KERNEL_DIR/dist/index.js" ] || { + echo "kernel dist not built — run 'npm run build' in $KERNEL_DIR first" >&2; exit 1 + } + mkdir -p "$TEST_CONFIG_DIR" + + node - "$STABLE_CONFIG" "$TEST_CONFIG" "$KERNEL_DIR" <<'NODE' +const fs = require("fs"); +const [, , stable, out, kernelDir] = process.argv; +const c = JSON.parse(fs.readFileSync(stable, "utf8")); +// Swap the ACP plugin spec to the kernel build (local path), keep other plugins. +if (Array.isArray(c.plugin)) { + c.plugin = c.plugin.map(p => + typeof p === "string" && p.startsWith("opencode-acp") ? kernelDir : p + ); +} else if (typeof c.plugin === "string" && c.plugin.startsWith("opencode-acp")) { + c.plugin = kernelDir; +} else { + c.plugin = [kernelDir]; +} +fs.writeFileSync(out, JSON.stringify(c, null, 2) + "\n"); +console.log("seeded config : " + out); +console.log("plugin spec : " + JSON.stringify(c.plugin)); +NODE + + # Rebuild the kernel dist into the worktree so the path plugin is current. + echo + echo ">> building kernel dist (tsup)…" + (cd "$KERNEL_DIR" && npm run build >/dev/null 2>&1) && echo " dist/index.js OK" || echo " WARN: build failed — using existing dist" + + LOCAL_BIN="${HOME}/.local/bin" + mkdir -p "$LOCAL_BIN" + if [ ! -f "$LOCAL_BIN/opencode-test" ] || ! cmp -s "$KERNEL_DIR/scripts/opencode-test.sh" "$LOCAL_BIN/opencode-test"; then + cp "$KERNEL_DIR/scripts/opencode-test.sh" "$LOCAL_BIN/opencode-test" + chmod +x "$LOCAL_BIN/opencode-test" + echo " installed launcher → $LOCAL_BIN/opencode-test" + fi + + echo + echo "opencode-test profile ready." + echo " Run: opencode-test # TUI" + echo " opencode-test run \"reply with OK\" # headless smoke test" + echo " After a run, kernel ACP state appears at:" + echo " $ROOT/data/opencode/storage/plugin/acp-kernel/" + exit 0 + ;; + + *) + echo "usage: $0 [--seed|--status]" >&2 + exit 2 + ;; +esac From 1c4f96935f373667355258c4253e696620470ddd Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 5 Aug 2026 09:42:07 +0800 Subject: [PATCH 4/4] feat(acp): report version + engine identity in /acp Add /acp version (and a banner line on every /acp output) so users can confirm the kernel-based build is active: opencode-acp v1.14.12 (engine: acp-kernel v0.0.16) Injects ACP_VERSION, ACP_ENGINE="acp-kernel", and KERNEL_VERSION at build time via tsup define. KERNEL_VERSION is resolved by walking up from import.meta.resolve("acp-kernel") to its package.json (its exports map does not expose ./package.json, so require.resolve fails). --- lib/kernel/commands.ts | 25 ++++++++++++++++++++++--- lib/kernel/index.ts | 1 + lib/kernel/version.ts | 13 +++++++++++++ tests/kernel-version.test.ts | 18 ++++++++++++++++++ tsup.config.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 lib/kernel/version.ts create mode 100644 tests/kernel-version.test.ts diff --git a/lib/kernel/commands.ts b/lib/kernel/commands.ts index b09f4d1e..a3239c95 100644 --- a/lib/kernel/commands.ts +++ b/lib/kernel/commands.ts @@ -4,6 +4,7 @@ import type { Logger } from "../logger" import { countTokens } from "../token-utils" import type { AcpCoreRuntime } from "./runtime" import type { SessionModelLimits } from "./hooks" +import { versionBanner, VERSION } from "./version" export interface AcpCommandContext { subcommand: string @@ -44,14 +45,30 @@ function formatK(n: number): string { export async function handleAcpCommand(ctx: AcpCommandContext): Promise { const sub = ctx.subcommand + if (sub === "version") { + await sendIgnored( + ctx, + [ + versionBanner(), + ` opencode-acp : v${VERSION.package}`, + ` engine : ${VERSION.engine}`, + ` acp-kernel : v${VERSION.kernel}`, + ].join("\n"), + ) + return true + } + if (sub === "help" || sub === "") { await sendIgnored( ctx, [ + versionBanner(), + "", "ACP commands:", - " /acp show this help", - " /acp context show context usage + compressible ranges", - " /acp stats show compression statistics", + " /acp show this help", + " /acp version show version + engine (confirm kernel build is active)", + " /acp context show context usage + compressible ranges", + " /acp stats show compression statistics", ].join("\n"), ) return true @@ -66,6 +83,7 @@ export async function handleAcpCommand(ctx: AcpCommandContext): Promise if (sub === "stats" || sub === "status") { const active = state.blocks.filter((b) => b.active) const lines = [ + versionBanner(), `ACP stats — session ${ctx.sessionId}`, `Context: ${formatK(tokenCount)} / ${formatK(kernelConfig.modelContextLimit)} tokens (${Math.round(report.contextUsage * 100)}%)`, `Blocks: ${state.blocks.length} total, ${active.length} active`, @@ -78,6 +96,7 @@ export async function handleAcpCommand(ctx: AcpCommandContext): Promise if (sub === "context") { const active = state.blocks.filter((b) => b.active) const lines = [ + versionBanner(), `ACP context — ${formatK(tokenCount)} / ${formatK(kernelConfig.modelContextLimit)} tokens (${Math.round(report.contextUsage * 100)}%)`, `Active compressed blocks: ${active.length}` + (active.length ? ` (${active.map((b) => b.blockId).join(", ")})` : ""), ] diff --git a/lib/kernel/index.ts b/lib/kernel/index.ts index 0fc1223f..38a44d67 100644 --- a/lib/kernel/index.ts +++ b/lib/kernel/index.ts @@ -25,3 +25,4 @@ export { type KernelToolContext, } from "./tools" export { handleAcpCommand, type AcpCommandContext } from "./commands" +export { versionBanner, VERSION } from "./version" diff --git a/lib/kernel/version.ts b/lib/kernel/version.ts new file mode 100644 index 00000000..54f20d61 --- /dev/null +++ b/lib/kernel/version.ts @@ -0,0 +1,13 @@ +declare const ACP_VERSION: string | undefined +declare const ACP_ENGINE: string | undefined +declare const KERNEL_VERSION: string | undefined + +export const VERSION = { + package: typeof ACP_VERSION !== "undefined" ? ACP_VERSION : "dev", + engine: typeof ACP_ENGINE !== "undefined" ? ACP_ENGINE : "unknown", + kernel: typeof KERNEL_VERSION !== "undefined" ? KERNEL_VERSION : "dev", +} + +export function versionBanner(): string { + return `opencode-acp v${VERSION.package} (engine: ${VERSION.engine} v${VERSION.kernel})` +} diff --git a/tests/kernel-version.test.ts b/tests/kernel-version.test.ts new file mode 100644 index 00000000..813fe47c --- /dev/null +++ b/tests/kernel-version.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { versionBanner, VERSION } from "../lib/kernel" + +test("versionBanner: canonical 'opencode-acp v (engine: v)' format", () => { + const banner = versionBanner() + assert.match( + banner, + /^opencode-acp v\S+ \(engine: \S+ v\S+\)$/, + "banner must be 'opencode-acp v (engine: v)'", + ) +}) + +test("VERSION: exposes non-empty package, engine, kernel strings", () => { + assert.ok(typeof VERSION.package === "string" && VERSION.package.length > 0) + assert.ok(typeof VERSION.engine === "string" && VERSION.engine.length > 0) + assert.ok(typeof VERSION.kernel === "string" && VERSION.kernel.length > 0) +}) diff --git a/tsup.config.ts b/tsup.config.ts index 0abc45f4..422c834a 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,5 +1,30 @@ import { defineConfig } from "tsup" import pkg from "./package.json" with { type: "json" } +import { fileURLToPath } from "url" +import { readFileSync } from "fs" +import path from "path" + +let kernelVersion = "unknown" +try { + const entry = fileURLToPath(import.meta.resolve("acp-kernel")) + let dir = path.dirname(entry) + for (let i = 0; i < 10 && dir !== path.dirname(dir); i++) { + const pj = path.join(dir, "package.json") + let data: any + try { + data = JSON.parse(readFileSync(pj, "utf8")) + } catch { + data = null + } + if (data && data.name === "acp-kernel") { + kernelVersion = data.version + break + } + dir = path.dirname(dir) + } +} catch { + kernelVersion = "unknown" +} export default defineConfig({ entry: ["index.ts"], @@ -14,5 +39,7 @@ export default defineConfig({ noExternal: ["jsonc-parser", "context-compress-algorithms", "acp-kernel"], define: { ACP_VERSION: JSON.stringify(pkg.version), + ACP_ENGINE: JSON.stringify("acp-kernel"), + KERNEL_VERSION: JSON.stringify(kernelVersion), }, })