dsh-session-conductor 1.3.19 → 1.4.5

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
@@ -86,7 +86,9 @@ send_to_session({ sessionId, text, mode, dryRun }) // sessionId/text 必填;mo
86
86
  recall_memory({ rounds, session }) // 都可空:rounds 默认取配置、上限 50;session 空=自己
87
87
  context_usage({}) // 无参
88
88
  read_member_trace({ session, rounds, detail, include, maxCharsPerItem })
89
- // session 空=自己;rounds 默认 1(上限 20);detail=summary|full
89
+ // session 空=自己(可填成员名/id/前缀/标题);rounds 默认 1(上限 20)
90
+ // detail=summary(每条一行)|full(完整文本);include 可裁 reasoning,text,tool
91
+ // maxCharsPerItem 单条截断(默认 800)
90
92
  ```
91
93
 
92
94
  典型调用:
@@ -103,6 +105,15 @@ clone_member_as({ source: "probe1", name: "rename1" })
103
105
 
104
106
  // 给另一个会话发消息(dryRun 先预览目标状态与限流余量)
105
107
  send_to_session({ sessionId: "session-xxxx", text: "继续刚才的任务", dryRun: true })
108
+
109
+ // 看子代理最近一轮的执行过程(子代理只有最后一条回复会被自动发回,中间的思考靠这里读)
110
+ read_member_trace({ session: "writer" })
111
+
112
+ // 看最近 3 轮,只要思考与工具调用
113
+ read_member_trace({ session: "writer", rounds: 3, include: "reasoning,tool" })
114
+
115
+ // 要完整文本(默认 summary 每条压成一行)
116
+ read_member_trace({ session: "writer", detail: "full" })
106
117
  ```
107
118
 
108
119
  ## API
package/lib/apply.js CHANGED
@@ -317,24 +317,38 @@ export async function apply(ctx, config = {}) {
317
317
  // 不依赖官方「投影 pending + 请求头链」(那条链在新回合会回落部署默认模型),
318
318
  // override 由 set_member_model 写入(domain 持久 + 内存缓存镜像),每轮都改 → 跨轮持久。
319
319
  ctx.on("agent/request", async ({ agent }, next) => {
320
- const resolved = await next();
321
320
  const sessionId = agent?.session?.id;
321
+ // ① 进入即读 override 与官方 pending —— 这是 UI 切换「立即生效」的关键。
322
+ // 旧实现把同步放在 await next() 之后:请求组装阶段官方链路可能解析出旧模型
323
+ // (system-prompt/assemble 被缓存时 selection.assembled 停留旧值),返回阶段我们
324
+ // 再拿旧 override 一盖,本次请求就定型了;残留的 pending 要等下一轮才被读到,
325
+ // 表现为「UI 切了模型,AI 用旧模型回完一轮才切过去」。pending 只会被请求后的
326
+ // consumeSelection 清掉,waterfall 进入时它必然还在 —— 所以进入时同步最可靠。
322
327
  let override = await getMemberModelOverride(ctx, sessionId);
323
- // 官方界面切换同步:override 优先级高于官方链路,若用户在主会话界面用官方选择器改了模型,
324
- // 不把新值同步进 override 就会出现「界面显示新模型、请求一直走旧模型」且无处可查
325
- // (实测事故:批量写入的 override 把会话钉在已失效 provider 上,界面切换、改日志、重启全无效)。
326
- // 做法是同步而不是清除:官方切换会写投影 pending,这里把它写回 override,
327
- // 既让界面选择立刻生效,又保留 override 的跨轮持久能力(不会回落部署默认)。
328
328
  try {
329
329
  const pending = readOfficialPendingSelection(ctx, agent);
330
330
  const adopt = adoptOfficialSelection(override, pending);
331
331
  if (adopt) {
332
332
  await setMemberModelOverride(ctx, sessionId, adopt.provider, adopt.model);
333
333
  override = adopt;
334
- log(ctx, `官方界面切换已同步到 override:${sessionId} → ${adopt.provider}/${adopt.model}`);
334
+ log(ctx, `官方界面切换已同步到 override(请求前):${sessionId} → ${adopt.provider}/${adopt.model}`);
335
+ }
336
+ } catch (error) {
337
+ // 同步失败绝不打断请求:退回用旧 override
338
+ log(ctx, `官方切换同步失败(不影响本次请求): ${String(error?.message ?? error)}`);
339
+ }
340
+ const resolved = await next();
341
+ // ② 返回阶段兜底:极端情况下进入时投影尚未 fold 到最新(刚 append 事件还没被折叠),
342
+ // 返回阶段再给一次机会;正常路径这里 pending 与 override 已一致,不会再写。
343
+ try {
344
+ const pending = readOfficialPendingSelection(ctx, agent);
345
+ const adopt = adoptOfficialSelection(override, pending);
346
+ if (adopt) {
347
+ await setMemberModelOverride(ctx, sessionId, adopt.provider, adopt.model);
348
+ override = adopt;
349
+ log(ctx, `官方界面切换已同步到 override(返回兜底):${sessionId} → ${adopt.provider}/${adopt.model}`);
335
350
  }
336
351
  } catch (error) {
337
- // 同步失败绝不打断请求:退回用旧 override(行为与修复前一致)
338
352
  log(ctx, `官方切换同步失败(不影响本次请求): ${String(error?.message ?? error)}`);
339
353
  }
340
354
  const applied = applyModelOverride(resolved, override);
package/lib/client.js CHANGED
@@ -1,3 +1,15 @@
1
+ function __dshScLoad(registration) {
2
+ const host = window.__ModuleLoader__;
3
+ if (registration.id === "dsh-session-conductor") { host.load(registration); return; }
4
+ const cycle = (window.__dshScCycle = (window.__dshScCycle ?? 0) + (window.__dshScCycleOpen ? 0 : 1));
5
+ window.__dshScCycleOpen = true;
6
+ const seen = (window.__dshScSeen ??= new Set());
7
+ const id = `${registration.id}@${cycle}`;
8
+ if (seen.has(id)) return;
9
+ seen.add(id);
10
+ host.load({ ...registration, id });
11
+ }
12
+ window.__dshScCycleOpen = false;
1
13
  // dsh-session-conductor —— 会话管理增强插件(浏览器半边)。
2
14
  // dsh-skip-quality(conv-log:fallback 行为 UI 文案内嵌 JSON,非文档注释,属规则误报)
3
15
  //
@@ -72,7 +84,7 @@ window.__SC_TR__ = (key, vars) => {
72
84
  return String(val);
73
85
  };
74
86
 
75
- window.__ModuleLoader__.load({
87
+ __dshScLoad({
76
88
  id: "dsh-session-conductor",
77
89
  factory: (require) => {
78
90
  var module = { exports: {} };
@@ -2407,7 +2419,7 @@ window.__ModuleLoader__.load({
2407
2419
  return module.exports;
2408
2420
  },
2409
2421
  });
2410
- window.__ModuleLoader__.load({
2422
+ __dshScLoad({
2411
2423
  id: "dsh-session-conductor-group",
2412
2424
  factory: (require) => {
2413
2425
  var module = { exports: {} };
@@ -2522,7 +2534,7 @@ window.__ModuleLoader__.load({
2522
2534
  * 排队消息列表仍由原生 dock 负责(不重复渲染)。不改 DSH 原生源码。
2523
2535
  */
2524
2536
 
2525
- window.__ModuleLoader__.load({
2537
+ __dshScLoad({
2526
2538
  id: "dsh-session-conductor-processing",
2527
2539
  factory: (require) => {
2528
2540
  var module = { exports: {} };
@@ -2627,7 +2639,7 @@ window.__ModuleLoader__.load({
2627
2639
  * 设置 → 插件配置 → 「压缩模型」:会话模型旁单独选压缩用模型(省 pro 成本)。
2628
2640
  * 可选:跟随会话模型(默认)/ 指定便宜模型(agnes 等)。写入 /api/session-conductor/compaction-model。
2629
2641
  */
2630
- window.__ModuleLoader__.load({
2642
+ __dshScLoad({
2631
2643
  id: "dsh-session-conductor-compaction-model",
2632
2644
  factory: (require) => {
2633
2645
  var module = { exports: {} };
@@ -2751,7 +2763,7 @@ window.__ModuleLoader__.load({
2751
2763
  * UI 组件 scs_* = 页面级。
2752
2764
  */
2753
2765
 
2754
- window.__ModuleLoader__.load({
2766
+ __dshScLoad({
2755
2767
  id: "dsh-session-conductor-settings",
2756
2768
  factory: (require) => {
2757
2769
  var module = { exports: {} };
@@ -0,0 +1,93 @@
1
+ // dsh-session-conductor — 存储域不可用时的文件后备(降级实现)
2
+ //
3
+ // 【为什么单独成文件】`core/domain.js` 顶层静态依赖宿主包 `@deepseek-ai/dsh-storage-domain`,
4
+ // 工作区单测没有宿主依赖,静态 import 会让整个模块在导入时就 ERR_MODULE_NOT_FOUND。
5
+ // 把降级逻辑放在这里(只依赖 node 内置 + 已抽出的 json-store),就能被单测直接覆盖。
6
+ //
7
+ // 【降级触发条件】storageDomain 域出现下面任一情况时,改读写域自己的落盘 JSON:
8
+ // · 服务拿不到(ctx.get("storageDomain") 为空)
9
+ // · 名字仍被占(already open)而查到的旧句柄已 closed 或停在 disposing
10
+ // 这两种情况下插件所有依赖持久化的功能本会一路 500,只能重启恢复。
11
+
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { readJsonSafeSync, writeJsonAtomicSync } from "../shared/json-store.js";
15
+
16
+ /** DSH 主目录(与 core/config.js 的 resolveDshHome 同规则;此处内联避免底层模块反向依赖上层配置)。 */
17
+ function dshHomePath() {
18
+ if (process.env.DSH_HOME) return process.env.DSH_HOME;
19
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
20
+ return home ? join(home, ".dsh") : "";
21
+ }
22
+
23
+ /**
24
+ * 判定域句柄是否还能用。
25
+ *
26
+ * 【为什么还要看 disposing】宿主的 DomainImpl 在 close 一开始就置 `disposing = true`,
27
+ * 要等写链排空、存储单元关闭后才置 `closed = true`。如果关闭过程卡住(写链未排空),
28
+ * 句柄会长期停在 `disposing`:`closed` 仍是 false,但任何读写都会抛「domain is closed」。
29
+ * 只看 `closed` 会把它当成活域继续用,于是接口一路 500。
30
+ */
31
+ export function isUsableDomain(domain) {
32
+ return Boolean(domain) && domain.closed !== true && domain.disposing !== true;
33
+ }
34
+
35
+ /**
36
+ * 文件后备域:storageDomain 的域不可用(卡在关闭中 / 名字被占导致无法重新打开)时的降级实现。
37
+ *
38
+ * 【为什么要有它】域卡住时插件所有依赖持久化的功能(列表缓存、模板、各开关、成员模型 override)
39
+ * 会全部 500,只能靠重启宿主恢复。而这些状态本来就落盘在
40
+ * `<DSH_HOME>/storages/dsh_session_conductor.json`,直接读写同一份文件即可继续工作。
41
+ *
42
+ * 【只实现 global.get / global.set】全插件对域的使用面只有这两者(外加 close),没有用到 tables,
43
+ * 因此后备实现只管 global 一节,其余字段(unit / tables)原样保留,保证宿主下次启动能正常加载。
44
+ *
45
+ * 【串行写】域本身有写链保证串行;文件后备没有,这里用一条 Promise 链模拟,
46
+ * 避免并发 `global.set` 互相覆盖(读-改-写之间的丢更新)。
47
+ */
48
+ export function fileFallbackDomain() {
49
+ const home = dshHomePath();
50
+ const file = home ? join(home, "storages", "dsh_session_conductor.json") : "";
51
+ const readAll = () => {
52
+ if (!file) return {};
53
+ try { return readJsonSafeSync(file) ?? {}; } catch { return {}; }
54
+ };
55
+ let chain = Promise.resolve();
56
+ return {
57
+ // 供 isUsableDomain 判断:后备域永远可用
58
+ closed: false,
59
+ disposing: false,
60
+ __fallback: true,
61
+ global: {
62
+ get() {
63
+ return readAll().global ?? {};
64
+ },
65
+ set(value) {
66
+ chain = chain.then(async () => {
67
+ if (!file) throw new Error("DSH_HOME 未知,无法写入后备存储");
68
+ const current = readAll();
69
+ // 保留 unit / tables 原值:它们由宿主维护,后备路径不改动
70
+ const next = { ...current, global: value, tables: current.tables ?? {} };
71
+ if (current.unit !== undefined) next.unit = current.unit;
72
+ await writeJsonAtomicSync(file, next);
73
+ });
74
+ return chain;
75
+ },
76
+ },
77
+ close() { /* 后备域无需关闭 */ },
78
+ };
79
+ }
80
+
81
+ /** 降级只提示一次,避免每个请求都刷一行日志。 */
82
+ let fallbackLogged = false;
83
+ export function logOnceFallback(error, reused) {
84
+ if (fallbackLogged) return;
85
+ fallbackLogged = true;
86
+ try {
87
+ console.warn(
88
+ "[session-conductor] 存储域不可用,已降级为直接读写 storages/dsh_session_conductor.json:"
89
+ + `open 失败=${String(error?.message ?? error)}`
90
+ + `${reused ? ";复用的句柄已关闭/关闭中" : ""}。重启实例可恢复正常域。`,
91
+ );
92
+ } catch { /* 日志失败不影响主流程 */ }
93
+ }
@@ -6,6 +6,9 @@
6
6
 
7
7
  import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
8
8
  import { z } from "zod";
9
+ // 域降级为文件后备时用(与域落盘同一份 JSON,保持 unit/global/tables 结构)
10
+ // 域不可用时的文件后备(抽到独立文件,便于工作区单测直接覆盖)
11
+ import { isUsableDomain, fileFallbackDomain, logOnceFallback } from "./domain-fallback.js";
9
12
 
10
13
  /** 插件自身的持久化域:自动重命名/自动续跑开关 + 记账,存于 <DSH_HOME>/storages/dsh-session-conductor.json。
11
14
  * 版本保持 1:autoContinue 是纯增量字段(带 default),旧域文件可无损加载。 */
@@ -110,9 +113,33 @@ let domainLive = false;
110
113
  * @returns {Promise<object>} 域对象;storageDomain 服务缺失时 reject
111
114
  */
112
115
  export function pluginDomain(ctx) {
113
- if (domainPromise === null) {
116
+ if (domainPromise !== null) {
117
+ return domainPromise.then((cached) => {
118
+ if (isUsableDomain(cached)) return cached;
119
+ // 缓存里的句柄已不可用(关闭中 / 已关闭):丢掉重建
120
+ domainPromise = null;
121
+ domainLive = false;
122
+ if (cached && cached.__fallback) {
123
+ // 后备域本身可用,上面的 isUsableDomain 已放行;走到这里说明它被误标,直接重取
124
+ domainPromise = Promise.resolve(fileFallbackDomain());
125
+ return domainPromise;
126
+ }
127
+ return pluginDomain(ctx);
128
+ }, () => {
129
+ // 上次 open 失败:清掉缓存让它重新尝试(rejected Promise 不能被永久缓存)
130
+ domainPromise = null;
131
+ domainLive = false;
132
+ return pluginDomain(ctx);
133
+ });
134
+ }
135
+ {
114
136
  const storage = ctx.get("storageDomain");
115
- if (!storage) return Promise.reject(new Error("storageDomain 不可用"));
137
+ if (!storage) {
138
+ // 服务都拿不到:直接文件后备(报错只会让所有功能一起不可用)
139
+ domainLive = false;
140
+ domainPromise = Promise.resolve(fileFallbackDomain());
141
+ return domainPromise;
142
+ }
116
143
  domainPromise = storage.open(pluginDomainSpec).then((domain) => {
117
144
  domainLive = true;
118
145
  attachDomainClose(ctx, domain);
@@ -120,22 +147,25 @@ export function pluginDomain(ctx) {
120
147
  }).catch((error) => {
121
148
  // 模块热重载场景:本实例的缓存是空的,但上一份实例打开的域仍被 storageDomain
122
149
  // 按名字占用(facility 用 reserved 集合挡重复 open),于是再 open 必报 already-open。
123
- // 宿主提供了按名字查已开域的口子,这里直接复用那份 handle——
124
- // 否则重载后列表/模板/开关会一路 500,直到整个实例重启为止。
150
+ // 宿主提供了按名字查已开域的口子,这里直接复用那份 handle。
125
151
  const reused = isAlreadyOpenError(error) && typeof storage.get === "function"
126
152
  ? storage.get(pluginDomainSpec.name)
127
153
  : undefined;
128
- if (reused) {
154
+ if (isUsableDomain(reused)) {
129
155
  domainLive = true;
130
156
  attachDomainClose(ctx, reused);
131
157
  return reused;
132
158
  }
159
+ // 复用的句柄也不可用(典型:上一次 close 卡在 disposing)—— 此时既不能再 open
160
+ // (名字仍被占),也没有活句柄可用,只能降级到文件后备,让功能继续可用。
161
+ // 【注意】绝不能在这里递归调用 pluginDomain:它还会走 already-open 分支,形成死循环。
133
162
  domainPromise = null;
134
163
  domainLive = false;
135
- throw error;
164
+ logOnceFallback(error, reused);
165
+ return fileFallbackDomain();
136
166
  });
167
+ return domainPromise;
137
168
  }
138
- return domainPromise;
139
169
  }
140
170
 
141
171
  /** 判定「这个名字已经被占用」的重复打开错误(宿主 DomainError 带 code,保底看文案)。 */
@@ -24,12 +24,12 @@ export function memberDisplay(member, sessionId) {
24
24
  * @param {{provider: string, model: string}} route 目标路由
25
25
  * @returns {Promise<{rows: Array<{sessionId: string, label: string, ok: boolean, detail: string}>, okCount: number, failCount: number}>}
26
26
  */
27
- export async function applyModelToMany(ctx, targets, { provider, model }) {
27
+ export async function applyModelToMany(ctx, targets, { provider, model, override }) {
28
28
  const rows = [];
29
29
  for (const t of targets) {
30
30
  const label = memberDisplay(t.member, t.sessionId);
31
31
  try {
32
- const result = await switchAgentModel(ctx, t.sessionId, provider, model, t.agent ?? null);
32
+ const result = await switchAgentModel(ctx, t.sessionId, provider, model, t.agent ?? null, { override });
33
33
  if (result?.error) {
34
34
  rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(result.error) });
35
35
  } else {
@@ -120,8 +120,8 @@ async function renderMemberDetail(found) {
120
120
  * 二参:切换 + 回显查证快照(与 AI 工具 `set_member_model` 同一套落点:
121
121
  * 写 override / 事件、登记查证任务、按请求级记录给判定)。
122
122
  */
123
- async function switchMember(ctx, found, provider, model, invocation) {
124
- const result = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
123
+ async function switchMember(ctx, found, provider, model, invocation, override = false) {
124
+ const result = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent, { override });
125
125
  if (result.error) return { kind: "error", text: `切换失败:${result.error}` };
126
126
  // 登记查证任务:该成员下次发起请求时由 agent/request 事件判定,结果会注入发起方
127
127
  try {
@@ -179,7 +179,9 @@ export async function handleModelSwitchCommand(ctx, invocation) {
179
179
  return { kind: "success", text: await renderMemberDetail(found) };
180
180
  }
181
181
 
182
- // 二参:切换(单个或全部)
182
+ // 二/三参:切换(单个或全部)。可选尾参 `override` = 旧行为(额外写每轮强制改写的
183
+ // override);默认不写 —— 官方 model/selection 链路当轮生效且持久,不压 UI 的选择。
184
+ const wantOverride = parts.some((x) => x.toLowerCase() === "override");
183
185
  const routeRaw = parts[1];
184
186
  let provider;
185
187
  let model;
@@ -203,11 +205,11 @@ export async function handleModelSwitchCommand(ctx, invocation) {
203
205
 
204
206
  // 单个目标:沿用原有路径(带查证快照与查证任务登记)
205
207
  if (!resolved.all && resolved.targets.length === 1) {
206
- return await switchMember(ctx, resolved.targets[0], provider, model, invocation);
208
+ return await switchMember(ctx, resolved.targets[0], provider, model, invocation, wantOverride);
207
209
  }
208
210
 
209
211
  // 全部目标:串行逐个切,回执必须带完整名单
210
- const report = await applyModelToMany(ctx, resolved.targets, { provider, model });
212
+ const report = await applyModelToMany(ctx, resolved.targets, { provider, model, override: wantOverride });
211
213
  const extra = [];
212
214
  if (followedLead) {
213
215
  extra.push(`模型来源:跟随主代理 ${followedLead.sessionId}(${followedLead.modelSource})`);
@@ -144,7 +144,9 @@ export async function createTeammateWithModel(ctx, exec, args) {
144
144
  log(ctx, `创建成员 ${name}:中断后等待空闲 → ${JSON.stringify(idleState)}`);
145
145
 
146
146
  // ③ 写 model/selection:与 set_member_model 完全同一条链路(默认不碰任何 @Remote)
147
- const switchResult = await switchAgentModel(ctx, sessionId, modelProvider, model, null);
147
+ // 建成员即指定模型:成员未挂载、冷恢复走 descriptor,selection 链路管不到首轮 ——
148
+ // 这里保持旧行为(写 override),与「创建时定模型」的工具语义一致。
149
+ const switchResult = await switchAgentModel(ctx, sessionId, modelProvider, model, null, { override: true });
148
150
  log(ctx, `创建成员 ${name}(${sessionId})| 子代理provider=${subagentProvider} | 中断=${JSON.stringify(interrupt)} | 设模型 ${modelProvider}/${model}=${switchResult.error ? `失败 ${switchResult.error}` : "已写入"}`);
149
151
 
150
152
  if (switchResult.error) {
@@ -63,6 +63,41 @@ export async function setMemberModelOverride(ctx, sessionId, provider, model) {
63
63
  memberModelCache.set(sessionId, { provider, model });
64
64
  }
65
65
 
66
+ /**
67
+ * 删除一个会话的 override(domain 与内存镜像同时清)。
68
+ *
69
+ * 【为什么需要】override 一旦存在,`agent/request` 就会**每轮**按它改写请求模型,
70
+ * 优先级压过官方 UI / `model/selection` 链路。因此「不带 override 参数」的切换
71
+ * 不能只是不写 —— 必须把存量条目删掉,官方链路才能接管;否则表现为
72
+ * 「切换成功但请求仍走旧模型」(selection 写入后,请求仍按 override 的值发出)。
73
+ *
74
+ * 幂等:不存在时返回 {removed:false},不报错。
75
+ *
76
+ * @param {object} ctx 插件 context
77
+ * @param {string} sessionId 会话 id
78
+ * @returns {Promise<{removed: boolean}>}
79
+ */
80
+ export async function deleteMemberModelOverride(ctx, sessionId) {
81
+ if (!sessionId) return { removed: false };
82
+ const hadCache = memberModelCache.delete(sessionId);
83
+ let removedInDomain = false;
84
+ try {
85
+ const domain = await loadPluginDomain(ctx);
86
+ const state = domain.global.get() ?? {};
87
+ const overrides = { ...(state.memberModelOverrides ?? {}) };
88
+ if (Object.prototype.hasOwnProperty.call(overrides, sessionId)) {
89
+ delete overrides[sessionId];
90
+ await domain.global.set({ ...state, memberModelOverrides: overrides });
91
+ removedInDomain = true;
92
+ }
93
+ } catch (error) {
94
+ // domain 不可用(如工作区自测环境缺宿主包):内存镜像已清,但落盘那条还在。
95
+ // 必须如实把原因带回调用方 —— 否则「切换成功、请求仍走旧模型」无从排查。
96
+ return { removed: hadCache, error: String(error?.message ?? error) };
97
+ }
98
+ return { removed: hadCache || removedInDomain };
99
+ }
100
+
66
101
  /**
67
102
  * 把成员模型 override 应用到一次请求配置上(`agent/request` hook 的核心逻辑,单独导出便于单测)。
68
103
  * 丢掉继承的 reasoningEffort:换模型后应回到目标模型自己的 provider 默认行为。