sage-mem 2.3.1 → 2.4.1

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.3.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.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
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
 
@@ -376,6 +380,8 @@ Every consolidation leaves a **manifest** behind, in two forms: the human-readab
376
380
 
377
381
  It records item by item: **file / operation (create · rewrite · archive) / reason / byte change**, plus a structural audit comparison before and after the run.
378
382
 
383
+ **Since 2.4.0 the manifest also lists the facts that were deleted, one by one.** A rewrite or an archive can make a few facts that used to be written down stop existing, and the structural audit (dangling links / formatting / index) **cannot see a lost fact** — every check it runs can be green. So whenever something is removed, the manifest gets a section of its own listing the **exact original lines that no longer exist in the active memories**, with their original line numbers (so you can check them against the snapshot), and a rewrite also states the line-count change. Files taken by the auto-archive policy are listed too. The facts are computed by the host by diffing "the body before the write" against "the body written in" — **not self-reported by the model**: a model can miss things, a diff cannot. One real lesson: on 2026-11-05 a consolidation pass compressed away an entry that was still in effect — 「到期 2027-10-03、不自动续费」— and the run reported "audit 2 problems → 0 problems", all green.
384
+
379
385
  - **`reason` should be filled in.** A `write_memory` without `reason` **will not be refused** — the change takes effect all the same, but the manifest marks it as 「未自述」(not self-described) and attaches what the host can trace (which turn / what operation / byte change). The reason: losing an otherwise correct correction over one missing explanation is the greater cost.
380
386
  - **`archive_memory`'s `reason` is still required**; without it the action is refused outright, and refused actions get a section of their own, 「未能落地的改动」(changes that could not land), rather than disappearing silently.
381
387
 
@@ -517,7 +523,7 @@ Version-by-version compatibility record:
517
523
  | 0.2.1-rc.4 | compatible |
518
524
  | 0.2.1-rc.5 | compatible |
519
525
 
520
- **How to read `compatible`.** It means this plugin's codec and manifest contract has been checked against that host generation (code-level comparison plus the unit/contract suite), **not** that an end-to-end probe was run on that exact host build. The table is a declaration; how each claim was checked is summarized here. Real-host probes so far: `headless` (2026-10-15), `web` (2026-10-16), `web` on **0.2.1-rc.3** for the 2.0.0 release (2026-10-27: client activation, conflict panel arbitration, injection suppression, rollback to 1.9.2 — 23/23 checks), and the **DSH Go mobile engine on 0.2.1-rc.4** (2026-10-31: installed from the in-app plugin market, sidebar panel and settings section rendered by the app, `sidebar.panellist` island opened); `0.2.1-rc.5` was checked after upgrading the greycloud host the same day (composed config, boot, panel walk-through). The `desktop` profile has **not** been probed yet (see [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) §4b). CI runs on Ubuntu against **Node 18.x / 20.x / 24.x** with the `0.2.0-rc.1` peer generation; macOS and Windows are not CI-covered, and there is no desktop-host probe yet.
526
+ **How to read `compatible`.** It means this plugin's codec and manifest contract has been checked against that host generation (code-level comparison plus the unit/contract suite), **not** that an end-to-end probe was run on that exact host build. The table is a declaration; how each claim was checked is summarized here. Real-host probes so far: `headless` (2026-10-15), `web` (2026-10-16), `web` on **0.2.1-rc.3** for the 2.0.0 release (2026-10-27: client activation, conflict panel arbitration, injection suppression, rollback to 1.9.2 — 23/23 checks), and the **DSH Go mobile engine on 0.2.1-rc.4** (2026-10-31: installed from the in-app plugin market, sidebar panel and settings section rendered by the app, `sidebar.panellist` island opened); `0.2.1-rc.5` was checked on a local web instance the same day (composed config, boot, panel walk-through). The `desktop` profile has **not** been probed yet (see [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) §4b). CI runs on Ubuntu against **Node 18.x / 20.x / 24.x** with the `0.2.0-rc.1` peer generation; macOS and Windows are not CI-covered, and there is no desktop-host probe yet.
521
527
 
522
528
  Two points worth spelling out:
523
529
 
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.3.1`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.4.1`** —— 顶部 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
 
@@ -376,6 +380,8 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
376
380
 
377
381
  逐条记录:**文件 / 操作(新建·改写·归档)/ 缘由 / 字节变化**,外加运行前后的结构审计对比。
378
382
 
383
+ **2.4.0 起,声明还会逐条列出「被删掉的事实」**:改写与归档都可能让原来写着的几条事实不再存在,而结构审计(断链 / 格式 / 索引)**看不见事实丢失** —— 它能报的每一项都可能是绿的。所以每次删到东西,声明里单开一节,把**不再存在于活动记忆里的原文**逐条列出来(带原文行号,方便回快照对),改写还会写明行数变化。自动归档策略收走的文件同样列。事实由宿主拿「写之前的正文」与「写进去的新正文」对比算出,**不靠模型自述** —— 模型可以漏报,diff 不会。一处真实教训:2026-11-05 那趟整理把一条仍然生效的「到期 2027-10-03、不自动续费」压掉了,自报「审计 2 问题 → 0 问题」全绿。
384
+
379
385
  - **`reason` 应当填写。** `write_memory` 不带 `reason` **不会被拒绝** —— 改动照样生效,但声明里会把它标成「未自述」并附上宿主能追溯到的信息(第几轮 / 什么操作 / 字节变化)。理由:为了一句解释而丢掉一次本来正确的修正,代价更大。
380
386
  - **`archive_memory` 的 `reason` 仍必填**,缺了直接拒绝;被拒的动作单列一节「未能落地的改动」,不会静默消失。
381
387
 
@@ -519,7 +525,7 @@ sage-mem 是纯 DSH 插件,用 `package.json` 的 **`engines.dsh`** 声明宿
519
525
  | 0.2.1-rc.4 | compatible |
520
526
  | 0.2.1-rc.5 | compatible |
521
527
 
522
- **`compatible` 怎么读**:它表示本插件的 codec / manifest 契约在该宿主代上核对过(代码层比对 + 单元/契约测试),**不表示**在那个确切宿主构建上跑过端到端探针。这张表是声明,核对方式总结在下面。真实宿主探针目前跑过 `headless`(2026-10-15)、`web`(2026-10-16)、**0.2.1-rc.3 上的 `web`**(2026-10-27,2.0.0 发布前:客户端激活、冲突面板裁决、注入抑制、回退到 1.9.2,23/23 项通过),以及 **0.2.1-rc.4 上的 DSH Go 手机引擎**(2026-10-31:App 插件市场安装、侧栏面板与设置分区原生呈现、`sidebar.panellist` 岛屿打开);`0.2.1-rc.5` 于同日升级灰云月殿后按 dump/启动/面板走查核对;`desktop` profile **还没跑过**(见 [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) §4b)。CI 在 Ubuntu 上覆盖 **Node 18.x / 20.x / 24.x**,peer 钉 `0.2.0-rc.1` 一代;macOS 与 Windows 没有 CI 覆盖,desktop 宿主探针也还没有。
528
+ **`compatible` 怎么读**:它表示本插件的 codec / manifest 契约在该宿主代上核对过(代码层比对 + 单元/契约测试),**不表示**在那个确切宿主构建上跑过端到端探针。这张表是声明,核对方式总结在下面。真实宿主探针目前跑过 `headless`(2026-10-15)、`web`(2026-10-16)、**0.2.1-rc.3 上的 `web`**(2026-10-27,2.0.0 发布前:客户端激活、冲突面板裁决、注入抑制、回退到 1.9.2,23/23 项通过),以及 **0.2.1-rc.4 上的 DSH Go 手机引擎**(2026-10-31:App 插件市场安装、侧栏面板与设置分区原生呈现、`sidebar.panellist` 岛屿打开);`0.2.1-rc.5` 于同日在本地一个 web 实例上按 dump/启动/面板走查核对;`desktop` profile **还没跑过**(见 [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) §4b)。CI 在 Ubuntu 上覆盖 **Node 18.x / 20.x / 24.x**,peer 钉 `0.2.0-rc.1` 一代;macOS 与 Windows 没有 CI 覆盖,desktop 宿主探针也还没有。
523
529
 
524
530
  两点值得说明:
525
531
 
@@ -35,6 +35,7 @@
35
35
 
36
36
  import { auditMemoryDir } from './audit.js'
37
37
  import { autodreamToolSchemas, createToolRunner } from './tools.js'
38
+ import { entryFacts, POLICY_FACT_LIMIT } from './facts.js'
38
39
  import { warnLog } from '../memory/util.js'
39
40
  import { CONFIG_VERSION, DEFAULT_CONFIG, DEFAULT_TOKEN_BUDGET, FAILURE_BACKOFF_MS, FEATURE_DIR, LEGACY_SNAPSHOT_DIR, LEGACY_STATE_FILE, SCHEDULE_INTERVAL_MS, STARTUP_CHECK_DELAY_MS, STATE_DIR } from './config.js'
40
41
  import { countSessionsSince, newRunId, stampHuman } from './util.js'
@@ -43,7 +44,7 @@ import { archiveMemory } from '../memory/archive.js'
43
44
  import { scanMemoryFiles } from '../memory/scan.js'
44
45
  import { resolveStateRoot } from '../memory/config.js'
45
46
  import { basename, dirname, join } from 'node:path'
46
- import { stat } from 'node:fs/promises'
47
+ import { stat, readFile } from 'node:fs/promises'
47
48
 
48
49
  import * as gateMod from './gate.js'
49
50
  import * as llmloopMod from './llm-loop.js'
@@ -557,6 +558,19 @@ export class AutodreamEngine {
557
558
  const archived = []
558
559
  for (const c of candidates) {
559
560
  const reason = `自动归档:${c.type} 类,超过 ${c.limit} 天未被注入`
561
+ /**
562
+ * 归档前把正文读出来,只为声明能逐条列出「这条被收走的记忆承载了哪些事实」。
563
+ *
564
+ * 为什么这里用裸 `readFile`、而不是工具面那条句柄读取入口:读的是**上一步刚枚举出来的**
565
+ * 记忆目录顶层文件,结果只进人读声明、不参与任何安全判定;读不到就是 `null`,
566
+ * 声明照常落盘、只少这一节 —— 一条可选信息不该让整趟自动归档失败。
567
+ *
568
+ * 每条只留前 10 条事实:自动归档可能一次收走一批文件,机读记录不能因此膨胀成几百 KB
569
+ * (完整原文在 `archive/` 里,声明里会写明还有多少条没列)。
570
+ */
571
+ const policyFacts = await readFile(join(this.memoryDir, c.file), 'utf8')
572
+ .then((text) => entryFacts(text))
573
+ .catch(() => null)
560
574
  const res = await archiveMemory(this.memoryDir, c.file, reason)
561
575
  // 索引没摘掉要跟着进报告(复审 R11 的配套):报告是自动路径上唯一被人看到的地方。
562
576
  if (res.ok && res.indexError) {
@@ -568,6 +582,18 @@ export class AutodreamEngine {
568
582
  message: res.ok
569
583
  ? `已归档 ${res.file}(原因:${res.archivedReason})${res.indexError ? `;⚠ 索引没摘掉:${res.indexError}` : ''}`
570
584
  : res.error,
585
+ // ⚠️ **只在真的归档成功时**才挂事实:归档失败(最常见是 `archive/` 里已有同名文件)
586
+ // 意味着这个文件还在活动记忆里,把它整条列进「被删掉的事实」会让同一份声明自相矛盾
587
+ // —— 上面刚说「删了这些」,下面紧跟一句「已移入 archive/:0 / 1 条」。
588
+ // 2026-11-05 审查抓到这处(`policyTouched` 与 `rollback` 都过滤了 ok,只有这里漏)。
589
+ ...(res.ok === true && policyFacts && policyFacts.total > 0
590
+ ? {
591
+ removedKind: 'archive',
592
+ removedFacts: policyFacts.facts.slice(0, POLICY_FACT_LIMIT),
593
+ removedTotal: policyFacts.total,
594
+ entryTitle: policyFacts.title,
595
+ }
596
+ : {}),
571
597
  })
572
598
  }
573
599
  return { mode: 'auto', requested, candidates, archived, degraded: '' }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * autodream —— 「被删事实」的探测端(宿主侧,会碰文件系统)。
3
+ *
4
+ * `facts.js` 只管**怎么比、怎么列**(纯函数,好单测)。这里管**怎么读、怎么装进 change**:
5
+ * 1. 从真实文件取正文,且走与读工具同一条句柄读取入口(外部审查 2026-10-31 N03)——
6
+ * 声明会被人看到,里面引用的原文不能来自目录外。
7
+ * 2. 把比对结果整理成一条 `change` 的附加字段,让 `tools.js` 那边只留一句展开。
8
+ *
9
+ * **为什么单独一个文件**:`tools.js` 有一条 1100 行的体积守卫(`test/structure-test.mjs`),
10
+ * 这条护栏防的是「往工具执行器里塞逻辑」。声明功能加了几十行就把它顶爆了 ——
11
+ * 于是把「读 + 组装」搬出来,而不是把护栏改松。这种时候改护栏等于把唯一的门拆了。
12
+ */
13
+
14
+ import { inspectMemoryEntry, readVerified } from '../memory/scan.js'
15
+ import { diffRemovedFacts, entryFacts } from './facts.js'
16
+
17
+ /**
18
+ * 读一份正文,只给「被删掉的事实」做对照用。
19
+ *
20
+ * 为什么不复用 `fileFingerprint` 顺带读回来的字节:它的返回值会**原样进 manifest.json**
21
+ * 的 `before` / `after` 字段,多塞一份正文会把机读记录撑成两倍。
22
+ *
23
+ * 读不到就返回 `null` —— 声明里如实标「原文读不到」,绝不拿半份内容假装对比过。
24
+ */
25
+ export async function readTextForFacts(full) {
26
+ try {
27
+ const gate = await inspectMemoryEntry(full)
28
+ if (!gate.ok) return null
29
+ return await readVerified(full, gate, 'utf8')
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /**
36
+ * 改写(`write_memory`):算出要被写进 change 的「被删事实」字段。
37
+ *
38
+ * @param {'create'|'update'} op — 新建没有对照面
39
+ * @param {string|null} beforeText — 写之前的正文(读不到是 null)
40
+ * @param {string} afterText — 写进去的新正文
41
+ * @returns {object} 直接展开进 `changeRecord` 的 extra;没删到东西时是空对象
42
+ */
43
+ export function rewriteFactExtra(op, beforeText, afterText) {
44
+ if (op !== 'update') return {}
45
+ /**
46
+ * 读不到旧正文时**必须留痕**:否则「这份声明没有列出被删事实」与「这一趟什么都没删」
47
+ * 在纸上长得一模一样,而前者是「我们不知道」,后者是「我们知道没有」。
48
+ */
49
+ if (beforeText === null) return { removedUnavailable: true }
50
+ const removed = diffRemovedFacts(beforeText, afterText)
51
+ if (removed.total <= 0) return {}
52
+ return {
53
+ removedKind: 'rewrite',
54
+ removedFacts: removed.removed,
55
+ removedTotal: removed.total,
56
+ linesBefore: removed.beforeLines,
57
+ linesAfter: removed.afterLines,
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 整条归档(`archive_memory`):把这条记忆承载的事实列出来。
63
+ *
64
+ * 归档后它不再参与检索,那些事实对模型来说就等同于不存在了 —— 所以这里列的是
65
+ * 「这条记忆自己写下的全部事实」,不是「和谁比少了几行」(归档没有对照面)。
66
+ *
67
+ * @param {string|null} beforeText — 归档前的正文
68
+ * @returns {object} 直接展开进 `changeRecord` 的 extra
69
+ */
70
+ export function archiveFactExtra(beforeText) {
71
+ if (beforeText === null) return { removedUnavailable: true }
72
+ const entry = entryFacts(beforeText)
73
+ if (entry.total <= 0) return {}
74
+ return {
75
+ removedKind: 'archive',
76
+ removedFacts: entry.facts,
77
+ removedTotal: entry.total,
78
+ entryTitle: entry.title,
79
+ }
80
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * autodream —— 「被删掉的事实」抽取(纯函数,零依赖,可单测)。
3
+ *
4
+ * **为什么要有这一层**:整理声明原先只交代「改了哪个文件、字节从 A 变成 B」。
5
+ * 结构审计(断链 / 格式 / 索引)能看见这些,**看不见事实**。2026-11-05 实测:
6
+ * 一趟自动整理把某条记忆里「到期 2027-10-03、不自动续费」这句仍然生效的账压掉了,
7
+ * 跑完自报「审计 2 问题 → 0 问题」—— 结构一片绿,丢掉的东西没有任何痕迹。
8
+ * 人能发现它,只因为事后拿回滚点快照逐行 diff。
9
+ *
10
+ * 所以这一层回答一个问题:**这两份正文之间,哪几条事实不再存在了?**
11
+ * 它只做比对与呈现,不做判断 —— 判断永远是人的事。
12
+ *
13
+ * **口径(2026-11-05 定)**:
14
+ * - 覆盖两种删除动作:`archive`(整条退役)与 `update`(改写里被覆盖掉的内容)。
15
+ * - 事实的来源是**宿主自己算的 diff**,不是模型自述 —— 模型可以漏报,diff 不会。
16
+ *
17
+ * 三个降噪规则,都是为了「宁可少报噪音,不可多报噪音」:
18
+ * 1. **归一化比较**:抹掉纯格式差异(`-` / `#` / `>` / `**` / 反引号 / 空白),
19
+ * 重新排版不算删事实。
20
+ * 2. **多重集配额**:同一行在 after 里出现几次,before 里就保留几次;
21
+ * 只有「配额用完还没找到」的行才算删除(重复行被删掉一条也报得出来)。
22
+ * 3. **不降「包含关系」的噪**。曾经试过:删除行若被 after 的某一行包含(压缩合并成一句),
23
+ * 就当内容还在、不报。**2026-11-05 审查推翻了这个做法** —— 它会把真删除静默吃掉:
24
+ * - before「到期 2027-10-03,不自动续费」被删、after 里有 `## 2027` → 一行 4 字标题吞掉整条账;
25
+ * - before「自动续费」→ after「不自动续费」→ 否定翻转被当成「内容还在」。
26
+ * 这恰恰是本功能要防的那类静默丢失。所以现在**删除照报**,只在确认「长度比例合理」时
27
+ * 附一个 `mergedInto` 线索(可能是被并进了哪一行),让人自己判断,而不是替人不报。
28
+ */
29
+
30
+ /** 短于这个长度的行不算「事实」—— 分隔符、单字这类行列出来只会淹没真信号。 */
31
+ const MIN_FACT_CHARS = 4
32
+
33
+ /** 单条事实在声明里最多显示多少字符。 */
34
+ export const FACT_TEXT_MAX = 200
35
+
36
+ /** 单个文件最多列多少条被删事实(超出的只报数字,完整原文在快照 / archive/ 里)。 */
37
+ export const FACTS_PER_FILE = 40
38
+
39
+ /**
40
+ * 自动归档策略一次可能收走一批文件 —— 每条只留在机读记录里的前 N 条事实。
41
+ * 完整原文在 `archive/` 里,声明中会写明还有多少条没列。
42
+ */
43
+ export const POLICY_FACT_LIMIT = 10
44
+
45
+ /** 整份声明里「被删事实」这一节最多用多少字符 —— 声明会内嵌进报告,不能把它撑爆。 */
46
+ export const FACTS_TOTAL_MAX = 8000
47
+
48
+ /** 一行的比较键:抹掉纯格式差异。 */
49
+ export function lineKey(raw) {
50
+ return String(raw ?? '')
51
+ .replace(/\r/g, '')
52
+ .replace(/\*\*|__|`/g, '')
53
+ .replace(/^\s*(?:#{1,6}|>+|[-*+]|\d+[.)])\s+/, '')
54
+ .replace(/\s+/g, ' ')
55
+ .trim()
56
+ }
57
+
58
+ /** 正文切行:带 1-based 行号;空行丢掉(空行不是事实,也不该参与比对)。 */
59
+ function factLines(text) {
60
+ const raw = String(text ?? '').split('\n')
61
+ const out = []
62
+ /**
63
+ * frontmatter 是元数据,不是「这条记忆承载的事实」—— 它的键值变化(`type: project`
64
+ * 被删掉)不该出现在「被删掉的事实」里,否则每一趟整理都会被元数据噪音刷屏。
65
+ *
66
+ * 只在**文件开头**认它:正文中间的 `---` 是水平分隔线,跳过它会漏掉真事实。
67
+ */
68
+ let start = 0
69
+ const isFence = (s) => s.replace(/\r$/, '').trim() === '---'
70
+ if (raw.length && isFence(raw[0])) {
71
+ for (let j = 1; j < raw.length; j += 1) {
72
+ if (isFence(raw[j])) {
73
+ start = j + 1
74
+ break
75
+ }
76
+ }
77
+ }
78
+ for (let i = start; i < raw.length; i += 1) {
79
+ const key = lineKey(raw[i])
80
+ if (!key) continue
81
+ out.push({ line: i + 1, raw: raw[i].replace(/\s+$/, ''), key })
82
+ }
83
+ return out
84
+ }
85
+
86
+ /** 单条事实的展示文本:去掉行首的 markdown 结构标记,超长截断。 */
87
+ function factText(raw) {
88
+ const s = String(raw ?? '')
89
+ .replace(/\r/g, '')
90
+ .replace(/^\s*(?:#{1,6}|>+|[-*+]|\d+[.)])\s+/, '')
91
+ .trim()
92
+ if (s.length <= FACT_TEXT_MAX) return s
93
+ return `${s.slice(0, FACT_TEXT_MAX)}…`
94
+ }
95
+
96
+ /**
97
+ * 找「这条被删的内容可能是被并进了哪一行」。
98
+ *
99
+ * ⚠️ 这不是降噪,是**线索**。删除照报,这里只回答「有没有可能它还在别处」。
100
+ * 长度比例必须合理,否则 `## 2027` 这种 4 字标题会把整条 19 字的到期账说成「已合并」
101
+ * —— 那正是审查抓到的反例。上界放宽(3 倍或 60 字)因为「多行并成一行」时
102
+ * 合并后的行本来就比单行长好几倍;放宽的代价只是多一个提示,**不会让删除消失**。
103
+ *
104
+ * @returns {number} after 里的行号;找不到返回 0
105
+ */
106
+ function mergeHint(b, after) {
107
+ const lo = b.key.length * 0.6
108
+ const hi = Math.max(b.key.length * 3, 60)
109
+ for (const a of after) {
110
+ const n = a.key.length
111
+ if (n < lo || n > hi) continue
112
+ if (a.key === b.key) continue
113
+ if (a.key.includes(b.key) || (b.key.includes(a.key) && a.key.length >= MIN_FACT_CHARS)) return a.line
114
+ }
115
+ return 0
116
+ }
117
+
118
+ /**
119
+ * 从 before → after 的改写里抽出**被删掉的事实**。
120
+ *
121
+ * @param {string} beforeText — 写之前磁盘上的正文
122
+ * @param {string} afterText — 写之后的正文
123
+ * @returns {{removed:{line:number,text:string,mergedInto?:number}[], total:number, beforeLines:number, afterLines:number}}
124
+ * `removed` 已按 FACTS_PER_FILE 截断;`total` 是截断前真实条数。
125
+ */
126
+ export function diffRemovedFacts(beforeText, afterText) {
127
+ const before = factLines(beforeText)
128
+ const after = factLines(afterText)
129
+ /** after 里每个 key 还剩几次「可用来配对」。 */
130
+ const pool = new Map()
131
+ for (const a of after) pool.set(a.key, (pool.get(a.key) ?? 0) + 1)
132
+
133
+ const removed = []
134
+ let total = 0
135
+ for (const b of before) {
136
+ const left = pool.get(b.key) ?? 0
137
+ if (left > 0) {
138
+ pool.set(b.key, left - 1)
139
+ continue
140
+ }
141
+ if (b.key.length < MIN_FACT_CHARS) continue
142
+ total += 1
143
+ if (removed.length >= FACTS_PER_FILE) continue
144
+ // 只给**真正会展示出来的**那些算线索:前 40 条 × after 行数,而不是 N × M。
145
+ // 审查实测过不设这个界的版本:444 KB / 17.5k 短行时同步阻塞 3.6s(`after.some` 退化成 O(N²))。
146
+ const hint = mergeHint(b, after)
147
+ removed.push(hint ? { line: b.line, text: factText(b.raw), mergedInto: hint } : { line: b.line, text: factText(b.raw) })
148
+ }
149
+ return { removed, total, beforeLines: before.length, afterLines: after.length }
150
+ }
151
+
152
+ /**
153
+ * 整条归档时「这条记忆承载了哪些事实」。
154
+ *
155
+ * 归档不是改写 —— 没有对照面可言,整个条目都从活动记忆里消失了。
156
+ * 所以这里列的是**这条记忆自己写下的每一条事实**(不含 frontmatter 的分隔线与标题行),
157
+ * 让人一眼看完「我丢了什么」,而不是只看到一句「已归档 1 条」。
158
+ *
159
+ * @param {string} text — 归档前的正文
160
+ * @returns {{title:string, facts:{line:number,text:string}[], total:number}}
161
+ */
162
+ export function entryFacts(text) {
163
+ const lines = factLines(text)
164
+ let title = ''
165
+ const facts = []
166
+ let total = 0
167
+ for (const l of lines) {
168
+ // 标题行单独拿出来当小标题,不重复进事实列表。
169
+ if (!title && /^\s*#\s+/.test(l.raw)) {
170
+ title = factText(l.raw.replace(/^\s*#\s+/, ''))
171
+ continue
172
+ }
173
+ // frontmatter 的键值行不当作事实(它是元数据)。值里可能带正文性质的信息
174
+ // (description),但那是「这条记忆是什么」的一句话,不是它承载的事实。
175
+ if (/^\s*(?:---|name|description|metadata|aliases|type|node_type|updated|baseline|originSessionId)\s*:/.test(l.raw)) continue
176
+ if (/^\s*---\s*$/.test(l.raw)) continue
177
+ if (l.key.length < MIN_FACT_CHARS) continue
178
+ total += 1
179
+ if (facts.length < FACTS_PER_FILE) facts.push({ line: l.line, text: factText(l.raw) })
180
+ }
181
+ return { title, facts, total }
182
+ }
183
+
184
+ /**
185
+ * 把「被删事实」按整份声明的总预算收拢。
186
+ *
187
+ * @param {{file:string, kind:'rewrite'|'archive', removed:{line:number,text:string}[], total:number, beforeLines?:number, afterLines?:number, title?:string}[]} groups
188
+ * @returns {{shown:typeof groups, omittedGroups:number, omittedFacts:number, chars:number}}
189
+ */
190
+ export function budgetFactGroups(groups) {
191
+ const shown = []
192
+ let chars = 0
193
+ let omittedGroups = 0
194
+ let omittedFacts = 0
195
+ for (const g of groups) {
196
+ const cost = g.removed.reduce((n, f) => n + f.text.length + 24, 0) + 80
197
+ // 第一组总是显示:整节一条都出不来,等于这个功能没做。
198
+ if (shown.length > 0 && chars + cost > FACTS_TOTAL_MAX) {
199
+ omittedGroups += 1
200
+ omittedFacts += g.total
201
+ continue
202
+ }
203
+ chars += cost
204
+ shown.push(g)
205
+ }
206
+ return { shown, omittedGroups, omittedFacts, chars }
207
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { formatAudit } from './audit.js'
10
+ import { budgetFactGroups, FACTS_TOTAL_MAX } from './facts.js'
10
11
  import { LEGACY_REPORT_DIR, REPORT_DIR } from './config.js'
11
12
  import { OP_LABEL, formatDelta, readJson, safeName, stampHuman } from './util.js'
12
13
  import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
@@ -64,14 +65,170 @@ export async function writeDeclaration(payload) {
64
65
  rolledBackAtHuman: '',
65
66
  }
66
67
  const markdown = this.renderDeclaration(manifest)
67
- await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
68
+ // 人读用完整数据渲染(它自己有 8000 字符的预算),机读记录才按 64 KB 裁 —— 见 capFactsForManifest。
69
+ const capped = capFactsForManifest(manifest)
70
+ await writeFile(join(dir, 'manifest.json'), JSON.stringify(capped.stored, null, 2), 'utf8')
68
71
  await writeFile(join(dir, 'declaration.md'), markdown, 'utf8')
69
- return { dir, manifest, markdown }
72
+ return { dir, manifest, markdown, factsTruncated: capped.capped }
73
+ }
74
+
75
+ /**
76
+ * 从一份 manifest 里收拢「被删掉的事实」,来源有两处:
77
+ * - `changes[]`:模型调用 `write_memory`(改写)与 `archive_memory`(整条退役)时宿主算的 diff;
78
+ * - `archive.archived[]`:自动归档策略收走的文件(模型没碰过,但事实同样从活动记忆里消失)。
79
+ *
80
+ * 两处都要收 —— 只收 changes 会漏掉自动归档,那正好是「无人值守时丢了东西」最容易发生的地方。
81
+ */
82
+ function collectFactGroups(m) {
83
+ const groups = []
84
+ const push = (src, kind) => {
85
+ const facts = Array.isArray(src?.removedFacts) ? src.removedFacts.filter((f) => f && typeof f.text === 'string') : []
86
+ if (!facts.length) {
87
+ /**
88
+ * 读不到旧正文:**必须留痕**。否则「这一趟读不到、不知道删了什么」与
89
+ * 「这一趟什么都没删」在纸上长得一模一样 —— 而它们是完全不同的两件事。
90
+ */
91
+ if (src?.removedUnavailable === true) {
92
+ groups.push({
93
+ file: String(src.file ?? ''),
94
+ kind,
95
+ removed: [],
96
+ total: 0,
97
+ beforeLines: 0,
98
+ afterLines: 0,
99
+ title: '',
100
+ unavailable: true,
101
+ })
102
+ }
103
+ return
104
+ }
105
+ groups.push({
106
+ file: String(src.file ?? ''),
107
+ kind,
108
+ removed: facts,
109
+ total: Number(src.removedTotal) || facts.length,
110
+ beforeLines: Number(src.linesBefore) || 0,
111
+ afterLines: Number(src.linesAfter) || 0,
112
+ title: typeof src.entryTitle === 'string' ? src.entryTitle : '',
113
+ })
114
+ }
115
+ for (const c of Array.isArray(m.changes) ? m.changes : []) push(c, c?.removedKind === 'archive' ? 'archive' : 'rewrite')
116
+ for (const a of Array.isArray(m.archive?.archived) ? m.archive.archived : []) {
117
+ // ⚠️ 归档**失败**的条目一个字都不能列:文件还在活动记忆里(最常见是 archive/ 已有同名),
118
+ // 把它列进「被删掉的事实」会让同一份声明自相矛盾 —— 2026-11-05 审查抓到这处。
119
+ if (a?.ok === false) continue
120
+ push(a, 'archive')
121
+ }
122
+ return groups
123
+ }
124
+
125
+ /**
126
+ * 渲染「被删掉的事实」一节。
127
+ *
128
+ * 这一节存在的唯一理由:**结构审计看不见事实丢失**。它能报断链、格式、索引不一致,
129
+ * 但一条仍然生效的账被压缩掉时,它在每一张表上都是绿的(2026-11-05 实测)。
130
+ * 所以这里不留一句概括,逐条列原文 —— 人扫一眼就能说「这条不该删」。
131
+ */
132
+ function renderRemovedFacts(lines, groups, H) {
133
+ if (!groups.length) return
134
+ const { shown, omittedGroups, omittedFacts } = budgetFactGroups(groups)
135
+ if (!shown.length) return
136
+ lines.push(
137
+ `${H}# 被删掉的事实`,
138
+ '',
139
+ '> 这一趟之后,下面这些内容**不再存在于活动记忆里**。结构审计(断链 / 格式 / 索引)',
140
+ '> 看不见事实丢失 —— 2026-11-05 就有「一条仍然生效的到期账被压缩掉、审计报 2→0 全绿」的先例。',
141
+ '> 对着这里扫一眼,比事后翻回滚点便宜得多。',
142
+ '>',
143
+ '> 行号 `L12` 指**改动前**那份正文的行号(归档条目指 `archive/` 里那份),在回滚点快照里对得上。',
144
+ '',
145
+ )
146
+ for (const g of shown) {
147
+ if (g.unavailable) {
148
+ lines.push(
149
+ `**\`${g.file}\`**(${g.kind === 'archive' ? '归档' : '改写'} · ⚠ 改动前的原文读不到,这一条给不出对照)`,
150
+ '',
151
+ )
152
+ continue
153
+ }
154
+ lines.push(
155
+ g.kind === 'archive'
156
+ ? `**\`${g.file}\`**(归档 · 承载 ${g.total} 条事实${g.title ? ` · 「${g.title}」` : ''})`
157
+ : // 说「事实行」而不是「行」:这个数字是**去掉空行与 frontmatter 之后**的行数,
158
+ // 拿它当文件行数会让人对不上(一篇 60 行的文件可能只有 45 个事实行)。
159
+ `**\`${g.file}\`**(改写 · 事实行 ${g.beforeLines} → ${g.afterLines} · 删掉 ${g.total} 条)`,
160
+ )
161
+ for (const f of g.removed) {
162
+ // 有 mergedInto 只是**线索**(按长度比例推测「可能被并进了哪一行」),不是「所以没删」。
163
+ // 合并、否定翻转(「自动续费」→「不自动续费」)都可能落在这一档,判断交给人。
164
+ const hint = f.mergedInto ? `(内容可能已并进 \`L${f.mergedInto}\`,按长度比例推测)` : ''
165
+ lines.push(`- \`L${f.line}\` ${String(f.text).replace(/\s*\n\s*/g, ' ')}${hint}`)
166
+ }
167
+ const unlisted = g.total - g.removed.length
168
+ if (unlisted > 0) {
169
+ lines.push(`- …还有 ${unlisted} 条没列(全文${g.kind === 'archive' ? '已移入 `archive/`' : '在回滚点快照里'})`)
170
+ }
171
+ lines.push('')
172
+ }
173
+ if (omittedGroups) {
174
+ lines.push(
175
+ `> ⚠ 还有 ${omittedGroups} 个文件共 ${omittedFacts} 条没列出` +
176
+ `(这一节上限 ${FACTS_TOTAL_MAX} 字符;完整差异见回滚点快照)`,
177
+ '',
178
+ )
179
+ }
180
+ }
181
+
182
+ /**
183
+ * `manifest.json` 里「被删事实」的总量上限(字符)。
184
+ *
185
+ * **为什么需要**:运行记录**不轮转**(`listRuns` 那边写着「运行记录不会淘汰」),
186
+ * 一份被几百条事实撑到几百 KB 的机读记录会一直躺在状态目录里、一次都不会被清掉。
187
+ * 人读的 `declaration.md` 另有一份 8000 字符的预算,两边各算各的 —— 它们服务两种读者。
188
+ */
189
+ const MANIFEST_FACTS_MAX = 64 * 1024
190
+
191
+ /**
192
+ * 按预算裁掉机读记录里的事实明细(保留 `removedTotal` 这个数字,只丢原文)。
193
+ *
194
+ * ⚠️ 只在**写盘**时裁:`renderDeclaration` 拿到的是完整数据,所以人读的声明不受影响。
195
+ */
196
+ function capFactsForManifest(manifest) {
197
+ let used = 0
198
+ let capped = false
199
+ const take = (list) => {
200
+ const keep = []
201
+ for (const f of list) {
202
+ // ⚠️ 按**字节**算,不是按 `String.length` —— 中文一个字 3 字节,
203
+ // 按字符数封顶会让上限定成 64 KB 却写出 190 KB 的文件(第一版就这么错的)。
204
+ const cost = Buffer.byteLength(String(f?.text ?? ''), 'utf8') + 24
205
+ if (used + cost > MANIFEST_FACTS_MAX) {
206
+ capped = true
207
+ break
208
+ }
209
+ used += cost
210
+ keep.push(f)
211
+ }
212
+ return keep
213
+ }
214
+ const changes = (Array.isArray(manifest.changes) ? manifest.changes : []).map((c) => {
215
+ if (!Array.isArray(c?.removedFacts) || !c.removedFacts.length) return c
216
+ const keep = take(c.removedFacts)
217
+ return keep.length === c.removedFacts.length ? c : { ...c, removedFacts: keep, factsOmitted: true }
218
+ })
219
+ const archived = (Array.isArray(manifest.archive?.archived) ? manifest.archive.archived : []).map((a) => {
220
+ if (!Array.isArray(a?.removedFacts) || !a.removedFacts.length) return a
221
+ const keep = take(a.removedFacts)
222
+ return keep.length === a.removedFacts.length ? a : { ...a, removedFacts: keep, factsOmitted: true }
223
+ })
224
+ return {
225
+ stored: { ...manifest, changes, archive: { ...manifest.archive, archived }, ...(capped ? { factsTruncated: true } : {}) },
226
+ capped,
227
+ }
70
228
  }
71
229
 
72
230
  /** 渲染人读声明(报告里内嵌,所以标题层级可以从外面压)。 */
73
- export function renderDeclaration(m, headingLevel = 1) {
74
- const H = '#'.repeat(Math.max(1, Math.min(6, headingLevel)))
231
+ export function renderDeclaration(m, headingLevel = 1) { const H = '#'.repeat(Math.max(1, Math.min(6, headingLevel)))
75
232
  const lines = [
76
233
  `${H} 整理声明 · ${m.startedAtHuman}`,
77
234
  '',
@@ -125,6 +282,9 @@ export function renderDeclaration(m, headingLevel = 1) {
125
282
  )
126
283
  }
127
284
  }
285
+ // 「删了什么」紧跟在改动表之后:读完「改了哪几个文件」自然要问「丢了哪些事实」。
286
+ // 必须放在自动归档候选**之前** —— 那一节是「将要发生的清单」,混在一起会读成同一件事。
287
+ renderRemovedFacts(lines, collectFactGroups(m), H)
128
288
  if (m.archive && m.archive.mode !== 'off' && (m.archive.candidates.length || m.archive.degraded)) {
129
289
  lines.push(`${H}# 自动归档候选(策略:${m.archive.mode})`, '')
130
290
  // 降级必须**明说**:用户设了 auto 却什么都没动,原因不写在报告里就等于静默失灵。
@@ -28,6 +28,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'
28
28
  import { join, basename } from 'node:path'
29
29
  import { createHash } from 'node:crypto'
30
30
  import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './audit.js'
31
+ import { archiveFactExtra, readTextForFacts, rewriteFactExtra } from './fact-probe.js'
31
32
  import { archiveMemory } from '../memory/archive.js'
32
33
  import { MAX_FILE_BYTES, STATE_ROOT } from '../memory/config.js'
33
34
  import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from '../shared/md-name.js'
@@ -917,27 +918,39 @@ export function createToolRunner(opts) {
917
918
  const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
918
919
  const selfReported = reason.length >= MIN_REASON_CHARS
919
920
  const before = await fileFingerprint(full)
921
+ // 改写前的正文要**在写之前**读到:声明里「哪几条事实没了」是拿它跟新内容逐行比出来的。
922
+ // 读不到就是 null,声明会如实标出来 —— 宁可不报,也不报一份假的对照。
923
+ const beforeText = before.exist ? await readTextForFacts(full) : null
920
924
  // 走宿主写入(有 fs 服务时):原子性由宿主保证,**并且吃到写入门**(见 writeMemoryText)。
921
925
  // 内容闸门上面已按字节判过,这里只关心「写没写进去、写成了什么版本」。
922
926
  const outcome = await writeMemoryText(full, a.content)
923
927
  const after = await fileFingerprint(full)
928
+ const op =
929
+ // 宿主给了 operation(create / update)就直接用 —— 它比「看写之前存不存在」更权威:
930
+ // 那个指纹是我们**自己**在写之前 stat 的,中间还有一道缝。回落时才由指纹推。
931
+ outcome?.operation === 'create' || outcome?.operation === 'update'
932
+ ? outcome.operation
933
+ : before.exist
934
+ ? 'update'
935
+ : 'create'
924
936
  if (!selfReported) {
925
937
  // **不拒绝。** 一次改写缺了缘由,拒掉的代价是这条修正彻底丢失——而它可能
926
938
  // 正是这趟整理最该做的事。「照写 + 标未自述 + 用宿主能追溯到的信息顶替」
927
939
  // 既保住了改动,也让声明里一眼看得出哪些缘由是模型自己说的。
928
940
  notes.push(`${basename(full)} 的改动模型没有自述缘由,声明里已标「未自述」并附追溯信息`) }
929
941
  changeRecord(
930
- // 宿主给了 operation(create / update)就直接用 —— 它比「看写之前存不存在」更权威:
931
- // 那个指纹是我们**自己**在写之前 stat 的,中间还有一道缝。回落时才由指纹推。
932
- outcome?.operation === 'create' || outcome?.operation === 'update'
933
- ? outcome.operation
934
- : (before.exist ? 'update' : 'create'),
942
+ op,
935
943
  basename(full),
936
944
  selfReported ? reason : '',
937
945
  before,
938
946
  after,
939
947
  selfReported ? 'model' : 'auto',
940
- { via: outcome ? 'fs' : 'atomic', version: outcome?.version ?? null },
948
+ {
949
+ via: outcome ? 'fs' : 'atomic',
950
+ version: outcome?.version ?? null,
951
+ // 「删了什么」必须进机读记录:报告读不到它,人只能事后翻快照。
952
+ ...rewriteFactExtra(op, beforeText, a.content),
953
+ },
941
954
  )
942
955
  touched.push(basename(full))
943
956
  listCache = null
@@ -957,13 +970,25 @@ export function createToolRunner(opts) {
957
970
  return '错误:archive_memory 必须给 reason —— 一句话说明为什么这条该退役。请带上 reason 重发。'
958
971
  }
959
972
  const before = await fileFingerprint(full)
973
+ // 归档 = 整条从活动记忆里消失。声明里不能只写「已归档 1 条」,
974
+ // 要把这条记忆**承载了哪些事实**列出来 —— 归档后它不再参与检索,
975
+ // 那些事实对模型来说就等同于不存在了。
976
+ const beforeText = before.exist ? await readTextForFacts(full) : null
960
977
  const res = await archiveMemory(memoryDir, basename(full), reason)
961
978
  if (!res.ok) {
962
979
  if (res.code === 'not_found') return `错误:没有这个文件:${basename(full)}`
963
980
  if (res.code === 'exists') return `错误:archive/ 里已有同名文件(${basename(full)}),请先人工处理,避免覆盖。`
964
981
  return `错误:${String(res.error).replace(/^sage-mem: /, '')}`
965
982
  }
966
- changeRecord('archive', basename(full), reason, before, { exist: false, bytes: 0, sha256: null })
983
+ changeRecord(
984
+ 'archive',
985
+ basename(full),
986
+ reason,
987
+ before,
988
+ { exist: false, bytes: 0, sha256: null },
989
+ 'model',
990
+ archiveFactExtra(beforeText),
991
+ )
967
992
  touched.push(`${basename(full)} → archive/`)
968
993
  listCache = null
969
994
  auditCache = null
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 的样式差,别让人猜现在是开还是关。
@@ -3834,7 +3837,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3834
3837
  "sidebar.label": "记忆管理",
3835
3838
 
3836
3839
  "tab.files": "文件列表",
3837
- "tab.star": "记忆星图",
3840
+ "tab.star": "时间线",
3838
3841
  "tab.autodream": "自动做梦",
3839
3842
  "tab.archive": "自动归档",
3840
3843
  "tab.audit": "体检",
@@ -4317,7 +4320,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4317
4320
  "sidebar.label": "Memory management",
4318
4321
 
4319
4322
  "tab.files": "Files",
4320
- "tab.star": "Star map",
4323
+ "tab.star": "Timeline",
4321
4324
  "tab.autodream": "Autodream",
4322
4325
  "tab.archive": "Auto-archive",
4323
4326
  "tab.audit": "Audit",
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.3.1",
4
+ "version": "2.4.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",