@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/README.md +51 -110
- package/dist/client.cjs +17 -71
- package/dist/client.d.cts +4 -19
- package/dist/client.d.mts +4 -19
- package/dist/index.d.cts +19 -180
- package/dist/index.d.mts +19 -180
- package/dist/index.mjs +149 -162
- package/dist/rows.d.cts +4 -31
- package/dist/rows.d.mts +4 -31
- package/dist/rows.mjs +15 -452
- package/dist/shared-DX2Giyh3.mjs +5 -0
- package/locale/en.json +2 -2
- package/locale/zh.json +1 -1
- package/package.json +10 -8
- package/src/client/SessionModeSeat.tsx +7 -24
- package/src/client/api.ts +4 -14
- package/src/client/index.ts +34 -31
- package/src/client/locales.ts +2 -6
- package/src/client/use-roster.ts +3 -5
- package/src/index.ts +124 -147
- package/src/mode-sources.ts +42 -46
- package/src/modes.ts +136 -115
- package/src/persona.ts +3 -16
- package/src/rows.ts +26 -14
- package/src/scope.ts +197 -0
- package/src/shared.ts +18 -14
- package/dist/shared-ChCbbJa8.mjs +0 -14
package/src/client/index.ts
CHANGED
|
@@ -1,49 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* | 槽位 | 呈现 |
|
|
5
|
-
* | ------------------------- | --------------------------------------------------- |
|
|
6
|
-
* | `conversation.input.left` | chip——点开就是切换列表(新会话屏也是一个 composer) |
|
|
7
|
-
*
|
|
8
|
-
* 官方 `@deepseek-ai/dsh-client-ui-agent-preset` 保留(它提供 preset 的选择面与设置页 roster):官方管"挂哪套
|
|
9
|
-
* 行",我们管"会话级扩展",两套入口并存、互不遮挡。
|
|
10
|
-
*
|
|
11
|
-
* 头部的只读标签**没有**:chip 本来就把当前模式写在脸上(会话与新会话屏都有 composer),右上角再写一遍是
|
|
12
|
-
* 同一句话的复读。
|
|
13
|
-
*
|
|
14
|
-
* 本行的配置页(各模式的默认模型 `config.models`)不再由本包画:那个字段是 volatile 的,页面由
|
|
15
|
-
* `@morlay/dsh-client-ui-primitives` 按 schema 自动生成(它注册到本行的配置入口 `plugins.row.config`,
|
|
16
|
-
* key = `<bundle 包名>#<行 id>`)。本包只管会话里那一个面。
|
|
17
|
-
*/
|
|
1
|
+
// client 半:会话里那一个面(模式 chip,槽位 `conversation.input.left`,list + session scope)+ 配置页字段文案。
|
|
2
|
+
// 官方 `@deepseek-ai/dsh-client-ui-agent-preset` 保留(官方管"挂哪套行",我们管"会话级扩展",两套入口并存);
|
|
3
|
+
// 本行的配置页由 `@morlay/dsh-client-ui-primitives` 按 schema 自动生成,key = `<bundle 包名>#<行 id>`。
|
|
18
4
|
|
|
19
5
|
import type { Context } from "@deepseek-ai/cordis";
|
|
20
6
|
// Type-only:`ctx.remote` 的合并面(选模型的候选来自 LLM 目录)。
|
|
21
7
|
import type {} from "@deepseek-ai/dsh-api-remotes/client";
|
|
22
8
|
import type {} from "@deepseek-ai/dsh-client-locale/client";
|
|
23
9
|
import type {} from "@deepseek-ai/dsh-client-ui-slots";
|
|
24
|
-
|
|
25
10
|
// Type-only:槽位声明与 standard props(session / session-maybe / global)。
|
|
26
11
|
import type {} from "@morlay/dsh-client-ui-conversation/client";
|
|
12
|
+
import { POLICY_NAMES } from "../shared.ts";
|
|
27
13
|
import { SessionModeSeat } from "./SessionModeSeat.tsx";
|
|
28
14
|
import { en, zh, type SessionModeLocaleKey } from "./locales.ts";
|
|
29
15
|
|
|
30
16
|
declare module "@deepseek-ai/dsh-client-ui-slots" {
|
|
31
17
|
interface LocaleNamespaceMap {
|
|
32
|
-
|
|
18
|
+
// 会话里两个面的文案(配置页文案在通用 schema 表单的字典里)。
|
|
33
19
|
"session-mode": SessionModeLocaleKey;
|
|
34
20
|
}
|
|
35
21
|
}
|
|
36
22
|
|
|
37
|
-
|
|
23
|
+
// 浏览器半插件的字典命名空间。
|
|
38
24
|
const NS = "session-mode";
|
|
39
25
|
|
|
40
|
-
|
|
26
|
+
// 这一行里需要中文文案的字段(都在 `modes.<模式>.defaultModel` 里,所以按模板路径注册一次)。
|
|
41
27
|
const FIELDS = ["provider", "model", "reasoningEffort"] as const;
|
|
42
28
|
|
|
43
|
-
|
|
29
|
+
// 两份 policy 名单的字段名(`modes.<模式>.<字段>`):它们在 schema 上是**字符串数组**(`z.array(z.string())`,没有
|
|
30
|
+
// enum),候选值由本文件登记的封闭名单 `POLICY_NAMES` 给(见下面 `hints.select` 那一段)——所以两条已知 policy
|
|
31
|
+
// 都以"未配置"的形态各占一项可选,页面把每一项画成选择器。
|
|
32
|
+
const POLICY_FIELDS = ["allowPolicies", "denyPolicies"] as const;
|
|
33
|
+
|
|
34
|
+
// 动态键的占位段(与通用表单的字段树同一约定)。
|
|
44
35
|
const DYNAMIC = "*";
|
|
45
36
|
|
|
46
|
-
|
|
37
|
+
// 一个可配置 provider 的候选信息:显示名 + 它的配置在哪(模型清单从那份配置里读)。
|
|
47
38
|
interface ProviderEntry {
|
|
48
39
|
value: string;
|
|
49
40
|
label: string;
|
|
@@ -54,13 +45,13 @@ interface ProviderEntry {
|
|
|
54
45
|
export type { SessionModeSeatProps } from "./SessionModeSeat.tsx";
|
|
55
46
|
export type { SessionModeLocaleKey } from "./locales.ts";
|
|
56
47
|
|
|
57
|
-
|
|
48
|
+
// 需要的服务:槽位与字典(会话列表经槽位的标准 props 到达组件,不必自己 inject)。
|
|
58
49
|
export const inject = ["slots", "locale"];
|
|
59
50
|
|
|
60
|
-
|
|
51
|
+
// 本包 host 行 id:行配置页读的就是这个命名空间。
|
|
61
52
|
export const SESSION_MODE_NS = "session-mode";
|
|
62
53
|
|
|
63
|
-
|
|
54
|
+
// 按路径读一段配置里的值(本包只读 provider 档案里的模型清单)。
|
|
64
55
|
function readAt(root: unknown, path: readonly string[]): unknown {
|
|
65
56
|
return path.reduce<unknown>((node, segment) => {
|
|
66
57
|
if (Array.isArray(node)) return node[Number(segment)];
|
|
@@ -69,11 +60,7 @@ function readAt(root: unknown, path: readonly string[]): unknown {
|
|
|
69
60
|
}, root);
|
|
70
61
|
}
|
|
71
62
|
|
|
72
|
-
|
|
73
|
-
* 装上会话里的两个面,以及本行配置页的字段文案。
|
|
74
|
-
*
|
|
75
|
-
* @param ctx - 浏览器插件上下文。
|
|
76
|
-
*/
|
|
63
|
+
// 装上会话里的那一个面,以及本行配置页的字段文案。
|
|
77
64
|
export function apply(ctx: Context): void {
|
|
78
65
|
const t = ctx.locale.bind(NS);
|
|
79
66
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "session-mode: dictionaries");
|
|
@@ -95,6 +82,22 @@ export function apply(ctx: Context): void {
|
|
|
95
82
|
}, "session-mode: field wording"),
|
|
96
83
|
);
|
|
97
84
|
|
|
85
|
+
// 两份 policy 名单是**字符串数组**,键的候选是一份封闭名单(上游 waterfall 名,见 `shared.ts` 的
|
|
86
|
+
// `POLICY_NAMES`):在数组**项**那一路径上登记候选值,页面因此把每一项画成选择器,两条已知 policy 直接可选
|
|
87
|
+
// (不必手写名字)。名单住在 `shared.ts`——client 半不 import `modes.ts`(免得把 schemastery 拖进浏览器包)。
|
|
88
|
+
ctx.inject(["schemaFormHints"], (scope) =>
|
|
89
|
+
scope.effect(() => {
|
|
90
|
+
const offs = POLICY_FIELDS.map((key) =>
|
|
91
|
+
scope.schemaFormHints.select(SESSION_MODE_NS, ["modes", DYNAMIC, key, DYNAMIC], {
|
|
92
|
+
options: () => POLICY_NAMES.map((value) => ({ value })),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
return () => {
|
|
96
|
+
for (const off of offs) off();
|
|
97
|
+
};
|
|
98
|
+
}, "session-mode: policy candidates"),
|
|
99
|
+
);
|
|
100
|
+
|
|
98
101
|
// 选模型的候选不在本行的 schema 里:provider 是部署里的 LLM 目录(活着的路由 + 可配置声明),模型清单读那份声明
|
|
99
102
|
// 指向的配置(`settingsNs` / `settingsPath`)。目录异步取到后注册——注册本身就是一次变更通知。
|
|
100
103
|
ctx.inject(["schemaFormHints"], (scope) =>
|
package/src/client/locales.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* 只有会话里那个模式 chip 用这份字典:模式的名字与说明由 host 的清单给(那是数据,不是文案)。本行的配置页
|
|
5
|
-
* (各模式的默认模型)由通用 schema 表单渲染,文案在它自己的字典里。
|
|
6
|
-
*/
|
|
1
|
+
// client 半的文案:命名空间 `session-mode`(与 locale 注册时用的 key 一致)。模式的名字与说明由 host 的清单给
|
|
2
|
+
// (那是数据,不是文案);本行配置页的文案在通用 schema 表单自己的字典里。
|
|
7
3
|
|
|
8
4
|
export const zh = {
|
|
9
5
|
seatHint: "选择这个会话的模式",
|
package/src/client/use-roster.ts
CHANGED
|
@@ -2,13 +2,11 @@ import { useEffect, useState } from "react";
|
|
|
2
2
|
import type { SessionModeRoster } from "../shared.ts";
|
|
3
3
|
import { fetchRoster } from "./api.ts";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* 失败不缓存——下次挂载就是一次重试。
|
|
8
|
-
*/
|
|
5
|
+
// 模块级缓存:清单是装配事实(`session-mode` 行的 config),页面生命周期里不会变,两次挂载之间不必重拉。
|
|
6
|
+
// 失败不缓存——下次挂载就是一次重试。
|
|
9
7
|
let pending: Promise<SessionModeRoster> | undefined;
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
// 读模式清单;还没读到(或读失败)时返回 undefined,组件据此不渲染。
|
|
12
10
|
export function useRoster(): SessionModeRoster | undefined {
|
|
13
11
|
const [roster, setRoster] = useState<SessionModeRoster | undefined>(undefined);
|
|
14
12
|
useEffect(() => {
|
package/src/index.ts
CHANGED
|
@@ -1,26 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* **模式不是 Cordis 子树**。旧的 `@morlay/dsh-agent-preset` 每个模式都是一行
|
|
5
|
-
* `@deepseek-ai/dsh-agent-preset`,`config.plugins` 里装 persona 与 scope 行,靠 preset scope 的父链
|
|
6
|
-
* 对会话生效——代价是整个官方 registry(声明式行、每 revision 一棵 Loader 子树、`isolate` realm)都在
|
|
7
|
-
* 部署里。这里只留两件事:模式是一份数据,应用落在会话自己的 scope 上:
|
|
8
|
-
*
|
|
9
|
-
* | 事实 | 落在哪 |
|
|
10
|
-
* | ---------------- | ------------------------------------------------------------------------------------------ |
|
|
11
|
-
* | 模式清单与默认值 | 本行的 `config`(装配层可整体改写;`modes.ts` 给形状与校验) |
|
|
12
|
-
* | 各模式的默认模型 | 同一份 config 的顶层 `models`(**volatile**:设置页那张卡片改的就是它) |
|
|
13
|
-
* | 会话当前模式 | session 事件 `session-mode/selected` + 投影 `sessionMode`(log-only,重建读投影) |
|
|
14
|
-
* | 提示词 | `persona`:把模式的 persona 注册到该 agent 的 scope(`persona.ts`) |
|
|
15
|
-
* | 工具与注入开关 | 推给 `ctx.sessionToolScope`(`@morlay/dsh-context-assembler/scope`,行 id `context-assembler-scope`) |
|
|
16
|
-
* | 页面上的选择面 | HTTP 路由 `GET/POST /session-mode`(清单与切换)+ 会话投影(当前值) |
|
|
17
|
-
*
|
|
18
|
-
* 应用时机是 `agent/created`:它早于任何一次提示词装配(装配发生在 turn 里),所以 persona 一定在该会话
|
|
19
|
-
* 第一次装配之前就注册好了。模式在**空白会话**里可以切换,切换时对着已有的 agent 重新应用一遍。
|
|
20
|
-
*
|
|
21
|
-
* 切换只允许在空白窗口(还没开过 turn):会话的历史是在某个模式的工具集与提示词下产生的,换了模式,那段
|
|
22
|
-
* 历史就与实际装配对不上——与上游 `agentPresets.select` 的判据一致,也用同一个投影(`turnBoundary`)。
|
|
23
|
-
*/
|
|
1
|
+
// 会话模式的 host 半:模式清单(config 里的纯数据)、会话 ↔ 模式的选择、按会话应用 persona 与**收口**。模式是一份
|
|
2
|
+
// 数据,应用落在会话自己的 scope 上(persona + 工具收口与三个注入开关,见 `./scope.ts`),默认模型住在
|
|
3
|
+
// `modes.<id>.defaultModel`;换模式只允许在空白窗口。取舍见 `.agents/designs/20260924-会话模式.md`。
|
|
24
4
|
|
|
25
5
|
import { Service, type Context } from "@deepseek-ai/cordis";
|
|
26
6
|
import type { Agent } from "@deepseek-ai/dsh-agent";
|
|
@@ -35,32 +15,31 @@ import type {} from "@deepseek-ai/dsh-session-projection";
|
|
|
35
15
|
// Type-only:官方 roster 的会话投影(`agentPreset`)与它的选择事件——选择面归官方 preset,
|
|
36
16
|
// 我们读它的选择、落成自己的会话事实。
|
|
37
17
|
import type {} from "@deepseek-ai/dsh-agent-preset-registry";
|
|
38
|
-
|
|
18
|
+
// Type-only:上游 fs 的 policy 事件词汇(`fs/write-intent` / `fs/edit-intent` 的签名与 `FsWriteIntent`)。
|
|
19
|
+
import type {} from "@deepseek-ai/dsh-fs";
|
|
39
20
|
import { z } from "zod";
|
|
40
21
|
import {
|
|
41
22
|
configProblem,
|
|
23
|
+
derivedSkills,
|
|
42
24
|
type ResolvedConfig,
|
|
43
25
|
type SessionMode,
|
|
44
26
|
type SessionModeRole,
|
|
45
27
|
} from "./modes.ts";
|
|
46
28
|
import { installPersona } from "./persona.ts";
|
|
29
|
+
import { SessionScope } from "./scope.ts";
|
|
47
30
|
import {
|
|
48
31
|
SESSION_MODE_PATH,
|
|
32
|
+
type PolicyName,
|
|
49
33
|
type SessionModeRoster,
|
|
50
34
|
type SessionModeRow,
|
|
51
35
|
type SessionModeSelectResult,
|
|
52
36
|
} from "./shared.ts";
|
|
53
37
|
|
|
54
|
-
|
|
38
|
+
// Cordis 插件名:与行 id 一致。
|
|
55
39
|
export const name = "session-mode";
|
|
56
40
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
* (persona 的 section 注册在它上面)。
|
|
60
|
-
*
|
|
61
|
-
* 这些名字必须在 `inject` 里点名:cordis 的**属性访问**(`this.ctx.sessions`)要求本 fiber 声明过那个服务,
|
|
62
|
-
* 未声明会抛 `cannot get property "sessions" without inject`(`ctx.get(name)` 才不需要声明)。
|
|
63
|
-
*/
|
|
41
|
+
// 这些名字必须在 `inject` 里点名:cordis 的属性访问(`this.ctx.sessions`)要求本 fiber 声明过那个服务,
|
|
42
|
+
// 未声明会抛 `cannot get property "sessions" without inject`(`ctx.get(name)` 才不需要声明)。
|
|
64
43
|
export const inject = ["agents", "sessions", "sessionProjections", "systemPrompt"];
|
|
65
44
|
|
|
66
45
|
export { Config } from "./modes.ts";
|
|
@@ -72,7 +51,8 @@ export type {
|
|
|
72
51
|
SessionModeRole,
|
|
73
52
|
} from "./modes.ts";
|
|
74
53
|
export { SESSION_MODE_PATH } from "./shared.ts";
|
|
75
|
-
export
|
|
54
|
+
export { POLICY_NAMES } from "./shared.ts";
|
|
55
|
+
export type { PolicyName, SessionModeRoster, SessionModeRow } from "./shared.ts";
|
|
76
56
|
|
|
77
57
|
declare module "@deepseek-ai/cordis" {
|
|
78
58
|
interface Context {
|
|
@@ -82,10 +62,7 @@ declare module "@deepseek-ai/cordis" {
|
|
|
82
62
|
|
|
83
63
|
declare module "@deepseek-ai/dsh-session/types" {
|
|
84
64
|
interface SessionEventMap {
|
|
85
|
-
|
|
86
|
-
* 会话在空白窗口里换了模式。**log-only**:它记录此后每一步实际运行的模式,恢复与 fork 时据此重建
|
|
87
|
-
* (模式决定模型看到的工具与提示词,所以它必须进日志)。
|
|
88
|
-
*/
|
|
65
|
+
// 会话在空白窗口里换了模式;**log-only**,恢复与 fork 据此重建(模式决定模型看到的工具与提示词)。
|
|
89
66
|
"session-mode/selected": { sessionMode: string };
|
|
90
67
|
}
|
|
91
68
|
}
|
|
@@ -96,16 +73,16 @@ declare module "@deepseek-ai/dsh-session-projection/types" {
|
|
|
96
73
|
sessionModeEditable: boolean;
|
|
97
74
|
}
|
|
98
75
|
interface SessionProjectionMap {
|
|
99
|
-
|
|
76
|
+
// 会话当前模式;`null` 表示没选过(用部署默认)。
|
|
100
77
|
sessionMode: string | null;
|
|
101
|
-
|
|
78
|
+
// 会话还能不能换模式:`true` 是选择器,`false` 是只读标签(client 那个 chip 据此变形)。
|
|
102
79
|
sessionModeEditable: boolean;
|
|
103
80
|
}
|
|
104
81
|
}
|
|
105
82
|
|
|
106
83
|
const sessionModeSchema: z.ZodType<string | null> = z.union([z.string(), z.null()]);
|
|
107
84
|
|
|
108
|
-
|
|
85
|
+
// 会话模式的投影:初值来自空日志(没选过就是 `null`),只被选择事件推进。
|
|
109
86
|
export const sessionModeProjection = {
|
|
110
87
|
key: "sessionMode",
|
|
111
88
|
stateSchema: sessionModeSchema,
|
|
@@ -118,13 +95,8 @@ export const sessionModeProjection = {
|
|
|
118
95
|
|
|
119
96
|
const sessionModeEditableSchema: z.ZodType<boolean> = z.boolean();
|
|
120
97
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
* 历史**的模式一致,所以"开过 turn"之后连正在跑的那个 turn 也算)。
|
|
124
|
-
*
|
|
125
|
-
* **判据只有这一处**:服务端 {@link SessionModes.select} 的拒绝与 client chip 的只读形态都读它——以前
|
|
126
|
-
* client 只能等服务端报错,现在连入口都不给,而两边的结论来自同一份会话事实。
|
|
127
|
-
*/
|
|
98
|
+
// 这个会话能不能换模式的投影:空白会话为 `true`,`turn/start` 一落库就永远 `false`(换模式要的是整段历史的
|
|
99
|
+
// 模式一致)。判据只有这一处——服务端拒绝与 client chip 的只读形态都读它。
|
|
128
100
|
export const sessionModeEditableProjection = {
|
|
129
101
|
key: "sessionModeEditable",
|
|
130
102
|
stateSchema: sessionModeEditableSchema,
|
|
@@ -134,12 +106,16 @@ export const sessionModeEditableProjection = {
|
|
|
134
106
|
stateVersion: 1,
|
|
135
107
|
} satisfies ProjectionDefinition<"sessionModeEditable", boolean>;
|
|
136
108
|
|
|
137
|
-
|
|
109
|
+
// 模式清单、默认模式、按会话读取与切换。
|
|
138
110
|
export class SessionModes extends Service {
|
|
139
|
-
|
|
111
|
+
// 每个 agent 已经装上的那一份(persona + 默认模型兜底;模式变了就换一份)。
|
|
140
112
|
private readonly installs = new WeakMap<Agent, { mode: string; dispose: () => void }>();
|
|
141
113
|
|
|
142
|
-
|
|
114
|
+
// 按会话收口:工具名单、instruction / 技能目录 / 动态快照三个开关。它由本行**内部持有**(不发布服务)——收口的
|
|
115
|
+
// 输入就是模式定义、唯一消费者也是模式。
|
|
116
|
+
private readonly scope: SessionScope;
|
|
117
|
+
|
|
118
|
+
// 装配时的配置快照:`default` / `modes` 读它,改这两项靠 Loader 重挂这一行(已运行会话不自动换定义)。
|
|
143
119
|
readonly config: {
|
|
144
120
|
default: string;
|
|
145
121
|
modes: Record<string, SessionMode>;
|
|
@@ -156,16 +132,38 @@ export class SessionModes extends Service {
|
|
|
156
132
|
// 退役的顶层 `models` 还配着值就让装配期报错。
|
|
157
133
|
const problem = configProblem({ ...this.config, models: config.models });
|
|
158
134
|
if (problem !== undefined) throw new Error(problem);
|
|
135
|
+
this.scope = new SessionScope(ctx);
|
|
159
136
|
ctx.sessionProjections.register(sessionModeProjection);
|
|
160
137
|
ctx.sessionProjections.register(sessionModeEditableProjection);
|
|
138
|
+
// 按模式的 policy 拦截:两条上游 waterfall 各 `prepend` **一次**,注册在行 ctx 上、随行卸载一起撤。
|
|
139
|
+
//
|
|
140
|
+
// 接缝为什么只有这一种:fs 的调用是 `ctx.waterfall('fs/write-intent', target, actor, next)`——事件名在第一个
|
|
141
|
+
// 参数上,而 cordis 只在"第一个参数是对象 / 函数"时才取接收者并按 scope 过滤,所以这两条 waterfall 上
|
|
142
|
+
// **没有 scope 过滤**:注册在该 agent 的 ctx 上买不到隔离,只会多 N 份判断。归属只能从 `actor.agent` 认
|
|
143
|
+
// (工具把自己的 exec 当 actor 传进来),模式的判据每次调用**现算**——于是切模式 / `applyTo` / 子代理继承
|
|
144
|
+
// 都不需要换监听器,"重复应用不重复注册"是结构上的事,不靠判等维持。
|
|
145
|
+
//
|
|
146
|
+
// 位置:上游 `fs-observation-policy` 在这两条 waterfall 上**独占决策槽**(它不调 `next()`),所以链首只能靠
|
|
147
|
+
// `prepend` 抢——站在它后面就没有决策权。这份实现的前提就是这条契约(见
|
|
148
|
+
// `.agents/designs/20260929-按模式的policy拦截.md`)。
|
|
149
|
+
ctx.on(
|
|
150
|
+
// `satisfies PolicyName`:注册的事件名与 `POLICY_NAMES`(装配期校验与页面候选键读的同一份名单)必须在类型面
|
|
151
|
+
// 对得上——名单里删掉一个名字,这里就编不过,而不是静默拦不住。
|
|
152
|
+
"fs/write-intent" satisfies PolicyName,
|
|
153
|
+
(_target, actor, next) => this.decidePolicy("fs/write-intent", actor, next),
|
|
154
|
+
{ prepend: true },
|
|
155
|
+
);
|
|
156
|
+
ctx.on(
|
|
157
|
+
"fs/edit-intent" satisfies PolicyName,
|
|
158
|
+
(_target, actor, next) => this.decidePolicy("fs/edit-intent", actor, next),
|
|
159
|
+
{ prepend: true },
|
|
160
|
+
);
|
|
161
161
|
// 会话一建立就装上:这早于它的第一次装配,persona 因此一定在装配之前注册好。
|
|
162
162
|
ctx.on("agent/created", ({ agent }) => {
|
|
163
163
|
this.installFor(agent);
|
|
164
164
|
});
|
|
165
|
-
// 官方 roster
|
|
166
|
-
//
|
|
167
|
-
// 只在 preset → 模式的映射唯一时动手:本部署两个模式共享同一份 preset,反查无意义(`modeForPreset`
|
|
168
|
-
// 返回 `undefined`),选模式不会经过这条监听,模式事实由 `select` 自己落。
|
|
165
|
+
// 官方 roster 换 preset 时把该会话的扩展换成新 preset 那一份(先落成会话事实再重装);
|
|
166
|
+
// 只在 preset → 模式的映射唯一时动手(共享同一份 preset 时反查无意义)。
|
|
169
167
|
ctx.on("agent-preset/selected", (sessionId: SessionId, preset: string) => {
|
|
170
168
|
const mapped = this.modeForPreset(preset);
|
|
171
169
|
const agent = ctx.agents.get(sessionId);
|
|
@@ -177,12 +175,12 @@ export class SessionModes extends Service {
|
|
|
177
175
|
});
|
|
178
176
|
}
|
|
179
177
|
|
|
180
|
-
|
|
178
|
+
// 新会话用它:config 里的 `default`。
|
|
181
179
|
get defaultId(): string {
|
|
182
180
|
return this.config.default;
|
|
183
181
|
}
|
|
184
182
|
|
|
185
|
-
|
|
183
|
+
// 选择器要的清单:只列 `main` 角色的模式(id、展示名、说明,顺序即 config 里 `modes` 的插入序)。
|
|
186
184
|
list(): SessionModeRow[] {
|
|
187
185
|
return this.idsFor("main").map((id) => {
|
|
188
186
|
const mode = this.definition(id);
|
|
@@ -194,32 +192,24 @@ export class SessionModes extends Service {
|
|
|
194
192
|
});
|
|
195
193
|
}
|
|
196
194
|
|
|
197
|
-
|
|
198
|
-
* 声明了某个角色的模式 id(顺序即 config 的插入序):`main` 给用户选择器,`subagent` 给子代理候选。
|
|
199
|
-
* @param role - 目标角色。
|
|
200
|
-
* @returns 该角色下的模式 id。
|
|
201
|
-
*/
|
|
195
|
+
// 声明了某个角色的模式 id(顺序即 config 的插入序):`main` 给用户选择器,`subagent` 给子代理候选。
|
|
202
196
|
idsFor(role: SessionModeRole): string[] {
|
|
203
197
|
return Object.entries(this.config.modes)
|
|
204
198
|
.filter(([, mode]) => mode.role.includes(role))
|
|
205
199
|
.map(([id]) => id);
|
|
206
200
|
}
|
|
207
201
|
|
|
208
|
-
|
|
209
|
-
* 同 {@link idsFor},给的是定义——"指定 mode" 那条接缝要拿候选集。
|
|
210
|
-
* @param role - 目标角色。
|
|
211
|
-
* @returns 该角色下的模式与其定义。
|
|
212
|
-
*/
|
|
202
|
+
// 同 `idsFor`,给的是定义——"指定 mode" 那条接缝要拿候选集。
|
|
213
203
|
modesFor(role: SessionModeRole): { id: string; mode: SessionMode }[] {
|
|
214
204
|
return this.idsFor(role).map((id) => ({ id, mode: this.definition(id) }));
|
|
215
205
|
}
|
|
216
206
|
|
|
217
|
-
|
|
207
|
+
// 页面用的清单 + 默认模式。
|
|
218
208
|
roster(): SessionModeRoster {
|
|
219
209
|
return { default: this.defaultId, modes: this.list() };
|
|
220
210
|
}
|
|
221
211
|
|
|
222
|
-
|
|
212
|
+
// 按 id 取定义;未知 id 直接抛(切换路径上它就是用户的错)。
|
|
223
213
|
definition(id?: string): SessionMode {
|
|
224
214
|
const wanted = id ?? this.defaultId;
|
|
225
215
|
const mode = this.config.modes[wanted];
|
|
@@ -231,30 +221,50 @@ export class SessionModes extends Service {
|
|
|
231
221
|
return mode;
|
|
232
222
|
}
|
|
233
223
|
|
|
234
|
-
|
|
224
|
+
// 会话当前模式:投影上有就用它,否则是部署默认。
|
|
235
225
|
modeOf(session: Session): string {
|
|
236
226
|
const selected = this.ctx.sessionProjections.stateOf(session, "sessionMode");
|
|
237
227
|
return selected ?? this.defaultId;
|
|
238
228
|
}
|
|
239
229
|
|
|
240
|
-
|
|
230
|
+
// 会话当前模式的定义。
|
|
241
231
|
modeOfSession(session: Session): SessionMode {
|
|
242
232
|
return this.definition(this.modeOf(session));
|
|
243
233
|
}
|
|
244
234
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
235
|
+
// 这条 policy 规则在**这次调用**里还生效吗(`true` = 交给上游,`false` = 被这个模式禁用)。
|
|
236
|
+
// 判据只有一处:`actor.agent` → `agent.session` → `modeOf`。认不出 agent(没有 agent 的直接调用)、或模式 id 在
|
|
237
|
+
// config 里找不到(重挂前后的瞬间)时一律按"没配"读——拦截路径绝不抛错。
|
|
238
|
+
private policyInForce(policy: PolicyName, actor: object | undefined): boolean {
|
|
239
|
+
const agent = (actor as { readonly agent?: Agent } | undefined)?.agent;
|
|
240
|
+
if (agent === undefined) return true;
|
|
241
|
+
const mode = this.config.modes[this.modeOf(agent.session)];
|
|
242
|
+
if (mode === undefined) return true;
|
|
243
|
+
// 合成规则(deny 优先):生效集合 =(`allowPolicies` 空 ? 全部 : `allowPolicies`)− `denyPolicies`。
|
|
244
|
+
if (mode.denyPolicies.includes(policy)) return false;
|
|
245
|
+
return mode.allowPolicies.length === 0 || mode.allowPolicies.includes(policy);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 一条 policy 规则的裁决:规则生效就原样交给上游(`next()` 的返回值或拒绝照旧出去);被禁用就**先让上游算完
|
|
249
|
+
// 再丢掉结论**——上游在链首之后独占决策槽,"放过"唯一可能的形态就是无条件裁决(`fs/edit-intent` 上它是免
|
|
250
|
+
// "先读后改",`fs/write-intent` 上是连陈旧版本 / CAS 那层安全网一起丢)。上游抛出的拒绝也属于这条裁决:
|
|
251
|
+
// 接住它,返回 `undefined`(= 这次调用按"没有这条规则"继续)。
|
|
252
|
+
private async decidePolicy<T>(
|
|
253
|
+
policy: PolicyName,
|
|
254
|
+
actor: object | undefined,
|
|
255
|
+
next: () => T | Promise<T>,
|
|
256
|
+
): Promise<T | undefined> {
|
|
257
|
+
if (this.policyInForce(policy, actor)) return await next();
|
|
258
|
+
try {
|
|
259
|
+
await next();
|
|
260
|
+
} catch {
|
|
261
|
+
// 上游的拒绝是这次裁决的一部分:禁用就是连它一起不要。
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 把某个空白会话(必须还没开过 turn)切到某个模式:先把 agent preset 换成模式声明的那个
|
|
267
|
+
// (目标与当前相同时不切——换 preset 是一次重挂),再落会话事实并重装该 agent 的扩展。
|
|
258
268
|
async select(sessionId: SessionId, mode: string): Promise<string> {
|
|
259
269
|
const definition = this.definition(mode);
|
|
260
270
|
if (!definition.role.includes("main")) {
|
|
@@ -262,16 +272,15 @@ export class SessionModes extends Service {
|
|
|
262
272
|
}
|
|
263
273
|
const session = this.ctx.sessions.get(sessionId);
|
|
264
274
|
if (session === undefined) throw new Error(`未知的会话 ${sessionId}`);
|
|
265
|
-
//
|
|
266
|
-
// 所以"看起来能选"与"服务端接受"不会脱节。
|
|
275
|
+
// 空白窗口的判据只有一处:`sessionModeEditableProjection`(client chip 读同一个投影)。
|
|
267
276
|
if (this.ctx.sessionProjections.stateOf(session, "sessionModeEditable") === false) {
|
|
268
277
|
throw new Error("这个会话已经开始,模式不能再改;要换模式请新开一个会话。");
|
|
269
278
|
}
|
|
270
279
|
const agent = this.ctx.agents.get(sessionId);
|
|
271
280
|
const registry = this.presetRegistry();
|
|
272
281
|
if (agent !== undefined && registry !== undefined && definition.preset.length > 0) {
|
|
273
|
-
// 官方那条路自己也会查空白窗口(`agent-preset/locked
|
|
274
|
-
//
|
|
282
|
+
// 官方那条路自己也会查空白窗口(`agent-preset/locked`);它切完会 emit `agent-preset/selected`,
|
|
283
|
+
// 监听据此落事实并重装——所以这里先比一次投影,同一个值不写第二条。
|
|
275
284
|
if (this.presetOfAgent(registry, agent) !== definition.preset) {
|
|
276
285
|
await registry.select(agent, definition.preset);
|
|
277
286
|
}
|
|
@@ -284,12 +293,7 @@ export class SessionModes extends Service {
|
|
|
284
293
|
return mode;
|
|
285
294
|
}
|
|
286
295
|
|
|
287
|
-
|
|
288
|
-
* 某个 agent 当前挂着的 preset(registry 的 `composedPreset`)。
|
|
289
|
-
*
|
|
290
|
-
* 读不到时返回 `undefined`(按"未知"处理 → 该切就切):`composedPreset` 是 registry 较新的读面,替身与老
|
|
291
|
-
* 版本可能没有它;而"没挂任何 preset"(返回值 `undefined`)与"读不到"在这里是同一个结论。
|
|
292
|
-
*/
|
|
296
|
+
// 某个 agent 当前挂着的 preset(registry 的 `composedPreset`);读不到时按"未知"处理(该切就切)。
|
|
293
297
|
private presetOfAgent(registry: Context["agentPresets"], agent: Agent): string | undefined {
|
|
294
298
|
const read = (registry as { composedPreset?: (ctx: Context) => string | undefined })
|
|
295
299
|
.composedPreset;
|
|
@@ -301,10 +305,8 @@ export class SessionModes extends Service {
|
|
|
301
305
|
}
|
|
302
306
|
}
|
|
303
307
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
* 点名会让本行永不激活),所以按"可能拿不到"读——`ctx.get` 在当前 ctx 没声明那个服务时会抛。
|
|
307
|
-
*/
|
|
308
|
+
// 官方 preset registry;不在 `inject` 里点名(headless 部署没有它,点名会让本行永不激活),
|
|
309
|
+
// 所以按"可能拿不到"读——`ctx.get` 在当前 ctx 没声明那个服务时会抛。
|
|
308
310
|
private presetRegistry(): Context["agentPresets"] | undefined {
|
|
309
311
|
try {
|
|
310
312
|
return this.ctx.get("agentPresets");
|
|
@@ -313,13 +315,8 @@ export class SessionModes extends Service {
|
|
|
313
315
|
}
|
|
314
316
|
}
|
|
315
317
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
* 走这里,**未来的"指定 mode"入口(模型侧或配置侧)也走这里**(那条接缝还没做)。
|
|
319
|
-
* @param agent - 目标 agent。
|
|
320
|
-
* @param mode - 目标模式 id。
|
|
321
|
-
* @param options.record - 是否把这次切换写进会话日志(缺省写;只想改当前进程时给 `false`)。
|
|
322
|
-
*/
|
|
318
|
+
// 把某个活着的 agent 切到某个模式(不要求空白会话:子代理创建时的继承走这里);
|
|
319
|
+
// `options.record: false` 时不写会话日志(只改当前进程)。
|
|
323
320
|
applyTo(agent: Agent, mode: string, options: { record?: boolean } = {}): void {
|
|
324
321
|
this.definition(mode);
|
|
325
322
|
if (options.record !== false) {
|
|
@@ -328,17 +325,14 @@ export class SessionModes extends Service {
|
|
|
328
325
|
this.installFor(agent, mode);
|
|
329
326
|
}
|
|
330
327
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
*
|
|
334
|
-
* 继承要**写进子会话日志**:它是一条会话事实,冷恢复与 fork 都要靠它重建({@link modeOf} 只读投影)。
|
|
335
|
-
*/
|
|
328
|
+
// 该 agent 用哪个模式:会话选过(投影上有)优先,子代理继承父,其余用部署默认。
|
|
329
|
+
// 继承要写进子会话日志——它是一条会话事实,冷恢复与 fork 靠它重建(`modeOf` 只读投影)。
|
|
336
330
|
private resolveModeId(agent: Agent): string {
|
|
337
331
|
// `undefined` = 这个投影没注册(或还没初始化),与"没选过"(`null`)一样落到下一层。
|
|
338
332
|
const selected = this.ctx.sessionProjections.stateOf(agent.session, "sessionMode");
|
|
339
333
|
if (typeof selected === "string") return selected;
|
|
340
|
-
// 会话级选择归官方 roster
|
|
341
|
-
//
|
|
334
|
+
// 会话级选择归官方 roster;结论落成我们自己的会话事实(`sessionMode`:恢复、子代理继承、
|
|
335
|
+
// 服务端读取都读它)。
|
|
342
336
|
const mapped = this.modeForPreset(this.presetOf(agent.session));
|
|
343
337
|
if (mapped !== undefined) {
|
|
344
338
|
agent.session.append("session-mode/selected", { sessionMode: mapped });
|
|
@@ -350,7 +344,7 @@ export class SessionModes extends Service {
|
|
|
350
344
|
return inherited;
|
|
351
345
|
}
|
|
352
346
|
|
|
353
|
-
|
|
347
|
+
// 官方 roster 选定的 preset(没选过、或 registry 没装时为 `undefined`)。
|
|
354
348
|
private presetOf(session: Session): string | undefined {
|
|
355
349
|
try {
|
|
356
350
|
const state = this.ctx.sessionProjections.stateOf(session, "agentPreset");
|
|
@@ -361,16 +355,8 @@ export class SessionModes extends Service {
|
|
|
361
355
|
}
|
|
362
356
|
}
|
|
363
357
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
* `undefined`,不反查)。
|
|
367
|
-
*
|
|
368
|
-
* 本部署的两个模式共享同一个 preset(`MODE_PRESET_ID`),差异全在会话级收口,所以 preset → 模式的反查在
|
|
369
|
-
* 这里无意义:模式由**会话事实**决定(`session-mode/selected` 投影 → 子代理继承 → 部署默认),
|
|
370
|
-
* 官方 roster 选了什么 preset 不改变这个会话是哪个模式。这条反查留给"一对一映射"的部署形态。
|
|
371
|
-
* @param preset - preset id(`undefined` 表示没选过、或 registry 没装)。
|
|
372
|
-
* @returns 该 preset 唯一对应的模式 id;没配扩展、或由多个模式共享时 `undefined`。
|
|
373
|
-
*/
|
|
358
|
+
// 某个 preset 对应的模式 id——只在映射唯一时回答(共享同一份 preset、或没有模式挂它时 `undefined`);
|
|
359
|
+
// 本部署的模式由会话事实决定,这条反查留给"一对一映射"的部署形态。
|
|
374
360
|
modeForPreset(preset: string | undefined): string | undefined {
|
|
375
361
|
if (preset === undefined) return undefined;
|
|
376
362
|
const owners = Object.keys(this.config.modes).filter(
|
|
@@ -379,7 +365,7 @@ export class SessionModes extends Service {
|
|
|
379
365
|
return owners.length === 1 ? owners[0] : undefined;
|
|
380
366
|
}
|
|
381
367
|
|
|
382
|
-
|
|
368
|
+
// 子代理(有 durable 父会话)继承父当前模式;父不在场、或不是子代理时没有可继承的。
|
|
383
369
|
private inheritedModeId(agent: Agent): string | undefined {
|
|
384
370
|
const parentId = agent.session.header.parentSession;
|
|
385
371
|
if (parentId === undefined) return undefined;
|
|
@@ -387,7 +373,7 @@ export class SessionModes extends Service {
|
|
|
387
373
|
return parent === undefined ? undefined : this.modeOf(parent.session);
|
|
388
374
|
}
|
|
389
375
|
|
|
390
|
-
|
|
376
|
+
// 装或换该 agent 的那一份(幂等:同一模式不重复注册)。
|
|
391
377
|
private installFor(agent: Agent, modeId: string = this.resolveModeId(agent)): void {
|
|
392
378
|
const installed = this.installs.get(agent);
|
|
393
379
|
if (installed?.mode === modeId) return;
|
|
@@ -403,16 +389,20 @@ export class SessionModes extends Service {
|
|
|
403
389
|
for (const dispose of disposers) dispose();
|
|
404
390
|
},
|
|
405
391
|
});
|
|
406
|
-
|
|
392
|
+
// 收口要的那几项按模式定义整份推过去:`name` / `allowTools` / `denyTools` / 三个开关。`skills` 缺省时在这里
|
|
393
|
+
// 按这份定义自己的工具名单推导(见 `derivedSkills`)——收口那一侧只认解析后的布尔。
|
|
394
|
+
this.scope.apply(agent, {
|
|
395
|
+
name: mode.name,
|
|
396
|
+
allowTools: mode.allowTools,
|
|
397
|
+
denyTools: mode.denyTools,
|
|
398
|
+
instructions: mode.instructions,
|
|
399
|
+
skills: mode.skills ?? derivedSkills(mode),
|
|
400
|
+
runtimeContext: mode.runtimeContext,
|
|
401
|
+
});
|
|
407
402
|
}
|
|
408
403
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
* 还没跑过请求)时接管这一请求的路由;一旦用户选过(投影 `pending`)或会话已经落过 header,就不再插手。
|
|
412
|
-
*
|
|
413
|
-
* 它是**配置事实**,不写会话事件——重启后仍由 config 决定;设置页里那条会话级选择才是会话事实。读的是构造
|
|
414
|
-
* 时那份模式清单快照:设置页保存会让这一行重挂(`reconcileProfilePatches`),新定义随重挂生效。
|
|
415
|
-
*/
|
|
404
|
+
// 模式的默认模型兜底:只在会话尚无任何模型事实(没选过模型、也没落过 request header)时接管这一请求的
|
|
405
|
+
// 路由。它是配置事实、不写会话事件,读构造时的模式快照(设置页保存会让这一行重挂)。
|
|
416
406
|
private installDefaultModel(agent: Agent, modeId: string): () => void {
|
|
417
407
|
return agent.ctx.on("agent/request", async (_payload, next): Promise<LlmCallConfig> => {
|
|
418
408
|
const resolved = await next();
|
|
@@ -432,15 +422,6 @@ export class SessionModes extends Service {
|
|
|
432
422
|
};
|
|
433
423
|
});
|
|
434
424
|
}
|
|
435
|
-
|
|
436
|
-
/** 收口服务由 `@morlay/dsh-context-assembler/scope` 那一行发布;没装它就只有 persona。 */
|
|
437
|
-
private toolScope(): SessionToolScope | undefined {
|
|
438
|
-
try {
|
|
439
|
-
return this.ctx.get("sessionToolScope");
|
|
440
|
-
} catch {
|
|
441
|
-
return undefined;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
425
|
}
|
|
445
426
|
|
|
446
427
|
export function apply(ctx: Context, config: ResolvedConfig): void {
|
|
@@ -528,12 +509,8 @@ async function handleRoute(
|
|
|
528
509
|
}
|
|
529
510
|
}
|
|
530
511
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
*
|
|
534
|
-
* `webServer` **必须等**:它可能比本行晚激活,而一次性 `ctx.get` 取到 `undefined` 之后不会再试一次——
|
|
535
|
-
* 路由没注册的后果是请求落到静态资源 fallback,非 GET/HEAD 一律 405。
|
|
536
|
-
*/
|
|
512
|
+
// 把清单与切换挂到宿主路由表上。`webServer` 必须等:它可能比本行晚激活,一次性 `ctx.get` 取不到就不会
|
|
513
|
+
// 再试(后果是请求落到静态资源 fallback)。
|
|
537
514
|
function registerHttpRoutes(ctx: Context, modes: SessionModes): void {
|
|
538
515
|
ctx.inject(["webServer"], (scope) => {
|
|
539
516
|
// webServer 的类型由上游 `@deepseek-ai/dsh-host-webserver` 声明;这里只按用到的 register 面做结构转换。
|