dsh-memory-palace 1.6.3 → 1.7.1

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
@@ -2,22 +2,23 @@
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) [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com) [![npm](https://img.shields.io/npm/v/dsh-memory-palace.svg?label=npm&color=CB3837&labelColor=4D6BFE)](https://www.npmjs.com/package/dsh-memory-palace) [![DeepSeek Harness:0.1.2-rc.1](https://img.shields.io/badge/DeepSeek%20Harness-0.1.2--rc.1-success.svg?labelColor=4D6BFE)](https://github.com/deepseek-ai/deepseek-harness)
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) [![npm](https://img.shields.io/npm/v/dsh-memory-palace.svg?label=npm&labelColor=000000&color=ff4b01)](https://www.npmjs.com/package/dsh-memory-palace) [![DeepSeek Harness:0.1.5-rc.1](https://img.shields.io/badge/DeepSeek%20Harness-0.1.5--rc.1-success.svg?labelColor=4D6BFE)](https://github.com/deepseek-ai/deepseek-harness)
6
6
 
7
7
  ## 特性
8
8
 
9
- - **人类可读的真源**:记忆全部存储在 Markdown 文件中(`MEMORY.md` + 每日日志 `YYYY-MM-DD.md`),任何编辑器可直接修改,数据永远属于你。
10
- - **双层记忆**:用户级(跨项目个人偏好,默认 `~/.deepseek-harness/MEMORY.md`)+ 工作区级(项目约定,默认 `<cwd>/.deepseek-harness/memory/`)。
11
- - **日志迁移**:超过保留天数(默认 30 天)的每日日志自动迁移进 `MEMORY.md` 后删除,长期记忆持续沉淀。
12
- - **WorkBuddy / CodeBuddy 桥接**:项目已存在 `.workbuddy/memory` 或 `.codebuddy/memory` 时直接读写这些目录,无需重复维护记忆。
13
- - **记忆工具**:`memory_note`(项目级写入)、`memory_note_user`(用户级写入)、`memory_read`(聚合读取)、`memory_delete`(按内容删除,两阶段确认),全部内置去重,防止重复追加。
14
- - **会话标题栏「记忆」按钮**:支持手动蒸馏:①蒸馏会话 / ②蒸馏项目记忆。
15
- - **设置页集成**:DSH 设置中内置「记忆」面板(中英双语),所有配置均可图形化调整,无需改配置文件。
16
- - **主动记忆(主路径,插件模式)**:注入「记忆公民指令」引导 agent 在「修复 bug/根因+绕过」「验证 build/test 通过」「完成里程碑/关键决策」「用户表达偏好/约束」时主动调 `memory_note` / `memory_note_user` 落档 — 对标 WorkBuddy 的"智能记一笔"手感。
17
- - **智能模式(LLM 智能会话摘要)**:智能模式下,每轮命中防闲聊闸门后由 harness 把本会话**新增对话增量**(按 session 事件 seq 断点)提炼成摘要——`summary` 写每日日志 + durable 事实写 MEMORY.md。
18
- - **记忆注入**:记忆块仅在会话**首次**构建时注入(DSH 会话自身继承历史轮次,逐轮注入冗余且有过时断言干扰);发生上下文压缩(compaction)后自动重注。
19
- - **混合模式(子Agent+主Agent混合处理 🔥推荐)**:记忆子代理在每轮 turn/end 自动把本轮摘要写入**今日工作日志**(章节化组织、回喂今日日志做增量去重、重复/过时条目标删除线墓碑);MEMORY.md 写入权归还 agent(`memory_write` 章节化追加 / `memory_update_section` 整章节精确替换 / 双门禁内 `memory_reorganize` 全量重整)。
20
- - **标准 npm 插件包**:经 `dsh plugin` 一键装入 profile,`cordis.patch.yml` 声明 bundle patch,零手动改动 harness。
9
+ - **人类可读的真源**:记忆以纯 Markdown 存储,任何编辑器都能直接修改,数据始终属于你。
10
+ - **双层记忆**:用户级(跨项目个人偏好)+ 工作区级(项目约定),互不干扰。
11
+ - **日志自动沉淀**:超过保留期的每日日志自动并入长期记忆,结论不丢。
12
+ - **WorkBuddy / CodeBuddy 桥接**:项目已有对应记忆目录时直接读写,无需重复维护。
13
+ - **记忆工具**:AI 可主动写入、聚合读取、按内容删除(删除需人工确认),写入自带去重。
14
+ - **手动蒸馏**:会话标题栏一键把当前对话或项目记忆提炼成长期记忆。
15
+ - **设置页集成**:全部配置均可在 DSH 设置面板中图形化调整(按板块折叠),无需改配置文件。
16
+ - **自定义指令**:不适合写进记忆的特殊指令可在设置页配置,插件经系统提示词注入。
17
+ - **主动记忆(插件模式)**:以指令引导 AI 在完成任务、修复问题、确定决策、获知偏好时主动落档。
18
+ - **智能模式**:由模型自动提炼每轮新增内容,摘要进日志、长期事实进记忆。
19
+ - **记忆注入**:长期记忆与今日日志以「会话起始快照」常驻上下文、按文件身份各只注入一次 —— 既保证每个步骤都可见,又不破坏前缀缓存、不堆积冗余快照。
20
+ - **混合模式(🔥推荐)**:子代理每轮自动整理日志并去重,长期记忆由主 AI 主动维护,兼顾自动化与可控性。
21
+ - **标准插件包**:经官方插件机制一键安装,无需改动 harness。
21
22
 
22
23
  ## 记忆文件布局
23
24
 
@@ -26,11 +27,11 @@
26
27
  └── MEMORY.md # 用户级记忆(跨项目个人偏好)
27
28
 
28
29
  <项目根>/
29
- ├── .workbuddy/memory/ # 桥接 WorkBuddy 记忆(已存在时,按序优先)
30
+ ├── .workbuddy/memory/ # 桥接 WorkBuddy 记忆(存在时优先写入)
30
31
  │ ├── MEMORY.md # 项目级约定(buddy 目录保持嵌套,兼容 WB/CB 原生格式)
31
32
  │ └── 2026-08-16.md # 每日工作日志
32
- ├── .codebuddy/memory/ # 桥接 CodeBuddy 记忆(已存在时,结构同 .workbuddy)
33
- └── .deepseek-harness/ # 回退目录(无 buddy 目录时自动创建)
33
+ ├── .codebuddy/memory/ # 桥接 CodeBuddy 记忆(存在时,结构同 .workbuddy)
34
+ └── .deepseek-harness/ # dsh 原生目录(读取恒在首位;无 buddy 时作为写入目标创建)
34
35
  ├── MEMORY.md # 项目级约定(长期记忆,与 memory/ 同级)
35
36
  └── memory/
36
37
  └── 2026-08-16.md # 每日工作日志
@@ -38,33 +39,32 @@
38
39
 
39
40
  ## 工作原理
40
41
 
41
- **读取(每轮对话)**——`systemPrompt.section` 同步读盘,把以下内容拼进系统提示词:
42
+ **读取(每轮对话)**——两条通道并行:
42
43
 
43
44
  ```
44
- 用户级 MEMORY.md
45
- + 工作区 MEMORY.md
46
- + 今日日志 YYYY-MM-DD.md
47
- → 注入 system prompt,让 AI 跨会话保持一致
45
+ ① 长期记忆:全程常驻,每个步骤都可见;内容未变只写一次,压缩后自动重注
46
+ ② 今日日志:随系统提示词按轮次注入(高频变化,不进对话历史以免膨胀)
48
47
  ```
49
48
 
50
- **写入(每轮结束)**——监听 `session/event` 的 `turn/end`,经「防闲聊闸门」判定后异步追加:
49
+ **写入(每轮结束)**——经「防闲聊闸门」判定后异步追加:
51
50
 
52
51
  ```
53
- turn/end ──► 轻量兜底闸门
54
- │ 有工具调用 / 有错误 / agent 主动记 / 命中偏好·决策关键词 → 放行
55
- │ 否则:跳过(不写、不调 LLM)
56
- ├─► 轻量条目写入 YYYY-MM-DD.md(全部目标目录;可关)
57
- ├─► 若本轮出错且开启「对话出错自动记录」→ 「错误现象」写入对应 MEMORY.md
58
- └─► prune:超过 dailyLogRetentionDays 的日志蒸馏进 MEMORY.md 后删除
52
+ 本轮结束 ──► 闸门:有工具调用 / 有错误 / AI 主动记 / 命中偏好·决策关键词 → 放行
53
+ │ 否则跳过(不写、不调模型)
54
+ ├─► 摘要写入今日日志
55
+ ├─► 出错且开启「自动记录错误」→ 错误现象写入长期记忆
56
+ └─► 超期日志并入长期记忆后删除
59
57
  ```
60
58
 
59
+ 日志文件头统一为日期标题,写入时自动补齐(历史文件不回填)。
60
+
61
61
  **工具**——AI 在对话中按需调用:
62
62
 
63
63
  | 工具 | 层级 | 作用 |
64
64
  |---|---|---|
65
65
  | `memory_note` | 项目级 | 把约定/偏好写入当前项目全部目标 `MEMORY.md`(去重) |
66
66
  | `memory_note_user` | 用户级 | 把跨项目偏好写入 `~/.deepseek-harness/MEMORY.md`(去重) |
67
- | `memory_read` | 聚合 | 一次性读取用户级 + 项目级记忆、今日日志与最近 3 份历史日志 |
67
+ | `memory_read` | 按需 | 读取记忆。`scope` 默认 `memory`(用户级 + 项目级);可选 `project`(仅项目级,与 `memory_write` 的写侧 scope 同名)/ `today` / `yesterday` / `daily`(近三天日志)/ `all`(长期记忆 + 日志) |
68
68
  | `memory_delete` | 用户级/项目级/每日级 | 按内容删除记忆条目(两阶段确认:先预览匹配位置与内容,用户确认后再删;删除动作经 harness 原生确认弹窗硬闸门,真人点允许才真正执行) |
69
69
  | `memory_write`(hybrid) | 项目级/用户级 | 章节化写入:章节存在则末尾追加,不存在则新建章节(章节化格式规范) |
70
70
  | `memory_update_section`(hybrid) | 项目级/用户级 | 整章节精确替换/单条标记删除:oldText 归一化精确匹配防 stale,失败拒绝并回显实际内容 |
@@ -78,7 +78,7 @@ turn/end ──► 轻量兜底闸门
78
78
 
79
79
  ```bash
80
80
  dsh plugin --profile web add github:lovezi0/dsh-memory-palace
81
- # 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.6.3
81
+ # 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.7.1
82
82
  ```
83
83
 
84
84
  方式二:clone 后本地安装(开发 / 修改源码场景)
@@ -110,7 +110,7 @@ dsh plugin --profile web remove dsh-memory-palace
110
110
 
111
111
  ## 配置
112
112
  *面板路径为 DSH 设置 →「记忆」*
113
- 所有配置项(基础 / 自动记录 / 开发 三张卡片的字段、默认值与说明)已整理至 [CONFIG.md](./CONFIG.md)
113
+ 所有配置项(核心 / 自定义指令 / 自动记录 / 存储路径与预算 / 开发 五张卡片的字段、默认值与说明)已整理至 [CONFIG.md](./CONFIG.md)
114
114
 
115
115
  ## 开发
116
116
 
@@ -119,6 +119,16 @@ dsh plugin --profile web remove dsh-memory-palace
119
119
 
120
120
  ## 版本历史
121
121
 
122
+ - **1.7.1**
123
+ - 💪移除日志重注机制优化缓存命中降低堆积冗余
124
+ - 💪记忆设置UI优化
125
+ - 🔥增加自定义指令 prompt 配置与注入
126
+ - 💪memory_read 增加 scope 参数
127
+ - **1.7.0**
128
+ - 🔥新增独立通道注入记忆文件
129
+ - 🐛修复存在多种记忆路径时读取冲突的问题
130
+ - 💪优化记忆子 agent 投影消息节省 token
131
+ - 💪优化 SUBAGENT_SYSTEM 记忆输出格式
122
132
  - **1.6.3**
123
133
  - npm publish
124
134
  - **1.6.3.alpha.4**
package/lib/client.js CHANGED
@@ -16,8 +16,13 @@ window.__ModuleLoader__.load({
16
16
  title: "记忆",
17
17
  intro: "memory-palace 记忆宫殿:跨会话持久化的 Markdown 记忆。",
18
18
  core: "核心",
19
+ coreDesc: "buddy 记忆桥接与项目记忆目录",
20
+ custom: "自定义指令",
21
+ customDesc: "不适合写进用户记忆的特殊指令,经系统提示词注入",
19
22
  smartSummary: "自动记录",
23
+ smartSummaryDesc: "记忆模式、自动记录与模型参数",
20
24
  storage: "存储路径与预算",
25
+ storageDesc: "记忆文件路径、日志保留与注入预算",
21
26
  bridgeLabel: "桥接 buddy 项目记忆",
22
27
  bridgeHint: "项目已存在 .workbuddy / .codebuddy 记忆目录时直接读写,不再建 .deepseek-harness/memory/",
23
28
  dirsLabel: "buddy 记忆目录(逗号分隔,按序优先)",
@@ -47,6 +52,8 @@ window.__ModuleLoader__.load({
47
52
  subagentLogBudgetHint: "记忆子代理回喂今日工作日志的字符上限;超出时仅回喂章节目录,子代理按需读取章节。默认 20000。",
48
53
  summaryModelLabel: "模型",
49
54
  summaryModelHint: "记忆插件当前使用的模型(智能模式会话摘要 / 手动蒸馏 / hybrid 记忆子代理共用)。留空=复用当前会话的 provider/model;也可固定廉价小模型省 token。",
55
+ expand: "展开",
56
+ collapse: "收起",
50
57
  save: "保存",
51
58
  saving: "保存中…",
52
59
  discard: "放弃",
@@ -73,6 +80,7 @@ window.__ModuleLoader__.load({
73
80
  distillDebugLogLabel: "蒸馏调试日志",
74
81
  distillDebugLogHint: "开启后向 dsh 服务端控制台(stderr)输出蒸馏 LLM 调用诊断。info 级别仅输出元数据(不含文本);配合 distillLogLevel=debug 会额外打印 LLM 原始响应(分隔符包裹),仅限受信本地排障。",
75
82
  dev: "开发",
83
+ devDesc: "蒸馏调试日志与超时",
76
84
  distillTimeoutLabel: "蒸馏超时(秒)",
77
85
  distillTimeoutHint: "单次蒸馏 LLM 调用的超时上限(秒);超时视为失败并降级。覆盖智能模式摘要与手动蒸馏,默认 60 秒。",
78
86
  summaryMaxTokensLabel: "会话摘要最大 Token",
@@ -80,7 +88,10 @@ window.__ModuleLoader__.load({
80
88
  projectMaxTokensLabel: "项目蒸馏最大 Token",
81
89
  projectMaxTokensHint: "最终输出软预算(prompt 约束,思考不受限;实际硬上限由模型自身 maxTokens 决定)。默认 8000。",
82
90
  feedbackEnabledLabel: "回喂存量记忆",
83
- feedbackEnabledHint: "每次蒸馏时附带项目级 + 用户级记忆作为参考,使其能基于既有记忆做增量维护(delete 仅手动蒸馏开放)。混合模式下仅手动触发蒸馏受影响(自动路径走记忆子代理,自带日志回喂)。默认关。"
91
+ feedbackEnabledHint: "每次蒸馏时附带项目级 + 用户级记忆作为参考,使其能基于既有记忆做增量维护(delete 仅手动蒸馏开放)。混合模式下仅手动触发蒸馏受影响(自动路径走记忆子代理,自带日志回喂)。默认关。",
92
+ customLabel: "自定义指令内容",
93
+ customHint: "非空时注入系统提示词,位置在记忆插件与分工说明之后。对全部会话生效;留空则不注入。",
94
+ customPlaceholder: "例如:回答用中文;代码注释用英文;提交信息用祈使句…"
84
95
  };
85
96
 
86
97
  const en = {
@@ -88,8 +99,13 @@ window.__ModuleLoader__.load({
88
99
  title: "Memory",
89
100
  intro: "memory-palace: persistent, human-readable Markdown memory across sessions.",
90
101
  core: "Core",
102
+ coreDesc: "Buddy memory bridging and workspace memory dirs",
103
+ custom: "Custom instructions",
104
+ customDesc: "Special instructions that do not belong in user memory, injected via the system prompt",
91
105
  smartSummary: "Auto-record",
106
+ smartSummaryDesc: "Memory mode, auto-record and model parameters",
92
107
  storage: "Storage & budgets",
108
+ storageDesc: "Memory file paths, log retention and injection budgets",
93
109
  bridgeLabel: "Bridge buddy project memory",
94
110
  bridgeHint: "Read/write directly into existing .workbuddy / .codebuddy memory dirs instead of creating .deepseek-harness/memory/",
95
111
  dirsLabel: "Buddy memory dirs (comma-separated, first wins)",
@@ -119,6 +135,8 @@ window.__ModuleLoader__.load({
119
135
  subagentLogBudgetHint: "Max chars of today's daily log fed back to the memory sub-agent; beyond this only the chapter TOC is fed and the sub-agent reads sections on demand. Default 20000.",
120
136
  summaryModelLabel: "Model",
121
137
  summaryModelHint: "The model currently used by the memory plugin (shared by smart-mode session summarization / manual distillation / the hybrid memory sub-agent). Leave empty to reuse the current session's provider/model, or pin a cheap model (provider/model) to save tokens.",
138
+ expand: "Expand",
139
+ collapse: "Collapse",
122
140
  save: "Save",
123
141
  saving: "Saving…",
124
142
  discard: "Discard",
@@ -145,6 +163,7 @@ window.__ModuleLoader__.load({
145
163
  distillDebugLogLabel: "Distill debug log",
146
164
  distillDebugLogHint: "When on, prints distill LLM call diagnostics to the dsh server console (stderr). info level prints metadata only (no text); with distillLogLevel=debug it additionally prints the raw LLM response (wrapped in delimiters), for trusted local troubleshooting only.",
147
165
  dev: "Developer",
166
+ devDesc: "Distill debug log and timeout",
148
167
  distillTimeoutLabel: "Distill timeout (s)",
149
168
  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.",
150
169
  summaryMaxTokensLabel: "Session summary max tokens",
@@ -152,7 +171,10 @@ window.__ModuleLoader__.load({
152
171
  projectMaxTokensLabel: "Project distill max tokens",
153
172
  projectMaxTokensHint: "Final output soft budget (prompt constraint, thinking not limited; actual hard cap is the model's own maxTokens). Default 8000.",
154
173
  feedbackEnabledLabel: "Feed existing memory into distillation",
155
- feedbackEnabledHint: "Feed the project-level + user-level memory in as reference on every distillation so the LLM can maintain existing memory incrementally (delete is only allowed via manual distillation). In hybrid mode only manual distillation is affected (the automatic path runs through the memory sub-agent, which feeds the log itself). Off by default."
174
+ feedbackEnabledHint: "Feed the project-level + user-level memory in as reference on every distillation so the LLM can maintain existing memory incrementally (delete is only allowed via manual distillation). In hybrid mode only manual distillation is affected (the automatic path runs through the memory sub-agent, which feeds the log itself). Off by default.",
175
+ customLabel: "Custom instruction text",
176
+ customHint: "When non-empty it is injected into the system prompt right after the memory-plugin and memory-roles instructions. Applies to every session; leave empty to inject nothing.",
177
+ customPlaceholder: "e.g. Answer in Chinese; write code comments in English; use imperative commit subjects…"
156
178
  };
157
179
 
158
180
 
@@ -179,7 +201,8 @@ window.__ModuleLoader__.load({
179
201
  projectMaxTokens: v.projectMaxTokens != null ? String(v.projectMaxTokens) : "8000",
180
202
  reorgCooldownDays: v.reorgCooldownDays != null ? String(v.reorgCooldownDays) : "7",
181
203
  subagentLogBudget: v.subagentLogBudget != null ? String(v.subagentLogBudget) : "20000",
182
- feedbackEnabled: v.feedbackEnabled === false ? "false" : "true"
204
+ feedbackEnabled: v.feedbackEnabled === false ? "false" : "true",
205
+ customInstructions: v.customInstructions || ""
183
206
  };
184
207
  }
185
208
 
@@ -250,6 +273,9 @@ window.__ModuleLoader__.load({
250
273
  const [failed, setFailed] = react.useState(false);
251
274
  const [validation, setValidation] = react.useState(null);
252
275
  const [modelOptions, setModelOptions] = react.useState([]);
276
+ // v1.7.1(特性2):折叠卡片的展开态(key → bool)。照抄宿主 ui-settings-plugins/PluginCard
277
+ // 的语义 —— 纯前端临时状态、**不持久化**:「用户打开哪张卡」是一次阅读手势,不是配置。
278
+ const [openCards, setOpenCards] = react.useState({});
253
279
 
254
280
  // 初始加载(从自有 route 读真实值)。
255
281
  react.useEffect(() => {
@@ -334,6 +360,8 @@ window.__ModuleLoader__.load({
334
360
  // 保存成功后同步 remote(真实返回值),保留当前 draft(= 用户保存的目标值)。
335
361
  setRemote({ value: full.value, user: full.user, revision: full.revision, loaded: true });
336
362
  setDirty(false);
363
+ // 保存完成 = 这一轮编辑动作结束 → 收起全部卡片回到总览态(对齐宿主 PluginCard 行为)。
364
+ setOpenCards({});
337
365
  } catch (e) {
338
366
  setFailed(true);
339
367
  } finally {
@@ -423,19 +451,68 @@ window.__ModuleLoader__.load({
423
451
  borderTop: "1px solid var(--dsw-alias-border-l2, #e5e7eb)"
424
452
  };
425
453
 
454
+ // ---- v1.7.1(特性2):折叠卡片样式 —— 逐值照抄宿主 ui-settings-plugins 的 PluginCard.module.css ----
455
+ // 竖向「name 叠在 description 上」的卡片宿主无可复用组件(它导出的 DisclosureRow 是横向 24px
456
+ // 紧凑行,布局不同,宿主 README 亦明确区分二者),故手写;chevron 用 data URI 内联 SVG
457
+ // (本项目 client 只能 require("react"),不引宿主图标包)。
426
458
  const cardStyle = {
427
- border: "1px solid var(--dsw-alias-border-l2, #e5e7eb)",
428
- borderRadius: "12px",
429
- padding: "14px 16px",
459
+ border: "0.5px solid var(--dsw-alias-border-l4, #e5e7eb)",
460
+ // ⚠️ 必须显式写 borderColor:展开/收起靠 React diff 增删 cardOpenStyle 的 borderColor。
461
+ // 若只依赖 shorthand 的颜色,open 态写入的 borderColor 被移除后,CSSOM 中 border-color
462
+ // 声明随之删除 → 回退 UA 初始值 currentColor(文字色,近黑)→ 收起后边框变黑(真机 bug)。
463
+ borderColor: "var(--dsw-alias-border-l4, #e5e7eb)",
464
+ borderRadius: "16px",
430
465
  marginBottom: "12px",
431
- background: "var(--dsw-alias-bg-layer-2, #ffffff)"
466
+ background: "var(--dsw-alias-bg-layer-3, #fafafa)",
467
+ overflow: "hidden"
432
468
  };
433
- const groupTitleStyle = {
434
- margin: "0 0 4px",
435
- fontSize: "13px",
436
- fontWeight: "500",
469
+ const cardOpenStyle = {
470
+ ...cardStyle,
471
+ background: "var(--dsw-alias-bg-layer-2, #ffffff)",
472
+ borderColor: "var(--dsw-alias-label-dimmed, #c9ced6)"
473
+ };
474
+ const cardHeaderStyle = {
475
+ display: "flex",
476
+ alignItems: "center",
477
+ gap: "12px",
478
+ width: "100%",
479
+ padding: "14px 16px",
480
+ appearance: "none",
481
+ WebkitAppearance: "none",
482
+ border: "0",
483
+ background: "none",
484
+ font: "inherit",
485
+ textAlign: "left",
486
+ cursor: "pointer"
487
+ };
488
+ const cardHeadTextStyle = { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: "4px" };
489
+ const cardNameStyle = {
490
+ fontSize: "15px",
491
+ fontWeight: "600",
492
+ lineHeight: "1.4",
437
493
  color: "var(--dsw-alias-label-primary, #1f2329)"
438
494
  };
495
+ const cardDescStyle = {
496
+ fontSize: "13px",
497
+ lineHeight: "1.5",
498
+ color: "var(--dsw-alias-label-tertiary, #8a919f)"
499
+ };
500
+ const cardBodyStyle = {
501
+ borderTop: "0.5px solid var(--dsw-alias-border-l2, #e5e7eb)",
502
+ margin: "0 16px",
503
+ paddingBottom: "8px"
504
+ };
505
+ const chevronStyle = {
506
+ flexShrink: 0,
507
+ width: "14px",
508
+ height: "14px",
509
+ backgroundImage:
510
+ "url(data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M3.5 5.25L7 8.75L10.5 5.25' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E)",
511
+ backgroundRepeat: "no-repeat",
512
+ backgroundPosition: "center",
513
+ backgroundSize: "14px 14px",
514
+ transition: "transform .16s"
515
+ };
439
516
  const sectionStyle = {
440
517
  maxWidth: "760px",
441
518
  color: "var(--dsw-alias-label-primary, #1f2329)",
@@ -503,18 +580,45 @@ window.__ModuleLoader__.load({
503
580
  const hintStyle = { color: "var(--dsw-alias-label-tertiary, #8a919f)", margin: 0, fontSize: "12px", lineHeight: "1.5" };
504
581
  const errStyle = { color: "var(--dsw-alias-label-error, #d54941)", margin: 0, fontSize: "12px", lineHeight: "1.5" };
505
582
 
583
+ const toggleCard = (key) => setOpenCards((o) => ({ ...o, [key]: !o[key] }));
584
+ // 折叠卡片:header 自身是 button(整行可点 + aria-expanded + aria-label),body 仅在展开时渲染。
585
+ const card = (key, title, desc, children) =>
586
+ h("div", { style: openCards[key] ? cardOpenStyle : cardStyle }, [
587
+ h("button", {
588
+ type: "button",
589
+ className: "mp-card-header",
590
+ "aria-expanded": openCards[key] ? "true" : "false",
591
+ "aria-label": `${t(openCards[key] ? "collapse" : "expand")}: ${title}`,
592
+ style: cardHeaderStyle,
593
+ onClick: () => toggleCard(key)
594
+ }, [
595
+ h("span", { style: cardHeadTextStyle }, [
596
+ h("span", { style: cardNameStyle }, title),
597
+ h("span", { style: cardDescStyle }, desc)
598
+ ]),
599
+ h("span", { style: openCards[key] ? { ...chevronStyle, transform: "rotate(180deg)" } : chevronStyle })
600
+ ]),
601
+ openCards[key] ? h("div", { className: "mp-card-body", style: cardBodyStyle }, children) : null
602
+ ]);
603
+
506
604
  const num = (field) => ({
507
605
  type: "text",
508
606
  inputMode: "numeric",
509
607
  value: draft[field],
510
608
  disabled,
511
- style: { ...inputStyle, borderColor: validation === field ? "var(--dsw-alias-label-error, #d54941)" : undefined },
609
+ style: { ...inputStyle, borderColor: validation === field ? "var(--dsw-alias-label-error, #d54941)" : "var(--dsw-alias-border-l2, #e5e7eb)" },
512
610
  onChange: (e) => edit(field, e.target.value)
513
611
  });
514
612
 
515
613
  return h("div", { className: "memory-palace-settings", style: sectionStyle }, [
516
614
  // 强制覆盖宿主 Webview 全局样式对原生 checkbox 尺寸的覆盖(内联 style 可能不敌全局 CSS)。
517
- h("style", null, ".memory-palace-settings input[type=checkbox]{width:18px!important;height:18px!important;margin:0;flex-shrink:0!important;cursor:pointer;}"),
615
+ h("style", null,
616
+ ".memory-palace-settings input[type=checkbox]{width:18px!important;height:18px!important;margin:0;flex-shrink:0!important;cursor:pointer;}" +
617
+ // v1.7.1(特性2):展开区首条元素自带 borderTop(row/toggle 共用该样式),会与
618
+ // .mp-card-body 的 border-top 叠成双线 → 去掉首条的边框与上内边距。
619
+ ".memory-palace-settings .mp-card-body > *:first-child{border-top:0!important;padding-top:0!important;}" +
620
+ ".memory-palace-settings .mp-card-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4b5bff);outline-offset:-2px;border-radius:16px;}"
621
+ ),
518
622
  h("div", { style: { display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px" } }, [
519
623
  h("div", null, [
520
624
  h("h3", { style: headingStyle }, t("title")),
@@ -526,8 +630,7 @@ window.__ModuleLoader__.load({
526
630
  ])
527
631
  ]),
528
632
  failed ? h("p", { style: errStyle }, t("saveFailed")) : null,
529
- h("div", { style: cardStyle }, [
530
- h("p", { style: groupTitleStyle }, t("core")),
633
+ card("core", t("core"), t("coreDesc"), [
531
634
  h("div", { style: toggleStyle }, [
532
635
  h("div", { style: { flexShrink: 1, minWidth: 0 } }, [
533
636
  h("p", { style: { ...labelStyle, margin: 0 } }, t("bridgeLabel")),
@@ -548,8 +651,27 @@ window.__ModuleLoader__.load({
548
651
  placeholder: ".workbuddy/memory, .codebuddy/memory"
549
652
  }), "buddyWorkspaceMemoryDirs")
550
653
  ]),
551
- h("div", { style: cardStyle }, [
552
- h("p", { style: groupTitleStyle }, t("smartSummary")),
654
+ // v1.7.1(特性3):自定义指令。非空时经 system prompt 追加到「记忆分工说明」之后;
655
+ // 它是恒定内容(用户配置一次不变),故放 section 而非 E 投影 —— 零缓存成本。
656
+ card("custom", t("custom"), t("customDesc"), [
657
+ row(t("customLabel"), t("customHint"),
658
+ h("textarea", {
659
+ value: draft.customInstructions,
660
+ disabled,
661
+ rows: 6,
662
+ placeholder: t("customPlaceholder"),
663
+ style: {
664
+ ...inputStyle,
665
+ height: "auto",
666
+ minHeight: "120px",
667
+ padding: "10px 12px",
668
+ lineHeight: "1.6",
669
+ resize: "vertical"
670
+ },
671
+ onChange: (e) => edit("customInstructions", e.target.value)
672
+ }), "customInstructions")
673
+ ]),
674
+ card("smartSummary", t("smartSummary"), t("smartSummaryDesc"), [
553
675
  // 轮次结束自动记录(summarize):三种记忆模式的共用总闸门,始终显示,置于记忆模式之上、不随模式切换隐藏。
554
676
  toggle(t("summarizeLabel"), t("summarizeHint"), "summarize"),
555
677
  row(t("memoryModeLabel"),
@@ -599,16 +721,14 @@ window.__ModuleLoader__.load({
599
721
  row(t("projectMaxTokensLabel"), t("projectMaxTokensHint"), h("input", num("projectMaxTokens")), "projectMaxTokens")
600
722
  ])
601
723
  ]),
602
- h("div", { style: cardStyle }, [
603
- h("p", { style: groupTitleStyle }, t("storage")),
724
+ card("storage", t("storage"), t("storageDesc"), [
604
725
  row(t("userPathLabel"), t("userPathHint"), h("input", { ...num("userMemoryPath"), type: "text" }), "userMemoryPath"),
605
726
  row(t("wsDirLabel"), t("wsDirHint"), h("input", { ...num("workspaceMemoryDir"), type: "text" }), "workspaceMemoryDir"),
606
727
  row(t("retentionLabel"), draft.memoryMode === "hybrid" ? t("retentionHintHybrid") : t("retentionHint"), h("input", num("dailyLogRetentionDays")), "dailyLogRetentionDays"),
607
728
  row(t("userBudgetLabel"), t("userBudgetHint"), h("input", num("userBudgetChars")), "userBudgetChars"),
608
729
  row(t("wsBudgetLabel"), t("wsBudgetHint"), h("input", num("workspaceBudgetChars")), "workspaceBudgetChars")
609
730
  ]),
610
- h("div", { style: cardStyle }, [
611
- h("p", { style: groupTitleStyle }, t("dev")),
731
+ card("dev", t("dev"), t("devDesc"), [
612
732
  row(t("distillTimeoutLabel"), t("distillTimeoutHint"), h("input", num("summaryTimeoutMs")), "summaryTimeoutMs"),
613
733
  toggle(t("distillDebugLogLabel"), t("distillDebugLogHint"), "distillDebugLog")
614
734
  ])
@@ -20,17 +20,23 @@ export function createPaths(getConfig, getCwd = () => null) {
20
20
  .map((rel) => join(cwd, rel))
21
21
  .filter((d) => existsSync(d));
22
22
  }
23
- // 读取源:存在 buddy 目录则用它们;否则仅当 legacy dsh 目录已存在才读(不创建)。
23
+ // 读取源:dsh 原生目录恒在首位(不设 existsSync 门槛——即便每日日志目录尚未创建,
24
+ // 也要能读到同级的 .deepseek-harness/MEMORY.md),其后叠加磁盘上已存在的 buddy 目录。
25
+ // v1.7.0 修复:旧实现是「目录级排他」——buddyDirs() 命中即 return b,导致项目同时存在
26
+ // .workbuddy/memory(哪怕只有日志、无 MEMORY.md)时,.deepseek-harness/MEMORY.md 既读不到
27
+ // 也写不进、长期静默失效。现改为「dsh 优先 + buddy 叠加」,两者都可被读到。
28
+ // 顺序定义:[.deepseek-harness > .workbuddy > .codebuddy]
24
29
  function readDirs(cwd = activeCwd()) {
25
30
  const c = cfg();
26
31
  if (!c.enabled) return [];
27
- const b = buddyDirs(cwd);
28
- if (b.length) return b;
32
+ const out = [];
29
33
  const d = cwd ? join(cwd, c.workspaceMemoryDir) : null;
30
- // 不设 existsSync 门槛:即便每日日志目录尚未创建,也要能读到同级的 .deepseek-harness/MEMORY.md
31
- return d ? [d] : [];
34
+ if (d) out.push(d);
35
+ out.push(...buddyDirs(cwd));
36
+ return out;
32
37
  }
33
38
  // 写入目标:存在 buddy 目录则全部同步写入;否则回退 dsh 目录(按需创建)。
39
+ // 写入优先级保持 buddy 优先不变(读 dsh 优先 / 写 buddy 优先,读写刻意不对称)。
34
40
  function writeDirs(cwd = activeCwd()) {
35
41
  const c = cfg();
36
42
  if (!c.enabled) return [];
@@ -42,7 +42,7 @@ export function SUMMARY_PROMPT({ allowDelete = false, outputBudget } = {}) {
42
42
  );
43
43
  }
44
44
 
45
- // v1.2.0 项目记忆蒸馏 prompt(固化自 upgrade plan/v1.2.0/记忆蒸馏Prompt.md,供按钮「蒸馏项目记忆」稳定调用)。
45
+ // 项目记忆蒸馏 prompt(供按钮「蒸馏项目记忆」稳定调用)。
46
46
  // 用法:system = 本 prompt,user = 项目 MEMORY.md 全文,输出 = 精炼后的项目记忆(直接覆盖写回)。
47
47
  export function DISTILL_PROMPT({ outputBudget } = {}) {
48
48
  return (
@@ -3,7 +3,7 @@
3
3
  import { mkdir, readdir, unlink, readFile, writeFile } from "node:fs/promises";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { join, dirname } from "node:path";
6
- import { todayISO, expandHome, readMdSync, toHomeShort, normLine, isStructural, stripSmartTag } from "./text.mjs";
6
+ import { todayISO, expandHome, normLine, isStructural, stripSmartTag } from "./text.mjs";
7
7
 
8
8
  // 追加一行记忆,若目标文件已包含相同内容则跳过(去重),返回是否实际写入。
9
9
  export async function appendLineDedup(file, line) {
@@ -237,30 +237,6 @@ export function createRecords({ getConfig, paths }) {
237
237
  }
238
238
  }
239
239
 
240
- // 读取目录里最近的(非今日)每日日志,拼成一段供 memory_read 展示(最多 3 份)。
241
- async function recentLogs(dir) {
242
- let files = [];
243
- try {
244
- files = await readdir(dir);
245
- } catch {
246
- return "";
247
- }
248
- const today = todayISO();
249
- const dated = files
250
- .map((f) => /^(\d{4}-\d{2}-\d{2})\.md$/.exec(f))
251
- .filter((m) => m && m[1] < today)
252
- .map((m) => m[1])
253
- .sort()
254
- .reverse()
255
- .slice(0, 3);
256
- const parts = [];
257
- for (const date of dated) {
258
- const text = readMdSync(join(dir, `${date}.md`));
259
- if (text) parts.push(`# 日志 ${date} @ ${toHomeShort(dir)}\n${text.slice(0, cfg().workspaceBudgetChars)}`);
260
- }
261
- return parts.join("\n\n");
262
- }
263
-
264
240
  // 轻量结构化条目(无 LLM):截断原始文本,错误带 [ERROR] 前缀。
265
241
  async function writeLightEntry(dirs, turn, isError) {
266
242
  const userText = turn.find((b) => b.role === "user")?.text ?? "";
@@ -289,5 +265,7 @@ export function createRecords({ getConfig, paths }) {
289
265
  }
290
266
  }
291
267
 
292
- return { appendDaily, prune, recentLogs, writeLightEntry, captureError };
268
+ // v1.7.1:recentLogs 已删除 —— memory_read 的 scope='daily' 改为按日期(isoDaysAgo)直读,
269
+ // 不再需要"最近 N 份文件"语义(跨日跳空会取偏)。
270
+ return { appendDaily, prune, writeLightEntry, captureError };
293
271
  }
@@ -21,6 +21,20 @@ const normBlock = (t) =>
21
21
 
22
22
  const SECTION_RE = /^##\s+(.+?)\s*$/;
23
23
 
24
+ // v1.7.0 特性4:确保每日日志有规范文件头 `# YYYY-MM-DD`。
25
+ // 现状有三种形态(实测):①正常 `# 2026-09-08`;②首行空;③直接以 `## 章节` 开头(无文件头)。
26
+ // 本函数幂等:已有当日标题则原样返回;否则在文件头补一行标题 + 空行。
27
+ // 只在【写入时】调用(不回填历史文件),故存量日志可能新旧混存——可接受。
28
+ export function ensureLogHeader(md, date) {
29
+ const title = `# ${date}`;
30
+ const text = md || "";
31
+ const lines = text.split("\n");
32
+ // 已存在同日标题(不限首行,容忍前置空行)→ 不动
33
+ if (lines.some((l) => l.trim() === title)) return text;
34
+ const body = text.replace(/^\s+/, "");
35
+ return body ? `${title}\n\n${body}` : `${title}\n`;
36
+ }
37
+
24
38
  // 解析 `## ` 章节。返回 { head: string[], sections: [{ title, lines }] };
25
39
  // lines 含标题行本体;`#` 文件标题、前言与 `<!--` 注释归 head;`###` 及更深归当前章节。
26
40
  export function parseSections(md) {
@@ -14,6 +14,16 @@ export const todayISO = () => {
14
14
  return `${d.getFullYear()}-${m}-${day}`;
15
15
  };
16
16
 
17
+ // 本地日期偏移(0=今天,1=昨天,2=前天…),供 memory_read 按**日期**取日志
18
+ // (而不是"最近 N 份文件"——跨日跳空时会取偏)。同 todayISO 的教训:必须本地时间。
19
+ export const isoDaysAgo = (n = 0) => {
20
+ const d = new Date();
21
+ d.setDate(d.getDate() - n);
22
+ const m = String(d.getMonth() + 1).padStart(2, "0");
23
+ const day = String(d.getDate()).padStart(2, "0");
24
+ return `${d.getFullYear()}-${m}-${day}`;
25
+ };
26
+
17
27
  // 本地时间戳(YYYY-MM-DDTHH-mm-ss,`:` 换 `-` 兼容 Windows 文件名)。
18
28
  // 同 todayISO 的教训:必须用本地时间,不能用 toISOString()(UTC 会差 8 小时)。
19
29
  // 供项目记忆蒸馏备份文件名(MEMORY.md.{时间戳})使用。
@@ -1,7 +1,7 @@
1
1
  // memory-palace v1.6.0 hybrid 模式提示词:注入段二(HYBRID_PROACTIVE)+ 记忆子 agent
2
2
  // system prompt(SUBAGENT_SYSTEM)。纯常量,无状态。
3
3
  //
4
- // 定案锚点(爹 2026-08-30 拍板):
4
+ // 定案锚点:
5
5
  // - 日志由子 agent 每轮自动维护,agent 无需重复记录过程性信息;
6
6
  // - MEMORY.md 写入权归还 agent(主动调工具、章节化格式);
7
7
  // - 项目级全量重整双门禁(超预算 + 冷却 ≥7 天,机器校验),用户级禁止重整;
@@ -17,6 +17,9 @@ export const HYBRID_PROACTIVE =
17
17
 
18
18
  // 记忆子 agent system prompt(turn/end 触发,判定 + 产出一体)。
19
19
  // {date} 注入今日日期;回喂正文见 subagent.mjs 的首轮 user 消息。
20
+ // v1.7.0 特性4:新增【格式规范】段(7 正 + 3 反)——旧产物是"一堵墙"(单章节 + 零嵌套 +
21
+ // 零加粗 + 200 字长句),可读性差。格式细节写进 prompt 是因为 log_write_ops 的 entry 是
22
+ // 任意字符串(支持多行),缩进/加粗全靠模型自己写,机器侧不代劳。
20
23
  export function SUBAGENT_SYSTEM() {
21
24
  return (
22
25
  "你是记忆子代理,负责把本轮对话沉淀进【今日工作日志】(Markdown 文件,按 `## 章节` + `- 条目` 组织)。\n" +
@@ -37,6 +40,20 @@ export function SUBAGENT_SYSTEM() {
37
40
  "6. 今日日志为空(新的一天)时直接用 append/new_section 建章节写入即可。\n" +
38
41
  "7. 需要读取日志章节(目录模式回喂时)用 log_read_section;需要多个章节时务必在单次调用中把 sections 数组全部传齐,避免多轮往返浪费轮次。\n" +
39
42
  "\n" +
43
+ "【格式规范(必须遵守)】\n" +
44
+ "正面规则:\n" +
45
+ "1. 文件头由插件自动维护(`# YYYY-MM-DD`),你不要写文件头,直接写 `## 章节`。\n" +
46
+ "2. 一个主题一个 `##` 章节,章节之间留一个空行;同一主题条目超过 3 条时再拆 `###` 小节。\n" +
47
+ "3. 一条只记一个事实,≤ 120 字;超长拆成 `- 父项` + 两个空格缩进的 ` - 子项`(缩进必须自己写,插件不会替你缩进)。\n" +
48
+ "4. 关键结论 / 决策 / 纠错用 `**加粗**`;重要约束在句末标 `(重要)`。\n" +
49
+ "5. 条目内可用行内分组:`**结论**:` / `**待办**:` / `**约束**:` / `**产出**:` / `**用户约定**:`。\n" +
50
+ "6. 允许用一句「用户意图/反馈」作语境,只一句;禁止流水账。\n" +
51
+ "7. 可复现的命令 / 脚本用三反引号代码块包裹。\n" +
52
+ "反模式(明确禁止):\n" +
53
+ "- ❌ 「用户提出…我已改…产出文档…」式过程流水。\n" +
54
+ "- ❌ 自我过程表述(「我搜索了 A/B/C」)——除非结论依赖它。\n" +
55
+ "- ❌ 把 5 个结论塞进一条 200 字长句。\n" +
56
+ "\n" +
40
57
  "【输出】全程只做上述两件事:要么纯文本收尾,要么调工具写日志后收尾。不要向用户提问,不要输出多余解释。\n" +
41
58
  "\n" +
42
59
  "【环境边界】本环境不是代码执行环境,run_code 等工具均不存在;不要模仿上文出现过的任何工具调用,只能用 log_write_ops / log_read_section。"
@@ -11,7 +11,7 @@ import { join, dirname } from "node:path";
11
11
  import { BlockAssembler, createUserMessage, createToolResultMessage } from "@deepseek-ai/dsh-llm";
12
12
  import { todayISO, readMdSync } from "../common/text.mjs";
13
13
  import { eventsFrom } from "../common/session.mjs";
14
- import { appendToSectionText, upsertSectionText, createSectionText, markEntryDeletedText, parseSections, locateSection } from "../common/sections.mjs";
14
+ import { appendToSectionText, upsertSectionText, createSectionText, markEntryDeletedText, parseSections, locateSection, ensureLogHeader } from "../common/sections.mjs";
15
15
  import { HYBRID_PROACTIVE, SUBAGENT_SYSTEM } from "./prompts.mjs";
16
16
 
17
17
  export { HYBRID_PROACTIVE };
@@ -69,12 +69,26 @@ async function streamTurn(ctx, params, timeoutMs) {
69
69
 
70
70
  // 把本轮 surface 事件投影为消息数组(seq >= fromSeq;与 distillSessionCore 同源逻辑)。
71
71
  // v1.6.2-alpha.4:eventsFrom 兼容层(宿主 0.1.2-alpha.4 删除 Session.events getter)。
72
- function projectTurnMessages(session, fromSeq) {
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) {
73
83
  const hist = [];
74
84
  for (const e of eventsFrom(session, fromSeq)) {
75
85
  if (!SURFACE.has(e.type)) continue;
76
86
  const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
77
- if (m) hist.push(m);
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);
78
92
  }
79
93
  return hist;
80
94
  }
@@ -205,9 +219,12 @@ function applyLogOps(logFiles, dateStr, ops) {
205
219
  md = readFileSync(file, "utf8");
206
220
  } catch { /* 文件尚不存在 */ }
207
221
  const { cur, results } = apply(md);
208
- if (cur !== md) {
222
+ // v1.7.0 特性4:落盘前确保文件头为规范 `# YYYY-MM-DD`(空文件/首行空/直接以 ## 开头
223
+ // 三种形态都会补上;已有同日标题则原样)。仅在写入时补齐,不回填历史文件。
224
+ const withHeader = ensureLogHeader(cur, dateStr);
225
+ if (withHeader !== md) {
209
226
  await mkdir(dirname(file), { recursive: true });
210
- await writeFile(file, cur, "utf8");
227
+ await writeFile(file, withHeader, "utf8");
211
228
  }
212
229
  all.push(...results);
213
230
  }
@@ -232,7 +232,7 @@ export function registerHybridTools({ ctx, getConfig, paths, records, state }) {
232
232
  "(workspaceBudgetChars); (2) at least reorgCooldownDays (default 7) since the last reorganize. " +
233
233
  "If either gate fails the call is REJECTED — use memory_update_section instead. " +
234
234
  "Requirements for newContent: keep the full `## 章节` + `- 条目` format; NEVER lose still-valid key " +
235
- "information — read the daily logs (memory_read) and current MEMORY.md for cross-checking first; " +
235
+ "information — read the daily logs and current MEMORY.md for cross-checking first (memory_read with scope 'all'); " +
236
236
  "merge duplicates, drop only strikethrough/obsolete content; keep user-level MEMORY.md untouched " +
237
237
  "(user-level reorganization is FORBIDDEN). A last-reorg timestamp comment is appended automatically. " +
238
238
  "A user confirmation dialog is shown before execution.",
package/lib/index.js CHANGED
@@ -22,11 +22,10 @@
22
22
  // - src/index.mjs:最薄入口——apply() 装配 + 闭包运行时(session/event 跟踪、settle 时序、section 注入)
23
23
  // - src/client/(00-head…90-tail):浏览器 bundle 源码(build 时零依赖拼接为 lib/client.js 单文件,
24
24
  // 硬约束——dsh 客户端模块系统不支持插件相对 require)
25
- import { join } from "node:path";
26
25
  import Schema from "@deepseek-ai/schemastery";
27
26
  // v1.6.0-rc1:适配 DSH 0.1.2-alpha.2——dsh-settings 不再导出自由函数 installSettingsSection /
28
27
  // settingsNamespace,设置节注册改用 SettingsProvider 实例方法 installSection(经 ctx.inject(["settings"]))。
29
- import { todayISO, expandHome, toHomeShort, readMdSync, budgetClip, stripSmartTag, stripDeletedLines, extractText, extractToolErrorText } from "./common/text.mjs";
28
+ import { extractText, extractToolErrorText } from "./common/text.mjs";
30
29
  import { SCENE_KEYWORDS } from "./common/prompts.mjs";
31
30
  import { createPaths } from "./common/paths.mjs";
32
31
  import { createRecords } from "./common/records.mjs";
@@ -35,6 +34,7 @@ import { createDistill } from "./distill.mjs";
35
34
  import { registerTools } from "./tools.mjs";
36
35
  import { registerApi } from "./api.mjs";
37
36
  import { registerHybrid, HYBRID_PROACTIVE } from "./hybrid/index.mjs";
37
+ import { registerProjection } from "./projection.mjs";
38
38
 
39
39
  export const name = "memory-palace";
40
40
  // v1.6.0-rc1:settingsNamespace() 已删除,namespace 直接用字符串(宿主侧做类型级校验)。
@@ -60,7 +60,7 @@ export const Config = Schema.object({
60
60
  summaryTimeoutMs: Schema.number().default(60000).description("蒸馏 LLM 调用的超时(毫秒),超时视为失败并降级;覆盖智能模式摘要与手动蒸馏两条链路;默认 60000(60s)。"),
61
61
  distillDebugLog: Schema.boolean().default(false).description("调试开关:向 dsh 服务端 stderr 输出蒸馏 LLM 调用诊断。distillLogLevel=info 时仅输出元数据(模型解析/请求参数/流进度/错误详情,不含文本);distillLogLevel=debug 会额外打印 LLM 原始响应文本(分隔符包裹),仅限受信本地排障开启。"),
62
62
  // ---- v1.4.1:蒸馏 stderr 日志级别(平铺键,不进 UI;默认 info;distillDebugLog=true 时生效) ----
63
- distillLogLevel: Schema.string().default("info").description("蒸馏 stderr 日志级别:info=仅元数据诊断(默认,不打印 LLM 原始响应);debug=额外打印 LLM 原始响应文本(分隔符包裹),仅限受信本地排障开启。无需 UI 配置,经 profile/settings.yaml 设置 distillLogLevel 键。"),
63
+ distillLogLevel: Schema.string().default("info").description("蒸馏 stderr 日志级别:info=仅元数据诊断(默认,不打印 LLM 原始响应);debug=额外打印 LLM 原始响应文本(分隔符包裹),仅限受信本地排障开启。无需 UI 配置,经 DSH home($DSH_HOME)根目录的 settings.yaml 设置 distillLogLevel 键。"),
64
64
  // ---- v1.4.0:智能模式最终输出软预算(v1.4.1 起语义变更:prompt 软约束,思考不受限;实际硬上限由模型自身 maxTokens 决定) ----
65
65
  summaryMaxTokens: Schema.number().default(2000).description("会话摘要 LLM 的最终输出软预算(prompt 约束,思考不受限;实际硬上限由模型自身 maxTokens 决定)。默认 2000;可调大以容纳更多 durable 事实。"),
66
66
  projectMaxTokens: Schema.number().default(8000).description("手动「蒸馏项目记忆」LLM 的最终输出软预算(prompt 约束,思考不受限;实际硬上限由模型自身 maxTokens 决定)。默认 8000。"),
@@ -69,6 +69,9 @@ export const Config = Schema.object({
69
69
  // ---- v1.6.0:hybrid 模式配置(仅 memoryMode=hybrid 时生效) ----
70
70
  reorgCooldownDays: Schema.number().default(7).description("hybrid 模式:项目级 MEMORY.md 全量重整的冷却天数(距上次重整)。与「超出注入预算」双条件同时满足才允许 memory_reorganize;时间戳以 HTML 注释落在 MEMORY.md 文件尾。"),
71
71
  subagentLogBudget: Schema.number().default(20000).description("hybrid 模式:记忆子代理回喂今日工作日志的字符上限。超出时仅回喂章节目录,子代理用 log_read_section 按需读取章节。"),
72
+ // ---- v1.7.1(特性3):自定义指令(用户自写,经 system prompt 注入到「记忆分工 prompt」之后) ----
73
+ // 放 system 而非 E 投影:用户配置一次即恒定 → 零缓存成本(见下方 section 的分流原则)。
74
+ customInstructions: Schema.string().default("").description("自定义指令:非空时追加到系统提示词「记忆分工说明」之后,用于承载不适合写进用户记忆的特殊指令。对全部会话与三种记忆模式生效;留空则不注入。"),
72
75
  });
73
76
 
74
77
  export const inject = ["systemPrompt", "tools", "llm", "webServer", "webRuntime"];
@@ -125,9 +128,8 @@ export function apply(ctx, config) {
125
128
  lastSummarizedSeq: -1, // 智能模式增量摘要断点(session 事件 seq)
126
129
  summarySessionId: null,
127
130
  planModeActive: false, // 计划模式:禁写记忆(硬编码默认,无开关;仅经 session/event 的 plan/mode 翻转;未装 dsh-plan-mode 永不触发=不拦截)
128
- // v1.4.1:已注入记忆的 session 集合——仅首次注入(DSH 会话自身继承历史轮次,逐轮注入
129
- // = 内容重复 + 过时断言每轮在场);compaction 发生后清除对应 id,下次构建重注。
130
- injectedSessionIds: new Set(),
131
+ // v1.7.0:injectedSessionIds 已删除——记忆正文改经 E 投影(src/projection.mjs)注入为
132
+ // 常驻消息,compaction 后由投影自动重注,不再需要"仅首次注入"的会话级标记。
131
133
  };
132
134
  const TURN_BUFFER_MAX = 30;
133
135
  // v1.1.3:字符上限提升到 30k(长工具型 request 的工具结果动辄数千字符,6k 上限会触发
@@ -139,7 +141,7 @@ export function apply(ctx, config) {
139
141
  const paths = createPaths(() => source(), () => state.activeCwd);
140
142
  const records = createRecords({ getConfig: () => source(), paths });
141
143
  const distill = createDistill({ ctx, getConfig: () => source(), paths, records, state });
142
- registerTools({ ctx, getConfig: () => source(), paths, records, state });
144
+ registerTools({ ctx, getConfig: () => source(), paths, state });
143
145
  registerApi({ ctx, paths, distill, state, getSettingsFace });
144
146
  // v1.6.0:hybrid 模式独立模块。必须【无条件注册】——apply() 同步段执行时 settings 服务
145
147
  // 尚未挂载(v1.6.0-rc1 起设置节经 SettingsProvider.installSection 在 ctx.inject(["settings"])
@@ -149,6 +151,9 @@ export function apply(ctx, config) {
149
151
  // 运行时分派靠 _settle / proactive 的热 source() 判断;工具注册无副作用(plugin/smart
150
152
  // 的 prompt 不引导使用,guards 白名单只拦 hybrid 工具)。
151
153
  const hybrid = registerHybrid({ ctx, getConfig: () => source(), paths, records, state });
154
+ // v1.7.0 特性1:E 投影通道(记忆正文常驻注入)。与 hybrid 同理,无条件注册——
155
+ // 其内部读热配置,无副作用(enabled=false 时 buildProjections 返回空)。
156
+ registerProjection({ ctx, getConfig: () => source(), paths });
152
157
 
153
158
  // ---- session/event 跟踪:更新 activeCwd/activeSession/增量断点 + 累积 turnBuffer ----
154
159
  // 关键:一次用户请求在 dsh agent 循环里会被拆成多个 turn(每个工具调用一轮),
@@ -170,6 +175,15 @@ export function apply(ctx, config) {
170
175
  return;
171
176
  }
172
177
  if (type === "user/message" || type === "assistant/message" || type === "tool/result") {
178
+ // v1.7.0 特性1 连带修复:turnBuffer 只采集【真实用户消息】。
179
+ // E 投影的 user/message(source.kind === 'plugin')也在 SURFACE 里,若不过滤会被
180
+ // 采集进 buffer,并触发下方 `type === "user/message" && state.turnBuffer.length` 的
181
+ // _flushTurn() —— 打乱 request 边界、污染关键词闸门(结算时取第一条 user 文本)。
182
+ // 判定用【黑名单】而非白名单(fail-open):只在 source 明确存在且非 'user' 时排除。
183
+ // 理由:source 缺失时若按"非 user"丢弃,真实用户消息会被漏掉 → 闸门关闭 → 记忆整体
184
+ // 丢失(本项目历史上最严重的一类 bug)。注入类消息在 DSH 中必带 source,故放行风险极低。
185
+ const srcKind = event?.data?.source?.kind;
186
+ if (type === "user/message" && srcKind && srcKind !== "user") return;
173
187
  const text = extractText(event);
174
188
  if (text) {
175
189
  const role = type === "tool/result" ? "tool" : type === "user/message" ? "user" : "assistant";
@@ -194,48 +208,27 @@ export function apply(ctx, config) {
194
208
  // 不清空 buffer;用 debounce 在整段请求安静后结算一次。
195
209
  _scheduleSettle();
196
210
  } else if (type === "compaction" || type?.startsWith?.("compaction/")) {
197
- // v1.4.1:compaction 压缩上下文后,记忆块可能被压掉——清除已注入标记,
198
- // 下次 system prompt 构建时重注(压缩后上下文变薄,重注时机依然正确)。
199
- if (session?.id) state.injectedSessionIds.delete(session.id);
211
+ // v1.7.0:无需再清"已注入标记"(该机制已删)——记忆正文改经 E 投影注入,
212
+ // compaction 把投影消息移出 surface 后,后续 step 的去重扫描找不到它 → 自动重注。
200
213
  }
201
214
  });
202
215
 
203
216
  // ---------- 读取:同步 section text(systemPrompt 要求同步) ----------
217
+ // v1.7.1:section **只保留恒定内容**(intro 指令 + 记忆分工说明 + planNote),
218
+ // 今日工作日志已迁入 E 投影(src/projection.mjs)。
219
+ // 分流依据是「内容是否恒定」:恒定 → 留 section(system prompt 在序列最前,内容恒定则
220
+ // 前缀缓存永久有效);易变 → 走 E 投影追加到历史尾部,且按文件身份只注一次。
221
+ // ⚠️ 切勿把任何「每步可能变化」的内容放回本 section —— 变一次就会让其后整段前缀作废。
222
+ // 实测代价:9 次全量重算、命中率从 99.26% 掉到 94.53%(见 testdata/session.v3.jsonl)。
204
223
  ctx.systemPrompt.section({
205
224
  name: "memory-palace",
206
225
  order: 50,
207
226
  text: () => {
208
227
  const cfg = source();
209
228
  if (!cfg.enabled) return "";
210
- const userFileResolved = expandHome(cfg.userMemoryPath);
211
- const userFileShort = toHomeShort(userFileResolved);
212
- const blocks = [];
213
- // v1.2.0:注入按预算截断——用户级 ≤ userBudgetChars、工作区级(MEMORY.md 与今日日志)
214
- // 各自 ≤ workspaceBudgetChars。v1.4.1:budgetClip 改为"结构行 + 尾部最新条目"(修
215
- // LIFO/FIFO 错配——写入追加在尾部,旧实现只留头部导致最新结论永远进不了视野),
216
- // stripSmartTag 剥除行首 [smart] 标记(模型不可见该标签,存量自然消亡)。
217
- // v1.6.0:stripDeletedLines 剔除整行删除线墓碑(文件保留、注入不过)。
218
- const u = readMdSync(userFileResolved);
219
- if (u) blocks.push(`# 用户级记忆 (${userFileShort})\n${stripSmartTag(stripDeletedLines(budgetClip(u, cfg.userBudgetChars)))}`);
220
- for (const dir of paths.readDirs()) {
221
- const dirShort = toHomeShort(dir);
222
- const w = paths.memoryReadCandidates(dir).map(readMdSync).filter(Boolean).join("\n\n");
223
- if (w) blocks.push(`# 工作区记忆 (${dirShort})\n${stripSmartTag(stripDeletedLines(budgetClip(w, cfg.workspaceBudgetChars)))}`);
224
- const t = readMdSync(join(dir, `${todayISO()}.md`));
225
- if (t) blocks.push(`# 今日工作日志 (${todayISO()} @ ${dirShort})\n${stripSmartTag(stripDeletedLines(budgetClip(t, cfg.workspaceBudgetChars)))}`);
226
- }
227
- // v1.4.1:仅首次注入。DSH 会话自身引用历史轮次作为上下文,逐轮注入 = 内容重复 +
228
- // 过时断言每轮在场;记忆注入的价值大头在冷启动第一轮。已注入过的 session 返回
229
- // 空串;compaction 后经 session/event 清除标记实现重注。
230
- // intro 指令仍始终注入(无记忆时也不例外——否则 agent 不知道记忆系统存在,形成
231
- // 「无记忆→无指令→永不记」死循环);被跳过的只是记忆正文块。
232
- const sessionId = state.activeSession?.id ?? null;
233
- const alreadyInjected = sessionId ? state.injectedSessionIds.has(sessionId) : false;
234
- if (sessionId && !alreadyInjected && blocks.length) state.injectedSessionIds.add(sessionId);
235
- if (alreadyInjected) blocks.length = 0;
236
- // 记忆正文可能为空(冷启动 / 全新环境尚无任何记忆)。
237
- // 注意:intro(含记忆公民指令)必须【始终】注入——若因记忆为空而整体返回空串,
238
- // agent 将不知道记忆系统存在、不会主动记,形成「无记忆 → 无指令 → 永不记」的死循环。
229
+ // v1.7.0:alreadyInjected / injectedSessionIds 已整体删除(记忆正文改走 E 投影)。
230
+ // intro(含记忆公民指令)仍【始终】注入——若因记忆为空而整体返回空串,agent 将不知道
231
+ // 记忆系统存在、不会主动记,形成「无记忆 → 无指令 → 永不记」死循环。
239
232
  const bridged = paths.buddyDirs().length > 0;
240
233
  const antiMangle =
241
234
  "提及记忆文件路径时一律用 ~ 简写(如 ~/.deepseek-harness/MEMORY.md),不要逐字拼写绝对路径——你转述绝对路径容易漏掉目录分隔符。";
@@ -244,7 +237,7 @@ export function apply(ctx, config) {
244
237
  // v1.6.0:hybrid=记忆子代理自动维护日志 + agent 主动维护 MEMORY.md(见 HYBRID_PROACTIVE)。
245
238
  const proactive =
246
239
  source().memoryMode === "smart"
247
- ? "\n\n[记忆说明] 你的跨 session 记忆由 LLM 智能摘要自动维护(每轮结束自动提炼摘要并沉淀 durable 事实到 MEMORY.md),无需主动调用 memory_note / memory_note_user;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。"
240
+ ? "\n\n[记忆说明] 你的跨 session 记忆由 LLM 智能摘要自动维护(每轮结束自动提炼摘要并沉淀 durable 事实到 MEMORY.md),无需主动调用 memory_note / memory_note_user;读取记忆用 memory_read 工具,scope 默认 'memory',需要日志时显式传 scope(不要手动 glob/read 记忆文件)。"
248
241
  : source().memoryMode === "hybrid"
249
242
  ? HYBRID_PROACTIVE
250
243
  : "\n\n[记忆公民指令] 你拥有跨 session 的 Markdown 记忆。以下场景【必须】主动调用 memory_note(项目级约定)或 memory_note_user(跨项目个人偏好)落档,不要依赖轮次结束的自动兜底——它只做原始文本截断,无法代替你的高质量总结:\n" +
@@ -256,19 +249,25 @@ export function apply(ctx, config) {
256
249
  "判定标准:这条信息「下个 session 的我」还需要吗?不需要(闲聊、一次性操作、显而易见)就不记。格式:一句话结论开头 + 关键细节(命令/路径/数字),不写流水账。";
257
250
  const intro = bridged
258
251
  ? "你拥有持久化、人类可直接编辑的 Markdown 记忆文件。当前项目已存在 WorkBuddy/CodeBuddy 项目记忆目录,本插件直接读写这些目录(不再单独创建 .deepseek-harness/memory/)。" +
259
- "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
252
+ "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取记忆用 memory_read 工具,scope 默认 'memory'(用户级 + 项目级 MEMORY.md),需要今日/历史日志时显式传 scope:'daily' 或 'all'(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
253
+ "注意:上下文中的记忆是**会话起始快照**,不随记忆文件之后的更新自动刷新;需要以当前状态为依据时(改记忆前、或据记忆作答前)用 memory_read 重读。" +
260
254
  antiMangle + proactive
261
255
  : "你拥有持久化、人类可直接编辑的 Markdown 记忆文件(位于 ~/.deepseek-harness/MEMORY.md,以及各项目的 .deepseek-harness/MEMORY.md(长期记忆)与 .deepseek-harness/memory/(每日日志))。" +
262
- "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
256
+ "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取记忆用 memory_read 工具,scope 默认 'memory'(用户级 + 项目级 MEMORY.md),需要今日/历史日志时显式传 scope:'daily' 或 'all'(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
257
+ "注意:上下文中的记忆是**会话起始快照**,不随记忆文件之后的更新自动刷新;需要以当前状态为依据时(改记忆前、或据记忆作答前)用 memory_read 重读。" +
263
258
  antiMangle + proactive;
264
259
  // 计划模式禁写提示(硬编码默认行为,无开关):无论记忆模式都追加,让 agent 自觉不写(网关物理兜底仍生效)
265
260
  const planNote = state.planModeActive
266
261
  ? "\n\n[plan 模式] 当前处于 plan 模式,不要调用 memory_note / memory_note_user 写入记忆,也不要请求删除记忆(读取记忆用 memory_read,仍可用)。"
267
262
  : "";
268
263
  const introFull = intro + planNote;
269
- // v1.4.1:blocks 为空(已注入过)时只返回 intro 指令;返回值永非空串,
270
- // 避免 harness 对空 section 的处理歧义。
271
- return blocks.length ? [introFull, ...blocks].join("\n\n") : introFull;
264
+ // v1.7.1(特性3):自定义指令追加在「记忆插件 prompt + 记忆分工 prompt」之后 ——
265
+ // 拼接顺序即 system prompt 里的实际排布(同一 section 内完成,order 不变)。
266
+ // 它由用户在设置页配置,内容恒定 → 放 system 零缓存成本,不违反本节的分流原则。
267
+ const custom = typeof cfg.customInstructions === "string" ? cfg.customInstructions.trim() : "";
268
+ // v1.7.1:本 section 已无任何动态块(日志迁出 E 投影),直接返回常量指令。
269
+ // 返回值仍非空串(即便自定义指令为空也含 intro),避免 harness 对空 section 的处理歧义。
270
+ return custom ? `${introFull}\n\n[用户自定义指令] ${custom}` : introFull;
272
271
  },
273
272
  });
274
273
 
@@ -0,0 +1,164 @@
1
+ // memory-palace E 投影 —— 记忆正文与今日日志的独立注入通道。
2
+ //
3
+ // 背景(v1.6.3 及以前):用户级 + 项目级 MEMORY.md 走 systemPrompt.section,受
4
+ // 「仅首次注入」限制(`alreadyInjected` 打了标记就不撕,只有 compaction 清),
5
+ // 导致记忆正文在 turn 1 的 step 2 之后就消失、全程不再进场。
6
+ //
7
+ // 现在:记忆正文改经 `agent/pre-step` 投影为**独立 user 消息**,常驻对话历史:
8
+ // - 每个 step 都可见(消息在历史里,不依赖注入时机);
9
+ // - v1.7.1 起去重判据由「全文深比较」改为「**文件身份**」(首行 heading,含路径 + 日期):
10
+ // 同一文件只注一次,内容再变也不重注 —— 记忆正文由 agent 自己写入,其内容本就在
11
+ // 上下文(tool/call 参数)里,重注纯属冗余;换文件(跨日期日志)才视为新身份注入;
12
+ // - v1.7.1 起**今日工作日志由 systemPrompt.section 迁入本通道**。日志是 agent 唯一
13
+ // 「不知道自己缺」的内容(hybrid 子代理以独立 LLM stream 写入、不回注主对话),
14
+ // 必须注入;迁出后 section 只剩恒定指令 → system prompt 恒定 → 前缀缓存永久有效
15
+ // (此前日志在 section 里每步求值,一变就让序列最前的整段前缀作废)。
16
+ // - compaction 把消息移出 surface 后,身份判据即判为缺失 → 自动重注**磁盘最新版**,
17
+ // 顺带完成一次「刷新到最新」。
18
+ //
19
+ // 实现照抄宿主 `packages/context/agent-instructions/src/index.ts` 三件套(API 全公开、无第一方特权):
20
+ // 1. `inject: ['sessionProjections']`(服务随 dsh-base 默认装载)
21
+ // 2. `ctx.on('agent/pre-step', async ({agent, messages, step, signal}, next) => {...})`
22
+ // 3. `createUserMessage()`(来自 @deepseek-ai/dsh-llm)
23
+ // 投递方式:同步版——折叠进 `decision.messages`(`toSpliced(lastClaimedIndex + 1, 0, ...pending)`),
24
+ // 不做异步 inbox 刷新(agent-instructions 注释明写异步投影有两个 commit 边界的时序约束,
25
+ // 本版刻意回避;记忆更新后下一步自然带上新内容)。
26
+ //
27
+ // 🔴 契约风险:pre-step 监听器抛错会让**整个 turn 失败**(宿主测试
28
+ // packages/acp/acp/tests/turns.spec.ts:150-156 锚定),故钩子内整体 try-catch,
29
+ // 任何异常降级为「本步不投影」并留一行 stderr 线索,绝不冒泡。详见 DEVELOPMENT.md §7.4。
30
+ import { join } from "node:path";
31
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
32
+ import { expandHome, toHomeShort, todayISO, readMdSync, budgetClip, stripSmartTag, stripDeletedLines } from "./common/text.mjs";
33
+
34
+ // 投影消息的 source 标注。
35
+ // - kind: 'plugin' → 明确标注"插件注入",供下游按 source 过滤(子 agent 去噪 / turnBuffer 采集)。
36
+ // - form: 'instructions' → 宿主 ContextForm 联合内的合法值(曾拟用 'memory',但其不在该联合内,
37
+ // 属类型不合法;'instructions' 语义接近"指令性内容"且类型安全,UI 按指令样式呈现)。
38
+ const PROJECTION_SOURCE = Object.freeze({
39
+ kind: "plugin",
40
+ plugin: "dsh-memory-palace",
41
+ form: "instructions",
42
+ });
43
+
44
+ // v1.7.1 判据:投影身份 = 消息首行 heading(已编码目标文件路径 + 日期)。
45
+ // 只比身份、不比正文 —— 同一文件投影过一次后,内容再变也不重注。
46
+ function projectionIdentity(msg) {
47
+ const text = msg?.content?.[0]?.text ?? "";
48
+ const i = text.indexOf("\n");
49
+ return i === -1 ? text : text.slice(0, i);
50
+ }
51
+
52
+ // 只在双方都是本插件投影时才比身份 —— 规避真实用户消息与宿主注入消息
53
+ // (runtime context / skill 目录等)因首行文本巧合而误撞。
54
+ function isOwnProjection(msg) {
55
+ return msg?.source?.kind === "plugin" && msg?.source?.plugin === "dsh-memory-palace";
56
+ }
57
+
58
+ function sameProjectionIdentity(left, right) {
59
+ return isOwnProjection(left) && isOwnProjection(right)
60
+ && projectionIdentity(left) === projectionIdentity(right);
61
+ }
62
+
63
+ // 扫描当前 surface,判断该身份是否已经作为本插件消息存在于会话历史中。
64
+ // 存在 → 不重复注入("同一文件只注一次"靠它实现)。
65
+ // compaction 把消息移出 surface 后即判为缺失 → 自动重注磁盘最新版
66
+ // (身份不变、正文刷新,故无需 injectedSessionIds 之类的标记机制)。
67
+ function alreadyOnSurface(session, desired) {
68
+ const nodes = session?.surface?.nodes;
69
+ if (!Array.isArray(nodes)) return false;
70
+ for (const seq of nodes) {
71
+ const event = typeof session.eventAt === "function" ? session.eventAt(seq) : undefined;
72
+ if (event?.type === "user/message" && sameProjectionIdentity(event.data, desired)) return true;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ // 构造一条投影消息;内容为空(文件不存在 / 无记忆)时返回 null。
78
+ function buildProjection(text, heading) {
79
+ if (!text) return null;
80
+ return createUserMessage({
81
+ content: [{ type: "text", text: `${heading}\n${text}` }],
82
+ source: PROJECTION_SOURCE,
83
+ });
84
+ }
85
+
86
+ /**
87
+ * 收集本步要投影的消息(用户级 / 项目级 MEMORY.md / 今日工作日志,各自独立一条)。
88
+ * 拆成多条独立消息(v1.7.0 定案,v1.7.1 扩展):否则"某一条内容一改,其余连带重发"。
89
+ * @param {{ getConfig: () => object, paths: object }} deps
90
+ * @returns {object[]} 按 [用户级, 项目级…, 今日日志…] 顺序的消息数组(可能为空)
91
+ */
92
+ export function buildProjections({ getConfig, paths }) {
93
+ const cfg = getConfig();
94
+ if (!cfg.enabled) return [];
95
+ const out = [];
96
+ // 用户级 MEMORY.md
97
+ const userFile = expandHome(cfg.userMemoryPath);
98
+ const userText = readMdSync(userFile);
99
+ if (userText) {
100
+ const clipped = stripSmartTag(stripDeletedLines(budgetClip(userText, cfg.userBudgetChars)));
101
+ const msg = buildProjection(clipped, `# 用户级记忆 (${toHomeShort(userFile)})`);
102
+ if (msg) out.push(msg);
103
+ }
104
+ // 项目级 MEMORY.md:按 readDirs 顺序(dsh 优先叠加 buddy),同一文件路径只取一次。
105
+ const seen = new Set();
106
+ for (const dir of paths.readDirs()) {
107
+ for (const file of paths.memoryReadCandidates(dir)) {
108
+ if (seen.has(file)) continue;
109
+ seen.add(file);
110
+ const text = readMdSync(file);
111
+ if (!text) continue;
112
+ const clipped = stripSmartTag(stripDeletedLines(budgetClip(text, cfg.workspaceBudgetChars)));
113
+ const msg = buildProjection(clipped, `# 项目级记忆 (${toHomeShort(file)})`);
114
+ if (msg) out.push(msg);
115
+ }
116
+ }
117
+ // 今日工作日志(v1.7.1 由 systemPrompt.section 迁入)。heading 沿用原 section 格式,
118
+ // 天然含日期与目录 → 跨天 / 换目录自动成为新身份。预算沿用 workspaceBudgetChars,
119
+ // 与项目级 MEMORY.md 各自独立 budgetClip,互不挤占。
120
+ for (const dir of paths.readDirs()) {
121
+ const text = readMdSync(join(dir, `${todayISO()}.md`));
122
+ if (!text) continue;
123
+ const clipped = stripSmartTag(stripDeletedLines(budgetClip(text, cfg.workspaceBudgetChars)));
124
+ const msg = buildProjection(clipped, `# 今日工作日志 (${todayISO()} @ ${toHomeShort(dir)})`);
125
+ if (msg) out.push(msg);
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * 注册 E 投影钩子。
132
+ * @param {{ ctx: object, getConfig: () => object, paths: object }} deps
133
+ */
134
+ export function registerProjection({ ctx, getConfig, paths }) {
135
+ ctx.on("agent/pre-step", async ({ agent, messages, step, signal }, next) => {
136
+ // 先让瀑布流继续,拿到宿主与其他插件合成后的决策——投影要折叠进它的 messages。
137
+ const decision = await next();
138
+ try {
139
+ signal?.throwIfAborted?.();
140
+ if (decision.kind !== "enter") return decision;
141
+ const session = agent?.session;
142
+ const desired = buildProjections({ getConfig, paths });
143
+ if (!desired.length) return decision;
144
+ // 逐条去重(按**文件身份**、非内容):已被本步 messages 覆盖、或已在 surface 上的,跳过。
145
+ const pending = desired.filter((msg) =>
146
+ !messages.some((m) => sameProjectionIdentity(m, msg))
147
+ && !decision.messages.some((m) => sameProjectionIdentity(m, msg))
148
+ && !alreadyOnSurface(session, msg));
149
+ if (!pending.length) return decision;
150
+ // 折叠位置:紧跟在"本步被认领的消息"之后,使直接提示在前、驱动追加的 runtime context 在后。
151
+ // 照抄 agent-instructions:lastClaimedIndex 可能为 -1(本步无认领消息),此时插到最前。
152
+ const lastClaimedIndex = decision.messages.findLastIndex((m) => messages.includes(m));
153
+ const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, ...pending);
154
+ return { ...decision, messages: entered };
155
+ } catch (error) {
156
+ // 🔴 绝不冒泡:pre-step 抛错会让整个 turn 失败(宿主契约,见文件头)。
157
+ // 契约失效时表现为"记忆正文不注入",这行 warn 是唯一排查线索。
158
+ console.error(
159
+ `[memory-palace] projection skipped (step ${step}): ${error?.message || String(error)}`,
160
+ );
161
+ return decision;
162
+ }
163
+ });
164
+ }
package/lib/tools.mjs CHANGED
@@ -1,15 +1,15 @@
1
1
  // memory-palace 记忆工具:memory_note / memory_note_user / memory_read / memory_delete
2
2
  // + 删除硬确认闸门(tools/pre-execute → harness 原生确认弹窗)。
3
- // 经 registerTools 注入 ctx/config/paths/records/运行时状态;在 index.mjs 装配时调用一次。
3
+ // 经 registerTools 注入 ctx/config/paths/运行时状态;在 index.mjs 装配时调用一次。
4
4
  import { defineTool } from "@deepseek-ai/dsh-tools";
5
5
  import { join } from "node:path";
6
- import { todayISO, expandHome, toHomeShort, readMdSync } from "./common/text.mjs";
6
+ import { todayISO, isoDaysAgo, expandHome, toHomeShort, readMdSync, budgetClip } from "./common/text.mjs";
7
7
  import { appendLineDedup, findMatches, removeLineByMatch, recentLogDates } from "./common/records.mjs";
8
8
 
9
9
  /**
10
- * @param {{ ctx: object, getConfig: () => object, paths: object, records: object, state: object }} deps
10
+ * @param {{ ctx: object, getConfig: () => object, paths: object, state: object }} deps
11
11
  */
12
- export function registerTools({ ctx, getConfig, paths, records, state }) {
12
+ export function registerTools({ ctx, getConfig, paths, state }) {
13
13
  const cfg = () => getConfig();
14
14
 
15
15
  // 删除作用域的中文标签。
@@ -162,18 +162,34 @@ export function registerTools({ ctx, getConfig, paths, records, state }) {
162
162
  }),
163
163
  );
164
164
 
165
- // ---------- 可选工具:读取全部记忆(用户级 + 项目级 MEMORY.md 与每日日志) ----------
166
- // AI 说"读取项目记忆/看看记忆"时直接调用,返回聚合内容,避免 AI 自己翻文件、只找 MEMORY.md 而漏掉每日日志。
165
+ // ---------- 可选工具:读取记忆(scope 控制范围,默认只读 durable 长期记忆) ----------
166
+ // AI 说"读取项目记忆/看看记忆"时直接调用,返回聚合内容,避免 AI 自己翻文件、只找 MEMORY.md 而漏掉项目级记忆。
167
+ // v1.7.1:加 scope(memory 默认 / project / today / yesterday / daily / all)—— 当日日志实测
168
+ // 可达 1.7 万字符,默认值刻意不含它;确实需要日志时必须显式传 scope。
169
+ // project 原名 node —— 为与 memory_write / memory_update_section 的 scope='project' 统一而更名,消除读写两侧命名歧义。
167
170
  ctx.tools.register(
168
171
  defineTool({
169
172
  name: "memory_read",
170
173
  description:
171
- "Read all persistent memory: user-level MEMORY.md (cross-project preferences), " +
172
- "and for the current project the workspace MEMORY.md plus recent daily logs " +
173
- "(YYYY-MM-DD.md) from .workbuddy/memory, .codebuddy/memory, or .deepseek-harness/memory. " +
174
- "Use this instead of manually globbing/reading memory files — it aggregates everything. " +
175
- "This tool takes no parameters; call it with an empty object, e.g. tools.memory_read({}) — the runtime rejects undefined arguments.",
176
- parameters: {},
174
+ "Read persistent memory. scope defaults to 'memory' and decides what comes back: " +
175
+ "'memory' (default) = user-level MEMORY.md (cross-project preferences) + this project's workspace MEMORY.md; " +
176
+ "'project' = this project's workspace MEMORY.md only (same naming as the write-side scope of memory_write / memory_update_section); " +
177
+ "'today' / 'yesterday' = that single day's daily log; " +
178
+ "'daily' = the last three days of logs (today + yesterday + the day before); " +
179
+ "'all' = memory + daily (large — only when the logs are genuinely needed). " +
180
+ "Start from the default scope and widen it only when the question is really about logs. " +
181
+ "Use this instead of manually globbing/reading memory files. " +
182
+ "Call it as tools.memory_read({}) or tools.memory_read({ scope: 'daily' }) — the runtime rejects undefined arguments.",
183
+ parameters: {
184
+ scope: {
185
+ type: "string",
186
+ enum: ["memory", "project", "today", "yesterday", "daily", "all"],
187
+ description:
188
+ "What to read. 'memory' (default) = user-level + workspace MEMORY.md (durable long-term memory; usually all you need). " +
189
+ "'project' = workspace MEMORY.md only (same value as the write-side scope of memory_write). 'today' / 'yesterday' = that single day's log. " +
190
+ "'daily' = last three days of logs. 'all' = memory + daily.",
191
+ },
192
+ },
177
193
  output: {
178
194
  schema: {
179
195
  type: "object",
@@ -186,28 +202,50 @@ export function registerTools({ ctx, getConfig, paths, records, state }) {
186
202
  },
187
203
  render: (_args, value) => [{ type: "text", text: value?.memory ?? value?.message ?? "No memory." }],
188
204
  },
189
- async execute() {
205
+ async execute(args = {}) {
190
206
  const c = cfg();
191
207
  if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings.", memory: "" };
208
+ // v1.7.1:默认 scope='memory'(长期记忆)。刻意不设 'all' —— 默认全量会让 agent
209
+ // 不加思考地把日志(实测单日可达 1.7 万字符)一起吞下,白白吃掉上下文。
210
+ const scope = args?.scope ?? "memory";
211
+ const wantUser = scope === "memory" || scope === "all";
212
+ const wantProject = scope === "memory" || scope === "project" || scope === "all";
213
+ // 日志按**日期**取(而非"最近 N 份文件"——跨日跳空会取偏):today / yesterday / daily(近三天) / all
214
+ const logDays =
215
+ scope === "today" ? [0]
216
+ : scope === "yesterday" ? [1]
217
+ : scope === "daily" || scope === "all" ? [0, 1, 2]
218
+ : [];
192
219
  try {
193
220
  const blocks = [];
194
- const userFile = expandHome(c.userMemoryPath);
195
- const userFileShort = toHomeShort(userFile);
196
- const u = readMdSync(userFile);
197
- if (u) blocks.push(`# 用户级记忆 (${userFileShort})\n${u}`);
221
+ if (wantUser) {
222
+ const userFile = expandHome(c.userMemoryPath);
223
+ const u = readMdSync(userFile);
224
+ if (u) blocks.push(`# 用户级记忆 (${toHomeShort(userFile)})\n${u}`);
225
+ }
198
226
  for (const dir of paths.readDirs()) {
199
227
  const dirShort = toHomeShort(dir);
200
- const w = paths.memoryReadCandidates(dir).map(readMdSync).filter(Boolean).join("\n\n");
201
- if (w) blocks.push(`# 工作区记忆 (${dirShort})\n${w}`);
202
- const t = readMdSync(join(dir, `${todayISO()}.md`));
203
- if (t) blocks.push(`# 今日工作日志 (${todayISO()} @ ${dirShort})\n${t}`);
204
- const recent = await records.recentLogs(dir);
205
- if (recent) blocks.push(recent);
228
+ if (wantProject) {
229
+ // 标题必须用实际读到的 MEMORY.md 路径,而非日志目录 —— dsh 布局下 MEMORY.md 位于
230
+ // 日志目录的上一级,旧实现拿 dir(.../memory)当标题会误导真实路径(buddy 布局
231
+ // MEMORY.md 恰在 memory/ 内所以未暴露)。与 E 投影的 per-file 粒度对齐。
232
+ for (const f of paths.memoryReadCandidates(dir)) {
233
+ const t = readMdSync(f);
234
+ if (t) blocks.push(`# 工作区记忆 (${toHomeShort(f)})\n${t}`);
235
+ }
236
+ }
237
+ for (const n of logDays) {
238
+ const day = isoDaysAgo(n);
239
+ const t = readMdSync(join(dir, `${day}.md`));
240
+ if (!t) continue;
241
+ const label = n === 0 ? "今日" : n === 1 ? "昨日" : "前日";
242
+ blocks.push(`# ${label}工作日志 (${day} @ ${dirShort})\n${budgetClip(t, c.workspaceBudgetChars)}`);
243
+ }
206
244
  }
207
245
  if (!blocks.length) {
208
- return { ok: true, message: "No memory files found yet.", memory: "" };
246
+ return { ok: true, message: `No memory files found (scope=${scope}).`, memory: "" };
209
247
  }
210
- return { ok: true, message: "Memory loaded.", memory: blocks.join("\n\n") };
248
+ return { ok: true, message: `Memory loaded (scope=${scope}).`, memory: blocks.join("\n\n") };
211
249
  } catch (e) {
212
250
  return { ok: false, message: `Failed: ${e}`, memory: "" };
213
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-palace",
3
- "version": "1.6.3",
3
+ "version": "1.7.1",
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",