@morlay/dsh-session-mode 0.0.2 → 0.0.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.
package/dist/index.mjs CHANGED
@@ -1,22 +1,19 @@
1
- import { t as SESSION_MODE_PATH } from "./shared-ChCbbJa8.mjs";
1
+ import { n as SESSION_MODE_PATH, t as POLICY_NAMES } from "./shared-DX2Giyh3.mjs";
2
2
  import { Service } from "@deepseek-ai/cordis";
3
3
  import { ReasoningEffortId } from "@deepseek-ai/dsh-llm";
4
4
  import { z } from "zod";
5
5
  import z$1 from "@deepseek-ai/schemastery";
6
6
  import { PERSONA_PREFIX_SECTION, PERSONA_SUFFIX_SECTION } from "@deepseek-ai/dsh-system-prompt";
7
7
  //#region src/modes.ts
8
- /**
9
- * 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
10
- * (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
11
- */
8
+ function derivedSkills(mode) {
9
+ return (mode.allowTools.length === 0 || mode.allowTools.includes("skill")) && !mode.denyTools.includes("skill");
10
+ }
12
11
  const localized = (text) => text;
13
12
  const personaSchema = z$1.object({
14
13
  prefix: z$1.string().default(""),
15
14
  suffix: z$1.string().default("")
16
15
  });
17
- /** 角色是个封闭集合:写错的 role 在装配期就拒绝,而不是静默变成"谁都不用"。 */
18
16
  const roleSchema = z$1.union([z$1.const("main"), z$1.const("subagent")]);
19
- /** 默认模型的形状与全局 `agent-default-model` 一致;省略 effort 就跟 provider 默认。 */
20
17
  const modelSchema = z$1.object({
21
18
  provider: z$1.string().role("select", { source: "llm-providers" }).description(localized({
22
19
  zh: "服务商 id(部署里注册的任意路由,不论哪个适配器插件注册的)。",
@@ -33,8 +30,8 @@ const modelSchema = z$1.object({
33
30
  });
34
31
  const modeSchema = z$1.object({
35
32
  preset: z$1.string().default("").description(localized({
36
- zh: "这个模式挂哪个 agent preset(它的 id,官方或本部署自建的):行清单由那个 preset 提供,几个模式可以共享同一个;空串表示不挂(只能由 applyTo / 继承使用)。",
37
- en: "Which agent preset this mode rides on (its id, shipped or deployment-owned): that preset supplies the row list and several modes may share it; empty means none (usable only through applyTo / inheritance)."
33
+ zh: "这个模式挂哪个 agent preset(它的 id,官方或本部署自建的):行清单由那份 preset 提供,几个模式可以共享同一个。留空就是不绑——选这个模式不换 preset,会话保持当前挂着的那份,模式自己的提示词、工具收口与开关照常生效。",
34
+ en: "Which agent preset this mode rides on (its id, shipped or deployment-owned): that preset supplies the row list, and several modes may share it. Leave it empty to bind none — selecting the mode then keeps whatever preset the session already has, while the mode's persona, tool narrowing, and switches still apply."
38
35
  })),
39
36
  name: z$1.string().required().description(localized({
40
37
  zh: "模式名(事实文案;选择面归官方 roster)。",
@@ -50,21 +47,33 @@ const modeSchema = z$1.object({
50
47
  })),
51
48
  persona: personaSchema.default({}),
52
49
  allowTools: z$1.array(z$1.string()).default([]).description(localized({
53
- zh: "这个会话能用的工具;其余既不进目录,调用也被拒。",
54
- en: "Tools this session may use; everything else leaves the catalog and calls are refused."
50
+ zh: "这个会话能用的工具;其余既不进目录,调用也被拒。**留空就是不设收窄**:用这个会话挂着的 preset 提供的全部工具。",
51
+ en: "Tools this session may use; everything else leaves the catalog and calls are refused. Leave it empty to narrow nothing: the session then uses every tool its preset provides."
52
+ })),
53
+ denyTools: z$1.array(z$1.string()).default([]).description(localized({
54
+ zh: "这个模式不用的工具(黑名单):从 `allowTools` 定下的那份里减掉(`allowTools` 留空就是从全部里减)。留空 = 一条都不禁;同时命中两份名单时以这里为准。",
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
+ })),
57
+ allowPolicies: z$1.array(z$1.string()).default([]).description(localized({
58
+ zh: "上游 policy 规则的生效白名单(`fs/write-intent` / `fs/edit-intent`):留空 = 全部规则照旧生效;有值 = 只有列出的生效,其余的被绕过。",
59
+ 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."
60
+ })),
61
+ denyPolicies: z$1.array(z$1.string()).default([]).description(localized({
62
+ zh: "上游 policy 规则的黑名单:列出的规则禁用(它的裁决连拒绝一起丢掉,调用按没有这条规则继续)。留空 = 一条都不禁;同时命中两份名单时以这里为准。",
63
+ en: "Deny list of upstream policy rules: the listed ones are disabled — their verdict (rejection included) is dropped and the call proceeds as if the rule were absent. Empty denies nothing; a name in both lists is denied."
55
64
  })),
56
65
  instructions: z$1.boolean().default(true).description(localized({
57
- zh: "是否要 instruction 类注入(工作区指令、技能目录、用法正文)。",
58
- en: "Whether instruction-class injections apply (workspace instructions, skill catalog, guidance)."
66
+ zh: "是否要 instruction 类注入(工作区指令、用法正文):`false` 时丢掉官方 `agent-instructions` 的注入,并关掉通道自己的降级注入。",
67
+ en: "Whether instruction-class injections apply (workspace instructions, guidance): `false` drops the official `agent-instructions` injection and turns off the channel's own demoted delivery."
68
+ })),
69
+ skills: z$1.boolean().description(localized({
70
+ zh: "是否要技能目录(官方 `skill-catalog` 的注入)。**不写就按这个模式自己的工具名单推导**:`(allowTools 留空 ? 全部 : allowTools) − denyTools` 里含 `skill` 就要;写 `false` 就丢掉官方 `skill-catalog` 的注入(`skill` 工具的可见性仍归 `allowTools`)。",
71
+ en: "Whether the skill catalog applies (the official `skill-catalog` injection). Unset derives it from this mode's own tool lists: it applies when `(empty allowTools ? every tool : allowTools) − denyTools` contains `skill`; `false` drops the official `skill-catalog` injection (`skill` tool visibility still belongs to `allowTools`)."
59
72
  })),
60
73
  runtimeContext: z$1.boolean().default(true).description(localized({
61
74
  zh: "是否要动态快照(文件沙箱策略、审批策略)。",
62
75
  en: "Whether the runtime snapshot applies (sandbox and approval policy)."
63
76
  })),
64
- /**
65
- * 这个模式的默认模型。不标 `volatile`:`modes` 本身就是 volatile,整棵子树都在页面上——再标一层会被
66
- * schemastery 拒(`validateVolatileSchema` 不许 volatile 套 volatile)。
67
- */
68
77
  defaultModel: modelSchema.default(null).description(localized({
69
78
  zh: "这个模式的默认模型;省略就跟全局默认模型。只在会话还没有模型事实时接管。",
70
79
  en: "Default model for this mode; unset follows the global default. Applies only while a session has no model fact yet."
@@ -76,18 +85,13 @@ const Config = z$1.object({
76
85
  en: "Mode a session starts in before anyone picks one; must be an id in `modes`."
77
86
  })).volatile(),
78
87
  modes: z$1.dict(modeSchema).required().description(localized({
79
- zh: "模式清单:id → 定义(persona / 允许的工具 / 角色)。改它对**已运行会话**不自动生效——重挂后新建的会话、或重新应用模式的会话才用新定义。",
80
- en: "Mode roster: id to definition (persona, allowed tools, role). Edits do not follow into already-running sessions; sessions created after the row is remounted use the new definition."
88
+ zh: "模式清单:id → 定义(persona / 工具名单 / 生效的 policy 规则 / 角色)。改它对**已运行会话**不自动生效——重挂后新建的会话、或重新应用模式的会话才用新定义。",
89
+ en: "Mode roster: id to definition (persona, tool lists, effective policy rules, role). Edits do not follow into already-running sessions; sessions created after the row is remounted use the new definition."
81
90
  })).volatile(),
82
- /**
83
- * 退役字段:默认模型住在每个模式自己的 `defaultModel` 里。这里留着是为了**报错**(见 `configProblem`),
84
- * 页面上不出现(`hidden()`)。
85
- */
86
91
  models: z$1.dict(modelSchema).default({}).hidden()
87
92
  });
88
- /** 模式定义里不合法的地方(装配期 fail loud,而不是等到某个会话装配提示词时才发现)。 */
93
+ const POLICY_NAME_SET = new Set(POLICY_NAMES);
89
94
  function configProblem(config) {
90
- /** 没写 `role` 等于默认 `["main"]`(与 schema 的默认一致)——字面量与归一化后的形状都能校验。 */
91
95
  const roles = (id) => config.modes[id]?.role ?? ["main"];
92
96
  const ids = Object.keys(config.modes);
93
97
  if (ids.length === 0) return "session-mode: `modes` must declare at least one mode";
@@ -95,8 +99,8 @@ function configProblem(config) {
95
99
  const roleless = ids.filter((id) => roles(id).length === 0);
96
100
  if (roleless.length > 0) return `session-mode: mode(s) ${roleless.join(", ")} declare no \`role\`; declare main and/or subagent instead of leaving it empty`;
97
101
  if (!roles(config.default).includes("main")) return `session-mode: \`default\` names ${JSON.stringify(config.default)}, which does not declare role "main"; a session that can never be re-selected is a contradiction`;
98
- const empty = ids.filter((id) => (config.modes[id]?.allowTools ?? []).length === 0);
99
- if (empty.length > 0) return `session-mode: mode(s) ${empty.join(", ")} declare no \`allowTools\`; list the tools instead of leaving it empty`;
102
+ const unknownPolicies = Object.entries(config.modes).flatMap(([id, mode]) => [...mode.allowPolicies ?? [], ...mode.denyPolicies ?? []].filter((policy) => !POLICY_NAME_SET.has(policy)).map((policy) => `${id}: ${policy}`));
103
+ if (unknownPolicies.length > 0) return `session-mode: mode(s) ${unknownPolicies.join(", ")} name unknown policies; the known ones are ${POLICY_NAMES.join(", ")}`;
100
104
  if (Object.keys(config.models ?? {}).length > 0) return "session-mode: `models` has moved into each mode's `defaultModel`; move the entries there and drop the top-level `models`";
101
105
  const partial = Object.entries(config.modes).filter(([, mode]) => {
102
106
  const model = mode.defaultModel;
@@ -109,12 +113,6 @@ function configProblem(config) {
109
113
  //#region src/persona.ts
110
114
  const PREFIX_ORDER = "DEPLOYMENT_PERSONA_PREFIX";
111
115
  const SUFFIX_ORDER = "DEPLOYMENT_PERSONA_SUFFIX";
112
- /**
113
- * 把一段 persona 装到该 agent 的 scope 上。
114
- * @param agent - 目标 agent(用它的 `ctx` 定作用域)。
115
- * @param persona - 模式的提示词;缺省时两段都注册成空串,等于遮蔽掉部署级那层。
116
- * @returns 注销这两个 section 的 disposer(模式切换时先调它)。
117
- */
118
116
  function installPersona(agent, persona) {
119
117
  const prompt = agent.ctx.systemPrompt;
120
118
  const disposers = [prompt.section({
@@ -131,39 +129,99 @@ function installPersona(agent, persona) {
131
129
  };
132
130
  }
133
131
  //#endregion
132
+ //#region src/scope.ts
133
+ const OUT_OF_SCOPE = (toolName, modeName) => `${toolName} 不在「${modeName}」的工具白名单里,用它不会有结果;按当前模式提供的工具完成任务,或让用户切到别的模式。`;
134
+ const DENYLISTED = (toolName, modeName) => `${toolName} 被「${modeName}」的工具黑名单排除,用它不会有结果;按当前模式提供的工具完成任务,或让用户切到别的模式。`;
135
+ function refusalOf(toolName, modeName, denial) {
136
+ return denial === "denylisted" ? DENYLISTED(toolName, modeName) : OUT_OF_SCOPE(toolName, modeName);
137
+ }
138
+ 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;
143
+ return {
144
+ allow,
145
+ deny
146
+ };
147
+ }
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";
151
+ }
152
+ function kindOf(message) {
153
+ const kind = message.source.kind;
154
+ return typeof kind === "string" ? kind : void 0;
155
+ }
156
+ var SessionScope = class {
157
+ applied = /* @__PURE__ */ new WeakMap();
158
+ ctx;
159
+ constructor(ctx) {
160
+ this.ctx = ctx;
161
+ ctx.on("system-prompt/assemble", async (_assembly, context, next) => {
162
+ const agent = context.agent;
163
+ const applied = agent === void 0 ? void 0 : this.applied.get(agent);
164
+ if (applied === void 0 || applied.gate === void 0) return next();
165
+ const gate = applied.gate;
166
+ const result = await next();
167
+ return {
168
+ ...result,
169
+ sections: result.sections.filter((section) => section.name.startsWith(TOOL_SECTION_PREFIX) ? denialOf(gate, section.name.slice(5)) === void 0 : true),
170
+ tools: result.tools.filter((tool) => denialOf(gate, tool.name) === void 0)
171
+ };
172
+ });
173
+ ctx.on("agent/pre-step", async (payload, next) => {
174
+ const decision = await next();
175
+ if (decision.kind !== "enter") return decision;
176
+ const applied = this.applied.get(payload.agent);
177
+ if (applied === void 0) return decision;
178
+ const kept = decision.messages.filter((message) => {
179
+ 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 : {
185
+ ...decision,
186
+ messages: kept
187
+ };
188
+ }, { prepend: true });
189
+ }
190
+ apply(agent, definition) {
191
+ const previous = this.applied.get(agent);
192
+ if (previous !== void 0) previous.dispose();
193
+ const gate = gateOf(definition);
194
+ const dispose = agent.ctx.effect(() => {
195
+ const stoppers = [];
196
+ if (definition.runtimeContext === false) stoppers.push(agent.ctx.systemPrompt.suppressRuntimeContext());
197
+ const fiber = agent.ctx.inject(["tools"], (scope) => {
198
+ if (gate === void 0) return;
199
+ scope.tools.guard((exec) => {
200
+ const denial = denialOf(gate, exec.name);
201
+ return denial === void 0 ? void 0 : refusalOf(exec.name, definition.name, denial);
202
+ });
203
+ });
204
+ return [...stoppers, () => fiber.dispose()];
205
+ }, "session-mode: per-session gate");
206
+ this.channel()?.setInstructions(agent, definition.instructions);
207
+ this.applied.set(agent, {
208
+ gate,
209
+ instructions: definition.instructions,
210
+ skills: definition.skills,
211
+ dispose
212
+ });
213
+ }
214
+ channel() {
215
+ try {
216
+ return this.ctx.get("contextAssembler");
217
+ } catch {
218
+ return;
219
+ }
220
+ }
221
+ };
222
+ //#endregion
134
223
  //#region src/index.ts
135
- /**
136
- * 会话模式的 host 半:模式清单(config 里的纯数据)、会话 ↔ 模式的选择、按会话应用 persona 与收口。
137
- *
138
- * **模式不是 Cordis 子树**。旧的 `@morlay/dsh-agent-preset` 每个模式都是一行
139
- * `@deepseek-ai/dsh-agent-preset`,`config.plugins` 里装 persona 与 scope 行,靠 preset scope 的父链
140
- * 对会话生效——代价是整个官方 registry(声明式行、每 revision 一棵 Loader 子树、`isolate` realm)都在
141
- * 部署里。这里只留两件事:模式是一份数据,应用落在会话自己的 scope 上:
142
- *
143
- * | 事实 | 落在哪 |
144
- * | ---------------- | ------------------------------------------------------------------------------------------ |
145
- * | 模式清单与默认值 | 本行的 `config`(装配层可整体改写;`modes.ts` 给形状与校验) |
146
- * | 各模式的默认模型 | 同一份 config 的顶层 `models`(**volatile**:设置页那张卡片改的就是它) |
147
- * | 会话当前模式 | session 事件 `session-mode/selected` + 投影 `sessionMode`(log-only,重建读投影) |
148
- * | 提示词 | `persona`:把模式的 persona 注册到该 agent 的 scope(`persona.ts`) |
149
- * | 工具与注入开关 | 推给 `ctx.sessionToolScope`(`@morlay/dsh-context-assembler/scope`,行 id `context-assembler-scope`) |
150
- * | 页面上的选择面 | HTTP 路由 `GET/POST /session-mode`(清单与切换)+ 会话投影(当前值) |
151
- *
152
- * 应用时机是 `agent/created`:它早于任何一次提示词装配(装配发生在 turn 里),所以 persona 一定在该会话
153
- * 第一次装配之前就注册好了。模式在**空白会话**里可以切换,切换时对着已有的 agent 重新应用一遍。
154
- *
155
- * 切换只允许在空白窗口(还没开过 turn):会话的历史是在某个模式的工具集与提示词下产生的,换了模式,那段
156
- * 历史就与实际装配对不上——与上游 `agentPresets.select` 的判据一致,也用同一个投影(`turnBoundary`)。
157
- */
158
- /** Cordis 插件名:与行 id 一致。 */
159
224
  const name = "session-mode";
160
- /**
161
- * 依赖:投影服务(登记 `sessionMode`、读 `turnBoundary`)、读会话与 agent 的两个注册表,以及提示词注册表
162
- * (persona 的 section 注册在它上面)。
163
- *
164
- * 这些名字必须在 `inject` 里点名:cordis 的**属性访问**(`this.ctx.sessions`)要求本 fiber 声明过那个服务,
165
- * 未声明会抛 `cannot get property "sessions" without inject`(`ctx.get(name)` 才不需要声明)。
166
- */
167
225
  const inject = [
168
226
  "agents",
169
227
  "sessions",
@@ -171,7 +229,6 @@ const inject = [
171
229
  "systemPrompt"
172
230
  ];
173
231
  const sessionModeSchema = z.union([z.string(), z.null()]);
174
- /** 会话模式的投影:初值来自空日志(没选过就是 `null`),只被选择事件推进。 */
175
232
  const sessionModeProjection = {
176
233
  key: "sessionMode",
177
234
  stateSchema: sessionModeSchema,
@@ -184,13 +241,6 @@ const sessionModeProjection = {
184
241
  stateVersion: 1
185
242
  };
186
243
  const sessionModeEditableSchema = z.boolean();
187
- /**
188
- * 这个会话能不能换模式的投影:空白会话为 `true`,一旦 `turn/start` 落库就永远 `false`(换模式要的是**整段
189
- * 历史**的模式一致,所以"开过 turn"之后连正在跑的那个 turn 也算)。
190
- *
191
- * **判据只有这一处**:服务端 {@link SessionModes.select} 的拒绝与 client chip 的只读形态都读它——以前
192
- * client 只能等服务端报错,现在连入口都不给,而两边的结论来自同一份会话事实。
193
- */
194
244
  const sessionModeEditableProjection = {
195
245
  key: "sessionModeEditable",
196
246
  stateSchema: sessionModeEditableSchema,
@@ -202,11 +252,9 @@ const sessionModeEditableProjection = {
202
252
  },
203
253
  stateVersion: 1
204
254
  };
205
- /** 模式清单、默认模式、按会话读取与切换。 */
206
255
  var SessionModes = class extends Service {
207
- /** 每个 agent 已经装上的那一份(persona + 默认模型兜底;模式变了就换一份)。 */
208
256
  installs = /* @__PURE__ */ new WeakMap();
209
- /** 装配时的配置快照:`default` / `modes` 读它,改这两项靠 Loader 重挂这一行(已运行会话不自动换定义)。 */
257
+ scope;
210
258
  config;
211
259
  constructor(ctx, config) {
212
260
  super(ctx, "sessionModes");
@@ -219,8 +267,11 @@ var SessionModes = class extends Service {
219
267
  models: config.models
220
268
  });
221
269
  if (problem !== void 0) throw new Error(problem);
270
+ this.scope = new SessionScope(ctx);
222
271
  ctx.sessionProjections.register(sessionModeProjection);
223
272
  ctx.sessionProjections.register(sessionModeEditableProjection);
273
+ ctx.on("fs/write-intent", (_target, actor, next) => this.decidePolicy("fs/write-intent", actor, next), { prepend: true });
274
+ ctx.on("fs/edit-intent", (_target, actor, next) => this.decidePolicy("fs/edit-intent", actor, next), { prepend: true });
224
275
  ctx.on("agent/created", ({ agent }) => {
225
276
  this.installFor(agent);
226
277
  });
@@ -232,11 +283,9 @@ var SessionModes = class extends Service {
232
283
  this.installFor(agent, mapped);
233
284
  });
234
285
  }
235
- /** 新会话用它:config 里的 `default`。 */
236
286
  get defaultId() {
237
287
  return this.config.default;
238
288
  }
239
- /** 选择器要的清单:只列 `main` 角色的模式(id、展示名、说明,顺序即 config 里 `modes` 的插入序)。 */
240
289
  list() {
241
290
  return this.idsFor("main").map((id) => {
242
291
  const mode = this.definition(id);
@@ -247,60 +296,47 @@ var SessionModes = class extends Service {
247
296
  };
248
297
  });
249
298
  }
250
- /**
251
- * 声明了某个角色的模式 id(顺序即 config 的插入序):`main` 给用户选择器,`subagent` 给子代理候选。
252
- * @param role - 目标角色。
253
- * @returns 该角色下的模式 id。
254
- */
255
299
  idsFor(role) {
256
300
  return Object.entries(this.config.modes).filter(([, mode]) => mode.role.includes(role)).map(([id]) => id);
257
301
  }
258
- /**
259
- * 同 {@link idsFor},给的是定义——"指定 mode" 那条接缝要拿候选集。
260
- * @param role - 目标角色。
261
- * @returns 该角色下的模式与其定义。
262
- */
263
302
  modesFor(role) {
264
303
  return this.idsFor(role).map((id) => ({
265
304
  id,
266
305
  mode: this.definition(id)
267
306
  }));
268
307
  }
269
- /** 页面用的清单 + 默认模式。 */
270
308
  roster() {
271
309
  return {
272
310
  default: this.defaultId,
273
311
  modes: this.list()
274
312
  };
275
313
  }
276
- /** 按 id 取定义;未知 id 直接抛(切换路径上它就是用户的错)。 */
277
314
  definition(id) {
278
315
  const wanted = id ?? this.defaultId;
279
316
  const mode = this.config.modes[wanted];
280
317
  if (mode === void 0) throw new Error(`未知的模式 ${JSON.stringify(wanted)};可用的是 ${Object.keys(this.config.modes).join(", ")}`);
281
318
  return mode;
282
319
  }
283
- /** 会话当前模式:投影上有就用它,否则是部署默认。 */
284
320
  modeOf(session) {
285
321
  return this.ctx.sessionProjections.stateOf(session, "sessionMode") ?? this.defaultId;
286
322
  }
287
- /** 会话当前模式的定义。 */
288
323
  modeOfSession(session) {
289
324
  return this.definition(this.modeOf(session));
290
325
  }
291
- /**
292
- * 把某个空白会话切到某个模式。
293
- *
294
- * 模式带着它的 preset(`preset` 决定行清单),所以这里**先把 agent preset 换成模式声明的那个**,再落我们的
295
- * 会话事实:否则 preset realm 还是旧那一套的行,而它的注入在我们的开关之外(旧形态 `chat` 挂 `minimal`、
296
- * 会话的 preset 却还是 `standard` 时,上游 `agent-instructions` 会照旧把工作区指令注进这个"不要注入"的会话)。
297
- *
298
- * 目标 preset 与当前挂着的**相同时不切**(本部署两个模式共享同一份 preset,所以切模式通常走不到这一步):
299
- * 换 preset 是一次重挂(卸旧行、装新行),没有变化就没有理由付出这个代价。
300
- * @param sessionId - 目标会话(必须还没有开过 turn)。
301
- * @param mode - 目标模式 id。
302
- * @returns 提交后的模式 id。
303
- */
326
+ policyInForce(policy, actor) {
327
+ const agent = actor?.agent;
328
+ if (agent === void 0) return true;
329
+ const mode = this.config.modes[this.modeOf(agent.session)];
330
+ if (mode === void 0) return true;
331
+ if (mode.denyPolicies.includes(policy)) return false;
332
+ return mode.allowPolicies.length === 0 || mode.allowPolicies.includes(policy);
333
+ }
334
+ async decidePolicy(policy, actor, next) {
335
+ if (this.policyInForce(policy, actor)) return await next();
336
+ try {
337
+ await next();
338
+ } catch {}
339
+ }
304
340
  async select(sessionId, mode) {
305
341
  const definition = this.definition(mode);
306
342
  if (!definition.role.includes("main")) throw new Error(`模式 ${JSON.stringify(mode)} 不是用户可选的(它的 role 里没有 main)。`);
@@ -316,12 +352,6 @@ var SessionModes = class extends Service {
316
352
  if (agent !== void 0) this.installFor(agent);
317
353
  return mode;
318
354
  }
319
- /**
320
- * 某个 agent 当前挂着的 preset(registry 的 `composedPreset`)。
321
- *
322
- * 读不到时返回 `undefined`(按"未知"处理 → 该切就切):`composedPreset` 是 registry 较新的读面,替身与老
323
- * 版本可能没有它;而"没挂任何 preset"(返回值 `undefined`)与"读不到"在这里是同一个结论。
324
- */
325
355
  presetOfAgent(registry, agent) {
326
356
  const read = registry.composedPreset;
327
357
  if (typeof read !== "function") return void 0;
@@ -331,10 +361,6 @@ var SessionModes = class extends Service {
331
361
  return;
332
362
  }
333
363
  }
334
- /**
335
- * 官方 preset registry:行清单与它的选择面住在那一行。不在 `inject` 里点名(headless 部署没有它,
336
- * 点名会让本行永不激活),所以按"可能拿不到"读——`ctx.get` 在当前 ctx 没声明那个服务时会抛。
337
- */
338
364
  presetRegistry() {
339
365
  try {
340
366
  return this.ctx.get("agentPresets");
@@ -342,23 +368,11 @@ var SessionModes = class extends Service {
342
368
  return;
343
369
  }
344
370
  }
345
- /**
346
- * 把某个活着的 agent 切到某个模式。与 {@link select} 的差别:它不要求空白会话——子代理创建时的继承
347
- * 走这里,**未来的"指定 mode"入口(模型侧或配置侧)也走这里**(那条接缝还没做)。
348
- * @param agent - 目标 agent。
349
- * @param mode - 目标模式 id。
350
- * @param options.record - 是否把这次切换写进会话日志(缺省写;只想改当前进程时给 `false`)。
351
- */
352
371
  applyTo(agent, mode, options = {}) {
353
372
  this.definition(mode);
354
373
  if (options.record !== false) agent.session.append("session-mode/selected", { sessionMode: mode });
355
374
  this.installFor(agent, mode);
356
375
  }
357
- /**
358
- * 该 agent 用哪个模式:会话选过(投影上有)优先,子代理继承父,其余用部署默认。
359
- *
360
- * 继承要**写进子会话日志**:它是一条会话事实,冷恢复与 fork 都要靠它重建({@link modeOf} 只读投影)。
361
- */
362
376
  resolveModeId(agent) {
363
377
  const selected = this.ctx.sessionProjections.stateOf(agent.session, "sessionMode");
364
378
  if (typeof selected === "string") return selected;
@@ -372,7 +386,6 @@ var SessionModes = class extends Service {
372
386
  agent.session.append("session-mode/selected", { sessionMode: inherited });
373
387
  return inherited;
374
388
  }
375
- /** 官方 roster 选定的 preset(没选过、或 registry 没装时为 `undefined`)。 */
376
389
  presetOf(session) {
377
390
  try {
378
391
  const state = this.ctx.sessionProjections.stateOf(session, "agentPreset");
@@ -381,29 +394,17 @@ var SessionModes = class extends Service {
381
394
  return;
382
395
  }
383
396
  }
384
- /**
385
- * 某个 agent preset 对应的模式 id——**只在映射唯一时**回答(几个模式挂同一份 preset 时返回
386
- * `undefined`,不反查)。
387
- *
388
- * 本部署的两个模式共享同一个 preset(`MODE_PRESET_ID`),差异全在会话级收口,所以 preset → 模式的反查在
389
- * 这里无意义:模式由**会话事实**决定(`session-mode/selected` 投影 → 子代理继承 → 部署默认),
390
- * 官方 roster 选了什么 preset 不改变这个会话是哪个模式。这条反查留给"一对一映射"的部署形态。
391
- * @param preset - preset id(`undefined` 表示没选过、或 registry 没装)。
392
- * @returns 该 preset 唯一对应的模式 id;没配扩展、或由多个模式共享时 `undefined`。
393
- */
394
397
  modeForPreset(preset) {
395
398
  if (preset === void 0) return void 0;
396
399
  const owners = Object.keys(this.config.modes).filter((id) => this.config.modes[id]?.preset === preset);
397
400
  return owners.length === 1 ? owners[0] : void 0;
398
401
  }
399
- /** 子代理(有 durable 父会话)继承父当前模式;父不在场、或不是子代理时没有可继承的。 */
400
402
  inheritedModeId(agent) {
401
403
  const parentId = agent.session.header.parentSession;
402
404
  if (parentId === void 0) return void 0;
403
405
  const parent = this.ctx.agents.get(parentId);
404
406
  return parent === void 0 ? void 0 : this.modeOf(parent.session);
405
407
  }
406
- /** 装或换该 agent 的那一份(幂等:同一模式不重复注册)。 */
407
408
  installFor(agent, modeId = this.resolveModeId(agent)) {
408
409
  const installed = this.installs.get(agent);
409
410
  if (installed?.mode === modeId) return;
@@ -416,15 +417,15 @@ var SessionModes = class extends Service {
416
417
  for (const dispose of disposers) dispose();
417
418
  }
418
419
  });
419
- this.toolScope()?.apply(agent, mode);
420
+ this.scope.apply(agent, {
421
+ name: mode.name,
422
+ allowTools: mode.allowTools,
423
+ denyTools: mode.denyTools,
424
+ instructions: mode.instructions,
425
+ skills: mode.skills ?? derivedSkills(mode),
426
+ runtimeContext: mode.runtimeContext
427
+ });
420
428
  }
421
- /**
422
- * 模式的默认模型(`modes.<模式 id>.defaultModel`)兜底:只在会话**尚无任何模型事实**(没选过模型、也
423
- * 还没跑过请求)时接管这一请求的路由;一旦用户选过(投影 `pending`)或会话已经落过 header,就不再插手。
424
- *
425
- * 它是**配置事实**,不写会话事件——重启后仍由 config 决定;设置页里那条会话级选择才是会话事实。读的是构造
426
- * 时那份模式清单快照:设置页保存会让这一行重挂(`reconcileProfilePatches`),新定义随重挂生效。
427
- */
428
429
  installDefaultModel(agent, modeId) {
429
430
  return agent.ctx.on("agent/request", async (_payload, next) => {
430
431
  const resolved = await next();
@@ -441,14 +442,6 @@ var SessionModes = class extends Service {
441
442
  };
442
443
  });
443
444
  }
444
- /** 收口服务由 `@morlay/dsh-context-assembler/scope` 那一行发布;没装它就只有 persona。 */
445
- toolScope() {
446
- try {
447
- return this.ctx.get("sessionToolScope");
448
- } catch {
449
- return;
450
- }
451
- }
452
445
  };
453
446
  function apply(ctx, config) {
454
447
  registerHttpRoutes(ctx, new SessionModes(ctx, config));
@@ -499,12 +492,6 @@ async function handleRoute(modes, request, response) {
499
492
  respondJson(response, error instanceof TypeError ? 400 : 409, { error: message });
500
493
  }
501
494
  }
502
- /**
503
- * 把清单与切换挂到同一张宿主路由表上。
504
- *
505
- * `webServer` **必须等**:它可能比本行晚激活,而一次性 `ctx.get` 取到 `undefined` 之后不会再试一次——
506
- * 路由没注册的后果是请求落到静态资源 fallback,非 GET/HEAD 一律 405。
507
- */
508
495
  function registerHttpRoutes(ctx, modes) {
509
496
  ctx.inject(["webServer"], (scope) => {
510
497
  const webServer = scope.get("webServer");
@@ -517,4 +504,4 @@ function registerHttpRoutes(ctx, modes) {
517
504
  });
518
505
  }
519
506
  //#endregion
520
- export { Config, SESSION_MODE_PATH, SessionModes, apply, inject, name, sessionModeEditableProjection, sessionModeProjection };
507
+ export { Config, POLICY_NAMES, SESSION_MODE_PATH, SessionModes, apply, inject, name, sessionModeEditableProjection, sessionModeProjection };
package/dist/rows.d.cts CHANGED
@@ -1,38 +1,11 @@
1
- //#region src/mode-sources.d.ts
2
- /**
3
- * 模式定义的真源:`session-mode` 行的 `config` 由 `./rows.ts` 渲染(装配入口在 `packages/bundles/session-mode-profile`)。
4
- *
5
- * "自定义"就落在这份数据上——装配层(profile 的用户 patch 层)可以整体改写 `config.modes`,也可以只给
6
- * 某个模式换提示词或工具白名单,不需要任何插件行。
7
- *
8
- * `allowTools` 从 [`@morlay/dsh-agent-toolkit/rows`](../../../dsh-agent-toolkit/src/rows.ts) 的
9
- * `TOOLKIT_TOOL_NAMES` 派生(工具名与汉化同源):**行清单由本部署自己注册的那份 preset 提供**
10
- * ({@link MODE_PRESET_ID},两个模式共享它),白名单里那份 preset 没有的工具自动跳过。想改某一个模式的名单,
11
- * 直接在这条源数据里加/减。
12
- */
13
- /**
14
- * 本部署自己注册的那份 agent preset 的 id:两个模式共享它(行清单见
15
- * `packages/bundles/session-mode-profile`,`config.plugins` 引用 `@morlay/dsh-agent-toolkit/rows` 的
16
- * `TOOLKIT_PRESET_ROWS`)。
17
- *
18
- * 为什么自己注册而不是复用官方 preset:官方 `minimal` 没有 `tool-web`,`chat` 的白名单(提问 + 联网三件)
19
- * 收口后一件都不剩;而且官方 preset 自带的上游注入要在我们的开关之外做"让位",抢 `skill` 面还得把目录 kind
20
- * 换掉。行清单归我们之后,两个模式的**差异全在会话级收口**(persona / `allowTools` / 两个开关)。
21
- */
22
- declare const MODE_PRESET_ID = "mode-switch";
23
- //#endregion
24
1
  //#region src/rows.d.ts
25
- /** 一行装配条目:与 `cordis.patch.yml` 的顶层结构同形。 */
26
- interface PatchRow {
2
+ export interface PatchRow {
27
3
  readonly insert?: readonly RowEntry[];
28
4
  }
29
- /** `insert` 里的一个条目。 */
30
- interface RowEntry {
5
+ export interface RowEntry {
31
6
  readonly id: string;
32
7
  readonly name: string;
33
8
  readonly config?: Readonly<Record<string, unknown>>;
34
9
  }
35
- /** `session-mode` 行的装配:默认模式与各模式的扩展定义。 */
36
- declare function sessionModeRows(): readonly PatchRow[];
37
- //#endregion
38
- export { MODE_PRESET_ID, PatchRow, RowEntry, sessionModeRows };
10
+ export declare function sessionModeRows(): readonly PatchRow[];
11
+ //#endregion
package/dist/rows.d.mts CHANGED
@@ -1,38 +1,11 @@
1
- //#region src/mode-sources.d.ts
2
- /**
3
- * 模式定义的真源:`session-mode` 行的 `config` 由 `./rows.ts` 渲染(装配入口在 `packages/bundles/session-mode-profile`)。
4
- *
5
- * "自定义"就落在这份数据上——装配层(profile 的用户 patch 层)可以整体改写 `config.modes`,也可以只给
6
- * 某个模式换提示词或工具白名单,不需要任何插件行。
7
- *
8
- * `allowTools` 从 [`@morlay/dsh-agent-toolkit/rows`](../../../dsh-agent-toolkit/src/rows.ts) 的
9
- * `TOOLKIT_TOOL_NAMES` 派生(工具名与汉化同源):**行清单由本部署自己注册的那份 preset 提供**
10
- * ({@link MODE_PRESET_ID},两个模式共享它),白名单里那份 preset 没有的工具自动跳过。想改某一个模式的名单,
11
- * 直接在这条源数据里加/减。
12
- */
13
- /**
14
- * 本部署自己注册的那份 agent preset 的 id:两个模式共享它(行清单见
15
- * `packages/bundles/session-mode-profile`,`config.plugins` 引用 `@morlay/dsh-agent-toolkit/rows` 的
16
- * `TOOLKIT_PRESET_ROWS`)。
17
- *
18
- * 为什么自己注册而不是复用官方 preset:官方 `minimal` 没有 `tool-web`,`chat` 的白名单(提问 + 联网三件)
19
- * 收口后一件都不剩;而且官方 preset 自带的上游注入要在我们的开关之外做"让位",抢 `skill` 面还得把目录 kind
20
- * 换掉。行清单归我们之后,两个模式的**差异全在会话级收口**(persona / `allowTools` / 两个开关)。
21
- */
22
- declare const MODE_PRESET_ID = "mode-switch";
23
- //#endregion
24
1
  //#region src/rows.d.ts
25
- /** 一行装配条目:与 `cordis.patch.yml` 的顶层结构同形。 */
26
- interface PatchRow {
2
+ export interface PatchRow {
27
3
  readonly insert?: readonly RowEntry[];
28
4
  }
29
- /** `insert` 里的一个条目。 */
30
- interface RowEntry {
5
+ export interface RowEntry {
31
6
  readonly id: string;
32
7
  readonly name: string;
33
8
  readonly config?: Readonly<Record<string, unknown>>;
34
9
  }
35
- /** `session-mode` 行的装配:默认模式与各模式的扩展定义。 */
36
- declare function sessionModeRows(): readonly PatchRow[];
37
- //#endregion
38
- export { MODE_PRESET_ID, PatchRow, RowEntry, sessionModeRows };
10
+ export declare function sessionModeRows(): readonly PatchRow[];
11
+ //#endregion