dsh-memory-palace 1.8.0-alpha.1 → 1.8.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,10 @@ dsh plugin --profile web remove dsh-memory-palace
124
124
  ## 版本历史
125
125
 
126
126
  - **1.8.0**
127
+ - **1.8.0-alpha.2**
128
+ - 🐛修复多会话并发导致写侧串台 @sunligh91
129
+ - 🐛删除会话分桶后遗留的三个全局状态死字段
130
+ - 🐛修复plan 模式禁写记忆串台导致其他会话也禁写记忆
127
131
  - **1.8.0-alpha.1**
128
132
  - 💥移除插件模式与智能模式,记忆写入统一为混合模式
129
133
  - 💥配置项 memoryMode / autoCaptureErrors / dailyLogRetentionDays 删除(旧值静默失效)
@@ -0,0 +1,32 @@
1
+ // plan 模式判定(按会话)—— 单一判定入口,供 section 注入 / 结算闸门 / 写工具 deny 共用。
2
+ //
3
+ // 宿主事实:plan 模式是**会话级**状态,不是应用级开关 ——
4
+ // - 状态以事件 append 到单个会话:`session.append('plan/mode', { active })`
5
+ // (packages/plan/plan-mode/src/index.ts:434、:450)
6
+ // - plan-mode 插件自身也按 session 保存待提交意图与已记录状态:
7
+ // `this.pendingIntents.set(session, …)` / `this.loggedActive(session)`(同文件 :425、:434)
8
+ // - 状态由该会话的事件流折叠:`if (event.type === 'plan/mode') …`(同文件 :154)
9
+ //
10
+ // 故本插件不得用进程级全局承载它:A 会话进 plan 会波及 B 会话 —— B 的记忆不落盘、
11
+ // B 的 system prompt 被注入 plan 提示、B 的写工具被 deny(回归测试 tests/test-planmode-crosstalk.mjs)。
12
+ //
13
+ // 判定顺序:
14
+ // ① 拿得到会话身份(session.id)→ **只看该会话的记录**:无记录 = 该会话从未进 plan = false。
15
+ // 🔴 此处**不得**回落到全局值——那等于用别的会话的 plan 状态来判定本会话,正是本次修的串台。
16
+ // ② 拿不到会话身份(宿主某些调用形态的 exec 不带 agent.session,如 tools/pre-execute 的载荷)
17
+ // → 回落全局 `state.planModeActive`(= 最近一次 plan/mode 事件的值;fail-safe:偏向拦写,
18
+ // 绝不会写错项目)。
19
+ //
20
+ // ⚠️ 写成**纯函数**而非 state 上的方法:多处单测用裸 mock state 构造
21
+ // `{ planModeActive: … }`(tests/test-session-cwd.mjs、tests/test-load.mjs),
22
+ // 若挂成 `state.isPlanMode()` 这些 mock 会直接 TypeError。纯函数 + 兜底读全局,两条路径都兼容。
23
+ export function planModeOf(state, session) {
24
+ const sid = session?.id;
25
+ if (sid) {
26
+ const map = state?.planModeBySession;
27
+ // 生产路径:有会话身份 + 有分桶表 → 只看该会话(无记录即 false,绝不看全局)。
28
+ // 单测的裸 mock state 没有 planModeBySession → 落到下面的全局兜底,保持旧行为兼容。
29
+ if (map && typeof map.get === "function") return !!map.get(sid);
30
+ }
31
+ return !!state?.planModeActive;
32
+ }
@@ -1,419 +1,423 @@
1
- // memory-palace v1.6.0 hybrid 模式:记忆子 agent(turn/end 触发的轻量工具循环)。
2
- // 职责(定案):每轮必跑,判定+产出一体——无重点纯文本收尾(1 次调用);有重点通过循环内
3
- // 白名单工具把格式化摘要写入今日工作日志(2-5 次调用)。只操作今日日志,三种操作:
4
- // 新增章节 / 章节内追加 / 标记删除(~~删除线~~ 墓碑),禁止覆盖整文件与物理删除。
5
- // 架构依据(v1.6.0 调研):ctx.llm.stream 支持 GenerateOptions.tools,finish.kind ===
6
- // 'tool-calls' 时用 BlockAssembler.message() 回喂 assistant 消息 + createToolResultMessage
7
- // 回喂工具结果,自建循环;ctx.subagents 需活 Agent 作父,插件不可用。
8
- import { readFileSync } from "node:fs";
9
- import { writeFile, mkdir } from "node:fs/promises";
10
- import { join, dirname } from "node:path";
11
- import { BlockAssembler, createUserMessage, createToolResultMessage } from "@deepseek-ai/dsh-llm";
12
- import { todayISO, readMdSync } from "../common/text.mjs";
13
- import { eventsFrom } from "../common/session.mjs";
14
- import { appendToSectionText, upsertSectionText, createSectionText, markEntryDeletedText, parseSections, locateSection, ensureLogHeader } from "../common/sections.mjs";
15
- import { HYBRID_PROACTIVE, SUBAGENT_SYSTEM } from "./prompts.mjs";
16
-
17
- export { HYBRID_PROACTIVE };
18
-
19
- const MAX_ROUNDS = 6; // 循环轮次上限(含首轮);超限降级
20
- const MAX_RETRY = 3; // 未知工具触发的催促重试预算(v1.6.3 定为 3)
21
- const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
22
-
23
- // 日志文件写锁(全局 promise 链串行化):子 agent 落盘与手动蒸馏按钮/其他写路径并发防护。
24
- let logWriteChain = Promise.resolve();
25
- function withLogLock(file, fn) {
26
- const run = logWriteChain.then(fn, fn);
27
- logWriteChain = run.catch(() => {});
28
- return run;
29
- }
30
-
31
- // 模型解析(与 distill.mjs resolveModel 同逻辑,改动须两处同步):summaryModel 注册表反查
32
- // > 前缀兜底 > 会话 requestHeader 兜底。复制而非导出原因:hybrid 走独立模块定案,不碰 distill。
33
- async function resolveModel(cfg, ctx, session) {
34
- const sm = (cfg.summaryModel || "").trim();
35
- if (sm) {
36
- try {
37
- const providers = typeof ctx.llm?.listProviders === "function" ? ctx.llm.listProviders() : [];
38
- for (const p of providers || []) {
39
- try {
40
- const models = await ctx.llm.listModels(p.id);
41
- const m = (models || []).find((x) => x.id === sm);
42
- if (m) return { provider: m.provider, model: m.id };
43
- } catch { /* 该 provider 无模型列表,跳过 */ }
44
- }
45
- const provider = sm.split("/")[0].trim();
46
- if (provider && sm.includes("/")) return { provider, model: sm };
47
- } catch { /* 注册表不可用,走会话兜底 */ }
48
- }
49
- const conf = session?.requestHeader?.()?.config;
50
- if (conf && conf.provider && conf.model) return { provider: conf.provider, model: conf.model };
51
- return null;
52
- }
53
-
54
- // 单次流式调用(带 tools)。finish.kind 为 error/aborted 时抛出由上层降级;tool-calls/stop 正常返回。
55
- async function streamTurn(ctx, params, timeoutMs) {
56
- const signal = AbortSignal.timeout(timeoutMs);
57
- const res = ctx.llm.stream({ ...params, signal });
58
- const asm = new BlockAssembler();
59
- for await (const ch of res) {
60
- signal.throwIfAborted();
61
- asm.push(ch);
62
- }
63
- const finish = asm.finish;
64
- if (finish.kind === "error" || finish.kind === "aborted") {
65
- throw new Error(finish.failure?.message || finish.kind);
66
- }
67
- return { finish, asm };
68
- }
69
-
70
- // 把本轮 surface 事件投影为消息数组(seq >= fromSeq;与 distillSessionCore 同源逻辑)。
71
- // v1.6.2-alpha.4:eventsFrom 兼容层(宿主 0.1.2-alpha.4 删除 Session.events getter)。
72
- // v1.7.0 特性3(投影去噪):user 侧排除【注入类消息】,只留真实用户对话。
73
- // 旧实现只看事件类型不看 source,导致首轮 lastSummarizedSeq 较小时把 agent-instructions
74
- // baseline(AGENTS.md,可达 ~19k 字符)、system-prompt 快照、skill-catalog、以及本插件
75
- // 自己的 E 投影全部当成"本轮对话"喂给子 agent(污染日志 + 白烧 token)。
76
- // 判定用【黑名单】而非白名单(fail-open):source 存在且非 'user' 才排除——
77
- // 注入类消息(agent-instructions / plugin / skill-catalog / skill-invocation …)在 DSH 中
78
- // 必带 source.kind,故排除效果与白名单等价;而 source 缺失时保留,避免误丢真实用户请求
79
- // 导致子 agent 空转(与 index.mjs 的 turnBuffer 采集口径保持一致)。
80
- // assistant/message 与 tool/result 原样保留。
81
- // 导出供单测直接断言(tests/test-v1.7.0.mjs),运行时仍只在 runMemorySubagent 内调用。
82
- export function projectTurnMessages(session, fromSeq) {
83
- const hist = [];
84
- for (const e of eventsFrom(session, fromSeq)) {
85
- if (!SURFACE.has(e.type)) continue;
86
- const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
87
- if (!m) continue;
88
- // deriveEventMessage 对 user/message 直接返回 event.data(含 source),故此处可安全读取。
89
- const srcKind = m.source?.kind;
90
- if (e.type === "user/message" && srcKind && srcKind !== "user") continue;
91
- hist.push(m);
92
- }
93
- return hist;
94
- }
95
-
96
- // 今日日志回喂正文:≤ subagentLogBudget 全文;超出仅章节目录(标题 + 条目数 + 首条),
97
- // 由子 agent 用 log_read_section 按需拉取(定案)。空日志(跨零点新一天)明确告知。
98
- function buildLogBlock(dir, budget) {
99
- const file = join(dir, `${todayISO()}.md`);
100
- const text = readMdSync(file);
101
- if (!text) {
102
- return { file, oversized: false, body: "(今日日志为空——新的一天,直接用 append/new_section 建章节写入即可。)" };
103
- }
104
- if (text.length <= budget) {
105
- return { file, oversized: false, body: text };
106
- }
107
- const { sections } = parseSections(text);
108
- const toc = sections
109
- .map((s) => {
110
- const items = s.lines.slice(1).filter((l) => l.trim());
111
- const first = items[0]?.trim().slice(0, 80) || "";
112
- return `- ${s.title}(${items.length} 条,首条:${first})`;
113
- })
114
- .join("\n");
115
- return {
116
- file,
117
- oversized: true,
118
- body:
119
- `(日志过大(${text.length} 字符),以下仅目录;需要某章节完整内容时调用 log_read_section 读取)\n${toc}`,
120
- };
121
- }
122
-
123
- // 循环内工具执行:仅两个白名单工具,其余一律报错(模型越权时结果回喂 isError,不中断循环)。
124
- async function execLoopTool(block, logFiles, cfg) {
125
- const name = block.name;
126
- let args = {};
127
- try {
128
- args = JSON.parse(block.arguments || "{}");
129
- } catch {
130
- return { ok: false, message: "工具参数不是合法 JSON。" };
131
- }
132
- try {
133
- if (name === "log_read_section") {
134
- // v1.6.0 A 改进:一次读取多个章节(sections 数组),合并返回;兼容旧单数 section 参数。
135
- const file = logFiles[0];
136
- const md = readMdSync(file) || "";
137
- const wants = Array.isArray(args.sections)
138
- ? args.sections.filter((s) => s && typeof s === "string")
139
- : args.section
140
- ? [args.section]
141
- : [];
142
- if (!wants.length) return { ok: false, message: "sections 不能为空(数组,可一次传多个章节标题)。" };
143
- const parts = [];
144
- const missing = [];
145
- for (const s of wants) {
146
- const loc = locateSection(md, String(s).trim());
147
- if (!loc) {
148
- missing.push(String(s).trim());
149
- continue;
150
- }
151
- parts.push(loc.lines.slice(loc.start, loc.end).join("\n").trim());
152
- }
153
- if (!parts.length) {
154
- return { ok: false, message: `未找到章节:${missing.join("、")}。可用章节:${parseSections(md).sections.map((s) => s.title).join("、") || "(日志为空)"}` };
155
- }
156
- const note = missing.length ? `\n\n(以下章节未找到:${missing.join("、")})` : "";
157
- return { ok: true, content: parts.join("\n\n---\n\n") + note };
158
- }
159
- if (name === "log_write_ops") {
160
- const ops = Array.isArray(args.ops) ? args.ops : [];
161
- if (!ops.length) return { ok: false, message: "ops 为空。" };
162
- return await applyLogOps(logFiles, todayISO(), ops);
163
- }
164
- return {
165
- ok: false,
166
- unknown: true,
167
- name,
168
- message:
169
- `工具 ${name} 不存在,调用已拒绝。本环境仅提供两个工具:\n` +
170
- `- log_write_ops:写入今日日志(ops 数组,支持 append / new_section / mark_delete)\n` +
171
- `- log_read_section:读取今日日志中指定章节(sections 数组)\n` +
172
- `请立即改用 log_write_ops 提交 ops 完成本轮写入;不要调用其他工具,也不要直接结束。`,
173
- };
174
- } catch (e) {
175
- return { ok: false, message: `执行失败:${e?.message || String(e)}` };
176
- }
177
- }
178
-
179
- // 把 ops 序列应用到每个日志文件(读-改-写,经全局写锁串行化)。op → sections 纯函数:
180
- // append=upsert(存在追加/不存在新增章节,"不强行匹配")、new_section=已存在拒绝、
181
- // mark_delete=删除线墓碑(结构行保护)。
182
- function applyLogOps(logFiles, dateStr, ops) {
183
- const apply = (md) => {
184
- let cur = md || "";
185
- const results = [];
186
- for (const op of ops) {
187
- if (!op || typeof op !== "object") {
188
- results.push({ op: "invalid", ok: false, message: "op 非对象" });
189
- continue;
190
- }
191
- const section = String(op.section ?? "").trim();
192
- if (!section) {
193
- results.push({ op: op.op, ok: false, message: "section 不能为空" });
194
- continue;
195
- }
196
- if (op.op === "append") {
197
- const r = upsertSectionText(cur, section, op.entry);
198
- results.push({ op: "append", section, ok: r.ok, message: r.reason });
199
- if (r.ok) cur = r.text;
200
- } else if (op.op === "new_section") {
201
- const r = createSectionText(cur, section, op.entry);
202
- results.push({ op: "new_section", section, ok: r.ok, message: r.reason });
203
- if (r.ok) cur = r.text;
204
- } else if (op.op === "mark_delete") {
205
- const r = markEntryDeletedText(cur, section, op.oldText);
206
- results.push({ op: "mark_delete", section, ok: r.ok, message: r.reason });
207
- if (r.ok && r.text !== undefined) cur = r.text;
208
- } else {
209
- results.push({ op: op.op, ok: false, message: "未知 op(仅 append/new_section/mark_delete)" });
210
- }
211
- }
212
- return { cur, results };
213
- };
214
- return withLogLock(logFiles[0], async () => {
215
- const all = [];
216
- for (const file of logFiles) {
217
- let md = "";
218
- try {
219
- md = readFileSync(file, "utf8");
220
- } catch { /* 文件尚不存在 */ }
221
- const { cur, results } = apply(md);
222
- // v1.7.0 特性4:落盘前确保文件头为规范 `# YYYY-MM-DD`(空文件/首行空/直接以 ## 开头
223
- // 三种形态都会补上;已有同日标题则原样)。仅在写入时补齐,不回填历史文件。
224
- const withHeader = ensureLogHeader(cur, dateStr);
225
- if (withHeader !== md) {
226
- await mkdir(dirname(file), { recursive: true });
227
- await writeFile(file, withHeader, "utf8");
228
- }
229
- all.push(...results);
230
- }
231
- const applied = all.filter((r) => r.ok).length;
232
- return { ok: true, applied, total: all.length, results: all, date: dateStr };
233
- });
234
- }
235
-
236
- // 记忆子 agent 主入口。每轮必跑(定案:不走闸门);plan 模式拦截由调用方(index.mjs _settle)负责。
237
- // 返回 { ok, mode }:mode = "noop"(无重点收尾)| "written"(有落盘)| "no-tool-support";
238
- // ok=false 表示整体失败(不推进断点、下一次 turn/end 自动补蒸;不降级为任何轻量写入)。
239
- export async function runMemorySubagent({ ctx, getConfig, paths, state, session, dirs, isError }) {
240
- const cfg = getConfig();
241
- // 调试日志(v1.6.0):与 distill.mjs 同款门控。dbgFail=失败/跳过留痕(无条件输出,排障可观测);
242
- // dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度/ops 结果计数)。
243
- // 隐私红线:只打计数/字符数/元数据/错误 message,绝不打印对话或日志正文文本。
244
- const dbgFail = (why, extra) => console.error(`[memory-palace] subagent warn: ${why}`, extra ? JSON.stringify(extra) : "");
245
- const dbg = (why, extra) => { if (!cfg.distillDebugLog) return; console.error(`[memory-palace][debug] subagent ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
246
- if (!cfg.enabled || !session || !dirs.length) {
247
- dbgFail("disabled or no session/dirs", { enabled: !!cfg.enabled, hasSession: !!session, dirs: dirs.length });
248
- return { ok: false, mode: "disabled" };
249
- }
250
- const resolved = await resolveModel(cfg, ctx, session);
251
- if (!resolved) {
252
- dbgFail("no model", { summaryModel: cfg.summaryModel });
253
- return { ok: false, mode: "no-model" };
254
- }
255
- const { provider, model } = resolved;
256
- dbg("entry", { sessionId: session.id, fromSeq: state.lastSummarizedSeq, provider, model });
257
-
258
- const hist = projectTurnMessages(session, state.lastSummarizedSeq);
259
- if (!hist.length) {
260
- dbg("no events in range", { fromSeq: state.lastSummarizedSeq });
261
- return { ok: true, mode: "noop" };
262
- }
263
-
264
- const log = buildLogBlock(dirs[0], cfg.subagentLogBudget || 20000);
265
- const logFiles = dirs.map((dir) => join(dir, `${todayISO()}.md`));
266
- dbg("log feed", { file: log.file, oversized: log.oversized, chars: log.body.length, logFiles: logFiles.length });
267
-
268
- // 首轮 user 消息:本轮对话已在 hist 中逐条保留,末尾追加日志回喂 + 任务指令。
269
- const brief = isError ? "\n\n(提示:本轮对话中出现过错误信号,错误现象值得记录。)" : "";
270
- const kickoff = createUserMessage({
271
- content: [
272
- {
273
- type: "text",
274
- text:
275
- `【今日工作日志(${todayISO()})】\n${log.body}\n\n` +
276
- `请按系统指令处理上面的本轮对话(对话见前文消息):判定是否有实质内容需要写入日志,并执行。${brief}`,
277
- },
278
- ],
279
- source: { kind: "plugin:dsh-memory-palace" },
280
- });
281
-
282
- const tools = [
283
- {
284
- name: "log_read_section",
285
- description:
286
- "读取今日工作日志中指定章节的完整内容(含标题行与全部条目)。sections 接受数组,可一次读取多个章节(合并返回,未找到的章节会注明)——需要多个章节时务必在单次调用中全部传入,避免多轮往返。仅在回喂为目录模式(日志过大)时需要调用;全文回喂时无需调用。",
287
- parameters: {
288
- type: "object",
289
- additionalProperties: false,
290
- properties: {
291
- sections: {
292
- type: "array",
293
- description: "要读取的章节标题列表(不含 `## ` 前缀),可一次传多个。",
294
- items: { type: "string" },
295
- },
296
- },
297
- required: ["sections"],
298
- },
299
- },
300
- {
301
- name: "log_write_ops",
302
- description:
303
- "批量写入今日工作日志(只能操作今日日志)。ops 数组,每个元素:\n" +
304
- '- {"op":"append","section":"章节名","entry":"条目文本(一行,结论先行+关键细节,【不带 - 前缀】——插件会自动补)"}:章节存在则末尾追加,不存在则自动新增章节(不要强行匹配语义不符的章节);\n' +
305
- '- {"op":"new_section","section":"新章节名","entry":"首条条目(不带 - 前缀)"}:显式新建章节(已存在会拒绝,改用 append);\n' +
306
- '- {"op":"mark_delete","section":"章节名","oldText":"日志中该条目原文(可带或不带 - 前缀)"}:把重复/过时/被推翻的旧条目标记为删除线墓碑(非物理删除),oldText 逐行匹配,匹配失败会被拒绝;\n' +
307
- "一条消息可带多个 ops 一次提交;entry 为客观第三人称单行文本(不带列表符),禁止对话体与任何标签。",
308
- parameters: {
309
- type: "object",
310
- additionalProperties: false,
311
- properties: {
312
- ops: {
313
- type: "array",
314
- description: "日志写操作数组(见 description)。",
315
- items: {
316
- type: "object",
317
- additionalProperties: false,
318
- properties: {
319
- op: { type: "string", description: "append | new_section | mark_delete" },
320
- section: { type: "string", description: "章节标题(不含 ## 前缀)。" },
321
- entry: { type: "string", description: "append/new_section 的条目文本(一行)。" },
322
- oldText: { type: "string", description: "mark_delete 的目标条目原文。" },
323
- },
324
- required: ["op", "section"],
325
- },
326
- },
327
- },
328
- required: ["ops"],
329
- },
330
- },
331
- ];
332
-
333
- const system = SUBAGENT_SYSTEM();
334
- const messages = [...hist, kickoff];
335
- const timeoutMs = cfg.summaryTimeoutMs || 60000;
336
-
337
- try {
338
- // v1.6.3:本轮工具执行统计——appliedWrites=成功落盘 ops 数、toolAttempts=调用过工具次数、
339
- // unknownHits/unknownNames=误调未知工具次数与点名(供 stop 三态判定与催促重试文案)。
340
- let appliedWrites = 0;
341
- let toolAttempts = 0;
342
- let unknownHits = 0;
343
- let retries = 0;
344
- const unknownNames = new Set();
345
- for (let round = 0; round < MAX_ROUNDS; round++) {
346
- const { finish, asm } = await streamTurn(ctx, { provider, model, system, messages, tools }, timeoutMs);
347
- if (finish.kind === "stop") {
348
- // v1.6.3 stop 三态判定(修复:误调未知工具后模型直接收尾 → 日志丢失且断点被无脑推进)。
349
- // ① 有落盘 → 正常完成,推进断点。
350
- if (appliedWrites > 0) {
351
- state.lastSummarizedSeq = session.seq;
352
- dbg("done", { round, mode: "written", appliedWrites });
353
- return { ok: true, mode: "written" };
354
- }
355
- // ② 从未调用任何工具 → 模型判定本轮无实质内容(noop),推进断点。
356
- if (toolAttempts === 0) {
357
- state.lastSummarizedSeq = session.seq;
358
- dbg("done", { round, mode: "noop" });
359
- return { ok: true, mode: "noop" };
360
- }
361
- // ③ 命中过未知工具且零写入 → 回告工具清单 + 催促重试(预算 MAX_RETRY 次)。
362
- if (unknownHits > 0 && retries < MAX_RETRY) {
363
- retries++;
364
- const names = [...unknownNames].join("、");
365
- messages.push(createUserMessage({
366
- content: [{ type: "text", text:
367
- `你刚才调用了不存在的工具(${names}),且尚未向日志写入任何内容,任务未完成。\n` +
368
- `本轮唯一可用的写入工具是 log_write_ops(ops 支持 append / new_section / mark_delete);` +
369
- `需要读取章节时用 log_read_section。请立即重新提交写入。\n` +
370
- `若你重新判定本轮确实没有值得记录的内容,请输出一行纯文本说明后结束。` }],
371
- source: { kind: "plugin:dsh-memory-palace" },
372
- }));
373
- dbgFail("retry after unknown tool", { retries, names });
374
- continue;
375
- }
376
- // ④ 重试预算耗尽 / 已知工具用错后自主放弃 → 零写入,不推进断点(下一轮 turn/end 补蒸)。
377
- dbgFail("no write applied", { round, toolAttempts, unknownHits, retries });
378
- return { ok: false, mode: "no-write" };
379
- }
380
- if (finish.kind !== "tool-calls") {
381
- // max-tokens 等异常收尾:返回失败,断点不推进——下一次 turn/end 子代理自动补蒸。
382
- // v1.6.0 定案:不降级为轻量原文写入(无格式原文会破坏日志章节化结构),宁缺勿滥。
383
- dbgFail(`finish ${finish.kind}`, { round });
384
- return { ok: false, mode: `finish-${finish.kind}` };
385
- }
386
- messages.push(asm.message({ kind: "model", provider, model }));
387
- const calls = asm.blocks().filter((b) => b.type === "tool-call");
388
- dbg("tool-calls round", { round, calls: calls.length, names: calls.map((c) => c.name) });
389
- for (const call of calls) {
390
- toolAttempts++;
391
- const result = await execLoopTool(call, logFiles, cfg);
392
- if (result.unknown) {
393
- unknownHits++;
394
- unknownNames.add(call.name);
395
- }
396
- if (result.ok && result.applied > 0) appliedWrites += result.applied;
397
- const body = result.ok && result.content !== undefined ? String(result.content) : JSON.stringify(result);
398
- messages.push(
399
- createToolResultMessage({
400
- callId: call.id,
401
- content: [{ type: "text", text: body }],
402
- isError: !result.ok,
403
- }),
404
- );
405
- if (!result.ok) dbgFail(`tool ${call.name} rejected`, { message: result.message, opApplied: result.applied });
406
- else dbg("tool ok", { name: call.name, applied: result.applied, total: result.total });
407
- }
408
- }
409
- dbgFail("max-rounds exceeded", { rounds: MAX_ROUNDS });
410
- return { ok: false, mode: "max-rounds" };
411
- } catch (e) {
412
- if (e?.name === "TimeoutError" || /timeout|abort/i.test(String(e?.message))) {
413
- dbgFail("timeout", { timeoutMs });
414
- return { ok: false, mode: "timeout" };
415
- }
416
- dbgFail("error", { message: e?.message || String(e) });
417
- return { ok: false, mode: "error", message: e?.message || String(e) };
418
- }
419
- }
1
+ // memory-palace v1.6.0 hybrid 模式:记忆子 agent(turn/end 触发的轻量工具循环)。
2
+ // 职责(定案):每轮必跑,判定+产出一体——无重点纯文本收尾(1 次调用);有重点通过循环内
3
+ // 白名单工具把格式化摘要写入今日工作日志(2-5 次调用)。只操作今日日志,三种操作:
4
+ // 新增章节 / 章节内追加 / 标记删除(~~删除线~~ 墓碑),禁止覆盖整文件与物理删除。
5
+ // 架构依据(v1.6.0 调研):ctx.llm.stream 支持 GenerateOptions.tools,finish.kind ===
6
+ // 'tool-calls' 时用 BlockAssembler.message() 回喂 assistant 消息 + createToolResultMessage
7
+ // 回喂工具结果,自建循环;ctx.subagents 需活 Agent 作父,插件不可用。
8
+ import { readFileSync } from "node:fs";
9
+ import { writeFile, mkdir } from "node:fs/promises";
10
+ import { join, dirname } from "node:path";
11
+ import { BlockAssembler, createUserMessage, createToolResultMessage } from "@deepseek-ai/dsh-llm";
12
+ import { todayISO, readMdSync } from "../common/text.mjs";
13
+ import { eventsFrom } from "../common/session.mjs";
14
+ import { appendToSectionText, upsertSectionText, createSectionText, markEntryDeletedText, parseSections, locateSection, ensureLogHeader } from "../common/sections.mjs";
15
+ import { HYBRID_PROACTIVE, SUBAGENT_SYSTEM } from "./prompts.mjs";
16
+
17
+ export { HYBRID_PROACTIVE };
18
+
19
+ const MAX_ROUNDS = 6; // 循环轮次上限(含首轮);超限降级
20
+ const MAX_RETRY = 3; // 未知工具触发的催促重试预算(v1.6.3 定为 3)
21
+ const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
22
+
23
+ // 日志文件写锁(全局 promise 链串行化):子 agent 落盘与手动蒸馏按钮/其他写路径并发防护。
24
+ let logWriteChain = Promise.resolve();
25
+ function withLogLock(file, fn) {
26
+ const run = logWriteChain.then(fn, fn);
27
+ logWriteChain = run.catch(() => {});
28
+ return run;
29
+ }
30
+
31
+ // 模型解析(与 distill.mjs resolveModel 同逻辑,改动须两处同步):summaryModel 注册表反查
32
+ // > 前缀兜底 > 会话 requestHeader 兜底。复制而非导出原因:hybrid 走独立模块定案,不碰 distill。
33
+ async function resolveModel(cfg, ctx, session) {
34
+ const sm = (cfg.summaryModel || "").trim();
35
+ if (sm) {
36
+ try {
37
+ const providers = typeof ctx.llm?.listProviders === "function" ? ctx.llm.listProviders() : [];
38
+ for (const p of providers || []) {
39
+ try {
40
+ const models = await ctx.llm.listModels(p.id);
41
+ const m = (models || []).find((x) => x.id === sm);
42
+ if (m) return { provider: m.provider, model: m.id };
43
+ } catch { /* 该 provider 无模型列表,跳过 */ }
44
+ }
45
+ const provider = sm.split("/")[0].trim();
46
+ if (provider && sm.includes("/")) return { provider, model: sm };
47
+ } catch { /* 注册表不可用,走会话兜底 */ }
48
+ }
49
+ const conf = session?.requestHeader?.()?.config;
50
+ if (conf && conf.provider && conf.model) return { provider: conf.provider, model: conf.model };
51
+ return null;
52
+ }
53
+
54
+ // 单次流式调用(带 tools)。finish.kind 为 error/aborted 时抛出由上层降级;tool-calls/stop 正常返回。
55
+ async function streamTurn(ctx, params, timeoutMs) {
56
+ const signal = AbortSignal.timeout(timeoutMs);
57
+ const res = ctx.llm.stream({ ...params, signal });
58
+ const asm = new BlockAssembler();
59
+ for await (const ch of res) {
60
+ signal.throwIfAborted();
61
+ asm.push(ch);
62
+ }
63
+ const finish = asm.finish;
64
+ if (finish.kind === "error" || finish.kind === "aborted") {
65
+ throw new Error(finish.failure?.message || finish.kind);
66
+ }
67
+ return { finish, asm };
68
+ }
69
+
70
+ // 把本轮 surface 事件投影为消息数组(seq >= fromSeq;与 distillSessionCore 同源逻辑)。
71
+ // v1.6.2-alpha.4:eventsFrom 兼容层(宿主 0.1.2-alpha.4 删除 Session.events getter)。
72
+ // v1.7.0 特性3(投影去噪):user 侧排除【注入类消息】,只留真实用户对话。
73
+ // 旧实现只看事件类型不看 source,导致首轮 lastSummarizedSeq 较小时把 agent-instructions
74
+ // baseline(AGENTS.md,可达 ~19k 字符)、system-prompt 快照、skill-catalog、以及本插件
75
+ // 自己的 E 投影全部当成"本轮对话"喂给子 agent(污染日志 + 白烧 token)。
76
+ // 判定用【黑名单】而非白名单(fail-open):source 存在且非 'user' 才排除——
77
+ // 注入类消息(agent-instructions / plugin / skill-catalog / skill-invocation …)在 DSH 中
78
+ // 必带 source.kind,故排除效果与白名单等价;而 source 缺失时保留,避免误丢真实用户请求
79
+ // 导致子 agent 空转(与 index.mjs 的 turnBuffer 采集口径保持一致)。
80
+ // assistant/message 与 tool/result 原样保留。
81
+ // 导出供单测直接断言(tests/test-v1.7.0.mjs),运行时仍只在 runMemorySubagent 内调用。
82
+ export function projectTurnMessages(session, fromSeq) {
83
+ const hist = [];
84
+ for (const e of eventsFrom(session, fromSeq)) {
85
+ if (!SURFACE.has(e.type)) continue;
86
+ const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
87
+ if (!m) continue;
88
+ // deriveEventMessage 对 user/message 直接返回 event.data(含 source),故此处可安全读取。
89
+ const srcKind = m.source?.kind;
90
+ if (e.type === "user/message" && srcKind && srcKind !== "user") continue;
91
+ hist.push(m);
92
+ }
93
+ return hist;
94
+ }
95
+
96
+ // 今日日志回喂正文:≤ subagentLogBudget 全文;超出仅章节目录(标题 + 条目数 + 首条),
97
+ // 由子 agent 用 log_read_section 按需拉取(定案)。空日志(跨零点新一天)明确告知。
98
+ function buildLogBlock(dir, budget) {
99
+ const file = join(dir, `${todayISO()}.md`);
100
+ const text = readMdSync(file);
101
+ if (!text) {
102
+ return { file, oversized: false, body: "(今日日志为空——新的一天,直接用 append/new_section 建章节写入即可。)" };
103
+ }
104
+ if (text.length <= budget) {
105
+ return { file, oversized: false, body: text };
106
+ }
107
+ const { sections } = parseSections(text);
108
+ const toc = sections
109
+ .map((s) => {
110
+ const items = s.lines.slice(1).filter((l) => l.trim());
111
+ const first = items[0]?.trim().slice(0, 80) || "";
112
+ return `- ${s.title}(${items.length} 条,首条:${first})`;
113
+ })
114
+ .join("\n");
115
+ return {
116
+ file,
117
+ oversized: true,
118
+ body:
119
+ `(日志过大(${text.length} 字符),以下仅目录;需要某章节完整内容时调用 log_read_section 读取)\n${toc}`,
120
+ };
121
+ }
122
+
123
+ // 循环内工具执行:仅两个白名单工具,其余一律报错(模型越权时结果回喂 isError,不中断循环)。
124
+ async function execLoopTool(block, logFiles, cfg) {
125
+ const name = block.name;
126
+ let args = {};
127
+ try {
128
+ args = JSON.parse(block.arguments || "{}");
129
+ } catch {
130
+ return { ok: false, message: "工具参数不是合法 JSON。" };
131
+ }
132
+ try {
133
+ if (name === "log_read_section") {
134
+ // v1.6.0 A 改进:一次读取多个章节(sections 数组),合并返回;兼容旧单数 section 参数。
135
+ const file = logFiles[0];
136
+ const md = readMdSync(file) || "";
137
+ const wants = Array.isArray(args.sections)
138
+ ? args.sections.filter((s) => s && typeof s === "string")
139
+ : args.section
140
+ ? [args.section]
141
+ : [];
142
+ if (!wants.length) return { ok: false, message: "sections 不能为空(数组,可一次传多个章节标题)。" };
143
+ const parts = [];
144
+ const missing = [];
145
+ for (const s of wants) {
146
+ const loc = locateSection(md, String(s).trim());
147
+ if (!loc) {
148
+ missing.push(String(s).trim());
149
+ continue;
150
+ }
151
+ parts.push(loc.lines.slice(loc.start, loc.end).join("\n").trim());
152
+ }
153
+ if (!parts.length) {
154
+ return { ok: false, message: `未找到章节:${missing.join("、")}。可用章节:${parseSections(md).sections.map((s) => s.title).join("、") || "(日志为空)"}` };
155
+ }
156
+ const note = missing.length ? `\n\n(以下章节未找到:${missing.join("、")})` : "";
157
+ return { ok: true, content: parts.join("\n\n---\n\n") + note };
158
+ }
159
+ if (name === "log_write_ops") {
160
+ const ops = Array.isArray(args.ops) ? args.ops : [];
161
+ if (!ops.length) return { ok: false, message: "ops 为空。" };
162
+ return await applyLogOps(logFiles, todayISO(), ops);
163
+ }
164
+ return {
165
+ ok: false,
166
+ unknown: true,
167
+ name,
168
+ message:
169
+ `工具 ${name} 不存在,调用已拒绝。本环境仅提供两个工具:\n` +
170
+ `- log_write_ops:写入今日日志(ops 数组,支持 append / new_section / mark_delete)\n` +
171
+ `- log_read_section:读取今日日志中指定章节(sections 数组)\n` +
172
+ `请立即改用 log_write_ops 提交 ops 完成本轮写入;不要调用其他工具,也不要直接结束。`,
173
+ };
174
+ } catch (e) {
175
+ return { ok: false, message: `执行失败:${e?.message || String(e)}` };
176
+ }
177
+ }
178
+
179
+ // 把 ops 序列应用到每个日志文件(读-改-写,经全局写锁串行化)。op → sections 纯函数:
180
+ // append=upsert(存在追加/不存在新增章节,"不强行匹配")、new_section=已存在拒绝、
181
+ // mark_delete=删除线墓碑(结构行保护)。
182
+ function applyLogOps(logFiles, dateStr, ops) {
183
+ const apply = (md) => {
184
+ let cur = md || "";
185
+ const results = [];
186
+ for (const op of ops) {
187
+ if (!op || typeof op !== "object") {
188
+ results.push({ op: "invalid", ok: false, message: "op 非对象" });
189
+ continue;
190
+ }
191
+ const section = String(op.section ?? "").trim();
192
+ if (!section) {
193
+ results.push({ op: op.op, ok: false, message: "section 不能为空" });
194
+ continue;
195
+ }
196
+ if (op.op === "append") {
197
+ const r = upsertSectionText(cur, section, op.entry);
198
+ results.push({ op: "append", section, ok: r.ok, message: r.reason });
199
+ if (r.ok) cur = r.text;
200
+ } else if (op.op === "new_section") {
201
+ const r = createSectionText(cur, section, op.entry);
202
+ results.push({ op: "new_section", section, ok: r.ok, message: r.reason });
203
+ if (r.ok) cur = r.text;
204
+ } else if (op.op === "mark_delete") {
205
+ const r = markEntryDeletedText(cur, section, op.oldText);
206
+ results.push({ op: "mark_delete", section, ok: r.ok, message: r.reason });
207
+ if (r.ok && r.text !== undefined) cur = r.text;
208
+ } else {
209
+ results.push({ op: op.op, ok: false, message: "未知 op(仅 append/new_section/mark_delete)" });
210
+ }
211
+ }
212
+ return { cur, results };
213
+ };
214
+ return withLogLock(logFiles[0], async () => {
215
+ const all = [];
216
+ for (const file of logFiles) {
217
+ let md = "";
218
+ try {
219
+ md = readFileSync(file, "utf8");
220
+ } catch { /* 文件尚不存在 */ }
221
+ const { cur, results } = apply(md);
222
+ // v1.7.0 特性4:落盘前确保文件头为规范 `# YYYY-MM-DD`(空文件/首行空/直接以 ## 开头
223
+ // 三种形态都会补上;已有同日标题则原样)。仅在写入时补齐,不回填历史文件。
224
+ const withHeader = ensureLogHeader(cur, dateStr);
225
+ if (withHeader !== md) {
226
+ await mkdir(dirname(file), { recursive: true });
227
+ await writeFile(file, withHeader, "utf8");
228
+ }
229
+ all.push(...results);
230
+ }
231
+ const applied = all.filter((r) => r.ok).length;
232
+ return { ok: true, applied, total: all.length, results: all, date: dateStr };
233
+ });
234
+ }
235
+
236
+ // 记忆子 agent 主入口。每轮必跑(定案:不走闸门);plan 模式拦截由调用方(index.mjs _settle)负责。
237
+ // 返回 { ok, mode }:mode = "noop"(无重点收尾)| "written"(有落盘)| "no-tool-support";
238
+ // ok=false 表示整体失败(不推进断点、下一次 turn/end 自动补蒸;不降级为任何轻量写入)。
239
+ export async function runMemorySubagent({ ctx, getConfig, paths, state, session, dirs, isError, getSeq, setSeq }) {
240
+ const cfg = getConfig();
241
+ // 增量断点按会话取用:调用方(index.mjs)注入 getSeq/setSeq(按 sessionId 分桶)。
242
+ // 未注入时回落全局 state.lastSummarizedSeq,保持旧行为与向后兼容。
243
+ const readSeq = typeof getSeq === "function" ? getSeq : () => state.lastSummarizedSeq;
244
+ const writeSeq = typeof setSeq === "function" ? setSeq : (v) => { state.lastSummarizedSeq = v; };
245
+ // 调试日志(v1.6.0):与 distill.mjs 同款门控。dbgFail=失败/跳过留痕(无条件输出,排障可观测);
246
+ // dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度/ops 结果计数)。
247
+ // 隐私红线:只打计数/字符数/元数据/错误 message,绝不打印对话或日志正文文本。
248
+ const dbgFail = (why, extra) => console.error(`[memory-palace] subagent warn: ${why}`, extra ? JSON.stringify(extra) : "");
249
+ const dbg = (why, extra) => { if (!cfg.distillDebugLog) return; console.error(`[memory-palace][debug] subagent ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
250
+ if (!cfg.enabled || !session || !dirs.length) {
251
+ dbgFail("disabled or no session/dirs", { enabled: !!cfg.enabled, hasSession: !!session, dirs: dirs.length });
252
+ return { ok: false, mode: "disabled" };
253
+ }
254
+ const resolved = await resolveModel(cfg, ctx, session);
255
+ if (!resolved) {
256
+ dbgFail("no model", { summaryModel: cfg.summaryModel });
257
+ return { ok: false, mode: "no-model" };
258
+ }
259
+ const { provider, model } = resolved;
260
+ dbg("entry", { sessionId: session.id, fromSeq: readSeq(), provider, model });
261
+
262
+ const hist = projectTurnMessages(session, readSeq());
263
+ if (!hist.length) {
264
+ dbg("no events in range", { fromSeq: readSeq() });
265
+ return { ok: true, mode: "noop" };
266
+ }
267
+
268
+ const log = buildLogBlock(dirs[0], cfg.subagentLogBudget || 20000);
269
+ const logFiles = dirs.map((dir) => join(dir, `${todayISO()}.md`));
270
+ dbg("log feed", { file: log.file, oversized: log.oversized, chars: log.body.length, logFiles: logFiles.length });
271
+
272
+ // 首轮 user 消息:本轮对话已在 hist 中逐条保留,末尾追加日志回喂 + 任务指令。
273
+ const brief = isError ? "\n\n(提示:本轮对话中出现过错误信号,错误现象值得记录。)" : "";
274
+ const kickoff = createUserMessage({
275
+ content: [
276
+ {
277
+ type: "text",
278
+ text:
279
+ `【今日工作日志(${todayISO()})】\n${log.body}\n\n` +
280
+ `请按系统指令处理上面的本轮对话(对话见前文消息):判定是否有实质内容需要写入日志,并执行。${brief}`,
281
+ },
282
+ ],
283
+ source: { kind: "plugin:dsh-memory-palace" },
284
+ });
285
+
286
+ const tools = [
287
+ {
288
+ name: "log_read_section",
289
+ description:
290
+ "读取今日工作日志中指定章节的完整内容(含标题行与全部条目)。sections 接受数组,可一次读取多个章节(合并返回,未找到的章节会注明)——需要多个章节时务必在单次调用中全部传入,避免多轮往返。仅在回喂为目录模式(日志过大)时需要调用;全文回喂时无需调用。",
291
+ parameters: {
292
+ type: "object",
293
+ additionalProperties: false,
294
+ properties: {
295
+ sections: {
296
+ type: "array",
297
+ description: "要读取的章节标题列表(不含 `## ` 前缀),可一次传多个。",
298
+ items: { type: "string" },
299
+ },
300
+ },
301
+ required: ["sections"],
302
+ },
303
+ },
304
+ {
305
+ name: "log_write_ops",
306
+ description:
307
+ "批量写入今日工作日志(只能操作今日日志)。ops 数组,每个元素:\n" +
308
+ '- {"op":"append","section":"章节名","entry":"条目文本(一行,结论先行+关键细节,【不带 - 前缀】——插件会自动补)"}:章节存在则末尾追加,不存在则自动新增章节(不要强行匹配语义不符的章节);\n' +
309
+ '- {"op":"new_section","section":"新章节名","entry":"首条条目(不带 - 前缀)"}:显式新建章节(已存在会拒绝,改用 append);\n' +
310
+ '- {"op":"mark_delete","section":"章节名","oldText":"日志中该条目原文(可带或不带 - 前缀)"}:把重复/过时/被推翻的旧条目标记为删除线墓碑(非物理删除),oldText 逐行匹配,匹配失败会被拒绝;\n' +
311
+ "一条消息可带多个 ops 一次提交;entry 为客观第三人称单行文本(不带列表符),禁止对话体与任何标签。",
312
+ parameters: {
313
+ type: "object",
314
+ additionalProperties: false,
315
+ properties: {
316
+ ops: {
317
+ type: "array",
318
+ description: "日志写操作数组(见 description)。",
319
+ items: {
320
+ type: "object",
321
+ additionalProperties: false,
322
+ properties: {
323
+ op: { type: "string", description: "append | new_section | mark_delete" },
324
+ section: { type: "string", description: "章节标题(不含 ## 前缀)。" },
325
+ entry: { type: "string", description: "append/new_section 的条目文本(一行)。" },
326
+ oldText: { type: "string", description: "mark_delete 的目标条目原文。" },
327
+ },
328
+ required: ["op", "section"],
329
+ },
330
+ },
331
+ },
332
+ required: ["ops"],
333
+ },
334
+ },
335
+ ];
336
+
337
+ const system = SUBAGENT_SYSTEM();
338
+ const messages = [...hist, kickoff];
339
+ const timeoutMs = cfg.summaryTimeoutMs || 60000;
340
+
341
+ try {
342
+ // v1.6.3:本轮工具执行统计——appliedWrites=成功落盘 ops 数、toolAttempts=调用过工具次数、
343
+ // unknownHits/unknownNames=误调未知工具次数与点名(供 stop 三态判定与催促重试文案)。
344
+ let appliedWrites = 0;
345
+ let toolAttempts = 0;
346
+ let unknownHits = 0;
347
+ let retries = 0;
348
+ const unknownNames = new Set();
349
+ for (let round = 0; round < MAX_ROUNDS; round++) {
350
+ const { finish, asm } = await streamTurn(ctx, { provider, model, system, messages, tools }, timeoutMs);
351
+ if (finish.kind === "stop") {
352
+ // v1.6.3 stop 三态判定(修复:误调未知工具后模型直接收尾 → 日志丢失且断点被无脑推进)。
353
+ // ① 有落盘 → 正常完成,推进断点。
354
+ if (appliedWrites > 0) {
355
+ writeSeq(session.seq);
356
+ dbg("done", { round, mode: "written", appliedWrites });
357
+ return { ok: true, mode: "written" };
358
+ }
359
+ // ② 从未调用任何工具 → 模型判定本轮无实质内容(noop),推进断点。
360
+ if (toolAttempts === 0) {
361
+ writeSeq(session.seq);
362
+ dbg("done", { round, mode: "noop" });
363
+ return { ok: true, mode: "noop" };
364
+ }
365
+ // ③ 命中过未知工具且零写入 → 回告工具清单 + 催促重试(预算 MAX_RETRY 次)。
366
+ if (unknownHits > 0 && retries < MAX_RETRY) {
367
+ retries++;
368
+ const names = [...unknownNames].join("、");
369
+ messages.push(createUserMessage({
370
+ content: [{ type: "text", text:
371
+ `你刚才调用了不存在的工具(${names}),且尚未向日志写入任何内容,任务未完成。\n` +
372
+ `本轮唯一可用的写入工具是 log_write_ops(ops 支持 append / new_section / mark_delete);` +
373
+ `需要读取章节时用 log_read_section。请立即重新提交写入。\n` +
374
+ `若你重新判定本轮确实没有值得记录的内容,请输出一行纯文本说明后结束。` }],
375
+ source: { kind: "plugin:dsh-memory-palace" },
376
+ }));
377
+ dbgFail("retry after unknown tool", { retries, names });
378
+ continue;
379
+ }
380
+ // ④ 重试预算耗尽 / 已知工具用错后自主放弃 → 零写入,不推进断点(下一轮 turn/end 补蒸)。
381
+ dbgFail("no write applied", { round, toolAttempts, unknownHits, retries });
382
+ return { ok: false, mode: "no-write" };
383
+ }
384
+ if (finish.kind !== "tool-calls") {
385
+ // max-tokens 等异常收尾:返回失败,断点不推进——下一次 turn/end 子代理自动补蒸。
386
+ // v1.6.0 定案:不降级为轻量原文写入(无格式原文会破坏日志章节化结构),宁缺勿滥。
387
+ dbgFail(`finish ${finish.kind}`, { round });
388
+ return { ok: false, mode: `finish-${finish.kind}` };
389
+ }
390
+ messages.push(asm.message({ kind: "model", provider, model }));
391
+ const calls = asm.blocks().filter((b) => b.type === "tool-call");
392
+ dbg("tool-calls round", { round, calls: calls.length, names: calls.map((c) => c.name) });
393
+ for (const call of calls) {
394
+ toolAttempts++;
395
+ const result = await execLoopTool(call, logFiles, cfg);
396
+ if (result.unknown) {
397
+ unknownHits++;
398
+ unknownNames.add(call.name);
399
+ }
400
+ if (result.ok && result.applied > 0) appliedWrites += result.applied;
401
+ const body = result.ok && result.content !== undefined ? String(result.content) : JSON.stringify(result);
402
+ messages.push(
403
+ createToolResultMessage({
404
+ callId: call.id,
405
+ content: [{ type: "text", text: body }],
406
+ isError: !result.ok,
407
+ }),
408
+ );
409
+ if (!result.ok) dbgFail(`tool ${call.name} rejected`, { message: result.message, opApplied: result.applied });
410
+ else dbg("tool ok", { name: call.name, applied: result.applied, total: result.total });
411
+ }
412
+ }
413
+ dbgFail("max-rounds exceeded", { rounds: MAX_ROUNDS });
414
+ return { ok: false, mode: "max-rounds" };
415
+ } catch (e) {
416
+ if (e?.name === "TimeoutError" || /timeout|abort/i.test(String(e?.message))) {
417
+ dbgFail("timeout", { timeoutMs });
418
+ return { ok: false, mode: "timeout" };
419
+ }
420
+ dbgFail("error", { message: e?.message || String(e) });
421
+ return { ok: false, mode: "error", message: e?.message || String(e) };
422
+ }
423
+ }
@@ -11,6 +11,7 @@ import { join, dirname } from "node:path";
11
11
  import { readFileSync } from "node:fs";
12
12
  import { expandHome, toHomeShort, readMdSync, nowStamp, normLine } from "../common/text.mjs";
13
13
  import { appendToSectionText, upsertSectionText, replaceSectionText, markEntryDeletedText, locateSection } from "../common/sections.mjs";
14
+ import { planModeOf } from "../common/planmode.mjs";
14
15
 
15
16
  const REORG_MARK = "memory-palace:last-reorg:";
16
17
  const REORG_MARK_RE = new RegExp(`\\n?<!--\\s*${REORG_MARK}[^>]*-->\\s*$`);
@@ -315,7 +316,8 @@ export function attachHybridGuards(ctx, getConfig, paths, state) {
315
316
  ctx.on("tools/pre-execute", async (exec, next) => {
316
317
  const name = exec?.name;
317
318
  if (!HYBRID_WRITE_TOOLS.has(name)) return next();
318
- if (state.planModeActive) {
319
+ // 按**会话**判定(plan 模式是会话级状态,见 common/planmode.mjs)——别的会话进 plan 不该 deny 本会话
320
+ if (planModeOf(state, exec?.agent?.session)) {
319
321
  return { kind: "deny", reason: "plan 模式下禁止写入记忆" };
320
322
  }
321
323
  if (name !== "memory_reorganize") return next();
package/lib/index.js CHANGED
@@ -30,6 +30,7 @@ import Schema from "@deepseek-ai/schemastery";
30
30
  // settingsNamespace,设置节注册改用 SettingsProvider 实例方法 installSection(经 ctx.inject(["settings"]))。
31
31
  import { extractText, extractToolErrorText } from "./common/text.mjs";
32
32
  import { createPaths } from "./common/paths.mjs";
33
+ import { planModeOf } from "./common/planmode.mjs";
33
34
  import { createRecords } from "./common/records.mjs";
34
35
  import { createDistill } from "./distill.mjs";
35
36
  import { registerTools } from "./tools.mjs";
@@ -175,21 +176,29 @@ export function apply(ctx, config) {
175
176
  const state = {
176
177
  activeCwd: null, // 最近活跃 session 的 cwd(session/event 更新)
177
178
  activeSession: null, // 最近活跃 session 引用
178
- turnBuffer: [], // 每轮缓冲 {role,text},role ∈ user/assistant/tool
179
- sawErrorTurn: false, // 跨 turn 累积的 request 级错误信号(作为 isError 传给记忆子代理)
180
- settleTimer: null, // debounce 计时器
181
- // 记忆子代理的增量断点(session 事件 seq)——v1.8.0 核查确认:本字段**不是** smart 模式专属,
182
- // hybrid 子代理自己用它(hybrid/subagent.mjs 读取 + 成功时推进),故保留。
183
- lastSummarizedSeq: -1,
184
- breakpointSessionId: null, // 断点所属会话(跨会话必须重置,见 session/event 处理)
185
- planModeActive: false, // 计划模式:禁写记忆(硬编码默认,无开关;仅经 session/event 的 plan/mode 翻转;未装 dsh-plan-mode 永不触发=不拦截)
179
+ // ---- 每会话状态分桶(修复:结算写入错项目)----
180
+ // 下面三者的语义都是「当前请求所属会话」,原实现放在进程级全局上,而 session/event 是无条件
181
+ // 覆盖的 → 多会话并发时必然互相污染:A 会话的内容被结算进 B 项目目录。按 sessionId 分桶后互不干扰。
182
+ // 被取代并删除的全局单值字段(勿再引回):`turnBuffer` / `sawErrorTurn` / `settleTimer`
183
+ // 对应前三个 Map;`lastSummarizedSeq` / `breakpointSessionId` 对应 seqBySession。
184
+ // 注:seqBySession 是记忆子代理的**增量断点**(session 事件 seq),不是已删的 smart 模式残留
185
+ // ——`hybrid/subagent.mjs` 在调用方未注入 getSeq/setSeq 时仍会回落到全局 state.lastSummarizedSeq
186
+ // (单测 tests/test-hybrid.mjs 就走该回退路径)。
187
+ buffersBySession: new Map(), // sessionId -> { turnBuffer, sawErrorTurn, session }
188
+ settleTimers: new Map(), // sessionId -> debounce timer
189
+ seqBySession: new Map(), // sessionId -> 记忆子代理增量断点
190
+ // plan 模式:禁写记忆(硬编码默认,无开关)。
191
+ // 宿主是**会话级**状态(`session.append('plan/mode')`,见 common/planmode.mjs),故按会话记;
192
+ // `planModeActive` 仅作「拿不到会话上下文」时的兜底值。未装 dsh-plan-mode 则永不触发 = 不拦截。
193
+ planModeBySession: new Map(), // sessionId -> 该会话是否处于 plan 模式(判定优先看它)
194
+ planModeActive: false, // 兜底:最近一次 plan/mode 事件的值,供拿不到会话上下文的场合使用
186
195
  // v1.7.2:会话 → agent 预设 id(仅"切换预设"事件写入;创建头另在 presetOfSession 里兜底读)。
187
196
  // 只增不减:会话结束后残留几条字符串,代价可忽略;切回同一 session 断言预设不变(宿主侧保证)。
188
197
  presetBySession: new Map(),
189
198
  // v1.7.0:injectedSessionIds 已删除——记忆正文改经 E 投影(src/projection.mjs)注入为
190
199
  // 常驻消息,compaction 后由投影自动重注,不再需要"仅首次注入"的会话级标记。
191
- // v1.8.0:recentAgentWrote(原防闲聊闸门信号)与 lastSummarizedSeq / summarySessionId
192
- // (原 smart 模式增量断点)随 plugin / smart 两模式一并删除。
200
+ // v1.8.0:recentAgentWrote(原防闲聊闸门信号)与 summarySessionId(原 smart 模式会话标记)
201
+ // 随 plugin / smart 两模式一并删除。
193
202
  };
194
203
  const TURN_BUFFER_MAX = 30;
195
204
  // v1.1.3:字符上限提升到 30k(长工具型 request 的工具结果动辄数千字符,6k 上限会触发
@@ -326,15 +335,29 @@ export function apply(ctx, config) {
326
335
  // 触发时 buffer 只剩残缺片段(无 user 请求、无 tool/result),工具期错误信号(extractToolErrorText)
327
336
  // 会取不到,传给记忆子代理的 isError 随之失准。
328
337
  // 结算策略:debounce(安静期后只结算一次)+ 新 user/message 时立即结算上一个 request。
338
+ // 取/建某会话的状态桶(sessionId 缺失时归入同一个兜底桶,保持旧行为不失效)。
339
+ function bucketOf(sessionId) {
340
+ const id = sessionId || "__nosession__";
341
+ let b = state.buffersBySession.get(id);
342
+ if (!b) {
343
+ b = { turnBuffer: [], sawErrorTurn: false, session: null };
344
+ state.buffersBySession.set(id, b);
345
+ }
346
+ return b;
347
+ }
348
+
329
349
  ctx.on("session/event", (session, event) => {
330
350
  if (session) state.activeSession = session;
331
351
  if (session?.header?.cwd) state.activeCwd = session.header.cwd;
352
+ const sid = session?.id || "__nosession__";
353
+ const bucket = bucketOf(sid);
354
+ if (session) bucket.session = session;
332
355
  // 切换 session 时重置记忆子代理的增量断点(新会话从 firstLiveSeq 起算)。
333
356
  // 必须做:seq 按会话独立编号,沿用上一会话的断点会让 projectTurnMessages 取到空区间 →
334
357
  // 子代理静默 noop、日志永不落盘(v1.6.0 起沿用至今;v1.8.0 核查确认此处不是 smart 残留)。
335
- if (session && session.id !== state.breakpointSessionId) {
336
- state.breakpointSessionId = session.id;
337
- state.lastSummarizedSeq = session.firstLiveSeq;
358
+ // 断点同样按会话分桶:全局单值会让并发会话互相重置。
359
+ if (session && !state.seqBySession.has(sid)) {
360
+ state.seqBySession.set(sid, session.firstLiveSeq);
338
361
  }
339
362
  const type = event?.type;
340
363
  // v1.7.2:预设切换(仅空会话可切;宿主在切换提交后追加该事件)。记进 Map 供 isSessionSilent 读取。
@@ -349,13 +372,20 @@ export function apply(ctx, config) {
349
372
  }
350
373
  // 计划模式:禁写记忆(硬编码默认行为,无开关;不依赖 dsh-plan-mode 服务,未装则永不触发=安全降级=不拦截)
351
374
  if (type === "plan/mode") {
352
- state.planModeActive = !!(event?.data?.active);
375
+ // plan 模式是**会话级**事实(宿主 `session.append('plan/mode')`)→ 必须按会话记:
376
+ // 用全局单值会让 A 的 plan 状态波及 B —— B 记忆不落盘 / B 的 prompt 被注入 plan 提示 / B 写工具被拒。
377
+ const on = !!(event?.data?.active);
378
+ // 兜底值【始终】跟随最近一次事件:工具闸门等拿不到会话上下文的场合靠它判定
379
+ // (若只在"无会话事件"时才写,兜底会恒为 false → 那些场合漏拦)。
380
+ state.planModeActive = on;
381
+ // 会话级精确记录:有会话身份时判定只看这张表(无记录 = 该会话未进 plan = false,不回落全局)。
382
+ if (session?.id) state.planModeBySession.set(session.id, on);
353
383
  return;
354
384
  }
355
385
  if (type === "user/message" || type === "assistant/message" || type === "tool/result") {
356
386
  // v1.7.0 特性1 连带修复:turnBuffer 只采集【真实用户消息】。
357
387
  // E 投影的 user/message(source.kind === 'plugin:dsh-memory-palace')也在 SURFACE 里,若不过滤
358
- // 会被采集进 buffer,并触发下方 `type === "user/message" && state.turnBuffer.length` 的
388
+ // 会被采集进 buffer,并触发下方 `type === "user/message" && bucket.turnBuffer.length` 的
359
389
  // _flushTurn() —— 打乱 request 边界、污染回喂子代理的对话内容。
360
390
  // 判定用【黑名单】而非白名单(fail-open):只在 source 明确存在且非 'user' 时排除。
361
391
  // 理由:source 缺失时若按"非 user"丢弃,真实用户消息会被漏掉 → request 边界与错误信号全错
@@ -366,21 +396,23 @@ export function apply(ctx, config) {
366
396
  if (text) {
367
397
  const role = type === "tool/result" ? "tool" : type === "user/message" ? "user" : "assistant";
368
398
  // 新用户请求到来:先结算并清空上一个 request(若存在),避免与本请求混淆。
369
- if (type === "user/message" && state.turnBuffer.length) _flushTurn();
370
- state.turnBuffer.push({ role, text });
371
- if (state.turnBuffer.length > TURN_BUFFER_MAX) state.turnBuffer = state.turnBuffer.slice(-TURN_BUFFER_MAX);
372
- const total = state.turnBuffer.reduce((n, b) => n + b.text.length, 0);
399
+ // 缓冲按会话分桶:否则 A 会话的消息会混进 B 会话的结算,连同内容一起写错项目。
400
+ if (type === "user/message" && bucket.turnBuffer.length) _flushTurn(sid, bucket);
401
+ bucket.turnBuffer.push({ role, text });
402
+ if (bucket.turnBuffer.length > TURN_BUFFER_MAX) bucket.turnBuffer = bucket.turnBuffer.slice(-TURN_BUFFER_MAX);
403
+ const total = bucket.turnBuffer.reduce((n, b) => n + b.text.length, 0);
373
404
  if (total > TURN_BUFFER_CHAR_CAP) {
374
405
  // v1.1.3 修复:溢出时【保留尾部最近内容】而非替换成占位——占位会让工具期错误检测
375
406
  // (extractToolErrorText)失效,传给记忆子代理的 isError 失准、错误现象漏记。
376
- state.turnBuffer = state.turnBuffer.slice(-8);
407
+ bucket.turnBuffer = bucket.turnBuffer.slice(-8);
377
408
  }
378
409
  }
379
410
  } else if (type === "turn/end") {
380
411
  // 累积 request 级错误信号(跨 turn 合并),供结算时统一判定并传给记忆子代理。
381
- if (event?.data?.reason?.kind === "error") state.sawErrorTurn = true;
382
- // 不清空 buffer;用 debounce 在整段请求安静后结算一次。
383
- _scheduleSettle();
412
+ // 同样按会话分桶:全局标志会让 A 会话的错误把 B 会话的日志标成 isError。
413
+ if (event?.data?.reason?.kind === "error") bucket.sawErrorTurn = true;
414
+ // 不清空 buffer;用 debounce 在整段请求安静后结算一次(定时器按会话独立)。
415
+ _scheduleSettle(sid);
384
416
  } else if (type === "compaction" || type?.startsWith?.("compaction/")) {
385
417
  // v1.7.0:无需再清"已注入标记"(该机制已删)——记忆正文改经 E 投影注入,
386
418
  // compaction 把投影消息移出 surface 后,后续 step 的去重扫描找不到它 → 自动重注。
@@ -425,8 +457,9 @@ export function apply(ctx, config) {
425
457
  "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取记忆用 memory_read 工具,scope 默认 'memory'(用户级 + 项目级 MEMORY.md),需要今日/历史日志时显式传 scope:'daily' 或 'all'(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
426
458
  "注意:上下文中的记忆是**会话起始快照**,不随记忆文件之后的更新自动刷新;需要以当前状态为依据时(改记忆前、或据记忆作答前)用 memory_read 重读。" +
427
459
  antiMangle + proactive;
428
- // 计划模式禁写提示(硬编码默认行为,无开关):让 agent 自觉不写(网关物理兜底仍生效)
429
- const planNote = state.planModeActive
460
+ // 计划模式禁写提示(硬编码默认行为,无开关):让 agent 自觉不写(网关物理兜底仍生效)。
461
+ // 按**会话**判定:plan 模式是会话级状态,别的会话进 plan 不该往本会话 prompt 里塞这条提示。
462
+ const planNote = planModeOf(state, context?.agent?.session)
430
463
  ? "\n\n[plan 模式] 当前处于 plan 模式,不要调用 memory_note / memory_note_user 写入记忆,也不要请求删除记忆(读取记忆用 memory_read,仍可用)。"
431
464
  : "";
432
465
  const introFull = intro + planNote;
@@ -443,14 +476,17 @@ export function apply(ctx, config) {
443
476
  // ---------- 结算一个完整 request:交给记忆子代理写今日日志(不在此处做任何直接写入) ----------
444
477
  // v1.8.0:原「防闲聊闸门 + 轻量兜底 + 错误捕获」(plugin)、「LLM 摘要分派」(smart)
445
478
  // 与「记忆写入总开关」(summarize) 均已删除,本函数收敛为单一路径:前置闸门 → 算错误信号 → 子代理。
446
- async function _settle(capturedTurn, isError) {
447
- // 计划模式禁写(硬编码默认,无开关):写入路径在此一处拦截
448
- if (state.planModeActive) return;
479
+ async function _settle(capturedTurn, isError, sid, boundSession) {
480
+ // 会话身份由入参显式传入(原实现读全局 state.activeSession,多会话并发时会写到别的项目目录)。
481
+ // boundSession 缺失时兜底用全局,保持旧行为不失效。
482
+ const session = boundSession || state.activeSession;
483
+ // 计划模式禁写(硬编码默认,无开关):plan 模式是**会话级**状态 → 只拦该会话本身;写入路径在此一处拦截
484
+ if (planModeOf(state, session)) return;
449
485
  const cfg = source();
450
486
  if (!cfg.enabled) return;
451
487
  // v1.7.2:静默会话(极简等预设)不写任何记忆——记忆子代理在此一处提前返回。
452
- if (isSessionSilent(state.activeSession)) return;
453
- const cwd = state.activeSession?.header?.cwd ?? null;
488
+ if (isSessionSilent(session)) return;
489
+ const cwd = session?.header?.cwd ?? null;
454
490
  const dirs = paths.writeDirs(cwd);
455
491
  if (!dirs.length) return;
456
492
 
@@ -465,26 +501,45 @@ export function apply(ctx, config) {
465
501
  // 失败【不】降级为任何轻量写入(v1.6.0 定案:无格式原文会破坏日志章节化结构)——
466
502
  // 子代理内部不推进断点,下一次 turn/end 自动补蒸。
467
503
  void hybrid
468
- .runMemorySubagent({ ctx, getConfig: () => source(), paths, records, state, session: state.activeSession, dirs, isError: effectiveIsError })
504
+ .runMemorySubagent({
505
+ ctx,
506
+ getConfig: () => source(),
507
+ paths,
508
+ records,
509
+ state,
510
+ session,
511
+ dirs,
512
+ isError: effectiveIsError,
513
+ // 断点按会话分桶:全局单值在并发会话下会被互相重置,导致子代理静默 noop、日志不落盘。
514
+ getSeq: () => state.seqBySession.get(sid) ?? session?.firstLiveSeq ?? 0,
515
+ setSeq: (v) => state.seqBySession.set(sid, v),
516
+ })
469
517
  .catch(() => {});
470
518
  }
471
519
 
472
520
  // 立即结算并清空当前缓冲(供 debounce 到点、或收到新 user/message 时调用)。
473
- function _flushTurn() {
474
- const capturedTurn = state.turnBuffer;
475
- const wasErrorTurn = state.sawErrorTurn;
476
- state.turnBuffer = [];
477
- state.sawErrorTurn = false;
521
+ function _flushTurn(sid, bucket) {
522
+ // 结算归属由入参会话决定(不再读全局 state.activeSession)。
523
+ const bound = bucket || bucketOf(sid);
524
+ const capturedTurn = bound.turnBuffer;
525
+ const wasErrorTurn = bound.sawErrorTurn;
526
+ bound.turnBuffer = [];
527
+ bound.sawErrorTurn = false;
478
528
  if (!capturedTurn.length) return;
479
- void _settle(capturedTurn, wasErrorTurn).catch(() => {});
529
+ void _settle(capturedTurn, wasErrorTurn, sid, bound.session).catch(() => {});
480
530
  }
481
531
 
482
532
  // debounce:每次 turn/end 重置计时器;SETTLE_DELAY 内无新 turn/end(即整段请求安静)才结算一次。
483
- function _scheduleSettle() {
484
- if (state.settleTimer) clearTimeout(state.settleTimer);
485
- state.settleTimer = setTimeout(() => {
486
- state.settleTimer = null;
487
- _flushTurn();
533
+ function _scheduleSettle(sid) {
534
+ // 每个会话独立计时:共用单一定时器时,B 的 turn/end 会把 A 的待结算 clearTimeout 掉 → A 整段丢失。
535
+ const id = sid || "__nosession__";
536
+ const prev = state.settleTimers.get(id);
537
+ if (prev) clearTimeout(prev);
538
+ const t = setTimeout(() => {
539
+ state.settleTimers.delete(id);
540
+ const bound = state.buffersBySession.get(id);
541
+ if (bound) _flushTurn(id, bound);
488
542
  }, SETTLE_DELAY);
543
+ state.settleTimers.set(id, t);
489
544
  }
490
545
  }
package/lib/tools.mjs CHANGED
@@ -5,6 +5,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
5
5
  import { join } from "node:path";
6
6
  import { todayISO, isoDaysAgo, expandHome, toHomeShort, readMdSync, budgetClip, stripDeletedLines } from "./common/text.mjs";
7
7
  import { appendLineDedup, findMatches, removeLineByMatch, recentLogDates } from "./common/records.mjs";
8
+ import { planModeOf } from "./common/planmode.mjs";
8
9
 
9
10
  /**
10
11
  * @param {{ ctx: object, getConfig: () => object, paths: object, state: object }} deps
@@ -409,8 +410,9 @@ export function registerTools({ ctx, getConfig, paths, state }) {
409
410
  const WRITE_TOOLS = new Set(["memory_note", "memory_note_user", "memory_delete"]);
410
411
  ctx.on("tools/pre-execute", async (exec, next) => {
411
412
  const name = exec?.name;
412
- // plan 模式禁写:三个写工具一律 deny,连预览/确认都拦
413
- if (state.planModeActive && WRITE_TOOLS.has(name)) {
413
+ // plan 模式禁写:三个写工具一律 deny,连预览/确认都拦。
414
+ // 按**会话**判定(plan 模式是会话级状态,见 common/planmode.mjs)——别的会话进 plan 不该 deny 本会话。
415
+ if (planModeOf(state, exec?.agent?.session) && WRITE_TOOLS.has(name)) {
414
416
  return { kind: "deny", reason: "plan 模式下禁止写入记忆" };
415
417
  }
416
418
  if (name !== "memory_delete") return next();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-palace",
3
- "version": "1.8.0-alpha.1",
3
+ "version": "1.8.0-alpha.2",
4
4
  "description": "把 WorkBuddy 的文件式记忆系统移植进 DeepSeek Harness:用户级 + 工作区的人类可读 Markdown 记忆(~/.deepseek-harness/MEMORY.md 与 <cwd>/.deepseek-harness/memory/)。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",