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 +7 -0
- package/lib/client.js +19 -16
- package/lib/index.js +127 -17
- package/lib/typert.host.js +21 -13
- package/package.json +71 -69
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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)
|
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 = {
|
|
@@ -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.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"@deepseek-ai/
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
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
|
+
}
|