dsh-rtk-slim 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cooper Zhuang
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 CHANGED
@@ -1,3 +1,197 @@
1
- # Temporary Holding Version
1
+ # dsh-rtk-slim
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH(DeepSeek Harness)宿主插件:把 `bash` / `job_output` 的超大输出交给
4
+ [rtk](https://github.com/rtk-ai/rtk)(Rust Token Killer)精简后再交给模型,并且**每一次精简都可追溯、
5
+ 每一次失败都保留原文**。
6
+
7
+ ## 功能
8
+
9
+ - 挂在官方 `tools/post-execute` waterfall 上,**登记为最内层**(不加 `prepend`),因此看到的是原始结果;
10
+ `dsh-spill-policy` 在外层,仍可对精简后的文本再落盘。
11
+ - 只改模型看到的 `content`,**从不动命令、不动 `value`、绝不把成功调用变成 `isError`**。
12
+ - 家族嗅探决定 rtk 参数:识别得出才用 `-f <filter>`(cargo-test / pytest / git-log / grep / find),
13
+ 识别不出走裸 `rtk pipe`(rtk 对认不出的内容逐字节透传)。这避免了「`-f` 选错把内容删掉」的失败模式。
14
+ - 替换前过四道闸门:标记逐字保全、失败证据保全、压缩比合理、**必须严格变小**;任一不过就保留原文。
15
+ - 每次替换都把**全文**经官方 `ctx.spillStore` 落盘,并在文本末尾附一行
16
+ `(rtk condensed A → B bytes. Full formatted result stored at: <path>. …)`,
17
+ 模型随时可以按路径取回原文。
18
+ - 同一进程内同类失败只告警一次(不刷屏);宿主日志里能分清「没挂上」「挂了但被停用」「挂了但 rtk 不可用」。
19
+
20
+ ## 依赖:外部 `rtk` 二进制
21
+
22
+ **本插件不捆绑 rtk。** 精简能力来自外部命令行程序 rtk:
23
+
24
+ - 上游仓库:<https://github.com/rtk-ai/rtk>(按上游说明自行安装);
25
+ - 安装完成后确认 `rtk` 在宿主进程的 `PATH` 里:
26
+
27
+ ```sh
28
+ rtk --version # 能打印版本即可
29
+ ```
30
+
31
+ - 不在 `PATH` 或想用别的路径时,用配置项 `command`(默认 `'rtk'`)指定绝对路径。
32
+
33
+ 平台要求:只要 DSH 宿主与 `rtk` 能在同一平台运行即可(macOS / Linux / Windows 均可);
34
+ 插件按平台处理子进程的超时与取消(POSIX 杀进程组,Windows 用 `taskkill /T`)。
35
+
36
+ ### rtk 缺失 / 不可用时的行为
37
+
38
+ **不生效,但绝不影响原有结果**:
39
+
40
+ - 启动 `rtk` 失败(未安装、不在 `PATH`、权限不足)→ 插件放弃压缩,模型看到的内容与**完全没装本插件时一致**,
41
+ 宿主日志留一条 `rtk-slim: rtk:spawn: rtk did not condense bash output` 告警(每个进程只告警一次)。
42
+ - rtk 崩溃(非 0 退出)、超时(默认 10 s)、输出超限或调用被取消 → 同样保留原文,日志记录对应原因。
43
+ - 全文落盘失败且 `requireSpill` 为 `true`(默认)→ 放弃替换、保留原文(「有损必可追溯」)。
44
+
45
+ 这些降级路径都有自动化测试覆盖;rtk 存在但内容本身压缩不了时,插件同样原样放行。
46
+
47
+ ## 安装
48
+
49
+ ```sh
50
+ dsh plugin --profile <profile> add dsh-rtk-slim
51
+ ```
52
+
53
+ 把 `<profile>` 换成目标 profile(例如桌面版 `desktop`、`dsh web` 用 `web`)。
54
+ 安装后**重启一次宿主**最稳妥(bundle 是否支持热挂载取决于宿主版本);重启后按下一节确认已激活。
55
+
56
+ 从源码安装:
57
+
58
+ ```sh
59
+ git clone https://github.com/CooperZhuang/dsh-plugins-cooper-custom.git
60
+ dsh plugin --profile <profile> add ./dsh-plugins-cooper-custom/packages/dsh-rtk-slim
61
+ ```
62
+
63
+ ### 卸载
64
+
65
+ ```sh
66
+ dsh plugin --profile <profile> remove dsh-rtk-slim
67
+ ```
68
+
69
+ 删除后重启宿主一次。
70
+
71
+ ## 如何确认已激活
72
+
73
+ 插件在挂载时写一行启动记录(宿主本身不把插件激活写进磁盘,这是最直接的证据):
74
+
75
+ ```sh
76
+ tail -n 3 "${DSH_HOME:-$HOME/.dsh}/logs/dsh-rtk-slim.log"
77
+ # 2026-10-16T04:35:12.345Z mounted pid=14813 enabled=true command=rtk tools=bash,job_output minBytes=2048 timeoutMs=10000 spill=true requireSpill=true notice=true ultraCompact=false filters={}
78
+ ```
79
+
80
+ - 有 `mounted` 行 → 宿主确实加载了(`enabled=false` 也会留痕,便于区分「没挂上」与「挂了但被停用」)。
81
+ - 想快速停用而不卸载:在 profile 的 `cordis.patch.yml` 里给 `rtk-slim` 行加 `config: { enabled: false }`
82
+ (同样需要重启宿主)。
83
+
84
+ ## 配置(全部有默认值)
85
+
86
+ 配置写在 profile 的补丁层里(`dsh plugin add` 会接入本包自带的 `cordis.patch.yml`):
87
+
88
+ ```yaml
89
+ - insert:
90
+ - id: rtk-slim
91
+ name: dsh-rtk-slim
92
+ config:
93
+ enabled: true
94
+ ```
95
+
96
+ | 字段 | 默认 | 说明 |
97
+ |---|---|---|
98
+ | `enabled` | `true` | `false` 时完全不注册监听器 |
99
+ | `command` | `'rtk'` | rtk 可执行文件(裸名走 PATH) |
100
+ | `tools` | `['bash','job_output']` | 需要过滤的工具名 |
101
+ | `minBytes` | `2048` | 低于此字节数放行(避免为小输出付一次 spawn) |
102
+ | `maxInputBytes` | `4194304` | 高于此字节数不调 rtk |
103
+ | `timeoutMs` | `10000` | rtk 超时(超时按进程组击杀整个进程树) |
104
+ | `maxOutputBytes` | `8388608` | 捕获 stdout 上限 |
105
+ | `filters` | `{}` | 按家族 id 覆盖 filter 名,或传 `false` 停用某家族 |
106
+ | `spill` | `true` | 是否落盘全文 |
107
+ | `requireSpill` | `true` | 落盘失败时是否放弃替换(「有损必可追溯」) |
108
+ | `notice` | `true` | 是否追加 notice 行 |
109
+ | `ultraCompact` | `false` | 透传 `--ultra-compact` 给 rtk |
110
+
111
+ 把 `requireSpill` 设为 `false` 即可放宽成「压缩但不落盘」。
112
+
113
+ ## 兼容性
114
+
115
+ - **DSH**:`engines.dsh: ^0.2.1-rc.2 || ^0.2.2-alpha.2`。
116
+ - **Node.js**:`>=24`。
117
+ - **rtk**:不依赖特定版本;`rtk --version` 能跑即可(下表数字在 rtk 1.1.0 上测得)。
118
+
119
+ 本插件碰到的内核面只有三处:
120
+
121
+ - `tools/post-execute` waterfall(`dsh-tools`):`exec` / `result` / `next()` 签名、
122
+ `PostToolDecision` 的两种 `accept` 形态(`content` 与 `value`)以及 `additionalContexts` 透传;
123
+ `next()` 的兜底仍是 `{kind:'accept'}`(不带 `content`),所以本插件拿到的仍是**原始**文本。
124
+ - `ctx.get('spillStore')`(`dsh-spill`):`saveText({owner, source, suggestedName, content})` →
125
+ `{locator, bytes, retrievalHint}`。归属取 `exec.agent.session.header.id`。
126
+ - 插件契约:`export const name / inject / Config / apply`(cordis 的 object plugin)、
127
+ `dsh.bundle.patch` 清单字段、`cordis.patch.yml` 的 `insert` 行格式。
128
+
129
+ `lib/` 对 `@deepseek-ai/dsh-*` 的**运行时导入为 0**(只用 JSDoc `import type`)。所以 DSH 将来改 API,
130
+ 最坏后果是「不压缩」,而不是加载即失败。
131
+
132
+ ## 评测(真实 rtk,`test/fixtures/` 语料)
133
+
134
+ `minBytes: 512`,rtk 1.1.0,数字为字节数;输入含末尾的 `[exit code: 0]` 状态标记:
135
+
136
+ | 输入 | 压缩前 | 压缩后正文 | filter |
137
+ |---|---|---|---|
138
+ | `git log --oneline`(74 提交) | 4120 B | **247 B** | `-f git-log` |
139
+ | pytest(30 用例) | 2327 B | **26 B** | `-f pytest` |
140
+ | cargo test 通过(40 用例) | 880 B | 38 B | `-f cargo-test` |
141
+ | cargo test 失败(40 用例) | 888 B | **98 B** | `-f cargo-test`(失败行保留) |
142
+ | `grep -rn`(70 命中 / 4 文件) | 7718 B | **3138 B** | `-f grep`(rtk 按文件分组) |
143
+ | `ls -la` | 2253 B | 逐字节不变 | 落 `auto` 透传 |
144
+ | 源码 / JSON | — | 逐字节不变 | 落 `auto` 透传 |
145
+
146
+ 数字只作量级参考:随 rtk 版本与输入变化,插件不依赖具体数值;默认 `minBytes: 2048` 时
147
+ 低于门槛的输入(如上面 880 B 的 cargo 通过)不会调用 rtk。
148
+
149
+ ## 排错
150
+
151
+ - **先看启动记录**:`${DSH_HOME:-$HOME/.dsh}/logs/dsh-rtk-slim.log` 的 `mounted` 行(见「如何确认已激活」)。
152
+ - **宿主日志**:DSH Desktop 把宿主日志写在应用数据目录的 `logs/host/dsh-<date>.log`
153
+ (macOS 为 `~/Library/Application Support/DSH Desktop/logs/host/`)。纯文本行(`[I]/[W]/[E]`)
154
+ 记配置重载、包解析失败等;`rtk-slim:*` 的告警也在这里。
155
+ - **只想验证「下次启动会不会挂上」而不重启**:Desktop profile 被应用独占,`--dump-config` 会被拒绝时,
156
+ 可以复制一份 profile 用同一套组合逻辑验证:
157
+
158
+ ```sh
159
+ cp -Rc ~/.dsh/profiles/desktop ~/.dsh/profiles/rtkcheck # APFS clone,秒级
160
+ node .../dsh/lib/bin.js --profile rtkcheck --dump-config | grep -A3 rtk-slim
161
+ rm -rf ~/.dsh/profiles/rtkcheck # 验完删掉
162
+ ```
163
+
164
+ - **想在一个一次性的隔离宿主里验证挂载**(不碰自己的会话/记忆/凭据):
165
+
166
+ ```sh
167
+ export DSH_HOME=/tmp/rtk-verify
168
+ node .../dsh/lib/bin.js --profile rtkcheck --from-default-profile headless --dump-config
169
+ ```
170
+
171
+ headless 模板自带的 base bundle 已含 `bash` / `job_output` / `spill-local` / `spill-policy`,插件全路径可用;
172
+ 没有凭据时 LLM 调用会失败,但**插件挂载已经发生**——启动日志就是证据。
173
+
174
+ ## 开发
175
+
176
+ ```sh
177
+ npm install --ignore-scripts
178
+ npm test # node --test test/*.test.mjs
179
+ npm run typecheck # tsc --checkJs(仅 lib/)
180
+ npm run check # 两者都跑
181
+ node test/fixtures/make-fixtures.mjs # 重新生成语料(确定性)
182
+ ```
183
+
184
+ - 端到端用例会调用**真实 rtk**:`rtk` 不在 `PATH` 时自动跳过;装在非标准路径时用
185
+ `RTK_BIN=/path/to/rtk npm test` 指定。其余用例用 `test/bin/rtk-stub.mjs`,不依赖外部程序。
186
+ - 类型检查只覆盖 `lib/**`:那是「用运行时同版本类型校验插件契约」的落点;测试文件是动态桩。
187
+ - 语料必须**忠实于真实输出**:例如 rtk 的 `grep` filter 按文件分组,语料若写成「每个文件 1 行命中」
188
+ 就不会被压缩(曾因此误判为代码 bug)。
189
+
190
+ ## 设计文档
191
+
192
+ - 设计(v3):[docs/superpowers/specs/2026-10-16-dsh-rtk-slim-design.md](https://github.com/CooperZhuang/dsh-plugins-cooper-custom/blob/main/packages/dsh-rtk-slim/docs/superpowers/specs/2026-10-16-dsh-rtk-slim-design.md)
193
+ - 实施计划:[docs/superpowers/plans/2026-10-16-dsh-rtk-slim-implementation.md](https://github.com/CooperZhuang/dsh-plugins-cooper-custom/blob/main/packages/dsh-rtk-slim/docs/superpowers/plans/2026-10-16-dsh-rtk-slim-implementation.md)
194
+
195
+ ## License
196
+
197
+ MIT
@@ -0,0 +1,17 @@
1
+ # The dsh-rtk-slim bundle patch: one row inserting the rtk output condenser over
2
+ # the profile root. Address it by id (`rtk-slim`) from the profile's own patch
3
+ # layer; the last write wins.
4
+ #
5
+ # Every behavioural knob lives on this row as a live config field (see the
6
+ # plugin's Config schema). This row carries deployment facts only.
7
+ - insert:
8
+ - id: rtk-slim
9
+ name: dsh-rtk-slim
10
+ config:
11
+ # command: rtk # bare name resolves through PATH
12
+ # tools: ['bash', 'job_output']
13
+ # minBytes: 2048
14
+ # spill: true
15
+ # requireSpill: true
16
+ # notice: true
17
+ enabled: true
package/docs/DETAIL.md ADDED
@@ -0,0 +1,11 @@
1
+ # Tool output slimming
2
+
3
+ Condenses oversized tool output: the model sees a short notice with byte counts; the full text goes to the spill store and can be fetched later.
4
+
5
+ ## How it works
6
+ - Hooks the tools/post-execute waterfall shared by native and PTC execution.
7
+ - Content-aware condensation with line/byte thresholds and an rtk condensed notice.
8
+
9
+ ## Caveat
10
+ - Native mode: condensation changes what the model sees.
11
+ - PTC mode: only the session-log dispatch content is condensed; the program value and model context are unchanged.
@@ -0,0 +1,12 @@
1
+ # 工具输出瘦身
2
+
3
+ 对过长的工具输出做压缩:模型先看到摘要与字节数,完整原文写进 spill 存储,需要时按路径取回。
4
+
5
+ ## 工作方式
6
+ - 挂在 tools/post-execute waterfall 上,native 与 PTC 执行路径共用这条流水线。
7
+ - 按内容类型挑选压缩器(行数/字节阈值、format 提示),输出 rtk condensed 通知。
8
+ - 原文写入 spill(目录由 profile 的 spill-local 行固定),可随时取回。
9
+
10
+ ## 口径
11
+ - native 模式:压缩直接影响模型可见输出。
12
+ - PTC 模式:压缩只作用于会话日志里的 dispatch content,模型拿到的是程序返回值,上下文大小不变。
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 8h22l-8.5 10.5V27l-5 2v-10.5L7 8z" fill="#14B8A6"/></svg>
package/lib/content.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Boundary between the harness content-block payload and plain text.
3
+ *
4
+ * `tools/post-execute` hands over `result.content` as a `ContentBlock[]`, not a
5
+ * string. The plugin is only interested in the common single-text-block case
6
+ * (what `bash` and `job_output` render); anything else is left untouched so we
7
+ * never fight a tool's own definition-owned content finalizer.
8
+ *
9
+ * @module dsh-rtk-slim/content
10
+ */
11
+
12
+ /**
13
+ * @typedef {import('@deepseek-ai/dsh-llm').ContentBlock} ContentBlock
14
+ */
15
+
16
+ /**
17
+ * Text of a payload that is exactly one text block.
18
+ * @param {readonly ContentBlock[] | undefined} content
19
+ * @returns {string | undefined} `undefined` means "out of scope, bail".
20
+ */
21
+ export function singleText(content) {
22
+ if (!Array.isArray(content) || content.length !== 1) return undefined
23
+ const block = content[0]
24
+ if (block === null || typeof block !== 'object' || block.type !== 'text') return undefined
25
+ return block.text
26
+ }
27
+
28
+ /**
29
+ * Re-wrap condensed text as the single text block the registry expects.
30
+ * @param {string} text
31
+ * @returns {ContentBlock[]}
32
+ */
33
+ export function textBlock(text) {
34
+ return [{ type: 'text', text }]
35
+ }
package/lib/guards.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Invariants a condensation must satisfy before it may replace model-visible
3
+ * text. Any failure means "keep the original" — never an error result.
4
+ *
5
+ * I1 (marker preservation) is structural: callers split the trailing status
6
+ * block off before calling rtk and re-attach it verbatim afterwards, so it
7
+ * cannot be lost here.
8
+ *
9
+ * @module dsh-rtk-slim/guards
10
+ */
11
+
12
+ /**
13
+ * Failure-signal lines. Deliberately narrow: a bare `failed` word would make
14
+ * the invariant vacuously false on a passing run ("0 failed"), killing the
15
+ * main win.
16
+ */
17
+ export const FAILURE_PATTERNS = [
18
+ /^test result: FAILED/,
19
+ /^FAILED\b/,
20
+ /^--- FAIL:/,
21
+ /^error(\[E\d+\])?:/,
22
+ /^Traceback \(most recent call last\):/,
23
+ /panicked at/,
24
+ /^AssertionError\b/,
25
+ /^FAIL\b/,
26
+ ]
27
+
28
+ /**
29
+ * Distinct failure-signal lines in `text` (trimmed).
30
+ * @param {string} text
31
+ * @returns {string[]}
32
+ */
33
+ export function collectFailureLines(text) {
34
+ const found = new Set()
35
+ for (const line of text.split('\n')) {
36
+ const trimmed = line.trim()
37
+ if (trimmed.length === 0) continue
38
+ if (FAILURE_PATTERNS.some((pattern) => pattern.test(trimmed))) found.add(trimmed)
39
+ }
40
+ return [...found]
41
+ }
42
+
43
+ /** @typedef {{ ok: true } | { ok: false, reason: 'empty' | 'unchanged' | 'expanded' | 'failures' | 'ratio' }} GuardResult */
44
+
45
+ /**
46
+ * @param {{ raw: string, condensed: string }} input both already-extracted text
47
+ * @returns {GuardResult}
48
+ */
49
+ export function checkInvariants({ raw, condensed }) {
50
+ if (condensed.length === 0) return { ok: false, reason: 'empty' }
51
+ const rawBytes = Buffer.byteLength(raw, 'utf8')
52
+ const condensedBytes = Buffer.byteLength(condensed, 'utf8')
53
+ if (condensed.trim() === raw.trim()) return { ok: false, reason: 'unchanged' }
54
+ if (condensedBytes >= rawBytes) return { ok: false, reason: 'expanded' }
55
+ if (collectFailureLines(raw).some((line) => !condensed.includes(line))) {
56
+ return { ok: false, reason: 'failures' }
57
+ }
58
+ if (condensedBytes < rawBytes * 0.02 && rawBytes >= 32 * 1024) {
59
+ const hasOmissionMarker = /\[\+\d+ lines? omitted\]/.test(condensed)
60
+ const hasSpillNotice = condensed.includes('Full formatted result stored at:')
61
+ if (!hasOmissionMarker && !hasSpillNotice) return { ok: false, reason: 'ratio' }
62
+ }
63
+ return { ok: true }
64
+ }
package/lib/index.js ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * dsh-rtk-slim: condense oversized `bash` / `job_output` text through `rtk`
3
+ * before the model sees it, with a recoverable full-text copy and a
4
+ * no-surprises failure surface.
5
+ *
6
+ * Registered WITHOUT `prepend`, i.e. as the innermost listener of the
7
+ * `tools/post-execute` waterfall: every outer listener (notably
8
+ * `dsh-spill-policy`, which prepends) has already run `next()` by the time we
9
+ * see the result, so we condense the original text and spill-policy then gets
10
+ * to post-process our (smaller) output.
11
+ *
12
+ * @module dsh-rtk-slim
13
+ */
14
+
15
+ import z from '@deepseek-ai/schemastery'
16
+ import { singleText, textBlock } from './content.js'
17
+ import { checkInvariants } from './guards.js'
18
+ import { logStartup } from './log.js'
19
+ import { joinTailMarkers, splitTailMarkers } from './markers.js'
20
+ import { runRtk } from './rtk.js'
21
+ import { chooseFilter } from './sniff.js'
22
+ import { formatCondenseNotice, saveFullText } from './spill.js'
23
+
24
+ export const name = 'rtk-slim'
25
+
26
+ /** The tools registry owns the waterfall we hook. */
27
+ export const inject = ['tools']
28
+
29
+ export const Config = z.object({
30
+ enabled: z.boolean().default(true),
31
+ command: z.string().default('rtk'),
32
+ tools: z.array(z.string()).default(['bash', 'job_output']),
33
+ minBytes: z.number().default(2048),
34
+ maxInputBytes: z.number().default(4194304),
35
+ timeoutMs: z.number().default(10000),
36
+ maxOutputBytes: z.number().default(8388608),
37
+ filters: z.dict(z.union([z.string(), z.const(false)])).default({}),
38
+ spill: z.boolean().default(true),
39
+ requireSpill: z.boolean().default(true),
40
+ notice: z.boolean().default(true),
41
+ ultraCompact: z.boolean().default(false),
42
+ })
43
+
44
+ /**
45
+ * @typedef {object} SlimConfig
46
+ * @property {boolean} enabled
47
+ * @property {string} command
48
+ * @property {string[]} tools
49
+ * @property {number} minBytes
50
+ * @property {number} maxInputBytes
51
+ * @property {number} timeoutMs
52
+ * @property {number} maxOutputBytes
53
+ * @property {Record<string, string | false>} filters
54
+ * @property {boolean} spill
55
+ * @property {boolean} requireSpill
56
+ * @property {boolean} notice
57
+ * @property {boolean} ultraCompact
58
+ */
59
+
60
+ /**
61
+ * Defensive defaults so the handler is usable with a raw config object in
62
+ * tests, and with a schema-validated one from the loader.
63
+ * @param {Partial<SlimConfig> | undefined} raw
64
+ * @returns {SlimConfig}
65
+ */
66
+ function normalizeConfig(raw) {
67
+ const defaults = {
68
+ enabled: true,
69
+ command: 'rtk',
70
+ tools: ['bash', 'job_output'],
71
+ minBytes: 2048,
72
+ maxInputBytes: 4194304,
73
+ timeoutMs: 10000,
74
+ maxOutputBytes: 8388608,
75
+ filters: {},
76
+ spill: true,
77
+ requireSpill: true,
78
+ notice: true,
79
+ ultraCompact: false,
80
+ }
81
+ const merged = { ...defaults, ...(raw ?? {}) }
82
+ if (!Array.isArray(merged.tools)) merged.tools = defaults.tools
83
+ if (merged.filters === null || typeof merged.filters !== 'object') merged.filters = {}
84
+ return merged
85
+ }
86
+
87
+ /**
88
+ * Build the `tools/post-execute` listener. Exported for tests that drive it
89
+ * without a cordis context.
90
+ * @param {{ logger?: { warn: (message: string) => void }, get: (name: string) => any }} ctx
91
+ * @param {Partial<SlimConfig> | undefined} rawConfig
92
+ * @param {{ runRtk?: typeof runRtk, saveFullText?: typeof saveFullText }} [deps]
93
+ * @returns {(exec: any, result: any, next: () => Promise<any>) => Promise<any>}
94
+ */
95
+ export function createPostExecuteHandler(ctx, rawConfig, deps = {}) {
96
+ const config = normalizeConfig(rawConfig)
97
+ const run = deps.runRtk ?? runRtk
98
+ const spill = deps.saveFullText ?? saveFullText
99
+ const warned = new Set()
100
+
101
+ /**
102
+ * One line per distinct reason, ever.
103
+ * @param {string} key
104
+ * @param {string} detail
105
+ */
106
+ function warn(key, detail) {
107
+ if (warned.has(key)) return
108
+ warned.add(key)
109
+ ctx.logger?.warn(`rtk-slim: ${key}: ${detail}`)
110
+ }
111
+
112
+ return async function postExecute(exec, result, next) {
113
+ // Let downstream (more inner) listeners settle first; a throw here belongs
114
+ // to the registry, not to us.
115
+ const decision = await next()
116
+ try {
117
+ if (decision === null || typeof decision !== 'object' || decision.kind !== 'accept') return decision
118
+ if (Object.hasOwn(decision, 'value')) return decision
119
+ if (result?.isError === true) return decision
120
+ if (!config.tools.includes(exec?.name)) return decision
121
+
122
+ const raw = singleText(decision.content ?? result?.content)
123
+ if (raw === undefined) return decision
124
+
125
+ const rawBytes = Buffer.byteLength(raw, 'utf8')
126
+ if (rawBytes < config.minBytes || rawBytes > config.maxInputBytes) return decision
127
+
128
+ const { body, tail } = splitTailMarkers(raw)
129
+ if (body.length === 0) return decision
130
+
131
+ const { filter } = chooseFilter(body, config.filters)
132
+ const args = filter === undefined ? ['pipe'] : ['pipe', '-f', filter]
133
+ if (config.ultraCompact) args.push('--ultra-compact')
134
+
135
+ const produced = await run({
136
+ command: config.command,
137
+ args,
138
+ input: body,
139
+ timeoutMs: config.timeoutMs,
140
+ signal: exec?.signal,
141
+ maxOutputBytes: config.maxOutputBytes,
142
+ })
143
+ if (!produced.ok) {
144
+ warn(`rtk:${produced.reason}`, `rtk did not condense ${exec?.name} output`)
145
+ return decision
146
+ }
147
+
148
+ const condensedBody = produced.text.replace(/\n+$/, '')
149
+ const guard = checkInvariants({ raw: body, condensed: condensedBody })
150
+ if (!guard.ok) {
151
+ if (guard.reason !== 'unchanged') warn(`guard:${guard.reason}`, `condensation rejected for ${exec?.name}`)
152
+ return decision
153
+ }
154
+
155
+ /** @type {import('./spill.js').Ref | undefined} */
156
+ let ref
157
+ if (config.spill) {
158
+ ref = await spill(ctx, exec, raw)
159
+ if (ref === undefined && config.requireSpill) {
160
+ warn('spill:failed', `no recoverable copy for ${exec?.name}; keeping the original`)
161
+ return decision
162
+ }
163
+ }
164
+
165
+ const notice =
166
+ config.notice && ref !== undefined
167
+ ? formatCondenseNotice({
168
+ beforeBytes: rawBytes,
169
+ afterBytes: Buffer.byteLength(condensedBody, 'utf8'),
170
+ ref,
171
+ })
172
+ : undefined
173
+
174
+ const assembled = [condensedBody, notice, tail].filter((part) => part !== undefined && part !== '').join('\n')
175
+ return {
176
+ kind: 'accept',
177
+ content: textBlock(assembled),
178
+ ...(decision.additionalContexts === undefined ? {} : { additionalContexts: decision.additionalContexts }),
179
+ }
180
+ } catch (error) {
181
+ warn('unexpected', String(error instanceof Error ? error.message : error))
182
+ return decision
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * @param {{ on: (event: string, listener: any) => any, logger?: { warn: (message: string) => void }, get: (name: string) => any }} ctx
189
+ * @param {Partial<SlimConfig> | undefined} config
190
+ */
191
+ export function apply(ctx, config) {
192
+ const resolved = normalizeConfig(config)
193
+ // Nothing else in the harness records activation to disk, so this one line is
194
+ // what makes "did it mount, and with which config?" answerable after a restart.
195
+ logStartup(
196
+ `mounted pid=${process.pid} enabled=${resolved.enabled} command=${resolved.command} tools=${resolved.tools.join(',')} ` +
197
+ `minBytes=${resolved.minBytes} timeoutMs=${resolved.timeoutMs} spill=${resolved.spill} requireSpill=${resolved.requireSpill} ` +
198
+ `notice=${resolved.notice} ultraCompact=${resolved.ultraCompact} filters=${JSON.stringify(resolved.filters)}`,
199
+ )
200
+ if (resolved.enabled === false) return
201
+ ctx.on('tools/post-execute', createPostExecuteHandler(ctx, resolved))
202
+ }
package/lib/log.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Startup evidence. The harness never logs plugin activation to disk (dsh web
3
+ * and the Desktop shell keep stdout in memory), so "did it actually mount, and
4
+ * with which config?" is otherwise unanswerable after a restart. One appended
5
+ * line per activation settles it.
6
+ *
7
+ * Never throws: a logging failure must not stop the plugin from loading.
8
+ *
9
+ * @module dsh-rtk-slim/log
10
+ */
11
+
12
+ import { appendFileSync, mkdirSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+
16
+ /**
17
+ * `$DSH_HOME/logs/dsh-rtk-slim.log`, falling back to `~/.dsh/logs/...`.
18
+ * @returns {string}
19
+ */
20
+ export function defaultLogPath() {
21
+ const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0 ? process.env.DSH_HOME : join(homedir(), '.dsh')
22
+ return join(home, 'logs', 'dsh-rtk-slim.log')
23
+ }
24
+
25
+ /**
26
+ * Append one timestamped line.
27
+ * @param {string} line
28
+ * @param {string} [path]
29
+ * @returns {boolean} whether the line was written
30
+ */
31
+ export function logStartup(line, path = defaultLogPath()) {
32
+ try {
33
+ mkdirSync(dirname(path), { recursive: true })
34
+ appendFileSync(path, `${new Date().toISOString()} ${line}\n`)
35
+ return true
36
+ } catch {
37
+ return false
38
+ }
39
+ }
package/lib/markers.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Trailing status markers, exactly as the harness bash tool renders them
3
+ * (`dsh-tool-bash`'s `renderResult` / `renderPromoted`). They are the model's
4
+ * contract for exit status, so they must survive condensation byte-for-byte.
5
+ *
6
+ * @module dsh-rtk-slim/markers
7
+ */
8
+
9
+ /** One trailing status marker line. `[status: …]` is `job_output`'s own line. */
10
+ const MARKER = /^\[(?:exit code: -?\d+|timed out after \d+ms|stopped: [^\]]*|status: [^\]]*|killed by signal: [^\]]*|sandbox: [^\]]*|still running after \d+ms; moved to background job [^\]]+)\]$/
11
+
12
+ /** Opening words of the guidance paragraph that follows a background promotion. */
13
+ const BACKGROUND_GUIDANCE = 'The command keeps running in the background.'
14
+
15
+ /**
16
+ * Split the trailing status block off a rendered tool result.
17
+ * @param {string} text
18
+ * @returns {{ body: string, tail: string }} `tail` is `''` when nothing matched.
19
+ */
20
+ export function splitTailMarkers(text) {
21
+ if (text.length === 0) return { body: '', tail: '' }
22
+ const lines = text.split('\n')
23
+ let cut = lines.length
24
+ // The promoted-to-background shape is "<marker>\n<guidance>"; consume the
25
+ // guidance only when a real marker sits directly above it.
26
+ if (cut > 1 && lines[cut - 1].startsWith(BACKGROUND_GUIDANCE) && MARKER.test(lines[cut - 2])) cut -= 1
27
+ while (cut > 0 && MARKER.test(lines[cut - 1])) cut -= 1
28
+ if (cut === lines.length) return { body: text, tail: '' }
29
+ return { body: lines.slice(0, cut).join('\n'), tail: lines.slice(cut).join('\n') }
30
+ }
31
+
32
+ /**
33
+ * Re-attach a split `tail`. `joinTailMarkers(...split(x)) === x` for every `x`.
34
+ * @param {string} body
35
+ * @param {string} tail
36
+ * @returns {string}
37
+ */
38
+ export function joinTailMarkers(body, tail) {
39
+ if (tail === '') return body
40
+ return body === '' ? tail : `${body}\n${tail}`
41
+ }
package/lib/rtk.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * rtk invocation. Owns every way the child can misbehave: missing binary,
3
+ * non-zero exit, timeout, caller cancellation, runaway output. Every failure is
4
+ * a value, never a throw — the caller keeps the original text.
5
+ *
6
+ * @module dsh-rtk-slim/rtk
7
+ */
8
+
9
+ import { spawn } from 'node:child_process'
10
+
11
+ /** @typedef {{ ok: true, text: string } | { ok: false, reason: 'spawn' | 'exit' | 'timeout' | 'aborted' | 'too-large' }} RunResult */
12
+
13
+ /**
14
+ * @param {object} input
15
+ * @param {string} input.command
16
+ * @param {string[]} input.args
17
+ * @param {string} input.input stdin payload
18
+ * @param {number} input.timeoutMs
19
+ * @param {AbortSignal} [input.signal]
20
+ * @param {number} input.maxOutputBytes
21
+ * @returns {Promise<RunResult>}
22
+ */
23
+ export function runRtk({ command, args, input, timeoutMs, signal, maxOutputBytes }) {
24
+ return new Promise((resolve) => {
25
+ if (signal !== undefined && signal.aborted) {
26
+ resolve({ ok: false, reason: 'aborted' })
27
+ return
28
+ }
29
+ /** @type {import('node:child_process').ChildProcess} */
30
+ let child
31
+ try {
32
+ // `detached` gives the child its own process group so a runaway rtk and
33
+ // its descendants can be killed as a unit.
34
+ child = spawn(command, args, { detached: true, stdio: ['pipe', 'pipe', 'ignore'], signal })
35
+ } catch {
36
+ resolve({ ok: false, reason: 'spawn' })
37
+ return
38
+ }
39
+
40
+ let settled = false
41
+ let bytes = 0
42
+ /** @type {Buffer[]} */
43
+ const chunks = []
44
+
45
+ const timer = setTimeout(() => finish({ ok: false, reason: 'timeout' }, true), timeoutMs)
46
+ const onAbort = () => finish({ ok: false, reason: 'aborted' }, true)
47
+
48
+ function cleanup() {
49
+ clearTimeout(timer)
50
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
51
+ }
52
+
53
+ function killGroup() {
54
+ if (child.pid === undefined) return
55
+ if (process.platform === 'win32') {
56
+ // Windows has no process-group signals: `process.kill(-pid)` throws, the
57
+ // child survives, and its open stdout pipe keeps this process alive after
58
+ // a timeout/abort (observed as the whole test file hanging). taskkill /T
59
+ // walks the tree, which is what `detached` was buying on POSIX.
60
+ try {
61
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true })
62
+ .on('error', () => { /* already gone */ })
63
+ } catch {
64
+ // already gone
65
+ }
66
+ return
67
+ }
68
+ try {
69
+ process.kill(-child.pid, 'SIGKILL')
70
+ } catch {
71
+ // already gone
72
+ }
73
+ }
74
+
75
+ /**
76
+ * @param {RunResult} result
77
+ * @param {boolean} [kill]
78
+ */
79
+ function finish(result, kill) {
80
+ if (settled) return
81
+ settled = true
82
+ cleanup()
83
+ if (kill === true) killGroup()
84
+ resolve(result)
85
+ }
86
+
87
+ if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
88
+
89
+ child.on('error', () => {
90
+ finish({ ok: false, reason: signal !== undefined && signal.aborted ? 'aborted' : 'spawn' })
91
+ })
92
+
93
+ child.stdout?.on('data', (/** @type {Buffer} */ chunk) => {
94
+ bytes += chunk.length
95
+ if (bytes > maxOutputBytes) {
96
+ finish({ ok: false, reason: 'too-large' }, true)
97
+ return
98
+ }
99
+ chunks.push(chunk)
100
+ })
101
+
102
+ child.on('close', (code) => {
103
+ if (code !== 0) {
104
+ finish({ ok: false, reason: 'exit' })
105
+ return
106
+ }
107
+ finish({ ok: true, text: Buffer.concat(chunks).toString('utf8') })
108
+ })
109
+
110
+ child.stdin?.on('error', () => {
111
+ // EPIPE when the child is already gone; the close/error handler decides.
112
+ })
113
+ child.stdin?.end(input)
114
+ })
115
+ }
package/lib/sniff.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Family detection: decide whether `rtk pipe -f <filter>` is provably
3
+ * applicable to this text. A wrong `-f` destroys content (measured: 751 B of
4
+ * unrelated text collapsed to 4 lines), so anything unrecognised falls through
5
+ * to a bare `rtk pipe`, which passes unrecognised text through byte-for-byte.
6
+ *
7
+ * @module dsh-rtk-slim/sniff
8
+ */
9
+
10
+ const CARGO_RUNNING = /^running \d+ tests?$/m
11
+ const CARGO_RESULT = /^test result:/m
12
+ const PYTEST_HEADER = /^=+ .*test session starts.* =+$/m
13
+ const PYTEST_OUTCOME = /^\S+::\S+ (?:PASSED|FAILED|ERROR)/m
14
+ const GIT_ONELINE = /^[0-9a-f]{7,40} \S/
15
+ const GIT_COMMIT = /^commit [0-9a-f]{40}$/m
16
+ const GREP_LINE = /^[^\s:]*[/.][^\s:]*:\d+:/
17
+ const BARE_LINE = /^\S+$/
18
+
19
+ /**
20
+ * @param {string} text
21
+ * @param {RegExp} re
22
+ * @returns {number}
23
+ */
24
+ function countLines(text, re) {
25
+ const global = new RegExp(re.source, 'gm')
26
+ const found = text.match(global)
27
+ return found === null ? 0 : found.length
28
+ }
29
+
30
+ /** @typedef {{ id: string, filter: string, test: (text: string) => boolean }} Family */
31
+
32
+ /** Ordered families; first match wins. */
33
+ export const FAMILIES = /** @type {Family[]} */ ([
34
+ {
35
+ id: 'cargo-test',
36
+ filter: 'cargo-test',
37
+ test: (text) => CARGO_RUNNING.test(text) && CARGO_RESULT.test(text),
38
+ },
39
+ {
40
+ id: 'pytest',
41
+ filter: 'pytest',
42
+ test: (text) => PYTEST_HEADER.test(text) || PYTEST_OUTCOME.test(text),
43
+ },
44
+ {
45
+ id: 'git-log',
46
+ filter: 'git-log',
47
+ test: (text) => countLines(text, GIT_ONELINE) >= 5 || GIT_COMMIT.test(text),
48
+ },
49
+ {
50
+ id: 'grep',
51
+ filter: 'grep',
52
+ test: (text) => countLines(text, GREP_LINE) >= 5,
53
+ },
54
+ {
55
+ id: 'find',
56
+ filter: 'find',
57
+ test: (text) => {
58
+ const bare = text.split('\n').filter((line) => BARE_LINE.test(line))
59
+ if (bare.length < 10) return false
60
+ return bare.every((line) => line.includes('/')) && bare.every((line) => !line.includes(':'))
61
+ },
62
+ },
63
+ ])
64
+
65
+ /**
66
+ * @typedef {Record<string, string | false>} FilterOverrides
67
+ */
68
+
69
+ /**
70
+ * @param {string} text body text (trailing status markers already removed)
71
+ * @param {FilterOverrides} [overrides] config `filters`
72
+ * @returns {{ family: string, filter: string | undefined }} `auto` means "bare rtk pipe"
73
+ */
74
+ export function chooseFilter(text, overrides = {}) {
75
+ for (const family of FAMILIES) {
76
+ const override = overrides[family.id]
77
+ if (override === false) continue
78
+ if (!family.test(text)) continue
79
+ return { family: family.id, filter: typeof override === 'string' ? override : family.filter }
80
+ }
81
+ return { family: 'auto', filter: undefined }
82
+ }
package/lib/spill.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Lossy-but-traceable condensation: every condensed text is recoverable in
3
+ * full. The store is the official `ctx.spillStore` service (backed by
4
+ * `dsh-spill-local`), used exactly the way `dsh-spill-policy` uses it, so the
5
+ * notice wording and the file layout are the ones the harness already knows.
6
+ *
7
+ * @module dsh-rtk-slim/spill
8
+ */
9
+
10
+ /** @typedef {{ locator: string, bytes?: number, retrievalHint?: string }} Ref */
11
+
12
+ const FALLBACK_HINT = 'Use read with offset/limit, or grep this path to search within it.'
13
+
14
+ /**
15
+ * Persist the untouched original text.
16
+ * @param {{ get: (name: string) => any }} ctx
17
+ * @param {{ name: string, callId?: string, agent?: any }} exec
18
+ * @param {string} text
19
+ * @returns {Promise<Ref | undefined>} `undefined` means "no traceability available".
20
+ */
21
+ export async function saveFullText(ctx, exec, text) {
22
+ const store = ctx.get('spillStore')
23
+ if (store === null || typeof store !== 'object' || typeof store.saveText !== 'function') return undefined
24
+ const sessionId = exec.agent?.session?.header?.id
25
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
26
+ try {
27
+ const ref = await store.saveText({
28
+ owner: { sessionId },
29
+ source: { kind: 'tool', toolName: exec.name, callId: exec.callId, label: 'result' },
30
+ suggestedName: `${exec.name}.txt`,
31
+ content: text,
32
+ })
33
+ if (ref === null || typeof ref !== 'object' || typeof ref.locator !== 'string' || ref.locator.length === 0) {
34
+ return undefined
35
+ }
36
+ return ref
37
+ } catch {
38
+ return undefined
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {{ beforeBytes: number, afterBytes: number, ref: Ref }} input
44
+ * @returns {string}
45
+ */
46
+ export function formatCondenseNotice({ beforeBytes, afterBytes, ref }) {
47
+ const hint = typeof ref.retrievalHint === 'string' && ref.retrievalHint.length > 0 ? ref.retrievalHint : FALLBACK_HINT
48
+ return `(rtk condensed ${beforeBytes} → ${afterBytes} bytes. Full formatted result stored at: ${ref.locator}. ${hint})`
49
+ }
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tool output slimming",
4
+ "description": "Condenses oversized tool output, spilling the full text to a file that can be fetched on demand.",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "工具输出瘦身",
4
+ "description": "把超长的工具输出压缩成摘要,原文落进 spill 文件,需要时再取回。",
5
+ "detail": "docs/DETAIL.zh.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "dsh-rtk-slim",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "DSH host plugin: condense oversized bash/job_output text through rtk, with lossless spill traceability",
9
+ "type": "module",
10
+ "main": "lib/index.js",
11
+ "exports": {
12
+ ".": {
13
+ "default": "./lib/index.js"
14
+ },
15
+ "./cordis.patch.yml": "./cordis.patch.yml",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "lib",
20
+ "cordis.patch.yml",
21
+ "README.md",
22
+ "LICENSE",
23
+ "icon.svg",
24
+ "locale/*.json",
25
+ "docs/DETAIL.md",
26
+ "docs/DETAIL.zh.md"
27
+ ],
28
+ "license": "MIT",
29
+ "scripts": {
30
+ "test": "node --test test/*.test.mjs",
31
+ "typecheck": "tsc -p jsconfig.json",
32
+ "check": "npm run typecheck && npm test"
33
+ },
34
+ "engines": {
35
+ "node": ">=24",
36
+ "dsh": "^0.2.1-rc.2 || ^0.2.2-alpha.2"
37
+ },
38
+ "keywords": [
39
+ "dsh",
40
+ "deepseek-harness",
41
+ "dsh-plugin",
42
+ "cordis-plugin",
43
+ "rtk",
44
+ "token-reduction",
45
+ "output-compaction",
46
+ "bash",
47
+ "spill"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/CooperZhuang/dsh-plugins-cooper-custom.git",
52
+ "directory": "packages/dsh-rtk-slim"
53
+ },
54
+ "homepage": "https://github.com/CooperZhuang/dsh-plugins-cooper-custom/tree/main/packages/dsh-rtk-slim#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/CooperZhuang/dsh-plugins-cooper-custom/issues"
57
+ },
58
+ "dependencies": {
59
+ "@deepseek-ai/schemastery": "^3.18.5"
60
+ },
61
+ "devDependencies": {
62
+ "@deepseek-ai/dsh-llm": "0.2.2-rc.1",
63
+ "@deepseek-ai/dsh-tools": "0.2.2-rc.1",
64
+ "@types/node": "^26.6.5",
65
+ "typescript": "^7.0.2"
66
+ },
67
+ "dsh": {
68
+ "manifestVersion": 1,
69
+ "bundle": {
70
+ "patch": "./cordis.patch.yml"
71
+ }
72
+ },
73
+ "icon": "./icon.svg"
74
+ }