@morlay/dsh-context-assembler 0.0.1-alpha.2

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,129 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import type { AssembledSection } from "@deepseek-ai/dsh-system-prompt";
4
+ import { ContextAssembler, type PromptEntry } from "./channel.ts";
5
+ import { DEFAULT_KEEP, DEFAULT_REPLACE, DEFAULT_SUPPRESS } from "./defaults.ts";
6
+ import { RULES_SECTION, latestReminderText, reminderMessage } from "./reminder.ts";
7
+ import { RULES_TEXT } from "./rules.ts";
8
+
9
+ export { DEFAULT_KEEP, DEFAULT_REPLACE, DEFAULT_SUPPRESS } from "./defaults.ts";
10
+ export type {
11
+ InjectionMode,
12
+ PromptEntry,
13
+ PromptRuleDeclaration,
14
+ PromptSkillDeclaration,
15
+ } from "./channel.ts";
16
+ export { ContextAssembler } from "./channel.ts";
17
+ export { renderVirtualSkill } from "./reminder.ts";
18
+
19
+ export const name = "context-assembler";
20
+
21
+ /** 规则声明的位置:紧跟部署 persona(order 0),在任何降级内容之前。 */
22
+ const RULES_ORDER = 1;
23
+
24
+ export const inject = ["systemPrompt", "skills"];
25
+
26
+ export interface Config {
27
+ /** 留在系统提示词里的 section 名;其余非空 section 降级为 reminder。 */
28
+ keep?: string[];
29
+ /** 不进提示词的 section 名(部署级噪音)。 */
30
+ suppress?: string[];
31
+ /** 装配结果上改写的 section 文本(section 名 → 中文文案)。 */
32
+ replace?: Record<string, string>;
33
+ }
34
+
35
+ export const Config: z<Config> = z.object({
36
+ keep: z.array(z.string()).default([...DEFAULT_KEEP]),
37
+ suppress: z.array(z.string()).default([...DEFAULT_SUPPRESS]),
38
+ replace: z.dict(z.string()).default({ ...DEFAULT_REPLACE }),
39
+ });
40
+
41
+ /**
42
+ * 提示词注入的唯一通道:system prompt 里只留 `keep`,其余内容按声明的方式到达模型——
43
+ * 降级为紧随用户消息的 reminder、或直接丢弃(写进按需 skill 正文是调用方自己的事)。
44
+ */
45
+ export function apply(ctx: Context, config: Config): void {
46
+ const channel = new ContextAssembler(ctx);
47
+ const keep = new Set(config.keep ?? DEFAULT_KEEP);
48
+ for (const name of config.suppress ?? DEFAULT_SUPPRESS) channel.suppressSection(name);
49
+ for (const [name, text] of Object.entries(config.replace ?? DEFAULT_REPLACE)) {
50
+ channel.replaceSection(name, () => text);
51
+ }
52
+
53
+ ctx.systemPrompt.section({ name: RULES_SECTION, order: RULES_ORDER, text: RULES_TEXT });
54
+
55
+ ctx.on("system-prompt/assemble", async (_assembly, context, next) => {
56
+ const result = await next();
57
+ const agent = context.agent;
58
+ const demoted = new Map<string, string>();
59
+ const sections: AssembledSection[] = [];
60
+ let changed = false;
61
+
62
+ for (const section of result.sections) {
63
+ if (channel.isSuppressed(section.name)) {
64
+ changed = true;
65
+ continue;
66
+ }
67
+ const text = channel.replacement(section.name, agent) ?? section.text;
68
+ if (text !== section.text) changed = true;
69
+ if (keep.has(section.name) || text.length === 0) {
70
+ sections.push(text === section.text ? section : { ...section, text });
71
+ continue;
72
+ }
73
+ changed = true;
74
+ demoted.set(sectionId(section.name), text);
75
+ }
76
+
77
+ if (agent !== undefined) channel.sync(agent, { sections: demoted });
78
+ return changed ? { ...result, sections } : result;
79
+ });
80
+
81
+ ctx.on("agent/pre-step", async ({ agent, messages }, next) => {
82
+ const decision = await next();
83
+
84
+ if (decision.kind === "reject" || decision.messages.length === 0) return decision;
85
+ const pending = await pendingEntries(agent, channel);
86
+ if (pending.length === 0) return decision;
87
+
88
+ const claimedEnd = decision.messages.findLastIndex((message) => messages.includes(message));
89
+ return {
90
+ ...decision,
91
+ messages: decision.messages.toSpliced(
92
+ claimedEnd + 1,
93
+ 0,
94
+ ...pending.map(([key, entry]) => reminderMessage(key, entry.text, entry.source)),
95
+ ),
96
+ };
97
+ });
98
+
99
+ ctx.on(
100
+ "agent/request-error",
101
+ async ({ agent, signal }, next) => {
102
+ const action = await next();
103
+ if (action?.kind !== "retry" || signal.aborted) return action;
104
+ for (const [key, entry] of await pendingEntries(agent, channel)) {
105
+ agent.session.append("user/message", reminderMessage(key, entry.text, entry.source), {
106
+ surfaceOp: "append",
107
+ });
108
+ }
109
+ return action;
110
+ },
111
+ { prepend: true },
112
+ );
113
+ }
114
+
115
+ /** 降级 section 的条目 id:一条 section 一个 id,于是同一次变化只重发那一条。 */
116
+ function sectionId(name: string): string {
117
+ return `section:${name}`;
118
+ }
119
+
120
+ /** 本步要注入的条目:键相同且文本未变就不注入,按键字典序(顺序不随注册顺序抖动)。 */
121
+ async function pendingEntries(
122
+ agent: Parameters<typeof latestReminderText>[0],
123
+ channel: ContextAssembler,
124
+ ): Promise<[string, PromptEntry][]> {
125
+ const entries = await channel.collect(agent);
126
+ return [...entries]
127
+ .filter(([key, entry]) => latestReminderText(agent, key) !== entry.text)
128
+ .toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
129
+ }
@@ -0,0 +1,92 @@
1
+ import type { Agent } from "@deepseek-ai/dsh-agent";
2
+ import { createUserMessage, type MessageSource } from "@deepseek-ai/dsh-llm";
3
+ import type { UserMessage } from "@deepseek-ai/dsh-session";
4
+
5
+ const REMINDER_OPEN = "<system-reminder";
6
+ const REMINDER_CLOSE = "</system-reminder>";
7
+
8
+ /**
9
+ * 覆盖规则在系统提示词里声明一次(见 [`rules.ts`](./rules.ts)),所以每条 reminder 只带 id 与正文,
10
+ * 不再重复解释"最新一条覆盖更早的"。
11
+ */
12
+ export const RULES_SECTION = "assembler:rules";
13
+
14
+ /** 通道自己的条目形态(注入方没声明 source 时的默认):规则块,幂等键是 id。 */
15
+ export interface PromptReminderSource {
16
+ kind: "context-assembler";
17
+ form: "instructions";
18
+ /** 条目 id:同 id 的最新一条取代更早的同 id 条目。本插件早先落库的消息没有它。 */
19
+ id?: string;
20
+ }
21
+
22
+ declare module "@deepseek-ai/dsh-llm" {
23
+ interface MessageSourceMap {
24
+ "context-assembler": PromptReminderSource;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * 这条消息是不是通道注入的条目——判据是 source 里的幂等键,而不是 kind:接管上游那两面的条目
30
+ * 用上游 kind(`agent-instructions` / `skill-catalog`),幂等必须照样认。
31
+ */
32
+ export function promptEntryIdOf(source: MessageSource): string | undefined {
33
+ const id = (source as { readonly id?: unknown }).id;
34
+ return typeof id === "string" ? id : undefined;
35
+ }
36
+
37
+ function escapeFrameBody(body: string): string {
38
+ return body.replaceAll(REMINDER_CLOSE, "<\\/system-reminder>");
39
+ }
40
+
41
+ function escapeAttr(value: string): string {
42
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
43
+ }
44
+
45
+ export function renderReminder(id: string, body: string): string {
46
+ return `${REMINDER_OPEN} id="${escapeAttr(id)}">\n${escapeFrameBody(body)}\n${REMINDER_CLOSE}`;
47
+ }
48
+
49
+ /**
50
+ * 虚拟 skill(运行时注册、没有资源目录)的正文形态:只有 `<skill_instructions>`,
51
+ * 不渲染上游 `renderSkillContent` 的 `<skill_resources>`(那段对虚拟 skill 是噪音)。
52
+ */
53
+ export function renderVirtualSkill(name: string, content: string): string {
54
+ return [
55
+ `<skill_content name="${escapeAttr(name)}">`,
56
+ "<skill_instructions>",
57
+ content,
58
+ "</skill_instructions>",
59
+ "</skill_content>",
60
+ ].join("\n");
61
+ }
62
+
63
+ /** 这条消息是不是通道注入的条目(判据是 source 里的幂等键,见 `promptEntryIdOf`)。 */
64
+ export function isPromptReminder(message: UserMessage): boolean {
65
+ return promptEntryIdOf(message.source) !== undefined;
66
+ }
67
+
68
+ /** surface 上最近一条该 id 的 reminder 原文(含信封);没有则 undefined。 */
69
+ export function latestReminderText(agent: Agent, id: string): string | undefined {
70
+ for (const seq of agent.session.surface.nodes.toReversed()) {
71
+ const event = agent.session.eventAt(seq);
72
+ if (event?.type !== "user/message") continue;
73
+ if (promptEntryIdOf(event.data.source) !== id) continue;
74
+ const [block] = event.data.content;
75
+ return event.data.content.length === 1 && block?.type === "text" ? block.text : "";
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * 注入一条提醒:`text` 是已渲染好的完整正文(规则块或内容块),`key` 是它的幂等键。
82
+ * `source` 声明这条消息对外的身份——接管上游那两面用上游 kind,我们自己的条目留默认。
83
+ */
84
+ export function reminderMessage(key: string, text: string, source?: MessageSource): UserMessage {
85
+ return createUserMessage({
86
+ content: [{ type: "text", text }],
87
+ source: {
88
+ ...(source ?? { kind: "context-assembler", form: "instructions" }),
89
+ id: key,
90
+ } as MessageSource,
91
+ });
92
+ }
@@ -0,0 +1,14 @@
1
+ import { RULES_SECTION } from "./reminder.ts";
2
+
3
+ /**
4
+ * 覆盖规则的声明文本:写进**系统提示词**(不在 reminder 里重复),所以每条 reminder 只带 id 与正文。
5
+ *
6
+ * 为什么放系统提示词而不是首条 reminder:它是固定文本(不随会话变,不付缓存代价),而且放在这里
7
+ * 才是系统级规则该在的位置——首条 reminder 自身会被压缩、被覆盖,权威性更低。
8
+ */
9
+ export const RULES_TEXT = [
10
+ "带 id 的 system-reminder 块是本系统提示词的一部分,持续有效:同 id 只有最新一条生效并取代更早的,不同 id 互不影响。",
11
+ "skill_content 是要遵循的技能说明(随提示送来的与你后来加载的一样),file_content 是本步引用的文件内容。",
12
+ ].join("");
13
+
14
+ export { RULES_SECTION };
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 组装出口:按 config 把各能力装成一个 cordis 插件树。
3
+ *
4
+ * 装配面因此只有**一行**(`@morlay/dsh-context-assembler`):`isolate` 是行级选项,一行声明就覆盖它整棵子树,
5
+ * 而这里 `ctx.plugin()` 出来的子插件都继承行 ctx 的 isolate map 与 scope——通道服务仍在 preset 的
6
+ * realm 里、`scope` 的归属判据仍按 preset scope 成立。**每个子插件各有自己的 `inject`**(合成单入口会
7
+ * 让 inject 变并集,一个可选搭档缺席就拖垮整包),这是这层组装唯一必须守住的东西。
8
+ *
9
+ * `coding` 不带 config(完整一套);`chat` 用 `capabilities` 裁掉不要的能力、用 `options` 给留下的
10
+ * 那些传参(能力名就是子出口名)。
11
+ */
12
+ import type { Context } from "@deepseek-ai/cordis";
13
+ import z from "@deepseek-ai/schemastery";
14
+ import * as agentInstructions from "./agent-instructions/index.ts";
15
+ import * as assembler from "./assembler/index.ts";
16
+ import * as scope from "./scope/index.ts";
17
+ import * as skillCatalog from "./skill-catalog/index.ts";
18
+
19
+ export const name = "context-assembler-tree";
20
+
21
+ const CAPABILITIES = {
22
+ assembler,
23
+ "agent-instructions": agentInstructions,
24
+ "skill-catalog": skillCatalog,
25
+ scope,
26
+ } as const;
27
+
28
+ export type Capability = keyof typeof CAPABILITIES;
29
+
30
+ const CAPABILITY_NAMES = Object.keys(CAPABILITIES) as Capability[];
31
+
32
+ export interface Config {
33
+ /** 装哪些能力(名字即子出口名);缺省全部——标准模式要完整的一套。未知名字在装载时报错。 */
34
+ capabilities?: string[];
35
+ /** 各能力自己的 config,按能力名给(例如 `{ scope: { allowTools: [...] } }`)。 */
36
+ options?: Record<string, Record<string, unknown>>;
37
+ }
38
+
39
+ export const Config: z<Config> = z.object({
40
+ capabilities: z.array(z.string()).default([...CAPABILITY_NAMES]),
41
+ options: z.any().default({}),
42
+ });
43
+
44
+ export function apply(ctx: Context, config: Config): void {
45
+ const wanted = config.capabilities ?? CAPABILITY_NAMES;
46
+ const options = config.options ?? {};
47
+ for (const name of wanted) {
48
+ const capability = CAPABILITIES[name as Capability];
49
+ if (capability === undefined) {
50
+ throw new Error(
51
+ `context: unknown capability "${name}"(可选:${CAPABILITY_NAMES.join(" / ")})`,
52
+ );
53
+ }
54
+ ctx.plugin(capability, options[name] ?? {});
55
+ }
56
+ }
package/src/rows.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 本包作为 **bundle** 的行清单:`dsh.profile.bundles` 列出本包时由它渲染成 `cordis.patch.yml`。
3
+ *
4
+ * 通道装在 host 平面,整份部署共享一套注入。**不做隔离**——工具说明
5
+ * (`@morlay/dsh-agent-toolkit/guidance`,在别的包里)与 skill 目录这类消费者都在别的行上
6
+ * `inject` 它,隔离会把它们挡在组外(行停在 waiting,不报错)。
7
+ *
8
+ * preset 只装 `scopeRow()`(模式的开关):它 `inject` 通道,`ctx.get("contextAssembler")` 在 agent
9
+ * 子树里可达,不需要另装一份通道。
10
+ */
11
+
12
+ /** 组装出口(包根)就是那个"按 config 装能力"的插件。 */
13
+ export const CONTEXT_PACKAGE = "@morlay/dsh-context-assembler";
14
+
15
+ /** 一行 plugin entry(本模块只描述形状,类型由消费方自己那份行类型决定)。 */
16
+ export interface ContextRow {
17
+ readonly id: string;
18
+ readonly name: string;
19
+ readonly config?: Readonly<Record<string, unknown>>;
20
+ }
21
+
22
+ /**
23
+ * 通道那一行:包的主出口是**组装插件**——按 config 决定装哪些能力、各带什么参数,所以装配面
24
+ * 只有这一行,组成与各能力的 config 都归包内(缺省即完整的一套;`chat` 用它裁剪)。
25
+ */
26
+ export function contextChannel(config?: Readonly<Record<string, unknown>>): ContextRow {
27
+ return {
28
+ id: "context-assembler",
29
+ name: CONTEXT_PACKAGE,
30
+ ...(config === undefined ? {} : { config }),
31
+ };
32
+ }
33
+
34
+ /**
35
+ * 模式开关那一行:`scope` 出口按 preset 配(工具白名单、instruction 总开关、动态快照开关)。
36
+ *
37
+ * 通道在 profile 平面装一次,这一行只按模式收口(它 `inject` `contextAssembler`,
38
+ * `ctx.get("contextAssembler")` 在 agent 子树里可达)。
39
+ */
40
+ export function scopeRow(config?: Readonly<Record<string, unknown>>): {
41
+ readonly id: string;
42
+ readonly name: string;
43
+ readonly config?: Readonly<Record<string, unknown>>;
44
+ } {
45
+ return {
46
+ id: "context-scope",
47
+ name: "@morlay/dsh-context-assembler/scope",
48
+ ...(config === undefined ? {} : { config }),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * bundle patch 的行清单:通道一行(`scope` 是模式的开关,由 preset 装 `scopeRow()`)。
54
+ *
55
+ * 形态是 `insert`:这些行由本包提供给 host 平面。写成 `- id: x` 那种"改已有行"的形态时,装配期找不到
56
+ * 目标行(只 warn 后跳过),通道根本没装上,引用它的行停在 waiting。
57
+ */
58
+ export const PATCH_ROWS: readonly { readonly insert: readonly ContextRow[] }[] = [
59
+ {
60
+ insert: [
61
+ contextChannel({ capabilities: ["assembler", "agent-instructions", "skill-catalog"] }),
62
+ ],
63
+ },
64
+ ];
@@ -0,0 +1,118 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type { Agent } from "@deepseek-ai/dsh-agent";
3
+ import { scopeChainOf, scopeOf } from "@deepseek-ai/dsh-scope";
4
+ import z from "@deepseek-ai/schemastery";
5
+ import type {} from "../assembler/index.ts";
6
+
7
+ export const name = "context-scope";
8
+
9
+ export const inject = ["tools", "systemPrompt"];
10
+
11
+ export interface Config {
12
+ /**
13
+ * 只保留这些工具;其余(含 host 层 bundle 加进来的)既不进模型目录,调用不了,它们自己注册的说明
14
+ * section(`tool:<工具名>`)也不留在提示词里。
15
+ */
16
+ allowTools?: string[];
17
+ /**
18
+ * 是否要 instruction 类的注入(工作区指令、技能目录、用法正文那类规则块)。缺省要;
19
+ * `false` 表示这个模式不要任何 instructions——对话模式就是它。
20
+ *
21
+ * 关的是**规则块与降级 section**(通道的 `setInstructions`);内容块(`auto` 组正文、引用材料)
22
+ * 不受这个开关管,要靠对应的注入方自己不注册(chat 同时给了 `groups: false`)。
23
+ * 注意这与工具白名单是两件事:工具没给,跟着它的注入本来就该自己关(见各注入包对 requires 的判断);
24
+ * 这里是"连与工具无关的 instruction 也不要"的总开关。
25
+ */
26
+ instructions?: boolean;
27
+ /**
28
+ * 是否要 runtime context(动态快照:文件沙箱策略、审批策略)。缺省要;`false` 表示这个模式不要它们
29
+ * ——对话模式没有文件与 shell 工具,"能改工作区哪些文件、要不要走审批"对它全是噪音。
30
+ *
31
+ * 抑制是**按 scope** 的(上游按装配的 scope 链查抑制器),所以本行装在 preset 子树里就只作用于这个
32
+ * 模式的会话;它只挡注入,不改任何提供方的行为(沙箱该怎么判还怎么判)。
33
+ */
34
+ runtimeContext?: boolean;
35
+ }
36
+
37
+ export const Config: z<Config> = z.object({
38
+ allowTools: z.array(z.string()).default([]),
39
+ instructions: z.boolean().default(true),
40
+ runtimeContext: z.boolean().default(true),
41
+ });
42
+
43
+ const OUT_OF_SCOPE = (toolName: string): string =>
44
+ `${toolName} 不在本模式的工具范围内,用它不会有结果;按当前模式提供的工具完成任务,或让用户切到别的模式。`;
45
+
46
+ /** 单个工具的说明 section 名前缀:`tool:<工具名>`(`tools:` 那类是聚合块,不归白名单管)。 */
47
+ const TOOL_SECTION_PREFIX = "tool:";
48
+
49
+ /**
50
+ * 把某个 preset 的工具收成"只有这些"。
51
+ *
52
+ * 为什么需要它:preset composition 只能决定"加什么",管不了 host 层——`dsh.profile.bundles` 打开的
53
+ * bundle(例如实验性的 Agent Teams)会在 host 层插工具行与 section,对所有 preset 一视同仁。模式想
54
+ * 表达"我只有这几个工具、一条提示词都不要"时,唯一与来源无关的做法是收口"有什么"。
55
+ *
56
+ * 为什么不是 `tools.restrict()`:它会改动可见工具集,从而触发 `tools/change`;上游 `tool-subagent`
57
+ * 正是用该事件做 composition reconcile——两边互相触发会变成装配风暴。这里用投影过滤加执行层 guard:
58
+ * 既不碰注册表、也不发事件。
59
+ */
60
+ export function apply(ctx: Context, config: Config): void {
61
+ const allow = new Set(config.allowTools ?? []);
62
+ if (allow.size === 0) {
63
+ throw new Error(
64
+ "context-scope: `allowTools` must name at least one tool; omit the row instead of mounting an empty scope",
65
+ );
66
+ }
67
+ const presetKey = scopeOf(ctx);
68
+ const scoped = new WeakSet<Agent>();
69
+
70
+ if (config.runtimeContext === false) {
71
+ // service 在 scoped ctx 上是 shadow:注册落在本行的 scope(preset 子树),抑制因此只覆盖这个模式。
72
+ ctx.systemPrompt.suppressRuntimeContext();
73
+ }
74
+
75
+ const belongs = (agent: Agent): boolean => {
76
+ if (presetKey === undefined) return true;
77
+ const key = scopeOf(agent.ctx);
78
+ return key !== undefined && scopeChainOf(key).includes(presetKey);
79
+ };
80
+
81
+ /** 每个会话只做一次:拨提示词开关,并挂上执行层白名单。 */
82
+ const scopeOnce = (agent: Agent): void => {
83
+ if (scoped.has(agent) || !belongs(agent)) return;
84
+ scoped.add(agent);
85
+ // 通道是可选的(`ctx.get` 在未声明 inject 的 ctx 上会抛,所以自己兜住):没有它就没有 instruction
86
+ // 可关、也没人收窄技能目录,但工具目录与执行 guard 照常生效——不该因为一个可选搭档缺席就让整个插件
87
+ // 不激活。工具收窄必须交给它:投影层的过滤不碰注册表,注册表上看不出"谁能用"。
88
+ try {
89
+ const channel = ctx.get("contextAssembler");
90
+ if (config.instructions === false) channel?.setInstructions(agent, false);
91
+ channel?.restrictTools(agent, (tool) => allow.has(tool));
92
+ } catch {
93
+ void 0;
94
+ }
95
+ // inject 回调只是在该 ctx 上解锁 `tools`;调用必须落在 `agent.ctx` 上才是这个会话的作用域。
96
+ agent.ctx.inject(["tools"], () => {
97
+ agent.ctx.tools.guard((exec) => (allow.has(exec.name) ? undefined : OUT_OF_SCOPE(exec.name)));
98
+ });
99
+ };
100
+
101
+ // 在**装配期**注册,不在创建期:创建期 tools 的可用性还在变,那时注册容易与装配流程互相牵扯
102
+ // (`tools/change` 的回响就是这么来的)。目录过滤也在这里,每步幂等。
103
+ ctx.on("system-prompt/assemble", async (_assembly, context, next) => {
104
+ const agent = context.agent;
105
+ if (agent !== undefined) scopeOnce(agent);
106
+ const result = await next();
107
+ return {
108
+ ...result,
109
+ // 工具自己的说明 section 与工具目录同源:目录里没有的工具,它的说明也不该留在提示词里。
110
+ sections: result.sections.filter((section) =>
111
+ section.name.startsWith(TOOL_SECTION_PREFIX)
112
+ ? allow.has(section.name.slice(TOOL_SECTION_PREFIX.length))
113
+ : true,
114
+ ),
115
+ tools: result.tools.filter((tool) => allow.has(tool.name)),
116
+ };
117
+ });
118
+ }
@@ -0,0 +1,177 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type { Agent } from "@deepseek-ai/dsh-agent";
3
+ import { isModelInvocable, isSkillName } from "@deepseek-ai/dsh-skill";
4
+ import type {} from "@deepseek-ai/dsh-tool-skill";
5
+ import { defineTool } from "@deepseek-ai/dsh-tools";
6
+ import type {} from "../assembler/index.ts";
7
+ import { renderVirtualSkill } from "../assembler/index.ts";
8
+
9
+ export const name = "context-skill-catalog";
10
+
11
+ export const inject = ["skills", "tools", "contextAssembler"];
12
+
13
+ export const CATALOG_ID = "skill-catalog";
14
+
15
+ /** 目录行里的描述长度上限(上游默认同值)。 */
16
+ const DESCRIPTION_MAX_LENGTH = 500;
17
+
18
+ /**
19
+ * skill 目录 + `skill` 工具:目录是规则块(一行名字 + 摘要),正文按需加载。
20
+ *
21
+ * 目录只列模型可调用的 skill;`auto` 的 skill 标 `modelInvocable: false`,所以它不出现在目录里
22
+ * (它的正文已经随提示送达)。工具的渲染用通道的虚拟 skill 形态,不带 `<skill_resources>`。
23
+ */
24
+ export function apply(ctx: Context): void {
25
+ /**
26
+ * 本会话上次算出的目录条目:`source` 是同步的、正文是异步的,两者共用这一次计算的结果
27
+ * (`collect` 里先算正文再取 source)。
28
+ */
29
+ const catalogEntries = new WeakMap<Agent, readonly { name: string; description: string }[]>();
30
+
31
+ ctx.contextAssembler.registerRule({
32
+ id: CATALOG_ID,
33
+ // 对外身份沿用上游 kind:客户端标签与按 kind 认领的消费方认得这是技能目录,`entries` 就是
34
+ // 目录行里发布的那份清单(非模型消费者照它列条目)。
35
+ source: (agent) => ({
36
+ kind: "skill-catalog",
37
+ form: "catalog",
38
+ entries: catalogEntries.get(agent) ?? [],
39
+ }),
40
+ text: async (agent) => {
41
+ // 目录没了(工具不在、快照不全、一个 skill 都不可见)就把上次的条目也清掉:`source` 与正文同源。
42
+ const noCatalog = (): string => {
43
+ catalogEntries.delete(agent);
44
+ return "";
45
+ };
46
+ // "谁能用"要连模式的白名单一起算(注册表里有不等于这个会话能用),否则被收窄的工具还会留下技能目录。
47
+ const visible = ctx.contextAssembler.visibleTools(
48
+ agent,
49
+ (tool) => ctx.tools.get(tool, agent) !== undefined,
50
+ );
51
+ // 依赖关系:没有 `skill` 工具(被白名单挡掉或被别的 composition 拿掉)时,目录没有意义——
52
+ // 模型拿到了名字也加载不了。是否注入跟着工具走,而不是靠每个模式去列"不要哪些"。
53
+ if (!visible("skill")) return noCatalog();
54
+ // 必须带上会话的 cwd 与作用域:本地 skill 发现(`~/.agents/skills`、`{cwd}/.agents/skills`、
55
+ // 项目根)由 preset 层的 `skill-filesystem` 行提供,host 层的同名行在 web 组合里是禁用的——
56
+ // 不传作用域只看得见全局层(本仓库注册的运行时 skill),不传 cwd 连项目根都不扫。
57
+ const snapshot = await ctx.skills.snapshot({
58
+ cwd: agent.session.header.cwd,
59
+ scope: agent,
60
+ });
61
+ if (!snapshot.complete) return noCatalog();
62
+ // 技能也跟着工具走:依赖的工具一个都不可见的 skill 不进目录(模型看到名字也用不上)。
63
+ const hidden = ctx.contextAssembler.hiddenSkills(visible);
64
+ const skills = snapshot.skills
65
+ .filter(isModelInvocable)
66
+ .filter((skill) => !hidden.has(skill.name));
67
+ if (skills.length === 0) return noCatalog();
68
+ const entries = skills.map((skill) => ({
69
+ name: skill.name,
70
+ description: clamp(skill.description, DESCRIPTION_MAX_LENGTH),
71
+ }));
72
+ catalogEntries.set(agent, entries);
73
+ return [
74
+ "<available_skills>",
75
+ ...entries.map((entry) => `- ${entry.name}: ${entry.description}`),
76
+ "</available_skills>",
77
+ "",
78
+ "任务与某个 skill 说明匹配时,先加载它再行事。",
79
+ ].join("\n");
80
+ },
81
+ });
82
+
83
+ // 同名工具只能有一个所有者:host 层的 `tool-skill` 行由 patch 禁用,但万一没禁掉(上游改了行 id、
84
+ // 或别的 bundle 又插了一份),重复注册会让 tools/change 抖动甚至让 session 创建失败——所以先看清
85
+ // 注册表里有没有,有就让给对方,我们只负责目录。
86
+ if (ctx.tools.get("skill") !== undefined) {
87
+ ctx.logger.warn(
88
+ "context-skill-catalog: a `skill` tool is already registered; keeping the existing one and only publishing the catalog",
89
+ );
90
+ } else {
91
+ registerSkillTool(ctx);
92
+ }
93
+ }
94
+
95
+ function registerSkillTool(ctx: Context): void {
96
+ const skillTool = defineTool({
97
+ name: "skill",
98
+ description: "按需加载 skill 的完整说明。",
99
+ parameters: {
100
+ name: { type: "string", required: true, description: "技能目录里的 skill 名。" },
101
+ },
102
+ output: {
103
+ // 与上游 `tool-skill` 的输出**同形**(`additionalProperties: false`,少一个字段就是校验不过):
104
+ // 客户端按这个结构渲染 skill 结果,缺 `provider` 会让那次调用直接失败。
105
+ schema: {
106
+ type: "object",
107
+ additionalProperties: false,
108
+ properties: {
109
+ name: { type: "string", required: true },
110
+ provider: { type: "string", required: true },
111
+ resourceBase: {
112
+ oneOf: [
113
+ {
114
+ type: "object",
115
+ additionalProperties: false,
116
+ properties: {
117
+ kind: { type: "string", required: true, const: "directory" },
118
+ path: { type: "string", required: true },
119
+ },
120
+ },
121
+ {
122
+ type: "object",
123
+ additionalProperties: false,
124
+ properties: {
125
+ kind: { type: "string", required: true, const: "url" },
126
+ url: { type: "string", required: true },
127
+ },
128
+ },
129
+ {
130
+ type: "object",
131
+ additionalProperties: false,
132
+ properties: {
133
+ kind: { type: "string", required: true, const: "opaque" },
134
+ description: { type: "string", required: true },
135
+ },
136
+ },
137
+ ],
138
+ },
139
+ content: { type: "string", required: true },
140
+ },
141
+ },
142
+ render: (_args, value) => [
143
+ { type: "text", text: renderVirtualSkill(value.name, value.content) },
144
+ ],
145
+ },
146
+ async execute(args, exec) {
147
+ if (!isSkillName(args.name)) throw new Error(`invalid skill name "${args.name}"`);
148
+ const lookup = {
149
+ cwd: exec.agent?.session.header.cwd,
150
+ signal: exec.signal,
151
+ scope: exec.agent,
152
+ };
153
+ const skill = await ctx.skills.get(args.name, lookup);
154
+ if (skill === undefined)
155
+ throw new Error(`skill "${args.name}" is unknown or no longer available`);
156
+ if (!isModelInvocable(skill))
157
+ throw new Error(`skill "${args.name}" is not available for model invocation`);
158
+ // 注册表里那份是不过滤的全量(技能注册是装配期一次),按需加载时按这个会话重算一份:
159
+ // 组正文不该讲这个会话没装的工具。
160
+ const content = ctx.contextAssembler.contentFor(skill.name, exec.agent) ?? skill.content;
161
+ return { name: skill.name, provider: skill.provider, content };
162
+ },
163
+ presentCall(args) {
164
+ return {
165
+ card: "generic",
166
+ title: `Load skill ${args.name}`,
167
+ kind: "read",
168
+ rawInput: args.name,
169
+ };
170
+ },
171
+ });
172
+ ctx.tools.register(skillTool);
173
+ }
174
+
175
+ function clamp(text: string, max: number): string {
176
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
177
+ }