@flotiarenor/dsh-tool-text-editor 1.0.0 → 1.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/README.md +40 -14
- package/README.zh.md +33 -9
- package/lib/core.mjs +88 -4
- package/lib/editor.mjs +70 -14
- package/package.json +12 -11
package/README.md
CHANGED
|
@@ -17,7 +17,9 @@ byte by default) and `writeText` does not restore a file's line-ending style.
|
|
|
17
17
|
|
|
18
18
|
On top of fidelity: **unified diffs** (with a `dry_run` preview), **automatic backups**, an **edit
|
|
19
19
|
ledger**, **`grep` / `lines` anchors** so old text never has to be copied by hand, **ambiguity
|
|
20
|
-
refusal**,
|
|
20
|
+
refusal**, **near-miss candidates** when an anchor does not match, and a **token-budgeted result
|
|
21
|
+
text**: the model sees one stat line plus a line-budgeted diff body, while the full diff goes to the
|
|
22
|
+
UI card (see "Result text and the token budget").
|
|
21
23
|
|
|
22
24
|
## Implementation and requirements
|
|
23
25
|
|
|
@@ -79,17 +81,34 @@ Both installs may coexist: the preset layer shadows the host layer with an ident
|
|
|
79
81
|
`file_path` and `new_text` are required; give **exactly one** anchor: `old_text` (literal, copied from
|
|
80
82
|
`read`), `grep` (regex; the matched line/block including its trailing newline), or `lines` (e.g.
|
|
81
83
|
`"263:270"`). `mode` is `replace` (default) / `after` / `before` / `append` / `prepend`; also `count`
|
|
82
|
-
(require exactly N occurrences and replace all), `nth` (k-th occurrence), `strict`, `
|
|
83
|
-
`count` and `nth` are mutually exclusive.
|
|
84
|
+
(require exactly N occurrences and replace all), `nth` (k-th occurrence), `strict`, `diff` (`auto` /
|
|
85
|
+
`full` / `none`), `dry_run`, `note`. `count` and `nth` are mutually exclusive.
|
|
84
86
|
|
|
85
87
|
### `write_text` — create or fully replace a file
|
|
86
88
|
|
|
87
|
-
`file_path` + `content
|
|
88
|
-
follows the **majority** line-ending style of its
|
|
89
|
-
default.
|
|
89
|
+
`file_path` + `content` (plus the same `diff` / `dry_run` / `note`); creation needs no flag, an
|
|
90
|
+
overwrite is backed up first, and a brand-new file follows the **majority** line-ending style of its
|
|
91
|
+
siblings (same extension first) with no BOM by default.
|
|
90
92
|
|
|
91
|
-
Both **write by default** (like the built-ins); pass `dry_run: true` to preview.
|
|
92
|
-
|
|
93
|
+
Both **write by default** (like the built-ins); pass `dry_run: true` to preview.
|
|
94
|
+
|
|
95
|
+
### Result text and the token budget
|
|
96
|
+
|
|
97
|
+
Tool results land in the **append-only session history, which is never prefix-cached**, so every byte
|
|
98
|
+
returned per call accumulates. The result is therefore split into layers:
|
|
99
|
+
|
|
100
|
+
| Layer | Content | Where it goes |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| Model-facing (`brief` / `diff`) | warnings + one stat line (e.g. `replace@60 +1/-1`) + a budgeted diff body (0 context lines) | the model context |
|
|
103
|
+
| Full record (`stdout`) | path header, the complete diff with context lines, the backup name | UI / logs / human triage |
|
|
104
|
+
| UI card (`presentationMeta`) | the same `{ path, oldText, newText }` hunk list the built-in `edit` / `write` project | the Web UI, never the model context |
|
|
105
|
+
|
|
106
|
+
- The path appears **exactly once** in the model-facing text, and the backup filename is no longer
|
|
107
|
+
echoed (it stays in `stdout` and in the ledger).
|
|
108
|
+
- The body is returned only when the change is genuinely small (`diff: auto`, threshold
|
|
109
|
+
`maxDiffLines`); otherwise the model gets the stat line plus a one-line hint and decides for itself
|
|
110
|
+
whether to `read`. `diff: full` forces the body, but **it is still capped** — uncapped, a 400-line
|
|
111
|
+
rewrite echoes 7.5k tokens straight back (a measured ~1.0x amplification of the content just sent).
|
|
93
112
|
|
|
94
113
|
## Deliberate limitations
|
|
95
114
|
|
|
@@ -98,8 +117,9 @@ the tools.
|
|
|
98
117
|
|
|
99
118
|
- **Writes bypass `ctx.fs`.** The file is written by the plugin itself, so the fs-observation policy
|
|
100
119
|
(read-before-write, version freshness), the sandbox, `sandbox_permissions` escalation and Windows
|
|
101
|
-
DACL preservation are all skipped
|
|
102
|
-
|
|
120
|
+
DACL preservation are all skipped. The atomic write is the plugin's own (same-directory temp file +
|
|
121
|
+
fsync + rename) and the diff card comes from its own `presentationMeta` (the built-ins project the
|
|
122
|
+
`ctx.fs` `before` / `after` instead).
|
|
103
123
|
- **Line anchors are not content-verified.** `lines` and `before` / `after <line>` locate text by line
|
|
104
124
|
number alone: a wrong number does not fail, it edits somewhere else. When the anchor has to be
|
|
105
125
|
verifiable, use `old_text` or `grep`.
|
|
@@ -119,7 +139,9 @@ There is no Config schema: the preset row's `config:` mapping is passed through
|
|
|
119
139
|
| `ledger` | `true` | append a JSONL record to `artifactsDir/edits.log` |
|
|
120
140
|
| `artifactsDir` | `<workspace>/.dsh` | where backups and the ledger live |
|
|
121
141
|
| `newFileBom` | `false` | write a UTF-8 BOM when creating a new file |
|
|
122
|
-
| `context` | `3` | context lines in the
|
|
142
|
+
| `context` | `3` | context lines in the **human-facing** diff (affects `stdout` and the UI card only; the model-facing body always uses 0) |
|
|
143
|
+
| `diff` | `'auto'` | default policy for the model-facing diff body; a per-call `diff` argument overrides it |
|
|
144
|
+
| `maxDiffLines` | `30` | line budget for the model-facing diff body: `auto` drops it when exceeded, `full` truncates at it |
|
|
123
145
|
| `root` | `process.cwd()` | fallback workspace when a call has no agent session |
|
|
124
146
|
|
|
125
147
|
`DSH_TEXT_EDITOR_EOL` (`lf` \| `crlf`) overrides the line-ending inference for **new** files.
|
|
@@ -128,7 +150,7 @@ There is no Config schema: the preset row's `config:` mapping is passed through
|
|
|
128
150
|
|
|
129
151
|
```powershell
|
|
130
152
|
# run from the root of a clone of this repository
|
|
131
|
-
node tools/selftest.mjs #
|
|
153
|
+
node tools/selftest.mjs # 92/92 on Windows + Node 24
|
|
132
154
|
node tools/check-license.mjs # license / dependency / Node-only gate
|
|
133
155
|
node tools/gen-schema.mjs # embedded schemas still match the DSL
|
|
134
156
|
```
|
|
@@ -141,7 +163,11 @@ refusal, usage errors, binary/invalid-UTF-8 refusal, `.dsh/` and outside-workspa
|
|
|
141
163
|
EOL inference, multi-hunk diffs, end-of-file newline changes and concurrent writes — **plus a
|
|
142
164
|
plugin-layer suite** that drives `apply()` with a fake context and asserts tool registration, the
|
|
143
165
|
guidance section, that every returned value satisfies `OUTPUT_SCHEMA`, the `render()` text, and the
|
|
144
|
-
config plumbing (`root` / `backup` / `ledger` / `newFileBom`)
|
|
166
|
+
config plumbing (`root` / `backup` / `ledger` / `newFileBom`) — **and a token-budget suite**: a
|
|
167
|
+
full-file rewrite must not echo the content back, a small edit still shows the changed lines, the
|
|
168
|
+
`diff: none` / `full` boundaries hold, the path appears once, and the complete diff flows only into
|
|
169
|
+
the UI card. The token budget is a design constraint rather than an implementation detail, so it
|
|
170
|
+
carries its own regression test; otherwise one "let me print a bit more here" quietly removes it.
|
|
145
171
|
|
|
146
172
|
`tools/gen-schema.mjs` needs an installed `@deepseek-ai/dsh-tools`: it looks for one under the dsh
|
|
147
173
|
profile's `node_modules` and under the npm global prefix, and `DSH_TOOLS_ENTRY` overrides that lookup.
|
|
@@ -150,7 +176,7 @@ It exits 2 when it cannot find one.
|
|
|
150
176
|
## Layout
|
|
151
177
|
|
|
152
178
|
```
|
|
153
|
-
lib/core.mjs # the core: BOM/EOL, anchors, matching, diff, backups, ledger, atomic write, per-target lock
|
|
179
|
+
lib/core.mjs # the core: BOM/EOL, anchors, matching, diff (with the result budget), backups, ledger, atomic write, per-target lock
|
|
154
180
|
lib/editor.mjs # the plugin: schemas, validation, tool registration (zero-dep ESM, no build)
|
|
155
181
|
preset/preset.yml # preset name/description, as dsh lists it
|
|
156
182
|
scripts/install-preset.mjs # derives the user preset from the local dsh installation
|
package/README.zh.md
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
BOM 字节),且 `writeText` 不按原文件风格还原行尾。
|
|
17
17
|
|
|
18
18
|
在保真之外,本插件还提供:**统一 diff**(可用 dry-run 预览)、**写入前自动备份**、**编辑台账**、
|
|
19
|
-
**`grep` / `lines`
|
|
19
|
+
**`grep` / `lines` 锚点**(无需人工誊抄原文)、**歧义时拒绝写入**、**最接近候选**提示,以及
|
|
20
|
+
**面向 token 预算的结果文本**:模型只看到一行统计加受行数预算约束的 diff 正文,完整 diff 走 UI 卡片
|
|
21
|
+
(见「结果文本与 token 预算」)。
|
|
20
22
|
|
|
21
23
|
## 实现与依赖
|
|
22
24
|
|
|
@@ -80,6 +82,7 @@ dsh --profile web --dump-config # 应当能看到 "# == @flotiarenor/dsh-tool-
|
|
|
80
82
|
| `count` | - | 要求恰好 N 处命中并全部替换(不符则拒绝写入)。 |
|
|
81
83
|
| `nth` | - | 只替换第 k 处(1-based)。 |
|
|
82
84
|
| `strict` | - | 禁用宽松匹配(只接受精确匹配)。 |
|
|
85
|
+
| `diff` | - | 结果里 diff 正文的详细程度:`auto`(默认,改动小时才给)/ `full`(总给,仍封顶)/ `none`(不给)。 |
|
|
83
86
|
| `dry_run` | - | 只输出 diff,不写入文件。 |
|
|
84
87
|
| `note` | - | 一行说明,记入编辑台账。 |
|
|
85
88
|
|
|
@@ -88,16 +91,33 @@ dsh --profile web --dump-config # 应当能看到 "# == @flotiarenor/dsh-tool-
|
|
|
88
91
|
|
|
89
92
|
### `write_text` —— 整文件新建/覆盖
|
|
90
93
|
|
|
91
|
-
`file_path` + `content
|
|
92
|
-
|
|
94
|
+
`file_path` + `content`(另接受与 `edit_text` 相同的 `diff` / `dry_run` / `note`);目标不存在时自动
|
|
95
|
+
新建,覆盖前先备份。新建文件的行尾风格取自同目录的多数派(同扩展名优先),默认不写 BOM。
|
|
96
|
+
|
|
97
|
+
### 结果文本与 token 预算
|
|
98
|
+
|
|
99
|
+
工具结果会进入 **append-only 的会话历史,永远不会被前缀缓存**,所以每次调用回吐的字节逐次累积。
|
|
100
|
+
因此本插件把结果分成两层:
|
|
101
|
+
|
|
102
|
+
| 层 | 内容 | 去向 |
|
|
103
|
+
| ------------------------ | ------------------------------------------------------------------------------- | --------------------------- |
|
|
104
|
+
| 模型可见(`brief` / `diff`) | 警告 + 一行统计(如 `replace@60 +1/-1`)+ 受预算约束的 diff 正文(0 上下文行) | 模型上下文 |
|
|
105
|
+
| 完整版(`stdout`) | 路径头、含上下文行的完整 diff、备份名 | UI / 日志 / 人工排查 |
|
|
106
|
+
| UI 卡片(`presentationMeta`) | 与原生 `edit` / `write` 同形的 `{ path, oldText, newText }` hunk 列表 | Web UI(**不进模型上下文**) |
|
|
107
|
+
|
|
108
|
+
- 路径在模型可见文本里**只出现一次**,备份文件名不再回吐(它留在 `stdout` 与编辑台账里)。
|
|
109
|
+
- 正文默认只在改动确实很小时才给(`diff: auto`,阈值见 `maxDiffLines`);否则只给统计行并提供一行提示,
|
|
110
|
+
让模型自己决定要不要 `read`。`diff: full` 强制给正文,但**仍然封顶** —— 没有封顶时,一次"整体重写
|
|
111
|
+
400 行"会把 7.5k tokens 原样回吐(实测回吐量 ≈ 输入内容的 1.0x)。
|
|
93
112
|
|
|
94
113
|
## 已知限制
|
|
95
114
|
|
|
96
115
|
以下均为有意的设计取舍,而非缺陷;采用前请对照自身场景确认。
|
|
97
116
|
|
|
98
117
|
- **写入不经由 `ctx.fs`。** 文件由本插件直接写入,因此不经过 fs 观察策略(先读后写、版本新鲜度校验)、
|
|
99
|
-
沙箱与 `sandbox_permissions` 审批升权,也不保留 Windows DACL
|
|
100
|
-
|
|
118
|
+
沙箱与 `sandbox_permissions` 审批升权,也不保留 Windows DACL。原子写由本插件自己保证
|
|
119
|
+
(同目录临时文件 + fsync + rename),diff 卡片则由 `presentationMeta` 自行提供(原生工具用的是
|
|
120
|
+
`ctx.fs` 的 `before` / `after`)。
|
|
101
121
|
- **行号锚点不做内容校验。** `lines` 与 `before` / `after <行号>` 仅按行号定位:行号有误不会报错,
|
|
102
122
|
改动会落在非预期位置;定位需要可校验时,请改用 `old_text` 或 `grep`。
|
|
103
123
|
- **同目标串行仅限本进程。** 进程内按目标路径排队,并配合原子写,故并行的工具调用不会相互覆盖;
|
|
@@ -115,7 +135,9 @@ dsh --profile web --dump-config # 应当能看到 "# == @flotiarenor/dsh-tool-
|
|
|
115
135
|
| `ledger` | `true` | 往 `artifactsDir/edits.log` 追加一条 JSONL 记录 |
|
|
116
136
|
| `artifactsDir` | `<工作区>/.dsh` | 备份与台账所在目录 |
|
|
117
137
|
| `newFileBom` | `false` | 新建文件时是否写 UTF-8 BOM |
|
|
118
|
-
| `context` | `3` |
|
|
138
|
+
| `context` | `3` | **人读** diff 的上下文行数(只影响 `stdout` 与 UI 卡片;模型可见正文固定 0 行) |
|
|
139
|
+
| `diff` | `'auto'` | 模型可见 diff 正文的默认策略,可被逐调用的 `diff` 参数覆盖 |
|
|
140
|
+
| `maxDiffLines` | `30` | 模型可见 diff 正文的行数预算:`auto` 超了就只给统计行,`full` 按它截断 |
|
|
119
141
|
| `root` | `process.cwd()` | 无 agent 会话时的回退工作区 |
|
|
120
142
|
|
|
121
143
|
环境变量 `DSH_TEXT_EDITOR_EOL`(`lf` \| `crlf`)可覆盖**新建文件**的行尾推断。
|
|
@@ -124,7 +146,7 @@ dsh --profile web --dump-config # 应当能看到 "# == @flotiarenor/dsh-tool-
|
|
|
124
146
|
|
|
125
147
|
```powershell
|
|
126
148
|
# 在本仓库根目录执行
|
|
127
|
-
node tools/selftest.mjs # Windows + Node 24 参考结果
|
|
149
|
+
node tools/selftest.mjs # Windows + Node 24 参考结果 92/92
|
|
128
150
|
node tools/check-license.mjs # 许可证 / 依赖 / 纯 Node 门禁
|
|
129
151
|
node tools/gen-schema.mjs # 内嵌 schema 是否仍与作者 DSL 一致
|
|
130
152
|
```
|
|
@@ -133,7 +155,9 @@ node tools/gen-schema.mjs # 内嵌 schema 是否仍与作者 DSL 一致
|
|
|
133
155
|
二进制与非法 UTF-8、路径护栏(`.dsh/`、工作区之外)、行尾多数派推断、跨多个 hunk、末尾换行差异、
|
|
134
156
|
并发写入不产生半截文件;**并含一层插件层断言**:以模拟 ctx 驱动 `apply()`,验证工具注册、引导段
|
|
135
157
|
身份、每个返回值均满足 `OUTPUT_SCHEMA`、`render()` 输出,以及 config 透传(`root` / `backup` /
|
|
136
|
-
`ledger` / `newFileBom
|
|
158
|
+
`ledger` / `newFileBom`);**还有一层回吐预算断言**:整体重写不得回吐内容、小改动仍给改动行、
|
|
159
|
+
`diff: none` / `full` 的边界、路径只出现一次、完整 diff 只走 UI 卡片。token 预算是设计约束而非实现
|
|
160
|
+
细节,因此它自带回归测试 —— 否则一次"顺手多打印一点"就能把它悄悄取消掉。
|
|
137
161
|
|
|
138
162
|
`tools/gen-schema.mjs` 需要一份装有 `@deepseek-ai/dsh-tools` 的 dsh:它会在 dsh profile 的
|
|
139
163
|
`node_modules` 与 npm 全局目录中自动查找,也可用 `DSH_TOOLS_ENTRY` 显式指定;找不到入口时退出码为 2。
|
|
@@ -141,7 +165,7 @@ node tools/gen-schema.mjs # 内嵌 schema 是否仍与作者 DSL 一致
|
|
|
141
165
|
## 目录结构
|
|
142
166
|
|
|
143
167
|
```
|
|
144
|
-
lib/core.mjs # 编辑核心:BOM/行尾、锚点、匹配、diff
|
|
168
|
+
lib/core.mjs # 编辑核心:BOM/行尾、锚点、匹配、diff(含回吐预算)、备份、台账、原子写、同目标串行
|
|
145
169
|
lib/editor.mjs # 插件本体:schema、参数校验、工具注册(零依赖 ESM,无构建)
|
|
146
170
|
preset/preset.yml # preset 的名字/描述(dsh 列表里显示的内容)
|
|
147
171
|
scripts/install-preset.mjs # 从本机 dsh 派生用户 preset
|
package/lib/core.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* 它承担 dsh 原生 `write`/`edit` 在 Windows 上做不到的事:
|
|
7
7
|
* * **保留 UTF-8 BOM**(原生工具会丢);
|
|
8
8
|
* * **行尾跟随文件**(原生 `write` 会把 CRLF 文件拍成 LF);
|
|
9
|
-
* *
|
|
9
|
+
* * unified diff **分两层**:模型只看到一行统计加受预算约束的 diff 正文(`brief` / `diff`),
|
|
10
|
+
* 完整细节留在 `stdout`(人类与 UI 卡片用)。落盘前自动备份,记编辑台账;
|
|
10
11
|
* * 锚点可以从目标取(`grep` 正则 / `lines` 行号),不必手抄旧文本;
|
|
11
12
|
* * 匹配失败时给"最接近的候选",歧义时拒绝写盘而不是猜。
|
|
12
13
|
*
|
|
@@ -18,6 +19,12 @@
|
|
|
18
19
|
*
|
|
19
20
|
* 备份与台账的格式:`<工作区>/.dsh/backups/<扁平化绝对路径>@<时间戳>` 与
|
|
20
21
|
* `<工作区>/.dsh/edits.log` 里的 JSONL 记录(字段见 `appendLedger`)。
|
|
22
|
+
*
|
|
23
|
+
* 为什么要给 diff 定预算(`diff` / `maxDiffLines`):工具结果是 **append-only 的会话历史,
|
|
24
|
+
* 永远不会被前缀缓存**,所以每次调用回吐的字节会逐次累积。而"新建/整体重写"的 unified diff
|
|
25
|
+
* 是每一行都带 `+` 的整文件回显 —— 实测回吐量 ≈ 输入内容的 1.0x,等于把刚发出去的内容再发
|
|
26
|
+
* 一遍。因此正文默认只在改动确实很小时才给(`auto`),其余情况只给统计行,让模型自己决定
|
|
27
|
+
* 要不要 `read` 文件;`none` 完全不回吐,`full` 强制回吐但仍受行数封顶。
|
|
21
28
|
*/
|
|
22
29
|
|
|
23
30
|
import {
|
|
@@ -39,6 +46,10 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
|
|
|
39
46
|
export const UTF8_BOM_BYTES = Buffer.from([0xef, 0xbb, 0xbf])
|
|
40
47
|
export const GUARD_DIRS = ['.git', '.dsh']
|
|
41
48
|
export const DEFAULT_CONTEXT = 3
|
|
49
|
+
/** 模型可见 diff 正文的默认行数预算:`auto` 超了就不给正文,`full` 也按它截断。 */
|
|
50
|
+
export const DEFAULT_MAX_DIFF_LINES = 30
|
|
51
|
+
/** `diff` 的取值:`auto` 小改动才给正文 / `full` 总给(仍封顶)/ `none` 只给统计行。 */
|
|
52
|
+
export const DIFF_MODES = ['auto', 'full', 'none']
|
|
42
53
|
/** LCS 动态规划的格子上限:超过就退化成"整块替换",避免大文件吃光内存。 */
|
|
43
54
|
const MAX_DIFF_CELLS = 4_000_000
|
|
44
55
|
|
|
@@ -565,6 +576,65 @@ export function diffStat(before, after) {
|
|
|
565
576
|
return { added, removed }
|
|
566
577
|
}
|
|
567
578
|
|
|
579
|
+
/**
|
|
580
|
+
* 按预算裁剪**模型可见**的 diff 正文。
|
|
581
|
+
*
|
|
582
|
+
* 只有 `full` 会强制给正文,且仍然封顶 —— 封顶是这里存在的全部理由:没有它,一次"整体重写
|
|
583
|
+
* 400 行"就会把 7.5k tokens 原样回吐(实测放大率 1.02x),而模型刚刚才把这段内容发出去。
|
|
584
|
+
*
|
|
585
|
+
* @param text - 已生成的 unified diff 文本(调用方传 0 上下文行的那份,只保留改动行)。
|
|
586
|
+
* @param mode - `auto` | `full` | `none`(见 `DIFF_MODES`)。
|
|
587
|
+
* @param maxLines - 正文行数预算,默认 `DEFAULT_MAX_DIFF_LINES`。
|
|
588
|
+
* @returns 模型可见的正文;裁剪时带一行提示(省略或截断),可能为空字符串。
|
|
589
|
+
*/
|
|
590
|
+
export function diffBudget(text, mode, maxLines = DEFAULT_MAX_DIFF_LINES) {
|
|
591
|
+
if (mode === 'none' || text === '') return ''
|
|
592
|
+
const lines = text.trimEnd().split('\n')
|
|
593
|
+
if (lines.length <= maxLines) return lines.join('\n')
|
|
594
|
+
if (mode === 'auto') {
|
|
595
|
+
return `[diff omitted: ${lines.length} lines > budget ${maxLines}; pass diff:"full" or read the file]`
|
|
596
|
+
}
|
|
597
|
+
return [
|
|
598
|
+
...lines.slice(0, maxLines),
|
|
599
|
+
`[diff truncated: ${lines.length} lines total, budget ${maxLines}]`,
|
|
600
|
+
].join('\n')
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* 把 unified diff 文本解析成 UI diff 卡片的词汇:`{ path, oldText, newText }` 列表,
|
|
605
|
+
* 与原生 `edit` / `write` 的 `presentationMeta` 同形(纯新增的 hunk 用 `oldText: null`)。
|
|
606
|
+
*
|
|
607
|
+
* 只服务**人类**:这条路径不经过模型上下文,所以完整 diff(含上下文行)走这里,模型拿到的
|
|
608
|
+
* 是 `diffBudget` 裁过的那份。
|
|
609
|
+
*
|
|
610
|
+
* @param text - unified diff 文本。
|
|
611
|
+
* @param path - 卡片上标注的路径。
|
|
612
|
+
* @returns 每个 hunk 一个条目;没有可解析的 hunk 时返回空数组。
|
|
613
|
+
*/
|
|
614
|
+
export function hunksFromDiff(text, path) {
|
|
615
|
+
const hunks = []
|
|
616
|
+
let current = null
|
|
617
|
+
for (const line of text.split('\n')) {
|
|
618
|
+
if (line.startsWith('@@')) {
|
|
619
|
+
current = { old: [], next: [] }
|
|
620
|
+
hunks.push(current)
|
|
621
|
+
continue
|
|
622
|
+
}
|
|
623
|
+
if (current === null || line.startsWith('\\')) continue
|
|
624
|
+
if (line.startsWith('-')) current.old.push(line.slice(1))
|
|
625
|
+
else if (line.startsWith('+')) current.next.push(line.slice(1))
|
|
626
|
+
else if (line.startsWith(' ')) {
|
|
627
|
+
current.old.push(line.slice(1))
|
|
628
|
+
current.next.push(line.slice(1))
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return hunks.map((hunk) => ({
|
|
632
|
+
path,
|
|
633
|
+
oldText: hunk.old.length > 0 ? hunk.old.join('\n') : null,
|
|
634
|
+
newText: hunk.next.join('\n'),
|
|
635
|
+
}))
|
|
636
|
+
}
|
|
637
|
+
|
|
568
638
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
569
639
|
// 六、备份、台账、原子写、同目标串行
|
|
570
640
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -723,7 +793,7 @@ function countLf(buffer) {
|
|
|
723
793
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
724
794
|
|
|
725
795
|
function fail(path, message) {
|
|
726
|
-
return { path, ok: false, wrote: false, dryRun: false, stdout: '', stderr: message }
|
|
796
|
+
return { path, ok: false, wrote: false, dryRun: false, stdout: '', stderr: message, brief: '', diff: '' }
|
|
727
797
|
}
|
|
728
798
|
|
|
729
799
|
function hintText(match) {
|
|
@@ -749,8 +819,10 @@ function hintText(match) {
|
|
|
749
819
|
* plan(edit):`{ kind:'edit', filePath, mode, newText, oldText, anchor:{value}|null, count, nth, strict, dryRun, note }`
|
|
750
820
|
* plan(write):`{ kind:'write', filePath, content, dryRun, note }`
|
|
751
821
|
*
|
|
752
|
-
* @param context - `{ root, artifactsDir, backup, log, tool, context, newFileBom }`
|
|
753
|
-
* @returns 规范结果 `{ path, ok, wrote, dryRun, stdout, stderr }`
|
|
822
|
+
* @param context - `{ root, artifactsDir, backup, log, tool, context, newFileBom, diff, maxDiffLines }`
|
|
823
|
+
* @returns 规范结果 `{ path, ok, wrote, dryRun, stdout, stderr, brief, diff }`
|
|
824
|
+
* `stdout` = 完整人读文本(含路径头、完整 diff、备份名),`stderr` = 失败原因;
|
|
825
|
+
* `brief` / `diff` = **模型可见**的那一份(统计行与受预算约束的正文)。
|
|
754
826
|
*/
|
|
755
827
|
export async function applyPlan(plan, context) {
|
|
756
828
|
const {
|
|
@@ -761,6 +833,8 @@ export async function applyPlan(plan, context) {
|
|
|
761
833
|
tool = 'edit_text',
|
|
762
834
|
context: diffContext = DEFAULT_CONTEXT,
|
|
763
835
|
newFileBom = false,
|
|
836
|
+
diff: diffMode = 'auto',
|
|
837
|
+
maxDiffLines = DEFAULT_MAX_DIFF_LINES,
|
|
764
838
|
} = context
|
|
765
839
|
const absPath = isAbsolute(plan.filePath) ? resolve(plan.filePath) : resolve(root, plan.filePath)
|
|
766
840
|
const label = relativeLabel(root, absPath)
|
|
@@ -921,6 +995,12 @@ export async function applyPlan(plan, context) {
|
|
|
921
995
|
const kinds = [...new Set(applied.map(([k, s, e]) => (k === 'replace' ? `replace@${s}${e !== s ? `-${e}` : ''}` : k)))]
|
|
922
996
|
.join('、')
|
|
923
997
|
|
|
998
|
+
// 模型可见的那一份:警告 + 一行统计(+A/-B),加受预算约束的正文。
|
|
999
|
+
// 正文用 0 上下文行 —— 模型刚做完这次编辑,周边内容它要么已经读过,要么可以再 read;
|
|
1000
|
+
// 完整 diff(带 context 行)只走 stdout 与 UI 卡片,不进这条路径。
|
|
1001
|
+
const brief = [...warnings, `${kinds} +${stat.added}/-${stat.removed}`].join('\n')
|
|
1002
|
+
const visibleDiff = diffBudget(unifiedDiff(label, original, output, 0), diffMode, maxDiffLines)
|
|
1003
|
+
|
|
924
1004
|
if (plan.dryRun) {
|
|
925
1005
|
return {
|
|
926
1006
|
path: plan.filePath,
|
|
@@ -929,6 +1009,8 @@ export async function applyPlan(plan, context) {
|
|
|
929
1009
|
dryRun: true,
|
|
930
1010
|
stdout: [...warnings, head, diff.trimEnd(), `DRY RUN ${label}:${kinds}(+${stat.added}/-${stat.removed})`].filter((s) => s !== '').join('\n') + '\n',
|
|
931
1011
|
stderr: '',
|
|
1012
|
+
brief,
|
|
1013
|
+
diff: visibleDiff,
|
|
932
1014
|
}
|
|
933
1015
|
}
|
|
934
1016
|
|
|
@@ -968,6 +1050,8 @@ export async function applyPlan(plan, context) {
|
|
|
968
1050
|
dryRun: false,
|
|
969
1051
|
stdout: [...warnings, head, diff.trimEnd(), `OK ${label}:${kinds}(+${stat.added}/-${stat.removed})${backupName ? `;备份 ${backupName}` : ''}`].filter((s) => s !== '').join('\n') + '\n',
|
|
970
1052
|
stderr: '',
|
|
1053
|
+
brief,
|
|
1054
|
+
diff: visibleDiff,
|
|
971
1055
|
}
|
|
972
1056
|
})
|
|
973
1057
|
}
|
package/lib/editor.mjs
CHANGED
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
*
|
|
21
21
|
* 有意为之的取舍:
|
|
22
22
|
* * 写盘**不经过** `ctx.fs`:绕过 fs 观察策略(先读后写 / 版本新鲜度)、沙箱与
|
|
23
|
-
* `sandbox_permissions` 审批升权、原生原子写与 Windows DACL
|
|
24
|
-
*
|
|
23
|
+
* `sandbox_permissions` 审批升权、原生原子写与 Windows DACL 保留。原生原子写与本插件的
|
|
24
|
+
* 临时文件 + fsync + rename 等价,这段绕过的代价已在 README 写明。
|
|
25
|
+
* * 模型可见的结果文本**刻意很省**:路径只出现一次,只有一行统计加受预算约束的 diff 正文
|
|
26
|
+
* (`brief` / `diff`);备份名等细节只留在台账与 `stdout` 里。完整 diff 走
|
|
27
|
+
* `presentationMeta` 的 UI 卡片 —— 那条路径不进模型上下文。理由见 `lib/core.mjs` 头部。
|
|
25
28
|
* * `lines` / `before <行号>` 是**盲锚点**:行号错了不会报错,会改在别的地方。
|
|
26
29
|
* * 同目标串行只覆盖**本进程内**;跨进程(另一个 dsh 实例、你手边的编辑器)仍可能互相覆盖。
|
|
27
30
|
*
|
|
@@ -30,12 +33,14 @@
|
|
|
30
33
|
* ledger: boolean 默认 true(追加 artifactsDir/edits.log)
|
|
31
34
|
* artifactsDir: string 默认 <工作区>/.dsh
|
|
32
35
|
* newFileBom: boolean 默认 false(新建文件是否写 BOM)
|
|
33
|
-
* context: number diff
|
|
36
|
+
* context: number 人读 diff 的上下文行数,默认 3(只影响 stdout 与 UI 卡片)
|
|
37
|
+
* diff: 'auto'|'full'|'none' 模型可见 diff 正文的默认策略,默认 'auto'
|
|
38
|
+
* maxDiffLines: number 模型可见 diff 正文的行数预算,默认 30
|
|
34
39
|
* root: string 没有 agent 会话时的回退工作区
|
|
35
40
|
* 新建文件的行尾推断可用环境变量 `DSH_TEXT_EDITOR_EOL` = lf | crlf 覆盖(见 lib/core.mjs)。
|
|
36
41
|
*/
|
|
37
42
|
|
|
38
|
-
import { UsageError, applyPlan, toLf } from './core.mjs'
|
|
43
|
+
import { DIFF_MODES, UsageError, applyPlan, hunksFromDiff, toLf } from './core.mjs'
|
|
39
44
|
|
|
40
45
|
export const name = 'tool-text-editor'
|
|
41
46
|
|
|
@@ -56,8 +61,9 @@ const GUIDANCE =
|
|
|
56
61
|
+ 'only when a `_text` call reports that it cannot run.'
|
|
57
62
|
|
|
58
63
|
const EDIT_DESCRIPTION =
|
|
59
|
-
'Edit one existing text file. Preserves the UTF-8 BOM and the file line-ending style,
|
|
60
|
-
+ '
|
|
64
|
+
'Edit one existing text file. Preserves the UTF-8 BOM and the file line-ending style, backs the previous '
|
|
65
|
+
+ 'content up, and returns a stat line plus the changed lines when the diff is small (see '
|
|
66
|
+
+ '`diff`). Give exactly ONE anchor: `old_text` (literal, '
|
|
61
67
|
+ 'copied from `read`), `grep` (a regular expression whose matching line/block becomes the anchor), or '
|
|
62
68
|
+ '`lines` (e.g. "263:270"). `mode` defaults to `replace`; use `after`/`before` to insert beside a '
|
|
63
69
|
+ '`grep`/`lines` anchor, `append`/`prepend` for the file ends. Prefer `old_text`/`grep`: a wrong line '
|
|
@@ -65,8 +71,9 @@ const EDIT_DESCRIPTION =
|
|
|
65
71
|
+ '`nth` or `count` says which/how many. Set `dry_run` to preview without writing.'
|
|
66
72
|
|
|
67
73
|
const WRITE_DESCRIPTION =
|
|
68
|
-
'Create or completely replace one text file. Preserves the UTF-8 BOM and the file line-ending style, '
|
|
69
|
-
+ '
|
|
74
|
+
'Create or completely replace one text file. Preserves the UTF-8 BOM and the file line-ending style, backs '
|
|
75
|
+
+ 'the previous content up, and returns a stat line instead of echoing the content '
|
|
76
|
+
+ 'back (see `diff`). Creation needs no flag: '
|
|
70
77
|
+ 'the tool detects whether the target exists, and a brand-new file follows the line-ending style of its '
|
|
71
78
|
+ 'sibling files. Set `dry_run` to preview without writing.'
|
|
72
79
|
|
|
@@ -83,6 +90,7 @@ export const EDIT_PARAMETERS = {
|
|
|
83
90
|
count: { type: 'number', description: 'Require exactly N occurrences and replace all of them.' },
|
|
84
91
|
nth: { type: 'number', description: 'Replace the k-th occurrence only (1-based).' },
|
|
85
92
|
strict: { type: 'boolean', description: 'Disable relaxed matching.' },
|
|
93
|
+
diff: { type: 'string', description: 'Diff detail in the result: auto (default, small changes only) | full (always, capped) | none.', enum: DIFF_MODES },
|
|
86
94
|
dry_run: { type: 'boolean', description: 'Print the diff without writing.' },
|
|
87
95
|
note: { type: 'string', description: 'One-line reason recorded in the edit ledger.' },
|
|
88
96
|
},
|
|
@@ -94,6 +102,7 @@ export const WRITE_PARAMETERS = {
|
|
|
94
102
|
properties: {
|
|
95
103
|
file_path: { type: 'string', description: 'Target file, resolved against the session working directory when relative.' },
|
|
96
104
|
content: { type: 'string', description: 'Complete new file content.' },
|
|
105
|
+
diff: { type: 'string', description: 'Diff detail in the result: auto (default, small changes only) | full (always, capped) | none.', enum: DIFF_MODES },
|
|
97
106
|
dry_run: { type: 'boolean', description: 'Print the diff without writing.' },
|
|
98
107
|
note: { type: 'string', description: 'One-line reason recorded in the edit ledger.' },
|
|
99
108
|
},
|
|
@@ -111,11 +120,19 @@ export const OUTPUT_SCHEMA = {
|
|
|
111
120
|
dryRun: { type: 'boolean' },
|
|
112
121
|
stdout: { type: 'string' },
|
|
113
122
|
stderr: { type: 'string' },
|
|
123
|
+
brief: { type: 'string' },
|
|
124
|
+
diff: { type: 'string' },
|
|
114
125
|
},
|
|
115
|
-
required: ['path', 'ok', 'wrote', 'dryRun', 'stdout', 'stderr'],
|
|
126
|
+
required: ['path', 'ok', 'wrote', 'dryRun', 'stdout', 'stderr', 'brief', 'diff'],
|
|
116
127
|
}
|
|
117
128
|
|
|
118
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* 模型可见的结果文本:**一行统计(+ 受预算约束的 diff 正文)**。
|
|
131
|
+
*
|
|
132
|
+
* 这里刻意不复述任何东西:路径只出现一次,备份名只留在台账与 `stdout` 里,完整 diff 交给
|
|
133
|
+
* UI 卡片。工具结果是 append-only 的会话历史、永不缓存,所以每个字节都要问一句"模型真需要吗";
|
|
134
|
+
* 失败时相反 —— 原因必须完整,因为下一次调用要靠它自救。
|
|
135
|
+
*/
|
|
119
136
|
function renderResult(_args, value) {
|
|
120
137
|
const where = value.path === '' ? '' : ' ' + value.path
|
|
121
138
|
if (!value.ok) {
|
|
@@ -123,8 +140,27 @@ function renderResult(_args, value) {
|
|
|
123
140
|
return [{ type: 'text', text: 'FAIL' + where + '\n' + (detail === '' ? '(no output)' : detail) }]
|
|
124
141
|
}
|
|
125
142
|
const head = value.dryRun ? 'DRY RUN' + where + ' (nothing written)' : 'WROTE' + where
|
|
126
|
-
const body = value.
|
|
127
|
-
return [{ type: 'text', text:
|
|
143
|
+
const body = [value.brief, value.diff].map((part) => part.trim()).filter((part) => part !== '')
|
|
144
|
+
return [{ type: 'text', text: [head, ...body].join('\n') }]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* UI diff 卡片:**完整** diff(含配置的上下文行)只走这条路径。
|
|
149
|
+
* 元数据随 `tool/result` 持久化,供实时与回放两条路径使用;模型永远看不到它。
|
|
150
|
+
*/
|
|
151
|
+
function presentationMeta(_args, value) {
|
|
152
|
+
const diffs = value.ok === true && value.wrote === true ? hunksFromDiff(value.stdout, value.path) : []
|
|
153
|
+
return { diffs, path: value.path }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 有 diff 卡片可用就交给 UI;否则返回 `undefined`,让宿主回退到文本渲染。 */
|
|
157
|
+
function presentResult(_args, result) {
|
|
158
|
+
if (result.isError === true) return undefined
|
|
159
|
+
const meta = result.meta
|
|
160
|
+
const diffs = meta !== null && typeof meta === 'object' && Array.isArray(meta.diffs) ? meta.diffs : []
|
|
161
|
+
if (diffs.length === 0) return undefined
|
|
162
|
+
const title = typeof meta.path === 'string' && meta.path !== '' ? meta.path : undefined
|
|
163
|
+
return title === undefined ? { card: 'diff', diffs } : { card: 'diff', title, diffs }
|
|
128
164
|
}
|
|
129
165
|
|
|
130
166
|
function stringArg(value, label, { required = false } = {}) {
|
|
@@ -151,6 +187,14 @@ function booleanArg(value, label) {
|
|
|
151
187
|
return value
|
|
152
188
|
}
|
|
153
189
|
|
|
190
|
+
function diffModeArg(value) {
|
|
191
|
+
if (value === undefined) return undefined
|
|
192
|
+
if (typeof value !== 'string' || !DIFF_MODES.includes(value)) {
|
|
193
|
+
throw new UsageError('diff must be one of ' + DIFF_MODES.join(' / '))
|
|
194
|
+
}
|
|
195
|
+
return value
|
|
196
|
+
}
|
|
197
|
+
|
|
154
198
|
/**
|
|
155
199
|
* 校验并归一 `edit_text` 入参。
|
|
156
200
|
* @throws {UsageError} 参数不合法(不会落盘)。
|
|
@@ -192,6 +236,7 @@ export function planEdit(args) {
|
|
|
192
236
|
nth,
|
|
193
237
|
strict: booleanArg(args.strict, 'strict') === true,
|
|
194
238
|
dryRun: booleanArg(args.dry_run, 'dry_run') === true,
|
|
239
|
+
diff: diffModeArg(args.diff),
|
|
195
240
|
note: stringArg(args.note, 'note') ?? '',
|
|
196
241
|
}
|
|
197
242
|
}
|
|
@@ -205,6 +250,7 @@ export function planWrite(args) {
|
|
|
205
250
|
filePath,
|
|
206
251
|
content: toLf(args.content),
|
|
207
252
|
dryRun: booleanArg(args.dry_run, 'dry_run') === true,
|
|
253
|
+
diff: diffModeArg(args.diff),
|
|
208
254
|
note: stringArg(args.note, 'note') ?? '',
|
|
209
255
|
}
|
|
210
256
|
}
|
|
@@ -225,6 +271,8 @@ function fail(path, message) {
|
|
|
225
271
|
dryRun: false,
|
|
226
272
|
stdout: '',
|
|
227
273
|
stderr: message,
|
|
274
|
+
brief: '',
|
|
275
|
+
diff: '',
|
|
228
276
|
}
|
|
229
277
|
}
|
|
230
278
|
|
|
@@ -244,7 +292,12 @@ export function apply(ctx, config) {
|
|
|
244
292
|
log: settings.ledger !== false,
|
|
245
293
|
context: Number.isFinite(settings.context) ? settings.context : undefined,
|
|
246
294
|
newFileBom: settings.newFileBom === true,
|
|
295
|
+
...(Number.isFinite(settings.maxDiffLines) && settings.maxDiffLines >= 1
|
|
296
|
+
? { maxDiffLines: Math.floor(settings.maxDiffLines) }
|
|
297
|
+
: {}),
|
|
247
298
|
}
|
|
299
|
+
/** 配置层的默认 diff 策略;逐调用的 `diff` 参数优先于它。 */
|
|
300
|
+
const diffDefault = DIFF_MODES.includes(settings.diff) ? settings.diff : 'auto'
|
|
248
301
|
|
|
249
302
|
ctx.systemPrompt.section({ name: 'tool:edit_text', order: 116, text: GUIDANCE })
|
|
250
303
|
|
|
@@ -259,6 +312,7 @@ export function apply(ctx, config) {
|
|
|
259
312
|
root,
|
|
260
313
|
...(artifactsDir === undefined ? {} : { artifactsDir }),
|
|
261
314
|
...planOptions,
|
|
315
|
+
diff: plan.diff ?? diffDefault,
|
|
262
316
|
tool: plan.kind === 'write' ? WRITE_TOOL : EDIT_TOOL,
|
|
263
317
|
})
|
|
264
318
|
}
|
|
@@ -267,7 +321,8 @@ export function apply(ctx, config) {
|
|
|
267
321
|
name: EDIT_TOOL,
|
|
268
322
|
description: EDIT_DESCRIPTION,
|
|
269
323
|
parameters: EDIT_PARAMETERS,
|
|
270
|
-
output: { schema: OUTPUT_SCHEMA, render: renderResult },
|
|
324
|
+
output: { schema: OUTPUT_SCHEMA, render: renderResult, presentationMeta },
|
|
325
|
+
presentResult,
|
|
271
326
|
timeoutMs: TIMEOUT_MS,
|
|
272
327
|
async execute(args, exec) {
|
|
273
328
|
let plan
|
|
@@ -285,7 +340,8 @@ export function apply(ctx, config) {
|
|
|
285
340
|
name: WRITE_TOOL,
|
|
286
341
|
description: WRITE_DESCRIPTION,
|
|
287
342
|
parameters: WRITE_PARAMETERS,
|
|
288
|
-
output: { schema: OUTPUT_SCHEMA, render: renderResult },
|
|
343
|
+
output: { schema: OUTPUT_SCHEMA, render: renderResult, presentationMeta },
|
|
344
|
+
presentResult,
|
|
289
345
|
timeoutMs: TIMEOUT_MS,
|
|
290
346
|
async execute(args, exec) {
|
|
291
347
|
let plan
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flotiarenor/dsh-tool-text-editor",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Byte-faithful text editing tools (edit_text / write_text) for DeepSeek Harness: they preserve a UTF-8 BOM and the file's own CRLF/LF style (the built-in write/edit tools do not),
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Byte-faithful text editing tools (edit_text / write_text) for DeepSeek Harness: they preserve a UTF-8 BOM and the file's own CRLF/LF style (the built-in write/edit tools do not), back the previous content up, accept grep/lines anchors, and return a token-budgeted result (one stat line plus a bounded diff body, with the full diff going to the UI card instead of the model context). Pure Node and in-process: no external runtime, no child process, no third-party package, no build step, no imports beyond node: builtins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/editor.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
"patch": "./cordis.patch.yml"
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node tools/check-license.mjs && node tools/selftest.mjs",
|
|
19
|
+
"check": "node tools/check-license.mjs",
|
|
20
|
+
"check:license": "node tools/check-license.mjs",
|
|
21
|
+
"check:schema": "node tools/gen-schema.mjs",
|
|
22
|
+
"selftest": "node tools/selftest.mjs",
|
|
23
|
+
"install:preset": "node scripts/install-preset.mjs",
|
|
24
|
+
"prepublishOnly": "node tools/check-license.mjs && node tools/selftest.mjs"
|
|
25
|
+
},
|
|
17
26
|
"files": [
|
|
18
27
|
"lib",
|
|
19
28
|
"preset",
|
|
@@ -57,13 +66,5 @@
|
|
|
57
66
|
"repository": {
|
|
58
67
|
"type": "git",
|
|
59
68
|
"url": "https://github.com/Flotiarenor/dsh-tool-text-editor.git"
|
|
60
|
-
},
|
|
61
|
-
"scripts": {
|
|
62
|
-
"test": "node tools/check-license.mjs && node tools/selftest.mjs",
|
|
63
|
-
"check": "node tools/check-license.mjs",
|
|
64
|
-
"check:license": "node tools/check-license.mjs",
|
|
65
|
-
"check:schema": "node tools/gen-schema.mjs",
|
|
66
|
-
"selftest": "node tools/selftest.mjs",
|
|
67
|
-
"install:preset": "node scripts/install-preset.mjs"
|
|
68
69
|
}
|
|
69
|
-
}
|
|
70
|
+
}
|