sage-mem 2.1.7 → 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.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
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.7`** —— 顶部 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 是最直接的反馈。
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)
@@ -290,6 +290,62 @@ export function createWriteQueue() {
290
290
  }
291
291
  }
292
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
+
293
349
  /**
294
350
  * ⚠️ 回滚写回请用 **`writeFileAtomic`**,不要用 `copyFile(src, dst)`。
295
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.7",
4
+ "version": "2.1.8",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",