dsh-ledger-memory 0.1.1 → 0.2.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
@@ -69,18 +69,40 @@ dsh plugin add dsh-ledger-memory
69
69
  /ledger light 轻量压缩
70
70
  /ledger log 查活动日志
71
71
  /ledger handoff 写一份交接单
72
+ /ledger mode 看/切换压缩模式(steady / surgical)
72
73
  ```
73
74
 
74
75
  ### 设置面板
75
76
 
76
- **设置 → 项目台账(台账 / 压缩 / 日志)**,可调十二项:回合末卡片开关、高保真压缩水位、
77
- 增量压缩的涨幅与保留量、活动日志五档开关、活跃台账行数上限、变更登记条数、水位提醒档位等。
77
+ **设置 → 项目台账(台账 / 压缩 / 日志)**,可调十三项:**压缩模式**、回合末卡片开关、
78
+ 高保真压缩水位、增量压缩的涨幅与保留量、活动日志五档开关、活跃台账行数上限、
79
+ 变更登记条数、水位提醒档位等。
78
80
 
79
- > 一条要知道的语义:`maxActiveLines` / `changeRegisterMax` / `logLevels` **以每个项目
80
- > 自己清单里的值为准**(日志是只追加的证据,全局设置改不动它)。
81
- > 面板里这三项是**新项目初始化时写进去的默认值**。
81
+ > 一条要知道的语义:`mode` / `maxActiveLines` / `changeRegisterMax` / `logLevels`
82
+ > **以每个项目自己清单里的值为准**。面板里这几项是**新项目初始化时写进去的默认值**。
82
83
 
83
- ## 十二个工具
84
+ ## 两个压缩模式
85
+
86
+ 压缩方式可以按项目选。默认是「正常推进」,两者共用同一套压缩,区别只在压缩前后多做或少做什么:
87
+
88
+ | | **正常推进**(`steady`,默认) | **反复追究**(`surgical`) |
89
+ |---|---|---|
90
+ | 压缩时怎么切 | **按位置**:只压最旧一段,更早的一刀切 | **按内容分流**,再压 |
91
+ | 谁做判断 | 不用判断(省一次模型输出) | AI 做一次分流 |
92
+ | 适合 | 一路向前干活 | 要反复回看约束与假设的活 |
93
+
94
+ **「反复追究」怎么工作**:水位到点时会先让 AI 调 `ledger_triage` 把这一段分成三个桶 ——
95
+
96
+ - **留(`keep`)**:下一轮不知道就会做错的东西(当前状态、未验证的假设、你定过的约束)。压缩后**逐字注回**。
97
+ - **落(`stash`)**:以后可能要查、但不该占上下文的(判断与理由、走不通的路、关键文件位置)。
98
+ 写进项目的 `_stash/`,**永不注入**,需要时自己 grep。
99
+ - **丢(`drop`)**:已被取代的,只进日志。
100
+
101
+ `keep` 有条数上限——**留得太多等于没压**,超了会按 AI 给的顺序截断并如实报出截掉几条。
102
+
103
+ 切换:设置面板里选,或 `/ledger mode steady` / `/ledger mode surgical`(只影响当前项目)。
104
+
105
+ ## 十三个工具
84
106
 
85
107
  AI 在对话里调用,你也可以让它调用:
86
108
 
@@ -96,6 +118,7 @@ AI 在对话里调用,你也可以让它调用:
96
118
  | `ledger_log` | 查活动日志(五档,**从不注入上下文**) |
97
119
  | `ledger_log_write` | 追加一条回合小结 |
98
120
  | `ledger_handoff` | 写交接单,供下一个会话接手 |
121
+ | `ledger_triage` | 把阶段性产出分成「留 / 落 / 丢」三个桶(「反复追究」模式用) |
99
122
  | `ledger_compact` | 请求一次高保真压缩 |
100
123
  | `ledger_checkpoint` | 把这一刻的选择交给用户(记台账 / 记+压缩 / 记+交接 / 跳过) |
101
124
 
package/lib/client.js CHANGED
@@ -266,6 +266,22 @@ window.__ModuleLoader__.load({
266
266
  border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px;
267
267
  }
268
268
  .pl-settings__mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
269
+ /* 二选一(模式):把两个选项的**取舍**都摆在眼前,不下拉折叠 */
270
+ .pl-settings__choices { display: flex; flex-direction: column; gap: 6px; width: 100%; }
271
+ .pl-settings__choice {
272
+ display: flex; gap: 8px; align-items: flex-start; cursor: pointer;
273
+ padding: 8px 10px; border-radius: 8px;
274
+ border: 1px solid var(--dsw-alias-border-l1);
275
+ background: var(--dsw-alias-bg-layer-1);
276
+ }
277
+ .pl-settings__choice--on {
278
+ border-color: var(--dsw-alias-state-success-primary);
279
+ background: var(--dsw-alias-bg-layer-2);
280
+ }
281
+ .pl-settings__choice input { margin-top: 2px; flex: none; }
282
+ .pl-settings__choice-body { display: flex; flex-direction: column; gap: 2px; }
283
+ .pl-settings__choice-label { font-size: 13px; color: var(--dsw-alias-label-primary); }
284
+ .pl-settings__choice-hint { font-size: 12px; line-height: 1.55; color: var(--dsw-alias-label-secondary); }
269
285
  `;
270
286
 
271
287
  function installCss(ctx) {
@@ -334,6 +350,51 @@ window.__ModuleLoader__.load({
334
350
  ]);
335
351
  }
336
352
 
353
+ /**
354
+ * 二选一(含以上)行 —— 每个选项一个单选按钮 + 名字 + 说明。
355
+ *
356
+ * ★ 为什么不用下拉框:这一项的两个选项**各有各的取舍**(省一次模型输出 vs
357
+ * 按内容分流),用户需要**看见取舍**才能选对。下拉框把说明藏起来了。
358
+ * ★ 选项来自宿主(`spec.options` 是**已展开的数组** —— 宿主那边是取值函数,
359
+ * 为躲 TDZ;见宿主 `SETTING_SPECS` 的注释)。客户端**不认识任何模式名**,
360
+ * 只按 `value`/`label`/`hint` 渲染 ⇒ 以后加第三个模式**不用改本文件**。
361
+ */
362
+ function ChoiceRow(props) {
363
+ const { spec, value, onChange, disabled } = props;
364
+ const opts = Array.isArray(spec.options) ? spec.options : [];
365
+ return h(Row, { label: spec.label, hint: spec.hint, stack: true }, [
366
+ h(
367
+ "div",
368
+ { key: "g", className: "pl-settings__choices" },
369
+ opts.map((o) =>
370
+ h(
371
+ "label",
372
+ {
373
+ key: String(o.value),
374
+ className:
375
+ "pl-settings__choice" + (String(o.value) === String(value) ? " pl-settings__choice--on" : ""),
376
+ },
377
+ [
378
+ h("input", {
379
+ key: "r",
380
+ type: "radio",
381
+ name: spec.key,
382
+ value: o.value,
383
+ checked: String(o.value) === String(value),
384
+ disabled: disabled === true,
385
+ onChange: () => onChange(o.value),
386
+ }),
387
+ h("span", { key: "t", className: "pl-settings__choice-body" }, [
388
+ h("span", { key: "l", className: "pl-settings__choice-label" }, o.label || String(o.value)),
389
+ o.hint ? h("span", { key: "h", className: "pl-settings__choice-hint" }, o.hint) : null,
390
+ ]),
391
+ ]
392
+ )
393
+ )
394
+ ),
395
+ ]);
396
+ }
397
+
337
398
  /** 档位行(文本框,逗号分隔)。 */
338
399
  function TiersRow(props) {
339
400
  const { spec, value, onChange, disabled } = props;
@@ -394,6 +455,7 @@ window.__ModuleLoader__.load({
394
455
  const value = draft[spec.key];
395
456
  const set = (v) => setDraft(Object.assign({}, draft, { [spec.key]: v }));
396
457
  if (spec.kind === "boolean") return h(BoolRow, { key: spec.key, spec, value, onChange: set, disabled });
458
+ if (spec.kind === "choice") return h(ChoiceRow, { key: spec.key, spec, value, onChange: set, disabled });
397
459
  if (spec.kind === "number") return h(NumberRow, { key: spec.key, spec, value, onChange: set, disabled });
398
460
  if (spec.kind === "tiers") return h(TiersRow, { key: spec.key, spec, value, onChange: set, disabled });
399
461
  if (spec.kind === "levels") return h(LevelsRow, { key: spec.key, spec, value, onChange: set, disabled });
package/lib/index.js CHANGED
@@ -142,6 +142,8 @@ export const inject = ["tools"];
142
142
  */
143
143
  function settingDefaults() {
144
144
  return {
145
+ // ★★ 两个模式(2026-10-10,用户拍板)。见 `MODE_*` 常量与 `modeOf` 的注释。
146
+ mode: MODE_STEADY, // 默认「正常推进」= 与今天的行为逐条一致
145
147
  turnEndOffer: false, // 回合末四选一卡片(2026-10-09 用户决定默认关)
146
148
  highFidelityCompact: true, // 水位到点跑我们自己的高保真压缩
147
149
  // 触发水位:**复用那个常量**,不抄字面量(早于内核的 0.8)
@@ -171,6 +173,33 @@ function settingDefaults() {
171
173
  * `kind` 决定面板画什么控件;`scope` 说明它是否被项目清单覆盖。
172
174
  */
173
175
  const SETTING_SPECS = [
176
+ {
177
+ key: "mode",
178
+ kind: "choice",
179
+ label: "压缩模式",
180
+ // ★★ 这里**必须用取值函数**,不能直接写 `MODE_STEADY`:
181
+ // 本数组是**顶层 const**,模块初始化时当场求值;而那两个常量定义在文件更后面
182
+ // ⇒ 直接引用会 `ReferenceError: Cannot access 'MODE_STEADY' before initialization`
183
+ // (实测撞到,`import` 当场失败 ⇒ 内核起不来)。
184
+ // ★ 这与 `settingDefaults()` 为什么是函数**同一个坑**,见它上面的长注释。
185
+ options: () => [
186
+ {
187
+ value: MODE_STEADY,
188
+ label: "正常推进",
189
+ hint: "按位置压缩:只压最旧一段、保留最近一段。省一次模型输出,适合一路向前干活。",
190
+ },
191
+ {
192
+ value: MODE_SURGICAL,
193
+ label: "反复追究",
194
+ hint:
195
+ "压缩前先让 AI 把这一段**分三个桶**:留在上下文 / 落进项目按需查 / 丢掉。" +
196
+ "适合要反复回看约束与假设的活。代价是每次多一段模型输出。",
197
+ },
198
+ ],
199
+ hint:
200
+ "★ 这一项**项目清单优先**:如果项目的 .dsh-ledger.json 里登记了 mode,以那个为准;" +
201
+ "这里的值只是**新项目**登记的默认值。也可以用 `/ledger mode` 随时切换。",
202
+ },
174
203
  {
175
204
  key: "turnEndOffer",
176
205
  kind: "boolean",
@@ -315,6 +344,16 @@ function normalizeSettings(raw) {
315
344
  value.logLevels = parseLevels(v, settingDefaults().logLevels);
316
345
  continue;
317
346
  }
347
+ if (k === "mode") {
348
+ // ★ 只接受登记过的模式名。打错一个字就**丢弃并如实报告**,
349
+ // 而不是静默退到默认 —— 否则用户以为切到了"反复追究",实际还在"正常推进"。
350
+ if (typeof v !== "string" || !MODES.includes(v)) {
351
+ dropped.push(k);
352
+ continue;
353
+ }
354
+ value.mode = v;
355
+ continue;
356
+ }
318
357
  if (k === "pressureTiers") {
319
358
  if (!Array.isArray(v)) {
320
359
  dropped.push(k);
@@ -472,6 +511,44 @@ const MANIFEST_REL = ".dsh-ledger.json";
472
511
  /** 注入消息的 source.kind。自定义 kind ⇒ 官方 pre-step 的清理逻辑认不出它、动不了它。 */
473
512
  const INJECT_KIND = "project-ledger-notes";
474
513
 
514
+ // ══════════════════════════════════════════════════════════════════════════════
515
+ // ★★★ 两个模式(2026-10-10,用户拍板)
516
+ //
517
+ // 用户原话:*"我们把它定义为这个插件的两个模式。因为其实两个模式各有各的优劣性。
518
+ // 正常向前推进的任务就执行现在的这个模式,如果需要反复追究的话,就执行第二种模式。"*
519
+ //
520
+ // 机制上的真实差别(**只有一个**,其余全同):
521
+ //
522
+ // · `steady`(正常推进):压缩时**按位置**切 —— 从末尾往回攒够 `deltaKeepPoints`
523
+ // 点,更早的一刀切掉。**不做内容判断**,省一次模型输出。
524
+ // 代价:位置 ≠ 重要性,最旧那一段里可能正好有这个任务**最早的约束**。
525
+ //
526
+ // · `surgical`(反复追究):压缩**前**先让 AI 对这 20 点做一次**分流**(三个桶):
527
+ // 留(keep) → 下一轮一定要知道的,压缩后**逐字重注入**
528
+ // 落(stash) → 以后可能要查的,写进项目的 `_stash/`,**不占上下文**
529
+ // 丢(drop) → 已被取代的,只进日志
530
+ // 代价:每次多一段模型输出;分错了会把垃圾留在上下文里。
531
+ //
532
+ // ★ 为什么这是"在 A 之上加一层"而不是另起一套:**内核的摘要提示词写死在
533
+ // `dsh-compaction-basic` 里**(实测它的可配置项只有 thresholdRatio / retainRatio /
534
+ // retainTokens / summarization* / maxTokens / modelPolicies —— **没有摘要模板**)。
535
+ // 所以"压缩时保什么内容"我们改不了;能做的是**压缩后把该留的注回去**
536
+ // (胶囊已经在走这条路)。⇒ 两模式共用同一套压缩,差别只在压缩前后多做什么。
537
+ //
538
+ // ★ 默认必须是 `steady`:与今天的行为逐条一致(自检情形 27 有断言钉住默认值)。
539
+ const MODE_STEADY = "steady";
540
+ const MODE_SURGICAL = "surgical";
541
+ const MODES = [MODE_STEADY, MODE_SURGICAL];
542
+
543
+ /** 分流单的桶名(用户说的"划分")。 */
544
+ const TRIAGE_KEEP = "keep";
545
+ const TRIAGE_STASH = "stash";
546
+ const TRIAGE_DROP = "drop";
547
+
548
+ /** `_stash/` 只存桶的目录名(在项目根下)。 */
549
+ const STASH_DIR = "_stash";
550
+
551
+
475
552
  /** 活跃台账行数阈值默认值。 */
476
553
  const DEFAULT_MAX_ACTIVE_LINES = 300;
477
554
 
@@ -622,6 +699,33 @@ function changeRegisterMaxOf(info) {
622
699
  return currentSettings().changeRegisterMax;
623
700
  }
624
701
 
702
+ /**
703
+ * 这个项目用哪个压缩模式 —— **项目清单优先**(与 `maxActiveLines` / `changeRegisterMax`
704
+ * 同一条口径,见 D25「一个事实只能有一个判定出口」)。
705
+ *
706
+ * ★ 为什么模式属于**项目**而不是全局:用户说的是*"正常向前推进的**任务**…需要反复追究的"*
707
+ * —— 同一个仓库里,"把这个特性做完"和"查清这个 bug 为什么只在周二复现"就是两种活。
708
+ * 做成全局开关,就没法"平时推进、查问题时转追究"。
709
+ *
710
+ * ★ 清单里的值**只认登记过的模式名**:写错了就退回默认,并**不静默**——
711
+ * `ledger_status` 会如实报"清单里的 mode 不认识"。
712
+ *
713
+ * @param {object} info `inspect()` 的结果
714
+ * @returns {"steady"|"surgical"} 生效模式
715
+ */
716
+ function modeOf(info) {
717
+ const m = info?.manifest?.mode;
718
+ if (typeof m === "string" && MODES.includes(m)) return m;
719
+ const setting = currentSettings().mode;
720
+ return MODES.includes(setting) ? setting : MODE_STEADY;
721
+ }
722
+
723
+ /** 清单里的 mode 是不是写错了(供 `ledger_status` 如实报告,不静默)。 */
724
+ function modeManifestSuspect(info) {
725
+ const m = info?.manifest?.mode;
726
+ return m !== undefined && !(typeof m === "string" && MODES.includes(m));
727
+ }
728
+
625
729
  /**
626
730
  * 一段台账文本**去掉变更登记区之后**的行数。
627
731
  *
@@ -741,6 +845,31 @@ function bumpRevision(root) {
741
845
  }
742
846
  }
743
847
 
848
+ /**
849
+ * 把「压缩模式」写进**项目清单**(`.dsh-ledger.json`)。
850
+ *
851
+ * ★ 为什么**不**改 `revision`:revision 是**台账内容**的并发保护(见 `bumpRevision`
852
+ * 的注释 —— 它防的是"两个会话各写各的、后者覆盖前者")。模式是**配置**不是内容,
853
+ * 动它不该让别人的 `ledger_write(ifRevision)` 失败。
854
+ * 混在一起的代价是:用户敲一次 `/ledger mode`,另一个会话的台账写入就被拒了。
855
+ *
856
+ * @returns {{ok:boolean, reason?:string}}
857
+ */
858
+ function writeManifestMode(root, info, mode) {
859
+ try {
860
+ if (!MODES.includes(mode)) return { ok: false, reason: `不认识的模式:${String(mode)}` };
861
+ const prev = info?.manifest ?? readManifest(root);
862
+ if (prev === undefined) return { ok: false, reason: "读不到清单" };
863
+ const next = { ...prev, mode, updatedAt: stamp(new Date()) };
864
+ const abs = path.join(root, MANIFEST_REL);
865
+ if (!isUnder(root, abs)) return { ok: false, reason: "清单路径逃出项目根" };
866
+ fs.writeFileSync(abs, JSON.stringify(next, null, 2) + "\n", "utf8");
867
+ return { ok: true };
868
+ } catch (e) {
869
+ return { ok: false, reason: String(e?.message ?? e) };
870
+ }
871
+ }
872
+
744
873
  /**
745
874
  * 「看起来像台账」的保守候选名。
746
875
  * ★ 刻意保守:只认名字里带 ledger / 台账 / status / progress 的。
@@ -1338,6 +1467,104 @@ function persistCapsule(root, info, sid, snap, opts) {
1338
1467
  }
1339
1468
  }
1340
1469
 
1470
+ /**
1471
+ * 把「分流单」里 `落(stash)` 那一桶写进项目的只存区(`_stash/`)。
1472
+ *
1473
+ * ★ 用户原话(2026-10-10):*"有必要查到的就放进项目中…没有必要保留的就归档"*
1474
+ * 以及他对"落"的定位:**以后可能要查、但不该占上下文**。
1475
+ *
1476
+ * ★ 为什么不复用活动日志:日志的硬不变量是「**一行 = 一条**」(解析 / grep / limit
1477
+ * 全建立在它上面),而一条分流项天然是**多行**(判断 + 理由 + 出处)。硬塞进去
1478
+ * 会破坏那个不变量 —— 胶囊当年就踩过这个坑,所以它也是一个独立文件。
1479
+ *
1480
+ * ★ 为什么一个会话一个文件、而不是一条一个文件:
1481
+ * 分流本来就**按次**发生(每次压缩一次),一次会话可能分流几十条。一条一文件
1482
+ * 会把 `_stash/` 撑成几千个小文件;一天一文件又不好按会话回溯。
1483
+ * 一张单子一个文件 = **可 diff、可按会话查、条数可控**。
1484
+ *
1485
+ * ★ 为什么**不需要**启用活动日志(与胶囊不同):
1486
+ * 胶囊是"日志的衍生物",而 `_stash/` 是**用户明确要的中间层**(他拍板选的
1487
+ * "项目里的新文件区")。但**仍然**只在有台账的项目里写 —— 与"没登记就不往
1488
+ * 项目里写任何东西"那条承诺一致。
1489
+ *
1490
+ * @param {string} root 项目根
1491
+ * @param {object} info `inspect(root)` 的结果
1492
+ * @param {string} sid 会话 id
1493
+ * @param {Array<{what:string, why?:string, where?:string}>} items 要"落"的条目
1494
+ * @param {{source?:string, at?:Date, kept?:number, dropped?:number}} [opts]
1495
+ * @returns {{ok:boolean, rel?:string, count?:number, reason?:string}}
1496
+ */
1497
+ function persistStash(root, info, sid, items, opts) {
1498
+ if (!Array.isArray(items) || items.length === 0) {
1499
+ return { ok: false, count: 0, reason: "没有要「落」的条目" };
1500
+ }
1501
+ // ★ 与胶囊同一条承诺:**没有台账就不往项目里写任何东西**。
1502
+ if (info?.activeRel === undefined) {
1503
+ return { ok: false, count: 0, reason: "这个项目还没有台账 ⇒ 按约定不往项目里写文件" };
1504
+ }
1505
+ const now = opts?.at instanceof Date ? opts.at : new Date();
1506
+ const { dir: period } = archiveStamp(now, info?.manifest?.archiveWeekMode);
1507
+ const relDir = `${STASH_DIR}/${period}`;
1508
+ const absDir = path.resolve(root, relDir);
1509
+ if (!isUnder(root, absDir)) return { ok: false, count: 0, reason: "只存目录路径逃出项目根" };
1510
+
1511
+ const tag = tagOfId(sid);
1512
+ const rel = `${relDir}/${capsuleFileStamp(now)}-${tag}.md`;
1513
+ const abs = path.resolve(root, rel);
1514
+ if (!isUnder(root, abs)) return { ok: false, count: 0, reason: "只存文件路径逃出项目根" };
1515
+
1516
+ const src =
1517
+ opts?.source === "auto:delta" ? "自动(增量压缩前)"
1518
+ : opts?.source === "auto:70%" ? "自动(70% 水位压缩前)"
1519
+ : opts?.source === "manual" ? "手动(工具)"
1520
+ : typeof opts?.source === "string" && opts.source !== "" ? String(opts.source)
1521
+ : "未知来源";
1522
+
1523
+ const rows = items
1524
+ .map((it) => {
1525
+ const what = String(it?.what ?? "").trim();
1526
+ if (what === "") return "";
1527
+ const why = String(it?.why ?? "").trim();
1528
+ const where = String(it?.where ?? "").trim();
1529
+ return (
1530
+ `- **${what}**\n` +
1531
+ (why === "" ? "" : ` - 为什么留着:${why}\n`) +
1532
+ (where === "" ? "" : ` - 去哪查:${where}\n`)
1533
+ );
1534
+ })
1535
+ .filter((x) => x !== "")
1536
+ .join("");
1537
+ if (rows === "") return { ok: false, count: 0, reason: "条目全是空的" };
1538
+
1539
+ const body =
1540
+ `# 只存单 · ${stampSec(now)} · [${tag}]\n\n` +
1541
+ `> **来源**:${src}\n` +
1542
+ `> **会话**:\`${sid}\`\n` +
1543
+ `> **这是什么**:压缩时被判为「**以后可能要查、但不该占上下文**」的东西。\n` +
1544
+ "> 它**不参与归档、永不注入上下文** —— 需要时自己 grep 这个目录。\n" +
1545
+ (Number.isFinite(opts?.kept) || Number.isFinite(opts?.dropped)
1546
+ ? `> **同一次分流的其余去向**:留在上下文 ${opts?.kept ?? 0} 条 · 丢掉 ${opts?.dropped ?? 0} 条。\n`
1547
+ : "") +
1548
+ "\n" +
1549
+ rows;
1550
+
1551
+ try {
1552
+ ensureDir(absDir);
1553
+ // ★ `wx` = 永不覆写(记录是证据),与胶囊同一道保证。
1554
+ try {
1555
+ fs.writeFileSync(abs, body, { encoding: "utf8", flag: "wx" });
1556
+ return { ok: true, rel, count: items.length };
1557
+ } catch (e) {
1558
+ if (e?.code !== "EEXIST") throw e;
1559
+ const rel2 = `${relDir}/${capsuleFileStamp(now)}-${tag}-2.md`;
1560
+ fs.writeFileSync(path.resolve(root, rel2), body, { encoding: "utf8", flag: "wx" });
1561
+ return { ok: true, rel: rel2, count: items.length };
1562
+ }
1563
+ } catch (e) {
1564
+ return { ok: false, count: 0, reason: `写只存单失败:${String(e?.message ?? e)}` };
1565
+ }
1566
+ }
1567
+
1341
1568
 
1342
1569
  /**
1343
1570
  * 采集缓冲的落盘延迟(毫秒)。
@@ -2032,7 +2259,13 @@ function deriveSettingsView(ctx) {
2032
2259
  const editor = typeof ctx?.get === "function" ? ctx.get("configEditor") : undefined;
2033
2260
  const out = {
2034
2261
  settings: currentSettings(),
2035
- specs: SETTING_SPECS.map((s) => ({ ...s })),
2262
+ // ★ `options` 是**取值函数**(见 SETTING_SPECS 上的 TDZ 注释):出站给客户端前
2263
+ // 必须**展开成数组**,否则 `{...s}` 会带一个函数过去 —— `JSON.stringify`
2264
+ // 直接把它丢掉,面板上那个选择器就成了空的,而且**不报错**。
2265
+ specs: SETTING_SPECS.map((s) => ({
2266
+ ...s,
2267
+ ...(typeof s.options === "function" ? { options: s.options() } : {}),
2268
+ })),
2036
2269
  defaults: settingDefaults(),
2037
2270
  writable: false,
2038
2271
  };
@@ -2696,11 +2929,26 @@ function renderAfterCompaction(info, capsule, meta) {
2696
2929
  ? "本插件在上下文水位到 **70%** 时主动执行了一次压缩(比内核默认的 80% 早)," +
2697
2930
  "为的是在丢失之前把状态落袋。"
2698
2931
  : "本会话的较早历史已被压缩。";
2932
+ // ★ 「反复追究」模式:压缩**前** AI 自己分出来的 keep 桶,这里逐字注回。
2933
+ // 它与机械胶囊**并列**而不是取代 —— 两者失败模式不同(见 `state.triageKeep` 注释)。
2934
+ const keep = Array.isArray(meta?.keep) ? meta.keep : [];
2935
+ const keepBlock =
2936
+ keep.length === 0
2937
+ ? ""
2938
+ : "---\n\n## ★ 分流单 · 留在上下文的那一份(**压缩前你自己挑的**)\n\n" +
2939
+ "> 这是你在压缩前判为「下一轮不知道就会做错」的东西,**逐字**注回。\n" +
2940
+ "> 其余已按你的划分处理:能查的落进了 `_stash/`,被取代的丢了。\n\n" +
2941
+ keep.map((k, i) => `${i + 1}. ${String(k?.what ?? "").trim()}`).join("\n") +
2942
+ "\n\n";
2699
2943
  return (
2700
2944
  head +
2701
- "\n## 上下文刚被压缩(台账 + 高保真胶囊已重新注入)\n\n" +
2945
+ "\n## 上下文刚被压缩(台账" +
2946
+ (keep.length > 0 ? " + 分流单" : "") +
2947
+ " + 高保真胶囊已重新注入)\n\n" +
2702
2948
  why +
2703
- "\n\n下面是**当前生效的台账**与**逐字胶囊**,请以它们为准接上工作," +
2949
+ "\n\n下面是**当前生效的台账**" +
2950
+ (keep.length > 0 ? "、**你自己挑的分流单**" : "") +
2951
+ "与**逐字胶囊**,请以它们为准接上工作," +
2704
2952
  "**不要**凭记忆复述被压掉的历史。\n\n" +
2705
2953
  // ★★ 需求 ④(D35):**必须**声明"这些是历史素材,不是用户此刻的指令"。
2706
2954
  //
@@ -2719,6 +2967,7 @@ function renderAfterCompaction(info, capsule, meta) {
2719
2967
  "③ 若台账已超行数上限,先 `ledger_archive` 再写;" +
2720
2968
  "④ **若你需要某个文件的确切内容,直接重读磁盘**(胶囊里有路径)—— " +
2721
2969
  "重读是确定性的,比回忆可靠。\n\n" +
2970
+ keepBlock +
2722
2971
  (capsule === undefined
2723
2972
  ? ""
2724
2973
  : `---\n\n## 高保真胶囊(**逐字**,本插件在压缩前整理)\n${capsule}\n---\n\n`) +
@@ -3303,27 +3552,46 @@ function headOf(info) {
3303
3552
  function renderPressureNotice(info, pressure, tier) {
3304
3553
  const pct = Math.round(pressure.ratio * 100);
3305
3554
  const line = Math.round(tier * 100);
3555
+ // ★ 「反复追究」模式:提醒里**多一件事** —— 分流单(用户说的"对输出的东西做划分")。
3556
+ // 用户原话:*"对这 20 输出的工作量进行划分…最终只有 10%"*
3557
+ const surgical = modeOf(info) === MODE_SURGICAL;
3306
3558
  const head =
3307
3559
  headOf(info) +
3308
3560
  `\n## 上下文水位已到 ${line}%(现 ${pct}%)\n\n` +
3309
3561
  `实测:已用 ${pressure.total} tokens / 官方上限 ${pressure.window} tokens。` +
3310
3562
  "官方会在 **80%** 处自动压缩,那时已读进上下文的东西会丢。\n\n";
3311
3563
 
3564
+ // 分流那一步的文案(两种模式共用同一段,避免两处各写一份而分叉)。
3565
+ const triageStep =
3566
+ "3. ★ **做一次分流**(`ledger_triage`)—— 把这一段产出**当场分成三个桶**:\n" +
3567
+ " - `keep` **留在上下文**:下一轮不知道就会做错的东西(当前任务状态、" +
3568
+ "还没验证的假设、**用户定过的约束**)。压缩后它会被**逐字注回**。\n" +
3569
+ " - `stash` **落进项目**:以后可能要查、但不该占上下文的(判断与理由、" +
3570
+ "走不通的路、关键文件位置)。写进 `_stash/`,**永不注入**,需要时自己 grep。\n" +
3571
+ " - `drop` **丢掉**:已被取代 / 已完成且不再影响判断的。\n" +
3572
+ " ⚠️ **留得太多等于没压** —— `keep` 只放「不知道就会做错」的," +
3573
+ `最多 ${TRIAGE_KEEP_MAX} 条(超了按你给的顺序截断,并会如实告诉你截掉几条)。\n`;
3574
+
3312
3575
  if (tier >= 0.7) {
3313
3576
  return (
3314
3577
  head +
3315
- "**顺序只有两步:先固化这一步的进展,然后照常继续干活。**\n" +
3578
+ (surgical
3579
+ ? "**顺序是三步:先固化这一步的进展,再分流,然后照常继续干活。**\n"
3580
+ : "**顺序只有两步:先固化这一步的进展,然后照常继续干活。**\n") +
3316
3581
  "1. 把本轮进展写进活跃台账(`ledger_write`,带 `ifRevision` 与你的会话编号);\n" +
3317
3582
  (info.logRel !== undefined
3318
3583
  ? "2. 写一条活动日志 L1 摘要(`ledger_log_write`,title 必填)。\n"
3319
3584
  : "") +
3320
- "\n★★ **压缩会等你写完**:在你把上面两步做完之前,插件**不会**压缩" +
3585
+ (surgical ? triageStep : "") +
3586
+ "\n★★ **压缩会等你写完**:在你把上面这些做完之前,插件**不会**压缩" +
3321
3587
  "(先记台账、再压缩 —— 否则压掉的正是这一轮唯一的那份进展)。\n" +
3322
- "\n★★ **做完这两步就继续手上的工作,不要停、也不要为此提问。**" +
3588
+ "\n★★ **做完就继续手上的工作,不要停、也不要为此提问。**" +
3323
3589
  "本插件会在你结束本轮后**自动执行一次高保真压缩**(它拦不住官方的 80%," +
3324
3590
  "所以**在 70% 就提前把自己的那份做好**)。\n" +
3325
3591
  "压缩对你**基本无感**:旧历史会被换成摘要,同时插件会把你这一段用过的" +
3326
- "**逐字胶囊**(用户原话 / 报错原文 / 器物索引 / 可重读路径)重新注回来 —— " +
3592
+ "**逐字胶囊**(用户原话 / 报错原文 / 器物索引 / 可重读路径)" +
3593
+ (surgical ? "与**你自己挑的分流单**" : "") +
3594
+ "重新注回来 —— " +
3327
3595
  "所以压完之后你仍然知道我们刚才在做什么、改过哪些文件。\n\n" +
3328
3596
  "★ 若你**确实**想在这个水位把选择权交给用户(例如本轮要做的事一时说不完," +
3329
3597
  `值得写交接单),那才调用 \`${CHECKPOINT_TOOL}\`;否则**不必打扰他**。\n\n` +
@@ -3338,7 +3606,13 @@ function renderPressureNotice(info, pressure, tier) {
3338
3606
  (info.logRel !== undefined
3339
3607
  ? "2. 写一条活动日志 L1 摘要(`ledger_log_write`,title 必填)。\n"
3340
3608
  : "") +
3609
+ // ★ 分流**只在 70% 那一档要**(它比写台账贵:要 AI 判断)。
3610
+ // 30/50% 只做"低成本固化",不打扰它做分类 —— 否则每档都要它多产出一段。
3341
3611
  "\n★ **压缩会等你写完**(先记台账、再压缩);写完就继续干活,**不要为此停顿**。\n" +
3612
+ (surgical
3613
+ ? "★ 本项目在**反复追究**模式:到 **70%** 那一档,除了上面两步," +
3614
+ "插件还会要求你做一次 `ledger_triage` **分流**(留 / 落 / 丢)。\n"
3615
+ : "") +
3342
3616
  "★ **70%** 那一档也是照常继续干活 —— 区别只是插件会在那一档**自己压一次**,\n" +
3343
3617
  " 把状态在官方 80% 压缩之前落袋。\n" +
3344
3618
  "★ 任何时候你**自己觉得有必要**(做完一个阶段、要换方向、改动很大)," +
@@ -3968,6 +4242,31 @@ function noteFlushed(state, exec) {
3968
4242
  }
3969
4243
  }
3970
4244
 
4245
+ /**
4246
+ * 「反复追究」模式下的**分流义务**:我们要求过它分流吗?它分了吗?
4247
+ *
4248
+ * ★ 为什么**不能**塞进 `flushOwedAt` 一起判:
4249
+ * `noteFlushed` 在写台账时**就会**清掉 `flushOwedAt`(那是"落盘"的义务)。
4250
+ * 而分流是**另一件事** —— 若共用一个标记,AI"只写了台账没分流"就会**静默**
4251
+ * 通过闸门 ⇒ 压缩照跑、keep 桶是空的 ⇒ **模式退化成正常推进,且没人知道**。
4252
+ * ⇒ 两条义务各自记,闸门两条都查。这与 `flushOwedAt` 当初为什么要独立于
4253
+ * 周期判据,是同一个道理(**做成参数/共用一个标记,就会有人漏**)。
4254
+ *
4255
+ * @returns {{owed:boolean}} `owed` = 要求过但还没做
4256
+ */
4257
+ function triageOwed(state, sid) {
4258
+ const key = String(sid);
4259
+ const owedAt = state.triageOwedAt?.get?.(key);
4260
+ if (owedAt === undefined) return { owed: false };
4261
+ const doneAt = state.triageAt?.get?.(key);
4262
+ if (doneAt !== undefined && doneAt > owedAt) {
4263
+ // 它照做了(`ledger_triage` 里已经清掉,这里是双保险)。
4264
+ state.triageOwedAt.delete(key);
4265
+ return { owed: false };
4266
+ }
4267
+ return { owed: true, owedAt };
4268
+ }
4269
+
3971
4270
  /**
3972
4271
  * 「压缩前强制落盘」的那道闸门。
3973
4272
  *
@@ -4101,6 +4400,35 @@ function flushBeforeCompact(state, sid, info, opts) {
4101
4400
  if (flushedAt > lastCompactAt) {
4102
4401
  // 本周期真落过盘 ⇒ 之前的"被拦"计数清零(它只描述"连续没落盘")。
4103
4402
  state.flushBlocked.delete(key);
4403
+ // ★★ 但**分流**是另一条义务:落盘不等于分过流。
4404
+ // 在「反复追究」模式下,若我们要求过分流而它没做,**仍然不许压** ——
4405
+ // 否则 keep 桶是空的,压缩照跑,模式**静默退化**成正常推进。
4406
+ if (modeOf(info) === MODE_SURGICAL && triageOwed(state, sid).owed === true) {
4407
+ const trec = state.triageBlocked.get(key) ?? { count: 0 };
4408
+ trec.count += 1;
4409
+ state.triageBlocked.set(key, trec);
4410
+ if (trec.count < FLUSH_BLOCK_GIVEUP) {
4411
+ return {
4412
+ ok: false,
4413
+ missing: ["分流单"],
4414
+ reason:
4415
+ "本项目在**反复追究**模式:压缩前要先做一次分流,而**这一轮还没做**。" +
4416
+ "用 `ledger_triage` 把这一段分成三个桶(`keep` 留上下文 / `stash` 落进项目 / " +
4417
+ "`drop` 丢掉),之后压缩会自动继续。",
4418
+ hint: "ledger_triage",
4419
+ blockedCount: trec.count,
4420
+ owedTriage: true,
4421
+ };
4422
+ }
4423
+ // 死锁防护:等不到就放行,但如实标注(不假装分过流)。
4424
+ state.triageOwedAt.delete(key);
4425
+ state.triageBlocked.delete(key);
4426
+ return {
4427
+ ok: true,
4428
+ why: `已连续 ${trec.count} 次要求分流仍未做,本次放行(避免压缩永不发生)`,
4429
+ forcedThrough: true,
4430
+ };
4431
+ }
4104
4432
  return { ok: true, why: "本周期内已落盘" };
4105
4433
  }
4106
4434
 
@@ -5100,6 +5428,24 @@ function defineStatusTool(ctx) {
5100
5428
  activeLines: info.activeLines,
5101
5429
  maxActiveLines: info.maxActiveLines,
5102
5430
  overThreshold: info.overThreshold,
5431
+ // ★★★ 两个模式(2026-10-10):**生效值**与**它是从哪来的**分开报。
5432
+ // 只报生效值不够 —— 用户改了清单却发现"没变",必须能一眼看出是清单没读到、
5433
+ // 还是清单里那个值不认识(后者要**说出来**,不许静默退默认)。
5434
+ mode: modeOf(info),
5435
+ modeSource: (() => {
5436
+ const m = info.manifest?.mode;
5437
+ if (typeof m === "string" && MODES.includes(m)) return "manifest";
5438
+ const s = currentSettings().mode;
5439
+ if (typeof m === "string") return "manifest-unknown";
5440
+ return MODES.includes(s) ? "setting" : "default";
5441
+ })(),
5442
+ modeManifestValue: info.manifest?.mode ?? null,
5443
+ modeManifestSuspect: modeManifestSuspect(info),
5444
+ modeHint: modeManifestSuspect(info)
5445
+ ? `清单里的 \`mode\` = ${JSON.stringify(info.manifest?.mode)} **不认识** ⇒ ` +
5446
+ `当前按「正常推进」跑。认得的只有:${MODES.join(" / ")}。`
5447
+ : null,
5448
+ modeLabel: modeOf(info) === MODE_SURGICAL ? "反复追究" : "正常推进",
5103
5449
  git: gitAvailable() ?? null,
5104
5450
  // ★★ 把"压缩服务够不够得着"**报出来**。
5105
5451
  //
@@ -6732,6 +7078,161 @@ function defineHandoffTool() {
6732
7078
  };
6733
7079
  }
6734
7080
 
7081
+ /**
7082
+ * ⑦之二 `ledger_triage` —— 「反复追究」模式下的**分流单**(用户说的"划分")。
7083
+ *
7084
+ * ★ 用户原话(2026-10-10):*"对这 20 输出的工作量进行划分…最终只有 10%"*
7085
+ * 以及 *"没必要保留的是有必要查到的就放进项目中,然后没有必要保留的就归档"*
7086
+ *
7087
+ * 三个桶(**必须由 AI 判断**,不是机械规则 —— 规则抓不到"假设 / 走不通的路"):
7088
+ *
7089
+ * keep 留在上下文:**下一轮一定要知道**的。压缩后**逐字重注入**。
7090
+ * stash 落进项目:以后可能查,但**不该占上下文**。写进 `_stash/`,按需 grep。
7091
+ * drop 丢掉:已被取代 / 已完成且不再影响判断。
7092
+ *
7093
+ * ★ 为什么 `keep` 必须有硬上限:不限的话,"什么都重要"会把上下文撑回去,
7094
+ * 等于没压。超上限就**按顺序截断**(AI 给的顺序即优先级),
7095
+ * 并**如实报出被截掉几条** —— 否则分流不可验证。
7096
+ */
7097
+ const TRIAGE_KEEP_MAX = 12;
7098
+
7099
+ function defineTriageTool(state) {
7100
+ return {
7101
+ name: "ledger_triage",
7102
+ description:
7103
+ "Split the work done so far into three buckets before a compaction runs. Use it in the " +
7104
+ "'surgical' (反复追究) mode, or whenever you are about to lose detail you may need later. " +
7105
+ "`keep` = must be known next turn; it is re-injected VERBATIM after the compaction. " +
7106
+ "`stash` = may be needed later but should not occupy context; written into the project's " +
7107
+ "_stash/ directory and never injected. `drop` = superseded. Being honest here is the whole " +
7108
+ "point: if you put everything in `keep`, nothing is compressed.",
7109
+ parameters: {
7110
+ type: "object",
7111
+ properties: {
7112
+ keep: {
7113
+ type: "array",
7114
+ description:
7115
+ "Things that MUST be known next turn — current task state, unverified assumptions, " +
7116
+ "constraints the user set. Re-injected verbatim after compaction. Keep it SHORT. " +
7117
+ `At most ${TRIAGE_KEEP_MAX} items; extras are truncated (in your order) and reported.`,
7118
+ items: {
7119
+ type: "object",
7120
+ properties: {
7121
+ what: { type: "string", description: "The thing itself, one line." },
7122
+ },
7123
+ required: ["what"],
7124
+ additionalProperties: false,
7125
+ },
7126
+ },
7127
+ stash: {
7128
+ type: "array",
7129
+ description:
7130
+ "Worth keeping but NOT worth context — decisions and their reasons, dead ends you " +
7131
+ "already ruled out, key file locations. Written to the project's _stash/ for later grep.",
7132
+ items: {
7133
+ type: "object",
7134
+ properties: {
7135
+ what: { type: "string", description: "What it is, one line." },
7136
+ why: { type: "string", description: "Why it may be worth finding again." },
7137
+ where: {
7138
+ type: "string",
7139
+ description: "Where to look — an exact path or command. ★ Paths, not descriptions.",
7140
+ },
7141
+ },
7142
+ required: ["what"],
7143
+ additionalProperties: false,
7144
+ },
7145
+ },
7146
+ drop: {
7147
+ type: "array",
7148
+ description:
7149
+ "Superseded or finished-and-irrelevant. Listed so the split is auditable; nothing is written.",
7150
+ items: {
7151
+ type: "object",
7152
+ properties: { what: { type: "string" } },
7153
+ required: ["what"],
7154
+ additionalProperties: false,
7155
+ },
7156
+ },
7157
+ root: ROOT_PARAM,
7158
+ },
7159
+ additionalProperties: false,
7160
+ },
7161
+ output: { schema: OK_SCHEMA, render: renderResult },
7162
+ async execute(args, exec) {
7163
+ const root = rootFromExec(exec, args);
7164
+ const info = inspect(root);
7165
+ if (info.activeRel === undefined) {
7166
+ return {
7167
+ ok: false,
7168
+ reason:
7169
+ "这个项目还没有台账 ⇒ 按约定不往项目里写文件。先 `/ledger init`," +
7170
+ "或者用 `root` 点名一个已经有台账的项目。",
7171
+ };
7172
+ }
7173
+ const arr = (v) => (Array.isArray(v) ? v.filter((x) => x !== null && typeof x === "object") : []);
7174
+ const keepAll = arr(args?.keep);
7175
+ const stash = arr(args?.stash);
7176
+ const drop = arr(args?.drop);
7177
+
7178
+ const keep = keepAll.slice(0, TRIAGE_KEEP_MAX).map((x) => ({
7179
+ what: String(x?.what ?? "").trim(),
7180
+ })).filter((x) => x.what !== "");
7181
+ const overflow = Math.max(0, keepAll.length - TRIAGE_KEEP_MAX);
7182
+
7183
+ if (keep.length === 0 && stash.length === 0 && drop.length === 0) {
7184
+ return { ok: false, reason: "三个桶都是空的 —— 没有内容可分。" };
7185
+ }
7186
+
7187
+ const sid = String(exec?.agent?.session?.header?.id ?? "");
7188
+ const at = stampSec(new Date());
7189
+
7190
+ // ★ keep 桶存进 state,压缩后由重注那一步消费(与胶囊同一条通道)。
7191
+ if (keep.length > 0 && sid !== "") {
7192
+ state.triageKeep.set(String(sid), { at, items: keep });
7193
+ }
7194
+ // ★★ 记下"这次分流做过了" ⇒ 闸门那条**分流义务**随之兑现(见 `triageOwed`)。
7195
+ // ⚠️ 即使 keep 是空的也要记:只 stash / 只 drop 也是一次**合法的分流**,
7196
+ // 否则闸门会一直等它做一件它已经做过的事(活锁)。
7197
+ if (sid !== "") {
7198
+ const key = String(sid);
7199
+ state.triageAt.set(key, Date.now());
7200
+ state.triageOwedAt?.delete?.(key);
7201
+ state.triageBlocked?.delete?.(key);
7202
+ }
7203
+
7204
+ // ★ stash 桶落盘(只在有台账的项目里写)。
7205
+ let stashResult = { ok: false, count: 0 };
7206
+ if (stash.length > 0) {
7207
+ stashResult = persistStash(root, info, sid, stash, {
7208
+ source: "manual",
7209
+ kept: keep.length,
7210
+ dropped: drop.length,
7211
+ });
7212
+ }
7213
+
7214
+ return {
7215
+ ok: true,
7216
+ kept: keep.length,
7217
+ stashed: stashResult.ok === true ? stashResult.count : 0,
7218
+ dropped: drop.length,
7219
+ stashPath: stashResult.rel ?? null,
7220
+ overflow,
7221
+ mode: modeOf(info),
7222
+ note:
7223
+ `分流完成:留 ${keep.length} 条(压缩后逐字重注) · ` +
7224
+ `落 ${stashResult.ok === true ? stashResult.count : 0} 条` +
7225
+ (stashResult.ok === true ? `到 \`${stashResult.rel}\`` : `(未落盘:${stashResult.reason})`) +
7226
+ ` · 丢 ${drop.length} 条。` +
7227
+ (overflow > 0
7228
+ ? `\n★ keep 超上限 ${TRIAGE_KEEP_MAX},**已截掉 ${overflow} 条**(按你给的顺序保留前 ${TRIAGE_KEEP_MAX} 条)。`
7229
+ : "") +
7230
+ "\n★ 提醒:**留得太多等于没压。** keep 只放「下一轮不知道就会做错」的东西。",
7231
+ };
7232
+ },
7233
+ };
7234
+ }
7235
+
6735
7236
  /**
6736
7237
  * ⑦ ledger_log —— 回溯读活动日志(**不注入上下文**,只在需要时读切片)。
6737
7238
  */
@@ -6957,6 +7458,14 @@ const LEDGER_SUBCOMMANDS = [
6957
7458
  "立刻做一次**轻量**压缩:**只压最旧一段、保留最近一大块**。水位小幅回落,新近细节完整保留。",
6958
7459
  },
6959
7460
  { sub: "log", label: "看活动日志状态", description: "四档粒度开没开、有多少文件。不注入上下文。" },
7461
+ {
7462
+ sub: "mode",
7463
+ label: "切换压缩模式(正常推进 / 反复追究)",
7464
+ description:
7465
+ "在「**正常推进**」(按位置压最旧一段,省一次模型输出)与「**反复追究**」" +
7466
+ "(压缩前由 AI 分流:留上下文 / 落进项目 `_stash/` / 丢掉)之间切换。" +
7467
+ "★ 写进**这个项目**的 `.dsh-ledger.json`,只影响本项目;不带参数则只报当前模式。",
7468
+ },
6960
7469
  {
6961
7470
  sub: "handoff",
6962
7471
  label: "写交接单(给下一场会话)",
@@ -7232,7 +7741,7 @@ async function runManualLightCompactNow(ctx, invocation, state, root) {
7232
7741
  * 真正执行一个子命令。**卡片选中的与手打的走的是同一条路** ——
7233
7742
  * 同一个子命令无论从哪个入口进来,后果必须一模一样。
7234
7743
  */
7235
- async function runLedgerSub(ctx, invocation, state, sub, capsule) {
7744
+ async function runLedgerSub(ctx, invocation, state, sub, capsule, argument) {
7236
7745
  const root = projectRootOf(invocation?.agent);
7237
7746
  if (root === undefined) {
7238
7747
  return { kind: "error", text: "找不到本会话的工作目录,无法定位项目。" };
@@ -7256,6 +7765,74 @@ async function runLedgerSub(ctx, invocation, state, sub, capsule) {
7256
7765
  return await runManualLightCompactNow(ctx, invocation, state, root);
7257
7766
  }
7258
7767
 
7768
+ // ── `/ledger mode [steady|surgical]` —— 切换本项目压缩模式(2026-10-10)──
7769
+ //
7770
+ // ★ 为什么**写进项目清单**而不是全局设置:模式是**项目**的性质(见 `modeOf`)。
7771
+ // 写全局会把别的项目一起改掉 —— 而那正是用户明确排除的(他选的是"项目级")。
7772
+ // ★ 为什么**必须**有这条命令:设置面板那一项的 hint 里写了"也可以用 `/ledger mode`
7773
+ // 随时切换"。写了就得有,否则是空承诺(本文件反复强调的那条纪律)。
7774
+ if (sub === "mode") {
7775
+ const info = inspect(root);
7776
+ const want = typeof argument === "string" ? argument.trim().toLowerCase() : "";
7777
+ const cur = modeOf(info);
7778
+ const label = (m) => (m === MODE_SURGICAL ? "反复追究" : "正常推进");
7779
+
7780
+ if (info.activeRel === undefined) {
7781
+ return {
7782
+ kind: "error",
7783
+ text:
7784
+ "这个项目还没有台账,写不了模式(模式登记在 `.dsh-ledger.json` 里)。\n\n" +
7785
+ "先 `/ledger init`,或直接给工具传 `root` 点名一个有台账的项目。",
7786
+ };
7787
+ }
7788
+ // 不带参数 = 只报当前模式(只读,不改任何东西)。
7789
+ if (want === "") {
7790
+ const suspect = modeManifestSuspect(info);
7791
+ return {
7792
+ kind: "success",
7793
+ text:
7794
+ `**压缩模式:${label(cur)}**(\`${cur}\`)\n\n` +
7795
+ (suspect
7796
+ ? `⚠ 清单里的 \`mode\` 是 \`${JSON.stringify(info.manifest?.mode)}\`,**不认识** ⇒ ` +
7797
+ `当前按默认的「${label(MODE_STEADY)}」在跑。\n\n`
7798
+ : "") +
7799
+ "· `正常推进`(steady):压缩时**按位置**只压最旧一段、保留最近一段。" +
7800
+ "省一次模型输出,适合一路向前干活。\n" +
7801
+ "· `反复追究`(surgical):压缩**前**先让 AI 分三个桶 —— " +
7802
+ "`keep` 留上下文(压缩后逐字注回)/ `stash` 落进项目 `_stash/`(不占上下文,按需查)/ " +
7803
+ "`drop` 丢掉。适合要反复回看约束与假设的活。\n\n" +
7804
+ `切换:\`/ledger mode steady\` 或 \`/ledger mode surgical\`。` +
7805
+ (info.manifest?.mode === undefined
7806
+ ? "\n\n(当前项目清单里**没有**登记 mode ⇒ 走的是设置面板里的默认值。)"
7807
+ : `\n\n(当前项目清单里登记的 mode = \`${info.manifest.mode}\`。)`),
7808
+ };
7809
+ }
7810
+ if (!MODES.includes(want)) {
7811
+ return {
7812
+ kind: "error",
7813
+ text:
7814
+ `认不出模式 \`${want}\`。只有两个:\`steady\`(正常推进)、\`surgical\`(反复追究)。\n\n` +
7815
+ "★ 这里**故意不改**任何东西 —— 打错一个字就静默退到默认," +
7816
+ "会让用户以为切过去了(配置类功能最难查的失败模式)。",
7817
+ };
7818
+ }
7819
+ const r = writeManifestMode(root, info, want);
7820
+ if (r.ok !== true) {
7821
+ return { kind: "error", text: `写清单失败:${r.reason}` };
7822
+ }
7823
+ return {
7824
+ kind: "success",
7825
+ text:
7826
+ `已把**这个项目**的压缩模式切到「**${label(want)}**」(\`${want}\`)。\n\n` +
7827
+ `登记位置:\`${path.join(root, MANIFEST_REL)}\`\n\n` +
7828
+ (want === MODE_SURGICAL
7829
+ ? "★ 下一档水位提醒(70%)起,压缩前会要求你做一次 `ledger_triage` 分流," +
7830
+ "压完把 `keep` 桶**逐字注回**。\n"
7831
+ : "★ 已回到常态:压缩只按位置压最旧一段,不再要求分流。\n") +
7832
+ "★ 这项**只影响本项目** —— 别的项目各按自己清单里的模式走。",
7833
+ };
7834
+ }
7835
+
7259
7836
  if (sub === "log") {
7260
7837
  const info = inspect(root);
7261
7838
  if (info.logRel === undefined) {
@@ -7430,6 +8007,11 @@ async function runLedgerSub(ctx, invocation, state, sub, capsule) {
7430
8007
  async function handleLedgerCommand(ctx, invocation, state, capsule) {
7431
8008
  const raw = String(invocation?.rawInput ?? "").trim();
7432
8009
  const sub = (raw.split(/\s+/)[0] ?? "").toLowerCase();
8010
+ // ★ 第二个及之后的词 = 参数(目前只有 `mode <steady|surgical>` 用)。
8011
+ // ★ 平台**没有** `invocation.argument` 这个东西 —— 命令拿到的是整串 `rawInput`
8012
+ // (见上面 `raw.split(/\s+/)[0]` 的原本写法)。所以要自己切出来,
8013
+ // 不能想当然地读一个不存在的字段(读了就是永远 `""`)。
8014
+ const rest = raw.split(/\s+/).slice(1).join(" ");
7433
8015
 
7434
8016
  // 显式帮助:不弹卡片,直接印文字(脚本化/观察时不该被一张卡片挡住)。
7435
8017
  if (sub === "help") return { kind: "success", text: ledgerHelpText() };
@@ -7440,10 +8022,10 @@ async function handleLedgerCommand(ctx, invocation, state, capsule) {
7440
8022
  if (picked === undefined) {
7441
8023
  return { kind: "success", text: ledgerHelpText("(没有选择,以下是用法)") };
7442
8024
  }
7443
- return await runLedgerSub(ctx, invocation, state, picked, capsule);
8025
+ return await runLedgerSub(ctx, invocation, state, picked, capsule, "");
7444
8026
  }
7445
8027
 
7446
- return await runLedgerSub(ctx, invocation, state, sub, capsule);
8028
+ return await runLedgerSub(ctx, invocation, state, sub, capsule, rest);
7447
8029
  }
7448
8030
 
7449
8031
  // ═══════════════════════════════════════════════════════════════════════════
@@ -7628,6 +8210,30 @@ function newState() {
7628
8210
  * 正是我们要救的东西。
7629
8211
  */
7630
8212
  capsuleSnapshot: new Map(),
8213
+ /**
8214
+ * sid → 「反复追究」模式里 **keep 桶**(压缩后逐字重注,与胶囊同一条通道)。
8215
+ *
8216
+ * ★ 为什么与胶囊**分开存**而不是并进胶囊:
8217
+ * 胶囊是**插件机械抓的**(用户原话 / 报错 / 器物索引),它保证"模型忘了我也不会忘";
8218
+ * keep 桶是**模型判断的**(假设 / 当前状态),它会漏。
8219
+ * 两者来源不同、失败模式不同 ⇒ 分开存,压缩后**两份都注**,
8220
+ * 一份当地板(零成本、不会漏),一份当天花板(有判断力)。见 `MODE_SURGICAL` 注释。
8221
+ *
8222
+ * ★ 用完即删:它只描述"紧接着的那一次压缩",不是会话状态(与 `hfSource` 同一条口径)。
8223
+ */
8224
+ triageKeep: new Map(),
8225
+ /**
8226
+ * sid → **我们要求过分流的时刻**(「反复追究」模式的第二道义务)。
8227
+ *
8228
+ * ★ 为什么不与 `flushOwedAt` 共用:`noteFlushed` 在写台账时就会清掉那个标记,
8229
+ * 而"写了台账"**不等于**"分过流"。共用 ⇒ "只写台账没分流"会静默通过闸门
8230
+ * ⇒ keep 桶空着压缩照跑 ⇒ **模式退化且无人察觉**。
8231
+ */
8232
+ triageOwedAt: new Map(),
8233
+ /** sid → 上一次**真的做完分流**的时刻(与 `triageOwedAt` 比大小判是否兑现)。 */
8234
+ triageAt: new Map(),
8235
+ /** sid → 连续被"要求分流却没做"拦下的次数(死锁防护,与 `flushBlocked` 同口径)。 */
8236
+ triageBlocked: new Map(),
7631
8237
  /**
7632
8238
  * sid → **这一次压缩是谁发起的**(需求 ②③:四条入口要分得清)。
7633
8239
  *
@@ -8047,6 +8653,7 @@ export function apply(ctx, config) {
8047
8653
  ["ledger_log_write", () => defineLogWriteTool(state)],
8048
8654
  ["ledger_note", defineNoteTool],
8049
8655
  ["ledger_handoff", defineHandoffTool],
8656
+ ["ledger_triage", () => defineTriageTool(state)],
8050
8657
  ["ledger_conform", defineConformTool],
8051
8658
  ["ledger_compact", () => defineCompactTool(ctx, state, capsule)],
8052
8659
  [CHECKPOINT_TOOL, () => defineCheckpointTool(ctx, state)],
@@ -8169,9 +8776,13 @@ export function apply(ctx, config) {
8169
8776
  // 真正的输入对应物);没有就现取(例如官方 80% 那次压缩,我们没参与)。
8170
8777
  const snap = state.capsuleSnapshot.get(String(sid));
8171
8778
  state.capsuleSnapshot.delete(String(sid));
8779
+ // ★ 「反复追究」模式:AI 自己分的 keep 桶,**消费掉**(同胶囊,描述"紧接着那一次压缩")。
8780
+ const tri = state.triageKeep.get(String(sid));
8781
+ state.triageKeep.delete(String(sid));
8172
8782
  text = renderInjection(info, undefined, {
8173
8783
  capsule: snap ?? renderCapsule(info, capsule?.get(String(sid))),
8174
8784
  ours: snap !== undefined,
8785
+ keep: tri?.items,
8175
8786
  });
8176
8787
  } else {
8177
8788
  // 没变化就别重复注入 —— 去重靠"指纹 + 文本"两道。
@@ -8235,6 +8846,13 @@ export function apply(ctx, config) {
8235
8846
  // 立了会永久卡住压缩 ⇒ 所以加 `activeRel` 判断。
8236
8847
  if (info.activeRel !== undefined) {
8237
8848
  state.flushOwedAt.set(String(sid), Date.now());
8849
+ // ★★ 「反复追究」模式:**同一刻**再立一条**分流**义务。
8850
+ // ★ 只在 tier≥0.7 立 —— 分流比写台账贵(要 AI 判断),
8851
+ // 30/50% 那两档的文案里也**没有**要求分流(见 `renderPressureNotice`),
8852
+ // 立了就成了"要求一件我没说的事"。
8853
+ if (hit >= 0.7 && modeOf(info) === MODE_SURGICAL) {
8854
+ state.triageOwedAt.set(String(sid), Date.now());
8855
+ }
8238
8856
  }
8239
8857
  }
8240
8858
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ledger-memory",
3
- "version": "0.1.1",
4
- "description": "台账记忆(Ledger Memory):为 DSH 提供工程化的项目记忆 —— 台账的检测/注入/归档/Git 配合,活动日志(L0–L4),三层上下文压缩与逐字胶囊落盘,以及跨会话的交接单闭环。可回溯、可追查。",
3
+ "version": "0.2.0",
4
+ "description": "台账记忆(Ledger Memory):为 DSH 提供工程化的项目记忆 —— 台账的检测/注入/归档/Git 配合,活动日志(L0–L4),三层上下文压缩与逐字胶囊落盘,两种压缩模式(正常推进 / 反复追究),以及跨会话的交接单闭环。可回溯、可追查。",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -10,6 +10,7 @@
10
10
  "memory",
11
11
  "context",
12
12
  "compaction",
13
+ "triage",
13
14
  "handoff",
14
15
  "traceability",
15
16
  "agent"