dsh-session-conductor 1.2.2 → 1.3.1
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 -4
- package/lib/apply.js +128 -9
- package/lib/core/config.js +7 -0
- 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 +349 -0
- package/lib/features/session-messaging/tools.js +115 -3
- package/lib/repair.js +28 -3
- package/lib/sessions/events.js +104 -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 +33 -1
package/README.md
CHANGED
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
| 压缩模型选择 | 上下文自动压缩用模型独立选择(省成本) | 设置 → 插件 → 压缩模型 |
|
|
22
22
|
| 模板注入 | 方案模板 / 收尾模板:本地设备上传 / 在线网址 / DSH 目录浏览三种导入,内容可再编辑,注入系统提示词(方案/收尾各一条上下文注入) | 设置 → 会话管理 → 模板注入 |
|
|
23
23
|
| 自动重命名模型 | 指定便宜/专用模型做标题分析 | 设置 → 会话管理 |
|
|
24
|
-
| 成员模型切换 | 给队友/子代理单独切模型(只动该成员会话,不影响其他成员):先用 `list_models` 拿确切模型 id,再 `set_member_model` 按成员名/sessionId 切换(写 `model/selection`
|
|
25
|
-
|
|
|
26
|
-
|
|
|
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` |
|
|
27
28
|
| 指定模型新建会话 | AI 新建会话并可**直接指定 provider/model**(建完即切到该模型),可选带一条初始消息让它立刻开始干活。**缺省工作目录 = 调用方会话自己的 cwd**(不落在 DSH 安装目录里)。本体 `create` 本身不带模型,所以是 `create` → `selectModel` 两步;任一步失败会如实回执(如「会话已建,但模型未设置」),不谎报成功。**支持 `dryRun` 预览**(含模型是否存在于目录的核对),不建会话 | AI 工具 `create_session` |
|
|
28
29
|
|
|
29
30
|
## 界面预览
|
|
@@ -147,7 +148,7 @@ allowBuilds:
|
|
|
147
148
|
|
|
148
149
|
| 端点 | 方法 | 用途 |
|
|
149
150
|
|---|---|---|
|
|
150
|
-
| `/list` | GET | 会话列表(标题/目录/时间/状态,落盘缓存按 revision
|
|
151
|
+
| `/list` | GET | 会话列表(标题/目录/时间/状态,落盘缓存按 revision 增量)。加 `?job=1` 或请求头 `Accept: application/x-job` 改为注册宿主后台 job 并即刻返回 jobId(构建慢时不阻塞请求;宿主无 jobs 服务时返回 501,请求方回落到默认分支) |
|
|
151
152
|
| `/archive` / `/restore` | POST | 归档 / 恢复会话 |
|
|
152
153
|
| `/delete` | POST | 删除会话(拒绝运行中) |
|
|
153
154
|
| `/undo` | POST | 撤回最后一条消息 |
|
package/lib/apply.js
CHANGED
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
maybeScheduleContinue, runAutoContinueSession, runAutoScan, scheduleScan, AUTO_CONTINUE_SCAN_DELAY_MS,
|
|
29
29
|
} from "./features/continue/scan.js";
|
|
30
30
|
import { num } from "./shared/util.js";
|
|
31
|
-
import { registerContextUsageTool, createContextUsageInjector } from "./features/context-usage/index.js";
|
|
31
|
+
import { registerContextUsageTool, createContextUsageInjector, readContextUsage } from "./features/context-usage/index.js";
|
|
32
|
+
import { buildMemoryRecall } from "./features/memory-recall/index.js";
|
|
32
33
|
import { registerSessionMessagingTools } from "./features/session-messaging/index.js";
|
|
34
|
+
import { registerMemoryRecallTool } from "./features/memory-recall/index.js";
|
|
33
35
|
import { registerGroupRoutes } from "./group.js";
|
|
34
36
|
import { saveTemplate, saveTemplateFromUrl, saveTemplateFromPath, listTemplateDir, removeTemplate, readTemplateSync, readCompletionConventionSync, collectTemplateSlotText, planGateAllows, planEnforceDenyMessage, TEMPLATE_DEFAULTS, TEMPLATE_SLOTS, TEMPLATE_MAX_BYTES, PLAN_ENFORCE_EDIT_TOOLS, PLAN_ENFORCE_BASH_WRITE_RE } from "./template-inject.js";
|
|
35
37
|
import { name } from "./shared/constants.js";
|
|
@@ -51,7 +53,8 @@ import { SEARCH_MIN_QUERY, searchSessions } from './features/search.js';
|
|
|
51
53
|
// 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
|
|
52
54
|
// applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
|
|
53
55
|
// 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
|
|
54
|
-
import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOverride, validateModelPair
|
|
56
|
+
import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOverride, validateModelPair,
|
|
57
|
+
registerSwitchWatch, noteRequestModel, takeSwitchOutcomes, readLastRequestModel } from './features/member-model/index.js';
|
|
55
58
|
// 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
|
|
56
59
|
import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
|
|
57
60
|
import { installProcessGuards } from './guards.js';
|
|
@@ -268,6 +271,33 @@ export async function apply(ctx, config = {}) {
|
|
|
268
271
|
try {
|
|
269
272
|
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
270
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
|
+
}
|
|
271
301
|
// list 流式(仅当请求声明 Accept: application/x-ndjson)——live 会话先快速
|
|
272
302
|
// 写出,冷会话串行逐个解析边写边出,前端 fetch stream 逐行追加(每获取一个立即显示,
|
|
273
303
|
// 不等全部);排序由前端按 updatedAt 倒序插入保持(流式下后端不再整体排序)。
|
|
@@ -1237,7 +1267,15 @@ export async function apply(ctx, config = {}) {
|
|
|
1237
1267
|
ctx.on("agent/request", async ({ agent }, next) => {
|
|
1238
1268
|
const resolved = await next();
|
|
1239
1269
|
const override = await getMemberModelOverride(ctx, agent?.session?.id);
|
|
1240
|
-
|
|
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;
|
|
1241
1279
|
});
|
|
1242
1280
|
// createUserMessage 来自宿主包:动态导入 + 守卫。
|
|
1243
1281
|
// 为什么必须判类型:工作区自测环境没有 @deepseek-ai/* 依赖,直接引用标识符会抛
|
|
@@ -1287,18 +1325,56 @@ export async function apply(ctx, config = {}) {
|
|
|
1287
1325
|
// 多条重复的用量行(噪音 + 白烧 token)。
|
|
1288
1326
|
// · 默认开启(core/config.js 的 injectContextUsage);关掉即退化为「按需调用 context_usage 工具」。
|
|
1289
1327
|
// · 读不到投影/服务未挂载时注入器返回空串 ⇒ 静默跳过,绝不影响正常回合。
|
|
1290
|
-
|
|
1328
|
+
// 三个开关任一开启就需要本 hook(用量注入 / 记忆取回 / 切换查证回报);turn 门控保证一回合只注入一次。
|
|
1329
|
+
// 固定 enabled=true:切换查证的结果是一句话回报,不能因为前两个功能关了就不送。
|
|
1330
|
+
const usageInjector = createContextUsageInjector({ enabled: true });
|
|
1291
1331
|
ctx.on("agent/pre-step", async ({ agent, turn, signal }, next) => {
|
|
1292
1332
|
const decision = await next();
|
|
1293
1333
|
if (decision?.kind === "reject" || signal?.aborted) return decision;
|
|
1294
1334
|
// 与模板注入同款守卫:工作区自测环境没有 @deepseek-ai/* 依赖,createUserMessage 会是 null
|
|
1295
1335
|
if (typeof createUserMessage !== "function") return decision;
|
|
1296
1336
|
if (!usageInjector.shouldInject(agent, turn)) return decision;
|
|
1297
|
-
const usageText = usageInjector.textFor(ctx, agent);
|
|
1298
|
-
|
|
1337
|
+
const usageText = cfg?.injectContextUsage === false ? "" : usageInjector.textFor(ctx, agent);
|
|
1338
|
+
// 记忆取回:本回合用量比上次明显下降 ⇒ 判定上下文疑似丢失,取回最近几轮消息文本 + AI 首条回应。
|
|
1339
|
+
// 与用量注入**合并成同一条消息**(一回合只注入一条,避免叠加);阈值/轮数/上限见 cfg。
|
|
1340
|
+
let recallText = null;
|
|
1341
|
+
if (cfg?.retrieveMemory !== false) {
|
|
1342
|
+
try {
|
|
1343
|
+
const snapshot = readContextUsage(ctx, agent);
|
|
1344
|
+
recallText = await buildMemoryRecall(ctx, {
|
|
1345
|
+
sessionKey: agent,
|
|
1346
|
+
sessionId: agent?.session?.id,
|
|
1347
|
+
current: snapshot?.ok ? snapshot.occupancy : null,
|
|
1348
|
+
opts: {
|
|
1349
|
+
rounds: cfg?.retrieveMemoryRounds,
|
|
1350
|
+
minDropRatio: cfg?.retrieveMemoryMinDropRatio,
|
|
1351
|
+
minDropTokens: cfg?.retrieveMemoryMinDropTokens,
|
|
1352
|
+
maxTotalChars: cfg?.retrieveMemoryMaxChars,
|
|
1353
|
+
maxCharsPerItem: cfg?.retrieveMemoryMaxCharsPerItem,
|
|
1354
|
+
},
|
|
1355
|
+
});
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
log(ctx, `记忆取回失败(跳过本次): ${String(error?.message ?? error)}`);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
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");
|
|
1374
|
+
if (!text) return decision;
|
|
1299
1375
|
try {
|
|
1300
1376
|
const injected = createUserMessage({
|
|
1301
|
-
content: [{ type: "text", text
|
|
1377
|
+
content: [{ type: "text", text }],
|
|
1302
1378
|
source: { kind: "plugin:dsh-session-conductor", plugin: "dsh-session-conductor", form: "instructions" },
|
|
1303
1379
|
});
|
|
1304
1380
|
return { ...decision, messages: [...(decision.messages || []), injected] };
|
|
@@ -1365,7 +1441,7 @@ export async function apply(ctx, config = {}) {
|
|
|
1365
1441
|
return [{ type: "text", text: String(value) }];
|
|
1366
1442
|
}
|
|
1367
1443
|
},
|
|
1368
|
-
async execute(args) {
|
|
1444
|
+
async execute(args, exec) {
|
|
1369
1445
|
const provider = String(args?.provider ?? "").trim();
|
|
1370
1446
|
const model = String(args?.model ?? "").trim();
|
|
1371
1447
|
if (!provider || !model) return "切换失败:provider 和 model 都必填(先用 list_models 查确切值)";
|
|
@@ -1377,7 +1453,42 @@ export async function apply(ctx, config = {}) {
|
|
|
1377
1453
|
const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
|
|
1378
1454
|
if (switchResult.error) return `切换失败:${switchResult.error}`;
|
|
1379
1455
|
const memberLabel = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
|
|
1380
|
-
|
|
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");
|
|
1381
1492
|
}
|
|
1382
1493
|
}));
|
|
1383
1494
|
// 上下文用量自查工具:让 AI 自己按需查占用与构成,而不是每轮把数值注入系统提示词
|
|
@@ -1394,6 +1505,14 @@ export async function apply(ctx, config = {}) {
|
|
|
1394
1505
|
} catch (error) {
|
|
1395
1506
|
log(ctx, `跨会话工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
1396
1507
|
}
|
|
1508
|
+
try {
|
|
1509
|
+
// 记忆取回的手动通道:让 AI 自己按需回溯(自动注入之外的补充)
|
|
1510
|
+
if (registerMemoryRecallTool(ctx, defineTool, tools)) {
|
|
1511
|
+
log(ctx, "记忆取回工具已注册 (recall_memory)");
|
|
1512
|
+
}
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
log(ctx, `记忆取回工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
1515
|
+
}
|
|
1397
1516
|
log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
|
|
1398
1517
|
});
|
|
1399
1518
|
}
|
package/lib/core/config.js
CHANGED
|
@@ -24,6 +24,13 @@ export const DEFAULTS = {
|
|
|
24
24
|
turnTimeoutMs: 20 * 60 * 1000, // 单回合续跑最长等待,超时取消
|
|
25
25
|
scanIntervalMs: 5 * 60 * 1000, // 周期扫描间隔
|
|
26
26
|
injectContextUsage: true, // 每轮把上下文用量注入消息尾部(默认开;关掉则只有 context_usage 工具按需查)
|
|
27
|
+
// ── 记忆取回(memory-recall):检测到上下文疑似丢失时,把最近几轮的用户消息文本 + AI 首条回应重新注入 ──
|
|
28
|
+
retrieveMemory: true, // 总开关(默认开)
|
|
29
|
+
retrieveMemoryRounds: 3, // 取回最近几轮
|
|
30
|
+
retrieveMemoryMinDropRatio: 0.10, // 触发阈值:已用量下降比例(0.10 = 10%)
|
|
31
|
+
retrieveMemoryMinDropTokens: 0, // 触发阈值:下降绝对量(0 = 不限;两者满足其一即触发)
|
|
32
|
+
retrieveMemoryMaxChars: 4000, // 注入总长上限(字符)
|
|
33
|
+
retrieveMemoryMaxCharsPerItem: 800, // 单条(用户消息文本 / AI 首条回应)上限(字符)
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
/** 运行期配置(面板/测试可改)。外部只读引用;改整体请用 setConfig。 */
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// dsh-session-conductor — 记忆取回(memory-recall)
|
|
2
|
+
//
|
|
3
|
+
// 【解决什么】DSH 上下文被压缩、或重启后快照尚未加载时,AI 会「失忆」:重复提问、
|
|
4
|
+
// 重复劳动、误解需求。而用户消息文本在上下文里占比极小(1M 上下文里可能总共没超过 3 句),
|
|
5
|
+
// 却恰恰是最不该丢的部分 —— 本模块把这些消息文本(连同 AI 当时的首个回应)重新注入。
|
|
6
|
+
//
|
|
7
|
+
// 【怎么判「失忆」】每回合比较上下文已用量:本次比上次**明显下降**(默认 ≥10%,可配)即判定。
|
|
8
|
+
// 为什么不直接监测压缩:用量下降能覆盖所有「失去上下文」的形态 —— 压缩只是最常见一种;
|
|
9
|
+
// 例如实例重启后快照未加载时用量会掉到 0,那同样该按失忆处理。
|
|
10
|
+
//
|
|
11
|
+
// 【取什么】最近 N 轮(默认 3)的**用户消息文本** + 该轮 **AI 的第一条回应**(优先 reasoning)。
|
|
12
|
+
// · 以用户消息为锚:消息文本占比小但信息密度最高,是恢复上下文的支点;
|
|
13
|
+
// · 要 AI 的第一条回应:那是「当时」对消息文本的理解,直接给出结论,省得 AI 重新推测。
|
|
14
|
+
// 具体读取(按 zstd 帧流式、常量内存)在 sessions/events.js 的 readRecentUserTurns。
|
|
15
|
+
//
|
|
16
|
+
// 【怎么注入】走 apply.js 的 agent/pre-step + 官方 createUserMessage(logged 通道),
|
|
17
|
+
// 放在消息**尾部**(前缀保持稳定 ⇒ prompt cache 仍命中),与每轮用量注入**合并成一条**。
|
|
18
|
+
//
|
|
19
|
+
// 【失败即静默】日志缺失/损坏、用量读不到、首回合无上次用量 —— 一律返回 null,绝不阻断回合。
|
|
20
|
+
import { readRecentUserTurns } from "../../sessions/events.js";
|
|
21
|
+
import { log } from "../../shared/log.js";
|
|
22
|
+
import { cfg } from "../../core/config.js";
|
|
23
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
|
|
26
|
+
// ── 用量记忆的**持久化**(解决「重启后不触发」)──────────────────────────────
|
|
27
|
+
// 只用内存 WeakMap 时实例一重启就清空 ⇒ 首个回合没有「上次用量」可比 ⇒
|
|
28
|
+
// 而重启后用量恰好是 0(快照未加载)—— **最该触发的场景反而永不触发**。
|
|
29
|
+
// 因此把每个会话的上次用量落盘(节流写),重启后读回来继续比较。
|
|
30
|
+
const STATE_SUB = ["storages", "dsh-session-conductor"];
|
|
31
|
+
const STATE_FILE = "recall-state.json";
|
|
32
|
+
const STATE_WRITE_THROTTLE_MS = 30_000;
|
|
33
|
+
let diskState = null; // { [sessionId]: { usedTokens, at } }
|
|
34
|
+
let diskLoaded = false;
|
|
35
|
+
let lastWriteAt = 0;
|
|
36
|
+
let statePathOverride = null; // 测试注入:显式指定状态文件路径(不依赖 DSH_HOME 环境变量)
|
|
37
|
+
|
|
38
|
+
/** 测试钩子:显式指定状态文件路径(传 null 恢复默认)。 */
|
|
39
|
+
export function setStatePathForTest(path) {
|
|
40
|
+
statePathOverride = path ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 状态文件路径(随 DSH_HOME 走,不写死本机路径;拿不到 HOME 时返回空串 = 不落盘)。 */
|
|
44
|
+
function stateFilePath() {
|
|
45
|
+
if (statePathOverride) return statePathOverride;
|
|
46
|
+
const home = process.env.DSH_HOME || (process.env.HOME ? join(process.env.HOME, ".dsh") : "");
|
|
47
|
+
return home ? join(home, ...STATE_SUB, STATE_FILE) : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 读落盘状态(进程内只读一次;任何异常都退化成空表,绝不向上抛)。 */
|
|
51
|
+
function loadDiskState() {
|
|
52
|
+
if (diskLoaded) return diskState;
|
|
53
|
+
diskLoaded = true;
|
|
54
|
+
diskState = {};
|
|
55
|
+
const file = stateFilePath();
|
|
56
|
+
if (!file) return diskState;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
59
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) diskState = parsed;
|
|
60
|
+
} catch {
|
|
61
|
+
// 首次运行 / 文件损坏:按空表继续
|
|
62
|
+
}
|
|
63
|
+
return diskState;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 节流落盘(每 STATE_WRITE_THROTTLE_MS 至多一次;失败只留痕,不影响回合)。 */
|
|
67
|
+
function saveDiskStateThrottled(ctx) {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
if (now - lastWriteAt < STATE_WRITE_THROTTLE_MS) return;
|
|
70
|
+
lastWriteAt = now;
|
|
71
|
+
const file = stateFilePath();
|
|
72
|
+
if (!file) return;
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
75
|
+
writeFileSync(`${file}.tmp`, JSON.stringify(diskState), "utf8");
|
|
76
|
+
renameSync(`${file}.tmp`, file);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
log(ctx, `记忆取回:用量状态落盘失败(不影响功能): ${String(error?.message ?? error)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 测试钩子:重置落盘状态缓存(配合把 DSH_HOME 指向临时目录用)。 */
|
|
83
|
+
export function resetDiskStateForTest() {
|
|
84
|
+
diskState = null;
|
|
85
|
+
diskLoaded = false;
|
|
86
|
+
lastWriteAt = 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 每会话上一次的用量。WeakMap:键(会话/agent 对象)被回收即自动释放,不会泄漏。 */
|
|
90
|
+
let lastUsageBySession = new WeakMap();
|
|
91
|
+
|
|
92
|
+
/** 读取某会话上一次记录的用量(测试与诊断用)。 */
|
|
93
|
+
export function takeLastUsage(key) {
|
|
94
|
+
return key ? lastUsageBySession.get(key) ?? null : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 记录本次用量(供下一回合比较)。 */
|
|
98
|
+
export function noteUsage(key, usage) {
|
|
99
|
+
if (key && usage) lastUsageBySession.set(key, usage);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 测试钩子:清空用量记忆(WeakMap 无法逐项清,直接换新实例)。 */
|
|
103
|
+
export function resetRecallStateForTest() {
|
|
104
|
+
lastUsageBySession = new WeakMap();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 判定「上下文疑似丢失」:本次已用量比上次明显下降。
|
|
109
|
+
*
|
|
110
|
+
* @param {{usedTokens:number, percent?:number}|null} prev 上次用量
|
|
111
|
+
* @param {{usedTokens:number, percent?:number}|null} current 本次用量
|
|
112
|
+
* @param {{minDropRatio?:number, minDropTokens?:number}} [opts] 阈值(默认比例 10%;绝对量 0=不限)
|
|
113
|
+
* @returns {{lost:boolean, prev:object|null, dropRatio:number, dropTokens:number, reason:string}}
|
|
114
|
+
* `reason` 如实说明「为什么没触发」,便于诊断(也会写进注入文本的判定依据)
|
|
115
|
+
*/
|
|
116
|
+
export function detectContextLoss(prev, current, opts = {}) {
|
|
117
|
+
const minRatio = Number.isFinite(opts.minDropRatio) ? opts.minDropRatio : 0.10;
|
|
118
|
+
const minTokens = Number.isFinite(opts.minDropTokens) ? opts.minDropTokens : 0;
|
|
119
|
+
const base = { lost: false, prev: prev ?? null, dropRatio: 0, dropTokens: 0 };
|
|
120
|
+
if (!current || typeof current.usedTokens !== "number") return { ...base, reason: "本次用量不可用(投影未上报?)" };
|
|
121
|
+
if (!prev || typeof prev.usedTokens !== "number") return { ...base, prev: null, reason: "没有上次用量(首个回合)" };
|
|
122
|
+
const dropTokens = prev.usedTokens - current.usedTokens;
|
|
123
|
+
if (dropTokens <= 0) return { ...base, dropRatio: 0, dropTokens: 0, reason: "用量未下降" };
|
|
124
|
+
const dropRatio = prev.usedTokens > 0 ? dropTokens / prev.usedTokens : 0;
|
|
125
|
+
const hit = dropRatio >= minRatio || (minTokens > 0 && dropTokens >= minTokens);
|
|
126
|
+
return {
|
|
127
|
+
lost: hit,
|
|
128
|
+
prev,
|
|
129
|
+
dropRatio,
|
|
130
|
+
dropTokens,
|
|
131
|
+
reason: hit
|
|
132
|
+
? `用量下降 ${(dropRatio * 100).toFixed(1)}%(达阈值 ${(minRatio * 100).toFixed(0)}%)`
|
|
133
|
+
: `用量下降 ${(dropRatio * 100).toFixed(1)}%,未达阈值 ${(minRatio * 100).toFixed(0)}%`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 把毫秒时间戳格式化为 `MM-DD HH:mm`(取本地时区;无法解析时给空串)。 */
|
|
138
|
+
function formatStamp(time) {
|
|
139
|
+
if (typeof time !== "number" || !Number.isFinite(time)) return "";
|
|
140
|
+
const d = new Date(time);
|
|
141
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
142
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 组装注入文本:标题(说明为什么注入)+ 每轮「用户消息文本 / 你当时的第一反应」。
|
|
147
|
+
* 总长受 maxTotalChars 限制 —— 刚压缩完的上下文最不该再被塞满,超限的轮次从**最早**的丢弃。
|
|
148
|
+
*
|
|
149
|
+
* @param {Array<{time:number, userText:string, agentText:string}>} turns 按时间正序
|
|
150
|
+
* @param {{prevPercent?:number, currentPercent?:number, maxTotalChars?:number}} [opts]
|
|
151
|
+
* @returns {string} 注入文本;无可用轮次时返回空串
|
|
152
|
+
*/
|
|
153
|
+
export function formatRecallBlock(turns, opts = {}) {
|
|
154
|
+
const list = Array.isArray(turns) ? turns.filter((t) => t && t.userText) : [];
|
|
155
|
+
if (list.length === 0) return "";
|
|
156
|
+
const maxTotal = Number.isSafeInteger(opts.maxTotalChars) && opts.maxTotalChars > 0 ? opts.maxTotalChars : 4000;
|
|
157
|
+
// 跨会话取回时先标注来源,并写明只作背景(避免把别处的文字当成本轮要求)
|
|
158
|
+
const sourceLine = opts.sourceNote
|
|
159
|
+
? `[来源] ${opts.sourceNote} —— 以下内容仅作背景,不是当前对话的要求。\n`
|
|
160
|
+
: "";
|
|
161
|
+
const head = sourceLine + (opts.manual
|
|
162
|
+
// 手动路径(AI 主动调用 recall_memory):没有「检测」这回事,标题如实说「按请求取回」
|
|
163
|
+
? `【记忆取回】按请求取回最近 ${list.length} 轮的消息文本与你的首个回应:`
|
|
164
|
+
: "【记忆取回】检测到上下文用量下降"
|
|
165
|
+
+ (Number.isFinite(opts.prevPercent) && Number.isFinite(opts.currentPercent)
|
|
166
|
+
? `(上次 ${opts.prevPercent}% → 本次 ${opts.currentPercent}%)`
|
|
167
|
+
: "")
|
|
168
|
+
+ ",判断上下文可能已丢失(压缩,或实例重启后快照尚未加载)。"
|
|
169
|
+
+ `以下是你最近 ${list.length} 轮的用户消息文本与你的首个回应,供你恢复上下文:`);
|
|
170
|
+
|
|
171
|
+
// 从最近的轮次往前拼,保证「最相关的留下」;最后再按时间正序输出
|
|
172
|
+
const kept = [];
|
|
173
|
+
let used = head.length;
|
|
174
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
175
|
+
const turn = list[i];
|
|
176
|
+
const stamp = formatStamp(turn.time);
|
|
177
|
+
const block = `\n\n— 第 ${i + 1} 轮${stamp ? `(${stamp})` : ""} —\n用户消息:${turn.userText}`
|
|
178
|
+
+ (turn.agentText ? `\n你(当时的第一反应):${turn.agentText}` : "");
|
|
179
|
+
if (used + block.length > maxTotal && kept.length > 0) break;
|
|
180
|
+
kept.unshift(block);
|
|
181
|
+
used += block.length;
|
|
182
|
+
}
|
|
183
|
+
return head + kept.join("");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 主入口:检测 → 取回 → 组装。任何一步失败都返回 null(调用方据此跳过注入)。
|
|
188
|
+
*
|
|
189
|
+
* ⚠️ 无论是否触发,**都会记账**(`noteUsage`):否则读取失败时会每个回合反复触发。
|
|
190
|
+
*
|
|
191
|
+
* @param {object} ctx 插件 context
|
|
192
|
+
* @param {object} args { sessionKey(会话/agent 对象,用作记忆键), sessionId, current(本次用量), opts }
|
|
193
|
+
* @returns {Promise<string|null>} 注入文本;不需要/拿不到时 null
|
|
194
|
+
*/
|
|
195
|
+
export async function buildMemoryRecall(ctx, { sessionKey, sessionId, current, opts = {} }) {
|
|
196
|
+
// 上次用量:优先内存(同进程最准),内存没有就读**落盘值** —— 重启后靠它才能比较出「掉到 0」。
|
|
197
|
+
const memory = takeLastUsage(sessionKey);
|
|
198
|
+
const disk = sessionId ? loadDiskState()[sessionId] : null;
|
|
199
|
+
const prev = memory ?? (disk && typeof disk.usedTokens === "number" ? disk : null);
|
|
200
|
+
noteUsage(sessionKey, current);
|
|
201
|
+
if (sessionId && current && typeof current.usedTokens === "number") {
|
|
202
|
+
loadDiskState()[sessionId] = { usedTokens: current.usedTokens, percent: current.percent, at: Date.now() };
|
|
203
|
+
saveDiskStateThrottled(ctx);
|
|
204
|
+
}
|
|
205
|
+
const verdict = detectContextLoss(prev, current, {
|
|
206
|
+
minDropRatio: opts.minDropRatio,
|
|
207
|
+
minDropTokens: opts.minDropTokens,
|
|
208
|
+
});
|
|
209
|
+
if (!verdict.lost) return null;
|
|
210
|
+
if (!sessionId) return null;
|
|
211
|
+
let turns = null;
|
|
212
|
+
try {
|
|
213
|
+
turns = await readRecentUserTurns(ctx, sessionId, {
|
|
214
|
+
rounds: opts.rounds,
|
|
215
|
+
maxCharsPerItem: opts.maxCharsPerItem,
|
|
216
|
+
root: opts.root,
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
log(ctx, `记忆取回:读会话日志失败(跳过本次注入): ${String(error?.message ?? error)}`);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
if (!turns || turns.length === 0) return null;
|
|
223
|
+
const text = formatRecallBlock(turns, {
|
|
224
|
+
prevPercent: verdict.prev?.percent,
|
|
225
|
+
currentPercent: current?.percent,
|
|
226
|
+
maxTotalChars: opts.maxTotalChars,
|
|
227
|
+
});
|
|
228
|
+
if (!text) return null;
|
|
229
|
+
log(ctx, `记忆取回:${verdict.reason} ⇒ 注入 ${turns.length} 轮(${text.length} 字符)`);
|
|
230
|
+
return text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 工具输出形态(与其它工具一致:字符串 + text 渲染)。 */
|
|
234
|
+
const TEXT_OUTPUT = {
|
|
235
|
+
schema: { type: "string" },
|
|
236
|
+
render(_args, value) {
|
|
237
|
+
return [{ type: "text", text: String(value) }];
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 注册 `recall_memory` AI 工具:**让 AI 自己按需取回记忆**(自动注入之外的手动通道)。
|
|
243
|
+
*
|
|
244
|
+
* 用途:上下文被压缩后 AI 想主动回溯、或想翻更早的几轮时调用;参数 `rounds` = 取几轮
|
|
245
|
+
* (默认取配置 `retrieveMemoryRounds`,上限 50)。只读 —— 只返回文本给调用方,不改上下文。
|
|
246
|
+
*
|
|
247
|
+
* @returns {boolean} 是否成功注册
|
|
248
|
+
*/
|
|
249
|
+
export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
|
|
250
|
+
if (typeof defineTool !== "function" || typeof tools?.register !== "function") return false;
|
|
251
|
+
const read = deps.readTurns ?? readRecentUserTurns;
|
|
252
|
+
const format = deps.format ?? formatRecallBlock;
|
|
253
|
+
const config = deps.cfg ?? cfg;
|
|
254
|
+
const resolve = deps.resolveSession ?? resolveSessionTarget;
|
|
255
|
+
const maxRounds = 50;
|
|
256
|
+
tools.register(defineTool({
|
|
257
|
+
name: "recall_memory",
|
|
258
|
+
description:
|
|
259
|
+
"取回最近几轮的记忆:每轮的**用户消息文本** + 你当时的**首个回应**(优先取当时的 reasoning)。"
|
|
260
|
+
+ "何时用:上下文被压缩/重启后想主动回溯,或想确认「用户最初是怎么说的」。"
|
|
261
|
+
+ `参数 rounds = 取几轮(默认 ${config?.retrieveMemoryRounds ?? 3},上限 ${maxRounds})。`
|
|
262
|
+
+ "只读:只把文本返回给你,不会自动改上下文。",
|
|
263
|
+
parameters: {
|
|
264
|
+
rounds: { type: "number", description: `取最近几轮(默认 ${config?.retrieveMemoryRounds ?? 3},上限 ${maxRounds})` },
|
|
265
|
+
session: { type: "string", description: "目标会话:id / id 前缀 / 标题子串;缺省或空 = 取自身(行为与之前一致)" },
|
|
266
|
+
},
|
|
267
|
+
output: TEXT_OUTPUT,
|
|
268
|
+
async execute(args, exec) {
|
|
269
|
+
const selfId = exec?.agent?.session?.id;
|
|
270
|
+
if (!selfId) return "拿不到当前会话 id,无法取回记忆。";
|
|
271
|
+
const asked = Number.isSafeInteger(args?.rounds) && args.rounds > 0 ? args.rounds : (config?.retrieveMemoryRounds ?? 3);
|
|
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
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const turns = await read(ctx, targetId, {
|
|
289
|
+
rounds,
|
|
290
|
+
maxCharsPerItem: config?.retrieveMemoryMaxCharsPerItem,
|
|
291
|
+
});
|
|
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
|
+
});
|
|
299
|
+
return text || "没取到可用的记忆。";
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return `取回记忆失败:${String(error?.message ?? error)}`;
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
}));
|
|
305
|
+
return true;
|
|
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
|
}
|
|
@@ -217,3 +217,106 @@ export async function readColdSessionSummary(ctx, sessionId, opts = {}) {
|
|
|
217
217
|
frames: stats.frames,
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
|
+
|
|
221
|
+
// ---------- 记忆取回:取最近 N 轮「用户消息文本 + AI 首条回应」----------
|
|
222
|
+
|
|
223
|
+
/** 从 content 分块里取纯文本(跳过 reasoning 等非文本块)。 */
|
|
224
|
+
function textPartsOf(content) {
|
|
225
|
+
if (!Array.isArray(content)) return "";
|
|
226
|
+
const parts = [];
|
|
227
|
+
for (const part of content) {
|
|
228
|
+
if (typeof part?.text === "string" && part.text) parts.push(part.text);
|
|
229
|
+
}
|
|
230
|
+
return parts.join("\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 从 `assistant/message` 里取「该轮 AI 的第一条回应」文本。
|
|
235
|
+
* 优先 `reasoning`(当时的推理最能体现「我那时怎么理解你的话」),没有则取正文 `text`。
|
|
236
|
+
*/
|
|
237
|
+
function firstAgentTextOf(data) {
|
|
238
|
+
const content = data?.message?.content;
|
|
239
|
+
if (!Array.isArray(content)) return "";
|
|
240
|
+
const pick = (kind) => content.find((p) => p?.type === kind && typeof p.text === "string" && p.text)?.text ?? "";
|
|
241
|
+
return pick("reasoning") || pick("text") || "";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 截断到 maxChars(超出加省略号),并压缩连续空白行。 */
|
|
245
|
+
function clipText(text, maxChars) {
|
|
246
|
+
const normalized = String(text ?? "").replace(/\n{3,}/g, "\n\n").trim();
|
|
247
|
+
if (maxChars > 0 && normalized.length > maxChars) return `${normalized.slice(0, maxChars)}…`;
|
|
248
|
+
return normalized;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 取最近 N 轮「用户消息文本 + 该轮 AI 的第一条回应」(**按 zstd 帧流式读,常量内存**)。
|
|
253
|
+
*
|
|
254
|
+
* 【锚点为什么是 user/message 而不是 agent/inbox/spliced】前者是「真正成为用户输入」的那条,
|
|
255
|
+
* 后者是同一句话的**投递副本**(实测同一句话会先出现在 spliced、后出现在 user/message),
|
|
256
|
+
* 用后者会把一轮数成两条。
|
|
257
|
+
* 【为什么只认 source.kind === "user"】插件自己注入的消息(`plugin:dsh-session-conductor`)
|
|
258
|
+
* 以及 runtime-context / user-approval / model-selection 都不是用户消息文本;若不排除,
|
|
259
|
+
* 「记忆取回」注入的块本身会被下一轮取回,形成越注越多的反馈环。
|
|
260
|
+
* 【连续多条用户消息算一轮】同一回合内(其间没有 turn/end)到达的多条用户消息合并成一条。
|
|
261
|
+
*
|
|
262
|
+
* @param {object} ctx 插件 context(本函数不取服务,保留同签名)
|
|
263
|
+
* @param {string} sessionId 会话 id
|
|
264
|
+
* @param {object} [opts] rounds(默认 3)/ maxCharsPerItem(默认 800)/ root(测试用)
|
|
265
|
+
* @returns {Promise<Array<{time:number, userText:string, agentText:string}>|null>} 按时间正序;null = 日志没找到
|
|
266
|
+
*/
|
|
267
|
+
export async function readRecentUserTurns(ctx, sessionId, opts = {}) {
|
|
268
|
+
const file = findSessionLogById(sessionId, opts.root ?? sessionsRootOf());
|
|
269
|
+
if (!file) return null;
|
|
270
|
+
const rounds = Number.isSafeInteger(opts.rounds) && opts.rounds > 0 ? opts.rounds : 3;
|
|
271
|
+
const perItem = Number.isSafeInteger(opts.maxCharsPerItem) && opts.maxCharsPerItem > 0 ? opts.maxCharsPerItem : 800;
|
|
272
|
+
const buf = readFileSync(file);
|
|
273
|
+
const decoder = new StringDecoder("utf8");
|
|
274
|
+
let pending = "";
|
|
275
|
+
const turns = [];
|
|
276
|
+
let current = null;
|
|
277
|
+
|
|
278
|
+
const consumeLine = (line) => {
|
|
279
|
+
if (!line) return;
|
|
280
|
+
let event;
|
|
281
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
282
|
+
const type = event?.type;
|
|
283
|
+
if (type === "turn/end") {
|
|
284
|
+
// 回合结束:此后到来的用户消息算新的一轮
|
|
285
|
+
if (current) current.closed = true;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (type === "user/message") {
|
|
289
|
+
if (event?.data?.source?.kind !== "user") return; // 排除注入与运行期上下文
|
|
290
|
+
const text = clipText(textPartsOf(event?.data?.content), perItem);
|
|
291
|
+
if (!text) return;
|
|
292
|
+
if (current && !current.closed) {
|
|
293
|
+
// 同回合内的连续用户消息:合并(用空行分隔,保持消息文本可辨)
|
|
294
|
+
current.userText = current.userText ? `${current.userText}\n\n${text}` : text;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
current = { time: typeof event?.time === "number" ? event.time : null, userText: text, agentText: "", closed: false };
|
|
298
|
+
turns.push(current);
|
|
299
|
+
if (turns.length > rounds) turns.shift(); // 只留最近 rounds 轮(常量内存)
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (type === "assistant/message") {
|
|
303
|
+
if (!current || current.agentText) return; // 只要该轮的第一条
|
|
304
|
+
const text = clipText(firstAgentTextOf(event?.data), perItem);
|
|
305
|
+
if (text) current.agentText = text;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
await streamDecodedFrames(buf, (chunk) => {
|
|
310
|
+
pending += decoder.write(chunk);
|
|
311
|
+
let idx;
|
|
312
|
+
while ((idx = pending.indexOf("\n")) >= 0) {
|
|
313
|
+
const line = pending.slice(0, idx);
|
|
314
|
+
pending = pending.slice(idx + 1);
|
|
315
|
+
consumeLine(line);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
pending += decoder.end();
|
|
319
|
+
consumeLine(pending);
|
|
320
|
+
|
|
321
|
+
return turns.map((t) => ({ time: t.time, userText: t.userText, agentText: t.agentText }));
|
|
322
|
+
}
|
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
|
+
"version": "1.3.1",
|
|
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」的不一致。
|
|
@@ -227,6 +228,37 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
|
|
|
227
228
|
### 节约模式(token-saving-mode)
|
|
228
229
|
- 用户表达节约 token 意愿("省点""节约")时启用:直达答案不客套、结构优先、默认 ≤200 字或 10 行代码、禁冗余、复杂问题先结论后理由、高峰价格用户自己留意勿主动提醒。
|
|
229
230
|
|
|
231
|
+
## 十三、记忆取回(memory-recall,1.3.0 起)
|
|
232
|
+
|
|
233
|
+
**解决什么**:上下文被压缩、截断,或实例重启后快照未加载时,AI 会「失忆」——重复提问、重复劳动、
|
|
234
|
+
误解需求。而用户消息在上下文里占比极小(1M 上下文可能总共没超过 3 句),却最不该丢。
|
|
235
|
+
|
|
236
|
+
**两条通道**:
|
|
237
|
+
|
|
238
|
+
| 通道 | 触发 | 参数 |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| **自动注入** | 每回合比较上下文已用量:本次比上次下降 ≥ `retrieveMemoryMinDropRatio`(默认 0.10)⇒ 判定「疑似丢失」,取回后与用量行**合并成一条**注入(消息尾部) | 无(走配置) |
|
|
241
|
+
| **AI 工具 `recall_memory`** | AI 自己想回溯时主动调用 | `rounds` = 取几轮(默认取配置,上限 50) |
|
|
242
|
+
|
|
243
|
+
**取什么**:最近 N 轮(默认 3)的**用户消息文本** + 该轮 **AI 的第一条回应**(优先 reasoning,缺失取正文)。
|
|
244
|
+
|
|
245
|
+
- 以用户消息为锚:占比小但信息密度最高,是恢复上下文的支点;
|
|
246
|
+
- 要 AI 的第一条回应:那是「当时」对消息的理解,直接给结论,省得重新推测。
|
|
247
|
+
|
|
248
|
+
**实现要点(改这里前先读)**:
|
|
249
|
+
|
|
250
|
+
- 读取走 `readRecentUserTurns`(`lib/sessions/events.js`):按 zstd 帧**流式**读日志文件,
|
|
251
|
+
常量内存(实测 3 轮 27 ms / heap 6.1 MB);
|
|
252
|
+
- 锚点只认 `user/message` 且 `data.source.kind === "user"` —— **必须排除插件自身注入**
|
|
253
|
+
(`plugin:dsh-session-conductor`)与 `runtime-context`,否则注入块会被下一轮取回形成反馈环;
|
|
254
|
+
- `agent/inbox/spliced` 是同一句话的投递副本,**忽略**(否则一轮会数成两条);
|
|
255
|
+
- 同回合内(其间无 `turn/end`)连续多条用户消息**合并成一轮**;
|
|
256
|
+
- 无论是否触发都记账(`noteUsage`),否则读取失败会每回合反复触发;
|
|
257
|
+
- 任何一步失败(日志缺失/损坏、用量读不到、首回合无上次用量)都静默返回 null,绝不阻断回合。
|
|
258
|
+
|
|
259
|
+
**配置**:`retrieveMemory`(默认开)/ `retrieveMemoryRounds`(3) / `retrieveMemoryMinDropRatio`(0.10) /
|
|
260
|
+
`retrieveMemoryMinDropTokens`(0) / `retrieveMemoryMaxChars`(4000) / `retrieveMemoryMaxCharsPerItem`(800)。
|
|
261
|
+
|
|
230
262
|
## 相关
|
|
231
263
|
|
|
232
264
|
- dsh-session-conductor(使用手册,与本文档配套)
|