sage-mem 0.9.4 → 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.
@@ -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 }
@@ -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
  // ────────────────────────────── 回滚点(快照) ──────────────────────────────
@@ -151,18 +151,111 @@ async function scanEntries(memoryDir, ctx) {
151
151
  return out
152
152
  }
153
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
+
154
246
  /**
155
247
  * 在 DSH 会话记录里做定向搜索。
156
248
  *
157
- * 会话正文是 zstd 压缩的(`session.v4.jsonl.zstd`),Node 24 的
158
- * `zlib.zstdDecompress` 能直接解 —— 不需要外部 zstd 可执行文件。
249
+ * 会话正文是 zstd 压缩的(`session.v4.jsonl.zstd`),而且是**逐行一个帧** ——
250
+ * 所以解压必须逐帧走(见 `decodeZstdFrames`),不需要外部 zstd 可执行文件。
159
251
  * 只按 mtime 从新到旧扫,命中够数就停:这是「找昨天的报错原文」这类
160
252
  * 定向问题的用法,不是全量索引。
161
253
  *
162
254
  * @param {string} sessionsRoot — `$DSH_HOME/sessions`
163
- * @param {string} query
164
- * @param {number} limit
165
- * @param {number} sinceMs — 只看这个时间之后改过的会话
255
+ * @param {string} query — 关键词(≥2 字符)
256
+ * @param {number} limit — 最多返回多少段
257
+ * @param {number} sinceMs — 只看这个时间之后改过的会话。工具层**有意传 0**:
258
+ * 模型搜的是全部会话,不限于上次整理之后(这个参数留给按需过滤的调用方)
166
259
  * @returns {Promise<string>}
167
260
  */
168
261
  async function searchSessions(sessionsRoot, query, limit, sinceMs) {
@@ -176,7 +269,7 @@ async function searchSessions(sessionsRoot, query, limit, sinceMs) {
176
269
  } catch {
177
270
  return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
178
271
  }
179
- if (typeof zlib.zstdDecompress !== 'function') {
272
+ if (typeof zlib.zstdDecompressSync !== 'function') {
180
273
  return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
181
274
  }
182
275
 
@@ -205,19 +298,21 @@ async function searchSessions(sessionsRoot, query, limit, sinceMs) {
205
298
  for (const d of dirs.slice(0, 40)) {
206
299
  try {
207
300
  const raw = await readFile(d.file)
208
- const text = zlib.zstdDecompress(raw).toString('utf8')
301
+ const text = decodeZstdFrames(zlib, raw)
302
+ if (text === null) continue
209
303
  const lines = text.split(/\r?\n/)
210
304
  for (const line of lines) {
211
305
  if (line.length < 8) continue
212
306
  if (!line.toLowerCase().includes(needle)) continue
213
307
  hits.push(`[${localStamp(d.mtimeMs)}] ${line.length > 500 ? line.slice(0, 500) + '…' : line}`)
214
308
  total += line.length
215
- if (total > SEARCH_LIMIT || hits.length > limit) break
309
+ // `>= limit`:判据是「已经攒够 limit 段了」,不是「超过了」——后者每次多返回一段。
310
+ if (total > SEARCH_LIMIT || hits.length >= limit) break
216
311
  }
217
312
  } catch {
218
313
  // 单个会话读不动就跳过:autodream 不该因为一个坏文件整体失败。
219
314
  }
220
- if (total > SEARCH_LIMIT || hits.length > limit) break
315
+ if (total > SEARCH_LIMIT || hits.length >= limit) break
221
316
  }
222
317
  if (!hits.length) return `(在 ${sinceMs ? '上次整理之后' : '全部'}的会话记录里没找到含「${query}」的内容)`
223
318
  return `命中 ${hits.length} 段(按 mtime 由新到旧):\n\n${hits.join('\n')}`
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",
4
+ "version": "0.9.5",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {