dsh-session-conductor 1.3.19 → 1.4.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
@@ -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) {
@@ -334,10 +334,14 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
334
334
  * ① 持久化切换意图:不依赖 live agent —— 成员 inactive/未挂载时也能切,下次运行生效。
335
335
  * @returns {Promise<{note: string}>} note 直接拼进回执
336
336
  */
337
- async function persistSwitchIntent(ctx, sessionId, provider, model) {
337
+ async function persistSwitchIntent(ctx, sessionId, provider, model, options = {}) {
338
+ // override 是**显式参数**(/model-switch … override / set_member_model 的 override:true):
339
+ // 默认不写 —— 官方 model/selection 链路当轮立即生效且跨轮持久(干净会话实测),
340
+ // 每轮强制改写的 override 会压过 UI 的选择,表现为「界面切了、请求不动」。
341
+ if (options?.override !== true) return { note: ",未写 override(走官方 model/selection 链路)" };
338
342
  try {
339
343
  await setMemberModelOverride(ctx, sessionId, provider, model);
340
- return { note: ",已记录切换意图" };
344
+ return { note: ",已写 override(每轮强制改写,旧行为;UI 切换依赖同步机制)" };
341
345
  } catch (error) {
342
346
  return { note: `,意图持久化失败:${String(error?.message ?? error)}` };
343
347
  }
@@ -382,10 +386,40 @@ async function tryOfficialSelectModel(ctx, sessionId, provider, model, overrideN
382
386
  }
383
387
  }
384
388
 
385
- export async function switchAgentModel(ctx, sessionId, provider, model, agent = null) {
389
+ /**
390
+ * 把切换结果同步到 live agent 的 `options`(宿主官方「查看子智能体」视图读的就是它)。
391
+ *
392
+ * 【为什么需要】宿主 experimental/agent-team 的 `memberView()` 里,成员模型取的是
393
+ * `live.options.model` —— 即 agent **创建时**的 AgentOptions,与我们写的 `model/selection`
394
+ * 事件无关。不同步的话会出现「界面一直显示创建时的老模型,而实际请求早已换了模型」,
395
+ * 两边长期对不上,排查时极易误判。
396
+ *
397
+ * 【为什么能改】宿主 ReactLoopAgent 构造里是 `this.options = options` 直接引用、**没有 freeze**
398
+ * (该文件只冻结 boundaryMessages 与请求标记)。这里仍做 `isFrozen` 兜底:
399
+ * 万一将来宿主冻结了,跳过同步即可,绝不影响切换本身。
400
+ *
401
+ * @returns {boolean} 是否真的写进去了
402
+ */
403
+ export function syncLiveAgentOptions(ctx, live, provider, model) {
404
+ if (!live || !live.options) return false;
405
+ try {
406
+ if (Object.isFrozen(live.options)) {
407
+ log(ctx, "成员模型切换:live agent 的 options 已被冻结,跳过视图同步(不影响实际请求)");
408
+ return false;
409
+ }
410
+ live.options.provider = provider;
411
+ live.options.model = model;
412
+ return true;
413
+ } catch (error) {
414
+ log(ctx, `成员模型切换:同步 live options 失败(不影响实际请求): ${String(error?.message ?? error)}`);
415
+ return false;
416
+ }
417
+ }
418
+
419
+ export async function switchAgentModel(ctx, sessionId, provider, model, agent = null, options = {}) {
386
420
  const timer = createStageTimer();
387
421
  try {
388
- return await applyModelSwitch(ctx, sessionId, provider, model, agent, timer);
422
+ return await applyModelSwitch(ctx, sessionId, provider, model, agent, timer, options);
389
423
  } finally {
390
424
  // 无论成功失败都留一行分阶段耗时:排查「切模型卡很久」时直接看慢在哪一段
391
425
  log(ctx, `模型切换耗时:${timer.summary()}(标记合计 ${timer.total()}ms)`);
@@ -393,20 +427,27 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
393
427
  }
394
428
 
395
429
  /** 切换主流程(逐段打点,计时器只观测、不改变行为)。 */
396
- async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer) {
430
+ async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer, options = {}) {
397
431
  if (!sessionId) return { error: "缺少 sessionId(无法定位目标会话)" };
398
- // ① 持久化切换意图(不依赖 live agent——成员 inactive/未挂载时也能切,下次运行生效)
399
- const { note: overrideNote } = await persistSwitchIntent(ctx, sessionId, provider, model);
432
+ // ① 持久化切换意图(不依赖 live agent——成员 inactive/未挂载时也能切,下次运行生效)。
433
+ // 只有显式传 override:true 才写 override(旧行为);默认走官方 model/selection 链路。
434
+ const { note: overrideNote } = await persistSwitchIntent(ctx, sessionId, provider, model, options);
400
435
  timer.mark("写意图");
401
436
  const live = agent ?? ctx.get("agents")?.get?.(sessionId) ?? null;
402
437
  timer.mark("定位成员");
403
- // ② 成员当前未挂载(inactive):直接把事件写进会话日志——官方 selectionFor 读投影即生效。
404
- // (Team 成员唤醒走 sendMessage → followup,不经过 resumeSetupFor,只记意图不会被应用。)
405
- if (typeof live?.ctx?.on !== "function" || !live?.session) {
438
+ // ② 成员真的没有被实例化(agents 里取不到、或没有 session):只能把事件写进会话日志,
439
+ // 官方 selectionFor 读投影即生效。
440
+ //
441
+ // 【判定为什么只看 session】旧条件还要求 `typeof live.ctx.on === "function"`,但 agent 空闲后
442
+ // `ctx.scope` 已 unwind,`live.ctx.on` 不再可用 —— 于是「空闲但仍在内存」的成员被误判成未挂载,
443
+ // 走这条分支就永远拿不到 live.options 去同步(官方 list_agents / 查看子智能体显示的模型会一直
444
+ // 停在创建时的值)。实测:官方 list_agents 对空闲成员仍能返回 model 字段,说明 agents.get() 有对象,
445
+ // 那就是能同步的。写事件与同步 options 都只需要 session,不需要活跃的 ctx。
446
+ if (!live?.session) {
406
447
  const written = await appendSelectionEventToLog(ctx, sessionId, provider, model);
407
448
  timer.mark("写会话日志");
408
449
  if (written.error) {
409
- return { error: `成员未挂载,且写入会话日志失败:${written.error}(切换意图已记录,可稍后重试)` };
450
+ return { error: `成员未挂载,且写入会话日志失败:${written.error}${overrideNote},可稍后重试` };
410
451
  }
411
452
  return { via: `已写入会话日志的 model/selection 事件(成员未挂载)${written.note ? "," + written.note : ""}${overrideNote},该成员下次运行时生效` };
412
453
  }
@@ -430,7 +471,12 @@ async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer) {
430
471
  } catch (error) {
431
472
  eventNote = `,事件写入失败:${String(error?.message ?? error)}`;
432
473
  }
433
- let via = `model/selection 事件(官方投影接管)${eventNote}${overrideNote},下一回合生效`;
474
+ // ④-补:把结果同步进 live agent 的 options —— 官方「查看子智能体」视图显示的是它
475
+ // (memberView 读 live.options.model),不跟着改就会一直显示创建时的老模型。
476
+ const synced = syncLiveAgentOptions(ctx, live, provider, model);
477
+ timer.mark("同步视图");
478
+ let via = `model/selection 事件(官方投影接管)${eventNote}${overrideNote},下一回合生效`
479
+ + (synced ? ";已同步成员视图的模型显示" : "");
434
480
  // ⑤ 官方接口优先(若该部署暴露):
435
481
  // · 新版:ctx.apiProxy.sessions.selectModel(会话级模型选择,持久化到会话)
436
482
  // · 旧版:ctx.get("sessionController").selectModel(Typert @Remote,服务端直调可能不可用)
@@ -549,8 +595,10 @@ export async function readLastRequestModel(sessionId, root = null) {
549
595
  const { StringDecoder } = await import("node:string_decoder");
550
596
  const buf = await readFile(file); // 日志最大可达数十 MB,异步读避免阻塞事件循环
551
597
  const { scanZstdFrames } = await import("../../zstd-frames.js");
552
- // 只流式解码最后若干帧:请求头/选择意图判定只关心最近一次,够用且省内存
553
- const TAIL_FRAMES = 8;
598
+ // 只流式解码尾部若干帧:判定只关心最近一次,够用且省内存。
599
+ // 【为什么不是 8】一个回合常有上百个事件(step/tool/assistant 各占一帧),
600
+ // 请求头往往落在倒数几十帧之外;取 8 帧会把「跑过很多轮」的会话误报成「从未运行过」。
601
+ const TAIL_FRAMES = 400;
554
602
  let tailFrames;
555
603
  try {
556
604
  const { frames } = scanZstdFrames(buf);
@@ -565,10 +613,8 @@ export async function readLastRequestModel(sessionId, root = null) {
565
613
  let lastRequest = null;
566
614
  let lastSelection = null;
567
615
  const PENDING_LIMIT = 8 * 1024 * 1024; // 单行缓冲上限:超过即视为异常,避免无上限增长
568
- const consume = (line) => {
569
- if (!line) return;
570
- let event;
571
- try { event = JSON.parse(line); } catch { return; }
616
+ // 事件级处理(行级与全量兜底共用):只记「最后一次」请求头与选择意图
617
+ const consumeEvent = (event) => {
572
618
  const hit = modelEventOf(event);
573
619
  if (hit === null) return;
574
620
  if (hit.kind === "request") {
@@ -578,6 +624,12 @@ export async function readLastRequestModel(sessionId, root = null) {
578
624
  lastSelection = hit.value;
579
625
  }
580
626
  };
627
+ const consume = (line) => {
628
+ if (!line) return;
629
+ let event;
630
+ try { event = JSON.parse(line); } catch { return; }
631
+ consumeEvent(event);
632
+ };
581
633
  const onChunk = (chunk) => {
582
634
  pending += decoder.write(chunk);
583
635
  if (pending.length > PENDING_LIMIT) {
@@ -606,5 +658,11 @@ export async function readLastRequestModel(sessionId, root = null) {
606
658
  }
607
659
  pending += decoder.end();
608
660
  consume(pending);
661
+ // 尾部窗口内没有任何模型事件:退回全量流式扫一遍(复用公共底座,常量内存),
662
+ // 避免把「跑过很多轮但最近几帧都是工具/正文」的会话误报成「从未运行过」。
663
+ if (lastRequest === null && lastSelection === null) {
664
+ const { streamSessionEvents } = await import("../../sessions/log-stream.js");
665
+ await streamSessionEvents(buf, consumeEvent);
666
+ }
609
667
  return { lastRequest, lastSelection, scanned };
610
668
  }
@@ -98,7 +98,8 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
98
98
  parameters: {
99
99
  target: { type: "string", description: "目标成员:成员名、sessionId 或标题关键字。留空 = 全部成员(排除 Lead)" },
100
100
  provider: { type: "string", description: "provider 分组名(list_models 的顶层分组)。留空 = 跟随主代理的 provider" },
101
- model: { type: "string", description: "该 provider 下的模型 id(含前缀)。留空 = 跟随主代理的 model" }
101
+ model: { type: "string", description: "该 provider 下的模型 id(含前缀)。留空 = 跟随主代理的 model" },
102
+ override: { type: "boolean", description: "可选。true = 额外写持久 override(每轮强制改写请求,旧行为;用于官方 selection 链路不生效的场景)。默认 false = 只写 model/selection 事件,走官方链路(UI 切换不被压制)" }
102
103
  },
103
104
  output: {
104
105
  schema: { type: "string" },
@@ -124,10 +125,11 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
124
125
  const pair = await validateModelPair(ctx, provider, model);
125
126
  if (pair.error) return `切换失败:${pair.error}`;
126
127
  // target 留空 = 作用于全部成员;与聊天指令 /model-switch 走同一套目标解析
128
+ const wantOverride = args?.override === true;
127
129
  const resolved = resolveTargets(ctx, args?.target, { caller });
128
130
  if (resolved.error) return `切换失败:${resolved.error}`;
129
131
  if (resolved.all) {
130
- const report = await applyModelToMany(ctx, resolved.targets, { provider, model });
132
+ const report = await applyModelToMany(ctx, resolved.targets, { provider, model, override: wantOverride });
131
133
  const extra = [];
132
134
  if (followedLead) {
133
135
  extra.push(`模型来源:跟随主代理 ${followedLead.sessionId}(${followedLead.modelSource})`);
@@ -137,7 +139,7 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
137
139
  return renderBatchReport(`批量切换模型 → ${provider}/${model}`, report, extra);
138
140
  }
139
141
  const found = resolved.targets[0];
140
- const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
142
+ const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent, { override: wantOverride });
141
143
  if (switchResult.error) return `切换失败:${switchResult.error}`;
142
144
  const memberLabel = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
143
145
  // 查证(注册 job,不阻塞):登记待查证项 → 该成员下次发起请求时由 agent/request 事件判定
@@ -18,6 +18,8 @@
18
18
  //
19
19
  // 【失败即静默】日志缺失/损坏、用量读不到、首回合无上次用量 —— 一律返回 null,绝不阻断回合。
20
20
  import { readRecentUserTurns } from "../../sessions/events.js";
21
+ // 会话目标解析的公共底座(id / 前缀 / 标题 / 成员名;与成员轨迹共用)
22
+ import { resolveTargetSession } from "../../sessions/resolve.js";
21
23
  import { log } from "../../shared/log.js";
22
24
  import { cfg } from "../../core/config.js";
23
25
  import { writeJsonAtomicSync, readJsonSafeSync } from "../../shared/json-store.js";
@@ -329,40 +331,12 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
329
331
  * @returns {Promise<{id:string,title?:string|null}|{candidates:Array<{id:string,title?:string|null}>}|null>}
330
332
  */
331
333
  export async function resolveSessionTarget(ctx, want, selfId) {
332
- const { sessionsRootOf } = await import("../../sessions/events.js");
333
- // 异步遍历:会话目录可能上千个,同步 stat/readdir 会把事件循环钉住
334
- const { readdir, stat } = await import("node:fs/promises");
335
- const { join } = await import("node:path");
336
- const root = sessionsRootOf();
337
- const ids = [];
338
- const isDirectory = async (p) => {
339
- try { return (await stat(p)).isDirectory(); } catch { return false; }
340
- };
341
- if (root && await isDirectory(root)) {
342
- for (const proj of await readdir(root)) {
343
- const pp = join(root, proj);
344
- if (!(await isDirectory(pp))) continue;
345
- for (const dir of await readdir(pp)) {
346
- if (!(await isDirectory(join(pp, dir)))) continue;
347
- if (dir.startsWith("session-") || dir === selfId) ids.push(dir);
348
- }
349
- }
350
- }
351
- if (ids.includes(want)) return { id: want };
352
- const byPrefix = ids.filter((id) => id.startsWith(want));
353
- if (byPrefix.length === 1) return { id: byPrefix[0] };
354
- if (byPrefix.length > 1) return { candidates: byPrefix.map((id) => ({ id })) };
355
- // 标题子串:只在落盘索引里找(避免为了解析一个参数去逐份读日志)
356
- try {
357
- const { loadListDiskCache } = await import("../../sessions/list-cache.js");
358
- let entries = {};
359
- try { entries = loadListDiskCache(ctx)?.entries ?? {}; } catch { entries = {}; }
360
- const lower = want.toLowerCase();
361
- const byTitle = Object.entries(entries)
362
- .filter(([, e]) => String(e?.title ?? "").toLowerCase().includes(lower))
363
- .map(([id, e]) => ({ id, title: e?.title ?? null }));
364
- if (byTitle.length === 1) return byTitle[0];
365
- if (byTitle.length > 1) return { candidates: byTitle };
366
- } catch { /* 索引不可用:退化为只按 id 匹配 */ }
367
- return null;
334
+ // 实现已抽到公共底座 sessions/resolve.js(成员轨迹与记忆取回共用同一套解析)。
335
+ //
336
+ // 旧实现在这里用 `dir.startsWith("session-")` 判断会话目录,而子代理/成员会话的目录名是
337
+ // **裸 UUID**(不带 session- 前缀),导致那些会话永远进不了候选集 —— 完整 id 与前缀都解析不出来。
338
+ // 公共底座改为「目录里有会话日志文件」作为判据,与目录命名规则解耦。
339
+ //
340
+ // 顺带获得成员名支持:`recall_memory({ session: "writer" })` 这类写法现在也能解析。
341
+ return await resolveTargetSession(ctx, want, selfId);
368
342
  }
@@ -7,6 +7,7 @@
7
7
  * 其余(会话操作实现、标题工具、日志)在本模块内直接 import,不再依赖 apply 的作用域。
8
8
  */
9
9
  import { log } from '../shared/log.js';
10
+ import { restoreSessionsFromExport } from '../sessions/export-restore.js';
10
11
  import { MSG_NEED_SESSION_ID } from '../shared/constants.js';
11
12
  import { titleString, foldTitle, workspaceNameOf, archiveTitleWithWs, stripArchiveWsPrefix } from './rename/title.js';
12
13
  import { unarchiveSession, deleteSession, undoLastMessage, deleteBatchSessions, deleteByRule, emptyTrash, purgeExpiredTrash } from './session-ops.js';
@@ -107,6 +108,26 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
107
108
  }
108
109
  };
109
110
 
111
+ // ---------- POST /api/session-conductor/restore-from-export ----------
112
+ // 把「会话导出包」(解压后的明文 jsonl 目录)覆盖回运行时格式。
113
+ // 为什么不能直接拷:导出包是明文 session.v4.jsonl,而运行时读的是带校验和的多帧
114
+ // session.v4.jsonl.zstd;直接拷会被宿主判「总和检查码不匹配」或 corrupt。
115
+ // 默认 dryRun(只报告会覆盖哪些、事件多少),body.dryRun=false 才写盘。
116
+ route("/api/session-conductor/restore-from-export", "restore-from-export-failed", async (body, res) => {
117
+ const exportDir = typeof body?.exportDir === "string" ? body.exportDir : "";
118
+ if (exportDir === "") {
119
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: "需要 exportDir(解压后的导出包目录绝对路径)" } });
120
+ }
121
+ const result = await restoreSessionsFromExport({
122
+ exportDir,
123
+ sessionIds: Array.isArray(body?.sessionIds) ? body.sessionIds : undefined,
124
+ dryRun: body?.dryRun !== false,
125
+ eventsPerFrame: typeof body?.eventsPerFrame === "number" ? body.eventsPerFrame : undefined,
126
+ ctx,
127
+ });
128
+ send(res, result.ok ? 200 : 400, result);
129
+ }, { requireSessionId: false });
130
+
110
131
  // ---------- POST /api/session-conductor/archive ----------
111
132
  // 归档时给标题加「[工作区名] 」前缀(数据层带前缀,面板显示剥离,已归档视图按前缀分组)。
112
133
  route("/api/session-conductor/archive", "archive-failed", async (body, res) => {
@@ -0,0 +1,384 @@
1
+ // dsh-session-conductor — 从「会话导出包」恢复/覆盖会话
2
+ //
3
+ // 【为什么需要】DSH 的会话导出包是**明文 jsonl** 目录结构(`session.v4.jsonl` +
4
+ // `subagents/<id>/session.v4.jsonl` + `media/`),而运行时读的是
5
+ // `session.v4.jsonl.zstd` —— 带校验和的**多帧** zstd 容器,首帧必须恰好一行 header。
6
+ // 因此「把备份覆盖回去」不是简单拷贝:必须按官方容器格式重新编码,否则宿主会报
7
+ // 「会话文件 总和检查码不匹配」或 `corrupt session log`(本机两种都踩过)。
8
+ //
9
+ // 【三种坑与本模块的对应做法】
10
+ // ① 无校验和帧 ⇒ 宿主拒读:压缩时固定带 ZSTD_c_checksumFlag(复用 zstd-frames 的 CHECKSUM_OPTIONS)。
11
+ // ② 单帧塞进整份日志 ⇒ 读取端一次性解压整帧、内存尖峰:按 eventsPerFrame 分帧(宿主自己也是分批 append)。
12
+ // ③ 覆盖 seeded 会话时打乱 inherited 标记 ⇒ 宿主断言 "seeded session requires an inherited event count":
13
+ // 只覆盖日志文件本身,不动 projcache 与目录内其它文件;报告里回显 inherited 标记位置供核对。
14
+ //
15
+ // 【安全】默认 dryRun:只扫描并报告「会覆盖哪些会话、事件多少、帧多少」,不写盘。
16
+ // 真正写盘时:先校验 header.id 与目标一致、事件 seq 严格连续,再写 tmp 后 rename 原子替换;
17
+ // 写完重新逐帧解码复核事件数与末事件,复核不通过即记为错误。
18
+
19
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import zlib from 'node:zlib';
22
+ import { createHash } from 'node:crypto';
23
+ import { scanZstdFrames, validateHeaderFrame, decodeAllFrames, CHECKSUM_OPTIONS } from '../zstd-frames.js';
24
+ import { sessionsRootOf, findSessionLogById } from './events.js';
25
+ import { log } from '../shared/log.js';
26
+
27
+ /** 每个 zstd 事件帧里装多少条事件(宿主自己也是分批 append,太大会让读取端内存尖峰)。 */
28
+ const DEFAULT_EVENTS_PER_FRAME = 1000;
29
+
30
+ /** 导出包里明文的固定文件名。 */
31
+ const PLAIN_NAME = 'session.v4.jsonl';
32
+
33
+ /**
34
+ * 把一个明文 jsonl 编码成 DSH 官方 zstd 多帧容器(首帧恰好一行 header,每帧带校验和)。
35
+ *
36
+ * @param {string} plainText 明文 jsonl 全文
37
+ * @param {{eventsPerFrame?: number}} [opts]
38
+ * @returns {{ok: true, buffer: Buffer, header: object, eventCount: number, frames: number, inheritedAt: number|null}
39
+ * | {ok: false, error: string}}
40
+ */
41
+ export function encodePlainToZstd(plainText, opts = {}) {
42
+ const eventsPerFrame = Math.max(1, opts.eventsPerFrame ?? DEFAULT_EVENTS_PER_FRAME);
43
+ const lines = String(plainText).split('\n').filter((l) => l.trim() !== '');
44
+ if (lines.length < 2) return { ok: false, error: '明文事件不足(至少需要 header + 1 条事件)' };
45
+
46
+ let header;
47
+ try {
48
+ header = JSON.parse(lines[0]);
49
+ } catch (error) {
50
+ return { ok: false, error: `首行不是合法 JSON:${String(error?.message ?? error)}` };
51
+ }
52
+ if (header?.type !== 'session' || !header?.id) return { ok: false, error: '首行不是 session header' };
53
+
54
+ // 事件必须 seq 严格连续:不连续的日志写出去宿主也会判 corrupt,这里直接拒绝
55
+ const events = [];
56
+ let prev = -1;
57
+ let gaps = 0;
58
+ let bad = 0;
59
+ let inheritedAt = null;
60
+ for (let i = 1; i < lines.length; i += 1) {
61
+ let e;
62
+ try {
63
+ e = JSON.parse(lines[i]);
64
+ } catch {
65
+ bad += 1;
66
+ continue;
67
+ }
68
+ if (typeof e?.seq === 'number') {
69
+ if (prev >= 0 && e.seq !== prev + 1) gaps += 1;
70
+ prev = e.seq;
71
+ }
72
+ if (e?.type === 'session/end-seed' && e?.data?.inherited === true) inheritedAt = e.seq;
73
+ events.push(e);
74
+ }
75
+ if (bad > 0) return { ok: false, error: `明文里有 ${bad} 行坏 JSON` };
76
+ if (gaps > 0) return { ok: false, error: `明文事件 seq 不连续(${gaps} 处),拒绝编码` };
77
+
78
+ const frames = [zlib.zstdCompressSync(Buffer.from(lines[0] + '\n', 'utf8'), CHECKSUM_OPTIONS)];
79
+ for (let i = 0; i < events.length; i += eventsPerFrame) {
80
+ const chunk = events.slice(i, i + eventsPerFrame).map((e) => JSON.stringify(e)).join('\n') + '\n';
81
+ frames.push(zlib.zstdCompressSync(Buffer.from(chunk, 'utf8'), CHECKSUM_OPTIONS));
82
+ }
83
+ return { ok: true, buffer: Buffer.concat(frames), header, eventCount: events.length, frames: frames.length, inheritedAt };
84
+ }
85
+
86
+ /**
87
+ * 扫描导出包目录,列出「里面有哪些会话、各自对应哪个目标目录」。
88
+ *
89
+ * 目标目录解析顺序:① 按会话 id 找现有会话目录(覆盖已有会话)② 找不到就用同族目录
90
+ * (导出包里父会话所在的项目目录)新建 —— 同族会话必然同 cwd,避免自己实现路径编码。
91
+ *
92
+ * @param {string} exportDir 解压后的导出包目录
93
+ * @param {{home?: string}} [opts]
94
+ * @returns {{entries: Array<object>, error?: string}}
95
+ */
96
+ export function scanExportDir(exportDir, opts = {}) {
97
+ if (!existsSync(exportDir)) return { entries: [], error: `导出目录不存在:${exportDir}` };
98
+ // 注意:sessionsRootOf() 不接受参数(它只读 process.env.DSH_HOME),
99
+ // 所以要支持自定义 home 必须自己拼 <home>/sessions,不能把 home 当参数传进去。
100
+ const root = opts.home ? join(opts.home, 'sessions') : sessionsRootOf();
101
+ const entries = [];
102
+
103
+ const readHeaderOf = (plainPath) => {
104
+ try {
105
+ const firstLine = readFileSync(plainPath, 'utf8').split('\n', 1)[0];
106
+ return JSON.parse(firstLine);
107
+ } catch {
108
+ return null;
109
+ }
110
+ };
111
+
112
+ // 父会话(导出包根下的 session.v4.jsonl)
113
+ const parentPlain = join(exportDir, PLAIN_NAME);
114
+ let familyDir = null;
115
+ if (existsSync(parentPlain)) {
116
+ const header = readHeaderOf(parentPlain);
117
+ if (header?.id) {
118
+ const existing = findSessionLogById(header.id, root);
119
+ const targetDir = existing ? join(existing, '..') : null;
120
+ if (targetDir) familyDir = join(targetDir, '..');
121
+ entries.push({
122
+ role: 'parent',
123
+ id: header.id,
124
+ plainPath: parentPlain,
125
+ targetDir,
126
+ action: targetDir ? 'overwrite' : 'skip',
127
+ reason: targetDir ? '' : '找不到该会话的现有目录',
128
+ });
129
+ }
130
+ }
131
+
132
+ // 子代理(subagents/<id>/session.v4.jsonl)
133
+ const subsDir = join(exportDir, 'subagents');
134
+ if (existsSync(subsDir)) {
135
+ for (const name of readdirSync(subsDir)) {
136
+ const plainPath = join(subsDir, name, PLAIN_NAME);
137
+ if (!existsSync(plainPath)) continue;
138
+ const header = readHeaderOf(plainPath);
139
+ const id = header?.id;
140
+ if (!id) {
141
+ entries.push({ role: 'subagent', id: name, plainPath, targetDir: null, action: 'skip', reason: '明文 header 无法解析' });
142
+ continue;
143
+ }
144
+ const existing = findSessionLogById(id, root);
145
+ if (existing) {
146
+ entries.push({ role: 'subagent', id, plainPath, targetDir: join(existing, '..'), action: 'overwrite' });
147
+ } else if (familyDir) {
148
+ entries.push({ role: 'subagent', id, plainPath, targetDir: join(familyDir, id), action: 'create' });
149
+ } else {
150
+ entries.push({ role: 'subagent', id, plainPath, targetDir: null, action: 'skip', reason: '既无现有目录、又无同族目录可落' });
151
+ }
152
+ if (id !== name) {
153
+ const last = entries[entries.length - 1];
154
+ last.reason = `${last.reason ? last.reason + ';' : ''}目录名(${name})与 header.id 不一致`;
155
+ }
156
+ }
157
+ }
158
+ return { entries };
159
+ }
160
+
161
+ /**
162
+ * 从导出包的 `media/` 恢复附件对象到内容寻址对象库。
163
+ *
164
+ * 【为什么必须做】会话消息里引用的是 `sha256:<64hex>` 附件对象,宿主按
165
+ * `<DSH_HOME>/attachments/v1/objects/<前两位>/<64hex>` 取文件。只覆盖会话日志而不恢复
166
+ * 附件,会话就会在下一轮报「Attachment object is missing」(本机实测:恢复 4 个会话后
167
+ * 168 个引用里缺 27 个)。
168
+ *
169
+ * 【安全】导出包里的文件名形如 `sha256:<64hex>.<ext>`,这里**逐个重算 sha256 与文件名比对**,
170
+ * 不一致直接拒绝(不把坏文件写进对象库);已存在的对象跳过(内容寻址天然幂等)。
171
+ *
172
+ * @param {object} o
173
+ * @param {string} o.exportDir 解压后的导出包目录
174
+ * @param {string} o.home DSH_HOME
175
+ * @param {boolean} [o.dryRun] true 只统计不写盘(默认 true)
176
+ * @returns {{placed:number, existed:number, rejected:number, dryRun:boolean, errors:string[]}}
177
+ */
178
+ export async function restoreExportMedia(o) {
179
+ const dryRun = o?.dryRun !== false;
180
+ const mediaDir = join(o?.exportDir ?? '', 'media');
181
+ const out = { placed: 0, existed: 0, rejected: 0, dryRun, errors: [] };
182
+ if (!existsSync(mediaDir)) return out;
183
+ const objRoot = join(o?.home ?? '', 'attachments', 'v1', 'objects');
184
+
185
+ for (const name of readdirSync(mediaDir)) {
186
+ const m = /^sha256:([0-9a-f]{64})(?:\.[A-Za-z0-9]+)?$/.exec(name);
187
+ if (!m) {
188
+ out.rejected += 1;
189
+ out.errors.push(`跳过(命名不合规范):${name}`);
190
+ continue;
191
+ }
192
+ const hash = m[1];
193
+ const target = join(objRoot, hash.slice(0, 2), hash);
194
+ if (existsSync(target)) {
195
+ out.existed += 1;
196
+ continue;
197
+ }
198
+ let buf;
199
+ try {
200
+ buf = readFileSync(join(mediaDir, name));
201
+ } catch (e) {
202
+ out.rejected += 1;
203
+ out.errors.push(`${name}:读取失败 ${String(e?.message ?? e)}`);
204
+ continue;
205
+ }
206
+ // 内容自证:重算 sha256 必须等于文件名里的 hash
207
+ const actual = createHash('sha256').update(buf).digest('hex');
208
+ if (actual !== hash) {
209
+ out.rejected += 1;
210
+ out.errors.push(`${name}:内容校验失败(实际 sha256=${actual.slice(0, 12)}…),拒绝写入`);
211
+ continue;
212
+ }
213
+ if (dryRun) {
214
+ out.placed += 1;
215
+ continue;
216
+ }
217
+ try {
218
+ mkdirSync(join(objRoot, hash.slice(0, 2)), { recursive: true, mode: 0o700 });
219
+ writeFileSync(target, buf, { mode: 0o600 });
220
+ out.placed += 1;
221
+ } catch (e) {
222
+ out.rejected += 1;
223
+ out.errors.push(`${name}:写入失败 ${String(e?.message ?? e)}`);
224
+ }
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * 恢复后让「新日志」真正生效:清投影缓存 + 尝试把 live 会话卸载回冷状态。
231
+ *
232
+ * 【为什么必须做】DSH 有**三层状态**:会话日志(磁盘)/ projcache(投影缓存)/ 进程内存里的 agent。
233
+ * 只覆盖磁盘日志时,界面仍显示旧内容——本机实测:日志 11:20 已覆盖,projcache 还停在 11:06,
234
+ * 打开会话看到的就是替换前的内容。因此恢复后必须:
235
+ * ① 删掉该会话的 projcache 条目(否则冷读仍会拿旧投影);
236
+ * ② 若会话还是 live 的(内存里有 agent),把它 detach 回冷状态,下次打开才会重新从磁盘读。
237
+ *
238
+ * detach 需要插件 ctx(要 agents/sessions 服务);没有 ctx 时只清缓存并如实标注,
239
+ * 由调用方提示「需重载/重启宿主」。
240
+ *
241
+ * @param {object} o
242
+ * @param {string} o.home DSH_HOME
243
+ * @param {string} o.sessionId 会话 id
244
+ * @param {object} [o.ctx] 插件 ctx(有则尝试 detach)
245
+ * @returns {Promise<{projcacheCleared: boolean, detached: boolean, note?: string}>}
246
+ */
247
+ async function invalidateAfterRestore({ home, sessionId, ctx }) {
248
+ const out = { projcacheCleared: false, detached: false };
249
+ // ① 清投影缓存
250
+ try {
251
+ const cacheFile = join(home, 'storages', 'session_projcache', 'sessions', `${sessionId}.json`);
252
+ if (existsSync(cacheFile)) {
253
+ unlinkSync(cacheFile);
254
+ out.projcacheCleared = true;
255
+ }
256
+ } catch (error) {
257
+ out.note = `清投影缓存失败:${String(error?.message ?? error)}`;
258
+ }
259
+ // ② 尝试 detach(让内存态丢弃;非 live 幂等返回 ok)
260
+ if (ctx) {
261
+ try {
262
+ const { detachSessionAgent } = await import('./detach.js');
263
+ const r = await detachSessionAgent(ctx, sessionId);
264
+ out.detached = r?.ok === true;
265
+ if (!r?.ok && r?.error?.message && !out.note) out.note = `detach 未执行:${r.error.message}`;
266
+ } catch (error) {
267
+ if (!out.note) out.note = `detach 失败:${String(error?.message ?? error)}`;
268
+ }
269
+ } else if (!out.note) {
270
+ out.note = '未传 ctx,未尝试 detach(若会话仍打开着,需重载/重启宿主才能看到新内容)';
271
+ }
272
+ return out;
273
+ }
274
+
275
+ /**
276
+ * 按导出包恢复/覆盖会话。
277
+ *
278
+ * @param {object} o
279
+ * @param {string} o.exportDir 解压后的导出包目录
280
+ * @param {string} [o.home] DSH_HOME(缺省按宿主约定推导)
281
+ * @param {string[]} [o.sessionIds] 只处理这些会话(缺省全部)
282
+ * @param {boolean} [o.dryRun] true 只扫描报告(默认 true)
283
+ * @param {number} [o.eventsPerFrame]
284
+ * @param {object} [o.ctx] 可选:传插件 ctx 时顺带写日志,并尝试 detach(把 live 会话卸载回冷状态)
285
+ * @param {boolean} [o.invalidate] 默认 true:恢复后清该会话 projcache,避免「覆盖了但界面还是旧内容」
286
+ * @param {boolean} [o.media] 默认 true:同时把导出包 media/ 里的附件恢复到对象库
287
+ * (只覆盖日志不恢复附件,会话下一轮会报 Attachment object is missing)
288
+ * @returns {Promise<{ok:boolean, dryRun:boolean, results:Array<object>, errors:Array<string>}>}
289
+ */
290
+ export async function restoreSessionsFromExport(o) {
291
+ const dryRun = o?.dryRun !== false;
292
+ const { entries, error } = scanExportDir(o?.exportDir, { home: o?.home });
293
+ if (error) return { ok: false, dryRun, results: [], errors: [error] };
294
+ const only = Array.isArray(o?.sessionIds) && o.sessionIds.length > 0 ? new Set(o.sessionIds) : null;
295
+ const results = [];
296
+ const errors = [];
297
+
298
+ for (const entry of entries) {
299
+ if (only && !only.has(entry.id)) continue;
300
+ if (entry.action === 'skip') {
301
+ results.push({ ...entry, status: 'skipped' });
302
+ continue;
303
+ }
304
+ let plain;
305
+ try {
306
+ plain = readFileSync(entry.plainPath, 'utf8');
307
+ } catch (e) {
308
+ errors.push(`${entry.id}:读明文失败 ${String(e?.message ?? e)}`);
309
+ results.push({ ...entry, status: 'error' });
310
+ continue;
311
+ }
312
+ const encoded = encodePlainToZstd(plain, { eventsPerFrame: o?.eventsPerFrame });
313
+ if (!encoded.ok) {
314
+ errors.push(`${entry.id}:${encoded.error}`);
315
+ results.push({ ...entry, status: 'error' });
316
+ continue;
317
+ }
318
+ if (encoded.header.id !== entry.id) {
319
+ errors.push(`${entry.id}:明文 header.id=${encoded.header.id} 与目录不一致`);
320
+ results.push({ ...entry, status: 'error' });
321
+ continue;
322
+ }
323
+ const summary = {
324
+ ...entry,
325
+ status: dryRun ? 'would-restore' : 'restored',
326
+ eventCount: encoded.eventCount,
327
+ frames: encoded.frames,
328
+ inheritedAt: encoded.inheritedAt,
329
+ bytes: encoded.buffer.length,
330
+ };
331
+ if (dryRun) {
332
+ results.push(summary);
333
+ continue;
334
+ }
335
+ // 真正写盘:目录缺则建,写 tmp 后 rename 原子替换,再逐帧复核
336
+ try {
337
+ if (!existsSync(entry.targetDir)) {
338
+ mkdirSync(entry.targetDir, { recursive: true, mode: 0o700 });
339
+ summary.created = true;
340
+ }
341
+ const target = join(entry.targetDir, 'session.v4.jsonl.zstd');
342
+ const tmp = join(entry.targetDir, '.session.restore.tmp');
343
+ writeFileSync(tmp, encoded.buffer, { mode: 0o600 });
344
+ renameSync(tmp, target);
345
+ const after = readFileSync(target);
346
+ const headerCheck = await validateHeaderFrame(after);
347
+ const { frames } = scanZstdFrames(after);
348
+ const checksums = frames.map((fr) => (after.readUInt8(fr.start + 4) & 0x04) !== 0);
349
+ const backLines = (await decodeAllFrames(after)).split('\n').filter((l) => l.trim() !== '');
350
+ const okVerify = headerCheck.ok && checksums.every(Boolean) && backLines.length - 1 === encoded.eventCount;
351
+ summary.verified = okVerify;
352
+ summary.sizeMB = Math.round((statSync(target).size / 1048576) * 10) / 10;
353
+ if (!okVerify) {
354
+ errors.push(
355
+ `${entry.id}:写后复核未通过(首帧 ${headerCheck.ok}、校验和 ${checksums.every(Boolean)}、事件 ${backLines.length - 1}/${encoded.eventCount})`,
356
+ );
357
+ } else if (o?.invalidate !== false) {
358
+ // 复核通过才失效:顺序很重要——先确保磁盘是对的,再丢缓存/卸载
359
+ const inv = await invalidateAfterRestore({ home: o?.home ?? '', sessionId: entry.id, ctx: o?.ctx });
360
+ summary.projcacheCleared = inv.projcacheCleared;
361
+ summary.detached = inv.detached;
362
+ if (inv.note) summary.note = inv.note;
363
+ }
364
+ results.push(summary);
365
+ } catch (e) {
366
+ errors.push(`${entry.id}:写盘失败 ${String(e?.message ?? e)}`);
367
+ results.push({ ...summary, status: 'error' });
368
+ }
369
+ }
370
+ // 附件:只恢复会话日志、不恢复 media 会让会话下一轮报 Attachment object is missing
371
+ let media = null;
372
+ if (o?.media !== false) {
373
+ try {
374
+ media = await restoreExportMedia({ exportDir: o?.exportDir, home: o?.home ?? '', dryRun });
375
+ if (media.rejected > 0) errors.push(...media.errors.slice(0, 5));
376
+ } catch (e) {
377
+ errors.push(`恢复附件失败:${String(e?.message ?? e)}`);
378
+ }
379
+ }
380
+ if (o?.ctx) {
381
+ log(o.ctx, `导出包恢复:${dryRun ? '预览' : '执行'} ${results.length} 项,附件 ${media ? `新增 ${media.placed}/已有 ${media.existed}/拒绝 ${media.rejected}` : '未处理'},错误 ${errors.length} 条`);
382
+ }
383
+ return { ok: errors.length === 0, dryRun, results, errors, media };
384
+ }
@@ -16,6 +16,8 @@ import { readFile } from "node:fs/promises";
16
16
  import { findSessionLogById, sessionsRootOf } from "./events.js";
17
17
  // 流式逐事件读取的公共底座(与记忆取回共用;本模块只负责「按 turn/step 怎么组织」)
18
18
  import { streamSessionEvents } from "./log-stream.js";
19
+ // 目标解析的公共底座(id / 前缀 / 标题 / 成员名;与记忆取回共用)
20
+ import { resolveTargetSession } from "./resolve.js";
19
21
 
20
22
  /** 单条文本默认截断长度(summary 模式下更短,见 SUMMARY_CLIP)。 */
21
23
  const DEFAULT_MAX_ITEM = 800;
@@ -292,9 +294,10 @@ export function registerMemberTraceTool(ctx, defineTool, tools, deps = {}) {
292
294
  const selfId = exec?.agent?.session?.id;
293
295
  if (!selfId) return "拿不到当前会话 id,无法读取轨迹。";
294
296
 
295
- // 目标解析:与记忆取回共用同一套(成员名 → id / 前缀 / 标题;命中多个给候选,不猜)
297
+ // 目标解析走公共底座(sessions/resolve.js):id / id 前缀 / 标题 / 成员名,命中多个给候选不猜。
298
+ // 两个工具(本工具与记忆取回)共用同一套,避免各自一套解析各缺一块。
296
299
  const want = typeof args?.session === "string" ? args.session.trim() : "";
297
- const resolveFn = resolve ?? (await import("../features/memory-recall/index.js")).resolveSessionTarget;
300
+ const resolveFn = resolve ?? resolveTargetSession;
298
301
  const rounds = Math.min(
299
302
  Number.isSafeInteger(args?.rounds) && args.rounds > 0 ? args.rounds : 1,
300
303
  maxRounds,
@@ -308,12 +311,14 @@ export function registerMemberTraceTool(ctx, defineTool, tools, deps = {}) {
308
311
 
309
312
  let targetId = selfId;
310
313
  if (want && want !== selfId) {
311
- const hit = await resolveFn(ctx, want, selfId);
314
+ const hit = await resolveFn(ctx, want, selfId, { agent: exec?.agent });
312
315
  if (hit?.candidates) {
313
316
  return `「${want}」匹配到多个目标,请用更完整的 id 重试:\n`
314
317
  + hit.candidates.slice(0, 10).map((c) => `- ${c.id}${c.title ? ` | ${c.title}` : ""}`).join("\n");
315
318
  }
316
- if (!hit?.id) return `找不到目标会话「${want}」:没有匹配的成员名 / 会话 id / id 前缀 / 标题。`;
319
+ if (!hit?.id) {
320
+ return `找不到目标会话「${want}」:没有匹配的成员名 / 会话 id / id 前缀 / 标题。`;
321
+ }
317
322
  targetId = hit.id;
318
323
  }
319
324
 
@@ -0,0 +1,120 @@
1
+ // dsh-session-conductor — 会话目标解析(公共底座)
2
+ //
3
+ // 【为什么抽出来】「用户/AI 给一个模糊的目标(id / id 前缀 / 标题 / 成员名),解析成确切 sessionId」
4
+ // 这件事原先只写在记忆取回里(`resolveSessionTarget`),成员轨迹又自己接了一套,两边各有缺口。
5
+ //
6
+ // 【原先的缺陷】旧实现判断「这是不是一个会话目录」用的是 `dir.startsWith("session-")`。
7
+ // 主会话的目录名确实是 `session-<uuid>` 形式,**但子代理/成员会话的目录名是裸 UUID**
8
+ // (例如 `c9a6a72c-908e-4040-b88c-2e6ce5014378`)。于是成员会话永远进不了候选集,
9
+ // 完整 id 和 id 前缀都解析不出来,工具只好报「找不到目标会话」。
10
+ //
11
+ // 【现在的判据】不看目录名叫什么,只看**里面有没有会话日志文件**(`findSessionLog`)——
12
+ // 这是宿主自己也用的判据,且与目录命名规则解耦(v0/v3/v4 各格式都能识别)。
13
+ //
14
+ // 【为什么异步】会话目录可能上千个;同步 stat/readdir 会把事件循环钉住,拖慢整个宿主。
15
+ import { readdir, stat } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { findSessionLog } from "../session-log.js";
18
+ import { sessionsRootOf } from "./events.js";
19
+
20
+ /**
21
+ * 列出全部会话 id(按「目录里有会话日志」判断,不看目录名前缀)。
22
+ *
23
+ * @param {string} root 会话根目录
24
+ * @returns {Promise<string[]>}
25
+ */
26
+ async function listSessionIds(root) {
27
+ const ids = [];
28
+ const isDir = async (p) => {
29
+ try { return (await stat(p)).isDirectory(); } catch { return false; }
30
+ };
31
+ if (!root || !(await isDir(root))) return ids;
32
+ for (const group of await readdir(root)) {
33
+ const groupPath = join(root, group);
34
+ if (!(await isDir(groupPath))) continue;
35
+ for (const dir of await readdir(groupPath)) {
36
+ const dirPath = join(groupPath, dir);
37
+ if (!(await isDir(dirPath))) continue;
38
+ // 关键:用「有没有日志文件」而不是「目录名像不像 session-xxx」
39
+ if (findSessionLog(dirPath)) ids.push(dir);
40
+ }
41
+ }
42
+ return ids;
43
+ }
44
+
45
+ /**
46
+ * 把一个模糊目标解析成会话 id。支持:完整 id → id 前缀 → 标题子串。
47
+ *
48
+ * 多个命中时**不猜**,返回候选清单交给调用方展示。
49
+ *
50
+ * @param {object} ctx 插件 context(用于落盘索引里的标题)
51
+ * @param {string} want 用户给的目标
52
+ * @param {string} [selfId] 当前会话 id(等于它时直接返回)
53
+ * @param {{root?: string}} [opts]
54
+ * @returns {Promise<{id: string, title?: string|null} | {candidates: Array<{id: string, title?: string|null}>} | null>}
55
+ * null = 三段都没命中
56
+ */
57
+ export async function resolveSessionId(ctx, want, selfId, opts = {}) {
58
+ const raw = String(want ?? "").trim();
59
+ if (!raw) return null;
60
+ if (selfId && raw === selfId) return { id: selfId };
61
+
62
+ const root = opts.root ?? sessionsRootOf();
63
+ const ids = await listSessionIds(root);
64
+
65
+ if (ids.includes(raw)) return { id: raw };
66
+ const byPrefix = ids.filter((id) => id.startsWith(raw));
67
+ if (byPrefix.length === 1) return { id: byPrefix[0] };
68
+ if (byPrefix.length > 1) return { candidates: byPrefix.map((id) => ({ id })) };
69
+
70
+ // 标题子串:只查落盘索引(避免为了解析一个参数去逐份读日志)
71
+ try {
72
+ const { loadListDiskCache } = await import("./list-cache.js");
73
+ let entries = {};
74
+ try { entries = loadListDiskCache(ctx)?.entries ?? {}; } catch { entries = {}; }
75
+ const lower = raw.toLowerCase();
76
+ const byTitle = Object.entries(entries)
77
+ .filter(([, e]) => String(e?.title ?? "").toLowerCase().includes(lower))
78
+ .map(([id, e]) => ({ id, title: e?.title ?? null }));
79
+ if (byTitle.length === 1) return byTitle[0];
80
+ if (byTitle.length > 1) return { candidates: byTitle };
81
+ } catch {
82
+ // 索引不可用:退化为只按 id 解析
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * 解析目标会话,**额外支持 Team 成员名**。
89
+ *
90
+ * 成员名是日常最顺手的写法(`read_member_trace({ session: "writer" })`),但成员名既不是会话 id
91
+ * 也不是会话标题,通用解析认不了。这里在通用解析未命中时补一层成员解析。
92
+ *
93
+ * 用动态 `import()` 引成员定位:`sessions/` 是本插件底层,`features/member-model/` 是上层,
94
+ * 静态引用会形成层级倒置;且宿主没装 agent-team 时成员解析本就不可用,动态导入让那种情况自然降级。
95
+ *
96
+ * @param {object} ctx 插件 context
97
+ * @param {string} want 用户给的目标
98
+ * @param {string} [selfId] 当前会话 id
99
+ * @param {{root?: string, agent?: object}} [opts] agent 用于成员名册的作用域(缺省按全局名册)
100
+ * @returns {Promise<{id: string, title?: string|null, matched?: string} | {candidates: Array<{id: string, title?: string|null}>} | null>}
101
+ */
102
+ export async function resolveTargetSession(ctx, want, selfId, opts = {}) {
103
+ const direct = await resolveSessionId(ctx, want, selfId, opts);
104
+ if (direct) return direct;
105
+
106
+ try {
107
+ const { findTargetAgent } = await import("../features/member-model/locate.js");
108
+ const found = findTargetAgent(ctx, want, { caller: opts.agent ?? null });
109
+ if (found?.sessionId) return { id: found.sessionId, matched: found.matched ?? "成员名" };
110
+ if (found?.error && /匹配到 \d+ 个/.test(String(found.error))) {
111
+ // 成员名模糊命中多个:把它转成候选清单,保持「不猜」的一致行为
112
+ const ids = [...String(found.error).matchAll(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g)]
113
+ .map((m) => ({ id: m[1] }));
114
+ if (ids.length > 1) return { candidates: ids };
115
+ }
116
+ } catch {
117
+ // 成员解析不可用:按未命中处理,由调用方给出统一报错
118
+ }
119
+ return null;
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.3.19",
3
+ "version": "1.4.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",