@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 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**, and **near-miss candidates** when an anchor does not match.
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`, `dry_run`, `note`.
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`; 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.
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. The returned text
92
- always includes the diff.
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 and the Web UI therefore shows no diff card (the model still
102
- receives the textual diff).
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 unified diff |
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 # 75/75 on Windows + Node 24
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
- 同目录的多数派(同扩展名优先),默认不写 BOM。
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。对应地,Web UI 不会生成 diff 卡片
100
- (模型仍可读到文本 diff)。
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` | unified diff 的上下文行数 |
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 参考结果 75/75
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
- * * 默认先出 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 定预算(`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 保留,也不产生 Web UI 的
24
- * diff 卡片(模型仍能看到文本 diff)。
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 上下文行数,默认 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,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
- /** 模型可见的结果文本:失败给 stderr + stdout,成功给头部 + unified diff。 */
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.stdout.trim()
127
- return [{ type: 'text', text: body === '' ? head : head + '\n' + body }]
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.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.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
+ }