一句话定位
替换 pi 默认的「保留最近 20k tokens」压缩策略,改为用 Gemini Flash 对整个对话做完整总结------117 行,行为替换型事件的范例。
作用(为什么存在)
前三期拆过拦截(permission-gate)、生命周期(git-checkpoint)、完整插件(todo),这期是行为替换 :拦截 session_before_compact 事件,接管压缩行为本身。
默认压缩是「保留最近 20k tokens 的对话」,会丢掉早前上下文;这个插件改成「总结全部消息 → 只留摘要」。更重要的是它展示了两个工程实践:
- 换模型做脏活:总结用 Gemini Flash(便宜/快),主对话模型不被浪费
- 返回值决定行为 :返回
{compaction}用自定义结果,返回undefined退回默认------压缩永远不会因为插件故障而中断
关键信息
| 项 | 内容 |
|---|---|
| 源码位置 | examples/extensions/custom-compaction.ts(117 行) |
| 核心 API | on("session_before_compact") + ctx.modelRegistry.find/complete + serializeConversation + convertToLlm |
| 插件类型 | 行为替换型(拦截型事件) |
触发流程 / 数据流
plaintext
上下文水位线 / 用户 /compact / 溢出恢复
→ session_before_compact 事件(reason: manual | threshold | overflow)
→ 从 preparation 取消息、token 数、保留点、上次摘要
→ modelRegistry.find("google", "gemini-2.5-flash")
├─ 无 → return undefined(默认压缩)
└─ 有 → 序列化全部消息 → complete() 让 Gemini 写摘要
├─ 非空 → return { compaction: {...} }(自定义生效)
├─ 空 → return undefined(默认)
└─ 异常 → return undefined(默认)
架构 / 流程

关键代码解读
typescript
pi.on("session_before_compact", async (event, ctx) => {
// ① 取压缩准备数据
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = event.preparation;
// ② 找便宜模型做总结(registerProvider 是前置条件)
const model = ctx.modelRegistry.find("google", "gemini-2.5-flash");
if (!model) return; // ③ 没注册 → 静默退回默认压缩
// ④ 全部消息序列化成可读文本
const conversationText = serializeConversation(convertToLlm([...messagesToSummarize, ...turnPrefixMessages]));
// ⑤ 带 signal 调 LLM:用户取消压缩时中止,不白烧 token
const response = await ctx.modelRegistry.complete(model, { messages: summaryMessages }, {
maxTokens: 8192, signal, cacheRetention: "none", sessionId: uuidv7(),
});
const summary = /* 提取 text 内容 */;
if (!summary.trim()) {
if (!signal.aborted) ctx.ui.notify("summary was empty, using default", "warning");
return; // ⑥ 空摘要 → 默认压缩
}
// ⑦ 返回 compaction 对象 = 用自定义结果替换默认行为
return { compaction: { summary, firstKeptEntryId, tokensBefore, usage: response.usage } };
});
亮点 / 踩坑
亮点 1:摘要提示词的设计。 提示词里刻意强调 "The summary will replace the ENTIRE conversation history"------让模型知道写不全 = 永久丢失,逼它覆盖 6 个维度(目标/决策/代码变更/进行中/阻塞/下一步)。压缩场景下,「写全」比「写精」重要。
亮点 2:三条降级链全静默。 模型没注册、摘要为空、LLM 异常------全部 return undefined 退回默认压缩,永不中断会话。「扩展可以增强,但不能破坏」。
踩坑提示:模型必须预先注册。 modelRegistry.find 找不到 Gemini Flash 时整个自定义压缩不生效------这是强依赖,跑 demo 前要先确认模型可用。
边界(Limitations)
| 边界 | 表现 |
|---|---|
| 模型依赖 | gemini-2.5-flash 必须已注册,否则全程走默认 |
| 降级链 | 三条路(没注册/摘要空/异常)都静默退回默认,不中断会话 |
| 取消语义 | signal.aborted 时空摘要不算错误,不弹 warning |
| 返回语义 | {compaction} 替换 / undefined 默认------靠返回值类型决定行为 |
场景(Scenarios)
- 能生效:模型已注册 + 压缩触发(threshold/manual/overflow)+ 摘要非空
- 退回默认:模型未注册 / 摘要为空 / LLM 异常(三类都静默)
- 用户取消:压缩中 abort → LLM 中止 → 空摘要被 signal 识别为已取消,静默处理
可借鉴的模式
- 行为替换型事件 :返回
{compaction}用自定义、undefined用默认------与前几期 permission-gate 的{block}、todo 的details同族:返回值决定行为。 - 降级链兜底:所有失败路径退回默认,绝不中断主流程。
- 换模型做脏活:便宜/快模型处理总结类任务,主模型专注对话。
- signal 传播:耗时调用必须传 AbortSignal,用户取消时中止,避免白烧 token。
- previousSummary 上下文:前一轮摘要带进新摘要,连续压缩不丢历史脉络。
一句话总结
117 行告诉你如何安全地「替换」框架行为:返回值决定替换、降级链保证不破坏、换模型省钱、传 signal 尊重取消。