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 +12 -1
- package/lib/apply.js +22 -8
- package/lib/client.js +17 -5
- package/lib/core/domain-fallback.js +93 -0
- package/lib/core/domain.js +37 -7
- package/lib/features/member-model/batch.js +2 -2
- package/lib/features/member-model/command.js +7 -5
- package/lib/features/member-model/create-teammate.js +3 -1
- package/lib/features/member-model/switch.js +76 -18
- package/lib/features/member-model/tools.js +5 -3
- package/lib/features/memory-recall/index.js +10 -36
- package/lib/features/session-ops-routes.js +21 -0
- package/lib/sessions/export-restore.js +384 -0
- package/lib/sessions/member-trace.js +9 -4
- package/lib/sessions/resolve.js +120 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/core/domain.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ②
|
|
404
|
-
//
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
//
|
|
297
|
+
// 目标解析走公共底座(sessions/resolve.js):id / id 前缀 / 标题 / 成员名,命中多个给候选不猜。
|
|
298
|
+
// 两个工具(本工具与记忆取回)共用同一套,避免各自一套解析各缺一块。
|
|
296
299
|
const want = typeof args?.session === "string" ? args.session.trim() : "";
|
|
297
|
-
const resolveFn = resolve ??
|
|
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)
|
|
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
|
+
"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",
|