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 CHANGED
@@ -25,7 +25,8 @@ sage-mem 换成文件式:每条记忆一个 markdown 文件,直接用编辑
25
25
 
26
26
  ## 功能
27
27
 
28
- - **按问题检索注入**:每次提问,插件扫记忆目录,按相关性把记忆注入 system prompt,agent 第一轮就「想起来」
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
- return { file: name, content }
65
- } catch {
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
- /** 按得分选相关文件,降序取 top N。 */
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 => ({ ...f, score: score(query, f.content) }))
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
- const m = content.match(/^---\n([\s\S]*?)\n---/)
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
- const hit = body.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))
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
- const files = await scanMemoryFiles()
146
- const relevant = selectRelevant(text, files)
147
- if (relevant.length === 0) return assembled
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
- const parts = relevant.map(f => {
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
- await writeFile(join(MEMORY_DIR, safe), String(content ?? ''), 'utf8')
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 { entries = await readdir(MEMORY_DIR, { withFileTypes: true }) } catch { return [] }
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 { return null }
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)
@@ -6,8 +6,12 @@
6
6
  */
7
7
  import { z } from 'zod'
8
8
 
9
- const fileNameSchema = z.string()
10
- const fileContentSchema = z.string()
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.3",
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",