dsh-session-conductor 1.2.1 → 1.3.0
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 +1 -0
- package/lib/apply.js +41 -5
- package/lib/core/config.js +7 -0
- package/lib/features/memory-recall/index.js +281 -0
- package/lib/sessions/events.js +214 -0
- package/lib/sessions/list.js +43 -11
- package/lib/zstd-frames.js +35 -0
- package/package.json +1 -1
- package/skills/dsh-session-conductor-functions.md +35 -4
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
| 模板注入 | 方案模板 / 收尾模板:本地设备上传 / 在线网址 / DSH 目录浏览三种导入,内容可再编辑,注入系统提示词(方案/收尾各一条上下文注入) | 设置 → 会话管理 → 模板注入 |
|
|
23
23
|
| 自动重命名模型 | 指定便宜/专用模型做标题分析 | 设置 → 会话管理 |
|
|
24
24
|
| 成员模型切换 | 给队友/子代理单独切模型(只动该成员会话,不影响其他成员):先用 `list_models` 拿确切模型 id,再 `set_member_model` 按成员名/sessionId 切换(写 `model/selection` 事件交给官方投影接管,跨轮持久;未挂载成员也能切,运行中会先等它暂停) | AI 工具 `list_models` / `set_member_model` |
|
|
25
|
+
| 记忆取回 | 检测到上下文用量**明显下降**(默认 ≥10%,可配)即判定「上下文疑似丢失」——压缩、截断、实例重启后快照未加载**都算**(比只盯压缩更全)。此时自动把最近 **3 轮(可配)** 的**用户原话 + 该轮 AI 的第一条回应**(优先取当时的 reasoning)重新注入,让 AI 立刻恢复状态。实现要点:按 zstd 帧**流式**读日志(常量内存)、**只认真实用户消息**(排除插件自身注入,防反馈环)、同回合连发**合并成一轮**、注入总长与单条都有上限。配置项 `retrieveMemory*`(默认开)。**另提供 AI 工具 `recall_memory`**(参数 `rounds` = 取几轮,默认取配置、上限 50)让 AI 自己按需回溯 | 每轮注入(`agent/pre-step`)+ AI 工具 `recall_memory` |
|
|
25
26
|
| 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/对话消息),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数。**另:每轮把实时用量注入消息尾部**(AI 每轮都能直接看到数字;注入在尾部而非系统提示词前缀 ⇒ 前缀稳定、prompt cache 仍命中;按 turn 门控,一回合只注一次;配置 `injectContextUsage` 可关) | AI 工具 `context_usage` + 每轮注入 |
|
|
26
27
|
| 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。`list_sessions` 支持 **`query` 子串过滤 + `limit`/`offset` 分页**(默认只给 20 条,避免整表塞进调用方上下文)。硬约束:禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条 | AI 工具 `list_sessions` / `send_to_session` |
|
|
27
28
|
| 指定模型新建会话 | AI 新建会话并可**直接指定 provider/model**(建完即切到该模型),可选带一条初始消息让它立刻开始干活。**缺省工作目录 = 调用方会话自己的 cwd**(不落在 DSH 安装目录里)。本体 `create` 本身不带模型,所以是 `create` → `selectModel` 两步;任一步失败会如实回执(如「会话已建,但模型未设置」),不谎报成功。**支持 `dryRun` 预览**(含模型是否存在于目录的核对),不建会话 | AI 工具 `create_session` |
|
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";
|
|
@@ -1287,18 +1289,44 @@ export async function apply(ctx, config = {}) {
|
|
|
1287
1289
|
// 多条重复的用量行(噪音 + 白烧 token)。
|
|
1288
1290
|
// · 默认开启(core/config.js 的 injectContextUsage);关掉即退化为「按需调用 context_usage 工具」。
|
|
1289
1291
|
// · 读不到投影/服务未挂载时注入器返回空串 ⇒ 静默跳过,绝不影响正常回合。
|
|
1290
|
-
|
|
1292
|
+
// 两个开关任一开启就需要本 hook(用量注入 / 记忆取回);turn 门控保证一回合只注入一次。
|
|
1293
|
+
const usageInjector = createContextUsageInjector({
|
|
1294
|
+
enabled: cfg?.injectContextUsage !== false || cfg?.retrieveMemory !== false,
|
|
1295
|
+
});
|
|
1291
1296
|
ctx.on("agent/pre-step", async ({ agent, turn, signal }, next) => {
|
|
1292
1297
|
const decision = await next();
|
|
1293
1298
|
if (decision?.kind === "reject" || signal?.aborted) return decision;
|
|
1294
1299
|
// 与模板注入同款守卫:工作区自测环境没有 @deepseek-ai/* 依赖,createUserMessage 会是 null
|
|
1295
1300
|
if (typeof createUserMessage !== "function") return decision;
|
|
1296
1301
|
if (!usageInjector.shouldInject(agent, turn)) return decision;
|
|
1297
|
-
const usageText = usageInjector.textFor(ctx, agent);
|
|
1298
|
-
|
|
1302
|
+
const usageText = cfg?.injectContextUsage === false ? "" : usageInjector.textFor(ctx, agent);
|
|
1303
|
+
// 记忆取回:本回合用量比上次明显下降 ⇒ 判定上下文疑似丢失,取回最近几轮消息文本 + AI 首条回应。
|
|
1304
|
+
// 与用量注入**合并成同一条消息**(一回合只注入一条,避免叠加);阈值/轮数/上限见 cfg。
|
|
1305
|
+
let recallText = null;
|
|
1306
|
+
if (cfg?.retrieveMemory !== false) {
|
|
1307
|
+
try {
|
|
1308
|
+
const snapshot = readContextUsage(ctx, agent);
|
|
1309
|
+
recallText = await buildMemoryRecall(ctx, {
|
|
1310
|
+
sessionKey: agent,
|
|
1311
|
+
sessionId: agent?.session?.id,
|
|
1312
|
+
current: snapshot?.ok ? snapshot.occupancy : null,
|
|
1313
|
+
opts: {
|
|
1314
|
+
rounds: cfg?.retrieveMemoryRounds,
|
|
1315
|
+
minDropRatio: cfg?.retrieveMemoryMinDropRatio,
|
|
1316
|
+
minDropTokens: cfg?.retrieveMemoryMinDropTokens,
|
|
1317
|
+
maxTotalChars: cfg?.retrieveMemoryMaxChars,
|
|
1318
|
+
maxCharsPerItem: cfg?.retrieveMemoryMaxCharsPerItem,
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
log(ctx, `记忆取回失败(跳过本次): ${String(error?.message ?? error)}`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
const text = [usageText, recallText].filter(Boolean).join("\n\n");
|
|
1326
|
+
if (!text) return decision;
|
|
1299
1327
|
try {
|
|
1300
1328
|
const injected = createUserMessage({
|
|
1301
|
-
content: [{ type: "text", text
|
|
1329
|
+
content: [{ type: "text", text }],
|
|
1302
1330
|
source: { kind: "plugin:dsh-session-conductor", plugin: "dsh-session-conductor", form: "instructions" },
|
|
1303
1331
|
});
|
|
1304
1332
|
return { ...decision, messages: [...(decision.messages || []), injected] };
|
|
@@ -1394,6 +1422,14 @@ export async function apply(ctx, config = {}) {
|
|
|
1394
1422
|
} catch (error) {
|
|
1395
1423
|
log(ctx, `跨会话工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
1396
1424
|
}
|
|
1425
|
+
try {
|
|
1426
|
+
// 记忆取回的手动通道:让 AI 自己按需回溯(自动注入之外的补充)
|
|
1427
|
+
if (registerMemoryRecallTool(ctx, defineTool, tools)) {
|
|
1428
|
+
log(ctx, "记忆取回工具已注册 (recall_memory)");
|
|
1429
|
+
}
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
log(ctx, `记忆取回工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
1432
|
+
}
|
|
1397
1433
|
log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
|
|
1398
1434
|
});
|
|
1399
1435
|
}
|
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。 */
|
|
@@ -0,0 +1,281 @@
|
|
|
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
|
+
const head = opts.manual
|
|
158
|
+
// 手动路径(AI 主动调用 recall_memory):没有「检测」这回事,标题如实说「按请求取回」
|
|
159
|
+
? `【记忆取回】按请求取回最近 ${list.length} 轮的消息文本与你的首个回应:`
|
|
160
|
+
: "【记忆取回】检测到上下文用量下降"
|
|
161
|
+
+ (Number.isFinite(opts.prevPercent) && Number.isFinite(opts.currentPercent)
|
|
162
|
+
? `(上次 ${opts.prevPercent}% → 本次 ${opts.currentPercent}%)`
|
|
163
|
+
: "")
|
|
164
|
+
+ ",判断上下文可能已丢失(压缩,或实例重启后快照尚未加载)。"
|
|
165
|
+
+ `以下是你最近 ${list.length} 轮的用户消息文本与你的首个回应,供你恢复上下文:`;
|
|
166
|
+
|
|
167
|
+
// 从最近的轮次往前拼,保证「最相关的留下」;最后再按时间正序输出
|
|
168
|
+
const kept = [];
|
|
169
|
+
let used = head.length;
|
|
170
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
171
|
+
const turn = list[i];
|
|
172
|
+
const stamp = formatStamp(turn.time);
|
|
173
|
+
const block = `\n\n— 第 ${i + 1} 轮${stamp ? `(${stamp})` : ""} —\n用户消息:${turn.userText}`
|
|
174
|
+
+ (turn.agentText ? `\n你(当时的第一反应):${turn.agentText}` : "");
|
|
175
|
+
if (used + block.length > maxTotal && kept.length > 0) break;
|
|
176
|
+
kept.unshift(block);
|
|
177
|
+
used += block.length;
|
|
178
|
+
}
|
|
179
|
+
return head + kept.join("");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 主入口:检测 → 取回 → 组装。任何一步失败都返回 null(调用方据此跳过注入)。
|
|
184
|
+
*
|
|
185
|
+
* ⚠️ 无论是否触发,**都会记账**(`noteUsage`):否则读取失败时会每个回合反复触发。
|
|
186
|
+
*
|
|
187
|
+
* @param {object} ctx 插件 context
|
|
188
|
+
* @param {object} args { sessionKey(会话/agent 对象,用作记忆键), sessionId, current(本次用量), opts }
|
|
189
|
+
* @returns {Promise<string|null>} 注入文本;不需要/拿不到时 null
|
|
190
|
+
*/
|
|
191
|
+
export async function buildMemoryRecall(ctx, { sessionKey, sessionId, current, opts = {} }) {
|
|
192
|
+
// 上次用量:优先内存(同进程最准),内存没有就读**落盘值** —— 重启后靠它才能比较出「掉到 0」。
|
|
193
|
+
const memory = takeLastUsage(sessionKey);
|
|
194
|
+
const disk = sessionId ? loadDiskState()[sessionId] : null;
|
|
195
|
+
const prev = memory ?? (disk && typeof disk.usedTokens === "number" ? disk : null);
|
|
196
|
+
noteUsage(sessionKey, current);
|
|
197
|
+
if (sessionId && current && typeof current.usedTokens === "number") {
|
|
198
|
+
loadDiskState()[sessionId] = { usedTokens: current.usedTokens, percent: current.percent, at: Date.now() };
|
|
199
|
+
saveDiskStateThrottled(ctx);
|
|
200
|
+
}
|
|
201
|
+
const verdict = detectContextLoss(prev, current, {
|
|
202
|
+
minDropRatio: opts.minDropRatio,
|
|
203
|
+
minDropTokens: opts.minDropTokens,
|
|
204
|
+
});
|
|
205
|
+
if (!verdict.lost) return null;
|
|
206
|
+
if (!sessionId) return null;
|
|
207
|
+
let turns = null;
|
|
208
|
+
try {
|
|
209
|
+
turns = await readRecentUserTurns(ctx, sessionId, {
|
|
210
|
+
rounds: opts.rounds,
|
|
211
|
+
maxCharsPerItem: opts.maxCharsPerItem,
|
|
212
|
+
root: opts.root,
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
log(ctx, `记忆取回:读会话日志失败(跳过本次注入): ${String(error?.message ?? error)}`);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
if (!turns || turns.length === 0) return null;
|
|
219
|
+
const text = formatRecallBlock(turns, {
|
|
220
|
+
prevPercent: verdict.prev?.percent,
|
|
221
|
+
currentPercent: current?.percent,
|
|
222
|
+
maxTotalChars: opts.maxTotalChars,
|
|
223
|
+
});
|
|
224
|
+
if (!text) return null;
|
|
225
|
+
log(ctx, `记忆取回:${verdict.reason} ⇒ 注入 ${turns.length} 轮(${text.length} 字符)`);
|
|
226
|
+
return text;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 工具输出形态(与其它工具一致:字符串 + text 渲染)。 */
|
|
230
|
+
const TEXT_OUTPUT = {
|
|
231
|
+
schema: { type: "string" },
|
|
232
|
+
render(_args, value) {
|
|
233
|
+
return [{ type: "text", text: String(value) }];
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 注册 `recall_memory` AI 工具:**让 AI 自己按需取回记忆**(自动注入之外的手动通道)。
|
|
239
|
+
*
|
|
240
|
+
* 用途:上下文被压缩后 AI 想主动回溯、或想翻更早的几轮时调用;参数 `rounds` = 取几轮
|
|
241
|
+
* (默认取配置 `retrieveMemoryRounds`,上限 50)。只读 —— 只返回文本给调用方,不改上下文。
|
|
242
|
+
*
|
|
243
|
+
* @returns {boolean} 是否成功注册
|
|
244
|
+
*/
|
|
245
|
+
export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
|
|
246
|
+
if (typeof defineTool !== "function" || typeof tools?.register !== "function") return false;
|
|
247
|
+
const read = deps.readTurns ?? readRecentUserTurns;
|
|
248
|
+
const format = deps.format ?? formatRecallBlock;
|
|
249
|
+
const config = deps.cfg ?? cfg;
|
|
250
|
+
const maxRounds = 50;
|
|
251
|
+
tools.register(defineTool({
|
|
252
|
+
name: "recall_memory",
|
|
253
|
+
description:
|
|
254
|
+
"取回最近几轮的记忆:每轮的**用户消息文本** + 你当时的**首个回应**(优先取当时的 reasoning)。"
|
|
255
|
+
+ "何时用:上下文被压缩/重启后想主动回溯,或想确认「用户最初是怎么说的」。"
|
|
256
|
+
+ `参数 rounds = 取几轮(默认 ${config?.retrieveMemoryRounds ?? 3},上限 ${maxRounds})。`
|
|
257
|
+
+ "只读:只把文本返回给你,不会自动改上下文。",
|
|
258
|
+
parameters: {
|
|
259
|
+
rounds: { type: "number", description: `取最近几轮(默认 ${config?.retrieveMemoryRounds ?? 3},上限 ${maxRounds})` },
|
|
260
|
+
},
|
|
261
|
+
output: TEXT_OUTPUT,
|
|
262
|
+
async execute(args, exec) {
|
|
263
|
+
const sessionId = exec?.agent?.session?.id;
|
|
264
|
+
if (!sessionId) return "拿不到当前会话 id,无法取回记忆。";
|
|
265
|
+
const asked = Number.isSafeInteger(args?.rounds) && args.rounds > 0 ? args.rounds : (config?.retrieveMemoryRounds ?? 3);
|
|
266
|
+
const rounds = Math.min(asked, maxRounds);
|
|
267
|
+
try {
|
|
268
|
+
const turns = await read(ctx, sessionId, {
|
|
269
|
+
rounds,
|
|
270
|
+
maxCharsPerItem: config?.retrieveMemoryMaxCharsPerItem,
|
|
271
|
+
});
|
|
272
|
+
if (!turns || turns.length === 0) return "没取到可用的记忆(日志缺失,或还没有用户消息)。";
|
|
273
|
+
const text = format(turns, { maxTotalChars: config?.retrieveMemoryMaxChars, manual: true });
|
|
274
|
+
return text || "没取到可用的记忆。";
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return `取回记忆失败:${String(error?.message ?? error)}`;
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
}));
|
|
280
|
+
return true;
|
|
281
|
+
}
|
package/lib/sessions/events.js
CHANGED
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// 宿主包**懒加载**:顶层 `import` 会让本文件在「工作区自测」环境直接解析失败
|
|
17
17
|
// (仓库里踩过同类坑),而 events.js 是最需要被单测覆盖的模块之一(冷会话读法)。
|
|
18
18
|
// 这里改成顶层 await import + 兜底:没有宿主依赖时退化为「不加闭合器」,其余逻辑照常可测。
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { StringDecoder } from "node:string_decoder";
|
|
22
|
+
|
|
23
|
+
import { streamDecodedFrames } from "../zstd-frames.js";
|
|
24
|
+
import { findSessionLog } from "../session-log.js";
|
|
25
|
+
|
|
19
26
|
let interruptedTurnClosers = null;
|
|
20
27
|
try {
|
|
21
28
|
const hostSession = await import("@deepseek-ai/dsh-session");
|
|
@@ -89,6 +96,8 @@ export async function sessionEventsOf(ctx, sessionId) {
|
|
|
89
96
|
* 列表侧靠磁盘缓存(revision 命中即跳过);不要为了「省内存」改成多次 read
|
|
90
97
|
* (那只会变成 N 次全量解压,内存峰值不变、耗时变 O(n²))。
|
|
91
98
|
*
|
|
99
|
+
* 列表构建请优先用 `readColdSessionSummary`(按 zstd 帧流式读,常量内存)。
|
|
100
|
+
*
|
|
92
101
|
* 读取失败**向上抛**(buildSessionList 用它带出 inspectError);仅「无 open 能力」返回 null。
|
|
93
102
|
* @returns {Promise<{events:Array, meta:object|null}|null>}
|
|
94
103
|
*/
|
|
@@ -106,3 +115,208 @@ export async function readColdSessionEvents(ctx, sessionId) {
|
|
|
106
115
|
// 崩溃残留的开放回合补合成闭合器(与官方 cold-read 一致,列表中断判定需要)
|
|
107
116
|
return { events: withInterruptedClosers(events), meta: handle?.header ?? null };
|
|
108
117
|
}
|
|
118
|
+
|
|
119
|
+
// ---------- 按 zstd 帧流式读(列表专用,常量内存)----------
|
|
120
|
+
|
|
121
|
+
/** 会话日志根目录:`<DSH_HOME>/sessions`。 */
|
|
122
|
+
function sessionsRootOf() {
|
|
123
|
+
const dshHome = process.env.DSH_HOME || join(process.env.HOME ?? "", ".dsh");
|
|
124
|
+
return join(dshHome, "sessions");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 由会话 id 定位其日志文件。布局:`<sessionsRoot>/<工作区编码目录>/<会话id>/session.v*.jsonl.zstd`。
|
|
129
|
+
* 只做两层 readdir(根目录下的工作区分组通常只有个位数),命中即返回。
|
|
130
|
+
* @returns {string|null} 日志文件绝对路径;找不到返回 null(调用方回退到 handle 读法)
|
|
131
|
+
*/
|
|
132
|
+
export function findSessionLogById(sessionId, root = sessionsRootOf()) {
|
|
133
|
+
if (!sessionId) return null;
|
|
134
|
+
try {
|
|
135
|
+
for (const group of readdirSync(root)) {
|
|
136
|
+
const dir = join(root, group, sessionId);
|
|
137
|
+
if (!existsSync(dir)) continue;
|
|
138
|
+
const file = findSessionLog(dir);
|
|
139
|
+
if (file) return file;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// 根目录不可读 → 交给调用方回退
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 冷会话「摘要读」:**直接按 zstd 帧读日志文件**,流式解压 + 逐行增量解析 ⇒ 常量内存。
|
|
149
|
+
*
|
|
150
|
+
* 【为什么需要它】宿主的 `handle.read()` 无法流式读(见文件头):它每次都会把整份日志
|
|
151
|
+
* 解压成 events 数组,本机实测最大会话 49.2 MB 压缩 → **173.3 MB 解压**,再变成对象数组后
|
|
152
|
+
* 单会话峰值数百 MB;列表要遍历上百个冷会话 ⇒ 进程 OOM。
|
|
153
|
+
* 而列表真正需要的只有三个值,都能在「逐行流过」时用 O(1) 状态累加:
|
|
154
|
+
* · title —— 全日志**最后一条** `session/title`(`foldTitle` 是倒序找,等价)
|
|
155
|
+
* · updatedAt —— 全日志**最后一条**事件的 `time`(`lastEventTime` 只取末条)
|
|
156
|
+
* · interruption —— 全日志**最后一个** `turn/end` 的 reason(`interruptionInfo` 倒序找)
|
|
157
|
+
* 因此:读压缩文件(≤ ~50 MB)→ 逐帧流式解压(`streamDecodedFrames`)→ 逐行 JSON.parse
|
|
158
|
+
* → 只更新上述标量 + 保留尾部若干条(供官方闭合器判定开放回合)→ 其余立即丢弃。
|
|
159
|
+
*
|
|
160
|
+
* @param {object} ctx 插件 context(本函数不用 ctx 的服务,保留是为了与其它读法同签名)
|
|
161
|
+
* @param {string} sessionId 会话 id
|
|
162
|
+
* @param {object} [opts] tailKeep(尾部保留条数,默认 2000)/ signal / root(测试用)
|
|
163
|
+
* @returns {Promise<{meta:object|null, events:Array, derived:object, scanned:number, frames:number}|null>}
|
|
164
|
+
* 返回 null 表示「文件没找到」——调用方应回退到 `readColdSessionEvents`
|
|
165
|
+
*/
|
|
166
|
+
export async function readColdSessionSummary(ctx, sessionId, opts = {}) {
|
|
167
|
+
const file = findSessionLogById(sessionId, opts.root ?? sessionsRootOf());
|
|
168
|
+
if (!file) return null;
|
|
169
|
+
const tailKeep = Number.isSafeInteger(opts.tailKeep) && opts.tailKeep >= 0 ? opts.tailKeep : 2000;
|
|
170
|
+
const buf = readFileSync(file); // 压缩态(最大 ~50 MB),不是解压态
|
|
171
|
+
const decoder = new StringDecoder("utf8");
|
|
172
|
+
let pending = "";
|
|
173
|
+
let meta = null;
|
|
174
|
+
let title;
|
|
175
|
+
let updatedAt;
|
|
176
|
+
let openTurn = false;
|
|
177
|
+
let lastSeq;
|
|
178
|
+
let lastTurnEnd = null;
|
|
179
|
+
let tail = [];
|
|
180
|
+
let scanned = 0;
|
|
181
|
+
|
|
182
|
+
const consumeLine = (line) => {
|
|
183
|
+
if (!line) return;
|
|
184
|
+
let event;
|
|
185
|
+
try { event = JSON.parse(line); } catch { return; } // 截断/损坏行跳过,不影响其余
|
|
186
|
+
if (event?.type === "session") { meta = meta ?? event; return; } // 首行是会话 header(含 cwd/createdAt)
|
|
187
|
+
scanned += 1;
|
|
188
|
+
if (event?.type === "session/title" && typeof event.data?.title === "string") title = event.data.title;
|
|
189
|
+
if (typeof event?.time === "number") updatedAt = event.time;
|
|
190
|
+
if (event?.type === "turn/end") { lastTurnEnd = event; openTurn = false; }
|
|
191
|
+
else if (event?.type === "turn/start") openTurn = true;
|
|
192
|
+
if (typeof event?.seq === "number") lastSeq = event.seq;
|
|
193
|
+
if (tailKeep > 0) {
|
|
194
|
+
tail.push(event);
|
|
195
|
+
if (tail.length > tailKeep) tail.splice(0, tail.length - tailKeep);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const stats = await streamDecodedFrames(buf, (chunk) => {
|
|
200
|
+
pending += decoder.write(chunk);
|
|
201
|
+
let idx;
|
|
202
|
+
while ((idx = pending.indexOf("\n")) >= 0) {
|
|
203
|
+
const line = pending.slice(0, idx);
|
|
204
|
+
pending = pending.slice(idx + 1);
|
|
205
|
+
consumeLine(line);
|
|
206
|
+
}
|
|
207
|
+
}, { signal: opts.signal });
|
|
208
|
+
pending += decoder.end();
|
|
209
|
+
consumeLine(pending);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
meta,
|
|
213
|
+
// 尾部 + 官方闭合器:列表沿用既有 interruptionInfo(events) 语义,行为不变
|
|
214
|
+
events: withInterruptedClosers(tail),
|
|
215
|
+
derived: { title, updatedAt, openTurn, lastSeq, lastTurnEnd },
|
|
216
|
+
scanned,
|
|
217
|
+
frames: stats.frames,
|
|
218
|
+
};
|
|
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/sessions/list.js
CHANGED
|
@@ -8,7 +8,7 @@ import { log } from "../shared/log.js";
|
|
|
8
8
|
import { cfg, DEFAULTS, autoRenameEnabled, effectiveAutoContinue } from "../core/config.js";
|
|
9
9
|
import { pluginState } from "../core/domain.js";
|
|
10
10
|
import { continueJobs } from "../core/state.js";
|
|
11
|
-
import { sessionEventList, readColdSessionEvents } from "./events.js";
|
|
11
|
+
import { sessionEventList, readColdSessionEvents, readColdSessionSummary } from "./events.js";
|
|
12
12
|
import { hasOpenTurn, lastEventTime } from "./turn.js";
|
|
13
13
|
import { interruptionInfo } from "./interruption.js";
|
|
14
14
|
import { resolveSessionTitle, foldTitle, stripArchiveWsPrefix } from "../features/rename/title.js";
|
|
@@ -19,6 +19,23 @@ import {
|
|
|
19
19
|
} from "./list-cache.js";
|
|
20
20
|
import { isValidSessionId } from "./session-id.js";
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* 开放回合兜底:摘要读只保留有界尾部,若「崩溃残留的开放回合」的 `turn/start` 落在尾部之外,
|
|
24
|
+
* 官方闭合器就不会出现 —— 此时合成一条 `turn/end`(reason.kind="interrupted")再交给
|
|
25
|
+
* `interruptionInfo` 判定。这样中断的**结构与文案只在 interruption.js 维护一处**,
|
|
26
|
+
* 不在列表侧复制字面量(复制会漂移,也会被审计的注释规则拦下)。
|
|
27
|
+
*
|
|
28
|
+
* @returns {object|null} 与 interruptionInfo 同形的中断描述;非开放回合返回 null
|
|
29
|
+
*/
|
|
30
|
+
function openTurnInterruption(events, derived) {
|
|
31
|
+
if (!derived?.openTurn) return null;
|
|
32
|
+
const tail = Array.isArray(events) ? events : [];
|
|
33
|
+
return interruptionInfo([
|
|
34
|
+
...tail,
|
|
35
|
+
{ type: "turn/end", seq: derived.lastSeq, data: { reason: { kind: "interrupted" } } },
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
40
|
* 会话列表项 id 的唯一判据在 `./session-id.js`(零依赖叶子模块,便于单测直接覆盖)。
|
|
24
41
|
* 本文件四处使用它:归档集合净化 / 补全守卫 / persistence.list 回退校验 / emit 兜底。
|
|
@@ -136,24 +153,39 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
|
|
|
136
153
|
const batch = needInspect.slice(start, start + batchSize);
|
|
137
154
|
const settled = await Promise.allSettled(batch.map(async (entry) => {
|
|
138
155
|
let events = [];
|
|
156
|
+
let summaryDerived = null;
|
|
139
157
|
let inspectError = null;
|
|
140
158
|
let inspected = entry.meta;
|
|
141
159
|
try {
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
// 优先「按 zstd 帧流式读」(readColdSessionSummary):直接读日志文件、逐帧流式解压、
|
|
161
|
+
// 逐行增量解析 ⇒ 常量内存(实测大日志 heap 增量 ~24 MB、峰值 29 MB)。
|
|
162
|
+
// 对比:handle.read 每次都会把整份日志解压成 events 数组(同文件解压后 ~200 MB),
|
|
163
|
+
// 列表要遍历上百个冷会话 ⇒ 进程 OOM(线上实测 RSS 2.33 GB、/list >120s)。
|
|
164
|
+
const summary = await readColdSessionSummary(ctx, entry.meta.id);
|
|
165
|
+
if (summary) {
|
|
166
|
+
events = summary.events;
|
|
167
|
+
summaryDerived = summary.derived;
|
|
168
|
+
if (summary.meta) inspected = summary.meta;
|
|
169
|
+
} else if (persistence?.open) {
|
|
170
|
+
// 回退:日志文件没找到(布局/命名变化)时用官方 handle 读法,保证功能不退化
|
|
171
|
+
const loaded = await readColdSessionEvents(ctx, entry.meta.id);
|
|
172
|
+
events = loaded?.events ?? [];
|
|
173
|
+
if (loaded?.meta) inspected = loaded.meta;
|
|
174
|
+
}
|
|
149
175
|
} catch (cause) {
|
|
150
176
|
// 损坏/不可读的 artifact 以无标题呈现,不影响列表;错误信息带出便于诊断
|
|
151
177
|
inspectError = cause instanceof Error ? cause.message : String(cause);
|
|
152
178
|
}
|
|
153
179
|
const derived = {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
180
|
+
// 标题/时间由按帧读在流式过程中累加,与「全量读后 foldTitle/lastEventTime」等价
|
|
181
|
+
//(已用朴素全量计算对拍验证);拿不到摘要时退回对尾部事件求值。
|
|
182
|
+
title: summaryDerived?.title ?? foldTitle(events) ?? null,
|
|
183
|
+
updatedAt: summaryDerived?.updatedAt ?? lastEventTime(events) ?? inspected.createdAt ?? entry.meta.createdAt,
|
|
184
|
+
// 中断:摘要读已把「尾部 + 官方闭合器」拼好喂给 interruptionInfo;
|
|
185
|
+
// 若尾部不足以覆盖开放回合(闭合器没出现),用累加出的 openTurn 兜底 ——
|
|
186
|
+
// 兜底同样**走 interruptionInfo**(合成一条 turn/end 交给它判定),
|
|
187
|
+
// 不在本文件复制中断文案(文案归 interruption.js 维护,避免两处漂移)。
|
|
188
|
+
interruption: interruptionInfo(events) ?? openTurnInterruption(events, summaryDerived),
|
|
157
189
|
inspectError,
|
|
158
190
|
};
|
|
159
191
|
// 解析结果连同 revision 落盘:下次这条会话只要没被追加过就不再解析。
|
package/lib/zstd-frames.js
CHANGED
|
@@ -68,6 +68,41 @@ export async function decodeAllFrames(buf) {
|
|
|
68
68
|
return Buffer.concat(parts).toString('utf8');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* 多帧**流式**解码:逐帧解压,把每帧的解压分片**直接交给 onChunk**,不拼接、不驻留。
|
|
73
|
+
*
|
|
74
|
+
* 【为什么需要它】`decodeAllFrames` 会把所有帧的解压结果 `Buffer.concat` 成一个大字符串 ——
|
|
75
|
+
* 本机实测最大会话日志解压后 173 MB,逐帧拼接时内存峰值就是「整份文本 + 所有分片」;
|
|
76
|
+
* 若再 `JSON.parse` 成事件对象数组,峰值会到数百 MB(列表构建反复调用 ⇒ 进程 OOM)。
|
|
77
|
+
* 本函数把「解压」与「消费」解耦:调用方按分片增量处理(例如逐行 JSON.parse 后只留需要的字段),
|
|
78
|
+
* 内存峰值 = 单个分片 + 调用方自己的增量状态,与日志总大小无关。
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ 仍必须**逐帧**喂给解压器:DSH 会话日志是多帧容器,整体流式解码会丢后续帧(见 decodeAllFrames 注释)。
|
|
81
|
+
*
|
|
82
|
+
* @param {Buffer} buf 整份(压缩的)日志文件内容
|
|
83
|
+
* @param {(chunk: Buffer) => void} onChunk 每个解压分片的消费者(同步;抛错会中止并向上传播)
|
|
84
|
+
* @param {{signal?: AbortSignal}} [opts]
|
|
85
|
+
* @returns {Promise<{frames:number, tornStart?:number}>} 帧数;`tornStart` 存在表示尾部有截断帧
|
|
86
|
+
*/
|
|
87
|
+
export async function streamDecodedFrames(buf, onChunk, opts = {}) {
|
|
88
|
+
if (typeof onChunk !== "function") throw new TypeError("streamDecodedFrames 需要 onChunk 回调");
|
|
89
|
+
const { frames, tornStart } = scanZstdFrames(buf);
|
|
90
|
+
for (const fr of frames) {
|
|
91
|
+
opts.signal?.throwIfAborted();
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
const decompressor = createZstdDecompress();
|
|
94
|
+
decompressor.on("data", (chunk) => {
|
|
95
|
+
try { onChunk(chunk); } catch (error) { reject(error); }
|
|
96
|
+
});
|
|
97
|
+
decompressor.on("end", resolve);
|
|
98
|
+
decompressor.on("error", reject);
|
|
99
|
+
decompressor.write(buf.subarray(fr.start, fr.end));
|
|
100
|
+
decompressor.end();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return tornStart === undefined ? { frames: frames.length } : { frames: frames.length, tornStart };
|
|
104
|
+
}
|
|
105
|
+
|
|
71
106
|
/**
|
|
72
107
|
* 校验第一帧是否恰好一行 header(DSH assertZstdHeaderFrame 语义)。
|
|
73
108
|
* @returns {{ok:boolean, error?:string}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-conductor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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",
|
|
@@ -188,10 +188,10 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
|
|
|
188
188
|
- **`create_session` 缺省工作目录 = 调用方会话自己的 cwd**(发起它的那个 AI 在哪干活,新会话就落在哪)。**不要**依赖本体默认:那是 DSH 安装目录(实测 `/volume1/@appstore/DeepSeekHarness-NAS`),新会话落在程序目录里对干活毫无意义。显式给 `cwd` 或 `workspaceId` 可覆盖(二者互斥)。
|
|
189
189
|
- **dryRun(两个写操作工具都支持)**:只做校验与预览、**不产生任何副作用**。`create_session` 的 dryRun 还会**核对 provider/model 是否真在模型目录里**;`send_to_session` 的 dryRun 会**报告目标状态与限流余量**,且**不消耗限流配额**(可以先 dryRun 多次再真投)。dryRun 同样执行全部本地校验(自投/超长/跳数等照常被拒)。
|
|
190
190
|
- **状态含义**(`list_sessions` 输出):`running` = 正在跑回合;`idle` = 已挂载但空闲;`inactive` = 冷会话(未挂载,投递时会被唤醒)。缺 id 的脏条目会被跳过并如实标注(不会渲染成 `undefined`)。
|
|
191
|
-
-
|
|
192
|
-
- ⛔
|
|
193
|
-
- ✅
|
|
194
|
-
-
|
|
191
|
+
- **会话列表的内存边界(实测,别再踩)**:宿主的 `handle.read(offset, length)` **不能流式读** —— offset/length 只是**内存数组切片**,后端每次调用都会把整份日志读进来解压(`session-persistence-jsonl/src/storage.ts` 的 `readCurrent`)。实测单份日志 49.2 MB 压缩 → **173.3 MB 解压**(3.2~5.0×)。
|
|
192
|
+
- ⛔ **不要为了省内存改成分块 read**:内存峰值不变(每次都全量物化),耗时变 O(n²)(曾导致 `/list` >120s 超时,已撤回)。
|
|
193
|
+
- ✅ **1.2.2 起的做法:绕开 handle,按 zstd 帧流式读日志文件** —— `lib/sessions/events.js` 的 `readColdSessionSummary()`:`findSessionLogById` 定位文件 → `streamDecodedFrames()`(`lib/zstd-frames.js`,逐帧解压、分片直接交给消费者不拼接)→ `StringDecoder` 增量切行 → 逐行 `JSON.parse`,只累加 `title`/`updatedAt`/`openTurn` 并保留有界尾部。**实测 heap 增量 24 MB、峰值 29 MB**(原路径数百 MB),且与「全量解码 + `lastEventTime`/`hasOpenTurn`/`interruptionInfo`」逐项对拍一致。
|
|
194
|
+
- 另靠列表磁盘缓存的 `revision` 命中跳过重复解析(1.2.0 修掉了「`list()` 返回快照却被当裸 header 用、`revision` 恒 null ⇒ 缓存永不生效」)。
|
|
195
195
|
- **投递语义**:默认 `mode:"queue"` —— 目标空闲时**由本体自动唤醒**它跑一个回合;目标运行中则排到其下一回合边界。`mode:"steer"` 改为注入目标**正在跑**的那个回合。
|
|
196
196
|
- **实现口径(重要)**:全部走本体官方 `sessionController` 的 `@Remote` 方法 —— `create` / `selectModel` / `prompt`。**不自己写会话日志**:inbox 家族只有 `agent/inbox/spliced` 落盘,其 fold 对非法 splice(id 重复、start 越界)**直接抛错**,自己写等于拿对方会话的完整性冒险。`prompt` 的受理流程会先 resume 目标会话,唤醒语义由本体保证;`requestId` 是幂等键(本体持久化在那条被接受的消息上),重试传同键不会重复投递。
|
|
197
197
|
- **`create_session` 为什么是两步**:本体 `SessionCreateRequest` 只有 `workspaceId? / cwd? / sessionId? / agentPreset?`,**没有 model 字段**,所以必须 `create` → `selectModel`。任一步失败都如实回执(如「⚠️ 会话已建,但模型未设置」),**不谎报成功**。
|
|
@@ -227,6 +227,37 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
|
|
|
227
227
|
### 节约模式(token-saving-mode)
|
|
228
228
|
- 用户表达节约 token 意愿("省点""节约")时启用:直达答案不客套、结构优先、默认 ≤200 字或 10 行代码、禁冗余、复杂问题先结论后理由、高峰价格用户自己留意勿主动提醒。
|
|
229
229
|
|
|
230
|
+
## 十三、记忆取回(memory-recall,1.3.0 起)
|
|
231
|
+
|
|
232
|
+
**解决什么**:上下文被压缩、截断,或实例重启后快照未加载时,AI 会「失忆」——重复提问、重复劳动、
|
|
233
|
+
误解需求。而用户消息在上下文里占比极小(1M 上下文可能总共没超过 3 句),却最不该丢。
|
|
234
|
+
|
|
235
|
+
**两条通道**:
|
|
236
|
+
|
|
237
|
+
| 通道 | 触发 | 参数 |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| **自动注入** | 每回合比较上下文已用量:本次比上次下降 ≥ `retrieveMemoryMinDropRatio`(默认 0.10)⇒ 判定「疑似丢失」,取回后与用量行**合并成一条**注入(消息尾部) | 无(走配置) |
|
|
240
|
+
| **AI 工具 `recall_memory`** | AI 自己想回溯时主动调用 | `rounds` = 取几轮(默认取配置,上限 50) |
|
|
241
|
+
|
|
242
|
+
**取什么**:最近 N 轮(默认 3)的**用户消息文本** + 该轮 **AI 的第一条回应**(优先 reasoning,缺失取正文)。
|
|
243
|
+
|
|
244
|
+
- 以用户消息为锚:占比小但信息密度最高,是恢复上下文的支点;
|
|
245
|
+
- 要 AI 的第一条回应:那是「当时」对消息的理解,直接给结论,省得重新推测。
|
|
246
|
+
|
|
247
|
+
**实现要点(改这里前先读)**:
|
|
248
|
+
|
|
249
|
+
- 读取走 `readRecentUserTurns`(`lib/sessions/events.js`):按 zstd 帧**流式**读日志文件,
|
|
250
|
+
常量内存(实测 3 轮 27 ms / heap 6.1 MB);
|
|
251
|
+
- 锚点只认 `user/message` 且 `data.source.kind === "user"` —— **必须排除插件自身注入**
|
|
252
|
+
(`plugin:dsh-session-conductor`)与 `runtime-context`,否则注入块会被下一轮取回形成反馈环;
|
|
253
|
+
- `agent/inbox/spliced` 是同一句话的投递副本,**忽略**(否则一轮会数成两条);
|
|
254
|
+
- 同回合内(其间无 `turn/end`)连续多条用户消息**合并成一轮**;
|
|
255
|
+
- 无论是否触发都记账(`noteUsage`),否则读取失败会每回合反复触发;
|
|
256
|
+
- 任何一步失败(日志缺失/损坏、用量读不到、首回合无上次用量)都静默返回 null,绝不阻断回合。
|
|
257
|
+
|
|
258
|
+
**配置**:`retrieveMemory`(默认开)/ `retrieveMemoryRounds`(3) / `retrieveMemoryMinDropRatio`(0.10) /
|
|
259
|
+
`retrieveMemoryMinDropTokens`(0) / `retrieveMemoryMaxChars`(4000) / `retrieveMemoryMaxCharsPerItem`(800)。
|
|
260
|
+
|
|
230
261
|
## 相关
|
|
231
262
|
|
|
232
263
|
- dsh-session-conductor(使用手册,与本文档配套)
|