sage-mem 2.1.13 → 2.1.14

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.13`** — 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.14`** — 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.13`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.14`** —— 顶部 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 是最直接的反馈。
@@ -110,9 +110,9 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
110
110
  const files = await scanMemoryFiles(ctx, sessionId)
111
111
 
112
112
  // ── 代次:**先处理状态转换,再算正文与控制提示** ──
113
- // 压缩 / 清空(消息条数下降)在这一行里就让签名、两本账、卡签名、baseline 标记
114
- // 一起归零。放到后面(比如去重之后再算)就等于「拿上一代的账本判这一代」——
115
- // 复审 R03 的「同题压缩后仍被挡住」正是这么来的。
113
+ // `begin()` 会把「这一次观测到的消息条数」立刻落进状态(Q08)——去重 / 零命中 /
114
+ // 预算拒绝的轮次不会走到 commit,若不在这里落账,下一次 begin 就还拿旧基数比,
115
+ // 压缩就永远判不出来。条数下降时它同时把签名、两本账、卡签名、baseline 标记归零。
116
116
  const ledgerKey = session.id || 'no-session-id'
117
117
  const st = state.begin(ledgerKey, messages.length)
118
118
 
@@ -249,9 +249,10 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
249
249
  return assembled
250
250
  }
251
251
 
252
- // 状态一次写回:签名、两本账、卡签名、缺口卡、条数、baseline 标记。
253
- // 放在这里(而不是更早)是刻意的 —— 上面每一步都可能没真注进去东西,
254
- // 那就不该让状态前进;一次失败把一条记忆永久挡在门外是最难查的一种。
252
+ // 投递账一次写回:签名、两本账、卡签名、缺口卡、baseline 标记,以及
253
+ // **delivered 条数**(`messageCount`)。放在这里(而不是更早)是刻意的 ——
254
+ // 上面每一步都可能没真注进去东西,那就不该让投递账前进;一次失败把一条记忆永久
255
+ // 挡在门外是最难查的一种。压缩判据用的**观测账**在 `begin()` 里已经落过了(Q08)。
255
256
  state.commit(ledgerKey, {
256
257
  signature,
257
258
  contentBytes,
@@ -6,9 +6,20 @@
6
6
  * 否则一次失败会把这条记忆永久挡在门外。唯一的例外是代次变化:那是**已经发生的事实**
7
7
  * (上下文真的被压缩了),必须在算任何东西之前生效,否则这一步会拿着上一代的账做判断。
8
8
  *
9
- * ⚠️ 诚实边界:压缩信号是「消息条数下降」这条启发式,它**覆盖不了条数不降的压缩**
10
- * (宿主原地改写历史,或截断后又在同一步补足条数)。那种情况下这里看不出代次变化,
11
- * 记忆仍会被去重挡住。要真正解决得等宿主给出可比的上下文代次标识(目前没有)。
9
+ * ── 2.1.14(外部审查 Q08):两本账拆开 ─────────────────────────────────
10
+ * 旧实现只有一个 `messageCount`,记的是「上一次**见效**时的条数」,只有真正提交才前进。
11
+ * 于是被去重 / 零命中 / 预算拒绝的那些轮次从不更新它 —— 2 条时注入过、涨到 20 条被去重、
12
+ * 压缩到 10 条再问时,压缩判定拿 10 比的是 2(投递账),不是 20(刚观测到的实况),
13
+ * 判不出压缩,签名没清,继续去重、不注入。
14
+ *
15
+ * 现在两本账:
16
+ * - `lastObservedMessageCount`:**每次 assemble 都推进**(观测是既成事实,与是否投递无关);
17
+ * - `messageCount`(delivered 账):只有成功投递才前进。
18
+ * 压缩判定只看观测账;`begin()` 会在返回前把观测写回状态,所以「不提交的轮次」也不会丢。
19
+ *
20
+ * ⚠️ 诚实边界(作者已声明,本版不越界):压缩信号是「消息条数下降」这条启发式,它
21
+ * **覆盖不了条数不降的压缩**(宿主原地改写历史,或截断后又在同一步补足条数)。那种情况下
22
+ * 这里看不出代次变化,记忆仍会被去重挡住。要真正解决得等宿主给出可比的上下文代次标识。
12
23
  */
13
24
  export function createSessionInjectState(maxSessions = 64) {
14
25
  const sessions = new Map()
@@ -18,27 +29,44 @@ export function createSessionInjectState(maxSessions = 64) {
18
29
  cardBytes: 0, // 本代状态卡累计字节(与正文分账,见 config.js)
19
30
  cardSig: '', // 本代已发过的那张卡(含检查点句柄的合成文本)
20
31
  gapCard: '', // 本代渲染过的缺口卡正文(签名没变时沿用,避免换个数字重发)
21
- messageCount: -1, // 上一次见效时的消息条数(压缩判据)
32
+ lastObservedMessageCount: -1, // 上一次 assemble **观测到**的消息条数(压缩判据的唯一基点)
33
+ messageCount: -1, // 上一次**成功投递**时的消息条数(delivered 账,只有 commit 推进)
22
34
  baselineDone: false, // 本代是否已经给过 baseline
23
35
  })
24
36
  return {
25
37
  /**
26
38
  * 处理代次变化,取回本会话这一代的起点。**必须在算正文与控制提示之前调用。**
27
39
  * 返回值是一份快照 —— 调用方只读,写回统一走 `commit()`。
40
+ *
41
+ * ⚠️ 与旧实现不同:这里会**立刻**把观测到的条数写回状态。观测是已经发生的事实,
42
+ * 而「去重 / 零命中 / 预算拒绝」的轮次不会调用 `commit()` —— 不在这里落账,
43
+ * 下一次 begin 就还拿着旧基数比(Q08)。
28
44
  */
29
45
  begin(key, messageCount) {
30
46
  const prev = sessions.get(key)
31
- const generationChanged = !!prev && messageCount < prev.messageCount
32
- // 条数下降 → 四样东西一起归零。少清任何一样都会留下一个「上一代的真相」:
47
+ const observed = Number.isFinite(messageCount) ? messageCount : -1
48
+ // 压缩判据对着**观测账**比,不是对着上一次投递比。
49
+ const generationChanged = !!prev && observed >= 0 && prev.lastObservedMessageCount >= 0 &&
50
+ observed < prev.lastObservedMessageCount
51
+ // 条数下降 → 除了观测基点,四样东西一起归零。少清任何一样都会留下一个「上一代的真相」:
33
52
  // 字节账残留 → 新内容注不进来;baseline 残留 → 它再也不出现;
34
- // 卡签名残留 → 恢复入口永久失效。
35
- if (generationChanged) sessions.set(key, { ...fresh(), messageCount })
36
- const cur = sessions.get(key) ?? fresh()
37
- return { ...cur, generationChanged }
53
+ // 卡签名残留 → 恢复入口永久失效;投递账残留 → 调试/诊断把旧代当新代。
54
+ const base = generationChanged ? fresh() : { ...(prev ?? fresh()) }
55
+ base.lastObservedMessageCount = observed
56
+ sessions.set(key, base)
57
+ // 只 begin 不提交的会话(全程被去重)也会进 Map —— 长驻宿主里必须同样按 FIFO 收敛。
58
+ if (sessions.size > maxSessions) sessions.delete(sessions.keys().next().value)
59
+ return { ...base, generationChanged }
38
60
  },
39
- /** 一次写回。只防长驻进程无限增长:被挤掉的老会话下次醒来至多把同一批记忆再注一次。 */
61
+ /**
62
+ * 一次写回。只防长驻进程无限增长:被挤掉的老会话下次醒来至多把同一批记忆再注一次。
63
+ *
64
+ * 合并(而不是整体替换)是刻意的:`begin()` 刚写下的观测账必须留在状态里 ——
65
+ * 调用方只回传投递账(签名 / 字节 / 卡 / baseline),不该关心、也不该覆盖观测账。
66
+ */
40
67
  commit(key, next) {
41
- sessions.set(key, { ...next })
68
+ const prev = sessions.get(key) ?? fresh()
69
+ sessions.set(key, { ...prev, ...(next && typeof next === 'object' ? next : {}) })
42
70
  // FIFO 而不是 LRU:Map 重写已存在的键不会挪动位置,与旧行为一致。
43
71
  if (sessions.size > maxSessions) sessions.delete(sessions.keys().next().value)
44
72
  },
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.13",
4
+ "version": "2.1.14",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",