sage-mem 1.9.2 → 2.0.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
@@ -3,7 +3,7 @@
3
3
  > 中文版见 [README.zh.md](https://github.com/gezi-wen/sage-mem/blob/main/README.zh.md)
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/sage-mem?color=blue)](https://www.npmjs.com/package/sage-mem)
6
- [![npm downloads](https://img.shields.io/npm/dm/sage-mem)](https://www.npmjs.com/package/sage-mem)
6
+ [![npm downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fgezi-wen%2Fsage-mem%2Fbadges%2Fnpm-downloads.json)](https://www.npmjs.com/package/sage-mem)
7
7
  [![tests](https://img.shields.io/github/actions/workflow/status/gezi-wen/sage-mem/test.yml?label=tests)](https://github.com/gezi-wen/sage-mem/actions/workflows/test.yml)
8
8
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2018-3c873a)](#compatibility)
9
9
  [![DSH](https://img.shields.io/badge/DSH-%E2%89%A5%200.1.2--rc.1-8a2be2)](#compatibility)
@@ -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: `1.9.2`** — 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.0.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.
@@ -70,7 +70,7 @@ Most memory schemes take the "SQLite + a long-running worker" route: once memori
70
70
  | Feature | Description |
71
71
  |---|---|
72
72
  | **File-based storage** | One `.md` per memory: frontmatter holds metadata, the body holds content. No database, no hidden format |
73
- | **Automatic injection** | Every turn automatically scans the memory directory and picks the most relevant few by relevance to inject into context. The model "recalls" on the first turn, without having to go and read files itself |
73
+ | **Automatic injection** | Every new question rescans the memory directory and re-picks the most relevant few for that question, injecting them into context before the model speaks — no file reading needed |
74
74
  | **The model can look things up too** | Two retrieval tools are also given to the model (`search_memory` / `read_memory`), so it can go through memories on its own and read the original text by line |
75
75
  | **Writes are not overwritten** | While you are editing and the model is changing things at the same time, the later write does not silently clobber the earlier one; the conflict is left as a pending record for you to decide which side to keep |
76
76
  | **Zero extra services** | No worker, no SQLite, no port, no long-running process, and no extra API key or cloud service (the only runtime dependency is the ordinary npm library `zod`) |
@@ -116,7 +116,7 @@ Then **open a new session** and try three steps:
116
116
 
117
117
  1. Tell the model "remember: my cat is called Zhima, and it likes sunbathing" — it writes this into the memory directory by its rules
118
118
  2. Open another **new session** and ask "what is my cat called"
119
- 3. It should answer "Zhima" on the first turn — the memory was retrieved and injected automatically by the plugin; the model did not go and read files itself
119
+ 3. Its first reply should answer "Zhima" — the memory was retrieved and injected automatically by the plugin; the model did not go and read files itself
120
120
 
121
121
  <details>
122
122
  <summary>Manual install (if you do not want the CLI, or want to install a local development build)</summary>
@@ -254,6 +254,10 @@ Something transparent deserves a clear account of how it selects its material, b
254
254
 
255
255
  **The gap status card speaks only when there really is a gap**: it appears only when there are candidates that were not read, or candidates omitted by the budget, and it states the scope, the count, the best clue and **one action you can take**; when not a single thing matched, it speaks only if you **asked about your own history**, or this task **has a checkpoint** (otherwise the hint would be ignored as noise). A single card is capped at 512 bytes and deduplicated by content signature.
256
256
 
257
+ **"This claim no longer holds" becomes a fact you can check in the file (from 2.0.0).** A memory's frontmatter may carry three optional fields: `valid_at` (when the claim starts to hold), `invalid_at` (when it stops counting), and `superseded_by` (which memory holds the current wording). **The injection side reads them**: a memory whose `invalid_at` has passed is no longer injected as body text (reason `expired_source`); an **unresolved contradiction** (an open record with `kind: contradiction`) keeps **both sides** out of the body (`conflict_pending`) until you resolve it on the 「记忆管理 → 冲突」(Memory management → Conflicts) tab. **Write-failure conflicts are not suppressed** (stale version / the file was changed elsewhere / a half-finished merge) — what is on disk is still the current content, and hiding it too would report "this write did not land" as "this memory is untrustworthy".
258
+
259
+ **Suppression is never silent.** While contradictions are pending, the status card adds a line — "另有 N 条矛盾待裁决,相关记忆本轮未注入正文" (N contradictions are pending; the related memories were not injected this round) — with the concrete count, so a change in the count can send a fresh card, and the panel header carries a permanent badge. **The tools are unaffected**: `search_memory` / `read_memory` still find and read invalid memories, marking them "已失效" and carrying `invalidAt` / `supersededBy` — when you ask about history, the old wording is evidence, not noise. To go back to the old behaviour temporarily, set `SAGE_MEM_VALIDITY=0` (that disables only this filter; the rest of this release stays).
260
+
257
261
  **There is a relevance threshold on the summary side too.** In Chinese, units like "怎么" and "什么" appear in a great many `description`s, so an unrelated "今天天气怎么样" can hit a dozen memories off a single "怎么". The threshold is normalized against **the corpus's own IDF scale**, which brings the candidate count for unrelated questions down to about one seventh; what gets cleared out is mainly the weak summary candidates that should have lost anyway.
258
262
 
259
263
  The comparison on the offline evaluation set (before and after the threshold): **Recall@5 84.1% → 87.0%, spurious injection (memories injected that are unrelated to the question) 14.1% → 5.6%**.
@@ -293,7 +297,10 @@ The guard has two paths, with **different** coverage, spelled out:
293
297
  | **The plugin itself** writing (autodream consolidation) | The caller passes the version it read at write time | A pending record `<state directory>/conflicts/<id>.json` is written, keeping **the version on disk** and **the version it wanted to write** — with only one side there is nothing to judge |
294
298
 
295
299
  - **Conflicts are not merged automatically**; which side to keep is your decision (the record is JSON, open it directly when you need to). Silent retry and silent abandonment both decide on your behalf.
296
- - **The injection side does not read this queue yet**: a memory with an unresolved conflict is still injected as body text. The `conflict_pending` suppression rule arrives with **2.0.0 conflict detection**, together with the panel entry that resolves conflicts — wiring the suppression without that entry would make a memory **permanently non-injectable**, which is worse than the current state. For now, open `conflicts/<id>.json` and set `status` to `resolved` or `dismissed` (or delete the file).
300
+ - **From 2.0.0 the injection side does read this queue**: only the **two sides of a `contradiction`** (status `open`) are suppressed; the three write-failure kinds (`stale_version` / `external_change` / `unresolved_merge`) are still injected — what is on disk there is the current content. The resolution entry and the suppression ship together, so there is no longer a state where "suppression is wired but there is no entry point".
301
+ - **Resolution lives on the 「记忆管理 → 冲突」(Memory management → Conflicts) tab** (the sixth tab, with a permanent badge in the panel header): you look at both bodies one by one, and the actions are "mark this one invalid", "undo invalidation" and "delete record", with two-step confirmation for the dangerous ones. "Mark invalid" only adds one line `invalid_at` to the memory's frontmatter (optionally plus `superseded_by`) — **the file stays in place, the body does not change by a single byte, and `updated` is not touched**; the write is idempotent (clicking twice does not rewrite), it can be undone, and a failure returns an explicit error while the record stays pending.
302
+ - **Conflict records only ever gain fields**: 2.0.0 appends `otherFile` / `decision` / `decidedAt` / `appliedVersion` / `invalidatedFile` at the tail, and the record version stays 1; 1.9.x readers ignore keys they do not know, so no migration is needed in either direction.
303
+ - **Manual edits are outside the guard**: editing the file directly takes effect too (delete `invalid_at` and the memory is injected again); the plugin does not police hand edits — that is the tradeoff of file-based memory, see boundary C11.
297
304
  - **The same conflict is not duplicated**: if a failure matches an unresolved record field for field (same file, both bodies, both versions), no extra copy is appended.
298
305
  - **The host file sandbox**: autodream runs **outside any session**, so it cannot inherit a session workspace. On a `workspace-write` host the plugin passes a per-call sandbox policy **narrowed to the memory directory** (narrower than the fallback root); on a `read-only` host it passes nothing, the write is still refused, and the run is recorded as a **failure** (not "success, nothing changed").
299
306
  - Known boundary: **editing memories in a shell does not go through this path**. The write gate guards "memory writes that go through the tools", not "all modifications to files"; redirection and the like inside `pwsh` are not recognized. If it cannot be sure, it does not claim it — so that "cannot see it" is not mistakenly recorded as fact.
@@ -408,7 +415,8 @@ The two things are moved **differently**, spelled out:
408
415
  | `SAGE_MEM_MAX_BASELINE` | `8` (1–50) | number of `baseline` slots |
409
416
  | `SAGE_MEM_STALE_DAYS` | `1` (0–365) | threshold for marking a heading 「保存于 N 天前」(saved N days ago); 0 = mark every one |
410
417
  | `SAGE_MEM_TASK_ID` | empty | scope of task checkpoints; empty falls back to the session id |
411
- | `SAGE_MEM_DEBUG` | off | debug logging; when on, the panel's **工具 ▾ → 注入调试** shows the last 8 injection attempts (read-only, never written to disk) |
418
+ | `SAGE_MEM_DEBUG` | off | debug logging; when on, the panel's **工具 ▾ → 注入调试** shows the last 8 injection attempts (read-only, never written to disk; it also lists any memory kept out of the body by an expired `invalid_at` or an unresolved contradiction, with the reason and the conflict id) |
419
+ | `SAGE_MEM_VALIDITY` | on | master switch for invalidity filtering and contradiction suppression. **Only `0` / `false` / `off` turn it off** (empty or misspelled values count as on); off restores the 1.9.x injection behaviour |
412
420
 
413
421
  Value semantics: **out-of-range values are clamped to the boundary, illegal values (including pure whitespace) fall back to the default.**
414
422
 
@@ -421,7 +429,7 @@ The panel can change the five caps above (written to `<state directory>/settings
421
429
  | File | Purpose |
422
430
  |---|---|
423
431
  | `access.json` | access ledger (`injected` / `read` / `use`) |
424
- | `conflicts/<id>.json` | write conflicts awaiting manual handling |
432
+ | `conflicts/<id>.json` | pending conflicts: a failed write, or a pair of contradictions awaiting resolution |
425
433
  | `tasks/<task id>/WORKING.md` | task checkpoints |
426
434
  | `settings.json` / `reserved.json` / `locked.json` | settings, reserved names, locks |
427
435
  | `autodream.json`, `autodream/` | autodream configuration, snapshots, run records |
@@ -451,7 +459,7 @@ Apart from `access.json`, `conflicts/` and `tasks/`, the rest is internal implem
451
459
  | File | Content |
452
460
  |---|---|
453
461
  | [1.0.0-contract.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md) | public commitments characterized item by item + the assertion each guarantee points to |
454
- | [1.0.0-boundaries.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-boundaries.md) | 35 capabilities characterized item by item (with basis and means of verification) |
462
+ | [1.0.0-boundaries.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-boundaries.md) | 38 capabilities characterized item by item (with basis and means of verification) |
455
463
  | [1.0.0-eval-report.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-eval-report.md) | quantified results for retrieval quality and budget, and what these numbers **cannot** measure |
456
464
  | [review-findings.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/review-findings.md) | the permanent ledger of what external review found: each finding, its disposition, and **which assertion watches over it** |
457
465
  | [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) | release discipline (the four sanitization checks, evaluation exit codes, probes, the four anchors) |
@@ -500,8 +508,9 @@ Version-by-version compatibility record:
500
508
  | 0.2.0-rc.1 | compatible |
501
509
  | 0.2.0-rc.2 | compatible |
502
510
  | 0.2.1-rc.1 | compatible |
511
+ | 0.2.1-rc.3 | compatible |
503
512
 
504
- **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) and `web` (2026-10-16); 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.
513
+ **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), and `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); 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.
505
514
 
506
515
  Two points worth spelling out:
507
516
 
@@ -554,6 +563,9 @@ npm test # all assertions (including the three guards)
554
563
 
555
564
  ## FAQ
556
565
 
566
+ **What changes in injection behaviour after upgrading to 2.0.0?**
567
+ 2.0.0 is a **behaviour-changing release** (hence MAJOR): ① a memory whose `invalid_at` has passed is **no longer injected as body text**; ② a memory with an unresolved contradiction keeps **both sides out of the body** until you resolve it on the 「记忆管理 → 冲突」(Memory management → Conflicts) tab; ③ **unlabelled old memories behave exactly as before** — no migration is needed, and there is no migration script. **Downgrading to 1.9.x is safe**: no data is damaged, but the old version ignores the invalid markers (invalid memories are injected again) and the conflicts tab disappears — that is **functionality disappearing, not data corruption**. If you only want to turn this filter off while keeping the rest of this release, use `SAGE_MEM_VALIDITY=0` (only `0` / `false` / `off` count as off).
568
+
557
569
  **What do I do if my memories are not being injected?**
558
570
  First confirm the memory directory is right (`SAGE_MEM_DIR`, default `~/.sage-mem/memory`), then check whether the files have frontmatter (broken frontmatter is **handled leniently** — the file is still read, with fields falling back to defaults). The 「体检」(audit) page in the panel reports structural problems such as dangling index entries, missing index entries and broken links.
559
571
 
@@ -575,6 +587,9 @@ Yes, it is off by default. It and archiving are two different things: archiving
575
587
  **The plugin market says "the registry's latest is older than the installed version, updating would downgrade"?**
576
588
  That is a **stale `/latest` cache on the download-region mirror**, not a mis-published package: the mirror's full metadata (the document pnpm reads) already has the new version, while the `/<pkg>/latest` shortcut still serves an old one. Retry after a minute or two; if it persists, temporarily switch the market's download region to "Global" (the origin registry, registry.npmjs.org), update, then switch back. To check what is actually published: `npm view sage-mem dist-tags`.
577
589
 
590
+ **Why is the downloads badge an endpoint badge instead of `npm/dm`?**
591
+ npm's own `downloads/point/last-month` API has returned 0 for every package since July 2026, which made `npm/dm` render a red `0/month`. The badge is now computed from the still-working `downloads/range` API by a daily GitHub Actions job and published to the `badges` branch as a [shields.io endpoint badge](https://shields.io/badges/endpoint-badge). If the number has not moved for more than ~2 days, it is usually a delayed scheduled run (or an npm API outage, in which case the last good value is kept on purpose).
592
+
578
593
  ## Uninstall
579
594
 
580
595
  ```bash
package/README.zh.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > English · [README.md](https://github.com/gezi-wen/sage-mem/blob/main/README.md)
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/sage-mem?color=blue)](https://www.npmjs.com/package/sage-mem)
6
- [![npm downloads](https://img.shields.io/npm/dm/sage-mem)](https://www.npmjs.com/package/sage-mem)
6
+ [![npm downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fgezi-wen%2Fsage-mem%2Fbadges%2Fnpm-downloads.json)](https://www.npmjs.com/package/sage-mem)
7
7
  [![tests](https://img.shields.io/github/actions/workflow/status/gezi-wen/sage-mem/test.yml?label=tests)](https://github.com/gezi-wen/sage-mem/actions/workflows/test.yml)
8
8
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2018-3c873a)](#兼容性)
9
9
  [![DSH](https://img.shields.io/badge/DSH-%E2%89%A5%200.1.2--rc.1-8a2be2)](#兼容性)
@@ -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
- **当前发布版本:`1.9.2`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.0.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 是最直接的反馈。
@@ -72,7 +72,7 @@ sage-mem 补上这一层,做法是:**把该记住的事写成一张张卡片
72
72
  | 特性 | 说明 |
73
73
  |---|---|
74
74
  | **文件式存储** | 每条记忆一个 `.md`:frontmatter 存元数据,正文存内容。无数据库、无隐藏格式 |
75
- | **自动注入** | 每轮提问自动扫记忆目录、按相关度挑出最相关的几条注入上下文。模型第一轮就「想起来」,不需要它自己去翻文件 |
75
+ | **自动注入** | 每条新问题都会重新扫一遍记忆目录,按这条问题挑出最相关的几条,在模型开口之前拼进上下文 —— 不用模型自己去翻文件 |
76
76
  | **模型也能自己查** | 另给模型两个检索工具(`search_memory` / `read_memory`),它可以主动翻记忆、按行读原文 |
77
77
  | **写入不被覆盖** | 你正在编辑、模型同时在改的时候,后写的不会静默盖掉先写的;冲突留成待处理记录,由你决定保留哪边 |
78
78
  | **零额外服务** | 无 worker、无 SQLite、无端口、无常驻进程,不需要额外的 API key 或云服务(运行时唯一的依赖是普通 npm 库 `zod`) |
@@ -116,7 +116,7 @@ dsh plugin --profile web list
116
116
 
117
117
  1. 跟模型说「记住:我的猫叫芝麻,它喜欢晒太阳」—— 它按规则把这条写进记忆目录
118
118
  2. 再开一个**新会话**,问「我的猫叫什么」
119
- 3. 第一轮就该答出「芝麻」—— 记忆由插件自动检索并注入,模型没有主动去翻文件
119
+ 3. 它的第一个回答就该答出「芝麻」—— 记忆由插件自动检索并注入,模型没有主动去翻文件
120
120
 
121
121
  <details>
122
122
  <summary>手动安装(不想用 CLI,或要装本地开发版本)</summary>
@@ -254,6 +254,10 @@ baselinePriority: 10 # 可选:多条 baseline 之间谁先注入(越大越
254
254
 
255
255
  **缺口状态卡只在真有缺口时说话**:有候选没读、或有候选被预算省略时才出现,说清范围、条数、最佳线索和**一个可做的动作**;一条都没命中时,只有在你**问自己的历史**、或这条任务**有检查点**时才说(否则提示会被当噪音忽略)。单张卡上限 512 字节,且按内容签名去重。
256
256
 
257
+ **「这条已经不作数了」会变成文件里可核对的事实(2.0.0 起)。** 记忆的 frontmatter 可以带三个可选字段:`valid_at`(说法从什么时候成立)、`invalid_at`(从什么时候起不再作数)、`superseded_by`(现行口径在哪条记忆里)。**注入侧会读它们**:`invalid_at` 已经到点的那条不再进正文(理由是 `expired_source`);一对**未经裁决的矛盾**(`kind: contradiction` 的 open 记录)**两方都不进正文**(`conflict_pending`),直到你在「记忆管理 → 冲突」页签里裁决。**写失败型的冲突不抑制**(版本过期 / 文件在别处被改过 / 合并到一半)—— 盘上那份仍然是现行内容,把它们也藏起来等于把「这次没写进去」误报成「记忆不可信」。
258
+
259
+ **抑制绝不静默。** 有矛盾待裁决时,状态卡会多一句「另有 N 条矛盾待裁决,相关记忆本轮未注入正文」(带具体条数,所以条数一变就能再发一次),面板头部也有一个常显徽标。**工具不受影响**:`search_memory` / `read_memory` 照旧能搜到、读到失效记忆,结果里会标「已失效」并带 `invalidAt` / `supersededBy` —— 问历史时旧口径是证据,不是噪音。想临时回到旧行为,把 `SAGE_MEM_VALIDITY` 设成 `0`(只关这一层过滤,本版其它修复都还在)。
260
+
257
261
  **摘要侧也有相关性门槛。** 中文里「怎么」「什么」这类词元在大量 `description` 里都有,一句无关的「今天天气怎么样」可能靠一个「怎么」命中十几份记忆。门槛按**语料自身的 IDF 尺度**归一,无关问题的候选数因此降到约七分之一;被清掉的主要是本就该落选的弱摘要候选。
258
262
 
259
263
  离线评测集上的对照(门槛前后):**Recall@5 84.1% → 87.0%、误注入(注入了与问题无关的记忆)14.1% → 5.6%**。
@@ -293,7 +297,10 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
293
297
  | **插件自己**写(自动做梦的整理) | 调用方在写入时带上读到的版本 | 落一条待处理记录 `<状态目录>/conflicts/<id>.json`,里面留着**盘上那一版**和**想要写的那一版** —— 只有一边等于没得判 |
294
298
 
295
299
  - **冲突不会自动合并**,保留哪边由你决定(记录是 JSON,需要时直接打开看)。静默重试和静默放弃都是在替你决定。
296
- - **注入侧目前不读这个队列**:有未裁决冲突的记忆照常作为正文注入;`conflict_pending` 这条抑制规则要等 **2.0.0 的矛盾检测**(连面板里的裁决入口一起)才生效 —— 只接抑制、不做入口,会让一条记忆**永久不再被注入**,比现在更糟。现阶段处理:打开 `conflicts/<id>.json`,把 `status` 改成 `resolved` 或 `dismissed`(或直接删掉文件)。
300
+ - **2.0.0 起注入侧会读这个队列**:只有 `kind: contradiction` 且 `status: open` 的**矛盾型**两方被抑制;另外三种写失败型(`stale_version` / `external_change` / `unresolved_merge`)照常注入 —— 盘上那份才是现行内容。裁决入口与抑制是同一次交付,不再有「只接抑制、没有入口」的状态。
301
+ - **裁决在「记忆管理 → 冲突」页签**(第 6 个页签,面板头部另有常显徽标):逐条看两份正文,动作是「标此条失效」「撤销失效」「删除记录」,危险动作两段式确认。「标失效」只往记忆 frontmatter 加一行 `invalid_at`(可选再写 `superseded_by`)—— **文件留在原位、正文一个字节不动、不改 `updated`**;写盘幂等(连点两次不重写)、可撤销,失败时明确返回错误、记录保持待裁决。
302
+ - **冲突记录字段只增不改**:2.0.0 在尾部追加 `otherFile` / `decision` / `decidedAt` / `appliedVersion` / `invalidatedFile` 五个,记录版本仍是 1;1.9.x 读端忽略不认识的键,双向都不用迁移。
303
+ - **手工编辑不在看守范围**:直接改文件也生效(删掉 `invalid_at` 就恢复注入),插件不拦手改 —— 这是文件式记忆的取舍,见能力边界表 C11。
297
304
  - **同一件事不会越积越多**:一次失败如果与队列里某条未裁决记录逐字段相同(同一文件、两份正文、两个版本),不再追加副本。
298
305
  - **宿主的文件沙箱**:自动做梦在**会话外**运行,拿不到会话工作区。宿主用 `workspace-write` 时,插件给每次写入带上**收窄到记忆目录**的逐次沙箱策略(比回退根更窄);宿主是 `read-only` 时不传策略、写入照旧被拒,且这一趟会被记成**失败**(不是「成功,没改什么」)。
299
306
  - 已知边界:**在 shell 里改记忆不走这条路**。写门守的是「经过工具的记忆写入」,不是「所有对文件的修改」;`pwsh` 里的重定向之类认不出来。认不准就不认,免得把「看不到」误记成事实。
@@ -408,7 +415,8 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
408
415
  | `SAGE_MEM_MAX_BASELINE` | `8`(1–50) | `baseline` 名额 |
409
416
  | `SAGE_MEM_STALE_DAYS` | `1`(0–365) | 标题标「保存于 N 天前」的门槛;0 = 每条都标 |
410
417
  | `SAGE_MEM_TASK_ID` | 空 | 任务检查点的作用域;空则回落会话 id |
411
- | `SAGE_MEM_DEBUG` | 关 | 调试日志;打开后面板「工具 ▾ → 注入调试」显示最近 8 次注入的账(只读、不落盘) |
418
+ | `SAGE_MEM_DEBUG` | 关 | 调试日志;打开后面板「工具 ▾ → 注入调试」显示最近 8 次注入的账(只读、不落盘;同时列出被已失效标注 / 未裁决矛盾挡在正文外的记忆与理由,矛盾还带冲突 id) |
419
+ | `SAGE_MEM_VALIDITY` | 开 | 失效过滤与矛盾抑制的总开关。**只认 `0` / `false` / `off` 为关闭**(空值、拼错都按开启);关掉后回到 1.9.x 的注入行为 |
412
420
 
413
421
  取值语义:**越界夹到边界,非法值(含纯空格)回落默认。**
414
422
 
@@ -421,7 +429,7 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
421
429
  | 文件 | 用途 |
422
430
  |---|---|
423
431
  | `access.json` | 访问台账(`injected` / `read` / `use`) |
424
- | `conflicts/<id>.json` | 待人工处理的写入冲突 |
432
+ | `conflicts/<id>.json` | 待处理的冲突:写入失败,或一对等待裁决的矛盾 |
425
433
  | `tasks/<任务 id>/WORKING.md` | 任务检查点 |
426
434
  | `settings.json` / `reserved.json` / `locked.json` | 设置、保留名、锁定 |
427
435
  | `autodream.json`、`autodream/` | 自动做梦的配置、快照、运行记录 |
@@ -451,7 +459,7 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
451
459
  | 文件 | 内容 |
452
460
  |---|---|
453
461
  | [1.0.0-contract.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md) | 公开承诺逐条定性 + 每条保证指向的断言 |
454
- | [1.0.0-boundaries.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-boundaries.md) | 35 条能力逐条定性(含依据与验证手段) |
462
+ | [1.0.0-boundaries.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-boundaries.md) | 38 条能力逐条定性(含依据与验证手段) |
455
463
  | [1.0.0-eval-report.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-eval-report.md) | 检索质量与预算的量化结果,以及这些数字**测不到**什么 |
456
464
  | [review-findings.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/review-findings.md) | 外部复审发现的永久账:每条发现、处置、**由哪条断言看着** |
457
465
  | [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) | 发版纪律(脱敏四查、评测退出码、探针、四锚点) |
@@ -502,8 +510,9 @@ sage-mem 是纯 DSH 插件,用 `package.json` 的 **`engines.dsh`** 声明宿
502
510
  | 0.2.0-rc.1 | compatible |
503
511
  | 0.2.0-rc.2 | compatible |
504
512
  | 0.2.1-rc.1 | compatible |
513
+ | 0.2.1-rc.3 | compatible |
505
514
 
506
- **`compatible` 怎么读**:它表示本插件的 codec / manifest 契约在该宿主代上核对过(代码层比对 + 单元/契约测试),**不表示**在那个确切宿主构建上跑过端到端探针。这张表是声明,核对方式总结在下面。真实宿主探针目前只跑过 `headless`(2026-10-15)与 `web`(2026-10-16);`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 宿主探针也还没有。
515
+ **`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 项通过);`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 宿主探针也还没有。
507
516
 
508
517
  两点值得说明:
509
518
 
@@ -557,6 +566,9 @@ npm test # 全部断言(含三条守卫)
557
566
 
558
567
  ## 常见问题
559
568
 
569
+ **升级到 2.0.0 之后,注入行为变了什么?**
570
+ 2.0.0 是**行为变更版**(所以按 MAJOR 发):① 带 `invalid_at` 且已到点的记忆**不再进正文**;② 有未裁决矛盾的记忆**两方都不进正文**,直到你在「记忆管理 → 冲突」页签里裁决;③ **没有标注的旧记忆行为不变**,不需要迁移,也不会有迁移脚本。**回退到 1.9.x 是安全的**:不会损坏任何数据,但「失效」标记会被旧版本忽略(失效记忆重新注入)、冲突页签消失 —— 那是**功能消失,不是数据损坏**。如果只想关掉这层过滤、保留本版其它修复,用 `SAGE_MEM_VALIDITY=0`(只认 `0` / `false` / `off` 为关闭)。
571
+
560
572
  **记忆没被注入怎么办?**
561
573
  先确认记忆目录对不对(`SAGE_MEM_DIR`,默认 `~/.sage-mem/memory`),再看文件有没有 frontmatter(坏掉的 frontmatter **会被宽容解析**:字段回落默认,文件照样读得到)。面板里的「体检」页会报出索引悬空、漏索引、断链这类结构问题。
562
574
 
@@ -578,6 +590,9 @@ npm test # 全部断言(含三条守卫)
578
590
  **插件市场报「registry 上的最新版比已装还旧,更新会降级」?**
579
591
  那是**下载地区镜像的 `/latest` 缓存滞后**,不是包发错了:镜像的全量元数据(pnpm 读的那份)已经是新版,但 `/<包>/latest` 这个快捷入口还缓存着旧值。先等一两分钟重试;仍然不行就把插件市场的「下载地区」临时切到「全球」(直连 registry.npmjs.org)更新,完成后再切回。核对实际发布版本:`npm view sage-mem dist-tags`。
580
592
 
593
+ **顶部「月下载量」为什么改成了 endpoint 徽章?**
594
+ npm 官方的 `downloads/point/last-month` 接口从 2026 年 7 月起对所有包都返回 0,`npm/dm` 徽章因此渲染成红色的 `0/month`;现在这个数字由 GitHub Actions 每天从仍然可用的 `downloads/range` 接口求和,发布到 `badges` 分支,再用 [shields.io endpoint badge](https://shields.io/badges/endpoint-badge) 渲染。若它超过约 2 天没变化,通常只是定时任务延迟(或 npm 接口故障,此时会特意保留上一次的正常值)。
595
+
581
596
  ## 卸载
582
597
 
583
598
  ```bash
@@ -24,7 +24,8 @@
24
24
  import { readdir, readFile } from 'node:fs/promises'
25
25
  import { join } from 'node:path'
26
26
  import { hasFrontmatter, parseFrontmatter } from '../memory/frontmatter.js'
27
- import { SESSION_LOG_KEY } from '../shared/md-name.js'
27
+ import { parseValidityTime } from '../memory/validity.js'
28
+ import { SESSION_LOG_KEY, isPlainMdName } from '../shared/md-name.js'
28
29
  import { ARCHIVE_DIR_NAME, inspectMemoryEntry } from '../memory/scan.js'
29
30
  import { warnLog } from '../memory/util.js'
30
31
 
@@ -61,6 +62,23 @@ const ARCHIVE_DIR = 'archive'
61
62
  /** YAML 行形态:`key: value`(顶层键,不含缩进的嵌套行)。 */
62
63
  const YAML_LEAF_RE = /^[A-Za-z0-9_.-]+:\s*(.*)$/
63
64
 
65
+ /**
66
+ * 有效性标注的「提示」文案(2.0.0)。三种原因:值认不出 / 时间在未来 / 指针疑似落空。
67
+ *
68
+ * 为什么要提示却不计入 problems:这三个字段是**可选**的(R1:缺失即老行为),写坏了
69
+ * 的真实后果也只是「按未标注处理」——记忆本身没坏、行为与 1.9.2 一致。而 `problems`
70
+ * 是面板与模型看到的唯一健康数字,把「可选字段写得不够漂亮」算进去,它立刻变成
71
+ * 恒大于 0 的噪音(issue #8 的教训,`CHANGELOG.md` 里记过)。
72
+ *
73
+ * 文案里写清「后果 + 现在怎么处理」:只说「格式不对」,模型会去猜该怎么改,
74
+ * 而这几个字段的填法是用户语义(哪个时间点算失效),审计不该替它决定。
75
+ */
76
+ const VALIDITY_HINT_TEXT = Object.freeze({
77
+ bad_format: '值不是带时区偏移的 ISO 8601 → 按未标注处理(行为同老版本,不失效)',
78
+ future: '时间在未来 → 暂不失效(到点后才生效)',
79
+ missing: '指向的文件名在本目录与归档区都找不到(可能已改名 / 归档)',
80
+ })
81
+
64
82
  /**
65
83
  * 审计用的 frontmatter 视图:**不再自己写一份正则**,改成正典解析的薄包装。
66
84
  *
@@ -75,6 +93,11 @@ export function parseFrontmatterLite(content) {
75
93
  description: fm.description,
76
94
  type: String(fm.type).trim(),
77
95
  hasFrontmatter: hasFrontmatter(content),
96
+ // 有效性三字段(2.0.0)直接透传:判据仍只有 `parseFrontmatter` 一份,这里只是
97
+ // 审计顺手拿到的视图 —— 若在这里再写一份解析,坏格式的判定就会与注入侧漂移(D22)。
98
+ validAt: fm.validAt,
99
+ invalidAt: fm.invalidAt,
100
+ supersededBy: fm.supersededBy,
78
101
  }
79
102
  }
80
103
 
@@ -182,11 +205,16 @@ function stripCode(raw) {
182
205
 
183
206
  /**
184
207
  * 七查主入口。
208
+ *
185
209
  * @param {string} dir — 记忆根目录
210
+ * @param {{nowMs?: number}} [opts] — `nowMs` 只用于「未来 `invalid_at`」那条提示:
211
+ * 审计本身要读盘(不纯),但同一条规律仍然成立 —— 判定依赖的时钟必须能由调用方钉住,
212
+ * 否则夹具没法写出「未来」这种确定性场景(R3)。不传才用当下。
186
213
  * @returns {Promise<object>} 结构化审计结果(problems 是六项之和)
187
214
  */
188
- export async function auditMemoryDir(dir) {
215
+ export async function auditMemoryDir(dir, opts = {}) {
189
216
  const { files, all, skipped, indexText, archiveKeys, archiveCount } = await readDirSnapshot(dir)
217
+ const nowMs = Number.isFinite(opts?.nowMs) ? opts.nowMs : Date.now()
190
218
  const entries = files.filter((f) => f !== INDEX_FILE)
191
219
 
192
220
  const targets = indexTargets(indexText)
@@ -205,6 +233,11 @@ export async function auditMemoryDir(dir) {
205
233
  const archivedLinks = []
206
234
  const noFrontmatter = []
207
235
  const linkKeys = new Set()
236
+ /**
237
+ * 有效性标注的提示(2.0.0):`{file, field, reason}`,**不进 problems**。
238
+ * 三种原因见 VALIDITY_HINT_TEXT —— 共同点是「可选字段写得不对」,不是记忆坏了。
239
+ */
240
+ const validityHints = []
208
241
 
209
242
  const contents = new Map()
210
243
  /**
@@ -240,6 +273,16 @@ export async function auditMemoryDir(dir) {
240
273
  linkKeys.add(f)
241
274
  linkKeys.add(f.replace(/\.md$/i, ''))
242
275
  }
276
+ /**
277
+ * `superseded_by` 的「目标存在」名字池:顶层全部 `.md` + 归档区名字。
278
+ *
279
+ * 池子取宽不取严(两边都不区分大小写):这条判据只产出**提示**,而 R6 明确
280
+ * 「目标可能被归档 / 改名、检索不依赖它」—— 把一条指对归档文件的指针报成落空,
281
+ * 会催用户去改一个本来正确的值。反过来漏报的代价只是少一句提醒。
282
+ */
283
+ const supersededPool = new Set()
284
+ for (const n of all) supersededPool.add(n.toLowerCase())
285
+ for (const n of archiveKeys) supersededPool.add(n.toLowerCase())
243
286
 
244
287
  for (const f of readable) {
245
288
  const raw = contents.get(f)
@@ -273,6 +316,35 @@ export async function auditMemoryDir(dir) {
273
316
  hashHazards.push({ file: f, line: line.trim().slice(0, 100) })
274
317
  }
275
318
  }
319
+ /**
320
+ * 有效性三字段的提示(2.0.0 R2 / R3 / R6)—— 三项都只提示、不进 problems:
321
+ * · 时间认不出(R2)→ 按未标注处理,注入行为与 1.9.2 逐字相同;
322
+ * · `invalid_at` 在未来(R3)→ 暂不失效,到点才生效;
323
+ * · `superseded_by` 落空(R6)→ 它只是线索不是外键,检索不依赖它。
324
+ * 「文件名合法」复用 `shared/md-name.js` 的判据(D22:不另写一份正则),
325
+ * 与设置页 / 归档 / 写门对「合法 .md 名」的口径保持同一份。
326
+ */
327
+ for (const [field, value] of [['valid_at', meta.validAt], ['invalid_at', meta.invalidAt]]) {
328
+ if (String(value ?? '').trim() === '') continue
329
+ const parsed = parseValidityTime(value)
330
+ if (!parsed.ok) {
331
+ validityHints.push({ file: f, field, reason: 'bad_format' })
332
+ continue
333
+ }
334
+ // 只有 invalid_at 的「未来」会改变行为(暂不失效);valid_at 在未来本身合法
335
+ // (说法尚未开始成立),本版不据此抑制注入,所以不提示。
336
+ if (field === 'invalid_at' && parsed.ms > nowMs) {
337
+ validityHints.push({ file: f, field, reason: 'future' })
338
+ }
339
+ }
340
+ const supersededBy = String(meta.supersededBy ?? '').trim()
341
+ if (supersededBy !== '') {
342
+ if (!isPlainMdName(supersededBy)) {
343
+ validityHints.push({ file: f, field: 'superseded_by', reason: 'bad_format' })
344
+ } else if (!supersededPool.has(supersededBy.toLowerCase())) {
345
+ validityHints.push({ file: f, field: 'superseded_by', reason: 'missing' })
346
+ }
347
+ }
276
348
  }
277
349
  /**
278
350
  * ⚠️ 只在**非代码**文本里找双链。
@@ -329,6 +401,8 @@ export async function auditMemoryDir(dir) {
329
401
  brokenLinks,
330
402
  // 指向归档的链接是提示,**不进 problems**(见文件头那段)。
331
403
  archivedLinks,
404
+ // 有效性标注的提示(2.0.0),同样**不进 problems** —— 见 VALIDITY_HINT_TEXT 上方那段。
405
+ validityHints,
332
406
  hashHazards,
333
407
  noFrontmatter: noFrontmatter.map((file) => ({ file })),
334
408
  unreadable: unreadable.map((file) => ({ file })),
@@ -360,6 +434,12 @@ export function formatAudit(report, limit = 12) {
360
434
  section('双链断链', report.brokenLinks, (r) => `${r.file} -> [[${r.target}]]`)
361
435
  // 指向归档单列且写明「不计问题」:否则模型会去「修」一条本来正确的链接。
362
436
  section('指向已归档(提示,不计入问题)', report.archivedLinks ?? [], (r) => `${r.file} -> [[${r.target}]]`)
437
+ // 有效性标注同理:字段是可选的,写坏了只是「按未标注处理」,不是记忆坏了。
438
+ section(
439
+ '有效性标注提示(提示,不计入问题)',
440
+ report.validityHints ?? [],
441
+ (r) => `${r.file} ${r.field}: ${VALIDITY_HINT_TEXT[r.reason] ?? r.reason}`,
442
+ )
363
443
  section('未加引号的「空格+#」', report.hashHazards, (r) => `${r.file} :: ${r.line}`)
364
444
  section('无 frontmatter', report.noFrontmatter, (r) => r.file)
365
445
  // 读不动的单列,且排最后:它不是「这条记忆写错了」,是「我没读到它」——
@@ -29,8 +29,8 @@ export function buildAutodreamSystemPrompt(opts) {
29
29
  ? `- **不许改动的文件**(使用者指定的保护名单):${reserved.map((f) => `\`${f}\``).join('、')} —— 这些多半是手写单副本、人工维护的重要记忆,一个字都不要动`
30
30
  : '- 有些记忆是使用者手写单副本、人工维护的;本次没有额外指定保护名单,遇到明显「成篇手写、结构不像流水」的记忆请保守处理'
31
31
  const modeLine = apply
32
- ? '本次是**改写模式**:你有写工具,可以直接改记忆文件。运行前已经把整个 memory 目录快照了一份(这就是回滚点),改坏能整体退回去——但这只保证「可恢复」,不保证「改得对」。宁可少改,不要乱动。\n\n**每一次写入都请写清 `reason`。** 这一趟是无人值守跑的,人只能靠这份缘由判断你改得对不对。写清「为什么改这条、改成了什么」,不要写「整理优化」这种没有信息量的话。缺了**不会被拒绝**——改动照样生效——但整理声明里会把那条标成「未自述」,用宿主能追溯到的信息顶替。'
33
- : '本次是**只读模式**:你没有任何写工具。把所有发现写成建议清单,写进你最后的回复里——要具体到「哪个文件、改成什么」,不要泛泛而谈。'
32
+ ? '本次是**改写模式**:你有写工具,可以直接改记忆文件。运行前已经把整个 memory 目录快照了一份(这就是回滚点),改坏能整体退回去——但这只保证「可恢复」,不保证「改得对」。宁可少改,不要乱动。\n\n**每一次写入都请写清 `reason`。** 这一趟是无人值守跑的,人只能靠这份缘由判断你改得对不对。写清「为什么改这条、改成了什么」,不要写「整理优化」这种没有信息量的话。缺了**不会被拒绝**——改动照样生效——但整理声明里会把那条标成「未自述」,用宿主能追溯到的信息顶替。\n\n**遇到两条说法互斥的记忆(同一件事两种说法),用 `report_contradiction` 报出来,不要自己删改其中任何一条。** 裁决由人做:报出来之后这两条暂时都不进正文,人会在面板上选哪条作废。自己删改等于替人决定「哪条历史不该存在」。'
33
+ : '本次是**只读模式**:你没有任何写工具。把所有发现写成建议清单,写进你最后的回复里——要具体到「哪个文件、改成什么」,不要泛泛而谈。发现两条记忆互相矛盾时,单列出来说明哪两条、哪里互斥。'
34
34
 
35
35
  return `你是 sage-mem(一个文件式跨会话记忆系统)的整理进程。你的记忆不是数据库,是一个装着 markdown 文件的目录,靠 frontmatter 分类、靠双字组匹配检索。你要做的是一次反思性通读,把最近的经历沉淀成耐久、有条理的记忆。
36
36
 
@@ -82,7 +82,7 @@ export function buildAutodreamUserPrompt(opts) {
82
82
  - 每条一行 \`- [标题](文件.md) — 一句话\`
83
83
  - 移除指向已归档 / 已不存在文件的指针
84
84
  - 补上新写的重要记忆
85
- - 两条记忆互相矛盾时,把错的那条改对`
85
+ - 两条记忆互相矛盾时,用 \`report_contradiction\` 报出来(不要自己删改其中任何一条)`
86
86
  : `- 给出 \`MEMORY.md\` 该增删哪些行的**具体清单**(逐字给出要改成的那行文本)`
87
87
 
88
88
  return `# Autodream:一次记忆整理
@@ -33,7 +33,7 @@ import { MAX_FILE_BYTES, STATE_ROOT } from '../memory/config.js'
33
33
  import { INDEX_FILE_KEY, SESSION_LOG_KEY } from '../shared/md-name.js'
34
34
  import { stampHuman as localStamp } from '../shared/stamp.js'
35
35
  import { writeFileAtomic, warnLog } from '../memory/util.js'
36
- import { buildConflict, listConflicts, writeConflict } from '../memory/conflicts.js'
36
+ import { buildConflict, listConflicts, readConflict, sameConflict, writeConflict } from '../memory/conflicts.js'
37
37
  import { memoryPathOf } from '../memory/paths.js'
38
38
  import { decideWriteIntent } from '../memory/write-gate.js'
39
39
  import { inspectMemoryEntry, reservedIdentitySet } from '../memory/scan.js'
@@ -396,6 +396,21 @@ export function autodreamToolSchemas(opts) {
396
396
  additionalProperties: false,
397
397
  },
398
398
  },
399
+ {
400
+ name: 'report_contradiction',
401
+ description:
402
+ '报告一对**说法互斥**的记忆(同一件事两种说法,例如「部署在 A」与「部署在 B」)。它只落一条待人工裁决的记录,**不修改任何记忆文件**。裁决之前这两条都不会被注入正文;不要自己删改其中任何一条来「消除矛盾」。',
403
+ parameters: {
404
+ type: 'object',
405
+ properties: {
406
+ old: str('旧说法 / 可能已作废的那条记忆的文件名'),
407
+ new: str('新说法 / 现行的那条记忆的文件名'),
408
+ note: str('一句话说明两条到底哪里互斥(人要靠它判断该保留哪条)'),
409
+ },
410
+ required: ['old', 'new', 'note'],
411
+ additionalProperties: false,
412
+ },
413
+ },
399
414
  )
400
415
  }
401
416
  return tools
@@ -646,24 +661,18 @@ export function createToolRunner(opts) {
646
661
  * (见 `buildConflict`),而「模型每一趟都试图覆盖一份它没读过的记忆」是**结构性**的
647
662
  * —— 每一趟都会生成新 id,于是 `conflicts/` 只增不减,而其中绝大多数是同一件事的副本。
648
663
  *
649
- * 判据是**逐字段相等**(kind / file / 两份正文 / 两个版本):任何一处不同都算另一件事,
664
+ * 判据是**逐字段相等**(kind / file / otherFile / 两份正文 / 两个版本):任何一处不同都算另一件事,
650
665
  * 仍然是两条独立记录 —— 去重只跳过重复,不做「合并」,更不做裁决。
666
+ * `otherFile` 必须进比较列表:否则「A 对 B」与「A 对 C」会被当成同一件事,
667
+ * 第二对矛盾就永远落不了盘。
651
668
  * 读队列失败一律当作「没有重复」:去重是省磁盘的优化,不能因为读不动就连该留的记录也不留。
652
669
  */
653
670
  async function hasSameOpenConflict(record) {
654
671
  try {
655
672
  const listed = await listConflicts({ stateRoot: STATE_ROOT })
656
673
  if (!listed.ok) return false
657
- return listed.items.some(
658
- (c) =>
659
- c.status === 'open' &&
660
- c.kind === record.kind &&
661
- c.file === record.file &&
662
- c.expectedVersion === record.expectedVersion &&
663
- c.actualVersion === record.actualVersion &&
664
- c.incoming === record.incoming &&
665
- c.onDisk === record.onDisk,
666
- )
674
+ // 判据本身在 `conflicts.js` 的 `sameConflict`(唯一一份):这里只负责「读队列 + 任一相等」。
675
+ return listed.items.some((c) => sameConflict(c, record))
667
676
  } catch {
668
677
  return false
669
678
  }
@@ -918,6 +927,62 @@ export function createToolRunner(opts) {
918
927
  }
919
928
  return `已归档 ${basename(full)}(原因:${reason})。`
920
929
  }
930
+ case 'report_contradiction': {
931
+ // 2.0.0:**只落待裁决记录,不动记忆文件**。检测只发生在整理这条已有路径上
932
+ // (不做常驻扫描、不加公开工具面,D29 Q5);裁决永远由人做。
933
+ if (!apply) return '错误:当前是「只出报告」模式,没有写权限。请把发现的矛盾写进你的最终回复。'
934
+ const oldName = safeMemoryPath(memoryDir, a.old)
935
+ const newName = safeMemoryPath(memoryDir, a.new)
936
+ if (!oldName || !newName) return '错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名)。'
937
+ const oldFull = join(memoryDir, await resolveRealName(memoryDir, basename(oldName)))
938
+ const newFull = join(memoryDir, await resolveRealName(memoryDir, basename(newName)))
939
+ if (basename(oldFull) === basename(newFull)) return '错误:old 与 new 不能是同一个文件。'
940
+ const note = typeof a.note === 'string' ? a.note.trim() : ''
941
+ if (note.length < MIN_REASON_CHARS) {
942
+ return '错误:report_contradiction 必须给 note —— 一句话说明这两条到底哪里互斥。'
943
+ }
944
+ // 两份正文都存进记录:面板上人要对照着判,只留一边等于没得判。
945
+ const readBody = async (full) => {
946
+ const gate = await inspectMemoryEntry(full)
947
+ if (!gate.ok) return null
948
+ return await readFile(full, 'utf8').catch(() => null)
949
+ }
950
+ const onDisk = await readBody(oldFull)
951
+ const incoming = await readBody(newFull)
952
+ if (onDisk === null || incoming === null) {
953
+ return '错误:old / new 里有读不到的文件,先用 list_memory 确认真实文件名。'
954
+ }
955
+ const record = buildConflict({
956
+ kind: 'contradiction',
957
+ file: basename(oldFull),
958
+ otherFile: basename(newFull),
959
+ incoming,
960
+ onDisk,
961
+ note,
962
+ tool: 'report_contradiction',
963
+ at: new Date().toISOString(),
964
+ })
965
+ const pair = `${basename(oldFull)} ↔ ${basename(newFull)}`
966
+ if (await hasSameOpenConflict(record)) {
967
+ notes.push(`发现矛盾:${pair}(队列里已有同一对,未重复落盘)`)
968
+ return `已记录(此前已有同一对待裁决记录):${pair}。`
969
+ }
970
+ /**
971
+ * id 的时间戳只精确到秒:同一对在**同一秒**里「裁决完又重报」会撞上已裁决记录的 id,
972
+ * 盲写会把它拉回 open 并把裁决收据冲掉(状态机被这条路径绕过)。
973
+ * 撞上就换一个后缀,让这次报告成为**一条新记录** —— 旧判断留在原记录里,不该被覆盖。
974
+ */
975
+ let target = record
976
+ for (let n = 2; n <= 9; n++) {
977
+ const existing = await readConflict(target.id, { stateRoot: STATE_ROOT })
978
+ if (existing?.code === 'missing') break
979
+ target = { ...record, id: `${record.id}-${n}` }
980
+ }
981
+ const written = await writeConflict(target, { stateRoot: STATE_ROOT })
982
+ if (!written.ok) return `错误:矛盾记录没落下来(${written.code}):${written.error ?? ''}`
983
+ notes.push(`发现矛盾:${pair}(待人工裁决:${note})`)
984
+ return `已记录矛盾:${pair}。裁决之前这两条都不会被注入正文;请不要自行删改其中任何一条。`
985
+ }
921
986
  default:
922
987
  return `错误:没有这个工具:${name}`
923
988
  }