dsh-memory-palace 1.4.0 → 1.6.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.
@@ -33,7 +33,7 @@ export function expandHome(p) {
33
33
  }
34
34
 
35
35
  // 反向简写:home 下的绝对路径 → ~ 简写(统一正斜杠)。AI 转述绝对路径极易拼错
36
- // (实测出现过 lovezi0.deepseek-harness 缺分隔符),一律喂给它 ~ 简写。
36
+ // (实测出现过缺分隔符,如 ~/.deepseek-harness 被拼成 ~.deepseek-harness),一律喂给它 ~ 简写。
37
37
  export function toHomeShort(p) {
38
38
  if (!p) return p;
39
39
  const h = homedir();
@@ -54,13 +54,59 @@ export function readMdSync(path) {
54
54
  }
55
55
  }
56
56
 
57
- // v1.2.0 预算截断:注入 system prompt 时按字符上限截断(保留头部 + 截断标记)。
58
- // budget <= 0 / 未配置视为不限。userBudgetChars / workspaceBudgetChars 在 text() 注入路径生效,
59
- // 修复 v1.1.x 之前两配置为死配置的问题(与 README「注入系统提示词的长度上限」语义对齐)。
57
+ // v1.4.1 重写:修 LIFO/FIFO 错配(旧实现 slice(0, budget) 只留头部,而写入追加在尾部——
58
+ // 最新结论永远进不了注入视野,"写了等于没写")。新策略:保留全部结构行(# / <!--)+
59
+ // 从尾部向前累积内容行至预算用尽,中间以截断标记衔接。budget <= 0 / 未配置视为不限。
60
60
  export function budgetClip(text, budget) {
61
61
  if (!text || !budget || budget <= 0) return text;
62
62
  if (text.length <= budget) return text;
63
- return text.slice(0, budget) + `\n…(已截断,原 ${text.length} 字符,仅注入前 ${budget} 字符)`;
63
+ const lines = text.split("\n");
64
+ const head = [];
65
+ let i = 0;
66
+ // 头部连续结构行(文件标题/注释区)与空行无条件保留
67
+ while (i < lines.length && (!lines[i].trim() || isStructural(lines[i].trim()))) {
68
+ head.push(lines[i]);
69
+ i++;
70
+ }
71
+ // 尾部向前累积内容行至预算用尽
72
+ const tail = [];
73
+ let used = 0;
74
+ let j = lines.length - 1;
75
+ while (j >= i && used < budget) {
76
+ const cost = lines[j].length + 1;
77
+ if (used + cost > budget && tail.length > 0) break;
78
+ tail.unshift(lines[j]);
79
+ used += cost;
80
+ j--;
81
+ }
82
+ if (j < i) return head.join("\n") + (head.length ? "\n" : "") + tail.join("\n");
83
+ const marker = `…(已截断,原 ${text.length} 字符,仅注入结构行与尾部最近条目)`;
84
+ return [...head, marker, ...tail].join("\n");
85
+ }
86
+
87
+ // v1.4.1:剥除行首 `[smart]` 标记(含重复),保留列表符与其余内容。用于注入与回喂的读侧——
88
+ // 模型全程不可见该标签(写侧 v1.4.1 起不再拼接),存量磁盘条目自然消亡、无需迁移。
89
+ // 逐行处理:允许行首有 `N|` 计数前缀(readNumberedMemory 的回喂格式)与列表符,
90
+ // 只剥「列表符/行首之后紧跟的 [smart] 标记序列」;正文中间的 "smart" 字样不误伤。
91
+ export function stripSmartTag(text) {
92
+ if (!text) return text;
93
+ return text
94
+ .split("\n")
95
+ .map((line) =>
96
+ line.replace(/^(\s*(?:\d+\|)?\s*[-*]\s+|\s*(?:\d+\|)\s*)?(\[smart\]\s*)+/, "$1"),
97
+ )
98
+ .join("\n");
99
+ }
100
+
101
+ // v1.6.0:注入侧删除线过滤——整行都是删除线墓碑(`~~...~~`,可带 `- ` 列表符)的条目
102
+ // 视为已作废,注入 system prompt 时剔除(省 token、防模型把墓碑当现行有效);
103
+ // 行内局部删除线(`- 旧名 ~~原名~~` 之类)不是墓碑,不滤。文件本体保留墓碑供审计。
104
+ export function stripDeletedLines(text) {
105
+ if (!text) return text;
106
+ return text
107
+ .split("\n")
108
+ .filter((line) => !/^\s*(?:-\s+)?~~[\s\S]+~~\s*$/.test(line))
109
+ .join("\n");
64
110
  }
65
111
 
66
112
  // 行归一化:去掉 `- `/`* ` 前缀、合并空白、转小写,使"带不带前缀"的子串匹配都鲁棒。
package/lib/distill.mjs CHANGED
@@ -4,7 +4,7 @@ import { mkdir, unlink, writeFile, rename } from "node:fs/promises";
4
4
  import { join, dirname } from "node:path";
5
5
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
6
6
  import { SUMMARY_PROMPT, DISTILL_PROMPT } from "./common/prompts.mjs";
7
- import { expandHome, readMdSync, toHomeShort, nowStamp } from "./common/text.mjs";
7
+ import { expandHome, readMdSync, toHomeShort, nowStamp, stripSmartTag } from "./common/text.mjs";
8
8
  import { appendLineDedup, readNumberedMemory, applyMemoryOp } from "./common/records.mjs";
9
9
  import { runWithRetry, RETRY_CONSTANTS } from "./common/retry.mjs";
10
10
 
@@ -167,11 +167,11 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
167
167
  // (解析失败时抽取 {...} JSON 块兜底,仍失败则 summary 置空、记极简占位,绝不把 LLM 自由文本当 summary 落盘)。
168
168
  const timeoutMs = c.summaryTimeoutMs || 60000;
169
169
  const t0 = Date.now();
170
- dbg("session request", { provider, model, messages: messages.length, maxTokens: c.summaryMaxTokens, timeoutMs, maxRetries: RETRY_CONSTANTS.MAX_RETRIES });
170
+ dbg("session request", { provider, model, messages: messages.length, maxTokens: "(model native cap)", timeoutMs, maxRetries: RETRY_CONSTANTS.MAX_RETRIES });
171
171
  let result;
172
172
  try {
173
173
  result = await runWithRetry(
174
- () => streamOnce({ provider, model, system: SUMMARY_PROMPT({ allowDelete }), messages, maxTokens: c.summaryMaxTokens }, timeoutMs),
174
+ () => streamOnce({ provider, model, system: SUMMARY_PROMPT({ allowDelete, outputBudget: c.summaryMaxTokens }), messages }, timeoutMs),
175
175
  {
176
176
  maxRetries: RETRY_CONSTANTS.MAX_RETRIES,
177
177
  onAttempt: ({ attempt, maxRetries }) => dbg("session attempt", { attempt, maxRetries }),
@@ -185,6 +185,15 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
185
185
  const { finish, text, chunks, deltaChars, tFirstChunk } = result;
186
186
  dbg("session finish", { kind: finish?.kind, attempts: result.attempts });
187
187
  dbg("session stream done", { chunks, deltaChars, firstChunkMs: tFirstChunk });
188
+ // 本地调试(受 distillDebugLog + distillLogLevel==="debug" 双门控):把 LLM 原始响应 text 原样打到 stderr,
189
+ // 并用分隔符整段包裹,便于从 stderr 一眼框住起止、不与其它 dbg 行混淆。
190
+ if (cfg().distillDebugLog && cfg().distillLogLevel === "debug") {
191
+ console.error("[memory-palace][debug] session llm raw text begin >>>");
192
+ console.error("--------------------------->");
193
+ console.error(text);
194
+ console.error("<----------------------------");
195
+ console.error("[memory-palace][debug] session llm raw text end <<<");
196
+ }
188
197
  if (finish && finish.kind === "max-tokens") {
189
198
  console.error("[memory-palace] distill warn: llm finish max-tokens (attempting partial JSON)", JSON.stringify({}));
190
199
  }
@@ -230,9 +239,12 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
230
239
  }
231
240
  for (const d of durable) {
232
241
  if (!d || typeof d !== "object") continue;
233
- const fact = String(d.fact ?? "").trim();
242
+ let fact = String(d.fact ?? "").trim();
234
243
  if (!fact) continue;
235
- const line = `- [smart] ${fact}`;
244
+ // v1.4.1:写侧不再拼接 [smart] 标签(防回喂二次拼接形成 [smart] [smart] 双前缀、
245
+ // 防标签注意力稀释);防御性剥除 LLM 自行带回的标签,保证落盘条目干净。
246
+ fact = stripSmartTag(fact).trim();
247
+ const line = `- ${fact}`;
236
248
  if (d.scope === "user") {
237
249
  await appendLineDedup(expandHome(c.userMemoryPath), line).catch(() => {});
238
250
  } else {
@@ -304,7 +316,7 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
304
316
  return { ok: false, message: "无法确定蒸馏模型(summaryModel 未配置且会话无模型信息)。" };
305
317
  }
306
318
  ({ provider, model } = resolved);
307
- dbg("project request", { provider, model, inputChars: raw.length, maxTokens: c.projectMaxTokens, timeoutMs: c.summaryTimeoutMs || 60000 });
319
+ dbg("project request", { provider, model, inputChars: raw.length, maxTokens: "(model native cap)", timeoutMs: c.summaryTimeoutMs || 60000 });
308
320
 
309
321
  // LLM 蒸馏:system = 固化 DISTILL_PROMPT,user = MEMORY.md 全文。带失败重试(runWithRetry)。
310
322
  const timeoutMs = c.summaryTimeoutMs || 60000;
@@ -315,14 +327,13 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
315
327
  {
316
328
  provider,
317
329
  model,
318
- system: DISTILL_PROMPT,
330
+ system: DISTILL_PROMPT({ outputBudget: c.projectMaxTokens }),
319
331
  messages: [
320
332
  createUserMessage({
321
333
  content: [{ type: "text", text: raw }],
322
334
  source: { kind: "plugin", plugin: "memory-palace" },
323
335
  }),
324
336
  ],
325
- maxTokens: c.projectMaxTokens,
326
337
  },
327
338
  timeoutMs,
328
339
  ),
@@ -338,6 +349,14 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
338
349
  return { ok: false, message: `蒸馏${kindZh}(重试 ${e?.attempts || 0} 次后仍失败):${cls.message || ""}` };
339
350
  }
340
351
  const { finish, text, chunks, deltaChars, gotFirstChunk, tFirstChunk } = result;
352
+ // 本地调试(受 distillDebugLog + distillLogLevel==="debug" 双门控,与会话蒸馏路径一致):分隔符包裹整段原始响应。
353
+ if (cfg().distillDebugLog && cfg().distillLogLevel === "debug") {
354
+ console.error("[memory-palace][debug] project llm raw text begin >>>");
355
+ console.error("--------------------------->");
356
+ console.error(text);
357
+ console.error("<----------------------------");
358
+ console.error("[memory-palace][debug] project llm raw text end <<<");
359
+ }
341
360
  if (finish && finish.kind === "max-tokens") {
342
361
  dbg("project finish", { kind: "max-tokens", attempts: result.attempts });
343
362
  } else {
@@ -0,0 +1,18 @@
1
+ // memory-palace v1.6.0 hybrid 模式:模块入口(装配)。
2
+ // 仅在 memoryMode === "hybrid" 时由 index.mjs 调用 registerHybrid(memoryMode 切换需重启
3
+ // dsh——注册时机安全)。不注册任何 DSH 全局资源以外的东西;runMemorySubagent 由 index.mjs
4
+ // 的 _settle hybrid 分支每轮调用。
5
+ import { runMemorySubagent } from "./subagent.mjs";
6
+ import { registerHybridTools, attachHybridGuards } from "./tools.mjs";
7
+
8
+ export { HYBRID_PROACTIVE } from "./prompts.mjs";
9
+
10
+ /**
11
+ * @param {{ ctx: object, getConfig: () => object, paths: object, records: object, state: object }} deps
12
+ * @returns {{ runMemorySubagent: Function }}
13
+ */
14
+ export function registerHybrid({ ctx, getConfig, paths, records, state }) {
15
+ registerHybridTools({ ctx, getConfig, paths, records, state });
16
+ attachHybridGuards(ctx, getConfig, paths, state);
17
+ return { runMemorySubagent };
18
+ }
@@ -0,0 +1,42 @@
1
+ // memory-palace v1.6.0 hybrid 模式提示词:注入段二(HYBRID_PROACTIVE)+ 记忆子 agent
2
+ // system prompt(SUBAGENT_SYSTEM)。纯常量,无状态。
3
+ //
4
+ // 定案锚点(爹 2026-08-30 拍板):
5
+ // - 日志由子 agent 每轮自动维护,agent 无需重复记录过程性信息;
6
+ // - MEMORY.md 写入权归还 agent(主动调工具、章节化格式);
7
+ // - 项目级全量重整双门禁(超预算 + 冷却 ≥7 天,机器校验),用户级禁止重整;
8
+ // - 操作规范写在工具 description(定案 #11),本文件只做方向性引导,不堆格式细节。
9
+
10
+ // hybrid 模式注入 system prompt 的段二(替换 plugin 的记忆公民指令 / smart 的"无需主动"说明)。
11
+ export const HYBRID_PROACTIVE =
12
+ "\n\n[记忆说明] 分工如下:\n" +
13
+ "① 每轮结束由记忆子代理自动提炼摘要写入今日工作日志(含去重:重复/过时条目会以删除线标记),你无需调用工具记录过程性信息,也无需复述日志内容。\n" +
14
+ "② 长期记忆 MEMORY.md(项目级与用户级)由你主动维护:完成任务、关键决策、踩坑修复、用户偏好等 durable 事实,用 memory_write 追加(章节不存在会自动新建);过时内容用 memory_update_section 整章节精确替换或标删,绝不手写覆盖记忆文件。\n" +
15
+ "③ 项目级 MEMORY.md 仅在「超出注入预算 且 距上次重整 ≥ 重整冷却期」两条件同时满足时,才可调用 memory_reorganize 全量重整;重整前先读日志与现有记忆对照,切勿丢失关键信息。不满足条件时只能用 memory_update_section 做章节级修正。\n" +
16
+ "④ 用户级 MEMORY.md 禁止全量重整,只允许增量写入与整章节精确替换。";
17
+
18
+ // 记忆子 agent system prompt(turn/end 触发,判定 + 产出一体)。
19
+ // {date} 注入今日日期;回喂正文见 subagent.mjs 的首轮 user 消息。
20
+ export function SUBAGENT_SYSTEM() {
21
+ return (
22
+ "你是记忆子代理,负责把本轮对话沉淀进【今日工作日志】(Markdown 文件,按 `## 章节` + `- 条目` 组织)。\n" +
23
+ "\n" +
24
+ "【判定(第一优先)】先判断本轮对话是否包含值得写入日志的实质内容:完成了任务、修了 bug、做了决策、得出结论、用户表达了偏好或约束。\n" +
25
+ "- 无重点(闲聊、寒暄、一句话问答、无任何可沉淀信息):直接输出一行纯文本说明,不调用任何工具,立即结束。\n" +
26
+ "- 有重点:通过工具把格式化摘要写入日志,然后输出一行简短确认,结束。\n" +
27
+ "\n" +
28
+ "【日志写入规则(严格)】\n" +
29
+ "1. 只能通过 log_write_ops 工具写入,每次可批量提交多个操作;禁止请求重写整个文件。\n" +
30
+ "2. 三种操作:\n" +
31
+ " - append:章节存在则在其末尾追加条目,不存在则自动新增章节(不要强行匹配现有章节,语义确实不符就走新章节名);\n" +
32
+ " - new_section:显式新建章节并写入首条条目(若章节已存在会被拒绝,此时改用 append);\n" +
33
+ " - mark_delete:把日志中重复、过时或被本轮推翻的旧条目标记删除(删除线墓碑,非物理删除)。oldText 必须是日志中该条目的原文(可含或不含 `- ` 前缀),逐行匹配,匹配失败会被拒绝。\n" +
34
+ "3. 去重是你的职责:回喂的今日日志中已存在的结论禁止再次追加;发现语义重复或相互矛盾的旧条目,用 mark_delete 标记其中过时的一条(保留更完整/更新的那条)。\n" +
35
+ "4. 条目格式:一行一条、以结论开头、带关键细节(命令/路径/数字),客观第三人称,禁止对话体、请示、内心独白;禁止输出 [smart] 等任何标签。【entry 一律不带列表符(`- `/`* `)——插件写入时会自动补齐,你带了会变成双列表符脏数据】\n" +
36
+ "5. 章节名用简短的中文主题词(如 `## 环境必知`、`## 渠道映射排查`),宁多章节少混杂。\n" +
37
+ "6. 今日日志为空(新的一天)时直接用 append/new_section 建章节写入即可。\n" +
38
+ "7. 需要读取日志章节(目录模式回喂时)用 log_read_section;需要多个章节时务必在单次调用中把 sections 数组全部传齐,避免多轮往返浪费轮次。\n" +
39
+ "\n" +
40
+ "【输出】全程只做上述两件事:要么纯文本收尾,要么调工具写日志后收尾。不要向用户提问,不要输出多余解释。"
41
+ );
42
+ }
@@ -0,0 +1,349 @@
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 { appendToSectionText, upsertSectionText, createSectionText, markEntryDeletedText, parseSections, locateSection } from "../common/sections.mjs";
14
+ import { HYBRID_PROACTIVE, SUBAGENT_SYSTEM } from "./prompts.mjs";
15
+
16
+ export { HYBRID_PROACTIVE };
17
+
18
+ const MAX_ROUNDS = 6; // 循环轮次上限(含首轮);超限降级
19
+ const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
20
+
21
+ // 日志文件写锁(全局 promise 链串行化):子 agent 落盘与手动蒸馏按钮/其他写路径并发防护。
22
+ let logWriteChain = Promise.resolve();
23
+ function withLogLock(file, fn) {
24
+ const run = logWriteChain.then(fn, fn);
25
+ logWriteChain = run.catch(() => {});
26
+ return run;
27
+ }
28
+
29
+ // 模型解析(与 distill.mjs resolveModel 同逻辑,改动须两处同步):summaryModel 注册表反查
30
+ // > 前缀兜底 > 会话 requestHeader 兜底。复制而非导出原因:hybrid 走独立模块定案,不碰 distill。
31
+ async function resolveModel(cfg, ctx, session) {
32
+ const sm = (cfg.summaryModel || "").trim();
33
+ if (sm) {
34
+ try {
35
+ const providers = typeof ctx.llm?.listProviders === "function" ? ctx.llm.listProviders() : [];
36
+ for (const p of providers || []) {
37
+ try {
38
+ const models = await ctx.llm.listModels(p.id);
39
+ const m = (models || []).find((x) => x.id === sm);
40
+ if (m) return { provider: m.provider, model: m.id };
41
+ } catch { /* 该 provider 无模型列表,跳过 */ }
42
+ }
43
+ const provider = sm.split("/")[0].trim();
44
+ if (provider && sm.includes("/")) return { provider, model: sm };
45
+ } catch { /* 注册表不可用,走会话兜底 */ }
46
+ }
47
+ const conf = session?.requestHeader?.()?.config;
48
+ if (conf && conf.provider && conf.model) return { provider: conf.provider, model: conf.model };
49
+ return null;
50
+ }
51
+
52
+ // 单次流式调用(带 tools)。finish.kind 为 error/aborted 时抛出由上层降级;tool-calls/stop 正常返回。
53
+ async function streamTurn(ctx, params, timeoutMs) {
54
+ const signal = AbortSignal.timeout(timeoutMs);
55
+ const res = ctx.llm.stream({ ...params, signal });
56
+ const asm = new BlockAssembler();
57
+ for await (const ch of res) {
58
+ signal.throwIfAborted();
59
+ asm.push(ch);
60
+ }
61
+ const finish = asm.finish;
62
+ if (finish.kind === "error" || finish.kind === "aborted") {
63
+ throw new Error(finish.failure?.message || finish.kind);
64
+ }
65
+ return { finish, asm };
66
+ }
67
+
68
+ // 把本轮 surface 事件投影为消息数组(seq >= fromSeq;与 distillSessionCore 同源逻辑)。
69
+ function projectTurnMessages(session, fromSeq) {
70
+ const hist = [];
71
+ for (const e of session.events || []) {
72
+ if (e.seq < fromSeq || !SURFACE.has(e.type)) continue;
73
+ const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
74
+ if (m) hist.push(m);
75
+ }
76
+ return hist;
77
+ }
78
+
79
+ // 今日日志回喂正文:≤ subagentLogBudget 全文;超出仅章节目录(标题 + 条目数 + 首条),
80
+ // 由子 agent 用 log_read_section 按需拉取(定案)。空日志(跨零点新一天)明确告知。
81
+ function buildLogBlock(dir, budget) {
82
+ const file = join(dir, `${todayISO()}.md`);
83
+ const text = readMdSync(file);
84
+ if (!text) {
85
+ return { file, oversized: false, body: "(今日日志为空——新的一天,直接用 append/new_section 建章节写入即可。)" };
86
+ }
87
+ if (text.length <= budget) {
88
+ return { file, oversized: false, body: text };
89
+ }
90
+ const { sections } = parseSections(text);
91
+ const toc = sections
92
+ .map((s) => {
93
+ const items = s.lines.slice(1).filter((l) => l.trim());
94
+ const first = items[0]?.trim().slice(0, 80) || "";
95
+ return `- ${s.title}(${items.length} 条,首条:${first})`;
96
+ })
97
+ .join("\n");
98
+ return {
99
+ file,
100
+ oversized: true,
101
+ body:
102
+ `(日志过大(${text.length} 字符),以下仅目录;需要某章节完整内容时调用 log_read_section 读取)\n${toc}`,
103
+ };
104
+ }
105
+
106
+ // 循环内工具执行:仅两个白名单工具,其余一律报错(模型越权时结果回喂 isError,不中断循环)。
107
+ async function execLoopTool(block, logFiles, cfg) {
108
+ const name = block.name;
109
+ let args = {};
110
+ try {
111
+ args = JSON.parse(block.arguments || "{}");
112
+ } catch {
113
+ return { ok: false, message: "工具参数不是合法 JSON。" };
114
+ }
115
+ try {
116
+ if (name === "log_read_section") {
117
+ // v1.6.0 A 改进:一次读取多个章节(sections 数组),合并返回;兼容旧单数 section 参数。
118
+ const file = logFiles[0];
119
+ const md = readMdSync(file) || "";
120
+ const wants = Array.isArray(args.sections)
121
+ ? args.sections.filter((s) => s && typeof s === "string")
122
+ : args.section
123
+ ? [args.section]
124
+ : [];
125
+ if (!wants.length) return { ok: false, message: "sections 不能为空(数组,可一次传多个章节标题)。" };
126
+ const parts = [];
127
+ const missing = [];
128
+ for (const s of wants) {
129
+ const loc = locateSection(md, String(s).trim());
130
+ if (!loc) {
131
+ missing.push(String(s).trim());
132
+ continue;
133
+ }
134
+ parts.push(loc.lines.slice(loc.start, loc.end).join("\n").trim());
135
+ }
136
+ if (!parts.length) {
137
+ return { ok: false, message: `未找到章节:${missing.join("、")}。可用章节:${parseSections(md).sections.map((s) => s.title).join("、") || "(日志为空)"}` };
138
+ }
139
+ const note = missing.length ? `\n\n(以下章节未找到:${missing.join("、")})` : "";
140
+ return { ok: true, content: parts.join("\n\n---\n\n") + note };
141
+ }
142
+ if (name === "log_write_ops") {
143
+ const ops = Array.isArray(args.ops) ? args.ops : [];
144
+ if (!ops.length) return { ok: false, message: "ops 为空。" };
145
+ return await applyLogOps(logFiles, todayISO(), ops);
146
+ }
147
+ return { ok: false, message: `未知工具 ${name}(仅 log_read_section / log_write_ops 可用)。` };
148
+ } catch (e) {
149
+ return { ok: false, message: `执行失败:${e?.message || String(e)}` };
150
+ }
151
+ }
152
+
153
+ // 把 ops 序列应用到每个日志文件(读-改-写,经全局写锁串行化)。op → sections 纯函数:
154
+ // append=upsert(存在追加/不存在新增章节,"不强行匹配")、new_section=已存在拒绝、
155
+ // mark_delete=删除线墓碑(结构行保护)。
156
+ function applyLogOps(logFiles, dateStr, ops) {
157
+ const apply = (md) => {
158
+ let cur = md || "";
159
+ const results = [];
160
+ for (const op of ops) {
161
+ if (!op || typeof op !== "object") {
162
+ results.push({ op: "invalid", ok: false, message: "op 非对象" });
163
+ continue;
164
+ }
165
+ const section = String(op.section ?? "").trim();
166
+ if (!section) {
167
+ results.push({ op: op.op, ok: false, message: "section 不能为空" });
168
+ continue;
169
+ }
170
+ if (op.op === "append") {
171
+ const r = upsertSectionText(cur, section, op.entry);
172
+ results.push({ op: "append", section, ok: r.ok, message: r.reason });
173
+ if (r.ok) cur = r.text;
174
+ } else if (op.op === "new_section") {
175
+ const r = createSectionText(cur, section, op.entry);
176
+ results.push({ op: "new_section", section, ok: r.ok, message: r.reason });
177
+ if (r.ok) cur = r.text;
178
+ } else if (op.op === "mark_delete") {
179
+ const r = markEntryDeletedText(cur, section, op.oldText);
180
+ results.push({ op: "mark_delete", section, ok: r.ok, message: r.reason });
181
+ if (r.ok && r.text !== undefined) cur = r.text;
182
+ } else {
183
+ results.push({ op: op.op, ok: false, message: "未知 op(仅 append/new_section/mark_delete)" });
184
+ }
185
+ }
186
+ return { cur, results };
187
+ };
188
+ return withLogLock(logFiles[0], async () => {
189
+ const all = [];
190
+ for (const file of logFiles) {
191
+ let md = "";
192
+ try {
193
+ md = readFileSync(file, "utf8");
194
+ } catch { /* 文件尚不存在 */ }
195
+ const { cur, results } = apply(md);
196
+ if (cur !== md) {
197
+ await mkdir(dirname(file), { recursive: true });
198
+ await writeFile(file, cur, "utf8");
199
+ }
200
+ all.push(...results);
201
+ }
202
+ const applied = all.filter((r) => r.ok).length;
203
+ return { ok: true, applied, total: all.length, results: all, date: dateStr };
204
+ });
205
+ }
206
+
207
+ // 记忆子 agent 主入口。每轮必跑(定案:不走闸门);plan 模式拦截由调用方(index.mjs _settle)负责。
208
+ // 返回 { ok, mode }:mode = "noop"(无重点收尾)| "written"(有落盘)| "no-tool-support";
209
+ // ok=false 表示整体失败(调用方降级 writeLightEntry,不推进断点)。
210
+ export async function runMemorySubagent({ ctx, getConfig, paths, state, session, dirs, isError }) {
211
+ const cfg = getConfig();
212
+ // 调试日志(v1.6.0):与 distill.mjs 同款门控。dbgFail=失败/跳过留痕(无条件输出,排障可观测);
213
+ // dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度/ops 结果计数)。
214
+ // 隐私红线:只打计数/字符数/元数据/错误 message,绝不打印对话或日志正文文本。
215
+ const dbgFail = (why, extra) => console.error(`[memory-palace] subagent skip: ${why}`, extra ? JSON.stringify(extra) : "");
216
+ const dbg = (why, extra) => { if (!cfg.distillDebugLog) return; console.error(`[memory-palace][debug] subagent ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
217
+ if (!cfg.enabled || !session || !dirs.length) {
218
+ dbgFail("disabled or no session/dirs", { enabled: !!cfg.enabled, hasSession: !!session, dirs: dirs.length });
219
+ return { ok: false, mode: "disabled" };
220
+ }
221
+ const resolved = await resolveModel(cfg, ctx, session);
222
+ if (!resolved) {
223
+ dbgFail("no model", { summaryModel: cfg.summaryModel });
224
+ return { ok: false, mode: "no-model" };
225
+ }
226
+ const { provider, model } = resolved;
227
+ dbg("entry", { sessionId: session.id, fromSeq: state.lastSummarizedSeq, provider, model });
228
+
229
+ const hist = projectTurnMessages(session, state.lastSummarizedSeq);
230
+ if (!hist.length) {
231
+ dbg("no events in range", { fromSeq: state.lastSummarizedSeq });
232
+ return { ok: true, mode: "noop" };
233
+ }
234
+
235
+ const log = buildLogBlock(dirs[0], cfg.subagentLogBudget || 20000);
236
+ const logFiles = dirs.map((dir) => join(dir, `${todayISO()}.md`));
237
+ dbg("log feed", { file: log.file, oversized: log.oversized, chars: log.body.length, logFiles: logFiles.length });
238
+
239
+ // 首轮 user 消息:本轮对话已在 hist 中逐条保留,末尾追加日志回喂 + 任务指令。
240
+ const brief = isError ? "\n\n(提示:本轮对话中出现过错误信号,错误现象值得记录。)" : "";
241
+ const kickoff = createUserMessage({
242
+ content: [
243
+ {
244
+ type: "text",
245
+ text:
246
+ `【今日工作日志(${todayISO()})】\n${log.body}\n\n` +
247
+ `请按系统指令处理上面的本轮对话(对话见前文消息):判定是否有实质内容需要写入日志,并执行。${brief}`,
248
+ },
249
+ ],
250
+ source: { kind: "plugin", plugin: "memory-palace" },
251
+ });
252
+
253
+ const tools = [
254
+ {
255
+ name: "log_read_section",
256
+ description:
257
+ "读取今日工作日志中指定章节的完整内容(含标题行与全部条目)。sections 接受数组,可一次读取多个章节(合并返回,未找到的章节会注明)——需要多个章节时务必在单次调用中全部传入,避免多轮往返。仅在回喂为目录模式(日志过大)时需要调用;全文回喂时无需调用。",
258
+ parameters: {
259
+ type: "object",
260
+ additionalProperties: false,
261
+ properties: {
262
+ sections: {
263
+ type: "array",
264
+ description: "要读取的章节标题列表(不含 `## ` 前缀),可一次传多个。",
265
+ items: { type: "string" },
266
+ },
267
+ },
268
+ required: ["sections"],
269
+ },
270
+ },
271
+ {
272
+ name: "log_write_ops",
273
+ description:
274
+ "批量写入今日工作日志(只能操作今日日志)。ops 数组,每个元素:\n" +
275
+ '- {"op":"append","section":"章节名","entry":"条目文本(一行,结论先行+关键细节,【不带 - 前缀】——插件会自动补)"}:章节存在则末尾追加,不存在则自动新增章节(不要强行匹配语义不符的章节);\n' +
276
+ '- {"op":"new_section","section":"新章节名","entry":"首条条目(不带 - 前缀)"}:显式新建章节(已存在会拒绝,改用 append);\n' +
277
+ '- {"op":"mark_delete","section":"章节名","oldText":"日志中该条目原文(可带或不带 - 前缀)"}:把重复/过时/被推翻的旧条目标记为删除线墓碑(非物理删除),oldText 逐行匹配,匹配失败会被拒绝;\n' +
278
+ "一条消息可带多个 ops 一次提交;entry 为客观第三人称单行文本(不带列表符),禁止对话体与任何标签。",
279
+ parameters: {
280
+ type: "object",
281
+ additionalProperties: false,
282
+ properties: {
283
+ ops: {
284
+ type: "array",
285
+ description: "日志写操作数组(见 description)。",
286
+ items: {
287
+ type: "object",
288
+ additionalProperties: false,
289
+ properties: {
290
+ op: { type: "string", description: "append | new_section | mark_delete" },
291
+ section: { type: "string", description: "章节标题(不含 ## 前缀)。" },
292
+ entry: { type: "string", description: "append/new_section 的条目文本(一行)。" },
293
+ oldText: { type: "string", description: "mark_delete 的目标条目原文。" },
294
+ },
295
+ required: ["op", "section"],
296
+ },
297
+ },
298
+ },
299
+ required: ["ops"],
300
+ },
301
+ },
302
+ ];
303
+
304
+ const system = SUBAGENT_SYSTEM();
305
+ const messages = [...hist, kickoff];
306
+ const timeoutMs = cfg.summaryTimeoutMs || 60000;
307
+
308
+ try {
309
+ for (let round = 0; round < MAX_ROUNDS; round++) {
310
+ const { finish, asm } = await streamTurn(ctx, { provider, model, system, messages, tools }, timeoutMs);
311
+ if (finish.kind === "stop") {
312
+ state.lastSummarizedSeq = session.seq;
313
+ dbg("done", { round, mode: round === 0 ? "noop" : "written" });
314
+ return { ok: true, mode: round === 0 ? "noop" : "written" };
315
+ }
316
+ if (finish.kind !== "tool-calls") {
317
+ // max-tokens 等异常收尾:返回失败,断点不推进——下一次 turn/end 子代理自动补蒸。
318
+ // v1.6.0 定案:不降级 writeLightEntry(无格式原文会破坏日志章节化结构),宁缺勿滥。
319
+ dbgFail(`finish ${finish.kind}`, { round });
320
+ return { ok: false, mode: `finish-${finish.kind}` };
321
+ }
322
+ messages.push(asm.message({ kind: "model", provider, model }));
323
+ const calls = asm.blocks().filter((b) => b.type === "tool-call");
324
+ dbg("tool-calls round", { round, calls: calls.length, names: calls.map((c) => c.name) });
325
+ for (const call of calls) {
326
+ const result = await execLoopTool(call, logFiles, cfg);
327
+ const body = result.ok && result.content !== undefined ? String(result.content) : JSON.stringify(result);
328
+ messages.push(
329
+ createToolResultMessage({
330
+ callId: call.id,
331
+ content: [{ type: "text", text: body }],
332
+ isError: !result.ok,
333
+ }),
334
+ );
335
+ if (!result.ok) dbgFail(`tool ${call.name} rejected`, { message: result.message, opApplied: result.applied });
336
+ else dbg("tool ok", { name: call.name, applied: result.applied, total: result.total });
337
+ }
338
+ }
339
+ dbgFail("max-rounds exceeded", { rounds: MAX_ROUNDS });
340
+ return { ok: false, mode: "max-rounds" };
341
+ } catch (e) {
342
+ if (e?.name === "TimeoutError" || /timeout|abort/i.test(String(e?.message))) {
343
+ dbgFail("timeout", { timeoutMs });
344
+ return { ok: false, mode: "timeout" };
345
+ }
346
+ dbgFail("error", { message: e?.message || String(e) });
347
+ return { ok: false, mode: "error", message: e?.message || String(e) };
348
+ }
349
+ }