dsh-memory-palace 1.2.2 → 1.4.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
@@ -1,10 +1,8 @@
1
- # dsh-memory-palace
1
+ # dsh-memory-palace <img src="assets/memory-icon.svg" width="36" height="36" alt="dsh-memory-palace" />
2
2
 
3
3
  把 WorkBuddy 的文件式记忆系统移植进 [DeepSeek Harness](https://www.deepseek.com/harness/) —— 为 Harness 提供**跨会话持久化、人类可直接编辑的 Markdown 记忆**。
4
4
 
5
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
-
7
- 记忆就是普通文本文件,用记事本就能改。AI 每轮对话把它读进上下文,对话结束后把新内容追加进日志——不依赖任何私有格式、不落 JSON、不锁数据。
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
8
6
 
9
7
  ## 特性
10
8
 
@@ -37,10 +35,6 @@
37
35
  └── 2026-08-16.md # 每日工作日志
38
36
  ```
39
37
 
40
- > **布局迭代(v1.1.x)**:早期版本曾把项目级 MEMORY.md 放在 `.deepseek-harness/memory/MEMORY.md`(嵌套);现已改为与 `memory/` **同级**(`.deepseek-harness/MEMORY.md`)。旧嵌套位置仍会被读取(向后兼容、不丢旧数据),但**写入一律走同级**。
41
-
42
- > **桥接规则**:`bridgeBuddyMemory` 开启时,只要项目里存在任一 buddy 记忆目录,就**只**读写这些目录,不再创建 `.deepseek-harness/memory/`;全部不存在时才回退到 dsh 目录。buddy 目录绝不被主动创建。
43
-
44
38
  ## 工作原理
45
39
 
46
40
  **读取(每轮对话)**——`systemPrompt.section` 同步读盘,把以下内容拼进系统提示词:
@@ -80,7 +74,7 @@ turn/end ──► 轻量兜底闸门
80
74
 
81
75
  ```bash
82
76
  dsh plugin --profile web add github:lovezi0/dsh-memory-palace
83
- # 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.2.2
77
+ # 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.4.0
84
78
  ```
85
79
 
86
80
  方式二:clone 后本地安装(开发 / 修改源码场景)
@@ -99,7 +93,7 @@ dsh plugin --profile web add . # 装入 web profile(profile 名按你的实
99
93
  # 直接由 dsh 从 npm 拉取并装入(本机若已配镜像会自动走镜像)
100
94
  dsh plugin --profile web add dsh-memory-palace
101
95
  # 锁定版本:
102
- dsh plugin --profile web add dsh-memory-palace@1.2.2
96
+ dsh plugin --profile web add dsh-memory-palace@1.4.0
103
97
 
104
98
  # 或先手动用 npm 安装(显式指定镜像),再装入:
105
99
  npm install dsh-memory-palace --registry=https://registry.npmmirror.com/
@@ -113,49 +107,27 @@ dsh plugin --profile web remove dsh-memory-palace
113
107
  ```
114
108
 
115
109
  ## 配置
116
-
117
- 可在 DSH 设置 →「记忆」面板中调整,或通过 profile 配置注入:
118
-
119
- | 配置项 | 默认值 | 说明 |
120
- |---|---|---|
121
- | 总开关 | `true` | 总开关,关闭后不注入、不写入 |
122
- | 用户级记忆路径 | `~/.deepseek-harness/MEMORY.md` | 用户级记忆文件路径(支持 `~` 展开) |
123
- | 工作区记忆目录 | `.deepseek-harness/memory` | 无 buddy 目录时回退的项目记忆目录 |
124
- | 日志保留天数 | `30` | 每日日志保留天数,过期迁移进 `MEMORY.md` |
125
- | 用户级记忆字数上限 | `4000` | 注入系统提示词的用户级记忆长度上限(字符) |
126
- | 工作区级记忆字数上限 | `3000` | 注入系统提示词的工作区级记忆长度上限(字符) |
127
- | 桥接 Buddy 记忆 | `true` | 检测并直接读写 WorkBuddy / CodeBuddy 项目记忆目录 |
128
- | Buddy 记忆目录列表 | `[".workbuddy/memory", ".codebuddy/memory"]` | 要桥接的 buddy 目录列表(按优先级,已存在的全部同步写入) |
129
-
130
- ### 自动记录(设置页「记忆 → 自动记录」卡片)
131
-
132
- | 配置项 | 默认值 | 说明 |
133
- |---|---|---|
134
- | 记忆模式 | `plugin` | 两种互斥模式:`plugin`=记忆公民指令+轮次轻量+错误捕获(默认);`smart`=LLM 智能会话摘要(summary→每日日志 + durable→MEMORY.md,带 `[smart]` 标记)。**切换需重启 dsh 生效** |
135
- | 轮次结束自动记录 | `true` | 插件模式下:它是「agent 主动记忆」主路径失效时的安全网,保证实质工作不丢,代价是只留原始文本、不做总结。智能模式下该开关仍为总闸门 |
136
- | 摘要模型 | `""`(空=复用当前会话模型) | 智能模式专用:留空自动复用当前会话 provider/model;填 `provider/model`(如 `deepseek/deepseek-chat`)固定廉价模型省 token |
137
- | 对话出错自动记录 | `true` | 插件模式下:自动捕获 in-session 错误并写入「错误现象」到对应 MEMORY.md;「根因/方案」由 agent 主动记;默认开启,**关闭无需重启 dsh**。智能模式下错误由 LLM 摘要统一提炼 |
138
-
139
- > **设置保存(v1.1.4 起)**:设置页保存已**真正落盘**
140
- >
141
- > 历史方案(仍可用作兜底):直接在 profile 的 `cordis.patch.yml` 注入配置(id-targeted config override,与插件 bundle insert 的 id 一致):
142
- >
143
- > ```yaml
144
- > - id: memory-palace
145
- > name: 'dsh-memory-palace'
146
- > config:
147
- > memoryMode: smart
148
- > summaryModel: ''
149
- > ```
150
- >
151
- > 修改后重启 dsh 生效。
110
+ *面板路径为 DSH 设置 →「记忆」*
111
+ 所有配置项(基础 / 自动记录 / 开发 三张卡片的字段、默认值与说明)已整理至 [CONFIG.md](./CONFIG.md)
152
112
 
153
113
  ## 开发
154
114
 
155
- 目录结构、构建与测试、技术要点见 [DEVELOPMENT.md](./DEVELOPMENT.md)。
115
+ 架构 / 构建 / 技术要点 / 蒸馏失败重试 见 [DEVELOPMENT.md](./DEVELOPMENT.md)。
116
+ 提示词与智能模式蒸馏(公民指令 / 摘要 / 蒸馏) 见 [PROMPTS.md](./PROMPTS.md)。
156
117
 
157
118
  ## 版本历史
158
119
 
120
+ - **1.4.0**
121
+ - 🔥智能模式增加最大输出Token限制
122
+ - 🔥智能模式增加失败重试机制
123
+ - 🔥智能模式会增加回馈存量记忆
124
+ - 🐛修复自动记录部分设置位置错误问题
125
+ - **1.3.0**
126
+ - 🔥新增计划模式禁止写入记忆
127
+ - 💪设置-记忆控件样式优化 *使用dsh原生样式*
128
+ - 💪清理重复patch信息
129
+ - 🔥增加调试模式 *默认关闭,仅用于智能模式蒸馏失败输出调试信息*
130
+ - **1.2.3**
159
131
  - **1.2.2**
160
132
  - 🐛修复智能模式自定义摘要模型不生效的问题
161
133
  - **1.2.1**
@@ -179,10 +151,6 @@ dsh plugin --profile web remove dsh-memory-palace
179
151
  - 🔥新增删除记忆工具
180
152
  - **outdated(0.x)** — 双层 Markdown 记忆读写 / 设置页集成等 0.x 历史,见 [CHANGELOG.md](./CHANGELOG.md)
181
153
 
182
- ## 废弃方案
183
-
184
- 插件侧 LLM 自动摘要(`ctx.llm.stream()` 路线)曾在 v0.7.1 因 4 个契约/时机坑废弃;**v1.1.0 已重新论证并以「智能模式」可选形态复活**——经 `session.events` / `deriveEventMessage` / `requestHeader()?.config` 根除旧坑,默认仍为插件模式。见 [废弃方案:为什么不用 LLM 自动摘要](./ABANDONED-LLM-SUMMARY.md)。
185
-
186
154
  ## 参考与致谢
187
155
 
188
156
  本插件开发深度参考了以下两个开源项目(本包实现为各自机制的简化落地,不含其完整功能):
package/cordis.patch.yml CHANGED
@@ -1,17 +1,9 @@
1
1
  # dsh-memory-palace bundle patch: 一个 insert 注册本插件。
2
2
  # insert.name 必须是【包名】(pnpm add 后由 profile 的 node_modules 解析到 lib/index.js);
3
3
  # insert.id 必须与入口导出的 cordis `name`('memory-palace')一致。
4
+ # 注:包不再自带 config 块——值与原 Config schema 默认一致,cordis 按 schema 填充默认,
5
+ # 不传 config 不会 undefined。如需在 profile 层注入覆盖配置,走自己 profile 的
6
+ # cordis.patch.yml(`- id: memory-palace` + `config:`,id-targeted,非 insert)。
4
7
  - insert:
5
8
  - id: memory-palace
6
9
  name: 'dsh-memory-palace'
7
- config:
8
- enabled: true
9
- userMemoryPath: '~/.deepseek-harness/MEMORY.md'
10
- workspaceMemoryDir: '.deepseek-harness/memory'
11
- dailyLogRetentionDays: 30
12
- userBudgetChars: 4000
13
- workspaceBudgetChars: 3000
14
- bridgeBuddyMemory: true
15
- buddyWorkspaceMemoryDirs:
16
- - '.workbuddy/memory'
17
- - '.codebuddy/memory'
package/lib/api.mjs CHANGED
@@ -167,7 +167,8 @@ export function registerApi({ ctx, paths, distill, state, getSettingsFace }) {
167
167
  throw Object.assign(new Error("no memory dirs for this workspace"), { code: "bad-request", status: 400 });
168
168
  }
169
169
  // 整段会话全量蒸馏(fromSeq=0);成功后推进增量断点,防智能模式对同段事件重复摘要。
170
- const r = await distill.distillSessionCore(session, dirs, 0);
170
+ // allowDelete:true —— 手动按钮开放 delete 操作(自动智能模式传 false,见 summarizeTurn)。
171
+ const r = await distill.distillSessionCore(session, dirs, 0, { allowDelete: true });
171
172
  if (r.ok && session.id === state.summarySessionId) state.lastSummarizedSeq = session.seq;
172
173
  apiWriteOk(res, r);
173
174
  return;
package/lib/client.js CHANGED
@@ -32,7 +32,7 @@ window.__ModuleLoader__.load({
32
32
  wsBudgetLabel: "工作区预算(字符)",
33
33
  wsBudgetHint: "工作区 MEMORY.md 的最大字符数",
34
34
  summarizeLabel: "轮次结束自动记录",
35
- summarizeHint: "它是「agent 主动记忆」主路径失效时的安全网,保证实质工作不丢,代价是只留原始文本、不做总结。",
35
+ summarizeHint: "两种记忆模式的共用总闸门:关闭后无论插件模式还是智能模式都不写记忆。插件模式下它作为「agent 主动记忆」主路径失效时的安全网,只留原始文本、不做总结;智能模式下开启则走 LLM 智能摘要(总结 + 提炼)。",
36
36
  autoCaptureErrorsLabel: "对话出错时自动记录到记忆",
37
37
  autoCaptureErrorsHint: "对话中(含代码运行报错、工具执行失败)出错时,自动把「错误现象」写入对应 MEMORY.md(用户级/项目级);「根因/方案」由 agent 按记忆公民指令主动记。默认开,关闭无需重启。",
38
38
  memoryModeLabel: "记忆模式",
@@ -62,7 +62,18 @@ window.__ModuleLoader__.load({
62
62
  distilling: "蒸馏中…",
63
63
  notifySessionDone: "会话记忆蒸馏完成",
64
64
  notifyProjectDone: "项目记忆蒸馏完成",
65
- notifyFail: "记忆蒸馏失败"
65
+ notifyFail: "记忆蒸馏失败",
66
+ distillDebugLogLabel: "蒸馏调试日志",
67
+ distillDebugLogHint: "开启后向 dsh 服务端控制台(stderr)输出蒸馏 LLM 调用诊断,仅排障用,平时关闭。",
68
+ dev: "开发",
69
+ distillTimeoutLabel: "蒸馏超时(秒)",
70
+ distillTimeoutHint: "单次蒸馏 LLM 调用的超时上限(秒);超时视为失败并降级。覆盖智能模式摘要与手动蒸馏,默认 60 秒。",
71
+ summaryMaxTokensLabel: "会话摘要最大 Token(智能模式)",
72
+ summaryMaxTokensHint: "智能模式会话摘要 LLM 的最大输出 Token(summary + durable 合计上限)。默认 2000,过小会截断 durable 事实,可上调到 4000+。",
73
+ projectMaxTokensLabel: "项目蒸馏最大 Token",
74
+ projectMaxTokensHint: "手动「蒸馏项目记忆」LLM 的最大输出 Token(蒸馏后 MEMORY.md 长度上限)。默认 8000,过小会截断浓缩结果,可上调。",
75
+ feedbackEnabledLabel: "蒸馏回喂存量记忆",
76
+ feedbackEnabledHint: "开启后,自动智能模式(turn/end)与手动蒸馏会话按钮均会把项目级 + 用户级 MEMORY.md 全文(逐行编号、无截断)回喂 LLM,使其能基于既有记忆做 add/replace/delete 增量维护。delete 仅手动蒸馏按钮开放,自动模式跳过(防误删)。默认关。"
66
77
  };
67
78
 
68
79
  const en = {
@@ -87,7 +98,7 @@ window.__ModuleLoader__.load({
87
98
  wsBudgetLabel: "Workspace budget (chars)",
88
99
  wsBudgetHint: "Max chars for the workspace MEMORY.md",
89
100
  summarizeLabel: "Auto-record at turn end",
90
- summarizeHint: "It is the safety net when the 'agent proactive memory' main path fails: substantive work is never lost, at the cost of keeping only raw text without summarization.",
101
+ summarizeHint: "The shared master gate for both memory modes: when off, neither plugin nor smart mode writes any memory. In plugin mode it is the safety net when the 'agent proactive memory' main path fails (keeps raw text, no summarization); in smart mode it enables the LLM summarization path (summary + extraction).",
91
102
  autoCaptureErrorsLabel: "Auto-record conversation errors",
92
103
  autoCaptureErrorsHint: "On in-session errors, auto write the 'error' into the corresponding MEMORY.md (user/project); 'root cause / fix' is recorded proactively by the agent. On by default; off takes effect without restart.",
93
104
  memoryModeLabel: "Memory mode",
@@ -117,12 +128,23 @@ window.__ModuleLoader__.load({
117
128
  distilling: "Distilling…",
118
129
  notifySessionDone: "Session memory distilled",
119
130
  notifyProjectDone: "Project memory distilled",
120
- notifyFail: "Memory distillation failed"
131
+ notifyFail: "Memory distillation failed",
132
+ distillDebugLogLabel: "Distill debug log",
133
+ distillDebugLogHint: "When on, prints distill LLM call diagnostics to the dsh server console (stderr). For troubleshooting only; keep off normally.",
134
+ dev: "Developer",
135
+ distillTimeoutLabel: "Distill timeout (s)",
136
+ distillTimeoutHint: "Timeout (in seconds) for a single distill LLM call; on timeout it fails and degrades. Covers both smart-mode summary and manual distillation. Default 60s.",
137
+ summaryMaxTokensLabel: "Session summary max tokens (smart mode)",
138
+ summaryMaxTokensHint: "Max output tokens for the smart-mode session summary LLM call (summary + durable combined cap). Default 2000; too low truncates durable facts, raise to 4000+ if needed.",
139
+ projectMaxTokensLabel: "Project distill max tokens",
140
+ projectMaxTokensHint: "Max output tokens for the manual 'distill project memory' LLM call (cap on the resulting MEMORY.md length). Default 8000; too low truncates the condensed result, raise if needed.",
141
+ feedbackEnabledLabel: "Feed existing memory into distillation",
142
+ feedbackEnabledHint: "When on, both the auto smart-mode (turn/end) and the manual session distillation feed the full project-level + user-level MEMORY.md (line-numbered, no truncation) back to the LLM so it can maintain memory incrementally via add/replace/delete. delete is only allowed via the manual distill button; auto mode skips it (to avoid accidental memory loss). Off by default."
121
143
  };
122
144
 
123
145
 
124
- const FIELD_NUMERIC = new Set(["dailyLogRetentionDays", "userBudgetChars", "workspaceBudgetChars"]);
125
- const FIELD_BOOL = new Set(["bridgeBuddyMemory", "summarize", "autoCaptureErrors"]);
146
+ const FIELD_NUMERIC = new Set(["dailyLogRetentionDays", "userBudgetChars", "workspaceBudgetChars", "summaryTimeoutMs", "summaryMaxTokens", "projectMaxTokens"]);
147
+ const FIELD_BOOL = new Set(["bridgeBuddyMemory", "summarize", "autoCaptureErrors", "distillDebugLog", "feedbackEnabled"]);
126
148
 
127
149
  function projectDraft(snap) {
128
150
  const v = snap && snap.value ? snap.value : {};
@@ -136,8 +158,13 @@ window.__ModuleLoader__.load({
136
158
  workspaceBudgetChars: v.workspaceBudgetChars != null ? String(v.workspaceBudgetChars) : "3000",
137
159
  summarize: v.summarize === false ? "false" : "true",
138
160
  autoCaptureErrors: v.autoCaptureErrors === false ? "false" : "true",
161
+ distillDebugLog: v.distillDebugLog === false ? "false" : "true",
162
+ summaryTimeoutMs: v.summaryTimeoutMs != null ? String(Math.round((Number(v.summaryTimeoutMs) || 0) / 1000)) : "60",
139
163
  memoryMode: v.memoryMode || "plugin",
140
- summaryModel: v.summaryModel || ""
164
+ summaryModel: v.summaryModel || "",
165
+ summaryMaxTokens: v.summaryMaxTokens != null ? String(v.summaryMaxTokens) : "2000",
166
+ projectMaxTokens: v.projectMaxTokens != null ? String(v.projectMaxTokens) : "8000",
167
+ feedbackEnabled: v.feedbackEnabled === false ? "false" : "true"
141
168
  };
142
169
  }
143
170
 
@@ -266,7 +293,10 @@ window.__ModuleLoader__.load({
266
293
  if (FIELD_BOOL.has(field)) {
267
294
  section[field] = text === "true";
268
295
  } else if (FIELD_NUMERIC.has(field)) {
269
- if (text !== "") section[field] = Number(text);
296
+ if (text !== "") {
297
+ const n = Number(text);
298
+ section[field] = field === "summaryTimeoutMs" ? n * 1000 : n;
299
+ }
270
300
  } else if (field === "buddyWorkspaceMemoryDirs") {
271
301
  section[field] = text.split(",").map((s) => s.trim()).filter(Boolean);
272
302
  } else {
@@ -296,9 +326,11 @@ window.__ModuleLoader__.load({
296
326
  const userLayer = remote.user && typeof remote.user === "object" ? remote.user : {};
297
327
  const isOverridden = (field) => field in userLayer;
298
328
 
329
+ // 左侧文字容器:min-width:0 + flexShrink:1 让长描述正常换行收缩,不再撑爆 flex 把 checkbox 挤变形。
330
+ const toggleLabelBoxStyle = { flexShrink: 1, minWidth: 0 };
299
331
  const toggle = (label, hint, field, disabledOverride) =>
300
332
  h("div", { style: toggleStyle }, [
301
- h("div", null, [
333
+ h("div", { style: toggleLabelBoxStyle }, [
302
334
  h("p", { style: { ...labelStyle, margin: 0 } }, label),
303
335
  h("p", { style: hintStyle }, hint)
304
336
  ]),
@@ -306,6 +338,7 @@ window.__ModuleLoader__.load({
306
338
  type: "checkbox",
307
339
  checked: draft[field] === "true",
308
340
  disabled: disabled || disabledOverride,
341
+ style: checkboxStyle,
309
342
  onChange: (e) => edit(field, e.target.checked ? "true" : "false")
310
343
  })
311
344
  ]);
@@ -333,6 +366,27 @@ window.__ModuleLoader__.load({
333
366
  fontSize: "13px",
334
367
  lineHeight: "1.5"
335
368
  };
369
+ const selectStyle = {
370
+ ...inputStyle,
371
+ height: "34px",
372
+ appearance: "none",
373
+ WebkitAppearance: "none",
374
+ backgroundImage: "url(data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E)",
375
+ backgroundPosition: "right 12px center",
376
+ backgroundRepeat: "no-repeat",
377
+ backgroundSize: "12px 12px",
378
+ paddingRight: "32px"
379
+ };
380
+ const checkboxStyle = {
381
+ width: "18px",
382
+ height: "18px",
383
+ margin: "0",
384
+ accentColor: "var(--dsw-alias-brand-primary, #4b5bff)",
385
+ // 锁死:不被 flex 长描述挤压缩(flex-shrink 默认 1 会在布局阶段无视 width/!important)。
386
+ flexShrink: 0,
387
+ flexGrow: 0,
388
+ alignSelf: "center"
389
+ };
336
390
  const toggleStyle = {
337
391
  display: "flex",
338
392
  alignItems: "center",
@@ -431,7 +485,9 @@ window.__ModuleLoader__.load({
431
485
  onChange: (e) => edit(field, e.target.value)
432
486
  });
433
487
 
434
- return h("div", { style: sectionStyle }, [
488
+ return h("div", { className: "memory-palace-settings", style: sectionStyle }, [
489
+ // 强制覆盖宿主 Webview 全局样式对原生 checkbox 尺寸的覆盖(内联 style 可能不敌全局 CSS)。
490
+ h("style", null, ".memory-palace-settings input[type=checkbox]{width:18px!important;height:18px!important;margin:0;flex-shrink:0!important;cursor:pointer;}"),
435
491
  h("div", { style: { display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px" } }, [
436
492
  h("div", null, [
437
493
  h("h3", { style: headingStyle }, t("title")),
@@ -446,7 +502,7 @@ window.__ModuleLoader__.load({
446
502
  h("div", { style: cardStyle }, [
447
503
  h("p", { style: groupTitleStyle }, t("core")),
448
504
  h("div", { style: toggleStyle }, [
449
- h("div", null, [
505
+ h("div", { style: { flexShrink: 1, minWidth: 0 } }, [
450
506
  h("p", { style: { ...labelStyle, margin: 0 } }, t("bridgeLabel")),
451
507
  h("p", { style: hintStyle }, t("bridgeHint"))
452
508
  ]),
@@ -454,6 +510,7 @@ window.__ModuleLoader__.load({
454
510
  type: "checkbox",
455
511
  checked: draft.bridgeBuddyMemory === "true",
456
512
  disabled,
513
+ style: checkboxStyle,
457
514
  onChange: (e) => edit("bridgeBuddyMemory", e.target.checked ? "true" : "false")
458
515
  })
459
516
  ]),
@@ -466,30 +523,37 @@ window.__ModuleLoader__.load({
466
523
  ]),
467
524
  h("div", { style: cardStyle }, [
468
525
  h("p", { style: groupTitleStyle }, t("smartSummary")),
526
+ // 轮次结束自动记录(summarize):两种记忆模式的共用总闸门,始终显示,置于记忆模式之上、不随模式切换隐藏。
527
+ toggle(t("summarizeLabel"), t("summarizeHint"), "summarize"),
469
528
  row(t("memoryModeLabel"), draft.memoryMode === "smart" ? t("memoryModeHintSmart") : t("memoryModeHintPlugin"),
470
529
  h("select", {
471
530
  value: draft.memoryMode === "smart" ? "smart" : "plugin",
472
531
  disabled,
473
532
  onChange: (e) => edit("memoryMode", e.target.value),
474
- style: { ...inputStyle, height: "34px" }
533
+ style: selectStyle
475
534
  }, [
476
535
  h("option", { value: "plugin" }, "插件模式"),
477
536
  h("option", { value: "smart" }, "智能模式")
478
537
  ]), "memoryMode"),
479
538
  draft.memoryMode === "smart"
480
- ? row(t("summaryModelLabel"), t("summaryModelHint"),
481
- h("select", {
482
- // 已存储值不在下拉选项内(如旧版双前缀脏数据)→ 归零为「复用当前会话模型」,
483
- // 避免 select 显示悬空值、保存时又把脏值写回 settings.yaml。
484
- value: modelOptions.some((o) => o.value === draft.summaryModel) ? draft.summaryModel : "",
485
- disabled,
486
- onChange: (e) => edit("summaryModel", e.target.value)
487
- }, [
488
- h("option", { value: "" }, "复用当前会话模型"),
489
- ...modelOptions.map((o) => h("option", { value: o.value }, o.label))
490
- ]), "summaryModel")
539
+ ? h("div", null, [
540
+ row(t("summaryModelLabel"), t("summaryModelHint"),
541
+ h("select", {
542
+ // 已存储值不在下拉选项内(如旧版双前缀脏数据)→ 归零为「复用当前会话模型」,
543
+ // 避免 select 显示悬空值、保存时又把脏值写回 settings.yaml。
544
+ value: modelOptions.some((o) => o.value === draft.summaryModel) ? draft.summaryModel : "",
545
+ disabled,
546
+ style: selectStyle,
547
+ onChange: (e) => edit("summaryModel", e.target.value)
548
+ }, [
549
+ h("option", { value: "" }, "复用当前会话模型"),
550
+ ...modelOptions.map((o) => h("option", { value: o.value }, o.label))
551
+ ]), "summaryModel"),
552
+ toggle(t("feedbackEnabledLabel"), t("feedbackEnabledHint"), "feedbackEnabled"),
553
+ row(t("summaryMaxTokensLabel"), t("summaryMaxTokensHint"), h("input", num("summaryMaxTokens")), "summaryMaxTokens"),
554
+ row(t("projectMaxTokensLabel"), t("projectMaxTokensHint"), h("input", num("projectMaxTokens")), "projectMaxTokens")
555
+ ])
491
556
  : h("div", null, [
492
- toggle(t("summarizeLabel"), t("summarizeHint"), "summarize"),
493
557
  toggle(t("autoCaptureErrorsLabel"), t("autoCaptureErrorsHint"), "autoCaptureErrors")
494
558
  ])
495
559
  ]),
@@ -500,6 +564,11 @@ window.__ModuleLoader__.load({
500
564
  row(t("retentionLabel"), t("retentionHint"), h("input", num("dailyLogRetentionDays")), "dailyLogRetentionDays"),
501
565
  row(t("userBudgetLabel"), t("userBudgetHint"), h("input", num("userBudgetChars")), "userBudgetChars"),
502
566
  row(t("wsBudgetLabel"), t("wsBudgetHint"), h("input", num("workspaceBudgetChars")), "workspaceBudgetChars")
567
+ ]),
568
+ h("div", { style: cardStyle }, [
569
+ h("p", { style: groupTitleStyle }, t("dev")),
570
+ row(t("distillTimeoutLabel"), t("distillTimeoutHint"), h("input", num("summaryTimeoutMs")), "summaryTimeoutMs"),
571
+ toggle(t("distillDebugLogLabel"), t("distillDebugLogHint"), "distillDebugLog")
503
572
  ])
504
573
  ]);
505
574
  }
@@ -4,17 +4,42 @@
4
4
  // 场景关键词(增强原方案 D):命中即视为"用户告知偏好 / 做出技术决策",纯文本轮次也写。
5
5
  export const SCENE_KEYWORDS = ["记住", "记一下", "remind", "偏好", "决定", "以后都", "约定", "采用", "根因", "修复"];
6
6
 
7
- // 智能模式(memoryMode==="smart")的摘要提炼 prompt:让 LLM 输出 {summary, durable:[{scope,fact}]}。
8
- // 产物:summary → 每日日志(带 [smart] 标记);durable → MEMORY.md(- [smart] <fact>,按 scope 分层)。
9
- export const SUMMARY_PROMPT =
10
- "你是会话记忆提炼助手。根据下面这段【新产生的】对话(含工具调用与结果),提炼两样东西:\n" +
11
- "1) summary:一段简洁中文摘要——这段对话做了什么、关键结果(路径/命令/数字)。\n" +
12
- "2) durable:值得跨 session 长期记住的事实列表(最多 3 条),每条带 scope:\n" +
13
- ' - "project" = 当前项目的约定/决策/技术事实\n' +
14
- ' - "user" = 跨项目的个人偏好/习惯\n' +
15
- " 闲聊、一次性操作、显而易见的内容、纯错误现象(错误已有独立记录)不要提炼;没有就留空数组。\n" +
16
- "只输出 JSON,不要多余文字,格式:\n" +
17
- '{"summary":"...","durable":[{"scope":"project","fact":"一句话"}]}';
7
+ // 智能模式(memoryMode==="smart")的摘要提炼 prompt(v1.4.0 起为函数,支持回喂存量记忆)。
8
+ // 让 LLM 输出 {summary, durable:[{scope,fact}], memoryOps:[...]};memoryOps 用于基于编号的现有记忆做增量维护。
9
+ // allowDelete=true(手动蒸馏按钮):开放 add/replace/delete;false(自动智能模式):禁 delete,仅 add/replace。
10
+ // 注意:回喂是否实际发生由 distill.mjs 按 cfg().feedbackEnabled 决定(智能模式级,自动/手动均生效);
11
+ // 此处仅声明能力边界——allowDelete 决定 prompt 是否允许 delete 指令(自动模式禁 delete,手动按钮放开)。
12
+ export function SUMMARY_PROMPT({ allowDelete = false } = {}) {
13
+ const feedbackInstr = allowDelete
14
+ ? "【维护现有记忆】若对话中提供了「项目级记忆 / 用户级记忆(逐行编号)」,你可在输出 memoryOps 中对现有记忆做增量维护:\n" +
15
+ '- add:新增一条事实(scope + fact),勿与现有重复;\n' +
16
+ '- replace:line=现有行号,oldText=该行精确原文,newText=替换后全文(仅内容行,禁止改动 # / <!-- 结构行);\n' +
17
+ '- delete:line=现有行号,oldText=该行精确原文(仅内容行,禁止删除 # / <!-- 结构行);\n' +
18
+ "所有 replace/delete 必须带 oldText 精确匹配(匹配失败将被拒绝、不修改任何内容)。"
19
+ : "【维护现有记忆】若对话中提供了「项目级记忆 / 用户级记忆(逐行编号)」,你可在输出 memoryOps 中对现有记忆做增量维护:\n" +
20
+ '- add:新增一条事实(scope + fact),勿与现有重复;\n' +
21
+ '- replace:line=现有行号,oldText=该行精确原文,newText=替换后全文(仅内容行);\n' +
22
+ "(当前为自动模式,禁止使用 delete,避免误删记忆;replace 必须带 oldText 精确匹配,匹配失败将被拒绝。)";
23
+
24
+ return (
25
+ "你是会话记忆提炼助手。根据下面这段【新产生的】对话(含工具调用与结果),提炼两样东西:\n" +
26
+ "1) summary:一段简洁中文摘要——这段对话做了什么、关键结果(路径/命令/数字)。\n" +
27
+ "2) durable:值得跨 session 长期记住的事实列表(最多 3 条),每条带 scope:\n" +
28
+ ' - "project" = 当前项目的约定/决策/技术事实\n' +
29
+ ' - "user" = 跨项目的个人偏好/习惯\n' +
30
+ " 闲聊、一次性操作、显而易见的内容、纯错误现象(错误已有独立记录)不要提炼;没有就留空数组。\n" +
31
+ "【输出风格硬约束】\n" +
32
+ "1) summary 必须是**客观、第三人称、陈述性**的中文摘要,描述「对话完成了什么 / 得出了什么结论」;\n" +
33
+ " **严禁**包含:提问、提议、请示、寒暄、自我指涉(如「我可以帮你…」「需要的话…」「要不要…」)、\n" +
34
+ " 内心独白(如「But wait」「I should…」)或任何对话体 / 元叙述——你是在**写记忆**,不是在**对话**。\n" +
35
+ "2) durable 每条必须是**可独立成立的客观事实**;**严禁**把「助手说过的话 / 向用户提问 / 未确认的提议」本身当作事实记录。\n" +
36
+ "3) 若对话中仅含未决的提问或提议、无确定结论,summary 写「本次对话为未决讨论,暂无确定结论」,durable 留空,绝不可照抄对话体文本。\n" +
37
+ feedbackInstr +
38
+ "\n只输出 JSON,不要多余文字,格式:\n" +
39
+ '{"summary":"...","durable":[{"scope":"project","fact":"一句话"}],"memoryOps":[{"op":"add","scope":"project","fact":"..."},{"op":"replace","line":12,"oldText":"...","newText":"..."},{"op":"delete","line":7,"oldText":"..."}]}\n' +
40
+ "(没有需要维护的现有记忆时,memoryOps 留空数组 []。)"
41
+ );
42
+ }
18
43
 
19
44
  // v1.2.0 项目记忆蒸馏 prompt(固化自 upgrade plan/v1.2.0/记忆蒸馏Prompt.md,供按钮「蒸馏项目记忆」稳定调用)。
20
45
  // 用法:system = 本 prompt,user = 项目 MEMORY.md 全文,输出 = 精炼后的项目记忆(直接覆盖写回)。
@@ -75,6 +75,85 @@ export async function removeLineByMatch(file, match) {
75
75
  return { removed, ok: true, reason: "done", lines: removedLines };
76
76
  }
77
77
 
78
+ // 全文逐行编号读取(v1.4.0 特性3:回喂存量记忆)。返回 { lines:[{n,raw,structural}], text }。
79
+ // 行号 n 与文件实际行一一对应(1 起),供 LLM 通过 line 引用;text 为每行 `n|raw` 拼接,直接喂模型。
80
+ // 不截断、无字符预算(用户明确要求全量回喂,避免截断导致记忆错误)。
81
+ export function readNumberedMemory(file) {
82
+ let cur = "";
83
+ try {
84
+ cur = readFileSync(file, "utf8");
85
+ } catch {
86
+ return { lines: [], text: "" };
87
+ }
88
+ if (!cur) return { lines: [], text: "" };
89
+ const lines = cur.split("\n").map((raw, i) => {
90
+ const t = raw.trim();
91
+ return { n: i + 1, raw, structural: isStructural(t) };
92
+ });
93
+ const text = lines.map((l) => `${l.n}|${l.raw}`).join("\n");
94
+ return { lines, text };
95
+ }
96
+
97
+ // 基于编号现有记忆的增量维护操作(v1.4.0 特性3)。op 之一:
98
+ // - add:追加一行(text/newText,去重);scope 由 caller 决定目标文件。
99
+ // - replace:替换第 line 行(写前重读 + normLine(oldText) 精确匹配校验,防 stale 行号误改)。
100
+ // - delete:删除第 line 行(结构行 / 未匹配 oldText 均拒绝)。
101
+ // 写前重读(re-read before write)保证基于最新文件内容,避免并发/重试导致的 stale 行号错位。
102
+ // 返回 { ok, reason, changed, line?, removed?, actual? }
103
+ export async function applyMemoryOp(file, op) {
104
+ const o = op || {};
105
+ await mkdir(dirname(file), { recursive: true });
106
+ let cur = "";
107
+ try {
108
+ cur = readFileSync(file, "utf8");
109
+ } catch {
110
+ /* 文件尚不存在 */
111
+ }
112
+ const lines = cur.split("\n");
113
+ const normRaw = (s) => normLine((s || "").trim());
114
+
115
+ if (o.op === "add") {
116
+ const text = (o.text ?? o.newText ?? "").trim();
117
+ if (!text) return { ok: false, reason: "empty-add" };
118
+ if (cur && normRaw(cur).includes(normRaw(text))) return { ok: true, reason: "dup", changed: false };
119
+ const base = cur && !cur.endsWith("\n") ? cur + "\n" : cur;
120
+ await writeFile(file, `${base}${text}\n`, "utf8");
121
+ return { ok: true, reason: "added", changed: true };
122
+ }
123
+
124
+ if (o.op !== "replace" && o.op !== "delete") {
125
+ return { ok: false, reason: "unknown-op", op: o.op };
126
+ }
127
+
128
+ const n = o.line;
129
+ if (!Number.isInteger(n) || n < 1 || n > lines.length) {
130
+ return { ok: false, reason: "bad-line", line: n, totalLines: lines.length };
131
+ }
132
+ const idx = n - 1;
133
+ const rawLine = lines[idx];
134
+ const trimmed = rawLine.trim();
135
+ if (isStructural(trimmed)) {
136
+ return { ok: false, reason: "structural-protected", line: n };
137
+ }
138
+ // 写前重读后精确匹配:确保操作基于最新内容(防止 stale 行号导致误改/误删)。
139
+ const oldNorm = normRaw(o.oldText);
140
+ if (oldNorm && normRaw(rawLine) !== oldNorm) {
141
+ return { ok: false, reason: "conflict", line: n, expected: o.oldText, actual: trimmed };
142
+ }
143
+
144
+ if (o.op === "delete") {
145
+ const newLines = lines.slice(0, idx).concat(lines.slice(idx + 1));
146
+ await writeFile(file, newLines.join("\n"), "utf8");
147
+ return { ok: true, reason: "deleted", changed: true, line: n, removed: trimmed };
148
+ }
149
+ // replace
150
+ const newText = (o.newText ?? "").trim();
151
+ if (!newText) return { ok: false, reason: "empty-replace" };
152
+ lines[idx] = newText;
153
+ await writeFile(file, lines.join("\n"), "utf8");
154
+ return { ok: true, reason: "replaced", changed: true, line: n };
155
+ }
156
+
78
157
  // 读取目录下最近的非今日每日日志日期(最多 limit 份,降序)。供删除范围圈定。
79
158
  export async function recentLogDates(dir, limit = 3) {
80
159
  let files = [];
@@ -0,0 +1,138 @@
1
+ // memory-palace 蒸馏 LLM 调用失败重试(v1.4.0 特性2)。
2
+ // 纯函数 + 通用重试驱动,与具体 distill 逻辑解耦,可被 test-load.mjs 直接单测。
3
+ //
4
+ // 设计要点(已与用户确认):
5
+ // - 错误分三类:retryable(5xx/网络/超时/中断,可重试)、limited(429/529 限流,可重试但分类不同)、
6
+ // fatal(401/403/404 鉴权/模型不存在/永久客户端错误,不重试)。
7
+ // - 退避:指数退避 delay = min(BASE_DELAY_MS * FACTOR^attempt, MAX_DELAY_MS) + 抖动(≤15%)。
8
+ // - 上限:通用 MAX_RETRIES=3;500 类服务端错误单独限到 HTTP500_MAX_RETRIES=1(500 多为主服务挂,
9
+ // 重试意义不大且拖慢降级)。
10
+ // - 每次尝试【独立超时】:makeAttempt 内部每次新开 AbortSignal.timeout(timeoutMs),避免单次超时耗尽累计预算。
11
+ export const RETRY_CONSTANTS = {
12
+ BASE_DELAY_MS: 2000,
13
+ FACTOR: 2,
14
+ MAX_RETRIES: 3,
15
+ MAX_DELAY_MS: 32000,
16
+ HTTP500_MAX_RETRIES: 1,
17
+ };
18
+
19
+ // 把一个可能携带状态码的 error / finish.failure 归一为分类结果。
20
+ // 入参 err 可能是:stream 抛出的 Error、ctx.llm.stream 同步抛出的错误、或 finish.failure 对象。
21
+ export function classifyFailure(err) {
22
+ const e = err && typeof err === "object" ? err : {};
23
+ const status =
24
+ e.status ??
25
+ e.statusCode ??
26
+ e.code ??
27
+ e.response?.status ??
28
+ (typeof e.status === "number" ? e.status : undefined);
29
+ const code = e.code || e.name;
30
+ const message = String(e.message || e.statusText || "")
31
+ .concat(" ", String(e.response?.statusText || ""))
32
+ .trim();
33
+
34
+ // fatal:鉴权 / 模型不存在 / 永久客户端错误 —— 重试无意义。
35
+ if (status === 401 || status === 403) return { kind: "fatal", status, message };
36
+ if (status === 404) return { kind: "fatal", status, message };
37
+ if (status === 400) return { kind: "fatal", status, message };
38
+
39
+ // limited:限流 / 过载 —— 可重试,但 caller 可按 limited 走更激进退避(此处同用指数退避即可)。
40
+ if (status === 429 || status === 529) return { kind: "limited", status, message };
41
+
42
+ // retryable:5xx 服务端错误(含 500,单独限重试次数见 runWithRetry)。
43
+ if (typeof status === "number" && status >= 500 && status < 600) {
44
+ return { kind: "retryable", status, message };
45
+ }
46
+
47
+ // 网络类错误(连接重置/超时/连接拒绝)——DNS 解析失败(ENOTFOUND) 不重试(见升级计划:通常需人工干预)。
48
+ if (
49
+ code === "ECONNRESET" ||
50
+ code === "ETIMEDOUT" ||
51
+ code === "ECONNREFUSED" ||
52
+ code === "ENETUNREACH"
53
+ ) {
54
+ return { kind: "retryable", status, message };
55
+ }
56
+
57
+ // 超时 / 中断:AbortSignal 触发、显式 aborted/timeout 字样。
58
+ if (
59
+ e.name === "AbortError" ||
60
+ code === "ABORT_ERR" ||
61
+ /aborted|timeout|timed out|deadline/i.test(message)
62
+ ) {
63
+ return { kind: "retryable", status, message };
64
+ }
65
+
66
+ // 兜底:未知错误不盲目重试(避免死循环),按 fatal 处理。
67
+ return { kind: "fatal", status, message };
68
+ }
69
+
70
+ // 指数退避 + 抖动(≤15%)。attempt 从 0 起算。
71
+ export function backoffDelayMs(attempt) {
72
+ const { BASE_DELAY_MS, FACTOR, MAX_DELAY_MS } = RETRY_CONSTANTS;
73
+ const n = Math.max(0, attempt | 0);
74
+ const base = Math.min(BASE_DELAY_MS * Math.pow(FACTOR, n), MAX_DELAY_MS);
75
+ const jitter = Math.random() * (base * 0.15);
76
+ return Math.round(base + jitter);
77
+ }
78
+
79
+ export function sleep(ms) {
80
+ return new Promise((resolve) => setTimeout(resolve, ms));
81
+ }
82
+
83
+ // 重试失败后的结构化错误(caller 据此降级,不向上抛裸 Error)。
84
+ export class LlmRetryExhausted extends Error {
85
+ constructor(cls, attempts) {
86
+ super(`llm call exhausted after ${attempts} attempt(s): ${cls.kind} ${cls.status || ""} ${cls.message}`);
87
+ this.name = "LlmRetryExhausted";
88
+ this.cls = cls;
89
+ this.attempts = attempts;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 通用重试驱动。
95
+ * @param {() => Promise<{ finish?: { kind?: string, failure?: any }, text?: string }>} makeAttempt
96
+ * 单次尝试:内部自行创建独立 AbortSignal 并处理流;返回 { finish, text }。
97
+ * 成功 → finish.kind 为 undefined/'done'/'max-tokens';失败 → finish.kind 为 'error'/'aborted'(含 failure)
98
+ * 或抛异常(网络/超时)。
99
+ * @param {{ maxRetries?: number, classify?: (e:any)=>any, onAttempt?: (info:any)=>void }} opts
100
+ * @returns {Promise<{ finish?: any, text?: string, attempts: number }>}
101
+ */
102
+ export async function runWithRetry(makeAttempt, opts = {}) {
103
+ const { maxRetries = RETRY_CONSTANTS.MAX_RETRIES, onAttempt } = opts;
104
+ const classify = opts.classify || classifyFailure;
105
+ let lastCls = null;
106
+
107
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
108
+ if (onAttempt) onAttempt({ attempt, maxRetries });
109
+ try {
110
+ const res = await makeAttempt(attempt);
111
+ const kind = res?.finish?.kind;
112
+ if (kind === "error" || kind === "aborted") {
113
+ const cls = classify(res.finish?.failure || {});
114
+ lastCls = cls;
115
+ // 500 类单独限重试次数;其余按 maxRetries。
116
+ const cap = cls.status === 500 ? RETRY_CONSTANTS.HTTP500_MAX_RETRIES : maxRetries;
117
+ if ((cls.kind === "retryable" || cls.kind === "limited") && attempt < cap) {
118
+ await sleep(backoffDelayMs(attempt));
119
+ continue;
120
+ }
121
+ throw new LlmRetryExhausted(cls, attempt + 1);
122
+ }
123
+ // 成功 / max-tokens:直接返回,文本解析交给 caller。
124
+ return { ...res, attempts: attempt + 1 };
125
+ } catch (err) {
126
+ const cls = classify(err);
127
+ lastCls = cls;
128
+ const cap = cls.status === 500 ? RETRY_CONSTANTS.HTTP500_MAX_RETRIES : maxRetries;
129
+ if ((cls.kind === "retryable" || cls.kind === "limited") && attempt < cap) {
130
+ await sleep(backoffDelayMs(attempt));
131
+ continue;
132
+ }
133
+ throw new LlmRetryExhausted(cls, attempt + 1);
134
+ }
135
+ }
136
+ // 理论不可达(循环内必然返回或抛出),兜底。
137
+ throw new LlmRetryExhausted(lastCls || { kind: "fatal", message: "unknown" }, maxRetries + 1);
138
+ }
package/lib/distill.mjs CHANGED
@@ -5,7 +5,8 @@ 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
7
  import { expandHome, readMdSync, toHomeShort, nowStamp } from "./common/text.mjs";
8
- import { appendLineDedup } from "./common/records.mjs";
8
+ import { appendLineDedup, readNumberedMemory, applyMemoryOp } from "./common/records.mjs";
9
+ import { runWithRetry, RETRY_CONSTANTS } from "./common/retry.mjs";
9
10
 
10
11
  /**
11
12
  * @param {{ ctx: object, getConfig: () => object, paths: object, records: object, state: object }} deps
@@ -13,42 +14,102 @@ import { appendLineDedup } from "./common/records.mjs";
13
14
  export function createDistill({ ctx, getConfig, paths, records, state }) {
14
15
  const cfg = () => getConfig();
15
16
 
17
+ // 调试日志(v1.3.0):dbgFail=失败/跳过留痕(无条件输出,不受开关控制,保证排障可观测);
18
+ // dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度计数/错误详情)。
19
+ // 隐私红线:只打计数/字符数/元数据/错误 message+stack 首行,绝不打印对话或 MEMORY.md 文本。
20
+ const dbgFail = (why, extra) => console.error(`[memory-palace] distill skip: ${why}`, extra ? JSON.stringify(extra) : "");
21
+ const dbg = (why, extra) => { if (!cfg().distillDebugLog) return; console.error(`[memory-palace][debug] ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
22
+
16
23
  // 模型解析(v1.2.3 修复):summaryModel 存的是注册表 models[].id 原样(client 下拉 value = m.id),
17
24
  // pi-ai getModel(provider, id) 按 model.id 全等匹配(pi-ai models.js: getModels(provider).find(m => m.id === id)),
18
25
  // 所以 model 参数必须【原样直传】summaryModel——绝不能 split 拆段。v1.2.2 之前拆段把
19
26
  // `nvidia/nemotron-3-ultra-550b-a55b` 变成裸 id `nemotron-3-ultra-550b-a55b`,全等匹配失败抛 UNKNOWN_MODEL。
20
- // provider 取 summaryModel 首段(带前缀 id 的 route key,如 nvidia/nemotron-3-ultra-550b-a55b → nvidia)。
27
+ // provider 解析优先级:
28
+ // 1) 注册表反查(最稳):遍历 ctx.llm.listProviders(),对每个 provider 调 listModels,
29
+ // 找 m.id === summaryModel 全等命中 → 返回 { provider: m.provider, model: m.id }。
30
+ // 带前缀 id(nvidia/nemotron-3-ultra-550b-a55b)与裸 id(mimo-v2.5 属 xiaomi)都能一次命中。
31
+ // 2) 首段兜底:listProviders 不可用(测试 mock 等)时,取 summaryModel 首段当 provider(openai/gpt-4o 格式)。
21
32
  // summaryModel 为空 → 会话 requestHeader config 兜底(复用当前会话 provider/model,原样直传)。
22
- // 边界:裸 id 格式(如 GLM-4.7-Flash,无 provider 前缀)首段取不到正确 provider → 需要
23
- // ctx.llm.listProviders() 反查注册表;当前配置(nvidia 带前缀 / zai 裸 id)用首段即可,裸 id 场景暂不处理。
24
- function resolveModel(summaryModel, session) {
33
+ async function findModelInRegistry(modelId) {
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 === modelId);
40
+ if (m) return { provider: m.provider, model: m.id };
41
+ } catch { /* 该 provider 无模型列表或不可用,跳过 */ }
42
+ }
43
+ } catch { /* listProviders 不可用,跳过反查 */ }
44
+ return null;
45
+ }
46
+ async function resolveModel(summaryModel, session) {
25
47
  const sm = (summaryModel || "").trim();
26
48
  if (sm) {
49
+ const hit = await findModelInRegistry(sm);
50
+ if (hit) { dbg("resolveModel: registry hit", { provider: hit.provider, model: hit.model }); return hit; }
27
51
  const provider = sm.split("/")[0].trim();
28
- if (provider) return { provider, model: sm };
52
+ if (provider && sm.includes("/")) { dbg("resolveModel: prefix fallback", { provider, model: sm }); return { provider, model: sm }; }
29
53
  }
30
54
  const conf = session?.requestHeader?.()?.config;
31
- if (conf && conf.provider && conf.model) return { provider: conf.provider, model: conf.model };
55
+ if (conf && conf.provider && conf.model) { dbg("resolveModel: session fallback", { provider: conf.provider, model: conf.model }); return { provider: conf.provider, model: conf.model }; }
56
+ dbgFail("resolveModel: no model resolved", { summaryModel: sm, hasHeader: !!(conf && conf.provider) });
32
57
  return null;
33
58
  }
34
59
 
35
60
  // 项目蒸馏乐观锁:按目标 MEMORY.md 绝对路径防并发重复蒸馏。
36
61
  const distillLocks = new Set();
37
62
 
63
+ // 单次 LLM 流尝试(供 runWithRetry 调用):每次【新建独立 AbortSignal】,避免单次超时耗尽累计预算。
64
+ // 返回 { finish, text, chunks, deltaChars, gotFirstChunk, tFirstChunk, elapsed };流中抛错(网络/超时/中断)原样向上抛,
65
+ // 由 runWithRetry 分类决定是否重试。finish.kind 为 error/aborted 时不抛(让 runWithRetry 据此决定重试/降级)。
66
+ async function streamOnce(params, timeoutMs) {
67
+ const signal = AbortSignal.timeout(timeoutMs);
68
+ const t0 = Date.now();
69
+ let gotFirstChunk = false, tFirstChunk = null, chunks = 0, deltaChars = 0;
70
+ const res = ctx.llm.stream({ ...params, signal });
71
+ const asm = new BlockAssembler();
72
+ try {
73
+ for await (const ch of res) {
74
+ signal.throwIfAborted();
75
+ chunks++;
76
+ deltaChars += (ch.text?.length || 0);
77
+ if (!gotFirstChunk) { gotFirstChunk = true; tFirstChunk = Date.now() - t0; }
78
+ asm.push(ch);
79
+ }
80
+ } catch (e) {
81
+ // 流中抛错(网络/超时/中断):原样抛出供 runWithRetry 分类重试或失败。
82
+ throw e;
83
+ }
84
+ const finish = asm.finish;
85
+ const text = asm
86
+ .blocks()
87
+ .filter((b) => b.type === "text")
88
+ .map((b) => b.text ?? "")
89
+ .join("")
90
+ .trim();
91
+ return { finish, text, chunks, deltaChars, gotFirstChunk, tFirstChunk, elapsed: Date.now() - t0 };
92
+ }
93
+
38
94
  // 蒸馏核心(v1.2.0 抽取):对指定 session 的 [fromSeq, ∞) surface 事件做 LLM 智能摘要并写盘。
39
95
  // 自动智能模式(summarizeTurn,增量断点)与按钮「蒸馏会话」(全量 fromSeq=0)共用本核心。
40
96
  // 返回 { ok, summary, durableCount };失败 { ok:false }(调用方各自决定降级策略)。
41
- async function distillSessionCore(session, dirs, fromSeq) {
42
- const dbg = (why, extra) => console.error(`[memory-palace] distill skip: ${why}`, extra ?? "");
97
+ async function distillSessionCore(session, dirs, fromSeq, opts) {
98
+ const allowDelete = !!(opts && opts.allowDelete);
99
+ // 回喂存量记忆(v1.4.0 特性3):智能模式级能力——feedbackEnabled 开启即在【自动智能模式(turn/end)
100
+ // 与手动蒸馏按钮】两条链路都回喂项目级 + 用户级 MEMORY.md 全文(逐行编号、无截断)。
101
+ // delete 仍仅手动蒸馏(allowDelete=true)开放;自动模式 memoryOps 中的 delete 在 §5.5 被 !allowDelete 跳过。
102
+ // 回喂目标严格限定为 MEMORY.md;每日日志(.deepseek-harness/memory/YYYY-MM-DD.md)不读取、不改写、不删旧行。
103
+ const useFeedback = !!cfg().feedbackEnabled;
43
104
  if (!session) {
44
- dbg("no session");
105
+ dbgFail("no session");
45
106
  return { ok: false };
46
107
  }
47
108
  const c = cfg();
48
- // 1. 模型解析:summaryModel 原样直传(注册表 id)> 复用当前会话模型;皆缺 → 降级。
49
- const resolved = resolveModel(c.summaryModel, session);
109
+ // 1. 模型解析:summaryModel 注册表反查(带前缀/裸 id 均可)> 复用当前会话模型;皆缺 → 降级。
110
+ const resolved = await resolveModel(c.summaryModel, session);
50
111
  if (!resolved) {
51
- dbg("no model", { summaryModel: c.summaryModel, header: JSON.stringify(session.requestHeader?.()?.config) });
112
+ dbgFail("no model", { summaryModel: c.summaryModel, header: JSON.stringify(session.requestHeader?.()?.config) });
52
113
  return { ok: false };
53
114
  }
54
115
  const { provider, model } = resolved;
@@ -57,70 +118,103 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
57
118
  const newEvents = (session.events || []).filter(
58
119
  (e) => e.seq >= fromSeq && SURFACE.has(e.type),
59
120
  );
121
+ dbg("session core entry", { sessionId: session.id, fromSeq, eventsLen: newEvents.length });
60
122
  const hist = [];
61
123
  for (const e of newEvents) {
62
124
  const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
63
125
  if (m) hist.push(m);
64
126
  }
65
127
  if (!hist.length) {
66
- dbg("no events in range", { fromSeq, eventsLen: (session.events || []).length, newEvents: newEvents.length, hasDerive: !!session.deriveEventMessage });
128
+ dbgFail("no events in range", { fromSeq, eventsLen: (session.events || []).length, newEvents: newEvents.length, hasDerive: !!session.deriveEventMessage });
67
129
  return { ok: false };
68
130
  }
131
+ // 2.5 回喂存量记忆(v1.4.0 特性3):仅当 useFeedback 时,读取项目级 + 用户级 MEMORY.md 全文
132
+ // (逐行编号、无截断)作为上下文前置消息,让 LLM 基于既有记忆做 add/replace/delete 增量维护。
133
+ let messages = hist;
134
+ const feedbackFiles = { project: null, user: null };
135
+ if (useFeedback) {
136
+ const sessCwd = session?.header?.cwd ?? undefined;
137
+ const projectMem = paths.memoryFileOf(dirs[0], sessCwd);
138
+ const userMem = expandHome(cfg().userMemoryPath);
139
+ // 始终记录目标文件路径(即使文件尚不存在),便于后续 add 操作创建文件。
140
+ feedbackFiles.project = projectMem;
141
+ feedbackFiles.user = userMem;
142
+ let fbText = "";
143
+ if (projectMem) {
144
+ const pm = readNumberedMemory(projectMem);
145
+ if (pm.text) fbText += "【项目级记忆(逐行编号,行号从 1 开始)】\n" + pm.text + "\n";
146
+ }
147
+ if (userMem) {
148
+ const um = readNumberedMemory(userMem);
149
+ if (um.text) fbText += "【用户级记忆(逐行编号,行号从 1 开始)】\n" + um.text + "\n";
150
+ }
151
+ if (fbText) {
152
+ messages = [
153
+ createUserMessage({
154
+ content: [{ type: "text", text: "以下是现有记忆(仅供你增量维护参考,不要当作本次对话内容):\n" + fbText }],
155
+ source: { kind: "plugin", plugin: "memory-palace" },
156
+ }),
157
+ ...hist,
158
+ ];
159
+ }
160
+ dbg("session feedback", { hasProject: !!feedbackFiles.project, hasUser: !!feedbackFiles.user });
161
+ }
69
162
  // 3. LLM 调用:借鉴 dsh-sideband 的 summarizeJob(src/summarizer.ts)——
70
163
  // a) SUMMARY_PROMPT 放 system 参数(GenerateOptions.system),指令不占 user 消息、模型更遵守;
71
164
  // b) AbortSignal.timeout 超时保护(默认 60s,Config.summaryTimeoutMs 可调),流循环内 throwIfAborted,
72
165
  // 杜绝「LLM 慢/挂起 → for await 无限等待」导致的摘要静默失败;
73
166
  // c) finish.kind 细化:error/aborted → 失败降级(探针带 failure.message);max-tokens → 尝试用已有文本
74
- // (JSON.parse 失败自然回落全文/降级,不会产生坏数据)。
167
+ // (解析失败时抽取 {...} JSON 块兜底,仍失败则 summary 置空、记极简占位,绝不把 LLM 自由文本当 summary 落盘)。
75
168
  const timeoutMs = c.summaryTimeoutMs || 60000;
76
- const signal = AbortSignal.timeout(timeoutMs);
77
- const res = ctx.llm.stream({
78
- provider,
79
- model,
80
- system: SUMMARY_PROMPT,
81
- messages: hist,
82
- maxTokens: 800,
83
- signal,
84
- });
85
- const asm = new BlockAssembler();
169
+ const t0 = Date.now();
170
+ dbg("session request", { provider, model, messages: messages.length, maxTokens: c.summaryMaxTokens, timeoutMs, maxRetries: RETRY_CONSTANTS.MAX_RETRIES });
171
+ let result;
86
172
  try {
87
- for await (const ch of res) {
88
- signal.throwIfAborted();
89
- asm.push(ch);
90
- }
173
+ result = await runWithRetry(
174
+ () => streamOnce({ provider, model, system: SUMMARY_PROMPT({ allowDelete }), messages, maxTokens: c.summaryMaxTokens }, timeoutMs),
175
+ {
176
+ maxRetries: RETRY_CONSTANTS.MAX_RETRIES,
177
+ onAttempt: ({ attempt, maxRetries }) => dbg("session attempt", { attempt, maxRetries }),
178
+ },
179
+ );
91
180
  } catch (e) {
92
- dbg("stream aborted/timeout/error", { message: e?.message ?? "", aborted: signal.aborted, reason: signal.reason?.message ?? "" });
93
- return { ok: false };
94
- }
95
- const finish = asm.finish;
96
- if (finish && (finish.kind === "error" || finish.kind === "aborted")) {
97
- dbg("llm finish", { kind: finish.kind, message: finish.failure?.message ?? "" });
181
+ const cls = e?.cls || { kind: "fatal", message: String(e?.message ?? e) };
182
+ dbgFail("session distill exhausted", { kind: cls.kind, status: cls.status ?? "", attempts: e?.attempts });
98
183
  return { ok: false };
99
184
  }
185
+ const { finish, text, chunks, deltaChars, tFirstChunk } = result;
186
+ dbg("session finish", { kind: finish?.kind, attempts: result.attempts });
187
+ dbg("session stream done", { chunks, deltaChars, firstChunkMs: tFirstChunk });
100
188
  if (finish && finish.kind === "max-tokens") {
101
- dbg("llm finish max-tokens (attempting partial JSON)", {});
189
+ console.error("[memory-palace] distill warn: llm finish max-tokens (attempting partial JSON)", JSON.stringify({}));
102
190
  }
103
- const text = asm
104
- .blocks()
105
- .filter((b) => b.type === "text")
106
- .map((b) => b.text ?? "")
107
- .join("")
108
- .trim();
109
191
  if (!text) {
110
- dbg("empty llm text");
192
+ dbgFail("empty llm text");
111
193
  return { ok: false };
112
194
  }
113
- // 4. 解析 JSON:{summary, durable:[{scope,fact}]};解析失败回退全文当 summary。
114
- let summary = text;
195
+ // 4. 解析 JSON:{summary, durable:[{scope,fact}], memoryOps:[...]}。
196
+ // 防污染(v1.4.0 修复):解析失败【绝不】退化成"全文当 summary"——否则对话原文 / 助手独白 / 向用户的请示
197
+ // 会被整段灌进每日日志。先尝试从自由文本抽取最后一个 {...} JSON 块再解析;仍失败则 summary 留空。
198
+ let summary = "";
115
199
  let durable = [];
116
- try {
117
- const parsed = JSON.parse(text);
118
- if (parsed && typeof parsed === "object") {
119
- if (typeof parsed.summary === "string") summary = parsed.summary;
120
- if (Array.isArray(parsed.durable)) durable = parsed.durable;
121
- }
122
- } catch {
123
- /* 非 JSON:全文当 summary */
200
+ let memoryOps = [];
201
+ let jsonOk = false;
202
+ const tryParseJson = (s) => {
203
+ try {
204
+ const o = JSON.parse(s);
205
+ return o && typeof o === "object" ? o : null;
206
+ } catch { return null; }
207
+ };
208
+ let parsed = tryParseJson(text);
209
+ if (!parsed) {
210
+ const block = text.match(/\{[\s\S]*\}/);
211
+ if (block) parsed = tryParseJson(block[0]);
212
+ }
213
+ if (parsed) {
214
+ jsonOk = true;
215
+ if (typeof parsed.summary === "string") summary = parsed.summary;
216
+ if (Array.isArray(parsed.durable)) durable = parsed.durable;
217
+ if (Array.isArray(parsed.memoryOps)) memoryOps = parsed.memoryOps;
124
218
  }
125
219
  // 5. 写盘:summary → 每日日志 [smart] 前缀;durable → MEMORY.md `- [smart] fact`(按 scope 分层,去重)。
126
220
  // v1.2.0 修复:手动蒸馏(按钮)时 dirs 按 session.header.cwd 解析,但 memoryFileOf 缺省用闭包
@@ -128,7 +222,11 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
128
222
  // 此处显式传会话 cwd,使 dsh 目录正确解析到同级 MEMORY.md(自动模式 activeCwd 即会话 cwd,行为不变)。
129
223
  const sessCwd = session?.header?.cwd ?? undefined;
130
224
  for (const dir of dirs) {
131
- await records.appendDaily(dir, `\n[smart] ${summary}\n\n`);
225
+ // summary 为空(解析失败/未产出)→ 记极简占位,绝不把 LLM 自由文本当 summary 落盘(v1.4.0 防污染)。
226
+ const dailyLine = summary
227
+ ? `\n[smart] ${summary}\n\n`
228
+ : "\n[smart] 本段会话已蒸馏,但 LLM 未返回可解析的结构化摘要,已跳过原文落盘以避免污染记忆。\n\n";
229
+ await records.appendDaily(dir, dailyLine);
132
230
  }
133
231
  for (const d of durable) {
134
232
  if (!d || typeof d !== "object") continue;
@@ -143,6 +241,25 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
143
241
  }
144
242
  }
145
243
  }
244
+ // 5.5 应用 memoryOps(v1.4.0 特性3 增量维护):useFeedback 时由 LLM 产出(自动/手动皆可能)。
245
+ // add → 追加到对应 MEMORY.md(file 由 scope 决定,可能即时创建);replace/delete → applyMemoryOp
246
+ // (写前重读 + normLine 精确匹配 + 结构行保护,防止 stale 行号误改/误删)。
247
+ // 自动模式(allowDelete=false)prompt 同步禁用 delete,且下方 delete op 经 !allowDelete 跳过;
248
+ // 自动模式仅允许 add/replace 增量维护,delete 仅手动蒸馏按钮开放。
249
+ let opApplied = 0, opSkipped = 0;
250
+ for (const op of memoryOps) {
251
+ if (!op || typeof op !== "object") continue;
252
+ const scope = op.scope === "user" ? "user" : "project";
253
+ const file = scope === "user" ? feedbackFiles.user : feedbackFiles.project;
254
+ if (!file) { opSkipped++; continue; }
255
+ if (op.op === "delete" && !allowDelete) { opSkipped++; continue; }
256
+ const r = await applyMemoryOp(file, op).catch(() => ({ ok: false, reason: "exception" }));
257
+ if (r && r.ok && r.changed) opApplied++;
258
+ else if (r && !r.ok) dbgFail("memoryOp rejected", { op: op.op, reason: r.reason, line: r.line });
259
+ }
260
+ dbg("session memoryOps", { total: memoryOps.length, applied: opApplied, skipped: opSkipped });
261
+ dbg("session parsed", { jsonOk, summaryChars: summary.length, durableCount: durable.length });
262
+ dbg("session write done", { dirs: dirs.length, durableWritten: durable.length });
146
263
  return { ok: true, summary, durableCount: durable.length };
147
264
  }
148
265
 
@@ -170,52 +287,63 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
170
287
  const memFile = paths.memoryFileOf(primary, cwd);
171
288
  if (!memFile) return { ok: false, message: "无法解析项目 MEMORY.md 路径。" };
172
289
  const memShort = toHomeShort(memFile);
290
+ dbg("project entry", { cwd, memFile: toHomeShort(memFile) });
173
291
 
174
292
  const raw = readMdSync(memFile);
175
293
  if (!raw) return { ok: false, message: `项目记忆为空(${memShort}),无需蒸馏。` };
294
+ dbg("project loaded", { memFile: toHomeShort(memFile), rawChars: raw.length });
176
295
 
177
296
  if (distillLocks.has(memFile)) return { ok: false, message: "蒸馏正在进行中,请稍候再试。" };
178
297
  distillLocks.add(memFile);
179
298
  try {
180
- // 模型解析:与 distillSessionCore 同序(summaryModel 原样直传 > 会话 requestHeader)。
181
- const resolved = resolveModel(c.summaryModel, session);
299
+ const t0 = Date.now();
300
+ let provider = null, model = null;
301
+ // 模型解析:与 distillSessionCore 同序(summaryModel 注册表反查 > 会话 requestHeader)。
302
+ const resolved = await resolveModel(c.summaryModel, session);
182
303
  if (!resolved) {
183
304
  return { ok: false, message: "无法确定蒸馏模型(summaryModel 未配置且会话无模型信息)。" };
184
305
  }
185
- const { provider, model } = resolved;
306
+ ({ provider, model } = resolved);
307
+ dbg("project request", { provider, model, inputChars: raw.length, maxTokens: c.projectMaxTokens, timeoutMs: c.summaryTimeoutMs || 60000 });
186
308
 
187
- // LLM 蒸馏:system = 固化 DISTILL_PROMPT,user = MEMORY.md 全文。
309
+ // LLM 蒸馏:system = 固化 DISTILL_PROMPT,user = MEMORY.md 全文。带失败重试(runWithRetry)。
188
310
  const timeoutMs = c.summaryTimeoutMs || 60000;
189
- const signal = AbortSignal.timeout(timeoutMs);
190
- const res = ctx.llm.stream({
191
- provider,
192
- model,
193
- system: DISTILL_PROMPT,
194
- messages: [
195
- createUserMessage({
196
- content: [{ type: "text", text: raw }],
197
- source: { kind: "plugin", plugin: "memory-palace" },
198
- }),
199
- ],
200
- maxTokens: 4000,
201
- signal,
202
- });
203
- const asm = new BlockAssembler();
204
- for await (const ch of res) {
205
- signal.throwIfAborted();
206
- asm.push(ch);
311
+ let result;
312
+ try {
313
+ result = await runWithRetry(
314
+ () => streamOnce(
315
+ {
316
+ provider,
317
+ model,
318
+ system: DISTILL_PROMPT,
319
+ messages: [
320
+ createUserMessage({
321
+ content: [{ type: "text", text: raw }],
322
+ source: { kind: "plugin", plugin: "memory-palace" },
323
+ }),
324
+ ],
325
+ maxTokens: c.projectMaxTokens,
326
+ },
327
+ timeoutMs,
328
+ ),
329
+ {
330
+ maxRetries: RETRY_CONSTANTS.MAX_RETRIES,
331
+ onAttempt: ({ attempt, maxRetries }) => dbg("project attempt", { attempt, maxRetries }),
332
+ },
333
+ );
334
+ } catch (e) {
335
+ const cls = e?.cls || { kind: "fatal", message: String(e?.message ?? e) };
336
+ dbgFail("project distill exhausted", { kind: cls.kind, status: cls.status ?? "", attempts: e?.attempts });
337
+ const kindZh = cls.kind === "fatal" ? "失败" : "限流/超时";
338
+ return { ok: false, message: `蒸馏${kindZh}(重试 ${e?.attempts || 0} 次后仍失败):${cls.message || ""}` };
207
339
  }
208
- const finish = asm.finish;
209
- if (finish && (finish.kind === "error" || finish.kind === "aborted")) {
210
- return { ok: false, message: `蒸馏失败(LLM ${finish.kind}):${finish.failure?.message ?? ""}` };
340
+ const { finish, text, chunks, deltaChars, gotFirstChunk, tFirstChunk } = result;
341
+ if (finish && finish.kind === "max-tokens") {
342
+ dbg("project finish", { kind: "max-tokens", attempts: result.attempts });
343
+ } else {
344
+ dbg("project finish", { kind: finish?.kind, attempts: result.attempts });
211
345
  }
212
- const text = asm
213
- .blocks()
214
- .filter((b) => b.type === "text")
215
- .map((b) => b.text ?? "")
216
- .join("")
217
- .trim();
218
- if (!text) return { ok: false, message: "蒸馏结果为空,未覆盖任何文件。" };
346
+ if (!text) { dbgFail("project empty result", { chunks, deltaChars }); return { ok: false, message: "蒸馏结果为空,未覆盖任何文件。" }; }
219
347
 
220
348
  // 写 cover(与 MEMORY.md 同目录,同文件系统保证 rename 原子)。
221
349
  const coverFile = join(dirname(memFile), "memory-cover.md");
@@ -237,12 +365,16 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
237
365
  await rename(backup, memFile).catch(() => {});
238
366
  throw e;
239
367
  }
368
+ dbg("project write done", { coverChars: text.length, backup: toHomeShort(backup) });
240
369
  return {
241
370
  ok: true,
242
371
  message: `蒸馏成功:${memShort} 已更新(原 ${raw.length} 字符 → ${text.length} 字符),备份 ${toHomeShort(backup)}。`,
243
372
  };
244
373
  } catch (e) {
245
- return { ok: false, message: `蒸馏失败:${e?.message || String(e)}` };
374
+ const elapsedS = Math.round((Date.now() - t0) / 1000);
375
+ dbgFail("project distill timeout/error", { elapsed: Date.now() - t0, message: e?.message ?? String(e), stack: (e?.stack || "").split("\n")[0] });
376
+ const msg = `蒸馏失败:模型「${model || "?"}」在 ${elapsedS}s 内未成功完成(写入/覆盖阶段异常:${e?.message ?? String(e)})。`;
377
+ return { ok: false, message: msg };
246
378
  } finally {
247
379
  distillLocks.delete(memFile);
248
380
  }
package/lib/index.js CHANGED
@@ -52,7 +52,13 @@ export const Config = Schema.object({
52
52
  // ---- v1.1.0:双记忆模式(插件模式=现状;智能模式=LLM 智能会话摘要),设置页切换,互斥,切换需重启 dsh ----
53
53
  memoryMode: Schema.union(["plugin", "smart"]).default("plugin").description("记忆模式:plugin=记忆公民指令+轮次轻量+错误捕获;smart=LLM 智能会话摘要(summary→每日日志 + durable→MEMORY.md,带 [smart] 标记)。切换需重启 dsh 生效。"),
54
54
  summaryModel: Schema.string().default("").description("智能模式的摘要模型,留空=复用当前会话 provider/model;也可填 provider/model(如 deepseek/deepseek-chat)固定廉价模型省 token。"),
55
- summaryTimeoutMs: Schema.number().default(60000).description("智能模式单次摘要调用的超时(毫秒),超时视为失败并降级轻量条目;默认 60s。"),
55
+ summaryTimeoutMs: Schema.number().default(60000).description("蒸馏 LLM 调用的超时(毫秒),超时视为失败并降级;覆盖智能模式摘要与手动蒸馏两条链路;默认 60000(60s)。"),
56
+ distillDebugLog: Schema.boolean().default(false).description("调试开关:向 dsh 服务端 stderr 输出蒸馏 LLM 调用诊断日志(模型解析、请求参数、流进度计数、错误详情)。仅排障用,平时关闭;开启后也不打印任何对话内容或记忆文本。"),
57
+ // ---- v1.4.0:智能模式最大输出 Token 限制(特性1,默认保守,可由设置页放大以防摘要/durable 被截断) ----
58
+ summaryMaxTokens: Schema.number().default(2000).description("智能模式会话摘要 LLM 的最大输出 Token(summary + durable 合计上限)。默认 2000,过小会截断 durable 事实;可上调到 4000+。"),
59
+ projectMaxTokens: Schema.number().default(8000).description("手动「蒸馏项目记忆」LLM 的最大输出 Token(蒸馏后 MEMORY.md 长度上限)。默认 8000,过小会截断浓缩结果;可上调。"),
60
+ // ---- v1.4.0:蒸馏时回喂存量记忆(特性3,默认关;开启后自动智能模式 turn/end 与手动蒸馏按钮均回喂,delete 仅手动放开) ----
61
+ feedbackEnabled: Schema.boolean().default(false).description("蒸馏时把项目级 + 用户级 MEMORY.md 全文(逐行编号、无截断)回喂给 LLM,使其能基于既有记忆做 add/replace/delete 增量维护。默认关;开启后自动智能模式(turn/end)与手动蒸馏会话按钮均回喂存量记忆;delete 仍仅手动按钮开放,自动模式跳过(防误删)。"),
56
62
  });
57
63
 
58
64
  export const inject = ["systemPrompt", "tools", "llm", "webServer", "webRuntime"];
@@ -103,6 +109,7 @@ export function apply(ctx, config) {
103
109
  settleTimer: null, // debounce 计时器
104
110
  lastSummarizedSeq: -1, // 智能模式增量摘要断点(session 事件 seq)
105
111
  summarySessionId: null,
112
+ planModeActive: false, // 计划模式:禁写记忆(硬编码默认,无开关;仅经 session/event 的 plan/mode 翻转;未装 dsh-plan-mode 永不触发=不拦截)
106
113
  };
107
114
  const TURN_BUFFER_MAX = 30;
108
115
  // v1.1.3:字符上限提升到 30k(长工具型 request 的工具结果动辄数千字符,6k 上限会触发
@@ -131,6 +138,11 @@ export function apply(ctx, config) {
131
138
  state.lastSummarizedSeq = session.firstLiveSeq;
132
139
  }
133
140
  const type = event?.type;
141
+ // 计划模式:禁写记忆(硬编码默认行为,无开关;不依赖 dsh-plan-mode 服务,未装则永不触发=安全降级=不拦截)
142
+ if (type === "plan/mode") {
143
+ state.planModeActive = !!(event?.data?.active);
144
+ return;
145
+ }
134
146
  if (type === "user/message" || type === "assistant/message" || type === "tool/result") {
135
147
  const text = extractText(event);
136
148
  if (text) {
@@ -204,13 +216,20 @@ export function apply(ctx, config) {
204
216
  : "你拥有持久化、人类可直接编辑的 Markdown 记忆文件(位于 ~/.deepseek-harness/MEMORY.md,以及各项目的 .deepseek-harness/MEMORY.md(长期记忆)与 .deepseek-harness/memory/(每日日志))。" +
205
217
  "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
206
218
  antiMangle + proactive;
207
- return blocks.length ? [intro, ...blocks].join("\n\n") : intro;
219
+ // 计划模式禁写提示(硬编码默认行为,无开关):无论记忆模式都追加,让 agent 自觉不写(网关物理兜底仍生效)
220
+ const planNote = state.planModeActive
221
+ ? "\n\n[plan 模式] 当前处于 plan 模式,不要调用 memory_note / memory_note_user 写入记忆,也不要请求删除记忆(读取记忆用 memory_read,仍可用)。"
222
+ : "";
223
+ const introFull = intro + planNote;
224
+ return blocks.length ? [introFull, ...blocks].join("\n\n") : introFull;
208
225
  },
209
226
  });
210
227
 
211
228
  // ---------- 结算一个完整 request:基于累积缓冲做「错误捕获 + 轻量兜底记录(闸门)」,不调 LLM ----------
212
229
  // 主路径是 agent 按记忆公民指令主动调 memory_note;此处只兜底保证"实质轮次不丢"。
213
230
  async function _settle(capturedTurn, isError, errMsg) {
231
+ // 计划模式禁写(硬编码默认,无开关):自动写路径全拦截(智能模式/轻量兜底/错误捕获同源跳过)
232
+ if (state.planModeActive) return;
214
233
  const cfg = source();
215
234
  if (!cfg.enabled) return;
216
235
  const dirs = paths.writeDirs();
package/lib/tools.mjs CHANGED
@@ -360,8 +360,15 @@ export function registerTools({ ctx, getConfig, paths, records, state }) {
360
360
  // (即删除用户记忆时你看到的「沙箱授权弹窗」)——比两阶段的 AI 级 confirm:true 更可靠:
361
361
  // 即便模型误传 confirm:true,没有真人点击弹窗允许也绝不会真正删除。
362
362
  // 预览(confirm 省略或为 false)直接放行、不弹窗,由 execute 返回候选供 AI 转述、供用户选择删哪几条。
363
+ // 计划模式禁写(硬编码默认,无开关):三个写工具统一 deny(连预览/确认都拦),仅禁写不禁读。
364
+ const WRITE_TOOLS = new Set(["memory_note", "memory_note_user", "memory_delete"]);
363
365
  ctx.on("tools/pre-execute", async (exec, next) => {
364
- if (exec?.name !== "memory_delete") return next();
366
+ const name = exec?.name;
367
+ // plan 模式禁写:三个写工具一律 deny,连预览/确认都拦
368
+ if (state.planModeActive && WRITE_TOOLS.has(name)) {
369
+ return { kind: "deny", reason: "plan 模式下禁止写入记忆" };
370
+ }
371
+ if (name !== "memory_delete") return next();
365
372
  const a = (exec.arguments && typeof exec.arguments === "object") ? exec.arguments : {};
366
373
  const confirm = a.confirm === true;
367
374
  if (!confirm) return next(); // 预览阶段:只查不删,无需弹窗
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-palace",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
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",