dsh-session-conductor 1.3.0 → 1.3.2
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 +5 -5
- package/lib/apply.js +92 -9
- package/lib/core/domain.js +36 -16
- package/lib/features/member-model/index.js +5 -0
- package/lib/features/member-model/override.js +11 -3
- package/lib/features/member-model/switch.js +115 -1
- package/lib/features/memory-recall/index.js +75 -7
- package/lib/features/session-messaging/tools.js +115 -3
- package/lib/repair.js +28 -3
- package/lib/sessions/events.js +1 -1
- package/lib/zstd-frames.js +77 -2
- package/package.json +2 -1
- package/scripts/check-member-model.mjs +31 -0
- package/skills/dsh-session-conductor-functions.md +2 -1
package/README.md
CHANGED
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
| 压缩模型选择 | 上下文自动压缩用模型独立选择(省成本) | 设置 → 插件 → 压缩模型 |
|
|
22
22
|
| 模板注入 | 方案模板 / 收尾模板:本地设备上传 / 在线网址 / DSH 目录浏览三种导入,内容可再编辑,注入系统提示词(方案/收尾各一条上下文注入) | 设置 → 会话管理 → 模板注入 |
|
|
23
23
|
| 自动重命名模型 | 指定便宜/专用模型做标题分析 | 设置 → 会话管理 |
|
|
24
|
-
| 成员模型切换 | 给队友/子代理单独切模型(只动该成员会话,不影响其他成员):先用 `list_models` 拿确切模型 id,再 `set_member_model` 按成员名/sessionId 切换(写 `model/selection`
|
|
25
|
-
| 记忆取回 | 检测到上下文用量**明显下降**(默认 ≥10%,可配)即判定「上下文疑似丢失」——压缩、截断、实例重启后快照未加载**都算**(比只盯压缩更全)。此时自动把最近 **3 轮(可配)**
|
|
26
|
-
| 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens
|
|
27
|
-
| 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。`list_sessions` 支持 **`query` 子串过滤 + `limit`/`offset` 分页**(默认只给 20
|
|
24
|
+
| 成员模型切换 | 给队友/子代理单独切模型(只动该成员会话,不影响其他成员):先用 `list_models` 拿确切模型 id,再 `set_member_model` 按成员名/sessionId 切换(写 `model/selection` 事件交给官方投影接管,跨轮持久;未挂载成员也能切,运行中会先等它暂停)。**返回值直接带「切没切成」的查证**:立刻给「最近一次实际请求的 provider/model(取自 `request/header`,请求级权威记录)+ 最近一次切换意图 + 一行判定」,并**注册查证任务(不阻塞)**——该成员下次发起请求时按 `agent/request` 事件判定,确认结果在下一次 step 注入给发起方(不轮询、不等待);另附可复制的复核命令 `node scripts/check-member-model.mjs <sessionId>`。判定只认请求时真实使用的模型,不信注入的 `[model changed: …]` 提示 | AI 工具 `list_models` / `set_member_model` + 脚本 `scripts/check-member-model.mjs` |
|
|
25
|
+
| 记忆取回 | 检测到上下文用量**明显下降**(默认 ≥10%,可配)即判定「上下文疑似丢失」——压缩、截断、实例重启后快照未加载**都算**(比只盯压缩更全)。此时自动把最近 **3 轮(可配)** 的**用户消息文本 + 该轮 AI 的第一条回应**(优先取当时的 reasoning)重新注入,让 AI 立刻恢复状态。实现要点:按 zstd 帧**流式**读日志(常量内存)、**只认真实用户消息**(排除插件自身注入,防反馈环)、同回合连发**合并成一轮**、注入总长与单条都有上限。配置项 `retrieveMemory*`(默认开)。**另提供 AI 工具 `recall_memory`**(参数 `rounds` = 取几轮,默认取配置、上限 50;参数 `session` = 目标会话:完整 id / 唯一前缀 / 标题子串,缺省或留空即取自身)让 AI 自己按需回溯,**也能取其它会话**(跨会话排查时在会话内直接看到对方上下文;命中多个候选会给出候选清单,不随便挑一个) | 每轮注入(`agent/pre-step`)+ AI 工具 `recall_memory` |
|
|
26
|
+
| 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/消息文本),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数。**另:每轮把实时用量注入消息尾部**(AI 每轮都能直接看到数字;注入在尾部而非系统提示词前缀 ⇒ 前缀稳定、prompt cache 仍命中;按 turn 门控,一回合只注一次;配置 `injectContextUsage` 可关) | AI 工具 `context_usage` + 每轮注入 |
|
|
27
|
+
| 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。`list_sessions` 支持 **`query` 子串过滤 + `limit`/`offset` 分页**(默认只给 20 条,避免整表塞进调用方上下文)。**索引快路径 + 后台化**:`fromIndex=true` 直接读落盘索引(秒回、不重建,不含 live 状态与归档态),默认刷新路径先等 3 秒拿热缓存结果、超时就把**同一次在途构建**交给宿主后台 job 并立刻返回 jobId(冷缓存全量构建实测 226 秒 / 201 会话 / 1.5 GB 日志,热缓存 47 毫秒;job 注册失败会同步降级并带回原因)。硬约束:禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条 | AI 工具 `list_sessions` / `send_to_session` |
|
|
28
28
|
| 指定模型新建会话 | AI 新建会话并可**直接指定 provider/model**(建完即切到该模型),可选带一条初始消息让它立刻开始干活。**缺省工作目录 = 调用方会话自己的 cwd**(不落在 DSH 安装目录里)。本体 `create` 本身不带模型,所以是 `create` → `selectModel` 两步;任一步失败会如实回执(如「会话已建,但模型未设置」),不谎报成功。**支持 `dryRun` 预览**(含模型是否存在于目录的核对),不建会话 | AI 工具 `create_session` |
|
|
29
29
|
|
|
30
30
|
## 界面预览
|
|
@@ -148,7 +148,7 @@ allowBuilds:
|
|
|
148
148
|
|
|
149
149
|
| 端点 | 方法 | 用途 |
|
|
150
150
|
|---|---|---|
|
|
151
|
-
| `/list` | GET | 会话列表(标题/目录/时间/状态,落盘缓存按 revision
|
|
151
|
+
| `/list` | GET | 会话列表(标题/目录/时间/状态,落盘缓存按 revision 增量)。加 `?job=1` 或请求头 `Accept: application/x-job` 改为注册宿主后台 job 并即刻返回 jobId(构建慢时不阻塞请求;宿主无 jobs 服务时返回 501,请求方回落到默认分支) |
|
|
152
152
|
| `/archive` / `/restore` | POST | 归档 / 恢复会话 |
|
|
153
153
|
| `/delete` | POST | 删除会话(拒绝运行中) |
|
|
154
154
|
| `/undo` | POST | 撤回最后一条消息 |
|
package/lib/apply.js
CHANGED
|
@@ -53,7 +53,8 @@ import { SEARCH_MIN_QUERY, searchSessions } from './features/search.js';
|
|
|
53
53
|
// 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
|
|
54
54
|
// applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
|
|
55
55
|
// 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
|
|
56
|
-
import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOverride, validateModelPair
|
|
56
|
+
import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOverride, validateModelPair,
|
|
57
|
+
registerSwitchWatch, noteRequestModel, takeSwitchOutcomes, readLastRequestModel } from './features/member-model/index.js';
|
|
57
58
|
// 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
|
|
58
59
|
import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
|
|
59
60
|
import { installProcessGuards } from './guards.js';
|
|
@@ -270,6 +271,33 @@ export async function apply(ctx, config = {}) {
|
|
|
270
271
|
try {
|
|
271
272
|
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
272
273
|
if (req.method !== "GET") invalidateSessionListCache();
|
|
274
|
+
// job 模式(`?job=1` 或 `Accept: application/x-job`):冷缓存下全量构建很慢
|
|
275
|
+
// (本机实测 201 份日志 / 1.5 GB 时 226 秒),把构建交给宿主后台 job 并**立即**返回 jobId,
|
|
276
|
+
// 调用方改用 job_output / job_list 取结果,前台不再被钉住。
|
|
277
|
+
// 默认(无 job 声明)仍走下面的普通 JSON / NDJSON 流式分支 —— 一字不动,避免改坏面板。
|
|
278
|
+
const wantJob = /application\/x-job/i.test(req.headers?.accept ?? "")
|
|
279
|
+
|| String(req.url ?? "").includes("job=1");
|
|
280
|
+
if (wantJob) {
|
|
281
|
+
const jobs = ctx?.get?.("jobs");
|
|
282
|
+
if (!jobs || typeof jobs.start !== "function") {
|
|
283
|
+
send(res, 501, { ok: false, error: { code: "unsupported", message: "宿主未挂载 jobs 服务,无法注册后台 job;请改用默认(流式/JSON)路径" } });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// 构建只做一次;完成后顺手失效 TTL,保证随后任何一条路径读到的都是本次结果
|
|
287
|
+
const done = buildSessionListCached(ctx).then(
|
|
288
|
+
(sessions) => { invalidateSessionListCache(); return { status: "completed", output: JSON.stringify({ ok: true, sessions }) }; },
|
|
289
|
+
(error) => ({ status: "failed", detail: String(error?.message ?? error) }),
|
|
290
|
+
);
|
|
291
|
+
const spec = { kind: "session-conductor-list", label: "list(会话列表刷新)", run: () => ({ cancel: () => {}, done }) };
|
|
292
|
+
// HTTP 入口没有执行主体 ⇒ 用「无主」注册(宿主存在全局 job 控制器即可);
|
|
293
|
+
// 无主不可用时如实回 501,由调用方决定是否改用默认路径(不静默吞掉)。
|
|
294
|
+
try {
|
|
295
|
+
send(res, 200, { ok: true, async: true, jobId: jobs.start(spec), hint: "用宿主 job_output / job_list 取结果" });
|
|
296
|
+
} catch (error) {
|
|
297
|
+
send(res, 501, { ok: false, error: { code: "unsupported", message: `注册后台 job 失败:${String(error?.message ?? error)}` } });
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
273
301
|
// list 流式(仅当请求声明 Accept: application/x-ndjson)——live 会话先快速
|
|
274
302
|
// 写出,冷会话串行逐个解析边写边出,前端 fetch stream 逐行追加(每获取一个立即显示,
|
|
275
303
|
// 不等全部);排序由前端按 updatedAt 倒序插入保持(流式下后端不再整体排序)。
|
|
@@ -1239,7 +1267,15 @@ export async function apply(ctx, config = {}) {
|
|
|
1239
1267
|
ctx.on("agent/request", async ({ agent }, next) => {
|
|
1240
1268
|
const resolved = await next();
|
|
1241
1269
|
const override = await getMemberModelOverride(ctx, agent?.session?.id);
|
|
1242
|
-
|
|
1270
|
+
const applied = applyModelOverride(resolved, override);
|
|
1271
|
+
// 切换查证(事件驱动):记录本次请求**真实使用**的 provider/model;若该会话有待查证项,
|
|
1272
|
+
// 就在这里按事件判定结果并入队(切换本身不阻塞、不轮询 —— 只等事件到)。
|
|
1273
|
+
try {
|
|
1274
|
+
noteRequestModel(agent?.session?.id, applied);
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
log(ctx, `切换查证记录失败(不影响本次请求): ${String(error?.message ?? error)}`);
|
|
1277
|
+
}
|
|
1278
|
+
return applied;
|
|
1243
1279
|
});
|
|
1244
1280
|
// createUserMessage 来自宿主包:动态导入 + 守卫。
|
|
1245
1281
|
// 为什么必须判类型:工作区自测环境没有 @deepseek-ai/* 依赖,直接引用标识符会抛
|
|
@@ -1289,10 +1325,9 @@ export async function apply(ctx, config = {}) {
|
|
|
1289
1325
|
// 多条重复的用量行(噪音 + 白烧 token)。
|
|
1290
1326
|
// · 默认开启(core/config.js 的 injectContextUsage);关掉即退化为「按需调用 context_usage 工具」。
|
|
1291
1327
|
// · 读不到投影/服务未挂载时注入器返回空串 ⇒ 静默跳过,绝不影响正常回合。
|
|
1292
|
-
//
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
});
|
|
1328
|
+
// 三个开关任一开启就需要本 hook(用量注入 / 记忆取回 / 切换查证回报);turn 门控保证一回合只注入一次。
|
|
1329
|
+
// 固定 enabled=true:切换查证的结果是一句话回报,不能因为前两个功能关了就不送。
|
|
1330
|
+
const usageInjector = createContextUsageInjector({ enabled: true });
|
|
1296
1331
|
ctx.on("agent/pre-step", async ({ agent, turn, signal }, next) => {
|
|
1297
1332
|
const decision = await next();
|
|
1298
1333
|
if (decision?.kind === "reject" || signal?.aborted) return decision;
|
|
@@ -1322,7 +1357,20 @@ export async function apply(ctx, config = {}) {
|
|
|
1322
1357
|
log(ctx, `记忆取回失败(跳过本次): ${String(error?.message ?? error)}`);
|
|
1323
1358
|
}
|
|
1324
1359
|
}
|
|
1325
|
-
|
|
1360
|
+
// 切换查证回报:切换发起方在**下一次 step** 收到一行结果(切换本身注册 job 后立刻返回,
|
|
1361
|
+
// 判定由 agent/request 事件触发 —— 这里只负责把结果送到对的人手上)。
|
|
1362
|
+
let switchNote = "";
|
|
1363
|
+
try {
|
|
1364
|
+
const outcomes = takeSwitchOutcomes(agent?.session?.id);
|
|
1365
|
+
if (outcomes.length > 0) {
|
|
1366
|
+
switchNote = outcomes.map((o) => (o.ok
|
|
1367
|
+
? `【切换查证】✅ 已确认生效:本次请求实际使用 ${o.actual}`
|
|
1368
|
+
: `【切换查证】❌ 未生效:期望 ${o.expected},本次请求实际使用 ${o.actual}`)).join("\n");
|
|
1369
|
+
}
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
log(ctx, `切换查证回报失败(跳过本次): ${String(error?.message ?? error)}`);
|
|
1372
|
+
}
|
|
1373
|
+
const text = [usageText, recallText, switchNote].filter(Boolean).join("\n\n");
|
|
1326
1374
|
if (!text) return decision;
|
|
1327
1375
|
try {
|
|
1328
1376
|
const injected = createUserMessage({
|
|
@@ -1393,7 +1441,7 @@ export async function apply(ctx, config = {}) {
|
|
|
1393
1441
|
return [{ type: "text", text: String(value) }];
|
|
1394
1442
|
}
|
|
1395
1443
|
},
|
|
1396
|
-
async execute(args) {
|
|
1444
|
+
async execute(args, exec) {
|
|
1397
1445
|
const provider = String(args?.provider ?? "").trim();
|
|
1398
1446
|
const model = String(args?.model ?? "").trim();
|
|
1399
1447
|
if (!provider || !model) return "切换失败:provider 和 model 都必填(先用 list_models 查确切值)";
|
|
@@ -1405,7 +1453,42 @@ export async function apply(ctx, config = {}) {
|
|
|
1405
1453
|
const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
|
|
1406
1454
|
if (switchResult.error) return `切换失败:${switchResult.error}`;
|
|
1407
1455
|
const memberLabel = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
|
|
1408
|
-
|
|
1456
|
+
// 查证(注册 job,不阻塞):登记待查证项 → 该成员下次发起请求时由 agent/request 事件判定
|
|
1457
|
+
// → 结果注入回本次调用方。同时立刻附一份快照:最近一次实际请求 + 最近一次切换意图。
|
|
1458
|
+
// 判定只认「请求时真实使用的模型」(request/header),不信注入的提示语。
|
|
1459
|
+
try {
|
|
1460
|
+
registerSwitchWatch(found.sessionId, provider, model, exec?.agent?.session?.id ?? null);
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
log(ctx, `切换查证登记失败(不影响本次切换): ${String(error?.message ?? error)}`);
|
|
1463
|
+
}
|
|
1464
|
+
let snap = "";
|
|
1465
|
+
let verdict = "";
|
|
1466
|
+
try {
|
|
1467
|
+
const { lastRequest, lastSelection } = await readLastRequestModel(found.sessionId);
|
|
1468
|
+
if (lastRequest) {
|
|
1469
|
+
snap = `【查证·最近一次实际请求】${lastRequest.provider}/${lastRequest.model}`
|
|
1470
|
+
+ (lastRequest.time ? `(${new Date(lastRequest.time).toLocaleString("zh-CN")})` : "");
|
|
1471
|
+
verdict = (lastRequest.provider === provider && lastRequest.model === model)
|
|
1472
|
+
? "【查证·判定】✅ 该成员最近的请求已经是新模型"
|
|
1473
|
+
: "【查证·判定】⏳ 待下一回合生效:最近一次请求是切换前发出的(属正常,不代表失败)";
|
|
1474
|
+
} else {
|
|
1475
|
+
snap = "【查证·最近一次实际请求】会话日志里还没有 request/header 记录(该成员可能从未运行过)";
|
|
1476
|
+
verdict = "【查证·判定】⏳ 待该成员首次运行后生效";
|
|
1477
|
+
}
|
|
1478
|
+
if (lastSelection) snap += `\n【查证·最近一次切换意图】${lastSelection.provider}/${lastSelection.model}`;
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
snap = `【查证·快照】读取会话日志失败:${String(error?.message ?? error)}`;
|
|
1481
|
+
}
|
|
1482
|
+
// 复核命令按**当前插件安装位置**生成(不写死本机路径),可直接复制执行
|
|
1483
|
+
const scriptUrl = new URL("../scripts/check-member-model.mjs", import.meta.url);
|
|
1484
|
+
const cmd = `node ${decodeURIComponent(scriptUrl.pathname)} ${found.sessionId}`;
|
|
1485
|
+
return [
|
|
1486
|
+
`已把成员 ${memberLabel}(${found.matched} 匹配)切到 ${provider}/${model};生效方式:${switchResult.via}`,
|
|
1487
|
+
snap,
|
|
1488
|
+
verdict,
|
|
1489
|
+
"【查证·任务】已登记(不阻塞):该成员下次发起请求时按事件判定,结果会在下一次 step 注入给你",
|
|
1490
|
+
`复核命令(可直接复制):\n${cmd}`,
|
|
1491
|
+
].filter(Boolean).join("\n");
|
|
1409
1492
|
}
|
|
1410
1493
|
}));
|
|
1411
1494
|
// 上下文用量自查工具:让 AI 自己按需查占用与构成,而不是每轮把数值注入系统提示词
|
package/lib/core/domain.js
CHANGED
|
@@ -115,30 +115,50 @@ export function pluginDomain(ctx) {
|
|
|
115
115
|
if (!storage) return Promise.reject(new Error("storageDomain 不可用"));
|
|
116
116
|
domainPromise = storage.open(pluginDomainSpec).then((domain) => {
|
|
117
117
|
domainLive = true;
|
|
118
|
-
|
|
119
|
-
// ctx.effect 会抛 cannot create effect on inactive context,且 rejected Promise 被缓存后 list 一直 500。
|
|
120
|
-
try {
|
|
121
|
-
ctx.effect(() => () => {
|
|
122
|
-
// 关闭的同时把缓存与存活标记一起清掉——下一次 apply 才会重新 open。
|
|
123
|
-
// 【原代码】只 return domain.close()(缓存不清),靠 apply 开头无条件 domainPromise = null 兜,
|
|
124
|
-
// 遇到「域还活着就重载」时会在旧的活域上再 open 一次 → storage 报 already open → list 500。
|
|
125
|
-
domainLive = false;
|
|
126
|
-
domainPromise = null;
|
|
127
|
-
return domain.close();
|
|
128
|
-
}, "session-conductor: domain close");
|
|
129
|
-
} catch {
|
|
130
|
-
/* 请求路径打开时不挂 effect;apply() 已预热并挂过 */
|
|
131
|
-
}
|
|
118
|
+
attachDomainClose(ctx, domain);
|
|
132
119
|
return domain;
|
|
133
|
-
}).catch((
|
|
120
|
+
}).catch((error) => {
|
|
121
|
+
// 模块热重载场景:本实例的缓存是空的,但上一份实例打开的域仍被 storageDomain
|
|
122
|
+
// 按名字占用(facility 用 reserved 集合挡重复 open),于是再 open 必报 already-open。
|
|
123
|
+
// 宿主提供了按名字查已开域的口子,这里直接复用那份 handle——
|
|
124
|
+
// 否则重载后列表/模板/开关会一路 500,直到整个实例重启为止。
|
|
125
|
+
const reused = isAlreadyOpenError(error) && typeof storage.get === "function"
|
|
126
|
+
? storage.get(pluginDomainSpec.name)
|
|
127
|
+
: undefined;
|
|
128
|
+
if (reused) {
|
|
129
|
+
domainLive = true;
|
|
130
|
+
attachDomainClose(ctx, reused);
|
|
131
|
+
return reused;
|
|
132
|
+
}
|
|
134
133
|
domainPromise = null;
|
|
135
134
|
domainLive = false;
|
|
136
|
-
throw
|
|
135
|
+
throw error;
|
|
137
136
|
});
|
|
138
137
|
}
|
|
139
138
|
return domainPromise;
|
|
140
139
|
}
|
|
141
140
|
|
|
141
|
+
/** 判定「这个名字已经被占用」的重复打开错误(宿主 DomainError 带 code,保底看文案)。 */
|
|
142
|
+
function isAlreadyOpenError(error) {
|
|
143
|
+
return error?.code === "already-open" || /is already open/.test(String(error?.message ?? ""));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 挂关闭效果:只在 apply 的活跃 fiber 上成功,请求路径(inactive fiber)会抛并被吞掉。 */
|
|
147
|
+
function attachDomainClose(ctx, domain) {
|
|
148
|
+
try {
|
|
149
|
+
ctx.effect(() => () => {
|
|
150
|
+
// 关闭的同时把缓存与存活标记一起清掉——下一次 apply 才会重新 open。
|
|
151
|
+
// 【原代码】只 return domain.close()(缓存不清),靠 apply 开头无条件 domainPromise = null 兜,
|
|
152
|
+
// 遇到「域还活着就重载」时会在旧的活域上再 open 一次 → storage 报 already open → list 500。
|
|
153
|
+
domainLive = false;
|
|
154
|
+
domainPromise = null;
|
|
155
|
+
return domain.close();
|
|
156
|
+
}, "session-conductor: domain close");
|
|
157
|
+
} catch {
|
|
158
|
+
/* 请求路径打开时不挂 effect;apply() 已预热并挂过 */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
142
162
|
/** 读域里的全局状态对象。 */
|
|
143
163
|
export async function pluginState(ctx) {
|
|
144
164
|
const domain = await pluginDomain(ctx);
|
|
@@ -11,4 +11,9 @@
|
|
|
11
11
|
export { pickTeamCaller, memberStatusError, validateModelPair, findTargetAgent } from "./locate.js";
|
|
12
12
|
export { getMemberModelOverride, setMemberModelOverride, applyModelOverride, resetMemberModelCacheForTest } from "./override.js";
|
|
13
13
|
export { waitForIdle, appendSelectionEventToLog, switchAgentModel } from "./switch.js";
|
|
14
|
+
// 切换查证(事件驱动,注册 job 不阻塞):登记待查证项 → 每次请求记录实际模型 → 按事件判定 → 结果回报
|
|
15
|
+
export {
|
|
16
|
+
registerSwitchWatch, peekSwitchWatch, noteRequestModel, takeSwitchOutcomes,
|
|
17
|
+
peekLastRequestModel, readLastRequestModel, resetSwitchWatchForTest,
|
|
18
|
+
} from "./switch.js";
|
|
14
19
|
export { resumeSetupFor } from "./setup.js";
|
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
// 【职责】保存「这个会话要用哪个模型」的 override:domain 持久化 + 内存 Map 镜像,
|
|
4
4
|
// 供 agent/request hook 每轮读取(避免每轮请求都 await 存储)。
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// 【为什么懒加载】core/domain.js 静态依赖宿主包 @deepseek-ai/dsh-storage-domain;
|
|
7
|
+
// 工作区单测没有宿主依赖,静态 import 会让**整个本域**在导入时就 ERR_MODULE_NOT_FOUND
|
|
8
|
+
// (实测:查证单测只想要纯逻辑,却被这条链挡住)。改成首次真正读写 domain 时才解析。
|
|
9
|
+
let domainModPromise = null;
|
|
10
|
+
async function loadPluginDomain(ctx) {
|
|
11
|
+
if (!domainModPromise) domainModPromise = import("../../core/domain.js");
|
|
12
|
+
const mod = await domainModPromise;
|
|
13
|
+
return mod.pluginDomain(ctx);
|
|
14
|
+
}
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
17
|
* 成员模型切换 override(sessionId → {provider, model})。
|
|
@@ -31,7 +39,7 @@ export async function getMemberModelOverride(ctx, sessionId) {
|
|
|
31
39
|
if (!sessionId) return null;
|
|
32
40
|
if (!memberModelCacheLoaded) {
|
|
33
41
|
try {
|
|
34
|
-
const domain = await
|
|
42
|
+
const domain = await loadPluginDomain(ctx);
|
|
35
43
|
const overrides = domain.global.get()?.memberModelOverrides ?? {};
|
|
36
44
|
for (const [id, sel] of Object.entries(overrides)) {
|
|
37
45
|
if (sel && typeof sel.provider === "string" && typeof sel.model === "string") {
|
|
@@ -48,7 +56,7 @@ export async function getMemberModelOverride(ctx, sessionId) {
|
|
|
48
56
|
|
|
49
57
|
/** 写入一条 override(同时更新 domain 与内存镜像)。 */
|
|
50
58
|
export async function setMemberModelOverride(ctx, sessionId, provider, model) {
|
|
51
|
-
const domain = await
|
|
59
|
+
const domain = await loadPluginDomain(ctx);
|
|
52
60
|
const state = domain.global.get() ?? {};
|
|
53
61
|
const overrides = { ...(state.memberModelOverrides ?? {}), [sessionId]: { provider, model } };
|
|
54
62
|
await domain.global.set({ ...state, memberModelOverrides: overrides });
|
|
@@ -74,7 +74,7 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
|
|
|
74
74
|
}
|
|
75
75
|
const last = foldLastModelSelection(events);
|
|
76
76
|
if (last && last.provider === provider && last.model === model) {
|
|
77
|
-
return { ok: true, note: "
|
|
77
|
+
return { ok: true, note: "日志里已是该模型,无需重复写入" };
|
|
78
78
|
}
|
|
79
79
|
let maxSeq = -1;
|
|
80
80
|
for (const e of events) if (typeof e?.seq === "number" && e.seq > maxSeq) maxSeq = e.seq;
|
|
@@ -184,3 +184,117 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
|
|
|
184
184
|
}
|
|
185
185
|
return { via };
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
// ── 切换查证:按「事件」判定是否真的换成了新模型(注册 job,不轮询、不阻塞)──────────
|
|
189
|
+
//
|
|
190
|
+
// 【为什么按事件而不是等待】切换「下一回合才生效」,等待就等于阻塞调用方;而插件本来就拦着
|
|
191
|
+
// `agent/request`(每轮改写模型那处)—— 那里拿到的 provider/model 就是**本次请求真实使用**的值,
|
|
192
|
+
// 即「请求级权威记录」。于是:切换时登记一条待查证项,下次请求到达时按事件判定,结果入队回报。
|
|
193
|
+
//
|
|
194
|
+
// 【为什么不信提示语】上下文里注入的 `[model changed: …]` 与 `model/selection` 事件都只表达「意图」,
|
|
195
|
+
// 实测出现过「提示是 agnes、请求头仍是 wb」的不一致 —— 判定只认请求时实际使用的值。
|
|
196
|
+
const switchWatches = new Map(); // sessionId(目标) → { provider, model, at, requesterSessionId }
|
|
197
|
+
const lastRequestModels = new Map(); // sessionId → { provider, model, at }(进程内最近一次请求)
|
|
198
|
+
const pendingOutcomes = new Map(); // sessionId(回报对象) → [ { ok, provider, model, actual, at } ]
|
|
199
|
+
|
|
200
|
+
/** 登记一条待查证项(切换时调用)。requesterSessionId = 切换发起方,结果回报给它。 */
|
|
201
|
+
export function registerSwitchWatch(sessionId, provider, model, requesterSessionId = null) {
|
|
202
|
+
if (!sessionId) return false;
|
|
203
|
+
switchWatches.set(sessionId, { provider, model, at: Date.now(), requesterSessionId });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 查看待查证项(诊断用;无则 null)。 */
|
|
208
|
+
export function peekSwitchWatch(sessionId) {
|
|
209
|
+
return switchWatches.get(sessionId) ?? null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 记录本次请求实际使用的模型,并在有待查证项时按事件判定结果。 */
|
|
213
|
+
export function noteRequestModel(sessionId, config) {
|
|
214
|
+
const provider = config?.provider;
|
|
215
|
+
const model = config?.model;
|
|
216
|
+
if (!sessionId || !provider || !model) return null;
|
|
217
|
+
const at = Date.now();
|
|
218
|
+
lastRequestModels.set(sessionId, { provider, model, at });
|
|
219
|
+
const watch = switchWatches.get(sessionId);
|
|
220
|
+
if (!watch) return null;
|
|
221
|
+
const ok = provider === watch.provider && model === watch.model;
|
|
222
|
+
const outcome = { ok, expected: `${watch.provider}/${watch.model}`, actual: `${provider}/${model}`, at };
|
|
223
|
+
switchWatches.delete(sessionId);
|
|
224
|
+
const to = watch.requesterSessionId;
|
|
225
|
+
if (to) {
|
|
226
|
+
const list = pendingOutcomes.get(to) ?? [];
|
|
227
|
+
list.push(outcome);
|
|
228
|
+
pendingOutcomes.set(to, list);
|
|
229
|
+
}
|
|
230
|
+
return outcome;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 取走某会话待回报的查证结果(回报方调用一次即清空)。 */
|
|
234
|
+
export function takeSwitchOutcomes(sessionId) {
|
|
235
|
+
if (!sessionId) return [];
|
|
236
|
+
const list = pendingOutcomes.get(sessionId) ?? [];
|
|
237
|
+
pendingOutcomes.delete(sessionId);
|
|
238
|
+
return list;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 进程内最近一次请求使用的模型(无则 null;跨进程看 readLastRequestModel)。 */
|
|
242
|
+
export function peekLastRequestModel(sessionId) {
|
|
243
|
+
return sessionId ? lastRequestModels.get(sessionId) ?? null : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 测试钩子:清空查证状态。 */
|
|
247
|
+
export function resetSwitchWatchForTest() {
|
|
248
|
+
switchWatches.clear();
|
|
249
|
+
lastRequestModels.clear();
|
|
250
|
+
pendingOutcomes.clear();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 读会话日志里「最近一次实际请求使用的模型」与「最近一次切换意图」。
|
|
255
|
+
*
|
|
256
|
+
* 数据来源(按可靠性排序):
|
|
257
|
+
* · `request/header` 的 `data.header.config.{provider,model}` —— **请求级权威记录**(判定只看它);
|
|
258
|
+
* · `model/selection` 的 `data.{provider,model}` —— 切换意图(有则一并返回,便于对照)。
|
|
259
|
+
* 用按 zstd 帧流式读(常量内存),大日志也不会把整份文本读进内存。
|
|
260
|
+
*
|
|
261
|
+
* @returns {Promise<{lastRequest: {provider,model,time}|null, lastSelection: {provider,model,time}|null, scanned: number}>}
|
|
262
|
+
*/
|
|
263
|
+
export async function readLastRequestModel(sessionId, root = null) {
|
|
264
|
+
const { sessionsRootOf, findSessionLogById } = await import("../../sessions/events.js");
|
|
265
|
+
const file = findSessionLogById(sessionId, root ?? sessionsRootOf());
|
|
266
|
+
if (!file) return { lastRequest: null, lastSelection: null, scanned: 0 };
|
|
267
|
+
const { streamDecodedFrames } = await import("../../zstd-frames.js");
|
|
268
|
+
const { StringDecoder } = await import("node:string_decoder");
|
|
269
|
+
const buf = readFileSync(file);
|
|
270
|
+
const decoder = new StringDecoder("utf8");
|
|
271
|
+
let pending = "";
|
|
272
|
+
let scanned = 0;
|
|
273
|
+
let lastRequest = null;
|
|
274
|
+
let lastSelection = null;
|
|
275
|
+
const consume = (line) => {
|
|
276
|
+
if (!line) return;
|
|
277
|
+
let event;
|
|
278
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
279
|
+
if (event?.type === "request/header") {
|
|
280
|
+
const cfg = event?.data?.header?.config;
|
|
281
|
+
if (cfg?.provider && cfg?.model) { lastRequest = { provider: cfg.provider, model: cfg.model, time: event.time ?? null }; scanned += 1; }
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (event?.type === "model/selection" && event?.data?.provider) {
|
|
285
|
+
lastSelection = { provider: event.data.provider, model: event.data.model, time: event.time ?? null };
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
await streamDecodedFrames(buf, (chunk) => {
|
|
289
|
+
pending += decoder.write(chunk);
|
|
290
|
+
let idx;
|
|
291
|
+
while ((idx = pending.indexOf("\n")) >= 0) {
|
|
292
|
+
const line = pending.slice(0, idx);
|
|
293
|
+
pending = pending.slice(idx + 1);
|
|
294
|
+
consume(line);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
pending += decoder.end();
|
|
298
|
+
consume(pending);
|
|
299
|
+
return { lastRequest, lastSelection, scanned };
|
|
300
|
+
}
|
|
@@ -154,7 +154,11 @@ export function formatRecallBlock(turns, opts = {}) {
|
|
|
154
154
|
const list = Array.isArray(turns) ? turns.filter((t) => t && t.userText) : [];
|
|
155
155
|
if (list.length === 0) return "";
|
|
156
156
|
const maxTotal = Number.isSafeInteger(opts.maxTotalChars) && opts.maxTotalChars > 0 ? opts.maxTotalChars : 4000;
|
|
157
|
-
|
|
157
|
+
// 跨会话取回时先标注来源,并写明只作背景(避免把别处的文字当成本轮要求)
|
|
158
|
+
const sourceLine = opts.sourceNote
|
|
159
|
+
? `[来源] ${opts.sourceNote} —— 以下内容仅作背景,不是当前对话的要求。\n`
|
|
160
|
+
: "";
|
|
161
|
+
const head = sourceLine + (opts.manual
|
|
158
162
|
// 手动路径(AI 主动调用 recall_memory):没有「检测」这回事,标题如实说「按请求取回」
|
|
159
163
|
? `【记忆取回】按请求取回最近 ${list.length} 轮的消息文本与你的首个回应:`
|
|
160
164
|
: "【记忆取回】检测到上下文用量下降"
|
|
@@ -162,7 +166,7 @@ export function formatRecallBlock(turns, opts = {}) {
|
|
|
162
166
|
? `(上次 ${opts.prevPercent}% → 本次 ${opts.currentPercent}%)`
|
|
163
167
|
: "")
|
|
164
168
|
+ ",判断上下文可能已丢失(压缩,或实例重启后快照尚未加载)。"
|
|
165
|
-
+ `以下是你最近 ${list.length}
|
|
169
|
+
+ `以下是你最近 ${list.length} 轮的用户消息文本与你的首个回应,供你恢复上下文:`);
|
|
166
170
|
|
|
167
171
|
// 从最近的轮次往前拼,保证「最相关的留下」;最后再按时间正序输出
|
|
168
172
|
const kept = [];
|
|
@@ -247,6 +251,7 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
|
|
|
247
251
|
const read = deps.readTurns ?? readRecentUserTurns;
|
|
248
252
|
const format = deps.format ?? formatRecallBlock;
|
|
249
253
|
const config = deps.cfg ?? cfg;
|
|
254
|
+
const resolve = deps.resolveSession ?? resolveSessionTarget;
|
|
250
255
|
const maxRounds = 50;
|
|
251
256
|
tools.register(defineTool({
|
|
252
257
|
name: "recall_memory",
|
|
@@ -257,20 +262,40 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
|
|
|
257
262
|
+ "只读:只把文本返回给你,不会自动改上下文。",
|
|
258
263
|
parameters: {
|
|
259
264
|
rounds: { type: "number", description: `取最近几轮(默认 ${config?.retrieveMemoryRounds ?? 3},上限 ${maxRounds})` },
|
|
265
|
+
session: { type: "string", description: "目标会话:id / id 前缀 / 标题子串;缺省或空 = 取自身(行为与之前一致)" },
|
|
260
266
|
},
|
|
261
267
|
output: TEXT_OUTPUT,
|
|
262
268
|
async execute(args, exec) {
|
|
263
|
-
const
|
|
264
|
-
if (!
|
|
269
|
+
const selfId = exec?.agent?.session?.id;
|
|
270
|
+
if (!selfId) return "拿不到当前会话 id,无法取回记忆。";
|
|
265
271
|
const asked = Number.isSafeInteger(args?.rounds) && args.rounds > 0 ? args.rounds : (config?.retrieveMemoryRounds ?? 3);
|
|
266
272
|
const rounds = Math.min(asked, maxRounds);
|
|
273
|
+
const want = typeof args?.session === "string" ? args.session.trim() : "";
|
|
274
|
+
// 缺省 / 空 / 等于自身 ⇒ 走自身路径,行为与之前逐字一致
|
|
275
|
+
let targetId = selfId;
|
|
276
|
+
let fromOther = false;
|
|
277
|
+
if (want && want !== selfId) {
|
|
278
|
+
const hit = await resolve(ctx, want, selfId);
|
|
279
|
+
if (hit?.candidates) {
|
|
280
|
+
return `「${want}」匹配到多个目标,请用更完整的 id 重试:\n`
|
|
281
|
+
+ hit.candidates.slice(0, 10).map((c) => `- ${c.id}${c.title ? ` | ${c.title}` : ""}`).join("\n");
|
|
282
|
+
}
|
|
283
|
+
if (!hit?.id) return `找不到目标会话「${want}」:没有匹配的会话 id / id 前缀 / 标题。`;
|
|
284
|
+
targetId = hit.id;
|
|
285
|
+
fromOther = true;
|
|
286
|
+
}
|
|
267
287
|
try {
|
|
268
|
-
const turns = await read(ctx,
|
|
288
|
+
const turns = await read(ctx, targetId, {
|
|
269
289
|
rounds,
|
|
270
290
|
maxCharsPerItem: config?.retrieveMemoryMaxCharsPerItem,
|
|
271
291
|
});
|
|
272
|
-
if (!turns || turns.length === 0) return "
|
|
273
|
-
const text = format(turns, {
|
|
292
|
+
if (!turns || turns.length === 0) return "没取到可用的记忆(日志缺失,或该会话还没有用户消息)。";
|
|
293
|
+
const text = format(turns, {
|
|
294
|
+
maxTotalChars: config?.retrieveMemoryMaxChars,
|
|
295
|
+
manual: true,
|
|
296
|
+
// 跨会话时标注来源,并写明只作背景——别把别处的文字当成当前这轮的要求
|
|
297
|
+
...(fromOther ? { sourceNote: `来自另一个会话 ${targetId}` } : {}),
|
|
298
|
+
});
|
|
274
299
|
return text || "没取到可用的记忆。";
|
|
275
300
|
} catch (error) {
|
|
276
301
|
return `取回记忆失败:${String(error?.message ?? error)}`;
|
|
@@ -279,3 +304,46 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
|
|
|
279
304
|
}));
|
|
280
305
|
return true;
|
|
281
306
|
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 解析 `session` 参数:完整 id → id 前缀 → 标题子串;命中多个返回候选清单(不猜)。
|
|
310
|
+
*
|
|
311
|
+
* 只用**便宜的来源**:会话目录名(id 的权威来源)与落盘索引里的标题。
|
|
312
|
+
* 不调用全量列表构建 —— 那是 226 秒级的重活,不该被一个参数解析拖进来。
|
|
313
|
+
*
|
|
314
|
+
* @returns {Promise<{id:string,title?:string|null}|{candidates:Array<{id:string,title?:string|null}>}|null>}
|
|
315
|
+
*/
|
|
316
|
+
export async function resolveSessionTarget(ctx, want, selfId) {
|
|
317
|
+
const { sessionsRootOf } = await import("../../sessions/events.js");
|
|
318
|
+
const { readdirSync, existsSync, statSync } = await import("node:fs");
|
|
319
|
+
const { join } = await import("node:path");
|
|
320
|
+
const root = sessionsRootOf();
|
|
321
|
+
const ids = [];
|
|
322
|
+
if (root && existsSync(root)) {
|
|
323
|
+
for (const proj of readdirSync(root)) {
|
|
324
|
+
const pp = join(root, proj);
|
|
325
|
+
try { if (!statSync(pp).isDirectory()) continue; } catch { continue; }
|
|
326
|
+
for (const dir of readdirSync(pp)) {
|
|
327
|
+
try { if (!statSync(join(pp, dir)).isDirectory()) continue; } catch { continue; }
|
|
328
|
+
if (dir.startsWith("session-") || dir === selfId) ids.push(dir);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (ids.includes(want)) return { id: want };
|
|
333
|
+
const byPrefix = ids.filter((id) => id.startsWith(want));
|
|
334
|
+
if (byPrefix.length === 1) return { id: byPrefix[0] };
|
|
335
|
+
if (byPrefix.length > 1) return { candidates: byPrefix.map((id) => ({ id })) };
|
|
336
|
+
// 标题子串:只在落盘索引里找(避免为了解析一个参数去逐份读日志)
|
|
337
|
+
try {
|
|
338
|
+
const { loadListDiskCache } = await import("../../sessions/list-cache.js");
|
|
339
|
+
let entries = {};
|
|
340
|
+
try { entries = loadListDiskCache(ctx)?.entries ?? {}; } catch { entries = {}; }
|
|
341
|
+
const lower = want.toLowerCase();
|
|
342
|
+
const byTitle = Object.entries(entries)
|
|
343
|
+
.filter(([, e]) => String(e?.title ?? "").toLowerCase().includes(lower))
|
|
344
|
+
.map(([id, e]) => ({ id, title: e?.title ?? null }));
|
|
345
|
+
if (byTitle.length === 1) return byTitle[0];
|
|
346
|
+
if (byTitle.length > 1) return { candidates: byTitle };
|
|
347
|
+
} catch { /* 索引不可用:退化为只按 id 匹配 */ }
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
@@ -26,6 +26,66 @@ const TEXT_OUTPUT = {
|
|
|
26
26
|
|
|
27
27
|
// ── 工具:list_sessions ───────────────────────────────────────────────────
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 刷新路径的「快等待」窗口:先直接等构建这么久,等到了就直接给结果;
|
|
31
|
+
* 等不到再把它交给宿主后台 job(**复用同一份在途 Promise**,不重启构建)。
|
|
32
|
+
*
|
|
33
|
+
* 为什么需要它:冷缓存下全量构建实测 226 秒(201 会话 / 1.5 GB 日志),
|
|
34
|
+
* 工具直接把调用方钉住会让整轮长时间无回应;但热缓存只要 ~47 ms,
|
|
35
|
+
* 一律走 job 又会让常见情况白多一次 job_output 往返。所以按「先等一会儿,超时转 job」处理。
|
|
36
|
+
*/
|
|
37
|
+
const LIST_FAST_WAIT_MS = 3000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 注册宿主后台 job:带 owner 试一次,失败退「无主」再试,仍失败才降级同步。
|
|
41
|
+
*
|
|
42
|
+
* 宿主契约(照 dsh-git-push 的实测结论抄,不要凭猜):
|
|
43
|
+
* · `owner` 必须是**有活 agent 的会话 id 字符串**(`exec.agent.id`),传对象或错的 id 会抛
|
|
44
|
+
* 「session "xxx" has no live agent」;
|
|
45
|
+
* · owner 缺省(unowned)在存在全局控制器时同样可注册,所以带 owner 失败 ≠ 不能后台化;
|
|
46
|
+
* · 两步都失败才同步降级,并把失败原因带回去,便于当场定位(不静默降级)。
|
|
47
|
+
*
|
|
48
|
+
* @returns {{jobId?: string, error?: string}}
|
|
49
|
+
*/
|
|
50
|
+
function startHostJob(jobs, spec, owner) {
|
|
51
|
+
if (!jobs || typeof jobs.start !== "function") return { error: "宿主未挂载 jobs 服务(ctx.get('jobs') 不可用)" };
|
|
52
|
+
const reasons = [];
|
|
53
|
+
if (owner) {
|
|
54
|
+
try { return { jobId: jobs.start({ ...spec, owner }) }; } catch (error) { reasons.push(`带 owner 失败:${(error && error.message) || error}`); }
|
|
55
|
+
}
|
|
56
|
+
try { return { jobId: jobs.start(spec) }; } catch (error) { reasons.push(`无主注册失败:${(error && error.message) || error}`); }
|
|
57
|
+
return { error: reasons.join(" | ") };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 从落盘索引直接组装一份精简列表(不重建索引)。索引不可用时返回 null。 */
|
|
61
|
+
async function readListIndex(ctx) {
|
|
62
|
+
const { loadListDiskCache } = await import("../../sessions/list-cache.js");
|
|
63
|
+
const entries = loadListDiskCache(ctx)?.entries;
|
|
64
|
+
if (!entries || typeof entries !== "object") return null;
|
|
65
|
+
return Object.entries(entries).map(([id, e]) => ({
|
|
66
|
+
id,
|
|
67
|
+
title: e?.title ?? null,
|
|
68
|
+
cwd: e?.cwd ?? null,
|
|
69
|
+
updatedAt: e?.updatedAt ?? e?.createdAt ?? 0,
|
|
70
|
+
parentSession: e?.parentSession ?? null,
|
|
71
|
+
fromIndex: true,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 索引入口(fromIndex=true)用的精简渲染:只给定位所需字段,明确标注来自索引。 */
|
|
76
|
+
function formatIndexList(items, { limit, offset, query }) {
|
|
77
|
+
const q = typeof query === "string" ? query.trim().toLowerCase() : "";
|
|
78
|
+
const filtered = q
|
|
79
|
+
? items.filter((it) => [it.id, it.title ?? "", it.cwd ?? ""].some((v) => String(v).toLowerCase().includes(q)))
|
|
80
|
+
: items;
|
|
81
|
+
const page = filtered.slice(offset, offset + limit);
|
|
82
|
+
const head = `索引直读:命中 ${filtered.length} 条(索引共 ${items.length} 条),本页 ${page.length} 条`
|
|
83
|
+
+ (filtered.length > offset + limit ? `;下一页用 offset=${offset + limit}` : "")
|
|
84
|
+
+ "。⚠️ 这是落盘索引里的记录,不含 live 状态与归档态;要看实时状态请去掉 fromIndex 走一次刷新。";
|
|
85
|
+
const body = page.map((it) => `- ${it.id}${it.title ? ` | ${it.title}` : ""}${it.cwd ? ` | ${it.cwd}` : ""}`).join("\n");
|
|
86
|
+
return body ? `${head}\n${body}` : `${head}\n(无匹配)`;
|
|
87
|
+
}
|
|
88
|
+
|
|
29
89
|
function registerListSessions(ctx, defineTool, tools) {
|
|
30
90
|
tools.register(defineTool({
|
|
31
91
|
name: "list_sessions",
|
|
@@ -36,21 +96,73 @@ function registerListSessions(ctx, defineTool, tools) {
|
|
|
36
96
|
+ "先用 query 按 id/标题/工作区子串收窄,再按回执提示的 offset 翻页。"
|
|
37
97
|
+ "状态含义:running = 正在跑回合;idle = 已挂载但空闲;inactive = 冷会话(未挂载,被投递时会被唤醒)。"
|
|
38
98
|
+ "用法:先调它拿目标 id,再把 id 原样传给 send_to_session —— 不要凭记忆或标题猜 id。"
|
|
39
|
-
+ "缺 id 的脏数据会被自动跳过并如实标注。"
|
|
99
|
+
+ "缺 id 的脏数据会被自动跳过并如实标注。"
|
|
100
|
+
+ `**慢路径会注册宿主后台 job**:冷缓存时全量构建很慢(实测 226 秒),`
|
|
101
|
+
+ `所以等过 ${LIST_FAST_WAIT_MS / 1000} 秒还没好就转后台 job 并立即回 jobId,`
|
|
102
|
+
+ "用宿主 job_output 取正式列表(前台不再被钉住)。"
|
|
103
|
+
+ "**想立刻拿一份精简列表可用 fromIndex=true**:直接读落盘索引,不触发重建(很适合「先看有哪些会话、拿 id」这一步)。",
|
|
40
104
|
parameters: {
|
|
41
105
|
query: { type: "string", description: "按 id / 标题 / 工作区子串过滤(不区分大小写),会话多时先用它收窄" },
|
|
42
106
|
limit: { type: "number", description: `本页最多返回多少条(默认 ${LIST_DEFAULT_LIMIT},上限 ${LIST_MAX_LIMIT})` },
|
|
43
107
|
offset: { type: "number", description: "跳过前多少条(默认 0;回执里会给出下一页该用的 offset)" },
|
|
108
|
+
fromIndex: { type: "boolean", description: "true = 直接读落盘索引(秒回,不含 live 状态),不触发重建;缺省 = 刷新(可能等很久,超时自动转后台 job)" },
|
|
44
109
|
},
|
|
45
110
|
output: TEXT_OUTPUT,
|
|
46
111
|
async execute(args, exec) {
|
|
47
112
|
try {
|
|
48
|
-
const items = await listSessions(ctx);
|
|
49
113
|
const limit = Number.isSafeInteger(args?.limit) && args.limit > 0
|
|
50
114
|
? Math.min(args.limit, LIST_MAX_LIMIT)
|
|
51
115
|
: LIST_DEFAULT_LIMIT;
|
|
52
116
|
const offset = Number.isSafeInteger(args?.offset) && args.offset > 0 ? args.offset : 0;
|
|
53
|
-
|
|
117
|
+
const currentId = currentSessionId(exec?.agent);
|
|
118
|
+
|
|
119
|
+
// ① 索引直读:快路径,不重建索引
|
|
120
|
+
if (args?.fromIndex === true) {
|
|
121
|
+
const items = await readListIndex(ctx);
|
|
122
|
+
if (!items) return "索引直读不可用:落盘索引为空或尚未建立。请去掉 fromIndex 走一次刷新(会重建索引)。";
|
|
123
|
+
return formatIndexList(items, { limit, offset, query: args?.query });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ② 刷新路径:构建一次(build 是唯一那份在途 Promise,后面转 job 时复用它,不重启构建)
|
|
127
|
+
const build = (async () => {
|
|
128
|
+
const items = await listSessions(ctx);
|
|
129
|
+
// 本次查询结束后让面板随后的刷新能看到最新结果:失效 TTL + 确保索引落盘。
|
|
130
|
+
// 面板交互本身维持原样(仍走 /list 的 NDJSON 流式),这里只保证它下一次取到的是新数据。
|
|
131
|
+
try {
|
|
132
|
+
const { invalidateSessionListCache, saveListDiskCacheNow } = await import("../../sessions/list-cache.js");
|
|
133
|
+
invalidateSessionListCache();
|
|
134
|
+
saveListDiskCacheNow(ctx);
|
|
135
|
+
} catch { /* 面板更新是尽力而为,不影响工具结果 */ }
|
|
136
|
+
return formatSessionList(items, currentId, { limit, offset, query: args?.query });
|
|
137
|
+
})();
|
|
138
|
+
|
|
139
|
+
const timer = new Promise((resolve) => {
|
|
140
|
+
const t = setTimeout(() => resolve(null), LIST_FAST_WAIT_MS);
|
|
141
|
+
t.unref?.();
|
|
142
|
+
});
|
|
143
|
+
const quick = await Promise.race([build, timer]);
|
|
144
|
+
if (quick !== null) return quick; // 快路径:直接给结果
|
|
145
|
+
|
|
146
|
+
// ③ 慢路径:交给宿主后台 job(done 直接 await 上面那份 build)
|
|
147
|
+
const jobs = ctx?.get?.("jobs");
|
|
148
|
+
const started = startHostJob(jobs, {
|
|
149
|
+
kind: "session-conductor-list",
|
|
150
|
+
label: "list_sessions 刷新",
|
|
151
|
+
run: () => {
|
|
152
|
+
const done = build.then(
|
|
153
|
+
(output) => ({ status: "completed", output }),
|
|
154
|
+
(error) => ({ status: "failed", detail: shortError(error) }),
|
|
155
|
+
);
|
|
156
|
+
return { cancel: () => {}, done };
|
|
157
|
+
},
|
|
158
|
+
}, exec?.agent?.id);
|
|
159
|
+
if (started.jobId) {
|
|
160
|
+
return `已注册宿主后台 job(正在后台列出,完成会自动返回);如需主动查:job_output ${started.jobId}`
|
|
161
|
+
+ "(想立刻拿一份精简列表可改用 fromIndex=true)";
|
|
162
|
+
}
|
|
163
|
+
// ④ 连 job 都注册不上:退回同步执行,并把原因带回(不静默降级)
|
|
164
|
+
const output = await build;
|
|
165
|
+
return `${output}\n\n(注:宿主后台 job 不可用,已同步执行。原因:${started.error})`;
|
|
54
166
|
} catch (error) {
|
|
55
167
|
return `列出会话失败:${shortError(error)}`;
|
|
56
168
|
}
|
package/lib/repair.js
CHANGED
|
@@ -185,7 +185,27 @@ export async function encodeSessionText(text) {
|
|
|
185
185
|
export async function repairCorruptSessions(ctx, { dryRun = false } = {}) {
|
|
186
186
|
const persistence = ctx.get("sessionPersistence");
|
|
187
187
|
if (!persistence?.listArtifacts || !persistence?.readRaw) {
|
|
188
|
-
|
|
188
|
+
// ── 0.2.x 兜底:退回 zstd 帧修复 ─────────────────────────────────────────
|
|
189
|
+
// 内容修复依赖 0.1.x 的 listArtifacts/readRaw,0.2.x 起这两个方法已不存在 ⇒
|
|
190
|
+
// 原实现直接返回 error;而面板「扫描/修复损坏会话」按钮打的正是本函数
|
|
191
|
+
// ⇒ 用户明明有 zstd 帧损坏(会话列表消失、加载报 corrupt Zstandard),面板却扫不出来、只显示无损坏。
|
|
192
|
+
// 因此:内容路径不可用时改用帧修复(同一「损坏会话」语义,且它只依赖文件系统,实测可用)。
|
|
193
|
+
const frames = dryRun ? await scanCorruptFrames() : await repairCorruptFrames({ dryRun: false });
|
|
194
|
+
return {
|
|
195
|
+
framesFallback: true,
|
|
196
|
+
scanned: frames.total ?? frames.scanned ?? 0,
|
|
197
|
+
healthy: frames.healthy ?? 0,
|
|
198
|
+
corrupt: typeof frames.corrupt === "number" ? frames.corrupt : (frames.corrupt?.length ?? 0),
|
|
199
|
+
fixed: frames.fixed ?? 0,
|
|
200
|
+
failed: frames.failed ?? 0,
|
|
201
|
+
sessions: (frames.corruptList ?? frames.corrupt ?? []).map((c) => ({
|
|
202
|
+
id: typeof c === "string" ? c : c.id,
|
|
203
|
+
problems: [String(typeof c === "string" ? "帧损坏" : (c.error ?? "帧损坏")).slice(0, 140)],
|
|
204
|
+
})),
|
|
205
|
+
errors: frames.errors ?? [],
|
|
206
|
+
backups: [],
|
|
207
|
+
note: "当前 DSH 的持久化后端不提供 listArtifacts/readRaw(0.2.x),已按 zstd 帧损坏扫描/修复。",
|
|
208
|
+
};
|
|
189
209
|
}
|
|
190
210
|
const sessionsSvc = ctx.get("sessions");
|
|
191
211
|
const liveIds = new Set((sessionsSvc?.list() ?? []).map((s) => s.id));
|
|
@@ -274,10 +294,15 @@ export async function scanCorruptSessions(ctx) {
|
|
|
274
294
|
return repairCorruptSessions(ctx, { dryRun: true });
|
|
275
295
|
}
|
|
276
296
|
|
|
277
|
-
/**
|
|
297
|
+
/** 当前实例是否可做会话修复。 */
|
|
278
298
|
export function supportsRepair(ctx) {
|
|
279
299
|
const persistence = ctx.get("sessionPersistence");
|
|
280
|
-
|
|
300
|
+
if (persistence !== void 0
|
|
301
|
+
&& typeof persistence?.listArtifacts === "function"
|
|
302
|
+
&& typeof persistence?.readRaw === "function") return true; // 0.1.x:内容修复路径可用
|
|
303
|
+
// 0.2.x:listArtifacts/readRaw 已移除 ⇒ 改按「磁盘上有 jsonl.zstd 会话日志」判定
|
|
304
|
+
// (帧修复只依赖文件系统)。历史判据在 0.2.x 恒为 false,会让面板的扫描按钮拿到 501「不支持」。
|
|
305
|
+
try { return existsSync(sessionsRootOf()); } catch { return false; }
|
|
281
306
|
}
|
|
282
307
|
|
|
283
308
|
// ==================== zstd 帧修复(救援恢复完善,) ====================
|
package/lib/sessions/events.js
CHANGED
|
@@ -119,7 +119,7 @@ export async function readColdSessionEvents(ctx, sessionId) {
|
|
|
119
119
|
// ---------- 按 zstd 帧流式读(列表专用,常量内存)----------
|
|
120
120
|
|
|
121
121
|
/** 会话日志根目录:`<DSH_HOME>/sessions`。 */
|
|
122
|
-
function sessionsRootOf() {
|
|
122
|
+
export function sessionsRootOf() {
|
|
123
123
|
const dshHome = process.env.DSH_HOME || join(process.env.HOME ?? "", ".dsh");
|
|
124
124
|
return join(dshHome, "sessions");
|
|
125
125
|
}
|
package/lib/zstd-frames.js
CHANGED
|
@@ -148,14 +148,23 @@ export function validateHeaderFrame(buf) {
|
|
|
148
148
|
* @returns {{ok:boolean, id?:string, eventCount?:number, fixed?:boolean, error?:string}}
|
|
149
149
|
*/
|
|
150
150
|
export async function fixZstdFile(path, backupDir = join(dirname(path), '.zstd-fix-backup')) {
|
|
151
|
+
const origBuf = readFileSync(path);
|
|
151
152
|
// 先校验:本身已合规则跳过(避免无谓重写)
|
|
152
|
-
const check = await validateHeaderFrame(
|
|
153
|
+
const check = await validateHeaderFrame(origBuf);
|
|
153
154
|
if (check.ok) return { ok: true, id: check.header.id, eventCount: null, fixed: false, note: '已合规' };
|
|
155
|
+
|
|
156
|
+
// ── 帧级损坏(bad magic / 末帧内容坏)⇒ 截断到「最长的可解码前缀」────────────
|
|
157
|
+
// 为什么必须单独处理:本机最常见的两种损坏是「尾部垃圾」(进程在写入中途被杀,
|
|
158
|
+
// 最后一次 append 留下半截数据)与「末帧内容坏」(帧头正常但 zstd 载荷校验失败),
|
|
159
|
+
// 这两种情况下前面成百上千帧往往**完好**。原实现一遇 bad magic 就直接返回失败
|
|
160
|
+
// ⇒ 等于修不了(线上实测:`fixZstdFile` 对一份 4798 帧、只有末帧坏 + 尾部 7KB 垃圾的
|
|
161
|
+
// 日志报 `bad magic at 11649807` 后放弃)。
|
|
154
162
|
if (!check.error?.includes('corrupt') && !check.error?.includes('header')) {
|
|
163
|
+
const truncated = await truncateToDecodablePrefix(path, origBuf, check.error, backupDir);
|
|
164
|
+
if (truncated) return truncated;
|
|
155
165
|
return { ok: false, error: check.error };
|
|
156
166
|
}
|
|
157
167
|
|
|
158
|
-
const origBuf = readFileSync(path);
|
|
159
168
|
let text;
|
|
160
169
|
try { text = await decodeAllFrames(origBuf); } catch (e) {
|
|
161
170
|
return { ok: false, error: '帧解码失败: ' + e.message };
|
|
@@ -183,6 +192,72 @@ export async function fixZstdFile(path, backupDir = join(dirname(path), '.zstd-f
|
|
|
183
192
|
return { ok: true, id: header.id, eventCount: events.length, fixed: true, backup: bak, origSize: origBuf.length, newSize: newBuf.length };
|
|
184
193
|
}
|
|
185
194
|
|
|
195
|
+
/**
|
|
196
|
+
* 把「帧级损坏」的日志截断到**最长的可解码前缀**(保留原始帧,不重编码)。
|
|
197
|
+
*
|
|
198
|
+
* 步骤:① 取帧表 —— `scanZstdFrames` 遇坏 magic 会抛错(错误信息含偏移),
|
|
199
|
+
* 据此退回到该偏移之前的前缀再扫一次;② **逐帧解码**,停在第一帧解不开之前,
|
|
200
|
+
* 那个位置就是安全截断点;③ 截断后首帧必须仍是「恰好一行 header」。
|
|
201
|
+
*
|
|
202
|
+
* 返回 null 表示「不是截断能解的损坏形态」,交给调用方按原错误返回。
|
|
203
|
+
*
|
|
204
|
+
* @param {string} path 日志文件绝对路径
|
|
205
|
+
* @param {Buffer} buf 该文件当前内容
|
|
206
|
+
* @param {string} [originalError] 触发修复的原始错误(写进返回值便于排查)
|
|
207
|
+
* @param {string} backupDir 备份目录
|
|
208
|
+
*/
|
|
209
|
+
async function truncateToDecodablePrefix(path, buf, originalError, backupDir) {
|
|
210
|
+
let frames = null;
|
|
211
|
+
try {
|
|
212
|
+
({ frames } = scanZstdFrames(buf));
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const at = /bad magic at (\d+)/.exec(String(error?.message ?? ''));
|
|
215
|
+
const cut = at ? Number(at[1]) : 0;
|
|
216
|
+
if (!(cut > 0)) return null; // 连开头都不是合法帧 ⇒ 截断无意义
|
|
217
|
+
try { ({ frames } = scanZstdFrames(buf.subarray(0, cut))); } catch { return null; }
|
|
218
|
+
}
|
|
219
|
+
if (!frames || frames.length === 0) return null;
|
|
220
|
+
|
|
221
|
+
let safeEnd = 0;
|
|
222
|
+
let dropped = null;
|
|
223
|
+
for (const [i, fr] of frames.entries()) {
|
|
224
|
+
try {
|
|
225
|
+
const plain = await decodeFrame(buf.subarray(fr.start, fr.end));
|
|
226
|
+
if (!plain || plain.length === 0) throw new Error('解出空内容');
|
|
227
|
+
} catch (error) {
|
|
228
|
+
dropped = { index: i, reason: String(error?.message ?? error) };
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
safeEnd = fr.end;
|
|
232
|
+
}
|
|
233
|
+
if (!dropped || safeEnd < 4) return null; // 没找到坏帧 ⇒ 不是本函数负责的形态
|
|
234
|
+
|
|
235
|
+
const head = await validateHeaderFrame(buf.subarray(0, safeEnd));
|
|
236
|
+
if (!head.ok) return null;
|
|
237
|
+
|
|
238
|
+
mkdirSync(backupDir, { recursive: true });
|
|
239
|
+
const rel = path.replace(/^.*\/sessions\//, '').replace(/\//g, '__');
|
|
240
|
+
const bak = join(backupDir, rel);
|
|
241
|
+
if (!existsSync(bak)) copyFileSync(path, bak);
|
|
242
|
+
const tmp = path + '.fixtmp';
|
|
243
|
+
writeFileSync(tmp, buf.subarray(0, safeEnd));
|
|
244
|
+
try { chmodSync(tmp, statSync(path).mode); } catch { /* 尽力 */ }
|
|
245
|
+
renameSync(tmp, path);
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
id: head.header.id,
|
|
249
|
+
fixed: true,
|
|
250
|
+
mode: 'truncate',
|
|
251
|
+
origSize: buf.length,
|
|
252
|
+
newSize: safeEnd,
|
|
253
|
+
droppedFrame: dropped.index,
|
|
254
|
+
droppedBytes: buf.length - safeEnd,
|
|
255
|
+
droppedReason: dropped.reason,
|
|
256
|
+
originalError,
|
|
257
|
+
backup: bak,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
186
261
|
/**
|
|
187
262
|
* 扫描会话目录下全部 session.jsonl.zstd,找出帧损坏的文件。
|
|
188
263
|
* @param sessionsRoot 会话根目录(如 <dshHome>/sessions)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-conductor",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
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",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"lib",
|
|
22
|
+
"scripts",
|
|
22
23
|
"skills",
|
|
23
24
|
"cordis.patch.yml"
|
|
24
25
|
],
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 复核「成员模型切换是否生效」——**只认请求级权威记录**。
|
|
3
|
+
//
|
|
4
|
+
// 用法:
|
|
5
|
+
// node scripts/check-member-model.mjs <sessionId>
|
|
6
|
+
//
|
|
7
|
+
// 输出:该会话最近一次实际请求使用的 provider/model(来自 `request/header`)+
|
|
8
|
+
// 最近一次切换意图(来自 `model/selection`),两者对照即可判断「切没切成」。
|
|
9
|
+
//
|
|
10
|
+
// 【为什么以 request/header 为准】上下文里注入的 `[model changed: …]` 通知与 `model/selection`
|
|
11
|
+
// 事件都只表达「意图」;实测出现过「提示是 agnes、请求头仍是 wb」的不一致 ——
|
|
12
|
+
// 只有请求时**真实使用**的 provider/model 算数。
|
|
13
|
+
import { readLastRequestModel } from "../lib/features/member-model/switch.js";
|
|
14
|
+
|
|
15
|
+
const sessionId = process.argv[2];
|
|
16
|
+
if (!sessionId) {
|
|
17
|
+
console.error("用法:node scripts/check-member-model.mjs <sessionId>");
|
|
18
|
+
process.exit(2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { lastRequest, lastSelection, scanned } = await readLastRequestModel(sessionId);
|
|
22
|
+
if (!lastRequest) {
|
|
23
|
+
console.log(`会话 ${sessionId}:日志里没有 request/header 记录(该会话可能从未发起过请求)`);
|
|
24
|
+
} else {
|
|
25
|
+
const when = lastRequest.time ? new Date(lastRequest.time).toLocaleString("zh-CN") : "时间未知";
|
|
26
|
+
console.log(`最近一次实际请求:${lastRequest.provider}/${lastRequest.model}(${when})`);
|
|
27
|
+
}
|
|
28
|
+
if (lastSelection) {
|
|
29
|
+
console.log(`最近一次切换意图:${lastSelection.provider}/${lastSelection.model}`);
|
|
30
|
+
}
|
|
31
|
+
console.log(`(共扫描 ${scanned} 条 request/header;判定只看「最近一次实际请求」)`);
|
|
@@ -136,7 +136,8 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
|
|
|
136
136
|
- **同时仍写 `model/selection` 事件**:让官方 `agent/pre-step` 追加 `[model changed: …]` 通知(可观测痕迹)。
|
|
137
137
|
- **成员状态**:未挂载(inactive)**也能切**——override 落 domain;若会话日志里还没有该选择,会把事件直接写进会话日志(`appendSelectionEventToLog`,解码 → 追加 → 校验 → 备份 → 原子写回)。正在执行回合时会**先等它暂停**(`whenIdle`,最长 60s)再切。
|
|
138
138
|
- **验收方式(重要)**:
|
|
139
|
-
- ✅
|
|
139
|
+
- ✅ **现在不用自己查了**:`set_member_model` 的返回值**直接带查证**——立刻给「最近一次实际请求的 provider/model + 最近一次切换意图 + 一行判定」,并**注册查证任务(不阻塞)**:该成员下次发起请求时由 `agent/request` 事件判定(插件本来就拦这个事件改模型),确认结果在下一次 step 注入给发起方。想要更细的自查,用返回值里附的复核命令 `node scripts/check-member-model.mjs <sessionId>`。
|
|
140
|
+
- ✅ **请求级权威记录**:`request/header` 的 `data.header.config.{provider,model}` —— 注意是**两层**(`data.header.config`,不是 `data.config`);**连续多轮都应是新模型**(这是「跨轮持久」的唯一可靠证据)。
|
|
140
141
|
- ✅ 会话 `model/selection` 事件(切换意图落盘)。
|
|
141
142
|
- ✅ projcache `modelSelection.lastUsed` / `pending`(官方投影状态)。
|
|
142
143
|
- ⚠️ **不要**只用 `model/selection` 事件或会话内注入的 `[model changed: …]` 提示下结论——实测出现过「提示是 agnes、请求头仍是 wb」的不一致。
|