sage-mem 0.4.3 → 0.5.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 +5 -2
- package/lib/client.js +5 -2
- package/lib/index.js +199 -28
- package/lib/typert.host.js +10 -2
- 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/client.js
CHANGED
|
@@ -1641,7 +1641,9 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1641
1641
|
const safeName = name.endsWith(".md") ? name : name + ".md";
|
|
1642
1642
|
const content = buildFrontmatter(type, formDesc.trim()) + formBody;
|
|
1643
1643
|
remote.writeFile(safeName, content)
|
|
1644
|
-
.then(function () {
|
|
1644
|
+
.then(function (r) {
|
|
1645
|
+
// host 侧对保留名 / 非字符串内容 / 超限一律返回 { ok: false, error },不再抛异常。
|
|
1646
|
+
if (r && r.ok === false) { setError(r.error || "写入被拒绝"); return; }
|
|
1645
1647
|
closeForm();
|
|
1646
1648
|
setNotice(editingFile ? "已更新 " + safeName : "已保存 " + safeName);
|
|
1647
1649
|
load();
|
|
@@ -1653,7 +1655,8 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1653
1655
|
function confirmDelete(file) {
|
|
1654
1656
|
setBusy(true); setError(null); setNotice("");
|
|
1655
1657
|
remote.deleteFile(file)
|
|
1656
|
-
.then(function () {
|
|
1658
|
+
.then(function (r) {
|
|
1659
|
+
if (r && r.ok === false) { setError(r.error || "删除被拒绝"); return; }
|
|
1657
1660
|
setDeleting(null);
|
|
1658
1661
|
setNotice("已删除 " + file);
|
|
1659
1662
|
load();
|
package/lib/index.js
CHANGED
|
@@ -15,12 +15,60 @@
|
|
|
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, stat } from 'node:fs/promises'
|
|
18
|
+
import { readFile, readdir, writeFile, rename, 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
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 保留名:设置页的写/删方法一律不得触碰。
|
|
28
|
+
*
|
|
29
|
+
* 列进来的两类文件:
|
|
30
|
+
* 1. MEMORY.md / session-log.md —— 不是记忆条目:不进检索、不进星图、不出现在
|
|
31
|
+
* 设置页列表里,它们是索引与追加型流水(session-log.md 已 130+ KB)。
|
|
32
|
+
* 「+ 添加记忆」表单里手打这两个名字就能整份覆盖,而这个表单是照
|
|
33
|
+
* buildFrontmatter() 重建 frontmatter 的,原有内容一个字都留不下。
|
|
34
|
+
* 2. project_heartscape.md / project_self-cognition.md —— 人格连续性的手写载体
|
|
35
|
+
* (AGENTS.md 指定的落盘目标),单副本、纯人工维护;表单写还会把
|
|
36
|
+
* frontmatter 里的 originSessionId / birthday 等字段一并抹掉。
|
|
37
|
+
*
|
|
38
|
+
* 判定依据是「丢了能不能从别处重建」:memory 目录**不在任何版本控制下**
|
|
39
|
+
* (在 E:\shengdian 下 `git rev-parse` 直接报 not a git repository),
|
|
40
|
+
* deleteFile 是 unlink、没有回收站,所以宁严勿松。
|
|
41
|
+
* 这几个文件要改,用文件工具直接编辑。
|
|
42
|
+
*/
|
|
43
|
+
const RESERVED_FILES = new Set([
|
|
44
|
+
'MEMORY.md',
|
|
45
|
+
'session-log.md',
|
|
46
|
+
'project_heartscape.md',
|
|
47
|
+
'project_self-cognition.md',
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 单文件体积上限(字节)。定 512 KB 的理由:
|
|
52
|
+
* - 当前 memory 目录最大的一条正文 19 KB(project_guikit.md),索引与流水都排除在外,
|
|
53
|
+
* 512 KB 是它的 26 倍,任何正常记忆都够用;
|
|
54
|
+
* - 记忆是要塞进 system prompt 的,一条 512 KB 的记忆本身就等于把上下文撑爆——
|
|
55
|
+
* 到这个量级基本可以判定是写错了目标(比如把日志、代码贴进来)。
|
|
56
|
+
* 与 lib/typert.host.js 的 fileContentSchema.max(512 * 1024) 保持一致。
|
|
57
|
+
*/
|
|
58
|
+
const MAX_FILE_BYTES = 512 * 1024
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
|
|
62
|
+
* (历史坑见 assemble 监听器里那段注释:表现是「插件在、记忆不再注入」,日志一字没有)。
|
|
63
|
+
* @param ctx — 插件上下文,取 ctx.logger.warn
|
|
64
|
+
* @param msg — 消息正文,调用方负责带上文件名 / 目录名
|
|
65
|
+
* @param sessionId — 可选 session id,有就带
|
|
66
|
+
*/
|
|
67
|
+
function warnLog(ctx, msg, sessionId) {
|
|
68
|
+
const line = `sage-mem: ${msg}${sessionId ? ` (session ${sessionId})` : ''}`
|
|
69
|
+
if (ctx?.logger?.warn) ctx.logger.warn(line)
|
|
70
|
+
else console.warn(line)
|
|
71
|
+
}
|
|
24
72
|
|
|
25
73
|
/** 提取消息文本,兼容 content 为字符串或 text-block 数组两种形态。 */
|
|
26
74
|
function extractText(content) {
|
|
@@ -51,8 +99,8 @@ function score(query, text) {
|
|
|
51
99
|
return hit / q.size
|
|
52
100
|
}
|
|
53
101
|
|
|
54
|
-
/** 扫 memory
|
|
55
|
-
async function scanMemoryFiles() {
|
|
102
|
+
/** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
|
|
103
|
+
async function scanMemoryFiles(ctx, sessionId) {
|
|
56
104
|
try {
|
|
57
105
|
const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
|
|
58
106
|
const names = entries
|
|
@@ -61,21 +109,43 @@ async function scanMemoryFiles() {
|
|
|
61
109
|
const loaded = await Promise.all(names.map(async (name) => {
|
|
62
110
|
try {
|
|
63
111
|
const content = await readFile(join(MEMORY_DIR, name), 'utf8')
|
|
64
|
-
|
|
65
|
-
|
|
112
|
+
const meta = parseFrontmatter(content)
|
|
113
|
+
return {
|
|
114
|
+
file: name,
|
|
115
|
+
content,
|
|
116
|
+
description: meta.description || '',
|
|
117
|
+
baseline: parseBaseline(content),
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
|
|
121
|
+
// 一律出声:这里的静默化就是把「记忆不再注入」变成无声事故的那一手。
|
|
122
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`, sessionId)
|
|
66
123
|
return null
|
|
67
124
|
}
|
|
68
125
|
}))
|
|
69
126
|
return loaded.filter(Boolean)
|
|
70
|
-
} catch {
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`, sessionId)
|
|
71
129
|
return []
|
|
72
130
|
}
|
|
73
131
|
}
|
|
74
132
|
|
|
75
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* 相关性打分只用 frontmatter 的 description + 文件名,不扫全文。
|
|
135
|
+
*
|
|
136
|
+
* 旧版拿 query 的双字去全文里找,得分是「覆盖率」,分母是 query,不惩罚
|
|
137
|
+
* 文件长度 —— 越长的记忆越容易蒙中。实测 8 个典型问题:project_heartscape
|
|
138
|
+
* (5.9KB) 与 project_self-cognition (6.8KB) 命中 7 个,连「今天天气不错」都
|
|
139
|
+
* 注入 2300 token;而「论文写得怎么样了」该命中的 project_position-paper
|
|
140
|
+
* 反被挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
|
|
141
|
+
* 信号,噪音大幅下降。
|
|
142
|
+
*/
|
|
76
143
|
function selectRelevant(query, files) {
|
|
77
144
|
return files
|
|
78
|
-
.map(f =>
|
|
145
|
+
.map(f => {
|
|
146
|
+
const hay = f.description + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
|
|
147
|
+
return { ...f, score: score(query, hay) }
|
|
148
|
+
})
|
|
79
149
|
.filter(f => f.score > 0)
|
|
80
150
|
.sort((a, b) => b.score - a.score)
|
|
81
151
|
.slice(0, MAX_RESULTS)
|
|
@@ -83,22 +153,56 @@ function selectRelevant(query, files) {
|
|
|
83
153
|
|
|
84
154
|
/** 解析 frontmatter 的 name/description/type(兼容顶层与 metadata 嵌套两种 type)。 */
|
|
85
155
|
function parseFrontmatter(content) {
|
|
86
|
-
|
|
156
|
+
// 换行必须容忍 CRLF:任何用 Windows 编辑器碰过一次的文件都会被换成 \r\n,
|
|
157
|
+
// 而旧版这里写的是 /^---\n/ —— 匹配失败 → description/type 全空 →
|
|
158
|
+
// 「baseline 走 parseBaseline 那条带 \r?\n 的正则照旧命中,但这条记忆永远检索不到」,
|
|
159
|
+
// 症状极具欺骗性。:123 的 parseBaseline 与 :277 的 extractTitle 早已是 \r?\n。
|
|
160
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
87
161
|
if (!m) return { name: '', description: '', type: '' }
|
|
88
162
|
const body = m[1]
|
|
89
163
|
const grab = (key) => {
|
|
90
|
-
|
|
164
|
+
// [ \t] / [ \t\r] 而不是 \s:\s 会连 CRLF 的 \r 一起吃进捕获组,值尾留一个 \r
|
|
165
|
+
// 会让下面的引号剥离(["']$)失配,description 尾巴上挂一个残引号。
|
|
166
|
+
const hit = body.match(new RegExp(`^${key}:[ \\t]*(.+?)[ \\t\\r]*$`, 'm'))
|
|
91
167
|
if (!hit) return ''
|
|
92
168
|
return hit[1].replace(/^["']|["']$/g, '').trim()
|
|
93
169
|
}
|
|
94
170
|
let type = grab('type')
|
|
95
171
|
if (!type) {
|
|
96
|
-
const nested = body.match(/^metadata:\s*\n(?:\s+[^\n]+\n)*?\s+type:\s*(\S+)/m)
|
|
172
|
+
const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
|
|
97
173
|
if (nested) type = nested[1]
|
|
98
174
|
}
|
|
99
175
|
return { name: grab('name'), description: grab('description'), type }
|
|
100
176
|
}
|
|
101
177
|
|
|
178
|
+
/** 解析 frontmatter 的 `baseline` 标记:会话第一回合无条件注入。 */
|
|
179
|
+
function parseBaseline(content) {
|
|
180
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
181
|
+
if (!m) return false
|
|
182
|
+
const hit = m[1].match(/^baseline:\s*(.+)$/m)
|
|
183
|
+
if (!hit) return false
|
|
184
|
+
return /^(true|yes|1)$/i.test(hit[1].replace(/^["']|["']$/g, '').trim())
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 原子写:先写同目录临时文件,再 rename 覆盖目标。
|
|
189
|
+
*
|
|
190
|
+
* 直写的风险是崩在中途(断电、进程被杀、磁盘满)留下半截文件 —— frontmatter
|
|
191
|
+
* 缺半边,这条记忆从此解析不出来、检索不到,而文件看上去还在。
|
|
192
|
+
* 同卷 rename 是原子的:读方要么看到旧全文、要么看到新全文,不存在中间态。
|
|
193
|
+
* 任何一步失败都不碰目标文件,临时文件收尾清掉。
|
|
194
|
+
*/
|
|
195
|
+
async function writeFileAtomic(target, content) {
|
|
196
|
+
const tmp = `${target}.${process.pid}.tmp`
|
|
197
|
+
try {
|
|
198
|
+
await writeFile(tmp, content, 'utf8')
|
|
199
|
+
await rename(tmp, target)
|
|
200
|
+
} finally {
|
|
201
|
+
// rename 成功后 tmp 已不存在(ENOENT),失败时把它清掉不留垃圾。
|
|
202
|
+
await unlink(tmp).catch(() => {})
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
102
206
|
/** 防路径穿越:只取 basename,且必须在 memory 目录内(.md,排除索引)。 */
|
|
103
207
|
function safeName(raw) {
|
|
104
208
|
const name = basename(String(raw || ''))
|
|
@@ -128,25 +232,60 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
128
232
|
// 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
|
|
129
233
|
new StarmapGateway(ctx)
|
|
130
234
|
|
|
235
|
+
// 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
|
|
236
|
+
// 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
|
|
237
|
+
const recallCache = new Map()
|
|
238
|
+
|
|
131
239
|
// 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
|
|
132
|
-
// 从 agent.session 拿当前 user message,扫 memory
|
|
240
|
+
// 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
|
|
133
241
|
// 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
|
|
134
242
|
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
135
243
|
const assembled = await next()
|
|
244
|
+
// session id 在 try 外先取好:catch 里也要能带上它(拿到什么带什么)。
|
|
245
|
+
const sessionId = context?.agent?.session?.id ?? ''
|
|
136
246
|
try {
|
|
137
247
|
const agent = context?.agent
|
|
138
248
|
const session = agent?.session
|
|
139
249
|
if (!session || typeof session.deriveMessages !== 'function') return assembled
|
|
140
250
|
const messages = [...session.deriveMessages()]
|
|
141
|
-
const lastUser = messages.reverse().find(m => m?.role === 'user')
|
|
142
|
-
const text = extractText(lastUser?.content)
|
|
143
|
-
if (!text || text.length < 2) return assembled
|
|
144
251
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
252
|
+
// 关键:DSH 0.1.5 把工具结果(source.kind === 'tool')和注入内容
|
|
253
|
+
// (source.kind === 'plugin')也存成 role: 'user' 的消息。只按 role 取
|
|
254
|
+
// 「最后一条」会取到 tool-result,extractText 得到空串,下面的长度检查
|
|
255
|
+
// 直接返回——表现就是全程静默不注入。只有 source.kind === 'user' 是真人输入。
|
|
256
|
+
const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
|
|
257
|
+
const text = extractText(userMessages[userMessages.length - 1]?.content)
|
|
258
|
+
|
|
259
|
+
const files = await scanMemoryFiles(ctx, sessionId)
|
|
260
|
+
const picked = []
|
|
261
|
+
const seen = new Set()
|
|
262
|
+
const take = (f) => {
|
|
263
|
+
if (seen.has(f.file)) return
|
|
264
|
+
seen.add(f.file)
|
|
265
|
+
picked.push(f)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 新会话首步:注入 frontmatter 标了 `baseline: true` 的记忆。当前这条
|
|
269
|
+
// 用户消息在 assemble 时还没写进 session,所以首步这里恰好是 0 条。
|
|
270
|
+
// 这一层补「开场白失忆」——「继续吧」这类短话在 description 里找不到
|
|
271
|
+
// 匹配,纯检索会返回 0 条,而那恰恰是最需要上下文的时刻。
|
|
272
|
+
if (userMessages.length === 0) {
|
|
273
|
+
files
|
|
274
|
+
.filter(f => f.baseline)
|
|
275
|
+
.sort((a, b) => a.file.localeCompare(b.file))
|
|
276
|
+
.slice(0, MAX_BASELINE)
|
|
277
|
+
.forEach(take)
|
|
278
|
+
}
|
|
148
279
|
|
|
149
|
-
|
|
280
|
+
if (text && text.length >= 2) selectRelevant(text, files).forEach(take)
|
|
281
|
+
if (picked.length === 0) return assembled
|
|
282
|
+
|
|
283
|
+
const signature = text + '\u0000' + picked.map(f => f.file).join(',')
|
|
284
|
+
if (recallCache.get(session.id) === signature) return assembled
|
|
285
|
+
recallCache.set(session.id, signature)
|
|
286
|
+
if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
|
|
287
|
+
|
|
288
|
+
const parts = picked.map(f => {
|
|
150
289
|
const body = f.content.length > MAX_CHARS_PER_FILE
|
|
151
290
|
? f.content.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
|
|
152
291
|
: f.content
|
|
@@ -161,7 +300,11 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
161
300
|
text: `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n${parts.join('\n\n')}`,
|
|
162
301
|
}],
|
|
163
302
|
}
|
|
164
|
-
} catch {
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// 这是整条注入链的外壳。裸吞的代价就是 :174-193 记的那个坑:权限错、编码坏、
|
|
305
|
+
// frontmatter 正则哪天不匹配,表现都是「插件在、但记忆不再注入」,日志一字没有。
|
|
306
|
+
// 现在只降级(返回未注入的 assembly),但一定留痕。
|
|
307
|
+
warnLog(ctx, `recall injection failed: ${err?.message ?? err}`, sessionId)
|
|
165
308
|
return assembled
|
|
166
309
|
}
|
|
167
310
|
})
|
|
@@ -169,7 +312,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
169
312
|
|
|
170
313
|
/** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小)。 */
|
|
171
314
|
async listFiles() {
|
|
172
|
-
const files = await scanMemoryFiles()
|
|
315
|
+
const files = await scanMemoryFiles(this.ctx, '')
|
|
173
316
|
return files.map(f => {
|
|
174
317
|
const meta = parseFrontmatter(f.content)
|
|
175
318
|
return {
|
|
@@ -189,18 +332,38 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
189
332
|
return { name: safe, content }
|
|
190
333
|
}
|
|
191
334
|
|
|
192
|
-
/**
|
|
335
|
+
/**
|
|
336
|
+
* 写(新增或覆盖)单个记忆文件。
|
|
337
|
+
*
|
|
338
|
+
* 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
|
|
339
|
+
* 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
|
|
340
|
+
*/
|
|
193
341
|
async writeFile(name, content) {
|
|
194
342
|
const safe = safeName(name)
|
|
195
343
|
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
196
|
-
|
|
344
|
+
if (RESERVED_FILES.has(safe)) {
|
|
345
|
+
return { ok: false, file: safe, error: `sage-mem: reserved file, edit it with the file tools: ${safe}` }
|
|
346
|
+
}
|
|
347
|
+
// 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
|
|
348
|
+
// 原文件就此被一行垃圾顶掉,且没有任何错误信号。
|
|
349
|
+
if (typeof content !== 'string') {
|
|
350
|
+
return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
|
|
351
|
+
}
|
|
352
|
+
const bytes = Buffer.byteLength(content, 'utf8')
|
|
353
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
354
|
+
return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
|
|
355
|
+
}
|
|
356
|
+
await writeFileAtomic(join(MEMORY_DIR, safe), content)
|
|
197
357
|
return { ok: true, file: safe }
|
|
198
358
|
}
|
|
199
359
|
|
|
200
|
-
/**
|
|
360
|
+
/** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。 */
|
|
201
361
|
async deleteFile(name) {
|
|
202
362
|
const safe = safeName(name)
|
|
203
363
|
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
364
|
+
if (RESERVED_FILES.has(safe)) {
|
|
365
|
+
return { ok: false, error: `sage-mem: reserved file, refusing to delete: ${safe}` }
|
|
366
|
+
}
|
|
204
367
|
await unlink(join(MEMORY_DIR, safe))
|
|
205
368
|
return { ok: true }
|
|
206
369
|
}
|
|
@@ -220,9 +383,14 @@ function extractTitle(content) {
|
|
|
220
383
|
}
|
|
221
384
|
|
|
222
385
|
/** 扫记忆目录,读全部 .md(跳过索引与 session-log),带体积与修改时间。 */
|
|
223
|
-
async function scanStars() {
|
|
386
|
+
async function scanStars(ctx) {
|
|
224
387
|
let entries
|
|
225
|
-
try {
|
|
388
|
+
try {
|
|
389
|
+
entries = await readdir(MEMORY_DIR, { withFileTypes: true })
|
|
390
|
+
} catch (err) {
|
|
391
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`)
|
|
392
|
+
return []
|
|
393
|
+
}
|
|
226
394
|
const names = entries
|
|
227
395
|
.filter(e => e.isFile() && e.name.endsWith('.md') && e.name !== 'MEMORY.md' && e.name !== 'session-log.md')
|
|
228
396
|
.map(e => e.name)
|
|
@@ -231,7 +399,10 @@ async function scanStars() {
|
|
|
231
399
|
const full = join(MEMORY_DIR, name)
|
|
232
400
|
const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
|
|
233
401
|
return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
|
|
234
|
-
} catch {
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`)
|
|
404
|
+
return null
|
|
405
|
+
}
|
|
235
406
|
}))
|
|
236
407
|
return loaded.filter(Boolean)
|
|
237
408
|
}
|
|
@@ -246,7 +417,7 @@ export class StarmapGateway extends TypertRemoteService {
|
|
|
246
417
|
constructor(ctx) { super(ctx, 'starmap') }
|
|
247
418
|
|
|
248
419
|
async listStars() {
|
|
249
|
-
const files = await scanStars()
|
|
420
|
+
const files = await scanStars(this.ctx)
|
|
250
421
|
const stars = files.map(f => {
|
|
251
422
|
const meta = parseFrontmatter(f.content)
|
|
252
423
|
const title = extractTitle(f.content)
|
package/lib/typert.host.js
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from 'zod'
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// 文件名:与 host 侧 safeName 同一套底线 —— 非空、无路径分隔符与 Windows 非法字符、
|
|
10
|
+
// 以 .md 结尾、长度有界。(真正的目录逃逸防护仍在 host 侧 safeName。)
|
|
11
|
+
const fileNameSchema = z.string().min(1).max(255).regex(/^[^\\/:*?"<>|\u0000-\u001f]+\.md$/)
|
|
12
|
+
// 正文:512 KB 上限。理由是「记忆要塞进 system prompt」——现行 memory 目录最大一条 19 KB,
|
|
13
|
+
// 到半兆量级基本是写错了目标(贴了日志/代码进来)。与 lib/index.js 的 MAX_FILE_BYTES 一致。
|
|
14
|
+
const fileContentSchema = z.string().max(512 * 1024)
|
|
11
15
|
|
|
12
16
|
const starmapStarSchema = z.object({
|
|
13
17
|
'file': z.string().readonly(),
|
|
@@ -38,12 +42,16 @@ const readResultSchema = z.object({
|
|
|
38
42
|
'name': z.string().readonly(),
|
|
39
43
|
'content': z.string().readonly(),
|
|
40
44
|
}).readonly()
|
|
45
|
+
// 写/删失败不再抛异常,走 { ok: false, error } 分支:error 必须声明,否则 strict
|
|
46
|
+
// 校验会把它当未知字段剥掉,设置页只能看到一个没有原因的 ok:false。
|
|
41
47
|
const writeResultSchema = z.object({
|
|
42
48
|
'ok': z.boolean().readonly(),
|
|
43
49
|
'file': z.string().readonly(),
|
|
50
|
+
'error': z.string().readonly().optional(),
|
|
44
51
|
}).readonly()
|
|
45
52
|
const deleteResultSchema = z.object({
|
|
46
53
|
'ok': z.boolean().readonly(),
|
|
54
|
+
'error': z.string().readonly().optional(),
|
|
47
55
|
}).readonly()
|
|
48
56
|
|
|
49
57
|
export const TYPERT = {
|
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.1",
|
|
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",
|