dsh-session-conductor 1.2.2 → 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 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
- const usageInjector = createContextUsageInjector({ enabled: cfg?.injectContextUsage !== false });
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
- if (!usageText) return decision;
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: usageText }],
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
  }
@@ -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
+ }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.2.2",
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",
@@ -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(使用手册,与本文档配套)