From e5096f72e576ab802a94cfee2c7a7dfe1e6b9ea2 Mon Sep 17 00:00:00 2001 From: pi-agent Date: Mon, 7 Sep 2026 22:16:30 +0800 Subject: [PATCH 1/2] feat(compress): opt-in wire-level strip of historical image payloads (closes #321) compress.stripImages (default off) + compress.stripImagesKeepRecent (default 5), three-level deepest-wins merge. Hooks pi's before_provider_request (post-serialization, pre-HTTP) and calls acp-kernel stripHistoricalImages (>= 0.0.58). Protocol mapping: anthropic-messages / openai-completions / openai-responses (+azure/codex); unknown APIs untouched; stands down under the bili proxy; identity ref when nothing to strip. --- CHANGELOG.md | 1 + CONFIGURATION.md | 2 + CONFIGURATION.zh-CN.md | 2 + src/config.ts | 12 +++ src/index.ts | 28 ++++++ src/runtime.ts | 15 +++- src/strip-images.ts | 54 +++++++++++ tests/strip-images.test.ts | 179 +++++++++++++++++++++++++++++++++++++ 8 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/strip-images.ts create mode 100644 tests/strip-images.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6abd6d8..d1d3deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased (master, since v0.1.38) +- **feat(compress): 可选 wire 层剥离历史图像载荷(closes #321,kernel #215)** — 图像字节随每个上游请求逐字重发(codec 把图像移出 CoreMessage.text 到边车,压缩折叠了文本也照样转发原图);长图像会话成本高。新增 `compress.stripImages`(默认关)+ `compress.stripImagesKeepRecent`(默认 5,global/provider/model 三级最深优先):开启后在 pi 的 `before_provider_request` 钩子(请求体序列化后、HTTP 前)调用 acp-kernel `stripHistoricalImages`,把早于最近 N 条消息的图像部分从原始请求体剥离,纯图像消息折叠 `"[image]"` 文本占位符保持消息数/角色序稳定;协议映射仅 anthropic-messages / openai-completions / openai-responses(含 azure/codex 变体),未知协议一律不动;bili 代理让位时跳过;无事可剥返回原引用(零重序列化)。实现:`src/strip-images.ts`(`apiToStripProtocol` / `applyStripImages`),`runtime.stripImagesFor(ctx)`,挂载 `wireBeforeProviderRequest`。注意:开启后历史消息字节变化会一次性使该消息的前缀缓存失效(随消息逐出最近 N 窗口发生,自愈);provider 真实 usage 随之下降,刻度翻转由 #268 的增长基线重置接管。依赖 acp-kernel 0.0.58 - **fix(nudge): 增长基线刻度混用复位(closes #267)** — token 计量在估算值 ↔ provider 真实 usage 锚点间翻转(hostFloor 激活/失活)时,增长增量跨翻转相减是伪度量:旧刻度基线对新刻度 tokenCount 相减,凭空多出 "+35K 假增长",绕过 cadence 节流乱发 nudge(或反向漏发)。现在翻转时统一重置全部增长基线:kernel 侧 `lastNudgeShownTokens` / `lastPerMessageNudgeTokens` / `lastShownByTier`(0.0.55 per-tier cadence 基线)+ 扩展侧 `clearNudgeTokenStamps()`(#316 引入的同轮 re-inject 刻度戳,旧刻度戳同样会伪造满地板增长触发伪重注入);usage bands 的 floor-stale 行为保留不动。附带:② `/acp` 面板与 `acp_status` 百分比分母统一为与实时仲裁相同的 `window − maxOutput`(新增 `src/overflow-selfheal.ts` `applyOutputHeadroom`,此前面板按全窗口报告而 nudge bands 按 headroom 后窗口仲裁);③ auto-update 限流与只读停止标记按安装位置 sha256 隔离(多副本互不干扰)。实现:`src/runtime.ts` `noteTokenScale`/`dropTokenScale`,`src/index.ts` 翻转检测重置,`src/update.ts` `locationKey`;测试 `tests/growth-scale-flip.test.ts` + `tests/update.test.ts` 扩展 - **fix(state): 导入会话后从会话日志重放重建压缩状态(closes #299)** — pi 的 `importFromJsonl` 只拷贝 `.jsonl`,`.acp.json` sidecar 不随行,导入的会话压缩状态静默归零(全量重发 → 重压缩 → 全量重缓存)。新增最后一级兑底:sidecar 与父会话继承都未命中且日志含成功 compress 调用时,首个 context 事件中重放日志(assistant toolCall 参数 + 非 error toolResult 配对,逐调用先 `processTurn` 补齐 `messageRefs` 再 `applyCompression`,镜像线上流水顺序),重建 blocks/summaries/effectiveMessageIds/stats 并落盘 sidecar;errored/no-op/无法解析的调用跳过(批量原子,拒绝即状态不变),重放幂等(sidecar 重建后不再触发)。新增 `src/state-rebuild.ts`(`hasCompressHistory` / `rebuildStateFromLog`),挂载 `src/runtime.ts` `stateFor`。上游宿主侧修复另行提案(ranxianglei/pi#1:import 拷贝 `${basename}.*` sidecar + export 保留 `parentSession`) - **feat(kernel): acp-kernel 0.0.55 → 0.0.56 — 面板文案修正 + decompress 后 block 状态保持 (#182)** — ① 面板 "Context (session accounting)" 行改为 "includes compressed originals; shrinks slower than the sent view"(原 "never shrinks" 不成立:摘要替换被 prune 的原文后会降);"tokens compressed" 标注 cumulative 消除与当前 active blocks 的表面矛盾。② sync.ts 行为修复:宿主标记 `expanded` 的 block(用户显式 decompress 还原过)在 syncBlocks 中保持 inactive,不再被重新激活——否则已还原的消息下一轮又被折叠(双倍成本 + 丢原文);重新压缩同区间会创建新 block。适配 tests/commands-kit-panel.test.ts 断言新文案 diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 44ade3a..aa9f9cc 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -141,6 +141,8 @@ All keys below are currently **ACTIVE**. | `compress.maxContextLimit` | number \| string | `"75%"` | 🟢 ACTIVE | Context threshold that triggers forced compression nudges. | | `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | Context threshold that triggers emergency truncation. | | `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | Token growth step for soft compression nudges. | +| `compress.stripImages` | boolean | `false` | 🟢 ACTIVE | **Opt-in** wire-level strip of historical image payloads (issue #321). When `true`, every message older than the most recent `stripImagesKeepRecent` has its image parts dropped from the outbound provider body; image-only messages collapse to a `"[image]"` text placeholder. Supported wire dialects: anthropic-messages, openai-completions, openai-responses (incl. azure/codex variants). | +| `compress.stripImagesKeepRecent` | number | `5` | 🟢 ACTIVE | How many of the most recent messages keep their image payloads when `stripImages` is on. | **Prompts keys** diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 412ddbf..e872964 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -140,6 +140,8 @@ | `compress.maxContextLimit` | number \| string | `"75%"` | 🟢 ACTIVE | 触发强制压缩 nudge 的上下文阈值。 | | `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | 触发紧急截断的上下文阈值。 | | `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | 软压缩 nudge 的 token 增长步长。 | +| `compress.stripImages` | boolean | `false` | 🟢 ACTIVE | **可选开启**:wire 层剥离历史图像载荷(issue #321)。为 `true` 时,除最近 `stripImagesKeepRecent` 条消息外,历史消息的图像部分在上游请求体中被剥离;纯图像消息折叠为 `"[image]"` 文本占位符。支持协议:anthropic-messages、openai-completions、openai-responses(含 azure/codex 变体)。 | +| `compress.stripImagesKeepRecent` | number | `5` | 🟢 ACTIVE | `stripImages` 开启时保留图像载荷的最近消息条数。 | **prompts 键** diff --git a/src/config.ts b/src/config.ts index 09aa42f..71935e7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -129,6 +129,16 @@ export interface CompressSettings { * window and would suppress every nudge. Maps to kernel * nudge.minPressureBenefitTokens. */ minPressureBenefitTokens?: number; + /** Opt-in wire-level strip of historical image payloads (issue #321, kernel + * #215). Default: false (images ride along verbatim, current behavior). + * When true, every message older than `stripImagesKeepRecent` has its image + * parts dropped from the outbound provider body (image-only messages + * collapse to a "[image]" text placeholder). Host-side policy only — the + * strip primitive lives in acp-kernel's wire layer. */ + stripImages?: boolean; + /** How many of the MOST RECENT messages keep their image payloads when + * `stripImages` is enabled. Default: 5. Ignored when stripImages is off. */ + stripImagesKeepRecent?: number; } /** Per-provider compression overrides. Carries the same tuning fields as the @@ -358,6 +368,8 @@ export function mergeCompress( emergencyThresholdPercent: model?.emergencyThresholdPercent ?? provider?.emergencyThresholdPercent ?? global?.emergencyThresholdPercent, nudgeGrowthTokens: model?.nudgeGrowthTokens ?? provider?.nudgeGrowthTokens ?? global?.nudgeGrowthTokens, minPressureBenefitTokens: model?.minPressureBenefitTokens ?? provider?.minPressureBenefitTokens ?? global?.minPressureBenefitTokens, + stripImages: model?.stripImages ?? provider?.stripImages ?? global?.stripImages, + stripImagesKeepRecent: model?.stripImagesKeepRecent ?? provider?.stripImagesKeepRecent ?? global?.stripImagesKeepRecent, }; } diff --git a/src/index.ts b/src/index.ts index 6f6680c..b8121fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { coreOutToAgentMessages, extractText } from "./messages.js"; import { buildAcpSystemPrompt, ACP_DELEGATE_PROMPT } from "./system-prompt.js"; import { delegateStatusWidget } from "./fleet-widget.js"; import { openFleetInspector } from "./fleet-inspector.js"; +import { applyStripImages } from "./strip-images.js"; import { wireToolGuardrails } from "./tool-guardrails.js"; import { debug, logError, logInfo, logWarn, logThrow, closeLogStream } from "./log.js"; import { collectCoveredMessageIds, estimateTokens, lastUserMessageId, collectImageTokens, modelSupportsImages, sentViewTokenCount } from "./tokens.js"; @@ -82,6 +83,7 @@ export function createAcpExtension(adapter: AdapterConfig = {}): ExtensionFactor wireDelegateReadTracking(pi); wireSessionLifecycle(pi, runtime, standDownIfProxied); wireContextTransform(pi, runtime, standDownIfProxied); + wireBeforeProviderRequest(pi, runtime, standDownIfProxied); wireSystemPrompt(pi, runtime); wireToolGuardrails(pi, runtime); wireOverflowSelfHeal(pi, runtime); @@ -238,6 +240,32 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf }); } +// Opt-in wire-level strip of historical image payloads (issue #321, kernel +// #215). pi serializes the provider request body from the (already transformed) +// messages and fires before_provider_request with the RAW payload right before +// the HTTP call — the same wire point the billion-context proxy strips at. We +// only touch the body when the policy is enabled AND something was actually +// removed; returning undefined keeps pi's payload reference untouched. +function wireBeforeProviderRequest(pi: ExtensionAPI, runtime: AcpRuntime, standDownIfProxied: (ctx: ExtensionContext) => boolean): void { + pi.on("before_provider_request", async (event, ctx) => { + if (runtime.refused) return; + if (standDownIfProxied(ctx)) return; + const settings = runtime.stripImagesFor(ctx); + if (!settings.enabled) return; + const outcome = applyStripImages(event.payload, (ctx.model as { api?: string } | undefined)?.api, settings); + if (outcome.removed > 0) { + logInfo("strip-images", { + sid: ctx.sessionManager.getSessionId(), + event: "stripped", + removed: outcome.removed, + keepRecent: settings.keepRecent, + }); + return outcome.body; + } + return; + }); +} + // The core integration: Pi's `context` event fires before every LLM call with the // messages about to be sent. We run acp-kernel's processTurn (prune + ref-tag + // nudge decision) and return the transformed AgentMessage[]. diff --git a/src/runtime.ts b/src/runtime.ts index cfb7bbd..5d7ada1 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -8,7 +8,7 @@ import { type Config, type Prompts, } from "acp-kernel"; -import { resolveConfig, type AdapterConfig } from "./config.js"; +import { resolveConfig, resolveCompress, type AdapterConfig } from "./config.js"; import { entriesToCoreMessages, extractText, matchesStoredText, messageIdentity, messageRef } from "./messages.js"; import { SessionStateStore, type LiveRefOrigin } from "./state.js"; import { hasCompressHistory, rebuildStateFromLog } from "./state-rebuild.js"; @@ -92,6 +92,9 @@ export interface AcpRuntime { clearCompressRetryTracking(): void; liveContextLimit(ctx: ExtensionContext): number; configFor(ctx: ExtensionContext): Config; + /** Effective historical-image strip policy for the active model (issue #321). + * Host-side policy — deliberately NOT part of the kernel Config object. */ + stripImagesFor(ctx: ExtensionContext): { enabled: boolean; keepRecent: number }; /** Re-read ~/./acp.json + //acp.json and re-derive the adapter * config when the contents change. Cheap no-op when unchanged. Called at * session_start and on every context event so config edits apply live. */ @@ -381,6 +384,14 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { return resolveConfig(adapterRef, liveContextLimit(ctx), m?.provider, m?.id); } + function stripImagesFor(ctx: ExtensionContext): { enabled: boolean; keepRecent: number } { + const m = ctx.model as { provider?: string; id?: string } | undefined; + const c = resolveCompress(adapterRef.compress, m?.provider, m?.id); + const raw = Number(c.stripImagesKeepRecent); + const keepRecent = Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5; + return { enabled: c.stripImages === true, keepRecent }; + } + async function reloadConfig(cwd: string): Promise { let user; try { @@ -457,4 +468,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { let refused = false; let refusalMessage: string | null = null; - return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} + return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale, stripImagesFor };} diff --git a/src/strip-images.ts b/src/strip-images.ts new file mode 100644 index 0000000..524e7e6 --- /dev/null +++ b/src/strip-images.ts @@ -0,0 +1,54 @@ +import type { StripProtocol } from "acp-kernel/wire"; +import { stripHistoricalImages } from "acp-kernel/wire"; + +// Opt-in (default OFF) wire-level strip of historical image payloads +// (issue #321, kernel #215). Image bytes ride along verbatim on every request +// even after compression folds the surrounding text — the codecs move images +// out of CoreMessage.text into sidecars, so the raw payload is forwarded +// regardless of what got summarized. With compress.stripImages enabled, every +// message older than the most recent `compress.stripImagesKeepRecent` (default +// 5) has its image parts dropped in the outbound provider body; an image-only +// message collapses to a "[image]" text placeholder so message count / role +// ordering stay stable. The strip runs at pi's before_provider_request hook on +// the RAW parsed body, after codec serialization and before the HTTP call. + +/** Map pi-ai's Api id to the kernel wire dialect. Only dialects the kernel + * strip primitive explicitly supports are mapped; everything else returns + * null (= no-op), so exotic or future APIs are never mangled. */ +export function apiToStripProtocol(api: string | undefined): StripProtocol { + switch (api) { + case "anthropic-messages": + return "anthropic"; + case "openai-completions": + return "openai"; + case "openai-responses": + case "azure-openai-responses": + case "openai-codex-responses": + return "responses"; + default: + return null; + } +} + +export interface StripImagesOutcome { + /** Replacement body to send, or undefined when the payload is unchanged. */ + body?: unknown; + /** Number of image parts removed (0 = untouched). */ + removed: number; +} + +/** Strip historical images from a provider request body. Pure: returns + * { removed: 0 } (and NO body) when disabled, the protocol is unsupported, + * the body is not strip-shaped, or nothing is older than keepRecent. */ +export function applyStripImages( + body: unknown, + api: string | undefined, + settings: { enabled: boolean; keepRecent: number }, +): StripImagesOutcome { + if (!settings.enabled) return { removed: 0 }; + const protocol = apiToStripProtocol(api); + if (!protocol) return { removed: 0 }; + const result = stripHistoricalImages(body, protocol, settings.keepRecent); + if (result.removed === 0) return { removed: 0 }; + return { body: result.body, removed: result.removed }; +} diff --git a/tests/strip-images.test.ts b/tests/strip-images.test.ts new file mode 100644 index 0000000..e247344 --- /dev/null +++ b/tests/strip-images.test.ts @@ -0,0 +1,179 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAcpExtension } from "../src/index.js"; +import { apiToStripProtocol, applyStripImages } from "../src/strip-images.js"; +import { resolveCompress } from "../src/config.js"; + +// Issue #321 (ranxianglei/billion-context-pi#321): opt-in wire-level strip of +// historical image payloads via acp-kernel's stripHistoricalImages (kernel +// #215). The strip runs at pi's before_provider_request hook on the RAW +// serialized body, before the HTTP call. Default OFF. + +test("apiToStripProtocol maps known pi-ai APIs, null for everything else", () => { + assert.equal(apiToStripProtocol("anthropic-messages"), "anthropic"); + assert.equal(apiToStripProtocol("openai-completions"), "openai"); + assert.equal(apiToStripProtocol("openai-responses"), "responses"); + assert.equal(apiToStripProtocol("azure-openai-responses"), "responses"); + assert.equal(apiToStripProtocol("openai-codex-responses"), "responses"); + // Unsupported / exotic dialects must never be mangled. + assert.equal(apiToStripProtocol("bedrock-converse-stream"), null); + assert.equal(apiToStripProtocol("google-generative-ai"), null); + assert.equal(apiToStripProtocol(undefined), null); + assert.equal(apiToStripProtocol("some-future-api"), null); +}); + +function anthropicBody(n: number) { + return { + model: "claude", + system: "sys", + messages: Array.from({ length: n }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "image", source: { type: "base64", data: `img-${i}` } }, { type: "text", text: `t${i}` }], + })), + }; +} + +test("applyStripImages: disabled / unknown api / no-op bodies return removed 0 and no replacement", () => { + const body = anthropicBody(8); + assert.deepEqual(applyStripImages(body, "anthropic-messages", { enabled: false, keepRecent: 5 }), { removed: 0 }); + assert.deepEqual(applyStripImages(body, "bedrock-converse-stream", { enabled: true, keepRecent: 5 }), { removed: 0 }); + // Text-only body: nothing to strip, kernel returns the input reference. + const textOnly = { messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }; + assert.deepEqual(applyStripImages(textOnly, "anthropic-messages", { enabled: true, keepRecent: 5 }), { removed: 0 }); +}); + +test("applyStripImages: anthropic — older-than-keepRecent image parts dropped, recent kept", () => { + const body = anthropicBody(8); // images in messages 0..7 + const out = applyStripImages(body, "anthropic-messages", { enabled: true, keepRecent: 5 }); + assert.equal(out.removed, 3); // messages 0,1,2 are older than the last 5 + assert.notEqual(out.body, body); + const msgs = (out.body as typeof body).messages; + assert.ok(!JSON.stringify(msgs[0]).includes("img-0"), "old image gone"); + assert.ok(JSON.stringify(msgs[5]).includes("img-5"), "recent image kept"); + assert.ok(JSON.stringify(msgs[7]).includes("img-7"), "newest image kept"); + // Text parts survive alongside the strip. + assert.ok(JSON.stringify(msgs[0]).includes("t0")); +}); + +test("applyStripImages: openai + responses dialects strip their own part types", () => { + const openai = { + messages: Array.from({ length: 6 }, (_, i) => ({ + role: "user", + content: [{ type: "image_url", image_url: { url: `data:image/png;base64,${i}` } }], + })), + }; + const outO = applyStripImages(openai, "openai-completions", { enabled: true, keepRecent: 2 }); + assert.equal(outO.removed, 4); + // Image-only message collapses to a text placeholder, message count stable. + const msgsO = (outO.body as typeof openai).messages; + assert.equal(msgsO.length, 6); + assert.deepEqual(msgsO[0].content, [{ type: "text", text: "[image]" }]); + + const responses = { + model: "gpt", + input: [ + { role: "user", content: [{ type: "input_image", image_url: `data:${0}` }] }, + { role: "user", content: [{ type: "input_image", image_url: `data:${1}` }] }, + { role: "user", content: [{ type: "input_text", text: "tail" }] }, + ], + }; + const outR = applyStripImages(responses, "openai-responses", { enabled: true, keepRecent: 1 }); + assert.equal(outR.removed, 2); // items 0 and 1 are older than the last 1 + assert.deepEqual((outR.body as typeof responses).input[0].content, [{ type: "input_text", text: "[image]" }]); + assert.ok((outR.body as typeof responses).input[2].content[0].type === "input_text", "non-image tail untouched"); +}); + +test("resolveCompress: stripImages fields follow the three-level deepest-wins merge", () => { + const c = resolveCompress( + { + stripImages: true, + stripImagesKeepRecent: 5, + providers: { + "anthropic": { stripImagesKeepRecent: 9, models: { "claude-x": { stripImages: false } } }, + }, + }, + "anthropic", + "claude-x", + ); + assert.equal(c.stripImages, false); // model level wins + assert.equal(c.stripImagesKeepRecent, 9); // provider level wins over global + const other = resolveCompress( + { stripImages: true, stripImagesKeepRecent: 5, providers: { "anthropic": { stripImagesKeepRecent: 9, models: { "claude-x": { stripImages: false } } } } }, + "anthropic", + "other-model", + ); + assert.equal(other.stripImages, true); // global inherited + const off = resolveCompress(undefined, undefined, undefined); + assert.equal(off.stripImages, undefined); // default: never strip +}); + +// --- End-to-end through the extension's registered before_provider_request handler --- + +function captureApi() { + const handlers = new Map any)[]>(); + const api = { + on(event: string, handler: (e: any, ctx: any) => any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + tools: [] as any[], + commands: new Map(), + registerTool(tool: any) { this.tools.push(tool); }, + registerCommand(name: string, options: any) { this.commands.set(name, options); }, + }; + return { api, handlers }; +} + +function ctxFor(model: Record = {}) { + return { + mode: "rpc", + hasUI: false, + cwd: join(tmpdir(), "strip-images-cwd"), + ui: { notify: () => {}, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: { contextWindow: 200_000, id: "test-model", ...model }, + sessionManager: { getSessionId: () => "s1", getSessionFile: () => "/tmp/none.jsonl" }, + }; +} + +async function stripViaHandler(adapter: any, model: Record, payload: unknown) { + const { api, handlers } = captureApi(); + createAcpExtension(adapter)(api as any); + const h = handlers.get("before_provider_request")!; + assert.ok(h.length >= 1, "handler registered"); + return await h[0]!({ type: "before_provider_request", payload }, ctxFor(model)); +} + +test("before_provider_request: strips historical images when compress.stripImages is on", async () => { + const payload = anthropicBody(8); + const result = await stripViaHandler( + { modelContextLimit: 200_000, autoUpdate: false, compress: { stripImages: true } }, + { api: "anthropic-messages" }, + payload, + ); + assert.ok(result && typeof result === "object"); + assert.ok(!JSON.stringify(result).includes("img-0"), "historical image stripped on the wire"); + assert.ok(JSON.stringify(result).includes("img-7"), "recent image kept"); +}); + +test("before_provider_request: default off leaves the payload reference untouched", async () => { + const payload = anthropicBody(8); + const result = await stripViaHandler( + { modelContextLimit: 200_000, autoUpdate: false }, + { api: "anthropic-images" }, + payload, + ); + assert.equal(result, undefined, "no replacement body when disabled"); +}); + +test("before_provider_request: bili-proxy baseUrl stands down even when enabled", async () => { + const payload = anthropicBody(8); + const result = await stripViaHandler( + { modelContextLimit: 200_000, autoUpdate: false, compress: { stripImages: true } }, + { api: "anthropic-messages", baseUrl: "http://127.0.0.1:9111/bili/https://api.anthropic.com" }, + payload, + ); + assert.equal(result, undefined, "proxy owns the wire; extension must not touch the body"); +}); From decb5d062d590fefb4491b0f7747bdf3740f32b8 Mon Sep 17 00:00:00 2001 From: pi-agent Date: Mon, 7 Sep 2026 22:24:20 +0800 Subject: [PATCH 2/2] chore(kernel): acp-kernel 0.0.56 -> 0.0.58 (stripHistoricalImages) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac569bb..4cd2ccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@earendil-works/pi-coding-agent": "0.83.0", "@earendil-works/pi-tui": "0.83.0", "@types/node": "^26.1.2", - "acp-kernel": "0.0.56", + "acp-kernel": "0.0.58", "tsup": "^8.5.1", "tsx": "^4.23.1", "typescript": "^7.0.2" @@ -3200,9 +3200,9 @@ } }, "node_modules/acp-kernel": { - "version": "0.0.56", - "resolved": "https://registry.npmjs.org/acp-kernel/-/acp-kernel-0.0.56.tgz", - "integrity": "sha512-5bNbzKHmbojWAHxA3dOa7yL1WD+6DSPe+szTLCGYE4k3afUUIz/v6akrGGUfn8tzkckVBno9SfPlT6iVj14D6g==", + "version": "0.0.58", + "resolved": "https://registry.npmjs.org/acp-kernel/-/acp-kernel-0.0.58.tgz", + "integrity": "sha512-4gEME4qSDTxYYE25BALP7vae3WJcUnF6WS39EoZvbc6UN+D0mTWks2Dp+SlMjdn4N+cCFK6mvFcVVtLJl5QAtg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 6026b08..df36728 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@earendil-works/pi-coding-agent": "0.83.0", "@earendil-works/pi-tui": "0.83.0", "@types/node": "^26.1.2", - "acp-kernel": "0.0.56", + "acp-kernel": "0.0.58", "tsup": "^8.5.1", "tsx": "^4.23.1", "typescript": "^7.0.2"