dsh-armory 2.4.3 → 2.4.4

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.
@@ -16,16 +16,112 @@
16
16
  // 重绑为**调用方 ctx** —— 所以用 `agent.ctx.systemPrompt.section(...)` 注册,段就落在
17
17
  // 该会话自己的 scope 层:只影响这个会话,且 ctx.effect 返回的 disposer 可随时撤销。
18
18
  //
19
- // 与「召唤预设」的分工:召唤写 profile patch,管**新会话的预设选择器**(长期身份);
19
+ // ── 为什么专家会「掉」(2026-10 抖音反馈)────────────────────────────────
20
+ // 上面那个 section 的生命周期是**这个 live agent 的 scope**,不是会话文件:
21
+ // - 会话切走/落盘后 agent 被卸载(dsh-session 明说「persisted-but-unloaded sources」),
22
+ // 再打开走 agents.resume() 重建 agent —— 旧 scope 连同 section 一起销毁;
23
+ // - 宿主重启同理,且本模块原来只有内存 Map,重启后连「挂过谁」都不知道;
24
+ // - 原先 reconcilePersonas 还把「不在 agents.list() 里」当成用户取消,把仅有的记录删掉。
25
+ // 于是 chip 消失、人设失效,只能重新勾选——这正是「每个会话中专家容易掉」的来源。
26
+ //
27
+ // WorkBuddy 的逻辑是「专家属于这个会话」:选择是持久的,会话回来时专家就回来。这里照做:
28
+ // 1. 选择(plugin/name/personaText)落盘到 ~/.dsh/wb-market/experts/session-persona.json;
29
+ // 2. 每个 agent/created(含 resume / clear / compaction)都把该会话的选择重新注入;
30
+ // 宿主在创建事务里 await 串行监听器,所以恢复后的**第一个模型请求**就带着人设;
31
+ // 3. reconcile 只清理失效的运行时 section,**绝不**因为会话暂时不在运行就删选择;
32
+ // 4. 只有用户点 ×(detach)才删除选择。
33
+ // 人设正文随选择一起存:重启/离线时不必再请求专家市场,恢复出来的行为与当时一致。
34
+ //
35
+ // 与「召唤预设」的分工不变:召唤写 profile patch,管**新会话的预设选择器**(长期身份);
20
36
  // 本模块管**当前会话的即时人设**(会话内挂/换/摘)。两者互不干扰:预设仍在,随时可用;
21
- // 会话内挂的专家只是这段提示词 section 的替换,不动工具目录与任何持久化配置。
37
+ // 会话内挂的专家只是这段提示词 section 的替换,不动工具目录与 profile 配置。新增的落盘
38
+ // 文件只记录「哪个会话挂了哪位专家」,是纯增值数据,损坏时静默重置。
39
+
40
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
41
+ import { homedir } from "node:os";
42
+ import { dirname, join } from "node:path";
22
43
 
23
44
  /** 与 dsh-persona / dsh-subagent 共用的 section 名(同名即遮蔽,最近 scope 胜出)。 */
24
45
  const PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
25
- /** 单个会话最多保留的撤销记录(防泄漏;正常使用远小于此)。 */
46
+ /** 最多记住多少个会话的选择(防泄漏;正常使用远小于此)。 */
26
47
  const MAX_SESSIONS = 64;
27
- /** sessionId -> { plugin, name, disposer } */
48
+ /** 运行中的注入记录:sessionId -> { plugin, name, disposer, agentCtx }。 */
28
49
  const activePersonas = new Map();
50
+ /** 持久化的用户选择:sessionId -> { plugin, name, personaText, savedAt }。 */
51
+ const stickyPersonas = new Map();
52
+ let stickyLoaded = false;
53
+
54
+ // ── 落盘 ────────────────────────────────────────────────────────────────
55
+ // 与 expert-market.js 的 cacheDir 同一根(同一套 DSH_ARMORY_MARKET_HOME 测试隔离口)。
56
+ // 不 import 那个模块:它带 yaml 等依赖,而本模块会被 test/session-persona.mjs 直接 import。
57
+ function storeFile() {
58
+ const root = process.env.DSH_ARMORY_MARKET_HOME || join(homedir(), ".dsh", "wb-market");
59
+ return join(root, "experts", "session-persona.json");
60
+ }
61
+ function ensureSticky() {
62
+ if (stickyLoaded) return;
63
+ stickyLoaded = true;
64
+ let raw = null;
65
+ try {
66
+ raw = JSON.parse(readFileSync(storeFile(), "utf8"));
67
+ } catch {
68
+ return; // 没有文件 / 损坏:纯增值数据,静默重置
69
+ }
70
+ const rows = Array.isArray(raw?.sessions) ? raw.sessions : [];
71
+ for (const row of rows) {
72
+ const sessionId = typeof row?.sessionId === "string" ? row.sessionId.trim() : "";
73
+ const personaText = typeof row?.personaText === "string" ? row.personaText.trim() : "";
74
+ if (!sessionId || !personaText) continue;
75
+ stickyPersonas.set(sessionId, {
76
+ plugin: String(row.plugin || ""),
77
+ name: String(row.name || row.plugin || "专家"),
78
+ personaText,
79
+ savedAt: Number(row.savedAt) || 0,
80
+ });
81
+ }
82
+ pruneSticky();
83
+ }
84
+ function persistSticky(ctx) {
85
+ try {
86
+ const target = storeFile();
87
+ mkdirSync(dirname(target), { recursive: true });
88
+ const payload = {
89
+ version: 1,
90
+ sessions: [...stickyPersonas.entries()].map(([sessionId, row]) => ({ sessionId, ...row })),
91
+ };
92
+ const tmp = `${target}.${process.pid}.${Date.now().toString(36)}.tmp`;
93
+ try {
94
+ writeFileSync(tmp, JSON.stringify(payload, null, 1), "utf8");
95
+ renameSync(tmp, target);
96
+ } catch (error) {
97
+ rmSync(tmp, { force: true });
98
+ throw error;
99
+ }
100
+ } catch (error) {
101
+ // 落盘失败不影响本次挂载:section 已经生效,只是重启后不会自动恢复。
102
+ ctx?.logger?.warn?.(`armory: 会话专家选择落盘失败(本次仍生效):${error?.message || error}`);
103
+ }
104
+ }
105
+ /** 超出上限时丢掉最久没用的选择(连同其运行时注入)。 */
106
+ function pruneSticky() {
107
+ if (stickyPersonas.size <= MAX_SESSIONS) return;
108
+ const oldest = [...stickyPersonas.entries()].sort((a, b) => a[1].savedAt - b[1].savedAt);
109
+ for (const [sessionId] of oldest.slice(0, stickyPersonas.size - MAX_SESSIONS)) {
110
+ stickyPersonas.delete(sessionId);
111
+ clearPersona(sessionId);
112
+ }
113
+ }
114
+
115
+ // ── 运行时注入 ───────────────────────────────────────────────────────────
116
+ // 插件 HMR 后模块级 Map 会重置,但 agent scope 里的 section 仍在——此时再注册同名 section
117
+ // 会被宿主拒绝(NamedEntries 同名即抛)。用一个跨模块实例共享的 WeakMap 记住「这个 agent
118
+ // 作用域注册过什么」,重装时先撤旧的再装新的,HMR 前后都能做到幂等。
119
+ const REGISTRATION_KEY = Symbol.for("dsh-armory.sessionPersona.registrations");
120
+ function registrationStore() {
121
+ const host = globalThis;
122
+ if (!(host[REGISTRATION_KEY] instanceof WeakMap)) host[REGISTRATION_KEY] = new WeakMap();
123
+ return host[REGISTRATION_KEY];
124
+ }
29
125
 
30
126
  function agentCtxOf(ctx, sessionId) {
31
127
  const agents = typeof ctx?.get === "function" ? ctx.get("agents") : undefined;
@@ -38,38 +134,82 @@ function agentCtxOf(ctx, sessionId) {
38
134
  }
39
135
 
40
136
  /**
41
- * 摘掉某会话当前注入的人设(若有)。
42
- * @returns {boolean} 是否真的摘掉了一段。
137
+ * 摘掉某会话当前注入的人设(若有)。只动运行时 section,不动持久化选择。
138
+ * @returns {{existed: boolean, disposed: boolean}} existed=有过运行时记录;
139
+ * disposed=真正执行了撤销句柄(adopted 记录没有句柄,只能等会话重建)。
43
140
  */
44
141
  function clearPersona(sessionId) {
45
142
  const current = activePersonas.get(sessionId);
46
- if (!current) return false;
143
+ if (!current) return { existed: false, disposed: false };
47
144
  activePersonas.delete(sessionId);
145
+ let disposed = false;
146
+ if (typeof current.disposer === "function") {
147
+ try {
148
+ current.disposer();
149
+ disposed = true;
150
+ } catch { /* 撤销失败不回滚状态:section 已随 scope 失效,重复撤销无害 */ }
151
+ }
152
+ if (current.agentCtx) registrationStore().delete(current.agentCtx);
153
+ return { existed: true, disposed };
154
+ }
155
+
156
+ function isDuplicateSection(error) {
157
+ return /already registered/i.test(String(error?.message || error));
158
+ }
159
+
160
+ /**
161
+ * 把一段人设注册进某个 agent scope。同名 section 已存在时视为「已在生效」(HMR 前一代
162
+ * 留下的注册无法撤销),返回 adopted 而不是把异常抛给用户。
163
+ */
164
+ function registerSection(agentCtx, text) {
165
+ const prompt = agentCtx.systemPrompt;
166
+ if (!prompt || typeof prompt.section !== "function") {
167
+ throw new Error("该会话没有 systemPrompt 服务,无法注入人设");
168
+ }
169
+ // 同一作用域里本模块先前留下的注册:先撤掉,保证「换专家」是替换而不是叠加。
170
+ try {
171
+ registrationStore().get(agentCtx)?.();
172
+ } catch { /* 已失效的 disposer 重复调用无害 */ }
173
+ registrationStore().delete(agentCtx);
48
174
  try {
49
- current.disposer?.();
50
- } catch { /* 撤销失败不回滚状态:section 已随 scope 失效,重复撤销无害 */ }
51
- return true;
175
+ // 与 dsh-persona 同款:同名 section + 同一处 order = 遮蔽该会话的部署人设前缀。
176
+ const disposer = prompt.section({
177
+ name: PERSONA_PREFIX_SECTION,
178
+ order: typeof prompt.getSectionOrder === "function"
179
+ ? prompt.getSectionOrder("DEPLOYMENT_PERSONA_PREFIX")
180
+ : 0,
181
+ text,
182
+ });
183
+ registrationStore().set(agentCtx, disposer);
184
+ return { disposer, adopted: false };
185
+ } catch (error) {
186
+ if (!isDuplicateSection(error)) throw error;
187
+ return { disposer: null, adopted: true };
188
+ }
52
189
  }
53
190
 
54
- /** 当前挂在某会话上的专家(供客户端 chip 显示真实状态)。 */
191
+ /** 当前挂在某会话上的专家(读的是用户选择;会话暂时不在运行也有值)。 */
55
192
  export function personaOf(sessionId) {
56
- const row = activePersonas.get(sessionId);
193
+ ensureSticky();
194
+ const row = stickyPersonas.get(String(sessionId || "").trim());
57
195
  return row ? { plugin: row.plugin, name: row.name } : null;
58
196
  }
59
197
 
60
- /** 所有会话的挂载快照(会话被销毁后由 reconcile 清理)。 */
198
+ /**
199
+ * 所有「挂了专家」的会话快照。
200
+ * 刻意以持久化选择为准而不是运行时 Map:会话被宿主卸载时 agent scope 已销毁,但用户
201
+ * 的选择还在,chip 不应闪没;恢复完成后 expertActive 仍是同一答案。
202
+ */
61
203
  export function listPersonas() {
62
- return [...activePersonas.entries()].map(([sessionId, row]) => ({ sessionId, plugin: row.plugin, name: row.name }));
63
- }
64
-
65
- /** 会话已不在运行 → 清掉它的撤销记录(disposer 早已随 scope 失效)。 */
66
- export function reconcilePersonas(ctx) {
67
- const agents = typeof ctx?.get === "function" ? ctx.get("agents") : undefined;
68
- if (!agents || typeof agents.get !== "function") return;
69
- const live = new Set(agents.list().map((agent) => agent.id));
70
- for (const sessionId of [...activePersonas.keys()]) {
71
- if (!live.has(sessionId)) activePersonas.delete(sessionId);
204
+ ensureSticky();
205
+ const rows = new Map([...stickyPersonas.entries()].map(([sessionId, row]) => [sessionId, { plugin: row.plugin, name: row.name }]));
206
+ // 运行时记录覆盖持久化选择:persist=false 的替身人设不进粘性表,但它才是会话里
207
+ // 真正生效的那一位;同会话后来挂了替身时,看板 / chip 不能还显示上一位专家。
208
+ // 会话卸载后 reconcile 会清掉运行时记录,这时才回落到持久化选择。
209
+ for (const [sessionId, row] of activePersonas) {
210
+ rows.set(sessionId, { plugin: row.plugin, name: row.name });
72
211
  }
212
+ return [...rows.entries()].map(([sessionId, row]) => ({ sessionId, ...row }));
73
213
  }
74
214
 
75
215
  /**
@@ -77,44 +217,158 @@ export function reconcilePersonas(ctx) {
77
217
  * @param {object} ctx - 插件宿主上下文。
78
218
  * @param {string} sessionId - 目标会话。
79
219
  * @param {object} spec - { plugin, name, personaText }。
220
+ * @param {{persist?: boolean}} [options] - persist=false 用于「按已存选择恢复」,不再写盘。
80
221
  * @returns {{ok: true, plugin: string, name: string, replaced: boolean}}
81
222
  */
82
- export function attachPersona(ctx, sessionId, spec) {
223
+ export function attachPersona(ctx, sessionId, spec, options = {}) {
83
224
  const id = String(sessionId || "").trim();
84
225
  if (!id) throw new Error("缺少会话 id");
85
226
  const text = String(spec?.personaText || "").trim();
86
227
  if (!text) throw new Error("人设内容为空,无法挂载");
228
+ ensureSticky();
87
229
  const { agentCtx, error } = agentCtxOf(ctx, id);
88
230
  if (error) throw new Error(error);
89
- const prompt = agentCtx.systemPrompt;
90
- if (!prompt || typeof prompt.section !== "function") {
91
- throw new Error("该会话没有 systemPrompt 服务,无法注入人设");
231
+ const replaced = clearPersona(id).existed;
232
+ const { disposer, adopted } = registerSection(agentCtx, text);
233
+ if (adopted) {
234
+ // 只可能来自上一版插件在同一 agent scope 留下的同名 section(那一代的 disposer 已丢):
235
+ // 人设仍然生效,但本次拿不到撤销句柄,点 × 要等会话重建后才彻底生效。
236
+ ctx?.logger?.warn?.("armory: 该会话已有同名专家人设 section,本次沿用(重启 DSH 后可正常取消)");
92
237
  }
93
- const replaced = clearPersona(id);
94
- // 与 dsh-persona 同款:同名 section + 同一处 order = 遮蔽该会话的部署人设前缀。
95
- const disposer = prompt.section({
96
- name: PERSONA_PREFIX_SECTION,
97
- order: typeof prompt.getSectionOrder === "function"
98
- ? prompt.getSectionOrder("DEPLOYMENT_PERSONA_PREFIX")
99
- : 0,
100
- text,
101
- });
102
- activePersonas.set(id, {
103
- plugin: String(spec?.plugin || ""),
104
- name: String(spec?.name || spec?.plugin || "专家"),
105
- disposer,
106
- });
107
- // 防泄漏:超出上限时丢掉最早的记录(其 disposer 一并撤销)。
108
- while (activePersonas.size > MAX_SESSIONS) {
109
- const oldest = activePersonas.keys().next().value;
110
- clearPersona(oldest);
238
+ const plugin = String(spec?.plugin || "");
239
+ const name = String(spec?.name || spec?.plugin || "专家");
240
+ activePersonas.set(id, { plugin, name, disposer, agentCtx });
241
+ if (options.persist !== false) {
242
+ stickyPersonas.set(id, { plugin, name, personaText: text, savedAt: Date.now() });
243
+ pruneSticky();
244
+ persistSticky(ctx);
111
245
  }
112
- return { ok: true, plugin: String(spec?.plugin || ""), name: String(spec?.name || spec?.plugin || "专家"), replaced };
246
+ // adopted:人设已在生效,但本次拿不到撤销句柄。不能把它当成一次完整成功——
247
+ // 客户端据此提示「取消需重启」,否则用户点 × 看到 removed=false 却不知道原因。
248
+ return { ok: true, applied: !adopted, needsRestart: adopted, plugin, name, replaced };
113
249
  }
114
250
 
115
- /** 摘掉某会话的专家人设。@returns {{ok: true, removed: boolean}} */
251
+ /**
252
+ * 摘掉某会话的专家人设与持久化选择(用户点 ×)。
253
+ * @returns {{ok: true, removed: boolean, needsRestart: boolean}}
254
+ * removed 只代表「运行时 section 真的撤销了或持久化选择被删了」;
255
+ * adopted 记录没有撤销句柄,此时 needsRestart=true,不能谎报已彻底取消。
256
+ */
116
257
  export function detachPersona(ctx, sessionId) {
117
258
  const id = String(sessionId || "").trim();
118
259
  if (!id) throw new Error("缺少会话 id");
119
- return { ok: true, removed: clearPersona(id) };
260
+ ensureSticky();
261
+ const runtime = clearPersona(id);
262
+ const removedSticky = stickyPersonas.delete(id);
263
+ if (removedSticky) persistSticky(ctx);
264
+ return {
265
+ ok: true,
266
+ removed: runtime.disposed || removedSticky,
267
+ needsRestart: runtime.existed && !runtime.disposed,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * 会话(重新)创建时按已存选择恢复人设。任何失败都只返回原因、不抛出——本函数挂在
273
+ * 宿主的串行 agent/created 监听器上,抛错会**否决会话创建**。
274
+ * @returns {{restored: boolean, plugin?: string, name?: string, reason?: string}}
275
+ */
276
+ export function restorePersonaForAgent(ctx, agent) {
277
+ const id = String(agent?.id || "").trim();
278
+ if (!id) return { restored: false, reason: "缺少会话 id" };
279
+ ensureSticky();
280
+ const record = stickyPersonas.get(id);
281
+ if (!record) return { restored: false, reason: "该会话没有专家选择" };
282
+ const agentCtx = agent.ctx;
283
+ if (!agentCtx) return { restored: false, reason: "该会话没有可用的作用域上下文" };
284
+ const current = activePersonas.get(id);
285
+ if (current && current.agentCtx === agentCtx) return { restored: false, reason: "人设已生效" };
286
+ try {
287
+ const result = attachPersona(ctx, id, {
288
+ plugin: record.plugin,
289
+ name: record.name,
290
+ personaText: record.personaText,
291
+ }, { persist: false });
292
+ return { restored: true, applied: result.applied, needsRestart: result.needsRestart, plugin: result.plugin, name: result.name };
293
+ } catch (error) {
294
+ // 记录保留:下一次 agent/created 或 reconcile 再试,绝不因为一次失败丢掉选择。
295
+ return { restored: false, reason: String(error?.message || error) };
296
+ }
297
+ }
298
+
299
+ /**
300
+ * 对齐运行时注入与宿主存活会话:
301
+ * - 失效(agent 已卸载/已被新 agent 取代)的运行时记录撤销,但**保留用户选择**;
302
+ * - 活着的 agent 若有选择却没有注入(切换会话恢复、插件热重载、上次恢复失败),立刻补上。
303
+ * 不抛错:它由只读 RPC expertActive 轮询调用,不能影响状态查询。
304
+ */
305
+ export function reconcilePersonas(ctx) {
306
+ const agents = typeof ctx?.get === "function" ? ctx.get("agents") : undefined;
307
+ if (!agents || typeof agents.get !== "function" || typeof agents.list !== "function") return;
308
+ ensureSticky();
309
+ let live = [];
310
+ try {
311
+ live = agents.list() || [];
312
+ } catch {
313
+ return;
314
+ }
315
+ for (const [sessionId, row] of [...activePersonas]) {
316
+ const agent = live.find((candidate) => candidate.id === sessionId);
317
+ if (agent && agent.ctx === row.agentCtx) continue;
318
+ clearPersona(sessionId);
319
+ }
320
+ for (const agent of live) {
321
+ if (!stickyPersonas.has(agent.id)) continue;
322
+ const current = activePersonas.get(agent.id);
323
+ if (current && current.agentCtx === agent.ctx) continue;
324
+ restorePersonaForAgent(ctx, agent);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * 安装会话专家运行时:订阅 agent/created(宿主串行 await,恢复后首轮即带人设),并对
330
+ * 安装时已经活着的会话做一次补装(插件热重载 / 运行中安装的场景)。
331
+ * @returns {() => void} 幂等清理函数。
332
+ */
333
+ export function installPersonaRuntime(ctx) {
334
+ if (typeof ctx?.on !== "function") return () => {};
335
+ ensureSticky();
336
+ let disposed = false;
337
+ let off;
338
+ const cleanup = () => {
339
+ if (disposed) return;
340
+ disposed = true;
341
+ try { off?.(); } catch { /* noop */ }
342
+ };
343
+ try {
344
+ off = ctx.on("agent/created", (payload) => {
345
+ // 边界:agent/created 是串行且抛错会否决创建的。人设恢复失败只能记账,不能挡会话。
346
+ try {
347
+ const agent = payload?.agent;
348
+ if (agent?.id) {
349
+ const result = restorePersonaForAgent(ctx, agent);
350
+ if (!result.restored && result.reason && result.reason !== "该会话没有专家选择" && result.reason !== "人设已生效") {
351
+ ctx.logger?.warn?.(`armory: 恢复会话专家失败(不影响会话创建):${result.reason}`);
352
+ }
353
+ }
354
+ } catch (error) {
355
+ ctx.logger?.warn?.(`armory: 恢复会话专家异常(已吞掉,不阻断会话创建):${error?.message || error}`);
356
+ }
357
+ });
358
+ if (typeof ctx.effect === "function") ctx.effect(() => cleanup, "armory.sessionPersona");
359
+ } catch (error) {
360
+ cleanup();
361
+ ctx.logger?.warn?.(`armory: 订阅 agent/created 失败,会话专家不会自动恢复:${error?.message || error}`);
362
+ return () => {};
363
+ }
364
+ // 安装前就活着的会话(热重载):逐个补装。
365
+ try {
366
+ const agents = typeof ctx.get === "function" ? ctx.get("agents") : undefined;
367
+ if (agents && typeof agents.list === "function") {
368
+ for (const agent of agents.list() || []) {
369
+ if (agent?.id && stickyPersonas.has(agent.id)) restorePersonaForAgent(ctx, agent);
370
+ }
371
+ }
372
+ } catch { /* 补装失败留给下一次 reconcile */ }
373
+ return cleanup;
120
374
  }
@@ -0,0 +1,178 @@
1
+ // 技能禁用数据层(工具裁缝技能账本「禁用 / 释放」的唯一落盘点)。
2
+ //
3
+ // 机制:写 SKILL.md frontmatter 的 `disable-model-invocation` 标志。这是宿主
4
+ // (@deepseek-ai/dsh-skill-filesystem)官方支持的「模型侧禁用」键:置 true 后该技能
5
+ // 不再进模型目录与加载器,Schema 与描述不再随每轮请求下发(上下文税归零);输入框
6
+ // 手动 /引用 不受影响。skills 根目录有监听器,改动会被自动拾取,无需重启宿主。
7
+ // 「释放」= 移除该标志,恢复模型可调用。禁用是可逆编辑,不是卸载——目录与正文分毫不动。
8
+ //
9
+ // 为什么不让客户端传路径直接改:与 lib/skill-remove.js 同一理由——RPC 入参是浏览器
10
+ // 可写的。这里只认「宿主注册表给这条 skill 的真实 source + path」,并把写入面钉死在
11
+ // 四类 skills 根目录里(用户级 ~/.agents/skills、~/.dsh/skills,项目级 <cwd>/.agents/
12
+ // skills、<cwd>/.dsh/skills)。bundled(随应用分发,升级会被覆盖回去)、runtime
13
+ // (不落盘)、custom(没有可信根目录做包含性校验)一律拒绝——白名单与 skill-remove 同集。
14
+ //
15
+ // 安全闸门(与 skill-remove 同序,任一不过即抛错,绝不「尽力而为」地写):
16
+ // 1. source 在白名单内,且能推导出绝对根目录(项目级必须有 cwd);
17
+ // 2. path 是绝对路径,resolve 后严格位于根目录内(≠ 根自身,`..` 穿越不过);
18
+ // 注册表的 path 通常指向 <skill>/SKILL.md,也可能指向目录或扁平 <name>.md,
19
+ // 归一到「要编辑的那个 .md 清单文件」再校验;
20
+ // 3. 清单文件的 realpath 必须仍在根目录 realpath 内(防符号链接把写入面带出根;
21
+ // 链接指回根内是允许的——写 realpath 而不是链接路径,用户的链接结构分毫不动)。
22
+ //
23
+ // frontmatter 手术只动一处(editInvocationFlag 纯函数,磁盘外可测):按与宿主解析器
24
+ // 相同的规则定位开/闭 `---` 行之间的块——
25
+ // 禁用:删掉块内全部 `disable-model-invocation` 行(重复键是脏数据,统一收敛),
26
+ // 在闭行前插入一行 `disable-model-invocation: true`;
27
+ // 释放:删掉块内全部该行(值为 false 的也删——恢复作者未写该键时的原貌)。
28
+ // 其余字节(键序、缩进、注释、正文)原样保留;行尾风格按文件现状保留(\n / \r\n)。
29
+ // 开/闭行缺失(没有合法 frontmatter)一律拒绝——那不是本层该修的文件。
30
+ //
31
+ // 纯数据层:零网络、零宿主服务依赖;home / cwd 可注入,测试用 mkdtemp 隔离。
32
+ import { readFile, realpath, stat } from 'node:fs/promises';
33
+ import { isAbsolute, join, relative, resolve } from 'node:path';
34
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
35
+ import { skillRootFor } from './skill-remove.js';
36
+
37
+ /** frontmatter 里的模型侧禁用键:宿主 dsh-skill-filesystem 的 canonical 拼写。 */
38
+ export const DISABLED_INVOCATION_KEY = 'disable-model-invocation';
39
+
40
+ const FLAG_LINE_RE = /^disable-model-invocation[ \t]*:/;
41
+
42
+ function describeSource(source) {
43
+ return source ? `「${source}」` : '未知来源';
44
+ }
45
+
46
+ /** 严格包含:child 在 parent 之内且不等于 parent。 */
47
+ function isInside(parent, child) {
48
+ const rel = relative(parent, child);
49
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
50
+ }
51
+
52
+ /** 包含或相等:符号链接项只做包含校验,允许恰好落在根上。 */
53
+ function isInsideOrEqual(parent, child) {
54
+ const rel = relative(parent, child);
55
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
56
+ }
57
+
58
+ /**
59
+ * 在 SKILL.md 原文里写入 / 移除 disable-model-invocation 标志(纯字符串手术)。
60
+ *
61
+ * 开/闭行判定与宿主 parseFrontmatter 同规则:首行去掉 \r 后必须恰为 `---`,闭行
62
+ * 同为一整行 `---`;找不到任一者视为「没有合法 frontmatter」返回 null(调用方拒绝)。
63
+ *
64
+ * @param {string} text SKILL.md 原文
65
+ * @param {boolean} disabled true = 置标志,false = 移除标志
66
+ * @returns {string | null} 改写后的全文;无合法 frontmatter 时 null
67
+ */
68
+ export function editInvocationFlag(text, disabled) {
69
+ if (typeof text !== 'string' || text.length === 0) return null;
70
+ const firstBreak = text.indexOf('\n');
71
+ if (firstBreak < 0) return null;
72
+ if (text.slice(0, firstBreak).replace(/\r$/, '') !== '---') return null;
73
+ // 闭行检索与宿主 findClosingFrontmatter 同步:逐行找去掉 \r 后恰为 `---` 的一行。
74
+ let lineStart = firstBreak + 1;
75
+ let closeStart = -1;
76
+ while (lineStart <= text.length) {
77
+ const nextBreak = text.indexOf('\n', lineStart);
78
+ const lineEnd = nextBreak < 0 ? text.length : nextBreak;
79
+ if (text.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
80
+ closeStart = lineStart;
81
+ break;
82
+ }
83
+ if (nextBreak < 0) return null;
84
+ lineStart = nextBreak + 1;
85
+ }
86
+ if (closeStart < 0) return null;
87
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
88
+ const block = text.slice(firstBreak + 1, closeStart);
89
+ // 块恒以换行收口(closeStart 是闭行行首),split 的尾随空串是切片伪影,弹掉;
90
+ // 中间的空行是真实内容,必须保留。
91
+ const lines = block.split('\n');
92
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
93
+ const kept = lines.map((line) => line.replace(/\r$/, '')).filter((line) => !FLAG_LINE_RE.test(line));
94
+ if (disabled) kept.push(DISABLED_INVOCATION_KEY + ': true');
95
+ // 闭行行首之前是上一行的换行符,所以块内每行(含新插入行)都要以行尾收口;
96
+ // 空块释放后保持空串——开闭行紧邻的空 frontmatter 依然合法。
97
+ const nextBlock = kept.length ? kept.join(eol) + eol : '';
98
+ return text.slice(0, firstBreak + 1) + nextBlock + text.slice(closeStart);
99
+ }
100
+
101
+ /**
102
+ * 把一条宿主注册表 skill 的「模型可调用」开关落到磁盘。
103
+ *
104
+ * @param {{ name?: string, source?: string, path?: string }} skill 宿主注册表返回的条目
105
+ * @param {{ disabled: boolean, cwd?: string, home?: string }} options
106
+ * @returns {Promise<{ path: string, disabled: boolean, changed: boolean }>}
107
+ * path 是实际写入的清单文件绝对路径;changed=false 表示文件本就处于目标状态
108
+ * (幂等重放),未写盘。
109
+ */
110
+ export async function setSkillModelInvocationOnDisk(skill, options = {}) {
111
+ const disabled = options.disabled === true;
112
+ const source = typeof skill?.source === 'string' ? skill.source : '';
113
+ const root = skillRootFor(source, options);
114
+ if (!root) {
115
+ if (source === 'project-agents' || source === 'project-dsh') {
116
+ throw new Error(`禁用项目级 skill 需要当前项目路径(来源:${describeSource(source)})`);
117
+ }
118
+ throw new Error(
119
+ `这个 skill 来自${describeSource(source)},不在可禁用范围内(只支持用户级/项目级的 .dsh/skills 与 .agents/skills)`,
120
+ );
121
+ }
122
+ if (!isAbsolute(root)) throw new Error('拒绝修改:无法确定 skills 根目录的绝对路径');
123
+ const raw = typeof skill?.path === 'string' ? skill.path.trim() : '';
124
+ if (!raw) throw new Error('这个 skill 没有可修改的磁盘路径(运行时注册的 skill 不落盘)');
125
+ if (!isAbsolute(raw)) throw new Error('拒绝修改:skill 路径不是绝对路径');
126
+
127
+ const rootAbs = resolve(root);
128
+ const rawAbs = resolve(raw);
129
+ if (!isInside(rootAbs, rawAbs)) {
130
+ throw new Error(`拒绝修改:skill 路径不在预期的 skills 根目录内(${rootAbs})`);
131
+ }
132
+
133
+ // stat 跟随符号链接:整个 skill 目录是链接(常见于把仓库目录链进 skills 根)时
134
+ // 也能正确识别成目录,最终由 realpath 闸门判定链接落点是否可信。
135
+ let rawInfo;
136
+ try {
137
+ rawInfo = await stat(rawAbs);
138
+ } catch (error) {
139
+ if (error?.code === 'ENOENT') throw new Error('这个 skill 已经不在磁盘上了(可能刚被移动或删除)');
140
+ throw error;
141
+ }
142
+ let manifestPath;
143
+ if (rawInfo.isDirectory()) {
144
+ manifestPath = join(rawAbs, 'SKILL.md');
145
+ } else if (rawInfo.isFile() && rawAbs.toLowerCase().endsWith('.md')) {
146
+ manifestPath = rawAbs; // 扁平 <name>.md 形态的 skill,清单就是文件本身
147
+ } else {
148
+ throw new Error('拒绝修改:skill 路径不是 skill 目录,也不是 .md 清单文件');
149
+ }
150
+ const manifest = await stat(manifestPath).catch(() => null);
151
+ if (!manifest || !manifest.isFile()) {
152
+ throw new Error('拒绝修改:目录里没有 SKILL.md,看起来不是 skill 目录');
153
+ }
154
+
155
+ // realpath 校验:防「根内路径 → 符号链接实际指向根外」。根目录本身是符号链接
156
+ // (例如 ~/.agents → 别处)是允许的,所以比的是 realpath 后的根。
157
+ const rootReal = await realpath(rootAbs);
158
+ const fileReal = await realpath(manifestPath);
159
+ if (!isInsideOrEqual(rootReal, fileReal)) {
160
+ throw new Error('拒绝修改:skill 路径经符号链接指向了 skills 根目录之外');
161
+ }
162
+
163
+ // BOM 会同时挡住宿主解析器(首行不再是 ---)与本层手术,明确拒绝而不是绕过。
164
+ const original = await readFile(fileReal, 'utf8');
165
+ if (original.charCodeAt(0) === 0xfeff) {
166
+ throw new Error('拒绝修改:SKILL.md 带 BOM 头,宿主解析器也无法读取它');
167
+ }
168
+ const next = editInvocationFlag(original, disabled);
169
+ if (next === null) {
170
+ throw new Error('拒绝修改:SKILL.md 没有合法的 YAML frontmatter(开/闭 --- 行缺失),本层不做修补');
171
+ }
172
+ if (next === original) {
173
+ return { path: fileReal, disabled, changed: false };
174
+ }
175
+ // 写 realpath:rename 永远落在真实文件上,绝不把用户的符号链接替换成普通文件。
176
+ await writeFileAtomic(fileReal, next, { mode: manifest.mode });
177
+ return { path: fileReal, disabled, changed: true };
178
+ }