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 +21 -0
- package/README.md +196 -2
- package/cordis.patch.yml +17 -0
- package/docs/DETAIL.md +11 -0
- package/docs/DETAIL.zh.md +12 -0
- package/icon.svg +1 -0
- package/lib/content.js +35 -0
- package/lib/guards.js +64 -0
- package/lib/index.js +202 -0
- package/lib/log.js +39 -0
- package/lib/markers.js +41 -0
- package/lib/rtk.js +115 -0
- package/lib/sniff.js +82 -0
- package/lib/spill.js +49 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +72 -4
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
|
-
#
|
|
1
|
+
# dsh-rtk-slim
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/cordis.patch.yml
ADDED
|
@@ -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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rtk-slim",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|