dsh-gokifu-compact 1.11.13 → 1.11.15

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/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # 更新记录
2
2
 
3
+ ## 1.11.15 — 2026-10-31
4
+
5
+ ### P2-2 · fold 变成可观测的:每一次都能查到「走了哪条路由、多久、花了多少 token」
6
+
7
+ **问题**:fold 只有 `count` 和最后一次的 mode —— **想回答「上次压缩为什么这么慢 / 用的是哪个模型 /
8
+ 是不是降级了」只能翻会话日志**,而日志会滚掉。
9
+
10
+ **改法**
11
+
12
+ - `foldMark` 追加四个字段(外加 `foldCount`):
13
+ | 字段 | 含义 |
14
+ |---|---|
15
+ | **`route`** | `provider/model` —— **无条件记录**(有的 provider 不回 `usage`,但路由永远可得;旧写法把 route 写在 `usage` 判断里,缺 usage 就丢) |
16
+ | **`fallback`** | 本轮摘要模式(`rules` / `null`)—— 与 S3 的降级标记同源 |
17
+ | **`ms`** | `summary + commit + flush` 的**端到端**耗时 |
18
+ | **`usage`** | 真实 usage 快照(provider 没回就是空) |
19
+ - **裁传统一**:`FOLD_MARKS_KEEP = 20`,**常规 fold 与 tier-2 共用同一个上限**(旧版 tier-2 单独用 `tier2After×2`,
20
+ 两条路径各裁各的 ⇒ 保留条数不可预测)。要改只改这一个常量。
21
+ - **`doctor` 新增「最近 5 次 fold」**:跨会话倒序 5 条;旧图缺字段显示 **「—」**(不猜);tier-2 记录单独标注。
22
+
23
+ **测试 +4(542 → 546)**:foldMark 四字段 / 降级时 `fallback=rules` / 20 条裁剪 / doctor 的 5 条与「—」。
24
+ 变异:不写四字段 / 去掉裁剪 / doctor 不显示 —— 三条逐条红。
25
+
26
+ ### S11 · 文档状态三处与事实对齐(代码零改动)
27
+
28
+ 审计 §八 R9 指出三处文档与事实相反:
29
+
30
+ | 处 | 修复 |
31
+ |---|---|
32
+ | `README` 版本行 | 曾写 `1.7.1` ⇒ 现**每次发版自动同步**(`README:3` 与 `package.json` 一致,由 `check-maintenance` 与发布门禁共同盯着) |
33
+ | `docs/计划表.md` | 首行标注 **「已归档」**,并注明「2026-10-29 起唯一持续任务表改为仓库根的 `计划表.md`」 |
34
+ | `HANDOFF.md` 顶部 | 日期 `2026-10-28` → **`2026-10-31`**;删掉过期的「最紧急:v1.3.5 修 worldHash P0」三条;新增**最新状态表**(版本 / tag / npm shasum / 单测数 / verify / check / **live 仍 1.11.2、落后多版** / 未发的 P2-2 / **P6-C 真实 apply 仍欠**);旧 1.9.1 块降级为「历史状态 …… 留作时间线」 |
35
+
36
+ **diff 核对**:与备份比 **−5 行 / +19 行**,**尾部 6 行完全一致**(旧内容一字未删)。
37
+
38
+ ### 验证
39
+
40
+ - 全量:**546 passed / 0 skipped / 0 failed**(**45 个测试文件**;542 + 4)
41
+ - `engine-harness` **43/0** · `tier2` **7/0** · `ledger` **31/0** · `release-hardening` **22/0**
42
+ - `bench 2000`:**351 tokens**(与基线一致)
43
+ - `npm pack --dry-run --json`:**75 文件**(数量不变)
44
+ - **`verify-install`:14/16** —— ⚠ **不是本版引入的缺陷**,是 **live profile 的环境漂移**(见下)
45
+
46
+ ### 未验证 / 已知问题
47
+
48
+ - **`verify-install` 14/16**:这是**本机 live profile 与当前代码的漂移**,与 1.11.15 的内容无关 ——
49
+ **没有动 live**(未改 `profiles/*/cordis.patch.yml`、未重装)。如实记录:这版发布时该门禁是红的。
50
+ - P2-2 的四个字段**只在夹具与引擎级验证**;**没有在真机上让一次真实 fold 落后再从 doctor 读回**去确认显示正确。
51
+ - `ms` 是**端到端墙钟**,包含 `flush` 的磁盘时间 ⇒ 与机器负载相关,**不能当模型的推理耗时**。
52
+ - S11 是文档项,**代码零改动**;`docs/计划表.md` 里更早的历史内容未逐条复核。
53
+
54
+ ## 1.11.14 — 2026-10-31
55
+
56
+ ### S3 · 降级要看得见:规则摘要会在热清单里明说「这次没有模型润色」
57
+
58
+ **问题**:模型调用失败时 `summarize` 会退到**规则摘要**(纯模板拼装)。这一步**静默发生** ——
59
+ 用户只看到「压缩过了」,不知道这次的摘要**没有经过模型**,遇到摘要质量差时会以为插件坏了。
60
+
61
+ **改法**
62
+
63
+ - `summarize` 的**四条路径**(含 auto 与 manual)都写两个字段到图上:
64
+ **`lastFoldMode`**(`"rules"` / `"hybrid"` / `"llm"`)与 **`lastFoldDegraded`**(同一事实的布尔别名,
65
+ 热清单/诊断读哪个都行)。**写失败绝不影响摘要本身**。
66
+ - 热清单在 `mode === "rules"` 时固定显示一行:**`⚠ 上次摘要是规则生成(无模型润色)`**;
67
+ `hybrid` 与旧图(没有这个字段)**不显示** —— 不制造假警报。
68
+ - 这一行**计入 `prePathChars`**,**500 token 预算仍然成立**。
69
+
70
+ ### S4 · `minFold` 按窗口缩放:小窗口不再被 8000 卡住
71
+
72
+ **问题**:`minFoldTokens` 原来是固定 **8000**。窗口小的模型(例如 32K)永远够不到这个门槛 ⇒
73
+ **永远不 fold**,历史一直堆到溢出为止。
74
+
75
+ - `resolveSpec` 返回 **`minFoldTokens = max(8000, 窗口 × 0.08)`**,`selectRange` 用它;
76
+ **overflow 分支回落配置值**(溢出自救不该被这个下限挡住)
77
+ - **显式配置优先**:`8000` 只作为**默认哨兵**(注释里写明了这点),用户写了值就按用户的来
78
+
79
+ **测试 +9(533 → 542)**:`ledger` S3-1/S3-2(提示行出现与不出现)、
80
+ `engine-harness` S3-3…S3-5(四条路径写标记 / 旧图不显示 / 预算仍成立)+ **S4-1…S4-4**
81
+ (缩放公式 / 显式配置优先 / overflow 回落 / 边界)、`manual-compact` MC-4 补 manual 标记。
82
+ 变异:删提示行 / rules 不写标记 / 取消缩放 —— 三条逐条红。
83
+
84
+ ### 验证
85
+
86
+ - 全量:**542 passed / 0 skipped / 0 failed**(**45 个测试文件**;533 + 9)
87
+ - `engine-harness` **39/0** · `ledger` **31/0** · `release-hardening` 22/0
88
+ - `verify-install` **16/16**
89
+ - `bench 2000`:**351 tokens**(与基线一致)
90
+ - `npm pack --dry-run --json`:**75 文件**(数量不变)
91
+
92
+ **未验证**
93
+
94
+ - S3 的提示行只在**夹具与引擎级**验证过「rules 才出现」;**没有在真机上制造一次真实的模型失败**去确认它在真实热清单里的位置与可读性。
95
+ - S4 的缩放只经单测覆盖;**没有在 32K 这类小窗口的真实会话里观察过 fold 频次变化**。
96
+ - 「500 预算仍成立」是**按 token 估算**的(夹具上的 prePathChars 统计),不是真机计费对账。
97
+
3
98
  ## 1.11.13 — 2026-10-31
4
99
 
5
100
  ### ① 手动压缩失败**说人话**(新增房间 R16 `manual-compact`)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-GOkifu-compact
2
2
 
3
- > 当前版本:**`1.11.13`** · npm [`dsh-gokifu-compact`](https://www.npmjs.com/package/dsh-gokifu-compact) · [GitHub Releases](https://github.com/MYCF711/dsh-GOkifu-compact/releases) · 更新记录见 [CHANGELOG.md](CHANGELOG.md)
3
+ > 当前版本:**`1.11.15`** · npm [`dsh-gokifu-compact`](https://www.npmjs.com/package/dsh-gokifu-compact) · [GitHub Releases](https://github.com/MYCF711/dsh-GOkifu-compact/releases) · 更新记录见 [CHANGELOG.md](CHANGELOG.md)
4
4
 
5
5
  **DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有 351 tokens 的热清单」。**
6
6
 
@@ -8,7 +8,7 @@
8
8
  热清单 351 tokens (2000 边合成夹具 · L5 降级档 · 2026-10-30/10-31 两次实测同值;旧版全量 28,800)
9
9
  失败保留 8 / 8 (历史口径 †;官方 8 段模板 4.67 / 8)
10
10
  清单开销 未复现/待测 (原载 0.2% = 历史单会话 v1.3.1 后实测;无复跑命令、无对照组;源 evidence/真实计费核对.md)
11
- 验证 533 项单测(0 skipped) · 真实包 8/8 † · 真实进程 fold 3~6/6 †
11
+ 验证 546 项单测(0 skipped) · 真实包 8/8 † · 真实进程 fold 3~6/6 †
12
12
  ```
13
13
 
14
14
  GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gokifu-compact",
3
- "version": "1.11.13",
3
+ "version": "1.11.15",
4
4
  "description": "DSH context-compression backend with a go game record (kifu) DAG: node = position, edge = attempted move + result. Incremental fold (each span summarized once), failure memory that survives compaction, transposition detection (chess-engine style), and a tiny hot ledger rebuilt every step.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -203,6 +203,8 @@ let objectHash = 0;
203
203
  let indexStale = 0;
204
204
  let newestFlush = 0;
205
205
  let lastFold = null;
206
+ /** ★ P2-2(task-192):跨会话收集 foldMarks(带 session 名),供「最近 5 次 fold」展示。 */
207
+ const recentFoldMarks = [];
206
208
  let todayFolds = 0;
207
209
  let todayShadow = 0;
208
210
  let todaySessions = 0;
@@ -240,6 +242,7 @@ if (nodeFs.existsSync(chessRoot)) {
240
242
  if (node.worldHash && typeof node.worldHash === "object") objectHash += 1;
241
243
  }
242
244
  const marks = g.foldMarks ?? [];
245
+ for (const mark of marks) recentFoldMarks.push({ ...mark, session: d.name });
243
246
  const todayMarks = marks.filter((m) => (m.at ?? 0) >= today0);
244
247
  if (todayMarks.length > 0) todaySessions += 1;
245
248
  todayFolds += todayMarks.length;
@@ -288,6 +291,31 @@ add(
288
291
  ` → 摘要输入 ${cost.inputTokens ?? "?"} / 输出 ${cost.outputTokens ?? "?"} tok` +
289
292
  `(${cost.provider ?? "?"}/${cost.model ?? "?"}${cost.source ? ",来自" + cost.source : `,fold #${cost.foldCount}`},${cost.session.slice(0, 24)})`,
290
293
  );
294
+ /* ★ P2-2(task-192):最近 5 次 fold 的 route / fallback / ms / usage。
295
+ 旧图的 foldMark 没有这些字段 ⇒ 明确写「—」(不许用 0/undefined 冒充数据)。
296
+ tier-2 的合并记录不是 fold(没有 route/usage),也按「—」显示。 */
297
+ {
298
+ const fmt = (value) => (value === null || value === undefined || value === "" ? "—" : String(value));
299
+ const sorted = recentFoldMarks
300
+ .filter((m) => typeof m.at === "number")
301
+ .sort((a, b) => b.at - a.at)
302
+ .slice(0, 5);
303
+ if (sorted.length === 0) {
304
+ add("最近 5 次 fold(route/fallback/ms/usage)", "warn", "没有任何 fold 记录");
305
+ } else {
306
+ const lines = sorted.map((m) => {
307
+ const when = new Date(m.at).toISOString();
308
+ const route = m.route ? `${fmt(m.route.provider)}/${fmt(m.route.model)}` : "—";
309
+ const usage = m.usage ? `in ${fmt(m.usage.inputTokens)} / out ${fmt(m.usage.outputTokens)}` : "—";
310
+ const shadow = fmt(m.shadowedTokens);
311
+ const ms = fmt(m.ms);
312
+ const fb = fmt(m.fallback);
313
+ const kind = m.tier2 === true ? "tier-2 " : "";
314
+ return `${when} ${kind}#${fmt(m.foldCount)} 遮蔽 ${shadow} tok · 路由 ${route} · fallback ${fb} · ${ms} ms · ${usage}(${String(m.session).slice(0, 24)})`;
315
+ });
316
+ add("最近 5 次 fold(route/fallback/ms/usage)", "ok", lines.join("\n"));
317
+ }
318
+ }
291
319
  add("最近一次落盘", newestFlush > 0 ? "ok" : "warn", newestFlush ? new Date(newestFlush).toISOString() : "没有任何棋谱");
292
320
 
293
321
  /* ── 5. 热清单预算(fallback 仪表盘,P7 / task-58) ──
package/src/engine.mjs CHANGED
@@ -35,6 +35,11 @@ import { annotateManualCompactError } from "./rooms/manual-compact.mjs";
35
35
 
36
36
  const BASE_KEYS = new Set(BASE_KEY_LIST);
37
37
 
38
+ /** ★ P2-2(task-192):foldMarks 只留最近这么多条(可观测用,不是审计日志)。
39
+ * 这是一条**统一规则**:常规 fold 与 tier-2 合并共用同一个上限(旧版 tier-2 单独用
40
+ * `tier2After×2`,两条路径各裁各的 ⇒ 行为不可预测)。要改只改这一个常量。 */
41
+ const FOLD_MARKS_KEEP = 20;
42
+
38
43
  /** 热清单标记档位(ledger.mjs 的 `markers` 选项)。 */
39
44
 
40
45
  /* ───────────────── P7:fallback 仪表盘(engine 侧) ───────────────── */
@@ -331,6 +336,22 @@ export default class ChessCompactionEngine extends BasicCompactionEngine {
331
336
  }
332
337
  /* ────────────────────────── 摘要(唯一官方钩子) ────────────────────────── */
333
338
 
339
+ /**
340
+ * ★ S3(task-186):把「本次 fold 的摘要来源」写进图(auto 与 manual 都经过 summarize)。
341
+ * `lastFoldMode` = "rules" | "hybrid" | "llm";`lastFoldDegraded` 是同一个事实的布尔别名
342
+ * (热清单/诊断读哪个都行)。写失败绝不影响摘要本身。
343
+ */
344
+ #markFoldMode(g, mode) {
345
+ try {
346
+ if (g === null || typeof g !== "object") return;
347
+ this._lastSummaryMode = mode;
348
+ g.lastFoldMode = mode;
349
+ g.lastFoldDegraded = mode === "rules";
350
+ } catch {
351
+ /* 图不可写时不打断摘要 */
352
+ }
353
+ }
354
+
334
355
  async summarize(input, agent, signal) {
335
356
  const session = agent.session;
336
357
  const g = this.#graph(session);
@@ -352,11 +373,17 @@ export default class ChessCompactionEngine extends BasicCompactionEngine {
352
373
  model: routed?.model ?? "rules",
353
374
  });
354
375
 
355
- if (mode === "rules") return ruleResult();
376
+ if (mode === "rules") {
377
+ this.#markFoldMode(g, "rules");
378
+ return ruleResult();
379
+ }
356
380
 
357
381
  const target = summarizeTarget(this.chess, session, agent);
358
382
  if (target === undefined) {
359
- if (mode === "hybrid") return ruleResult();
383
+ if (mode === "hybrid") {
384
+ this.#markFoldMode(g, "rules");
385
+ return ruleResult();
386
+ }
360
387
  throw new Error("chess-context: no provider/model available for summarization");
361
388
  }
362
389
 
@@ -431,6 +458,7 @@ export default class ChessCompactionEngine extends BasicCompactionEngine {
431
458
  this.ctx.logger?.warn?.(
432
459
  `chess-context: 摘要模型全部不可用(${String(error?.message ?? error)})——退回规则摘要(零模型调用),保证 fold 能落地`,
433
460
  );
461
+ this.#markFoldMode(g, "rules");
434
462
  return {
435
463
  summary: [{ type: "text", text: ruleSummary(g, this.#activeRange()) }],
436
464
  rawOutput: undefined,
@@ -457,11 +485,12 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
457
485
  }
458
486
  // 记账:这次摘要调用了多少 token(provider 真实 usage)。#commit 会把它写进 graph.lastFoldUsage,
459
487
  // doctor / 计费对账直接读,不用再翻会话日志里的 compaction/summary 事件。
460
- if (assembler.usage !== undefined) {
461
- this._lastSummaryUsage = assembler.usage;
462
- this._lastSummaryProvider = used.provider;
463
- this._lastSummaryModel = used.model;
464
- }
488
+ // ★ P2-2(task-192):**路由无条件记录**(有的 provider 不回 usage,但 route 永远可得);
489
+ // usage 只在真返回时记 —— foldMark.route 因此不会因为缺 usage 变成 null。
490
+ this._lastSummaryProvider = used.provider;
491
+ this._lastSummaryModel = used.model;
492
+ if (assembler.usage !== undefined) this._lastSummaryUsage = assembler.usage;
493
+ this.#markFoldMode(g, mode);
465
494
  return {
466
495
  summary,
467
496
  rawOutput: raw,
@@ -584,6 +613,8 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
584
613
  return null;
585
614
  }
586
615
 
616
+ // ★ P2-2(task-192):fold 端到端耗时(summary + commit + flush)—— 写进 foldMarks.ms
617
+ const foldStartedAt = Date.now();
587
618
  this._chessActiveRange = { start: range.start, end: range.end };
588
619
  let result;
589
620
  try {
@@ -612,6 +643,13 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
612
643
  // 记「checkpoint 消息的 seq」,不是被遮蔽区间的起点 —— 后者 fold 之后已不在 surface 上,
613
644
  // 任何读这个字段做判断的地方都会拿到一个不存在的 seq(2026-10-29 实机:值一直是 9)。
614
645
  g.checkpointSeq = checkpointSeqs(session).at(-1) ?? null;
646
+ // ★ P2-2(task-192):每次 fold 的可观测字段 —— 路由、是否降级、耗时、真实 usage。
647
+ // 旧图里的 foldMark 没有这些字段 ⇒ doctor 显示「—」(向后兼容)。
648
+ // **快照必须在下面清空 _lastSummary* 之前取**(否则 route 永远是 null —— 踩过)。
649
+ const summaryMode = this._lastSummaryMode ?? null;
650
+ const summaryUsage = this._lastSummaryUsage ?? null;
651
+ const summaryRoute = { provider: this._lastSummaryProvider ?? null, model: this._lastSummaryModel ?? null };
652
+ this._lastSummaryMode = undefined;
615
653
  if (this._lastSummaryUsage !== undefined) {
616
654
  g.lastFoldUsage = {
617
655
  ...this._lastSummaryUsage,
@@ -629,7 +667,14 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
629
667
  seqEnd: range.end,
630
668
  shadowedTokens: range.shadowedTokens,
631
669
  totalAfter: after.totalTokens,
670
+ foldCount: g.foldCount,
671
+ route: summaryRoute,
672
+ fallback: summaryMode === "rules" ? "rules" : null,
673
+ ms: Math.max(0, Date.now() - foldStartedAt),
674
+ usage: summaryUsage === null ? null : { ...summaryUsage },
632
675
  });
676
+ // ★ P2-2:只保留最近 20 条(旧版无限增长 —— 2000 边/长期会话里 foldMarks 是纯膨胀)
677
+ if (g.foldMarks.length > FOLD_MARKS_KEEP) g.foldMarks = g.foldMarks.slice(-FOLD_MARKS_KEEP);
633
678
  // ★ 2026-10-29 修复:这里原来传的是 `{ md: true }`,而 `#flush(session, { tree = false })`
634
679
  // 只认 `tree` —— 参数名不匹配 ⇒ fold 后**从不**重写 `nodes/*.md`,
635
680
  // 压缩后的节点详情停在第一次落盘的样子(Kimi 评估实测发现)。
@@ -660,7 +705,8 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
660
705
  pricedNodes: measurement.nodes,
661
706
  eventAt: (seq) => session.eventAt(seq),
662
707
  retainTokens: spec.retainTokens,
663
- minFoldTokens: this.chess.minFoldTokens,
708
+ // ★ S4:用 resolveSpec 算出的窗口缩放值;overflow 路径没有 spec.minFoldTokens ⇒ 回落配置值
709
+ minFoldTokens: spec.minFoldTokens ?? this.chess.minFoldTokens,
664
710
  overflow,
665
711
  balancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
666
712
  balancedAfter: (seq) => toolPairingBalancedAfter(session, seq),
@@ -706,7 +752,7 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
706
752
  if (result !== null && result !== undefined) {
707
753
  const g = this.#graph(session);
708
754
  g.foldMarks.push({ at: Date.now(), seqStart: start, seqEnd: end, tier2: true });
709
- if (g.foldMarks.length > this.chess.tier2After * 2) g.foldMarks = g.foldMarks.slice(-this.chess.tier2After);
755
+ if (g.foldMarks.length > FOLD_MARKS_KEEP) g.foldMarks = g.foldMarks.slice(-FOLD_MARKS_KEEP);
710
756
  this.#flush(session);
711
757
  this.ctx.logger?.info?.(`chess-context: tier-2 合并最老的两个 checkpoint(seq ${start}–${end})`);
712
758
  }
@@ -748,6 +794,7 @@ function resolvePolicy(chess, target) {
748
794
  headroomTokens: chess.headroomTokens,
749
795
  retainRatio: chess.retainRatio,
750
796
  retainTokens: chess.retainTokens,
797
+ minFoldTokens: chess.minFoldTokens,
751
798
  };
752
799
  }
753
800
 
@@ -778,7 +825,15 @@ function resolveSpec(policy, contextWindow, reservedCompletionTokens, firstFold
778
825
  if (retainTokens >= thresholdTokens) {
779
826
  throw new Error(`chess-context: retainTokens (${retainTokens}) must be less than threshold (${thresholdTokens})`);
780
827
  }
781
- return { thresholdTokens, retainTokens, contextWindow };
828
+ // ★ S4(task-186):minFoldTokens 默认按窗口缩放 —— `max(8000, 窗口×0.08)`。
829
+ // 旧默认恒 8000:小窗口下「1.2 步就够 fold」的夹具会被反复触发(每步都在压)。
830
+ // 显式配置优先:`minFoldTokens` 只要不是默认哨兵值 8000(preset 模板总写 8000 ⇒ 无法区分
831
+ // 「没配」与「配了默认值」),就按用户给的值走。
832
+ const DEFAULT_MIN_FOLD = 8000;
833
+ const configuredMinFold = Number(policy.minFoldTokens);
834
+ const scaledMinFold = Math.max(DEFAULT_MIN_FOLD, Math.floor(contextWindow * 0.08));
835
+ const minFoldTokens = Number.isFinite(configuredMinFold) && configuredMinFold !== DEFAULT_MIN_FOLD ? configuredMinFold : scaledMinFold;
836
+ return { thresholdTokens, retainTokens, contextWindow, minFoldTokens };
782
837
  }
783
838
 
784
839
  function summarizeTarget(chess, session, agent) {
@@ -599,6 +599,10 @@ export function renderLedger(g, { maxTokens = 500, tailFailures = 20, markers =
599
599
  if (level === 4 && doomed(4) && doomed(5)) continue;
600
600
  const lines = [];
601
601
  lines.push("<chess-ledger>");
602
+ // ★ S3 降级可见(task-186):上一次 fold 的摘要是**规则生成**时固定打一行提示 ——
603
+ // 否则模型会把没有润色的规则事实当成正常摘要读。旧图没有这个字段 ⇒ 不显示(向后兼容)。
604
+ // 两个字段都认:`lastFoldMode`(规范字段)与 `lastFoldDegraded`(同时写的布尔别名)。
605
+ if (g.lastFoldMode === "rules" || g.lastFoldDegraded === true) lines.push("⚠ 上次摘要是规则生成(无模型润色)");
602
606
  // ★ C-17 rev6:L3+ 的 root 裁剪从 180 收到 168 —— 给「最小路径行」预留 12 字符。
603
607
  // 不这么做时,1.9.1 在 L3 恰好用满预算的会话(如 5b38b355 HEAD=497/1490 字符)
604
608
  // 只要多一行 `# path: n185` 就 501,成为唯一一个「新增超预算」。路径信息优先于