sage-mem 0.9.3 → 0.9.5

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
@@ -156,7 +156,7 @@ SAGE_MEM_DIR=/path/to/your/memory
156
156
  - **`baseline: true` 的记忆每场会话第一步无条件注入**,最多 8 条——给「你是谁」「待办清单」这类每次开场都该在场的记忆用。多条 baseline 之间的先后由 `baselinePriority` 决定(数字越大越前,缺省 0),**不按文件名排**
157
157
  - 记忆目录读不到、权限错、frontmatter 格式坏,这些都**降级为不注入并写日志**,不会静默失效
158
158
 
159
- 单文件上限 512 KB(写入时校验);标签最多 20 个、每个 40 字符(再多就不是标签,是一句话被塞进 `tags` 了)。
159
+ 单文件上限 512 KB,**读写两侧都校验**(读的时候先看体积,超了就不读进来);标签最多 20 个、每个 40 字符(再多就不是标签,是一句话被塞进 `tags` 了)。
160
160
 
161
161
  ## 自动做梦(autodream)
162
162
 
@@ -261,7 +261,7 @@ sage-mem 只做三件事,全都在你本机:
261
261
 
262
262
  | 它做什么 | 具体范围 |
263
263
  |---|---|
264
- | **读记忆** | 只读 `SAGE_MEM_DIR`(默认 `~/.sage-mem/memory`)下的顶层 `*.md`;单文件上限 512 KB 在**写入**时校验 |
264
+ | **读记忆** | 只读 `SAGE_MEM_DIR`(默认 `~/.sage-mem/memory`)下的顶层 `*.md`,单文件上限 512 KB(读写两侧都查) |
265
265
  | **写记忆** | 经 agent 的文件工具或设置页写同一个目录;`MEMORY.md`、`session-log.md` 等保留名受保护,设置页碰不到 |
266
266
  | **注入** | 把选中的记忆作为上下文交给模型(最多 10 条相关 + 8 条 `baseline`,单场会话累计上限 60 KB) |
267
267
 
@@ -77,6 +77,8 @@ export class AutodreamEngine {
77
77
  this.phase = ''
78
78
  this.startedAt = 0
79
79
  this.runId = ''
80
+ /** 本次持锁凭据(`gate.js` 的 acquireLock 写入、releaseLock 校验)。 */
81
+ this.lockToken = ''
80
82
  this.stepLog = []
81
83
  this.lastScanAt = 0
82
84
  this.state = { config: { ...DEFAULT_CONFIG }, version: CONFIG_VERSION, lastRunAt: 0, lastResult: null, retryAfter: 0 }
@@ -238,6 +240,7 @@ export class AutodreamEngine {
238
240
  sessionsRoot: this.sessionsRoot,
239
241
  apply: !!cfg.apply,
240
242
  withSessions,
243
+ ctx: this.ctx,
241
244
  })
242
245
  const toolSchemas = autodreamToolSchemas({ apply: !!cfg.apply, withSessions })
243
246
 
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import { LOCK_STALE_MS, SESSION_SCAN_INTERVAL_MS } from './config.js'
10
- import { countSessionsSince } from './util.js'
11
- import { mkdir, open, rm, stat } from 'node:fs/promises'
10
+ import { countSessionsSince, readJson } from './util.js'
11
+ import { link, mkdir, open, rename, rm, stat } from 'node:fs/promises'
12
+ import { randomUUID } from 'node:crypto'
12
13
 
13
14
  /**
14
15
  * 三级门控(顺序即成本顺序)。
@@ -54,25 +55,89 @@ export async function acquireLock() {
54
55
  // 新鲜(未过期)就先拒绝——正常升级流程会重启进程,这条只兜「新旧并行」那一小段窗口。
55
56
  const legacy = await stat(this.legacyLockPath).catch(() => null)
56
57
  if (legacy && Date.now() - legacy.mtimeMs <= LOCK_STALE_MS) return false
58
+ /** 本次持有的凭据:释放时靠它认「这把锁还是我的吗」。 */
59
+ const token = randomUUID()
57
60
  try {
58
61
  const fh = await open(this.lockPath, 'wx')
59
- await fh.write(JSON.stringify({ pid: process.pid, at: Date.now(), runId: this.runId }))
62
+ await fh.write(JSON.stringify({ pid: process.pid, at: Date.now(), runId: this.runId, token }))
60
63
  await fh.close()
64
+ this.lockToken = token
61
65
  return true
62
66
  } catch (err) {
63
67
  if (err?.code !== 'EEXIST') throw err
64
68
  const info = await stat(this.lockPath).catch(() => null)
65
69
  if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
66
- // 上次进程崩了留下的锁:清掉重来(不然 autodream 永远回不来)。
67
- await rm(this.lockPath, { force: true })
70
+ /**
71
+ * 上次进程崩了留下的锁:清掉重来(不然 autodream 永远回不来)。
72
+ *
73
+ * 「判断过期 → 删掉 → 重来」这三步之间有窗口:另一个进程可能在这中间**抢到了这把锁**。
74
+ * 所以改用 `rename` 抢占(原子,同一把锁只有一个进程能移走)。移走之后**两道复核**,
75
+ * 缺一不可:
76
+ *
77
+ * ① `mtime` 仍然超期 —— 这是**真正的判据**。`stat` 判过期与读 `before` 之间还有一次
78
+ * FS 操作的缝:若这期间别人已完成抢占并建了一把**新鲜**锁,我们读到的 `before`
79
+ * 就是它,token 比对会放行,于是两个实例都以为自己拿到了(实测能复现)。
80
+ * ② `token` 与读到的 `before` 一致 —— 防「内容被换过」。
81
+ *
82
+ * 任一条不成立:说明我们移走的不是自己判定的那把过期锁,**还回去、认输**。
83
+ */
84
+ const before = await readJson(this.lockPath)
85
+ const grabbed = `${this.lockPath}.stale-${randomUUID().replace(/-/g, '')}`
86
+ const moved = await rename(this.lockPath, grabbed).then(
87
+ () => true,
88
+ () => false,
89
+ )
90
+ if (!moved) return this.acquireLock() // 别人抢先移走了,直接重来
91
+ const movedInfo = await stat(grabbed).catch(() => null)
92
+ const after = await readJson(grabbed)
93
+ const stillStale = movedInfo !== null && Date.now() - movedInfo.mtimeMs > LOCK_STALE_MS
94
+ if (!stillStale || after?.token !== before?.token) {
95
+ // 还回去**不能覆盖**已经存在的锁(Windows 的 rename 会直接盖掉别人刚建的新锁)。
96
+ // 用 `link` 还原:目标存在时它报 EEXIST,不会覆盖任何东西。
97
+ const restored = await link(grabbed, this.lockPath).then(
98
+ () => true,
99
+ () => false,
100
+ )
101
+ await rm(grabbed, { force: true }).catch((err) => {
102
+ this.log?.('warn', `锁抢占复核不通过且遗留文件清不掉:${grabbed}(${err?.message ?? err})`)
103
+ })
104
+ if (!restored) {
105
+ this.log?.('warn', `锁抢占复核不通过,但锁位置已被占用,未覆盖它:${this.lockPath}`)
106
+ }
107
+ return false
108
+ }
109
+ await rm(grabbed, { force: true }).catch((err) => {
110
+ this.log?.('warn', `过期锁清理失败(残留 ${grabbed}):${err?.message ?? err}`)
111
+ })
68
112
  return this.acquireLock()
69
113
  }
70
114
  return false
71
115
  }
72
116
  }
73
117
 
118
+ /**
119
+ * 释放锁 —— **只删自己那一把**。
120
+ *
121
+ * 旧实现是无条件 `rm`:一个慢下来的持有者(比如刚跑完两小时的整理)释放时,
122
+ * 会把**别人中途抢到的新锁**删掉 —— 于是两个整理进程同时往里写,而两边都以为自己独占。
123
+ * 判据落在锁文件里的 `token` 上:不是自己的,一个字都不动。
124
+ *
125
+ * ⚠️ 边界说清楚:`token` 明文躺在锁文件里,**它不是秘密**,防的是「协作的两个实例各跑各的」
126
+ * 这类误删,不防「能读这个目录的对手」—— 那种对手本来就能直接删锁文件。
127
+ */
74
128
  export async function releaseLock() {
75
- await rm(this.lockPath, { force: true }).catch(() => {})
129
+ const token = this.lockToken
130
+ // 没持有过(acquireLock 从没成功过)→ 什么都不做。旧实现会在这里删掉别人的锁。
131
+ if (!token) return
132
+ const held = await readJson(this.lockPath)
133
+ if (!held || held.token !== token) {
134
+ this.lockToken = ''
135
+ return
136
+ }
137
+ await rm(this.lockPath, { force: true }).catch((err) => {
138
+ this.log?.('warn', `释放锁失败:${err?.message ?? err}`)
139
+ })
140
+ this.lockToken = ''
76
141
  }
77
142
 
78
143
  // ────────────────────────────── 回滚点(快照) ──────────────────────────────
@@ -23,6 +23,8 @@
23
23
 
24
24
  import { readdir, readFile } from 'node:fs/promises'
25
25
  import { join } from 'node:path'
26
+ import { inspectMemoryEntry } from './memory/scan.js'
27
+ import { warnLog } from './memory/util.js'
26
28
 
27
29
  /** 索引文件名。它本身不是一条记忆,是索引。 */
28
30
  const INDEX_FILE = 'MEMORY.md'
@@ -98,7 +100,23 @@ async function readDirSnapshot(dir) {
98
100
  const all = entries
99
101
  .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
100
102
  .map((e) => e.name)
101
- const files = all.filter((f) => !AUDIT_SKIP_FILES.has(f.toLowerCase()))
103
+ const files = []
104
+ for (const f of all) {
105
+ if (AUDIT_SKIP_FILES.has(f.toLowerCase())) continue
106
+ /**
107
+ * 与「列表 / 注入 / 模型工具」同一条闸门。
108
+ *
109
+ * 审计原先自己扫目录、不过闸门,于是同一个目录会给出互相矛盾的两个数:
110
+ * 列表说 1 条(链接 / 超大的被跳过),体检说 3 条 —— 用户根本不知道该信哪个。
111
+ * 被跳过的条目在这里同样不计入 fileCount,原因落到日志。
112
+ */
113
+ const gate = await inspectMemoryEntry(join(dir, f))
114
+ if (!gate.ok) {
115
+ warnLog(null, `audit: skip ${f}: ${gate.error}`)
116
+ continue
117
+ }
118
+ files.push(f)
119
+ }
102
120
  let indexText = ''
103
121
  try {
104
122
  indexText = await readFile(join(dir, INDEX_FILE), 'utf8')
@@ -29,7 +29,8 @@ import { join, basename } from 'node:path'
29
29
  import { createHash } from 'node:crypto'
30
30
  import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
31
31
  import { archiveMemory } from './memory/archive.js'
32
- import { writeFileAtomic } from './memory/util.js'
32
+ import { writeFileAtomic, warnLog } from './memory/util.js'
33
+ import { inspectMemoryEntry, reservedIdentitySet } from './memory/scan.js'
33
34
 
34
35
  /** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
35
36
  const READ_LIMIT = 20000
@@ -103,7 +104,7 @@ function ageDays(ms) {
103
104
  * @param {string} memoryDir
104
105
  * @returns {Promise<Array<object>>}
105
106
  */
106
- async function scanEntries(memoryDir) {
107
+ async function scanEntries(memoryDir, ctx) {
107
108
  let entries
108
109
  try {
109
110
  entries = await readdir(memoryDir, { withFileTypes: true })
@@ -111,14 +112,25 @@ async function scanEntries(memoryDir) {
111
112
  if (err?.code === 'ENOENT') return []
112
113
  throw err
113
114
  }
115
+ const reservedIds = await reservedIdentitySet(memoryDir)
114
116
  const names = entries
115
117
  .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !NON_ENTRY_FILES.has(e.name.toLowerCase()))
116
118
  .map((e) => e.name)
117
119
  const out = []
118
120
  for (const name of names) {
119
121
  const full = join(memoryDir, name)
122
+ // 与注入 / 面板同一条闸门:模型看到的清单不该包含「住在别处」的文件。
123
+ const gate = await inspectMemoryEntry(full)
124
+ if (!gate.ok) {
125
+ warnLog(ctx, `autodream: skip ${name}: ${gate.error}`)
126
+ continue
127
+ }
128
+ if (gate.id && reservedIds.has(gate.id)) {
129
+ warnLog(ctx, `autodream: skip ${name}: 这是某个保留名的另一个名字`)
130
+ continue
131
+ }
120
132
  try {
121
- const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
133
+ const content = await readFile(full, 'utf8')
122
134
  const meta = parseFrontmatterLite(content)
123
135
  const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '')
124
136
  const h1 = body.match(/^#\s+(.+)$/m)
@@ -126,8 +138,8 @@ async function scanEntries(memoryDir) {
126
138
  file: name,
127
139
  // 索引不是一条记忆:标出来,否则模型会看到「MEMORY.md · reference」而困惑。
128
140
  type: name.toLowerCase() === INDEX_FILE ? '索引' : meta.type || 'reference',
129
- bytes: info.size,
130
- mtimeMs: info.mtimeMs,
141
+ bytes: gate.size,
142
+ mtimeMs: gate.mtimeMs,
131
143
  title: (h1 ? h1[1] : meta.name || '').trim(),
132
144
  description: meta.description || '',
133
145
  })
@@ -139,18 +151,111 @@ async function scanEntries(memoryDir) {
139
151
  return out
140
152
  }
141
153
 
154
+ /** zstd 帧魔数(小端读出来的值)。 */
155
+ const ZSTD_MAGIC = 0xfd2fb528
156
+ /** 同一个魔数的字节形态:认不出帧头时要靠它找下一个帧的起点。 */
157
+ const ZSTD_MAGIC_BYTES = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
158
+
159
+ /**
160
+ * 量一个 zstd 帧占多少字节(从 `off` 开始)。不是合法帧头、或帧被截断,返回 0。
161
+ *
162
+ * 为什么要自己量:会话记录是**逐行一个帧**拼起来的,而 Node 的解码器都只认第一个帧 ——
163
+ * - `zstdDecompressSync(buf)`:实测只解出第一帧(4.44 MB / 3148 帧的文件只出 266 字符)
164
+ * - `zstdDecompress(buf)`:**单参数调用直接抛** `ERR_INVALID_ARG_TYPE`(它要 callback,
165
+ * 不是 promise 版)—— 旧代码正是这么调的,于是每个会话都在 `catch` 里被静默跳过,
166
+ * 整条 `search_sessions` 实际上**一次都没成功过**
167
+ * 所以只能沿帧头走一遍:Magic → Frame_Header_Descriptor → (Window / DictID / ContentSize)
168
+ * → 逐块 Block_Header 直到 Last_Block → 可选 checksum。
169
+ */
170
+ function zstdFrameLength(buf, off) {
171
+ if (off + 5 > buf.length) return 0
172
+ if (buf.readUInt32LE(off) !== ZSTD_MAGIC) return 0
173
+ let p = off + 4
174
+ const fhd = buf[p++]
175
+ const fcsFlag = fhd >> 6
176
+ const singleSegment = (fhd >> 5) & 1
177
+ const checksum = (fhd >> 2) & 1
178
+ const dictIdFlag = fhd & 3
179
+ if (!singleSegment) p += 1 // Window_Descriptor
180
+ p += [0, 1, 2, 4][dictIdFlag] // Dictionary_ID
181
+ p += singleSegment ? [1, 2, 4, 8][fcsFlag] : [0, 2, 4, 8][fcsFlag] // Frame_Content_Size
182
+ if (p > buf.length) return 0
183
+ for (;;) {
184
+ if (p + 3 > buf.length) return 0
185
+ const h = buf[p] | (buf[p + 1] << 8) | (buf[p + 2] << 16)
186
+ p += 3
187
+ const last = h & 1
188
+ const type = (h >> 1) & 3
189
+ const size = h >>> 3
190
+ if (type === 3) return 0 // Reserved:不是我们能认的帧
191
+ p += type === 1 ? 1 : size // RLE 块只存 1 字节
192
+ if (last) break
193
+ if (p > buf.length) return 0
194
+ }
195
+ if (checksum) p += 4
196
+ return p <= buf.length ? p - off : 0
197
+ }
198
+
199
+ /**
200
+ * 把「可能由多个帧拼成」的 zstd 缓冲解成文本。
201
+ *
202
+ * 逐帧解、**一帧坏掉不影响后面的帧**;连帧头都认不出时跳到下一个魔数继续。
203
+ * 解不出来的帧会被跳过(可能返回空串),整个缓冲里一帧都没解出来则返回 `null`
204
+ * —— 由调用方决定怎么记账。
205
+ */
206
+ function decodeZstdFrames(zlib, buf) {
207
+ const parts = []
208
+ let off = 0
209
+ let frames = 0
210
+ let decoded = 0
211
+ while (off < buf.length) {
212
+ const len = zstdFrameLength(buf, off)
213
+ if (len <= 0) {
214
+ /**
215
+ * 认不出帧头:**跳到下一个魔数**继续,而不是停下。
216
+ *
217
+ * 停下的代价是「这一帧之后的内容全部静默丢失」—— 而那些帧本来是好的
218
+ * (实测:5 帧里第 3 帧的块头被改成 Reserved,后面两帧就再也搜不到了)。
219
+ * 未对齐的魔数也可能落在压缩数据里,那时的帧长多半解析不通,由下面的 catch 兜住。
220
+ */
221
+ const next = buf.indexOf(ZSTD_MAGIC_BYTES, off + 1)
222
+ if (next < 0) break
223
+ off = next
224
+ continue
225
+ }
226
+ try {
227
+ parts.push(zlib.zstdDecompressSync(buf.subarray(off, off + len)))
228
+ decoded += 1
229
+ } catch {
230
+ // 单帧坏掉:跳过它,继续后面的帧
231
+ }
232
+ off += len
233
+ frames += 1
234
+ }
235
+ if (frames === 0) {
236
+ try {
237
+ return zlib.zstdDecompressSync(buf).toString('utf8')
238
+ } catch {
239
+ return null
240
+ }
241
+ }
242
+ if (decoded === 0) return null
243
+ return Buffer.concat(parts).toString('utf8')
244
+ }
245
+
142
246
  /**
143
247
  * 在 DSH 会话记录里做定向搜索。
144
248
  *
145
- * 会话正文是 zstd 压缩的(`session.v4.jsonl.zstd`),Node 24 的
146
- * `zlib.zstdDecompress` 能直接解 —— 不需要外部 zstd 可执行文件。
249
+ * 会话正文是 zstd 压缩的(`session.v4.jsonl.zstd`),而且是**逐行一个帧** ——
250
+ * 所以解压必须逐帧走(见 `decodeZstdFrames`),不需要外部 zstd 可执行文件。
147
251
  * 只按 mtime 从新到旧扫,命中够数就停:这是「找昨天的报错原文」这类
148
252
  * 定向问题的用法,不是全量索引。
149
253
  *
150
254
  * @param {string} sessionsRoot — `$DSH_HOME/sessions`
151
- * @param {string} query
152
- * @param {number} limit
153
- * @param {number} sinceMs — 只看这个时间之后改过的会话
255
+ * @param {string} query — 关键词(≥2 字符)
256
+ * @param {number} limit — 最多返回多少段
257
+ * @param {number} sinceMs — 只看这个时间之后改过的会话。工具层**有意传 0**:
258
+ * 模型搜的是全部会话,不限于上次整理之后(这个参数留给按需过滤的调用方)
154
259
  * @returns {Promise<string>}
155
260
  */
156
261
  async function searchSessions(sessionsRoot, query, limit, sinceMs) {
@@ -164,7 +269,7 @@ async function searchSessions(sessionsRoot, query, limit, sinceMs) {
164
269
  } catch {
165
270
  return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
166
271
  }
167
- if (typeof zlib.zstdDecompress !== 'function') {
272
+ if (typeof zlib.zstdDecompressSync !== 'function') {
168
273
  return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
169
274
  }
170
275
 
@@ -193,19 +298,21 @@ async function searchSessions(sessionsRoot, query, limit, sinceMs) {
193
298
  for (const d of dirs.slice(0, 40)) {
194
299
  try {
195
300
  const raw = await readFile(d.file)
196
- const text = zlib.zstdDecompress(raw).toString('utf8')
301
+ const text = decodeZstdFrames(zlib, raw)
302
+ if (text === null) continue
197
303
  const lines = text.split(/\r?\n/)
198
304
  for (const line of lines) {
199
305
  if (line.length < 8) continue
200
306
  if (!line.toLowerCase().includes(needle)) continue
201
307
  hits.push(`[${localStamp(d.mtimeMs)}] ${line.length > 500 ? line.slice(0, 500) + '…' : line}`)
202
308
  total += line.length
203
- if (total > SEARCH_LIMIT || hits.length > limit) break
309
+ // `>= limit`:判据是「已经攒够 limit 段了」,不是「超过了」——后者每次多返回一段。
310
+ if (total > SEARCH_LIMIT || hits.length >= limit) break
204
311
  }
205
312
  } catch {
206
313
  // 单个会话读不动就跳过:autodream 不该因为一个坏文件整体失败。
207
314
  }
208
- if (total > SEARCH_LIMIT || hits.length > limit) break
315
+ if (total > SEARCH_LIMIT || hits.length >= limit) break
209
316
  }
210
317
  if (!hits.length) return `(在 ${sinceMs ? '上次整理之后' : '全部'}的会话记录里没找到含「${query}」的内容)`
211
318
  return `命中 ${hits.length} 段(按 mtime 由新到旧):\n\n${hits.join('\n')}`
@@ -327,7 +434,7 @@ const MIN_REASON_CHARS = 4
327
434
  * @returns {{run:(name:string, args:object)=>Promise<string>, touchedFiles:()=>string[], changes:()=>object[], warnings:()=>string[]}}
328
435
  */
329
436
  export function createToolRunner(opts) {
330
- const { memoryDir, sessionsRoot, apply, withSessions } = opts
437
+ const { memoryDir, sessionsRoot, apply, withSessions, ctx } = opts
331
438
  let listCache = null
332
439
  let auditCache = null
333
440
  const touched = []
@@ -396,7 +503,7 @@ export function createToolRunner(opts) {
396
503
  try {
397
504
  switch (name) {
398
505
  case 'list_memory': {
399
- if (!listCache) listCache = await scanEntries(memoryDir)
506
+ if (!listCache) listCache = await scanEntries(memoryDir, ctx)
400
507
  const lines = listCache.map(
401
508
  (e) =>
402
509
  `- ${e.file} · ${e.type} · ${Math.round(e.bytes / 1024)}KB · ${ageDays(e.mtimeMs)}天前 · ${localStamp(e.mtimeMs)}\n ${(e.title || '(无标题)').slice(0, 60)}${e.description ? ' — ' + e.description.slice(0, LIST_DESC_CHARS) : ''}`,
@@ -408,6 +515,17 @@ export function createToolRunner(opts) {
408
515
  if (!validated) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
409
516
  // 统一落到磁盘上的真实名字:NTFS 大小写不敏感,`A.md` 与 `a.md` 是同一个文件。
410
517
  const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
518
+ /**
519
+ * 过闸门再读:**名字在记忆目录里不等于内容住在记忆目录里**。
520
+ * 注入与面板两条腿都挡住了,这条腿(模型工具)也必须挡 ——
521
+ * 否则外部文件的内容照样会被读进来、送进模型上下文。
522
+ */
523
+ const gate = await inspectMemoryEntry(full)
524
+ if (!gate.ok) {
525
+ return gate.code === 'missing'
526
+ ? `错误:没有这个文件:${basename(full)}`
527
+ : `错误:拒绝读取 ${basename(full)}:${gate.error}`
528
+ }
411
529
  const content = await readFile(full, 'utf8').catch((err) => {
412
530
  if (err?.code === 'ENOENT') return null
413
531
  throw err
package/lib/index.js CHANGED
@@ -22,12 +22,12 @@ import { MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESS
22
22
  import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
23
23
  import { isReserved, nameKey, safeName } from './memory/naming.js'
24
24
  import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
25
- import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars } from './memory/scan.js'
25
+ import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
26
26
  import { archiveMemory, restoreMemory } from './memory/archive.js'
27
27
  import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
28
28
  import { warnLog, writeFileAtomic } from './memory/util.js'
29
29
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
30
- import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
30
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
31
31
  import { basename, join } from 'node:path'
32
32
 
33
33
  /**
@@ -121,6 +121,9 @@ async function readReservedFile() {
121
121
  function rawName(raw) {
122
122
  const s = typeof raw === 'string' ? raw : ''
123
123
  if (!s || basename(s) !== s) return null
124
+ // `:` 是 NTFS 交替数据流的分隔符:`MEMORY.md:evil.md` 能过扩展名判据,落盘却是写到
125
+ // 索引的隐藏流上(`readdir` 看不见)。与 `safeName` 同一条理由,这里也要拒。
126
+ if (s.includes(':')) return null
124
127
  return /\.md$/i.test(s) ? s : null
125
128
  }
126
129
 
@@ -389,11 +392,24 @@ export class MemoryGateway extends TypertRemoteService {
389
392
  })
390
393
  }
391
394
 
392
- /** 读单个记忆文件全文。 */
395
+ /**
396
+ * 读单个记忆文件全文。
397
+ *
398
+ * 过闸门再读:名字在记忆目录里**不等于**内容住在记忆目录里 —— 符号链接 / 目录联接 /
399
+ * 硬链接都能让一次「读自己的记忆」变成读别人的文件,而读到的内容会被注入模型上下文。
400
+ */
393
401
  async readFile(name) {
394
402
  const safe = safeName(name)
395
403
  if (!safe) throw new Error('sage-mem: invalid file name')
396
- const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
404
+ const full = join(MEMORY_DIR, safe)
405
+ // 名字不是保留名 ≠ 文件不是保留名:8.3 短名(`MEMORY~1.MD`)文本上与索引毫无关系,
406
+ // 却是同一个文件。别把索引当普通记忆交出去。
407
+ if (await shadowsReserved(full)) {
408
+ throw new Error(`sage-mem: refusing to read ${safe}: 这是某个保留名(索引 / 流水)的另一个名字`)
409
+ }
410
+ const gate = await inspectMemoryEntry(full)
411
+ if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
412
+ const content = await readFile(full, 'utf8')
397
413
  return { name: safe, content }
398
414
  }
399
415
 
@@ -412,6 +428,12 @@ export class MemoryGateway extends TypertRemoteService {
412
428
  }
413
429
  const safe = safeName(name)
414
430
  if (!safe) throw new Error('sage-mem: invalid file name')
431
+ // 名字文本不是保留名 **不等于** 文件不是保留名:NTFS 的 8.3 短名(`MEMORY~1.MD`)
432
+ // 在文本上跟索引毫无关系,inode 却是同一个 —— 实测它能改写索引。
433
+ // 保留名保护必须落在**文件身份**上,否则「保留名不可写」这句承诺是假的。
434
+ if (await shadowsReserved(join(MEMORY_DIR, safe))) {
435
+ return { ok: false, file: safe, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),请用文件工具改` }
436
+ }
415
437
  // 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
416
438
  // 原文件就此被一行垃圾顶掉,且没有任何错误信号。
417
439
  if (typeof content !== 'string') {
@@ -421,7 +443,19 @@ export class MemoryGateway extends TypertRemoteService {
421
443
  if (bytes > MAX_FILE_BYTES) {
422
444
  return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
423
445
  }
424
- await writeFileAtomic(join(MEMORY_DIR, safe), content)
446
+ /**
447
+ * 目标已存在时也要过闸门:链接 / 硬链接一律拒绝。
448
+ *
449
+ * 原子写本身不会写穿链接(rename 换掉的是链接本身),但那样「改这条记忆」就变成了
450
+ * 「把这个名字指向别处」—— 用户以为改的是记忆,实际磁盘上多了一个指向外部的名字。
451
+ * `limitBytes = 0` 表示不在这里判体积(上面刚按字符数查过)。
452
+ */
453
+ const target = join(MEMORY_DIR, safe)
454
+ const existing = await inspectMemoryEntry(target, { limitBytes: null })
455
+ if (!existing.ok && existing.code !== 'missing') {
456
+ return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
457
+ }
458
+ await writeFileAtomic(target, content)
425
459
  return { ok: true, file: safe }
426
460
  }
427
461
 
@@ -432,6 +466,10 @@ export class MemoryGateway extends TypertRemoteService {
432
466
  }
433
467
  const safe = safeName(name)
434
468
  if (!safe) throw new Error('sage-mem: invalid file name')
469
+ // 同 writeFile:8.3 短名能绕过名字文本判据,删除保护也必须看文件身份。
470
+ if (await shadowsReserved(join(MEMORY_DIR, safe))) {
471
+ return { ok: false, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),拒绝删除` }
472
+ }
435
473
  await unlink(join(MEMORY_DIR, safe))
436
474
  return { ok: true }
437
475
  }
@@ -497,11 +535,19 @@ export class MemoryGateway extends TypertRemoteService {
497
535
  async readArchived(name) {
498
536
  const safe = safeName(name)
499
537
  if (!safe) throw new Error('sage-mem: invalid file name')
538
+ // 归档区本身先过目录闸门:它若是指向别处的联接,读归档就等于读外部的文件。
539
+ const dirGate = await inspectDir(MEMORY_ARCHIVE_DIR)
540
+ if (!dirGate.ok) throw new Error(`sage-mem: archive dir unavailable: ${dirGate.error}`)
500
541
  const full = join(MEMORY_ARCHIVE_DIR, safe)
501
- const info = await stat(full).catch(() => null)
542
+ const gate = await inspectMemoryEntry(full)
502
543
  // 找不到就抛:与 readFile 一致。静默返回空串会让面板显示一个「空的记忆」,
503
544
  // 而用户改完一存 —— 就把一条真实内容顶掉了。
504
- if (!info?.isFile()) throw new Error(`sage-mem: archived file not found: ${safe}`)
545
+ if (!gate.ok) {
546
+ // ⚠️「形状不对」与「不存在」是两回事:把联接 / 超限都报成「文件不存在」,
547
+ // 会让用户去找一个明明躺在盘上的文件。
548
+ if (gate.code === 'missing') throw new Error(`sage-mem: archived file not found: ${safe}`)
549
+ throw new Error(`sage-mem: refusing to read archived ${safe}: ${gate.error}`)
550
+ }
505
551
  const content = await readFile(full, 'utf8')
506
552
  return { name: safe, content }
507
553
  }
@@ -520,10 +566,21 @@ export class MemoryGateway extends TypertRemoteService {
520
566
  }
521
567
  const safe = safeName(name)
522
568
  if (!safe) throw new Error('sage-mem: invalid file name')
569
+ const dirGate = await inspectDir(MEMORY_ARCHIVE_DIR)
570
+ if (!dirGate.ok) return { ok: false, file: safe, error: `sage-mem: archive dir unavailable: ${dirGate.error}` }
523
571
  const full = join(MEMORY_ARCHIVE_DIR, safe)
524
- const info = await stat(full).catch(() => null)
572
+ const gate = await inspectMemoryEntry(full, { limitBytes: null })
525
573
  // 只改归档区里**已经存在**的文件:这条 remote 不是「把任意文件写进归档区」的后门。
526
- if (!info?.isFile()) return { ok: false, file: safe, error: `sage-mem: archived file not found: ${safe}` }
574
+ if (!gate.ok) {
575
+ return {
576
+ ok: false,
577
+ file: safe,
578
+ error:
579
+ gate.code === 'missing'
580
+ ? `sage-mem: archived file not found: ${safe}`
581
+ : `sage-mem: refusing to write archived ${safe}: ${gate.error}`,
582
+ }
583
+ }
527
584
  if (typeof content !== 'string') {
528
585
  return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
529
586
  }
@@ -669,9 +726,15 @@ export class MemoryGateway extends TypertRemoteService {
669
726
  }
670
727
  const safe = rawName(name)
671
728
  if (!safe) return { ok: false, error: `sage-mem: invalid file name: ${String(name)}` }
729
+ // 保留名同样过闸门:链接 / 硬链接一样能把「读索引 / 读流水」变成读外部文件。
730
+ // ⚠️ 体积**不**在这里判(`limitBytes = 0`):流水天然比单条记忆大得多,
731
+ // 它有自己的上限(`MAX_RAW_BYTES`,见上面的注释)。
732
+ const full = join(MEMORY_DIR, safe)
733
+ const gate = await inspectMemoryEntry(full, { limitBytes: null })
734
+ if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
672
735
  // 读不到就抛(与 readFile 一致):静默返回空串会让面板显示一份「空的索引」,
673
736
  // 用户一存就把真索引顶掉。
674
- const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
737
+ const content = await readFile(full, 'utf8')
675
738
  return { name: safe, content }
676
739
  }
677
740
 
@@ -699,7 +762,14 @@ export class MemoryGateway extends TypertRemoteService {
699
762
  error: `sage-mem: content too large (${bytes} bytes > ${MAX_RAW_BYTES / (1024 * 1024)} MB raw limit)`,
700
763
  }
701
764
  }
702
- await writeFileAtomic(join(MEMORY_DIR, safe), content)
765
+ const target = join(MEMORY_DIR, safe)
766
+ // 与 `writeFile` 同一条理由:目标已存在时若是链接 / 硬链接,一律拒绝 ——
767
+ // 「改这份索引」不该变成「把这个名字指向别处」。体积由上面那道 `MAX_RAW_BYTES` 管。
768
+ const existing = await inspectMemoryEntry(target, { limitBytes: null })
769
+ if (!existing.ok && existing.code !== 'missing') {
770
+ return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
771
+ }
772
+ await writeFileAtomic(target, content)
703
773
  return { ok: true, file: safe }
704
774
  }
705
775
  }
@@ -15,7 +15,7 @@
15
15
  * 就是临时目录),拿模块级常量会写错地方 —— 那是会碰到真实记忆的危险错误。
16
16
  */
17
17
 
18
- import { ARCHIVE_DIR_NAME, addArchiveMeta, localStamp, stripArchiveMeta } from './scan.js'
18
+ import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, localStamp, stripArchiveMeta } from './scan.js'
19
19
  import { isReserved, nameKey, safeName } from './naming.js'
20
20
  import { writeFileAtomic } from './util.js'
21
21
  import { mkdir, readFile, stat, unlink } from 'node:fs/promises'
@@ -49,9 +49,26 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
49
49
  const safe = gate.safe
50
50
  const src = join(memoryDir, safe)
51
51
  const dst = join(archiveDirOf(memoryDir), safe)
52
- const info = await stat(src).catch(() => null)
52
+ /**
53
+ * 两道闸门,缺一不可:
54
+ * - 源文件:链接 / 硬链接指向的可能是记忆目录**之外**的文件,归档它 = 把它从外部搬走
55
+ * - 归档区:它若被换成指向外部的目录联接,归档 = 把记忆搬出记忆目录
56
+ * (体积不在这里判:归档是搬移,不是写入。)
57
+ */
58
+ const srcGate = await inspectMemoryEntry(src, { limitBytes: null })
53
59
  // 根目录里没有 → 文件名写错,或者它已经是归档态(归档区那份不该再归档一次)。
54
- if (!info?.isFile()) return { ok: false, code: 'not-found', error: `sage-mem: file not found: ${safe}` }
60
+ if (!srcGate.ok) {
61
+ return {
62
+ ok: false,
63
+ code: srcGate.code === 'missing' ? 'not-found' : srcGate.code,
64
+ error: `sage-mem: file not found: ${safe}(${srcGate.error})`,
65
+ }
66
+ }
67
+ const dirGate = await inspectDir(archiveDirOf(memoryDir))
68
+ // 归档区还不存在是正常状态(下面 mkdir 会建)。
69
+ if (!dirGate.ok && dirGate.code !== 'missing') {
70
+ return { ok: false, code: dirGate.code, error: `sage-mem: archive dir unavailable: ${dirGate.error}` }
71
+ }
55
72
  if (await stat(dst).catch(() => null)) {
56
73
  // 归档区已有同名:不覆盖 —— 那份旧归档可能是用户想留的另一版。
57
74
  return { ok: false, code: 'exists', error: `sage-mem: already archived: ${safe}` }
@@ -79,11 +96,25 @@ export async function restoreMemory(memoryDir, name) {
79
96
  const safe = gate.safe
80
97
  const dst = join(memoryDir, safe)
81
98
  const src = join(archiveDirOf(memoryDir), safe)
82
- const info = await stat(src).catch(() => null)
83
- if (!info?.isFile()) return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}` }
84
- if (await stat(dst).catch(() => null)) {
99
+ // 归档区过目录闸门:联接指向外部时,「恢复」会把外部的文件搬进记忆目录。
100
+ const dirGate = await inspectDir(archiveDirOf(memoryDir))
101
+ if (!dirGate.ok) {
102
+ return {
103
+ ok: false,
104
+ code: dirGate.code === 'missing' ? 'not-archived' : dirGate.code,
105
+ error: `sage-mem: not archived: ${safe}(${dirGate.error})`,
106
+ }
107
+ }
108
+ const srcGate = await inspectMemoryEntry(src, { limitBytes: null })
109
+ if (!srcGate.ok) return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}(${srcGate.error})` }
110
+ // 顶层已有同名(且不是链接)时拒绝:根目录那份是正在用的记忆,归档这份只是退役版。
111
+ const dstGate = await inspectMemoryEntry(dst, { limitBytes: null })
112
+ if (dstGate.ok) {
85
113
  return { ok: false, code: 'target-exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
86
114
  }
115
+ if (dstGate.code !== 'missing') {
116
+ return { ok: false, code: dstGate.code, error: `sage-mem: refusing to restore over ${safe}: ${dstGate.error}` }
117
+ }
87
118
  const content = await readFile(src, 'utf8')
88
119
  await writeFileAtomic(dst, stripArchiveMeta(content))
89
120
  await unlink(src)
@@ -48,6 +48,11 @@ export function safeName(raw) {
48
48
  // basename 与原文不一致=含目录成分。旧版是「悄悄取 basename 继续」,
49
49
  // 与 starmap 那套(要求 safe === raw)行为不一致;现在统一为拒绝。
50
50
  if (basename(raw) !== raw) return null
51
+ // `:` 在 NTFS 上是**交替数据流**的分隔符:`MEMORY.md:evil.md` 以 `.md` 结尾、能过下面
52
+ // 所有判据,落盘时却会写到索引的隐藏数据流上(`readdir` 看不见,只写流不报错)。
53
+ // 实测更糟:`writeFile('MEMORY.md:evil.md')` 会在 `rename` 处抛**未捕获**的 EINVAL ——
54
+ // 违背「拒绝一律返回 {ok:false}、不抛」这条契约。正常文件名不允许 `:`,直接拒。
55
+ if (raw.includes(':')) return null
51
56
  if (!/\.md$/i.test(raw)) return null
52
57
  if (isNonEntry(raw)) return null
53
58
  return raw
@@ -10,11 +10,11 @@
10
10
  * 文件可能被手工搬来搬去,随文件走的元数据才不会与文件脱节。
11
11
  */
12
12
 
13
- import { MEMORY_DIR } from './config.js'
13
+ import { MAX_FILE_BYTES, MEMORY_DIR, RESERVED_FILES } from './config.js'
14
14
  import { parseBaseline, parseFrontmatter } from './frontmatter.js'
15
15
  import { isMemoryEntry } from './naming.js'
16
16
  import { warnLog } from './util.js'
17
- import { readFile, readdir, stat } from 'node:fs/promises'
17
+ import { lstat, readFile, readdir } from 'node:fs/promises'
18
18
  import { join } from 'node:path'
19
19
 
20
20
  /** 归档区目录名。与 autodream 工具里的 ARCHIVE_DIR 同名同义(同一个目录)。 */
@@ -112,18 +112,160 @@ export function addArchiveMeta(content, reason, at) {
112
112
  return stripped.slice(0, m.index) + block.join(eol) + stripped.slice(m.index + m[0].length)
113
113
  }
114
114
 
115
+ /**
116
+ * 记忆文件形状闸门:只认「普通文件、体积在给定上限内」的 `.md`。
117
+ *
118
+ * 为什么要闸门:记忆目录里的一条 `x.md` **不一定真的住在记忆目录里** ——
119
+ * **符号链接 / 目录联接**把名字留在记忆目录、把内容指到任意路径:读它就是读别人的文件,
120
+ * 写它就是改别人的文件,而这些内容会被注入模型上下文。
121
+ * 用 `lstat` 而不是 `stat`:`stat` 会跟随链接,永远看不出这一层。
122
+ *
123
+ * ⚠️ 本机实测:Windows 上 `symlink()`(文件或目录)要特权、默认 EPERM,但
124
+ * `symlink(dir, 'junction')` **能建** —— 所以这不是理论风险。
125
+ *
126
+ * **硬链接(`nlink > 1`)不拦,只提示**(返回值里的 `links`,由调用方负责出声)。
127
+ * 取舍理由:硬链接**不提供**「指向任意路径」的能力(不能跨卷,只能由已在同卷上有权限的人建),
128
+ * 它带来的是「内容可能被记忆目录之外改到」—— 这是完整性提示,不是越界读取。
129
+ * 而拦它的代价是**真实误伤**:`cp -al` / `rsync --link-dest` 这类快照备份会让源文件的
130
+ * `nlink` 变成 2,于是整条记忆从注入与面板里静默消失(实测复现过)。
131
+ * 宁可提示、不可丢记忆 —— 与「大小写判定宁可多留」同一条原则。
132
+ *
133
+ * 体积上限也在这里:它原先只写在**写入**路径上,读路径谁来都全量读进内存,
134
+ * 等于上限只在「自己人写」的前提下成立(一条被手工粘进来的日志就能把整轮注入吃光)。
135
+ *
136
+ * @param {string} full — 绝对路径
137
+ * @param {{limitBytes?: number|null}} [opts] — `limitBytes: null` = **不判体积**(归档区用);
138
+ * 不传则用 `MAX_FILE_BYTES`。⚠️ 显式 `null` 与「随便传个 0 / NaN」是两回事:
139
+ * 非有限数一律按「超过上限」处理,不会静默把闸门关掉。
140
+ * @returns {Promise<{ok:true,size:number,mtimeMs:number,links:number}|{ok:false,code:string,error:string}>}
141
+ */
142
+ export async function inspectMemoryEntry(full, opts = {}) {
143
+ const limitBytes = opts?.limitBytes === undefined ? MAX_FILE_BYTES : opts.limitBytes
144
+ let info
145
+ try {
146
+ info = await lstat(full)
147
+ } catch (err) {
148
+ return {
149
+ ok: false,
150
+ code: err?.code === 'ENOENT' ? 'missing' : 'stat-failed',
151
+ error: `读不到文件属性(${err?.code ?? err})`,
152
+ }
153
+ }
154
+ if (info.isSymbolicLink()) return { ok: false, code: 'symlink', error: '是指向别处的符号链接 / 目录联接' }
155
+ if (!info.isFile()) return { ok: false, code: 'not-file', error: '不是普通文件' }
156
+ // null = 明确「不判体积」;其余一律按数值上限处理(0 / NaN / 字符串都会被当成超限)。
157
+ if (limitBytes !== null && (!Number.isFinite(limitBytes) || info.size > limitBytes)) {
158
+ return {
159
+ ok: false,
160
+ code: 'too-big',
161
+ error: Number.isFinite(limitBytes)
162
+ ? `体积 ${info.size} 字节,超过单文件上限 ${limitBytes} 字节`
163
+ : `体积 ${info.size} 字节,上限参数不合法(${String(limitBytes)})`,
164
+ }
165
+ }
166
+ return { ok: true, size: info.size, mtimeMs: info.mtimeMs, links: info.nlink, id: identityOf(info) }
167
+ }
168
+
169
+ /** 文件身份键(`dev:ino`)。拿不到 inode 的平台返回 null(那就退回名字文本判据)。 */
170
+ export function identityOf(info) {
171
+ return info && info.ino ? `${info.dev}:${info.ino}` : null
172
+ }
173
+
174
+ /**
175
+ * 保留名在磁盘上的**文件身份**集合(`dev:ino`)。
176
+ *
177
+ * 为什么需要它:保留名保护原先只比**名字文本**(`nameKey` 归一 + 小写)。但 NTFS 上一个
178
+ * 文件可以有第二个 8.3 短名 —— `MEMORY~1.MD` 与 `MEMORY.md` 文本上毫无关系,判据却必须
179
+ * 认出它们是**同一个文件**:实测在能建短名的卷上,`writeFile('MEMORY~1.MD')` 能改写索引、
180
+ * `deleteFile('MEMORY~2.MD')` 能把它删掉,而 `isReserved()` 一路放行。
181
+ *
182
+ * 返回集合在每次调用时现取(保留名就那两三个,一次目录写入的开销可忽略)。
183
+ */
184
+ export async function reservedIdentitySet(dir = MEMORY_DIR) {
185
+ const ids = new Set()
186
+ for (const name of RESERVED_FILES) {
187
+ const info = await lstat(join(dir, name)).catch(() => null)
188
+ const id = identityOf(info)
189
+ if (id) ids.add(id)
190
+ }
191
+ return ids
192
+ }
193
+
194
+ /** 这个名字在磁盘上是不是某个保留名的另一个名字(8.3 短名 / 硬链接)。 */
195
+ export async function shadowsReserved(full, dir = MEMORY_DIR) {
196
+ const ids = await reservedIdentitySet(dir)
197
+ if (!ids.size) return false
198
+ const info = await lstat(full).catch(() => null)
199
+ const id = identityOf(info)
200
+ return id !== null && ids.has(id)
201
+ }
202
+
203
+ /**
204
+ * 子目录闸门:`archive/` 这类**插件自己管理的目录**必须是真目录,不能是指向别处的联接。
205
+ *
206
+ * ⚠️ 只对 `archive/` 这类子目录用,**不**对记忆目录本身用:把 `SAGE_MEM_DIR` 指向一个
207
+ * 链接目录(例如网盘同步目录)是正当用法,拦掉它属于误伤。而 `archive/` 是归档动作的
208
+ * 落地处 —— 它若是联接,归档就等于把记忆搬出记忆目录,恢复则可能把外部文件搬进来。
209
+ *
210
+ * @returns {Promise<{ok:true}|{ok:false,code:string,error:string}>}
211
+ */
212
+ export async function inspectDir(dir) {
213
+ let info
214
+ try {
215
+ info = await lstat(dir)
216
+ } catch (err) {
217
+ return {
218
+ ok: false,
219
+ code: err?.code === 'ENOENT' ? 'missing' : 'stat-failed',
220
+ error: `读不到目录属性(${err?.code ?? err})`,
221
+ }
222
+ }
223
+ if (info.isSymbolicLink()) return { ok: false, code: 'symlink', error: '是指向别处的符号链接 / 目录联接' }
224
+ if (!info.isDirectory()) return { ok: false, code: 'not-dir', error: '不是目录' }
225
+ return { ok: true }
226
+ }
227
+
115
228
  /** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
116
229
  export async function scanMemoryFiles(ctx, sessionId) {
117
230
  try {
118
231
  const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
232
+ /** 保留名的文件身份:8.3 短名与硬链接都得靠它才认得出(名字文本上它们毫无关系)。 */
233
+ const reservedIds = await reservedIdentitySet()
234
+ /**
235
+ * 链接条目在 `isFile()` 这一步就被滤掉了(`readdir` 的 Dirent 报的是链接本身),
236
+ * 也就是说**闸门根本没机会看到它们** —— 不在这里出声,用户只会看到「这条记忆不见了」。
237
+ */
238
+ for (const e of entries) {
239
+ if (e.isSymbolicLink() && /\.md$/i.test(e.name)) {
240
+ warnLog(ctx, `skip ${e.name}: 是指向别处的符号链接 / 目录联接`, sessionId)
241
+ }
242
+ }
119
243
  const names = entries
120
244
  .filter(e => e.isFile() && isMemoryEntry(e.name))
121
245
  .map(e => e.name)
122
246
  const loaded = await Promise.all(names.map(async (name) => {
247
+ const full = join(MEMORY_DIR, name)
248
+ // 先过闸门(lstat + 体积),再读:链接与超大文件都在**全量读之前**被挡下,
249
+ // 而不是读进来之后才发现不该读。mtime 由闸门一并给出,比原来少一跳 stat。
250
+ const gate = await inspectMemoryEntry(full)
251
+ /**
252
+ * 8.3 短名 / 硬链接都可能是「保留名的另一个名字」:`MEMORY~1.MD` 与 `MEMORY.md`
253
+ * 文本上毫无关系,inode 却是同一个。不按身份拦,索引就会以普通记忆的身份被读进上下文。
254
+ */
255
+ if (gate.ok && gate.id && reservedIds.has(gate.id)) {
256
+ warnLog(ctx, `skip ${name}: 这是某个保留名(索引 / 流水)的另一个名字,不当记忆读`, sessionId)
257
+ return null
258
+ }
259
+ // 硬链接**不拦**(见 inspectMemoryEntry 的取舍),但要让用户知道内容可能被外部改到。
260
+ if (gate.ok && gate.links > 1) {
261
+ warnLog(ctx, `note ${name}: 有 ${gate.links} 个硬链接名,内容可能被记忆目录之外改到`, sessionId)
262
+ }
263
+ if (!gate.ok) {
264
+ warnLog(ctx, `skip ${name}: ${gate.error}`, sessionId)
265
+ return null
266
+ }
123
267
  try {
124
- const full = join(MEMORY_DIR, name)
125
- // stat 与 readFile 并行 —— 多要一个 mtime 不该变成串行两跳。
126
- const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
268
+ const content = await readFile(full, 'utf8')
127
269
  const meta = parseFrontmatter(content)
128
270
  return {
129
271
  file: name,
@@ -134,7 +276,7 @@ export async function scanMemoryFiles(ctx, sessionId) {
134
276
  baselinePriority: meta.baselinePriority || 0,
135
277
  // 新鲜度的两个来源:frontmatter 的 updated(权威)与文件 mtime(退路)。
136
278
  updated: meta.updated || '',
137
- mtimeMs: info.mtimeMs,
279
+ mtimeMs: gate.mtimeMs,
138
280
  }
139
281
  } catch (err) {
140
282
  // 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
@@ -159,7 +301,7 @@ export function extractTitle(content) {
159
301
  }
160
302
 
161
303
  /** 扫一个目录,读回星图要的轻量元数据(正文 + 体积 + 修改时间)。 */
162
- async function readStarDir(dir, ctx) {
304
+ async function readStarDir(dir, ctx, opts = {}) {
163
305
  let entries
164
306
  try {
165
307
  entries = await readdir(dir, { withFileTypes: true })
@@ -170,11 +312,22 @@ async function readStarDir(dir, ctx) {
170
312
  const names = entries
171
313
  .filter(e => e.isFile() && isMemoryEntry(e.name))
172
314
  .map(e => e.name)
315
+ const reservedIds = await reservedIdentitySet()
173
316
  const loaded = await Promise.all(names.map(async (name) => {
317
+ const full = join(dir, name)
318
+ const gate = await inspectMemoryEntry(full, opts)
319
+ if (!gate.ok) {
320
+ warnLog(ctx, `skip ${full}: ${gate.error}`)
321
+ return null
322
+ }
323
+ // 星图同样不显示「保留名的另一个名字」(8.3 短名 / 硬链接)。
324
+ if (gate.id && reservedIds.has(gate.id)) {
325
+ warnLog(ctx, `skip ${full}: 这是某个保留名(索引 / 流水)的另一个名字`)
326
+ return null
327
+ }
174
328
  try {
175
- const full = join(dir, name)
176
- const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
177
- return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
329
+ const content = await readFile(full, 'utf8')
330
+ return { name, content, bytes: gate.size, mtimeMs: gate.mtimeMs }
178
331
  } catch (err) {
179
332
  if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${full}: ${err?.message ?? err}`)
180
333
  return null
@@ -188,7 +341,21 @@ async function readStarDir(dir, ctx) {
188
341
  * 归档区不存在是正常状态(没人归档过)→ 空数组,不是错误。
189
342
  */
190
343
  export async function scanArchived(ctx) {
191
- const rows = await readStarDir(MEMORY_ARCHIVE_DIR, ctx)
344
+ // 归档区是**插件自己建的子目录**,必须过目录闸门:它若被换成指向外部的联接,
345
+ // 「归档 = 搬进 archive/」就变成「把记忆搬出记忆目录」。
346
+ const gate = await inspectDir(MEMORY_ARCHIVE_DIR)
347
+ if (!gate.ok) {
348
+ // ENOENT 是正常状态(还没人归档过)→ 空数组;其余一律出声,绝不静默当成「没有归档」。
349
+ if (gate.code !== 'missing') warnLog(ctx, `归档区不可用,跳过:${gate.error}`)
350
+ return []
351
+ }
352
+ /**
353
+ * ⚠️ 归档区**不判体积**(`limitBytes: null`):归档是「把这条记忆收起来」,
354
+ * 不是「新写一条记忆」—— 拿活动记忆的上限去卡归档区,会造出「能归档、列不出、
355
+ * 读不回」的半死状态(实测:600 KB 的记忆归档成功,却从馆里消失、又还能被覆盖)。
356
+ * 形状闸门(symlink)照旧生效。
357
+ */
358
+ const rows = await readStarDir(MEMORY_ARCHIVE_DIR, ctx, { limitBytes: null })
192
359
  return rows.map(f => ({ ...f, archived: true }))
193
360
  }
194
361
 
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.3",
4
+ "version": "0.9.5",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {