sage-mem 0.9.0 → 0.9.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/lib/autodream/rollback.js +62 -1
- package/lib/autodream/snapshot.js +18 -1
- package/lib/autodream-audit.js +61 -9
- package/package.json +8 -8
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import { REPORT_DIR } from './config.js'
|
|
10
10
|
import { readJson, safeName, snapshotEntries, stampCompact, stampHuman } from './util.js'
|
|
11
|
+
import { restoreMemory } from '../memory/archive.js'
|
|
11
12
|
import { randomUUID } from 'node:crypto'
|
|
12
|
-
import { copyFile, mkdir, readdir, rename, stat, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
13
14
|
import { join } from 'node:path'
|
|
14
15
|
|
|
15
16
|
/** 读回滚留痕(面板也许要显示「最近谁退回过」)。 */
|
|
@@ -86,6 +87,34 @@ export async function rollback(opts = {}) {
|
|
|
86
87
|
const protectionId = `pre-rollback-${stamp}-${randomUUID().replace(/-/g, '').slice(0, 4)}`
|
|
87
88
|
const protection = await this.snapshot(protectionId, 'pre-rollback')
|
|
88
89
|
|
|
90
|
+
/**
|
|
91
|
+
* ⚠️ 保护快照不完整就**中止**,一个字都不许写。
|
|
92
|
+
*
|
|
93
|
+
* 它是「滚错了还能再退回来」这句承诺的唯一支撑。残缺时继续往下滚,等于拿一张假保险
|
|
94
|
+
* 去覆盖当前版本 —— 而当前版本恰恰是此刻最想保住的东西。残缺的回滚点比没有回滚点
|
|
95
|
+
* **更危险**,这与 `engine.js` 开工前那条(`snapshot.failed.length` 就中止)是同一条原则,
|
|
96
|
+
* 只是回滚这条路上一直漏了。
|
|
97
|
+
*
|
|
98
|
+
* 顺手把它删掉:它落进 `snapshots/` 就会被 `listSnapshots()` 当成一份可选的回滚点,
|
|
99
|
+
* 而按它恢复只会把「没存下来的那些文件」误判成「快照里没有」→ 停进 `archive/`。
|
|
100
|
+
*/
|
|
101
|
+
if (protection.failed.length || protection.count !== protection.expected) {
|
|
102
|
+
await rm(protection.dir, { recursive: true, force: true }).catch(() => {})
|
|
103
|
+
const why = protection.failed.length
|
|
104
|
+
? `有 ${protection.failed.length} 个文件没存下来(${protection.failed.slice(0, 3).join('、')}${
|
|
105
|
+
protection.failed.length > 3 ? '…' : ''
|
|
106
|
+
})`
|
|
107
|
+
: `只存下 ${protection.count}/${protection.expected} 个文件`
|
|
108
|
+
this.log('warn', `回滚中止:保护快照不完整,${why}`)
|
|
109
|
+
this.phase = ''
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error:
|
|
113
|
+
`回滚前保护快照不完整(${why})。为免滚错之后退不回来,这次回滚已中止,` +
|
|
114
|
+
'记忆目录一个字没动。请检查文件占用与磁盘空间后重试。',
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
89
118
|
// ② 恢复
|
|
90
119
|
this.phase = '恢复文件'
|
|
91
120
|
const snapNames = await snapshotEntries(snapDir)
|
|
@@ -155,6 +184,38 @@ export async function rollback(opts = {}) {
|
|
|
155
184
|
}
|
|
156
185
|
}
|
|
157
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 策略自动归档也要退回来 —— 它按设计**不写进 `changes`**。
|
|
190
|
+
*
|
|
191
|
+
* `engine.js` 把两本账分开了:模型改的进 `changes`,确定性归档策略移走的进
|
|
192
|
+
* `archive.archived`(已落盘在运行记录 `manifest.json` 里)。只遍历 `changes`
|
|
193
|
+
* 就会整段漏掉,而这趟运行照样被标成「已回滚」、面板照报「恢复 0 个文件…已回滚」——
|
|
194
|
+
* 用户以为退回来了,文件其实还躺在 `archive/` 里。
|
|
195
|
+
*
|
|
196
|
+
* 恢复走 `memory/archive.js` 的共享实现(与手动恢复、模型工具同一份):它会顺手
|
|
197
|
+
* 去掉 `archived_at` / `archived_reason` 两行留痕,否则「恢复」回来的是一份带着
|
|
198
|
+
* 「我已被归档」标记的活记忆。
|
|
199
|
+
*
|
|
200
|
+
* ⚠️ 只恢复**运行前快照里存在**的文件:策略候选是运行末尾扫出来的,理论上可能包含
|
|
201
|
+
* 这趟新造的文件,把它搬回来等于凭空激活一条运行前不存在的记忆(Q06 的形态)。
|
|
202
|
+
*/
|
|
203
|
+
for (const a of runManifest.archive?.archived ?? []) {
|
|
204
|
+
if (!a?.ok || !a.file) continue
|
|
205
|
+
const name = a.file
|
|
206
|
+
if (restored.includes(name)) continue // 上面 `changes` 那段已经把它放回来了
|
|
207
|
+
if (!snapSet.has(name)) {
|
|
208
|
+
skipped.push(`${name}(运行前快照里没有,说明是这趟新造的,回滚不激活它)`)
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (await stat(join(this.memoryDir, name)).catch(() => null)) {
|
|
212
|
+
skipped.push(`${name}(顶层已有同名文件,不覆盖)`)
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
const res = await restoreMemory(this.memoryDir, name)
|
|
216
|
+
if (res.ok) restored.push(name)
|
|
217
|
+
else skipped.push(`${name}(恢复失败:${res.error})`)
|
|
218
|
+
}
|
|
158
219
|
}
|
|
159
220
|
|
|
160
221
|
// ③ 留痕
|
|
@@ -36,7 +36,24 @@ export async function snapshot(id, kind = 'run') {
|
|
|
36
36
|
if (err?.code === 'EEXIST') throw new Error(`回滚点 id 撞车:${id} 已存在,拒绝覆盖已有的回滚点`)
|
|
37
37
|
throw err
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* ⚠️ 枚举失败**不能**当成「目录是空的」。
|
|
41
|
+
*
|
|
42
|
+
* 旧写法是 `.catch(() => [])`:读不动时静默返回空列表,于是生成一份 `count=0 / expected=0 /
|
|
43
|
+
* failed=[]` 的快照 —— 它在任何一个下游检查里都长得像「一次成功的快照」,而实际上它
|
|
44
|
+
* 什么都没存。回滚据此认为「退得回去」,`scope:'all'` 时更会把顶层文件全部当成
|
|
45
|
+
* 「快照里没有」而停进 `archive/`。
|
|
46
|
+
*
|
|
47
|
+
* `ENOENT` 是唯一合法的「这里没有文件」(记忆目录还没建)。其余一律出声:宁可让调用方
|
|
48
|
+
* 在写任何东西之前中止,也不要交出一份假的回滚点。
|
|
49
|
+
*/
|
|
50
|
+
let entries
|
|
51
|
+
try {
|
|
52
|
+
entries = await readdir(this.memoryDir, { withFileTypes: true })
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (err?.code === 'ENOENT') entries = []
|
|
55
|
+
else throw new Error(`快照失败:读不了记忆目录(${err?.message ?? err}),拒绝生成一份不完整的回滚点`)
|
|
56
|
+
}
|
|
40
57
|
const files = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
41
58
|
const recorded = []
|
|
42
59
|
/** 没存下来的文件。**不是「警告一下就继续」**:调用方要拿它决定是否中止。 */
|
package/lib/autodream-audit.js
CHANGED
|
@@ -28,14 +28,17 @@ import { join } from 'node:path'
|
|
|
28
28
|
const INDEX_FILE = 'MEMORY.md'
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* 审计**整个跳过**的文件(不读、不当被审对象)。
|
|
32
32
|
*
|
|
33
|
-
* session-log.md
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* session-log.md 是追加型流水,逐会话的账本而不是一条记忆;它没有
|
|
34
|
+
* `user_/feedback_/project_/reference_` 前缀,扫进来只会白白读一大坨再报假问题。
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ 别和 `lib/memory/config.js` 的 `NON_ENTRY_FILES` 混为一谈 —— **同名不同义**:
|
|
37
|
+
* 那个管「列不列进设置页 / 星图 / 检索」,这个管「审计读不读」。它们的差集就是
|
|
38
|
+
* **索引 `MEMORY.md`**:它**要**被读(要当链接来源、要查行尾与链接),只是
|
|
39
|
+
* **不是一条记忆** —— 见下面 `f !== INDEX_FILE` 那两处。
|
|
37
40
|
*/
|
|
38
|
-
const
|
|
41
|
+
const AUDIT_SKIP_FILES = new Set(['session-log.md'])
|
|
39
42
|
|
|
40
43
|
/** 归档区目录名(与 autodream-tools.js 的 ARCHIVE_DIR、宿主侧 memory/scan.js 同名)。 */
|
|
41
44
|
const ARCHIVE_DIR = 'archive'
|
|
@@ -95,7 +98,7 @@ async function readDirSnapshot(dir) {
|
|
|
95
98
|
const all = entries
|
|
96
99
|
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
97
100
|
.map((e) => e.name)
|
|
98
|
-
const files = all.filter((f) => !
|
|
101
|
+
const files = all.filter((f) => !AUDIT_SKIP_FILES.has(f.toLowerCase()))
|
|
99
102
|
let indexText = ''
|
|
100
103
|
try {
|
|
101
104
|
indexText = await readFile(join(dir, INDEX_FILE), 'utf8')
|
|
@@ -126,6 +129,33 @@ async function readDirSnapshot(dir) {
|
|
|
126
129
|
return { files, all, skipped: all.length - files.length, indexText, archiveKeys, archiveCount }
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
/**
|
|
133
|
+
* 把**代码**从正文里拿掉,只留散文 —— 用来避免把代码里的 `[[…]]` 当成链接。
|
|
134
|
+
*
|
|
135
|
+
* 两类都要剥:**围栏代码块**(跨行,```` ``` ```` 起止)与**行内代码**
|
|
136
|
+
* (`` `x` ``、`` `` x `` `` —— 后者的内容里可以再出现反引号,所以闭合必须找
|
|
137
|
+
* **等长**的那一串,不能用「遇到下一枚反引号就算完」)。
|
|
138
|
+
*
|
|
139
|
+
* ⚠️ **按行剥行内代码**:行内代码本来就极少跨行,按行做能把「反引号不配对」时的
|
|
140
|
+
* 损失限制在一行内,不至于把后面真正的链接整段吞掉。2026-10-09 实测踩过一次:
|
|
141
|
+
* 只认单反引号时,`` `` `[[双链]]` `` `` 这种写法会被漏掉、照样报假断链。
|
|
142
|
+
* @param {string} raw — 文件全文
|
|
143
|
+
*/
|
|
144
|
+
function stripCode(raw) {
|
|
145
|
+
const out = []
|
|
146
|
+
let fence = null
|
|
147
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
148
|
+
const m = line.match(/^\s*(`{3,})/)
|
|
149
|
+
if (fence !== null) {
|
|
150
|
+
if (m && m[1].length >= fence.length) fence = null // 围栏闭合
|
|
151
|
+
continue // 围栏内的行整行丢弃(含起止那两行)
|
|
152
|
+
}
|
|
153
|
+
if (m) { fence = m[1]; continue }
|
|
154
|
+
out.push(line.replace(/(`+)[^\n]*?\1/g, ''))
|
|
155
|
+
}
|
|
156
|
+
return out.join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
129
159
|
/**
|
|
130
160
|
* 七查主入口。
|
|
131
161
|
* @param {string} dir — 记忆根目录
|
|
@@ -175,13 +205,28 @@ export async function auditMemoryDir(dir) {
|
|
|
175
205
|
}
|
|
176
206
|
for (const f of readable) linkKeys.add(f)
|
|
177
207
|
for (const f of readable) linkKeys.add(f.replace(/\.md$/, ''))
|
|
208
|
+
/**
|
|
209
|
+
* ⚠️ 链接目标池还必须含**非条目文件**(`session-log.md` / `MEMORY.md`)。
|
|
210
|
+
*
|
|
211
|
+
* 它们不在 `readable` 里(不参与检索),但 `[[session-log]]` 指向的是**真实存在的文件** ——
|
|
212
|
+
* 只因它不参与检索就报成断链,是假阳性。这与上面 `dangling` 用 `allSet` 而非 `files`
|
|
213
|
+
* 判定悬空是同一个理由,别只在一处想到。(2026-10-09 实测:健康记忆库被报 1 条假断链)
|
|
214
|
+
*/
|
|
215
|
+
for (const f of all) {
|
|
216
|
+
linkKeys.add(f)
|
|
217
|
+
linkKeys.add(f.replace(/\.md$/i, ''))
|
|
218
|
+
}
|
|
178
219
|
|
|
179
220
|
for (const f of readable) {
|
|
180
221
|
const raw = contents.get(f)
|
|
181
222
|
if (raw.includes('\r')) crlf.push(f)
|
|
182
223
|
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
183
224
|
if (!fmMatch) {
|
|
184
|
-
|
|
225
|
+
/**
|
|
226
|
+
* 索引 `MEMORY.md` 是**纯清单、不是一条记忆**(见 `lib/memory/config.js` 的
|
|
227
|
+
* `NON_ENTRY_FILES` 说明),不要求 frontmatter。报它等于给每一个健康的记忆库挂一盏常驻红灯。
|
|
228
|
+
*/
|
|
229
|
+
if (f !== INDEX_FILE) noFrontmatter.push(f)
|
|
185
230
|
} else {
|
|
186
231
|
const meta = parseFrontmatterLite(raw)
|
|
187
232
|
const prefix = f.split('_')[0]
|
|
@@ -196,7 +241,14 @@ export async function auditMemoryDir(dir) {
|
|
|
196
241
|
}
|
|
197
242
|
}
|
|
198
243
|
}
|
|
199
|
-
|
|
244
|
+
/**
|
|
245
|
+
* ⚠️ 只在**非代码**文本里找双链。
|
|
246
|
+
*
|
|
247
|
+
* `` `[[双链]]` `` 写在行内代码或围栏代码块里,是在**讲链接语法本身**,不是一条链接。
|
|
248
|
+
* 直接在原文上 `matchAll` 会把它报成断链(2026-10-09 实测的假阳性)。
|
|
249
|
+
*/
|
|
250
|
+
const prose = stripCode(raw)
|
|
251
|
+
for (const m of prose.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
200
252
|
const target = m[1].trim()
|
|
201
253
|
if (linkKeys.has(target)) continue
|
|
202
254
|
// 指向归档区的链接:单列提示、不计问题 —— 归档不是删除,目标还在库里躺着。
|
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 plain markdown files, with a memory star-map view, type/tag filtering, retrieval aliases, and an optional autodream pass that consolidates memories on demand or on a schedule — with per-run rollback points, a per-file change declaration (what changed and why), and a separately selectable model for the consolidation pass",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"cordis.patch.yml",
|
|
16
16
|
"NOTICE"
|
|
17
17
|
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node test/run.mjs",
|
|
20
|
+
"build:client": "node scripts/build-client.mjs",
|
|
21
|
+
"images": "node scripts/make-readme-images.mjs"
|
|
22
|
+
},
|
|
18
23
|
"license": "Apache-2.0",
|
|
19
24
|
"engines": {
|
|
20
25
|
"node": ">=18",
|
|
@@ -85,10 +90,5 @@
|
|
|
85
90
|
"ai-agent",
|
|
86
91
|
"agent",
|
|
87
92
|
"claude-code"
|
|
88
|
-
]
|
|
89
|
-
|
|
90
|
-
"test": "node test/run.mjs",
|
|
91
|
-
"build:client": "node scripts/build-client.mjs",
|
|
92
|
-
"images": "node scripts/make-readme-images.mjs"
|
|
93
|
-
}
|
|
94
|
-
}
|
|
93
|
+
]
|
|
94
|
+
}
|