@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/mask.mjs
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Flotiarenor
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* mask.mjs —— dsh 插件行:把宿主的原生 `write` / `edit` 从这个 agent 的可见面里去掉。
|
|
5
|
+
*
|
|
6
|
+
* 需要它的理由:`edit_text` / `write_text` 修好的正是原生那两个工具的三个缺陷(丢 UTF-8 BOM、把 CRLF 拍成
|
|
7
|
+
* LF、`old_string` 差一个空格就报 `FS_EDIT_NOT_FOUND`),但它们**仍然在工具表里**,于是每次请求都要付两份钱
|
|
8
|
+
* ——两个 schema(`write` 728 B + `edit` 1026 B = 1754 B,`node tools/measure-context.mjs --vs-native` 实测)、
|
|
9
|
+
* 两段引导(`tool:write` 220 B + `tool:edit` 388 B = 608 B),外加本插件自己那段劝模型别用它们的引导,
|
|
10
|
+
* 合计 2362 B/请求,全部只是为了让模型**不去**碰那两个工具。
|
|
11
|
+
*
|
|
12
|
+
* 做法是三个通道,都作用在**每个 agent 自己的作用域**上:
|
|
13
|
+
* * `tools.guard(...)` —— 调用被否决(`apply` 阶段就挂,见下);
|
|
14
|
+
* * `agent.ctx.tools.restrict({ deny })` —— 注册表只有一套可见性解析器(`view(scope).visible`),schema
|
|
15
|
+
* 下发、名称查找与调用派发读的是同一张视图,所以被收窄的名字既**不出现在工具表里**也**调不动**
|
|
16
|
+
* (直呼其名得到 `UNKNOWN_TOOL`)。不是"藏起来但还留着后门";
|
|
17
|
+
* * `agent.ctx.systemPrompt.section({ name: 'tool:write', text: '' })` —— 在更近的层注册同名**空段**,
|
|
18
|
+
* 遮蔽 `dsh-tool-fs` 注册的那两段引导(`ScopedLayers.merge` 近者胜,空段在渲染时被丢弃)。
|
|
19
|
+
*
|
|
20
|
+
* ## 为什么不能只在 `agent/created` 里做(本文件的历史坑)
|
|
21
|
+
*
|
|
22
|
+
* `restrict()` 只能从**带作用域的上下文**调用,而本行自己的上下文就是 preset 的常驻作用域——原生工具注册在
|
|
23
|
+
* **同一个层**里,"同一层注册的名字不算可收窄的全局名",所以从本行调用必然抛错,只有 `agent.ctx` 才行。
|
|
24
|
+
* 最早的实现因此把收窄挂在 `agent/created` 上,前提是"agent 一定是在 preset 挂好之后才创建的"。**这个前提在
|
|
25
|
+
* GUI 里不成立**:真实流程是"先按默认 preset 建 agent,再把用户选的 preset 重新挂上去",而
|
|
26
|
+
* `AgentPresets.recompose()` 做的是**父级 re-link,不是重建 agent**(`bindScopeParent` 的 rebind),
|
|
27
|
+
* `agent/created` 早就在旧组合下发完了,新挂上来的监听器永远收不到东西。实测:88 个会话日志的 128 条
|
|
28
|
+
* `request/header` 里,**25 条同时带着原生工具与本插件工具**(bug 现场),只有 3 条收窄了
|
|
29
|
+
* (`node tools/audit-session.mjs --tools`)。
|
|
30
|
+
*
|
|
31
|
+
* 所以现在有三条通路,任何一条先到就先收窄:
|
|
32
|
+
* 1. **`apply` 阶段就挂守卫**(挂在本行的作用域层上,与 agent 创建顺序无关):被守卫拦到时顺手用
|
|
33
|
+
* `exec.agent.ctx` 补一次收窄,让**下一次请求**的工具表就是干净的。守卫是"最迟防线",不是主路径;
|
|
34
|
+
* 2. **`agent/created`**:建档时就加入本 preset 的 agent(含 subagent)立即收窄。该事件由 **agent 注册表**
|
|
35
|
+
* (`ctx.agents`)发出:宿主没有注册表的自建组合里它根本不会出现,那时守卫是唯一的兜底;
|
|
36
|
+
* 3. **`tools/change`**:0.1.5-rc.2 的 `recompose()` 在重新绑定作用域之后会发它;此时枚举
|
|
37
|
+
* `ctx.agents.list()`,把**属于本组合**的 agent 收窄。这就是 GUI 换 preset 的那条路。
|
|
38
|
+
*
|
|
39
|
+
* ## 归属判据("这个 agent 是不是加入了我这个常驻组合")
|
|
40
|
+
*
|
|
41
|
+
* 不能靠 `dsh-scope` 的 `scopeOf` / `scopeChainOf`:本包"零依赖、只用 `node:` 内建"是
|
|
42
|
+
* `tools/check-license.mjs` 的硬断言,而那两个函数只有 `@deepseek-ai/dsh-scope` 导出。等价判据在
|
|
43
|
+
* `dsh-tools` 自己的作用域解析里:`tools.guardReason(exec)` 遍历 `chainLayers(exec.agent)`,也就是**这个
|
|
44
|
+
* agent 的作用域链上每一层**——本行的守卫只挂在本行的层上,所以"守卫被调用"当且仅当"本层在链上"。
|
|
45
|
+
*
|
|
46
|
+
* 探测的判据是**对象身份**,不是名字:只有本模块自己铸出来的 exec 对象才会得到哨兵回复。这一点是必须的——
|
|
47
|
+
* `guardReason()` 在 `prepareExecution` 里跑在"工具存不存在"之前,**看不见的名字一样会走到守卫**(未注册的
|
|
48
|
+
* 名字要到派发阶段才变成 `UNKNOWN_TOOL`),所以"起个没人用的名字"并不足以保证没人在真实调用里拿到哨兵。
|
|
49
|
+
* 真实派发每次都会铸一个新的 exec 对象,永远不可能等于探测对象。
|
|
50
|
+
*
|
|
51
|
+
* 判据是三态:`member` / `outsider` / `unknown`,**只有明确答复才是答复**:
|
|
52
|
+
* * 别人的守卫先答了(`guardReason` 先看全局层,再按作用域链从远到近取第一个非空答复)⇒ `unknown`;
|
|
53
|
+
* * 判据本身不可用(守卫没挂上、这个 dsh 没有 `guardReason`、查询抛错)⇒ `unknown`,并记一条 warn。
|
|
54
|
+
* 巡查只在 `outsider` 上撤销收窄:把"不知道"当成"不是我的人"会**静默放开**一个本来有效的屏蔽,比不收窄更糟。
|
|
55
|
+
* 代价是"换出去还原"这条也跟着降级,所以判据不可用时必须有日志。
|
|
56
|
+
*
|
|
57
|
+
* `guardReason` 不是 `dsh-tools` 公开表面的一部分(`dsh-tool-cordis` 列出的 ToolRuntime 表面是 register /
|
|
58
|
+
* restrict / guard / get / schemas / executionMode / execute / presentAs,没有它),它是实现方法、且被运行时
|
|
59
|
+
* 自己用(`prepareExecution`)。所以这里按"可能消失"对待:消失就降级成只靠事件收窄,并且**大声说出来**
|
|
60
|
+
* (warn),而不是悄悄少做一半。
|
|
61
|
+
*
|
|
62
|
+
* ## 离开组合也要还原
|
|
63
|
+
*
|
|
64
|
+
* agent 换到别的 preset 时,本行在它作用域上注册的东西(`restrict` / 空段 / 逃生口)必须一起撤销:否则那个
|
|
65
|
+
* agent 在新 preset 里既没有原生名字、也没有本插件的名字,等于**没有任何写工具**。所以每次巡查对"不再是本组合
|
|
66
|
+
* 成员"的 agent 调一次 `unmaskAgent()`——与 `dsh-tool-subagent` 的 `reconcileComposedAgents()`(成员
|
|
67
|
+
* install、非成员 remove)同形。
|
|
68
|
+
*
|
|
69
|
+
* 同一道理的另一半:这些注册挂在 **agent 的 fiber** 上,所以**本行自己被卸载**(HMR 重载、给 preset 行加
|
|
70
|
+
* `disabled: true`)时它们不会跟着走。本行因此还注册了卸载钩子,行卸载时把手上所有 agent 的注册一并撤销
|
|
71
|
+
* ——否则那一行撤了、屏蔽却留着,而已经没有任何实例能再把它撤掉。
|
|
72
|
+
*
|
|
73
|
+
* 只作用于**加入本行的 agent**:没有 preset 归属的 agent、以及其它 preset 的 agent 不受影响——这正好留出
|
|
74
|
+
* 对照组(换个 preset 新建会话,原生工具照旧可用)。
|
|
75
|
+
*
|
|
76
|
+
* 宿主平面安装(profile 层)是**另一档**:那里的上下文没有作用域,守卫会落到全局层,全局层的"收窄"会连
|
|
77
|
+
* 没有挂本插件的 preset 一起改。本行探测得出这件事(不带 agent 的探测只有全局层上的守卫会回答)并自动退化成
|
|
78
|
+
* "只管守卫、绝不收窄",同时记一条 warn——不需要任何配置键来表达这个意图。
|
|
79
|
+
*
|
|
80
|
+
* 有意为之的取舍:
|
|
81
|
+
* * **不是权限边界**。dsh 文档把 `restrict()` 定义为 live visibility composition:原生工具仍然注册在注册表
|
|
82
|
+
* 里(GUI 的插件/工具清单可能照旧列出它们),shell 命令也一样能写文件。它保证的是"模型眼里只剩字节
|
|
83
|
+
* 工具",不是"文件受保护";
|
|
84
|
+
* * 名字写在默认表里而不是硬编码在流程里:`tool-fs` 改名 / 拆包时改 `config` 即可。
|
|
85
|
+
*
|
|
86
|
+
* 配置(本插件没有 Config schema,preset 行的 `config:` 字段原样透传):
|
|
87
|
+
* deny: string[] 默认 ['write','edit'];只对**本 agent 真的看得见**的名字生效
|
|
88
|
+
* sections: string[] 默认 ['tool:write','tool:edit'];空段遮蔽的引导段名,[] 关闭
|
|
89
|
+
*
|
|
90
|
+
* `sections` 现在是**冗余的保险**:0.1.5-rc.2 起 `dsh-tool-fs` 把引导段写成
|
|
91
|
+
* `({ scope }) => ctx.tools.get('write', scope) === void 0 ? '' : '…'`,工具一旦被收窄,那段引导自己就不会
|
|
92
|
+
* 下发(实测:只挂 `read` 的组合,引导从 772 B 掉到 160 B)。保留它是因为别的写工具行未必这么写,而空段本身
|
|
93
|
+
* 不花 token。
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/** 默认要屏蔽的原生工具名,以及它们的引导段名。 */
|
|
97
|
+
const DEFAULT_DENY = ['write', 'edit']
|
|
98
|
+
const DEFAULT_SECTIONS = ['tool:write', 'tool:edit']
|
|
99
|
+
/** 遮蔽用的段顺序:与 `dsh-tool-fs` 注册时一致(按名字合并,顺序只影响可读性)。 */
|
|
100
|
+
const SECTION_ORDER = { 'tool:write': 101, 'tool:edit': 102 }
|
|
101
|
+
/** 守卫的拒绝原因:模型可见。**deny 档下它只在时序空隙里出现**(收窄还没落地那一刻),所以照样写清"改用哪个"。 */
|
|
102
|
+
const REFUSAL =
|
|
103
|
+
'拒绝:原生 edit/write 会丢 UTF-8 BOM,也不还原文件自身的行尾。改用 edit_text 做局部替换、'
|
|
104
|
+
+ 'write_text 新建或整篇覆盖。'
|
|
105
|
+
/**
|
|
106
|
+
* 归属探测用的假工具名与哨兵回复。
|
|
107
|
+
*
|
|
108
|
+
* 名字只是给人看的标签,**判据是对象身份**(见文件头):守卫只回答本模块铸出来的探测对象,
|
|
109
|
+
* 所以即便有人真的注册了一个同名工具,它的调用也拿不到哨兵。
|
|
110
|
+
*/
|
|
111
|
+
const PROBE_NAME = 'dsh-text-editor-mask-probe'
|
|
112
|
+
const PROBE_REPLY = 'dsh-text-editor-mask-probe:member'
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 只消费宿主服务。**必须声明**:cordis 只往声明了 `inject` 的上下文上提供这些服务,所以缺了它
|
|
116
|
+
* `ctx.tools` 就是 undefined——守卫在 `apply` 阶段就注册、收窄要读工具表,两者都依赖这个服务。
|
|
117
|
+
* `agents` 不在这里:它是**可选**的(没有 agent 注册表的组合里就没有 `agent/created`,那时守卫
|
|
118
|
+
* 那条路兜底),所以用 `ctx.get('agents')` 现取而不是声明依赖。
|
|
119
|
+
*/
|
|
120
|
+
export const inject = ['tools', 'systemPrompt']
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 注册"屏蔽原生 edit/write"的门禁。
|
|
124
|
+
* @param ctx - 插件上下文(preset 常驻作用域,宿主平面安装时是全局上下文)。
|
|
125
|
+
* @param config - preset 行配置(见文件头)。
|
|
126
|
+
*/
|
|
127
|
+
export function apply(ctx, config) {
|
|
128
|
+
const settings = config === undefined || config === null ? {} : config
|
|
129
|
+
const deny = Array.isArray(settings.deny) ? settings.deny.filter((name) => typeof name === 'string' && name !== '') : DEFAULT_DENY
|
|
130
|
+
const sections = Array.isArray(settings.sections)
|
|
131
|
+
? settings.sections.filter((name) => typeof name === 'string' && name !== '')
|
|
132
|
+
: DEFAULT_SECTIONS
|
|
133
|
+
|
|
134
|
+
/** 本行作用域上的服务对象:读工具表(`get(name, scope)`)与探测作用域链都走它,不依赖调用方。 */
|
|
135
|
+
const tools = ctx.tools
|
|
136
|
+
/** 守卫是否真的挂上了:没挂上时"归属判据"无从谈起,巡查只能按 `'unknown'` 处理。 */
|
|
137
|
+
let guardArmed = false
|
|
138
|
+
/**
|
|
139
|
+
* 每个 agent 的撤销句柄(`restrict` / 空段)。
|
|
140
|
+
*
|
|
141
|
+
* 用 Map 而不是 WeakMap:本行卸载时要能枚举出"手上还有谁",好把注册一并撤销(见文件头)。
|
|
142
|
+
* 强引用不会变成泄漏——agent 被销毁时 `agent/disposed` 会把条目摘掉,而 agent 自己的作用域
|
|
143
|
+
* 消亡本就会让这些注册一起消散。
|
|
144
|
+
*/
|
|
145
|
+
const installs = new Map()
|
|
146
|
+
/** 正在安装的 agent:注册动作同步发 `tools/change`,嵌套巡查会为同一个 agent 再进来(见 maskAgent)。 */
|
|
147
|
+
const installing = new WeakSet()
|
|
148
|
+
/** 只报一次的诊断(判据不可用时必须说话,但不能每次巡查都说)。 */
|
|
149
|
+
const reported = new Set()
|
|
150
|
+
/** 巡查的重入闸门:收窄会同步发 `tools/change`,不拦就会一层层嵌下去。 */
|
|
151
|
+
let sweeping = false
|
|
152
|
+
let sweepingAgain = false
|
|
153
|
+
|
|
154
|
+
/** 记一条警告:`ctx.logger` 在最小组合里可能不存在。 */
|
|
155
|
+
function warn(message) {
|
|
156
|
+
try {
|
|
157
|
+
ctx.logger?.warn?.(message)
|
|
158
|
+
} catch {
|
|
159
|
+
// 日志失败不该反过来影响门禁本身。
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 同一件事只报一次。 */
|
|
164
|
+
function warnOnce(key, message) {
|
|
165
|
+
if (reported.has(key)) return
|
|
166
|
+
reported.add(key)
|
|
167
|
+
warn(message)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 铸一个探测对象,并登记它的身份。
|
|
172
|
+
*
|
|
173
|
+
* 登记是必需的:`guardReason()` 会对**任何**名字调用守卫(看不见的名字也要到派发阶段才变成
|
|
174
|
+
* `UNKNOWN_TOOL`),所以判据只能是"这个 exec 是不是我铸的"。对象本身很轻,登记在 WeakSet 上,
|
|
175
|
+
* 用完即可回收。
|
|
176
|
+
* @param agent - 要问"本层在不在你的作用域链上"的 agent;省略则只问"守卫在不在全局层"。
|
|
177
|
+
* @returns 探测用的 exec 对象。
|
|
178
|
+
*/
|
|
179
|
+
const probes = new WeakSet()
|
|
180
|
+
function probeExec(agent) {
|
|
181
|
+
const exec = agent === undefined ? { name: PROBE_NAME } : { name: PROBE_NAME, agent }
|
|
182
|
+
probes.add(exec)
|
|
183
|
+
return exec
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 把一个 agent 的可见面收窄,并留下撤销句柄。
|
|
188
|
+
*
|
|
189
|
+
* 幂等判据是 `installs` 有没有这个 agent;**只有真的注册成功才记账**——失败(哪怕只是一次瞬时
|
|
190
|
+
* 失败)时留下空账会让它永久不被重试,而"屏蔽没做成"正是这一轮修掉的那个 bug 的形状。
|
|
191
|
+
*
|
|
192
|
+
* 全程 try/catch:这条路径可能跑在 `agent/created` 的同步派发里,也可能跑在工具调用的守卫阶段,
|
|
193
|
+
* 抛出只会把 agent 创建或那次调用带崩——所以失败时记日志,绝不让它外溢。
|
|
194
|
+
* @param agent - 已加入本组合的 agent(其 `ctx` 是它自己的作用域)。
|
|
195
|
+
*/
|
|
196
|
+
function maskAgent(agent) {
|
|
197
|
+
if (!narrowing || agent === null || typeof agent !== 'object' || installs.has(agent)) return
|
|
198
|
+
/**
|
|
199
|
+
* 必须再立一块"正在装"的牌子:注册动作本身会**同步**发 `tools/change`(`restrict()` 会 notify),
|
|
200
|
+
* 而这时账还没记上(要等全部注册成功才记,见下),嵌套巡查于是会为同一个 agent 再进来一次,
|
|
201
|
+
* 把同一套注册装两遍——第二遍的 `systemPrompt.section()` 会因为同名而抛。
|
|
202
|
+
* 形状与 `dsh-tool-subagent` 的 `installing` 集合一致。
|
|
203
|
+
*/
|
|
204
|
+
if (installing.has(agent)) return
|
|
205
|
+
installing.add(agent)
|
|
206
|
+
try {
|
|
207
|
+
const disposers = []
|
|
208
|
+
try {
|
|
209
|
+
// 收窄只能经 `agent.ctx`:同一层注册的名字不是"可收窄的全局名"(见文件头)。
|
|
210
|
+
const agentTools = agent.ctx.tools
|
|
211
|
+
// 只点名本 agent 真的看得见的工具:preset 没挂 tool-fs 时,restrict() 会因为"未知名字"抛错。
|
|
212
|
+
const present = deny.filter((name) => agentTools.get(name, agent) !== undefined)
|
|
213
|
+
if (present.length > 0) disposers.push(agentTools.restrict({ deny: present }))
|
|
214
|
+
} catch (error) {
|
|
215
|
+
warn(`tool-native-edit-mask: 收窄失败(agent ${agentId(agent)}):${errorText(error)}`)
|
|
216
|
+
}
|
|
217
|
+
for (const name of sections) {
|
|
218
|
+
try {
|
|
219
|
+
disposers.push(agent.ctx.systemPrompt.section({ name, order: SECTION_ORDER[name] ?? 199, text: '' }))
|
|
220
|
+
} catch (error) {
|
|
221
|
+
warn(`tool-native-edit-mask: 遮蔽 ${name} 失败(agent ${agentId(agent)}):${errorText(error)}`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// 什么都没注册上就不记账:下一次巡查会再试一遍(否则一次瞬时失败就是永久失败)。
|
|
225
|
+
if (disposers.length > 0) installs.set(agent, disposers)
|
|
226
|
+
} finally {
|
|
227
|
+
installing.delete(agent)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 撤销本行在一个 agent 作用域上注册的东西(`restrict` / 空段 / 逃生口)。
|
|
233
|
+
*
|
|
234
|
+
* 为什么必须做:收窄是注册在 **agent 自己的层**上的,它不随 preset 的更换自动消失。agent 换到别的
|
|
235
|
+
* preset 之后如果留着这条限制,新 preset 的原生工具会被旧 preset 的门禁继续挡掉,而本插件的工具又
|
|
236
|
+
* 已经随旧组合一起消失——那个会话就一个写工具都不剩了。
|
|
237
|
+
* @param agent - 巡查判定为"已离开本组合"的 agent。
|
|
238
|
+
*/
|
|
239
|
+
function unmaskAgent(agent) {
|
|
240
|
+
if (agent === null || typeof agent !== 'object') return
|
|
241
|
+
const disposers = installs.get(agent)
|
|
242
|
+
if (disposers === undefined) return
|
|
243
|
+
// 先摘账再撤销:撤销会发 `tools/change`,嵌套巡查必须看到一个已经处理完的状态。
|
|
244
|
+
installs.delete(agent)
|
|
245
|
+
for (const dispose of disposers) {
|
|
246
|
+
try {
|
|
247
|
+
dispose()
|
|
248
|
+
} catch (error) {
|
|
249
|
+
warn(`tool-native-edit-mask: 撤销失败:${errorText(error)}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 门禁谓词,也是归属探测器。
|
|
256
|
+
*
|
|
257
|
+
* 可见性判据必须在**调用发生时**按 `exec.agent` 现算:`tools.get` 是带作用域链的查询,而 `deny` 的
|
|
258
|
+
* 名字由组合里另一行注册,于是"本 agent 看不见但别处看得见"是常态——按注册时的某个 agent 算,就会把
|
|
259
|
+
* 兄弟 agent(或另一个 preset 的 agent)的原生工具一并拒掉(探针抓到过)。
|
|
260
|
+
* @param exec - 待执行的调用描述(至少含 `name` 与 `agent`)。
|
|
261
|
+
* @returns 拒绝原因,或 undefined 表示放行。
|
|
262
|
+
*/
|
|
263
|
+
function guard(exec) {
|
|
264
|
+
if (exec === null || typeof exec !== 'object') return undefined
|
|
265
|
+
if (probes.has(exec)) return PROBE_REPLY
|
|
266
|
+
if (!deny.includes(exec.name)) return undefined
|
|
267
|
+
const agent = exec.agent
|
|
268
|
+
if (agent === undefined || agent === null) return undefined
|
|
269
|
+
let visible = false
|
|
270
|
+
try {
|
|
271
|
+
visible = tools.get(exec.name, agent) !== undefined
|
|
272
|
+
} catch {
|
|
273
|
+
return undefined
|
|
274
|
+
}
|
|
275
|
+
if (!visible) return undefined
|
|
276
|
+
// 顺手收窄:守卫能拦到,说明本层就在这个 agent 的作用域链上(见下面 membership 的说明)。
|
|
277
|
+
// 于是下一次请求的工具表就是干净的,模型也不必先撞一次墙。
|
|
278
|
+
maskAgent(agent)
|
|
279
|
+
return REFUSAL
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 判据:这个 agent 是否加入了本行所在的常驻组合。
|
|
284
|
+
*
|
|
285
|
+
* 用探测对象调 `tools.guardReason()`:它会遍历 `chainLayers(exec.agent)`——**本行作用域层只在链上时才
|
|
286
|
+
* 会走到本行的守卫**,所以"拿到哨兵"就是"本层在链上"。这条判据只依赖 `dsh-tools`,不需要
|
|
287
|
+
* `@deepseek-ai/dsh-scope`(本包零依赖,见文件头)。
|
|
288
|
+
*
|
|
289
|
+
* 三态而不是布尔,两个理由:
|
|
290
|
+
* * `guardReason` 先看**全局层**、再按作用域链从远到近取第一个非空答复——别人的守卫完全可能先答
|
|
291
|
+
* (连"我是全局的、谁都拒"这种都有)。拿到"别人答的"不等于"不是我的人",所以是 `unknown`;
|
|
292
|
+
* * 判据本身不可用时同样必须是 `unknown`:把"不知道"当成"outsider"会让巡查**撤掉**一个本来有效的
|
|
293
|
+
* 收窄,那比不收窄更糟。
|
|
294
|
+
* @param agent - 候选 agent。
|
|
295
|
+
* @returns `'member'` / `'outsider'` / `'unknown'`。
|
|
296
|
+
*/
|
|
297
|
+
function membership(agent) {
|
|
298
|
+
if (!guardArmed || agent === null || typeof agent !== 'object') return 'unknown'
|
|
299
|
+
if (typeof tools.guardReason !== 'function') {
|
|
300
|
+
warnOnce(
|
|
301
|
+
'guardReason',
|
|
302
|
+
'tool-native-edit-mask: 这个 dsh 的 tools 服务没有 guardReason(),无法判断 agent 是否属于本组合:'
|
|
303
|
+
+ '只保留建档/守卫两条通路,换 preset 与换出去的还原不再生效(把 @deepseek-ai/dsh-tools 升到带它的版本即可)',
|
|
304
|
+
)
|
|
305
|
+
return 'unknown'
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const reply = tools.guardReason(probeExec(agent))
|
|
309
|
+
if (reply === PROBE_REPLY) return 'member'
|
|
310
|
+
return reply === undefined ? 'outsider' : 'unknown'
|
|
311
|
+
} catch {
|
|
312
|
+
return 'unknown'
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 巡查所有活 agent:属于本组合的收窄,离开本组合的还原,判据不可用的不动它。
|
|
318
|
+
*
|
|
319
|
+
* 触发点:`tools/change`(`recompose()` 重绑作用域之后会发;本行自己的收窄/撤销也会发)与
|
|
320
|
+
* `apply` 末尾各一次。**必须防重入**:收窄会同步发 `tools/change`,不拦就会一层层嵌下去
|
|
321
|
+
* (N 个 agent 同时加入就是 O(N²) 次作用域链遍历);这里用一个"跑完再跑一次"的闸门收口。
|
|
322
|
+
*/
|
|
323
|
+
function sweep() {
|
|
324
|
+
if (!narrowing) return
|
|
325
|
+
if (sweeping) {
|
|
326
|
+
sweepingAgain = true
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
// 注册表是**可选**的:宿主没有 agent 注册表(自建组合、最小 harness)时,它也不会发
|
|
330
|
+
// `agent/created`,那种组合里只有守卫那条路(见文件头)。`ctx.get` 也做存在性判断,
|
|
331
|
+
// 好让最小测试用的壳上下文不至于把挂载带崩。
|
|
332
|
+
if (typeof ctx.get !== 'function') return
|
|
333
|
+
const registry = ctx.get('agents')
|
|
334
|
+
if (registry === undefined || typeof registry.list !== 'function') return
|
|
335
|
+
sweeping = true
|
|
336
|
+
try {
|
|
337
|
+
for (const agent of registry.list()) {
|
|
338
|
+
// 逐个隔离:巡查跑在 `tools/change` 的同步派发里(也就是 `recompose()` 与 `restrict()` 里面),
|
|
339
|
+
// 让一个 agent 的失败冒出去会把那次组合变更一起带崩。失败只记一条日志,其余 agent 照常处理。
|
|
340
|
+
try {
|
|
341
|
+
const state = membership(agent)
|
|
342
|
+
if (state === 'member') maskAgent(agent)
|
|
343
|
+
else if (state === 'outsider') unmaskAgent(agent)
|
|
344
|
+
} catch (error) {
|
|
345
|
+
warn(`tool-native-edit-mask: 巡查 agent ${agentId(agent)} 失败:${errorText(error)}`)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch (error) {
|
|
349
|
+
warn(`tool-native-edit-mask: 枚举 agent 失败:${errorText(error)}`)
|
|
350
|
+
} finally {
|
|
351
|
+
sweeping = false
|
|
352
|
+
if (sweepingAgain) {
|
|
353
|
+
sweepingAgain = false
|
|
354
|
+
sweep()
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 本行到底该不该"收窄"。挂载方式决定答案,没有对应的配置键。
|
|
361
|
+
*
|
|
362
|
+
* 宿主平面安装(profile 层)时本行的上下文没有作用域,守卫会落到**全局层**上——那一层的收窄会连没有本
|
|
363
|
+
* 插件的 preset 一起改(把那些会话的写工具全拿走)。用无 agent 的探测问一句就知道了:只有全局层上的守卫
|
|
364
|
+
* 会回答(`guardReason` 先看全局层,且没有 agent 时到此为止)。命中即退化成"只管守卫、绝不收窄",并记一条
|
|
365
|
+
* warn——这是唯一安全的默认,因为宿主平面安装本来就没有"自己的组合"这个概念。
|
|
366
|
+
*
|
|
367
|
+
* 声明必须在挂守卫**之前**:`guard` 闭包会读它,而挂上守卫之后就可能有人同步调到本行的守卫
|
|
368
|
+
* (同一轮 `apply` 里注册工具的行并不罕见),那时读未初始化的 `let` 会抛 TDZ 错误。
|
|
369
|
+
*/
|
|
370
|
+
let narrowing = true
|
|
371
|
+
|
|
372
|
+
// 通路 1:`apply` 阶段就挂守卫。与 agent 创建顺序**无关**——这是"最迟防线":recompose 之后第一次
|
|
373
|
+
// 原生调用一定会被它拦下,并顺手把这个 agent 收窄。守卫挂在本行层上(只有本组合的 agent 走到它);
|
|
374
|
+
// 宿主平面安装时本行没有作用域,它落到全局层上,那一档在下面被识别出来后不做收窄。
|
|
375
|
+
// `guard()` 自身不 notify(`dsh-tools` 里就是这么定的),所以这里不会引发 `tools/change` 回环。
|
|
376
|
+
try {
|
|
377
|
+
ctx.tools.guard(guard)
|
|
378
|
+
guardArmed = true
|
|
379
|
+
} catch (error) {
|
|
380
|
+
warn(`tool-native-edit-mask: guard 注册失败:${errorText(error)}`)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (guardArmed) {
|
|
384
|
+
try {
|
|
385
|
+
if (tools.guardReason(probeExec(undefined)) === PROBE_REPLY) {
|
|
386
|
+
narrowing = false
|
|
387
|
+
warnOnce(
|
|
388
|
+
'unscoped',
|
|
389
|
+
'tool-native-edit-mask: 本行挂在宿主平面上(没有作用域),守卫落在全局层:只做否决、不做收窄。'
|
|
390
|
+
+ '想让本插件的两个工具也对所有 preset 可见,请把编辑行也装到宿主平面',
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
} catch {
|
|
394
|
+
// 探测不了就按默认走(按 agent 收窄)。
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 通路 2:建档时就加入本 preset 的 agent(含 subagent,它们同样父级到同一个常驻键)——
|
|
399
|
+
// 作用域路由保证注册在本行层上的监听器只收到**加入本组合**的 agent 的事件。
|
|
400
|
+
// 通路 3:换 preset。`recompose()` 重绑作用域之后会发 `tools/change`,此时 agent 的作用域链已经指向
|
|
401
|
+
// 本组合,巡查就能认出它、把它收窄;反过来,被移出本组合的 agent 也会在这一刻被还原。
|
|
402
|
+
if (narrowing) {
|
|
403
|
+
// 每个监听单独 try/catch:注册失败(壳上下文、别的实现的事件面)只该少一条通路,不该把整行挂载带崩,
|
|
404
|
+
// 守卫那条路仍然兜得住。
|
|
405
|
+
const listen = (event, handler) => {
|
|
406
|
+
try {
|
|
407
|
+
ctx.on(event, handler)
|
|
408
|
+
} catch (error) {
|
|
409
|
+
warn(`tool-native-edit-mask: 监听 ${event} 失败:${errorText(error)}(该通路失效,其余照常)`)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
listen('agent/created', ({ agent }) => maskAgent(agent))
|
|
413
|
+
listen('tools/change', sweep)
|
|
414
|
+
// agent 销毁时忘掉它:注册随它的作用域一起消散,账本留着只会是强引用。
|
|
415
|
+
listen('agent/disposed', ({ agent }) => installs.delete(agent))
|
|
416
|
+
// 挂载时就巡查一次:`apply` 之前就可能已经有 agent 绑到了这个常驻组合(`ensureStanding()` 复用挂载,
|
|
417
|
+
// 后加入的 agent 走 `agent/created`;remount 出来的新一代组合则要靠这一次巡查)。
|
|
418
|
+
sweep()
|
|
419
|
+
// 本行自己卸载(HMR 重载、行被 disabled)时,把手上所有 agent 的注册一并撤销:那些注册挂在
|
|
420
|
+
// **agent 的 fiber** 上,不随本行消失;留着就成了一层没有主人的屏蔽,之后再没人能撤掉它。
|
|
421
|
+
try {
|
|
422
|
+
ctx.effect(() => () => {
|
|
423
|
+
for (const agent of [...installs.keys()]) unmaskAgent(agent)
|
|
424
|
+
}, 'tool-native-edit-mask.unload()')
|
|
425
|
+
} catch (error) {
|
|
426
|
+
warn(`tool-native-edit-mask: 卸载钩子注册失败:${errorText(error)}`)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 把未知的抛出物渲染成一句日志。 */
|
|
432
|
+
function errorText(error) {
|
|
433
|
+
return error && error.message ? String(error.message) : String(error)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** agent 的 id:门禁的失败日志必须指明是**哪个** agent,否则一条 warn 只能让人猜。 */
|
|
437
|
+
function agentId(agent) {
|
|
438
|
+
const id = agent === null || typeof agent !== 'object' ? undefined : agent.id
|
|
439
|
+
return typeof id === 'string' && id !== '' ? id : '(unknown)'
|
|
440
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flotiarenor/dsh-tool-text-editor",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Byte-faithful text editing tools (edit_text / write_text) for DeepSeek Harness:
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Byte-faithful text editing tools (edit_text / write_text) for DeepSeek Harness. They fix what the built-in write/edit cannot do: both drop a UTF-8 BOM, neither restores the file's own line-ending style (writing LF content turns a CRLF file into an LF file), and edit matches old_string exactly with no fallback. These tools keep the BOM and the file's CRLF/LF style, fall back to relaxed line-block matching with nearest-candidate hints, accept grep/lines anchors, and return one stat line instead of echoing the change back into the model context. A call writes the target file and nothing else: no backup, no ledger, no diff projection. 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,15 +14,6 @@
|
|
|
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
|
-
},
|
|
26
17
|
"files": [
|
|
27
18
|
"lib",
|
|
28
19
|
"preset",
|
|
@@ -53,7 +44,7 @@
|
|
|
53
44
|
"url": "https://github.com/Flotiarenor"
|
|
54
45
|
},
|
|
55
46
|
"peerDependencies": {
|
|
56
|
-
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.6"
|
|
47
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 || >=0.1.5-rc.2"
|
|
57
48
|
},
|
|
58
49
|
"publishConfig": {
|
|
59
50
|
"access": "public",
|
|
@@ -66,5 +57,20 @@
|
|
|
66
57
|
"repository": {
|
|
67
58
|
"type": "git",
|
|
68
59
|
"url": "https://github.com/Flotiarenor/dsh-tool-text-editor.git"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"test": "node tools/check-license.mjs && node tools/selftest.mjs && node tools/measure-context.mjs --cap 2048",
|
|
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
|
+
"measure": "node tools/measure-context.mjs",
|
|
68
|
+
"measure:cap": "node tools/measure-context.mjs --cap 2048",
|
|
69
|
+
"measure:static": "node tools/measure-context.mjs --static --vs-native",
|
|
70
|
+
"probe:mask": "node tools/probe-mask.mjs",
|
|
71
|
+
"repro:mask": "node tools/repro-mask.mjs",
|
|
72
|
+
"audit": "node tools/audit-session.mjs",
|
|
73
|
+
"audit:tools": "node tools/audit-session.mjs --tools",
|
|
74
|
+
"install:preset": "node scripts/install-preset.mjs"
|
|
69
75
|
}
|
|
70
|
-
}
|
|
76
|
+
}
|
package/preset/preset.yml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: 2026 Flotiarenor
|
|
2
2
|
# SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
name: 字节保真编辑器(BOM/CRLF)
|
|
4
|
-
description:
|
|
4
|
+
description: dsh 自带 preset 的副本,额外挂载 edit_text / write_text 两个文本编辑工具:保 UTF-8 BOM 与文件自身 CRLF/LF 风格、宽松匹配(精确失败时按行块相似度回退)、支持 grep/lines 锚点;结果只回一行统计(不回显改动内容,也不回显路径),不在工作区留下任何旁路文件;read-only 会话下拒写(镜像宿主文件策略)。纯 Node 进程内实现(零依赖、不启动任何外部进程)
|