@flotiarenor/dsh-tool-text-editor 1.1.2 → 1.3.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/lib/editor.mjs CHANGED
@@ -3,44 +3,43 @@
3
3
  /**
4
4
  * editor.mjs —— dsh 模型工具插件:`edit_text` / `write_text`。
5
5
  *
6
- * 为什么存在:dsh 原生的 `write` / `edit` 在 Windows 上会丢掉 UTF-8 BOM,原生 `write` 还会把
7
- * CRLF 文件改写成 LF(实测确认;`@deepseek-ai/dsh-fs-local` 全文没有 BOM 处理,Node 的
8
- * `TextDecoder` 默认吞掉前导 BOM 字节)。本插件用**进程内 Node 实现**(`lib/core.mjs`)做同样
9
- * 的事,但把 BOM 与行尾保真、dry-run diff、自动备份、编辑台账、grep/lines 锚点一起带上。
6
+ * 原生 `write` / `edit`(由 `@deepseek-ai/dsh-fs-local` 实现)做不到的事,本插件用**进程内 Node 实现**
7
+ * (`lib/core.mjs`)补上,并把 `grep` / `lines` 锚点与宽松匹配一起带上:
8
+ * 1. **丢 UTF-8 BOM**:该实现没有 BOM 处理,Node 的 `TextDecoder` 默认吞掉前导 BOM 字节(`edit` 与
9
+ * `write` 都丢);
10
+ * 2. **不还原行尾**:`writeText` 按 `content` 里的行尾原样落盘,所以往 CRLF 文件里写 LF 内容就把它
11
+ * 变成 LF——本插件还原文件自身的风格;
12
+ * 3. **只做精确匹配**:`old_string` 差一个空格就报 `FS_EDIT_NOT_FOUND`,没有回退。
10
13
  *
11
- * 与原生工具的关系:注册的是两个**不与原生重名**的工具,原生 `edit`/`write` 原样保留,
12
- * 因此没有同名注册冲突,回退就是给 preset 行加 `disabled: true`。
14
+ * 与原生工具的关系:注册的是两个**不与原生重名**的工具,原生 `edit` / `write` 原样保留,因此没有同名注册
15
+ * 冲突,回退就是给 preset 行加 `disabled: true`。
13
16
  *
14
- * 依赖:**零**。只用 `node:` 内置模块 —— 不启动解释器或外部命令,不 import 任何包,没有构建
15
- * 步骤、没有第三方依赖、没有外部运行时。因此本文件既能被 preset 行用绝对路径加载,也能被 link
16
- * 进 profile 后按包名加载(preset 行的裸包名走宿主基址解析,模块内部的裸 import 走文件真实路径
17
- * 解析;零依赖让两种挂载方式都成立)。内嵌的 JSON Schema 是用 dsh 自己的
18
- * `parameterSchemaSpecToJsonSchema` / `valueSchemaSpecToJsonSchema` 生成的(生成脚本在仓库里,见
19
- * `tools/gen-schema.mjs`;它不随包发布)。
17
+ * 依赖:**零**。只用 `node:` 内置模块——不启动解释器或外部命令,不 import 任何包,没有构建步骤、没有第三方
18
+ * 依赖、没有外部运行时。因此本文件既能被 preset 行用绝对路径加载,也能被 link 进 profile 后按包名加载
19
+ * (preset 行的裸包名走宿主基址解析,模块内部的裸 import 走文件真实路径解析;零依赖让两种挂载方式都成立)。
20
+ * 内嵌的 JSON Schema 由 dsh 自己的 `parameterSchemaSpecToJsonSchema` / `valueSchemaSpecToJsonSchema`
21
+ * 生成(生成脚本见 `tools/gen-schema.mjs`,不随包发布)。
20
22
  *
21
23
  * 有意为之的取舍:
22
- * * 写盘**不经过** `ctx.fs`:绕过 fs 观察策略(先读后写 / 版本新鲜度)、沙箱与
23
- * `sandbox_permissions` 审批升权、原生原子写与 Windows DACL 保留。原生原子写与本插件的
24
- * 临时文件 + fsync + rename 等价,这段绕过的代价已在 README 写明。
25
- * * 模型可见文本受上限约束:路径仅出现一次,正文为一行统计加经 `diffBudget` 裁剪的 diff
26
- * (`brief` / `diff`);备份文件名等细节只保留在台账与 `stdout` 中。完整 diff 经
27
- * `presentationMeta` 投影为 UI 卡片,该路径不进入模型上下文。理由见 `lib/core.mjs` 头部。
24
+ * * 写盘**不经过** `ctx.fs`:绕过 fs 观察策略(先读后写 / 版本新鲜度)、沙箱与 `sandbox_permissions`
25
+ * 审批升权、原生原子写与 Windows DACL 保留。原生原子写与本插件的临时文件 + fsync + rename 等价,
26
+ * 代价已在 README 写明。正因为这条路径上没有第二个强制点,插件把会话文件策略里**唯一禁止写入的那一档**
27
+ * 镜像了回来:`read-only` 会话下在任何 I/O 之前拒写(见 `isReadOnly`)。
28
+ * * 模型可见文本只有一行统计(外加必要的警告):**既不回显改动内容,也不回显路径**——结果与调用一一绑定,
29
+ * `new_text` 与 `file_path` 都在调用方那一轮里。理由与实测数字见 `renderResult`。
30
+ * * **不写旁路记录**:没有备份、没有台账,改动只落在目标文件上。
28
31
  * * `lines` / `before <行号>` 是**盲锚点**:行号错了不会报错,会改在别的地方。
29
32
  * * 同目标串行只覆盖**本进程内**;跨进程(另一个 dsh 实例、你手边的编辑器)仍可能互相覆盖。
30
33
  *
31
34
  * 配置(本插件没有 Config schema,preset 行的 `config:` 字段原样透传):
32
- * backup: boolean 默认 true(落盘前备份到 artifactsDir/backups)
33
- * ledger: boolean 默认 true(追加 artifactsDir/edits.log)
34
- * artifactsDir: string 默认 <工作区>/.dsh
35
35
  * newFileBom: boolean 默认 false(新建文件是否写 BOM)
36
- * context: number 人读 diff 的上下文行数,默认 3(只影响 stdout 与 UI 卡片)
37
- * diff: 'auto'|'full'|'none' 模型可见 diff 正文的默认策略,默认 'auto'
38
- * maxDiffLines: number 模型可见 diff 正文的行数预算,默认 30
36
+ * guidance: 'full' | 'short' | false 默认 'full';短版去掉"优先于原生"那半句(原生已被
37
+ * `lib/mask.mjs` 屏蔽时才该用),false 则整段不注册
39
38
  * root: string 没有 agent 会话时的回退工作区
40
39
  * 新建文件的行尾推断可用环境变量 `DSH_TEXT_EDITOR_EOL` = lf | crlf 覆盖(见 lib/core.mjs)。
41
40
  */
42
41
 
43
- import { DIFF_MODES, UsageError, applyPlan, hunksFromDiff, toLf } from './core.mjs'
42
+ import { UsageError, applyPlan, toLf } from './core.mjs'
44
43
 
45
44
  export const name = 'tool-text-editor'
46
45
 
@@ -53,46 +52,45 @@ const TIMEOUT_MS = 60_000
53
52
  const EDIT_MODES = ['replace', 'after', 'before', 'append', 'prepend']
54
53
 
55
54
  /** 工具引导(order 116,落在工具引导区间 100–199)。文本里不能出现双花括号(会被当提示词变量)。 */
56
- const GUIDANCE =
57
- 'Prefer `edit_text` and `write_text` for text changes in this workspace: they preserve a UTF-8 BOM and '
58
- + 'the file existing CRLF/LF style, print a unified diff, back the previous content up, and accept `grep` '
59
- + 'or `lines` anchors so the old text never has to be copied by hand. The built-in `write` tool drops the '
60
- + 'BOM and rewrites a CRLF file as LF, and the built-in `edit` tool drops the BOM; use the built-ins '
61
- + 'only when a `_text` call reports that it cannot run.'
55
+ const GUIDANCE_FULL =
56
+ 'Prefer `edit_text`/`write_text` over the built-in `edit`/`write` for text changes: they keep the UTF-8 '
57
+ + 'BOM and the file CRLF/LF style. The built-ins drop the BOM, and `write` flattens CRLF to LF; use them '
58
+ + 'only when a `_text` call cannot run.'
59
+
60
+ /**
61
+ * `guidance: 'short'` 用的文本:原生 `edit`/`write` 已被 `lib/mask.mjs` 从这个 agent 的可见面去掉,
62
+ * "prefer … over the built-in" 这半句成了死重(模型看不到那两个工具),所以只留"用哪个、保什么"。
63
+ */
64
+ const GUIDANCE_SHORT =
65
+ 'Use `edit_text` for targeted changes and `write_text` to create or replace a file: both keep the UTF-8 '
66
+ + 'BOM and the file CRLF/LF style, and report one stat line.'
67
+
68
+ /** `guidance` 的合法取值。 */
69
+ const GUIDANCE_MODES = ['full', 'short', false]
62
70
 
63
71
  const EDIT_DESCRIPTION =
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, '
67
- + 'copied from `read`), `grep` (a regular expression whose matching line/block becomes the anchor), or '
68
- + '`lines` (e.g. "263:270"). `mode` defaults to `replace`; use `after`/`before` to insert beside a '
69
- + '`grep`/`lines` anchor, `append`/`prepend` for the file ends. Prefer `old_text`/`grep`: a wrong line '
70
- + 'number does not fail, it edits the wrong place. A literal that occurs more than once is refused unless '
71
- + '`nth` or `count` says which/how many. Set `dry_run` to preview without writing.'
72
+ 'Edit one existing text file. Preserves the BOM and line endings; returns one stat line '
73
+ + '(`replace@17 +1/-1`) instead of echoing the change. Give exactly ONE anchor: `old_text`, `grep` '
74
+ + '(regex) or `lines`. Prefer `old_text`/`grep`: a wrong line number edits the wrong place without '
75
+ + 'failing. A literal matching more than once is refused unless `count` declares how many you expect; a near '
76
+ + 'miss returns the closest candidates.'
72
77
 
73
78
  const WRITE_DESCRIPTION =
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: '
77
- + 'the tool detects whether the target exists, and a brand-new file follows the line-ending style of its '
78
- + 'sibling files. Set `dry_run` to preview without writing.'
79
+ 'Create or completely replace one text file. Preserves the BOM and line endings; returns one stat line '
80
+ + 'instead of echoing the content. A missing target is created, parent directories included, and empty '
81
+ + 'content creates a zero-byte file; a new file borrows its siblings\' line-ending style.'
79
82
 
80
83
  /** 参数 schema(等价于 defineTool 对 `tools/gen-schema.mjs` 里 DSL 的产物;那里会校验二者一致)。 */
81
84
  export const EDIT_PARAMETERS = {
82
85
  type: 'object',
83
86
  properties: {
84
- file_path: { type: 'string', description: 'Target file, resolved against the session working directory when relative.' },
87
+ file_path: { type: 'string', description: 'Target file; relative resolves against the session cwd.' },
85
88
  new_text: { type: 'string', description: 'Replacement / inserted text.' },
86
- old_text: { type: 'string', description: 'Literal anchor text to replace (exactly one anchor source).' },
87
- grep: { type: 'string', description: 'Regular-expression anchor: the matching line or line block is replaced.' },
88
- lines: { type: 'string', description: 'Line anchor, e.g. "263:270" or "120".' },
89
- mode: { type: 'string', description: 'Edit kind. Default replace.', enum: EDIT_MODES },
90
- count: { type: 'number', description: 'Require exactly N occurrences and replace all of them.' },
91
- nth: { type: 'number', description: 'Replace the k-th occurrence only (1-based).' },
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 },
94
- dry_run: { type: 'boolean', description: 'Print the diff without writing.' },
95
- note: { type: 'string', description: 'One-line reason recorded in the edit ledger.' },
89
+ old_text: { type: 'string', description: 'Literal anchor text (exactly one anchor source).' },
90
+ grep: { type: 'string', description: 'Regex anchor: the matching line block, its trailing newline included.' },
91
+ lines: { type: 'string', description: 'Line anchor, e.g. "263:270" or "120"; trailing newline included.' },
92
+ mode: { type: 'string', description: 'replace (default) substitutes the anchor; after/before insert beside a grep/lines anchor; append/prepend use the file ends.', enum: EDIT_MODES },
93
+ count: { type: 'number', description: 'Declare the expected number of hits (mismatch refuses to write); for `lines` anchors it declares how many lines the anchor covers.' },
96
94
  },
97
95
  required: ['file_path', 'new_text'],
98
96
  }
@@ -100,66 +98,46 @@ export const EDIT_PARAMETERS = {
100
98
  export const WRITE_PARAMETERS = {
101
99
  type: 'object',
102
100
  properties: {
103
- file_path: { type: 'string', description: 'Target file, resolved against the session working directory when relative.' },
101
+ file_path: { type: 'string', description: 'Target file; relative resolves against the session cwd.' },
104
102
  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 },
106
- dry_run: { type: 'boolean', description: 'Print the diff without writing.' },
107
- note: { type: 'string', description: 'One-line reason recorded in the edit ledger.' },
108
103
  },
109
104
  required: ['file_path', 'content'],
110
105
  }
111
106
 
112
- /** 两个工具共用的规范返回值。一切都在进程内完成,所以只有结果,没有退出码或后端标记。 */
107
+ /**
108
+ * 两个工具共用的规范返回值。一切都在进程内完成,所以只有结果,没有退出码或后端标记。
109
+ *
110
+ * 四个字段全部是**模型通道**的依据(`render` 只读它们),没有呈现层载荷。
111
+ */
113
112
  export const OUTPUT_SCHEMA = {
114
113
  type: 'object',
115
114
  additionalProperties: false,
116
115
  properties: {
117
116
  path: { type: 'string' },
118
117
  ok: { type: 'boolean' },
119
- wrote: { type: 'boolean' },
120
- dryRun: { type: 'boolean' },
121
- stdout: { type: 'string' },
122
- stderr: { type: 'string' },
123
118
  brief: { type: 'string' },
124
- diff: { type: 'string' },
119
+ stderr: { type: 'string' },
125
120
  },
126
- required: ['path', 'ok', 'wrote', 'dryRun', 'stdout', 'stderr', 'brief', 'diff'],
121
+ required: ['path', 'ok', 'brief', 'stderr'],
127
122
  }
128
123
 
129
124
  /**
130
- * 模型可见的结果文本:**一行统计、受上限约束的 diff 正文**。
125
+ * 模型可见的结果文本:成功是**一行统计**(`WROTE` 加 `brief`),失败是**完整原因**(`FAIL` 加 `stderr`)。
126
+ *
127
+ * 成功路径不回显改动内容:调用方刚发过 `new_text`,`replace@17 +1/-1` 已说明改在哪几行、改了多少,要看正文
128
+ * `read` 一次即可。失败路径相反——原因必须完整,它决定下一次调用。
131
129
  *
132
- * 成功路径只返回 `brief` 与 `diff`:路径仅出现一次,备份文件名只保留在台账与 `stdout` 中,
133
- * 完整 diff 由 UI 卡片承载。失败路径相反——原因必须完整,它决定调用方的下一次调用。
130
+ * 也不回显路径:结果与调用一一绑定(`tool/result` 带 `source.callId`),调用参数里的 `file_path` 就在同一轮
131
+ * 历史里,逐字回显它新信息量为零。实测(本机 79 个会话、86 条当前形状的结果):成功路径单条平均 116 B,
132
+ * 其中 `WROTE <路径>` 一行占 55.6 B;去掉路径后降到 66 B(−43%)。改动本身只在 GUI 的工具调用参数里可见。
134
133
  */
135
134
  function renderResult(_args, value) {
136
- const where = value.path === '' ? '' : ' ' + value.path
137
135
  if (!value.ok) {
138
- const detail = [value.stderr.trim(), value.stdout.trim()].filter((part) => part !== '').join('\n')
139
- return [{ type: 'text', text: 'FAIL' + where + '\n' + (detail === '' ? '(no output)' : detail) }]
136
+ const detail = value.stderr.trim()
137
+ return [{ type: 'text', text: 'FAIL\n' + (detail === '' ? '(no reason given)' : detail) }]
140
138
  }
141
- const head = value.dryRun ? 'DRY RUN' + where + ' (nothing written)' : 'WROTE' + where
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 }
139
+ const body = value.brief.trim()
140
+ return [{ type: 'text', text: body === '' ? 'WROTE' : 'WROTE\n' + body }]
163
141
  }
164
142
 
165
143
  function stringArg(value, label, { required = false } = {}) {
@@ -180,20 +158,6 @@ function integerArg(value, label, minimum) {
180
158
  return value
181
159
  }
182
160
 
183
- function booleanArg(value, label) {
184
- if (value === undefined) return undefined
185
- if (typeof value !== 'boolean') throw new UsageError(`${label} must be a boolean`)
186
- return value
187
- }
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
-
197
161
  /**
198
162
  * 校验并归一 `edit_text` 入参。
199
163
  * @throws {UsageError} 参数不合法(不会落盘)。
@@ -206,6 +170,9 @@ export function planEdit(args) {
206
170
  throw new UsageError('mode must be one of ' + EDIT_MODES.join(' / '))
207
171
  }
208
172
  const oldText = stringArg(args.old_text, 'old_text')
173
+ // 空锚点没有内容可寻址(`lines` / `grep` 才是点名行块的那两条路),而且它在匹配层会走成
174
+ // "零长命中",所以在这里就拒掉,别让一次参数错误变成一次全文扫描。
175
+ if (oldText === '') throw new UsageError('old_text must not be empty: use lines / grep to name a line block')
209
176
  const grep = stringArg(args.grep, 'grep')
210
177
  const lines = args.lines === undefined ? undefined : String(args.lines)
211
178
  const anchors = []
@@ -222,8 +189,6 @@ export function planEdit(args) {
222
189
  throw new UsageError(mode + ' takes no anchor, got ' + anchors.join(' + '))
223
190
  }
224
191
  const count = integerArg(args.count, 'count', 1)
225
- const nth = integerArg(args.nth, 'nth', 1)
226
- if (count !== undefined && nth !== undefined) throw new UsageError('count and nth are mutually exclusive')
227
192
  return {
228
193
  kind: 'edit',
229
194
  filePath,
@@ -232,11 +197,6 @@ export function planEdit(args) {
232
197
  oldText: oldText === undefined ? null : toLf(oldText),
233
198
  anchor: grep !== undefined ? { value: grep } : lines !== undefined ? { value: lines } : null,
234
199
  count,
235
- nth,
236
- strict: booleanArg(args.strict, 'strict') === true,
237
- dryRun: booleanArg(args.dry_run, 'dry_run') === true,
238
- diff: diffModeArg(args.diff),
239
- note: stringArg(args.note, 'note') ?? '',
240
200
  }
241
201
  }
242
202
 
@@ -248,9 +208,6 @@ export function planWrite(args) {
248
208
  kind: 'write',
249
209
  filePath,
250
210
  content: toLf(args.content),
251
- dryRun: booleanArg(args.dry_run, 'dry_run') === true,
252
- diff: diffModeArg(args.diff),
253
- note: stringArg(args.note, 'note') ?? '',
254
211
  }
255
212
  }
256
213
 
@@ -262,19 +219,52 @@ function workspaceRoot(exec, fallback) {
262
219
  return fallback
263
220
  }
264
221
 
265
- function fail(path, message) {
266
- return {
267
- path: typeof path === 'string' ? path : '',
268
- ok: false,
269
- wrote: false,
270
- dryRun: false,
271
- stdout: '',
272
- stderr: message,
273
- brief: '',
274
- diff: '',
222
+ // ─────────────────────────────────────────────────────────────────────────────
223
+ // 会话文件策略:read-only 时一并拒写
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+
226
+ /** read-only 会话里的拒写原因。说清"是策略,不是参数错",否则调用方会浪费一次重试。 */
227
+ const READ_ONLY_REASON = 'the session file policy is read-only, so writing is refused (the policy comes from the session settings, not from the path).'
228
+
229
+ /** 宿主返回值是外部输入,先窄化成"普通对象"再读字段。 */
230
+ function recordOf(value) {
231
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
232
+ }
233
+
234
+ /**
235
+ * 会话当前是否处于 `read-only` 文件策略。
236
+ *
237
+ * 本插件的写入**绕开 `ctx.fs`**(fs seam 的变更原语只有 `writeText` / `editText`,会丢 BOM、拍平 CRLF),
238
+ * 因此没有第二个强制点:沙箱、审批、`fs/observed` 都不在这条路径上。这里读宿主自己的 `sandboxPolicy`
239
+ * 归属方(`resolve({ session })`),把 read-only 这一档镜像回来——不新增任何提示词或 schema 字段,只在真的
240
+ * 拒一次时产生一行失败原因。
241
+ *
242
+ * 取舍:`sandboxPolicy` 是**可选**消费(`ctx.get`,不进 `inject`),服务缺席或解析失败一律按"不是只读"
243
+ * 处理——本工具不是安全边界,策略服务异常不该把写盘全禁掉。`workspace-write` 与 `danger-full-access` 仍走
244
+ * 既有的常量护栏(见 README 已知限制)。
245
+ *
246
+ * @param ctx - 插件上下文(用于 `ctx.get('sandboxPolicy')`)。
247
+ * @param exec - 工具执行上下文(提供会话)。
248
+ * @returns 是否只读。
249
+ */
250
+ function isReadOnly(ctx, exec) {
251
+ // 模拟 ctx(自测与量测脚本)没有 `get`:当成"没有策略事实"。
252
+ if (ctx === null || typeof ctx !== 'object' || typeof ctx.get !== 'function') return false
253
+ const policy = ctx.get('sandboxPolicy')
254
+ if (policy === null || typeof policy !== 'object' || typeof policy.resolve !== 'function') return false
255
+ try {
256
+ const session = exec && exec.agent ? exec.agent.session : undefined
257
+ const resolved = policy.resolve({ session })
258
+ return recordOf(resolved) !== null && resolved.mode === 'read-only'
259
+ } catch {
260
+ return false
275
261
  }
276
262
  }
277
263
 
264
+ function fail(path, message) {
265
+ return { path: typeof path === 'string' ? path : '', ok: false, brief: '', stderr: message }
266
+ }
267
+
278
268
  /**
279
269
  * 注册 `edit_text` / `write_text` 与工具引导段。
280
270
  * @param ctx - 插件上下文(注册随其 fiber 释放)。
@@ -283,22 +273,17 @@ function fail(path, message) {
283
273
  export function apply(ctx, config) {
284
274
  const settings = config === undefined || config === null ? {} : config
285
275
  const fallbackRoot = typeof settings.root === 'string' && settings.root !== '' ? settings.root : process.cwd()
286
- const artifactsDir = typeof settings.artifactsDir === 'string' && settings.artifactsDir !== ''
287
- ? settings.artifactsDir
288
- : undefined
289
- const planOptions = {
290
- backup: settings.backup !== false,
291
- log: settings.ledger !== false,
292
- context: Number.isFinite(settings.context) ? settings.context : undefined,
293
- newFileBom: settings.newFileBom === true,
294
- ...(Number.isFinite(settings.maxDiffLines) && settings.maxDiffLines >= 1
295
- ? { maxDiffLines: Math.floor(settings.maxDiffLines) }
296
- : {}),
276
+ const newFileBom = settings.newFileBom === true
277
+ const guidance = settings.guidance === undefined ? 'full' : settings.guidance
278
+ if (!GUIDANCE_MODES.includes(guidance)) {
279
+ throw new Error(`tool-text-editor: guidance must be one of full | short | false, got ${JSON.stringify(settings.guidance)}`)
297
280
  }
298
- /** 配置层的默认 diff 策略;逐调用的 `diff` 参数优先于它。 */
299
- const diffDefault = DIFF_MODES.includes(settings.diff) ? settings.diff : 'auto'
300
281
 
301
- ctx.systemPrompt.section({ name: 'tool:edit_text', order: 116, text: GUIDANCE })
282
+ // 引导段是可选的:屏蔽掉原生工具后(见 `lib/mask.mjs`),`guidance: 'short'` 省掉那半句已经作废的
283
+ // "prefer … over the built-in";`false` 则整段不注册(工具描述本身已经讲清用法)。
284
+ if (guidance !== false) {
285
+ ctx.systemPrompt.section({ name: 'tool:edit_text', order: 116, text: guidance === 'short' ? GUIDANCE_SHORT : GUIDANCE_FULL })
286
+ }
302
287
 
303
288
  /**
304
289
  * 组装上下文并执行。
@@ -306,22 +291,15 @@ export function apply(ctx, config) {
306
291
  * @param exec - 工具执行上下文(提供会话工作区)。
307
292
  */
308
293
  async function run(plan, exec) {
309
- const root = workspaceRoot(exec, fallbackRoot)
310
- return await applyPlan(plan, {
311
- root,
312
- ...(artifactsDir === undefined ? {} : { artifactsDir }),
313
- ...planOptions,
314
- diff: plan.diff ?? diffDefault,
315
- tool: plan.kind === 'write' ? WRITE_TOOL : EDIT_TOOL,
316
- })
294
+ if (isReadOnly(ctx, exec)) return fail(plan.filePath, READ_ONLY_REASON)
295
+ return await applyPlan(plan, { root: workspaceRoot(exec, fallbackRoot), newFileBom })
317
296
  }
318
297
 
319
298
  ctx.tools.register({
320
299
  name: EDIT_TOOL,
321
300
  description: EDIT_DESCRIPTION,
322
301
  parameters: EDIT_PARAMETERS,
323
- output: { schema: OUTPUT_SCHEMA, render: renderResult, presentationMeta },
324
- presentResult,
302
+ output: { schema: OUTPUT_SCHEMA, render: renderResult },
325
303
  timeoutMs: TIMEOUT_MS,
326
304
  async execute(args, exec) {
327
305
  let plan
@@ -339,8 +317,7 @@ export function apply(ctx, config) {
339
317
  name: WRITE_TOOL,
340
318
  description: WRITE_DESCRIPTION,
341
319
  parameters: WRITE_PARAMETERS,
342
- output: { schema: OUTPUT_SCHEMA, render: renderResult, presentationMeta },
343
- presentResult,
320
+ output: { schema: OUTPUT_SCHEMA, render: renderResult },
344
321
  timeoutMs: TIMEOUT_MS,
345
322
  async execute(args, exec) {
346
323
  let plan