@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/README.md +191 -117
- package/README.zh.md +67 -137
- package/lib/core.mjs +341 -375
- package/lib/editor.mjs +131 -154
- package/lib/mask.mjs +440 -0
- package/package.json +19 -13
- package/preset/preset.yml +1 -1
- package/scripts/install-preset.mjs +127 -74
package/lib/editor.mjs
CHANGED
|
@@ -3,44 +3,43 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* editor.mjs —— dsh 模型工具插件:`edit_text` / `write_text`。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `TextDecoder` 默认吞掉前导 BOM
|
|
9
|
-
*
|
|
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
|
|
12
|
-
*
|
|
14
|
+
* 与原生工具的关系:注册的是两个**不与原生重名**的工具,原生 `edit` / `write` 原样保留,因此没有同名注册
|
|
15
|
+
* 冲突,回退就是给 preset 行加 `disabled: true`。
|
|
13
16
|
*
|
|
14
|
-
* 依赖:**零**。只用 `node:`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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 {
|
|
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
|
|
57
|
-
'Prefer `edit_text`
|
|
58
|
-
+ 'the file
|
|
59
|
-
+ '
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
65
|
-
+ '
|
|
66
|
-
+ '
|
|
67
|
-
+ '
|
|
68
|
-
+ '
|
|
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
|
|
75
|
-
+ 'the
|
|
76
|
-
+ '
|
|
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
|
|
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
|
|
87
|
-
grep: { type: 'string', description: '
|
|
88
|
-
lines: { type: 'string', description: 'Line anchor, e.g. "263:270" or "120".' },
|
|
89
|
-
mode: { type: 'string', description: '
|
|
90
|
-
count: { type: 'number', description: '
|
|
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
|
|
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
|
-
|
|
119
|
+
stderr: { type: 'string' },
|
|
125
120
|
},
|
|
126
|
-
required: ['path', 'ok', '
|
|
121
|
+
required: ['path', 'ok', 'brief', 'stderr'],
|
|
127
122
|
}
|
|
128
123
|
|
|
129
124
|
/**
|
|
130
|
-
*
|
|
125
|
+
* 模型可见的结果文本:成功是**一行统计**(`WROTE` 加 `brief`),失败是**完整原因**(`FAIL` 加 `stderr`)。
|
|
126
|
+
*
|
|
127
|
+
* 成功路径不回显改动内容:调用方刚发过 `new_text`,`replace@17 +1/-1` 已说明改在哪几行、改了多少,要看正文
|
|
128
|
+
* `read` 一次即可。失败路径相反——原因必须完整,它决定下一次调用。
|
|
131
129
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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 =
|
|
139
|
-
return [{ type: 'text', text: 'FAIL
|
|
136
|
+
const detail = value.stderr.trim()
|
|
137
|
+
return [{ type: 'text', text: 'FAIL\n' + (detail === '' ? '(no reason given)' : detail) }]
|
|
140
138
|
}
|
|
141
|
-
const
|
|
142
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
343
|
-
presentResult,
|
|
320
|
+
output: { schema: OUTPUT_SCHEMA, render: renderResult },
|
|
344
321
|
timeoutMs: TIMEOUT_MS,
|
|
345
322
|
async execute(args, exec) {
|
|
346
323
|
let plan
|