sage-mem 0.4.2 → 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/NOTICE +3 -0
- package/README.md +5 -2
- package/lib/client.js +1357 -9
- package/lib/index.js +140 -12
- package/lib/typert.host.js +39 -0
- package/package.json +5 -4
package/lib/index.js
CHANGED
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
17
17
|
import { homedir } from 'node:os'
|
|
18
|
-
import { readFile, readdir, writeFile, unlink } from 'node:fs/promises'
|
|
18
|
+
import { readFile, readdir, writeFile, unlink, stat } from 'node:fs/promises'
|
|
19
19
|
import { join, basename } from 'node:path'
|
|
20
20
|
|
|
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 || ''))
|
|
@@ -125,9 +153,15 @@ function markRemote(cls, method, exportName) {
|
|
|
125
153
|
export class MemoryGateway extends TypertRemoteService {
|
|
126
154
|
constructor(ctx) {
|
|
127
155
|
super(ctx, 'memory')
|
|
156
|
+
// 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
|
|
157
|
+
new StarmapGateway(ctx)
|
|
158
|
+
|
|
159
|
+
// 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
|
|
160
|
+
// 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
|
|
161
|
+
const recallCache = new Map()
|
|
128
162
|
|
|
129
163
|
// 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
|
|
130
|
-
// 从 agent.session 拿当前 user message,扫 memory
|
|
164
|
+
// 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
|
|
131
165
|
// 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
|
|
132
166
|
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
133
167
|
const assembled = await next()
|
|
@@ -136,15 +170,44 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
136
170
|
const session = agent?.session
|
|
137
171
|
if (!session || typeof session.deriveMessages !== 'function') return assembled
|
|
138
172
|
const messages = [...session.deriveMessages()]
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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)
|
|
142
180
|
|
|
143
181
|
const files = await scanMemoryFiles()
|
|
144
|
-
const
|
|
145
|
-
|
|
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
|
|
146
204
|
|
|
147
|
-
const
|
|
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)
|
|
209
|
+
|
|
210
|
+
const parts = picked.map(f => {
|
|
148
211
|
const body = f.content.length > MAX_CHARS_PER_FILE
|
|
149
212
|
? f.content.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
|
|
150
213
|
: f.content
|
|
@@ -209,4 +272,69 @@ markRemote(MemoryGateway, 'readFile', 'readFile')
|
|
|
209
272
|
markRemote(MemoryGateway, 'writeFile', 'writeFile')
|
|
210
273
|
markRemote(MemoryGateway, 'deleteFile', 'deleteFile')
|
|
211
274
|
|
|
275
|
+
/** 提取正文第一个 `# ` 标题作为展示标题。 */
|
|
276
|
+
function extractTitle(content) {
|
|
277
|
+
const m = content.match(/^---\r?\n[\s\S]*?\r?\n---/)
|
|
278
|
+
const body = m ? content.slice(m[0].length) : content
|
|
279
|
+
const h1 = body.match(/^#\s+(.+)$/m)
|
|
280
|
+
return h1 ? h1[1].trim() : ''
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 扫记忆目录,读全部 .md(跳过索引与 session-log),带体积与修改时间。 */
|
|
284
|
+
async function scanStars() {
|
|
285
|
+
let entries
|
|
286
|
+
try { entries = await readdir(MEMORY_DIR, { withFileTypes: true }) } catch { return [] }
|
|
287
|
+
const names = entries
|
|
288
|
+
.filter(e => e.isFile() && e.name.endsWith('.md') && e.name !== 'MEMORY.md' && e.name !== 'session-log.md')
|
|
289
|
+
.map(e => e.name)
|
|
290
|
+
const loaded = await Promise.all(names.map(async (name) => {
|
|
291
|
+
try {
|
|
292
|
+
const full = join(MEMORY_DIR, name)
|
|
293
|
+
const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
|
|
294
|
+
return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
|
|
295
|
+
} catch { return null }
|
|
296
|
+
}))
|
|
297
|
+
return loaded.filter(Boolean)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 记忆星图(host 半)—— 只读扫描记忆目录,把每条记忆解析成一颗「星」。
|
|
302
|
+
* 通过 TypertRemoteService 暴露两个 remote 方法给浏览器半:
|
|
303
|
+
* - starmap.listStars() 全部星(元数据,不含正文)
|
|
304
|
+
* - starmap.readFile(n) 单条记忆全文(文件名白名单校验)
|
|
305
|
+
*/
|
|
306
|
+
export class StarmapGateway extends TypertRemoteService {
|
|
307
|
+
constructor(ctx) { super(ctx, 'starmap') }
|
|
308
|
+
|
|
309
|
+
async listStars() {
|
|
310
|
+
const files = await scanStars()
|
|
311
|
+
const stars = files.map(f => {
|
|
312
|
+
const meta = parseFrontmatter(f.content)
|
|
313
|
+
const title = extractTitle(f.content)
|
|
314
|
+
return {
|
|
315
|
+
file: f.name,
|
|
316
|
+
kind: meta.type || 'special',
|
|
317
|
+
title: title || meta.name || (meta.description ? meta.description.slice(0, 24) : f.name.replace(/\.md$/, '')),
|
|
318
|
+
desc: meta.description || '',
|
|
319
|
+
bytes: f.bytes,
|
|
320
|
+
mtimeMs: f.mtimeMs,
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
return { count: stars.length, stars }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async readFile(name) {
|
|
327
|
+
const raw = String(name || '')
|
|
328
|
+
const safe = basename(raw)
|
|
329
|
+
if (!safe || safe !== raw || !safe.endsWith('.md') || safe === 'MEMORY.md') {
|
|
330
|
+
throw new Error('sage-starmap: invalid file name')
|
|
331
|
+
}
|
|
332
|
+
const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
|
|
333
|
+
return { name: safe, content }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
markRemote(StarmapGateway, 'listStars', 'listStars')
|
|
338
|
+
markRemote(StarmapGateway, 'readFile', 'readFile')
|
|
339
|
+
|
|
212
340
|
export default MemoryGateway
|
package/lib/typert.host.js
CHANGED
|
@@ -9,6 +9,23 @@ import { z } from 'zod'
|
|
|
9
9
|
const fileNameSchema = z.string()
|
|
10
10
|
const fileContentSchema = z.string()
|
|
11
11
|
|
|
12
|
+
const starmapStarSchema = z.object({
|
|
13
|
+
'file': z.string().readonly(),
|
|
14
|
+
'kind': z.string().readonly(),
|
|
15
|
+
'title': z.string().readonly(),
|
|
16
|
+
'desc': z.string().readonly(),
|
|
17
|
+
'bytes': z.number().readonly(),
|
|
18
|
+
'mtimeMs': z.number().readonly(),
|
|
19
|
+
}).readonly()
|
|
20
|
+
const starmapListSchema = z.object({
|
|
21
|
+
'count': z.number().readonly(),
|
|
22
|
+
'stars': z.array(starmapStarSchema).readonly(),
|
|
23
|
+
}).readonly()
|
|
24
|
+
const starmapReadSchema = z.object({
|
|
25
|
+
'name': z.string().readonly(),
|
|
26
|
+
'content': z.string().readonly(),
|
|
27
|
+
}).readonly()
|
|
28
|
+
|
|
12
29
|
const fileSchema = z.object({
|
|
13
30
|
'file': z.string().readonly(),
|
|
14
31
|
'type': z.string().readonly(),
|
|
@@ -81,6 +98,28 @@ export const TYPERT = {
|
|
|
81
98
|
result: { mode: 'strict', typeSymbol: 'sage-mem/types#DeleteResult', schema: deleteResultSchema },
|
|
82
99
|
sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
|
|
83
100
|
},
|
|
101
|
+
{
|
|
102
|
+
id: 'sage-mem#starmap/listStars',
|
|
103
|
+
service: 'starmap',
|
|
104
|
+
namespace: 'starmap',
|
|
105
|
+
method: 'listStars',
|
|
106
|
+
invocation: { kind: 'direct' },
|
|
107
|
+
parameters: [],
|
|
108
|
+
result: { mode: 'strict', typeSymbol: 'sage-mem/types#StarmapList', schema: starmapListSchema },
|
|
109
|
+
sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'sage-mem#starmap/readFile',
|
|
113
|
+
service: 'starmap',
|
|
114
|
+
namespace: 'starmap',
|
|
115
|
+
method: 'readFile',
|
|
116
|
+
invocation: { kind: 'direct' },
|
|
117
|
+
parameters: [
|
|
118
|
+
{ name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema } },
|
|
119
|
+
],
|
|
120
|
+
result: { mode: 'strict', typeSymbol: 'sage-mem/types#StarmapRead', schema: starmapReadSchema },
|
|
121
|
+
sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
|
|
122
|
+
},
|
|
84
123
|
],
|
|
85
124
|
model: {
|
|
86
125
|
"services": [],
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sage-mem",
|
|
3
|
-
"description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in transparent markdown files",
|
|
4
|
-
"version": "0.
|
|
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.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",
|