dsh-memory-palace 1.6.3 → 1.7.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 +34 -29
- package/lib/common/paths.mjs +11 -5
- package/lib/common/prompts.mjs +1 -1
- package/lib/common/sections.mjs +14 -0
- package/lib/hybrid/prompts.mjs +18 -1
- package/lib/hybrid/subagent.mjs +22 -5
- package/lib/index.js +29 -32
- package/lib/projection.mjs +132 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
把 WorkBuddy 的文件式记忆系统移植进 [DeepSeek Harness](https://www.deepseek.com/harness/) —— 为 Harness 提供**跨会话持久化、人类可直接编辑的 Markdown 记忆**。
|
|
4
4
|
|
|
5
|
-
[](./LICENSE) [](https://awesome-dsh-plugin.com) [](./LICENSE) [](https://awesome-dsh-plugin.com) [](https://www.npmjs.com/package/dsh-memory-palace) [](https://github.com/deepseek-ai/deepseek-harness)
|
|
6
6
|
|
|
7
7
|
## 特性
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
- **WorkBuddy / CodeBuddy
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
9
|
+
- **人类可读的真源**:记忆以纯 Markdown 存储,任何编辑器都能直接修改,数据始终属于你。
|
|
10
|
+
- **双层记忆**:用户级(跨项目个人偏好)+ 工作区级(项目约定),互不干扰。
|
|
11
|
+
- **日志自动沉淀**:超过保留期的每日日志自动并入长期记忆,结论不丢。
|
|
12
|
+
- **WorkBuddy / CodeBuddy 桥接**:项目已有对应记忆目录时直接读写,无需重复维护。
|
|
13
|
+
- **记忆工具**:AI 可主动写入、聚合读取、按内容删除(删除需人工确认),写入自带去重。
|
|
14
|
+
- **手动蒸馏**:会话标题栏一键把当前对话或项目记忆提炼成长期记忆。
|
|
15
|
+
- **设置页集成**:全部配置均可在 DSH 设置面板中图形化调整,无需改配置文件。
|
|
16
|
+
- **主动记忆(插件模式)**:以指令引导 AI 在完成任务、修复问题、确定决策、获知偏好时主动落档。
|
|
17
|
+
- **智能模式**:由模型自动提炼每轮新增内容,摘要进日志、长期事实进记忆。
|
|
18
|
+
- **记忆注入**:长期记忆全程常驻、每个步骤都可见;今日日志按轮次注入,避免上下文膨胀。
|
|
19
|
+
- **混合模式(🔥推荐)**:子代理每轮自动整理日志并去重,长期记忆由主 AI 主动维护,兼顾自动化与可控性。
|
|
20
|
+
- **标准插件包**:经官方插件机制一键安装,无需改动 harness。
|
|
21
21
|
|
|
22
22
|
## 记忆文件布局
|
|
23
23
|
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
└── MEMORY.md # 用户级记忆(跨项目个人偏好)
|
|
27
27
|
|
|
28
28
|
<项目根>/
|
|
29
|
-
├── .workbuddy/memory/ # 桥接 WorkBuddy
|
|
29
|
+
├── .workbuddy/memory/ # 桥接 WorkBuddy 记忆(存在时优先写入)
|
|
30
30
|
│ ├── MEMORY.md # 项目级约定(buddy 目录保持嵌套,兼容 WB/CB 原生格式)
|
|
31
31
|
│ └── 2026-08-16.md # 每日工作日志
|
|
32
|
-
├── .codebuddy/memory/ # 桥接 CodeBuddy
|
|
33
|
-
└── .deepseek-harness/ #
|
|
32
|
+
├── .codebuddy/memory/ # 桥接 CodeBuddy 记忆(存在时,结构同 .workbuddy)
|
|
33
|
+
└── .deepseek-harness/ # dsh 原生目录(读取恒在首位;无 buddy 时作为写入目标创建)
|
|
34
34
|
├── MEMORY.md # 项目级约定(长期记忆,与 memory/ 同级)
|
|
35
35
|
└── memory/
|
|
36
36
|
└── 2026-08-16.md # 每日工作日志
|
|
@@ -38,26 +38,25 @@
|
|
|
38
38
|
|
|
39
39
|
## 工作原理
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
**读取(每轮对话)**——两条通道并行:
|
|
42
42
|
|
|
43
43
|
```
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
+ 今日日志 YYYY-MM-DD.md
|
|
47
|
-
→ 注入 system prompt,让 AI 跨会话保持一致
|
|
44
|
+
① 长期记忆:全程常驻,每个步骤都可见;内容未变只写一次,压缩后自动重注
|
|
45
|
+
② 今日日志:随系统提示词按轮次注入(高频变化,不进对话历史以免膨胀)
|
|
48
46
|
```
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
**写入(每轮结束)**——经「防闲聊闸门」判定后异步追加:
|
|
51
49
|
|
|
52
50
|
```
|
|
53
|
-
|
|
54
|
-
│
|
|
55
|
-
|
|
56
|
-
├─►
|
|
57
|
-
|
|
58
|
-
└─► prune:超过 dailyLogRetentionDays 的日志蒸馏进 MEMORY.md 后删除
|
|
51
|
+
本轮结束 ──► 闸门:有工具调用 / 有错误 / AI 主动记 / 命中偏好·决策关键词 → 放行
|
|
52
|
+
│ 否则跳过(不写、不调模型)
|
|
53
|
+
├─► 摘要写入今日日志
|
|
54
|
+
├─► 出错且开启「自动记录错误」→ 错误现象写入长期记忆
|
|
55
|
+
└─► 超期日志并入长期记忆后删除
|
|
59
56
|
```
|
|
60
57
|
|
|
58
|
+
日志文件头统一为日期标题,写入时自动补齐(历史文件不回填)。
|
|
59
|
+
|
|
61
60
|
**工具**——AI 在对话中按需调用:
|
|
62
61
|
|
|
63
62
|
| 工具 | 层级 | 作用 |
|
|
@@ -78,7 +77,7 @@ turn/end ──► 轻量兜底闸门
|
|
|
78
77
|
|
|
79
78
|
```bash
|
|
80
79
|
dsh plugin --profile web add github:lovezi0/dsh-memory-palace
|
|
81
|
-
# 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.
|
|
80
|
+
# 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.7.0
|
|
82
81
|
```
|
|
83
82
|
|
|
84
83
|
方式二:clone 后本地安装(开发 / 修改源码场景)
|
|
@@ -119,6 +118,12 @@ dsh plugin --profile web remove dsh-memory-palace
|
|
|
119
118
|
|
|
120
119
|
## 版本历史
|
|
121
120
|
|
|
121
|
+
- **1.7.0**
|
|
122
|
+
- **1.7.0.alpha.1**
|
|
123
|
+
- 🔥新增独立通道注入记忆文件
|
|
124
|
+
- 🐛修复存在多种记忆路径时读取冲突的问题
|
|
125
|
+
- 💪优化记忆子 agent 投影消息节省 token
|
|
126
|
+
- 💪优化 SUBAGENT_SYSTEM 记忆输出格式
|
|
122
127
|
- **1.6.3**
|
|
123
128
|
- npm publish
|
|
124
129
|
- **1.6.3.alpha.4**
|
package/lib/common/paths.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
28
|
-
if (b.length) return b;
|
|
32
|
+
const out = [];
|
|
29
33
|
const d = cwd ? join(cwd, c.workspaceMemoryDir) : null;
|
|
30
|
-
|
|
31
|
-
|
|
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 [];
|
package/lib/common/prompts.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export function SUMMARY_PROMPT({ allowDelete = false, outputBudget } = {}) {
|
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
//
|
|
45
|
+
// 项目记忆蒸馏 prompt(供按钮「蒸馏项目记忆」稳定调用)。
|
|
46
46
|
// 用法:system = 本 prompt,user = 项目 MEMORY.md 全文,输出 = 精炼后的项目记忆(直接覆盖写回)。
|
|
47
47
|
export function DISTILL_PROMPT({ outputBudget } = {}) {
|
|
48
48
|
return (
|
package/lib/common/sections.mjs
CHANGED
|
@@ -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) {
|
package/lib/hybrid/prompts.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// memory-palace v1.6.0 hybrid 模式提示词:注入段二(HYBRID_PROACTIVE)+ 记忆子 agent
|
|
2
2
|
// system prompt(SUBAGENT_SYSTEM)。纯常量,无状态。
|
|
3
3
|
//
|
|
4
|
-
//
|
|
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。"
|
package/lib/hybrid/subagent.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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,
|
|
227
|
+
await writeFile(file, withHeader, "utf8");
|
|
211
228
|
}
|
|
212
229
|
all.push(...results);
|
|
213
230
|
}
|
package/lib/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import { join } from "node:path";
|
|
|
26
26
|
import Schema from "@deepseek-ai/schemastery";
|
|
27
27
|
// v1.6.0-rc1:适配 DSH 0.1.2-alpha.2——dsh-settings 不再导出自由函数 installSettingsSection /
|
|
28
28
|
// settingsNamespace,设置节注册改用 SettingsProvider 实例方法 installSection(经 ctx.inject(["settings"]))。
|
|
29
|
-
import { todayISO,
|
|
29
|
+
import { todayISO, toHomeShort, readMdSync, budgetClip, stripSmartTag, stripDeletedLines, extractText, extractToolErrorText } from "./common/text.mjs";
|
|
30
30
|
import { SCENE_KEYWORDS } from "./common/prompts.mjs";
|
|
31
31
|
import { createPaths } from "./common/paths.mjs";
|
|
32
32
|
import { createRecords } from "./common/records.mjs";
|
|
@@ -35,6 +35,7 @@ import { createDistill } from "./distill.mjs";
|
|
|
35
35
|
import { registerTools } from "./tools.mjs";
|
|
36
36
|
import { registerApi } from "./api.mjs";
|
|
37
37
|
import { registerHybrid, HYBRID_PROACTIVE } from "./hybrid/index.mjs";
|
|
38
|
+
import { registerProjection } from "./projection.mjs";
|
|
38
39
|
|
|
39
40
|
export const name = "memory-palace";
|
|
40
41
|
// v1.6.0-rc1:settingsNamespace() 已删除,namespace 直接用字符串(宿主侧做类型级校验)。
|
|
@@ -125,9 +126,8 @@ export function apply(ctx, config) {
|
|
|
125
126
|
lastSummarizedSeq: -1, // 智能模式增量摘要断点(session 事件 seq)
|
|
126
127
|
summarySessionId: null,
|
|
127
128
|
planModeActive: false, // 计划模式:禁写记忆(硬编码默认,无开关;仅经 session/event 的 plan/mode 翻转;未装 dsh-plan-mode 永不触发=不拦截)
|
|
128
|
-
// v1.
|
|
129
|
-
//
|
|
130
|
-
injectedSessionIds: new Set(),
|
|
129
|
+
// v1.7.0:injectedSessionIds 已删除——记忆正文改经 E 投影(src/projection.mjs)注入为
|
|
130
|
+
// 常驻消息,compaction 后由投影自动重注,不再需要"仅首次注入"的会话级标记。
|
|
131
131
|
};
|
|
132
132
|
const TURN_BUFFER_MAX = 30;
|
|
133
133
|
// v1.1.3:字符上限提升到 30k(长工具型 request 的工具结果动辄数千字符,6k 上限会触发
|
|
@@ -149,6 +149,9 @@ export function apply(ctx, config) {
|
|
|
149
149
|
// 运行时分派靠 _settle / proactive 的热 source() 判断;工具注册无副作用(plugin/smart
|
|
150
150
|
// 的 prompt 不引导使用,guards 白名单只拦 hybrid 工具)。
|
|
151
151
|
const hybrid = registerHybrid({ ctx, getConfig: () => source(), paths, records, state });
|
|
152
|
+
// v1.7.0 特性1:E 投影通道(记忆正文常驻注入)。与 hybrid 同理,无条件注册——
|
|
153
|
+
// 其内部读热配置,无副作用(enabled=false 时 buildProjections 返回空)。
|
|
154
|
+
registerProjection({ ctx, getConfig: () => source(), paths });
|
|
152
155
|
|
|
153
156
|
// ---- session/event 跟踪:更新 activeCwd/activeSession/增量断点 + 累积 turnBuffer ----
|
|
154
157
|
// 关键:一次用户请求在 dsh agent 循环里会被拆成多个 turn(每个工具调用一轮),
|
|
@@ -170,6 +173,15 @@ export function apply(ctx, config) {
|
|
|
170
173
|
return;
|
|
171
174
|
}
|
|
172
175
|
if (type === "user/message" || type === "assistant/message" || type === "tool/result") {
|
|
176
|
+
// v1.7.0 特性1 连带修复:turnBuffer 只采集【真实用户消息】。
|
|
177
|
+
// E 投影的 user/message(source.kind === 'plugin')也在 SURFACE 里,若不过滤会被
|
|
178
|
+
// 采集进 buffer,并触发下方 `type === "user/message" && state.turnBuffer.length` 的
|
|
179
|
+
// _flushTurn() —— 打乱 request 边界、污染关键词闸门(结算时取第一条 user 文本)。
|
|
180
|
+
// 判定用【黑名单】而非白名单(fail-open):只在 source 明确存在且非 'user' 时排除。
|
|
181
|
+
// 理由:source 缺失时若按"非 user"丢弃,真实用户消息会被漏掉 → 闸门关闭 → 记忆整体
|
|
182
|
+
// 丢失(本项目历史上最严重的一类 bug)。注入类消息在 DSH 中必带 source,故放行风险极低。
|
|
183
|
+
const srcKind = event?.data?.source?.kind;
|
|
184
|
+
if (type === "user/message" && srcKind && srcKind !== "user") return;
|
|
173
185
|
const text = extractText(event);
|
|
174
186
|
if (text) {
|
|
175
187
|
const role = type === "tool/result" ? "tool" : type === "user/message" ? "user" : "assistant";
|
|
@@ -194,48 +206,33 @@ export function apply(ctx, config) {
|
|
|
194
206
|
// 不清空 buffer;用 debounce 在整段请求安静后结算一次。
|
|
195
207
|
_scheduleSettle();
|
|
196
208
|
} else if (type === "compaction" || type?.startsWith?.("compaction/")) {
|
|
197
|
-
// v1.
|
|
198
|
-
//
|
|
199
|
-
if (session?.id) state.injectedSessionIds.delete(session.id);
|
|
209
|
+
// v1.7.0:无需再清"已注入标记"(该机制已删)——记忆正文改经 E 投影注入,
|
|
210
|
+
// compaction 把投影消息移出 surface 后,后续 step 的去重扫描找不到它 → 自动重注。
|
|
200
211
|
}
|
|
201
212
|
});
|
|
202
213
|
|
|
203
214
|
// ---------- 读取:同步 section text(systemPrompt 要求同步) ----------
|
|
215
|
+
// v1.7.0:section 只保留「常量指令(intro)+ 今日工作日志」。
|
|
216
|
+
// 用户级 / 项目级 MEMORY.md 已移出 section,改由 E 投影(src/projection.mjs)承载为
|
|
217
|
+
// 常驻消息(每个 step 可见)。分流依据是「变化频率」:MEMORY.md 低频 durable → 投影;
|
|
218
|
+
// 今日日志高频易变 → 留 section 每步注入(不进历史以免膨胀)。
|
|
204
219
|
ctx.systemPrompt.section({
|
|
205
220
|
name: "memory-palace",
|
|
206
221
|
order: 50,
|
|
207
222
|
text: () => {
|
|
208
223
|
const cfg = source();
|
|
209
224
|
if (!cfg.enabled) return "";
|
|
210
|
-
const userFileResolved = expandHome(cfg.userMemoryPath);
|
|
211
|
-
const userFileShort = toHomeShort(userFileResolved);
|
|
212
225
|
const blocks = [];
|
|
213
|
-
//
|
|
214
|
-
//
|
|
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)))}`);
|
|
226
|
+
// 今日工作日志:每 step 注入,预算 workspaceBudgetChars(与项目 MEMORY.md 同字段,
|
|
227
|
+
// 但各自独立 budgetClip 截断,互不挤占)。v1.4.1 的 budgetClip 已是"结构行 + 尾部最新"。
|
|
220
228
|
for (const dir of paths.readDirs()) {
|
|
221
229
|
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
230
|
const t = readMdSync(join(dir, `${todayISO()}.md`));
|
|
225
231
|
if (t) blocks.push(`# 今日工作日志 (${todayISO()} @ ${dirShort})\n${stripSmartTag(stripDeletedLines(budgetClip(t, cfg.workspaceBudgetChars)))}`);
|
|
226
232
|
}
|
|
227
|
-
// v1.
|
|
228
|
-
//
|
|
229
|
-
//
|
|
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 将不知道记忆系统存在、不会主动记,形成「无记忆 → 无指令 → 永不记」的死循环。
|
|
233
|
+
// v1.7.0:alreadyInjected / injectedSessionIds 已整体删除(记忆正文改走 E 投影)。
|
|
234
|
+
// intro(含记忆公民指令)仍【始终】注入——若因记忆为空而整体返回空串,agent 将不知道
|
|
235
|
+
// 记忆系统存在、不会主动记,形成「无记忆 → 无指令 → 永不记」死循环。
|
|
239
236
|
const bridged = paths.buddyDirs().length > 0;
|
|
240
237
|
const antiMangle =
|
|
241
238
|
"提及记忆文件路径时一律用 ~ 简写(如 ~/.deepseek-harness/MEMORY.md),不要逐字拼写绝对路径——你转述绝对路径容易漏掉目录分隔符。";
|
|
@@ -266,8 +263,8 @@ export function apply(ctx, config) {
|
|
|
266
263
|
? "\n\n[plan 模式] 当前处于 plan 模式,不要调用 memory_note / memory_note_user 写入记忆,也不要请求删除记忆(读取记忆用 memory_read,仍可用)。"
|
|
267
264
|
: "";
|
|
268
265
|
const introFull = intro + planNote;
|
|
269
|
-
// v1.
|
|
270
|
-
//
|
|
266
|
+
// v1.7.0:blocks 仅含今日日志(用户级/项目级 MEMORY.md 已移出,改由 E 投影注入)。
|
|
267
|
+
// 返回值永非空串(只含 intro 时亦然),避免 harness 对空 section 的处理歧义。
|
|
271
268
|
return blocks.length ? [introFull, ...blocks].join("\n\n") : introFull;
|
|
272
269
|
},
|
|
273
270
|
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// memory-palace v1.7.0 特性1:E 投影 —— 记忆正文的独立注入通道。
|
|
2
|
+
//
|
|
3
|
+
// 背景(v1.6.3 及以前):用户级 + 项目级 MEMORY.md 走 systemPrompt.section,受
|
|
4
|
+
// 「仅首次注入」限制(`alreadyInjected` 打了标记就不撕,只有 compaction 清),
|
|
5
|
+
// 导致记忆正文在 turn 1 的 step 2 之后就消失、全程不再进场。
|
|
6
|
+
//
|
|
7
|
+
// 现在:两份 MEMORY.md 改经 `agent/pre-step` 投影为**独立 user 消息**,常驻对话历史:
|
|
8
|
+
// - 每个 step 都可见(消息在历史里,不依赖注入时机);
|
|
9
|
+
// - 内容未变只写一次(进历史后后续 step 的 decision.messages 已含它 → 去重命中);
|
|
10
|
+
// - compaction 把消息移出 surface 后,后续 step 找不到 → 自动重注(白送)。
|
|
11
|
+
//
|
|
12
|
+
// 实现照抄宿主 `packages/context/agent-instructions/src/index.ts` 三件套(API 全公开、无第一方特权):
|
|
13
|
+
// 1. `inject: ['sessionProjections']`(服务随 dsh-base 默认装载)
|
|
14
|
+
// 2. `ctx.on('agent/pre-step', async ({agent, messages, step, signal}, next) => {...})`
|
|
15
|
+
// 3. `createUserMessage()`(来自 @deepseek-ai/dsh-llm)
|
|
16
|
+
// 投递方式:同步版——折叠进 `decision.messages`(`toSpliced(lastClaimedIndex + 1, 0, desired)`),
|
|
17
|
+
// 不做异步 inbox 刷新(agent-instructions 注释明写异步投影有两个 commit 边界的时序约束,
|
|
18
|
+
// 本版刻意回避;记忆更新后下一步自然带上新内容)。
|
|
19
|
+
//
|
|
20
|
+
// 🔴 契约风险:pre-step 监听器抛错会让**整个 turn 失败**(宿主测试
|
|
21
|
+
// packages/acp/acp/tests/turns.spec.ts:150-156 锚定),故钩子内整体 try-catch,
|
|
22
|
+
// 任何异常降级为「本步不投影」并留一行 stderr 线索,绝不冒泡。详见 DEVELOPMENT.md §7.4。
|
|
23
|
+
import { isDeepStrictEqual } from "node:util";
|
|
24
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
25
|
+
import { expandHome, toHomeShort, readMdSync, budgetClip, stripSmartTag, stripDeletedLines } from "./common/text.mjs";
|
|
26
|
+
|
|
27
|
+
// 投影消息的 source 标注。
|
|
28
|
+
// - kind: 'plugin' → 明确标注"插件注入",供下游按 source 过滤(子 agent 去噪 / turnBuffer 采集)。
|
|
29
|
+
// - form: 'instructions' → 宿主 ContextForm 联合内的合法值(曾拟用 'memory',但其不在该联合内,
|
|
30
|
+
// 属类型不合法;'instructions' 语义接近"指令性内容"且类型安全,UI 按指令样式呈现)。
|
|
31
|
+
const PROJECTION_SOURCE = Object.freeze({
|
|
32
|
+
kind: "plugin",
|
|
33
|
+
plugin: "dsh-memory-palace",
|
|
34
|
+
form: "instructions",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// 同一份内容是否已在这批消息里(逐字段深比较,照抄 agent-instructions 的 sameContextPayload)。
|
|
38
|
+
function sameContextPayload(left, right) {
|
|
39
|
+
return isDeepStrictEqual(left.content, right.content)
|
|
40
|
+
&& isDeepStrictEqual(left.source, right.source);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 扫描当前 surface,判断该 payload 是否已经作为本插件消息存在于会话历史中。
|
|
44
|
+
// 存在 → 不重复注入("内容不变只写一次"靠它实现)。
|
|
45
|
+
function alreadyOnSurface(session, desired) {
|
|
46
|
+
const nodes = session?.surface?.nodes;
|
|
47
|
+
if (!Array.isArray(nodes)) return false;
|
|
48
|
+
for (const seq of nodes) {
|
|
49
|
+
const event = typeof session.eventAt === "function" ? session.eventAt(seq) : undefined;
|
|
50
|
+
if (event?.type === "user/message" && sameContextPayload(event.data, desired)) return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 构造一条投影消息;内容为空(文件不存在 / 无记忆)时返回 null。
|
|
56
|
+
function buildProjection(text, heading) {
|
|
57
|
+
if (!text) return null;
|
|
58
|
+
return createUserMessage({
|
|
59
|
+
content: [{ type: "text", text: `${heading}\n${text}` }],
|
|
60
|
+
source: PROJECTION_SOURCE,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 收集本步要投影的记忆消息(用户级 + 项目级,各自独立一条)。
|
|
66
|
+
* 两者拆成两条独立消息(v1.7.0 定案):否则"项目记忆一改,用户记忆连带重发"。
|
|
67
|
+
* @param {{ getConfig: () => object, paths: object }} deps
|
|
68
|
+
* @returns {object[]} 按 [用户级, 项目级…] 顺序的消息数组(可能为空)
|
|
69
|
+
*/
|
|
70
|
+
export function buildProjections({ getConfig, paths }) {
|
|
71
|
+
const cfg = getConfig();
|
|
72
|
+
if (!cfg.enabled) return [];
|
|
73
|
+
const out = [];
|
|
74
|
+
// 用户级 MEMORY.md
|
|
75
|
+
const userFile = expandHome(cfg.userMemoryPath);
|
|
76
|
+
const userText = readMdSync(userFile);
|
|
77
|
+
if (userText) {
|
|
78
|
+
const clipped = stripSmartTag(stripDeletedLines(budgetClip(userText, cfg.userBudgetChars)));
|
|
79
|
+
const msg = buildProjection(clipped, `# 用户级记忆 (${toHomeShort(userFile)})`);
|
|
80
|
+
if (msg) out.push(msg);
|
|
81
|
+
}
|
|
82
|
+
// 项目级 MEMORY.md:按 readDirs 顺序(dsh 优先叠加 buddy),同一文件路径只取一次。
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const dir of paths.readDirs()) {
|
|
85
|
+
for (const file of paths.memoryReadCandidates(dir)) {
|
|
86
|
+
if (seen.has(file)) continue;
|
|
87
|
+
seen.add(file);
|
|
88
|
+
const text = readMdSync(file);
|
|
89
|
+
if (!text) continue;
|
|
90
|
+
const clipped = stripSmartTag(stripDeletedLines(budgetClip(text, cfg.workspaceBudgetChars)));
|
|
91
|
+
const msg = buildProjection(clipped, `# 项目级记忆 (${toHomeShort(file)})`);
|
|
92
|
+
if (msg) out.push(msg);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 注册 E 投影钩子。
|
|
100
|
+
* @param {{ ctx: object, getConfig: () => object, paths: object }} deps
|
|
101
|
+
*/
|
|
102
|
+
export function registerProjection({ ctx, getConfig, paths }) {
|
|
103
|
+
ctx.on("agent/pre-step", async ({ agent, messages, step, signal }, next) => {
|
|
104
|
+
// 先让瀑布流继续,拿到宿主与其他插件合成后的决策——投影要折叠进它的 messages。
|
|
105
|
+
const decision = await next();
|
|
106
|
+
try {
|
|
107
|
+
signal?.throwIfAborted?.();
|
|
108
|
+
if (decision.kind !== "enter") return decision;
|
|
109
|
+
const session = agent?.session;
|
|
110
|
+
const desired = buildProjections({ getConfig, paths });
|
|
111
|
+
if (!desired.length) return decision;
|
|
112
|
+
// 逐条去重:已被本步的 messages 覆盖、或已在 surface 上的,跳过。
|
|
113
|
+
const pending = desired.filter((msg) =>
|
|
114
|
+
!messages.some((m) => sameContextPayload(m, msg))
|
|
115
|
+
&& !decision.messages.some((m) => sameContextPayload(m, msg))
|
|
116
|
+
&& !alreadyOnSurface(session, msg));
|
|
117
|
+
if (!pending.length) return decision;
|
|
118
|
+
// 折叠位置:紧跟在"本步被认领的消息"之后,使直接提示在前、驱动追加的 runtime context 在后。
|
|
119
|
+
// 照抄 agent-instructions:lastClaimedIndex 可能为 -1(本步无认领消息),此时插到最前。
|
|
120
|
+
const lastClaimedIndex = decision.messages.findLastIndex((m) => messages.includes(m));
|
|
121
|
+
const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, ...pending);
|
|
122
|
+
return { ...decision, messages: entered };
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// 🔴 绝不冒泡:pre-step 抛错会让整个 turn 失败(宿主契约,见文件头)。
|
|
125
|
+
// 契约失效时表现为"记忆正文不注入",这行 warn 是唯一排查线索。
|
|
126
|
+
console.error(
|
|
127
|
+
`[memory-palace] projection skipped (step ${step}): ${error?.message || String(error)}`,
|
|
128
|
+
);
|
|
129
|
+
return decision;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-memory-palace",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|