@morlay/dsh-session-mode 0.0.15 → 0.0.16

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,104 @@
1
+ // 模式配置的唯一模型:与 `{dshHome}/modes/<id>.toml` 同构。**包括 `session` 列**——会话级覆盖不是另一套
2
+ // 逐条目结构,而是同一份 `ModeConfig` 的可覆盖字段子集。TOML 不写 `session`(它属于会话日志)。
3
+ //
4
+ // 两个边界,测试只写在这两处:
5
+ // - `resolveModeConfig(config, session)`:配置 + 会话覆盖 → 生效配置(上下文的过滤与转换就在这一步);
6
+ // - `diffModeConfig(config, view)`:面板的 ViewModel(也是 `ModeConfig` 的编辑副本)→ 会话覆盖差异。
7
+
8
+ import type { SessionMode } from "./modes.ts";
9
+
10
+ // 与 TOML 同构的模式配置:`skills` 在 schema 里可选,这里归一成布尔;`session` 是会话列。
11
+ // 字段全部必填:TOML 读进来也先归一到这个形状。
12
+ export interface ModeConfig {
13
+ readonly presetsOnly: string[];
14
+ readonly name: string;
15
+ readonly description: string;
16
+ readonly role: SessionMode["role"];
17
+ readonly persona: { readonly prefix: string; readonly suffix: string };
18
+ readonly allowTools: string[];
19
+ readonly denyTools: string[];
20
+ readonly allowSkills: string[];
21
+ readonly denySkills: string[];
22
+ readonly allowPolicies: string[];
23
+ readonly denyPolicies: string[];
24
+ readonly instructions: boolean;
25
+ readonly skills: boolean;
26
+ readonly dropSections?: string[];
27
+ readonly runtimeContext: boolean;
28
+ }
29
+
30
+ // 会话覆盖是**全量**的:一份完整配置,不是字段级 merge。
31
+ export type ModeSession = ModeConfig;
32
+
33
+ // 面板的 ViewModel 就是一份 `ModeConfig`:编辑它,"应用到会话"产出一份 `ModeSession`。
34
+ export type ModeViewModel = ModeConfig;
35
+
36
+ // 配置 + 会话覆盖 → 生效配置。数组整体替换(组引用就在数组里,不需要另一套展开语义)。
37
+ export function resolveModeConfig(
38
+ config: ModeConfig,
39
+ session: ModeSession | undefined,
40
+ ): ModeConfig {
41
+ return structuredClone(session ?? config);
42
+ }
43
+
44
+ // 全量比较:ViewModel 与配置相同 → 没有会话覆盖(undefined);不同 → 整份 ViewModel 作为覆盖。
45
+ export function diffModeConfig(config: ModeConfig, view: ModeConfig): ModeSession | undefined {
46
+ return JSON.stringify(config) === JSON.stringify(view) ? undefined : structuredClone(view);
47
+ }
48
+
49
+ // 一份配置在某个会话里的三面:配置本身、会话覆盖、二者叠加的生效配置。
50
+
51
+ // 把 TOML/行配置读进来的那半归一成完整 `ModeConfig`(`skills` 缺省按工具名单推导由调用方给默认)。
52
+ export function asModeConfig(mode: SessionMode, skills: boolean): ModeConfig {
53
+ // schema 产物里字段可能可选,这里统一归一成完整配置。
54
+ return { ...mode, skills } as unknown as ModeConfig;
55
+ }
56
+
57
+ // 面板草稿:三态按"组引用 / 条目 id"记录。`unset` = 从两边名单里都移除。
58
+ export interface ModeDraft {
59
+ readonly lists?: Readonly<
60
+ Record<"tools" | "skills" | "policies", Readonly<Record<string, "allow" | "deny" | "unset">>>
61
+ >;
62
+ readonly persona?: { prefix: string; suffix: string } | null;
63
+ readonly switches?: Readonly<Record<string, boolean | null>>;
64
+ }
65
+
66
+ // ViewModel 的修改 → 符合预期的 `ModeConfig`:`denyTools: ["mcp:agent-browser"]` + 草稿 unset → `denyTools: []`。
67
+ export function applyDraft(
68
+ config: ModeConfig,
69
+ draft: ModeDraft,
70
+ // 默认基座:`null`(回到默认)时取它的值。省略就是 `config` 自己。
71
+ defaults?: ModeConfig,
72
+ ): ModeConfig {
73
+ const view: Record<string, unknown> = { ...structuredClone(config) };
74
+ const lists = [
75
+ ["tools", "allowTools", "denyTools"],
76
+ ["skills", "allowSkills", "denySkills"],
77
+ ["policies", "allowPolicies", "denyPolicies"],
78
+ ] as const;
79
+ for (const [kind, allowKey, denyKey] of lists) {
80
+ const allow = new Set(view[allowKey] as string[]);
81
+ const deny = new Set(view[denyKey] as string[]);
82
+ for (const [id, value] of Object.entries(draft.lists?.[kind] ?? {})) {
83
+ if (value === "deny") deny.add(id);
84
+ else if (value === "allow") {
85
+ deny.delete(id);
86
+ if (allow.size > 0) allow.add(id);
87
+ } else {
88
+ // unset = 配置里不写这一项:两边名单都移除。
89
+ allow.delete(id);
90
+ deny.delete(id);
91
+ }
92
+ }
93
+ view[allowKey] = [...allow];
94
+ view[denyKey] = [...deny];
95
+ }
96
+ if (draft.persona !== undefined && draft.persona !== null) view["persona"] = draft.persona;
97
+ for (const [key, value] of Object.entries(draft.switches ?? {})) {
98
+ // `null` = 回到默认:取默认基座(`config`)的那一项,而不是保留会话覆盖里的旧值。
99
+ if (value !== null) view[key] = value;
100
+ else if (defaults !== undefined)
101
+ view[key] = (defaults as unknown as Record<string, unknown>)[key];
102
+ }
103
+ return view as unknown as ModeConfig;
104
+ }
@@ -32,6 +32,8 @@ export interface ModeSource {
32
32
  // 是否要技能目录(官方 `skill-catalog` 的注入):**可选**,不写就按这个模式自己的工具名单推导(名单里含 `skill`
33
33
  // 就要,见 `modes.ts` 的 `derivedSkills`)。`coding` 留空名单 = 要;`chat` 的三件里没有 `skill` = 不要。
34
34
  readonly skills?: boolean;
35
+ // 这个模式下要丢弃的 system prompt section。
36
+ readonly dropSections?: readonly string[];
35
37
  readonly runtimeContext?: boolean;
36
38
  // 这个模式的默认模型(省略就跟全局 `agent-default-model`)。
37
39
  readonly defaultModel?: ModeModelSource;
@@ -77,8 +79,7 @@ export const MODE_SOURCES: readonly ModeSource[] = [
77
79
  {
78
80
  id: "coding",
79
81
  name: "编码模式",
80
- description:
81
- "功能完整的编码 Agent:文件、Shell、检索、联网等工具常驻,其余用法说明按需加载。",
82
+ description: "功能完整的编码 Agent:文件、Shell、检索、联网等工具常驻,其余用法说明按需加载。",
82
83
  persona: CODING_PERSONA,
83
84
  // 用户可选,也允许作为子代理的 mode(子代理默认继承父 mode,不看角色;这里是"可被指定"的候选集)。
84
85
  role: ["main", "subagent"],
@@ -117,8 +118,7 @@ export const MODE_SOURCES: readonly ModeSource[] = [
117
118
  {
118
119
  id: NOOP_MODE_ID,
119
120
  name: "原样模式",
120
- description:
121
- "与上游一致:不加人格提示词、不收窄工具与技能、policy 规则全开、官方注入面照旧。",
121
+ description: "与上游一致:不加人格提示词、不收窄工具与技能、policy 规则全开、官方注入面照旧。",
122
122
  // 用户可选,也允许作为子代理的 mode:它不做任何过滤,给子代理当候选同样成立。
123
123
  role: ["main", "subagent"],
124
124
  // 其余字段一律不写:名单留空 = 不设收窄,`instructions` / `runtimeContext` 走 schema 默认 `true`,
@@ -0,0 +1,53 @@
1
+ // 模式配置的持久化:用户自己的模式定义落在 `{dshHome}/modes/<id>.toml`,cordis 上的 `config.modes` 只是
2
+ // **默认模板**。读的时候用户文件覆盖同 id 的模板;写的时候只动用户文件,不碰 profile 的 patch。
3
+
4
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
7
+ import { parse, stringify } from "smol-toml";
8
+
9
+ function modesDir(): string {
10
+ return dshHomePath("modes");
11
+ }
12
+
13
+ // 用户改过的模式:id → 覆盖模板的那份定义。坏文件按"没有"处理(模板继续生效)。
14
+ export async function loadModeOverrides(): Promise<Record<string, Record<string, unknown>>> {
15
+ const dir = modesDir();
16
+ const entries = await readdir(dir).catch(() => [] as string[]);
17
+ const overrides: Record<string, Record<string, unknown>> = {};
18
+ for (const name of entries) {
19
+ if (!name.endsWith(".toml")) continue;
20
+ const text = await readFile(join(dir, name), "utf8").catch(() => undefined);
21
+ if (text === undefined) continue;
22
+ try {
23
+ overrides[name.slice(0, -".toml".length)] = parse(text) as Record<string, unknown>;
24
+ } catch {
25
+ // 坏文件不阻断启动:同 id 继续用模板。
26
+ }
27
+ }
28
+ return overrides;
29
+ }
30
+
31
+ // `smol-toml` 只出单行字符串:把含换行的值改写成 TOML 多行字符串,persona 这类才读得动。
32
+ function prettyToml(text: string): string {
33
+ return text
34
+ .split("\n")
35
+ .map((line) => {
36
+ const match = /^(\s*[A-Za-z0-9_.-]+)\s*=\s*"((?:[^"\\]|\\.)*)"$/u.exec(line);
37
+ if (match === null) return line;
38
+ const [, key, raw] = match;
39
+ if (key === undefined || raw === undefined || !raw.includes("\\n")) return line;
40
+ const value = raw.replace(/\\n/gu, "\n").replace(/\\"/gu, '"').replace(/"""/gu, '\\"""');
41
+ return `${key} = """\n${value}"""`;
42
+ })
43
+ .join("\n");
44
+ }
45
+
46
+ export async function writeMode(id: string, value: Record<string, unknown>): Promise<void> {
47
+ await mkdir(modesDir(), { recursive: true });
48
+ await writeFile(join(modesDir(), `${id}.toml`), prettyToml(stringify(value)), "utf8");
49
+ }
50
+
51
+ export async function removeMode(id: string): Promise<void> {
52
+ await rm(join(modesDir(), `${id}.toml`), { force: true });
53
+ }
package/src/modes.ts CHANGED
@@ -37,7 +37,7 @@ export interface SessionMode {
37
37
  // (persona / 工具名单 / policy 名单 / 三个开关 / `defaultModel`)在任意 preset 上都照常生效。
38
38
  // 有值时切到这个模式会让会话的 preset 落进名单:当前那份已经在名单里就原样不动,否则换成名单里的**第一个**
39
39
  // (`modes.ts` 只声明"允许哪些",落在哪个由 `SessionModes.select` 决定)。多个模式可以共享同一份名单。
40
- readonly presetsOnly: string[];
40
+ readonly presetsOnly?: readonly string[];
41
41
  // 模式的展示名(选择面归官方 roster;这里留着做事实文案)。
42
42
  readonly name: string;
43
43
  // 一句话说明这个模式干什么;空串表示没写。
@@ -75,10 +75,13 @@ export interface SessionMode {
75
75
  // 是否要动态快照(文件沙箱策略、审批策略那两条 snapshot section,以及 `standard` 那类 preset 声明的时钟
76
76
  // `time-context` 注入)。缺省要;`false` 表示这个模式不要它们——对话模式没有文件与 shell 工具,"能改工作区
77
77
  // 哪些文件、要不要走审批"对它全是噪音,它也不谈时间。
78
+ // 这个模式下要丢弃的 system prompt section(精确名或前缀)。
79
+ readonly dropSections?: string[];
78
80
  readonly runtimeContext: boolean;
79
81
  // 这个模式的默认模型;省略就跟全局 `agent-default-model`。**可选**:没配的模式在页面上不出现在这一行
80
82
  // (`defaultModel` 是它所在模式的一个可加字段)。
81
83
  readonly defaultModel?: SessionModeModel;
84
+
82
85
  // 退役字段:单个 `preset` 已换成白名单 `presetsOnly`。这里留着只为**报错**(见 `configProblem`),页面上不出现。
83
86
  readonly preset?: string;
84
87
  }
@@ -90,6 +93,12 @@ export function derivedSkills(mode: Pick<SessionMode, "allowTools" | "denyTools"
90
93
  return allowed && !mode.denyTools.includes("skill");
91
94
  }
92
95
 
96
+ // 一份模式定义 → 归一化后的形状(每个字段都有值):内置默认与 `{dshHome}/modes/*.toml` 覆盖都走这条,
97
+ // 否则缺字段的 mode 会在收口里读到 `undefined`(`allow.length` 之类)。
98
+ export function normalizeMode(value: unknown): SessionMode {
99
+ return modeSchema(value as never) as SessionMode;
100
+ }
101
+
93
102
  // 每个字段都带 default:schema 的产物因此没有 `undefined` 键(`exactOptionalPropertyTypes` 下"缺省的键"
94
103
  // 会与可选属性对不上)。`description()` 的类型签名只声明 `string`,这里做一次类型放行。
95
104
  const localized = (text: { zh: string; en: string }): string => text as unknown as string;
@@ -134,7 +143,7 @@ const modelSchema = z.object({
134
143
 
135
144
  // 本包 config 的**源码形状**:装配层与设置页写的那个形状。
136
145
  export interface Config {
137
- // 新会话(还没选过模式的会话)用哪个模式。必须是 `modes` 里的一个 id。
146
+ // 新会话(还没选过模式的会话)用哪个模式。必须是内置或目录里的一个 id。
138
147
  readonly default: string;
139
148
  // 模式清单:id → 定义(含各自的 `defaultModel`)。顺序即选择器里的顺序(`Object.entries` 的插入序)。
140
149
  readonly modes: Record<string, SessionMode>;
@@ -147,8 +156,9 @@ export interface Config {
147
156
  //
148
157
  // `default` 与 `modes` 都是 volatile:默认模式与整份模式清单(含各自的 `defaultModel`)都在行配置页上。
149
158
  export interface ResolvedConfig {
159
+ // 装配期注入(测试/高级用法):用户配置的正确路径是 `{dshHome}/modes/*.toml`。
160
+ readonly modes?: Record<string, SessionMode>;
150
161
  readonly default: Volatile<string>;
151
- readonly modes: Volatile<Record<string, SessionMode>>;
152
162
  // 退役的顶层字段:解析后仍在这儿(普通值,不 volatile),装配期据此发现"还配着值"并报错。
153
163
  readonly models: SessionModeModels;
154
164
  }
@@ -156,17 +166,6 @@ export interface ResolvedConfig {
156
166
  const modeSchema: z<SessionMode> = z.object({
157
167
  // 退役字段:这里留着只为**报错**(见 `configProblem`),页面上不出现(`hidden()`)。不给默认:没配就没这个键。
158
168
  preset: z.string().hidden(),
159
- presetsOnly: z
160
- .array(z.string())
161
- // 候选是部署里注册的 agent preset(具名源 `agent-presets`):字段只说"这是选几个",不关心它从哪来。
162
- .role("select", { source: "agent-presets" })
163
- .default([])
164
- .description(
165
- localized({
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.",
168
- }),
169
- ),
170
169
  name: z
171
170
  .string()
172
171
  .required()
@@ -273,6 +272,15 @@ const modeSchema: z<SessionMode> = z.object({
273
272
  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`).",
274
273
  }),
275
274
  ),
275
+ dropSections: z
276
+ .array(z.string())
277
+ .default([])
278
+ .description(
279
+ localized({
280
+ zh: "这个模式下要丢弃的 system prompt section:写 section 名(精确匹配)或以 `*` 结尾的前缀(如 `mcp-*`)。",
281
+ en: "System prompt sections to drop for this mode: exact names, or prefixes ending in `*`.",
282
+ }),
283
+ ),
276
284
  runtimeContext: z
277
285
  .boolean()
278
286
  .default(true)
@@ -297,6 +305,7 @@ const modeSchema: z<SessionMode> = z.object({
297
305
  });
298
306
 
299
307
  export const Config: z<Config, ResolvedConfig> = z.object({
308
+ modes: z.dict(modeSchema).default({}),
300
309
  default: z
301
310
  .string()
302
311
  .required()
@@ -307,20 +316,6 @@ export const Config: z<Config, ResolvedConfig> = z.object({
307
316
  }),
308
317
  )
309
318
  .volatile(),
310
- modes: z
311
- .dict(modeSchema)
312
- .required()
313
- .description(
314
- localized({
315
- zh:
316
- "模式清单:id → 定义(persona / 工具名单 / 生效的 policy 规则 / 角色)。改它对**已运行会话**不自动生效" +
317
- "——重挂后新建的会话、或重新应用模式的会话才用新定义。",
318
- en:
319
- "Mode roster: id to definition (persona, tool lists, effective policy rules, role). Edits do not follow " +
320
- "into already-running sessions; sessions created after the row is remounted use the new definition.",
321
- }),
322
- )
323
- .volatile(),
324
319
  // 退役字段:默认模型住在每个模式自己的 `defaultModel` 里。这里留着是为了**报错**(见 `configProblem`),
325
320
  // 页面上不出现(`hidden()`)。
326
321
  models: z.dict(modelSchema).default({}).hidden(),
@@ -0,0 +1,53 @@
1
+ // 本行替某个 agent 装的那一组注册的**收回账**。
2
+ //
3
+ // 为什么需要它:`agent.ctx.tools.register()` / `agent.ctx.systemPrompt.section()` / `agent.ctx.on()` 把 effect
4
+ // 挂在 **agent 的 scope fiber** 上(作用域必须落在那个 agent 的 layer 里,这是这些 API 的契约),所以这些注册
5
+ // **不随本行卸载**。于是「上一次装的那一份」如果只记在当前实例的 WeakMap 里,一旦本行被重挂(设置保存会让
6
+ // Loader 重新配置这一行),新实例就没有上一份可收,再装一次就抛:
7
+ // `tool "describe_tool" is already registered in this scope`、
8
+ // `prompt section "deployment:persona-prefix" is already registered in this scope`。
9
+ //
10
+ // 所以收回账挂两处,任何一处先到都收干净:
11
+ // - **本行**(`ctx`):行卸载/重挂时把这份注册一起收;
12
+ // - **agent**(`agent.ctx`):本行通常比 agent 活得久,agent 卸载时把上面那笔行账一起撤掉,别攒着死 agent 的闭包。
13
+ //
14
+ // `release()` 是**同步**且幂等的:`apply` 重入时「旧的收完再装新的」靠的就是这一点(`inject` fiber 的
15
+ // `dispose()` 是异步的——它要等子 fiber 卸载——所以它只能算链上的一笔,不能当唯一收回路径)。
16
+
17
+ import type { Context } from "@deepseek-ai/cordis";
18
+ import type { Agent } from "@deepseek-ai/dsh-agent";
19
+
20
+ export interface PerAgentAccount {
21
+ // 记一笔收回动作。账已经收过时就地执行,晚到的注册因此不会漏。
22
+ add(stop: () => void): void;
23
+ // 收回这一份:同步、幂等。
24
+ release(): void;
25
+ }
26
+
27
+ export function perAgentAccount(ctx: Context, agent: Agent): PerAgentAccount {
28
+ const stoppers: (() => void)[] = [];
29
+ let released = false;
30
+ let rowAccount: () => void = () => {};
31
+ let agentAccount: () => void = () => {};
32
+
33
+ const release = (): void => {
34
+ if (released) return;
35
+ released = true;
36
+ for (const stop of stoppers.splice(0).reverse()) stop();
37
+ // 两笔账本身也要撤:行账在行卸载时由本行跑,agent 账在 agent 卸载时由 agent 跑;这里先撤一次,
38
+ // 免得每换一次模式就在 agent 的 fiber 上攒一条不再用到的清理项。
39
+ agentAccount();
40
+ rowAccount();
41
+ };
42
+
43
+ rowAccount = ctx.effect(() => () => release(), "session-mode: per-agent releases");
44
+ agentAccount = agent.ctx.effect(() => () => release(), "session-mode: per-agent releases");
45
+
46
+ return {
47
+ add: (stop) => {
48
+ if (released) stop();
49
+ else stoppers.push(stop);
50
+ },
51
+ release,
52
+ };
53
+ }
@@ -0,0 +1,97 @@
1
+ // `describe_tool`:PTC 程序里现取某组工具的 d.ts(不把全量签名塞进 system)。
2
+ //
3
+ // 上游 PTC 执行器只允许"程序内(nested)调用任意已注册工具",所以这里注册成普通工具即可:
4
+ // 程序里 `await describe_tool({ group: "fs" })` 拿到 `declare global { interface ToolsSdk { … } }`
5
+ // (成员就是 `tools` 上的属性;名字带引号的用 `tools["名字"](args)`)。
6
+
7
+ import { jsonSchemaToTs } from "@deepseek-ai/dsh-tool-executor-ptc";
8
+ import { SHORT_TOOL_DESCRIPTIONS } from "@morlay/dsh-tool-guidance/groups";
9
+ import { toolGroupOf } from "./shared.ts";
10
+ import { renderTools, type PtcToolSchema } from "./ptc-sdk.ts";
11
+
12
+ /** 一条工具的静态描述(描述文本 + 宽松的 args/output 类型)。 */
13
+ export interface DescribeEntry {
14
+ readonly name: string;
15
+ readonly description: string;
16
+ /** 工具的参数 JSON Schema。 */
17
+ readonly parametersSchema?: unknown;
18
+ /** 工具的输出 JSON Schema。 */
19
+ readonly outputSchema?: unknown;
20
+ }
21
+
22
+ // 按工具组分组;组名与面板上的分组一致(`mcp:agent-browser` 等)。
23
+ export function groupEntries(
24
+ entries: readonly DescribeEntry[],
25
+ ): Map<string, readonly PtcToolSchema[]> {
26
+ const groups = new Map<string, PtcToolSchema[]>();
27
+ for (const entry of entries) {
28
+ const group = toolGroupOf(entry.name);
29
+ const list = groups.get(group);
30
+ const schema: PtcToolSchema = {
31
+ name: entry.name,
32
+ // 中文短描述统一由 `dsh-tool-guidance` 维护;没有登记就退回工具自己的 description。
33
+ description: SHORT_TOOL_DESCRIPTIONS[entry.name] ?? entry.description,
34
+ // 参数与返回都按工具自己的 schema 转成 TS。
35
+ args: schemaText(entry.parametersSchema),
36
+ output: schemaText(entry.outputSchema),
37
+ };
38
+ if (list === undefined) groups.set(group, [schema]);
39
+ else list.push(schema);
40
+ }
41
+ return groups;
42
+ }
43
+
44
+ // 输出 schema → TS;转不了就是 `unknown`(`jsonSchemaToTs` 自己兜底)。
45
+ function schemaText(schema: unknown): string {
46
+ return schema === undefined ? "JsonValue" : jsonSchemaToTs(schema);
47
+ }
48
+
49
+ // 分组的简要说明(一句话,不展开成员)。
50
+ const GROUP_SUMMARY: Readonly<Record<string, string>> = {
51
+ fs: "文件读写",
52
+ shell: "执行命令",
53
+ "web-access": "联网检索与抓取",
54
+ ask: "向用户提问",
55
+ plan: "计划与待办",
56
+ swarm: "子代理与消息",
57
+ skill: "加载技能说明",
58
+ code: "代码执行(PTC)",
59
+ mcp: "MCP 资源",
60
+ onDemand: "按需工具",
61
+ other: "其它",
62
+ };
63
+
64
+ // 常用组的说明(中文):随 describe 一起返回,不占 system。
65
+ const GROUP_SECTION: Readonly<Record<string, string>> = {
66
+ collab:
67
+ "## 协作\n\n可以派发子代理、给队友发消息、等待队友;所有人共享同一个工作目录与文件系统,写操作要划分互不重叠的范围,必要时用共享任务记录范围与依赖。",
68
+ "mcp:resources":
69
+ "## MCP 资源\n\n按 URI 读取 MCP server 暴露的只读资源(文档、日志、数据);先 list 再 read。当前 server 都是 tools-only,通常没有可读资源。",
70
+ fs: "## 文件\n\n读、写、改文件与检索;改文件前先读当前内容,避免覆盖别人的改动。",
71
+ shell: "## Shell\n\n执行命令;不要用它绕过文件工具做批量改写。",
72
+ "web-access": "## 联网\n\n搜索与抓取网页。",
73
+ skill: "## 技能\n\n按需加载技能说明,再照着做。",
74
+ };
75
+
76
+ function sectionOfGroup(group: string): string {
77
+ return GROUP_SECTION[group] ?? "";
78
+ }
79
+
80
+ function summaryOf(group: string): string {
81
+ if (group.startsWith("mcp:")) return `MCP · ${group.slice("mcp:".length)}`;
82
+ return GROUP_SUMMARY[group] ?? "其它";
83
+ }
84
+
85
+ // 工具的执行体:不带 group 给「分组 + 一句话说明」,带 group 给该组的 d.ts 片段。
86
+ export function describeTools(entries: readonly DescribeEntry[], group?: string): string {
87
+ const groups = groupEntries(entries);
88
+ if (group === undefined) {
89
+ return [...groups.keys()]
90
+ .sort()
91
+ .map((name) => `${name} — ${summaryOf(name)}`)
92
+ .join("\n");
93
+ }
94
+ const declaration = renderTools(groups.get(group) ?? []);
95
+ const section = sectionOfGroup(group);
96
+ return section.length === 0 ? declaration : `${section}\n\n${declaration}`;
97
+ }
package/src/ptc-sdk.ts ADDED
@@ -0,0 +1,90 @@
1
+ // PTC 的 SDK 说明:**不**把全部工具铺成一个大 `ToolArgsMap`(那样工具一变、system 前缀就变)。
2
+ //
3
+ // - system 里只放稳定骨架:`interface ToolsSdk`(含 `describe_tool`)+ `declare const tools: ToolsSdk`;
4
+ // - 具体分组通过 `describe_tool({ group })` 现取现用(同一条 interface 的成员片段,多次 describe 合并);
5
+ // - 工具增删只影响 describe 的结果,不改 system。
6
+ //
7
+ // 成员形态与键的判据归 `@morlay/dsh-tool-guidance/groups`:MCP 的工具名带 `-`,函数声明表达不了。
8
+ import {
9
+ TOOLS_SDK_INTERFACE,
10
+ renderToolKey,
11
+ renderToolsDeclaration,
12
+ } from "@morlay/dsh-tool-guidance/groups";
13
+
14
+ // 一个工具在 SDK 文本里的最小信息(与上游 `ToolSdkSchema` 同形,这里只取要用的字段)。
15
+ export interface PtcToolSchema {
16
+ readonly name: string;
17
+ // 工具的一行说明(`dsh-tool-guidance` 维护的中文短描述)。
18
+ readonly description: string;
19
+ // 参数的 TypeScript 类型文本(已由调用方从 JSON Schema 转换好)。
20
+ readonly args: string;
21
+ // 输出的 TypeScript 类型文本。
22
+ readonly output: string;
23
+ // 该工具在 harness 里的说明 section(`tool:<name>`):随 describe 一起返回,不占 system。
24
+ readonly section?: string;
25
+ }
26
+
27
+ // 稳定的 system 骨架:所有具体工具都通过 describe 取,字节不随工具集变化。
28
+ // group 清单由当前可见工具的组现算,模型才知道有哪些组可取。
29
+ export function renderSkeleton(groups: readonly string[], members: readonly string[] = []): string {
30
+ const list =
31
+ groups.length === 0 ? "(当前没有可用的组)" : groups.map((g) => JSON.stringify(g)).join(" | ");
32
+ return [
33
+ '在 `run_code` 写的程序里调用工具:`await tools.name(args)`;名字带引号的成员用索引访问(`tools["名字"](args)`)。**常驻组**的签名已列在下面;其余组用 `describe_tool({ group })` 现取(不带 `group` 返回分组清单):',
34
+ "",
35
+ "```ts",
36
+ "declare global {",
37
+ ` interface ${TOOLS_SDK_INTERFACE} {`,
38
+ ...members,
39
+ ` describe_tool(args: { group?: ${list} }): Promise<{ text?: string }>`,
40
+ " }",
41
+ ` const tools: ${TOOLS_SDK_INTERFACE}`,
42
+ "}",
43
+ "```",
44
+ ].join("\n");
45
+ }
46
+
47
+ export const PTC_SDK_SKELETON = renderSkeleton([]);
48
+
49
+ // 一段工具声明:参数与返回都内联成 TS 类型(各自由 JSON Schema 转换而来)。
50
+ export function renderTools(schemas: readonly PtcToolSchema[]): string {
51
+ if (schemas.length === 0) return "";
52
+ const sorted = [...schemas].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
53
+ const declaration = renderToolsDeclaration(
54
+ sorted.map((schema) => ({ name: schema.name, args: schema.args, output: schema.output })),
55
+ );
56
+ // 带引号的键只在有非常规名字时出现:多出来的那句提示也跟着出现,模型不用自己猜怎么调。
57
+ const hint = sorted.some((schema) => renderToolKey(schema.name) !== schema.name)
58
+ ? '名字带引号的成员用索引访问:`tools["名字"](args)`。\n\n'
59
+ : "";
60
+ // section 与工具组一一对应:先给说明,再给 `ts` 代码块里的 d.ts。
61
+ const sections = schemas
62
+ .map((schema) => schema.section?.trim() ?? "")
63
+ .filter((text) => text.length > 0)
64
+ .join("\n\n");
65
+ const lead = sections.length === 0 ? "" : `${sections}\n\n`;
66
+ return `${lead}${hint}${["```ts", declaration, "```"].join("\n")}`;
67
+ }
68
+
69
+ // `describe_tool()` 的结果:不带参数返回分组清单,带参数返回该组的声明片段。
70
+ export function renderDescribe(
71
+ groups: ReadonlyMap<string, readonly PtcToolSchema[]>,
72
+ group?: string,
73
+ ): string {
74
+ if (group === undefined) {
75
+ return [...groups.keys()].sort().join("\n");
76
+ }
77
+ const schemas = groups.get(group);
78
+ if (schemas === undefined) return "";
79
+ return renderTools(schemas);
80
+ }
81
+
82
+ // PTC 装配下把上游那份全量 `tools:sdk` 换成稳定骨架;别的 section 原样保留。
83
+ export function replaceToolsSdk<T extends { readonly name: string; readonly text: string }>(
84
+ sections: readonly T[],
85
+ skeleton: string,
86
+ ): readonly T[] {
87
+ return sections.map((section) =>
88
+ section.name === "tools:sdk" ? { ...section, text: skeleton } : section,
89
+ );
90
+ }
package/src/rows.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // 本包作为能力包发布的**装配数据**:`session-mode` 行的 config(各模式的会话级扩展)由这里渲染,
2
2
  // 装配入口在 `packages/bundles/session-mode-profile`(它同时装注入通道、工具说明与 subagent 那几行)。
3
3
 
4
- import { DEFAULT_MODE, MODE_SOURCES, type ModeSource } from "./mode-sources.ts";
4
+ import { DEFAULT_MODE, type ModeSource } from "./mode-sources.ts";
5
5
 
6
6
  // 一行装配条目:与 `cordis.patch.yml` 的顶层结构同形。
7
7
  export interface PatchRow {
@@ -21,8 +21,7 @@ function list<T>(values: readonly T[] | undefined): readonly T[] | undefined {
21
21
  }
22
22
 
23
23
  // 一个模式的源定义 → 行 config(schema 的输入形状:可省的字段就省)。
24
- function modeConfig(source: ModeSource): Record<string, unknown> {
25
- const presetsOnly = list(source.presetsOnly);
24
+ export function modeConfig(source: ModeSource): Record<string, unknown> {
26
25
  const allowTools = list(source.allowTools);
27
26
  const denyTools = list(source.denyTools);
28
27
  const allowSkills = list(source.allowSkills);
@@ -30,8 +29,6 @@ function modeConfig(source: ModeSource): Record<string, unknown> {
30
29
  const allowPolicies = list(source.allowPolicies);
31
30
  const denyPolicies = list(source.denyPolicies);
32
31
  return {
33
- // `presetsOnly` 是可选的:不写(或写成空数组)就是这个键不进 config,schema 默认空数组(= 不限制)。
34
- ...(presetsOnly === undefined ? {} : { presetsOnly }),
35
32
  name: source.name,
36
33
  description: source.description,
37
34
  ...(source.role === undefined ? {} : { role: [...source.role] }),
@@ -49,6 +46,7 @@ function modeConfig(source: ModeSource): Record<string, unknown> {
49
46
  ...(source.instructions === undefined ? {} : { instructions: source.instructions }),
50
47
  // `skills` 不写就是不进 config:缺省由这份定义自己的工具名单推导(写成 `false` 才落进 config)。
51
48
  ...(source.skills === undefined ? {} : { skills: source.skills }),
49
+ ...(source.dropSections === undefined ? {} : { dropSections: [...source.dropSections] }),
52
50
  ...(source.runtimeContext === undefined ? {} : { runtimeContext: source.runtimeContext }),
53
51
  ...(source.defaultModel === undefined ? {} : { defaultModel: { ...source.defaultModel } }),
54
52
  };
@@ -62,11 +60,9 @@ export function sessionModeRows(): readonly PatchRow[] {
62
60
  {
63
61
  id: "session-mode",
64
62
  name: "@morlay/dsh-session-mode",
63
+ // 模式定义不再走 cordis 配置:内置默认(coding/chat/noop)+ `{dshHome}/modes/*.toml`。
65
64
  config: {
66
65
  default: DEFAULT_MODE,
67
- modes: Object.fromEntries(
68
- MODE_SOURCES.map((source) => [source.id, modeConfig(source)]),
69
- ),
70
66
  },
71
67
  },
72
68
  ],