Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`,`<sessionFile>.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 断言新文案
Expand Down
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
2 changes: 2 additions & 0 deletions CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 键**

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
};
}

Expand Down
28 changes: 28 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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[].
Expand Down
15 changes: 13 additions & 2 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ~/.<dir>/acp.json + <cwd>/<dir>/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. */
Expand Down Expand Up @@ -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<void> {
let user;
try {
Expand Down Expand Up @@ -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 };}
54 changes: 54 additions & 0 deletions src/strip-images.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading