@aiwayds/dsh-dcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/cordis.patch.example.yml +19 -0
- package/lib/command.js +128 -0
- package/lib/config.js +113 -0
- package/lib/index.js +129 -0
- package/lib/summarizer.js +541 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fan56
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# dsh-dcp
|
|
2
|
+
|
|
3
|
+
[dsh](https://github.com/deepseek-ai/deepseek-harness)(DeepSeek Harness)的**确定性 context 压缩后端**:把 `compaction-basic` 的 LLM 摘要换成纯代码抽取,**每次压缩零 LLM 调用**。
|
|
4
|
+
|
|
5
|
+
设计参考 [Opencode-DCP/opencode-dynamic-context-pruning](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning)(opencode 社区的 context 剪枝插件,即 oh-my-openagent 所用的 opencode-dcp):重复调用去重、陈旧报错清理、"技术性摘要代替散文"、`/dcp` 命令、默认配置开箱即用——按 dsh 的 compaction capability seam 重新实现。
|
|
6
|
+
|
|
7
|
+
## 为什么
|
|
8
|
+
|
|
9
|
+
dsh 自带三层压缩里,`compaction-basic` 每次压缩都要付一次 LLM 摘要调用,且摘要质量取决于模型心情。dsh-dcp 换成确定性模板抽取:
|
|
10
|
+
|
|
11
|
+
- **零 LLM 调用**:压缩触发不再产生额外的 token 消耗
|
|
12
|
+
- **输出稳定**:相同输入永远得到相同摘要(可 diff、可测试)
|
|
13
|
+
- **保留硬信息**:文件路径、命令、报错串、待办、用户原话逐字保留,废话全丢
|
|
14
|
+
- **中文友好**:内容原样保留(不做英文转写),`thresholdRatio` 可直接调低提前触发
|
|
15
|
+
- **继承一切安全机制**:压力触发、保留尾巴、溢出恢复、事务锁、tool-pairing 边界全部复用官方实现(只 override 官方留的唯一钩子 `summarize()`)
|
|
16
|
+
|
|
17
|
+
## 快速开始
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 1. 克隆并安装(依赖与 dsh 0.1.0-rc.6 对齐)
|
|
21
|
+
git clone git@github.com:fan56/dsh-dcp.git ~/github/dsh-dcp
|
|
22
|
+
cd ~/github/dsh-dcp && npm install
|
|
23
|
+
|
|
24
|
+
# 2. 挂载:在 ~/.dsh/cordis.patch.yml 追加(name 必须是绝对路径)
|
|
25
|
+
# - id: compaction-basic
|
|
26
|
+
# name: /Users/<you>/github/dsh-dcp/lib/index.js
|
|
27
|
+
|
|
28
|
+
# 3. 重启 dsh,输入 /dcp 验证
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`/compact` 命令、自动压力触发、overflow 恢复、UI 的 checkpoint 卡片照常工作——它们只依赖 `ctx.compaction` 接口,与本后端无关。
|
|
32
|
+
|
|
33
|
+
## `/dcp` 命令
|
|
34
|
+
|
|
35
|
+
| 命令 | 作用 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `/dcp` | 状态:当前配置、压缩次数、shadowed token 数、省掉的 LLM 调用数 |
|
|
38
|
+
| `/dcp compact` | 立即压缩(确定性,无 LLM 调用;等价 `/compact`) |
|
|
39
|
+
| `/dcp set <k> <v>` | 本会话内调整参数,并打印持久化到 `cordis.patch.yml` 的片段 |
|
|
40
|
+
| `/dcp help` | 用法 |
|
|
41
|
+
|
|
42
|
+
可调键:`dedup`、`purgeErrors`、`maxItems`、`maxItemChars`、`maxSummaryTokens`、`language`、`thresholdRatio`。
|
|
43
|
+
|
|
44
|
+
## 配置
|
|
45
|
+
|
|
46
|
+
全部可选,默认即用。写在 `~/.dsh/cordis.patch.yml` 的 `config:` 下:
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
- id: compaction-basic
|
|
50
|
+
name: /Users/<you>/github/dsh-dcp/lib/index.js
|
|
51
|
+
config:
|
|
52
|
+
thresholdRatio: 0.7 # 中文场景建议 0.7(默认 0.8)
|
|
53
|
+
language: zh # 输出语言 en|zh:zh 额外启用中文报错/待办规则
|
|
54
|
+
# tokenEstimate: cjk # 默认即 cjk:CJK 字符按 ~2 字符/token 计价
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### dsh-dcp 自己的键
|
|
58
|
+
|
|
59
|
+
| 键 | 默认 | 说明 |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `dedup` | `true` | 统计重复的工具调用(同名+同参),在 Critical Context 里标注"×N,保留最近结果" |
|
|
62
|
+
| `protectedTools` | `['write', 'edit', 'apply_patch']` | 去重时跳过这些名字(子串匹配)的工具;设为 `[]` 可让所有工具参与去重 |
|
|
63
|
+
| `purgeErrors` | `true` | 旧报错折叠为一条省略提示,只保留最近 `maxItems` 条 |
|
|
64
|
+
| `maxItems` | `10` | 每个 section 最多条数 |
|
|
65
|
+
| `maxItemChars` | `200` | 每条最长字符(超出截断加 `…`) |
|
|
66
|
+
| `maxSummaryTokens` | `2048` | 摘要 token 预算(超出自动降级到更紧凑的格式) |
|
|
67
|
+
| `language` | `en` | 输出语言 `en`/`zh`。除填充文案外,`zh` 还启用**中文规则集**:识别中文报错关键词(找不到/失败/无法/拒绝/超时/崩溃…)和 `待办:` 标记;`en` 只用英文规则。section 标题固定英文(对下游模型是结构锚点) |
|
|
68
|
+
| `tokenEstimate` | `cjk` | 摘要预算的 token 计价方式:`cjk`(默认)把 **CJK 字符**(中文汉字、日文假名、韩文谚文、全角标点——CJK 不止中文)按 **~2 字符/token** 计价,ASCII 按 4 字符/token;`ascii` 则与宿主 meter 完全一致,所有字符一律 4 字符/token |
|
|
69
|
+
|
|
70
|
+
### 继承自 compaction-basic 的键
|
|
71
|
+
|
|
72
|
+
`thresholdRatio`(0.8)、`retainRatio`(0.16)、`retainTokens`、`compactionRetries`、`maxOverflowRetries`、`modelPolicies`、`auto` 等原样透传,语义见 [dsh-compaction-basic README](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/compaction/compaction-basic/README.md)。`summarizationProvider/Model/maxTokens` 只影响被替换掉的 LLM 摘要路径,保留只为配置兼容。
|
|
73
|
+
|
|
74
|
+
## 中文场景
|
|
75
|
+
|
|
76
|
+
dsh 的 host meter 对所有字符一律按 **4 字符/token** 计价,这对英文合理,对 CJK 却会低估约 2 倍(真实分词器对汉字/假名/谚文约 1~2 字符/token)。dsh-dcp 在**摘要预算**上不沿用这个启发式:
|
|
77
|
+
|
|
78
|
+
- **预算计价(`tokenEstimate: cjk`,默认)**:CJK 字符按 ~2 字符/token、ASCII 按 4 字符/token。纯英文文本与宿主 meter 完全一致;CJK 会话里预算反映真实成本,摘要不会因"中文被按 4 字符/token 贱卖"而膨胀到超出真实预算,也不会被 45% 预算规则饿死
|
|
79
|
+
- **中文规则集(`language: zh`)**:报错识别补充中文关键词(找不到/未找到/不存在/失败/错误/报错/异常/无法/拒绝/超时/崩溃/致命),待办识别补充 `待办:` 标记;`en` 模式只认英文规则
|
|
80
|
+
- **内容逐字保留**:用户原话、文件路径、命令、报错串原样进入摘要,不做英文转写,中文信息零损耗
|
|
81
|
+
|
|
82
|
+
一个**已知边界**:压缩**触发阈值**仍由宿主 meter 决定(在父类 `compactIfNeeded` 里,不在我们的 seam 内)。中文会话里宿主低估实际占用,阈值可能触发偏晚。补偿办法:把 `thresholdRatio` 调低到 `0.6~0.7`(或用 `modelPolicies` 按模型单独设),让压力检查提前;若仍频繁触发 context-overflow 恢复,再继续下调。
|
|
83
|
+
|
|
84
|
+
## 摘要长什么样
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
## Primary Request and Intent
|
|
88
|
+
- Fix the login redirect bug and update README.
|
|
89
|
+
|
|
90
|
+
## Files and Code
|
|
91
|
+
- /app/src/auth/login.ts — W×1 R×1
|
|
92
|
+
|
|
93
|
+
## Errors and Fixes
|
|
94
|
+
- bash: FAIL src/auth.test.ts
|
|
95
|
+
|
|
96
|
+
## Pending Jobs
|
|
97
|
+
- add regression test
|
|
98
|
+
|
|
99
|
+
## Current Work
|
|
100
|
+
- Fix the login redirect bug and update README.
|
|
101
|
+
|
|
102
|
+
## Next Step
|
|
103
|
+
- add regression test
|
|
104
|
+
|
|
105
|
+
## Critical Context
|
|
106
|
+
- dsh-dcp 确定性压缩了 12 条消息 / 8 次工具调用(未调用 LLM 摘要)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Section 结构与 `compaction-basic` 的 checkpoint 指令一致,因此:已有的 `<compacted-summary>` 检查点会被解析、去重后合并进新摘要(陈旧的 Current Work / Next Step 丢弃重生成);将来换回 basic 后端,它也能按官方规则合并 dsh-dcp 产生的检查点。
|
|
110
|
+
|
|
111
|
+
## 工作原理
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
BasicCompactionEngine dsh 官方:阈值/保留/事务/事件锁/tool-pairing
|
|
115
|
+
▲
|
|
116
|
+
│ 仅 override summarize()
|
|
117
|
+
DcpEngine ── register /dcp
|
|
118
|
+
│
|
|
119
|
+
summarizeDeterministically() 纯代码:抽取 → 合并 prior checkpoint → 预算压缩
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- 压缩区间选择、`compaction/start → summary → end` 事件序、`surfaceOp: replace`、收敛校验(摘要必须小于被压区间)、`ManualCompactionError` 错误分类,全部由父类承担
|
|
123
|
+
- 摘要预算 = `min(maxSummaryTokens, 45% × 被压区间)`,按 `tokenEstimate` 计价(默认 cjk,对中文准确;对纯英文与宿主一致);超出时逐级降级(砍条数 → 丢空 section → terse 固定格式 → 硬截断)。45% 的余量保证收敛校验(宿主按 4 字符/token 计价)总能通过
|
|
124
|
+
- 挂载方式是 `cordis.patch.yml` 按 `id: compaction-basic` 覆盖 `name`,行 id 不变、仍留在原 isolate 组内,隔离语义不破坏
|
|
125
|
+
|
|
126
|
+
## 与参考对象的差异
|
|
127
|
+
|
|
128
|
+
| | opencode-dcp | dsh-dcp |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| 宿主 | opencode | dsh(DeepSeek Harness) |
|
|
131
|
+
| 接入点 | 插件 + 模型可调的 compress 工具 | 官方 compaction seam 的 `summarize()` 钩子 |
|
|
132
|
+
| 历史 | 不改 session,请求前换占位符 | 走官方 durable 替换事务(`surfaceOp: replace`) |
|
|
133
|
+
| 摘要 | 模型生成技术摘要 | 确定性模板抽取,零 LLM 调用 |
|
|
134
|
+
| 命令 | `/dcp` TUI 面板 + `/dcp-compress` | `/dcp`(status/compact/set) |
|
|
135
|
+
| 配置 | `dcp.jsonc`(~30 键) | `cordis.patch.yml` config(8 个自有键) |
|
|
136
|
+
|
|
137
|
+
## 局限
|
|
138
|
+
|
|
139
|
+
- 确定性抽取不"理解"代码:它保路径/命令/报错/待办/用户原话,但不做语义归纳。需要语义摘要的场景请继续用默认 `compaction-basic`
|
|
140
|
+
- 摘要预算对中文已按真实密度计价(`tokenEstimate: cjk`);但**压缩触发阈值**仍在宿主侧按 4 字符/token 计价,中文会话触发偏晚,需手动调低 `thresholdRatio`(见"中文场景")
|
|
141
|
+
- 依赖绝对路径挂载 + 本目录 `npm install`(私仓未发 npm);`@deepseek-ai/*` 版本需与本机 dsh 一致(当前 `0.1.0-rc.6`,见 `package.json` 的 `overrides`)
|
|
142
|
+
|
|
143
|
+
## 开发
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm install
|
|
147
|
+
npm test # node:test,35 个用例:config / summarizer / command / engine
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 缓存对比脚本
|
|
151
|
+
|
|
152
|
+
`scripts/compare.mjs` 用真实 dsh 会话日志(`~/.dsh/sessions/**/session.jsonl.zstd`)静态模拟 provider 前缀缓存,对比"不压缩"基线与 dcp 后端:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
node scripts/compare.mjs <session.jsonl.zstd> [contextWindow] [thresholdRatio] [retainRatio] [language]
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
模拟的缓存模型与真实服务商一致:请求 N 的缓存命中 = 与请求 N-1 的最长公共 token 前缀;纯追加时上一请求是完整前缀(≈全命中),压缩把头部替换成新 checkpoint 后下一请求从冷开始。
|
|
159
|
+
|
|
160
|
+
**3 条真实会话验证**(128k 窗口、0.8 阈值、0.16 保留;session3 为 64k 窗口):
|
|
161
|
+
|
|
162
|
+
| 会话 | 体量 | 基线 hit / 输入 | dcp hit / 输入 | 压缩次数 | 压缩率 |
|
|
163
|
+
|---|---|---|---|---|---|
|
|
164
|
+
| S1(759 节点,中文主题调试) | 18.2 万 tok | 99.6% / 4152 万 | 99.1% / 2206 万(-47%) | 1 | 81,632→514(**158.8x**) |
|
|
165
|
+
| S2(765 节点) | 18.7 万 tok | 99.1% / 4187 万 | 98.8% / 2102 万(-50%) | 2 | 82,525→577、82,163→821(143x/100x) |
|
|
166
|
+
| S3(128k 未触发) | 12.3 万 tok | 98.6% / 1234 万 | 98.6% / 1234 万(不变) | 0 | — |
|
|
167
|
+
| S3(64k 窗口) | 12.3 万 tok | 98.6% / 1234 万 | 97.2% / 715 万(-42%) | 3 | 42,085→325 等(80~130x) |
|
|
168
|
+
|
|
169
|
+
结论:压缩让命中率下降不到 1 个百分点,绝对 miss 增加约等于每次压缩后那一个冷请求(1~2 万 token),而这个代价是**所有压缩后端共有的**(头部替换导致新 checkpoint 无前缀可蹭);同时总输入 token 减半。dcp 与官方 basic 的唯一差异在摘要大小与零 LLM 调用,不在缓存机制。
|
|
170
|
+
|
|
171
|
+
**摘要瘦身优化**(对注入上下文做了过滤,见 `extractFacts` 的 `isInjectedContext`):system-prompt 快照、skills 目录、AGENTS.md 指令这些每轮重新注入的消息不再进入摘要——S1 摘要 717→514 token(-28%),S2 摘要 693→577、883→821。纯英文与中英混合会话均无回归。
|
|
172
|
+
|
|
173
|
+
- `lib/index.js` — `DcpEngine`(挂载入口,default export)
|
|
174
|
+
- `lib/summarizer.js` — 确定性抽取与预算压缩(纯函数,可独立复用)
|
|
175
|
+
- `lib/command.js` — `/dcp` 命令
|
|
176
|
+
- `lib/config.js` — 配置切分与校验
|
|
177
|
+
- 升级 dsh 后:同步 `package.json` 里 `@deepseek-ai/*` 的版本与 `overrides`,`npm install && npm test`
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Example: mount dsh-dcp as the compaction backend.
|
|
2
|
+
#
|
|
3
|
+
# Append to ~/.dsh/cordis.patch.yml (home level, applies to every profile).
|
|
4
|
+
# The row id stays `compaction-basic` (patch overrides by id), so the line
|
|
5
|
+
# remains inside the standard preset's compaction isolate group and every
|
|
6
|
+
# consumer (/compact, UI checkpoint card, pressure triggers) keeps working.
|
|
7
|
+
#
|
|
8
|
+
# `name` MUST be an absolute path to this checkout's entry file; relative
|
|
9
|
+
# paths resolve against the loader's baseUrl and are easy to get wrong.
|
|
10
|
+
- id: compaction-basic
|
|
11
|
+
name: /Users/CHANGE/ME/dsh-dcp/lib/index.js
|
|
12
|
+
config:
|
|
13
|
+
thresholdRatio: 0.7 # optional: trigger earlier for CJK-heavy sessions
|
|
14
|
+
language: zh # optional: filler text language (content is verbatim)
|
|
15
|
+
# dedup: true # optional: every dsh-dcp key may be omitted
|
|
16
|
+
# purgeErrors: true
|
|
17
|
+
# maxItems: 10
|
|
18
|
+
# maxItemChars: 200
|
|
19
|
+
# maxSummaryTokens: 2048
|
package/lib/command.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/dcp` slash command: status, manual compaction, and runtime knobs —
|
|
3
|
+
* one entry point, no required arguments (design references opencode-dcp's
|
|
4
|
+
* `/dcp` panel in a dsh-idiomatic, text-only form).
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-dcp/command
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ManualCompactionError } from '@deepseek-ai/dsh-compaction'
|
|
10
|
+
import { RUNTIME_SETTABLE } from './config.js'
|
|
11
|
+
|
|
12
|
+
const USAGE = `Usage:
|
|
13
|
+
/dcp show status (mode, config, compaction stats)
|
|
14
|
+
/dcp compact compact now (deterministic, no LLM call)
|
|
15
|
+
/dcp set <k> <v> adjust a knob for this session (dedup, purgeErrors,
|
|
16
|
+
maxItems, maxItemChars, maxSummaryTokens, language,
|
|
17
|
+
tokenEstimate, thresholdRatio)`
|
|
18
|
+
|
|
19
|
+
const FAILURE_TEXT = Object.freeze({
|
|
20
|
+
busy: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
|
|
21
|
+
cancelled: 'Compaction cancelled.',
|
|
22
|
+
changed: 'The history selected for compaction changed before it could be replaced. The conversation is unchanged.',
|
|
23
|
+
summary: 'Compaction could not produce a useful summary. The conversation is unchanged.',
|
|
24
|
+
commit: 'Compaction did not finish cleanly; inspect the current session state before retrying.',
|
|
25
|
+
persistence: 'Compaction finished, but the session could not be saved.',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function success(text, sourceEventSeq) {
|
|
29
|
+
return sourceEventSeq === undefined ? { kind: 'success', text } : { kind: 'success', text, sourceEventSeq }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function failure(text) {
|
|
33
|
+
return { kind: 'error', text }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One `key=value` runtime assignment, or a diagnostic string. */
|
|
37
|
+
function applySet(engine, key, rawValue) {
|
|
38
|
+
const kind = RUNTIME_SETTABLE[key]
|
|
39
|
+
if (kind === undefined) return `unknown key "${key}" — settable: ${Object.keys(RUNTIME_SETTABLE).join(', ')}`
|
|
40
|
+
const value = rawValue.toLowerCase()
|
|
41
|
+
if (kind === 'boolean') {
|
|
42
|
+
if (!['on', 'off', 'true', 'false'].includes(value)) return `${key} expects on/off/true/false`
|
|
43
|
+
engine.dcp[key] = value === 'on' || value === 'true'
|
|
44
|
+
return `${key} = ${engine.dcp[key]} (this session)`
|
|
45
|
+
}
|
|
46
|
+
if (kind === 'language') {
|
|
47
|
+
if (value !== 'en' && value !== 'zh') return 'language expects en or zh'
|
|
48
|
+
engine.dcp.language = value
|
|
49
|
+
return `language = ${value} (this session)`
|
|
50
|
+
}
|
|
51
|
+
if (kind === 'token-estimate') {
|
|
52
|
+
if (value !== 'cjk' && value !== 'ascii') return 'tokenEstimate expects cjk or ascii'
|
|
53
|
+
engine.dcp.tokenEstimate = value
|
|
54
|
+
return `tokenEstimate = ${value} (this session)`
|
|
55
|
+
}
|
|
56
|
+
const numeric = Number(rawValue)
|
|
57
|
+
if (!Number.isFinite(numeric)) return `${key} expects a number`
|
|
58
|
+
if (kind === 'ratio') {
|
|
59
|
+
if (numeric <= 0 || numeric > 1) return 'thresholdRatio expects a number in (0, 1]'
|
|
60
|
+
if (engine.config.retainRatio !== undefined && engine.config.retainRatio >= numeric) {
|
|
61
|
+
return `thresholdRatio must stay above retainRatio (${engine.config.retainRatio})`
|
|
62
|
+
}
|
|
63
|
+
engine.config = Object.freeze({ ...engine.config, thresholdRatio: numeric })
|
|
64
|
+
return `thresholdRatio = ${numeric} (this session)`
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isInteger(numeric) || numeric < 1) return `${key} expects a positive integer`
|
|
67
|
+
engine.dcp[key] = numeric
|
|
68
|
+
return `${key} = ${numeric} (this session)`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Human-readable engine status block. */
|
|
72
|
+
function statusText(engine, version) {
|
|
73
|
+
const stats = engine.dcpStats
|
|
74
|
+
const lines = [
|
|
75
|
+
`dsh-dcp ${version} — deterministic compaction backend (zero LLM summarization calls)`,
|
|
76
|
+
`config: dedup=${engine.dcp.dedup} purgeErrors=${engine.dcp.purgeErrors} maxItems=${engine.dcp.maxItems} maxItemChars=${engine.dcp.maxItemChars} maxSummaryTokens=${engine.dcp.maxSummaryTokens} language=${engine.dcp.language} tokenEstimate=${engine.dcp.tokenEstimate} protectedTools=[${engine.dcp.protectedTools.join(', ')}] thresholdRatio=${engine.config.thresholdRatio}`,
|
|
77
|
+
`stats: ${stats.compactions} compaction${stats.compactions === 1 ? '' : 's'}, ~${stats.shadowedTokens} tokens shadowed, ${stats.compactions} LLM summary call${stats.compactions === 1 ? '' : 's'} avoided`,
|
|
78
|
+
]
|
|
79
|
+
if (stats.lastAt !== null) lines.push(`last compaction: ${new Date(stats.lastAt).toLocaleString()}`)
|
|
80
|
+
return lines.join('\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `/dcp compact` — manual deterministic compaction through the seam. */
|
|
84
|
+
async function compactNow(ctx, invocation, engine) {
|
|
85
|
+
try {
|
|
86
|
+
const result = await ctx.compaction.compactNow(invocation.agent, invocation.signal, invocation.commandId)
|
|
87
|
+
if (result === null) return success('No compactable history yet.')
|
|
88
|
+
return success(
|
|
89
|
+
`Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens) deterministically — no LLM call used.`,
|
|
90
|
+
result.summarySeq,
|
|
91
|
+
)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (invocation.signal.aborted) return failure(FAILURE_TEXT.cancelled)
|
|
94
|
+
if (error instanceof ManualCompactionError) return failure(FAILURE_TEXT[error.code] ?? error.message)
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Dispatch one `/dcp` invocation. */
|
|
100
|
+
export async function executeDcp(ctx, invocation, engine, version) {
|
|
101
|
+
const tokens = invocation.rawInput.trim().split(/\s+/).filter((token) => token.length > 0)
|
|
102
|
+
const [subcommand, ...rest] = tokens
|
|
103
|
+
if (subcommand === undefined) return success(statusText(engine, version))
|
|
104
|
+
if (subcommand === 'status') return success(statusText(engine, version))
|
|
105
|
+
if (subcommand === 'help') return success(USAGE)
|
|
106
|
+
if (subcommand === 'compact') return compactNow(ctx, invocation, engine)
|
|
107
|
+
if (subcommand === 'set') {
|
|
108
|
+
if (rest.length < 2) return failure(USAGE)
|
|
109
|
+
const outcome = applySet(engine, rest[0], rest.slice(1).join(' '))
|
|
110
|
+
if (!/^[\w.]+ = /.test(outcome)) return failure(outcome)
|
|
111
|
+
return success(
|
|
112
|
+
`${outcome}\npersist across restarts in ~/.dsh/cordis.patch.yml:\n - id: compaction-basic\n name: ${engine.pluginPath ?? '<abs path to dsh-dcp>/lib/index.js'}\n config:\n ${rest[0]}: ${rest.slice(1).join(' ')}`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
return failure(`unknown subcommand "${subcommand}"\n${USAGE}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Register `/dcp` on the host command registry.
|
|
120
|
+
* @returns {() => void} disposer for cordis effect teardown.
|
|
121
|
+
*/
|
|
122
|
+
export function registerDcpCommand(ctx, engine, version) {
|
|
123
|
+
return ctx.commands.register({
|
|
124
|
+
name: 'dcp',
|
|
125
|
+
description: 'dsh-dcp: deterministic compaction status and controls',
|
|
126
|
+
handler: (invocation) => executeDcp(ctx, invocation, engine, version),
|
|
127
|
+
})
|
|
128
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-dcp configuration: the dcp-specific knobs layered on top of
|
|
3
|
+
* compaction-basic's policy keys. Everything here is optional — the resolved
|
|
4
|
+
* defaults are the documented out-of-the-box behavior.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-dcp/config
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** dcp-specific keys this plugin adds on top of compaction-basic. */
|
|
10
|
+
export const DCP_CONFIG_KEYS = [
|
|
11
|
+
'dedup',
|
|
12
|
+
'purgeErrors',
|
|
13
|
+
'maxItems',
|
|
14
|
+
'maxItemChars',
|
|
15
|
+
'maxSummaryTokens',
|
|
16
|
+
'language',
|
|
17
|
+
'tokenEstimate',
|
|
18
|
+
'protectedTools',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/** compaction-basic policy keys forwarded to the parent engine verbatim. */
|
|
22
|
+
export const BASIC_CONFIG_KEYS = [
|
|
23
|
+
'thresholdRatio',
|
|
24
|
+
'retainRatio',
|
|
25
|
+
'retainTokens',
|
|
26
|
+
'summarizationProvider',
|
|
27
|
+
'summarizationModel',
|
|
28
|
+
'maxTokens',
|
|
29
|
+
'compactionRetries',
|
|
30
|
+
'maxOverflowRetries',
|
|
31
|
+
'modelPolicies',
|
|
32
|
+
'auto',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const DEFAULTS = Object.freeze({
|
|
36
|
+
dedup: true,
|
|
37
|
+
purgeErrors: true,
|
|
38
|
+
maxItems: 10,
|
|
39
|
+
maxItemChars: 200,
|
|
40
|
+
maxSummaryTokens: 2048,
|
|
41
|
+
language: 'en',
|
|
42
|
+
tokenEstimate: 'cjk',
|
|
43
|
+
protectedTools: Object.freeze(['write', 'edit', 'apply_patch']),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Split one loader-level plugin config into the parent engine's keys and the
|
|
48
|
+
* dcp keys, rejecting unknown keys loudly (mirrors compaction-basic's own
|
|
49
|
+
* fail-fast validation so typos never hide behind defaults).
|
|
50
|
+
*
|
|
51
|
+
* @param {Record<string, unknown>} config - untrusted plugin configuration.
|
|
52
|
+
* @returns {{ basic: Record<string, unknown>, dcp: Record<string, unknown> }}
|
|
53
|
+
*/
|
|
54
|
+
export function splitConfig(config = {}) {
|
|
55
|
+
const known = new Set([...BASIC_CONFIG_KEYS, ...DCP_CONFIG_KEYS])
|
|
56
|
+
for (const key of Object.keys(config)) {
|
|
57
|
+
if (!known.has(key)) throw new Error(`DcpConfig: unknown key "${key}"`)
|
|
58
|
+
}
|
|
59
|
+
const basic = {}
|
|
60
|
+
for (const key of BASIC_CONFIG_KEYS) {
|
|
61
|
+
if (config[key] !== undefined) basic[key] = config[key]
|
|
62
|
+
}
|
|
63
|
+
const dcp = {}
|
|
64
|
+
for (const key of DCP_CONFIG_KEYS) {
|
|
65
|
+
if (config[key] !== undefined) dcp[key] = config[key]
|
|
66
|
+
}
|
|
67
|
+
return { basic, dcp }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate and resolve dcp defaults.
|
|
72
|
+
*
|
|
73
|
+
* @param {Record<string, unknown>} raw - the dcp half of {@link splitConfig}.
|
|
74
|
+
* @returns {Readonly<{dedup: boolean, purgeErrors: boolean, maxItems: number, maxItemChars: number, maxSummaryTokens: number, language: 'en'|'zh', tokenEstimate: 'cjk'|'ascii', protectedTools: readonly string[]}>}
|
|
75
|
+
*/
|
|
76
|
+
export function resolveDcpConfig(raw = {}) {
|
|
77
|
+
if (raw.dedup !== undefined && typeof raw.dedup !== 'boolean') {
|
|
78
|
+
throw new Error('DcpConfig: dedup must be a boolean')
|
|
79
|
+
}
|
|
80
|
+
if (raw.purgeErrors !== undefined && typeof raw.purgeErrors !== 'boolean') {
|
|
81
|
+
throw new Error('DcpConfig: purgeErrors must be a boolean')
|
|
82
|
+
}
|
|
83
|
+
if (raw.language !== undefined && raw.language !== 'en' && raw.language !== 'zh') {
|
|
84
|
+
throw new Error('DcpConfig: language must be "en" or "zh"')
|
|
85
|
+
}
|
|
86
|
+
if (raw.tokenEstimate !== undefined && raw.tokenEstimate !== 'cjk' && raw.tokenEstimate !== 'ascii') {
|
|
87
|
+
throw new Error('DcpConfig: tokenEstimate must be "cjk" or "ascii"')
|
|
88
|
+
}
|
|
89
|
+
for (const key of ['maxItems', 'maxItemChars', 'maxSummaryTokens']) {
|
|
90
|
+
const value = raw[key]
|
|
91
|
+
if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < 1)) {
|
|
92
|
+
throw new Error(`DcpConfig: ${key} (${String(value)}) must be a positive integer`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (raw.protectedTools !== undefined) {
|
|
96
|
+
if (!Array.isArray(raw.protectedTools) || raw.protectedTools.some((item) => typeof item !== 'string' || item.length === 0)) {
|
|
97
|
+
throw new Error('DcpConfig: protectedTools must be an array of non-empty strings')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return Object.freeze({ ...DEFAULTS, ...raw })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Keys `/dcp set` may adjust at runtime, with their value kind for parsing. */
|
|
104
|
+
export const RUNTIME_SETTABLE = Object.freeze({
|
|
105
|
+
dedup: 'boolean',
|
|
106
|
+
purgeErrors: 'boolean',
|
|
107
|
+
maxItems: 'positive-integer',
|
|
108
|
+
maxItemChars: 'positive-integer',
|
|
109
|
+
maxSummaryTokens: 'positive-integer',
|
|
110
|
+
language: 'language',
|
|
111
|
+
tokenEstimate: 'token-estimate',
|
|
112
|
+
thresholdRatio: 'ratio',
|
|
113
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-dcp — deterministic context-pruning compaction backend for dsh.
|
|
3
|
+
*
|
|
4
|
+
* Replaces `compaction-basic`'s LLM summarization with a deterministic,
|
|
5
|
+
* template-based checkpoint extractor: zero auxiliary LLM calls per
|
|
6
|
+
* compaction, stable output for identical input, Chinese-friendly trigger
|
|
7
|
+
* tuning via the usual policy keys. Everything else — pressure triggers,
|
|
8
|
+
* retention, overflow recovery, durable transactions, tool-pairing safety —
|
|
9
|
+
* is inherited from `BasicCompactionEngine`, whose `summarize()` is the sole
|
|
10
|
+
* customization seam (see docs/subsystems/compaction.md in deepseek-harness).
|
|
11
|
+
*
|
|
12
|
+
* Design references Opencode-DCP/opencode-dynamic-context-pruning: dedup
|
|
13
|
+
* repeated tool calls, purge stale errors, technical summaries instead of
|
|
14
|
+
* prose, `/dcp` command, defaults that work with no configuration.
|
|
15
|
+
*
|
|
16
|
+
* Mount (home-level patch, `~/.dsh/cordis.patch.yml`):
|
|
17
|
+
*
|
|
18
|
+
* ```yaml
|
|
19
|
+
* - id: compaction-basic
|
|
20
|
+
* name: /absolute/path/to/dsh-dcp/lib/index.js
|
|
21
|
+
* config:
|
|
22
|
+
* thresholdRatio: 0.7 # optional; every key is optional
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-dcp
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createRequire } from 'node:module'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
import z from '@deepseek-ai/schemastery'
|
|
31
|
+
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
|
|
32
|
+
import { splitConfig, resolveDcpConfig } from './config.js'
|
|
33
|
+
import { summarizeDeterministically } from './summarizer.js'
|
|
34
|
+
import { registerDcpCommand } from './command.js'
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url)
|
|
37
|
+
const { version: VERSION } = require('../package.json')
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Deterministic compaction engine: `summarize()` overridden, everything else
|
|
41
|
+
* inherited. Registers the `/dcp` command beside the inherited `/compact`.
|
|
42
|
+
*/
|
|
43
|
+
/** Element schema mirroring compaction-basic's model-policy override shape. */
|
|
44
|
+
const modelPolicy = z.object({
|
|
45
|
+
provider: z.string().required(),
|
|
46
|
+
model: z.string().required(),
|
|
47
|
+
thresholdRatio: z.number(),
|
|
48
|
+
retainRatio: z.number(),
|
|
49
|
+
retainTokens: z.number().step(1).min(0),
|
|
50
|
+
summarizationProvider: z.string(),
|
|
51
|
+
summarizationModel: z.string(),
|
|
52
|
+
maxTokens: z.number().step(1).min(1),
|
|
53
|
+
compactionRetries: z.number().step(1).min(0),
|
|
54
|
+
maxOverflowRetries: z.number().step(1).min(0),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
export class DcpEngine extends BasicCompactionEngine {
|
|
58
|
+
static inject = ['llm', 'tokenMeter', 'sessions', 'commands']
|
|
59
|
+
|
|
60
|
+
static Config = z.object({
|
|
61
|
+
// compaction-basic policy keys (forwarded verbatim)
|
|
62
|
+
thresholdRatio: z.number(),
|
|
63
|
+
retainRatio: z.number(),
|
|
64
|
+
retainTokens: z.number().step(1).min(0),
|
|
65
|
+
summarizationProvider: z.string(),
|
|
66
|
+
summarizationModel: z.string(),
|
|
67
|
+
maxTokens: z.number().step(1).min(1),
|
|
68
|
+
compactionRetries: z.number().step(1).min(0),
|
|
69
|
+
maxOverflowRetries: z.number().step(1).min(0),
|
|
70
|
+
modelPolicies: z.array(modelPolicy),
|
|
71
|
+
auto: z.boolean(),
|
|
72
|
+
// dsh-dcp knobs
|
|
73
|
+
dedup: z.boolean(),
|
|
74
|
+
purgeErrors: z.boolean(),
|
|
75
|
+
maxItems: z.number().step(1).min(1),
|
|
76
|
+
maxItemChars: z.number().step(1).min(1),
|
|
77
|
+
maxSummaryTokens: z.number().step(1).min(1),
|
|
78
|
+
language: z.string(),
|
|
79
|
+
tokenEstimate: z.string(),
|
|
80
|
+
protectedTools: z.array(z.string()),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/** Resolved dcp knobs; mutable at runtime through `/dcp set`. */
|
|
84
|
+
dcp
|
|
85
|
+
|
|
86
|
+
/** Compaction counters surfaced by `/dcp`. */
|
|
87
|
+
dcpStats
|
|
88
|
+
|
|
89
|
+
/** Absolute module path, echoed by `/dcp set` for persistence snippets. */
|
|
90
|
+
pluginPath
|
|
91
|
+
|
|
92
|
+
constructor(ctx, config = {}) {
|
|
93
|
+
const { basic, dcp } = splitConfig(config)
|
|
94
|
+
super(ctx, basic)
|
|
95
|
+
this.dcp = { ...resolveDcpConfig(dcp) }
|
|
96
|
+
this.dcpStats = { compactions: 0, shadowedTokens: 0, lastAt: null }
|
|
97
|
+
this.pluginPath = fileURLToPath(import.meta.url)
|
|
98
|
+
const engine = this
|
|
99
|
+
ctx.effect(function* () {
|
|
100
|
+
yield registerDcpCommand(ctx, engine, VERSION)
|
|
101
|
+
}, 'dsh-dcp /dcp command lifecycle')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The sole overridden seam: condense the replayed region deterministically.
|
|
106
|
+
* No LLM call, no cancellation window beyond the fast synchronous walk.
|
|
107
|
+
* Budgeting uses the CJK-aware estimator unless `tokenEstimate: ascii` is set.
|
|
108
|
+
*/
|
|
109
|
+
async summarize(input, agent, signal) {
|
|
110
|
+
signal?.throwIfAborted()
|
|
111
|
+
try {
|
|
112
|
+
return summarizeDeterministically(input, this.dcp)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
115
|
+
throw new Error(`dsh-dcp deterministic summarization failed: ${message}`, { cause: error })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Count every committed compaction (automatic and manual) for `/dcp`. */
|
|
120
|
+
async compactRegion(start, end, agent, signal) {
|
|
121
|
+
const result = await super.compactRegion(start, end, agent, signal)
|
|
122
|
+
this.dcpStats.compactions += 1
|
|
123
|
+
this.dcpStats.shadowedTokens += result.shadowedTokenCount
|
|
124
|
+
this.dcpStats.lastAt = Date.now()
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default DcpEngine
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic region summarizer — the dsh-dcp core.
|
|
3
|
+
*
|
|
4
|
+
* Where compaction-basic replays the region into an LLM summarization call,
|
|
5
|
+
* this module condenses the same replayed messages with pure code: verbatim
|
|
6
|
+
* user intents, touched files, executed commands, one-line errors, pending
|
|
7
|
+
* todos, duplicate tool calls, and the durable facts of a prior checkpoint.
|
|
8
|
+
* Zero LLM calls, stable output for identical input (design references
|
|
9
|
+
* Opencode-DCP/opencode-dynamic-context-pruning: dedup, error purge,
|
|
10
|
+
* "technical summary instead of prose").
|
|
11
|
+
*
|
|
12
|
+
* The output keeps compaction-basic's checkpoint section names so downstream
|
|
13
|
+
* consumers (and a later basic-engine compaction merging prior
|
|
14
|
+
* `<compacted-summary>` blocks) see a familiar structure.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-dcp/summarizer
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Section headers shared with compaction-basic's checkpoint instruction. */
|
|
20
|
+
export const SECTIONS = Object.freeze({
|
|
21
|
+
intent: 'Primary Request and Intent',
|
|
22
|
+
concepts: 'Key Technical Concepts',
|
|
23
|
+
files: 'Files and Code',
|
|
24
|
+
errors: 'Errors and Fixes',
|
|
25
|
+
todos: 'Pending Jobs',
|
|
26
|
+
current: 'Current Work',
|
|
27
|
+
next: 'Next Step',
|
|
28
|
+
context: 'Critical Context',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** Prior-checkpoint sections worth carrying forward (stale ones regenerate). */
|
|
32
|
+
const CARRIED_SECTIONS = new Set([
|
|
33
|
+
SECTIONS.intent,
|
|
34
|
+
SECTIONS.concepts,
|
|
35
|
+
SECTIONS.files,
|
|
36
|
+
SECTIONS.errors,
|
|
37
|
+
SECTIONS.context,
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
|
41
|
+
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
|
42
|
+
|
|
43
|
+
/** Arg keys whose string value names a filesystem path. */
|
|
44
|
+
const PATH_KEYS = ['file_path', 'absolute_path', 'notebook_path', 'path', 'glob', 'pattern']
|
|
45
|
+
/** Arg keys whose string value is a shell command. */
|
|
46
|
+
const COMMAND_KEYS = ['command', 'cmd', 'script']
|
|
47
|
+
/** Tool-name fragments that mark a mutating (write-side) call. */
|
|
48
|
+
const WRITE_TOOL = /write|edit|patch|delete|remove|mkdir|move|rename|create/i
|
|
49
|
+
/** Command vocabulary lifted into Key Technical Concepts. */
|
|
50
|
+
const COMMAND_CONCEPTS = /\b(git|npm|pnpm|yarn|bun|cargo|go|python|pip|uv|docker|kubectl|helm|make|gradle|maven|curl|terraform)\b/gi
|
|
51
|
+
/** Checkbox todo lines in any user/assistant text. */
|
|
52
|
+
const TODO_LINE = /^\s*[-*]\s+\[( |x|X)\]\s*(.+)$/gm
|
|
53
|
+
/** English todo markers, colon-terminated to avoid prose hits. */
|
|
54
|
+
const MARKER_TODO_EN = /^\s*(?:TODO|FIXME)\s*[::]\s*(.+)$/gim
|
|
55
|
+
/** Chinese todo marker, colon-terminated. */
|
|
56
|
+
const MARKER_TODO_ZH = /^\s*待办\s*[::]\s*(.+)$/gim
|
|
57
|
+
/** First-line signals of a tool error, English set (terminal locale LANG=C). */
|
|
58
|
+
const ERROR_LINE_EN = /(error|failed|failure|fatal|exception|enoent|eacces|eperm|denied|refused|not found|cannot |unable |exit code [1-9])/i
|
|
59
|
+
/** First-line signals of a tool error, Chinese set — dsh-bash and friends can surface localized failures. */
|
|
60
|
+
const ERROR_LINE_ZH = /(失败|错误|报错|异常|找不到|未找到|不存在|无法|拒绝|超时|崩溃|致命)/i
|
|
61
|
+
|
|
62
|
+
/** Error regexes active for one output language. */
|
|
63
|
+
function errorPatterns(language) {
|
|
64
|
+
return language === 'zh' ? [ERROR_LINE_EN, ERROR_LINE_ZH] : [ERROR_LINE_EN]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Todo-line regexes active for one output language, split by shape. */
|
|
68
|
+
function todoPatterns(language) {
|
|
69
|
+
const markers = language === 'zh' ? [MARKER_TODO_EN, MARKER_TODO_ZH] : [MARKER_TODO_EN]
|
|
70
|
+
return { checkbox: TODO_LINE, markers }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* CJK scripts and full-width forms priced denser than ASCII: Han (中文), kana
|
|
75
|
+
* (日文), hangul (韩文), plus CJK punctuation and full-width variants. CJK is
|
|
76
|
+
* not only Chinese — every script here encodes a character in roughly one
|
|
77
|
+
* token in real tokenizers.
|
|
78
|
+
*/
|
|
79
|
+
const CJK_CHAR = /[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef\uac00-\ud7af]/g
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Token heuristic, selectable by the `tokenEstimate` config:
|
|
83
|
+
*
|
|
84
|
+
* - `cjk` (default): CJK scripts at ~2 chars/token, ASCII at 4. The host
|
|
85
|
+
* meter prices every character at 4/token, which matches English but
|
|
86
|
+
* underestimates CJK by ~2x, so CJK-heavy regions get compacted late and
|
|
87
|
+
* starved of budget. This mode prices all CJK scripts near reality while
|
|
88
|
+
* staying identical to the host for pure-ASCII text.
|
|
89
|
+
* - `ascii`: flat 4 chars/token for every character — exactly the host
|
|
90
|
+
* meter's numbers, for users who want byte-identical behavior.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} text - text to price.
|
|
93
|
+
* @param {'cjk'|'ascii'} [mode] - pricing mode.
|
|
94
|
+
* @returns {number} estimated tokens.
|
|
95
|
+
*/
|
|
96
|
+
export function estimateTextTokens(text, mode = 'cjk') {
|
|
97
|
+
const source = String(text)
|
|
98
|
+
if (mode === 'ascii') return Math.ceil(source.length / 4)
|
|
99
|
+
const cjk = (source.match(CJK_CHAR) ?? []).length
|
|
100
|
+
return Math.ceil(cjk / 2 + (source.length - cjk) / 4)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Token estimate for one message: text blocks, tool-call arguments, and
|
|
105
|
+
* tool-result payloads (the same content the host meter prices, minus its
|
|
106
|
+
* per-block overhead).
|
|
107
|
+
*
|
|
108
|
+
* @param {{ content?: Array<{ type: string }> }} message - any message-shaped object.
|
|
109
|
+
* @param {'cjk'|'ascii'} [mode] - pricing mode, see {@link estimateTextTokens}.
|
|
110
|
+
* @returns {number} estimated tokens.
|
|
111
|
+
*/
|
|
112
|
+
export function estimateMessageTokens(message, mode = 'cjk') {
|
|
113
|
+
const parts = []
|
|
114
|
+
for (const block of message?.content ?? []) {
|
|
115
|
+
if (block.type === 'text') parts.push(block.text)
|
|
116
|
+
else if (block.type === 'tool-call') parts.push(block.arguments)
|
|
117
|
+
else if (block.type === 'tool-result') {
|
|
118
|
+
for (const inner of block.content ?? []) {
|
|
119
|
+
if (inner.type === 'text') parts.push(inner.text)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return estimateTextTokens(parts.join('\n'), mode)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const i18n = Object.freeze({
|
|
127
|
+
en: Object.freeze({
|
|
128
|
+
none: '(none)',
|
|
129
|
+
elidedIntents: '({n} earlier user messages elided)',
|
|
130
|
+
elidedErrors: '({n} earlier errors elided)',
|
|
131
|
+
dedupNote: '{tool}({args}) ran {n}x — identical repeats, latest result kept in the retained tail',
|
|
132
|
+
contextNote: '{messages} messages / {calls} tool calls compacted deterministically by dsh-dcp (no LLM summarization call)',
|
|
133
|
+
carried: 'carried from prior checkpoint',
|
|
134
|
+
terseHeader: '{messages} messages ({calls} tool calls) compacted deterministically by dsh-dcp.',
|
|
135
|
+
}),
|
|
136
|
+
zh: Object.freeze({
|
|
137
|
+
none: '(无)',
|
|
138
|
+
elidedIntents: '(省略 {n} 条较早的用户消息)',
|
|
139
|
+
elidedErrors: '(省略 {n} 条较早的报错)',
|
|
140
|
+
dedupNote: '{tool}({args}) 执行了 {n} 次 —— 重复调用,仅保留最近一次结果',
|
|
141
|
+
contextNote: 'dsh-dcp 确定性压缩了 {messages} 条消息 / {calls} 次工具调用(未调用 LLM 摘要)',
|
|
142
|
+
carried: '继承自上一次压缩检查点',
|
|
143
|
+
terseHeader: 'dsh-dcp 确定性压缩了 {messages} 条消息({calls} 次工具调用)。',
|
|
144
|
+
}),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/** Collapse whitespace and hard-cap one item's length with an ellipsis. */
|
|
148
|
+
export function clip(text, maxChars) {
|
|
149
|
+
const flat = String(text).replace(/\s+/g, ' ').trim()
|
|
150
|
+
return flat.length <= maxChars ? flat : flat.slice(0, maxChars - 1) + '…'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Message text blocks joined with newlines (tool results and reasoning excluded). */
|
|
154
|
+
function textOf(message) {
|
|
155
|
+
return message.content
|
|
156
|
+
.filter((block) => block.type === 'text')
|
|
157
|
+
.map((block) => block.text)
|
|
158
|
+
.join('\n')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Re-injected host context that appears as user-role messages every turn:
|
|
163
|
+
* system-prompt snapshots, skill catalogs, and AGENTS.md-style instructions.
|
|
164
|
+
* The host re-sends these on every request, so copying them into a checkpoint
|
|
165
|
+
* is pure waste — the model never loses them. (A prior compaction checkpoint
|
|
166
|
+
* is handled separately, and plugin notice/relay/recall forms stay.)
|
|
167
|
+
*/
|
|
168
|
+
function isInjectedContext(message) {
|
|
169
|
+
const kind = message.source?.kind
|
|
170
|
+
if (kind === 'skill-catalog' || kind === 'agent-instructions') return true
|
|
171
|
+
return kind === 'plugin' && message.source.form === 'snapshot'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One-line summary a producer attached to a `notice`-form message (e.g. a
|
|
176
|
+
* settled subagent's closing account). Prefer it over the full text: it is
|
|
177
|
+
* the host's own terse condensation, bounded by CONTEXT_SUMMARY_MAX_CHARS.
|
|
178
|
+
*/
|
|
179
|
+
function noticeSummary(message) {
|
|
180
|
+
const source = message.source
|
|
181
|
+
if (source?.form === 'notice' && typeof source.summary === 'string' && source.summary.length > 0) {
|
|
182
|
+
return source.summary
|
|
183
|
+
}
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Tool-result blocks flattened as text, first non-empty line kept per block. */
|
|
188
|
+
function resultFirstLines(blocks) {
|
|
189
|
+
const lines = []
|
|
190
|
+
for (const block of blocks) {
|
|
191
|
+
if (block.type !== 'tool-result') continue
|
|
192
|
+
const text = block.content
|
|
193
|
+
.filter((inner) => inner.type === 'text')
|
|
194
|
+
.map((inner) => inner.text)
|
|
195
|
+
.join('\n')
|
|
196
|
+
.split('\n')
|
|
197
|
+
.map((line) => line.trim())
|
|
198
|
+
.find((line) => line.length > 0)
|
|
199
|
+
if (text !== undefined) lines.push({ isError: block.isError === true, line: text })
|
|
200
|
+
}
|
|
201
|
+
return lines
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Parse one prior checkpoint text into its `## ` sections.
|
|
206
|
+
* @returns {Map<string, string[]>} header → bullet/item lines (markers stripped).
|
|
207
|
+
*/
|
|
208
|
+
export function parseCheckpointSections(text) {
|
|
209
|
+
const inner = text.includes(SUMMARY_OPEN_TAG)
|
|
210
|
+
? text.slice(text.indexOf(SUMMARY_OPEN_TAG) + SUMMARY_OPEN_TAG.length, text.lastIndexOf(SUMMARY_CLOSE_TAG))
|
|
211
|
+
: text
|
|
212
|
+
const sections = new Map()
|
|
213
|
+
let header = ''
|
|
214
|
+
for (const line of inner.split('\n')) {
|
|
215
|
+
const match = /^##\s+(.+?)\s*$/.exec(line)
|
|
216
|
+
if (match !== null) {
|
|
217
|
+
header = match[1]
|
|
218
|
+
if (!sections.has(header)) sections.set(header, [])
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if (header !== '' && line.trim().length > 0) sections.get(header).push(line.trim().replace(/^[-*]\s+/, ''))
|
|
222
|
+
}
|
|
223
|
+
return sections
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** File path named by a parsed tool-call argument, when there is one. */
|
|
227
|
+
function argPath(parsed) {
|
|
228
|
+
if (parsed === undefined || parsed === null) return undefined
|
|
229
|
+
for (const key of PATH_KEYS) {
|
|
230
|
+
const value = parsed[key]
|
|
231
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
232
|
+
}
|
|
233
|
+
return undefined
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Shell command named by a parsed tool-call argument, when there is one. */
|
|
237
|
+
function argCommand(parsed) {
|
|
238
|
+
if (parsed === undefined || parsed === null) return undefined
|
|
239
|
+
for (const key of COMMAND_KEYS) {
|
|
240
|
+
const value = parsed[key]
|
|
241
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
242
|
+
}
|
|
243
|
+
return undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Short display form of one call's arguments for dedup notes. */
|
|
247
|
+
function argDisplay(name, parsed) {
|
|
248
|
+
const path = argPath(parsed)
|
|
249
|
+
if (path !== undefined) return clip(path, 80)
|
|
250
|
+
const command = argCommand(parsed)
|
|
251
|
+
if (command !== undefined) return clip(command, 80)
|
|
252
|
+
if (parsed !== undefined && parsed !== null && typeof parsed === 'object') {
|
|
253
|
+
const first = Object.values(parsed).find((value) => typeof value === 'string' && value.length > 0)
|
|
254
|
+
if (first !== undefined) return clip(first, 80)
|
|
255
|
+
}
|
|
256
|
+
return ''
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Walk the replayed region and collect every deterministic fact.
|
|
261
|
+
*
|
|
262
|
+
* @param {import('@deepseek-ai/dsh-llm').Message[]} messages - region messages in surface order.
|
|
263
|
+
* @param {'en'|'zh'} [language] - which error/todo keyword set applies; `zh` also
|
|
264
|
+
* recognizes localized (Chinese) failures and `待办` markers.
|
|
265
|
+
* @returns {object} the extracted fact bundle.
|
|
266
|
+
*/
|
|
267
|
+
export function extractFacts(messages, language = 'en') {
|
|
268
|
+
const facts = {
|
|
269
|
+
messageCount: messages.length,
|
|
270
|
+
intents: [],
|
|
271
|
+
files: new Map(),
|
|
272
|
+
commands: [],
|
|
273
|
+
errors: [],
|
|
274
|
+
pendingTodos: [],
|
|
275
|
+
concepts: new Set(),
|
|
276
|
+
dupCounts: new Map(),
|
|
277
|
+
toolCallCount: 0,
|
|
278
|
+
lastUserText: '',
|
|
279
|
+
lastAssistantText: '',
|
|
280
|
+
carried: new Map(),
|
|
281
|
+
}
|
|
282
|
+
const callsById = new Map()
|
|
283
|
+
const errorRegexes = errorPatterns(language)
|
|
284
|
+
const { checkbox, markers } = todoPatterns(language)
|
|
285
|
+
|
|
286
|
+
const rememberTodoLines = (text) => {
|
|
287
|
+
checkbox.lastIndex = 0
|
|
288
|
+
for (const match of text.matchAll(checkbox)) {
|
|
289
|
+
const item = match[2].trim()
|
|
290
|
+
if (item.length === 0) continue
|
|
291
|
+
if (match[1] === ' ') {
|
|
292
|
+
if (!facts.pendingTodos.some((existing) => existing === item)) facts.pendingTodos.push(item)
|
|
293
|
+
} else {
|
|
294
|
+
const index = facts.pendingTodos.indexOf(item)
|
|
295
|
+
if (index !== -1) facts.pendingTodos.splice(index, 1)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const pattern of markers) {
|
|
299
|
+
pattern.lastIndex = 0
|
|
300
|
+
for (const match of text.matchAll(pattern)) {
|
|
301
|
+
const item = match[1].trim()
|
|
302
|
+
if (item.length > 0 && !facts.pendingTodos.some((existing) => existing === item)) facts.pendingTodos.push(item)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const message of messages) {
|
|
308
|
+
if (message.role === 'assistant') {
|
|
309
|
+
const text = textOf(message)
|
|
310
|
+
if (text.trim().length > 0) {
|
|
311
|
+
facts.lastAssistantText = text
|
|
312
|
+
rememberTodoLines(text)
|
|
313
|
+
}
|
|
314
|
+
for (const block of message.content) {
|
|
315
|
+
if (block.type !== 'tool-call') continue
|
|
316
|
+
facts.toolCallCount += 1
|
|
317
|
+
let parsed
|
|
318
|
+
try {
|
|
319
|
+
parsed = JSON.parse(block.arguments)
|
|
320
|
+
} catch {
|
|
321
|
+
parsed = undefined
|
|
322
|
+
}
|
|
323
|
+
const info = { name: block.name, parsed }
|
|
324
|
+
callsById.set(block.id, info)
|
|
325
|
+
|
|
326
|
+
const path = argPath(parsed)
|
|
327
|
+
if (path !== undefined) {
|
|
328
|
+
const entry = facts.files.get(path) ?? { reads: 0, writes: 0 }
|
|
329
|
+
if (WRITE_TOOL.test(block.name)) entry.writes += 1
|
|
330
|
+
else entry.reads += 1
|
|
331
|
+
facts.files.set(path, entry)
|
|
332
|
+
const extension = /\.([a-z0-9]{1,5})$/i.exec(path)
|
|
333
|
+
if (extension !== null) facts.concepts.add(extension[1].toLowerCase())
|
|
334
|
+
}
|
|
335
|
+
const command = argCommand(parsed)
|
|
336
|
+
if (command !== undefined) {
|
|
337
|
+
facts.commands.push(command)
|
|
338
|
+
for (const match of command.match(COMMAND_CONCEPTS) ?? []) facts.concepts.add(match.toLowerCase())
|
|
339
|
+
}
|
|
340
|
+
const key = `${block.name}\u0000${block.arguments}`
|
|
341
|
+
const dup = facts.dupCounts.get(key) ?? { name: block.name, parsed, count: 0 }
|
|
342
|
+
dup.count += 1
|
|
343
|
+
facts.dupCounts.set(key, dup)
|
|
344
|
+
}
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// user-role messages: tool results, prior checkpoints, real user text
|
|
349
|
+
const results = resultFirstLines(message.content)
|
|
350
|
+
if (results.length > 0) {
|
|
351
|
+
for (const result of results) {
|
|
352
|
+
const isError = result.isError || errorRegexes.some((pattern) => pattern.test(result.line))
|
|
353
|
+
if (!isError) continue
|
|
354
|
+
const call = message.source?.kind === 'tool' ? callsById.get(message.source.callId) : undefined
|
|
355
|
+
const who = call === undefined ? 'tool' : call.name
|
|
356
|
+
facts.errors.push(`${who}: ${result.line}`)
|
|
357
|
+
}
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
const text = textOf(message)
|
|
361
|
+
if (text.trim().length === 0) continue
|
|
362
|
+
if (message.source?.kind === 'plugin' && message.source.plugin === 'compact') {
|
|
363
|
+
facts.carried = parseCheckpointSections(text)
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
if (isInjectedContext(message)) continue
|
|
367
|
+
const intentText = noticeSummary(message) ?? text
|
|
368
|
+
facts.lastUserText = intentText
|
|
369
|
+
facts.intents.push(intentText)
|
|
370
|
+
rememberTodoLines(intentText)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const command of facts.commands) facts.concepts.add(command.split(/\s+/)[0]?.toLowerCase() ?? '')
|
|
374
|
+
return facts
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Merge carried checkpoint lines with freshly extracted ones, deduped in order. */
|
|
378
|
+
function mergeCarried(facts, maxItems) {
|
|
379
|
+
const carriedOf = (header) => facts.carried.get(header) ?? []
|
|
380
|
+
const dedupe = (lines) => {
|
|
381
|
+
const seen = new Set()
|
|
382
|
+
const merged = []
|
|
383
|
+
for (const line of lines) {
|
|
384
|
+
const key = line.toLowerCase().replace(/[^a-z0-9/\u4e00-\u9fff]/g, '')
|
|
385
|
+
if (key.length === 0 || seen.has(key)) continue
|
|
386
|
+
seen.add(key)
|
|
387
|
+
merged.push(line)
|
|
388
|
+
}
|
|
389
|
+
return merged
|
|
390
|
+
}
|
|
391
|
+
const withElision = (lines, elidedTemplate, t) => {
|
|
392
|
+
if (lines.length <= maxItems) return lines
|
|
393
|
+
const kept = lines.slice(-Math.max(1, maxItems - 1))
|
|
394
|
+
kept.unshift(t(elidedTemplate).replace('{n}', String(lines.length - kept.length)))
|
|
395
|
+
return kept
|
|
396
|
+
}
|
|
397
|
+
const mergeFileLines = (fresh, carried) => {
|
|
398
|
+
const freshPaths = new Set(fresh.map((line) => line.split(/\s+[—-]\s+/)[0]?.trim() ?? line))
|
|
399
|
+
const survivors = carried.filter((line) => {
|
|
400
|
+
const path = line.split(/\s+[—-]\s+/)[0]?.trim() ?? line
|
|
401
|
+
return !freshPaths.has(path)
|
|
402
|
+
})
|
|
403
|
+
return [...fresh, ...survivors]
|
|
404
|
+
}
|
|
405
|
+
return { carriedOf, dedupe, withElision, mergeFileLines }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Render one section with bullets, or the localized "(none)"; null when dropped. */
|
|
409
|
+
function renderSection(header, items, t, dropEmpty) {
|
|
410
|
+
const unique = [...new Set(items.filter((item) => item !== undefined && item !== null && String(item).trim().length > 0))]
|
|
411
|
+
if (dropEmpty && unique.length === 0) return null
|
|
412
|
+
const body = unique.length === 0 ? [t('none')] : unique.map((item) => `- ${item}`)
|
|
413
|
+
return [`## ${header}`, ...body, '']
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Compose the deterministic checkpoint summary.
|
|
418
|
+
*
|
|
419
|
+
* @param {object} facts - {@link extractFacts} output.
|
|
420
|
+
* @param {object} options - resolved dcp config plus degradation knobs.
|
|
421
|
+
* @returns {string} markdown summary text.
|
|
422
|
+
*/
|
|
423
|
+
export function composeSummary(facts, options) {
|
|
424
|
+
const { maxItems, maxItemChars, language, dedup, purgeErrors, protectedTools = [], sectionCap = maxItems, itemChars = maxItemChars, dropEmpty = false } = options
|
|
425
|
+
const t = (key) => i18n[language][key]
|
|
426
|
+
const { carriedOf, dedupe, withElision, mergeFileLines } = mergeCarried(facts, sectionCap)
|
|
427
|
+
|
|
428
|
+
const intents = dedupe([
|
|
429
|
+
...carriedOf(SECTIONS.intent),
|
|
430
|
+
...facts.intents.map((text) => clip(text, itemChars)),
|
|
431
|
+
])
|
|
432
|
+
const intentItems = withElision(intents, 'elidedIntents', t)
|
|
433
|
+
|
|
434
|
+
const conceptItems = dedupe([...carriedOf(SECTIONS.concepts), ...facts.concepts])
|
|
435
|
+
|
|
436
|
+
const freshFileItems = [...facts.files.entries()].map(([path, ops]) =>
|
|
437
|
+
clip(`${path} — ${ops.writes > 0 ? `W×${ops.writes}${ops.reads > 0 ? ` R×${ops.reads}` : ''}` : `R×${ops.reads}`}`, itemChars),
|
|
438
|
+
)
|
|
439
|
+
const fileItems = dedupe(mergeFileLines(freshFileItems, carriedOf(SECTIONS.files))).slice(0, sectionCap)
|
|
440
|
+
|
|
441
|
+
// purgeErrors collapses older errors to an elision note (most recent kept);
|
|
442
|
+
// when disabled every distinct error survives up to the section cap.
|
|
443
|
+
const errorItems = dedupe([...carriedOf(SECTIONS.errors), ...facts.errors.map((line) => clip(line, itemChars))])
|
|
444
|
+
const errorLines = (purgeErrors ? withElision(errorItems, 'elidedErrors', t) : errorItems).slice(0, sectionCap)
|
|
445
|
+
|
|
446
|
+
const todoItems = facts.pendingTodos.map((item) => clip(item, itemChars)).slice(0, sectionCap)
|
|
447
|
+
|
|
448
|
+
const currentItems = []
|
|
449
|
+
if (facts.lastUserText.trim().length > 0) currentItems.push(clip(facts.lastUserText, itemChars))
|
|
450
|
+
if (facts.lastAssistantText.trim().length > 0) {
|
|
451
|
+
currentItems.push(clip(facts.lastAssistantText.split('\n').filter((line) => line.trim().length > 0).slice(0, 2).join(' / '), itemChars))
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const nextItem = todoItems.length > 0 ? todoItems[todoItems.length - 1] : t('none')
|
|
455
|
+
|
|
456
|
+
const contextItems = [...carriedOf(SECTIONS.context)]
|
|
457
|
+
if (dedup) {
|
|
458
|
+
for (const dup of facts.dupCounts.values()) {
|
|
459
|
+
if (dup.count < 2) continue
|
|
460
|
+
const isProtected = protectedTools.some((p) => dup.name.includes(p))
|
|
461
|
+
if (isProtected) continue
|
|
462
|
+
const args = argDisplay(dup.name, dup.parsed)
|
|
463
|
+
contextItems.push(t('dedupNote').replace('{tool}', dup.name).replace('{args}', args).replace('{n}', String(dup.count)))
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
contextItems.push(t('contextNote').replace('{messages}', String(facts.messageCount)).replace('{calls}', String(facts.toolCallCount)))
|
|
467
|
+
|
|
468
|
+
const blocks = [
|
|
469
|
+
renderSection(SECTIONS.intent, intentItems, t, dropEmpty),
|
|
470
|
+
renderSection(SECTIONS.concepts, conceptItems, t, dropEmpty),
|
|
471
|
+
renderSection(SECTIONS.files, fileItems, t, dropEmpty),
|
|
472
|
+
renderSection(SECTIONS.errors, errorLines, t, dropEmpty),
|
|
473
|
+
renderSection(SECTIONS.todos, todoItems, t, dropEmpty),
|
|
474
|
+
renderSection(SECTIONS.current, currentItems, t, dropEmpty),
|
|
475
|
+
renderSection(SECTIONS.next, [nextItem], t, dropEmpty),
|
|
476
|
+
renderSection(SECTIONS.context, contextItems, t, dropEmpty),
|
|
477
|
+
]
|
|
478
|
+
return blocks
|
|
479
|
+
.filter((section) => section !== null)
|
|
480
|
+
.map((section) => section.join('\n'))
|
|
481
|
+
.join('\n')
|
|
482
|
+
.trim()
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Last-resort fixed-shape summary for pathological budget situations. */
|
|
486
|
+
function composeTerse(facts, t, itemChars) {
|
|
487
|
+
const files = [...facts.files.keys()].slice(0, 12).join(', ')
|
|
488
|
+
const lastError = facts.errors.length > 0 ? clip(facts.errors[facts.errors.length - 1], Math.min(itemChars, 120)) : t('none')
|
|
489
|
+
const next = facts.pendingTodos.length > 0 ? clip(facts.pendingTodos[facts.pendingTodos.length - 1], Math.min(itemChars, 120)) : t('none')
|
|
490
|
+
const carriedIntents = (facts.carried.get(SECTIONS.intent) ?? []).slice(0, 3).map((line) => clip(line, Math.min(itemChars, 100)))
|
|
491
|
+
const lines = [
|
|
492
|
+
t('terseHeader').replace('{messages}', String(facts.messageCount)).replace('{calls}', String(facts.toolCallCount)),
|
|
493
|
+
`Request: ${clip(facts.lastUserText || carriedIntents[0] || t('none'), 160)}`,
|
|
494
|
+
files.length > 0 ? `Files: ${files}` : `Files: ${t('none')}`,
|
|
495
|
+
`Errors: ${lastError}`,
|
|
496
|
+
`Next: ${next}`,
|
|
497
|
+
...carriedIntents.map((line) => `Prior: ${line}`),
|
|
498
|
+
]
|
|
499
|
+
return lines.join('\n').trim()
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Deterministically summarize one compaction region under a token budget.
|
|
504
|
+
*
|
|
505
|
+
* Budgets with the CJK-aware estimator by default (honoring
|
|
506
|
+
* `dcp.tokenEstimate`); an explicit estimator may be supplied to mirror a
|
|
507
|
+
* different meter.
|
|
508
|
+
*
|
|
509
|
+
* @param {{ messages: import('@deepseek-ai/dsh-llm').Message[] }} input - replayed region.
|
|
510
|
+
* @param {object} dcp - resolved dcp config.
|
|
511
|
+
* @param {(message: unknown) => number} [estimateMessageLike] - token estimator; defaults to {@link estimateMessageTokens}.
|
|
512
|
+
* @returns {{ summary: { type: 'text', text: string }[], provider: string, model: string }}
|
|
513
|
+
*/
|
|
514
|
+
export function summarizeDeterministically(input, dcp, estimateMessageLike = (message) => estimateMessageTokens(message, dcp.tokenEstimate)) {
|
|
515
|
+
const facts = extractFacts(input.messages, dcp.language)
|
|
516
|
+
const regionTokens = input.messages.reduce((total, message) => total + estimateMessageLike(message), 0)
|
|
517
|
+
const targetTokens = Math.min(dcp.maxSummaryTokens, Math.floor(regionTokens * 0.45))
|
|
518
|
+
const estimate = (text) => estimateMessageLike({ role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
519
|
+
|
|
520
|
+
const attempts = [
|
|
521
|
+
() => composeSummary(facts, dcp),
|
|
522
|
+
() => composeSummary(facts, { ...dcp, sectionCap: 3, itemChars: 80, dropEmpty: true }),
|
|
523
|
+
() => composeSummary(facts, { ...dcp, sectionCap: 2, itemChars: 60, dropEmpty: true }),
|
|
524
|
+
() => composeTerse(facts, (key) => i18n[dcp.language][key], dcp.maxItemChars),
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
let text = attempts[0]()
|
|
528
|
+
for (const attempt of attempts.slice(1)) {
|
|
529
|
+
if (estimate(text) <= targetTokens) break
|
|
530
|
+
text = attempt()
|
|
531
|
+
}
|
|
532
|
+
if (estimate(text) > targetTokens) {
|
|
533
|
+
text = text.slice(0, Math.max(40, targetTokens * 3)).trim()
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
summary: [{ type: 'text', text }],
|
|
538
|
+
provider: 'dsh-dcp',
|
|
539
|
+
model: 'deterministic-v1',
|
|
540
|
+
}
|
|
541
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aiwayds/dsh-dcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic context-pruning compaction backend for dsh (DeepSeek Harness) — zero-LLM summaries, /dcp command, works out of the box. Design references Opencode-DCP/opencode-dynamic-context-pruning.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./summarizer": "./lib/summarizer.js",
|
|
10
|
+
"./config": "./lib/config.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"lib",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"cordis.patch.example.yml"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"dsh",
|
|
24
|
+
"deepseek-harness",
|
|
25
|
+
"dsh-plugin",
|
|
26
|
+
"compaction",
|
|
27
|
+
"context-pruning",
|
|
28
|
+
"dcp"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/fan56/dsh-dcp.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/fan56/dsh-dcp",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/fan56/dsh-dcp/issues"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
44
|
+
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
|
45
|
+
"@deepseek-ai/dsh-brand": "0.1.0-rc.6",
|
|
46
|
+
"@deepseek-ai/dsh-commands": "0.1.0-rc.6",
|
|
47
|
+
"@deepseek-ai/dsh-compaction": "0.1.0-rc.6",
|
|
48
|
+
"@deepseek-ai/dsh-compaction-basic": "0.1.0-rc.6",
|
|
49
|
+
"@deepseek-ai/dsh-compaction-tool-result-pruner": "0.1.0-rc.6",
|
|
50
|
+
"@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
|
|
51
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
52
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
|
|
53
|
+
"@deepseek-ai/dsh-token-meter": "0.1.0-rc.6",
|
|
54
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
55
|
+
},
|
|
56
|
+
"overrides": {
|
|
57
|
+
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
|
58
|
+
"@deepseek-ai/dsh-brand": "0.1.0-rc.6",
|
|
59
|
+
"@deepseek-ai/dsh-commands": "0.1.0-rc.6",
|
|
60
|
+
"@deepseek-ai/dsh-compaction": "0.1.0-rc.6",
|
|
61
|
+
"@deepseek-ai/dsh-compaction-tool-result-pruner": "0.1.0-rc.6",
|
|
62
|
+
"@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
|
|
63
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
64
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
|
|
65
|
+
"@deepseek-ai/dsh-token-meter": "0.1.0-rc.6"
|
|
66
|
+
}
|
|
67
|
+
}
|