sage-mem 2.1.6 → 2.1.7

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.6`** — 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.7`** — 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.6`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.7`** —— 顶部 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 是最直接的反馈。
@@ -11,9 +11,9 @@ import { readJson, safeName, snapshotEntries, stampCompact, stampHuman } from '.
11
11
  import { restoreMemory } from '../memory/archive.js'
12
12
  import { nameKey } from '../memory/naming.js'
13
13
  import { stripArchiveMeta } from '../memory/scan.js'
14
- import { writeFileAtomic } from '../memory/util.js'
14
+ import { writeFileAtomic, moveFileExclusive } from '../memory/util.js'
15
15
  import { randomUUID } from 'node:crypto'
16
- import { chmod, link, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
16
+ import { chmod, lstat, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
17
17
  import { basename, dirname, join } from 'node:path'
18
18
 
19
19
  /** 读回滚留痕(面板也许要显示「最近谁退回过」)。 */
@@ -230,26 +230,19 @@ export async function rollback(opts = {}) {
230
230
  await mkdir(archiveDir, { recursive: true })
231
231
 
232
232
  /**
233
- * **排他移动**:先 `link` 到目标(目标已存在 → `EEXIST`,绝不覆盖),成功后再删源。
233
+ * **排他移动**用 `memory/util.js` 的公共原语 `moveFileExclusive`(2026-10-31 外部审查 Q11):
234
+ * 归档 / 恢复 / 回滚只有**一份**实现,避免「公共那侧改了、这边还留着旧降级」的漂移。
234
235
  *
235
236
  * 为什么不能 `stat → rename`(复审复核实测构造出了覆盖):`rename` 在目标已存在时
236
237
  * **静默覆盖**,而 `stat` 与 `rename` 之间正是别人(面板 / 模型工具的 `archiveMemory`,
237
238
  * 它们不取 autodream 这把锁)可能刚把同名归档写进去的窗口 —— 与 R06② 同一类:
238
- * 第二次系统调用没有存在性前提。而本函数的注释一直承诺「绝不覆盖」,代码却做不到。
239
+ * 第二次系统调用没有存在性前提。
239
240
  *
240
- * 平台不支持硬链接 / 跨卷时退回 `rename`(与 `memory/util.js` 那边的降级口径一致),
241
- * 那是已知降级;NTFS / APFS / ext4 上走的是排他路径。
241
+ * 旧私有实现在「link 不支持」时退回 `rename`,注释写着「与 util 一致」却早已失实:
242
+ * 目标已存在时那份**历史旧归档**会被当前文件顶掉,回滚还报 ok:true。公共原语现在的
243
+ * 降级是「COPYFILE_EXCL 排他复制 → 校验 → 才删源」,无法识别的错误直接失败、不默认 rename。
242
244
  */
243
- const moveExclusive = async (from, to) => {
244
- try {
245
- await link(from, to)
246
- } catch (err) {
247
- if (err?.code === 'EEXIST') throw err
248
- await rename(from, to)
249
- return
250
- }
251
- await rm(from, { force: true })
252
- }
245
+ const moveExclusive = moveFileExclusive
253
246
 
254
247
  /**
255
248
  * 移进 archive/(不删)。同名时**换一个退役名**落进去,绝不覆盖,也绝不放弃。
@@ -2,8 +2,9 @@
2
2
  * sage-mem 记忆层 —— 通用小工具:统一日志出口与原子写。
3
3
  */
4
4
 
5
+ import { constants } from 'node:fs'
5
6
  import { randomUUID } from 'node:crypto'
6
- import { link, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
7
+ import { copyFile, link, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
7
8
 
8
9
  /**
9
10
  * 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
@@ -125,6 +126,66 @@ export async function writeFileExclusive(target, content) {
125
126
  return { exclusive }
126
127
  }
127
128
 
129
+ /**
130
+ * **排他移动**:把 `from` 移到 `to`,目标已存在时原子地报 `EEXIST`(绝不覆盖),
131
+ * 成功后删源。归档 / 回滚把「不该再留在顶层的文件」移进 `archive/` 时共用这一份。
132
+ *
133
+ * 为什么要有它(2026-10-31 外部审查 Q11):`rename` 在 POSIX 上会**静默覆盖**已存在的
134
+ * 目标。回滚的旧私有实现是「`link` 失败(非 EEXIST)就 `rename`」—— 在少数网络盘 /
135
+ * exFAT 上 link 报 EPERM/ENOTSUP,于是一份**历史旧归档**被当前文件顶掉,而回滚报成功。
136
+ * 「先 stat 再 rename」也修不了:两次系统调用之间正是别人写同名文件的位置。
137
+ *
138
+ * 两条路:
139
+ * 1. **硬链接**(首选):`link` 在目标存在时原子报 EEXIST;成功后再删源 —— 一次性原子可见;
140
+ * 2. **降级**(link 不可用):`copyFile(…, COPYFILE_EXCL)` 排他复制,逐字节校验**之后**
141
+ * 才删源。降级失去的是「一次性原子可见」,**不能同时放弃「不覆盖已有内容」**。
142
+ *
143
+ * ⚠️ 无法识别的错误直接失败,**绝不默认 `rename`** —— 那正是这次修掉的 bug。
144
+ * 任何一步失败都保证**源原样保留**、目标不留半截(源是唯一内容时不丢)。
145
+ *
146
+ * @returns {Promise<{exclusive:boolean}>} `exclusive:false` = 走的是复制降级(诊断用,不改变成败判定)
147
+ */
148
+ export async function moveFileExclusive(from, to) {
149
+ try {
150
+ await link(from, to)
151
+ } catch (err) {
152
+ // 目标已存在:这是**预期**的拒绝,原样抛给调用方(它负责换退役名)。
153
+ if (err?.code === 'EEXIST') throw err
154
+ if (!LINK_UNSUPPORTED_CODES.has(err?.code)) throw err
155
+ return copyMoveExclusive(from, to)
156
+ }
157
+ try {
158
+ await rm(from, { force: true })
159
+ } catch (err) {
160
+ // link 成功后 from/to 是同一 inode:删源失败时把**新名字**收回去,让调用方能干净重试。
161
+ await rm(to, { force: true }).catch(() => {})
162
+ throw err
163
+ }
164
+ return { exclusive: true }
165
+ }
166
+
167
+ /** 降级路径:排他复制 → 逐字节校验 → 删源;任何一步失败都删掉半成品、源原样保留。 */
168
+ async function copyMoveExclusive(from, to) {
169
+ await copyFile(from, to, constants.COPYFILE_EXCL)
170
+ try {
171
+ const [srcBytes, dstBytes] = await Promise.all([readFile(from), readFile(to)])
172
+ if (Buffer.compare(srcBytes, dstBytes) !== 0) {
173
+ throw Object.assign(new Error(`sage-mem: 复制 ${from} → ${to} 后内容不一致,已放弃(源未动)`), { code: 'EIO' })
174
+ }
175
+ } catch (err) {
176
+ await rm(to, { force: true }).catch(() => {})
177
+ throw err
178
+ }
179
+ try {
180
+ await rm(from, { force: true })
181
+ } catch (err) {
182
+ // 源删不掉:目标已经是校验过的完整副本,把它删掉让调用方能干净重试(源还在,不丢内容)。
183
+ await rm(to, { force: true }).catch(() => {})
184
+ throw err
185
+ }
186
+ return { exclusive: false }
187
+ }
188
+
128
189
  /**
129
190
  * **事务式接管源文件**:把源 `rename` 到只有本次调用知道的私有名,再逐字节核对
130
191
  * 它是不是调用方读到的那些字节。
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.6",
4
+ "version": "2.1.7",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",