@flotiarenor/dsh-tool-text-editor 1.0.0 → 1.1.1
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 +52 -12
- package/README.zh.md +43 -7
- package/lib/core.mjs +88 -4
- package/lib/editor.mjs +69 -14
- package/package.json +12 -11
package/README.md
CHANGED
|
@@ -19,6 +19,9 @@ On top of fidelity: **unified diffs** (with a `dry_run` preview), **automatic ba
|
|
|
19
19
|
ledger**, **`grep` / `lines` anchors** so old text never has to be copied by hand, **ambiguity
|
|
20
20
|
refusal**, and **near-miss candidates** when an anchor does not match.
|
|
21
21
|
|
|
22
|
+
The canonical return value of both tools, the composition of the model-facing text, and the UI card
|
|
23
|
+
projection are documented under "Return value".
|
|
24
|
+
|
|
22
25
|
## Implementation and requirements
|
|
23
26
|
|
|
24
27
|
The implementation is **in-process Node** (`lib/core.mjs`): `node:` builtins only, no subprocess, no
|
|
@@ -79,17 +82,48 @@ Both installs may coexist: the preset layer shadows the host layer with an ident
|
|
|
79
82
|
`file_path` and `new_text` are required; give **exactly one** anchor: `old_text` (literal, copied from
|
|
80
83
|
`read`), `grep` (regex; the matched line/block including its trailing newline), or `lines` (e.g.
|
|
81
84
|
`"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.
|
|
85
|
+
(require exactly N occurrences and replace all), `nth` (k-th occurrence), `strict`, `diff` (`auto` /
|
|
86
|
+
`full` / `none`), `dry_run`, `note`. `count` and `nth` are mutually exclusive.
|
|
84
87
|
|
|
85
88
|
### `write_text` — create or fully replace a file
|
|
86
89
|
|
|
87
|
-
`file_path` + `content
|
|
88
|
-
follows the **majority** line-ending style of its
|
|
89
|
-
default.
|
|
90
|
+
`file_path` + `content` (plus the same `diff` / `dry_run` / `note`); creation needs no flag, an
|
|
91
|
+
overwrite is backed up first, and a brand-new file follows the **majority** line-ending style of its
|
|
92
|
+
siblings (same extension first) with no BOM by default.
|
|
93
|
+
|
|
94
|
+
Both **write by default** (like the built-ins); pass `dry_run: true` to preview.
|
|
95
|
+
|
|
96
|
+
### Return value
|
|
97
|
+
|
|
98
|
+
Both tools return the same canonical value (`OUTPUT_SCHEMA`). Field contents and destinations:
|
|
99
|
+
|
|
100
|
+
| Field | Content | Destination |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `path` | the target path as supplied by the caller, echoed back | — |
|
|
103
|
+
| `ok` / `wrote` / `dryRun` | outcome flags | — |
|
|
104
|
+
| `brief` | warning lines plus one stat line, e.g. `replace@60 +1/-1` | model context |
|
|
105
|
+
| `diff` | a unified diff of the changed lines only (0 context lines), limited by `maxDiffLines` | model context |
|
|
106
|
+
| `stdout` | the full human record: path header, complete diff with context lines, backup filename | UI / logs / triage |
|
|
107
|
+
| `stderr` | failure reason (non-empty on failure) | model context |
|
|
108
|
+
|
|
109
|
+
The model-facing text consists of `brief` and `diff`, with the path appearing once in the leading
|
|
110
|
+
line. The complete diff is additionally projected by `output.presentationMeta` into a list of
|
|
111
|
+
`{ path, oldText, newText }`, the same card vocabulary the built-in `edit` / `write` tools use, and
|
|
112
|
+
handed to the Web UI by `presentResult`; that metadata is persisted with `tool/result` and never
|
|
113
|
+
enters the model context.
|
|
114
|
+
|
|
115
|
+
The `diff` argument selects the detail level of the `diff` field:
|
|
116
|
+
|
|
117
|
+
| Value | Behavior |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `auto` | default. The body is returned when it fits within `maxDiffLines`; otherwise it is omitted with a one-line note |
|
|
120
|
+
| `full` | the body is always returned; it is truncated with a one-line note when it exceeds `maxDiffLines` |
|
|
121
|
+
| `none` | no body is returned |
|
|
90
122
|
|
|
91
|
-
|
|
92
|
-
|
|
123
|
+
The body always uses 0 context lines; the `context` setting affects `stdout` and the UI card only.
|
|
124
|
+
`maxDiffLines` bounds the bytes returned to the model context by a single call: tool results are
|
|
125
|
+
appended to the session history and are not prefix-cached, so without a bound a full-file rewrite
|
|
126
|
+
produces a return of the same order as the content just sent (a measured ~1.0x amplification).
|
|
93
127
|
|
|
94
128
|
## Deliberate limitations
|
|
95
129
|
|
|
@@ -98,8 +132,9 @@ the tools.
|
|
|
98
132
|
|
|
99
133
|
- **Writes bypass `ctx.fs`.** The file is written by the plugin itself, so the fs-observation policy
|
|
100
134
|
(read-before-write, version freshness), the sandbox, `sandbox_permissions` escalation and Windows
|
|
101
|
-
DACL preservation are all skipped
|
|
102
|
-
|
|
135
|
+
DACL preservation are all skipped. The atomic write is implemented by the plugin (same-directory
|
|
136
|
+
temp file + fsync + rename), and the diff card is projected by the plugin's `presentationMeta`
|
|
137
|
+
whereas the built-ins use the `before` / `after` returned by `ctx.fs`.
|
|
103
138
|
- **Line anchors are not content-verified.** `lines` and `before` / `after <line>` locate text by line
|
|
104
139
|
number alone: a wrong number does not fail, it edits somewhere else. When the anchor has to be
|
|
105
140
|
verifiable, use `old_text` or `grep`.
|
|
@@ -119,7 +154,9 @@ There is no Config schema: the preset row's `config:` mapping is passed through
|
|
|
119
154
|
| `ledger` | `true` | append a JSONL record to `artifactsDir/edits.log` |
|
|
120
155
|
| `artifactsDir` | `<workspace>/.dsh` | where backups and the ledger live |
|
|
121
156
|
| `newFileBom` | `false` | write a UTF-8 BOM when creating a new file |
|
|
122
|
-
| `context` | `3` | context lines in the
|
|
157
|
+
| `context` | `3` | context lines in the diff of `stdout` and the UI card (the model-facing body always uses 0) |
|
|
158
|
+
| `diff` | `'auto'` | default policy for the `diff` field (`auto` / `full` / `none`); a per-call `diff` argument takes precedence |
|
|
159
|
+
| `maxDiffLines` | `30` | line limit for the `diff` field: `auto` omits the body when exceeded, `full` truncates at it |
|
|
123
160
|
| `root` | `process.cwd()` | fallback workspace when a call has no agent session |
|
|
124
161
|
|
|
125
162
|
`DSH_TEXT_EDITOR_EOL` (`lf` \| `crlf`) overrides the line-ending inference for **new** files.
|
|
@@ -128,7 +165,7 @@ There is no Config schema: the preset row's `config:` mapping is passed through
|
|
|
128
165
|
|
|
129
166
|
```powershell
|
|
130
167
|
# run from the root of a clone of this repository
|
|
131
|
-
node tools/selftest.mjs #
|
|
168
|
+
node tools/selftest.mjs # 92/92 on Windows + Node 24
|
|
132
169
|
node tools/check-license.mjs # license / dependency / Node-only gate
|
|
133
170
|
node tools/gen-schema.mjs # embedded schemas still match the DSL
|
|
134
171
|
```
|
|
@@ -141,7 +178,10 @@ refusal, usage errors, binary/invalid-UTF-8 refusal, `.dsh/` and outside-workspa
|
|
|
141
178
|
EOL inference, multi-hunk diffs, end-of-file newline changes and concurrent writes — **plus a
|
|
142
179
|
plugin-layer suite** that drives `apply()` with a fake context and asserts tool registration, the
|
|
143
180
|
guidance section, that every returned value satisfies `OUTPUT_SCHEMA`, the `render()` text, and the
|
|
144
|
-
config plumbing (`root` / `backup` / `ledger` / `newFileBom`)
|
|
181
|
+
config plumbing (`root` / `backup` / `ledger` / `newFileBom`) — **and a return-value suite**: a
|
|
182
|
+
full-file rewrite must not echo the content back, a small edit must still report the changed lines,
|
|
183
|
+
the three `diff` values must hold their documented boundaries, the path must appear once, and the
|
|
184
|
+
complete diff must be projected only through `presentationMeta`.
|
|
145
185
|
|
|
146
186
|
`tools/gen-schema.mjs` needs an installed `@deepseek-ai/dsh-tools`: it looks for one under the dsh
|
|
147
187
|
profile's `node_modules` and under the npm global prefix, and `DSH_TOOLS_ENTRY` overrides that lookup.
|
package/README.zh.md
CHANGED
|
@@ -18,6 +18,8 @@ BOM 字节),且 `writeText` 不按原文件风格还原行尾。
|
|
|
18
18
|
在保真之外,本插件还提供:**统一 diff**(可用 dry-run 预览)、**写入前自动备份**、**编辑台账**、
|
|
19
19
|
**`grep` / `lines` 锚点**(无需人工誊抄原文)、**歧义时拒绝写入**,以及**最接近候选**提示。
|
|
20
20
|
|
|
21
|
+
两个工具的规范返回值、模型可见文本的构成与 UI 卡片的投影方式见「返回值」。
|
|
22
|
+
|
|
21
23
|
## 实现与依赖
|
|
22
24
|
|
|
23
25
|
实现是**进程内 Node**(`lib/core.mjs`):只用 `node:` 内置模块,不启动任何子进程,无构建步骤、
|
|
@@ -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,46 @@ 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
|
+
### 返回值
|
|
98
|
+
|
|
99
|
+
两个工具返回同一份规范值(`OUTPUT_SCHEMA`)。字段内容与去向如下:
|
|
100
|
+
|
|
101
|
+
| 字段 | 内容 | 去向 |
|
|
102
|
+
| --------------------- | ------------------------------------------------------------------------ | ---------------- |
|
|
103
|
+
| `path` | 调用方给出的目标路径(原样回填) | — |
|
|
104
|
+
| `ok` / `wrote` / `dryRun` | 执行结果标志 | — |
|
|
105
|
+
| `brief` | 警告行与一行统计,如 `replace@60 +1/-1` | 模型上下文 |
|
|
106
|
+
| `diff` | 只由改动行组成的 unified diff(0 上下文行),受 `maxDiffLines` 限制 | 模型上下文 |
|
|
107
|
+
| `stdout` | 人读全文:路径头、含上下文行的完整 diff、备份文件名 | UI / 日志 / 排查 |
|
|
108
|
+
| `stderr` | 失败原因(失败时非空) | 模型上下文 |
|
|
109
|
+
|
|
110
|
+
模型可见文本由 `brief` 与 `diff` 组成,路径在起始行出现一次。完整 diff 另经
|
|
111
|
+
`output.presentationMeta` 投影为 `{ path, oldText, newText }` 列表,与原生 `edit` / `write` 的卡片
|
|
112
|
+
词汇同形,由 `presentResult` 交给 Web UI;该元数据随 `tool/result` 持久化,不进入模型上下文。
|
|
113
|
+
|
|
114
|
+
`diff` 参数决定 `diff` 字段的详细程度:
|
|
115
|
+
|
|
116
|
+
| 取值 | 行为 |
|
|
117
|
+
| --------- | -------------------------------------------------------------------------------- |
|
|
118
|
+
| `auto` | 默认。正文不超过 `maxDiffLines` 行时给出;超出时不给出正文,附一行省略提示 |
|
|
119
|
+
| `full` | 始终给出正文;超过 `maxDiffLines` 行时截断,附一行截断提示 |
|
|
120
|
+
| `none` | 不给出正文 |
|
|
121
|
+
|
|
122
|
+
正文固定使用 0 上下文行;`context` 配置只影响 `stdout` 与 UI 卡片。`maxDiffLines` 的作用是限制单次
|
|
123
|
+
调用回吐到模型上下文的字节数——工具结果按追加方式进入会话历史,不参与前缀缓存,因此不含上限时,
|
|
124
|
+
整文件重写会产生与输入内容等量级的回吐(实测放大率 ≈ 1.0x)。
|
|
93
125
|
|
|
94
126
|
## 已知限制
|
|
95
127
|
|
|
96
128
|
以下均为有意的设计取舍,而非缺陷;采用前请对照自身场景确认。
|
|
97
129
|
|
|
98
130
|
- **写入不经由 `ctx.fs`。** 文件由本插件直接写入,因此不经过 fs 观察策略(先读后写、版本新鲜度校验)、
|
|
99
|
-
沙箱与 `sandbox_permissions` 审批升权,也不保留 Windows DACL
|
|
100
|
-
|
|
131
|
+
沙箱与 `sandbox_permissions` 审批升权,也不保留 Windows DACL。原子写由本插件自行实现
|
|
132
|
+
(同目录临时文件 + fsync + rename);diff 卡片由本插件的 `presentationMeta` 提供,原生工具则取自
|
|
133
|
+
`ctx.fs` 返回的 `before` / `after`。
|
|
101
134
|
- **行号锚点不做内容校验。** `lines` 与 `before` / `after <行号>` 仅按行号定位:行号有误不会报错,
|
|
102
135
|
改动会落在非预期位置;定位需要可校验时,请改用 `old_text` 或 `grep`。
|
|
103
136
|
- **同目标串行仅限本进程。** 进程内按目标路径排队,并配合原子写,故并行的工具调用不会相互覆盖;
|
|
@@ -115,7 +148,9 @@ dsh --profile web --dump-config # 应当能看到 "# == @flotiarenor/dsh-tool-
|
|
|
115
148
|
| `ledger` | `true` | 往 `artifactsDir/edits.log` 追加一条 JSONL 记录 |
|
|
116
149
|
| `artifactsDir` | `<工作区>/.dsh` | 备份与台账所在目录 |
|
|
117
150
|
| `newFileBom` | `false` | 新建文件时是否写 UTF-8 BOM |
|
|
118
|
-
| `context` | `3` |
|
|
151
|
+
| `context` | `3` | `stdout` 与 UI 卡片中 diff 的上下文行数(模型可见正文固定 0 行) |
|
|
152
|
+
| `diff` | `'auto'` | `diff` 字段的默认策略(`auto` / `full` / `none`);逐调用的 `diff` 参数优先 |
|
|
153
|
+
| `maxDiffLines` | `30` | `diff` 字段的行数上限:`auto` 超出时不给出正文,`full` 超出时截断 |
|
|
119
154
|
| `root` | `process.cwd()` | 无 agent 会话时的回退工作区 |
|
|
120
155
|
|
|
121
156
|
环境变量 `DSH_TEXT_EDITOR_EOL`(`lf` \| `crlf`)可覆盖**新建文件**的行尾推断。
|
|
@@ -124,7 +159,7 @@ dsh --profile web --dump-config # 应当能看到 "# == @flotiarenor/dsh-tool-
|
|
|
124
159
|
|
|
125
160
|
```powershell
|
|
126
161
|
# 在本仓库根目录执行
|
|
127
|
-
node tools/selftest.mjs # Windows + Node 24 参考结果
|
|
162
|
+
node tools/selftest.mjs # Windows + Node 24 参考结果 92/92
|
|
128
163
|
node tools/check-license.mjs # 许可证 / 依赖 / 纯 Node 门禁
|
|
129
164
|
node tools/gen-schema.mjs # 内嵌 schema 是否仍与作者 DSL 一致
|
|
130
165
|
```
|
|
@@ -133,7 +168,8 @@ node tools/gen-schema.mjs # 内嵌 schema 是否仍与作者 DSL 一致
|
|
|
133
168
|
二进制与非法 UTF-8、路径护栏(`.dsh/`、工作区之外)、行尾多数派推断、跨多个 hunk、末尾换行差异、
|
|
134
169
|
并发写入不产生半截文件;**并含一层插件层断言**:以模拟 ctx 驱动 `apply()`,验证工具注册、引导段
|
|
135
170
|
身份、每个返回值均满足 `OUTPUT_SCHEMA`、`render()` 输出,以及 config 透传(`root` / `backup` /
|
|
136
|
-
`ledger` / `newFileBom
|
|
171
|
+
`ledger` / `newFileBom`);**以及一层返回值约束断言**:整文件重写不回显内容、小改动仍给出改动行、
|
|
172
|
+
`diff` 三种取值的行为边界、路径仅出现一次、完整 diff 只经 `presentationMeta` 投影。
|
|
137
173
|
|
|
138
174
|
`tools/gen-schema.mjs` 需要一份装有 `@deepseek-ai/dsh-tools` 的 dsh:它会在 dsh profile 的
|
|
139
175
|
`node_modules` 与 npm 全局目录中自动查找,也可用 `DSH_TOOLS_ENTRY` 显式指定;找不到入口时退出码为 2。
|
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` / `maxDiffLines` 的由来:工具结果按追加方式进入会话历史,不参与前缀缓存,因此单次调用
|
|
24
|
+
* 回吐的字节随调用次数累积。"新建 / 整体重写" 的 unified diff 每一行都带 `+`,等同于整文件回显
|
|
25
|
+
* ——实测回吐量与输入内容同量级(放大率 ≈ 1.0x)。故模型可见正文默认仅在改动较小时给出
|
|
26
|
+
* (`auto`),其余情况只给统计行,由调用方决定是否另行读取文件;`none` 不给出正文,`full` 始终
|
|
27
|
+
* 给出正文,但同样受行数上限约束。
|
|
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
|
+
* `none` 不返回正文;`auto` 仅在正文不超过上限时返回;`full` 始终返回正文,超过上限时截断并附
|
|
583
|
+
* 一行说明。上限的作用是避免整文件重写产生与输入内容同量级的回吐(实测放大率 ≈ 1.0x)。
|
|
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 上下文行:调用方刚完成这次编辑,周边内容或已读取过,或可自行读取。
|
|
1000
|
+
// 完整 diff(含上下文行)只出现在 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
|
+
* * 模型可见文本受上限约束:路径仅出现一次,正文为一行统计加经 `diffBudget` 裁剪的 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,18 @@ 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
|
+
* 成功路径只返回 `brief` 与 `diff`:路径仅出现一次,备份文件名只保留在台账与 `stdout` 中,
|
|
133
|
+
* 完整 diff 由 UI 卡片承载。失败路径相反——原因必须完整,它决定调用方的下一次调用。
|
|
134
|
+
*/
|
|
119
135
|
function renderResult(_args, value) {
|
|
120
136
|
const where = value.path === '' ? '' : ' ' + value.path
|
|
121
137
|
if (!value.ok) {
|
|
@@ -123,8 +139,27 @@ function renderResult(_args, value) {
|
|
|
123
139
|
return [{ type: 'text', text: 'FAIL' + where + '\n' + (detail === '' ? '(no output)' : detail) }]
|
|
124
140
|
}
|
|
125
141
|
const head = value.dryRun ? 'DRY RUN' + where + ' (nothing written)' : 'WROTE' + where
|
|
126
|
-
const body = value.
|
|
127
|
-
return [{ type: 'text', text:
|
|
142
|
+
const body = [value.brief, value.diff].map((part) => part.trim()).filter((part) => part !== '')
|
|
143
|
+
return [{ type: 'text', text: [head, ...body].join('\n') }]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* UI diff 卡片:**完整** diff(含配置的上下文行)只走这条路径。
|
|
148
|
+
* 元数据随 `tool/result` 持久化,供实时与回放两条路径使用;模型永远看不到它。
|
|
149
|
+
*/
|
|
150
|
+
function presentationMeta(_args, value) {
|
|
151
|
+
const diffs = value.ok === true && value.wrote === true ? hunksFromDiff(value.stdout, value.path) : []
|
|
152
|
+
return { diffs, path: value.path }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 有 diff 卡片可用就交给 UI;否则返回 `undefined`,让宿主回退到文本渲染。 */
|
|
156
|
+
function presentResult(_args, result) {
|
|
157
|
+
if (result.isError === true) return undefined
|
|
158
|
+
const meta = result.meta
|
|
159
|
+
const diffs = meta !== null && typeof meta === 'object' && Array.isArray(meta.diffs) ? meta.diffs : []
|
|
160
|
+
if (diffs.length === 0) return undefined
|
|
161
|
+
const title = typeof meta.path === 'string' && meta.path !== '' ? meta.path : undefined
|
|
162
|
+
return title === undefined ? { card: 'diff', diffs } : { card: 'diff', title, diffs }
|
|
128
163
|
}
|
|
129
164
|
|
|
130
165
|
function stringArg(value, label, { required = false } = {}) {
|
|
@@ -151,6 +186,14 @@ function booleanArg(value, label) {
|
|
|
151
186
|
return value
|
|
152
187
|
}
|
|
153
188
|
|
|
189
|
+
function diffModeArg(value) {
|
|
190
|
+
if (value === undefined) return undefined
|
|
191
|
+
if (typeof value !== 'string' || !DIFF_MODES.includes(value)) {
|
|
192
|
+
throw new UsageError('diff must be one of ' + DIFF_MODES.join(' / '))
|
|
193
|
+
}
|
|
194
|
+
return value
|
|
195
|
+
}
|
|
196
|
+
|
|
154
197
|
/**
|
|
155
198
|
* 校验并归一 `edit_text` 入参。
|
|
156
199
|
* @throws {UsageError} 参数不合法(不会落盘)。
|
|
@@ -192,6 +235,7 @@ export function planEdit(args) {
|
|
|
192
235
|
nth,
|
|
193
236
|
strict: booleanArg(args.strict, 'strict') === true,
|
|
194
237
|
dryRun: booleanArg(args.dry_run, 'dry_run') === true,
|
|
238
|
+
diff: diffModeArg(args.diff),
|
|
195
239
|
note: stringArg(args.note, 'note') ?? '',
|
|
196
240
|
}
|
|
197
241
|
}
|
|
@@ -205,6 +249,7 @@ export function planWrite(args) {
|
|
|
205
249
|
filePath,
|
|
206
250
|
content: toLf(args.content),
|
|
207
251
|
dryRun: booleanArg(args.dry_run, 'dry_run') === true,
|
|
252
|
+
diff: diffModeArg(args.diff),
|
|
208
253
|
note: stringArg(args.note, 'note') ?? '',
|
|
209
254
|
}
|
|
210
255
|
}
|
|
@@ -225,6 +270,8 @@ function fail(path, message) {
|
|
|
225
270
|
dryRun: false,
|
|
226
271
|
stdout: '',
|
|
227
272
|
stderr: message,
|
|
273
|
+
brief: '',
|
|
274
|
+
diff: '',
|
|
228
275
|
}
|
|
229
276
|
}
|
|
230
277
|
|
|
@@ -244,7 +291,12 @@ export function apply(ctx, config) {
|
|
|
244
291
|
log: settings.ledger !== false,
|
|
245
292
|
context: Number.isFinite(settings.context) ? settings.context : undefined,
|
|
246
293
|
newFileBom: settings.newFileBom === true,
|
|
294
|
+
...(Number.isFinite(settings.maxDiffLines) && settings.maxDiffLines >= 1
|
|
295
|
+
? { maxDiffLines: Math.floor(settings.maxDiffLines) }
|
|
296
|
+
: {}),
|
|
247
297
|
}
|
|
298
|
+
/** 配置层的默认 diff 策略;逐调用的 `diff` 参数优先于它。 */
|
|
299
|
+
const diffDefault = DIFF_MODES.includes(settings.diff) ? settings.diff : 'auto'
|
|
248
300
|
|
|
249
301
|
ctx.systemPrompt.section({ name: 'tool:edit_text', order: 116, text: GUIDANCE })
|
|
250
302
|
|
|
@@ -259,6 +311,7 @@ export function apply(ctx, config) {
|
|
|
259
311
|
root,
|
|
260
312
|
...(artifactsDir === undefined ? {} : { artifactsDir }),
|
|
261
313
|
...planOptions,
|
|
314
|
+
diff: plan.diff ?? diffDefault,
|
|
262
315
|
tool: plan.kind === 'write' ? WRITE_TOOL : EDIT_TOOL,
|
|
263
316
|
})
|
|
264
317
|
}
|
|
@@ -267,7 +320,8 @@ export function apply(ctx, config) {
|
|
|
267
320
|
name: EDIT_TOOL,
|
|
268
321
|
description: EDIT_DESCRIPTION,
|
|
269
322
|
parameters: EDIT_PARAMETERS,
|
|
270
|
-
output: { schema: OUTPUT_SCHEMA, render: renderResult },
|
|
323
|
+
output: { schema: OUTPUT_SCHEMA, render: renderResult, presentationMeta },
|
|
324
|
+
presentResult,
|
|
271
325
|
timeoutMs: TIMEOUT_MS,
|
|
272
326
|
async execute(args, exec) {
|
|
273
327
|
let plan
|
|
@@ -285,7 +339,8 @@ export function apply(ctx, config) {
|
|
|
285
339
|
name: WRITE_TOOL,
|
|
286
340
|
description: WRITE_DESCRIPTION,
|
|
287
341
|
parameters: WRITE_PARAMETERS,
|
|
288
|
-
output: { schema: OUTPUT_SCHEMA, render: renderResult },
|
|
342
|
+
output: { schema: OUTPUT_SCHEMA, render: renderResult, presentationMeta },
|
|
343
|
+
presentResult,
|
|
289
344
|
timeoutMs: TIMEOUT_MS,
|
|
290
345
|
async execute(args, exec) {
|
|
291
346
|
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.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), back the previous content up, accept grep/lines anchors, and return a bounded result: one stat line plus a diff body limited by maxDiffLines, while the complete diff is projected to the UI card and never enters 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
|
+
}
|