sage-mem 2.4.1 → 2.4.3

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.4.1`** — 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.4.3`** — 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.4.1`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.4.3`** —— 顶部 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/client.js CHANGED
@@ -3164,6 +3164,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3164
3164
  if (!r) return "";
3165
3165
  if (r.outcome === "injected") return "本步注入 " + (Array.isArray(r.injected) ? r.injected.length : 0) + " 条正文";
3166
3166
  if (r.outcome === "card-only") return "正文没进,只发了状态卡";
3167
+ // 2.4.3:短路闸门命中 —— 这一步**压根没算**。它的 outcome 也是 empty,
3168
+ // 不单独说一句就会跟「算过了但没有可注入的内容」混成同一种读数。
3169
+ if (r.gated) return "本步跳过:同题、同目录,未重算";
3167
3170
  return r.deduped ? "同题已注入过,本步去重" : "本步没有可注入的内容";
3168
3171
  }
3169
3172
  function outcomeClass(r) {
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { createHash } from 'node:crypto'
22
- import { mkdir, readdir, readFile, unlink } from 'node:fs/promises'
22
+ import { mkdir, readdir, readFile, stat, unlink } from 'node:fs/promises'
23
23
  import { dirname, join } from 'node:path'
24
24
  import { STATE_ROOT } from './config.js'
25
25
  import { writeFileAtomic } from './util.js'
@@ -460,6 +460,41 @@ const INDEX_CACHE = new Map()
460
460
  /** 注入侧缓存有效期。改这里要同时想清楚「用户点完面板多久该见效」。 */
461
461
  export const CONFLICT_INDEX_TTL_MS = 1000
462
462
 
463
+ /**
464
+ * 冲突队列的**廉价指纹**(目录里的文件名 + mtime 秒)。
465
+ *
466
+ * 注入链的短路闸门要判断「这一步的输入与上一步是否同一份」。记忆目录有 `namesKey`,
467
+ * 而冲突队列**不在记忆目录里** —— 用户在面板上裁决一条冲突,会改变「哪些记忆该被抑制」,
468
+ * 记忆目录却一个字节都没动。不把这一份算进指纹,裁决结果就要等下一轮提问才生效。
469
+ *
470
+ * 它与 `listConflictIndex` 的分工:那个要**读内容**(几十毫秒),这个只 `stat`
471
+ * (通常 0–2 个文件,微秒级)。闸门每一步都要判,只能用后者。
472
+ *
473
+ * @returns 读不到就返回 `null` —— 调用方据此**放弃短路**。把「看不见」当成「没变」,
474
+ * 会让一次读失败变成「这一步的记忆全部作废」。`ENOENT` 是「还没有任何冲突」,
475
+ * 是确定的事实,返回空串照常参与比较。
476
+ */
477
+ export async function conflictsFingerprint(opts) {
478
+ const dir = conflictsDir(opts && typeof opts === 'object' ? opts.stateRoot : undefined)
479
+ let names
480
+ try {
481
+ names = await readdir(dir)
482
+ } catch (err) {
483
+ return err?.code === 'ENOENT' ? '' : null
484
+ }
485
+ const rows = []
486
+ for (const name of names.sort()) {
487
+ try {
488
+ const info = await stat(join(dir, name))
489
+ rows.push(`${name}@${Math.round(info.mtimeMs)}`)
490
+ } catch {
491
+ // 读得到名字、读不到属性(这期间被删或被换):指纹里留一个「不知道」,下一步必然不等。
492
+ rows.push(`${name}@?`)
493
+ }
494
+ }
495
+ return rows.join(',')
496
+ }
497
+
463
498
  /** 这组文件的**摘要索引**:不带两份正文,给注入链用。 */
464
499
  export async function listConflictIndex(opts) {
465
500
  const dir = conflictsDir(opts && typeof opts === 'object' ? opts.stateRoot : undefined)
@@ -15,10 +15,10 @@
15
15
  * 2. 代次处理(`state.begin`)必须在算正文与控制提示**之前**,否则压缩后会拿上一代的账去判重(复审 R03)。
16
16
  */
17
17
  import { MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, STATE_ROOT, validityEnabled } from '../memory/config.js'
18
- import { listConflictIndex } from '../memory/conflicts.js'
18
+ import { conflictsFingerprint, listConflictIndex } from '../memory/conflicts.js'
19
19
  import { buildContradictionFileSet, buildExpirySet } from '../memory/validity.js'
20
20
  import { extractText } from '../memory/retrieval.js'
21
- import { scanMemoryFiles } from '../memory/scan.js'
21
+ import { scanMemoryFilesDetailed } from '../memory/scan.js'
22
22
  import { readCheckpoint } from '../memory/checkpoint.js'
23
23
  import { warnLog } from '../memory/util.js'
24
24
  import { selectRecall, dedupeByFile } from './select.js'
@@ -63,6 +63,43 @@ function validitySuppressedItems(items, conflictIds) {
63
63
  return out
64
64
  }
65
65
 
66
+ /** 短路指纹:问题文本 + 目录名集合 + baseline 是否已发 + 冲突队列。四样都在判定前就拿得到。 */
67
+ function gateFingerprint({ text, namesKey, baselineDone, conflicts }) {
68
+ return `${text}\u0000${namesKey ?? ''}\u0000${baselineDone ? 'b' : ''}\u0000${conflicts}`
69
+ }
70
+
71
+ /**
72
+ * 闸门命中时的调试账。
73
+ *
74
+ * 字段集与 `makeDebug()` 逐项一致(面板读的是同一份形状),数值全部来自
75
+ * 「这一步没有任何东西被重算」这个事实:没有候选、没有正文、没有卡。
76
+ * `gated: true` 把它与「算过了但没有东西可给」分开 —— 两者的 `outcome` 都是 `empty`,
77
+ * 不标出来,面板上就分不清「检索没命中」和「压根没检索」。
78
+ */
79
+ function gatedDebugPayload({ nowMs, sessionId, text, scanned, st }) {
80
+ return {
81
+ at: nowMs,
82
+ sessionId,
83
+ query: typeof text === 'string' ? text.slice(0, 160) : '',
84
+ outcome: 'empty',
85
+ deduped: true,
86
+ gated: true,
87
+ baseline: 0,
88
+ scanned: Array.isArray(scanned?.files) ? scanned.files.length : 0,
89
+ hits: 0,
90
+ scores: [],
91
+ body: [],
92
+ picked: [],
93
+ injected: [],
94
+ chars: 0,
95
+ sessionChars: st.contentBytes,
96
+ cardChars: 0,
97
+ skippedByBudget: 0,
98
+ validitySuppressed: [],
99
+ card: st.cardSig ? 'deduped-or-over-budget' : 'none',
100
+ }
101
+ }
102
+
66
103
  /**
67
104
  * @param {object} deps
68
105
  * @param {object} deps.ctx — 宿主 ctx(`scanMemoryFiles` / `warnLog` 用)
@@ -107,8 +144,6 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
107
144
  const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
108
145
  const text = extractText(userMessages[userMessages.length - 1]?.content)
109
146
 
110
- const files = await scanMemoryFiles(ctx, sessionId)
111
-
112
147
  // ── 代次:**先处理状态转换,再算正文与控制提示** ──
113
148
  // `begin()` 会把「这一次观测到的消息条数」立刻落进状态(Q08)——去重 / 零命中 /
114
149
  // 预算拒绝的轮次不会走到 commit,若不在这里落账,下一次 begin 就还拿旧基数比,
@@ -116,6 +151,38 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
116
151
  const ledgerKey = session.id || 'no-session-id'
117
152
  const st = state.begin(ledgerKey, messages.length)
118
153
 
154
+ // 扫描本身很便宜(命中缓存时只做一次 readdir 比文件名集合,1000 份实测 0.7 ms),
155
+ // 贵的是它后面的打分:逐份切段 + BM25。闸门因此放在扫描之后、检索之前。
156
+ const scanned = await scanMemoryFilesDetailed(ctx, sessionId)
157
+ const files = scanned.files
158
+
159
+ // ── 短路闸门(2.4.3)──
160
+ // 「问题没变、目录没变、baseline 该发的发了、冲突队列没动」同时成立时,这一步算出来的
161
+ // 候选必然与上一步逐条相同,`selectRecall` 的签名比对也必然判去重 —— 判据已经齐了,
162
+ // 就先判,把打分整段跳过。一个带 10 次工具调用的回合里,有 9 步走这条路。
163
+ //
164
+ // 三条不许短路的硬条件,缺一不可:
165
+ // 1. `scanned.cached === true` —— 这次是**缓存命中**,说明目录名集合没变、watcher 代次也没动
166
+ // (代次是「只改内容、不改名」的唯一失效信号),正文与上一步逐字相同,指纹才可信。
167
+ // `cached !== true` 说明这次是真扫了盘 —— 内容刚被改过,而指纹里只有文件名,判不出来;
168
+ // 2. `!st.generationChanged` —— 刚判出上下文被压缩,账本刚归零,必须重算;
169
+ // 3. `gate !== null` —— 冲突队列读不到(`conflictsFingerprint` 返回 null)时放弃,
170
+ // 宁可多算一遍,也不要把「看不见」当成「没变」。
171
+ const conflicts = await conflictsFingerprint({ stateRoot: STATE_ROOT })
172
+ const gate = conflicts === null
173
+ ? null
174
+ : gateFingerprint({ text, namesKey: scanned.namesKey, baselineDone: st.baselineDone, conflicts })
175
+ if (gate !== null && scanned.cached === true && !st.generationChanged && st.gate === gate) {
176
+ // 账照记:面板上「这一步为什么什么都没给」与「这一步根本不算」是两件事,
177
+ // 靠 `gated: true` 分开(两者的 outcome 都是 empty)。
178
+ if (debugEnabled()) {
179
+ const payload = gatedDebugPayload({ nowMs: Date.now(), sessionId, text, scanned, st })
180
+ debugRecall(payload)
181
+ recordInjection(payload)
182
+ }
183
+ return assembled
184
+ }
185
+
119
186
  const { picked, baselineTaken, relevant, intentInfo, bodyRanked, bestChunk, selected, signature, deduped } =
120
187
  selectRecall({ text, files, st })
121
188
 
@@ -261,6 +328,9 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
261
328
  gapCard: cardBody,
262
329
  messageCount: messages.length,
263
330
  baselineDone: st.baselineDone || baselineTaken > 0,
331
+ // 闸门与签名同一条纪律:**没投递出去就不前进**。上面任何一步空手而归都会提前
332
+ // return,走不到这里 —— 于是一次目录抖动不会把这一步的记忆关在门外。
333
+ gate,
264
334
  })
265
335
 
266
336
  emitDebug(makeDebug(parts.length > 0 ? 'injected' : 'card-only'))
@@ -280,12 +350,20 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
280
350
  },
281
351
  })
282
352
 
353
+ // ⚠️ `interpolate: false` 不是可选项,是**安全边界**(2026-11-07 真实事故)。
354
+ // 记忆正文是用户写的 Markdown,不是提示词模板。宿主默认会对每个 context 做
355
+ // `{{变量}}` 解析:正文里只要出现过一双大括号(`{{ }}`、`{{foo_bar}}`、`{{#if x}}`
356
+ // 都算),解析就在**模型还没开跑**时抛错,整轮对话直接失败,报错还是
357
+ // `malformed prompt variable reference … in context "sage-mem:recall"` ——
358
+ // 用户既不知道自己写了什么触发它,也改不动插件。关掉解析是这个问题的唯一解,
359
+ // 而正文本来就该逐字呈现(段落命中、行号、引用纪律全靠字面文本)。
283
360
  return {
284
361
  ...assembled,
285
362
  contexts: [...(assembled.contexts ?? []), {
286
363
  name: 'sage-mem:recall',
287
364
  order: 1000,
288
365
  text: recallText,
366
+ interpolate: false,
289
367
  }],
290
368
  }
291
369
  } catch (err) {
@@ -32,6 +32,11 @@ export function createSessionInjectState(maxSessions = 64) {
32
32
  lastObservedMessageCount: -1, // 上一次 assemble **观测到**的消息条数(压缩判据的唯一基点)
33
33
  messageCount: -1, // 上一次**成功投递**时的消息条数(delivered 账,只有 commit 推进)
34
34
  baselineDone: false, // 本代是否已经给过 baseline
35
+ // 2.4.3:上一次投递时的**短路指纹**(问题 + 目录名集合 + baseline 标记 + 冲突队列)。
36
+ // 与 `signature` 的区别是它**不依赖检索结果** —— 签名要先算完命中集才知道,而这个
37
+ // 只读现成的四样东西,所以能在打分之前判「这一步必然和上一步一样」。
38
+ // 同样只在 commit 前进:没投递出去的轮次不许把这个闸门关上。
39
+ gate: null,
35
40
  })
36
41
  return {
37
42
  /**
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.4.1",
4
+ "version": "2.4.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",