sage-mem 0.4.3 → 0.5.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 +5 -2
- package/lib/index.js +72 -11
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -25,7 +25,8 @@ sage-mem 换成文件式:每条记忆一个 markdown 文件,直接用编辑
|
|
|
25
25
|
|
|
26
26
|
## 功能
|
|
27
27
|
|
|
28
|
-
-
|
|
28
|
+
- **按问题检索注入**:每次提问,插件扫记忆目录,用 frontmatter 的 `description` 和文件名做匹配,把相关记忆注入上下文,agent 第一轮就「想起来」
|
|
29
|
+
- **每次会话必读**(v0.5):frontmatter 标了 `baseline: true` 的记忆,在新会话第一步无条件注入——给「待办清单」「你是谁」这类每次开场都该在场的记忆用(上限 5 条)
|
|
29
30
|
- **四类记忆**:`user`(用户是谁)/ `feedback`(工作方式指导)/ `project`(项目状态)/ `reference`(外部信息指针)
|
|
30
31
|
- **文件透明**:记忆就是 `*.md` 文件,frontmatter 存元数据,正文存内容
|
|
31
32
|
- **防膨胀**:写入靠规则引导 agent 判断「存不存」,无实质内容、能靠代码/git 推导的不存
|
|
@@ -86,6 +87,7 @@ sage-mem 是纯 DSH 插件,在 `package.json` 的 `dsh.compatibility.dshReleas
|
|
|
86
87
|
| 0.1.2-rc.1 | compatible |
|
|
87
88
|
| 0.1.3-alpha.1 | compatible |
|
|
88
89
|
| 0.1.3-alpha.2 | compatible |
|
|
90
|
+
| 0.1.5-rc.1 | compatible |
|
|
89
91
|
|
|
90
92
|
Node.js 要求 `>= 18`(见 `engines.node`)。
|
|
91
93
|
|
|
@@ -107,9 +109,10 @@ SAGE_MEM_DIR=/path/to/your/memory
|
|
|
107
109
|
```markdown
|
|
108
110
|
---
|
|
109
111
|
name: 可选的短名
|
|
110
|
-
description:
|
|
112
|
+
description: 一句话说清这条记忆是什么(检索靠它,务必写准)
|
|
111
113
|
metadata:
|
|
112
114
|
type: user # user / feedback / project / reference
|
|
115
|
+
baseline: true # 可选:新会话第一步无条件注入(上限 5 条)
|
|
113
116
|
---
|
|
114
117
|
|
|
115
118
|
记忆正文。
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { join, basename } from 'node:path'
|
|
|
21
21
|
const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem', 'memory')
|
|
22
22
|
const MAX_RESULTS = 5
|
|
23
23
|
const MAX_CHARS_PER_FILE = 1500
|
|
24
|
+
const MAX_BASELINE = 5
|
|
24
25
|
|
|
25
26
|
/** 提取消息文本,兼容 content 为字符串或 text-block 数组两种形态。 */
|
|
26
27
|
function extractText(content) {
|
|
@@ -51,7 +52,7 @@ function score(query, text) {
|
|
|
51
52
|
return hit / q.size
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
/** 扫 memory
|
|
55
|
+
/** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
|
|
55
56
|
async function scanMemoryFiles() {
|
|
56
57
|
try {
|
|
57
58
|
const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
|
|
@@ -61,7 +62,13 @@ async function scanMemoryFiles() {
|
|
|
61
62
|
const loaded = await Promise.all(names.map(async (name) => {
|
|
62
63
|
try {
|
|
63
64
|
const content = await readFile(join(MEMORY_DIR, name), 'utf8')
|
|
64
|
-
|
|
65
|
+
const meta = parseFrontmatter(content)
|
|
66
|
+
return {
|
|
67
|
+
file: name,
|
|
68
|
+
content,
|
|
69
|
+
description: meta.description || '',
|
|
70
|
+
baseline: parseBaseline(content),
|
|
71
|
+
}
|
|
65
72
|
} catch {
|
|
66
73
|
return null
|
|
67
74
|
}
|
|
@@ -72,10 +79,22 @@ async function scanMemoryFiles() {
|
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* 相关性打分只用 frontmatter 的 description + 文件名,不扫全文。
|
|
84
|
+
*
|
|
85
|
+
* 旧版拿 query 的双字去全文里找,得分是「覆盖率」,分母是 query,不惩罚
|
|
86
|
+
* 文件长度 —— 越长的记忆越容易蒙中。实测 8 个典型问题:project_heartscape
|
|
87
|
+
* (5.9KB) 与 project_self-cognition (6.8KB) 命中 7 个,连「今天天气不错」都
|
|
88
|
+
* 注入 2300 token;而「论文写得怎么样了」该命中的 project_position-paper
|
|
89
|
+
* 反被挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
|
|
90
|
+
* 信号,噪音大幅下降。
|
|
91
|
+
*/
|
|
76
92
|
function selectRelevant(query, files) {
|
|
77
93
|
return files
|
|
78
|
-
.map(f =>
|
|
94
|
+
.map(f => {
|
|
95
|
+
const hay = f.description + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
|
|
96
|
+
return { ...f, score: score(query, hay) }
|
|
97
|
+
})
|
|
79
98
|
.filter(f => f.score > 0)
|
|
80
99
|
.sort((a, b) => b.score - a.score)
|
|
81
100
|
.slice(0, MAX_RESULTS)
|
|
@@ -99,6 +118,15 @@ function parseFrontmatter(content) {
|
|
|
99
118
|
return { name: grab('name'), description: grab('description'), type }
|
|
100
119
|
}
|
|
101
120
|
|
|
121
|
+
/** 解析 frontmatter 的 `baseline` 标记:会话第一回合无条件注入。 */
|
|
122
|
+
function parseBaseline(content) {
|
|
123
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
124
|
+
if (!m) return false
|
|
125
|
+
const hit = m[1].match(/^baseline:\s*(.+)$/m)
|
|
126
|
+
if (!hit) return false
|
|
127
|
+
return /^(true|yes|1)$/i.test(hit[1].replace(/^["']|["']$/g, '').trim())
|
|
128
|
+
}
|
|
129
|
+
|
|
102
130
|
/** 防路径穿越:只取 basename,且必须在 memory 目录内(.md,排除索引)。 */
|
|
103
131
|
function safeName(raw) {
|
|
104
132
|
const name = basename(String(raw || ''))
|
|
@@ -128,8 +156,12 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
128
156
|
// 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
|
|
129
157
|
new StarmapGateway(ctx)
|
|
130
158
|
|
|
159
|
+
// 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
|
|
160
|
+
// 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
|
|
161
|
+
const recallCache = new Map()
|
|
162
|
+
|
|
131
163
|
// 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
|
|
132
|
-
// 从 agent.session 拿当前 user message,扫 memory
|
|
164
|
+
// 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
|
|
133
165
|
// 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
|
|
134
166
|
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
135
167
|
const assembled = await next()
|
|
@@ -138,15 +170,44 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
138
170
|
const session = agent?.session
|
|
139
171
|
if (!session || typeof session.deriveMessages !== 'function') return assembled
|
|
140
172
|
const messages = [...session.deriveMessages()]
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
173
|
+
|
|
174
|
+
// 关键:DSH 0.1.5 把工具结果(source.kind === 'tool')和注入内容
|
|
175
|
+
// (source.kind === 'plugin')也存成 role: 'user' 的消息。只按 role 取
|
|
176
|
+
// 「最后一条」会取到 tool-result,extractText 得到空串,下面的长度检查
|
|
177
|
+
// 直接返回——表现就是全程静默不注入。只有 source.kind === 'user' 是真人输入。
|
|
178
|
+
const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
|
|
179
|
+
const text = extractText(userMessages[userMessages.length - 1]?.content)
|
|
144
180
|
|
|
145
181
|
const files = await scanMemoryFiles()
|
|
146
|
-
const
|
|
147
|
-
|
|
182
|
+
const picked = []
|
|
183
|
+
const seen = new Set()
|
|
184
|
+
const take = (f) => {
|
|
185
|
+
if (seen.has(f.file)) return
|
|
186
|
+
seen.add(f.file)
|
|
187
|
+
picked.push(f)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 新会话首步:注入 frontmatter 标了 `baseline: true` 的记忆。当前这条
|
|
191
|
+
// 用户消息在 assemble 时还没写进 session,所以首步这里恰好是 0 条。
|
|
192
|
+
// 这一层补「开场白失忆」——「继续吧」这类短话在 description 里找不到
|
|
193
|
+
// 匹配,纯检索会返回 0 条,而那恰恰是最需要上下文的时刻。
|
|
194
|
+
if (userMessages.length === 0) {
|
|
195
|
+
files
|
|
196
|
+
.filter(f => f.baseline)
|
|
197
|
+
.sort((a, b) => a.file.localeCompare(b.file))
|
|
198
|
+
.slice(0, MAX_BASELINE)
|
|
199
|
+
.forEach(take)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (text && text.length >= 2) selectRelevant(text, files).forEach(take)
|
|
203
|
+
if (picked.length === 0) return assembled
|
|
204
|
+
|
|
205
|
+
const signature = text + '\u0000' + picked.map(f => f.file).join(',')
|
|
206
|
+
if (recallCache.get(session.id) === signature) return assembled
|
|
207
|
+
recallCache.set(session.id, signature)
|
|
208
|
+
if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
|
|
148
209
|
|
|
149
|
-
const parts =
|
|
210
|
+
const parts = picked.map(f => {
|
|
150
211
|
const body = f.content.length > MAX_CHARS_PER_FILE
|
|
151
212
|
? f.content.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
|
|
152
213
|
: f.content
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sage-mem",
|
|
3
3
|
"description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in transparent markdown files, with a memory star-map view",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"dshReleases": {
|
|
37
37
|
"0.1.2-rc.1": "compatible",
|
|
38
38
|
"0.1.3-alpha.1": "compatible",
|
|
39
|
-
"0.1.3-alpha.2": "compatible"
|
|
39
|
+
"0.1.3-alpha.2": "compatible",
|
|
40
|
+
"0.1.5-rc.1": "compatible"
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
},
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
},
|
|
52
53
|
"repository": {
|
|
53
54
|
"type": "git",
|
|
54
|
-
"url": "https://github.com/gezi-wen/sage-mem.git"
|
|
55
|
+
"url": "git+https://github.com/gezi-wen/sage-mem.git"
|
|
55
56
|
},
|
|
56
57
|
"keywords": [
|
|
57
58
|
"memory",
|