sage-mem 2.1.17 → 2.1.18
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/autodream/audit.js +63 -21
- package/lib/memory/index-file.js +31 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
14
|
|
|
15
|
-
**Current release: `2.1.
|
|
15
|
+
**Current release: `2.1.18`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
|
|
16
16
|
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
17
|
|
|
18
18
|
If it is useful to you, a Star is the most direct feedback.
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
|
|
16
16
|
|
|
17
|
-
**当前发布版本:`2.1.
|
|
17
|
+
**当前发布版本:`2.1.18`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
|
|
18
18
|
[契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
|
|
19
19
|
|
|
20
20
|
如果它对你有用,给个 Star 是最直接的反馈。
|
package/lib/autodream/audit.js
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
* sage-mem autodream — 记忆目录审计(七查)。
|
|
3
3
|
*
|
|
4
4
|
* 审计清单内置在 host 侧,让 autodream 每次跑完都能自证
|
|
5
|
-
*
|
|
6
|
-
* 1. 索引悬空 ——
|
|
5
|
+
* 没把记忆改坏(七项,外加「读不动」「归档残留」「索引大小写冲突」各单列一类):
|
|
6
|
+
* 1. 索引悬空 —— 索引指向不存在的文件
|
|
7
7
|
* 2. 漏索引 —— 存在但没进索引的记忆文件
|
|
8
8
|
* 3. type 与前缀不一致(分类看 type,前缀骗不了它)
|
|
9
9
|
* 4. CRLF —— 「某个 Windows 编辑器碰过」的可疑信号
|
|
10
10
|
* 5. 双链断链 —— [[目标]] 找不到对应文件或 frontmatter name
|
|
11
11
|
* 6. 未加引号值里的「空格+#」—— 会被 YAML 当注释吞掉,解析不报错但描述残缺
|
|
12
|
+
* 7. 索引大小写冲突 —— 同一目录同时存在 MEMORY.md / memory.md 等多个变体
|
|
13
|
+
* (只在大小写敏感的卷上可能出现):读哪一个都不算错,**歧义本身**要报。
|
|
14
|
+
* 索引的识别一律走 `memory/index-file.js`,不在本模块写死文件名。
|
|
12
15
|
*
|
|
13
16
|
* **归档区(`archive/`)不是审计对象**,只贡献「名字池」:正文里指向已归档记忆的
|
|
14
17
|
* `[[链接]]` 单列成 `archivedLinks`(提示),**不计入 problems** —— 归档是「静默失效
|
|
@@ -24,14 +27,12 @@
|
|
|
24
27
|
import { readdir, readFile } from 'node:fs/promises'
|
|
25
28
|
import { join } from 'node:path'
|
|
26
29
|
import { hasFrontmatter, parseFrontmatter } from '../memory/frontmatter.js'
|
|
30
|
+
import { isIndexName, resolveIndexNames } from '../memory/index-file.js'
|
|
27
31
|
import { parseValidityTime } from '../memory/validity.js'
|
|
28
32
|
import { SESSION_LOG_KEY, isPlainMdName } from '../shared/md-name.js'
|
|
29
33
|
import { ARCHIVE_DIR_NAME, inspectMemoryEntry } from '../memory/scan.js'
|
|
30
34
|
import { warnLog } from '../memory/util.js'
|
|
31
35
|
|
|
32
|
-
/** 索引文件名。它本身不是一条记忆,是索引。 */
|
|
33
|
-
const INDEX_FILE = 'MEMORY.md'
|
|
34
|
-
|
|
35
36
|
/**
|
|
36
37
|
* 归档 / 恢复中途崩溃留下的**私有名残留**:`a.md.archiving-<hex>` / `a.md.restoring-<hex>`。
|
|
37
38
|
*
|
|
@@ -55,7 +56,7 @@ const STRAY_RE = /\.(?:archiving|restoring)-[0-9a-f]+$/i
|
|
|
55
56
|
* ⚠️ 别和 `lib/memory/config.js` 的 `NON_ENTRY_FILES` 混为一谈 —— **同名不同义**:
|
|
56
57
|
* 那个管「列不列进设置页 / 星图 / 检索」,这个管「审计读不读」。它们的差集就是
|
|
57
58
|
* **索引 `MEMORY.md`**:它**要**被读(要当链接来源、要查行尾与链接),只是
|
|
58
|
-
* **不是一条记忆** —— 见下面 `
|
|
59
|
+
* **不是一条记忆** —— 见下面 `isIndexName` 那几处。
|
|
59
60
|
*/
|
|
60
61
|
const AUDIT_SKIP_FILES = new Set([SESSION_LOG_KEY])
|
|
61
62
|
|
|
@@ -123,8 +124,14 @@ export function indexTargets(indexText) {
|
|
|
123
124
|
/**
|
|
124
125
|
* 扫一个目录下的记忆文件(不含子目录 —— 归档区就该扫不到),
|
|
125
126
|
* 外加归档区的**名字池**(只有名字,不做检查)。
|
|
127
|
+
*
|
|
128
|
+
* 索引的识别与读取**不在这里写死文件名**:走 `memory/index-file.js` 的
|
|
129
|
+
* `resolveIndexNames`(大小写不敏感、多个变体时给出冲突列表),与「恢复归档时补索引」
|
|
130
|
+
* 等入口同一套规则。外部审查 Q17 的根因就是这里原先硬编码 `MEMORY.md`。
|
|
131
|
+
*
|
|
126
132
|
* @param {string} dir
|
|
127
|
-
* @returns {Promise<{files: string[], all: string[], skipped: number, indexText: string,
|
|
133
|
+
* @returns {Promise<{files: string[], all: string[], skipped: number, indexText: string,
|
|
134
|
+
* indexFile: string|null, indexVariants: string[], archiveKeys: Set<string>, archiveCount: number}>}
|
|
128
135
|
*/
|
|
129
136
|
async function readDirSnapshot(dir) {
|
|
130
137
|
let entries
|
|
@@ -132,6 +139,12 @@ async function readDirSnapshot(dir) {
|
|
|
132
139
|
const all = entries
|
|
133
140
|
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
134
141
|
.map((e) => e.name)
|
|
142
|
+
/**
|
|
143
|
+
* 索引的**实际磁盘名**:大小写不敏感地定位(与 `index-file.js` 同一套规则)。
|
|
144
|
+
* 同时拿到全部大小写变体 —— 多个变体只在大小写敏感的卷上可能同时存在,
|
|
145
|
+
* 那时目录本身有歧义,报出来而不是静默挑一个。
|
|
146
|
+
*/
|
|
147
|
+
const index = resolveIndexNames(entries.filter((e) => e.isFile()).map((e) => e.name))
|
|
135
148
|
const files = []
|
|
136
149
|
for (const f of all) {
|
|
137
150
|
if (AUDIT_SKIP_FILES.has(f.toLowerCase())) continue
|
|
@@ -150,10 +163,12 @@ async function readDirSnapshot(dir) {
|
|
|
150
163
|
files.push(f)
|
|
151
164
|
}
|
|
152
165
|
let indexText = ''
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
if (index.name !== null) {
|
|
167
|
+
try {
|
|
168
|
+
indexText = await readFile(join(dir, index.name), 'utf8')
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err?.code !== 'ENOENT') throw err
|
|
171
|
+
}
|
|
157
172
|
}
|
|
158
173
|
// 归档区:文件名、去掉 .md 的名字、以及 frontmatter 里的 `name`。
|
|
159
174
|
// 内容读不动只少一个名字,绝不让整场审计失败 —— 归档区本来就不是被审对象。
|
|
@@ -176,7 +191,17 @@ async function readDirSnapshot(dir) {
|
|
|
176
191
|
} catch (err) {
|
|
177
192
|
if (err?.code !== 'ENOENT') throw err
|
|
178
193
|
}
|
|
179
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
files,
|
|
196
|
+
all,
|
|
197
|
+
skipped: all.length - files.length,
|
|
198
|
+
indexText,
|
|
199
|
+
// 实际读取的索引名(null = 没有索引),以及全部大小写变体(>1 = 冲突)。
|
|
200
|
+
indexFile: index.name,
|
|
201
|
+
indexVariants: index.variants,
|
|
202
|
+
archiveKeys,
|
|
203
|
+
archiveCount,
|
|
204
|
+
}
|
|
180
205
|
}
|
|
181
206
|
|
|
182
207
|
/**
|
|
@@ -213,12 +238,21 @@ function stripCode(raw) {
|
|
|
213
238
|
* @param {{nowMs?: number}} [opts] — `nowMs` 只用于「未来 `invalid_at`」那条提示:
|
|
214
239
|
* 审计本身要读盘(不纯),但同一条规律仍然成立 —— 判定依赖的时钟必须能由调用方钉住,
|
|
215
240
|
* 否则夹具没法写出「未来」这种确定性场景(R3)。不传才用当下。
|
|
216
|
-
* @returns {Promise<object>} 结构化审计结果(problems
|
|
241
|
+
* @returns {Promise<object>} 结构化审计结果(problems 是所有计分项之和)
|
|
217
242
|
*/
|
|
218
243
|
export async function auditMemoryDir(dir, opts = {}) {
|
|
219
|
-
const { files, all, skipped, indexText, archiveKeys, archiveCount } =
|
|
244
|
+
const { files, all, skipped, indexText, indexFile, indexVariants, archiveKeys, archiveCount } =
|
|
245
|
+
await readDirSnapshot(dir)
|
|
220
246
|
const nowMs = Number.isFinite(opts?.nowMs) ? opts.nowMs : Date.now()
|
|
221
|
-
|
|
247
|
+
// 索引本身不是一条记忆:大小写归一后排除(`memory.md` / `MEMORY.MD` 一样排除)。
|
|
248
|
+
const entries = files.filter((f) => !isIndexName(f))
|
|
249
|
+
/**
|
|
250
|
+
* 索引的大小写变体同时存在 = 目录本身有歧义(只在大小写敏感卷上可能)。
|
|
251
|
+
*
|
|
252
|
+
* 定位规则会优先读规范名,但**另一份文件仍在磁盘上**:用户改了它不会生效。
|
|
253
|
+
* 这必须单独报出来让人删掉多余的,而不是静默挑一个(Q17)。
|
|
254
|
+
*/
|
|
255
|
+
const indexConflicts = indexVariants.length > 1 ? indexVariants.map((file) => ({ file })) : []
|
|
222
256
|
|
|
223
257
|
const targets = indexTargets(indexText)
|
|
224
258
|
const targetSet = new Set(targets.map((t) => t.toLowerCase()))
|
|
@@ -293,24 +327,25 @@ export async function auditMemoryDir(dir, opts = {}) {
|
|
|
293
327
|
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
294
328
|
if (!fmMatch) {
|
|
295
329
|
/**
|
|
296
|
-
*
|
|
297
|
-
* `NON_ENTRY_FILES`
|
|
330
|
+
* 索引(`MEMORY.md`,**大小写不敏感**:memory.md / MEMORY.MD 等同)是
|
|
331
|
+
* **纯清单、不是一条记忆**(见 `lib/memory/config.js` 的 `NON_ENTRY_FILES` 说明),
|
|
332
|
+
* 不要求 frontmatter。报它等于给每一个健康的记忆库挂一盏常驻红灯。
|
|
298
333
|
*/
|
|
299
|
-
if (f
|
|
334
|
+
if (!isIndexName(f)) noFrontmatter.push(f)
|
|
300
335
|
} else {
|
|
301
336
|
const meta = parseFrontmatterLite(raw)
|
|
302
337
|
const prefix = f.split('_')[0]
|
|
303
338
|
const effective = meta.type || 'reference'
|
|
304
339
|
/**
|
|
305
|
-
* ⚠️ **索引同样豁免「type 与前缀」这条**,与上面
|
|
340
|
+
* ⚠️ **索引同样豁免「type 与前缀」这条**,与上面 `!isIndexName(f)` 是同一个理由。
|
|
306
341
|
*
|
|
307
|
-
*
|
|
342
|
+
* 索引名没有下划线,`f.split('_')[0]` 得到的就是整个文件名;而历史版本的
|
|
308
343
|
* `write_memory` 硬要求内容以 frontmatter 开头,模型只好给索引补一个 `type: reference`
|
|
309
344
|
* —— 于是**每一个健康的记忆库都常驻一条**「type=reference 前缀=MEMORY.md」,把
|
|
310
345
|
* `problems` 这个唯一健康数字变成恒大于 0 的噪音(外部报告 2026-10-25 抓到)。
|
|
311
346
|
* 索引不是一条记忆,它没有「前缀」可言,这条检查对它不成立。
|
|
312
347
|
*/
|
|
313
|
-
if (f
|
|
348
|
+
if (!isIndexName(f) && effective !== prefix) {
|
|
314
349
|
typeMismatch.push({ file: f, type: meta.type || '(缺失)', prefix, effective })
|
|
315
350
|
}
|
|
316
351
|
for (const line of fmMatch[1].split(/\r?\n/)) {
|
|
@@ -383,6 +418,7 @@ export async function auditMemoryDir(dir, opts = {}) {
|
|
|
383
418
|
const problems =
|
|
384
419
|
dangling.length +
|
|
385
420
|
unlisted.length +
|
|
421
|
+
indexConflicts.length +
|
|
386
422
|
typeMismatch.length +
|
|
387
423
|
crlf.length +
|
|
388
424
|
brokenLinks.length +
|
|
@@ -397,8 +433,12 @@ export async function auditMemoryDir(dir, opts = {}) {
|
|
|
397
433
|
skipped,
|
|
398
434
|
archivedCount: archiveCount,
|
|
399
435
|
indexEntries: targets.length,
|
|
436
|
+
// 实际读取的索引名(null = 没有索引):大小写不敏感地定位,不用猜盘上是哪一种写法。
|
|
437
|
+
indexFile,
|
|
400
438
|
dangling: dangling.map((file) => ({ file })),
|
|
401
439
|
unlisted: unlisted.map((file) => ({ file })),
|
|
440
|
+
// 同一目录存在多个大小写变体(只在大小写敏感卷上可能)—— 单列一类、计入 problems。
|
|
441
|
+
indexConflicts,
|
|
402
442
|
typeMismatch,
|
|
403
443
|
crlf: crlf.map((file) => ({ file })),
|
|
404
444
|
brokenLinks,
|
|
@@ -432,6 +472,8 @@ export function formatAudit(report, limit = 12) {
|
|
|
432
472
|
}
|
|
433
473
|
section('索引悬空', report.dangling, (r) => r.file)
|
|
434
474
|
section('漏索引', report.unlisted, (r) => r.file)
|
|
475
|
+
// 多个大小写变体各自都可能是「用户以为的那份索引」:两边都列出来,让他删掉多余的。
|
|
476
|
+
section('索引大小写冲突(同一目录存在多个变体,请只保留一个)', report.indexConflicts ?? [], (r) => r.file)
|
|
435
477
|
section('type 与前缀不一致', report.typeMismatch, (r) => `${r.file} type=${r.type} 前缀=${r.prefix}`)
|
|
436
478
|
section('CRLF', report.crlf, (r) => r.file)
|
|
437
479
|
section('双链断链', report.brokenLinks, (r) => `${r.file} -> [[${r.target}]]`)
|
package/lib/memory/index-file.js
CHANGED
|
@@ -50,13 +50,43 @@ async function readIndexText(indexPath) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** 索引的规范文件名。大小写敏感卷上同时存在多个变体时,优先读它(与文档写法一致)。 */
|
|
54
|
+
export const CANONICAL_INDEX_NAME = 'MEMORY.md'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 这个名字算不算索引(大小写不敏感)——**唯一**判据,与 `findIndexFile` 同一套。
|
|
58
|
+
*
|
|
59
|
+
* 为什么单列出来:体检(`autodream/audit.js`)原先硬编码 `MEMORY.md`,排除索引时
|
|
60
|
+
* 又用精确字符串比较,于是索引合法地叫 `memory.md` 时,体检把索引本身报成
|
|
61
|
+
* 「漏索引 + 无 frontmatter」(外部审查 Q17)。所有「这是不是索引」的判断都走这里。
|
|
62
|
+
*/
|
|
63
|
+
export function isIndexName(name) {
|
|
64
|
+
return nameKey(name) === INDEX_KEY
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 从一组目录项名里定位索引:返回**全部大小写变体**与「该读哪一个」。
|
|
69
|
+
*
|
|
70
|
+
* 多个变体只可能在大小写敏感的卷上同时存在(NTFS 默认做不到同一目录两份)。
|
|
71
|
+
* 这里优先规范名 `MEMORY.md`,否则按字典序取第一个 —— 保证同一目录每次挑同一个;
|
|
72
|
+
* 同时把变体列表交出去,让调用方**明确报冲突**,而不是静默挑一个(Q17)。
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} names
|
|
75
|
+
* @returns {{name: string|null, variants: string[]}}
|
|
76
|
+
*/
|
|
77
|
+
export function resolveIndexNames(names) {
|
|
78
|
+
const variants = (Array.isArray(names) ? names : []).filter((n) => isIndexName(n)).sort()
|
|
79
|
+
if (variants.length === 0) return { name: null, variants }
|
|
80
|
+
return { name: variants.includes(CANONICAL_INDEX_NAME) ? CANONICAL_INDEX_NAME : variants[0], variants }
|
|
81
|
+
}
|
|
82
|
+
|
|
53
83
|
/**
|
|
54
84
|
* 索引文件的**磁盘真名**(大小写不敏感地找)。
|
|
55
85
|
* 不假设盘上是 `MEMORY.md` —— 系统盘是 Windows、用户可能就写成了小写。
|
|
56
86
|
*/
|
|
57
87
|
export async function findIndexFile(memoryDir) {
|
|
58
88
|
const names = await readdir(memoryDir).catch(() => [])
|
|
59
|
-
return names.
|
|
89
|
+
return resolveIndexNames(names).name
|
|
60
90
|
}
|
|
61
91
|
|
|
62
92
|
/** 索引里有没有指向该文件的条目(`](file.md)` 或 `](file.md#L12)`,大小写不敏感)。 */
|
package/package.json
CHANGED