dsh-session-conductor 1.3.3 → 1.3.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 CHANGED
@@ -21,7 +21,7 @@
21
21
  | 压缩模型选择 | 上下文自动压缩用模型独立选择(省成本) | 设置 → 插件 → 压缩模型 |
22
22
  | 模板注入 | 方案模板 / 收尾模板:本地设备上传 / 在线网址 / DSH 目录浏览三种导入,内容可再编辑,注入系统提示词(方案/收尾各一条上下文注入) | 设置 → 会话管理 → 模板注入 |
23
23
  | 自动重命名模型 | 指定便宜/专用模型做标题分析 | 设置 → 会话管理 |
24
- | 成员模型切换 | 给队友/子代理单独切模型(只动该成员会话,不影响其他成员):先用 `list_models` 拿确切模型 id,再 `set_member_model` 按成员名/sessionId 切换(写 `model/selection` 事件交给官方投影接管,跨轮持久;未挂载成员也能切,运行中会先等它暂停)。**返回值直接带「切没切成」的查证**:立刻给「最近一次实际请求的 provider/model(取自 `request/header`,请求级权威记录)+ 最近一次切换意图 + 一行判定」,并**注册查证任务(不阻塞)**——该成员下次发起请求时按 `agent/request` 事件判定,确认结果在下一次 step 注入给发起方(不轮询、不等待);另附可复制的复核命令 `node scripts/check-member-model.mjs <sessionId>`。判定只认请求时真实使用的模型,不信注入的 `[model changed: …]` 提示 | AI 工具 `list_models` / `set_member_model` + 脚本 `scripts/check-member-model.mjs` |
24
+ | 成员模型切换 | 给队友/子代理单独切模型(只动该成员会话,不影响其他成员):先用 `list_models` 拿确切模型 id,再 `set_member_model` 按成员名/sessionId 切换(写 `model/selection` 事件交给官方投影接管,跨轮持久;未挂载成员也能切,运行中会先等它暂停)。**返回值直接带「切没切成」的查证**:立刻给「最近一次实际请求的 provider/model(取自 `request/header`,请求级权威记录)+ 最近一次切换意图 + 一行判定」,并**注册查证任务(不阻塞)**——该成员下次发起请求时按 `agent/request` 事件判定,确认结果在下一次 step 注入给发起方(不轮询、不等待);另附可复制的复核命令 `node scripts/check-member-model.mjs <sessionId>`。判定只认请求时真实使用的模型,不信注入的 `[model changed: …]` 提示。**另提供用户聊天框指令 `/model-switch`**(注册在宿主 `ctx.commands`):无参 = 列成员与各自模型;`/model-switch <成员>` = 查该成员**实际**在用哪个模型(读请求级权威记录);`/model-switch <成员> <provider>/<模型>` = 切换并回显查证快照 —— 与 AI 工具共用同一条切换 + 查证路径,用户自己就能操作,不必绕道求 AI | AI 工具 `list_models` / `set_member_model` + 聊天框指令 `/model-switch` + 脚本 `scripts/check-member-model.mjs` |
25
25
  | 记忆取回 | 检测到上下文用量**明显下降**(默认 ≥10%,可配)即判定「上下文疑似丢失」——压缩、截断、实例重启后快照未加载**都算**(比只盯压缩更全)。此时自动把最近 **3 轮(可配)** 的**用户消息文本 + 该轮 AI 的第一条回应**(优先取当时的 reasoning)重新注入,让 AI 立刻恢复状态。实现要点:按 zstd 帧**流式**读日志(常量内存)、**只认真实用户消息**(排除插件自身注入,防反馈环)、同回合连发**合并成一轮**、注入总长与单条都有上限。配置项 `retrieveMemory*`(默认开)。**另提供 AI 工具 `recall_memory`**(参数 `rounds` = 取几轮,默认取配置、上限 50;参数 `session` = 目标会话:完整 id / 唯一前缀 / 标题子串,缺省或留空即取自身)让 AI 自己按需回溯,**也能取其它会话**(跨会话排查时在会话内直接看到对方上下文;命中多个候选会给出候选清单,不随便挑一个) | 每轮注入(`agent/pre-step`)+ AI 工具 `recall_memory` |
26
26
  | 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/消息文本),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数。**另:每轮把实时用量注入消息尾部**(AI 每轮都能直接看到数字;注入在尾部而非系统提示词前缀 ⇒ 前缀稳定、prompt cache 仍命中;按 turn 门控,一回合只注一次;配置 `injectContextUsage` 可关) | AI 工具 `context_usage` + 每轮注入 |
27
27
  | 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。`list_sessions` 支持 **`query` 子串过滤 + `limit`/`offset` 分页**(默认只给 20 条,避免整表塞进调用方上下文)。**索引快路径 + 后台化**:`fromIndex=true` 直接读落盘索引(秒回、不重建,不含 live 状态与归档态),默认刷新路径先等 3 秒拿热缓存结果、超时就把**同一次在途构建**交给宿主后台 job 并立刻返回 jobId(冷缓存全量构建实测 226 秒 / 201 会话 / 1.5 GB 日志,热缓存 47 毫秒;job 注册失败会同步降级并带回原因)。硬约束:禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条 | AI 工具 `list_sessions` / `send_to_session` |
@@ -142,6 +142,63 @@ allowBuilds:
142
142
  - **快照模式(推荐,免重启)**:`bash tools/preview-snapshot.sh` → 生成 `assets/preview-*.html`(内嵌生成时真实后端响应),file:// 打开即可查看,无 CORS、无假数据
143
143
  - **DSH 同源路由**:重启后访问 `/api/session-conductor/preview/settings`(或 `/panel`),同源实时 fetch
144
144
 
145
+ ### 聊天框指令:`/model-switch`
146
+
147
+ 在**用户聊天框**里直接看/切成员(子代理)的模型,不必求 AI。指令由插件注册到宿主的
148
+ `ctx.commands` 服务,**在插件加载时生效** —— 装好或更新后需重载一次实例,输入框里敲 `/model` 才有补全。
149
+
150
+ **① 看有哪些成员、各自在用什么**
151
+
152
+ ```
153
+ /model-switch
154
+ ```
155
+
156
+ ```
157
+ 成员模型(共 2 个)
158
+
159
+ · model-test(session-aaaa-1111) — 最近请求=wb/cn:deepseek-v4-flash
160
+ · writer(session-bbbb-2222) — 最近请求=n/deepseek-v4.1-flash
161
+
162
+ 要查**实际**在用什么(含历史请求,不只本次进程)或切换:
163
+ /model-switch <成员名或 sessionId> ← 查实际模型
164
+ /model-switch <成员名或 sessionId> <provider>/<模型> ← 切换并回显查证
165
+ ```
166
+
167
+ 「最近请求」取自**进程内真实请求记录**(瞬时、不扫日志);显示「未知」说明本进程还没见它请求过,用 ② 查历史。若同时显示目标与最近请求且不一致,会附 `⚠️ 与目标不一致(下一回合生效前属正常)`。
168
+
169
+ **② 查某个成员实际在用什么**
170
+
171
+ ```
172
+ /model-switch writer
173
+ ```
174
+
175
+ ```
176
+ 成员 writer(session-bbbb-2222)
177
+
178
+ 实际请求使用:n/deepseek-v4.1-flash(2026/10/17 05:18:00)
179
+ 最近切换意图:n/deepseek-v4.1-flash
180
+
181
+ 切换:/model-switch writer <provider>/<模型>
182
+ ```
183
+
184
+ 判定只认**请求时真实使用**的模型(日志里 `request/header` 的 `data.header.config.{provider,model}`),不拿「切换意图」当结论 —— 实测出现过「提示是 agni、请求头仍是 wb」的不一致。日志尚无记录时会回退显示「本次进程内观察到的最近请求」,而不是武断地说没有。
185
+
186
+ **③ 切换,并回显查证结果**
187
+
188
+ ```
189
+ /model-switch writer n/deepseek-v4.1-flash
190
+ ```
191
+
192
+ ```
193
+ 已把成员 writer(session-bbbb-2222) 切到 n/deepseek-v4.1-flash
194
+ 生效方式:model/selection 事件(官方投影接管)…
195
+ 最近一次实际请求:wb/cn:deepseek-v4-flash(2026/10/17 05:18:00)
196
+ 判定:⏳ 待下一回合生效(最近一次请求是切换前发出的,属正常)
197
+ 确认结果会在该成员下次发起请求时注入回发起方(事件驱动,不阻塞)。
198
+ ```
199
+
200
+ 看到 **⏳ 不代表失败**:切换本就下一回合生效(当前那次请求早已发出)。该成员下次请求时插件按事件判定,并把 `【切换查证】✅ 已确认生效:…`(或 `❌ 未生效:期望 X,实际 Y` —— 不假装成功)注入回发起方。定位可用**成员名**或 **sessionId**(名字支持唯一子串,重名会要求写全);模型必须写成 `provider/model`,写错会直接给正确格式。
201
+
145
202
  ## API
146
203
 
147
204
  插件以 `/api/session-conductor/*` 前缀注册宿主路由:
package/lib/apply.js CHANGED
@@ -54,7 +54,8 @@ import { SEARCH_MIN_QUERY, searchSessions } from './features/search.js';
54
54
  // applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
55
55
  // 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
56
56
  import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOverride, validateModelPair,
57
- registerSwitchWatch, noteRequestModel, takeSwitchOutcomes, readLastRequestModel } from './features/member-model/index.js';
57
+ registerSwitchWatch, noteRequestModel, takeSwitchOutcomes, readLastRequestModel,
58
+ registerModelSwitchCommand } from './features/member-model/index.js';
58
59
  // 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
59
60
  import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
60
61
  import { installProcessGuards } from './guards.js';
@@ -1542,5 +1543,10 @@ export async function apply(ctx, config = {}) {
1542
1543
  log(ctx, `记忆取回工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
1543
1544
  }
1544
1545
  log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
1546
+ // 聊天框指令 `/model-switch`:用户自己就能看/切成员模型的入口(与 AI 工具同一套切换 + 查证路径)。
1547
+ // 异步注册 + 独立兜底:宿主 commands 服务或 brand 包不可用时只留痕,绝不影响其它功能(同既有约定)。
1548
+ registerModelSwitchCommand(ctx)
1549
+ .then((ok) => { if (ok) log(ctx, "聊天框指令已注册 (/model-switch)"); })
1550
+ .catch((error) => log(ctx, `聊天框指令注册失败(不影响其它功能): ${String(error?.message ?? error)}`));
1545
1551
  });
1546
1552
  }
@@ -0,0 +1,208 @@
1
+ // dsh-session-conductor — 成员模型切换:聊天框指令 `/model-switch`
2
+ //
3
+ // 【这个文件解决什么】把「看/切成员(子代理)模型」搬到**用户聊天框**里:原来只有 AI 工具
4
+ // 和程序内函数两条路,用户自己想在输入框切一个成员得绕道求 AI。指令做成三条形态:
5
+ // · `/model-switch` → 列成员与各自模型(含本插件记录的 override)
6
+ // · `/model-switch <成员>` → 查这个成员**实际**在用哪个模型(读请求级权威记录)
7
+ // · `/model-switch <成员> <provider>/<模型>` → 切换,并回显查证快照(最近一次实际请求 + 判定)
8
+ //
9
+ // 【宿主的指令 API(照官方范例 @deepseek-ai/dsh-command-goal 抄,不是猜)】
10
+ // · 注册:`ctx.commands.register({ definitionId, name, description, input, handler })`
11
+ // definitionId 需用 `@deepseek-ai/dsh-commands/brand` 的 `CommandDefinitionId()` 包一层;
12
+ // · 取参:handler 收到 invocation,文本从 `invocation.rawInput`(命令后的原文)取,
13
+ // 发起会话从 `invocation.agent` 取;
14
+ // · 回显:handler 返回 `{ kind: "success" | "error", text }`,由聊天框渲染。
15
+ //
16
+ // 【为什么全程动态导入宿主包】工作区自测环境没有 @deepseek-ai/* 依赖;静态导入会在**导入期**
17
+ // 就抛 ERR_MODULE_NOT_FOUND,连累整个插件加载失败(本项目在 defineTool / createUserMessage
18
+ // 上踩过同类坑)。因此拿不到宿主能力时**跳过注册并如实留痕**,绝不影响插件其它功能。
19
+ import { log } from "../../shared/log.js";
20
+ import { findTargetAgent, pickTeamCaller } from "./locate.js";
21
+ import { getMemberModelOverride } from "./override.js";
22
+ import { readLastRequestModel, registerSwitchWatch, switchAgentModel, peekLastRequestModel } from "./switch.js";
23
+
24
+ const COMMAND_NAME = "model-switch";
25
+ const DEFINITION_ID = "@deepseek-ai/dsh-session-conductor/command-model-switch";
26
+
27
+ /** 把 `provider/model` 拆开(只按**第一个**斜杠切,模型 id 自身可能含斜杠/冒号)。 */
28
+ function splitPair(text) {
29
+ const raw = String(text ?? "").trim();
30
+ const at = raw.indexOf("/");
31
+ if (at <= 0) return { error: "模型要写成 <provider>/<model>(例:n/deepseek-v4.1-flash)" };
32
+ const provider = raw.slice(0, at).trim();
33
+ const model = raw.slice(at + 1).trim();
34
+ if (!provider || !model) return { error: "provider 与 model 都不能为空(例:n/deepseek-v4.1-flash)" };
35
+ return { provider, model };
36
+ }
37
+
38
+ /** 成员标签(有名字就带上,便于人读)。 */
39
+ function memberLabel(found) {
40
+ const name = found?.member?.name;
41
+ return name ? `${name}(${found.sessionId})` : String(found?.sessionId ?? "?");
42
+ }
43
+
44
+ /**
45
+ * 名册:Team 成员(spawn_teammate 创建的队友,不在普通 agents 列表里)+ 普通 agents。
46
+ * 两条来源都取不到时返回空数组(调用方据此给出可读提示)。
47
+ */
48
+ function collectMembers(ctx) {
49
+ const out = [];
50
+ const seen = new Set();
51
+ const push = (id, name, extra = {}) => {
52
+ if (!id || seen.has(id)) return;
53
+ seen.add(id);
54
+ out.push({ id, name: name ?? null, ...extra });
55
+ };
56
+ try {
57
+ const teams = ctx?.get?.("agentTeams");
58
+ const caller = pickTeamCaller(ctx);
59
+ if (teams?.listMembers && caller) {
60
+ for (const m of teams.listMembers(caller) ?? []) push(m?.id ?? m?.sessionId, m?.name, { provider: m?.provider, model: m?.model, source: "名册" });
61
+ }
62
+ } catch (error) {
63
+ log(ctx, `模型指令:读 Team 名册失败(跳过该来源): ${String(error?.message ?? error)}`);
64
+ }
65
+ try {
66
+ const agents = ctx?.get?.("agents");
67
+ for (const a of agents?.list?.() ?? []) push(a?.session?.id ?? a?.id, a?.session?.title ?? null, { source: "agent" });
68
+ } catch (error) {
69
+ log(ctx, `模型指令:读 agents 列表失败(跳过该来源): ${String(error?.message ?? error)}`);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** 无参:列成员与各自模型(**便宜来源**:本插件 override → 进程内最近一次请求 → 名册记录值)。 */
75
+ async function renderMemberList(ctx) {
76
+ const members = collectMembers(ctx);
77
+ if (members.length === 0) {
78
+ return "没读到任何成员。可能当前不是 Team 成员、名册不可用,或确实还没有队友。";
79
+ }
80
+ const lines = [`成员模型(共 ${members.length} 个)`, ""];
81
+ for (const m of members) {
82
+ let override = null;
83
+ try { override = await getMemberModelOverride(ctx, m.id); } catch { /* domain 暂不可用:按无 override 显示 */ }
84
+ const live = peekLastRequestModel(m.id); // 进程内最近一次请求:不读日志,瞬时
85
+ const record = m.provider && m.model ? `${m.provider}/${m.model}` : null;
86
+ const shown = [];
87
+ if (override) shown.push(`目标=${override.provider}/${override.model}`);
88
+ if (live) shown.push(`最近请求=${live.provider}/${live.model}`);
89
+ if (shown.length === 0 && record) shown.push(`名册记录=${record}`);
90
+ if (shown.length === 0) shown.push("未知(用下一条查实际模型)");
91
+ const flag = live && override && (live.provider !== override.provider || live.model !== override.model)
92
+ ? " ⚠️ 与目标不一致(下一回合生效前属正常)"
93
+ : "";
94
+ lines.push(`· ${m.name ?? "(无名)"}(${m.id}) — ${shown.join(" | ")}${flag}`);
95
+ }
96
+ lines.push("");
97
+ lines.push("要查**实际**在用什么(含历史请求,不只本次进程)或切换:");
98
+ lines.push(` /${COMMAND_NAME} <成员名或 sessionId> ← 查实际模型`);
99
+ lines.push(` /${COMMAND_NAME} <成员名或 sessionId> <provider>/<模型> ← 切换并回显查证`);
100
+ return lines.join("\n");
101
+ }
102
+
103
+ /** 一参:查该成员**实际**在用哪个模型(优先读日志里的 request/header = 请求级权威记录)。 */
104
+ async function renderMemberDetail(found) {
105
+ const { lastRequest, lastSelection } = await readLastRequestModel(found.sessionId);
106
+ // 日志里还没有记录时回退到「本次进程内观察到的最近请求」——比直接说「没有」有用得多
107
+ // (例如成员本进程刚跑过一轮,只是日志还没落盘/被截断)。
108
+ const live = peekLastRequestModel(found.sessionId);
109
+ const actual = lastRequest
110
+ ? `${lastRequest.provider}/${lastRequest.model}`
111
+ + (lastRequest.time ? `(${new Date(lastRequest.time).toLocaleString("zh-CN")})` : "")
112
+ : live
113
+ ? `${live.provider}/${live.model}(本次进程内观察到;日志里还没有 request/header 记录)`
114
+ : "日志里还没有 request/header(该成员可能从未发起过请求)";
115
+ const lines = [`成员 ${memberLabel(found)}`, "", `实际请求使用:${actual}`];
116
+ if (lastSelection) lines.push(`最近切换意图:${lastSelection.provider}/${lastSelection.model}`);
117
+ lines.push("");
118
+ lines.push(`切换:/${COMMAND_NAME} ${found.member?.name ?? found.sessionId} <provider>/<模型>`);
119
+ return lines.join("\n");
120
+ }
121
+
122
+ /**
123
+ * 二参:切换 + 回显查证快照(与 AI 工具 `set_member_model` 同一套落点:
124
+ * 写 override / 事件、登记查证任务、按请求级记录给判定)。
125
+ */
126
+ async function switchMember(ctx, found, provider, model, invocation) {
127
+ const result = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
128
+ if (result.error) return { kind: "error", text: `切换失败:${result.error}` };
129
+ // 登记查证任务:该成员下次发起请求时由 agent/request 事件判定,结果会注入发起方
130
+ try {
131
+ registerSwitchWatch(found.sessionId, provider, model, invocation?.agent?.session?.id ?? null);
132
+ } catch (error) {
133
+ log(ctx, `模型指令:登记查证失败(不影响切换): ${String(error?.message ?? error)}`);
134
+ }
135
+ const lines = [`已把成员 ${memberLabel(found)} 切到 ${provider}/${model}`, `生效方式:${result.via}`];
136
+ try {
137
+ const { lastRequest, lastSelection } = await readLastRequestModel(found.sessionId);
138
+ if (lastRequest) {
139
+ const same = lastRequest.provider === provider && lastRequest.model === model;
140
+ lines.push(`最近一次实际请求:${lastRequest.provider}/${lastRequest.model}`
141
+ + (lastRequest.time ? `(${new Date(lastRequest.time).toLocaleString("zh-CN")})` : ""));
142
+ lines.push(same
143
+ ? "判定:✅ 该成员最近的请求已经是新模型"
144
+ : "判定:⏳ 待下一回合生效(最近一次请求是切换前发出的,属正常)");
145
+ } else {
146
+ lines.push("判定:⏳ 该成员还没发起过请求,待首次运行后生效");
147
+ }
148
+ if (lastSelection) lines.push(`最近一次切换意图:${lastSelection.provider}/${lastSelection.model}`);
149
+ } catch (error) {
150
+ lines.push(`查证快照读取失败:${String(error?.message ?? error)}`);
151
+ }
152
+ lines.push("确认结果会在该成员下次发起请求时注入回发起方(事件驱动,不阻塞)。");
153
+ return { kind: "success", text: lines.join("\n") };
154
+ }
155
+
156
+ /**
157
+ * 指令处理入口(导出便于单测直接驱动,不必走宿主)。
158
+ *
159
+ * @param {object} ctx 插件 context
160
+ * @param {{rawInput?: string, agent?: object}} invocation 宿主给的调用信息
161
+ * @returns {Promise<{kind: "success"|"error", text: string}>} 交给聊天框渲染
162
+ */
163
+ export async function handleModelSwitchCommand(ctx, invocation) {
164
+ const raw = String(invocation?.rawInput ?? "").trim();
165
+ const parts = raw.split(/\s+/).filter(Boolean);
166
+ try {
167
+ if (parts.length === 0) return { kind: "success", text: await renderMemberList(ctx) };
168
+ const found = findTargetAgent(ctx, parts[0]);
169
+ if (found?.error) return { kind: "error", text: `找不到成员:${found.error}` };
170
+ if (parts.length === 1) return { kind: "success", text: await renderMemberDetail(found) };
171
+ const pair = splitPair(parts[1]);
172
+ if (pair.error) return { kind: "error", text: pair.error };
173
+ return await switchMember(ctx, found, pair.provider, pair.model, invocation);
174
+ } catch (error) {
175
+ return { kind: "error", text: `执行失败:${String(error?.message ?? error)}` };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * 注册 `/model-switch` 指令。
181
+ *
182
+ * @returns {Promise<boolean>} true = 已注册;false = 宿主 commands 服务或 brand 包不可用(已留痕)
183
+ */
184
+ export async function registerModelSwitchCommand(ctx) {
185
+ const commands = ctx?.get?.("commands");
186
+ if (!commands || typeof commands.register !== "function") {
187
+ log(ctx, `模型指令未注册:ctx.commands 不可用(宿主未挂载 commands 服务)`);
188
+ return false;
189
+ }
190
+ let CommandDefinitionId = null;
191
+ try {
192
+ const mod = await import("@deepseek-ai/dsh-commands/brand");
193
+ if (typeof mod?.CommandDefinitionId === "function") CommandDefinitionId = mod.CommandDefinitionId;
194
+ } catch { /* 工作区自测环境没有宿主依赖 → 走下面的返回 false */ }
195
+ if (!CommandDefinitionId) {
196
+ log(ctx, `模型指令未注册:拿不到 @deepseek-ai/dsh-commands/brand 的 CommandDefinitionId(缺宿主依赖)`);
197
+ return false;
198
+ }
199
+ commands.register({
200
+ definitionId: CommandDefinitionId(DEFINITION_ID),
201
+ name: COMMAND_NAME,
202
+ description: "查看或切换成员(子代理)的模型,并回显查证结果;无参 = 列出成员与各自模型",
203
+ input: { hint: "[<成员名|sessionId> [provider]/[model]]" },
204
+ recordInput: false,
205
+ handler: (invocation) => handleModelSwitchCommand(ctx, invocation),
206
+ });
207
+ return true;
208
+ }
@@ -17,3 +17,5 @@ export {
17
17
  peekLastRequestModel, readLastRequestModel, resetSwitchWatchForTest,
18
18
  } from "./switch.js";
19
19
  export { resumeSetupFor } from "./setup.js";
20
+ // 聊天框指令 /model-switch(用户视角入口,与 AI 工具共用同一条切换 + 查证路径)
21
+ export { registerModelSwitchCommand, handleModelSwitchCommand } from "./command.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "DSH session management enhancement: archive/restore sessions, batch & conditional delete, AI auto-rename, interrupted-session auto-resume, release idle sessions, workspace grouping, undo last message, repair damaged session logs, value analysis, full-text search, per-member model switching (list_models / set_member_model), and plan/closing template injection (system-prompt + context).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -127,6 +127,8 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
127
127
  ## 九、成员模型切换(AI 工具 list_models / set_member_model)
128
128
 
129
129
  - 用途:给 Team 里的队友/子代理**单独切模型**(只动该成员会话,不影响其他成员)。
130
+ - **用户聊天框指令 `/model-switch`**(用户视角入口):宿主把斜杠指令收在 `ctx.commands` 服务里,
131
+ 注册形态照官方范例 `@deepseek-ai/dsh-command-goal` 抄 —— `ctx.commands.register({definitionId, name, description, input:{hint}, recordInput, handler})`;`definitionId` 必须用 `@deepseek-ai/dsh-commands/brand` 的 `CommandDefinitionId("…")` 包一层。handler 收 `invocation`:文本从 `invocation.rawInput`(命令后原文)取、发起会话从 `invocation.agent` 取,返回 `{kind:"success"|"error", text}` 由聊天框渲染。三条形态:**无参**列成员与各自模型(便宜来源:本插件 override + 名册记录值)、**一参**查该成员**实际**在用哪个模型(读 `request/header`)、**两参**切换并回显查证快照。实现 `lib/features/member-model/command.js`(经域出口导出、由 `apply.js` 注册)。⚠️ 两个宿主能力(`ctx.commands`、`.../brand`)一律**动态导入 + 守卫**:拿不到就跳过注册并如实留痕 —— 静态引用宿主包会在导入期炸掉整个插件(本项目在 defineTool / createUserMessage 上踩过同类坑)。
130
132
  - `list_models`:列出可用模型(provider → models[])。**切模型前先用它拿确切 model id,不要凭记忆猜模型名。**
131
133
  - `set_member_model({target, provider, model})`:
132
134
  - `target` 传**成员名**、**sessionId** 或标题关键字。定位顺序:① `ctx.agentTeams.listMembers(caller)` 名册(覆盖 `spawn_teammate` 创建的队友——它们**不在** `ctx.agents.list()` 里);② `ctx.agents.list()`(普通活跃 agent,sessionId 精确 / 标题子串)。名册里成员名与 sessionId 都支持精确匹配,成员名还支持唯一子串。
@@ -145,6 +145,24 @@ pnpm add github:EIGHTfs/dsh-session-conductor#<commit>
145
145
  (有 prepare 的插件在 git 安装时会自动执行它 —— 例:dsh-git-push 的 prepare 构建不入库的
146
146
  `lib/client.js`,装完应确认产物在;本仓库无 prepare,装完即完整。)
147
147
 
148
+ ## 九、聊天框指令 `/model-switch`(用户视角)
149
+
150
+ 在聊天框里直接看/切成员(子代理)的模型,不必求 AI。**指令在插件加载时注册** —— 装好或更新后需重载一次实例,输入框里敲 `/model` 才有补全。
151
+
152
+ | 输入 | 作用 | 输出要点 |
153
+ |---|---|---|
154
+ | `/model-switch` | 列成员与各自模型 | 每行给「最近请求=provider/model」(进程内真实请求记录,瞬时);没有则退回「目标=…」或「未知」;两者不一致时附 `⚠️ 与目标不一致(下一回合生效前属正常)` |
155
+ | `/model-switch <成员名或 sessionId>` | 查该成员**实际**在用哪个模型 | 优先读日志里的 `request/header`(请求级权威记录)+ 最近切换意图;日志尚无记录时回退显示「本次进程内观察到的最近请求」 |
156
+ | `/model-switch <成员名或 sessionId> <provider>/<模型>` | 切换并回显查证 | 与 AI 工具同一套:生效方式 + 最近一次实际请求 + 判定(`✅ 已是新模型` / `⏳ 待下一回合生效`) |
157
+
158
+ **三个别踩的坑**
159
+
160
+ - **看到 ⏳ 不是失败**:切换本就下一回合生效(当前那次请求早已发出)。该成员下次请求时插件按 `agent/request` 事件判定,并把 `【切换查证】✅ 已确认生效:…` 或 `❌ 未生效:期望 X,实际 Y`(**不假装成功**)注入回发起方。
161
+ - **别拿「意图」当结论**:判定只认请求时**真实使用**的 `data.header.config.{provider,model}`;`model/selection` 事件与会话里那句 `[model changed: …]` 提示都可能与实际不符 —— 实测出现过「提示是 agni、请求头仍是 wb」。
162
+ - **模型必须写 `provider/model`**:写错会直接给出正确格式;成员用名字(支持唯一子串)或 `sessionId` 定位,重名会要求写全。
163
+
164
+ 实现见 `lib/features/member-model/command.js`;注册 API(`ctx.commands.register`)与守卫约定见「功能说明书」第九节。
165
+
148
166
  ## 相关
149
167
 
150
168
  - dsh-session-conductor-functions(功能说明书,每个功能参数/返回/注意事项)