diff --git a/NOTICE b/NOTICE index de301c5..3fec2a5 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 0000000..4c0e1bb --- /dev/null +++ b/devlog/2026-08-05_acp-kernel/DESIGN.md @@ -0,0 +1,180 @@ +# DESIGN — opencode-acp → acp-kernel migration + +Issue: dog/opencode-acp#42 · Branch: `2026-08-05_acp-kernel` + +> **⚠️ SUPERSEDED (2026-08-05)**: The phased migration described in §9 below was +> **rejected** by @dog on issue #42 in favour of a **one-shot fresh rewrite**. +> See `WORKLOG.md` for the as-built design. This document is retained as the +> original design rationale; the §9 phase plan was **not** executed. + +## 1. Why a phased migration + +The current engine (`lib/compress`, `lib/messages`, `lib/state`, `lib/gc`) is +load-bearing: ~70 files, persisted-state format, 900+ tests, `dcp-` XML tags in +production state files, and a `/dcp` command alias for backward compat +(AGENTS.md §2.6). A big-bang replacement would break the shipped plugin, the +persisted state of live sessions, and be unreviewable. So: + +- **Phase 1 (this PR)**: land `acp-kernel` + an additive `lib/kernel/` adapter. + Zero behavior change. Builds + tests stay green. +- **Phase 2+ (follow-ups)**: switch the hot paths to the kernel, migrate state, + delete the old engine. + +## 2. The two state shapes (and why they differ) + +### acp-kernel `CompressionState` (`acp-kernel/src/types.ts`) + +```ts +interface CompressionState { + blocks: CompressionBlock[]; // blockId: string ("b0"…), tier: 1|2|3 + messageRefs: { byRaw: Record; byRef: Record }; + nudge: NudgeState; // flat: lastPerMessageNudgeTokens, lastShownByTier{}, … + stats: CompressionStats; // tokensCompressed, compressionCount + nextBlockId: number; + nextRunId: number; +} +``` + +### opencode-acp `SessionState` (`lib/state/types.ts`) + +Richer / older format: + +```ts +interface SessionState { + prune: { messages: { byMessageId: Map; blocksById: Map; + 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 0000000..cbec99e --- /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 0000000..3d174a3 --- /dev/null +++ b/devlog/2026-08-05_acp-kernel/WORKLOG.md @@ -0,0 +1,104 @@ +# 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` + +## 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.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`. + +## 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. | + +`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/`. + +## 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** (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 7d2749e..fc08041 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 0000000..a3239c9 --- /dev/null +++ b/lib/kernel/commands.ts @@ -0,0 +1,109 @@ +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" +import { versionBanner, VERSION } from "./version" + +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 === "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 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 + } + + 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 = [ + 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`, + `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 = [ + 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(", ")})` : ""), + ] + 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/config.ts b/lib/kernel/config.ts new file mode 100644 index 0000000..d8bf8c5 --- /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/hooks.ts b/lib/kernel/hooks.ts new file mode 100644 index 0000000..d1283b8 --- /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 new file mode 100644 index 0000000..38a44d6 --- /dev/null +++ b/lib/kernel/index.ts @@ -0,0 +1,28 @@ +export { withPartsToCoreMessages, reconstructMessages, type CoreMessage, type ReconstructionResult } from "./messages" +export { resolveKernelConfig } from "./config" +export { + loadKernelState, + saveKernelState, + detectLegacyState, + 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" +export { versionBanner, VERSION } from "./version" diff --git a/lib/kernel/messages.ts b/lib/kernel/messages.ts new file mode 100644 index 0000000..6e7a7c6 --- /dev/null +++ b/lib/kernel/messages.ts @@ -0,0 +1,209 @@ +import type { CoreMessage } from "acp-kernel" +export type { CoreMessage } from "acp-kernel" +import type { WithParts } from "../state" + +// Projection between OpenCode's SDK shape (WithParts = { info, parts[] }) and +// acp-kernel's CoreMessage. +// +// OpenCode Part kinds: text { type,text,ignored? }, tool { type,tool,callID, +// state:{status,input?,output?,error?} }, reasoning { type,text }. +// +// 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 + text?: string + tool?: string + callID?: string + state?: { + status?: string + input?: unknown + output?: unknown + error?: string | { message?: 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 + return typeof err === "string" ? err : err?.message ?? "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 + } + + const text = extractText(parts) + if (text.length > 0) { + out.push({ id, role: role === "system" ? "system" : "user", contentType: "text", text }) + } + } + return out +} + +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("#") + 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"), + ) + 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 { 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 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+$/, "") + } + } + 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/runtime.ts b/lib/kernel/runtime.ts new file mode 100644 index 0000000..73219ae --- /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 0000000..09b2abf --- /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/lib/kernel/system-prompt.ts b/lib/kernel/system-prompt.ts new file mode 100644 index 0000000..ac18311 --- /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 0000000..46d0aba --- /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/kernel/version.ts b/lib/kernel/version.ts new file mode 100644 index 0000000..54f20d6 --- /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/lib/token-utils.ts b/lib/token-utils.ts index 8644328..868d372 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/package-lock.json b/package-lock.json index baffdde..0eda164 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 432fb0d..5954905 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/scripts/opencode-test.sh b/scripts/opencode-test.sh new file mode 100644 index 0000000..fb58fd6 --- /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 0000000..307faec --- /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 diff --git a/tests/kernel-smoke.test.ts b/tests/kernel-smoke.test.ts new file mode 100644 index 0000000..b573c36 --- /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") +}) diff --git a/tests/kernel-version.test.ts b/tests/kernel-version.test.ts new file mode 100644 index 0000000..813fe47 --- /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 f97da8d..422c834 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"], @@ -10,8 +35,11 @@ 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), + ACP_ENGINE: JSON.stringify("acp-kernel"), + KERNEL_VERSION: JSON.stringify(kernelVersion), }, })