@aiwayds/dsh-dcp 0.2.0 → 0.3.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/README.en.md ADDED
@@ -0,0 +1,144 @@
1
+ # dsh-dcp
2
+
3
+ Deterministic context-compaction backend for dsh (DeepSeek Harness): **context
4
+ compaction without an LLM call**, works out of the box.
5
+
6
+ > [简体中文](README.md) · **English**
7
+
8
+ ## Why
9
+
10
+ dsh compacts conversation context by default with `compaction-basic`, which
11
+ asks an LLM to re-summarize older messages on every compaction — costly, slow,
12
+ and non-deterministic. dsh-dcp is a pure-code port of the ideas behind
13
+ [opencode-dcp](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning)
14
+ (dedup, error cleanup, "technical summary instead of prose"):
15
+
16
+ - **Zero LLM calls**: compaction itself costs no extra tokens
17
+ - **Deterministic**: identical input always yields identical output
18
+ - **CJK-friendly**: verbatim user text / paths / commands / errors, priced at
19
+ real CJK density
20
+ - **Inherits all official safety**: triggers, retained tail, transaction
21
+ locks, tool-pairing — dsh's own machinery, only the summarizer is replaced
22
+
23
+ ## Effects
24
+
25
+ ### vs. the official compaction-basic
26
+
27
+ | | compaction-basic | dsh-dcp |
28
+ |---|---|---|
29
+ | Summarization | LLM rewrite per compaction | deterministic code extraction |
30
+ | LLM calls per compaction | 1 | **0** |
31
+ | Determinism | may differ run to run | identical input → identical output |
32
+ | Summary content | semantic | verbatim hard facts (paths/commands/errors/todos/user text) |
33
+ | Chinese | model re-transcribes | kept verbatim + CJK-aware pricing |
34
+ | Triggers/retention/overflow/safety | official | **inherited, identical** |
35
+ | Checkpoint format | official | compatible (mutually mergeable) |
36
+
37
+ It also borrows the dedup / error-purge / `/dcp` / technical-summary ideas from
38
+ [opencode-dcp](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning),
39
+ re-implemented against dsh's compaction seam — that one serves opencode, this
40
+ one serves dsh.
41
+
42
+ ### CJK adaptation
43
+
44
+ Content is kept verbatim (no re-transcription into English); tokens are priced
45
+ at real CJK density (~2 chars/token for Chinese/Japanese/Korean/full-width)
46
+ instead of the host's flat 4 chars/token that underestimates Chinese — so CJK
47
+ sessions get a budget that reflects real cost, and checkpoints stay
48
+ information-dense.
49
+
50
+ ### Real dsh session
51
+
52
+ ~80k tokens of history → ~700-token checkpoint (**~100x**), zero LLM calls;
53
+ cache-hit rate is barely affected (any backend pays one "cold request" right
54
+ after a compaction).
55
+
56
+ A checkpoint produced on a real session (Chinese content kept verbatim):
57
+
58
+ ```
59
+ ## Primary Request and Intent
60
+ - 帮我把登录页的重定向 bug 修掉
61
+
62
+ ## Files and Code
63
+ - /app/src/auth/login.ts — W×1 R×1
64
+
65
+ ## Errors and Fixes
66
+ - bash: FAIL src/auth.test.ts
67
+
68
+ ## Pending Jobs
69
+ - add regression test
70
+
71
+ ## Critical Context
72
+ - dsh-dcp 确定性压缩了 12 条消息 / 8 次工具调用(未调用 LLM 摘要)
73
+ ```
74
+
75
+ ## Not in scope
76
+
77
+ - **No semantic summarization**: it preserves facts that appeared, it does not
78
+ "understand" code. Need deep semantic checkpoints? Stick with the official
79
+ `compaction-basic`
80
+ - **Things dsh already does, deliberately not re-implemented**:
81
+ - tool-result pruning (`compaction-tool-result-pruner`, deterministic by size)
82
+ - trigger policy, retained tail, overflow recovery (inherited from official)
83
+ - `/compact` command, UI checkpoint cards (shipped with dsh)
84
+
85
+ ## Install
86
+
87
+ **Recommended: pair it with our dsh-tui-pi** (the TUI already depends on
88
+ dsh-dcp):
89
+
90
+ ```bash
91
+ npm i @aiwayds/dsh-tui-pi
92
+ dsh plugin add @aiwayds/dsh-dcp # activates dcp; the bundle auto-mounts
93
+ ```
94
+
95
+ **Standalone:**
96
+
97
+ ```bash
98
+ npm i @aiwayds/dsh-dcp
99
+ npx dsh-dcp-setup # safe: date-stamped backup → append-only → idempotent checks
100
+ ```
101
+
102
+ > dsh-dcp plugs into dsh's compaction seam and only affects profiles that
103
+ > mount it. The web profile does not bundle the TUI, so it keeps the official
104
+ > backend and is unaffected.
105
+
106
+ ## /dcp command
107
+
108
+ | Command | Effect |
109
+ |---|---|
110
+ | `/dcp` | status: config, compaction count, tokens saved |
111
+ | `/dcp compact` | compact now (zero LLM) |
112
+ | `/dcp set <k> <v>` | adjust a knob for this session, with a persist hint |
113
+
114
+ Settable: `dedup`, `purgeErrors`, `maxItems`, `maxItemChars`,
115
+ `maxSummaryTokens`, `language`, `tokenEstimate`, `thresholdRatio`.
116
+
117
+ ## Configuration
118
+
119
+ All optional, defaults work out of the box:
120
+
121
+ | Key | Default | Meaning |
122
+ |---|---|---|
123
+ | `thresholdRatio` | 0.7 | compaction trigger; 0.7 recommended for CJK-heavy sessions |
124
+ | `language` | `zh` | summary language; `zh` also enables Chinese error/"待办:" detection |
125
+ | `tokenEstimate` | `cjk` | CJK (zh/ja/ko/full-width) at ~2 chars/token; `ascii` matches the host |
126
+ | `dedup` | `true` | annotate repeated tool calls |
127
+ | `purgeErrors` | `true` | collapse stale errors into one note |
128
+ | `maxItems` / `maxItemChars` | 10 / 200 | summary density |
129
+ | `maxSummaryTokens` | 2048 | summary token budget |
130
+
131
+ ## Design reference
132
+
133
+ - [Opencode-DCP/opencode-dynamic-context-pruning](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning)
134
+ - dsh official compaction seam: `docs/subsystems/compaction.md` (deepseek-harness)
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ npm install && npm test # 45 tests: extractor/compaction/command/config/setup
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT
package/README.md CHANGED
@@ -1,107 +1,47 @@
1
1
  # dsh-dcp
2
2
 
3
- [dsh](https://github.com/deepseek-ai/deepseek-harness)(DeepSeek Harness)的**确定性 context 压缩后端**:把 `compaction-basic` 的 LLM 摘要换成纯代码抽取,**每次压缩零 LLM 调用**。
3
+ dsh(DeepSeek Harness)的确定性压缩后端:**上下文压缩不调 LLM**,开箱即用。
4
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 重新实现。
5
+ > **简体中文** · [English](README.en.md)
6
6
 
7
- ## 为什么
7
+ ## 为什么做
8
8
 
9
- dsh 自带三层压缩里,`compaction-basic` 每次压缩都要付一次 LLM 摘要调用,且摘要质量取决于模型心情。dsh-dcp 换成确定性模板抽取:
9
+ dsh 默认的压缩(`compaction-basic`)每次压缩都要让模型把旧对话**重新总结一遍**——费 token、慢、结果还不稳定。我们参考 opencode 社区的 [opencode-dcp](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning)(去重、清错、"技术摘要代替散文"),做了一个纯代码版本:
10
10
 
11
- - **零 LLM 调用**:压缩触发不再产生额外的 token 消耗
12
- - **输出稳定**:相同输入永远得到相同摘要(可 diff、可测试)
13
- - **保留硬信息**:文件路径、命令、报错串、待办、用户原话逐字保留,废话全丢
14
- - **中文友好**:内容原样保留(不做英文转写),`thresholdRatio` 可直接调低提前触发
15
- - **继承一切安全机制**:压力触发、保留尾巴、溢出恢复、事务锁、tool-pairing 边界全部复用官方实现(只 override 官方留的唯一钩子 `summarize()`)
11
+ - **零 LLM 调用**:压缩本身不消耗任何额外 token
12
+ - **输出稳定**:相同对话永远得到相同摘要
13
+ - **中文友好**:用户原话/路径/命令/报错逐字保留,按 CJK 真实密度计价
14
+ - **继承官方全部安全机制**:触发、保留尾巴、事务锁、tool-pairing 边界都复用 dsh 官方实现(只替换"摘要"这一环)
16
15
 
17
- ## 快速开始
16
+ ## 效果
18
17
 
19
- **用我们的 TUI(`dsh-tui-pi`)?什么都不用做。** `dsh-tui-pi` ≥ 0.4.2 依赖 `@aiwayds/dsh-dcp`,并在它自己的 `cordis.patch.yml` 里自动挂载(禁用 compaction-basic + 插入 dsh-dcp):
18
+ ### 与官方默认压缩的对比
20
19
 
21
- ```bash
22
- npm i @aiwayds/dsh-tui-pi # 重启 dsh 即可,dcp 开箱即用,无需手动配置
23
- ```
24
-
25
- **独立使用(非 tui 环境)** —— npm 只把包装进 node_modules,**挂载(写 patch 条目)不在 npm 行为里**,除非宿主 bundle(如 dsh-tui-pi)自带挂载。不装 tui 就得手动写:
26
-
27
- ```bash
28
- # 1. 安装
29
- npm i @aiwayds/dsh-dcp
30
- # 或源码:git clone git@github.com:fan56/dsh-dcp.git && cd dsh-dcp && npm install
31
-
32
- # 2. 手动挂载:在 ~/.dsh/cordis.patch.yml 追加
33
- - id: compaction-basic
34
- disabled: true
35
- - insert:
36
- - id: dsh-dcp
37
- name: '@aiwayds/dsh-dcp' # 包装进 profile 后用裸包名即可(与 tui-pi 一致)
38
- config:
39
- thresholdRatio: 0.7
40
- language: zh
41
-
42
- # 3. 重启 dsh,输入 /dcp 验证
43
- ```
44
-
45
- > 挂载名用**裸包名**(`@aiwayds/dsh-dcp`)即可——只要它装进了 profile 的 node_modules(`dsh plugin add` 或 profile package.json 依赖),loader 就能像解析 tui-pi 一样找到它;用**绝对路径**指向源码入口同样可行。
46
-
47
- `/compact` 命令、自动压力触发、overflow 恢复、UI 的 checkpoint 卡片照常工作——它们只依赖 `ctx.compaction` 接口,与本后端无关。
48
-
49
- ## `/dcp` 命令
50
-
51
- | 命令 | 作用 |
52
- |---|---|
53
- | `/dcp` | 状态:当前配置、压缩次数、shadowed token 数、省掉的 LLM 调用数 |
54
- | `/dcp compact` | 立即压缩(确定性,无 LLM 调用;等价 `/compact`) |
55
- | `/dcp set <k> <v>` | 本会话内调整参数,并打印持久化到 `cordis.patch.yml` 的片段 |
56
- | `/dcp help` | 用法 |
57
-
58
- 可调键:`dedup`、`purgeErrors`、`maxItems`、`maxItemChars`、`maxSummaryTokens`、`language`、`thresholdRatio`。
59
-
60
- ## 配置
61
-
62
- 全部可选,默认即用。写在 `~/.dsh/cordis.patch.yml` 的 `config:` 下:
63
-
64
- ```yaml
65
- - id: compaction-basic
66
- name: /Users/<you>/github/dsh-dcp/lib/index.js
67
- config:
68
- thresholdRatio: 0.7 # 中文场景建议 0.7(默认 0.8)
69
- language: zh # 输出语言 en|zh:zh 额外启用中文报错/待办规则
70
- # tokenEstimate: cjk # 默认即 cjk:CJK 字符按 ~2 字符/token 计价
71
- ```
72
-
73
- ### dsh-dcp 自己的键
74
-
75
- | 键 | 默认 | 说明 |
20
+ | | 官方 compaction-basic | dsh-dcp |
76
21
  |---|---|---|
77
- | `dedup` | `true` | 统计重复的工具调用(同名+同参),在 Critical Context 里标注"×N,保留最近结果" |
78
- | `protectedTools` | `['write', 'edit', 'apply_patch']` | 去重时跳过这些名字(子串匹配)的工具;设为 `[]` 可让所有工具参与去重 |
79
- | `purgeErrors` | `true` | 旧报错折叠为一条省略提示,只保留最近 `maxItems` 条 |
80
- | `maxItems` | `10` | 每个 section 最多条数 |
81
- | `maxItemChars` | `200` | 每条最长字符(超出截断加 `…`) |
82
- | `maxSummaryTokens` | `2048` | 摘要 token 预算(超出自动降级到更紧凑的格式) |
83
- | `language` | `en` | 输出语言 `en`/`zh`。除填充文案外,`zh` 还启用**中文规则集**:识别中文报错关键词(找不到/失败/无法/拒绝/超时/崩溃…)和 `待办:` 标记;`en` 只用英文规则。section 标题固定英文(对下游模型是结构锚点) |
84
- | `tokenEstimate` | `cjk` | 摘要预算的 token 计价方式:`cjk`(默认)把 **CJK 字符**(中文汉字、日文假名、韩文谚文、全角标点——CJK 不止中文)按 **~2 字符/token** 计价,ASCII 按 4 字符/token;`ascii` 则与宿主 meter 完全一致,所有字符一律 4 字符/token |
85
-
86
- ### 继承自 compaction-basic 的键
22
+ | 摘要方式 | 每次调 LLM 重写 | 确定性代码抽取 |
23
+ | 每次压缩的模型调用 | 1 次 | **0 次** |
24
+ | 输出稳定性 | 同对话多次可能不同 | 相同输入永远相同 |
25
+ | 摘要内容 | 语义归纳 | 逐字保硬信息(路径/命令/报错/待办/用户原话) |
26
+ | 中文 | 依赖模型转写 | 原样保留 + CJK 计价 |
27
+ | 触发/保留/溢出/安全 | 官方 | **继承官方,完全相同** |
28
+ | 检查点格式 | 官方 | 兼容(可互相合并) |
87
29
 
88
- `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 摘要路径,保留只为配置兼容。
30
+ 设计上还吸收了 [opencode-dcp](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning) 的思路(去重、清错、`/dcp` 命令、技术摘要),但按 dsh 的压缩接口重新实现——它服务于 opencode,dsh-dcp 服务于 dsh。
89
31
 
90
- ## 中文场景
32
+ ### CJK 适配
91
33
 
92
- dsh 的 host meter 对所有字符一律按 **4 字符/token** 计价,这对英文合理,对 CJK 却会低估约 2 倍(真实分词器对汉字/假名/谚文约 1~2 字符/token)。dsh-dcp 在**摘要预算**上不沿用这个启发式:
34
+ 内容逐字保留、不做英文转写;token 计价按 CJK 真实密度(中/日/韩/全角约 2 字符/token),不沿用宿主"4 字符/token"对中文的低估——中文会话的摘要预算反映真实成本,不会被饿死,信息更密集。
93
35
 
94
- - **预算计价(`tokenEstimate: cjk`,默认)**:CJK 字符按 ~2 字符/token、ASCII 按 4 字符/token。纯英文文本与宿主 meter 完全一致;CJK 会话里预算反映真实成本,摘要不会因"中文被按 4 字符/token 贱卖"而膨胀到超出真实预算,也不会被 45% 预算规则饿死
95
- - **中文规则集(`language: zh`)**:报错识别补充中文关键词(找不到/未找到/不存在/失败/错误/报错/异常/无法/拒绝/超时/崩溃/致命),待办识别补充 `待办:` 标记;`en` 模式只认英文规则
96
- - **内容逐字保留**:用户原话、文件路径、命令、报错串原样进入摘要,不做英文转写,中文信息零损耗
36
+ ### 真实 dsh 会话实测
97
37
 
98
- 一个**已知边界**:压缩**触发阈值**仍由宿主 meter 决定(在父类 `compactIfNeeded` 里,不在我们的 seam 内)。中文会话里宿主低估实际占用,阈值可能触发偏晚。补偿办法:把 `thresholdRatio` 调低到 `0.6~0.7`(或用 `modelPolicies` 按模型单独设),让压力检查提前;若仍频繁触发 context-overflow 恢复,再继续下调。
38
+ 一段约 8 万 token 的历史压成约 700 token(**~100x**),全程零 LLM 调用;缓存命中率几乎不变(压缩后总会有一个"冷请求",任何后端都一样)。
99
39
 
100
- ## 摘要长什么样
40
+ 真实会话里压缩出的检查点(中文内容逐字保留):
101
41
 
102
42
  ```
103
43
  ## Primary Request and Intent
104
- - Fix the login redirect bug and update README.
44
+ - 帮我把登录页的重定向 bug 修掉
105
45
 
106
46
  ## Files and Code
107
47
  - /app/src/auth/login.ts — W×1 R×1
@@ -112,85 +52,70 @@ dsh 的 host meter 对所有字符一律按 **4 字符/token** 计价,这对
112
52
  ## Pending Jobs
113
53
  - add regression test
114
54
 
115
- ## Current Work
116
- - Fix the login redirect bug and update README.
117
-
118
- ## Next Step
119
- - add regression test
120
-
121
55
  ## Critical Context
122
56
  - dsh-dcp 确定性压缩了 12 条消息 / 8 次工具调用(未调用 LLM 摘要)
123
57
  ```
124
58
 
125
- Section 结构与 `compaction-basic` 的 checkpoint 指令一致,因此:已有的 `<compacted-summary>` 检查点会被解析、去重后合并进新摘要(陈旧的 Current Work / Next Step 丢弃重生成);将来换回 basic 后端,它也能按官方规则合并 dsh-dcp 产生的检查点。
126
-
127
- ## 工作原理
128
-
129
- ```
130
- BasicCompactionEngine dsh 官方:阈值/保留/事务/事件锁/tool-pairing
131
- ▲
132
- │ 仅 override summarize()
133
- DcpEngine ── register /dcp
134
- │
135
- summarizeDeterministically() 纯代码:抽取 → 合并 prior checkpoint → 预算压缩
136
- ```
137
-
138
- - 压缩区间选择、`compaction/start → summary → end` 事件序、`surfaceOp: replace`、收敛校验(摘要必须小于被压区间)、`ManualCompactionError` 错误分类,全部由父类承担
139
- - 摘要预算 = `min(maxSummaryTokens, 45% × 被压区间)`,按 `tokenEstimate` 计价(默认 cjk,对中文准确;对纯英文与宿主一致);超出时逐级降级(砍条数 → 丢空 section → terse 固定格式 → 硬截断)。45% 的余量保证收敛校验(宿主按 4 字符/token 计价)总能通过
140
- - 挂载方式是 `cordis.patch.yml` 按 `id: compaction-basic` 覆盖 `name`,行 id 不变、仍留在原 isolate 组内,隔离语义不破坏
59
+ ## Not in scope
141
60
 
142
- ## 与参考对象的差异
61
+ - **不做语义归纳**:不"理解"代码,只保留"出现过的事实"。需要深度语义摘要的场景,请继续用官方 `compaction-basic`
62
+ - **dsh 已经有的我们不重复做**:
63
+ - 工具结果剪枝(`compaction-tool-result-pruner`,确定性按大小剪)
64
+ - 触发策略、保留尾巴、溢出恢复(直接继承官方)
65
+ - `/compact` 命令、UI 检查点卡片(dsh 自带)
143
66
 
144
- | | opencode-dcp | dsh-dcp |
145
- |---|---|---|
146
- | 宿主 | opencode | dsh(DeepSeek Harness) |
147
- | 接入点 | 插件 + 模型可调的 compress 工具 | 官方 compaction seam 的 `summarize()` 钩子 |
148
- | 历史 | 不改 session,请求前换占位符 | 走官方 durable 替换事务(`surfaceOp: replace`) |
149
- | 摘要 | 模型生成技术摘要 | 确定性模板抽取,零 LLM 调用 |
150
- | 命令 | `/dcp` TUI 面板 + `/dcp-compress` | `/dcp`(status/compact/set) |
151
- | 配置 | `dcp.jsonc`(~30 键) | `cordis.patch.yml` config(8 个自有键) |
67
+ ## 安装
152
68
 
153
- ## 局限
69
+ **推荐:配合我们的 dsh-tui-pi 用**(tui 已依赖 dsh-dcp):
154
70
 
155
- - 确定性抽取不"理解"代码:它保路径/命令/报错/待办/用户原话,但不做语义归纳。需要语义摘要的场景请继续用默认 `compaction-basic`
156
- - 摘要预算对中文已按真实密度计价(`tokenEstimate: cjk`);但**压缩触发阈值**仍在宿主侧按 4 字符/token 计价,中文会话触发偏晚,需手动调低 `thresholdRatio`(见"中文场景")
157
- - 依赖绝对路径挂载 + 本目录 `npm install`(私仓未发 npm);`@deepseek-ai/*` 版本需与本机 dsh 一致(当前 `0.1.0-rc.6`,见 `package.json` 的 `overrides`)
71
+ ```bash
72
+ npm i @aiwayds/dsh-tui-pi
73
+ dsh plugin add @aiwayds/dsh-dcp # 激活 dcp,bundle 自动挂载
74
+ ```
158
75
 
159
- ## 开发
76
+ **独立使用**:
160
77
 
161
78
  ```bash
162
- npm install
163
- npm test # node:test,35 个用例:config / summarizer / command / engine
79
+ npm i @aiwayds/dsh-dcp
80
+ npx dsh-dcp-setup # 安全脚本:带日期备份 → 只追加 → 幂等判断,不碰你已有的配置
164
81
  ```
165
82
 
166
- ### 缓存对比脚本
83
+ > dsh-dcp 挂在 dsh 的压缩接口上,只对挂载了它的 profile 生效。web profile 没挂 tui,继续用官方压缩,不受影响。
167
84
 
168
- `scripts/compare.mjs` 用真实 dsh 会话日志(`~/.dsh/sessions/**/session.jsonl.zstd`)静态模拟 provider 前缀缓存,对比"不压缩"基线与 dcp 后端:
85
+ ## /dcp 命令
169
86
 
170
- ```bash
171
- node scripts/compare.mjs <session.jsonl.zstd> [contextWindow] [thresholdRatio] [retainRatio] [language]
172
- ```
87
+ | 命令 | 作用 |
88
+ |---|---|
89
+ | `/dcp` | 状态:配置、压缩次数、省下的 token |
90
+ | `/dcp compact` | 立即压缩(零 LLM) |
91
+ | `/dcp set <k> <v>` | 会话内调参,并提示如何持久化 |
92
+
93
+ 可调键:`dedup`、`purgeErrors`、`maxItems`、`maxItemChars`、`maxSummaryTokens`、`language`、`tokenEstimate`、`thresholdRatio`。
94
+
95
+ ## 配置
173
96
 
174
- 模拟的缓存模型与真实服务商一致:请求 N 的缓存命中 = 与请求 N-1 的最长公共 token 前缀;纯追加时上一请求是完整前缀(≈全命中),压缩把头部替换成新 checkpoint 后下一请求从冷开始。
97
+ 全部可选,默认即用:
175
98
 
176
- **3 条真实会话验证**(128k 窗口、0.8 阈值、0.16 保留;session3 为 64k 窗口):
99
+ | 键 | 默认 | 说明 |
100
+ |---|---|---|
101
+ | `thresholdRatio` | 0.7 | 触发阈值;中文场景建议 0.7 |
102
+ | `language` | `zh` | 摘要语言;`zh` 额外识别中文报错和"待办:" |
103
+ | `tokenEstimate` | `cjk` | CJK(中/日/韩/全角)按 ~2 字符/token 计价;`ascii` 与宿主一致 |
104
+ | `dedup` | `true` | 标注重复工具调用 |
105
+ | `purgeErrors` | `true` | 旧报错折叠成一条提示 |
106
+ | `maxItems` / `maxItemChars` | 10 / 200 | 摘要密度 |
107
+ | `maxSummaryTokens` | 2048 | 摘要 token 预算 |
177
108
 
178
- | 会话 | 体量 | 基线 hit / 输入 | dcp hit / 输入 | 压缩次数 | 压缩率 |
179
- |---|---|---|---|---|---|
180
- | S1(759 节点,中文主题调试) | 18.2 万 tok | 99.6% / 4152 万 | 99.1% / 2206 万(-47%) | 1 | 81,632→514(**158.8x**) |
181
- | S2(765 节点) | 18.7 万 tok | 99.1% / 4187 万 | 98.8% / 2102 万(-50%) | 2 | 82,525→577、82,163→821(143x/100x) |
182
- | S3(128k 未触发) | 12.3 万 tok | 98.6% / 1234 万 | 98.6% / 1234 万(不变) | 0 | — |
183
- | S3(64k 窗口) | 12.3 万 tok | 98.6% / 1234 万 | 97.2% / 715 万(-42%) | 3 | 42,085→325 等(80~130x) |
109
+ ## 设计参考
184
110
 
185
- 结论:压缩让命中率下降不到 1 个百分点,绝对 miss 增加约等于每次压缩后那一个冷请求(1~2 万 token),而这个代价是**所有压缩后端共有的**(头部替换导致新 checkpoint 无前缀可蹭);同时总输入 token 减半。dcp 与官方 basic 的唯一差异在摘要大小与零 LLM 调用,不在缓存机制。
111
+ - [Opencode-DCP/opencode-dynamic-context-pruning](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning)
112
+ - dsh 官方 compaction 接口:`docs/subsystems/compaction.md`(deepseek-harness)
186
113
 
187
- **摘要瘦身优化**(对注入上下文做了过滤,见 `extractFacts` 的 `isInjectedContext`):system-prompt 快照、skills 目录、AGENTS.md 指令这些每轮重新注入的消息不再进入摘要——S1 摘要 717→514 token(-28%),S2 摘要 693→577、883→821。纯英文与中英混合会话均无回归。
114
+ ## 开发
188
115
 
189
- - `lib/index.js` — `DcpEngine`(挂载入口,default export)
190
- - `lib/summarizer.js` — 确定性抽取与预算压缩(纯函数,可独立复用)
191
- - `lib/command.js` — `/dcp` 命令
192
- - `lib/config.js` — 配置切分与校验
193
- - 升级 dsh 后:同步 `package.json` 里 `@deepseek-ai/*` 的版本与 `overrides`,`npm install && npm test`
116
+ ```bash
117
+ npm install && npm test # 45 个用例:抽取/压缩/命令/配置/安装脚本
118
+ ```
194
119
 
195
120
  ## License
196
121
 
package/lib/setup.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * dsh-dcp setup — safe patching of a user's cordis.patch.yml.
3
+ *
4
+ * The user's file may already carry their own entries (and comments), so this
5
+ * never parses-and-rewrites the whole document: it only APPENDS the dsh-dcp
6
+ * mount block, and only when the mount is not already present. An existing
7
+ * file is backed up (date-stamped) before any change; a missing file is
8
+ * generated fresh.
9
+ *
10
+ * @module dsh-dcp/setup
11
+ */
12
+ import { readdirSync, readFileSync } from 'node:fs'
13
+
14
+ /** The patch entry that disables the default LLM summarizer. */
15
+ const DISABLE_ENTRY = `- id: compaction-basic
16
+ disabled: true`
17
+
18
+ /** The dsh-dcp mount block, one YAML list item, appended to the file. */
19
+ export function mountBlock({ name, includeDisable }) {
20
+ const parts = []
21
+ if (includeDisable) parts.push(DISABLE_ENTRY)
22
+ parts.push(`- insert:
23
+ - id: dsh-dcp
24
+ name: ${name}
25
+ config:
26
+ thresholdRatio: 0.7
27
+ language: zh`)
28
+ return '\n# dsh-dcp — deterministic compaction backend (added by @aiwayds/dsh-dcp setup).\n' + parts.join('\n') + '\n'
29
+ }
30
+
31
+ /** Whether the given patch text already mounts dsh-dcp (idempotency guard). */
32
+ export function isMounted(text) {
33
+ return /(^|\n)\s*-\s+id:\s*dsh-dcp\b/.test(text)
34
+ }
35
+
36
+ /** Whether the patch text already has an entry with the given id. */
37
+ export function hasEntry(text, id) {
38
+ return new RegExp(`(^|\\n)\\s*-\\s+id:\\s*${id}\\b`).test(text)
39
+ }
40
+
41
+ /**
42
+ * Profiles under `profilesDir` that already bundle the given package (so a
43
+ * home-patch mount of the same package would duplicate its entry id).
44
+ * @param {string} profilesDir - `$DSH_HOME/profiles`.
45
+ * @param {string} pkg - package name, e.g. '@aiwayds/dsh-dcp'.
46
+ * @returns {string[]} profile names that bundle it.
47
+ */
48
+ export function findBundledProfiles(profilesDir, pkg) {
49
+ if (!profilesDir || !pkg) return []
50
+ let names
51
+ try {
52
+ names = readdirSync(profilesDir)
53
+ } catch {
54
+ return []
55
+ }
56
+ const bundled = []
57
+ for (const name of names) {
58
+ try {
59
+ const manifest = JSON.parse(readFileSync(`${profilesDir}/${name}/package.json`, 'utf8'))
60
+ if (manifest?.dsh?.profile?.bundles?.includes(pkg)) bundled.push(name)
61
+ } catch { /* not a profile dir / unreadable manifest */ }
62
+ }
63
+ return bundled
64
+ }
65
+
66
+ /**
67
+ * Decide what to do for one target patch file.
68
+ *
69
+ * @param {string|undefined} text - existing file content, or undefined when the file is absent.
70
+ * @param {{ name: string }} options - the resolvable dsh-dcp entry specifier to mount.
71
+ * @returns {{ action: 'create'|'patch'|'skip', block?: string, note?: string }}
72
+ */
73
+ export function planPatch(text, { name }) {
74
+ if (text === undefined || text === null) {
75
+ return { action: 'create', block: mountBlock({ name, includeDisable: true }) }
76
+ }
77
+ if (isMounted(text)) {
78
+ return { action: 'skip' }
79
+ }
80
+ const alreadyHasBasic = hasEntry(text, 'compaction-basic')
81
+ if (alreadyHasBasic) {
82
+ return {
83
+ action: 'patch',
84
+ block: mountBlock({ name, includeDisable: false }),
85
+ note: 'compaction-basic already has an entry in this file; make sure it is disabled, otherwise two backends would both register.',
86
+ }
87
+ }
88
+ return { action: 'patch', block: mountBlock({ name, includeDisable: true }) }
89
+ }
90
+
91
+ /** Date-stamped backup suffix, e.g. `.bak.20260818-1130`. */
92
+ export function backupStamp(date = new Date()) {
93
+ const p = (n) => String(n).padStart(2, '0')
94
+ return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}`
95
+ }
package/package.json CHANGED
@@ -1,24 +1,31 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-dcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
+ "bin": {
8
+ "dsh-dcp-setup": "scripts/setup.mjs"
9
+ },
7
10
  "exports": {
8
11
  ".": "./lib/index.js",
9
12
  "./summarizer": "./lib/summarizer.js",
10
13
  "./config": "./lib/config.js",
14
+ "./setup": "./lib/setup.js",
11
15
  "./package.json": "./package.json"
12
16
  },
13
17
  "files": [
14
18
  "lib",
19
+ "scripts",
15
20
  "README.md",
21
+ "README.en.md",
16
22
  "LICENSE",
17
23
  "cordis.patch.example.yml",
18
24
  "cordis.patch.yml"
19
25
  ],
20
26
  "scripts": {
21
- "test": "node --test"
27
+ "test": "node --test",
28
+ "setup": "node scripts/setup.mjs"
22
29
  },
23
30
  "keywords": [
24
31
  "dsh",
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Simulate compaction + provider prefix-cache mechanics against a real dsh
4
+ * session log, comparing the dcp backend with the no-compaction baseline.
5
+ *
6
+ * What it measures (static, deterministic — no LLM calls):
7
+ * - per-request input tokens under two pricings: host flat 4 chars/token
8
+ * and dcp's CJK-aware estimate
9
+ * - provider prefix-cache hit/miss: a request is billed as a cache hit for
10
+ * the longest token-prefix it shares with the previous request. Pure
11
+ * append → the previous request is a prefix of the next (≈full hit). A
12
+ * head-anchored compaction swaps the leading span for a checkpoint, so
13
+ * the next request starts cold (≈0 hit) — for EVERY backend, including
14
+ * compaction-basic; the checkpoint text is brand new.
15
+ * - when the dcp engine would compact (host tokens ≥ threshold × window)
16
+ * and what its deterministic summary costs vs the region it shadows.
17
+ *
18
+ * Usage:
19
+ * node scripts/compare.mjs <session.jsonl.zstd|session.jsonl> [contextWindow] [thresholdRatio] [retainRatio] [language]
20
+ *
21
+ * Environment: DCP_* mirrors the plugin config knobs (dedup, purgeErrors,
22
+ * maxItems, maxItemChars, maxSummaryTokens, protectedTools).
23
+ */
24
+ import fs from 'node:fs'
25
+ import { execFileSync } from 'node:child_process'
26
+ import { isDeepStrictEqual } from 'node:util'
27
+ import { summarizeDeterministically, estimateMessageTokens } from '../lib/summarizer.js'
28
+
29
+ const [, , sessionArg, windowArg, thresholdArg, retainArg, languageArg] = process.argv
30
+ const CONTEXT_WINDOW = Number(windowArg ?? 128000)
31
+ const THRESHOLD = Number(thresholdArg ?? 0.8)
32
+ const RETAIN = Number(retainArg ?? 0.16)
33
+ const LANGUAGE = languageArg ?? 'zh'
34
+
35
+ const DCP = {
36
+ dedup: process.env.DCP_DEDUP !== 'false',
37
+ purgeErrors: process.env.DCP_PURGE_ERRORS !== 'false',
38
+ maxItems: Number(process.env.DCP_MAX_ITEMS ?? 10),
39
+ maxItemChars: Number(process.env.DCP_MAX_ITEM_CHARS ?? 200),
40
+ maxSummaryTokens: Number(process.env.DCP_MAX_SUMMARY_TOKENS ?? 2048),
41
+ language: LANGUAGE,
42
+ tokenEstimate: 'cjk',
43
+ protectedTools: ['write', 'edit', 'apply_patch'],
44
+ }
45
+
46
+ const CHECKPOINT_PREAMBLE = 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
47
+
48
+ function loadSession(path) {
49
+ const source = path.endsWith('.zstd')
50
+ ? execFileSync('zstd', ['-d', '-f', '-c', path], { maxBuffer: 256 * 1024 * 1024 }).toString()
51
+ : fs.readFileSync(path, 'utf8')
52
+ return source.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line))
53
+ }
54
+
55
+ /** Rebuild the message surface (append + replace ops), returning ordered nodes. */
56
+ function rebuildSurface(events) {
57
+ const surface = []
58
+ for (const event of events) {
59
+ let message
60
+ if (event.type === 'user/message') message = event.data
61
+ else if (event.type === 'assistant/message') message = event.data.message
62
+ else if (event.type === 'tool/result') message = event.data.message
63
+ else continue
64
+ const node = { seq: event.seq, type: event.type, message }
65
+ const op = event.surfaceOp
66
+ if (op && op.op === 'replace') {
67
+ const start = surface.findIndex((n) => n.seq === op.start)
68
+ const end = surface.findIndex((n) => n.seq === op.end)
69
+ if (start !== -1 && end !== -1) surface.splice(start, end - start + 1, node)
70
+ } else {
71
+ surface.push(node)
72
+ }
73
+ }
74
+ return surface
75
+ }
76
+
77
+ /** Host token meter: flat 4 chars/token, mirroring dsh-token-meter minus per-block overhead. */
78
+ function hostTokens(content) {
79
+ return content.reduce((total, block) => {
80
+ if (block.type === 'text') return total + Math.ceil(block.text.length / 4)
81
+ if (block.type === 'tool-call') return total + Math.ceil(block.arguments.length / 4)
82
+ if (block.type === 'tool-result') {
83
+ return total + block.content.reduce((sum, inner) => sum + (inner.type === 'text' ? Math.ceil(inner.text.length / 4) : 0), 0)
84
+ }
85
+ return total
86
+ }, 0)
87
+ }
88
+
89
+ function nodeTokens(node, mode) {
90
+ return mode === 'cjk' ? estimateMessageTokens(node.message, 'cjk') : hostTokens(node.message.content)
91
+ }
92
+
93
+ function surfaceTokens(surface, mode) {
94
+ return surface.reduce((sum, node) => sum + nodeTokens(node, mode), 0)
95
+ }
96
+
97
+ /**
98
+ * Balance (open tool calls) before every cut, mirroring the official
99
+ * tool-pairing eventDelta: only `assistant/message` tool-call blocks open a
100
+ * call, only `tool/result` events close one; every other surface event
101
+ * (user messages, subagent-settled aggregates) is neutral — subagent tool
102
+ * calls live in the child session and never unbalance the parent surface.
103
+ */
104
+ function prefixBalances(nodes) {
105
+ const balance = [0]
106
+ let open = 0
107
+ for (const node of nodes) {
108
+ if (node.type === 'assistant/message') {
109
+ open += node.message.content.reduce((delta, block) => delta + (block.type === 'tool-call' ? 1 : 0), 0)
110
+ } else if (node.type === 'tool/result') {
111
+ open -= 1
112
+ }
113
+ balance.push(open)
114
+ }
115
+ return balance
116
+ }
117
+
118
+ /**
119
+ * Head-anchored region selection mirroring compaction-basic's
120
+ * `selectCompactableRange`: retain a priced tail, then back up to a
121
+ * tool-pairing-balanced cut. Returns the region nodes or null.
122
+ */
123
+ function selectRegion(nodes, retainTokens, mode) {
124
+ if (nodes.length === 0) return null
125
+ const balance = prefixBalances(nodes)
126
+ const tokens = nodes.map((node) => nodeTokens(node, mode))
127
+ let accumulated = 0
128
+ let keepFrom = nodes.length
129
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
130
+ accumulated += tokens[index]
131
+ keepFrom = index
132
+ if (accumulated >= retainTokens) break
133
+ }
134
+ if (keepFrom === 0) return null
135
+ while (keepFrom > 0 && balance[keepFrom] !== 0) keepFrom -= 1
136
+ if (keepFrom === 0) return null
137
+ return nodes.slice(0, keepFrom)
138
+ }
139
+
140
+ function checkpointMessage(summaryText, compactionId) {
141
+ return {
142
+ role: 'user',
143
+ content: [
144
+ { type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n<compacted-summary>` },
145
+ { type: 'text', text: summaryText },
146
+ { type: 'text', text: '</compacted-summary>' },
147
+ ],
148
+ source: { kind: 'plugin', plugin: 'compact', compactionId },
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Longest common token-prefix of two surfaces, node by node (conservative:
154
+ * a partially-shared node counts as zero).
155
+ */
156
+ function commonPrefixTokens(prev, curr, mode) {
157
+ let tokens = 0
158
+ const length = Math.min(prev.length, curr.length)
159
+ for (let index = 0; index < length; index += 1) {
160
+ if (!isDeepStrictEqual(prev[index].message, curr[index].message)) break
161
+ tokens += nodeTokens(curr[index], mode)
162
+ }
163
+ return tokens
164
+ }
165
+
166
+ function fmt(n) {
167
+ return n.toLocaleString('en-US')
168
+ }
169
+
170
+ /**
171
+ * Replay the session in event order, maintaining a persistent surface. At
172
+ * each step boundary the engine may compact (dcp scenario), then the request
173
+ * is priced and its prefix-cache hit measured against the previous request's
174
+ * surface.
175
+ */
176
+ function simulate(events, mode, useDcp) {
177
+ const surface = []
178
+ const stats = { requests: 0, inputTokens: 0, hitTokens: 0, compactions: [] }
179
+ let prevSurface = []
180
+ let compactionId = 0
181
+
182
+ for (const event of events) {
183
+ let message
184
+ if (event.type === 'user/message') message = event.data
185
+ else if (event.type === 'assistant/message') message = event.data.message
186
+ else if (event.type === 'tool/result') message = event.data.message
187
+ else if (event.type === 'step/start') {
188
+ if (useDcp) {
189
+ let safety = 0
190
+ while (surfaceTokens(surface, 'host') >= CONTEXT_WINDOW * THRESHOLD && safety < 8) {
191
+ const region = selectRegion(surface, Math.floor(CONTEXT_WINDOW * RETAIN), 'host')
192
+ if (region === null) break
193
+ const regionHost = region.reduce((sum, node) => sum + hostTokens(node.message.content), 0)
194
+ const regionCjk = region.reduce((sum, node) => sum + estimateMessageTokens(node.message, 'cjk'), 0)
195
+ const result = summarizeDeterministically({ messages: region.map((node) => node.message) }, DCP)
196
+ const framed = checkpointMessage(result.summary[0].text, `sim-${compactionId + 1}`)
197
+ const summaryHost = hostTokens(framed.content)
198
+ const summaryCjk = estimateMessageTokens(framed, 'cjk')
199
+ const checkpointNode = { seq: `checkpoint-${compactionId + 1}`, type: 'user/message', message: framed }
200
+
201
+ surface.splice(0, region.length, checkpointNode)
202
+ const totalAfter = surfaceTokens(surface, mode)
203
+ stats.compactions.push({
204
+ regionNodes: region.length,
205
+ regionHost,
206
+ regionCjk,
207
+ summaryHost,
208
+ summaryCjk,
209
+ compression: (regionHost / Math.max(1, summaryHost)).toFixed(1),
210
+ totalAfter,
211
+ preview: result.summary[0].text.split('\n').slice(0, 8).join(' | '),
212
+ })
213
+ compactionId += 1
214
+ safety += 1
215
+ }
216
+ }
217
+ const total = surfaceTokens(surface, mode)
218
+ const hit = commonPrefixTokens(prevSurface, surface, mode)
219
+ stats.requests += 1
220
+ stats.inputTokens += total
221
+ stats.hitTokens += hit
222
+ prevSurface = surface.map((node) => node)
223
+ continue
224
+ } else {
225
+ continue
226
+ }
227
+ const node = { seq: event.seq, type: event.type, message }
228
+ const op = event.surfaceOp
229
+ if (op && op.op === 'replace') {
230
+ const start = surface.findIndex((n) => n.seq === op.start)
231
+ const end = surface.findIndex((n) => n.seq === op.end)
232
+ if (start !== -1 && end !== -1) surface.splice(start, end - start + 1, node)
233
+ } else {
234
+ surface.push(node)
235
+ }
236
+ }
237
+ return stats
238
+ }
239
+
240
+ // -- main ------------------------------------------------------------------
241
+
242
+ const events = loadSession(sessionArg)
243
+ const surface = rebuildSurface(events)
244
+ const totalHost = surfaceTokens(surface, 'host')
245
+ const totalCjk = surfaceTokens(surface, 'cjk')
246
+
247
+ console.log('='.repeat(72))
248
+ console.log('dsh-dcp cache simulation on a real session')
249
+ console.log('='.repeat(72))
250
+ console.log(`session : ${sessionArg}`)
251
+ console.log(`window : ${fmt(CONTEXT_WINDOW)} · threshold ${THRESHOLD} (trigger at ${fmt(CONTEXT_WINDOW * THRESHOLD)} host tokens) · retain ${RETAIN} · language ${LANGUAGE}`)
252
+ console.log(`surface : ${surface.length} message nodes`)
253
+ console.log(`tokens : host ${fmt(totalHost)} · cjk ${fmt(totalCjk)} (ratio ${(totalCjk / totalHost).toFixed(3)})`)
254
+ console.log('')
255
+
256
+ for (const mode of ['host', 'cjk']) {
257
+ const baseline = simulate(events, mode, false)
258
+ const dcp = simulate(events, mode, true)
259
+ const hitRate = (stats) => (stats.inputTokens > 0 ? stats.hitTokens / stats.inputTokens : 0)
260
+ console.log(`── pricing: ${mode === 'host' ? 'host flat 4 chars/token' : 'dcp CJK-aware'} ──`)
261
+ console.log(` baseline (no compaction)`)
262
+ console.log(` ${baseline.requests} requests · input ${fmt(baseline.inputTokens)} tok · cache hit ${(hitRate(baseline) * 100).toFixed(1)}% · miss ${fmt(Math.round(baseline.inputTokens - baseline.hitTokens))} tok`)
263
+ console.log(` dcp backend`)
264
+ console.log(` ${dcp.compactions.length} compaction(s) · ${dcp.requests} requests · input ${fmt(dcp.inputTokens)} tok · cache hit ${(hitRate(dcp) * 100).toFixed(1)}% · miss ${fmt(Math.round(dcp.inputTokens - dcp.hitTokens))} tok`)
265
+ dcp.compactions.forEach((c, index) => {
266
+ console.log(` #${index + 1}: region ${fmt(c.regionHost)} tok (${c.regionNodes} nodes) → summary ${fmt(c.summaryHost)} tok · ${c.compression}x smaller · next request ${fmt(c.totalAfter)} tok (starts cold for every backend)`)
267
+ })
268
+ if (dcp.compactions.length > 0) {
269
+ const callInput = dcp.compactions.reduce((sum, c) => sum + c.regionHost, 0)
270
+ console.log(` LLM summarization calls: dcp 0 · compaction-basic ${dcp.compactions.length} (≈${fmt(Math.round(callInput))} input tokens replayed across them)`)
271
+ }
272
+ console.log('')
273
+ }
274
+
275
+ const last = simulate(events, 'cjk', true)
276
+ if (last.compactions.length > 0) {
277
+ const c = last.compactions[last.compactions.length - 1]
278
+ console.log('─ sample deterministic checkpoint (last compaction) ─')
279
+ console.log(c.preview)
280
+ console.log('')
281
+ console.log('Note: the request right after a compaction starts cold (hit ≈ 0)')
282
+ console.log('for ANY backend — compaction-basic included — because the leading')
283
+ console.log('span is replaced by brand-new checkpoint text. dcp only differs in')
284
+ console.log('summary size (here above) and in paying zero LLM calls per compaction.')
285
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-dcp setup: mount dsh-dcp into a cordis.patch.yml safely.
4
+ *
5
+ * - defaults to the home patch (`$DSH_HOME/cordis.patch.yml`, usually
6
+ * `~/.dsh/cordis.patch.yml`); `--profile <name>` targets that profile's
7
+ * patch; a positional argument targets an explicit file path
8
+ * - backs up an existing file to `<file>.bak.<YYYYMMDD-HHMM>` before touching it
9
+ * - appends only the dsh-dcp mount block — the user's own entries and
10
+ * comments are never rewritten
11
+ * - idempotent: skips when the mount is already present; never duplicates a
12
+ * `compaction-basic` entry
13
+ * - refuses to double-mount: if dsh-dcp is already a bundle in any affected
14
+ * profile, it aborts (use `--force` to override)
15
+ * - generates the file fresh when it does not exist
16
+ * - uses the running package's absolute entry path, so the mount resolves
17
+ * regardless of how dsh-dcp was installed
18
+ *
19
+ * Usage:
20
+ * node scripts/setup.mjs # home patch
21
+ * node scripts/setup.mjs --profile tui # tui profile's patch
22
+ * node scripts/setup.mjs /path/to/cordis.patch.yml
23
+ */
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { planPatch, backupStamp, findBundledProfiles } from '../lib/setup.js'
28
+
29
+ const PKG = '@aiwayds/dsh-dcp'
30
+ const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
31
+ const ENTRY = path.join(pkgRoot, 'lib', 'index.js')
32
+
33
+ function home() {
34
+ return process.env.DSH_HOME || path.join(process.env.HOME ?? '.', '.dsh')
35
+ }
36
+
37
+ function parseArgs(argv) {
38
+ const force = argv.includes('--force')
39
+ if (argv.includes('--profile')) {
40
+ const index = argv.indexOf('--profile')
41
+ const name = argv[index + 1]
42
+ if (!name) throw new Error('setup: --profile requires a profile name')
43
+ return { target: path.join(home(), 'profiles', name, 'cordis.patch.yml'), kind: 'profile', profileName: name, force }
44
+ }
45
+ const explicit = argv.find((a) => !a.startsWith('-'))
46
+ if (explicit) return { target: path.resolve(explicit), kind: 'explicit', force }
47
+ return { target: path.join(home(), 'cordis.patch.yml'), kind: 'home', force }
48
+ }
49
+
50
+ const { target, kind, profileName, force } = parseArgs(process.argv.slice(2))
51
+ const existed = fs.existsSync(target)
52
+ const text = existed ? fs.readFileSync(target, 'utf8') : undefined
53
+ if (!existed) console.log(`no ${target} — will generate a fresh patch file`)
54
+
55
+ const plan = planPatch(text, { name: ENTRY })
56
+ if (plan.action === 'skip') {
57
+ console.log('dsh-dcp is already mounted in this patch file — nothing to do.')
58
+ process.exit(0)
59
+ }
60
+
61
+ // Refuse to double-mount: a home/profile patch applies to the same profile as
62
+ // the bundle mechanism would, and two dsh-dcp entries would crash the loader.
63
+ let affected = []
64
+ if (kind === 'profile') affected = [profileName]
65
+ else if (kind === 'home') {
66
+ try {
67
+ affected = findBundledProfiles(path.join(home(), 'profiles'), PKG)
68
+ } catch { /* profiles dir unreadable — proceed */ }
69
+ }
70
+ if (affected.length > 0 && !force) {
71
+ console.error(`ERROR: dsh-dcp is already a bundle in profile(s): ${affected.join(', ')}.`)
72
+ console.error('Mounting it in the patch file too would duplicate the entry id and crash the loader.')
73
+ console.error(`Remove it from those profiles' bundles (dsh plugin rm ${PKG}), or pass --force to override.`)
74
+ process.exit(1)
75
+ }
76
+ if (affected.length > 0 && force) {
77
+ console.warn(`WARN: overriding — dsh-dcp is already a bundle in: ${affected.join(', ')}. You are on your own if the loader rejects the duplicate.`)
78
+ }
79
+
80
+ if (plan.note) console.warn(`WARN: ${plan.note}`)
81
+ if (existed) {
82
+ // back up the user's file only now that we know we will modify it
83
+ const backup = `${target}.bak.${backupStamp()}`
84
+ fs.copyFileSync(target, backup)
85
+ console.log(`backup: ${backup}`)
86
+ }
87
+ fs.mkdirSync(path.dirname(target), { recursive: true })
88
+ fs.appendFileSync(target, plan.block)
89
+ console.log(`${plan.action === 'create' ? 'created' : 'patched'} ${target}`)
90
+ console.log('restart dsh, then run /dcp to verify.')