sage-mem 0.5.0 → 0.5.2

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
@@ -88,6 +88,13 @@ sage-mem 是纯 DSH 插件,在 `package.json` 的 `dsh.compatibility.dshReleas
88
88
  | 0.1.3-alpha.1 | compatible |
89
89
  | 0.1.3-alpha.2 | compatible |
90
90
  | 0.1.5-rc.1 | compatible |
91
+ | 0.1.6-alpha.1 | compatible |
92
+ | 0.1.6-alpha.2 | compatible |
93
+
94
+ 自 DSH `0.1.6-alpha.2` 起,TypertCodec 契约从「读 `schema`」改为「读 `create()` 工厂」——
95
+ 仍只写 `schema:` 的插件会在注册阶段直接抛错,导致整棵插件树加载失败。自 0.5.2 起每个 codec
96
+ 同时带 `schema` 与 `create: () => schema`,因此**同一份构建同时兼容 alpha.1 及更早(读
97
+ schema)与 alpha.2 及以后(读 create)**,升级 DSH 时不需要换插件版本。
91
98
 
92
99
  Node.js 要求 `>= 18`(见 `engines.node`)。
93
100
 
package/lib/client.js CHANGED
@@ -63,7 +63,7 @@ const StarmapRuntime = (function (require, _react) {
63
63
  method: "listStars",
64
64
  invocation: { kind: "direct" },
65
65
  parameters: [],
66
- result: { mode: "strict", typeSymbol: "sage-starmap/types#StarList", schema: listSchema },
66
+ result: { mode: "strict", typeSymbol: "sage-starmap/types#StarList", schema: listSchema, create: () => listSchema },
67
67
  sourceLocation: { "file": "workspace/sage-starmap/lib/index.js", "line": 1, "column": 1 },
68
68
  },
69
69
  {
@@ -73,9 +73,9 @@ const StarmapRuntime = (function (require, _react) {
73
73
  method: "readFile",
74
74
  invocation: { kind: "direct" },
75
75
  parameters: [
76
- { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-starmap/types#FileName", schema: strSchema } },
76
+ { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-starmap/types#FileName", schema: strSchema, create: () => strSchema } },
77
77
  ],
78
- result: { mode: "strict", typeSymbol: "sage-starmap/types#FileContent", schema: readSchema },
78
+ result: { mode: "strict", typeSymbol: "sage-starmap/types#FileContent", schema: readSchema, create: () => readSchema },
79
79
  sourceLocation: { "file": "workspace/sage-starmap/lib/index.js", "line": 1, "column": 1 },
80
80
  },
81
81
  ],
@@ -1357,7 +1357,7 @@ const StarmapRuntime = (function (require, _react) {
1357
1357
  method: "listFiles",
1358
1358
  invocation: { kind: "direct" },
1359
1359
  parameters: [],
1360
- result: { mode: "strict", typeSymbol: "sage-mem/types#FileList", schema: listSchema },
1360
+ result: { mode: "strict", typeSymbol: "sage-mem/types#FileList", schema: listSchema, create: () => listSchema },
1361
1361
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
1362
1362
  },
1363
1363
  {
@@ -1367,9 +1367,9 @@ const StarmapRuntime = (function (require, _react) {
1367
1367
  method: "readFile",
1368
1368
  invocation: { kind: "direct" },
1369
1369
  parameters: [
1370
- { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema } },
1370
+ { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema, create: () => strSchema } },
1371
1371
  ],
1372
- result: { mode: "strict", typeSymbol: "sage-mem/types#FileContent", schema: readSchema },
1372
+ result: { mode: "strict", typeSymbol: "sage-mem/types#FileContent", schema: readSchema, create: () => readSchema },
1373
1373
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
1374
1374
  },
1375
1375
  {
@@ -1379,10 +1379,10 @@ const StarmapRuntime = (function (require, _react) {
1379
1379
  method: "writeFile",
1380
1380
  invocation: { kind: "direct" },
1381
1381
  parameters: [
1382
- { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema } },
1383
- { name: "content", wire: "content", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileContent", schema: strSchema } },
1382
+ { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema, create: () => strSchema } },
1383
+ { name: "content", wire: "content", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileContent", schema: strSchema, create: () => strSchema } },
1384
1384
  ],
1385
- result: { mode: "strict", typeSymbol: "sage-mem/types#WriteResult", schema: writeSchema },
1385
+ result: { mode: "strict", typeSymbol: "sage-mem/types#WriteResult", schema: writeSchema, create: () => writeSchema },
1386
1386
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
1387
1387
  },
1388
1388
  {
@@ -1392,9 +1392,9 @@ const StarmapRuntime = (function (require, _react) {
1392
1392
  method: "deleteFile",
1393
1393
  invocation: { kind: "direct" },
1394
1394
  parameters: [
1395
- { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema } },
1395
+ { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema, create: () => strSchema } },
1396
1396
  ],
1397
- result: { mode: "strict", typeSymbol: "sage-mem/types#DeleteResult", schema: deleteSchema },
1397
+ result: { mode: "strict", typeSymbol: "sage-mem/types#DeleteResult", schema: deleteSchema, create: () => deleteSchema },
1398
1398
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
1399
1399
  },
1400
1400
  {
@@ -1404,7 +1404,7 @@ const StarmapRuntime = (function (require, _react) {
1404
1404
  method: "listStars",
1405
1405
  invocation: { kind: "direct" },
1406
1406
  parameters: [],
1407
- result: { mode: "strict", typeSymbol: "sage-mem/types#StarmapList", schema: starmapListSchema },
1407
+ result: { mode: "strict", typeSymbol: "sage-mem/types#StarmapList", schema: starmapListSchema, create: () => starmapListSchema },
1408
1408
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
1409
1409
  },
1410
1410
  {
@@ -1414,9 +1414,9 @@ const StarmapRuntime = (function (require, _react) {
1414
1414
  method: "readFile",
1415
1415
  invocation: { kind: "direct" },
1416
1416
  parameters: [
1417
- { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema } },
1417
+ { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema, create: () => strSchema } },
1418
1418
  ],
1419
- result: { mode: "strict", typeSymbol: "sage-mem/types#StarmapRead", schema: starmapReadSchema },
1419
+ result: { mode: "strict", typeSymbol: "sage-mem/types#StarmapRead", schema: starmapReadSchema, create: () => starmapReadSchema },
1420
1420
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
1421
1421
  },
1422
1422
  ],
@@ -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,7 +15,7 @@
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')
@@ -23,6 +23,53 @@ const MAX_RESULTS = 5
23
23
  const MAX_CHARS_PER_FILE = 1500
24
24
  const MAX_BASELINE = 5
25
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
+ }
72
+
26
73
  /** 提取消息文本,兼容 content 为字符串或 text-block 数组两种形态。 */
27
74
  function extractText(content) {
28
75
  if (typeof content === 'string') return content.trim()
@@ -53,7 +100,7 @@ function score(query, text) {
53
100
  }
54
101
 
55
102
  /** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
56
- async function scanMemoryFiles() {
103
+ async function scanMemoryFiles(ctx, sessionId) {
57
104
  try {
58
105
  const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
59
106
  const names = entries
@@ -69,12 +116,16 @@ async function scanMemoryFiles() {
69
116
  description: meta.description || '',
70
117
  baseline: parseBaseline(content),
71
118
  }
72
- } catch {
119
+ } catch (err) {
120
+ // 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
121
+ // 一律出声:这里的静默化就是把「记忆不再注入」变成无声事故的那一手。
122
+ if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`, sessionId)
73
123
  return null
74
124
  }
75
125
  }))
76
126
  return loaded.filter(Boolean)
77
- } catch {
127
+ } catch (err) {
128
+ if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`, sessionId)
78
129
  return []
79
130
  }
80
131
  }
@@ -102,17 +153,23 @@ function selectRelevant(query, files) {
102
153
 
103
154
  /** 解析 frontmatter 的 name/description/type(兼容顶层与 metadata 嵌套两种 type)。 */
104
155
  function parseFrontmatter(content) {
105
- 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---/)
106
161
  if (!m) return { name: '', description: '', type: '' }
107
162
  const body = m[1]
108
163
  const grab = (key) => {
109
- 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'))
110
167
  if (!hit) return ''
111
168
  return hit[1].replace(/^["']|["']$/g, '').trim()
112
169
  }
113
170
  let type = grab('type')
114
171
  if (!type) {
115
- 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)
116
173
  if (nested) type = nested[1]
117
174
  }
118
175
  return { name: grab('name'), description: grab('description'), type }
@@ -127,6 +184,25 @@ function parseBaseline(content) {
127
184
  return /^(true|yes|1)$/i.test(hit[1].replace(/^["']|["']$/g, '').trim())
128
185
  }
129
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
+
130
206
  /** 防路径穿越:只取 basename,且必须在 memory 目录内(.md,排除索引)。 */
131
207
  function safeName(raw) {
132
208
  const name = basename(String(raw || ''))
@@ -165,6 +241,8 @@ export class MemoryGateway extends TypertRemoteService {
165
241
  // 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
166
242
  ctx.on('system-prompt/assemble', async (assembly, context, next) => {
167
243
  const assembled = await next()
244
+ // session id 在 try 外先取好:catch 里也要能带上它(拿到什么带什么)。
245
+ const sessionId = context?.agent?.session?.id ?? ''
168
246
  try {
169
247
  const agent = context?.agent
170
248
  const session = agent?.session
@@ -178,7 +256,7 @@ export class MemoryGateway extends TypertRemoteService {
178
256
  const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
179
257
  const text = extractText(userMessages[userMessages.length - 1]?.content)
180
258
 
181
- const files = await scanMemoryFiles()
259
+ const files = await scanMemoryFiles(ctx, sessionId)
182
260
  const picked = []
183
261
  const seen = new Set()
184
262
  const take = (f) => {
@@ -222,7 +300,11 @@ export class MemoryGateway extends TypertRemoteService {
222
300
  text: `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n${parts.join('\n\n')}`,
223
301
  }],
224
302
  }
225
- } catch {
303
+ } catch (err) {
304
+ // 这是整条注入链的外壳。裸吞的代价就是 :174-193 记的那个坑:权限错、编码坏、
305
+ // frontmatter 正则哪天不匹配,表现都是「插件在、但记忆不再注入」,日志一字没有。
306
+ // 现在只降级(返回未注入的 assembly),但一定留痕。
307
+ warnLog(ctx, `recall injection failed: ${err?.message ?? err}`, sessionId)
226
308
  return assembled
227
309
  }
228
310
  })
@@ -230,7 +312,7 @@ export class MemoryGateway extends TypertRemoteService {
230
312
 
231
313
  /** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小)。 */
232
314
  async listFiles() {
233
- const files = await scanMemoryFiles()
315
+ const files = await scanMemoryFiles(this.ctx, '')
234
316
  return files.map(f => {
235
317
  const meta = parseFrontmatter(f.content)
236
318
  return {
@@ -250,18 +332,38 @@ export class MemoryGateway extends TypertRemoteService {
250
332
  return { name: safe, content }
251
333
  }
252
334
 
253
- /** 写(新增或覆盖)单个记忆文件。 */
335
+ /**
336
+ * 写(新增或覆盖)单个记忆文件。
337
+ *
338
+ * 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
339
+ * 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
340
+ */
254
341
  async writeFile(name, content) {
255
342
  const safe = safeName(name)
256
343
  if (!safe) throw new Error('sage-mem: invalid file name')
257
- 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)
258
357
  return { ok: true, file: safe }
259
358
  }
260
359
 
261
- /** 删除单个记忆文件。 */
360
+ /** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。 */
262
361
  async deleteFile(name) {
263
362
  const safe = safeName(name)
264
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
+ }
265
367
  await unlink(join(MEMORY_DIR, safe))
266
368
  return { ok: true }
267
369
  }
@@ -281,9 +383,14 @@ function extractTitle(content) {
281
383
  }
282
384
 
283
385
  /** 扫记忆目录,读全部 .md(跳过索引与 session-log),带体积与修改时间。 */
284
- async function scanStars() {
386
+ async function scanStars(ctx) {
285
387
  let entries
286
- 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
+ }
287
394
  const names = entries
288
395
  .filter(e => e.isFile() && e.name.endsWith('.md') && e.name !== 'MEMORY.md' && e.name !== 'session-log.md')
289
396
  .map(e => e.name)
@@ -292,7 +399,10 @@ async function scanStars() {
292
399
  const full = join(MEMORY_DIR, name)
293
400
  const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
294
401
  return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
295
- } catch { return null }
402
+ } catch (err) {
403
+ if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`)
404
+ return null
405
+ }
296
406
  }))
297
407
  return loaded.filter(Boolean)
298
408
  }
@@ -307,7 +417,7 @@ export class StarmapGateway extends TypertRemoteService {
307
417
  constructor(ctx) { super(ctx, 'starmap') }
308
418
 
309
419
  async listStars() {
310
- const files = await scanStars()
420
+ const files = await scanStars(this.ctx)
311
421
  const stars = files.map(f => {
312
422
  const meta = parseFrontmatter(f.content)
313
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 = {
@@ -58,7 +66,7 @@ export const TYPERT = {
58
66
  method: 'listFiles',
59
67
  invocation: { kind: 'direct' },
60
68
  parameters: [],
61
- result: { mode: 'strict', typeSymbol: 'sage-mem/types#FileList', schema: listResultSchema },
69
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#FileList', schema: listResultSchema, create: () => listResultSchema },
62
70
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
63
71
  },
64
72
  {
@@ -68,9 +76,9 @@ export const TYPERT = {
68
76
  method: 'readFile',
69
77
  invocation: { kind: 'direct' },
70
78
  parameters: [
71
- { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema } },
79
+ { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema, create: () => fileNameSchema } },
72
80
  ],
73
- result: { mode: 'strict', typeSymbol: 'sage-mem/types#FileContent', schema: readResultSchema },
81
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#FileContent', schema: readResultSchema, create: () => readResultSchema },
74
82
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
75
83
  },
76
84
  {
@@ -80,10 +88,10 @@ export const TYPERT = {
80
88
  method: 'writeFile',
81
89
  invocation: { kind: 'direct' },
82
90
  parameters: [
83
- { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema } },
84
- { name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileContent', schema: fileContentSchema } },
91
+ { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema, create: () => fileNameSchema } },
92
+ { name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileContent', schema: fileContentSchema, create: () => fileContentSchema } },
85
93
  ],
86
- result: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteResult', schema: writeResultSchema },
94
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteResult', schema: writeResultSchema, create: () => writeResultSchema },
87
95
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
88
96
  },
89
97
  {
@@ -93,9 +101,9 @@ export const TYPERT = {
93
101
  method: 'deleteFile',
94
102
  invocation: { kind: 'direct' },
95
103
  parameters: [
96
- { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema } },
104
+ { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema, create: () => fileNameSchema } },
97
105
  ],
98
- result: { mode: 'strict', typeSymbol: 'sage-mem/types#DeleteResult', schema: deleteResultSchema },
106
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#DeleteResult', schema: deleteResultSchema, create: () => deleteResultSchema },
99
107
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
100
108
  },
101
109
  {
@@ -105,7 +113,7 @@ export const TYPERT = {
105
113
  method: 'listStars',
106
114
  invocation: { kind: 'direct' },
107
115
  parameters: [],
108
- result: { mode: 'strict', typeSymbol: 'sage-mem/types#StarmapList', schema: starmapListSchema },
116
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#StarmapList', schema: starmapListSchema, create: () => starmapListSchema },
109
117
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
110
118
  },
111
119
  {
@@ -115,9 +123,9 @@ export const TYPERT = {
115
123
  method: 'readFile',
116
124
  invocation: { kind: 'direct' },
117
125
  parameters: [
118
- { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema } },
126
+ { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema, create: () => fileNameSchema } },
119
127
  ],
120
- result: { mode: 'strict', typeSymbol: 'sage-mem/types#StarmapRead', schema: starmapReadSchema },
128
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#StarmapRead', schema: starmapReadSchema, create: () => starmapReadSchema },
121
129
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
122
130
  },
123
131
  ],
package/package.json CHANGED
@@ -1,69 +1,71 @@
1
- {
2
- "name": "sage-mem",
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
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": "./lib/index.js",
9
- "./client": "./lib/client.js",
10
- "./typert": "./lib/typert.host.js",
11
- "./package.json": "./package.json"
12
- },
13
- "files": [
14
- "lib/index.js",
15
- "lib/client.js",
16
- "lib/typert.host.js",
17
- "cordis.patch.yml",
18
- "NOTICE"
19
- ],
20
- "license": "Apache-2.0",
21
- "engines": {
22
- "node": ">=18"
23
- },
24
- "dsh": {
25
- "bundle": {
26
- "patch": "./cordis.patch.yml"
27
- },
28
- "client": {
29
- "platform": "web",
30
- "inject": [
31
- "@deepseek-ai/dsh-client-locale"
32
- ]
33
- },
34
- "compatibility": {
35
- "dsh": ">=0.1.2-rc.1",
36
- "dshReleases": {
37
- "0.1.2-rc.1": "compatible",
38
- "0.1.3-alpha.1": "compatible",
39
- "0.1.3-alpha.2": "compatible",
40
- "0.1.5-rc.1": "compatible"
41
- }
42
- }
43
- },
44
- "dependencies": {
45
- "zod": "^4.4.3"
46
- },
47
- "peerDependencies": {
48
- "@deepseek-ai/cordis": "^4.0.2",
49
- "@deepseek-ai/dsh-typert-protocol": "^0.1.2-rc.1",
50
- "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
51
- "react": "^18.0.0"
52
- },
53
- "repository": {
54
- "type": "git",
55
- "url": "git+https://github.com/gezi-wen/sage-mem.git"
56
- },
57
- "keywords": [
58
- "memory",
59
- "ai-memory",
60
- "long-term-memory",
61
- "markdown",
62
- "dsh",
63
- "deepseek-harness",
64
- "plugin",
65
- "ai-agent",
66
- "agent",
67
- "claude-code"
68
- ]
69
- }
1
+ {
2
+ "name": "sage-mem",
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.2",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./typert": "./lib/typert.host.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/index.js",
15
+ "lib/client.js",
16
+ "lib/typert.host.js",
17
+ "cordis.patch.yml",
18
+ "NOTICE"
19
+ ],
20
+ "license": "Apache-2.0",
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "dsh": {
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "platform": "web",
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-locale"
32
+ ]
33
+ },
34
+ "compatibility": {
35
+ "dsh": ">=0.1.2-rc.1",
36
+ "dshReleases": {
37
+ "0.1.2-rc.1": "compatible",
38
+ "0.1.3-alpha.1": "compatible",
39
+ "0.1.3-alpha.2": "compatible",
40
+ "0.1.5-rc.1": "compatible",
41
+ "0.1.6-alpha.1": "compatible",
42
+ "0.1.6-alpha.2": "compatible"
43
+ }
44
+ }
45
+ },
46
+ "dependencies": {
47
+ "zod": "^4.4.3"
48
+ },
49
+ "peerDependencies": {
50
+ "@deepseek-ai/cordis": "^4.0.2",
51
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.2-rc.1",
52
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
53
+ "react": "^18.0.0"
54
+ },
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/gezi-wen/sage-mem.git"
58
+ },
59
+ "keywords": [
60
+ "memory",
61
+ "ai-memory",
62
+ "long-term-memory",
63
+ "markdown",
64
+ "dsh",
65
+ "deepseek-harness",
66
+ "plugin",
67
+ "ai-agent",
68
+ "agent",
69
+ "claude-code"
70
+ ]
71
+ }