@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 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`, `dry_run`, `note`.
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`; creation needs no flag, an overwrite is backed up first, and a brand-new file
88
- follows the **majority** line-ending style of its siblings (same extension first) with no BOM by
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
- Both **write by default** (like the built-ins); pass `dry_run: true` to preview. The returned text
92
- always includes the diff.
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 and the Web UI therefore shows no diff card (the model still
102
- receives the textual diff).
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 unified diff |
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 # 75/75 on Windows + Node 24
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
- 同目录的多数派(同扩展名优先),默认不写 BOM。
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。对应地,Web UI 不会生成 diff 卡片
100
- (模型仍可读到文本 diff)。
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` | unified diff 的上下文行数 |
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 参考结果 75/75
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
- * * 默认先出 unified diff,落盘前自动备份,记编辑台账;
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 保留,也不产生 Web UI 的
24
- * diff 卡片(模型仍能看到文本 diff)。
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 上下文行数,默认 3
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, prints a unified '
60
- + 'diff, and backs the previous content up before writing. Give exactly ONE anchor: `old_text` (literal, '
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
- + 'prints a unified diff, and backs the previous content up before overwriting. Creation needs no flag: '
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
- /** 模型可见的结果文本:失败给 stderr + stdout,成功给头部 + unified diff。 */
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.stdout.trim()
127
- return [{ type: 'text', text: body === '' ? head : head + '\n' + body }]
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.0.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), print a unified diff, back the previous content up, and accept grep/lines anchors. Pure Node and in-process: no external runtime, no child process, no third-party package, no build step, no imports beyond node: builtins.",
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
+ }