@morlay/dsh-session-mode 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @morlay/dsh-session-mode
2
2
 
3
- **模式 = agent preset 的会话级扩展**:每个模式给这个会话加六样东西——一段 persona、一组工具名单(允许哪些、
4
- 另外禁掉哪些)、一份 policy 名单(哪些上游裁决规则生效)、instruction / 技能目录 / 动态快照三个开关,外加可选的
5
- 默认模型。模式**不绑 preset**:行清单(工具 / 命令 / 压缩 / 委派)由会话挂着
3
+ **模式 = agent preset 的会话级扩展**:每个模式给这个会话加六样东西——一段 persona、两组按名收窄的名单(工具与技能
4
+ 各自允许哪些、另外禁掉哪些)、一份 policy 名单(哪些上游裁决规则生效)、instruction / 技能目录 / 动态快照三个开关,
5
+ 外加可选的默认模型。模式**不绑 preset**:行清单(工具 / 命令 / 压缩 / 委派)由会话挂着
6
6
  的那份 preset 提供,本部署用官方 shipped `standard`(registry 的默认由官方 web-app 给,我们不覆盖);
7
7
  `config.modes.<id>.preset` 是可选声明,写了才会在切模式时把 preset 切过去。模式清单与默认值就是本行的 `config.modes`:真源在
8
8
  [`src/mode-sources.ts`](./src/mode-sources.ts),行 config 由 [`src/rows.ts`](./src/rows.ts) 渲染;**模式不是 Cordis
@@ -10,7 +10,8 @@
10
10
  与 `GET/POST /session-mode`,且只在**空白会话**成立。
11
11
 
12
12
  **按会话收口也在这个包里**([`src/scope.ts`](./src/scope.ts),模式行内部持有、不发布服务):工具目录与
13
- `tool:<工具名>` section 按同一份合成结果过滤、执行层 guard 挂在该 agent 的 ctx 上、三个开关
13
+ `tool:<工具名>` section 按同一份合成结果过滤、执行层 guard 挂在该 agent 的 ctx 上(同一份 guard 也按名拒 `skill`
14
+ 工具要加载的那一件技能)、技能目录按技能名单删掉被拒的那几行、三个开关
14
15
  (`instructions` / `skills` / `runtimeContext`)按会话生效——其中 `instructions: false` 与 `skills: false` 是在
15
16
  `agent/pre-step` 上**丢掉官方两条注入面**(工作区指令、技能目录)。取舍见
16
17
  [设计 抑制官方注入面](./.agents/designs/20260929-抑制官方注入面.md)。
@@ -42,7 +43,9 @@
42
43
  persona:
43
44
  prefix: 你是一个助手。……
44
45
  allowTools: [ask_user_question, web_search, web_fetch] # 留空 = 不设收窄(用 preset 的全部工具)
45
- denyTools: [] # 黑名单:从上面那份里减掉;两份同时命中时以这里为准(deny 优先)
46
+ denyTools: [load_workspace_dependencies] # 黑名单:从上面那份里减掉;两份同时命中时以这里为准(deny 优先)
47
+ allowSkills: [] # 技能白名单:留空 = 不设收窄(技能注册表里有什么就用什么)
48
+ denySkills: [office-docx, office-pptx, office-xlsx] # 技能黑名单:不进技能目录,`skill` 工具加载它也被拒
46
49
  instructions: false # 丢掉官方工作区指令的注入,并关掉通道自己的降级注入
47
50
  skills: false # 显式丢掉官方技能目录的注入;**本部署不写这一项**——推导已给出同一个值(见下)
48
51
  runtimeContext: false # 动态快照(沙箱策略、审批策略)
@@ -54,6 +57,18 @@
54
57
  就要技能目录(`allowTools` 留空即"全部",所以只有 `denyTools` 能把它推成 `false`)。本部署两个模式都不写它:
55
58
  `coding` 留空名单 → `true`,`chat` 的三件里没有 `skill` → `false`。要"工具收窄但目录照旧列"就显式写 `skills: true`。
56
59
 
60
+ 技能名单(`allowSkills` / `denySkills`)收的是**技能名**,与工具名单同一套合成规则(`allowSkills` 留空 = 不设收窄,
61
+ 两份同时命中时 deny 优先),收窄落在两处:`skill-catalog` 那条消息的**正文**(删掉 ``- `<名字>`: <说明>`` 那几行;
62
+ `source.entries` 是官方发布的记录、也是官方判"目录变没变"的输入,**不动它**)与 `skill` 工具的**调用参数**(拒的是
63
+ 它这次要加载的那一件,工具本身的可见性仍归 `allowTools`)。**不碰注册表**:技能照旧注册着,收的是"模型看到什么、
64
+ 能加载什么"。本部署用它收官方 Office 面——那三件技能由桌面宿主代码 `ctx.plugin` 挂载(不是 entry 行,profile 层
65
+ 停不掉),于是 `coding` 按名排除它们,`load_workspace_dependencies` 那件工具同样进 `denyTools`。
66
+
67
+ 代价与失效方式:正文删行认的是官方渲染形状(``- `<名字>`:`` 行首)。上游改了这个形状时那**一条目录消息整条不动**
68
+ (宁可多列一个名字,也不删错行),并打一条点名告警——那是这条名单面唯一的失灵信号。技能名是模型可见契约,跟着
69
+ 上游改名走。日志与页面读到的是官方那份**完整**结构化名单,收窄只在模型可见的正文上——这是刻意的:官方按那份名单
70
+ 的 digest 判目录要不要重发,改了它会每一步都重发一条目录。
71
+
57
72
  写错在装载时就拒绝:`default` 必须在清单里且声明 `main`、`role` 不能是空数组、`defaultModel` 要给全 `provider` 与
58
73
  `model`、`allowPolicies` / `denyPolicies` 里的名字必须是上游那两条 waterfall(`fs/write-intent` /
59
74
  `fs/edit-intent`)。改动等 Loader 重挂这一行生效,已运行会话不自动换定义。
package/dist/client.cjs CHANGED
@@ -16762,14 +16762,14 @@ window.__ModuleLoader__.load({
16762
16762
  document.head.appendChild(tag);
16763
16763
  }
16764
16764
  var SessionModeSeat_module_css_default = {
16765
- "seatLabel": "dDMF8q_seatLabel",
16766
16765
  "itemName": "dDMF8q_itemName",
16766
+ "seatIcon": "dDMF8q_seatIcon",
16767
16767
  "seat": "dDMF8q_seat",
16768
+ "seatLabel": "dDMF8q_seatLabel",
16768
16769
  "itemDesc": "dDMF8q_itemDesc",
16770
+ "chevron": "dDMF8q_chevron",
16769
16771
  "item": "dDMF8q_item",
16770
- "menuAnchor": "dDMF8q_menuAnchor",
16771
- "seatIcon": "dDMF8q_seatIcon",
16772
- "chevron": "dDMF8q_chevron"
16772
+ "menuAnchor": "dDMF8q_menuAnchor"
16773
16773
  };
16774
16774
  //#endregion
16775
16775
  //#region src/client/SessionModeSeat.tsx
package/dist/index.d.cts CHANGED
@@ -23,6 +23,8 @@ interface SessionMode {
23
23
  readonly persona: SessionModePersona;
24
24
  readonly allowTools: string[];
25
25
  readonly denyTools: string[];
26
+ readonly allowSkills: string[];
27
+ readonly denySkills: string[];
26
28
  readonly allowPolicies: string[];
27
29
  readonly denyPolicies: string[];
28
30
  readonly instructions: boolean;
package/dist/index.d.mts CHANGED
@@ -23,6 +23,8 @@ interface SessionMode {
23
23
  readonly persona: SessionModePersona;
24
24
  readonly allowTools: string[];
25
25
  readonly denyTools: string[];
26
+ readonly allowSkills: string[];
27
+ readonly denySkills: string[];
26
28
  readonly allowPolicies: string[];
27
29
  readonly denyPolicies: string[];
28
30
  readonly instructions: boolean;
package/dist/index.mjs CHANGED
@@ -54,6 +54,14 @@ const modeSchema = z$1.object({
54
54
  zh: "这个模式不用的工具(黑名单):从 `allowTools` 定下的那份里减掉(`allowTools` 留空就是从全部里减)。留空 = 一条都不禁;同时命中两份名单时以这里为准。",
55
55
  en: "Tools this mode must not use (deny list): subtracted from whatever `allowTools` settled on (with an empty `allowTools`, from everything). Empty denies nothing; a name in both lists is denied."
56
56
  })),
57
+ allowSkills: z$1.array(z$1.string()).default([]).description(localized({
58
+ zh: "这个模式能用的技能;其余既不进技能目录,`skill` 工具加载它也被拒。**留空就是不设收窄**:技能注册表里有什么就用什么。",
59
+ en: "Skills this mode may use; everything else leaves the skill catalog and loading it through the `skill` tool is refused. Leave it empty to narrow nothing: the session then sees every skill the registry carries."
60
+ })),
61
+ denySkills: z$1.array(z$1.string()).default([]).description(localized({
62
+ zh: "这个模式不用的技能(黑名单):从 `allowSkills` 定下的那份里减掉(`allowSkills` 留空就是从全部里减)。留空 = 一条都不禁;同时命中两份名单时以这里为准。",
63
+ en: "Skills this mode must not use (deny list): subtracted from whatever `allowSkills` settled on (with an empty `allowSkills`, from everything). Empty denies nothing; a name in both lists is denied."
64
+ })),
57
65
  allowPolicies: z$1.array(z$1.string()).default([]).description(localized({
58
66
  zh: "上游 policy 规则的生效白名单(`fs/write-intent` / `fs/edit-intent`):留空 = 全部规则照旧生效;有值 = 只有列出的生效,其余的被绕过。",
59
67
  en: "Allow list of upstream policy rules that stay in force (`fs/write-intent` / `fs/edit-intent`): empty keeps every rule; when set, only the listed ones stay, and the others are bypassed."
@@ -135,24 +143,69 @@ const DENYLISTED = (toolName, modeName) => `${toolName} 被「${modeName}」的
135
143
  function refusalOf(toolName, modeName, denial) {
136
144
  return denial === "denylisted" ? DENYLISTED(toolName, modeName) : OUT_OF_SCOPE(toolName, modeName);
137
145
  }
146
+ const SKILL_OUT_OF_SCOPE = (skillName, modeName) => `${skillName} 不在「${modeName}」的技能白名单里,加载它不会有结果;按当前模式可用的技能完成任务,或让用户切到别的模式。`;
147
+ const SKILL_DENYLISTED = (skillName, modeName) => `${skillName} 被「${modeName}」的技能黑名单排除,加载它不会有结果;按当前模式可用的技能完成任务,或让用户切到别的模式。`;
148
+ function skillRefusalOf(skillName, modeName, denial) {
149
+ return denial === "denylisted" ? SKILL_DENYLISTED(skillName, modeName) : SKILL_OUT_OF_SCOPE(skillName, modeName);
150
+ }
138
151
  const TOOL_SECTION_PREFIX = "tool:";
139
- function gateOf(definition) {
140
- const deny = new Set(definition.denyTools);
141
- const allow = definition.allowTools.length === 0 ? void 0 : new Set(definition.allowTools);
142
- if (allow === void 0 && deny.size === 0) return void 0;
152
+ const SKILL_TOOL_NAME = "skill";
153
+ function gateOfNames(allow, deny) {
154
+ const denied = new Set(deny);
155
+ const allowed = allow.length === 0 ? void 0 : new Set(allow);
156
+ if (allowed === void 0 && denied.size === 0) return void 0;
143
157
  return {
144
- allow,
145
- deny
158
+ allow: allowed,
159
+ deny: denied
146
160
  };
147
161
  }
148
- function denialOf(gate, toolName) {
149
- if (gate.deny.has(toolName)) return "denylisted";
150
- if (gate.allow !== void 0 && !gate.allow.has(toolName)) return "not-allowlisted";
162
+ function denialOf(gate, name) {
163
+ if (gate.deny.has(name)) return "denylisted";
164
+ if (gate.allow !== void 0 && !gate.allow.has(name)) return "not-allowlisted";
151
165
  }
152
166
  function kindOf(message) {
153
167
  const kind = message.source.kind;
154
168
  return typeof kind === "string" ? kind : void 0;
155
169
  }
170
+ const CATALOG_LINE = /^- `([^`]+)`: /u;
171
+ function narrowCatalog(message, gate, warn) {
172
+ const source = message.source;
173
+ if (source.kind !== "skill-catalog") return void 0;
174
+ const entries = source.entries;
175
+ const denied = new Set(entries.map((entry) => entry.name).filter((name) => denialOf(gate, name) !== void 0));
176
+ if (denied.size === 0) return void 0;
177
+ const [block, ...rest] = message.content;
178
+ if (block?.type !== "text" || rest.length > 0) {
179
+ warn(`skill catalog message is not one text block; denied skills stay listed: ${[...denied].join(", ")}`);
180
+ return;
181
+ }
182
+ const removed = /* @__PURE__ */ new Set();
183
+ const lines = block.text.split("\n").filter((line) => {
184
+ const match = CATALOG_LINE.exec(line);
185
+ if (match === null) return true;
186
+ const name = match[1] ?? "";
187
+ if (!denied.has(name)) return true;
188
+ removed.add(name);
189
+ return false;
190
+ });
191
+ const missing = [...denied].filter((name) => !removed.has(name));
192
+ if (missing.length > 0) {
193
+ warn(`skill catalog lines not recognized; denied skills stay listed: ${missing.join(", ")}`);
194
+ return;
195
+ }
196
+ return {
197
+ ...message,
198
+ content: [{
199
+ ...block,
200
+ text: lines.join("\n")
201
+ }]
202
+ };
203
+ }
204
+ function skillNameOf(arguments_) {
205
+ if (typeof arguments_ !== "object" || arguments_ === null) return void 0;
206
+ const name = arguments_.name;
207
+ return typeof name === "string" && name.length > 0 ? name : void 0;
208
+ }
156
209
  var SessionScope = class {
157
210
  applied = /* @__PURE__ */ new WeakMap();
158
211
  ctx;
@@ -175,30 +228,54 @@ var SessionScope = class {
175
228
  if (decision.kind !== "enter") return decision;
176
229
  const applied = this.applied.get(payload.agent);
177
230
  if (applied === void 0) return decision;
178
- const kept = decision.messages.filter((message) => {
231
+ let changed = false;
232
+ const kept = [];
233
+ for (const message of decision.messages) {
179
234
  const kind = kindOf(message);
180
- if (kind === "agent-instructions") return applied.instructions;
181
- if (kind === "skill-catalog") return applied.skills;
182
- return true;
183
- });
184
- return kept.length === decision.messages.length ? decision : {
235
+ if (kind === "agent-instructions") {
236
+ if (applied.instructions) kept.push(message);
237
+ else changed = true;
238
+ continue;
239
+ }
240
+ if (kind === "skill-catalog") {
241
+ if (!applied.skills) {
242
+ changed = true;
243
+ continue;
244
+ }
245
+ const narrowed = applied.skillGate === void 0 ? void 0 : narrowCatalog(message, applied.skillGate, (line) => this.ctx.logger.warn(`session-mode: ${line}`));
246
+ if (narrowed === void 0) kept.push(message);
247
+ else {
248
+ changed = true;
249
+ kept.push(narrowed);
250
+ }
251
+ continue;
252
+ }
253
+ kept.push(message);
254
+ }
255
+ return changed ? {
185
256
  ...decision,
186
257
  messages: kept
187
- };
258
+ } : decision;
188
259
  }, { prepend: true });
189
260
  }
190
261
  apply(agent, definition) {
191
262
  const previous = this.applied.get(agent);
192
263
  if (previous !== void 0) previous.dispose();
193
- const gate = gateOf(definition);
264
+ const gate = gateOfNames(definition.allowTools, definition.denyTools);
265
+ const skillGate = gateOfNames(definition.allowSkills, definition.denySkills);
194
266
  const dispose = agent.ctx.effect(() => {
195
267
  const stoppers = [];
196
268
  if (definition.runtimeContext === false) stoppers.push(agent.ctx.systemPrompt.suppressRuntimeContext());
197
269
  const fiber = agent.ctx.inject(["tools"], (scope) => {
198
- if (gate === void 0) return;
270
+ if (gate === void 0 && skillGate === void 0) return;
199
271
  scope.tools.guard((exec) => {
200
- const denial = denialOf(gate, exec.name);
201
- return denial === void 0 ? void 0 : refusalOf(exec.name, definition.name, denial);
272
+ const denial = gate === void 0 ? void 0 : denialOf(gate, exec.name);
273
+ if (denial !== void 0) return refusalOf(exec.name, definition.name, denial);
274
+ if (skillGate === void 0 || exec.name !== SKILL_TOOL_NAME) return void 0;
275
+ const skillName = skillNameOf(exec.arguments);
276
+ if (skillName === void 0) return void 0;
277
+ const skillDenial = denialOf(skillGate, skillName);
278
+ return skillDenial === void 0 ? void 0 : skillRefusalOf(skillName, definition.name, skillDenial);
202
279
  });
203
280
  });
204
281
  return [...stoppers, () => fiber.dispose()];
@@ -206,6 +283,7 @@ var SessionScope = class {
206
283
  this.channel()?.setInstructions(agent, definition.instructions);
207
284
  this.applied.set(agent, {
208
285
  gate,
286
+ skillGate,
209
287
  instructions: definition.instructions,
210
288
  skills: definition.skills,
211
289
  dispose
@@ -421,6 +499,8 @@ var SessionModes = class extends Service {
421
499
  name: mode.name,
422
500
  allowTools: mode.allowTools,
423
501
  denyTools: mode.denyTools,
502
+ allowSkills: mode.allowSkills,
503
+ denySkills: mode.denySkills,
424
504
  instructions: mode.instructions,
425
505
  skills: mode.skills ?? derivedSkills(mode),
426
506
  runtimeContext: mode.runtimeContext
package/dist/rows.mjs CHANGED
@@ -11,6 +11,12 @@ const MODE_SOURCES = [{
11
11
  description: "功能完整的编码 Agent:文件、Shell、检索、联网等工具常驻,其余用法说明按需加载。",
12
12
  persona: CODING_PERSONA,
13
13
  role: ["main", "subagent"],
14
+ denyTools: ["load_workspace_dependencies"],
15
+ denySkills: [
16
+ "office-docx",
17
+ "office-pptx",
18
+ "office-xlsx"
19
+ ],
14
20
  denyPolicies: ["fs/edit-intent"]
15
21
  }, {
16
22
  id: "chat",
@@ -34,6 +40,8 @@ function list(values) {
34
40
  function modeConfig(source) {
35
41
  const allowTools = list(source.allowTools);
36
42
  const denyTools = list(source.denyTools);
43
+ const allowSkills = list(source.allowSkills);
44
+ const denySkills = list(source.denySkills);
37
45
  const allowPolicies = list(source.allowPolicies);
38
46
  const denyPolicies = list(source.denyPolicies);
39
47
  return {
@@ -44,6 +52,8 @@ function modeConfig(source) {
44
52
  ...source.persona === void 0 ? {} : { persona: { ...source.persona } },
45
53
  ...allowTools === void 0 ? {} : { allowTools },
46
54
  ...denyTools === void 0 ? {} : { denyTools },
55
+ ...allowSkills === void 0 ? {} : { allowSkills },
56
+ ...denySkills === void 0 ? {} : { denySkills },
47
57
  ...allowPolicies === void 0 ? {} : { allowPolicies },
48
58
  ...denyPolicies === void 0 ? {} : { denyPolicies },
49
59
  ...source.instructions === void 0 ? {} : { instructions: source.instructions },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/dsh-session-mode",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Session modes for dsh: coding / chat as data — a mode is a persona plus its tool lists, its upstream policy lists, its instruction / skill-catalog / runtime-context switches and an optional default model. The host applies the current mode to each session, and the chip in the composer sits beside the official agent-preset entry, which stays.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -59,6 +59,7 @@
59
59
  "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
60
60
  "@deepseek-ai/dsh-skill": "0.2.0-rc.2",
61
61
  "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
62
+ "@deepseek-ai/dsh-tool-skill": "0.2.0-rc.2",
62
63
  "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
63
64
  "@morlay/dsh-client-ui-primitives": "0.0.7",
64
65
  "@morlay/dsh-context-assembler": "0.0.5",
package/src/index.ts CHANGED
@@ -389,12 +389,14 @@ export class SessionModes extends Service {
389
389
  for (const dispose of disposers) dispose();
390
390
  },
391
391
  });
392
- // 收口要的那几项按模式定义整份推过去:`name` / `allowTools` / `denyTools` / 三个开关。`skills` 缺省时在这里
392
+ // 收口要的那几项按模式定义整份推过去:`name` / 两份工具名单 / 两份技能名单 / 三个开关。`skills` 缺省时在这里
393
393
  // 按这份定义自己的工具名单推导(见 `derivedSkills`)——收口那一侧只认解析后的布尔。
394
394
  this.scope.apply(agent, {
395
395
  name: mode.name,
396
396
  allowTools: mode.allowTools,
397
397
  denyTools: mode.denyTools,
398
+ allowSkills: mode.allowSkills,
399
+ denySkills: mode.denySkills,
398
400
  instructions: mode.instructions,
399
401
  skills: mode.skills ?? derivedSkills(mode),
400
402
  runtimeContext: mode.runtimeContext,
@@ -20,6 +20,10 @@ export interface ModeSource {
20
20
  readonly allowTools?: readonly string[];
21
21
  // 这个模式不用的工具(黑名单):从 `allowTools` 定的那份里减掉(deny 优先)。
22
22
  readonly denyTools?: readonly string[];
23
+ // 这个模式能用的技能;**可选**,不写(留空)就是不设收窄——技能注册表里有什么就用什么。
24
+ readonly allowSkills?: readonly string[];
25
+ // 这个模式不用的技能(黑名单):从 `allowSkills` 定的那份里减掉(deny 优先)。
26
+ readonly denySkills?: readonly string[];
23
27
  // 上游 policy 规则的生效白名单:**可选**,不写(留空)= 全部规则生效。
24
28
  readonly allowPolicies?: readonly PolicyName[];
25
29
  // 上游 policy 规则的黑名单:列出的规则禁用(它在上游那条 waterfall 上的裁决被绕过)。
@@ -69,7 +73,11 @@ export const MODE_SOURCES: readonly ModeSource[] = [
69
73
  // 用户可选,也允许作为子代理的 mode(子代理默认继承父 mode,不看角色;这里是"可被指定"的候选集)。
70
74
  role: ["main", "subagent"],
71
75
  // 不写 `allowTools`:不设收窄——这个会话用它挂着的 preset 提供的全部工具(抄一份清单只会与行清单漂移)。
72
- // 不写 `denyTools`:一件工具都不禁。
76
+ // 只排除一件工具:官方 Office 组合(宿主代码挂载,profile 层停不掉它的行)里那个载荷查询。
77
+ denyTools: ["load_workspace_dependencies"],
78
+ // 官方 Office 技能的三个名字同样按会话排除:技能目录里不列它们,`skill` 工具加载它们也被拒。
79
+ // 名单是技能名这一层的模型可见契约(官方 `skill-office` 的 `SKILL_NAMES`),跟着上游改名走。
80
+ denySkills: ["office-docx", "office-pptx", "office-xlsx"],
73
81
  //
74
82
  // `denyPolicies` 只禁 `fs/edit-intent`(上游那条"先读后改"):改文件不再要求先读过——写路径上的
75
83
  // `fs/write-intent`(陈旧版本 CAS 那层安全网)照旧生效,那正是这条配置不写成"两条都禁"的理由。
package/src/modes.ts CHANGED
@@ -51,6 +51,12 @@ export interface SessionMode {
51
51
  // 这个模式**不用**哪些工具(黑名单):从 `allowTools` 定下的那份里减掉(`allowTools` 留空时就是从全部里减)。
52
52
  // 留空表示不禁任何工具。同时命中两份名单时以这里为准(deny 优先)。
53
53
  readonly denyTools: string[];
54
+ // 这个模式能用哪些**技能**;其余的既不进技能目录,`skill` 工具加载它也被拒。**留空表示不设收窄**:技能注册表
55
+ // 里有什么就用什么(官方 `skill-office` 那类由宿主代码挂载的技能也在这份"全部"里)。
56
+ readonly allowSkills: string[];
57
+ // 这个模式**不用**哪些技能(黑名单):从 `allowSkills` 定下的那份里减掉(`allowSkills` 留空时就是从全部里减)。
58
+ // 留空表示不禁任何技能;同时命中两份名单时以这里为准(deny 优先)。
59
+ readonly denySkills: string[];
54
60
  // 上游 policy 规则的**生效白名单**:留空(或不写)表示全部规则照旧生效;有值表示只有列出的那些生效,
55
61
  // 其余的按 `denyPolicies` 那一套被绕过。名单见 `POLICY_NAMES`。空数组与不写同义。
56
62
  readonly allowPolicies: string[];
@@ -198,6 +204,24 @@ const modeSchema: z<SessionMode> = z.object({
198
204
  en: "Tools this mode must not use (deny list): subtracted from whatever `allowTools` settled on (with an empty `allowTools`, from everything). Empty denies nothing; a name in both lists is denied.",
199
205
  }),
200
206
  ),
207
+ allowSkills: z
208
+ .array(z.string())
209
+ .default([])
210
+ .description(
211
+ localized({
212
+ zh: "这个模式能用的技能;其余既不进技能目录,`skill` 工具加载它也被拒。**留空就是不设收窄**:技能注册表里有什么就用什么。",
213
+ en: "Skills this mode may use; everything else leaves the skill catalog and loading it through the `skill` tool is refused. Leave it empty to narrow nothing: the session then sees every skill the registry carries.",
214
+ }),
215
+ ),
216
+ denySkills: z
217
+ .array(z.string())
218
+ .default([])
219
+ .description(
220
+ localized({
221
+ zh: "这个模式不用的技能(黑名单):从 `allowSkills` 定下的那份里减掉(`allowSkills` 留空就是从全部里减)。留空 = 一条都不禁;同时命中两份名单时以这里为准。",
222
+ en: "Skills this mode must not use (deny list): subtracted from whatever `allowSkills` settled on (with an empty `allowSkills`, from everything). Empty denies nothing; a name in both lists is denied.",
223
+ }),
224
+ ),
201
225
  allowPolicies: z
202
226
  .array(z.string())
203
227
  .default([])
@@ -298,6 +322,10 @@ interface Validated {
298
322
  readonly allowTools?: readonly string[];
299
323
  // 留空合法:不禁任何工具;与 `allowTools` 同时命中合法(deny 优先)。
300
324
  readonly denyTools?: readonly string[];
325
+ // 留空合法:不设技能收窄。
326
+ readonly allowSkills?: readonly string[];
327
+ // 留空合法:不禁任何技能;与 `allowSkills` 同时命中合法(deny 优先)。
328
+ readonly denySkills?: readonly string[];
301
329
  // 留空合法:全部规则生效。
302
330
  readonly allowPolicies?: readonly string[];
303
331
  // 留空合法:一条都不禁;与 `allowPolicies` 同时命中合法(deny 优先)。
package/src/rows.ts CHANGED
@@ -24,6 +24,8 @@ function list<T>(values: readonly T[] | undefined): readonly T[] | undefined {
24
24
  function modeConfig(source: ModeSource): Record<string, unknown> {
25
25
  const allowTools = list(source.allowTools);
26
26
  const denyTools = list(source.denyTools);
27
+ const allowSkills = list(source.allowSkills);
28
+ const denySkills = list(source.denySkills);
27
29
  const allowPolicies = list(source.allowPolicies);
28
30
  const denyPolicies = list(source.denyPolicies);
29
31
  return {
@@ -35,9 +37,12 @@ function modeConfig(source: ModeSource): Record<string, unknown> {
35
37
  ...(source.persona === undefined ? {} : { persona: { ...source.persona } }),
36
38
  // 四份名单都是可选的:不写(或写成空数组)就是这个键不进 config,schema 默认空数组。
37
39
  // `allowTools` 不写 = 不设收窄;`denyTools` 不写 = 一件都不禁(两份同配时 deny 优先);
40
+ // `allowSkills` / `denySkills` 同一套语义,收的是技能名(技能目录与 `skill` 工具两处一起收);
38
41
  // `allowPolicies` 不写 = 全部上游 policy 规则生效;`denyPolicies` 不写 = 一条都不禁。
39
42
  ...(allowTools === undefined ? {} : { allowTools }),
40
43
  ...(denyTools === undefined ? {} : { denyTools }),
44
+ ...(allowSkills === undefined ? {} : { allowSkills }),
45
+ ...(denySkills === undefined ? {} : { denySkills }),
41
46
  ...(allowPolicies === undefined ? {} : { allowPolicies }),
42
47
  ...(denyPolicies === undefined ? {} : { denyPolicies }),
43
48
  ...(source.instructions === undefined ? {} : { instructions: source.instructions }),
package/src/scope.ts CHANGED
@@ -18,6 +18,9 @@
18
18
  import { type Context } from "@deepseek-ai/cordis";
19
19
  import type { Agent, PreStepDecision } from "@deepseek-ai/dsh-agent";
20
20
  import type { UserMessage } from "@deepseek-ai/dsh-llm";
21
+ // 技能目录那条消息的 `source` 形状由官方 `tool-skill` 行声明(`MessageSourceMap` 的合并扩展):这里只借它的类型,
22
+ // 认它仍是按 `kind` 字符串读——运行期不依赖那一行在场。
23
+ import type {} from "@deepseek-ai/dsh-tool-skill";
21
24
  // 通道的服务声明(`ctx.contextAssembler`)住在 `@morlay/dsh-context-assembler` 的包根上;那是可选搭档,
22
25
  // 所以只借它的类型,取服务仍按"可能没有"处理。
23
26
  import type {} from "@morlay/dsh-context-assembler";
@@ -30,6 +33,10 @@ export interface SessionScopeDefinition {
30
33
  readonly allowTools: readonly string[];
31
34
  // 这个会话明确排除的工具。与 `allowTools` 同时命中一个名字时**黑名单优先**(拒,且文案说黑名单)。
32
35
  readonly denyTools: readonly string[];
36
+ // 这个会话能用的技能;其余既不进技能目录,`skill` 工具加载它也被拒。**留空表示不设白名单**——起点是全部技能。
37
+ readonly allowSkills: readonly string[];
38
+ // 这个会话明确排除的技能。与 `allowSkills` 同时命中一个名字时**黑名单优先**(拒,且文案说黑名单)。
39
+ readonly denySkills: readonly string[];
33
40
  // 这个会话要不要 instruction 类注入(`false` → 丢掉官方 `agent-instructions` 的注入,并关通道的降级注入)。
34
41
  readonly instructions: boolean;
35
42
  // 这个会话要不要技能目录(`false` → 丢掉官方 `skill-catalog` 的注入)。
@@ -53,28 +60,45 @@ function refusalOf(toolName: string, modeName: string, denial: Denial): string {
53
60
  : OUT_OF_SCOPE(toolName, modeName);
54
61
  }
55
62
 
63
+ // 技能被拒的两类原因:同一套白名单 / 黑名单语义,文案换到技能面(拒绝加载某一件技能,而不是某个工具)。
64
+ const SKILL_OUT_OF_SCOPE = (skillName: string, modeName: string): string =>
65
+ `${skillName} 不在「${modeName}」的技能白名单里,加载它不会有结果;按当前模式可用的技能完成任务,或让用户切到别的模式。`;
66
+
67
+ const SKILL_DENYLISTED = (skillName: string, modeName: string): string =>
68
+ `${skillName} 被「${modeName}」的技能黑名单排除,加载它不会有结果;按当前模式可用的技能完成任务,或让用户切到别的模式。`;
69
+
70
+ function skillRefusalOf(skillName: string, modeName: string, denial: Denial): string {
71
+ return denial === "denylisted"
72
+ ? SKILL_DENYLISTED(skillName, modeName)
73
+ : SKILL_OUT_OF_SCOPE(skillName, modeName);
74
+ }
75
+
56
76
  // 单个工具的说明 section 名前缀:`tool:<工具名>`(`tools:` 那类是聚合块,不归收口管)。
57
77
  const TOOL_SECTION_PREFIX = "tool:";
58
78
 
59
- // 一次收口**合成**出来的工具面:最终可用 = (白名单留空 ? 全部 : 白名单) − 黑名单。
60
- // `undefined` 表示不过滤(两条名单都空)——装配投影、section 过滤与执行 guard 三处都读同一个合成结果。
61
- type ToolGate = {
62
- // undefined = 不设白名单(全部工具都是起点)。
79
+ // 加载技能的那个官方工具名:官方 `tool-skill` 行的注册名,本包不 import 那一行(依赖面只到服务),所以按字符串读
80
+ // ——与 `kindOf` 读官方注入面同一做法。
81
+ const SKILL_TOOL_NAME = "skill";
82
+
83
+ // 一次收口**合成**出来的一份名单面:最终可用 = (白名单留空 ? 全部 : 白名单) − 黑名单。工具面与技能面同形:
84
+ // 两处(装配投影 / 执行 guard)都读同一个合成结果。
85
+ type NameGate = {
86
+ // undefined = 不设白名单(全部都是起点)。
63
87
  readonly allow: ReadonlySet<string> | undefined;
64
88
  readonly deny: ReadonlySet<string>;
65
89
  };
66
90
 
67
- function gateOf(definition: SessionScopeDefinition): ToolGate | undefined {
68
- const deny = new Set(definition.denyTools);
69
- const allow = definition.allowTools.length === 0 ? undefined : new Set(definition.allowTools);
70
- if (allow === undefined && deny.size === 0) return undefined;
71
- return { allow, deny };
91
+ function gateOfNames(allow: readonly string[], deny: readonly string[]): NameGate | undefined {
92
+ const denied = new Set(deny);
93
+ const allowed = allow.length === 0 ? undefined : new Set(allow);
94
+ if (allowed === undefined && denied.size === 0) return undefined;
95
+ return { allow: allowed, deny: denied };
72
96
  }
73
97
 
74
- // 判定一个工具名:`undefined` = 放行,否则是被拒的原因(黑名单先判,于是 deny 优先)。
75
- function denialOf(gate: ToolGate, toolName: string): Denial | undefined {
76
- if (gate.deny.has(toolName)) return "denylisted";
77
- if (gate.allow !== undefined && !gate.allow.has(toolName)) return "not-allowlisted";
98
+ // 判定一个名字:`undefined` = 放行,否则是被拒的原因(黑名单先判,于是 deny 优先)。
99
+ function denialOf(gate: NameGate, name: string): Denial | undefined {
100
+ if (gate.deny.has(name)) return "denylisted";
101
+ if (gate.allow !== undefined && !gate.allow.has(name)) return "not-allowlisted";
78
102
  return undefined;
79
103
  }
80
104
 
@@ -85,10 +109,67 @@ function kindOf(message: UserMessage): string | undefined {
85
109
  return typeof kind === "string" ? kind : undefined;
86
110
  }
87
111
 
112
+ // 技能目录正文里的条目行:`- \`<技能名>\`: <说明>`(官方 `tool-skill` 的渲染形状)。只认行首这一种,
113
+ // 认不出的行一律原样留着——宁可多列,不可把别的东西删掉。
114
+ const CATALOG_LINE = /^- `([^`]+)`: /u;
115
+
116
+ // 按技能名单收窄一条 `skill-catalog` 消息:正文删掉被拒技能那几行,`source.entries` 同步剔除。
117
+ // `undefined` = 这条消息不用动(名单没排除它任何一项,或者形状认不出)。
118
+ function narrowCatalog(
119
+ message: UserMessage,
120
+ gate: NameGate,
121
+ warn: (line: string) => void,
122
+ ): UserMessage | undefined {
123
+ const source = message.source;
124
+ if (source.kind !== "skill-catalog") return undefined;
125
+ const entries = source.entries;
126
+ const denied = new Set(
127
+ entries.map((entry) => entry.name).filter((name) => denialOf(gate, name) !== undefined),
128
+ );
129
+ if (denied.size === 0) return undefined;
130
+
131
+ const [block, ...rest] = message.content;
132
+ if (block?.type !== "text" || rest.length > 0) {
133
+ warn(
134
+ `skill catalog message is not one text block; denied skills stay listed: ${[...denied].join(", ")}`,
135
+ );
136
+ return undefined;
137
+ }
138
+ const removed = new Set<string>();
139
+ const lines = block.text.split("\n").filter((line) => {
140
+ const match = CATALOG_LINE.exec(line);
141
+ if (match === null) return true;
142
+ const name = match[1] ?? "";
143
+ if (!denied.has(name)) return true;
144
+ removed.add(name);
145
+ return false;
146
+ });
147
+ // 上游渲染形状漂移:正文里没定位到这些名字。这一条**整条不动**(正文与结构化名单保持一致:宁可多列,
148
+ // 不可出现"正文里有、名单里没有"的分叉),但要点出来——这条名单面就靠这行告警才不算静默失灵。
149
+ const missing = [...denied].filter((name) => !removed.has(name));
150
+ if (missing.length > 0) {
151
+ warn(`skill catalog lines not recognized; denied skills stay listed: ${missing.join(", ")}`);
152
+ return undefined;
153
+ }
154
+ // **只改正文**,`source.entries` 原样留着:官方 `tool-skill` 用那份结构化名单的 digest 判断"目录变没变"
155
+ // (`catalogHistory` 从会话日志里读它),改了它 digest 就永远对不上,于是每一步都重发一条目录。
156
+ // 收窄是"模型看到什么",不是"官方发布过什么":日志里留的是官方发布的完整名单,模型可见面由正文承担。
157
+ return { ...message, content: [{ ...block, text: lines.join("\n") }] };
158
+ }
159
+
160
+ // `skill` 工具调用的技能名:参数形状是官方那一行的 `{ name: string }`,认不出就不判(那是别人的工具)。
161
+ function skillNameOf(arguments_: unknown): string | undefined {
162
+ if (typeof arguments_ !== "object" || arguments_ === null) return undefined;
163
+ const name = (arguments_ as { readonly name?: unknown }).name;
164
+ return typeof name === "string" && name.length > 0 ? name : undefined;
165
+ }
166
+
88
167
  // 一个 agent 当前装着的那一份:`apply` 时解析好的开关(抑制面读它)。
89
168
  interface Applied {
90
169
  // 两条名单都空 = 不过滤(装配期与执行层都放行)。
91
- readonly gate: ToolGate | undefined;
170
+ readonly gate: NameGate | undefined;
171
+ // 技能面的名单:与工具面同形,`undefined` = 不设收窄(目录照旧、`skill` 工具不按名判)。
172
+ readonly skillGate: NameGate | undefined;
92
173
  readonly instructions: boolean;
93
174
  readonly skills: boolean;
94
175
  // 收回这一份在 `agent.ctx` 上的注册(抑制器与执行层 guard)。
@@ -122,7 +203,7 @@ export class SessionScope {
122
203
  });
123
204
 
124
205
  // 两个抑制面:官方 `agent-instructions`(工作区指令)与官方 `skill-catalog`(技能目录)都在装配投影之外自己
125
- // 往这条瀑布里 append,所以只有在这里丢掉它们的条目。
206
+ // 往这条瀑布里 append,所以只有在这里丢掉它们的条目(技能名单则在这里把目录按名收窄)。
126
207
  //
127
208
  // `prepend` 是必需的:官方那两行比本行早注册(会话挂着的 preset 先于 host 平面),瀑布里先注册的是**外层**,
128
209
  // 站在它们后面就看不到、也丢不掉它们 append 的条目——只有抢在最外层(`await next()` 之后再收)才拿得到最终
@@ -135,15 +216,36 @@ export class SessionScope {
135
216
  const applied = this.applied.get(payload.agent);
136
217
  // 没收过口的会话一律不动。
137
218
  if (applied === undefined) return decision;
138
- const kept = decision.messages.filter((message) => {
219
+ let changed = false;
220
+ const kept: UserMessage[] = [];
221
+ for (const message of decision.messages) {
139
222
  const kind = kindOf(message);
140
- if (kind === "agent-instructions") return applied.instructions;
141
- if (kind === "skill-catalog") return applied.skills;
142
- return true;
143
- });
144
- return kept.length === decision.messages.length
145
- ? decision
146
- : { ...decision, messages: kept };
223
+ if (kind === "agent-instructions") {
224
+ if (applied.instructions) kept.push(message);
225
+ else changed = true;
226
+ continue;
227
+ }
228
+ if (kind === "skill-catalog") {
229
+ if (!applied.skills) {
230
+ changed = true;
231
+ continue;
232
+ }
233
+ const narrowed =
234
+ applied.skillGate === undefined
235
+ ? undefined
236
+ : narrowCatalog(message, applied.skillGate, (line) =>
237
+ this.ctx.logger.warn(`session-mode: ${line}`),
238
+ );
239
+ if (narrowed === undefined) kept.push(message);
240
+ else {
241
+ changed = true;
242
+ kept.push(narrowed);
243
+ }
244
+ continue;
245
+ }
246
+ kept.push(message);
247
+ }
248
+ return changed ? { ...decision, messages: kept } : decision;
147
249
  },
148
250
  { prepend: true },
149
251
  );
@@ -153,8 +255,9 @@ export class SessionScope {
153
255
  apply(agent: Agent, definition: SessionScopeDefinition): void {
154
256
  const previous = this.applied.get(agent);
155
257
  if (previous !== undefined) previous.dispose();
156
- // 两条名单都空 = 不过滤:没有守卫要装,装配期也一路放行。
157
- const gate = gateOf(definition);
258
+ // 两份名单都空 = 不过滤:没有守卫要装,装配期也一路放行。
259
+ const gate = gateOfNames(definition.allowTools, definition.denyTools);
260
+ const skillGate = gateOfNames(definition.allowSkills, definition.denySkills);
158
261
 
159
262
  // 落在 `agent.ctx` 上的那两件用一个 effect 装:动态快照抑制是 scope 层的一次注册;执行层 guard 要等
160
263
  // `tools` 激活才装得上(`inject` 的回调),它挂在那个 inject fiber 下,所以收回时连 fiber 一起收。
@@ -166,10 +269,19 @@ export class SessionScope {
166
269
  }
167
270
  // 必须落在 `agent.ctx` 上才是这个会话的作用域。
168
271
  const fiber = agent.ctx.inject(["tools"], (scope) => {
169
- if (gate === undefined) return;
272
+ if (gate === undefined && skillGate === undefined) return;
170
273
  scope.tools.guard((exec) => {
171
- const denial = denialOf(gate, exec.name);
172
- return denial === undefined ? undefined : refusalOf(exec.name, definition.name, denial);
274
+ // 工具面:按名判这个工具本身能不能用。
275
+ const denial = gate === undefined ? undefined : denialOf(gate, exec.name);
276
+ if (denial !== undefined) return refusalOf(exec.name, definition.name, denial);
277
+ // 技能面:`skill` 工具本身照旧可用,被收窄的是它这次要加载的那一件技能。
278
+ if (skillGate === undefined || exec.name !== SKILL_TOOL_NAME) return undefined;
279
+ const skillName = skillNameOf(exec.arguments);
280
+ if (skillName === undefined) return undefined;
281
+ const skillDenial = denialOf(skillGate, skillName);
282
+ return skillDenial === undefined
283
+ ? undefined
284
+ : skillRefusalOf(skillName, definition.name, skillDenial);
173
285
  });
174
286
  });
175
287
  return [...stoppers, () => fiber.dispose()];
@@ -180,6 +292,7 @@ export class SessionScope {
180
292
 
181
293
  this.applied.set(agent, {
182
294
  gate,
295
+ skillGate,
183
296
  instructions: definition.instructions,
184
297
  skills: definition.skills,
185
298
  dispose,