sage-mem 2.1.6 → 2.1.8

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.8`** — 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.8`** —— 顶部 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/(不删)。同名时**换一个退役名**落进去,绝不覆盖,也绝不放弃。
package/lib/index.js CHANGED
@@ -1,17 +1,11 @@
1
1
  /**
2
- * sage-mem — 文件式记忆插件。
2
+ * sage-mem — 文件式记忆插件。记忆存在本地 markdown 目录(frontmatter + 正文,4 类),不是数据库。
3
+ * host 侧三件事:① 按问题检索注入:system-prompt/assemble 时扫 memory 目录、按双字匹配选相关文件,
4
+ * 读全文注入 system prompt,让 agent 第一轮就「想起」;② TypertRemoteService:给设置页「记忆管理」
5
+ * 提供文件列表/读/写/删;③ autodream(自动做梦):可选的记忆整理,见 lib/autodream.js。
3
6
  *
4
- * 记忆存在本地 markdown 文件目录(frontmatter + 正文,4 类),不是数据库。
5
- * host 侧三件事:
6
- * 1. 按问题检索注入:system-prompt/assemble 时扫 memory 目录,按双字匹配
7
- * 选相关文件,读全文注入 system prompt,让 agent 第一轮就「想起」
8
- * 2. TypertRemoteService:给设置页「记忆管理」提供文件列表/读/写/删
9
- * 3. autodream(自动做梦):可选的记忆整理,见 lib/autodream.js
10
- *
11
- * 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写
12
- * memory 目录——透明、可检查、防膨胀。无 worker、无 SQLite、无端口。
13
- *
14
- * 旧 worker 版见 git 分支 `sqlite-worker`。
7
+ * 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写 memory 目录——透明、可检查、
8
+ * 防膨胀。无 worker、无 SQLite、无端口。旧 worker 版见 git 分支 `sqlite-worker`。
15
9
  */
16
10
 
17
11
  import { AutodreamEngine, DEFAULT_CONFIG, auditMemoryDir } from './autodream.js'
@@ -26,7 +20,7 @@ import { invalidateMemory, undoMemoryInvalidation } from './memory/arbitrate.js'
26
20
  import { readSnapshot, writeSnapshot } from './memory/write-protocol.js'
27
21
  import { archiveMemory, archivedRevisionOf, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
28
22
  import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
29
- import { warnLog, writeFileAtomic } from './memory/util.js'
23
+ import { serializeByKey, warnLog, writeFileAtomic } from './memory/util.js'
30
24
  import { readLockSet, setLocked as setLockedImpl } from './memory/lock.js'
31
25
  import { openMemoryFolder } from './memory/open-folder.js'
32
26
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
@@ -694,20 +688,14 @@ markRemote(StarmapGateway, 'listStars', 'listStars')
694
688
  markRemote(StarmapGateway, 'readFile', 'readFile')
695
689
 
696
690
  /**
697
- * 2.0.0 的裁决入口:冲突队列的「可列、可读、可裁、可撤销」。
691
+ * 2.0.0 的裁决入口:冲突队列的「可列、可读、可裁、可撤销」。单独一个 namespace(`conflict`)
692
+ * 不并进 `memory`:这一层会**写用户的记忆文件**,与 memory 的读写正文不是一个权限面。
698
693
  *
699
- * 为什么单独一个 namespace(`conflict`)而不并进 `memory`:这一层会**写用户的记忆文件**
700
- * (把 `invalid_at` 写进 frontmatter),而 memory 面是读写正文。两张契约表的失败语义、
701
- * 危险等级都不同,混在一起只会让「读一下」和「改有效性」共用一个权限面。
702
- *
703
- * 四条硬约束(对应 `docs/2.0.0-plan.md` §5.5 / §7.3 / §7.6):
704
- * 1. **先写文件、后改记录**。反过来会出现「记录说已裁决、文件没改」的半态,
705
- * 用户再也看不到它(记录已 resolved,文件仍照旧注入)。
706
- * 2. `invalidate` 只能写**这条记录自己提到的那一方**(`file` / `otherFile`)——
707
- * 防「借裁决入口写任意记忆文件」。文件名本身还要再过 `safeName` 闸门。
708
- * 3. 失败一律返回对象、绝不抛;`ok` 只在真的做了报告里那件事时为 true。
709
- * 4. 写路径走宿主 `fs.writeText`:版本守卫 + 逐次沙箱策略(收窄到记忆目录),
710
- * 与 autodream 同一口径 —— `read-only` 不替用户翻案。
694
+ * 四条硬约束(`docs/2.0.0-plan.md` §5.5 / §7.3 / §7.6):① 先写文件、后改记录(反过来会出现
695
+ * 「记录说已裁决、文件没改」的半态,用户再也看不到它);② `invalidate` 只能写这条记录自己提到的
696
+ * 那一方(`file` / `otherFile`,文件名再过 `safeName` 闸门),防「借裁决入口写任意记忆文件」;
697
+ * ③ 失败一律返回对象、绝不抛,`ok` 只在真的做了报告里那件事时为 true;④ 写路径走宿主
698
+ * `fs.writeText`:版本守卫 + 逐次沙箱策略(收窄到记忆目录),与 autodream 同一口径。
711
699
  */
712
700
  export class ConflictGateway extends TypertRemoteService {
713
701
  constructor(ctx) { super(ctx, 'conflict') }
@@ -809,6 +797,9 @@ export class ConflictGateway extends TypertRemoteService {
809
797
  async dismiss(opts) {
810
798
  const read = await readConflict(opts?.id, { stateRoot: STATE_ROOT })
811
799
  if (!read.ok) return { ok: false, code: read.code, error: read.error }
800
+ // 2.1.8(N02):已裁决为「标失效」的记录不能直接驳回 —— 记忆上的失效标记还在,
801
+ // 驳回只会让它变成一条没有撤销入口的孤儿标记。要收掉判断请先撤销。
802
+ if (read.record.status === 'resolved' && read.record.decision === 'invalidate') return { ok: false, code: 'invalid_transition', error: '这条记录已裁决为失效且记忆仍带失效标记:先撤销,再决定要不要驳回' }
812
803
  // 记录里的 `note` 是冲突的由来(写进记录那一刻的事实),裁决理由**不覆盖**它 ——
813
804
  // 覆盖等于把证据改写成结论。
814
805
  const patched = await patchConflict(
@@ -863,6 +854,10 @@ export class ConflictGateway extends TypertRemoteService {
863
854
  * 这条改名不影响对外承诺。
864
855
  */
865
856
  async removeRecord(opts) {
857
+ // 2.1.8(N02):删掉一条「记忆仍标着失效」的记录等于删掉撤销入口 —— 标记就永久留在了
858
+ // 用户文件里。先撤销、再删记录。读不出来(坏记录)时照旧允许删:那是清理坏数据的出路。
859
+ const cur = await readConflict(opts?.id, { stateRoot: STATE_ROOT })
860
+ if (cur.ok && cur.record.status === 'resolved' && cur.record.decision === 'invalidate') return { ok: false, code: 'invalid_transition', error: '这条记录的记忆仍标着失效:先撤销(撤销会清掉标记)再删记录' }
866
861
  const removed = await removeConflict(opts?.id, { stateRoot: STATE_ROOT })
867
862
  if (!removed.ok) return { ok: false, code: removed.code, error: removed.error }
868
863
  return { ok: true }
@@ -876,6 +871,9 @@ markRemote(ConflictGateway, 'dismiss', 'dismiss')
876
871
  markRemote(ConflictGateway, 'undo', 'undo')
877
872
  markRemote(ConflictGateway, 'removeRecord', 'removeRecord')
878
873
 
874
+ // 2.1.8(N02):裁决入口整段按冲突 id 串行(第二方进队后重新读状态;理由见 util.js 的 serializeByKey)。
875
+ serializeByKey(ConflictGateway, ['invalidate', 'dismiss', 'undo', 'removeRecord'])
876
+
879
877
  /**
880
878
  * autodream 的 remote 面:设置页「自动做梦」标签页唯一的数据通道。
881
879
  *
@@ -19,8 +19,18 @@
19
19
  import { join } from 'node:path'
20
20
  import { inspectMemoryEntry, readVerified, yamlScalar } from './scan.js'
21
21
  import { safeName } from './naming.js'
22
+ import { createKeyedWriteQueue } from './util.js'
22
23
  import { parseValidityTime } from './validity.js'
23
24
 
25
+ /**
26
+ * 2.1.8(外部审查 N02):同一份记忆文件的「读快照 → 改标记 → 写回」整段串行。
27
+ *
28
+ * 单文件的版本 CAS 只覆盖「这一次读写窗口」,保护不了「两个不同冲突同时标同一文件」:
29
+ * 两边都基于旧字节改写,后写的那个会把前一个标记静默盖掉,而两份收据都声称成功。
30
+ * 串行之后后一个读到的就是前一个写完的字节(跨进程仍由宿主 replaceIfVersion 兜底)。
31
+ */
32
+ const withFileLock = createKeyedWriteQueue()
33
+
24
34
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
25
35
 
26
36
  /** 失效标记的两行(只认顶层键:`metadata:` 嵌套块里同名的键不是我们的)。 */
@@ -177,6 +187,11 @@ async function readForWrite(ctx, memoryDir, file) {
177
187
  * | {ok:false, code:'invalid'|'missing'|'stale_version'|'write_denied'|'unsupported'|'io_error', error:string}>}
178
188
  */
179
189
  export async function invalidateMemory({ ctx, memoryDir }, input) {
190
+ // 2.1.8(N02):按记忆文件串行(见 withFileLock 的注释)。文件名归一后当 key。
191
+ return withFileLock(String(safeName(input?.file) ?? ''), () => invalidateMemoryLocked({ ctx, memoryDir }, input))
192
+ }
193
+
194
+ async function invalidateMemoryLocked({ ctx, memoryDir }, input) {
180
195
  const invalidAt = typeof input?.invalidAt === 'string' ? input.invalidAt.trim() : ''
181
196
  // 写进用户文件的标记必须是我们能解析的格式:坏值会被 validity 层按「未标注」处理、
182
197
  // 记忆照常注入 —— 用户会看到「标了却没用」,那是最难查的一类静默失效。
@@ -213,6 +228,11 @@ export async function invalidateMemory({ ctx, memoryDir }, input) {
213
228
  * | {ok:false, code:'invalid'|'missing'|'stale_version'|'write_denied'|'unsupported'|'io_error', error:string}>}
214
229
  */
215
230
  export async function undoMemoryInvalidation({ ctx, memoryDir }, file) {
231
+ // 2.1.8(N02):与 invalidateMemory 同一条文件级串行(撤销和标记可能同时发生)。
232
+ return withFileLock(String(safeName(file) ?? ''), () => undoMemoryInvalidationLocked({ ctx, memoryDir }, file))
233
+ }
234
+
235
+ async function undoMemoryInvalidationLocked({ ctx, memoryDir }, file) {
216
236
  const read = await readForWrite(ctx, memoryDir, file)
217
237
  if (!read.ok) return read
218
238
  const built = removeValidityLines(read.content)
@@ -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
  * 它是不是调用方读到的那些字节。
@@ -229,6 +290,62 @@ export function createWriteQueue() {
229
290
  }
230
291
  }
231
292
 
293
+ /**
294
+ * 造一个**按 key 分队列**的进程内串行器(2.1.8 / 外部审查 N02 引入)。
295
+ *
296
+ * 为什么不能用一个全局队列:冲突裁决按 conflict id 串行,但不同 id 之间不该互相排队 ——
297
+ * 一条卡住的写入不该拖住整张冲突表。同一个 key 上,整段「读记录 → 写记忆 → 写收据」
298
+ * 必须一个接一个:两个相反裁决并发时,各自改不同记忆文件都能合法成功,最后同一份收据
299
+ * 只剩最后一份,而两份记忆都失效了 —— 单文件的版本 CAS 保护不了整项操作。
300
+ *
301
+ * 队列空闲后把 entry 摘掉:长进程里 id 会不断累积,不能每个 key 永久留一条闭包链。
302
+ * ⚠️ 与 `createWriteQueue` 同一条边界:**进程内**串行,不是跨进程锁。
303
+ *
304
+ * @returns {(key:string, fn:()=>Promise<any>)=>Promise<any>}
305
+ */
306
+ export function createKeyedWriteQueue() {
307
+ const entries = new Map()
308
+ return function enqueue(key, fn) {
309
+ const k = String(key)
310
+ let entry = entries.get(k)
311
+ if (!entry) {
312
+ entry = { queue: createWriteQueue(), pending: 0 }
313
+ entries.set(k, entry)
314
+ }
315
+ entry.pending += 1
316
+ const done = () => {
317
+ entry.pending -= 1
318
+ // pending 归零且这一条还是当前那条时才摘:期间有人入队的话 pending 不会到 0。
319
+ if (entry.pending === 0 && entries.get(k) === entry) entries.delete(k)
320
+ }
321
+ const run = entry.queue(fn)
322
+ // 只挂回调、不改返回的那条 promise(失败仍由调用方接住)。
323
+ run.then(done, done)
324
+ return run
325
+ }
326
+ }
327
+
328
+ /**
329
+ * 把一组「第一个参数是 `{id}`」的 remote 方法按 id 串行(2.1.8 / N02 的用法)。
330
+ *
331
+ * 为什么用原型包装而不是给每个方法再写一层:四个入口(invalidate / dismiss / undo /
332
+ * removeRecord)的**方法体本身要重新读状态**(这是队列内重读的实现),外面只多一层
333
+ * 「按 id 排队」的接线。包装发生在 class 定义之后、宿主清单之外;typert 的调用是
334
+ * `Reflect.get(receiver, method)` 现取,所以包装后的原型方法就是真正被执行的那个。
335
+ *
336
+ * @param {Function} cls — 目标类(方法定义在它的 prototype 上)
337
+ * @param {string[]} methods — 方法名
338
+ */
339
+ export function serializeByKey(cls, methods) {
340
+ const withLock = createKeyedWriteQueue()
341
+ for (const name of methods) {
342
+ const run = cls.prototype[name]
343
+ cls.prototype[name] = function serializeOne(first) {
344
+ return withLock(String(first?.id ?? ''), () => run.call(this, first))
345
+ }
346
+ }
347
+ }
348
+
232
349
  /**
233
350
  * ⚠️ 回滚写回请用 **`writeFileAtomic`**,不要用 `copyFile(src, dst)`。
234
351
  *
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.8",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",