sage-mem 1.9.1 → 2.0.0

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.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.0.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
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.
@@ -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 **1.10.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.
@@ -344,6 +351,14 @@ Consolidation is a background batch job and need not be the same model as the on
344
351
  - The panel always shows **which one is actually in effect**; if the route named in the configuration happens to disappear from the catalog, the dropdown still shows it rather than quietly jumping back to the default.
345
352
  - When the model catalog cannot be obtained (host differences), it degrades to typing `provider` and `model` by hand.
346
353
 
354
+ ### When the Report Does Not Fit
355
+
356
+ The consolidation report is written by the model in one go, and a single output has a length cap. **The cap, the retry and the truncated label** all have defined behaviour:
357
+
358
+ - **The single-output cap is configurable**: Settings → Autodream → Settings → Advanced → "single output cap" (default 32768 tokens, range 1024–131072). For reasoning models the reasoning and the body **share** that budget; when the closing turn spends it all on reasoning, no body is left — raise the cap and run again.
359
+ - **Retry when the body is empty**: if an output hits the cap with **no body at all** (reasoning only), one retry asks for "the body directly, do not re-analyse"; up to 2 times, and only then is the report marked truncated. When there is a partial body, the loop asks it to continue from where it stopped (up to 3 rounds).
360
+ - **Truncation is stated**: the report's first screen, the consolidation manifest, the machine-readable record and the Settings → Autodream "last run" line all say "report truncated (fragment)" — a half report is never reported as "success". If the closing step produced no text, the report says "the model gave no textual summary" instead of reusing narration from an earlier step.
361
+
347
362
  ### Rollback Points
348
363
 
349
364
  **Whenever this run is a "direct rewrite", a rollback point is created before work starts** (a full snapshot of the top-level `.md` files in the memory directory, each with a sha256), and after the run you can see it in the settings page and roll back to it in one click.
@@ -401,6 +416,7 @@ The two things are moved **differently**, spelled out:
401
416
  | `SAGE_MEM_STALE_DAYS` | `1` (0–365) | threshold for marking a heading 「保存于 N 天前」(saved N days ago); 0 = mark every one |
402
417
  | `SAGE_MEM_TASK_ID` | empty | scope of task checkpoints; empty falls back to the session id |
403
418
  | `SAGE_MEM_DEBUG` | off | debug logging; when on, the panel's **工具 ▾ → 注入调试** shows the last 8 injection attempts (read-only, never written to disk) |
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 |
404
420
 
405
421
  Value semantics: **out-of-range values are clamped to the boundary, illegal values (including pure whitespace) fall back to the default.**
406
422
 
@@ -413,7 +429,7 @@ The panel can change the five caps above (written to `<state directory>/settings
413
429
  | File | Purpose |
414
430
  |---|---|
415
431
  | `access.json` | access ledger (`injected` / `read` / `use`) |
416
- | `conflicts/<id>.json` | write conflicts awaiting manual handling |
432
+ | `conflicts/<id>.json` | pending conflicts: a failed write, or a pair of contradictions awaiting resolution |
417
433
  | `tasks/<task id>/WORKING.md` | task checkpoints |
418
434
  | `settings.json` / `reserved.json` / `locked.json` | settings, reserved names, locks |
419
435
  | `autodream.json`, `autodream/` | autodream configuration, snapshots, run records |
@@ -443,7 +459,7 @@ Apart from `access.json`, `conflicts/` and `tasks/`, the rest is internal implem
443
459
  | File | Content |
444
460
  |---|---|
445
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 |
446
- | [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) |
447
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 |
448
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** |
449
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) |
@@ -492,8 +508,9 @@ Version-by-version compatibility record:
492
508
  | 0.2.0-rc.1 | compatible |
493
509
  | 0.2.0-rc.2 | compatible |
494
510
  | 0.2.1-rc.1 | compatible |
511
+ | 0.2.1-rc.3 | compatible |
495
512
 
496
- **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.
497
514
 
498
515
  Two points worth spelling out:
499
516
 
@@ -546,6 +563,9 @@ npm test # all assertions (including the three guards)
546
563
 
547
564
  ## FAQ
548
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
+
549
569
  **What do I do if my memories are not being injected?**
550
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.
551
571
 
@@ -567,6 +587,9 @@ Yes, it is off by default. It and archiving are two different things: archiving
567
587
  **The plugin market says "the registry's latest is older than the installed version, updating would downgrade"?**
568
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`.
569
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
+
570
593
  ## Uninstall
571
594
 
572
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.1`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.0.0`** —— 顶部 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 是最直接的反馈。
@@ -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` 这条抑制规则要等 **1.10.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` 里的重定向之类认不出来。认不准就不认,免得把「看不到」误记成事实。
@@ -344,6 +351,14 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
344
351
  - 面板下方始终显示**实际生效的是哪条**;配置里那条路线万一从目录里消失了,下拉框也会把它显示出来而不是悄悄跳回默认。
345
352
  - 拿不到模型目录时(宿主差异),退化成手填 `provider` 与 `model`。
346
353
 
354
+ ### 报告写不完的时候
355
+
356
+ 整理报告由模型一口气写完,而单次输出有长度上限。**上限、重试、残篇标注**三件事都有明确行为:
357
+
358
+ - **单次输出上限可配**:设置页「自动做梦 → 设置 → 高级设置 → 单次输出上限」(默认 32768 token,范围 1024–131072)。思考型模型的思考与正文**共用**这份额度;收尾那轮思考吃满时正文会一个字不剩 —— 这类报告先把上限调大再跑。
359
+ - **零正文先重试**:一轮额度用尽却**没有任何正文**(只剩思考)时,先要求它「直接输出正文、不要重新分析」重试;最多 2 次,仍为零才标残篇。撞上限但**有正文**的,按「从中断处继续写」续写(最多 3 轮)。
360
+ - **残篇会被指出来**:报告首屏、整理声明、机读记录与设置页「上次运行」那一行都会标「报告被截断(残篇)」—— 不会再把半篇报成「成功」。收尾那一步没写文字时,报告写「模型没有给出文字说明」,**不拿中途旁白顶替**。
361
+
347
362
  ### 回滚点
348
363
 
349
364
  **只要这次是「直接改写」,开工前就先建一个回滚点**(记忆目录顶层 `.md` 全量快照,每条带 sha256),跑完之后能在设置页看到它、也能一键退回去。
@@ -401,6 +416,7 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
401
416
  | `SAGE_MEM_STALE_DAYS` | `1`(0–365) | 标题标「保存于 N 天前」的门槛;0 = 每条都标 |
402
417
  | `SAGE_MEM_TASK_ID` | 空 | 任务检查点的作用域;空则回落会话 id |
403
418
  | `SAGE_MEM_DEBUG` | 关 | 调试日志;打开后面板「工具 ▾ → 注入调试」显示最近 8 次注入的账(只读、不落盘) |
419
+ | `SAGE_MEM_VALIDITY` | 开 | 失效过滤与矛盾抑制的总开关。**只认 `0` / `false` / `off` 为关闭**(空值、拼错都按开启);关掉后回到 1.9.x 的注入行为 |
404
420
 
405
421
  取值语义:**越界夹到边界,非法值(含纯空格)回落默认。**
406
422
 
@@ -413,7 +429,7 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
413
429
  | 文件 | 用途 |
414
430
  |---|---|
415
431
  | `access.json` | 访问台账(`injected` / `read` / `use`) |
416
- | `conflicts/<id>.json` | 待人工处理的写入冲突 |
432
+ | `conflicts/<id>.json` | 待处理的冲突:写入失败,或一对等待裁决的矛盾 |
417
433
  | `tasks/<任务 id>/WORKING.md` | 任务检查点 |
418
434
  | `settings.json` / `reserved.json` / `locked.json` | 设置、保留名、锁定 |
419
435
  | `autodream.json`、`autodream/` | 自动做梦的配置、快照、运行记录 |
@@ -443,7 +459,7 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
443
459
  | 文件 | 内容 |
444
460
  |---|---|
445
461
  | [1.0.0-contract.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md) | 公开承诺逐条定性 + 每条保证指向的断言 |
446
- | [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 条能力逐条定性(含依据与验证手段) |
447
463
  | [1.0.0-eval-report.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-eval-report.md) | 检索质量与预算的量化结果,以及这些数字**测不到**什么 |
448
464
  | [review-findings.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/review-findings.md) | 外部复审发现的永久账:每条发现、处置、**由哪条断言看着** |
449
465
  | [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) | 发版纪律(脱敏四查、评测退出码、探针、四锚点) |
@@ -494,8 +510,9 @@ sage-mem 是纯 DSH 插件,用 `package.json` 的 **`engines.dsh`** 声明宿
494
510
  | 0.2.0-rc.1 | compatible |
495
511
  | 0.2.0-rc.2 | compatible |
496
512
  | 0.2.1-rc.1 | compatible |
513
+ | 0.2.1-rc.3 | compatible |
497
514
 
498
- **`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 宿主探针也还没有。
499
516
 
500
517
  两点值得说明:
501
518
 
@@ -549,6 +566,9 @@ npm test # 全部断言(含三条守卫)
549
566
 
550
567
  ## 常见问题
551
568
 
569
+ **升级到 2.0.0 之后,注入行为变了什么?**
570
+ 2.0.0 是**行为变更版**(所以按 MAJOR 发):① 带 `invalid_at` 且已到点的记忆**不再进正文**;② 有未裁决矛盾的记忆**两方都不进正文**,直到你在「记忆管理 → 冲突」页签里裁决;③ **没有标注的旧记忆行为不变**,不需要迁移,也不会有迁移脚本。**回退到 1.9.x 是安全的**:不会损坏任何数据,但「失效」标记会被旧版本忽略(失效记忆重新注入)、冲突页签消失 —— 那是**功能消失,不是数据损坏**。如果只想关掉这层过滤、保留本版其它修复,用 `SAGE_MEM_VALIDITY=0`(只认 `0` / `false` / `off` 为关闭)。
571
+
552
572
  **记忆没被注入怎么办?**
553
573
  先确认记忆目录对不对(`SAGE_MEM_DIR`,默认 `~/.sage-mem/memory`),再看文件有没有 frontmatter(坏掉的 frontmatter **会被宽容解析**:字段回落默认,文件照样读得到)。面板里的「体检」页会报出索引悬空、漏索引、断链这类结构问题。
554
574
 
@@ -570,6 +590,9 @@ npm test # 全部断言(含三条守卫)
570
590
  **插件市场报「registry 上的最新版比已装还旧,更新会降级」?**
571
591
  那是**下载地区镜像的 `/latest` 缓存滞后**,不是包发错了:镜像的全量元数据(pnpm 读的那份)已经是新版,但 `/<包>/latest` 这个快捷入口还缓存着旧值。先等一两分钟重试;仍然不行就把插件市场的「下载地区」临时切到「全球」(直连 registry.npmjs.org)更新,完成后再切回。核对实际发布版本:`npm view sage-mem dist-tags`。
572
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
+
573
596
  ## 卸载
574
597
 
575
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
  // 读不动的单列,且排最后:它不是「这条记忆写错了」,是「我没读到它」——
@@ -19,6 +19,16 @@ export const DEFAULT_CONFIG = {
19
19
  provider: '',
20
20
  model: '',
21
21
  maxSteps: 24,
22
+ /**
23
+ * 单次模型调用的输出上限(token)。
24
+ *
25
+ * ⚠️ 2026-10-26(1.9.2)从写死的 4096 提到 32768 并做成设置项。4096 对**思考型模型**
26
+ * 不够:思考与正文共用同一份额度,收尾那一轮里思考吃光额度、正文一个字都没剩下,
27
+ * 结果报告是残篇而面板报「成功」(2026-10-25 两趟手动整理连续复现)。
28
+ * 调大它不是治本(报告长度没有上界),治本是「零正文先重试、写不完才标残篇」;
29
+ * 做成设置项是为了让用户按自己模型的输出上限调,而不是每次为它改代码。
30
+ */
31
+ maxOutputTokens: 32768,
22
32
  maxSnapshotKeep: 5,
23
33
  /** 回滚按钮的默认范围:files = 只恢复该次运行动过的文件;all = 整目录恢复到该时点。 */
24
34
  rollbackScope: 'files',
@@ -316,6 +316,7 @@ export class AutodreamEngine {
316
316
  auditAfter,
317
317
  finalText: result.finalText,
318
318
  continuations: result.continuations,
319
+ retries: result.retries,
319
320
  truncated: result.truncated,
320
321
  archiveMode: policy.mode,
321
322
  archiveRequested: policy.requested,
@@ -344,6 +345,7 @@ export class AutodreamEngine {
344
345
  auditAfter,
345
346
  finalText: result.finalText,
346
347
  continuations: result.continuations,
348
+ retries: result.retries,
347
349
  truncated: result.truncated,
348
350
  transcript: result.transcript,
349
351
  tokensIn: result.tokensIn,
@@ -388,6 +390,7 @@ export class AutodreamEngine {
388
390
  tokensOut: result.tokensOut,
389
391
  // 残篇要能在界面上被认出来(面板读 lastResult):只报「成功」是不够的。
390
392
  continuations: result.continuations,
393
+ retries: result.retries,
391
394
  truncated: result.truncated,
392
395
  report: report.name,
393
396
  snapshotId: snapshot?.id ?? null,
@@ -403,6 +406,7 @@ export class AutodreamEngine {
403
406
  changeCount: result.changes.length,
404
407
  snapshotId: snapshot?.id ?? null,
405
408
  continuations: result.continuations,
409
+ retries: result.retries,
406
410
  truncated: result.truncated,
407
411
  auditBefore,
408
412
  auditAfter,
@@ -151,18 +151,42 @@ export async function streamOnce(llm, req) {
151
151
  }
152
152
 
153
153
  /**
154
- * 一次模型调用的输出上限。
154
+ * 一次模型调用的输出上限(配置缺字段 / 坏值时的回落值)。
155
155
  *
156
156
  * ⚠️ **调大它解决不了「长报告被截断」**:报告长度没有上界,调大只是把墙往后挪一格
157
157
  * (2026-10-17 实测:10-01 的报告 5532 字符、10-13 是 5957,都完整收尾;这次 8553 字符
158
158
  * 就断在半个句子里)。真正的处理是撞上它时**接着写** —— 见下面的 MAX_CONTINUATIONS。
159
+ *
160
+ * 但 4096 对**思考型模型**连「把收尾那一轮写完」都不够:思考与正文共用额度,
161
+ * 2026-10-25 两趟手动整理都在最后一步思考吃光额度、正文零字(1.9.2 改成可配置,默认 32768)。
159
162
  */
160
- const MAX_TOKENS_PER_CALL = 4096
163
+ const FALLBACK_MAX_TOKENS = 32768
164
+
165
+ /** 配置里的单次输出上限。缺字段(老配置 / 测试桩)或坏值都回落到常量。 */
166
+ export function resolveMaxTokens(cfg) {
167
+ const n = Number(cfg?.maxOutputTokens)
168
+ return Number.isFinite(n) && n >= 1024 ? Math.round(n) : FALLBACK_MAX_TOKENS
169
+ }
170
+
161
171
  /** 撞上单次上限后最多续写几轮:够把最后一段写完,又不至于让一次跑飞掉。 */
162
172
  const MAX_CONTINUATIONS = 3
173
+ /**
174
+ * 「零正文截断」最多重试几次(1.9.2)。
175
+ *
176
+ * 零正文不是「这段写完了」,而是**思考把额度吃光了**:直接标残篇会让一次本来能写完的报告
177
+ * 白丢。先重试一次「直接输出正文、不要重新分析」;仍为零才标残篇。
178
+ * 上限 2:思考型模型可能连续两轮都只顾着思考,再多就是拿 token 赌运气。
179
+ */
180
+ const MAX_EMPTY_RETRIES = 2
163
181
  /** 续写指令。三条禁令缺一不可 —— 重写整篇 / 重新开头 / 道歉寒暄,模型都干得出来。 */
164
182
  const CONTINUE_PROMPT =
165
183
  '(上一条回答被输出长度上限截断了。请**从中断处继续写**,不要重复已经写过的内容,不要重新开头,不要解释或道歉,直接接着写。)'
184
+ /**
185
+ * 零正文重试指令。与 CONTINUE_PROMPT 的区别:**没有「已写内容」可续**,
186
+ * 上一轮只有思考。所以这里要压住「再分析一遍」的冲动,只要正文。
187
+ */
188
+ const EMPTY_RETRY_PROMPT =
189
+ '(上一条回答把输出额度全用在思考上了,最终没有任何正文。请**直接输出正文**:不要再重新分析、不要重复思考过程、不要解释,直接给出这一轮要交付的内容。)'
166
190
 
167
191
  /** 取这一次调用里的全部文字块,拼成一段。 */
168
192
  function textOf(asm) {
@@ -206,8 +230,12 @@ export async function agentLoop(cfg, runOpts) {
206
230
  // 截断与续写的账:`continuations` 是「确实接着写过几轮」,`truncated` 是「续写用尽还是没写完」。
207
231
  // 两个都要进声明与报告 —— 「报告是残篇却报成功」正是这一版要修的那处静默。
208
232
  let continuations = 0
233
+ /** 零正文重试的账(1.9.2):与「续写」分开记 —— 一个是接着写,一个是根本没写出来。 */
234
+ let retries = 0
209
235
  let truncated = false
210
236
  let lastStep = 0
237
+ // 每次调用都从这里取上限:配置改了不用重启(下一趟就用新值),老配置缺字段也能跑。
238
+ const maxTokensPerCall = resolveMaxTokens(cfg)
211
239
  const allToolsUsed = []
212
240
  const addUsage = (u) => {
213
241
  if (!u) return
@@ -228,7 +256,7 @@ export async function agentLoop(cfg, runOpts) {
228
256
  system,
229
257
  messages,
230
258
  tools: toolSchemas,
231
- maxTokens: MAX_TOKENS_PER_CALL,
259
+ maxTokens: maxTokensPerCall,
232
260
  signal,
233
261
  })
234
262
  addUsage(asm.usage)
@@ -237,30 +265,64 @@ export async function agentLoop(cfg, runOpts) {
237
265
  let text = ''
238
266
  let calls = []
239
267
  let segment = 0
268
+ /** 下一段是不是「零正文重试」来的 —— 只影响过程清单上的标记。 */
269
+ let retryNext = false
240
270
  for (;;) {
271
+ const isRetry = retryNext
272
+ retryNext = false
241
273
  const segText = textOf(asm)
242
274
  const blocks = asm.blocks()
243
275
  const segCalls = blocks.filter((b) => b.type === 'tool-call')
244
276
  // 只有第一段可能带工具调用:被截断时 BlockAssembler 已经丢掉了参数残缺的 tool-call。
245
277
  if (segment === 0) calls = segCalls
246
278
  text += segText
279
+ const segMark = segment === 0 ? {} : isRetry ? { retry: true } : { continuation: true }
247
280
  transcript.push({
248
281
  step,
249
282
  text: segText,
250
283
  tools: segCalls.map((c) => c.name),
251
- ...(segment ? { continuation: true } : {}),
284
+ ...segMark,
252
285
  })
253
286
  this.stepLog.push({
254
287
  step,
255
288
  tools: segCalls.map((c) => c.name),
256
289
  chars: segText.length,
257
290
  at: Date.now(),
258
- ...(segment ? { continuation: true } : {}),
291
+ ...segMark,
259
292
  })
260
293
  segment += 1
261
294
  if (asm.finish.kind !== 'max-tokens') break
262
- // 空段没法接着写;续写名额用尽也一样 —— 两种都算「这篇没写完」,要如实记下来。
263
- if (!segText || continuations >= MAX_CONTINUATIONS) {
295
+ /**
296
+ * 零正文的截断:不是「这段写完了」,而是**思考把额度吃光了**(思考型模型常见)。
297
+ * 1.9.2 起先重试「直接输出正文」;重试用尽仍无正文才标残篇。
298
+ * 旧行为(1.0.1)是直接标残篇 —— 那会让一次本来能写完的报告白丢一段收尾。
299
+ */
300
+ if (!segText) {
301
+ if (retries >= MAX_EMPTY_RETRIES) {
302
+ truncated = true
303
+ break
304
+ }
305
+ retries += 1
306
+ // 把这一轮原样回灌(通常只有 reasoning 块):让模型看到它上一轮只思考、没交付。
307
+ // 完全空的 blocks 不回灌 —— 空的 assistant 消息不是合法输入,宁可不给。
308
+ if (blocks.length) messages.push(assistantMessage(blocks, provider, model))
309
+ messages.push(userMessage(EMPTY_RETRY_PROMPT))
310
+ retryNext = true
311
+ this.phase = `第 ${step} 轮:零正文被截断,重试直接输出正文 ${retries}/${MAX_EMPTY_RETRIES}`
312
+ asm = await this.streamOnce(llm, {
313
+ provider,
314
+ model,
315
+ system,
316
+ messages,
317
+ tools: toolSchemas,
318
+ maxTokens: maxTokensPerCall,
319
+ signal,
320
+ })
321
+ addUsage(asm.usage)
322
+ continue
323
+ }
324
+ // 有正文但被截断:接着写。名额用尽就如实标残篇。
325
+ if (continuations >= MAX_CONTINUATIONS) {
264
326
  truncated = true
265
327
  break
266
328
  }
@@ -274,13 +336,15 @@ export async function agentLoop(cfg, runOpts) {
274
336
  system,
275
337
  messages,
276
338
  tools: toolSchemas,
277
- maxTokens: MAX_TOKENS_PER_CALL,
339
+ maxTokens: maxTokensPerCall,
278
340
  signal,
279
341
  })
280
342
  addUsage(asm.usage)
281
343
  }
282
344
 
283
- if (text) finalText = text
345
+ // ④ **每一步都覆盖**(不是「有字才覆盖」):最后一步零正文时,收尾说明必须留空,
346
+ // 不能把中途某轮的旁白当成「模型的收尾说明」写进报告(2026-10-25 残篇报告的伴随症状)。
347
+ finalText = text
284
348
  if (asm.finish.kind === 'error') {
285
349
  throw new Error(`模型返回错误:${asm.finish.failure?.message ?? '未知'}`)
286
350
  }
@@ -308,9 +372,10 @@ export async function agentLoop(cfg, runOpts) {
308
372
 
309
373
  if (truncated) {
310
374
  // 过程清单要以「没写完」收尾:否则报告里那半句话看起来只是模型话说到一半。
375
+ const retryNote = retries ? `(其中零正文重试 ${retries} 次)` : ''
311
376
  transcript.push({
312
377
  step: lastStep,
313
- text: `(输出撞上单次长度上限,续写 ${continuations} 轮后仍未写完 —— 上面这段是残篇,后文缺失)`,
378
+ text: `(输出撞上单次长度上限,续写 ${continuations} 轮${retryNote}后仍未写完 —— 上面这段是残篇,后文缺失)`,
314
379
  tools: [],
315
380
  })
316
381
  }
@@ -321,6 +386,8 @@ export async function agentLoop(cfg, runOpts) {
321
386
  tokensIn,
322
387
  tokensOut,
323
388
  continuations,
389
+ /** 零正文重试次数(1.9.2):进报告与机读记录,让「重试过」和「一次写成」分得开。 */
390
+ retries,
324
391
  truncated,
325
392
  toolsUsed: allToolsUsed,
326
393
  touched: runner.touchedFiles(),