@morlay/dsh-session-mode 0.0.7 → 0.0.9

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.
@@ -0,0 +1,149 @@
1
+ // 部署的 LLM 目录读数:provider 候选(活着的路由 + 可配置声明)与每个 provider 的模型清单(在声明指向的那份
2
+ // 配置里)。
3
+ //
4
+ // 两处消费它:提示面的 `llm-providers` / `llm-models` 两个具名源(行配置页与通用 schema 表单用),以及 bundle
5
+ // 配置页的模型路由清单——"已设置的模型"按**路由**给,一条路由就是 provider + model 这一对。
6
+
7
+ import type { Context } from "@deepseek-ai/cordis";
8
+ import type {} from "@deepseek-ai/dsh-api-remotes/client";
9
+
10
+ // 一条可选路由:`key` 是稳定身份(调用方只用来比对,不解析它)。
11
+ export interface ModelRoute {
12
+ readonly key: string;
13
+ readonly provider: string;
14
+ readonly providerName: string;
15
+ readonly model: string;
16
+ readonly modelName: string;
17
+ }
18
+
19
+ // 一个 provider 与它当前可用的模型。
20
+ export interface ProviderModels {
21
+ readonly value: string;
22
+ readonly label: string;
23
+ readonly models: readonly { readonly value: string; readonly label: string }[];
24
+ }
25
+
26
+ // 路由的稳定身份:provider 与 model 一起才是路由。
27
+ export function routeKey(provider: string, model: string): string {
28
+ return `${provider}\u0000${model}`;
29
+ }
30
+
31
+ // 客户端 remote 的命名空间是一个**独立服务**(服务名 `remote.<命名空间>`,gateway 的 client 半按 mount 注册),
32
+ // 不是 `remote` 服务上的属性——属性访问读到的是 `undefined`。读不到(这一版部署没装那个命名空间)就按"没有"处理。
33
+ export function remoteNamespace<T>(scope: Context, namespace: string): T | undefined {
34
+ try {
35
+ return (scope as unknown as { get(key: string): unknown }).get(`remote.${namespace}`) as
36
+ | T
37
+ | undefined;
38
+ } catch {
39
+ // 服务没注册时 `ctx.get` 抛错:按"这个命名空间不在这一版部署里"处理。
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ // LLM 命名空间里本包用到的那两个读面:活着的路由与可配置声明。
45
+ interface LlmReader {
46
+ listProviders: () => Promise<
47
+ { ok: true; value: readonly { id: string; name: string }[] } | { ok: false }
48
+ >;
49
+ listConfigurableProviders: () => Promise<
50
+ | {
51
+ ok: true;
52
+ value: readonly {
53
+ provider: string;
54
+ displayName: string;
55
+ settingsNs: string;
56
+ settingsPath: readonly string[];
57
+ }[];
58
+ }
59
+ | { ok: false }
60
+ >;
61
+ }
62
+
63
+ // 一份 provider 档案的读数面(模型清单住在声明指向的那份配置里)。
64
+ interface ConfigFormsReader {
65
+ get(ns: string): { getSnapshot(): { value: unknown } };
66
+ }
67
+
68
+ // 按路径读一段配置里的值。
69
+ function readAt(root: unknown, path: readonly string[]): unknown {
70
+ return path.reduce<unknown>((node, segment) => {
71
+ if (Array.isArray(node)) return node[Number(segment)];
72
+ if (typeof node !== "object" || node === null) return undefined;
73
+ return Reflect.get(node, segment);
74
+ }, root);
75
+ }
76
+
77
+ // 一个 provider 当前有哪些模型:声明给了配置地址就按路径读它的 `models`(每项取 `id`)。
78
+ function modelsOf(
79
+ entry: { settingsNs: string; settingsPath: readonly string[] },
80
+ forms: ConfigFormsReader,
81
+ ): readonly { value: string; label: string }[] {
82
+ if (entry.settingsNs === "") return [];
83
+ const profile = readAt(forms.get(entry.settingsNs).getSnapshot().value, entry.settingsPath);
84
+ const models =
85
+ typeof profile === "object" && profile !== null ? Reflect.get(profile, "models") : undefined;
86
+ if (!Array.isArray(models)) return [];
87
+ return models.flatMap((model) => {
88
+ if (typeof model !== "object" || model === null) return [];
89
+ const id = Reflect.get(model, "id");
90
+ if (typeof id !== "string") return [];
91
+ // 显示名优先取档案里的 `name`("DeepSeek V4.1 Flash @ Ollama Cloud" 这种),没有就用 id。
92
+ const name = Reflect.get(model, "name");
93
+ return [{ value: id, label: typeof name === "string" && name !== "" ? name : id }];
94
+ });
95
+ }
96
+
97
+ // 部署里"已设置"的 provider 与它们的模型:活着的路由与可配置声明合并(按 provider 去重,声明的显示名优先)。
98
+ export async function loadLlmProviders(scope: Context): Promise<readonly ProviderModels[]> {
99
+ const llm = remoteNamespace<LlmReader>(scope, "llm");
100
+ const forms = (scope as unknown as { get(key: string): unknown }).get("configForms") as
101
+ | ConfigFormsReader
102
+ | undefined;
103
+ if (llm === undefined || forms === undefined) return [];
104
+ const [routes, directory] = await Promise.all([
105
+ llm.listProviders(),
106
+ llm.listConfigurableProviders(),
107
+ ]);
108
+ if (!routes.ok || !directory.ok) return [];
109
+ const merged = new Map<
110
+ string,
111
+ { value: string; label: string; settingsNs: string; settingsPath: readonly string[] }
112
+ >();
113
+ for (const entry of directory.value) {
114
+ merged.set(entry.provider, {
115
+ value: entry.provider,
116
+ label: entry.displayName,
117
+ settingsNs: entry.settingsNs,
118
+ settingsPath: [...entry.settingsPath],
119
+ });
120
+ }
121
+ for (const route of routes.value) {
122
+ if (!merged.has(route.id)) {
123
+ merged.set(route.id, {
124
+ value: route.id,
125
+ label: route.name,
126
+ settingsNs: "",
127
+ settingsPath: [],
128
+ });
129
+ }
130
+ }
131
+ return [...merged.values()].map((entry) => ({
132
+ value: entry.value,
133
+ label: entry.label,
134
+ models: modelsOf(entry, forms),
135
+ }));
136
+ }
137
+
138
+ // 目录里的每一条路由(provider + 它有模型的每一项):bundle 配置页的模型清单用它。
139
+ export function routesOf(providers: readonly ProviderModels[]): readonly ModelRoute[] {
140
+ return providers.flatMap((provider) =>
141
+ provider.models.map((model) => ({
142
+ key: routeKey(provider.value, model.value),
143
+ provider: provider.value,
144
+ providerName: provider.label,
145
+ model: model.value,
146
+ modelName: model.label,
147
+ })),
148
+ );
149
+ }
@@ -11,7 +11,7 @@ export const zh = {
11
11
  model: "模型",
12
12
  modelHint: "模型 id。",
13
13
  reasoningEffort: "思考档位",
14
- reasoningEffortHint: "省略就跟服务商自己的默认。",
14
+ reasoningEffortHint: "默认就跟服务商自己的默认。",
15
15
  };
16
16
 
17
17
  export const en: Record<keyof typeof zh, string> = {
@@ -24,7 +24,7 @@ export const en: Record<keyof typeof zh, string> = {
24
24
  model: "Model",
25
25
  modelHint: "Model id.",
26
26
  reasoningEffort: "Reasoning effort",
27
- reasoningEffortHint: "Unset keeps the provider's own default.",
27
+ reasoningEffortHint: "Default keeps the provider's own default.",
28
28
  };
29
29
 
30
30
  export type SessionModeLocaleKey = keyof typeof zh;
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  type SessionMode,
26
26
  type SessionModeRole,
27
27
  } from "./modes.ts";
28
+ import { SessionModeCatalog } from "./catalog.ts";
28
29
  import { installPersona } from "./persona.ts";
29
30
  import { SessionScope } from "./scope.ts";
30
31
  import {
@@ -263,8 +264,8 @@ export class SessionModes extends Service {
263
264
  return undefined;
264
265
  }
265
266
 
266
- // 把某个空白会话(必须还没开过 turn)切到某个模式:先把 agent preset 换成模式声明的那个
267
- // (目标与当前相同时不切——换 preset 是一次重挂),再落会话事实并重装该 agent 的扩展。
267
+ // 把某个空白会话(必须还没开过 turn)切到某个模式:先按模式的白名单把 agent preset 落到位(当前那份已经在
268
+ // 名单里就不换——换 preset 是一次重挂;不在名单里就换成名单里的第一个),再落会话事实并重装该 agent 的扩展。
268
269
  async select(sessionId: SessionId, mode: string): Promise<string> {
269
270
  const definition = this.definition(mode);
270
271
  if (!definition.role.includes("main")) {
@@ -278,11 +279,12 @@ export class SessionModes extends Service {
278
279
  }
279
280
  const agent = this.ctx.agents.get(sessionId);
280
281
  const registry = this.presetRegistry();
281
- if (agent !== undefined && registry !== undefined && definition.preset.length > 0) {
282
- // 官方那条路自己也会查空白窗口(`agent-preset/locked`);它切完会 emit `agent-preset/selected`,
283
- // 监听据此落事实并重装——所以这里先比一次投影,同一个值不写第二条。
284
- if (this.presetOfAgent(registry, agent) !== definition.preset) {
285
- await registry.select(agent, definition.preset);
282
+ if (agent !== undefined && registry !== undefined) {
283
+ const target = this.presetTarget(definition.presetsOnly, this.presetOfAgent(registry, agent));
284
+ if (target !== undefined) {
285
+ // 官方那条路自己也会查空白窗口(`agent-preset/locked`);它切完会 emit `agent-preset/selected`,
286
+ // 监听据此落事实并重装——所以这里先比一次投影,同一个值不写第二条。
287
+ await registry.select(agent, target);
286
288
  }
287
289
  }
288
290
  if (this.ctx.sessionProjections.stateOf(session, "sessionMode") !== mode) {
@@ -293,6 +295,19 @@ export class SessionModes extends Service {
293
295
  return mode;
294
296
  }
295
297
 
298
+ // 把当前挂着的 preset 落进模式的白名单:已经在名单里就返回 `undefined`(不换);不在里面(或读不到当前那份)
299
+ // 就返回名单的**第一个**(`undefined` = 名单为空 = 不限制,什么都不换)。
300
+ private presetTarget(
301
+ allowed: readonly string[],
302
+ current: string | undefined,
303
+ ): string | undefined {
304
+ if (allowed.length === 0) return undefined;
305
+ // 当前那份在名单里:保持不动(用户自己选的 preset 落在许可范围内,模式不去覆盖它)。
306
+ if (current !== undefined && allowed.includes(current)) return undefined;
307
+ const target = allowed[0];
308
+ return target === current ? undefined : target;
309
+ }
310
+
296
311
  // 某个 agent 当前挂着的 preset(registry 的 `composedPreset`);读不到时按"未知"处理(该切就切)。
297
312
  private presetOfAgent(registry: Context["agentPresets"], agent: Agent): string | undefined {
298
313
  const read = (registry as { composedPreset?: (ctx: Context) => string | undefined })
@@ -355,12 +370,12 @@ export class SessionModes extends Service {
355
370
  }
356
371
  }
357
372
 
358
- // 某个 preset 对应的模式 id——只在映射唯一时回答(共享同一份 preset、或没有模式挂它时 `undefined`);
359
- // 本部署的模式由会话事实决定,这条反查留给"一对一映射"的部署形态。
373
+ // 某个 preset 对应的模式 id——只在映射唯一时回答(只有它一个模式的名单里有这份 preset;没有模式认领、或几个
374
+ // 模式的名单都含它时 `undefined`);本部署的模式由会话事实决定,这条反查留给"一对一映射"的部署形态。
360
375
  modeForPreset(preset: string | undefined): string | undefined {
361
376
  if (preset === undefined) return undefined;
362
- const owners = Object.keys(this.config.modes).filter(
363
- (id) => this.config.modes[id]?.preset === preset,
377
+ const owners = Object.keys(this.config.modes).filter((id) =>
378
+ this.config.modes[id]?.presetsOnly.includes(preset),
364
379
  );
365
380
  return owners.length === 1 ? owners[0] : undefined;
366
381
  }
@@ -429,6 +444,8 @@ export class SessionModes extends Service {
429
444
  export function apply(ctx: Context, config: ResolvedConfig): void {
430
445
  const modes = new SessionModes(ctx, config);
431
446
  registerHttpRoutes(ctx, modes);
447
+ // 配置页的工具 / 技能候选:一条本包自己开的 Remote 面(客户端半自己 mount,见 `./catalog-remote.ts`)。
448
+ ctx.plugin(SessionModeCatalog);
432
449
  }
433
450
 
434
451
  interface HttpRequestLike {
@@ -7,10 +7,10 @@ import type { PolicyName } from "./shared.ts";
7
7
  // 一个模式的源定义:就是 `session-mode` 行 `config.modes` 里的一项。
8
8
  export interface ModeSource {
9
9
  readonly id: string;
10
- // 挂哪个 agent preset(它的 `id`):**可选**,不写就是不绑——选这个模式不换 preset,会话保持它当前挂着的那份,
11
- // 行清单由那份 preset 提供(官方四个 shipped preset 照旧可选)。写了才在切模式时把 preset 切过去;
12
- // 模式定义里可以整体改写它(用户 patch 层)。
13
- readonly preset?: string;
10
+ // 这个模式**只允许**挂哪些 agent preset(它们的 `id`):**可选**,不写(留空)就是不限制——选这个模式不换 preset,
11
+ // 会话保持它当前挂着的那份,行清单由那份 preset 提供(官方四个 shipped preset 照旧可选)。有值时切模式会让 preset
12
+ // 落进名单:当前的已经在里面就不动,否则换成第一个。模式定义里可以整体改写它(用户 patch 层)。
13
+ readonly presetsOnly?: readonly string[];
14
14
  readonly name: string;
15
15
  readonly description: string;
16
16
  // 归谁用:`main`(用户选择器,缺省)、`subagent`(可作子代理 mode 的候选)。
@@ -44,13 +44,16 @@ export interface ModeModelSource {
44
44
  readonly reasoningEffort?: string;
45
45
  }
46
46
 
47
- // 编码模式:编程专家 + 语言与思考纪律 + 工作目录提醒。
47
+ // 编码模式:编程专家 + 语言与思考纪律。
48
+ // 这里**不再有**工作目录那半句(原为 `你的工作目录在 \`{{cwd}}\``):上游 0.2.1-alpha.2 起工作目录由
49
+ // `working-directory:current` 这条运行时上下文注入(文本是 `Current working directory: "<绝对路径>".`),
50
+ // 而 persona 用的提示词插值是**严格**的——`cwd` 变量已不再注册,残留 `{{cwd}}` 会在渲染时直接抛
51
+ // `unknown prompt variable`(注册变量只剩 provider / model)。
48
52
  const CODING_PERSONA = {
49
53
  prefix: [
50
54
  "你是一个经验丰富的编程专家,YAGNI 是你的编程哲学,PDCA 是你的行为规范。",
51
55
  "全程用中文(专有名词除外),包括但不限于思考,回答,工具描述,subagent 提示词;思考不要陷入重复循环,一旦循环立即退出;思考聚焦需求理解与方案设计,不预演具体代码实现,正确性由验证环节确认。",
52
56
  ].join("\n"),
53
- suffix: "你的工作目录在 `{{cwd}}`",
54
57
  };
55
58
 
56
59
  // 对话模式:一个助手,保留语言与思考纪律,没有 suffix。
@@ -59,11 +62,16 @@ const CHAT_PERSONA = {
59
62
  "你是一个助手。全程用中文(专有名词除外),包括但不限于思考,回答,工具描述;思考不要陷入重复循环,一旦循环立即退出。",
60
63
  };
61
64
 
65
+ // 原样模式:**一份什么都不加的扩展**——persona 空、名单全空、三个开关不写,装配结果与"没有模式"时一致。
66
+ // 它存在的理由有两个:想完全按上游默认跑的会话有个可选项;排查扩展干扰时有一个对照档(切到它就只剩上游行为)。
67
+ // 它同样是 `session-mode` 行的一份定义,所以模式自己的收口代码照常经过它——只是没有任何收口要求。
68
+ const NOOP_MODE_ID = "noop";
69
+
62
70
  // 新会话用哪个模式(`session-mode` 行的 `config.default`)。
63
71
  export const DEFAULT_MODE = "coding";
64
72
 
65
- // 两个模式:编码与对话。**都不绑定 preset**(差异全在会话级收口),行清单由会话挂着的 preset 提供:`coding`
66
- // 不收窄(用全部),`chat` 收成提问 + 联网三件。
73
+ // 三个模式:编码、对话与原样。**都不限制 preset**(差异全在会话级收口),行清单由会话挂着的那份提供:`coding`
74
+ // 不收窄(用全部),`chat` 收成提问 + 联网三件,`noop` 什么都不加。
67
75
  export const MODE_SOURCES: readonly ModeSource[] = [
68
76
  {
69
77
  id: "coding",
@@ -78,10 +86,7 @@ export const MODE_SOURCES: readonly ModeSource[] = [
78
86
  // 官方 Office 技能的三个名字同样按会话排除:技能目录里不列它们,`skill` 工具加载它们也被拒。
79
87
  // 名单是技能名这一层的模型可见契约(官方 `skill-office` 的 `SKILL_NAMES`),跟着上游改名走。
80
88
  denySkills: ["office-docx", "office-pptx", "office-xlsx"],
81
- //
82
- // `denyPolicies` 只禁 `fs/edit-intent`(上游那条"先读后改"):改文件不再要求先读过——写路径上的
83
- // `fs/write-intent`(陈旧版本 CAS 那层安全网)照旧生效,那正是这条配置不写成"两条都禁"的理由。
84
- denyPolicies: ["fs/edit-intent"],
89
+ denyPolicies: ["fs/edit-intent", "fs/write-intent"],
85
90
  },
86
91
  {
87
92
  id: "chat",
@@ -98,10 +103,22 @@ export const MODE_SOURCES: readonly ModeSource[] = [
98
103
  // 配了只是噪音,所以留空。
99
104
  // 没有文件与 shell 工具,"能改工作区哪些文件、要不要走审批"对它全是噪音。
100
105
  //
101
- // 两个抑制面都关:`instructions: false` 丢掉官方 `agent-instructions`(工作区指令)的注入;这个模式不写
106
+ // 注入面全关:`instructions: false` 丢掉官方 `agent-instructions`(工作区指令)的注入;这个模式不写
102
107
  // `skills`,而白名单三件里没有 `skill`、`denyTools` 也留空 → 推导成 `false`,官方 `skill-catalog` 的注入
103
- // 同样丢掉。取舍见 `.agents/designs/20260929-抑制官方注入面.md`。
108
+ // 同样丢掉;`runtimeContext: false` 连动态快照与时钟(`standard` 那类 preset 声明的 `time-context`)一起收。
109
+ // **收不掉的**:上游 `working-directory:current` 是 required 的目录上下文(0.2.1-alpha.2 起工作目录只走这条),
110
+ // 抑制可选运行时上下文不影响它——chat 会话的模型仍会看到工作目录那条快照。
111
+ // 取舍见 `.agents/designs/20260929-抑制官方注入面.md`。
104
112
  instructions: false,
105
113
  runtimeContext: false,
106
114
  },
115
+ {
116
+ id: NOOP_MODE_ID,
117
+ name: "原样模式",
118
+ description: "与上游一致:不加人格提示词、不收窄工具与技能、policy 规则全开、官方注入面照旧。",
119
+ // 用户可选,也允许作为子代理的 mode:它不做任何过滤,给子代理当候选同样成立。
120
+ role: ["main", "subagent"],
121
+ // 其余字段一律不写:名单留空 = 不设收窄,`instructions` / `runtimeContext` 走 schema 默认 `true`,
122
+ // `skills` 由工具名单推导(`allowTools` 留空 → 含 `skill` → 要目录),`presetsOnly` 留空 = 不限制、`defaultModel` 跟全局。
123
+ },
107
124
  ];
package/src/modes.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // 模式的定义形状与它的装配期校验:一个模式就是"一段提示词 + 一组能力开关",本行的 `config.modes` 声明它们。
2
- // 行清单来自 `preset` 指的那份 agent preset;装配层能整体改写 `modes`,也可以只给某几个模式换提示词或白名单。
2
+ // 行清单来自会话挂着的那份 agent preset(`presetsOnly` 只声明这个模式允许挂哪些);装配层能整体改写 `modes`,
3
+ // 也可以只给某几个模式换提示词或白名单。
3
4
  // 取舍见 `.agents/adrs/20260925-默认模型住在模式定义里.md`、`.agents/designs/20260924-会话模式.md`。
4
5
 
5
6
  import type { Volatile } from "@deepseek-ai/cordis";
@@ -31,11 +32,12 @@ export type SessionModeModels = Readonly<Record<string, SessionModeModel>>;
31
32
  // 一个模式:提示词 + 能力开关。这份形状是 **schema 归一化之后**的(每个字段都有值,schema 用默认补上),
32
33
  // 配置里能省略哪些字段看 `modeSchema` 的 default。
33
34
  export interface SessionMode {
34
- // 这个模式挂在哪个 **agent preset** 上(官方四个,或本部署自己注册的那一份)。**可选**:不写(schema 里是空串)
35
- // 就是不绑——选这个模式不换 preset,会话保持它当前挂着的那份,行清单由那份 preset 提供;模式自己的那几项扩展
35
+ // 这个模式**只允许**挂哪些 **agent preset**(官方四个,或本部署自己注册的那一份)。**留空(不写)表示不限制**:
36
+ // 选这个模式不换 preset,会话保持它当前挂着的那份;行清单由那份 preset 提供,模式自己的那几项扩展
36
37
  // (persona / 工具名单 / policy 名单 / 三个开关 / `defaultModel`)在任意 preset 上都照常生效。
37
- // 写了才在切模式时切过去;**可以共享**(共享时反查无从下手,见 `SessionModes.modeForPreset`)。
38
- readonly preset: string;
38
+ // 有值时切到这个模式会让会话的 preset 落进名单:当前那份已经在名单里就原样不动,否则换成名单里的**第一个**
39
+ // (`modes.ts` 只声明"允许哪些",落在哪个由 `SessionModes.select` 决定)。多个模式可以共享同一份名单。
40
+ readonly presetsOnly: string[];
39
41
  // 模式的展示名(选择面归官方 roster;这里留着做事实文案)。
40
42
  readonly name: string;
41
43
  // 一句话说明这个模式干什么;空串表示没写。
@@ -70,12 +72,15 @@ export interface SessionMode {
70
72
  // denyTools` 里含 `skill` 就要(`allowTools` 留空即"全部",所以只有 `denyTools` 能把它推成 `false`)。
71
73
  // `false` 表示这个模式不要技能目录:丢掉官方 `skill-catalog` 的注入(`skill` 工具的收窄仍归 `allowTools`)。
72
74
  readonly skills?: boolean;
73
- // 是否要 runtime context(文件沙箱策略、审批策略那两条动态快照)。缺省要;`false` 表示这个模式不要它们
74
- // ——对话模式没有文件与 shell 工具,"能改工作区哪些文件、要不要走审批"对它全是噪音。
75
+ // 是否要动态快照(文件沙箱策略、审批策略那两条 snapshot section,以及 `standard` 那类 preset 声明的时钟
76
+ // `time-context` 注入)。缺省要;`false` 表示这个模式不要它们——对话模式没有文件与 shell 工具,"能改工作区
77
+ // 哪些文件、要不要走审批"对它全是噪音,它也不谈时间。
75
78
  readonly runtimeContext: boolean;
76
79
  // 这个模式的默认模型;省略就跟全局 `agent-default-model`。**可选**:没配的模式在页面上不出现在这一行
77
80
  // (`defaultModel` 是它所在模式的一个可加字段)。
78
81
  readonly defaultModel?: SessionModeModel;
82
+ // 退役字段:单个 `preset` 已换成白名单 `presetsOnly`。这里留着只为**报错**(见 `configProblem`),页面上不出现。
83
+ readonly preset?: string;
79
84
  }
80
85
 
81
86
  // `skills` 不写时的推导:起点是白名单(留空 = 起点是全部工具,含 `skill`),减去黑名单,还留着 `skill` 就要技能
@@ -121,8 +126,8 @@ const modelSchema = z.object({
121
126
  ),
122
127
  reasoningEffort: z.string().description(
123
128
  localized({
124
- zh: "思考档位;省略就跟服务商自己的默认。",
125
- en: "Reasoning effort; unset keeps the provider's own default.",
129
+ zh: "思考档位;默认跟服务商自己的默认。",
130
+ en: "Reasoning effort; the default is the provider's own.",
126
131
  }),
127
132
  ),
128
133
  });
@@ -149,13 +154,17 @@ export interface ResolvedConfig {
149
154
  }
150
155
 
151
156
  const modeSchema: z<SessionMode> = z.object({
152
- preset: z
153
- .string()
154
- .default("")
157
+ // 退役字段:这里留着只为**报错**(见 `configProblem`),页面上不出现(`hidden()`)。不给默认:没配就没这个键。
158
+ preset: z.string().hidden(),
159
+ presetsOnly: z
160
+ .array(z.string())
161
+ // 候选是部署里注册的 agent preset(具名源 `agent-presets`):字段只说"这是选几个",不关心它从哪来。
162
+ .role("select", { source: "agent-presets" })
163
+ .default([])
155
164
  .description(
156
165
  localized({
157
- zh: "这个模式挂哪个 agent preset(它的 id,官方或本部署自建的):行清单由那份 preset 提供,几个模式可以共享同一个。留空就是不绑——选这个模式不换 preset,会话保持当前挂着的那份,模式自己的提示词、工具收口与开关照常生效。",
158
- 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.",
166
+ zh: "这个模式只允许挂哪些 agent preset(它们的 id,官方或本部署自建的):行清单由挂着的那份提供,几个模式可以共享同一份名单。留空表示**允许全部**——哪份 preset 都行,选这个模式也不动会话当前挂着的那份;有值时当前的已经在名单里就不动,否则换成名单里的第一个。模式自己的提示词、工具收口与开关照常生效。",
167
+ en: "Which agent presets this mode may ride on (their ids, shipped or deployment-owned): that preset supplies the row list, and several modes may share one list. Empty means **every preset is allowed** — selecting the mode then leaves the session's current preset alone; when set, the current one stays if it is listed, otherwise the session switches to the first entry. The mode's persona, tool narrowing, and switches apply either way.",
159
168
  }),
160
169
  ),
161
170
  name: z
@@ -188,6 +197,8 @@ const modeSchema: z<SessionMode> = z.object({
188
197
  persona: personaSchema.default({}),
189
198
  allowTools: z
190
199
  .array(z.string())
200
+ // 候选是部署里的工具名(具名源 `catalog-tools`,由 client 半注册):字段只说"这是选几个",不关心清单从哪来。
201
+ .role("select", { source: "catalog-tools" })
191
202
  .default([])
192
203
  .description(
193
204
  localized({
@@ -197,6 +208,7 @@ const modeSchema: z<SessionMode> = z.object({
197
208
  ),
198
209
  denyTools: z
199
210
  .array(z.string())
211
+ .role("select", { source: "catalog-tools" })
200
212
  .default([])
201
213
  .description(
202
214
  localized({
@@ -206,6 +218,8 @@ const modeSchema: z<SessionMode> = z.object({
206
218
  ),
207
219
  allowSkills: z
208
220
  .array(z.string())
221
+ // 候选是技能注册表里的名字(具名源 `catalog-skills`)。
222
+ .role("select", { source: "catalog-skills" })
209
223
  .default([])
210
224
  .description(
211
225
  localized({
@@ -215,6 +229,7 @@ const modeSchema: z<SessionMode> = z.object({
215
229
  ),
216
230
  denySkills: z
217
231
  .array(z.string())
232
+ .role("select", { source: "catalog-skills" })
218
233
  .default([])
219
234
  .description(
220
235
  localized({
@@ -224,6 +239,8 @@ const modeSchema: z<SessionMode> = z.object({
224
239
  ),
225
240
  allowPolicies: z
226
241
  .array(z.string())
242
+ // 候选是上游那两条 policy 名(具名源 `policies`)。
243
+ .role("select", { source: "policies" })
227
244
  .default([])
228
245
  .description(
229
246
  localized({
@@ -233,6 +250,7 @@ const modeSchema: z<SessionMode> = z.object({
233
250
  ),
234
251
  denyPolicies: z
235
252
  .array(z.string())
253
+ .role("select", { source: "policies" })
236
254
  .default([])
237
255
  .description(
238
256
  localized({
@@ -251,8 +269,8 @@ const modeSchema: z<SessionMode> = z.object({
251
269
  ),
252
270
  skills: z.boolean().description(
253
271
  localized({
254
- zh: "是否要技能目录(官方 `skill-catalog` 的注入)。**不写就按这个模式自己的工具名单推导**:`(allowTools 留空 ? 全部 : allowTools) − denyTools` 里含 `skill` 就要;写 `false` 就丢掉官方 `skill-catalog` 的注入(`skill` 工具的可见性仍归 `allowTools`)。",
255
- 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`).",
272
+ zh: "是否要技能目录(官方 `skill-catalog` 的注入)。**默认就按这个模式自己的工具名单推导**:`(allowTools 留空 ? 全部 : allowTools) − denyTools` 里含 `skill` 就要;写 `false` 就丢掉官方 `skill-catalog` 的注入(`skill` 工具的可见性仍归 `allowTools`)。",
273
+ en: "Whether the skill catalog applies (the official `skill-catalog` injection). The default 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`).",
256
274
  }),
257
275
  ),
258
276
  runtimeContext: z
@@ -260,21 +278,20 @@ const modeSchema: z<SessionMode> = z.object({
260
278
  .default(true)
261
279
  .description(
262
280
  localized({
263
- zh: "是否要动态快照(文件沙箱策略、审批策略)。",
264
- en: "Whether the runtime snapshot applies (sandbox and approval policy).",
281
+ zh: "是否要动态快照(文件沙箱策略、审批策略)与时钟注入(`standard` 那类 preset 声明的 `time-context`)。",
282
+ en: "Whether the runtime snapshot (sandbox and approval policy) and the clock injection (`time-context`, declared by presets such as `standard`) apply.",
265
283
  }),
266
284
  ),
267
285
  // 这个模式的默认模型。不标 `volatile`:`modes` 本身就是 volatile,整棵子树都在页面上——再标一层会被
268
286
  // schemastery 拒(`validateVolatileSchema` 不许 volatile 套 volatile)。
269
- defaultModel: modelSchema
270
- // `default(null)` 是"没配就没有这个键":schemastery 对缺省的对象字段会造一个空对象,那样每个模式都会
287
+ defaultModel: modelSchema // `default(null)` 是"没配就没有这个键":schemastery 对缺省的对象字段会造一个空对象,那样每个模式都会
271
288
  // 凭空多出一行;给了 null 反而让它保持缺失(页面按非必填处理,从候选加成)。
272
289
  // 类型上放行一次:`null` 在这里只是"没有这个键"的写法,schema 的输入形状不接受它。
273
290
  .default(null as unknown as SessionModeModel)
274
291
  .description(
275
292
  localized({
276
- zh: "这个模式的默认模型;省略就跟全局默认模型。只在会话还没有模型事实时接管。",
277
- en: "Default model for this mode; unset follows the global default. Applies only while a session has no model fact yet.",
293
+ zh: "这个模式的默认模型;不设就跟全局默认模型。只在会话还没有模型事实时接管。",
294
+ en: "Default model for this mode; empty follows the global default. Applies only while a session has no model fact yet.",
278
295
  }),
279
296
  ),
280
297
  });
@@ -316,7 +333,9 @@ interface Validated {
316
333
  Record<
317
334
  string,
318
335
  {
319
- // 空串合法的"不挂";共享合法(差异由会话级收口表达),所以这里不做任何映射唯一性校验。
336
+ // 留空合法的"不限制";允许共享(差异由会话级收口表达),所以这里不做任何映射唯一性校验。
337
+ readonly presetsOnly?: readonly string[];
338
+ // 退役的单个 preset:还配着值就报错(它已经不再生效)。
320
339
  readonly preset?: string;
321
340
  // 留空合法:不设收窄(用 preset 的全部工具)。
322
341
  readonly allowTools?: readonly string[];
@@ -369,11 +388,18 @@ export function configProblem(config: Validated): string | undefined {
369
388
  return `session-mode: mode(s) ${unknownPolicies.join(", ")} name unknown policies; the known ones are ${POLICY_NAMES.join(", ")}`;
370
389
  }
371
390
  // `allowTools` **留空是合法的**:不设收窄,用这个会话挂着的 preset 的全部工具。
372
- // `preset` **允许共享**(共享时 preset → 模式的反查交给 `SessionModes.modeForPreset`),空串是"不挂";
373
- // 退役的顶层 `models` 还配着值就报错——别让一份"看着像配过"的配置静静地失效。
391
+ // `presetsOnly` **允许留空**(不限制会话挂哪份 preset)**也允许共享**(共享时 preset → 模式的反查交给
392
+ // `SessionModes.modeForPreset`);退役的顶层 `models` 还配着值就报错——别让一份"看着像配过"的配置静静地失效。
374
393
  if (Object.keys(config.models ?? {}).length > 0) {
375
394
  return "session-mode: `models` has moved into each mode's `defaultModel`; move the entries there and drop the top-level `models`";
376
395
  }
396
+ // 退役的单个 `preset`(现在是白名单 `presetsOnly`):旧写法照旧"看着像配过"却不再生效,所以装配期拒绝。
397
+ const retiredPresets = Object.entries(config.modes)
398
+ .filter(([, mode]) => mode.preset !== undefined)
399
+ .map(([id]) => id);
400
+ if (retiredPresets.length > 0) {
401
+ return `session-mode: mode(s) ${retiredPresets.join(", ")} still declare \`preset\`; it has moved to \`presetsOnly\` (the presets that mode may ride on)`;
402
+ }
377
403
  // 每个模式自己的默认模型:成对给全(`provider` 与 `model` 都要)。
378
404
  const partial = Object.entries(config.modes)
379
405
  .filter(([, mode]) => {
package/src/rows.ts CHANGED
@@ -22,6 +22,7 @@ function list<T>(values: readonly T[] | undefined): readonly T[] | undefined {
22
22
 
23
23
  // 一个模式的源定义 → 行 config(schema 的输入形状:可省的字段就省)。
24
24
  function modeConfig(source: ModeSource): Record<string, unknown> {
25
+ const presetsOnly = list(source.presetsOnly);
25
26
  const allowTools = list(source.allowTools);
26
27
  const denyTools = list(source.denyTools);
27
28
  const allowSkills = list(source.allowSkills);
@@ -29,8 +30,8 @@ function modeConfig(source: ModeSource): Record<string, unknown> {
29
30
  const allowPolicies = list(source.allowPolicies);
30
31
  const denyPolicies = list(source.denyPolicies);
31
32
  return {
32
- // `preset` 是可选的:不写就是不绑(行 config 里也不出现这个键,schema 默认空串)。
33
- ...(source.preset === undefined ? {} : { preset: source.preset }),
33
+ // `presetsOnly` 是可选的:不写(或写成空数组)就是这个键不进 config,schema 默认空数组(= 不限制)。
34
+ ...(presetsOnly === undefined ? {} : { presetsOnly }),
34
35
  name: source.name,
35
36
  description: source.description,
36
37
  ...(source.role === undefined ? {} : { role: [...source.role] }),