Skip to content

Commit 5b1a67c

Browse files
universe-hcyclaude
andcommitted
feat: 添加 /digest 命令(回溯蒸馏 / DFS 式上下文优化)
/digest 像 /rewind 一样弹消息选择器,让用户事后选一条较早消息, 把它到对话底部的上下文蒸馏成四栏 digest(复用 pop 的 DIGEST_PROMPT + summaryFraming:'digest' + 局部压缩),不 resubmit。与 /pop 平级、 共享同一底层蒸馏原语。feature flag 复用 PUSH_POP。 Co-Authored-By: claude-opus-4-8[1m] <noreply@anthropic.com>
1 parent 1637232 commit 5b1a67c

8 files changed

Lines changed: 231 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
| **📦 Artifacts(HTML 上传)** | 复刻 Anthropic 官方 Artifacts:模型把 HTML/数据看板/报告上传到公开 URL(7d/30d 自动过期),`/artifacts` 命令集中管理,Cloudflare Worker + R2 完全开源、可自托管 | [8 小时复刻报告](./docs/blog/2026-06-20-cloud-artifacts-8h-recap.md) · [在线 demo](https://cloud-artifacts.claude-code-best.win/30d/c2jfwi3E-y3fTZ1ors-KE.html) |
2323
| **🧠 Ultracode 多 Agent 编排** | `/ultracode` 注入 workflow 编排手册 + `Workflow` 工具跑确定性 JS 脚本(`agent`/`pipeline`/`parallel`/`phase`)+ `/workflows` 双栏监控面板;支持 journal 重放、token budget、并发 cap | [文档](https://ccb.agent-aura.top/docs/features/workflow-scripts) |
2424
| **🌿 Push/Pop 上下文栈** | `/push` 开一个继承完整上下文的讨论旁支,`/pop` 把讨论蒸馏成结构化 digest 回卷主线——讨论噪音不污染主线、只留结论;支持嵌套、`--to #N` 跨层弹出、`--list` 零成本列栈、栈感知 auto-compact 三选(`FEATURE_PUSH_POP=1`| [设计文档](./docs/features/push-pop-context-stack.md) · 源码 [`commands/push/`](./src/commands/push/) · [`commands/pop/`](./src/commands/pop/) |
25+
| **🍃 /digest 回溯蒸馏** |`/compact` 互补的上下文优化:给只能顺序处理的 agent 补上 DFS 式回溯——`/digest``/rewind` 一样弹选择器,圈定一段失败调试/报错/发散岔路,蒸馏成四栏结论、主线**前缀无损缓存友好**(compact 是全局打包重建缓存);与 `/pop` 共享同一底层蒸馏原语(平级入口、非命令互调),`/digest` 支持事后选点;蒸馏本身要花一次压缩调用,但因前缀缓存热、成本远低于 compact 的全量重建(`FEATURE_PUSH_POP=1`| [设计文档](./docs/features/digest.md) · 源码 [`commands/digest/`](./src/commands/digest/) |
2526
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/uds-inbox) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
2627
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
2728
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |

docs/features/digest.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# /digest — 上下文回溯与蒸馏(DFS 式上下文优化)
2+
3+
## 概述
4+
5+
`/digest` 是一种**上下文优化机制**,与 `/compact` 互补。它不是「push/pop 的附属」,也不是 push/pop 内部调用的命令——`/digest``/pop`**平级入口**,各自独立调用同一个底层蒸馏原语(局部上下文压缩 `partialCompactConversation` + 四栏 `DIGEST_PROMPT` 模板)。区别只在**由谁圈定蒸馏范围**`/pop` 用栈标记(marker+1),`/digest` 用消息选择器手选。
6+
7+
Agent 天然只能**顺序处理**:一步步往下跑,一旦某段探索失败、报错、走进死胡同,这些日志与试错来回会**一直留在上下文里**稀释后续推理,且无法回收——线性历史只能追加、不能回溯。`/digest` 给这种线性历史补上**类似 DFS 的回溯能力**:把一段可隔离的探索岔路(通常是失败调试 / 报错排查 / 发散讨论)精确圈定,蒸馏成一份结构化结论,上下文回到岔路**之前**的干净状态,只多一份 digest。它是「手术刀」——精确、可控、缓存友好。
8+
9+
`/digest``/push`+`/pop` 是这套底层蒸馏原语的两种入口,区别只在**由谁、何时圈定蒸馏范围**
10+
11+
- **`/push` + `/pop`**:预先划界。讨论开始**** `/push` 打标记,结束后 `/pop` 弹栈蒸馏。要求用户能提前预判「接下来要岔出去了」。
12+
- **`/digest`**:事后选点。不需要任何提前动作,事后像 `/rewind` 一样弹消息选择器,回溯选中一条较早的消息,把它到对话底部整段蒸馏。解决「污染往往事后才发现、来不及 push」的现实问题。
13+
14+
## 动机:给顺序 agent 补上回溯
15+
16+
把 agent 与用户的协作看作一棵搜索树:每个节点是一次上下文状态,展开是深入探索,回溯是收敛结论。普通对话的上下文只能**追加**、无法**回溯**——一次失败的调试、一段跑偏的排查,会永久留在上下文里,持续消耗 token 并稀释后续推理,agent 只能带着这些噪音继续往下走。
17+
18+
`/digest` 为这棵树补上「回溯」:圈定那段失败探索,只让**蒸馏结论**穿过节点边界,展开过程中的中间态(大日志、死胡同、被推翻的假设)被丢弃。这正是 DFS 里「子树探索完只向父节点回传结果」的语义。
19+
20+
## digest vs compact
21+
22+
两者都做上下文瘦身,但解决的膨胀类型、控制粒度、缓存影响完全不同。一句话:**compact 是「全局打包整段历史来续命」,digest 是「把一段你圈定的探索岔路沉淀成结论、主线无损」。**
23+
24+
| 维度 | `/compact` | `/digest` |
25+
|------|-----------|-----------|
26+
| **作用范围** | 整个会话——把到目前为止的全部历史总结成一份摘要,替换原始来回 | 只针对你显式圈定的那段岔路,主线其余部分完全不动 |
27+
| **触发/控制** | 常被动触发(触顶自动 compact)或一键;边界是「到现在为止全部」,不精确控制压什么 | 完全主动、结构化——你精确决定哪一段被收敛 |
28+
| **保留什么** | 主线原始细节大多丢失,只剩一份总摘要 | 圈定点之前的主线**逐字保留**,只有那段岔路被换成结论 |
29+
| **意图** | 省空间、避免触顶——「不得已的续命」 | 主动沉淀结论、丢掉过程噪音——「整理」而非「救急」 |
30+
| **前缀缓存** | 几乎重写整个上下文 → 前缀缓存**全部失效**,compact 后第一轮基本全量重建,较贵 | 主线前缀**无损**,只有尾部那段换成小 digest → 缓存损失极小 |
31+
32+
## digest 能否替代 compact
33+
34+
对懂上下文管理的开发者,digest **能大幅降低对 compact 的依赖**——把它从「常规操作」降级为「兜底手段」,但不能完全替代。
35+
36+
**digest 替得漂亮的场景**:凡是可隔离的探索/调试岔路(一堆试错、读大日志、跑命令,最后只想留一个结论)——这类天然有边界,圈定即可蒸馏,主线前缀无损、缓存友好、保真度高。养成「探索前先划界、或事后 `/digest` 回收」的习惯,大量本会把主线撑到触发 compact 的膨胀,会被提前在岔路里消化掉。
37+
38+
**compact 仍不可少的场景**
39+
40+
1. **主线自身的线性膨胀**:上下文变大不是因为岔路,而是主线本身活儿多——顺着一条线读了几十个文件、跑了很多步,全是要保留的主线。这没有可划的边界,只有 compact 能压主线本身。
41+
2. **触顶救急**:逼近窗口硬上限时往往正处在一长串主线操作中间,来不及优雅划界。compact 是兜底救命,digest 不解决「主线超长」。
42+
43+
**成本形态不同**:digest 的代价是**纪律/认知负担**(要持续预判哪些是岔路、记得划界,`/digest` 的事后选点缓解了这点但仍需判断选哪条消息);compact 的代价是**缓存重建 + 丢细节**,但零纪律(自动或一键)。
44+
45+
**建议的组合策略**(不是二选一):主力用 digest 主动隔离每段探索,让主线长期精简、缓存友好,把 compact 触发频率压下去;兜底在主线确实线性长到逼近上限时,再用 compact 做安全网。
46+
47+
> ⚠️ 两者都不是无损。digest 的价值高度依赖蒸馏摘要写得准;若 digest 丢了后续主线要用的关键细节,仍得回 transcript 翻原文(`ctrl+o` 查看历史里被折叠的原始尾部)。
48+
49+
## 用户体验
50+
51+
```
52+
/digest 弹出消息选择器 → 选一条较早的 user 消息 M
53+
→ "Distill from here into a digest"(可附加 context)
54+
→ 从 M 到对话底部被四栏 digest 替换
55+
```
56+
57+
- 选择器复用 `/rewind` 的消息列表 UI,但 digest 模式下选项收敛为两个:**Distill from here into a digest**(可选填一段附加 context 影响蒸馏侧重)与 **Never mind**;标题、拾取提示、确认文案切换为 digest 语义。
58+
- 蒸馏进行时输入框旁显示 spinner「↓ Distilling from selected message」。
59+
- 完成后弹出通知:`↓ Distilled from selected message (ctrl+o for history) · Context: ~X → ~Y tokens`,X/Y 为蒸馏前后的上下文规模。
60+
61+
### 与 /rewind 的差异
62+
63+
`/digest` 借用 `/rewind` 的消息选择器 UI,但语义相反:`/rewind`**丢弃**选中点之后的内容(回到过去、可选恢复文件),`/digest`**蒸馏保留**选中点之后的结论(折叠而非丢弃、不动文件、不 resubmit)。digest 模式通过选择器的 `mode` 参数与 rewind 隔离,rewind 的完整 restore 选项不受影响。
64+
65+
## 核心机制
66+
67+
`/digest` 的落地主体复用局部上下文压缩 `applyPartialCompactByUuid`(方向 `from`)——把选中消息之后的段落原地蒸馏替换。这与 `/pop` 走的是同一条压缩路径、同一份 digest 模板,差异只在「选点来源」(命令弹选择器手选 vs 弹栈顶):
68+
69+
```
70+
applyPartialCompactByUuid({
71+
pivotUuid: M.uuid,
72+
feedback: 用户输入 ?? DIGEST_TEMPLATE,
73+
direction: 'from',
74+
options: { promptOverride: DIGEST_PROMPT, summaryFraming: 'digest' },
75+
// 注意:无 resubmit —— 蒸馏后不重新触发 agent,只折叠上下文
76+
})
77+
```
78+
79+
digest 用固定的四栏结构,让蒸馏物对后续对话可直接消费:
80+
81+
| 栏目 | 内容 |
82+
|------|------|
83+
| Decisions | 探索确定了什么 + 关键理由 |
84+
| Rejected | 过程中否决了什么 + 否决原因(防止重蹈覆辙) |
85+
| Open questions | 未收敛的点 |
86+
| Action items | 对主线任务的具体影响 / 待办 |
87+
88+
## token / 缓存成本
89+
90+
`/digest` **会花 token**——蒸馏是一次真实的 LLM 调用,不是免费的。但它的成本结构相对 `/compact` 明显更省,这正是 digest 的核心优势:
91+
92+
- **蒸馏调用的固有成本**:走一次局部压缩 fork(`partialCompactConversation`),读入被蒸馏的那段上下文 + 输出四栏 digest。这是「把一段上下文蒸馏成结论」躲不掉的成本——探索内容接着聊本来也要为它付 token。与 `/pop` 走的是**同一次**压缩调用,digest 相对 pop 不额外多花。
93+
- **相对 compact 更省**:因主线前缀**无损**,蒸馏刚结束时前缀缓存必热,单次成本约等于一次 cache-read 加几百 token 输出;而 compact 几乎重写整个上下文、前缀缓存全失效、下一轮全量重建。所以「省」是相对 compact 而言,不是绝对零成本。
94+
- **唯一真正零成本的部分**:完成通知里的 `Context: ~X → ~Y tokens` 复用压缩过程**已经算好**`preCompactTokenCount`(压缩时计算)与 `truePostCompactTokenCount`(本地消息负载估算),**不发起任何新的 token_count 请求**,纯显示层。
95+
96+
## 架构
97+
98+
| 模块 | 路径 | 职责 |
99+
|------|------|------|
100+
| digest 命令 | `src/commands/digest/` | 命令定义 + 调 `openMessageSelector('digest')` |
101+
| 消息选择器 | `src/components/MessageSelector.tsx` | `mode` 参数区分 rewind / digest;digest 模式收敛选项与文案 |
102+
| 应用层 | `src/screens/REPL.tsx` | `messageSelectorMode` state/ref + `onSummarize` 的 digest 分支(无 resubmit + spinner + token 通知) |
103+
| digest 模板 | `src/services/pushStack/digestPrompt.ts` | 四栏 `DIGEST_PROMPT` + `DIGEST_TEMPLATE`(push/pop 与 digest 共用) |
104+
| 局部压缩 | `src/services/compact/compact.ts` | 复用主体;`promptOverride` + `summaryFraming: 'digest'` 参数 |
105+
106+
`ToolUseContext.openMessageSelector` 的签名从 `() => void` 扩展为 `(mode?: 'rewind' \| 'digest') => void`(向后兼容,rewind 不传参)。特性由 feature flag `PUSH_POP` 控制(`FEATURE_PUSH_POP=1`)。
107+
108+
**代码血缘**:四栏 `DIGEST_PROMPT` 与局部压缩的这套复用**最初是为 `/pop` 引入的**(见 `digestPrompt.ts` 的注释「Digest prompt for `/pop`」),`/digest` 命令后加、复用同一底座。`/pop` 的落地在 `REPL.tsx``applyPop`(pivot = 栈标记 marker+1),`/digest` 的落地在 `MessageSelector``onSummarize` 的 digest 分支(pivot = 手选消息)——两者各自调用同一个 `applyPartialCompactByUuid`**谁都不调用谁的命令**。所以「共享」是平级复用同一底层原语,不是命令互调,也不存在从属关系。
109+
110+
## 使用场景
111+
112+
### 场景 1:事后隔离一段失败调试
113+
114+
主线跑测试冒出 SIGSEGV,没来得及 `/push` 就一头扎进排查——读了大量日志、试了几个假设、改了些实验代码,二十轮后确认是环境问题。`/digest` 选中「开始排查的那条消息」,把整段排查蒸馏成「Decisions: 确认是环境问题 / Rejected: 排除了代码 bug」,主线上下文回到排查之前的干净状态、缓存无损。
115+
116+
### 场景 2:回收发散讨论
117+
118+
与 agent 就某个设计反复权衡了很久,方向已清晰但上下文塞满了被否决的中间方案。`/digest` 选中讨论起点,把发散过程折叠成「已裁决 + 被排除及原因」,后续实现不再被反复权衡的噪音稀释。
119+
120+
### 场景 3:主动控形替代频繁 compact
121+
122+
长会话里养成习惯:每完成一段可隔离的探索就 `/digest` 收一次,让主线长期精简、前缀缓存长期热。相比被动等触顶 compact(全量缓存重建 + 丢主线细节),这是「主动控形」而非「被动救命」——只在主线本身线性膨胀到逼近上限时才回落到 `/compact` 兜底。
123+
124+
## 演进方向
125+
126+
-`/push --list` 打通:允许 `/digest` 直接选一个已有 push 标记作为起点。
127+
- digest 模板自适应:按探索类型(排查型 / 方案裁决型 / 开放发散型)切换蒸馏侧重。
128+
- 事后多段回收:一次 `/digest` 圈定多段不连续岔路分别蒸馏。

src/Tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ export type ToolUseContext = {
243243
setStreamMode?: (mode: SpinnerMode) => void
244244
onCompactProgress?: (event: CompactProgressEvent) => void
245245
setSDKStatus?: (status: SDKStatus) => void
246-
openMessageSelector?: () => void
246+
openMessageSelector?: (mode?: 'rewind' | 'digest') => void
247247
/** Push/pop context stack (docs/features/push-pop-context-stack.md). Only
248248
* wired in interactive REPL contexts; the heavy apply logic lives there. */
249249
pushContextMark?: (note: string) => void

src/commands.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ const popCmd = feature('PUSH_POP')
178178
require('./commands/pop/index.js') as typeof import('./commands/pop/index.js')
179179
).default
180180
: null
181+
const digestCmd = feature('PUSH_POP')
182+
? (
183+
require('./commands/digest/index.js') as typeof import('./commands/digest/index.js')
184+
).default
185+
: null
181186
/* eslint-enable @typescript-eslint/no-require-imports */
182187
import thinkback from './commands/thinkback/index.js'
183188
import thinkbackPlay from './commands/thinkback-play/index.js'
@@ -384,6 +389,7 @@ const COMMANDS = memoize((): Command[] => [
384389
...(goalCmd ? [goalCmd] : []),
385390
...(pushCmd ? [pushCmd] : []),
386391
...(popCmd ? [popCmd] : []),
392+
...(digestCmd ? [digestCmd] : []),
387393
...(proactive ? [proactive] : []),
388394
...(monitorCmd ? [monitorCmd] : []),
389395
...(coordinatorCmd ? [coordinatorCmd] : []),

src/commands/digest/digest.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { LocalCommandResult } from '../../commands.js'
2+
import type { ToolUseContext } from '../../Tool.js'
3+
4+
export async function call(
5+
_args: string,
6+
context: ToolUseContext,
7+
): Promise<LocalCommandResult> {
8+
if (!context.openMessageSelector) {
9+
return {
10+
type: 'text',
11+
value: '/digest is only available in an interactive session.',
12+
}
13+
}
14+
context.openMessageSelector('digest')
15+
// Return a skip message to not append any messages.
16+
return { type: 'skip' }
17+
}

src/commands/digest/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Command } from '../../commands.js'
2+
3+
const digest = {
4+
description:
5+
'Distill everything from a selected message to the end into a digest (retroactive /push+/pop)',
6+
name: 'digest',
7+
argumentHint: '',
8+
type: 'local',
9+
supportsNonInteractive: false,
10+
load: () => import('./digest.js'),
11+
} satisfies Command
12+
13+
export default digest

src/components/MessageSelector.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ type Props = {
6666
onClose: () => void;
6767
/** Skip pick-list, land on confirm. Caller ran skip-check first. Esc closes fully (no back-to-list). */
6868
preselectedMessage?: UserMessage;
69+
/** 'digest' collapses options to a single "distill from here" action (retroactive /push+/pop). */
70+
mode?: 'rewind' | 'digest';
6971
};
7072

7173
const MAX_VISIBLE_MESSAGES = 7;
@@ -78,6 +80,7 @@ export function MessageSelector({
7880
onSummarize,
7981
onClose,
8082
preselectedMessage,
83+
mode = 'rewind',
8184
}: Props): React.ReactNode {
8285
const fileHistory = useAppState(s => s.fileHistory);
8386
const [error, setError] = useState<string | undefined>(undefined);
@@ -147,6 +150,17 @@ export function MessageSelector({
147150
showLabelWithValue: true,
148151
labelValueSeparator: ': ',
149152
};
153+
if (mode === 'digest') {
154+
return [
155+
{
156+
value: 'summarize',
157+
label: 'Distill from here into a digest',
158+
...summarizeInputProps,
159+
onChange: setSummarizeFromFeedback,
160+
},
161+
{ value: 'nevermind', label: 'Never mind' },
162+
];
163+
}
150164
baseOptions.push({
151165
value: 'summarize',
152166
label: 'Summarize from here',
@@ -386,7 +400,7 @@ export function MessageSelector({
386400
<Divider color="suggestion" />
387401
<Box flexDirection="column" marginX={1} gap={1}>
388402
<Text bold color="suggestion">
389-
Rewind
403+
{mode === 'digest' ? 'Digest' : 'Rewind'}
390404
</Text>
391405

392406
{error && (
@@ -402,8 +416,14 @@ export function MessageSelector({
402416
{!error && messageToRestore && hasMessagesToSelect && (
403417
<>
404418
<Text>
405-
Confirm you want to restore {!diffStatsForRestore && 'the conversation '}to the point before you sent this
406-
message:
419+
{mode === 'digest' ? (
420+
<>Distill everything from this message to the end into a digest:</>
421+
) : (
422+
<>
423+
Confirm you want to restore {!diffStatsForRestore && 'the conversation '}to the point before you sent
424+
this message:
425+
</>
426+
)}
407427
</Text>
408428
<Box
409429
flexDirection="column"
@@ -449,7 +469,9 @@ export function MessageSelector({
449469
)}
450470
{showPickList && (
451471
<>
452-
{isFileHistoryEnabled ? (
472+
{mode === 'digest' ? (
473+
<Text>Distill everything from this message to the end into a digest…</Text>
474+
) : isFileHistoryEnabled ? (
453475
<Text>Restore the code and/or conversation to the point before…</Text>
454476
) : (
455477
<Text>Restore and fork the conversation to the point before…</Text>

0 commit comments

Comments
 (0)