sage-mem 2.4.0 → 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.0`** — 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.
@@ -79,7 +79,7 @@ Most memory schemes take the "SQLite + a long-running worker" route: once memori
79
79
  | **Migration from Claude Code** | The persona (`CLAUDE.md` → `AGENTS.md`) is copied as-is; memories just need organizing into `.md` files under four types (see [the next section](#migrating-from-claude-code)) |
80
80
  | **Ships its own usage guide** | The package carries a skill (`skills/sage-mem/`): what a memory looks like, the four types, what not to store, the frontmatter pitfalls, how retrieval actually works, how to audit and fix a broken index. Installing the plugin is enough — the agent gets the instructions, no manual setup |
81
81
 
82
- The UI also carries four more capabilities (all of them optional): the memory management page, type and tag filtering, the memory star map, and audit & archiving.
82
+ The UI also carries four more capabilities (all of them optional): the memory management page, type and tag filtering, the timeline (an entries list by default, with an optional star-map view), and audit & archiving.
83
83
 
84
84
  **Since 1.8.0, memory management is also reachable from the sidebar**: a 「记忆管理」(memory management) icon appears in the sidebar and switches the central panel to this page (with a "back to conversation" control at the top).
85
85
  It can be turned off in the settings page under 「记忆管理 → 自动做梦 → 设置」(on by default; applies immediately, no restart);
@@ -165,7 +165,7 @@ The state directory (configuration, ledger, rollback points, conflict queue) def
165
165
 
166
166
  Every image below is **rendered from the UI code in this repository** (the same CSS, the same component tree; the data is demo data for illustration, not a real memory directory).
167
167
 
168
- Since 1.9.0 the memory-management surface follows the harness language (**Settings → General → Language**); memory content itself stays in whatever language it was written in, 2.1.0 extends it to the **Autodream** (run / settings / records) and **Auto-archive** screens; the star map is the only panel still Chinese until the next i18n batch.
168
+ Since 1.9.0 the memory-management surface follows the harness language (**Settings → General → Language**); memory content itself stays in whatever language it was written in, 2.1.0 extends it to the **Autodream** (run / settings / records) and **Auto-archive** screens; the timeline / star-map screen is the only panel still Chinese until the next i18n batch.
169
169
 
170
170
  ![File list: one Markdown file per memory, greyed rows are archived](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/files-list.png)
171
171
 
@@ -191,9 +191,13 @@ Since 1.9.0 the memory-management surface follows the harness language (**Settin
191
191
 
192
192
  *Archive candidates — each states "why archiving is suggested" (idle days vs the threshold for that type); files are touched only after you confirm one by one or select all. Archiving moves a file into `archive/`, it **does not delete**; to actually delete an archived entry, click 「彻底删除」(delete permanently) in the archive view and confirm once more.*
193
193
 
194
- ![Memory star map: dark stars are archived memories](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/starmap-archive.png)
194
+ ![Timeline (default view): entries sorted newest-first](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/starmap-list.png)
195
195
 
196
- *Memory star map — colour maps to type, size to volume, brightness to freshness; dark stars are archived memories, and opening one shows the archive time and reason.*
196
+ *Timeline — **this is what the tab opens on**: a list of entries sorted newest-first, most recently changed at the top. Want the picture? Switch to 「星空」(star map) in the top-right corner.*
197
+
198
+ ![Timeline · star-map view: dark stars are archived memories](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/starmap-archive.png)
199
+
200
+ *After switching to the star-map view — colour maps to type, size to volume, brightness to freshness; dark stars are archived memories, and opening one shows the archive time and reason.*
197
201
 
198
202
  ![Autodream · settings](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/autodream-settings.png)
199
203
 
@@ -203,9 +207,9 @@ Since 1.9.0 the memory-management surface follows the harness language (**Settin
203
207
 
204
208
  *Auto-archive — it is not the same thing as autodream: archiving is a **deterministic rule** (computed from idle days in the access ledger, no model call), which is why it gets a page of its own. Thresholds take effect on save, no restart needed.*
205
209
 
206
- ![Memory star map](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/starmap.png)
210
+ ![Timeline · star-map view](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/starmap.png)
207
211
 
208
- *Memory star map in full — the canvas draws only relations and distribution; the file name of a single memory appears only when you hover over or open that star. Rendering is redrawn on demand; no drawing is initiated while the view is static.*
212
+ *The star-map view in full — the canvas draws only relations and distribution; the file name of a single memory appears only when you hover over or open that star. Rendering is redrawn on demand; no drawing is initiated while the view is static.*
209
213
 
210
214
  ## Memory File Format
211
215
 
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.0`** —— 顶部 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 是最直接的反馈。
@@ -81,7 +81,7 @@ sage-mem 补上这一层,做法是:**把该记住的事写成一张张卡片
81
81
  | **从 Claude Code 迁移** | 人格(`CLAUDE.md` → `AGENTS.md`)照搬;记忆按四类整理成 `.md` 即可(见[下一节](#从-claude-code-迁移)) |
82
82
  | **自带使用指南** | 包内携带一份技能(`skills/sage-mem/`):记忆长什么样、四类怎么分、什么不该存、frontmatter 的坑、检索怎么工作、索引坏了怎么修。装上插件就够了 —— Agent 拿到说明书,不需要手工配置 |
83
83
 
84
- 界面上还有四块能力(都可以不用):记忆管理页、类型与标签筛选、记忆星图、体检与归档。
84
+ 界面上还有四块能力(都可以不用):记忆管理页、类型与标签筛选、时间线(默认是条目列表,可切星空视角)、体检与归档。
85
85
 
86
86
  **1.8.0 起,记忆管理还能从侧栏直接进**:侧栏多一个「记忆管理」图标,点它把中央主区切到本页(页顶有「返回会话」)。
87
87
  设置页「记忆管理 → 自动做梦 → 设置」里可以关掉这个入口(默认开,立即生效、不用重启);
@@ -165,7 +165,7 @@ SAGE_MEM_DIR=/path/to/your/memory
165
165
 
166
166
  下面每张图都是**从仓库里的界面代码渲染出来的**(同一份 CSS、同一棵组件树;数据是演示用的假数据,不是真实记忆目录)。
167
167
 
168
- 1.9.0 起,记忆管理这一块跟随宿主语言(**设置 → 通用 → 语言**);记忆内容本身保持它被写下时的语言,2.1.0 起扩展到**自动做梦**(运行 / 设置 / 记录)与**自动归档**两屏;只剩记忆星图仍是中文,等下一批 i18n。
168
+ 1.9.0 起,记忆管理这一块跟随宿主语言(**设置 → 通用 → 语言**);记忆内容本身保持它被写下时的语言,2.1.0 起扩展到**自动做梦**(运行 / 设置 / 记录)与**自动归档**两屏;只剩时间线/星图那一屏仍是中文,等下一批 i18n。
169
169
 
170
170
  ![文件列表:每条记忆一个 Markdown 文件,灰底的是已归档](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/files-list.png)
171
171
 
@@ -191,9 +191,13 @@ SAGE_MEM_DIR=/path/to/your/memory
191
191
 
192
192
  *归档候选 —— 每条写清「为什么建议归档」(闲置天数 vs 该类型阈值),逐条或全选后确认才动文件。归档是把文件移进 `archive/`,**不删除**;真要删掉一条归档件,得在档案馆里单独点「彻底删除」并再确认一次。*
193
193
 
194
- ![记忆星图:暗星是已归档的记忆](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/starmap-archive.png)
194
+ ![时间线(默认视角):按更新时间倒序的条目列表](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/starmap-list.png)
195
195
 
196
- *记忆星图 —— 颜色对应类型、大小对应体积、亮度对应新鲜度;暗星是已归档的记忆,点开可看归档时间与理由。*
196
+ *时间线 —— **打开这个标签先看见的就是这一屏**:按更新时间倒序的条目列表,最近改过的在最上面。想看图,右上角「星空」自己切。*
197
+
198
+ ![时间线 · 星空视角:暗星是已归档的记忆](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/starmap-archive.png)
199
+
200
+ *切到「星空」之后 —— 颜色对应类型、大小对应体积、亮度对应新鲜度,暗星是已归档的记忆,点开可看归档时间与理由。*
197
201
 
198
202
  ![自动做梦 · 设置](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/autodream-settings.png)
199
203
 
@@ -203,9 +207,9 @@ SAGE_MEM_DIR=/path/to/your/memory
203
207
 
204
208
  *自动归档 —— 它和自动做梦不是一件事:归档是**确定性规则**(按访问台账的闲置天数算,不调模型),所以单独一页。阈值保存即生效,不用重启。*
205
209
 
206
- ![记忆星图](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/starmap.png)
210
+ ![时间线 · 星空视角](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/starmap.png)
207
211
 
208
- *记忆星图全貌 —— 画布上只画关系与分布;悬停或点开某颗星时才显示那一条的文件名。渲染是按需重绘,画面静止时不发起任何绘制。*
212
+ *「星空」视角全貌 —— 画布上只画关系与分布;悬停或点开某颗星时才显示那一条的文件名。渲染是按需重绘,画面静止时不发起任何绘制。*
209
213
 
210
214
  ## 记忆文件格式
211
215
 
package/lib/client.js CHANGED
@@ -1128,10 +1128,12 @@ const StarmapRuntime = (function (require, _react) {
1128
1128
  const busyS = react.useState(false);
1129
1129
  const busy = busyS[0];
1130
1130
  const setBusy = busyS[1];
1131
- // 2.3.1:默认仍是「星空」—— 它是这个功能的招牌视觉(README 封面图),
1132
- // 而且改成 timeline 会让 8 条「画布真的画出来了 / 点得中暗星」的渲染断言失去
1133
- // 前提(那些只在空间视角成立)。变的是**按钮顺序**:时间线在前。
1134
- const viewS = react.useState("sky");
1131
+ // 2.4.1:默认视角是**时间线**。产品要求「点开这个标签先看见按时间排的条目」——
1132
+ // 星空是这个功能的可选视角,不是入口。原先默认星空,理由是它更适合当招牌视觉;
1133
+ // 但一个叫「时间线」的标签第一屏却是一张星图,等于让人先猜这里是什么。
1134
+ // 代价:8 条「画布真的画出来了 / 点得中暗星」的渲染断言失去前提,
1135
+ // 所以 client-render-test 里补了一条切视角的入口(先点「星空」再断言)。
1136
+ const viewS = react.useState("timeline");
1135
1137
  const viewMode = viewS[0];
1136
1138
  const setViewMode = viewS[1];
1137
1139
  const emptySkyS = react.useState(false);
@@ -1623,15 +1625,16 @@ const StarmapRuntime = (function (require, _react) {
1623
1625
  }
1624
1626
 
1625
1627
  return h("div", { className: "smap-card" },
1626
- // 卡片里不再重复「记忆星图 / sage-mem memory」这一行 —— 标题已经在面板顶部
1628
+ // 卡片里不再重复标题那一行 —— 标签本身叫「时间线」,标题已经在面板顶部
1627
1629
  // 那条共用头部带里(「我在哪」),这里只留控件,少一行噪音。
1628
1630
  h("div", { className: "smap-head" },
1629
1631
  h("span", { className: "smap-spacer" }),
1630
- // 2.3.1:时间线在前(默认视角),星空在后 —— 与默认值一致,别让人点开
1631
- // 一个高亮的按钮发现它不在第一位。
1632
+ // 2.4.1:内部视角按钮叫「列表 | 星空」—— 标签已经叫「时间线」,
1633
+ // 里面再放一个「时间线」按钮就成了「时间线 ▸ 时间线 | 星空」,读起来像 bug。
1634
+ // 「列表」也更准:它就是按更新时间倒序的条目列表。
1632
1635
  h("span", { className: "smap-viewtoggle" },
1633
- h("button", { className: "smap-vt-btn" + (viewMode === "timeline" ? " on" : ""), onClick: () => setViewMode("timeline"), title: "时间视角:最近更新在前" }, "时间线"),
1634
- h("button", { className: "smap-vt-btn" + (viewMode === "sky" ? " on" : ""), onClick: () => setViewMode("sky"), title: "空间视角:星座分布" }, "星空")),
1636
+ h("button", { className: "smap-vt-btn" + (viewMode === "timeline" ? " on" : ""), onClick: () => setViewMode("timeline"), title: "列表视角:最近更新在前" }, "列表"),
1637
+ h("button", { className: "smap-vt-btn" + (viewMode === "sky" ? " on" : ""), onClick: () => setViewMode("sky"), title: "星空视角:星座分布" }, "星空")),
1635
1638
  // 档案馆开关:默认开。关掉只是本地不再画暗星,不重新取数。
1636
1639
  // 只留文字,不挂数字(计数归脚注的「档案馆 N 颗暗星」);开/关靠 on 态
1637
1640
  // 与 aria-pressed 的样式差,别让人猜现在是开还是关。
@@ -3161,6 +3164,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3161
3164
  if (!r) return "";
3162
3165
  if (r.outcome === "injected") return "本步注入 " + (Array.isArray(r.injected) ? r.injected.length : 0) + " 条正文";
3163
3166
  if (r.outcome === "card-only") return "正文没进,只发了状态卡";
3167
+ // 2.4.3:短路闸门命中 —— 这一步**压根没算**。它的 outcome 也是 empty,
3168
+ // 不单独说一句就会跟「算过了但没有可注入的内容」混成同一种读数。
3169
+ if (r.gated) return "本步跳过:同题、同目录,未重算";
3164
3170
  return r.deduped ? "同题已注入过,本步去重" : "本步没有可注入的内容";
3165
3171
  }
3166
3172
  function outcomeClass(r) {
@@ -3834,7 +3840,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3834
3840
  "sidebar.label": "记忆管理",
3835
3841
 
3836
3842
  "tab.files": "文件列表",
3837
- "tab.star": "记忆星图",
3843
+ "tab.star": "时间线",
3838
3844
  "tab.autodream": "自动做梦",
3839
3845
  "tab.archive": "自动归档",
3840
3846
  "tab.audit": "体检",
@@ -4317,7 +4323,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4317
4323
  "sidebar.label": "Memory management",
4318
4324
 
4319
4325
  "tab.files": "Files",
4320
- "tab.star": "Star map",
4326
+ "tab.star": "Timeline",
4321
4327
  "tab.autodream": "Autodream",
4322
4328
  "tab.archive": "Auto-archive",
4323
4329
  "tab.audit": "Audit",
@@ -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.0",
4
+ "version": "2.4.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",