diff --git a/README.md b/README.md index 7334434a2f..c5b054fd67 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ | **🎯 Goal 持续驱动** | `/goal ` 设定目标后,自动跨轮驱动 agent 直至完成;带 token budget、completion/blocked audit、`pause`/`resume`/`continue`/`clear` 子命令,网络中断自动暂停 | 源码 [`commands/goal/`](./src/commands/goal/) · [`services/goal/`](./src/services/goal/) | | **📦 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) | | **🧠 Ultracode 多 Agent 编排** | `/ultracode` 注入 workflow 编排手册 + `Workflow` 工具跑确定性 JS 脚本(`agent`/`pipeline`/`parallel`/`phase`)+ `/workflows` 双栏监控面板;支持 journal 重放、token budget、并发 cap | [文档](https://ccb.agent-aura.top/docs/features/workflow-scripts) | +| **🌿 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/) | +| **🍃 /digest 回溯蒸馏** | 与 `/compact` 互补的上下文优化:给只能顺序处理的 agent 补上 DFS 式回溯——`/digest` 像 `/rewind` 一样弹选择器,圈定一段失败调试/报错/发散岔路,蒸馏成四栏结论、主线**前缀无损缓存友好**(compact 是全局打包重建缓存);与 `/pop` 共享同一底层蒸馏原语(平级入口、非命令互调),`/digest` 支持事后选点;蒸馏本身要花一次压缩调用,但因前缀缓存热、成本远低于 compact 的全量重建(`FEATURE_PUSH_POP=1`) | [设计文档](./docs/features/digest.md) · 源码 [`commands/digest/`](./src/commands/digest/) | | **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) | | **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) | | **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) | diff --git a/docs/features/digest.md b/docs/features/digest.md new file mode 100644 index 0000000000..a6da75890f --- /dev/null +++ b/docs/features/digest.md @@ -0,0 +1,128 @@ +# /digest — 上下文回溯与蒸馏(DFS 式上下文优化) + +## 概述 + +`/digest` 是一种**上下文优化机制**,与 `/compact` 互补。它不是「push/pop 的附属」,也不是 push/pop 内部调用的命令——`/digest` 与 `/pop` 是**平级入口**,各自独立调用同一个底层蒸馏原语(局部上下文压缩 `partialCompactConversation` + 四栏 `DIGEST_PROMPT` 模板)。区别只在**由谁圈定蒸馏范围**:`/pop` 用栈标记(marker+1),`/digest` 用消息选择器手选。 + +Agent 天然只能**顺序处理**:一步步往下跑,一旦某段探索失败、报错、走进死胡同,这些日志与试错来回会**一直留在上下文里**稀释后续推理,且无法回收——线性历史只能追加、不能回溯。`/digest` 给这种线性历史补上**类似 DFS 的回溯能力**:把一段可隔离的探索岔路(通常是失败调试 / 报错排查 / 发散讨论)精确圈定,蒸馏成一份结构化结论,上下文回到岔路**之前**的干净状态,只多一份 digest。它是「手术刀」——精确、可控、缓存友好。 + +`/digest` 与 `/push`+`/pop` 是这套底层蒸馏原语的两种入口,区别只在**由谁、何时圈定蒸馏范围**: + +- **`/push` + `/pop`**:预先划界。讨论开始**前** `/push` 打标记,结束后 `/pop` 弹栈蒸馏。要求用户能提前预判「接下来要岔出去了」。 +- **`/digest`**:事后选点。不需要任何提前动作,事后像 `/rewind` 一样弹消息选择器,回溯选中一条较早的消息,把它到对话底部整段蒸馏。解决「污染往往事后才发现、来不及 push」的现实问题。 + +## 动机:给顺序 agent 补上回溯 + +把 agent 与用户的协作看作一棵搜索树:每个节点是一次上下文状态,展开是深入探索,回溯是收敛结论。普通对话的上下文只能**追加**、无法**回溯**——一次失败的调试、一段跑偏的排查,会永久留在上下文里,持续消耗 token 并稀释后续推理,agent 只能带着这些噪音继续往下走。 + +`/digest` 为这棵树补上「回溯」:圈定那段失败探索,只让**蒸馏结论**穿过节点边界,展开过程中的中间态(大日志、死胡同、被推翻的假设)被丢弃。这正是 DFS 里「子树探索完只向父节点回传结果」的语义。 + +## digest vs compact + +两者都做上下文瘦身,但解决的膨胀类型、控制粒度、缓存影响完全不同。一句话:**compact 是「全局打包整段历史来续命」,digest 是「把一段你圈定的探索岔路沉淀成结论、主线无损」。** + +| 维度 | `/compact` | `/digest` | +|------|-----------|-----------| +| **作用范围** | 整个会话——把到目前为止的全部历史总结成一份摘要,替换原始来回 | 只针对你显式圈定的那段岔路,主线其余部分完全不动 | +| **触发/控制** | 常被动触发(触顶自动 compact)或一键;边界是「到现在为止全部」,不精确控制压什么 | 完全主动、结构化——你精确决定哪一段被收敛 | +| **保留什么** | 主线原始细节大多丢失,只剩一份总摘要 | 圈定点之前的主线**逐字保留**,只有那段岔路被换成结论 | +| **意图** | 省空间、避免触顶——「不得已的续命」 | 主动沉淀结论、丢掉过程噪音——「整理」而非「救急」 | +| **前缀缓存** | 几乎重写整个上下文 → 前缀缓存**全部失效**,compact 后第一轮基本全量重建,较贵 | 主线前缀**无损**,只有尾部那段换成小 digest → 缓存损失极小 | + +## digest 能否替代 compact + +对懂上下文管理的开发者,digest **能大幅降低对 compact 的依赖**——把它从「常规操作」降级为「兜底手段」,但不能完全替代。 + +**digest 替得漂亮的场景**:凡是可隔离的探索/调试岔路(一堆试错、读大日志、跑命令,最后只想留一个结论)——这类天然有边界,圈定即可蒸馏,主线前缀无损、缓存友好、保真度高。养成「探索前先划界、或事后 `/digest` 回收」的习惯,大量本会把主线撑到触发 compact 的膨胀,会被提前在岔路里消化掉。 + +**compact 仍不可少的场景**: + +1. **主线自身的线性膨胀**:上下文变大不是因为岔路,而是主线本身活儿多——顺着一条线读了几十个文件、跑了很多步,全是要保留的主线。这没有可划的边界,只有 compact 能压主线本身。 +2. **触顶救急**:逼近窗口硬上限时往往正处在一长串主线操作中间,来不及优雅划界。compact 是兜底救命,digest 不解决「主线超长」。 + +**成本形态不同**:digest 的代价是**纪律/认知负担**(要持续预判哪些是岔路、记得划界,`/digest` 的事后选点缓解了这点但仍需判断选哪条消息);compact 的代价是**缓存重建 + 丢细节**,但零纪律(自动或一键)。 + +**建议的组合策略**(不是二选一):主力用 digest 主动隔离每段探索,让主线长期精简、缓存友好,把 compact 触发频率压下去;兜底在主线确实线性长到逼近上限时,再用 compact 做安全网。 + +> ⚠️ 两者都不是无损。digest 的价值高度依赖蒸馏摘要写得准;若 digest 丢了后续主线要用的关键细节,仍得回 transcript 翻原文(`ctrl+o` 查看历史里被折叠的原始尾部)。 + +## 用户体验 + +``` +/digest 弹出消息选择器 → 选一条较早的 user 消息 M + → "Distill from here into a digest"(可附加 context) + → 从 M 到对话底部被四栏 digest 替换 +``` + +- 选择器复用 `/rewind` 的消息列表 UI,但 digest 模式下选项收敛为两个:**Distill from here into a digest**(可选填一段附加 context 影响蒸馏侧重)与 **Never mind**;标题、拾取提示、确认文案切换为 digest 语义。 +- 蒸馏进行时输入框旁显示 spinner「↓ Distilling from selected message」。 +- 完成后弹出通知:`↓ Distilled from selected message (ctrl+o for history) · Context: ~X → ~Y tokens`,X/Y 为蒸馏前后的上下文规模。 + +### 与 /rewind 的差异 + +`/digest` 借用 `/rewind` 的消息选择器 UI,但语义相反:`/rewind` 是**丢弃**选中点之后的内容(回到过去、可选恢复文件),`/digest` 是**蒸馏保留**选中点之后的结论(折叠而非丢弃、不动文件、不 resubmit)。digest 模式通过选择器的 `mode` 参数与 rewind 隔离,rewind 的完整 restore 选项不受影响。 + +## 核心机制 + +`/digest` 的落地主体复用局部上下文压缩 `applyPartialCompactByUuid`(方向 `from`)——把选中消息之后的段落原地蒸馏替换。这与 `/pop` 走的是同一条压缩路径、同一份 digest 模板,差异只在「选点来源」(命令弹选择器手选 vs 弹栈顶): + +``` +applyPartialCompactByUuid({ + pivotUuid: M.uuid, + feedback: 用户输入 ?? DIGEST_TEMPLATE, + direction: 'from', + options: { promptOverride: DIGEST_PROMPT, summaryFraming: 'digest' }, + // 注意:无 resubmit —— 蒸馏后不重新触发 agent,只折叠上下文 +}) +``` + +digest 用固定的四栏结构,让蒸馏物对后续对话可直接消费: + +| 栏目 | 内容 | +|------|------| +| Decisions | 探索确定了什么 + 关键理由 | +| Rejected | 过程中否决了什么 + 否决原因(防止重蹈覆辙) | +| Open questions | 未收敛的点 | +| Action items | 对主线任务的具体影响 / 待办 | + +## token / 缓存成本 + +`/digest` **会花 token**——蒸馏是一次真实的 LLM 调用,不是免费的。但它的成本结构相对 `/compact` 明显更省,这正是 digest 的核心优势: + +- **蒸馏调用的固有成本**:走一次局部压缩 fork(`partialCompactConversation`),读入被蒸馏的那段上下文 + 输出四栏 digest。这是「把一段上下文蒸馏成结论」躲不掉的成本——探索内容接着聊本来也要为它付 token。与 `/pop` 走的是**同一次**压缩调用,digest 相对 pop 不额外多花。 +- **相对 compact 更省**:因主线前缀**无损**,蒸馏刚结束时前缀缓存必热,单次成本约等于一次 cache-read 加几百 token 输出;而 compact 几乎重写整个上下文、前缀缓存全失效、下一轮全量重建。所以「省」是相对 compact 而言,不是绝对零成本。 +- **唯一真正零成本的部分**:完成通知里的 `Context: ~X → ~Y tokens` 复用压缩过程**已经算好**的 `preCompactTokenCount`(压缩时计算)与 `truePostCompactTokenCount`(本地消息负载估算),**不发起任何新的 token_count 请求**,纯显示层。 + +## 架构 + +| 模块 | 路径 | 职责 | +|------|------|------| +| digest 命令 | `src/commands/digest/` | 命令定义 + 调 `openMessageSelector('digest')` | +| 消息选择器 | `src/components/MessageSelector.tsx` | `mode` 参数区分 rewind / digest;digest 模式收敛选项与文案 | +| 应用层 | `src/screens/REPL.tsx` | `messageSelectorMode` state/ref + `onSummarize` 的 digest 分支(无 resubmit + spinner + token 通知) | +| digest 模板 | `src/services/pushStack/digestPrompt.ts` | 四栏 `DIGEST_PROMPT` + `DIGEST_TEMPLATE`(push/pop 与 digest 共用) | +| 局部压缩 | `src/services/compact/compact.ts` | 复用主体;`promptOverride` + `summaryFraming: 'digest'` 参数 | + +`ToolUseContext.openMessageSelector` 的签名从 `() => void` 扩展为 `(mode?: 'rewind' \| 'digest') => void`(向后兼容,rewind 不传参)。特性由 feature flag `PUSH_POP` 控制(`FEATURE_PUSH_POP=1`)。 + +**代码血缘**:四栏 `DIGEST_PROMPT` 与局部压缩的这套复用**最初是为 `/pop` 引入的**(见 `digestPrompt.ts` 的注释「Digest prompt for `/pop`」),`/digest` 命令后加、复用同一底座。`/pop` 的落地在 `REPL.tsx` 的 `applyPop`(pivot = 栈标记 marker+1),`/digest` 的落地在 `MessageSelector` → `onSummarize` 的 digest 分支(pivot = 手选消息)——两者各自调用同一个 `applyPartialCompactByUuid`,**谁都不调用谁的命令**。所以「共享」是平级复用同一底层原语,不是命令互调,也不存在从属关系。 + +## 使用场景 + +### 场景 1:事后隔离一段失败调试 + +主线跑测试冒出 SIGSEGV,没来得及 `/push` 就一头扎进排查——读了大量日志、试了几个假设、改了些实验代码,二十轮后确认是环境问题。`/digest` 选中「开始排查的那条消息」,把整段排查蒸馏成「Decisions: 确认是环境问题 / Rejected: 排除了代码 bug」,主线上下文回到排查之前的干净状态、缓存无损。 + +### 场景 2:回收发散讨论 + +与 agent 就某个设计反复权衡了很久,方向已清晰但上下文塞满了被否决的中间方案。`/digest` 选中讨论起点,把发散过程折叠成「已裁决 + 被排除及原因」,后续实现不再被反复权衡的噪音稀释。 + +### 场景 3:主动控形替代频繁 compact + +长会话里养成习惯:每完成一段可隔离的探索就 `/digest` 收一次,让主线长期精简、前缀缓存长期热。相比被动等触顶 compact(全量缓存重建 + 丢主线细节),这是「主动控形」而非「被动救命」——只在主线本身线性膨胀到逼近上限时才回落到 `/compact` 兜底。 + +## 演进方向 + +- 与 `/push --list` 打通:允许 `/digest` 直接选一个已有 push 标记作为起点。 +- digest 模板自适应:按探索类型(排查型 / 方案裁决型 / 开放发散型)切换蒸馏侧重。 +- 事后多段回收:一次 `/digest` 圈定多段不连续岔路分别蒸馏。 diff --git a/docs/features/push-pop-context-stack.md b/docs/features/push-pop-context-stack.md new file mode 100644 index 0000000000..6f057abc79 --- /dev/null +++ b/docs/features/push-pop-context-stack.md @@ -0,0 +1,190 @@ +# Push/Pop 上下文栈 — 讨论分支 + +## 概述 + +Push/Pop 上下文栈是一个面向用户的对话分支机制。它允许用户在一条主线任务的中途「压栈」出一个继承完整上下文的讨论旁支,自由发散地讨论,讨论结束后「弹栈」回到主线——讨论段从上下文中卷掉,只留下一份结构化的蒸馏 digest,主线不被讨论过程的噪音污染。 + +这一机制源自个人对Agent与人协作实际实践中的理解以及需求。个人感觉Agent工作时更加像一个搜索树结构:把 agent 与用户的协作看作一棵搜索树,每个节点是一次上下文状态,展开是深入探索,回溯是收敛结论。普通对话的上下文只能追加、无法回溯,导致一次跑偏的讨论会永久留在上下文里稀释后续推理。Push/Pop 为这棵树补上「回溯」能力:**弹栈时只有蒸馏物穿过节点边界**,展开过程中的中间态被丢弃,从而可以支持多分叉的讨论以及尝试。 + +它是一个纯增量、完全 opt-in 的独立特性:不 push 时零代码路径被激活,对现有 agent 行为无任何影响;与多 agent 编排的路由逻辑零耦合,可独立开发上线。 + +## 设计定位 + +### 目标 + +- `/push`:在当前对话打一个栈标记,之后的对话即「讨论分支」——继承此刻全部上下文,无需任何复制(就是当前会话继续往下聊)。 +- `/pop`:结束讨论分支——生成结构化 digest → 截断对话回到 push 点 → digest 注入主线。可选回滚讨论期间的文件改动。 +- 支持嵌套(栈深有上限)。 +- 栈感知地管理上下文:push 后即使忘了 pop 也能无限迭代下去,自动压缩触发时以栈感知方式保住当前分支,绝不静默丢失栈标记。 +- 跨会话持久化:栈随 transcript 持久化,`--resume` 恢复会话后仍能 `/pop`;恢复时每个标记重新投影到当前消息序列,锚点已被压缩 / 裁剪卷走的标记自动丢弃并透明通知,绝不静默丢栈。 + +### 非目标 + +- **真分支**(fork 独立会话 + 多会话栈切换 + 并行多分支):本方案采用「就地回卷」路线,把真分支留给未来服务 beam search 类场景。 +- **Agent 自主发起 push**(模型在不确定点自发起试错分支):属多 agent 编排范畴,另行设计。 + +### 两条路线的取舍 + +| 路线 | 做法 | 取舍 | +|------|------|------| +| A 真分支 | fork 出独立会话,主线冻结,分支并行 | 隔离彻底、可并行,但需要多会话栈切换、共享前缀 cache 保活等重型机制 | +| **B 就地回卷**(本方案) | 不 fork,就在当前会话打标记;pop 时把标记之后的段落原地蒸馏替换 | 零复制、零新会话,复用现有上下文压缩机制;代价是分支不能并行、pop 有一次前缀重写成本 | + +选择路线 B 的核心理由:它把「讨论分支」归约为「一次带自定义模板的局部上下文压缩」,绝大部分复杂度都由既有的压缩机制承担,特性表面积极小。 + +## 用户体验 + +``` +/push [备注] 压栈,进入讨论分支。输入框旁常驻栈深指示 +/push --list 列出栈内所有 push 点(编号 · 备注 · 预览 · 时间 · 深度)。纯本地读、零 API 成本 +/pop 弹栈顶:生成 digest → 展示给用户确认/编辑 → 应用(截断 + 注入) +/pop --to #N 跨层弹出:把 #N 之后的全部讨论段一次性蒸馏成单个 digest,回到 #N 标记点 +/pop --discard 纯丢弃:不生成 digest,直接截断回 push 点 +/pop --keep-code 截断对话但不回滚文件改动 +``` + +- pop 生成 digest 后先展示给用户确认(应用 / 编辑后应用 / 改为丢弃),再落地;最简形态可直接应用并提示查看历史。 +- 空栈时 `/pop` 报错提示;栈非空时退出会话前警告。 +- 命名约定:栈查看用 flag(`--list`)而非子命令 `/push list`——`/push` 的位置参数是自由备注文本,`list` 会被当成备注吞掉。 + +## 核心机制:就地回卷 + +Pop 的主体复用现有的**局部上下文压缩**(`partialCompactConversation`,方向 `from`)——即消息选择器里已有的「从某条消息之后重新总结」能力的程序化调用。该机制已经处理好了全部脏细节:压缩边界投影、被裁剪消息、边界标记、附件与 hook 结果保留、全屏与普通模式的回滚差异、会话 ID 重置、压缩后清理。 + +Pop 与通用「从此处总结」的差异只有三点,全部可参数化: + +1. **入口**:命令触发 vs 选择器 UI 触发。 +2. **总结指令**:四栏 digest 模板 vs 通用摘要。 +3. **确认交互**:digest 预览确认 vs 直接恢复。 + +### push 的语义 + +`push` 只做一件事:把「当前最后一条消息」的标识压入栈,连同备注、时间戳和一份预览快照。文件历史本就按消息粒度自动快照,无需额外动作。栈标记锚定在「当前最后一条消息」——它属于主线、pop 时必须保留;预览快照则在 push 那一刻一次性截取「用户最近一条指令」的文本片段,供 `/push --list` 显示这条分支「在讨论什么」。预览取用户指令而非最后一条原始消息,是因为最后一条常是无文本的纯工具调用回合,或 `No response requested.` 之类的合成占位消息,直接快照对列表毫无意义。快照在 push 时一次性截取,列表渲染时无需遍历回查,也不受后续压缩影响。 + +### pop 的语义 + +``` +1. 定位:在压缩边界投影后的消息序列里找到标记位置 markerIndex + pivot = markerIndex + 1 + —— 关键:标记记录的是「push 时最后一条消息」,它属于主线、必须保留, + 所以蒸馏区从它之后一条开始。若把标记本身算进蒸馏区,会把 push 前 + 刚得出的主线结论也吞进 digest。 +2. 校验:栈非空;标记仍在活跃上下文内(未被压缩卷走); + 若 push 后无新消息 → 走空栈快路径,直接弹栈不生成 digest。 +3. 生成 digest:以四栏模板局部压缩 pivot 之后的段落。 +4. 用户确认。 +5. 应用:保留前段 + 边界标记 + digest + 附件,与现有恢复逻辑一致。 +6. 文件处理:若讨论段有文件改动,询问是否回滚。 +7. 弹栈。 +``` + +### digest 模板 + +digest 用固定的四栏结构,让蒸馏物对主线可直接消费: + +| 栏目 | 内容 | +|------|------| +| 已裁决结论 | 讨论确定了什么 + 关键理由 | +| 被排除的方案 | 讨论中否决了什么 + 否决原因(防止主线重蹈覆辙) | +| 未决问题 | 讨论未收敛的点 | +| 主线行动项 | 对主线任务的具体影响 / 待办 | + +### cache 成本 + +Pop 后主线前缀 = push 点之前的部分 + 新 digest,下一次请求会有一次前缀重写——这是就地回卷的固有成本,可接受(讨论段接着聊本来也要为它付 token)。digest 生成本身走压缩 fork,讨论刚结束时 cache 必热,单次成本约等于一次 cache-read 加几百 token 输出。 + +## 设计决策 + +### 为什么复用局部压缩而非自建截断 + 注入 + +局部压缩已处理了压缩边界投影、被裁剪消息、边界标记、附件保留、全屏与普通模式的回滚差异、会话 ID 重置、压缩后清理等全部脏细节。自建等于重写这些。把 pop 归约为「一次带自定义模板和包装话术的局部压缩」,只需给压缩机制加一个可选参数包,就能承载 digest 的全部差异,不改任何现有调用方。 + +### 自动压缩必须栈感知而非被抑制 + +讨论期间若触发自动压缩,全量压缩会把 push 标记连同它记录的消息一起卷到压缩边界之前,pop 时校验失败、标记失效。 + +一个自然的想法是「push 期间抑制自动压缩」,但这会破坏 push 的零风险定位:用户 push 后**忘了 pop**、一路聊到撞窗口时,会被兜底压缩静默抹掉标记和上下文管理能力。因此正确策略是**不抑制自动压缩,而是让它栈感知 + 透明**——分三档: + +1. **交互式触发**(用户在场):弹确认,让用户在「压到最近 push 点」(默认)、「压到指定 push 点」、「全量压缩」之间选择。默认压到最近(栈顶)push 点——释放空间最多,且保住用户此刻正在进行的分支。若栈有多层,如实提醒此举会移除更老的 push 点;全量压缩则明确提醒会移除全部 push 点。 +2. **非交互触发**(无 UI 通道的自主循环):不弹窗,默认压到最近 push 点,事后用通知告知移除了哪些更老的 push 点。 +3. **兜底触发**(撞上下文上限、在异常处理里):同样默认压到最近 push 点;仅当压无可压时才退化为全量并通知标记已失效、建议手动回退。 + +**为什么默认「最近」而非「最老」**:自动压缩是救急,压到栈顶能释放最多空间、并保住用户此刻正在进行的分支,救急有效性最高。代价是嵌套时牺牲更老的 push 点,故交互档如实提醒、并留「压到指定 push 点」给想保留更多层的用户。单层 push(最常见场景)下「最近 == 唯一」,无取舍、无损。 + +**方向易混点**:pop 走 `from` 方向、保留标记**之前**(pivot = 标记 + 1);自动压缩走 `up_to` 方向、保留标记**及之后**(pivot = 标记本身)。两者方向相反。被牺牲的更老 push 点须从栈中同步弹出。 + +### 文件改动默认询问 + +pop 时若讨论段有文件改动,默认询问用户回滚还是保留,不静默二选一。理由:讨论分支里「顺手改个实验代码」和「讨论出结论顺便落盘」都是真实场景,方向不可预设。`--keep-code` / `--discard` 作为快捷路径。 + +### digest 的注入形式与包装话术 + +digest 以压缩机制既有的 summary 消息形式留在对话流中——它本质是一条带「压缩摘要」标记的用户角色消息,这是压缩机制的既定形态。 + +真正需要处理的是**包装话术**。压缩机制默认把摘要包进「本会话因上下文耗尽而从上一段对话继续」的话术,这是为「上下文耗尽的全量压缩」设计的。套在「讨论旁支收尾回主线」上会误导模型,让它以为整个会话被截断重启。因此 pop 需要一个 `digest` 语义的包装选项,改用「以下是刚结束的讨论旁支的蒸馏结论,主线在此基础上继续」这类话术;默认包装保持不变。 + +### 为什么四栏模板需要替换而非追加 + +压缩机制的「附加指令」参数只会被拼接到基础模板之后,而基础模板仍强制模型输出固定的 XML 结构。若四栏 digest 模板只走附加指令,就只是「建议」,压不住基础结构,输出格式不可控。因此需要给压缩机制加一个**替换基础模板**的可选参数,让 digest 模板完全接管输出结构。这个替换选项与上面的包装选项合并成同一个可选参数包,一次性加到压缩机制签名上——这是本特性唯一需要改动压缩机制的地方,纯增量,不传参数即维持原行为。 + +### 跨层 pop 与栈查看 + +**跨层 pop(`/pop --to #N`)**:栈有多层时,允许一次回到指定层 `#N`,而非只能逐层弹栈顶。语义是把 `#N` 之后的全部讨论段(含中间各层)一次性蒸馏成**单个** digest,回到 `#N` 标记处,栈上 `#N` 及以上所有层一并弹出。相比逐层 pop 的 N 次蒸馏 fork,跨层 pop 只花一次 fork——对 token 敏感场景是明显收益。代价是丢失分层结构(中间各层的结论混进一份 digest),故默认走一次性,逐层保留可作为演进变体。 + +**栈查看(`/push --list`)**:纯读栈状态,列出全部标记(编号 · 备注 · 预览 · 相对时间 · 深度),零 API token,不触发任何 fork。每行的预览取 push 时快照的用户指令片段(代表这条分支「在讨论什么」),是已有对话原文的截断,和会话恢复列表同理。快照在 push 时截取而非 list 时现算,是为了在讨论期自动压缩把原文卷走后,列表仍能显示预览。 + +### 嵌套语义 + +栈深有上限(超出时 `/push` 报错)。每层 pop 只卷掉栈顶标记之后的段落,digest 注入后归属于上一层分支(或主线)。嵌套 pop 的 digest 会被外层 pop 再次蒸馏——符合「每层只向上一层回传结论」的搜索树语义,无需特殊处理。 + +**token 成本**:每次 pop(除空栈快路径外)都会触发一次压缩 fork。嵌套 N 层逐层 pop = N 次 fork,且外层会把内层已注入的 digest 再喂进去二次总结。这是就地回卷蒸馏语义的固有成本,非缺陷,但深嵌套或频繁 push/pop 时是实打实的额外 token,应在帮助文案中说明「pop 会花一次总结调用」,让用户可预期。跨层 pop 正是为压低这一成本而设。 + +### 跨会话持久化 + +栈随 transcript 以 JSONL entry 落盘,镜像现有 worktree / goal 会话元数据的持久化管线。整个栈是一条 **last-wins** 的 `push-stack` entry:每次栈变化就整栈重写一条,空数组即表示栈已清空,无需单独的 tombstone 机制。写入沿用 `reAppendSessionMetadata` 的尾窗补写,保证栈状态不会被会话末尾的压缩尾窗吞掉。 + +`--resume` 恢复会话时,把每个持久化标记重新投影回当前消息序列(`projectPushStackOntoMessages`):只有锚点消息仍在活跃上下文内(未被压缩或 history-snip 卷走)的标记才保留——这与 `/pop` 的校验判据完全一致,所以恢复出的标记绝不会指向一条已不存在的消息。被丢弃的标记会计数并透明通知用户,绝不静默丢栈。这样 push 后即便中途保存退出、`--resume` 回来,残存的分支仍能正常 `/pop`。 + +## 架构 + +### 核心模块 + +| 模块 | 路径 | 职责 | +|------|------|------| +| push 命令 | `src/commands/push/` | 命令定义 + 压栈 + `--list` 栈查看渲染 | +| pop 命令 | `src/commands/pop/` | 命令定义 + pop 流程编排 + `--to` / `--discard` / `--keep-code` | +| 栈状态镜像 | `src/services/pushStack/` | 栈状态的模块级镜像 + digest 四栏模板,供压缩逻辑在 React 树外读取 | +| digest 包装 | `src/components/pushStack/` | 自动压缩栈感知策略的确认对话框 | +| 局部压缩 | `src/services/compact/compact.ts` | 复用主体;新增可选参数包(替换模板 + 包装话术) | +| 压缩话术 | `src/services/compact/prompt.ts` | digest 语义的包装文案 | +| 自动压缩 | `src/services/compact/autoCompact.ts` | 栈感知三档策略 | +| 应用层 | `src/screens/REPL.tsx` | 从恢复逻辑抽出可复用的落地函数供 pop 调用 + 栈深指示 UI + resume 投影恢复 | +| 全局状态 | `src/state/AppState.tsx` | 栈状态 `pushStack`(UI 数据源) | +| 跨会话持久化 | `src/utils/sessionStorage.ts` · `src/types/logs.ts` | 栈随 transcript 落盘(last-wins 单条 `push-stack` entry);resume 时 `projectPushStackOntoMessages` 重新投影 | + +栈状态采用双源:一份在应用状态里供 UI 渲染,一份写穿到模块级镜像供自动压缩逻辑在 React 树外同步读取。特性由 feature flag `PUSH_POP` 控制。 + +## 使用场景 + +### 场景 1:方案裁决旁支 + +主线正在实现某功能,中途对某个技术选型拿不定主意。`/push 讨论选型` 开一个旁支,把几个方案的利弊、边界都展开讨论清楚。`/pop` 后主线只收到一份「已裁决结论 + 被排除的方案及原因」的 digest,之前反复权衡的过程被卷掉,主线继续实现。 + +### 场景 2:临时排查旁支 + +主线跑测试时冒出一个可疑报错。`/push 查这个报错` 深入排查,读日志、试假设、改实验代码。若排查确认是环境问题、与主线无关,`/pop --discard --keep-code`(或回滚实验改动)干净返回,主线上下文不被排查噪音污染。 + +### 场景 3:嵌套发散 + +讨论旁支中又岔出一个更小的子问题,`/push` 再压一层。子问题聊完 `/pop` 回到上层讨论,上层结束再 `/pop` 回主线。每层只把自己的结论向上一层回传。若中途发现子问题的结论已足够、不必逐层收敛,`/pop --to #1` 一次回到主线。 + +### 场景 4:忘了 pop + +用户 push 后一路聊下去忘了 pop,上下文逼近窗口上限触发自动压缩。系统栈感知地压到最近 push 点、保住当前分支,并透明告知移除了哪些更老的 push 点——用户的当前工作不受影响,无需记得手动管理栈。 + +## 演进方向 + +- **真分支(路线 A)**:fork 独立会话 + 主线冻结 + 并行多分支 + 分支请求保活共享前缀 cache,服务 beam search 类场景。 +- **Agent 自主 push**:模型在不确定点自发起试错分支,触发判据在多 agent 编排方案中另行设计。 +- **逐层跨层 pop**:`/pop --to #N --layered` 保留分层 digest(以 N 次 fork 换分层结构)。 +- **digest 模板自适应**:按讨论类型(方案裁决型 / 排查型 / 开放发散型)切换模板。 diff --git a/scripts/defines.ts b/scripts/defines.ts index a21dfb3985..e39edd7754 100644 --- a/scripts/defines.ts +++ b/scripts/defines.ts @@ -98,4 +98,8 @@ export const DEFAULT_BUILD_FEATURES = [ // Persistent thread goal command — auto-continuation, JSONL persistence, // strict completion/blocked audit. See src/services/goal. 'GOAL', + // Push/Pop context stack — /push opens a discussion branch inheriting full + // context, /pop distills it to a digest and rewinds the mainline. Opt-in, + // zero code path when unused. See docs/features/push-pop-context-stack.md. + 'PUSH_POP', ] as const diff --git a/src/Tool.ts b/src/Tool.ts index caeac479e6..ab35e43675 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -10,6 +10,11 @@ import type { import type { UUID } from 'crypto' import type { z } from 'zod/v4' import type { Command } from './commands.js' +import type { + PopOptions, + CompactStrategyMarker, + CompactStrategyChoice, +} from './services/pushStack/state.js' import type { CanUseToolFn } from './hooks/useCanUseTool.js' import type { ThinkingConfig } from './utils/thinking.js' @@ -238,7 +243,22 @@ export type ToolUseContext = { setStreamMode?: (mode: SpinnerMode) => void onCompactProgress?: (event: CompactProgressEvent) => void setSDKStatus?: (status: SDKStatus) => void - openMessageSelector?: () => void + openMessageSelector?: (mode?: 'rewind' | 'digest') => void + /** Push/pop context stack (docs/features/push-pop-context-stack.md). Only + * wired in interactive REPL contexts; the heavy apply logic lives there. */ + pushContextMark?: (note: string) => void + applyPop?: (opts: PopOptions) => void + /** After a push-stack-aware auto-compact, retain only markers at or after + * the given marker id. Called by autoCompactIfNeeded to sync the REPL's + * pushStack when older markers are consumed by the compaction. */ + retainPushMarkersFrom?: (fromMarkerId: string) => void + /** Interactive three-way auto-compact strategy picker. When wired (REPL), + * the query loop awaits this before deciding full vs. partial compaction. + * Not wired in non-interactive / forked-agent contexts. */ + askCompactStrategy?: (opts: { + markers: ReadonlyArray + signal: AbortSignal + }) => Promise updateFileHistoryState: ( updater: (prev: FileHistoryState) => FileHistoryState, ) => void diff --git a/src/commands.ts b/src/commands.ts index bcfb6399f4..33ce01244a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -168,6 +168,21 @@ const goalCmd = feature('GOAL') require('./commands/goal/index.js') as typeof import('./commands/goal/index.js') ).default : null +const pushCmd = feature('PUSH_POP') + ? ( + require('./commands/push/index.js') as typeof import('./commands/push/index.js') + ).default + : null +const popCmd = feature('PUSH_POP') + ? ( + require('./commands/pop/index.js') as typeof import('./commands/pop/index.js') + ).default + : null +const digestCmd = feature('PUSH_POP') + ? ( + require('./commands/digest/index.js') as typeof import('./commands/digest/index.js') + ).default + : null /* eslint-enable @typescript-eslint/no-require-imports */ import thinkback from './commands/thinkback/index.js' import thinkbackPlay from './commands/thinkback-play/index.js' @@ -372,6 +387,9 @@ const COMMANDS = memoize((): Command[] => [ ...(buddy ? [buddy] : []), ...(poor ? [poor] : []), ...(goalCmd ? [goalCmd] : []), + ...(pushCmd ? [pushCmd] : []), + ...(popCmd ? [popCmd] : []), + ...(digestCmd ? [digestCmd] : []), ...(proactive ? [proactive] : []), ...(monitorCmd ? [monitorCmd] : []), ...(coordinatorCmd ? [coordinatorCmd] : []), diff --git a/src/commands/__tests__/pushpop.test.ts b/src/commands/__tests__/pushpop.test.ts new file mode 100644 index 0000000000..21da8f85d6 --- /dev/null +++ b/src/commands/__tests__/pushpop.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, mock, test, beforeAll } from 'bun:test' +import { logMock } from '../../../tests/mocks/log.js' +import { debugMock } from '../../../tests/mocks/debug.js' + +// Mock bun:bundle before anything that calls feature() +mock.module('bun:bundle', () => ({ feature: (_name: string) => false })) + +// Cut bootstrap/state.ts side-effect chain +mock.module('src/utils/log.ts', logMock) +mock.module('src/utils/debug.ts', debugMock) +mock.module('src/utils/config.ts', () => ({ + getGlobalConfig: () => ({}), + saveGlobalConfig: async () => {}, + getGlobalConfigWriteCount: () => 0, +})) +mock.module('src/utils/settings/settings.js', () => ({ + getCachedOrLoadSettings: async () => ({}), + getCachedSettings: () => ({}), +})) + +let parsePopArgs: ( + args: string, +) => import('../../services/pushStack/state.js').PopOptions | string + +beforeAll(async () => { + const popModule = await import('../pop/pop.js') + parsePopArgs = popModule.parsePopArgs +}) + +describe('parsePopArgs', () => { + test('empty args yields empty options', () => { + const result = parsePopArgs('') + expect(typeof result).toBe('object') + expect(result).toEqual({}) + }) + + test('--discard sets discard flag', () => { + const result = parsePopArgs('--discard') + expect(result).toEqual({ discard: true }) + }) + + test('--keep-code sets keepCode flag', () => { + const result = parsePopArgs('--keep-code') + expect(result).toEqual({ keepCode: true }) + }) + + test('--to #N parses marker number', () => { + const result = parsePopArgs('--to #2') + expect(result).toEqual({ to: 2 }) + }) + + test('--to N (without #) parses marker number', () => { + const result = parsePopArgs('--to 3') + expect(result).toEqual({ to: 3 }) + }) + + test('--to=N= syntax works', () => { + const result = parsePopArgs('--to=1') + expect(result).toEqual({ to: 1 }) + }) + + test('combined flags work', () => { + const result = parsePopArgs('--to #1 --keep-code') + expect(result).toEqual({ to: 1, keepCode: true }) + }) + + test('--to missing number returns error string', () => { + const result = parsePopArgs('--to') + expect(typeof result).toBe('string') + expect(result as string).toMatch(/Missing marker/) + }) + + test('--to 0 returns error (must be >= 1)', () => { + const result = parsePopArgs('--to 0') + expect(typeof result).toBe('string') + }) + + test('--to #abc returns error', () => { + const result = parsePopArgs('--to #abc') + expect(typeof result).toBe('string') + expect(result as string).toMatch(/Invalid marker/) + }) + + test('--to 2junk rejects trailing garbage (no partial parseInt)', () => { + const result = parsePopArgs('--to 2junk') + expect(typeof result).toBe('string') + expect(result as string).toMatch(/Invalid marker/) + }) + + test('--to=1.5 rejects non-integer', () => { + const result = parsePopArgs('--to=1.5') + expect(typeof result).toBe('string') + expect(result as string).toMatch(/Invalid marker/) + }) + + test('unknown flag returns error string', () => { + const result = parsePopArgs('--unknown') + expect(typeof result).toBe('string') + expect(result as string).toMatch(/Unknown option/) + }) +}) + +describe('push stack state', () => { + test('MAX_PUSH_DEPTH is 3', async () => { + const { MAX_PUSH_DEPTH } = await import('../../services/pushStack/state.js') + expect(MAX_PUSH_DEPTH).toBe(3) + }) + + test('getPushStackMirror starts empty', async () => { + const { getPushStackMirror } = await import( + '../../services/pushStack/state.js' + ) + const mirror = getPushStackMirror() + expect(Array.isArray(mirror)).toBe(true) + // May have stale content if other tests ran first — just verify it's readable + expect(mirror).toBeDefined() + }) + + test('setPushStackMirror round-trips', async () => { + const { getPushStackMirror, setPushStackMirror } = await import( + '../../services/pushStack/state.js' + ) + const marker = { + id: 'test-id', + messageUuid: + '00000000-0000-0000-0000-000000000001' as `${string}-${string}-${string}-${string}-${string}`, + note: 'test note', + timestamp: Date.now(), + anchorPreview: 'preview text', + } + setPushStackMirror([marker]) + const stack = getPushStackMirror() + expect(stack.length).toBe(1) + expect(stack[0]!.id).toBe('test-id') + expect(stack[0]!.note).toBe('test note') + // Restore + setPushStackMirror([]) + }) +}) + +describe('projectPushStackOntoMessages', () => { + const uuid = (n: number) => + `00000000-0000-0000-0000-${String(n).padStart(12, '0')}` as `${string}-${string}-${string}-${string}-${string}` + const marker = (id: string, n: number) => ({ + id, + messageUuid: uuid(n), + note: id, + timestamp: 0, + anchorPreview: '', + }) + const userMsg = (n: number) => + ({ + type: 'user', + uuid: uuid(n), + }) as unknown as import('../../types/message.js').Message + const boundary = (n: number) => + ({ + type: 'system', + subtype: 'compact_boundary', + uuid: uuid(n), + }) as unknown as import('../../types/message.js').Message + + test('keeps markers whose anchor is still in the active view', async () => { + const { projectPushStackOntoMessages } = await import( + '../../services/pushStack/state.js' + ) + const { validMarkers, droppedCount } = projectPushStackOntoMessages( + [marker('a', 1), marker('b', 2)], + [userMsg(1), userMsg(2)], + ) + expect(validMarkers.map(m => m.id)).toEqual(['a', 'b']) + expect(droppedCount).toBe(0) + }) + + test('drops markers carried off before a compact boundary and counts them', async () => { + const { projectPushStackOntoMessages } = await import( + '../../services/pushStack/state.js' + ) + // Boundary at index 1 → active view is [boundary, msg#3]; the pre-boundary + // anchor #1 is gone, mirroring what /pop would reject. + const { validMarkers, droppedCount } = projectPushStackOntoMessages( + [marker('old', 1), marker('live', 3)], + [userMsg(1), boundary(9), userMsg(3)], + ) + expect(validMarkers.map(m => m.id)).toEqual(['live']) + expect(droppedCount).toBe(1) + }) + + test('empty stack returns empty with zero dropped', async () => { + const { projectPushStackOntoMessages } = await import( + '../../services/pushStack/state.js' + ) + const { validMarkers, droppedCount } = projectPushStackOntoMessages( + [], + [userMsg(1)], + ) + expect(validMarkers).toEqual([]) + expect(droppedCount).toBe(0) + }) + + test('all markers invalid returns droppedCount = N', async () => { + const { projectPushStackOntoMessages } = await import( + '../../services/pushStack/state.js' + ) + const { validMarkers, droppedCount } = projectPushStackOntoMessages( + [marker('a', 1), marker('b', 2)], + [userMsg(5)], + ) + expect(validMarkers).toEqual([]) + expect(droppedCount).toBe(2) + }) +}) + +describe('DIGEST_PROMPT and DIGEST_TEMPLATE', () => { + test('DIGEST_PROMPT contains required four-section structure', async () => { + const { DIGEST_PROMPT } = await import( + '../../services/pushStack/digestPrompt.js' + ) + expect(DIGEST_PROMPT).toContain('[Decisions]') + expect(DIGEST_PROMPT).toContain('[Rejected]') + expect(DIGEST_PROMPT).toContain('[Open questions]') + expect(DIGEST_PROMPT).toContain('[Action items]') + expect(DIGEST_PROMPT).toContain('') + expect(DIGEST_PROMPT).toContain('') + }) + + test('DIGEST_TEMPLATE is a non-empty string', async () => { + const { DIGEST_TEMPLATE } = await import( + '../../services/pushStack/digestPrompt.js' + ) + expect(typeof DIGEST_TEMPLATE).toBe('string') + expect(DIGEST_TEMPLATE.length).toBeGreaterThan(0) + }) +}) + +describe('pop command integration', () => { + test('empty stack returns error text', async () => { + const { call } = await import('../pop/pop.js') + const ctx = { + getAppState: () => ({ pushStack: [] as unknown[] }), + applyPop: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /No push point to pop/, + ) + }) + + test('stack overflow --to N returns error text', async () => { + const { call } = await import('../pop/pop.js') + const ctx = { + getAppState: () => ({ + pushStack: [ + { + id: 'a', + messageUuid: '00000000-0000-0000-0000-000000000001', + note: '', + timestamp: 0, + anchorPreview: '', + }, + ], + }), + applyPop: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('--to #5', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /No push point #5/, + ) + }) + + test('interactive session with no applyPop returns error text', async () => { + const { call } = await import('../pop/pop.js') + const ctx = { + getAppState: () => ({ + pushStack: [ + { + id: 'a', + messageUuid: '00000000-0000-0000-0000-000000000001', + note: '', + timestamp: 0, + anchorPreview: '', + }, + ], + }), + applyPop: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /interactive session/, + ) + }) + + test('invalid args returns error text', async () => { + const { call } = await import('../pop/pop.js') + const ctx = { + getAppState: () => ({ pushStack: [] as unknown[] }), + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('--unknown-flag', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /Unknown option/, + ) + }) +}) + +describe('push command integration', () => { + test('--list on empty stack returns empty message', async () => { + const { call } = await import('../push/push.js') + const ctx = { + getAppState: () => ({ pushStack: [] }), + pushContextMark: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('--list', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /No active push points/, + ) + }) + + test('--list with items renders stack', async () => { + const { call } = await import('../push/push.js') + const now = Date.now() + const ctx = { + getAppState: () => ({ + pushStack: [ + { + id: 'a', + messageUuid: '00000000-0000-0000-0000-000000000001', + note: 'discuss API', + timestamp: now - 60000, + anchorPreview: 'let me think...', + }, + { + id: 'b', + messageUuid: '00000000-0000-0000-0000-000000000002', + note: '', + timestamp: now - 30000, + anchorPreview: 'another point', + }, + ], + }), + pushContextMark: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('--list', ctx) + expect(result.type).toBe('text') + const text = (result as { type: string; value: string }).value + expect(text).toContain('Push stack (2)') + expect(text).toContain('#1') + expect(text).toContain('#2') + expect(text).toContain('discuss API') + }) + + test('no pushContextMark returns not-interactive error', async () => { + const { call } = await import('../push/push.js') + const ctx = { + getAppState: () => ({ pushStack: [] }), + pushContextMark: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('my note', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /interactive session/, + ) + }) + + test('with pushContextMark invokes it and returns skip', async () => { + const { call } = await import('../push/push.js') + let capturedNote = '' + const ctx = { + getAppState: () => ({ pushStack: [] }), + pushContextMark: (note: string) => { + capturedNote = note + }, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('test branch note', ctx) + expect(result.type).toBe('skip') + expect(capturedNote).toBe('test branch note') + }) +}) diff --git a/src/commands/digest/digest.ts b/src/commands/digest/digest.ts new file mode 100644 index 0000000000..891e82114d --- /dev/null +++ b/src/commands/digest/digest.ts @@ -0,0 +1,22 @@ +import type { LocalCommandResult } from '../../commands.js' +import type { ToolUseContext } from '../../Tool.js' + +/** + * `/digest` handler. Opens the message selector in 'digest' mode so the user + * picks a start message; everything from there to the end is distilled into a + * digest (a retroactive `/push`+`/pop`). Interactive-only. + */ +export async function call( + _args: string, + context: ToolUseContext, +): Promise { + if (!context.openMessageSelector) { + return { + type: 'text', + value: '/digest is only available in an interactive session.', + } + } + context.openMessageSelector('digest') + // Return a skip message to not append any messages. + return { type: 'skip' } +} diff --git a/src/commands/digest/index.ts b/src/commands/digest/index.ts new file mode 100644 index 0000000000..639df32d58 --- /dev/null +++ b/src/commands/digest/index.ts @@ -0,0 +1,13 @@ +import type { Command } from '../../commands.js' + +const digest = { + description: + 'Distill everything from a selected message to the end into a digest (retroactive /push+/pop)', + name: 'digest', + argumentHint: '', + type: 'local', + supportsNonInteractive: false, + load: () => import('./digest.js'), +} satisfies Command + +export default digest diff --git a/src/commands/pop/index.ts b/src/commands/pop/index.ts new file mode 100644 index 0000000000..9bbdc6971e --- /dev/null +++ b/src/commands/pop/index.ts @@ -0,0 +1,13 @@ +import type { Command } from '../../commands.js' + +const pop = { + description: + 'Close the current discussion branch: distill it into a digest and rewind to the push point', + name: 'pop', + argumentHint: '[--to #N] [--discard] [--keep-code]', + type: 'local', + supportsNonInteractive: false, + load: () => import('./pop.js'), +} satisfies Command + +export default pop diff --git a/src/commands/pop/pop.ts b/src/commands/pop/pop.ts new file mode 100644 index 0000000000..a8b9caf658 --- /dev/null +++ b/src/commands/pop/pop.ts @@ -0,0 +1,92 @@ +import type { LocalCommandResult } from '../../commands.js' +import type { ToolUseContext } from '../../Tool.js' +import type { PopOptions } from '../../services/pushStack/state.js' + +/** + * Parses a marker argument (`#N` or `N`) into a positive ordinal. Rejects any + * input that is not a pure integer — `Number.parseInt` would otherwise silently + * accept `2junk` (→2) or `1.5` (→1) and pop the wrong branch. Returns null on + * any malformed value. + */ +function parseMarkerOrdinal(raw: string): number | null { + const digits = raw.replace(/^#/, '') + if (!/^\d+$/.test(digits)) return null + const n = Number.parseInt(digits, 10) + return Number.isInteger(n) && n >= 1 ? n : null +} + +/** + * Parses `/pop` flags into PopOptions. Recognized: + * --to #N | --to N cross-layer pop back to marker ordinal N (§4.7) + * --discard truncate without generating a digest + * --keep-code truncate conversation but skip file rollback + * Returns a string on parse error. + */ +export function parsePopArgs(args: string): PopOptions | string { + const opts: PopOptions = {} + const tokens = args.trim().split(/\s+/).filter(Boolean) + for (let i = 0; i < tokens.length; i++) { + const tok = tokens[i]! + if (tok === '--discard') { + opts.discard = true + } else if (tok === '--keep-code') { + opts.keepCode = true + } else if (tok === '--to') { + const next = tokens[++i] + if (next === undefined) + return 'Missing marker number after --to (e.g. /pop --to #1).' + const n = parseMarkerOrdinal(next) + if (n === null) return `Invalid marker number: ${next}` + opts.to = n + } else if (tok.startsWith('--to=')) { + const n = parseMarkerOrdinal(tok.slice('--to='.length)) + if (n === null) return `Invalid marker number: ${tok}` + opts.to = n + } else { + return `Unknown option: ${tok}` + } + } + return opts +} + +/** + * `/pop` handler. Parses flags, validates the target marker against the current + * stack, then hands off to the interactive `applyPop` callback which distills + * the branch into a digest and rewinds. Errors out (no state mutation) on an + * empty stack, an out-of-range `--to`, or a non-interactive session. + */ +export async function call( + args: string, + context: ToolUseContext, +): Promise { + const parsed = parsePopArgs(args) + if (typeof parsed === 'string') { + return { type: 'text', value: parsed } + } + + const stack = context.getAppState().pushStack + if (stack.length === 0) { + return { + type: 'text', + value: + 'No push point to pop. Use /push [note] to open a discussion branch first.', + } + } + + if (parsed.to !== undefined && parsed.to > stack.length) { + return { + type: 'text', + value: `No push point #${parsed.to} — the stack has ${stack.length}.`, + } + } + + if (!context.applyPop) { + return { + type: 'text', + value: '/pop is only available in an interactive session.', + } + } + + context.applyPop(parsed) + return { type: 'skip' } +} diff --git a/src/commands/push/index.ts b/src/commands/push/index.ts new file mode 100644 index 0000000000..03765afa36 --- /dev/null +++ b/src/commands/push/index.ts @@ -0,0 +1,14 @@ +import type { Command } from '../../commands.js' + +const push = { + description: + 'Open a discussion branch that inherits the current context (use /pop to distill it back)', + name: 'push', + aliases: ['stack'], + argumentHint: '[note] | --list', + type: 'local', + supportsNonInteractive: false, + load: () => import('./push.js'), +} satisfies Command + +export default push diff --git a/src/commands/push/push.ts b/src/commands/push/push.ts new file mode 100644 index 0000000000..d526ce8a49 --- /dev/null +++ b/src/commands/push/push.ts @@ -0,0 +1,59 @@ +import type { LocalCommandResult } from '../../commands.js' +import type { ToolUseContext } from '../../Tool.js' +import type { PushMarker } from '../../services/pushStack/state.js' +import { formatRelativeTimeAgo } from '../../utils/format.js' + +/** + * Renders `/push --list`: the current push stack, newest last, numbered #1..#N. + * Pure local read of AppState.pushStack — zero API tokens, no fork (§4.7). + */ +function renderStackList(stack: readonly PushMarker[]): string { + if (stack.length === 0) { + return 'No active push points. Use /push [note] to open a discussion branch.' + } + const now = new Date() + const lines = stack.map((m, i) => { + const depth = i + 1 + const when = formatRelativeTimeAgo(new Date(m.timestamp), { + style: 'short', + now, + }) + // branchPreview (first message inside the branch) wins over anchorPreview + // (the user's instruction at push time); both are truncated conversation text. + const preview = m.branchPreview || m.anchorPreview || '' + const note = m.note ? ` · ${m.note}` : '' + const previewPart = preview ? ` · ${preview}` : '' + return `#${depth}${note}${previewPart} · ${when} · depth ${depth}` + }) + return `Push stack (${stack.length}):\n${lines.join('\n')}` +} + +/** + * `/push [note]` (alias `/stack`) handler. `--list`/`-l` renders the current + * stack locally (no fork); otherwise marks a new push point via the interactive + * `pushContextMark` callback. Errors out in non-interactive sessions since the + * marker lives in REPL state (§4.7). + */ +export async function call( + args: string, + context: ToolUseContext, +): Promise { + const trimmed = args.trim() + + // `/push --list` (or `/stack --list`) lists the stack. Note: a bare + // `/push list` would be swallowed as a note, so a flag is required (§4.7). + if (trimmed === '--list' || trimmed === '-l') { + const stack = context.getAppState().pushStack + return { type: 'text', value: renderStackList(stack) } + } + + if (!context.pushContextMark) { + return { + type: 'text', + value: '/push is only available in an interactive session.', + } + } + + context.pushContextMark(trimmed) + return { type: 'skip' } +} diff --git a/src/components/MessageSelector.tsx b/src/components/MessageSelector.tsx index a54a84b03b..d3bcf8721e 100644 --- a/src/components/MessageSelector.tsx +++ b/src/components/MessageSelector.tsx @@ -66,6 +66,8 @@ type Props = { onClose: () => void; /** Skip pick-list, land on confirm. Caller ran skip-check first. Esc closes fully (no back-to-list). */ preselectedMessage?: UserMessage; + /** 'digest' collapses options to a single "distill from here" action (retroactive /push+/pop). */ + mode?: 'rewind' | 'digest'; }; const MAX_VISIBLE_MESSAGES = 7; @@ -78,6 +80,7 @@ export function MessageSelector({ onSummarize, onClose, preselectedMessage, + mode = 'rewind', }: Props): React.ReactNode { const fileHistory = useAppState(s => s.fileHistory); const [error, setError] = useState(undefined); @@ -147,6 +150,17 @@ export function MessageSelector({ showLabelWithValue: true, labelValueSeparator: ': ', }; + if (mode === 'digest') { + return [ + { + value: 'summarize', + label: 'Distill from here into a digest', + ...summarizeInputProps, + onChange: setSummarizeFromFeedback, + }, + { value: 'nevermind', label: 'Never mind' }, + ]; + } baseOptions.push({ value: 'summarize', label: 'Summarize from here', @@ -386,7 +400,7 @@ export function MessageSelector({ - Rewind + {mode === 'digest' ? 'Digest' : 'Rewind'} {error && ( @@ -402,8 +416,14 @@ export function MessageSelector({ {!error && messageToRestore && hasMessagesToSelect && ( <> - Confirm you want to restore {!diffStatsForRestore && 'the conversation '}to the point before you sent this - message: + {mode === 'digest' ? ( + <>Distill everything from this message to the end into a digest: + ) : ( + <> + Confirm you want to restore {!diffStatsForRestore && 'the conversation '}to the point before you sent + this message: + + )} - {isFileHistoryEnabled ? ( + {mode === 'digest' ? ( + Distill everything from this message to the end into a digest… + ) : isFileHistoryEnabled ? ( Restore the code and/or conversation to the point before… ) : ( Restore and fork the conversation to the point before… diff --git a/src/components/pushStack/CompactStrategyDialog.tsx b/src/components/pushStack/CompactStrategyDialog.tsx new file mode 100644 index 0000000000..09910fa740 --- /dev/null +++ b/src/components/pushStack/CompactStrategyDialog.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { Select } from '../CustomSelect/select.js'; +import { Dialog } from '../design-system/Dialog.js'; +import type { CompactStrategyChoice, CompactStrategyMarker } from '../../services/pushStack/state.js'; + +const FULL_VALUE = '__full__'; + +interface CompactStrategyDialogProps { + /** Markers ordered oldest → newest; the last is the nearest (top) push point. */ + markers: ReadonlyArray; + onChoice: (choice: CompactStrategyChoice) => void; +} + +/** + * Stack-aware auto-compact picker (§4.2). Shown when auto-compact fires with a + * non-empty push stack. Cancelling defaults to the nearest push point — the + * safe choice that preserves the branch the user is currently working in. + */ +export function CompactStrategyDialog({ markers, onChoice }: CompactStrategyDialogProps): React.ReactNode { + const top = markers[markers.length - 1]; + const depth = markers.length; + + const handleChange = React.useCallback( + (value: string) => { + if (value === FULL_VALUE) { + onChoice({ kind: 'full' }); + } else { + onChoice({ kind: 'partial', markerId: value }); + } + }, + [onChoice], + ); + + // Cancel → nearest push point (preserve current branch, release most space). + const handleCancel = React.useCallback(() => { + if (top) onChoice({ kind: 'partial', markerId: top.id }); + else onChoice({ kind: 'full' }); + }, [onChoice, top]); + + // Nearest first (default highlight), then older markers, then full compact. + const options: { label: string; value: string; description?: string }[] = []; + if (top) { + const noteSuffix = top.note ? ` (${top.note})` : ''; + options.push({ + label: `Compress to nearest push point #${depth}${noteSuffix}`, + value: top.id, + description: + depth > 1 + ? `Keeps your current branch. Removes ${depth - 1} older push point(s).` + : 'Keeps your current branch; only the mainline before it is compacted.', + }); + } + // Older markers (#1..#depth-1), newest-older first. + for (let i = markers.length - 2; i >= 0; i--) { + const m = markers[i]!; + const noteSuffix = m.note ? ` (${m.note})` : ''; + options.push({ + label: `Compress to push point #${m.depth}${noteSuffix}`, + value: m.id, + description: `Keeps push points #${m.depth}..#${depth}; compacts everything before #${m.depth}.`, + }); + } + options.push({ + label: 'Full compact', + value: FULL_VALUE, + description: `Removes all ${depth} push point(s); /pop will no longer work for them.`, + }); + + return ( + + + + You have {depth} open discussion branch{depth > 1 ? 'es' : ''}. Compacting to a push point keeps that branch + intact. + +