# dsh-force-compact **面向 DeepSeek Harness agent 的激进、本地优先上下文压缩。** 一个 DSH **Cordis 功能插件**,让 agent 的工作上下文始终保持精悍:用自建 llama.cpp 以 适中上下文承载 `Qwen3.8‑27B`,插件负责收缩会话本身——近乎大窗口的体验,无 API 费用、 数据不出本机。 [English](README.md) --- ## 为什么要做 - **本地推理**——agent 走标准 DeepSeek 适配器对接一台本地 OpenAI 兼容的 llama.cpp 服务端,无需另行编写适配器。 - **低上下文、高信号**——不与小上限较劲,而是直接收缩会话:agent 在短促的请求上 思考,同时靠压缩后的开头段保有深层记忆。 - **只关压缩的思考,其余放行**——`disableThinking: true`(默认)只关掉 *本插件自己的压缩摘要调用* 的思考;其他一切模型请求沿用机器原有配置,原样放行。 - **私密免费**——不计 token 费用,无数据外流。 --- ## 它能做什么 两条压缩引擎经由统一门面(`resolveCompaction`)共存,对调用者透明: | 引擎 | 启用条件 | 说明 | |------|----------|------| | **官方** | agent 领域内可解析出 `compaction` 服务 | 优先;委托 `compaction/basic`。 | | **内置** | 自动回落(典型标准预设将服务隔离,解析不到) | 基于 `ctx.sessions` / `ctx.llm.stream` / `ctx.tokenMeter` 的自包含持久事务;复用官方 `compaction/*` 事件词汇,跨版本回放安全。 | 无需切换——官方可达就走官方,不可达就内置接管。 ### 触发点 - **每次请求门禁(`agent/pre-step`)**——读取会话*预计*上下文 token(即界面右下角显示的 数字)。达到 `autoThresholdTokens` 时,拒绝本次出站请求并改压缩头部,最近 `retainLatestTokens` 逐字保留;低于阈值则请求照常进行。 - **回合结束 / 静止(`agent/status` → `idle`)**——agent 静止时(含子代理全部结束), 可选地经 `compactNow` 压缩(开关:`turnEndForceCompactionEnabled`)。 - **手动 `/force-compact`**——对忙/闲 agent 都能生效:空闲立即压缩;繁忙则排队一个 进程内强制标记,在下一个模型步骤消费。**命令本身惰性加载**——见安装章「命令可用性」。 在 `/` 菜单里这一行与官方一等公民命令同款外观:图标 + 本地化名称 + 本地化描述(官方的 外观表只覆盖一等公民命令,故由本插件客户端半部自己贴面——见 `AGENTS.md` 「指令行的官方外观」)。 - **`session/flush`**——等待型的持久化检查点。 每条路径最终都汇入唯一的「**压缩结果落入会话**」边界——也正是**发送 LiveUI 信令**的位置。 判定键用 `projectedTokens`(provider 锚定,与界面角标同源),故插件永远不会偏离你所见; **触发后循环压缩**(2026-09 语义):自动门禁、`/force-compact`、回合结束两条路径会在同 一次触发内**反复压缩,直到 `projectedTokens` 压回 `autoThresholdTokens` 以下**,或表面 已无可压缩头部(整个表面都不超过 `retainLatestTokens` 保留预算)为止;单轮"压缩后仍 ≥ 阈值"不再被跳过(旧版的 threshold-aware shrink-gate 已移除,它把 provider 压力基线 计入判定,baseline 偏高时会令可压缩会话滞留超阈值)。硬上限 `MAX_COMPACTION_ROUNDS=8` 兜底,防止 provider 基线异常时无限烧摘要调用。真正"物理防呆" 的拒绝保留不变:**摘要必须显著小于被遮蔽区间**(post-summary shrink gate,防膨胀)、 小于 `MIN_USEFUL_SPAN_TOKENS` 的小头部不浪费摘要调用、replay 消息上限、失败冷却、 busy 锁与表面一致性校验。 内置事务的影子价格账单取自与官方引擎相同的 `tokenMeter.measure` 逐节点单价,故米表 折叠协议结算正确——压缩后右下角计数器*下降*,而非漂移上升。 ### 思考控制:只作用于压缩 自 2026‑08 语义修订,`disableThinking` 只管一件事:**本插件自己的摘要调用** (`engine/builtin.js` → `engine/summarizer.js` → `ctx.llm.stream`)是否携带 `reasoningEffort:'off'`。其余一切不受影响: | 调用位置 | `disableThinking: true` 时的行为 | |---|---| | 内置引擎的摘要调用 | 携带 `reasoningEffort:'off'` | | 其他一切模型请求(业务、子代理、工具、其他插件) | 机器 `LlmCallConfig` 原样不动 | | 官方 `compaction` 服务的调用 | 不经过本插件任何接缝——不受影响 | 目标是 **llama.cpp / OpenAI 兼容端点**时,适配器吐出的 `thinking: { type: 'disabled' }` 字段在那里被静默忽略——所以摘要器**另外**打上 llama.cpp 原生顶层字段 `reasoning_effort: "none"`,门控条件与前一字**完全一致**。同一 options 对象携带两字段: | 端点家族 | 读取 | 结果 | |---|---|---| | 真·DeepSeek API | `reasoningEffort:'off'` → `thinking:{type:'disabled'}` | 关思考 ✅ | | llama.cpp / OpenAI 兼容 | `reasoning_effort:"none"`(顶层) | `enable_thinking=false` ✅ | 每一类端点都宽容无视对方家族的键,故双发无害。字段打在 `src/engine/summarizer.js` (紧挨 `llm.stream(options)` 调用之前),**不在** `llm/stream` 瀑布里——早期草稿曾在那 处尝试注入,实证证明结构性无效(中间层返回值被丢弃;就地改种子会使宿主崩溃);论证全文 见 `src/hooks/wire-rewrite.js` 模块头部(该模块**已退役**,只留这份历史说明)。 业务调用也要关思考?在请求头层面设定你 provider 的 `reasoningEffort`——插件有意退出 该决定。 #### 可观测性:每次摘要尝试的审计行 每次摘要尝试都会打两行日志(默认 `debug: true` 即见)——不抓包就能确认作用域裁定与其 wire 字段的持久证据: ``` [force-compact] : compaction thinking-policy — settings.disableThinking=true → extra.reasoningEffort='off' (this summarization call carries thinking-OFF) [force-compact] : summarization wire-fields → /: reasoningEffort='off' + reasoning_effort="none" (llama.cpp-native wire field) ``` - **第一行**(`engine/builtin.js`)记录 `disableThinking` 在哪里被读出、如何进入调用 选项;关闭时则记录*沿用机器默认*。 - **第二行**(`engine/summarizer.js`)记录离开 options 对象那一刻的两个 wire 字段(加上 解析出的 provider/模型);未盖章字段标注 `(absent…)`。 实证背书:对本机 llama.cpp 端点做过对照——基线请求返回非空 `reasoning_content` (默认会想),带顶层 `reasoning_effort:"none"` 的同款请求完全没有——该类端点上该字段 确实在关思考,而未携字段业务调用维持正常思考。 ### LiveUI 状态 极小的宿主→客户端信道(`liveUi` 设置字段被实时镜像到浏览器)替换**官方运行标签的前缀**—— 只动文字:harness 自己的计时文本、字体、颜色原样不动。 状态文字**如实报告 agent 当前在做哪一类耗时动作**,共三类,各由一个 agent 缝驱动: | 状态 | 语义 | 触发点 | |------|------|--------| | **`模型请求中`** | 已发出模型请求,等第一个 token | `agent/request` waterfall | | **`模型响应中`** | 第一个 token 到达,持续到响应结束 | `agent/assistant-stream` 的首个 `chunk`(`index === 0`) | | **`工具执行中`** | 非模型的 agent 内部操作在跑 | `tools/pre-execute` waterfall | `responding` 挂在**首块**而不是 attempt 的 `start` 帧:后者在任何 token 到达**之前**就发出 (紧跟 `llm.stream(request)` 调用),那一刻仍属 `requesting` 的时间跨度。 **压缩摘要请求不计入这三类**(用户明确要求"排除压缩时请求")——这是**结构性排除**而非过滤: 插件(与官方引擎)的摘要调用直连 `ctx.llm.stream`,不经过 `agent/request`、不产生 `agent/assistant-stream` 帧、也不派发工具,三类相位的插入点它一个都碰不到。 压缩横幅**保留**: - **`[强制压缩中>>>]`**——压缩即将提交前; - **`[压缩完成!]`**——压缩结果落定的瞬间;之后由下一个 agent 动作(通常是继续循环的 `模型请求中`)自然覆盖,**不再有 3 秒回落定时器**; - **会话结束「还原」**——agent 转入 idle(一轮彻底结束)时推一个**空文本**; 客户端把官方原文放回去并撤掉替换前缀。 harness 0.2.0 把这行运行态文案搬进了独立组件 `RunningStatus`(0.1.7 时的 `button[data-turn-process] > span` 现在只渲染**已结束**回合),锚点是容器属性 `data-chat-running`。结构如下: ```
深度求索中 ← 1px 裁剪的读屏播报 ← 分隔线 …鲸鱼动画(APNG mask + SVG 兜底)… ← 前缀动画小图标 深度求索中,用时1分14秒 ···
``` 客户端半部**只替换那句文字的前缀**:拿 `role="status"` 播报节点的文本当锚,按两者的 **公共前缀**切开原文,其余部分——harness 的计时尾巴(含官方结尾的 ` ···`)——逐字保留。 **前缀的鲸鱼动画图标与分隔线原样保留**,不写颜色、不改字体、不动布局;播报节点绝不触碰, 读屏器仍听到官方文案。 一个 `MutationObserver`(仅在有活跃相位期间连接)**双写**两处文字:TextShimmer 把同一句渲染了 两遍(真实文本节点 + 一份 `aria-hidden` 的动画高亮副本,后者的字由 CSS `::after { content: attr(data-shimmer-text) }` 从属性取)——只改文本节点的话,扫光扫过时会 露出官方旧文案。观察器在 React 重写的同一微任务里重新贴上,所以计时继续走、且全程零轮询。 徽标文字跟随应用语言:宿主写入语言无关的 `textId`(**相位名**)加规范中文文本, 客户端半部经自己的 `ctx.locale` zh/en/ja/ko 词典解析——英文界面显示 `Requesting model` / `Model responding` / `Running tool`,日文、韩文界面同理。 发送方绝对安全:信令故障绝不误伤真实的压缩事务。 --- ## 工作原理 ``` agent/request(payload, next) # 每次模型请求 return await next() # 纯透传(思考关闭只作用于本插件 # 自己的摘要调用) agent/pre-step(payload, next) # 每个模型步骤前 projectedTokens >= autoThresholdTokens? 否 -> next() # 放行 是 -> compactRegion(保留段之前的头部区间, signal) return { kind: "reject" } # 本步骤不请求模型 agent/status({ agent, status }) # 生命体征过渡 status === "idle" && turnEndForceCompactionEnabled? -> compactNow(agent, 新signal) # 回合结束压缩 session/flush(session) # 持久化检查点 选取区间 -> 投影消息 -> 预览 + 缩容门禁 -> compaction.compactRegion(start, end, agent, signal) ``` 支撑模块: - `src/hooks/guard.js` —— 每次请求的门禁:`agent/request` 纯透传(不再批量盖思考章)+ `pre-step` 阈值门 + 进程内强制标记(`thinkingDisabled` 仅作遗留谓词保留,热路径不再 调用)。 - `src/hooks/command.js` —— `/force-compact` 命令(惰性注册)。 - `src/hooks/idle.js` —— 回合结束强制压缩。 - `src/hooks/wire-rewrite.js` —— **已退役**,只留历史说明(记录 `llm/stream` 缝为何 无法追加 wire 字段),无任何导出。 - `src/engine/region.js` —— 选区(锚定头/尾,含官方工具配对台账)。 - `src/engine/summarizer.js` —— 一次性 LLM 摘要器,与官方 `compaction-basic` **结构**对齐 (三级 target 解析、前缀缓存对齐、`purpose:'compaction'` 标签、fail-closed 终局分类、用量采集);摘要**指令文案为中文**(结构一致、文案本地化,两条 `` 标签逐字保留) —— 官方引擎仍用英文指令,详见 `AGENTS.md` 的"摘要指令中文化"一节。 - `src/engine/builtin.js` —— 内置持久事务(官方 `compaction/*` 词汇)。 - `src/engine/checkpoint.js` —— 预览 + 缩容门禁 + 委托压缩服务。 - `src/core/projected.js` —— provider 锚定的 `projectedTokens`。 - `src/core/ui-signal.js` —— LiveUI 信道。 --- ## 安装 作为安装包(推荐): ```sh # 从 npm(已发布): npm install @falling-ts/dsh-force-compact # 从 git: dsh plugin --profile web add github:falling-ts/dsh-force-compact # 从本地检出: dsh plugin --profile web add ./dsh-force-compact ``` 或不安装,直接从本地检出以 `--patch` 覆盖层挂载: ```sh dsh web --patch dsh-force-compact/cordis.patch.yml ``` 插件加载 ⟺ `~/.dsh/logs/dsh-force-compact.log` 新增一行: ``` [force-compact] debug logging enabled — writing [force-compact] lines to ``` ### 命令可用性——`/force-compact` 是惰性加载的 `commands` 服务随 agent 预设平面到达,**晚于**插件启动期的 `apply`——所以注册发生在 第一次受守卫监听器激活时(`agent/request` / `agent/pre-step` / `agent/status` / `session/flush` 任一),首次成功后永久闩闭。实际效果:**实例(重新)启动后,全新会话的 `/` 命令列表在该会话第一次模型请求之前不会出现 `/force-compact`**——先发任意一条消息, 命令即进程级注册,此后所有会话可用。 - 成功:`[force-compact] /force-compact command registered (deferred)` - `commands` 永久缺席:约 10 分钟后打一条 `… still UNREGISTERED 10 min …` 警告,解释 空列表。注册完成前插件其余功能照常——属自愈式降级,不是安装失败。 验证压缩确实发生: ``` idle compaction (builtin) shadowed N nodes (~M tokens) builtin compaction OK — replaced span seq[A..B] (N nodes, ~K tokens) with a P-char checkpoint compaction thinking-policy — settings.disableThinking=true → extra.reasoningEffort='off' (…) summarization wire-fields → /: reasoningEffort='off' + reasoning_effort="none" (…) ``` (最后两行即上文「可观测性」所述的那对审计行。) --- ## 设置 命名空间 `falling-ts-force-compact`(= profile 里该条目的 loader id),值写进 profile 的 `cordis.patch.yml`(harness 0.1.7 起;旧版是 `$DSH_HOME/settings.yaml`): | 键 | 类型 | 默认 | 含义 | |-----|------|------|------| | `disableThinking` | 布尔 | `true` | 只有关闭时,本插件摘要调用不携带 `reasoningEffort:'off'`;其余请求一律机器默认。 | | `autoThresholdTokens` | 数字 ≥ 32000 | `32000` | 门禁的**默认**预计 token 触发值:未设置会话级覆盖的会话一律用它。越低越激进,常驻请求越小。读取时不低于 32000(低于则抬高)。参见[会话级阈值](#会话级阈值)。 | | `retainLatestTokens` | 正整数 ≥ 8000 | `8000` | 从会话最新条目起保留的最新 token 数,逐字保留;更早内容一次性发往摘要。读取时不低于 8000。同时驱动自动门禁与 `/force-compact`。 | | `turnEndForceCompactionEnabled` | 布尔 | `true` | agent 转入 `idle` 时压缩。 | | `debug` | 布尔 | `true` | 把 `[force-compact]` 诊断打到插件日志。**实时生效**(2026-09):开关在每一条导出日志时实时读取,改设置表单(或 profile 的 `cordis.patch.yml`)后下一行即生效,无需重启;`logFile` 路径在安装时固定,改路径需重启进程。 | | `logFile` | 字符串 | `~/.dsh/logs/dsh-force-compact.log` | 诊断输出路径(`~` 展开到家目录)。 | | `compactionMode` | `'realm' \| 'global'` | `'realm'` | 官方服务解析策略(优先级一)。 | | `builtinEnabled` | 布尔 | `true` | 内置引擎回落闸门。 | | `maxSummaryTokens` | 整数(1024–200000) | `1024` | 摘要 LLM `maxTokens` 上限。 | | `summarizationTimeoutMs` | 整数 5000–2147483647(毫秒) | `90000` | 一次摘要流的硬墙钟超时上限(挂起流守卫)。读取时不低于 5000(过小的上限会误判慢速本地端点挂起);不高于 2147483647——该值经 `AbortSignal.timeout` **调度**, 小数会直接抛错, 而 2^31..2^32-1 区间会被静默降成 1ms。超界值双向钳制, 小数截断。 | 示例——激进的**本地**档: ```yaml falling-ts-force-compact: disableThinking: true autoThresholdTokens: 40000 # 早压 ⇒ 常驻请求更小 retainLatestTokens: 8000 turnEndForceCompactionEnabled: true ``` `settings` 服务缺席时,插件回落到同样的默认值并照常压缩——命名空间可选,永不成为硬 依赖。 上表中涉及 token 计数的字段在设置表单里均支持 `K` / `M` 后缀 (`32K`、`1M` 或纯数字);按十进制解析(`32K` = 32000、`1M` = 1000000),上表的上下限依然生效。 下面的会话级控件用的是同一个解析器。 ### 会话级阈值 `autoThresholdTokens` 只是**默认值**。单个会话可以覆盖它,而不必改动共享设置: - 入口是输入框底部状态条里、**上下文占用百分比右侧**的 chip(与内置统计药丸同一行)。 它显示**固定文案**(图标 +「强制压缩阈值」),因此每一行看起来都一致、数字也不会挪动布局; 当前**生效**的阈值、所属会话与是否被覆盖,都放在悬停提示里(面板里也有)。 - 点击展开小面板:输入阈值后「保存」;或点「使用全局默认」清除覆盖。 - 输入接受纯数字或 `K`/`M` 后缀(`123K`、`1M`、`32000`);按十进制解析 (`32K` = 32000,`1M` = 1000000),同样受 32000 下限约束。无法解析时内联报错并拒写; 清空输入即回落到默认值。 - 当 `阈值 < 上下文窗口` 时,插件还会画出**压缩会在哪里触发**:圆环上按 `阈值 / 上下文窗口` 的角度画一个红色圆点(从 12 点起顺时针,与圆环填充同向); 展开面板的横条上按同一比例画一条红色竖线,高度恰好等于横条。阈值达到或超过 上下文窗口时不画——占用永远够不到它,也就不存在可标记的触发点。 - 值以会话 id 为键存在本命名空间的 `sessionThresholds` 下,只对所属会话生效。 所有门禁(自动压缩、`/force-compact`、区域压缩、checkpoint、idle)统一按 `sessionThresholds[sessionId] ?? autoThresholdTokens` 解析。 - 「不存在」是有意义的:删掉键即恢复默认,覆盖不会被误当成全局改动。 - chip 在「性能与用量」的简洁与详细两种展示模式下**都会**出现; 内置统计药丸在无内容时会自行隐藏,阈值 chip 不会。 ### 低上下文 llama.cpp 调优建议 `autoThresholdTokens` 保持在所供上下文的**明显之内**(例如以 80k–128k 舒适上下文供服, 就把阈值设在 40k):常驻请求小而延迟稳,agent 依旧靠压缩头段保有深层记忆。压力以 *预计* token(provider 锚定)度量,阈值与你界面上看到的数字可预期对应。 --- ## 行为说明 - **运行时依赖:** `compaction` 服务(preset 平面 `agent-presets:compaction-basic`), 经 `ctx.get('compaction')` 实时读取;不可用时回落内置引擎(或放行请求)。 - **可选依赖:** `settings` / `tokenMeter` / `commands` / `llm` / `agents` 均经 `ctx.get(...)` 读取并守卫;缺任一都优雅降级而非崩溃。 - **每请求读参数:** 参数每次模型请求读取,改动下次请求即生效,无需重启。 - **信号:** `agent/*` 瀑布转发当前回合的信号;`session/flush` 检查点与 `agent/status` 静止监听器各自新建 `AbortController`。 - **持久化:** 持久物为 `compaction/*` 括号事件 + 一条 `surfaceOp:replace` 的 `user/message` 检查点,跨版本回放安全。 - **客户端半部:** `web/client.js` 新增「强制压缩」设置分区(本地化标签),支持实时改 值(uSES 安全镜像),无任何定时器或跨会话状态。 - **无定时器:** 全插件不引入任何 timer——原 `publishDone` 的 3 秒回落已随随机状态 文本一并删除(见 `AGENTS.md` 的 LiveUI 节);其余全部纯监听器 + 进程内 `Map` 强制标记。 --- ## 截图 ![设置面板——「强制压缩」分区,五个旋钮均可在线编辑](assets/settings-panel.png) *设置页面——**强制压缩** 分区;上方九个字段全部支持不改重启地实时编辑。* ![会话页面——状态文字替换掉官方标签的前缀](assets/live-conversation.png) *会话页面——LiveUI 信令把每个运行中回合标签的前缀改写成当前 agent 动作 (`模型请求中` / `模型响应中` / `工具执行中`,压缩时是 `[强制压缩中>>>]` / `[压缩完成!]`), harness 计时在它后面继续走;会话结束(idle)时官方原文被还原。该截图摄于 2026-09 改版之前: 现在显示的是上面这三类实际状态,徽标保持官方灰色文字、官方字体。* --- ## 许可 MIT(见 LICENSE)。