@morlay/dsh-session-mode 0.0.1 → 0.0.3

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/src/scope.ts ADDED
@@ -0,0 +1,197 @@
1
+ // 按**会话**收口:一份模式定义落成这个会话的工具面与三个开关。收口的输入就是模式定义、唯一消费者也是模式,所以
2
+ // 它住在本包(`SessionModes.installFor` 直接调 `apply`),不发布任何服务、不认识 config 与页面。
3
+ //
4
+ // `apply` 落到这个 agent 身上四件事:
5
+ //
6
+ // - **装配期投影**:模型目录与 `tool:<工具名>` section 按同一份合成结果过滤;
7
+ // - **执行层 guard**(在 `agent.ctx` 的 `tools` inject 回调里):拒绝时给该模式的文案,并说得出是哪一类(白名单外 /
8
+ // 黑名单内);
9
+ // - **两个抑制面**(`agent/pre-step`):官方 `agent-instructions` 与 `skill-catalog` 的注入按开关丢掉——官方那两行
10
+ // 在装配投影之外自己往瀑布里 append,只能在瀑布上丢(不动注册表、也不动它们的行);
11
+ // - **另两个开关**(注入面那个 `skills` 在上面那条里):`runtimeContext: false` 抑制动态快照
12
+ // (`systemPrompt.suppressRuntimeContext`),`instructions: false` 关掉通道自己的降级注入
13
+ // (`ctx.contextAssembler.setInstructions`)。
14
+ //
15
+ // 同一个 agent 再 `apply` 就是换一份(旧 disposer 全部收回);不走 `tools.restrict()`(会触发 `tools/change`),
16
+ // 也不建 preset 子树。
17
+
18
+ import { type Context } from "@deepseek-ai/cordis";
19
+ import type { Agent, PreStepDecision } from "@deepseek-ai/dsh-agent";
20
+ import type { UserMessage } from "@deepseek-ai/dsh-llm";
21
+ // 通道的服务声明(`ctx.contextAssembler`)住在 `@morlay/dsh-context-assembler` 的包根上;那是可选搭档,
22
+ // 所以只借它的类型,取服务仍按"可能没有"处理。
23
+ import type {} from "@morlay/dsh-context-assembler";
24
+
25
+ // 一次收口的全部输入:模式定义里本文件用到的那几项(`SessionModes.installFor` 解析之后整份推过来)。
26
+ export interface SessionScopeDefinition {
27
+ // 拒绝对话时用的模式名。
28
+ readonly name: string;
29
+ // 这个会话能用的工具;其余既不进目录,调用也被拒。**留空表示不设白名单**——起点是全部工具。
30
+ readonly allowTools: readonly string[];
31
+ // 这个会话明确排除的工具。与 `allowTools` 同时命中一个名字时**黑名单优先**(拒,且文案说黑名单)。
32
+ readonly denyTools: readonly string[];
33
+ // 这个会话要不要 instruction 类注入(`false` → 丢掉官方 `agent-instructions` 的注入,并关通道的降级注入)。
34
+ readonly instructions: boolean;
35
+ // 这个会话要不要技能目录(`false` → 丢掉官方 `skill-catalog` 的注入)。
36
+ readonly skills: boolean;
37
+ // 这个会话要不要动态快照(`false` → 按 scope 抑制)。
38
+ readonly runtimeContext: boolean;
39
+ }
40
+
41
+ // 一条工具被拒的两类原因:白名单外 / 黑名单内。文案要能看出是哪一类,模型才知道怎么绕(换工具还是换模式)。
42
+ type Denial = "not-allowlisted" | "denylisted";
43
+
44
+ const OUT_OF_SCOPE = (toolName: string, modeName: string): string =>
45
+ `${toolName} 不在「${modeName}」的工具白名单里,用它不会有结果;按当前模式提供的工具完成任务,或让用户切到别的模式。`;
46
+
47
+ const DENYLISTED = (toolName: string, modeName: string): string =>
48
+ `${toolName} 被「${modeName}」的工具黑名单排除,用它不会有结果;按当前模式提供的工具完成任务,或让用户切到别的模式。`;
49
+
50
+ function refusalOf(toolName: string, modeName: string, denial: Denial): string {
51
+ return denial === "denylisted"
52
+ ? DENYLISTED(toolName, modeName)
53
+ : OUT_OF_SCOPE(toolName, modeName);
54
+ }
55
+
56
+ // 单个工具的说明 section 名前缀:`tool:<工具名>`(`tools:` 那类是聚合块,不归收口管)。
57
+ const TOOL_SECTION_PREFIX = "tool:";
58
+
59
+ // 一次收口**合成**出来的工具面:最终可用 = (白名单留空 ? 全部 : 白名单) − 黑名单。
60
+ // `undefined` 表示不过滤(两条名单都空)——装配投影、section 过滤与执行 guard 三处都读同一个合成结果。
61
+ type ToolGate = {
62
+ // undefined = 不设白名单(全部工具都是起点)。
63
+ readonly allow: ReadonlySet<string> | undefined;
64
+ readonly deny: ReadonlySet<string>;
65
+ };
66
+
67
+ function gateOf(definition: SessionScopeDefinition): ToolGate | undefined {
68
+ const deny = new Set(definition.denyTools);
69
+ const allow = definition.allowTools.length === 0 ? undefined : new Set(definition.allowTools);
70
+ if (allow === undefined && deny.size === 0) return undefined;
71
+ return { allow, deny };
72
+ }
73
+
74
+ // 判定一个工具名:`undefined` = 放行,否则是被拒的原因(黑名单先判,于是 deny 优先)。
75
+ function denialOf(gate: ToolGate, toolName: string): Denial | undefined {
76
+ if (gate.deny.has(toolName)) return "denylisted";
77
+ if (gate.allow !== undefined && !gate.allow.has(toolName)) return "not-allowlisted";
78
+ return undefined;
79
+ }
80
+
81
+ // 一条消息来自谁:官方那两个 kind(`agent-instructions` / `skill-catalog`)由各自的行在自己模块里声明,本包不
82
+ // import 它们,所以按字符串读——认不出的一律不算抑制面。
83
+ function kindOf(message: UserMessage): string | undefined {
84
+ const kind = (message.source as { readonly kind?: unknown }).kind;
85
+ return typeof kind === "string" ? kind : undefined;
86
+ }
87
+
88
+ // 一个 agent 当前装着的那一份:`apply` 时解析好的开关(抑制面读它)。
89
+ interface Applied {
90
+ // 两条名单都空 = 不过滤(装配期与执行层都放行)。
91
+ readonly gate: ToolGate | undefined;
92
+ readonly instructions: boolean;
93
+ readonly skills: boolean;
94
+ // 收回这一份在 `agent.ctx` 上的注册(抑制器与执行层 guard)。
95
+ readonly dispose: () => void;
96
+ }
97
+
98
+ // 按会话收口工具面与注入面的状态机:一份 per-agent 的状态 + 两条全局监听器。
99
+ export class SessionScope {
100
+ private readonly applied = new WeakMap<Agent, Applied>();
101
+ private readonly ctx: Context;
102
+
103
+ constructor(ctx: Context) {
104
+ this.ctx = ctx;
105
+ // 装配期过滤:这里只认已经 `apply` 过的会话;没登记过的(没装模式那层的部署)一律放行。
106
+ ctx.on("system-prompt/assemble", async (_assembly, context, next) => {
107
+ const agent = context.agent;
108
+ const applied = agent === undefined ? undefined : this.applied.get(agent);
109
+ if (applied === undefined || applied.gate === undefined) return next();
110
+ const gate = applied.gate;
111
+ const result = await next();
112
+ return {
113
+ ...result,
114
+ // 工具自己的说明 section 与工具目录同源:目录里没有的工具,它的说明也不该留在提示词里。
115
+ sections: result.sections.filter((section) =>
116
+ section.name.startsWith(TOOL_SECTION_PREFIX)
117
+ ? denialOf(gate, section.name.slice(TOOL_SECTION_PREFIX.length)) === undefined
118
+ : true,
119
+ ),
120
+ tools: result.tools.filter((tool) => denialOf(gate, tool.name) === undefined),
121
+ };
122
+ });
123
+
124
+ // 两个抑制面:官方 `agent-instructions`(工作区指令)与官方 `skill-catalog`(技能目录)都在装配投影之外自己
125
+ // 往这条瀑布里 append,所以只有在这里丢掉它们的条目。
126
+ //
127
+ // `prepend` 是必需的:官方那两行比本行早注册(会话挂着的 preset 先于 host 平面),瀑布里先注册的是**外层**,
128
+ // 站在它们后面就看不到、也丢不掉它们 append 的条目——只有抢在最外层(`await next()` 之后再收)才拿得到最终
129
+ // 消息表。
130
+ ctx.on(
131
+ "agent/pre-step",
132
+ async (payload, next): Promise<PreStepDecision> => {
133
+ const decision = await next();
134
+ if (decision.kind !== "enter") return decision;
135
+ const applied = this.applied.get(payload.agent);
136
+ // 没收过口的会话一律不动。
137
+ if (applied === undefined) return decision;
138
+ const kept = decision.messages.filter((message) => {
139
+ const kind = kindOf(message);
140
+ if (kind === "agent-instructions") return applied.instructions;
141
+ if (kind === "skill-catalog") return applied.skills;
142
+ return true;
143
+ });
144
+ return kept.length === decision.messages.length
145
+ ? decision
146
+ : { ...decision, messages: kept };
147
+ },
148
+ { prepend: true },
149
+ );
150
+ }
151
+
152
+ // 把某个会话收口到这份定义上(同一个 agent 再调就是换一份)。
153
+ apply(agent: Agent, definition: SessionScopeDefinition): void {
154
+ const previous = this.applied.get(agent);
155
+ if (previous !== undefined) previous.dispose();
156
+ // 两条名单都空 = 不过滤:没有守卫要装,装配期也一路放行。
157
+ const gate = gateOf(definition);
158
+
159
+ // 落在 `agent.ctx` 上的那两件用一个 effect 装:动态快照抑制是 scope 层的一次注册;执行层 guard 要等
160
+ // `tools` 激活才装得上(`inject` 的回调),它挂在那个 inject fiber 下,所以收回时连 fiber 一起收。
161
+ const dispose = agent.ctx.effect(() => {
162
+ const stoppers: (() => void)[] = [];
163
+ if (definition.runtimeContext === false) {
164
+ // service 在 `agent.ctx` 上是 shadow:抑制落在该 agent 的 scope 层,只覆盖这个会话。
165
+ stoppers.push(agent.ctx.systemPrompt.suppressRuntimeContext());
166
+ }
167
+ // 必须落在 `agent.ctx` 上才是这个会话的作用域。
168
+ const fiber = agent.ctx.inject(["tools"], (scope) => {
169
+ if (gate === undefined) return;
170
+ scope.tools.guard((exec) => {
171
+ const denial = denialOf(gate, exec.name);
172
+ return denial === undefined ? undefined : refusalOf(exec.name, definition.name, denial);
173
+ });
174
+ });
175
+ return [...stoppers, () => fiber.dispose()];
176
+ }, "session-mode: per-session gate");
177
+
178
+ // 通道那一侧的降级注入按 agent 覆盖,不需要收回。
179
+ this.channel()?.setInstructions(agent, definition.instructions);
180
+
181
+ this.applied.set(agent, {
182
+ gate,
183
+ instructions: definition.instructions,
184
+ skills: definition.skills,
185
+ dispose,
186
+ });
187
+ }
188
+
189
+ // 通道是可选的搭档:没有它就没有降级注入可关,工具面与两个抑制面照常生效。
190
+ private channel(): { setInstructions(agent: Agent, on: boolean): void } | undefined {
191
+ try {
192
+ return this.ctx.get("contextAssembler");
193
+ } catch {
194
+ return undefined;
195
+ }
196
+ }
197
+ }
package/src/shared.ts CHANGED
@@ -1,36 +1,40 @@
1
- /**
2
- * host 半与 client 半共用的接口面:一条 HTTP 路径、模式行的对外形状、请求与响应体。
3
- *
4
- * 为什么不是 Typert Remote:客户端的 remote 清单(`@deepseek-ai/dsh-api-remotes/client`)由上游硬编码,
5
- * 我们的服务不在里面,`ctx.remote.sessionModes` 解析不到。仓库既有的跨半通路是 HTTP 路由
6
- * (见 `@morlay/ui-conversation-message-actions` 的 `/session-editor`),这里沿用同一种。
7
- *
8
- * 会话当前模式不走这条通路:它是一条 session 投影(`sessionMode`),随会话列表一起到页面。
9
- */
1
+ // host 半与 client 半共用的接口面:一条 HTTP 路径、模式行的对外形状、请求与响应体。
2
+ // 走 HTTP 路由而不是 Typert Remote:客户端的 remote 清单由上游硬编码,我们的服务不在里面。
3
+ // 会话当前模式不走这条通路:它是 session 投影(`sessionMode`),随会话列表一起到页面。
10
4
 
11
- /** 模式清单与切换的路由路径:宿主(web 与桌面)在同一张路由表上服务它。 */
5
+ // 模式清单与切换的路由路径:宿主(web 与桌面)在同一张路由表上服务它。
12
6
  export const SESSION_MODE_PATH = "/session-mode";
13
7
 
14
- /** 一个模式对外的那部分:选择器要的名字与说明。 */
8
+ // 模式可以改写的上游 **policy 规则**名,即上游 waterfall 的事件名(是事件名、不是路径,所以**不带**前导斜杠):
9
+ // `fs/write-intent`(写意图)与 `fs/edit-intent`(改意图)。home 在这里而不是 `modes.ts`:host 半的装配期校验
10
+ // 与 client 半的页面候选键都要它,而 client 半不 import `modes.ts`(免得把 schemastery 拖进浏览器包)。
11
+ // 「能拦什么」取决于上游是否在那条 waterfall 上独占决策槽——它不是配置能自己长出来的东西,所以这份名单是封闭的,
12
+ // 写进来的名字不在名单里就在装配期拒绝(见 `configProblem`)。
13
+ export const POLICY_NAMES = ["fs/write-intent", "fs/edit-intent"] as const;
14
+
15
+ // 一条 policy 规则的名字。
16
+ export type PolicyName = (typeof POLICY_NAMES)[number];
17
+
18
+ // 一个模式对外的那部分:选择器要的名字与说明。
15
19
  export interface SessionModeRow {
16
20
  readonly id: string;
17
21
  readonly name: string;
18
22
  readonly description?: string;
19
23
  }
20
24
 
21
- /** `GET` 的响应体:清单与默认模式。 */
25
+ // `GET` 的响应体:清单与默认模式。
22
26
  export interface SessionModeRoster {
23
27
  readonly default: string;
24
28
  readonly modes: readonly SessionModeRow[];
25
29
  }
26
30
 
27
- /** `POST` 的请求体:把某个空白会话切到某个模式。 */
31
+ // `POST` 的请求体:把某个空白会话切到某个模式。
28
32
  export interface SessionModeSelectRequest {
29
33
  readonly sessionId: string;
30
34
  readonly mode: string;
31
35
  }
32
36
 
33
- /** `POST` 的响应体:切换后提交的模式 id。 */
37
+ // `POST` 的响应体:切换后提交的模式 id。
34
38
  export interface SessionModeSelectResult {
35
39
  readonly mode: string;
36
40
  }
@@ -1,14 +0,0 @@
1
- //#region src/shared.ts
2
- /**
3
- * host 半与 client 半共用的接口面:一条 HTTP 路径、模式行的对外形状、请求与响应体。
4
- *
5
- * 为什么不是 Typert Remote:客户端的 remote 清单(`@deepseek-ai/dsh-api-remotes/client`)由上游硬编码,
6
- * 我们的服务不在里面,`ctx.remote.sessionModes` 解析不到。仓库既有的跨半通路是 HTTP 路由
7
- * (见 `@morlay/ui-conversation-message-actions` 的 `/session-editor`),这里沿用同一种。
8
- *
9
- * 会话当前模式不走这条通路:它是一条 session 投影(`sessionMode`),随会话列表一起到页面。
10
- */
11
- /** 模式清单与切换的路由路径:宿主(web 与桌面)在同一张路由表上服务它。 */
12
- const SESSION_MODE_PATH = "/session-mode";
13
- //#endregion
14
- export { SESSION_MODE_PATH as t };