sage-mem 2.1.17 → 2.1.19

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
@@ -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.17`** — 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
15
+ **Current release: `2.1.19`** — 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`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.19`** —— 顶部 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 是最直接的反馈。
@@ -2,13 +2,16 @@
2
2
  * sage-mem autodream — 记忆目录审计(七查)。
3
3
  *
4
4
  * 审计清单内置在 host 侧,让 autodream 每次跑完都能自证
5
- * 没把记忆改坏(七项,外加「读不动」单列一类):
6
- * 1. 索引悬空 —— MEMORY.md 指向不存在的文件
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
- * **不是一条记忆** —— 见下面 `f !== INDEX_FILE` 那两处。
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, archiveKeys: Set<string>, archiveCount: number}>}
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
- try {
154
- indexText = await readFile(join(dir, INDEX_FILE), 'utf8')
155
- } catch (err) {
156
- if (err?.code !== 'ENOENT') throw err
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 { files, all, skipped: all.length - files.length, indexText, archiveKeys, archiveCount }
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 } = await readDirSnapshot(dir)
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
- const entries = files.filter((f) => f !== INDEX_FILE)
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
- * 索引 `MEMORY.md` 是**纯清单、不是一条记忆**(见 `lib/memory/config.js` 的
297
- * `NON_ENTRY_FILES` 说明),不要求 frontmatter。报它等于给每一个健康的记忆库挂一盏常驻红灯。
330
+ * 索引(`MEMORY.md`,**大小写不敏感**:memory.md / MEMORY.MD 等同)是
331
+ * **纯清单、不是一条记忆**(见 `lib/memory/config.js` 的 `NON_ENTRY_FILES` 说明),
332
+ * 不要求 frontmatter。报它等于给每一个健康的记忆库挂一盏常驻红灯。
298
333
  */
299
- if (f !== INDEX_FILE) noFrontmatter.push(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 与前缀」这条**,与上面 `f !== INDEX_FILE` 是同一个理由。
340
+ * ⚠️ **索引同样豁免「type 与前缀」这条**,与上面 `!isIndexName(f)` 是同一个理由。
306
341
  *
307
- * `MEMORY.md` 没有下划线,`f.split('_')[0]` 得到的就是整个 `MEMORY.md`;而历史版本的
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 !== INDEX_FILE && effective !== prefix) {
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}]]`)
@@ -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.find((n) => nameKey(n) === INDEX_KEY) ?? null
89
+ return resolveIndexNames(names).name
60
90
  }
61
91
 
62
92
  /** 索引里有没有指向该文件的条目(`](file.md)` 或 `](file.md#L12)`,大小写不敏感)。 */
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { constants } from 'node:fs'
6
6
  import { randomUUID } from 'node:crypto'
7
- import { copyFile, link, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
7
+ import { chmod, copyFile, link, lstat, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
8
8
 
9
9
  /**
10
10
  * 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
@@ -46,6 +46,31 @@ async function renameWithRetry(from, to, attempts = 8) {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * 新建文件(写进本原语的那些记忆与状态)的默认 mode:**只对本人可读写**。
51
+ *
52
+ * 为什么是明确的 0600 而不是「umask 默认」(2026-10-31 外部审查 Q18):记忆正文是隐私
53
+ * 内容,权限不该由开发机 / 服务器的 umask 环境决定(umask=022 时默认新建是 0644,
54
+ * 同机其它账号可读)。写进 `writeFileAtomic` 的既有文件一律继承原 mode,
55
+ * 所以这一条只影响**新建**。
56
+ */
57
+ const NEW_FILE_MODE = 0o600
58
+
59
+ /**
60
+ * 这次替换该用的 mode:**已有普通文件**继承它现在的权限位;新建文件用保守默认。
61
+ *
62
+ * ⚠️ 用 `lstat` 而不是 `stat`(2026-10-31 外部审查 Q18):目标是符号链接时不该跟随 ——
63
+ * `stat` 会拿到链接指向的**外部**文件的权限,等于把记忆文件的权限交给别处的文件决定;
64
+ * 而 rename 之后被替换的是链接本身(不是穿透),本来也不该继承外部的 mode。
65
+ *
66
+ * @returns {Promise<number>} mode 位(含 setuid/setgid/sticky:`& 0o7777`)
67
+ */
68
+ async function modeForTarget(target) {
69
+ const info = await lstat(target).catch(() => null)
70
+ if (!info || !info.isFile() || typeof info.mode !== 'number') return NEW_FILE_MODE
71
+ return info.mode & 0o7777
72
+ }
73
+
49
74
  /**
50
75
  * 原子写:先写同目录临时文件,再 rename 覆盖目标。
51
76
  *
@@ -53,13 +78,28 @@ async function renameWithRetry(from, to, attempts = 8) {
53
78
  * 缺半边,这条记忆从此解析不出来、检索不到,而文件看上去还在。
54
79
  * 同卷 rename 是原子的:读方要么看到旧全文、要么看到新全文,不存在中间态。
55
80
  * 任何一步失败都不碰目标文件,**自己那份**临时文件收尾清掉。
81
+ *
82
+ * ⚠️ **权限继承**(2026-10-31 外部审查 Q18):rename 替换的是**目录项**,新 inode
83
+ * 不会继承原文件的 mode。旧写法新建临时文件走默认 mode,于是一个被用户设成 0600 的
84
+ * 记忆,面板保存一次就变成 0644(umask=022),隐私被悄悄放宽,而且没有任何提示。
85
+ * 现在在 rename **之前**把 mode 贴到临时文件上:
86
+ * - 已有普通文件 → 继承它现在的权限(用 lstat,不跟随符号链接);
87
+ * - 新建文件 → 明确的保守模式 `NEW_FILE_MODE`(0600,不随 umask 漂)。
88
+ * 先 `writeFile(tmp, …, { mode })` 让临时文件**一开始就不会比目标宽**,再 `chmod` 成精确值
89
+ * (writeFile 的 mode 会被 umask 掩掉一部分,比如 0660 & ~022 = 0640)。
90
+ * 任何一步失败都发生在 rename 之前 → 目标文件一个字节、一个权限位都不会动。
91
+ * Windows 上 chmod 是 no-op,这里不依赖它能生效(行为断言在 POSIX 上跑)。
56
92
  */
57
93
  export async function writeFileAtomic(target, content) {
58
94
  tmpSeq += 1
59
95
  const uniq = `${process.pid}-${Date.now().toString(36)}-${tmpSeq.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
60
96
  const tmp = `${target}.${uniq}.tmp`
97
+ const mode = await modeForTarget(target)
61
98
  try {
62
- await writeFile(tmp, content, 'utf8')
99
+ await writeFile(tmp, content, { encoding: 'utf8', mode })
100
+ // 精确落位:writeFile 传的 mode 受 umask 掩码,chmod 不受 —— 两者都做才既「不会更宽」
101
+ // 又「不会被收紧」。放在 rename 之前,失败时目标仍是原样。
102
+ await chmod(tmp, mode)
63
103
  await renameWithRetry(tmp, target)
64
104
  } finally {
65
105
  // rename 成功后 tmp 已不存在(ENOENT),失败时把它清掉不留垃圾。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "2.1.17",
4
+ "version": "2.1.19",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",