@a9i5k4/dsh-auto-memory 2.4.2 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,6 +63,10 @@
63
63
  | `P10-importance-calibration.md` | importance 效应定标:`0.5+w×imp` 可配置 + 真实查询翻转率实测 | **P9 结论后** | `lib/index.js` dense 臂一行 + 配置项 | 低 |
64
64
  | `P11-silent-catch-observability.md` | fail-soft 空 catch 统一加 diag(**只加日志不改行为**,可与 P9 并行) | 无 | `lib/index.js` catch 行 | 低 |
65
65
  | **`FIX-AGENT-P11.md`** | **⭐ 复制即投喂:静默 catch 可观测(自包含,可与 P9 并行给另一 Agent)** | 无 | `lib/index.js` catch 行 | 低 |
66
+ | **`RELEASE-AGENT.md`** | **⭐ 复制即投喂:发版执行子代理(自包含;主对话=决策者只做开闸/放行/处置,见 `docs/internal/RELEASE-PROCESS.md` 顶部角色分工)** | 前置门已过 | `CHANGELOG.md` + `lib/client.js` 版本标识三处 | 高(单向门) |
67
+ | **`REGRESSION-AGENT.md`** | **⭐ 复制即投喂:全量回归执行(只读;失败套件单跑复验规则)** | 无 | 无(只跑不改) | 无 |
68
+ | **`DOCS-AUDIT-AGENT.md`** | **⭐ 复制即投喂:README/USER-GUIDE 双语对账(只读,产出对账单)** | 无 | 无(只读) | 无 |
69
+ | **`TRACE-PATROL-AGENT.md`** | **⭐ 复制即投喂:子代理痕迹巡检(只读盘点 + GC 备份区 + 残留风险)** | 无 | 无(只读) | 无 |
66
70
  | `EXEC-ORDER.md` | 执行顺序与冲突提示 | — | — | — |
67
71
 
68
72
  ---
@@ -0,0 +1,42 @@
1
+ # REGRESSION-AGENT · 全量回归执行子代理任务书
2
+
3
+ > **给使用者的说明**:一整段投喂(自包含)。用途:发版前置门、大改后的体检、接手陌生工作区时的基线盘点。**只读任务:不得改任何文件。**
4
+
5
+ ---
6
+
7
+ 你是 dsh-auto-memory 的回归执行子代理。工作目录:`D:\dsh-auto-memory`(Node.js 项目,零运行时依赖)。任务:**跑完全量冒烟回归并回报结构化结果**。禁止改任何文件、禁止 `git add/commit`、禁止重启 dsh web。
8
+
9
+ ## 步骤
10
+
11
+ 1. 记录基线:`git log --oneline -3` 与 `git status --short`(只记录,不处理)。
12
+ 2. 逐个运行全部套件(PowerShell):
13
+
14
+ ```powershell
15
+ cd D:\dsh-auto-memory
16
+ $results = @()
17
+ Get-ChildItem tests\smoke -File -Filter *.mjs | ForEach-Object {
18
+ $out = node $_.FullName 2>&1
19
+ $code = $LASTEXITCODE
20
+ $results += [pscustomobject]@{ name = $_.Name; code = $code; tail = ($out | Select-Object -Last 3) -join ' | ' }
21
+ }
22
+ $results | Where-Object code -ne 0
23
+ $results | Measure-Object | Select-Object -ExpandProperty Count
24
+ ```
25
+
26
+ 3. **失败套件单跑复验一次**:批量连跑受机器负载影响(历史案例:`smoke-test-m53-*` 连跑误报、单跑全绿)。复验仍失败才算真失败,回报里注明"连跑 FAIL / 单跑 FAIL"两行。
27
+ 4. 附带静态检查:`node --check lib\index.js; node --check lib\client.js`;对改动过的文件抽查前三字节非 `EF BB BF`(BOM 会让 dsh web 起不来)。
28
+
29
+ ## 回报格式(原样 JSON)
30
+
31
+ ```json
32
+ { "ok": true, "total": 70, "failed": [], "flaky": [], "node_check": "ok", "bom": "ok", "head": "<git log -3 首行>", "dirty": ["<git status --short 逐条>"], "durationMs": 0, "error_tail": "" }
33
+ ```
34
+
35
+ - `failed`:`["<套件名>: <失败断言行摘要≤120字符>"]`;`flaky`:连跑挂但单跑绿的套件名。
36
+ - `dirty` 原样列出——**脏树范围核实是主对话的决策项**,子代理只如实上报。
37
+ - 有任何套件既非 0 也非 1 的退出码、或套件文件无法运行 → `ok:false` + `error_tail`(≤20 行)。
38
+
39
+ ## 边界
40
+
41
+ - 不诊断根因、不修东西——那是主对话/修复子代理的事。
42
+ - 测试会创建临时目录(`os.tmpdir()` 下的 `dam-*`),跑完不清理不算失败;但**不得**去清理 `D:\dsh-auto-memory` 仓库内任何文件。
@@ -0,0 +1,125 @@
1
+ # RELEASE-AGENT · 发版执行子代理任务书
2
+
3
+ > **给使用者的说明**:**本任务书由主对话读取、填入版本号后原文派发给执行子代理**;用户不必、也不要绕过主对话直接投喂(两条入口并存才会产生并发冲突)。自包含,无需 `_COMMON.md`。
4
+ > 出错即停:任何一步失败或前置不满足,**立即停下回报**,绝不允许跳步、重试超过 1 次、或"先做完再说"。
5
+ > ⚠️ 子代理**不得重启 dsh web**;发布后宿主半边的生效由用户自己重启。
6
+
7
+ ---
8
+
9
+ 你是 dsh-auto-memory 的发版执行子代理。工作目录:`D:\dsh-auto-memory`(pre 线;发布基座在 `D:\dsh_debug\_publish_dsh-auto-memory`,下称 REL)。**本次要发布的版本号:`<ver>`(由主对话填入,形如 2.5.0)。**
10
+
11
+ ## 硬性禁令(违反任何一条=任务失败)
12
+
13
+ 1. **版本标识闸门未过 → 不得真构建;dry-run 未过 → 不得真构建;未真构建 → 不得提交 REL / push / publish。** 单向门按顺序开。
14
+ 2. **只允许改动**:`CHANGELOG.md`、`lib/client.js`(仅版本标识三处:CHANGELOG 字典、指纹行)、`package.json`(由 release.mjs 回写)。**其余任何文件出现改动立即停止回报。**
15
+ 3. **凭据纪律**:PAT 与 npm token 只从 `~/.dsh/memory/workspaces/--D--dsh_debug--/MEMORY.md` 里读,只拼进行内 URL/命令行;**严禁写进任何文件、日志、回报**(回报里凭据一律以 `<redacted>` 代替)。该记忆文件明确「只存本地,严禁写入任何会上传 GitHub/npm 的文件」。
16
+ 4. **不得 push 除非 PAT 读取成功**;**不得 `npm publish` 除非 push 成功且 tag 已打**。
17
+ 5. 全程**不得重启 dsh web**、不得动 `~/.dsh` 下运行中宿主的任何文件、不得并发跑第二个写盘任务。
18
+
19
+ ## 阶段 0 · 前置门(任一不满足 → 停,回报 `failed_step: 0`)
20
+
21
+ ```powershell
22
+ cd D:\dsh-auto-memory
23
+ Get-ChildItem tests\smoke -File -Filter *.mjs | ForEach-Object { node $_.FullName > $null 2>&1; if ($LASTEXITCODE -ne 0) { "FAIL: " + $_.Name } }
24
+ node --check lib\index.js; node --check lib\client.js
25
+ git status --short
26
+ git log --oneline -3
27
+ ```
28
+
29
+ - 全量回归 **0 失败**(批量连跑受负载影响的个别套件允许**单跑复验一次**,以单跑为准)。
30
+ - **脏树范围核实**:`release.mjs` 只复制 `lib/ tests/ python/ docs/ .github/` 与顶层白名单文件,仓库根下与发版无关的**未跟踪**杂项(`artifacts/`、`canvas-local/` 等)不进包,**记录即可继续**。必须停下回报的是:①任何**已跟踪**文件出现主对话告知的预期清单之外的改动 ②`lib/ tests/ docs/ .github/` 内出现预期外的新文件 ③删除型改动。
31
+ - **凭据读取**在阶段 6/7 才需要;读取失败就到那时再停,阶段 0-5 不需要碰凭据。
32
+
33
+ ## 阶段 1 · CHANGELOG(第 1 笔)
34
+
35
+ - `CHANGELOG.md` 顶部新增 `## [<ver>] — <日期> · <一句话主题>` 小节。内容要点由主对话提供;主对话没给 → 停,回报缺输入。
36
+ - 闸门会校验文件里出现 `## [<ver>]`,缺失即拒绝构建。
37
+
38
+ ## 阶段 2 · 软件内版本标识(第 2 笔,`lib/client.js`)
39
+
40
+ - `var CHANGELOG = { ... }` 顶部新增 `'<ver>': { zh: [...], en: [...] }`(应用内"更新说明"弹窗,文案取自 CHANGELOG 小节)。
41
+ - 第 10 行指纹行改为 `console.log('[dsh-auto-memory] client v<ver> fingerprint: ...')`(fingerprint 短语沿用当版,可顺手换新词)。
42
+ - `package.json.version` 不手改(release.mjs 构建时回写)。
43
+
44
+ ```powershell
45
+ node --check lib\client.js
46
+ git add lib tests docs CHANGELOG.md tools\release.mjs
47
+ git -c user.name="Aik358" -c user.email="aik358@users.noreply.github.com" commit -m "v<ver>: <一句话>"
48
+ ```
49
+
50
+ (`git status --short` 核对已暂存清单与主对话给的预期一致后才 commit;仓库根的无关未跟踪杂项**不要** `git add -A` 进来。)
51
+
52
+ ## 阶段 3 · dry-run 验闸门
53
+
54
+ ```powershell
55
+ node tools\release.mjs <ver> --dry-run
56
+ ```
57
+
58
+ 必须看到:`版本标识一致性: OK(CHANGELOG / 应用内更新说明 / 界面指纹行)` + `语法 ✓ BOM ✓ 无 pre/dev 残留 ✓` + `python/ 运行时完整 ✓ bench 已排除 ✓`。否则**停**,回报 `failed_step: 5.05` + 闸门原文(error_tail)。
59
+
60
+ ## 阶段 4 · 真构建
61
+
62
+ ```powershell
63
+ node tools\release.mjs <ver>
64
+ ```
65
+
66
+ 复核输出含 `开发树版本回写: … → <ver>`。构建会写 REL 树。**构建后 pre 线的 `package.json` 已被回写为新版本 → 立即补一笔提交**(v2.5.0 教训:不补会留下脏文件,污染下一次发版的脏树闸门):
67
+
68
+ ```powershell
69
+ cd D:\dsh-auto-memory
70
+ git add package.json
71
+ git -c user.name="Aik358" -c user.email="aik358@users.noreply.github.com" commit -m "v<ver>: 回写开发树版本号(release.mjs 构建后回写)"
72
+ ```
73
+
74
+ ## 阶段 5 · REL 提交 + tag
75
+
76
+ ```powershell
77
+ cd D:\dsh_debug\_publish_dsh-auto-memory
78
+ git add -A -- . ':(exclude).github/cloud/qq-webhook/index.zip'
79
+ git -c user.name="Aik358" -c user.email="aik358@users.noreply.github.com" commit -m "v<ver>: <一句话>"
80
+ git tag -f v<ver>
81
+ ```
82
+
83
+ (index.zip 是 SCF 部署产物,不入库——这是群日报会话定下的先例;REL 树里若再出现其他构建产物同样排除。)
84
+
85
+ ## 阶段 6 · push(带 PAT 行内 URL)
86
+
87
+ 从凭据记忆文件读 PAT 后(读取失败 → 停,`failed_step: 8`):
88
+
89
+ ```powershell
90
+ git -c credential.helper= push https://x-access-token:<PAT>@github.com/Aik358/dsh-auto-memory.git main --tags --force
91
+ ```
92
+
93
+ ## 阶段 7 · npm publish(单向门,最后一步)
94
+
95
+ npm token 同样从凭据记忆文件读(需 bypass-2FA 的那个):
96
+
97
+ ```powershell
98
+ npm publish . --registry=https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=<token> --access public
99
+ ```
100
+
101
+ **坑**:本机 `~/.npmrc` 指向 npmmirror;`npm view` 会命中本地缓存回读旧版本。判定一律用权威接口:
102
+
103
+ ```powershell
104
+ Invoke-RestMethod 'https://registry.npmjs.org/@a9i5k4%2Fdsh-auto-memory/latest' | Select-Object -ExpandProperty version
105
+ ```
106
+
107
+ ## 阶段 8 · 三处复核(缺一不算发完)
108
+
109
+ 1. registry `/latest` 的 version = `<ver>`(用上面的权威接口)
110
+ 2. `git ls-remote https://github.com/Aik358/dsh-auto-memory.git refs/heads/main`
111
+ 3. `git ls-remote https://github.com/Aik358/dsh-auto-memory.git refs/tags/v<ver>`
112
+
113
+ 三者一致才算 ok。
114
+
115
+ ## 回报格式(原样 JSON,不追加散文)
116
+
117
+ ```json
118
+ { "ok": true, "version": "<ver>", "pre_sha": "<pre 线 commit 短 sha>", "rel_sha": "<REL commit 短 sha>", "tag": "v<ver>", "npm_latest": "<ver>", "failed_step": "", "error_tail": "" }
119
+ ```
120
+
121
+ 失败时:`ok:false`,`failed_step` 填阶段号(0/1/2/3/4/5/6/7/8;dry-run 闸门不过记 3),`error_tail` 贴**最后 ≤20 行**原始报错(凭据替换为 `<redacted>`)。已知会出现且**不算失败**的情况:无。任何意外都按失败回报,由主对话决定处置。
122
+
123
+ ---
124
+
125
+ *回滚提示(供主对话,子代理不执行):未 push/publish 前一切可回滚(pre 线 `git reset`、REL 线 `git reset` + `git tag -d`);publish 后不可撤,只能发 patch。*
@@ -0,0 +1,29 @@
1
+ # TRACE-PATROL-AGENT · 子代理痕迹巡检任务书
2
+
3
+ > **给使用者的说明**:一整段投喂(自包含)。用途:定期盘点 DSH 全机子代理会话痕迹与本插件 GC 回收状况(背景:实测全机 686 个子代理会话中 638 个来自本插件,数量上千后拖慢会话列表加载——见 `lib/subagent-gc-pre.js` 头注)。**只读巡检**;回收动作由插件自身在运行时做,巡检子代理不得手动移动/删除任何会话目录。
4
+
5
+ ---
6
+
7
+ 你是 dsh-auto-memory 的痕迹巡检子代理。宿主数据根:`~/.dsh`(Windows = `C:\Users\<user>\.dsh`)。**禁止写/删/移动任何文件,禁止重启 dsh web。**
8
+
9
+ ## 巡检项(逐项产出数字与证据)
10
+
11
+ 1. **会话总量盘点**:`~/.dsh/sessions/<工作区>/<会话目录>/` 逐层统计:
12
+ - 会话目录总数、`session-*` 前缀(普通会话)数、裸 uuid(子代理)数。
13
+ - 子代理判别:读会话目录下 `session.jsonl[.zstd]` 首行 header,`origin==='subagent'`;`descriptor.label` 以 `auto-memory-` 开头(`PLUGIN_LABEL_PREFIX`)即本插件产物。zstd 文件可跳过首行解压、只统计目录名(裸 uuid + 无 `session-` 前缀),并在回报里注明"未解压判定"的比例。
14
+ 2. **GC 备份区**:`~/.dsh/subagent-gc-backup/`(`recycleSessions` 的 backupRoot)统计已回收数与目录体积;`_projcache/` 子目录单独计数。
15
+ 3. **残留风险**:`origin==='subagent'` 且 label 以 `auto-memory-` 开头、mtime 超过 `subagentGcKeepDays`(默认 3 天,`lib/index.js` 配置表)却仍在 sessions 目录的——列前 20 条(工作区/会话名/mtime)。这是"任务结束即回收"路径的漏网指标。
16
+ 4. **投影缓存**:`~/.dsh/` 下投影缓存目录(`*_projcache*` 类名样,实际以 `~/.dsh` 顶层目录列表为准)的文件数与体积;超过 2,000 文件标记 `warn`。
17
+ 5. **配置核对**:读 `~/.dsh/dsh-auto-memory-pre.json`(**注意 `-pre` 后缀**,别读成非 pre 的),摘 `subagentGcEnabled` / `subagentGcKeepDays` 实际值。
18
+
19
+ ## 回报格式(原样 JSON)
20
+
21
+ ```json
22
+ { "ok": true, "sessions_total": 0, "sessions_normal": 0, "subagent_total": 0, "subagent_plugin": 0, "backup": { "count": 0, "projcache": 0 }, "stale_over_keepdays": 0, "stale_sample": [], "projcache_files": 0, "warn": [], "config": { "subagentGcEnabled": true, "subagentGcKeepDays": 3 }, "summary": "一句话总评" }
23
+ ```
24
+
25
+ ## 边界
26
+
27
+ - 大目录统计用流式计数,不要把会话文件内容整读进内存。
28
+ - 若 `~/.dsh/sessions` 结构与上述不符(宿主版本变更),停下回报实际结构,禁止猜。
29
+ - 发现宿主正在写某会话文件(mtime < 5 分钟)→ 跳过该条并在 `warn` 注明,避免与运行中会话竞争。
package/lib/client.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 3. settings.section — 自动记忆设置页(存储位置、注入、反思风格)
8
8
  * Data flows over /api/dsh-auto-memory/* (loopback-only host routes).
9
9
  */
10
- console.log('[dsh-auto-memory] client v2.4.2 fingerprint: drawer-summary-ok')
10
+ console.log('[dsh-auto-memory] client v2.5.1 fingerprint: head-frame-decode')
11
11
  window.__ModuleLoader__.load({
12
12
  id: '@a9i5k4/dsh-auto-memory',
13
13
  factory: (require) => {
@@ -275,7 +275,7 @@ window.__ModuleLoader__.load({
275
275
  fWaterWindow: '水位估计窗口(token)', fWaterWindowHint: '会话消息按官方公式(4 字符≈1 token)估算达到该值即视为上下文将满;0=关闭。默认 65536(128K 窗口的保守半量),按所用模型调整。',
276
276
  fWaterAdvisory: '水位交接建议', fWaterAdvisoryHint: '水位越阈时在动态快照注入交接建议(写账本/刷新白板/建议开新窗);无人值守时静默。',
277
277
  fWaterAuto: '水位自动骨架账本', fWaterAutoHint: '越阈时自动写一篇系统骨架账本(每会话一次),防止模型忽视建议时交接材料缺失。',
278
- fWaterThreshold: '水位建议阈值', fWaterThresholdHint: '水位比例超过该值(0.1-1.5)即触发交接建议/骨架账本;默认 0.75——官方自动压缩阈值是 80%,必须留出余量才来得及走完交接。',
278
+ fWaterThreshold: '水位建议阈值', fWaterThresholdHint: '水位比例 = 上下文实占 ÷ 官方声明窗口(不再扣预留输出);超过该值(0.1-1.5)即触发交接建议/骨架账本;默认 0.75——官方自动压缩阈值是 80%,必须留出余量才来得及走完交接。',
279
279
  fHandoffPlan: '白板注入预算(字符)', fHandoffPlanHint: 'PLAN.md 全貌注入动态快照的硬截断预算;全文可经 memory_read 或面板白板页查看。默认 1200。',
280
280
  fHandoffLedger: '账本注入预算(字符)', fHandoffLedgerHint: '最新一篇交接账本注入动态快照的硬截断预算。默认 800。',
281
281
  fConsSchedule: '定时做梦式固化', fConsScheduleHint: '每天到点自动读最近日志发散提炼长期要点,写入项目笔记/用户级记忆(等价 memory_consolidate)。',
@@ -283,8 +283,8 @@ window.__ModuleLoader__.load({
283
283
  fConsScheduleDays: '固化回看天数', fConsScheduleDaysHint: '定时固化读取最近 N 天日志;默认 7。',
284
284
  fMaintSchedule: '定时 30 天蒸馏', fMaintScheduleHint: '每天到点自动把超过 30 天的旧日志蒸馏归档(等价 memory_maintain);无旧日志时零成本跳过。',
285
285
  fMaintScheduleTime: '蒸馏触发时间', fMaintScheduleTimeHint: 'HH:MM,默认 10:00(与固化时间错开)。',
286
- waterCardTitle: '上下文水位', waterAutoSrc: '自动检测', waterManualSrc: '手动设定', waterOfficialSrc: '官方路由容量', waterFallbackSrc: '回退默认值', waterNotMeasured: '本会话尚未测量(发送一轮后自动更新)', waterMeterOfficial: '官方计量', waterMeterHeuristic: '启发式降级', waterThresholdHint: '水位达到 {t} 时注入交接建议并自动补写账本;计量优先用官方 token-meter(与聊天框 context ring 同源),不可用时降级启发式估算;窗口优先取官方路由容量(request/context 的 contextWindow),其次按当前模型查 settings.yaml contextWindow,也可在下方手动覆盖。',
287
- autoContTitle: '自动接续', autoContHint: '开启后:水位达到阈值、且旧会话已空闲(harness 权威 running 位在轮次边界转为 false,取代旧「两轮水位持平」)时,弹出确认卡——同意=立即接续,拒绝=本轮跳过(同一边界不再提示),30-40 秒无操作视为挂机自动接续;接续前先让旧 Agent 刷新白板 PLAN 与交接账本,再建新会话(沿用工作区/模型/思考档位)并注入分层交接材料;触发后默认 30 分钟内不重复。', autoContOn: '自动接续:开', autoContOff: '自动接续:关', autoContThreshold: '阈值', autoContCountdown: '⏳ {s} 秒后自动接续…', autoContCancel: '取消', autoContConfirm: '本会话「可用额度」已用 {p}%(小圈读数见下行),旧会话已空闲(轮次边界)——现在接续到新会话?', autoContAgree: '同意接续', autoContReject: '拒绝', autoContTimeout: '{s} 秒后自动接续(视为挂机)', autoContRejected: '已跳过本次接续(下一个轮次边界再提醒)',
286
+ waterCardTitle: '上下文水位', waterAutoSrc: '自动检测', waterManualSrc: '手动设定', waterOfficialSrc: '官方路由容量', waterFallbackSrc: '回退默认值', waterNotMeasured: '本会话尚未测量(发送一轮后自动更新)', waterMeterOfficial: '官方计量', waterMeterHeuristic: '启发式降级', waterThresholdHint: '水位达到 {t} 时注入交接建议并自动补写账本;计量优先用官方 token-meter(与聊天框 context ring 同源),不可用时降级启发式估算;窗口优先取官方路由容量(request/context 的 contextWindow),其次按当前模型查 settings.yaml contextWindow,也可在下方手动覆盖;触发分母=官方声明窗口(provider 自报过硬限时取较小值),预留输出不参与扣减。',
287
+ autoContTitle: '自动接续', autoContHint: '开启后:水位达到阈值、且旧会话已空闲(harness 权威 running 位在轮次边界转为 false,取代旧「两轮水位持平」)时,弹出确认卡——同意=立即接续,拒绝=本轮跳过(同一边界不再提示),30-40 秒无操作视为挂机自动接续;接续前先让旧 Agent 刷新白板 PLAN 与交接账本,再建新会话(沿用工作区/模型/思考档位)并注入分层交接材料;触发后默认 30 分钟内不重复。', autoContOn: '自动接续:开', autoContOff: '自动接续:关', autoContThreshold: '阈值', autoContCountdown: '⏳ {s} 秒后自动接续…', autoContCancel: '取消', autoContConfirm: '本会话上下文已用 {p}%(按官方声明窗口计,官方约 80% 才压缩),旧会话已空闲(轮次边界)——现在接续到新会话?', autoContAgree: '同意接续', autoContReject: '拒绝', autoContTimeout: '{s} 秒后自动接续(视为挂机)', autoContRejected: '已跳过本次接续(下一个轮次边界再提醒)',
288
288
  continueCardTitle: '一键接续', continueCardHint: '写好交接账本→创建新会话→把交接材料作为首条消息预载,并自动切换到新会话继续任务(接续材料含白板节选+最新账本;当前工作区无账本时自动取全局最近一篇)。', continueBtn: '一键接续到新会话', continueBusy: '处理中…',
289
289
  fStyle: '反思风格', fStyleHint: '生活化 / 专业性 / 由内容决定。',
290
290
  fLocale: '界面语言', fLocaleHint: '默认跟随 DSH 系统语言;也可手动指定中文 / English。', followSystem: '跟随系统语言',
@@ -426,7 +426,7 @@ window.__ModuleLoader__.load({
426
426
  fWaterWindow: 'Water-level window (tokens)', fWaterWindowHint: 'Session messages priced with the official meter (4 chars ≈ 1 token); past this, context is considered nearly full; 0 disables. Default 65536 — a conservative half of a 128K window.',
427
427
  fWaterAdvisory: 'Water-level handoff advisory', fWaterAdvisoryHint: 'Injects a handoff advisory past the threshold (write ledger / refresh plan / suggest a new window); silent in unattended mode.',
428
428
  fWaterAuto: 'Auto skeleton ledger', fWaterAutoHint: 'Past the threshold, auto-writes one system skeleton ledger per session as a safety net if the model ignores the advisory.',
429
- fWaterThreshold: 'Water-level threshold', fWaterThresholdHint: 'Inject the handoff advisory / skeleton ledger once the ratio passes this value (0.1-1.5); default 0.75 — the official auto-compaction threshold is 80%, so a 5% margin is needed to finish the handoff first.',
429
+ fWaterThreshold: 'Water-level threshold', fWaterThresholdHint: 'The ratio counts session tokens against the official declared window (the reserved output budget is no longer deducted); past this value (0.1-1.5) the handoff advisory / skeleton ledger fires; default 0.75 — the official auto-compaction threshold is 80%, so a 5% margin is needed to finish the handoff first.',
430
430
  fHandoffPlan: 'Plan injection budget (chars)', fHandoffPlanHint: 'Hard truncation budget for injecting the PLAN.md snapshot into the dynamic memory; full text via memory_read or the Whiteboard tab. Default 1200.',
431
431
  fHandoffLedger: 'Ledger injection budget (chars)', fHandoffLedgerHint: 'Hard truncation budget for injecting the latest handoff ledger. Default 800.',
432
432
  fConsSchedule: 'Scheduled deep consolidation', fConsScheduleHint: 'Once a day at the set time, read recent logs and distill long-term value into project notes / user memory (same flow as memory_consolidate).',
@@ -434,8 +434,8 @@ window.__ModuleLoader__.load({
434
434
  fConsScheduleDays: 'Consolidation lookback (days)', fConsScheduleDaysHint: 'How many recent days of logs the scheduled consolidation reads; default 7.',
435
435
  fMaintSchedule: 'Scheduled 30-day distill', fMaintScheduleHint: 'Once a day at the set time, distill & archive logs older than 30 days (same flow as memory_maintain); zero cost when nothing to archive.',
436
436
  fMaintScheduleTime: 'Distill time', fMaintScheduleTimeHint: 'HH:MM, default 10:00 (staggered from the consolidation time).',
437
- waterCardTitle: 'Context water level', waterAutoSrc: 'auto-detected', waterManualSrc: 'manual', waterOfficialSrc: 'route capacity', waterFallbackSrc: 'fallback default', waterNotMeasured: 'Not measured for this session yet (updates after the next turn)', waterMeterOfficial: 'official meter', waterMeterHeuristic: 'heuristic fallback', waterThresholdHint: 'Past {t} of the window, the handoff advisory is injected and the skeleton ledger is backfilled; metering prefers the official token-meter (same source as the chat context ring), falling back to the heuristic when unavailable; the window prefers the official route capacity (request/context contextWindow), then the active model in settings.yaml, or your manual override below.',
438
- autoContTitle: 'Auto continue', autoContHint: 'When on: once the water level passes the threshold AND the old session is idle (the authoritative harness running bit flipped to false at a turn boundary, replacing the old "level unchanged across two polls"), a confirm card appears — Agree = continue now, Decline = skip this boundary (no re-prompt for it), no answer for 30-40s = unattended, continue automatically. Before continuing, the old agent refreshes PLAN + ledger; the new session keeps workspace/model/reasoning effort and receives layered handoff material; suppressed for 30 minutes after each run.', autoContOn: 'Auto continue: on', autoContOff: 'Auto continue: off', autoContThreshold: 'Threshold', autoContCountdown: '⏳ Auto-continue in {s}s…', autoContCancel: 'Cancel', autoContConfirm: 'Water level at {p}% and the old session is idle (turn boundary) — continue into a new session now?', autoContAgree: 'Continue', autoContReject: 'Decline', autoContTimeout: 'auto-continue in {s}s (unattended)', autoContRejected: 'Skipped this handoff (will ask again at the next turn boundary)',
437
+ waterCardTitle: 'Context water level', waterAutoSrc: 'auto-detected', waterManualSrc: 'manual', waterOfficialSrc: 'route capacity', waterFallbackSrc: 'fallback default', waterNotMeasured: 'Not measured for this session yet (updates after the next turn)', waterMeterOfficial: 'official meter', waterMeterHeuristic: 'heuristic fallback', waterThresholdHint: 'Past {t} of the window, the handoff advisory is injected and the skeleton ledger is backfilled; metering prefers the official token-meter (same source as the chat context ring), falling back to the heuristic when unavailable; the window prefers the official route capacity (request/context contextWindow), then the active model in settings.yaml, or your manual override below; the trigger denominator is the official declared window (min with any provider-reported hard limit) — the reserved output budget is not deducted.',
438
+ autoContTitle: 'Auto continue', autoContHint: 'When on: once the water level passes the threshold AND the old session is idle (the authoritative harness running bit flipped to false at a turn boundary, replacing the old "level unchanged across two polls"), a confirm card appears — Agree = continue now, Decline = skip this boundary (no re-prompt for it), no answer for 30-40s = unattended, continue automatically. Before continuing, the old agent refreshes PLAN + ledger; the new session keeps workspace/model/reasoning effort and receives layered handoff material; suppressed for 30 minutes after each run.', autoContOn: 'Auto continue: on', autoContOff: 'Auto continue: off', autoContThreshold: 'Threshold', autoContCountdown: '⏳ Auto-continue in {s}s…', autoContCancel: 'Cancel', autoContConfirm: 'Context at {p}% of the official declared window (official compaction starts ~80%) and the old session is idle (turn boundary) — continue into a new session now?', autoContAgree: 'Continue', autoContReject: 'Decline', autoContTimeout: 'auto-continue in {s}s (unattended)', autoContRejected: 'Skipped this handoff (will ask again at the next turn boundary)',
439
439
  continueCardTitle: 'One-click continue', continueCardHint: 'Writes the handoff ledger → creates a new session → injects the handoff material as the first message → switches to the new session automatically (material = PLAN excerpt + latest ledger; falls back to the most recent global ledger when this workspace has none).', continueBtn: 'Continue in a new session', continueBusy: 'Working…',
440
440
  fStyle: 'Reflection style', fStyleHint: 'Life-style / Professional / Auto.',
441
441
  fLocale: 'UI language', fLocaleHint: 'Follows the DSH system language by default; you can also pin Chinese / English.', followSystem: 'Follow system language',
@@ -567,6 +567,28 @@ window.__ModuleLoader__.load({
567
567
 
568
568
  // ───────────────────────── 更新弹窗 / 首次指导 ─────────────────────────
569
569
  var CHANGELOG = {
570
+ '2.5.1': { zh: [
571
+ '修复:DSH Desktop(Node 22/Electron)下装上本插件后 dsh web 启动 1-2 分钟必崩——每日巡检对巨型会话文件全量解压,Node 22 的 zstd 会直接崩掉宿主进程。现改用头帧解码,只解会话开头几帧即可完成判定(社区贡献者 fei009009 的 PR #29,感谢!)。',
572
+ '同类的「切会话时从磁盘推导模型/窗口」路径一并加固(前 32 帧/8MB 上限),不再有全量解压风险点。',
573
+ ], en: [
574
+ 'Fix: on DSH Desktop (Node 22/Electron), dsh web used to crash 1-2 minutes after startup with this plugin installed — the daily patrol fully decompressed giant session files, and Node 22\'s experimental zstd crashes the host process. Scanning now decodes only the first few frames (PR #29 by fei009009, thank you!).',
575
+ 'The same full-decompression path when deriving model/window from disk on session switch is hardened too (first 32 frames / 8MB cap).',
576
+ ] },
577
+ '2.5.0': { zh: [
578
+ '★ 水位口径修正:触发分母改用**官方声明窗口**(不再扣预留输出)——阈值 0.75 的触发点从约 46 万 token 后移到约 78.6 万,与官方「约 80% 才压缩」同一条线,不再"刚过半就接续"。',
579
+ '★ 新增预测性硬墙:上下文实占 + 预留输出 > 窗口(下一次请求必被拒,即 400 事故边界)时立即硬触发;压缩/溢出硬触发不变。本机这类大预留路由会由硬墙率先兜底,属"最后一格"而非浪费。',
580
+ '★ 接续序号改持久计数器(~/.dsh/memory/cont-seq.json):全局单调、换工作区不重号、转写包落盘失败自动回滚不跳号;计数器缺失时从历史「接续 #N」标题解析兜底。新窗口的接续标题不再错乱/丢失。',
581
+ '修复:接续序号为空时新会话标题退回自动生成的问题(宿主与浏览器两条路径现在都保证有序号);浏览器路径 rename 失败不再静默。',
582
+ '流程:发版/回归/双语对账/痕迹巡检四份子代理任务书固化(docs/prompts),主对话只做决策——对你无感,只是发版更稳。',
583
+ '注:自动接续出厂默认仍为关;新口径验证满意后你可以在设置里打开,或等下一版评估翻回默认。',
584
+ ], en: [
585
+ '★ Water-level caliber fixed: the trigger denominator is now the officially declared window (the reserved output budget is no longer deducted) — at threshold 0.75 the trigger point moves from ~460K to ~786K tokens on a 1M window, aligning with the official ~80% compaction point.',
586
+ '★ Predictive hard wall added: when session tokens + reserved output exceed the window (the next request would be rejected — the exact 400-incident boundary), continue immediately; compaction / overflow hard triggers unchanged.',
587
+ '★ Continue sequence numbers now use a persistent counter (~/.dsh/memory/cont-seq.json): globally monotonic, no duplicates across workspaces, automatic rollback when the transcript pack fails to write (no skipped numbers), and a title-scan fallback for pre-existing data.',
588
+ 'Fix: empty sequence numbers no longer skip the session rename (both host and browser paths guarantee one); browser-side rename failures are no longer silent.',
589
+ 'Process: four self-contained subagent task books (release / regression / docs audit / trace patrol) — invisible to you, just steadier releases.',
590
+ 'Note: auto-continue factory default stays off; after verifying the new caliber, turn it on in settings (or wait for the next release to re-evaluate the default).',
591
+ ] },
570
592
  '2.4.2': { zh: [
571
593
  '变更:自动接续改为**出厂默认关闭**(老用户已落盘的显式设置不受影响)。原因:现有水位判据把「预留输出」从分母里扣掉,实测在上下文刚过半时就触发接续,与官方「约 80% 才压缩」相差约 45%,对不熟悉的人就是白烧 token —— 先默认关,等判据改成与官方压缩同一坐标系后再评估翻回。',
572
594
  '想用的人可以在「白板 / 接续」页签里手动打开(开关一直没动,只是默认值变了)。',
@@ -2157,7 +2179,7 @@ window.__ModuleLoader__.load({
2157
2179
  // 接续序号标题:让用户一眼区分新会话 vs 延续会话
2158
2180
  if (d.contSeq && rf.session.rename && typeof rf.session.rename === 'function') {
2159
2181
  var contTitle = (locale === 'zh' ? '接续 #' : 'Cont.#') + String(d.contSeq) + (d.wsBase ? ' · ' + d.wsBase : '')
2160
- try { await rf.session.rename({ sessionId: newId, title: contTitle }) } catch (eRen) {}
2182
+ try { await rf.session.rename({ sessionId: newId, title: contTitle }) } catch (eRen) { try { console.warn('[dsh-auto-memory] rename failed: ' + ((eRen && eRen.message) || eRen)) } catch (eRen2) {} }
2161
2183
  }
2162
2184
  // 沿用旧会话模型(provider/model/reasoningEffort 由 host 从旧会话 request/header 折叠而来;不可用则静默回退路由默认)
2163
2185
  if (d.provider && d.model && typeof rf.session.selectModel === 'function') {
package/lib/index.js CHANGED
@@ -44,7 +44,7 @@ import { createMemoryHubPre } from './memory-hub.js'
44
44
  import { pickConsolidationTextPre } from './intent-clean.js'
45
45
  import { createStorageManagerPre } from './storage-manage.js'
46
46
  import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
47
- import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames } from './subagent-gc.js'
47
+ import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames, decodeZstdFramesHead } from './subagent-gc.js'
48
48
  import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre, scanPressureSignalsPre } from './water-window.js'
49
49
  import { buildSourceCatalog, CorpusRegistry, canonicalize } from './m4-corpus.js'
50
50
  import * as nodeZlib from 'node:zlib'
@@ -267,9 +267,8 @@ const DEFAULT_CONFIG = {
267
267
  /** 水位越阈时自动写一篇系统骨架账本(每会话一次;模型仍应自己写正式交接)。 */
268
268
  waterLevelAutoHandoff: true,
269
269
  /** 自动接续(默认关):水位≥阈值 且 harness 权威 running==false(轮次边界,取代旧"两轮水位持平")时弹确认卡。 */
270
- // 2026-09-10 改为默认关。原因:现有触发口径把「预留输出」从分母里扣掉(effectiveWin = win − reserve),
271
- // 实测在本机路由下「上下文刚过半」就触发,与官方「约 80% 才压缩」相差约 45%,对公开用户是纯浪费。
272
- // 待口径改为与官方压缩同坐标系后再评估是否翻回(见 docs/internal/NEXT-VERSION-TODO.md)。
270
+ // 2026-09-10 改为默认关(口径未修前刚过半就触发,纯浪费)。2026-09-13 口径已修:分母=官方声明窗口
271
+ // (不再扣预留输出),与官方压缩同坐标系 —— 是否翻回默认开由用户验证后定夺,此处保持 false 不动。
273
272
  // 注:老用户已落盘的显式值不受影响,只有新装用户吃这个默认。
274
273
  autoContinueEnabled: false,
275
274
  /** 自动接续水位阈值(0.5-0.95);2026-09-08 与 waterLevelThreshold 同步下调到 0.75(官方自动压缩阈值 80%,必须留余量)。 */
@@ -1840,7 +1839,10 @@ class MemoryEngine {
1840
1839
  if (dir) {
1841
1840
  const sessionFile = await this.resolveSessionFile(dir)
1842
1841
  const raw = sessionFile ? await readFile(sessionFile).catch(() => null) : null
1843
- const events = raw ? decodeZstdFrames(raw).split('\n').map((l) => { try { return JSON.parse(l) } catch (e) { return null } }).filter(Boolean) : []
1842
+ // 头帧解码(PR #29,2026-09-13):Node 22(Electron)下全量解压巨型会话文件会令宿主进程直接崩溃,
1843
+ // 而 request/header + request/context 都在会话开头 —— 前 32 帧 / 8MB 内必命中,够推导窗口与模型。
1844
+ // 旧巨会话切到本会话时的「切会话推导」路径因此不再有全量解压,与巡检扫描同防。
1845
+ const events = raw ? decodeZstdFramesHead(raw, 32, 8 * 1024 * 1024).split('\n').map((l) => { try { return JSON.parse(l) } catch (e) { return null } }).filter(Boolean) : []
1844
1846
  const info = findSessionModelPre(events)
1845
1847
  const wi = await this.resolveWaterWindow(info.provider, info.model)
1846
1848
  value = {
@@ -1901,7 +1903,14 @@ class MemoryEngine {
1901
1903
  sig = scanPressureSignalsPre(events)
1902
1904
  } catch (eSig) {}
1903
1905
  const reserve = Number(sessModel.maxTokens) || Number(sig.reservedTokens) || 0
1904
- const effectiveWin = (reserve > 0 && reserve < win * 0.9) ? (win - reserve) : win
1906
+ // ② 判定窗(2026-09-13 口径修正,NEXT-VERSION-TODO 改点1):正常触发线与官方压缩同坐标系 ——
1907
+ // 分母 = 官方声明窗口 win,provider 自报过硬限(400 文本)时取二者较小值。**不再把预留输出 reserve
1908
+ // 从分母里扣**:旧口径(分母 = win − reserve =「单请求可用额度」)让水位被系统性放大 —— 本机
1909
+ // 1,048,576 − 384,000 = 664,576,上下文刚过半(≈46 万)就触发接续,而官方约 80%(≈83.9 万)才压缩。
1910
+ // reserve 收缩为两个职责:①「距硬墙余量」展示(waterLevelWall) ②硬判据 estTokens + reserve > 判定窗
1911
+ // (下一次请求就会被 provider 拒绝时才硬触发;本机即 400 事故的复现边界:666,044 + 384,000 > 1,048,576)。
1912
+ const hardWin = Number(sig.overflow && sig.overflow.windowTokens) || 0
1913
+ const triggerWin = hardWin > 0 ? Math.min(win, hardWin) : win
1905
1914
  let estTokens = 0
1906
1915
  let meterLayer = 'heuristic'
1907
1916
  try {
@@ -1944,39 +1953,41 @@ class MemoryEngine {
1944
1953
  } catch (e) {}
1945
1954
  // 旧实现硬截断到 1.5 → 任何超额水位都显示成 150%,掩盖真实占用(用户实测 761692/131072 被显示为 150%)。
1946
1955
  // 现在如实上报,仅做防溢出的 99 倍上限。
1947
- const ratio = effectiveWin > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / effectiveWin, 99) : 0
1956
+ const ratio = triggerWin > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / triggerWin, 99) : 0
1948
1957
  const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.75, 0.1)
1949
1958
  // 硬触发:官方已经压缩过/已撞过窗口墙 —— 这一刻必须接续,不依赖比例是否算得准。
1950
- const hard = compacted || overflowed
1959
+ // 2026-09-13 增加预测性硬墙:estTokens + reserve > 判定窗 ⇒ 下一次请求必被 provider 拒绝
1960
+ // (消息实占 + 预留输出 > 上限,v2.4.1 之前的 400 事故就是这个等式)。
1961
+ const hardWall = estTokens > 0 && (estTokens + reserve > triggerWin)
1962
+ const hard = compacted || overflowed || hardWall
1951
1963
  const armRatio = hard ? Math.max(ratio, threshold) : ratio
1952
1964
  rt.waterLevel = armRatio
1953
1965
  rt.waterLevelTokens = estTokens
1954
1966
  // 2026-09-10:pre-step 的 arm 走的是 runtime 字段(见 checkWaterLevelAtStep → armAutoContinue),
1955
1967
  // 这里只写了 ratio/tokens、漏写 window/source,于是确认卡显示「512,311 / 0 token」(实测)。
1956
1968
  // 与 state 同源同值,一处漏写两处都缺 —— 必须成对维护。
1957
- rt.waterLevelWindow = effectiveWin
1969
+ rt.waterLevelWindow = triggerWin
1958
1970
  rt.waterLevelSource = winSource
1959
1971
  this.state.waterLevelRatio = armRatio
1960
1972
  this.state.waterLevelTokens = estTokens
1961
- this.state.waterLevelWindow = effectiveWin
1973
+ this.state.waterLevelWindow = triggerWin
1962
1974
  this.state.waterLevelSource = winSource
1963
1975
  this.state.waterLevelMeter = meterLayer
1964
1976
  this.state.waterLevelModel = sessModel.model || winInfo.model || ''
1965
1977
  this.state.waterLevelAt = Date.now()
1966
1978
  this.state.waterLevelMeasuredTokens = estTokens
1967
1979
  this.state.waterLevelReserve = reserve
1968
- // 双口径(2026-09-10):官方小圈的分母是**声明窗口**(win),我们的分母是**可用额度**(effectiveWin),
1969
- // 两者在本机差 384,000(预留输出)。用户看到「才 50% 就接续」正是因为只看到小圈那个数 ——
1970
- // 把 ring 一并上报,界面就能同时显示「占可用额度 x% · 官方小圈 y%」,不再各说各话。
1971
- this.state.waterLevelRing = (win > 0 && Number.isFinite(estTokens)) ? (estTokens / win) : 0
1980
+ // 双口径(2026-09-13 起):水位/触发/ring 同用一个分母(判定窗 = 官方声明窗口,provider 自报过硬限时取 min)。
1981
+ // 旧「可用额度 vs 声明窗口」双口径随 reserve 退出分母而消失;保留的第二个数是「距硬墙余量」(hardWin − reserve,
1982
+ // 只有撞过 400 的会话才有 hardWin)。ring 与 ratio 同分母:硬限未知的普通会话里 ring 就等于官方小圈读数。
1983
+ this.state.waterLevelRing = (triggerWin > 0 && Number.isFinite(estTokens)) ? (estTokens / triggerWin) : 0
1972
1984
  // provider 自报的硬上限只有撞过墙(400 文本)才拿得到;拿得到就把「真实可写上限」也算出来,
1973
1985
  // 让界面能显示「距硬墙还剩多少 token」(本机 = 1,048,576 − 384,000 = 664,576)。
1974
- const hardWin = Number(sig.overflow && sig.overflow.windowTokens) || 0
1975
1986
  this.state.waterLevelWall = hardWin > 0 ? Math.max(0, hardWin - reserve) : 0
1976
- this.state.waterLevelHardTrigger = hard ? (compacted ? 'compaction' : 'overflow') : ''
1987
+ this.state.waterLevelHardTrigger = hard ? (compacted ? 'compaction' : overflowed ? 'overflow' : 'wall') : ''
1977
1988
  // 会话级记录:切会话时 /handoff-state?sessionId= 直接取这份,不再显示别的会话的数
1978
1989
  this.rememberWaterRecord(agent && agent.session && agent.session.id ? String(agent.session.id) : '', {
1979
- ratio: armRatio, measuredRatio: ratio, tokens: estTokens, measuredTokens: estTokens, window: effectiveWin,
1990
+ ratio: armRatio, measuredRatio: ratio, tokens: estTokens, measuredTokens: estTokens, window: triggerWin,
1980
1991
  windowRaw: win, reserve, source: winSource, meter: meterLayer, model: this.state.waterLevelModel || '',
1981
1992
  threshold, hardTrigger: this.state.waterLevelHardTrigger, at: Date.now(), live: true,
1982
1993
  })
@@ -1985,7 +1996,7 @@ class MemoryEngine {
1985
1996
  if (!rt.waterLevelAdvised || compacted || overflowed) {
1986
1997
  rt.waterLevelAdvised = true
1987
1998
  diag('water level advisory: ratio=' + ratio.toFixed(2) + (hard ? '→' + armRatio.toFixed(2) + '(hard:' + this.state.waterLevelHardTrigger + ')' : '') +
1988
- ' tokens=' + estTokens + '/' + effectiveWin + '(win=' + win + ' reserve=' + reserve + ')' +
1999
+ ' tokens=' + estTokens + '/' + triggerWin + '(win=' + win + ' reserve=' + reserve + (hardWin > 0 ? ' hardWin=' + hardWin : '') + ')' +
1989
2000
  ' src=' + (winSource || '?') + ' meter=' + meterLayer + (compacted ? ' compaction-detected' : ''))
1990
2001
  }
1991
2002
  if (this.config.waterLevelAutoHandoff !== false && (!rt.waterLevelAutoHandoffDone || compacted || overflowed)) {
@@ -2092,6 +2103,96 @@ class MemoryEngine {
2092
2103
  return true
2093
2104
  }
2094
2105
 
2106
+ // ---------- 接续序号 v2(2026-09-13,NEXT-VERSION-TODO 改点2):持久计数器 ----------
2107
+ /** 接续序号计数器文件(~/.dsh/memory/cont-seq.json)。放在全局记忆根而非各工作区 handoff/ 目录:
2108
+ * handoffDir 按工作区解析,放那里既会被"落盘失败/清理"带偏计数,也无法保证「换工作区接续不重复已有序号」;
2109
+ * 全局单文件 + 工作区记账键(byWorkspace,键=工作区路径,仅作记账与兜底)既跨重启持久、又全局单调不重号。
2110
+ * 旧口径(数 handoff 目录里 prev-session-*.md 的文件数 +1)在落盘失败/跨工作区/carry 复用时重复或跳号,
2111
+ * 且取数为空时 rename 被整段跳过、标题退回自动生成 —— 均为用户实机观察过的现象。 */
2112
+ contSeqFile() { return path.join(dshHome(), 'memory', 'cont-seq.json') }
2113
+
2114
+ loadContSeqState() {
2115
+ try {
2116
+ const obj = JSON.parse(readFileSync(this.contSeqFile(), 'utf8'))
2117
+ if (obj && typeof obj === 'object') {
2118
+ return {
2119
+ last: Number(obj.last) || 0,
2120
+ byWs: (obj.byWorkspace && typeof obj.byWorkspace === 'object') ? obj.byWorkspace : {},
2121
+ }
2122
+ }
2123
+ } catch (e) {}
2124
+ return null
2125
+ }
2126
+
2127
+ saveContSeqState(st) {
2128
+ try {
2129
+ mkdirSync(path.dirname(this.contSeqFile()), { recursive: true })
2130
+ writeFileSync(this.contSeqFile(), JSON.stringify({ last: st.last, byWorkspace: st.byWs, updatedAt: Date.now() }, null, 2), 'utf8')
2131
+ return true
2132
+ } catch (e) { diag('cont-seq persist failed (in-memory only): ' + ((e && e.message) || e)); return false }
2133
+ }
2134
+
2135
+ /** 兼容老数据:计数器文件缺失/为空时,从既有会话日志解析历史「接续 #N / Cont.#N」标题的最大序号。
2136
+ * 扫描 ~/.dsh/sessions/<工作区>/<会话>/session.jsonl[.zstd],按 mtime 新→旧最多 120 个;找不到返回 0。 */
2137
+ async scanMaxContSeq() {
2138
+ try {
2139
+ const sessionsDir = path.join(dshHome(), 'sessions')
2140
+ const wsDirs = await readdir(sessionsDir).catch(() => [])
2141
+ const cands = []
2142
+ for (const w of wsDirs) {
2143
+ const sids = await readdir(path.join(sessionsDir, w)).catch(() => [])
2144
+ for (const sid of sids) {
2145
+ const file = await this.resolveSessionFile(path.join(sessionsDir, w, sid))
2146
+ if (!file) continue
2147
+ const st = await stat(file).catch(() => null)
2148
+ if (st && st.isFile()) cands.push({ file, mt: Number(st.mtimeMs) || 0 })
2149
+ }
2150
+ }
2151
+ cands.sort((a, b) => b.mt - a.mt)
2152
+ let max = 0
2153
+ for (const { file } of cands.slice(0, 120)) {
2154
+ try {
2155
+ const buf = await readFile(file)
2156
+ const text = file.endsWith('.zstd') ? (zstdDec ? zstdDecodeAllFrames(buf) : '') : buf.toString('utf8')
2157
+ if (!text) continue
2158
+ const re = /(?:接续 #|Cont\.#)(\d+)/g
2159
+ let m
2160
+ while ((m = re.exec(text))) { const n = Number(m[1]) || 0; if (n > max) max = n }
2161
+ } catch (eF) {}
2162
+ }
2163
+ return max
2164
+ } catch (e) { return 0 }
2165
+ }
2166
+
2167
+ /** 分配下一个接续序号:全局单调递增(换工作区也不重号),持久化到 cont-seq.json。
2168
+ * 文件缺失时先从历史标题解析最大值兜底(兼容 v2.4.2 及之前的文件计数老数据)。
2169
+ * 持久化失败只记 diag、序号照常返回(退化为进程内计数),不得阻塞接续。 */
2170
+ async allocContSeq(wsKey) {
2171
+ const st = this.loadContSeqState() || { last: 0, byWs: {} }
2172
+ if (!(st.last > 0)) {
2173
+ const scanned = await this.scanMaxContSeq().catch(() => 0)
2174
+ if (scanned > st.last) st.last = scanned
2175
+ }
2176
+ for (const v of Object.values(st.byWs)) { const n = Number(v) || 0; if (n > st.last) st.last = n }
2177
+ const next = st.last + 1
2178
+ st.last = next
2179
+ if (wsKey) st.byWs[String(wsKey)] = next
2180
+ this.saveContSeqState(st)
2181
+ return next
2182
+ }
2183
+
2184
+ /** 回滚刚分配的序号(转写包落盘失败时调用,保证不跳号)。只撤自己刚发出的那个号。 */
2185
+ rollbackContSeq(wsKey, seq) {
2186
+ try {
2187
+ const n = Number(seq) || 0
2188
+ if (!n) return
2189
+ const st = this.loadContSeqState() || { last: n, byWs: {} }
2190
+ if (st.last === n) st.last = n - 1
2191
+ if (wsKey && Number(st.byWs[String(wsKey)]) === n) delete st.byWs[String(wsKey)]
2192
+ this.saveContSeqState(st)
2193
+ } catch (e) { diag('cont-seq rollback failed: ' + ((e && e.message) || e)) }
2194
+ }
2195
+
2095
2196
  /** M-CM6-C·宿主兜底自动接续(2026-09-08,2.2.6):浏览器端 AutoContinueHost 被后台节流/关闭时,
2096
2197
  * 宿主自身在倒计时到期后直接建新会话。协议:turn-stopping 且水位≥阈值 → arm 倒计时(confirmSeconds,
2097
2198
  * 默认 35s,写入 _autoContState)→ 浏览器轮询 GET auto-continue-state 显示确认卡(同意=POST decide
@@ -2535,6 +2636,10 @@ class MemoryEngine {
2535
2636
  const NL = String.fromCharCode(10)
2536
2637
  const stamp = new Date().toISOString().slice(11, 19).replace(/:/g, '')
2537
2638
  const outPath = path.join(p.handoffDir, 'prev-session-' + sid.slice(0, 8) + '-' + stamp + '.md')
2639
+ // 接续序号 v2(2026-09-13):先分配序号、再写包;写包失败回滚计数器(不跳号)。
2640
+ // 序号属于这次接续(新会话标题「接续 #N」用),分配即持久化,重启/换工作区都不重号。
2641
+ let contSeq = 0
2642
+ try { contSeq = await this.allocContSeq(p.ws) } catch (eSeq) { diag('allocContSeq error: ' + ((eSeq && eSeq.message) || eSeq)) }
2538
2643
  const body = [
2539
2644
  '# 旧会话完整转写(' + sid + ')', '',
2540
2645
  '- 旧会话 ID: ' + sid,
@@ -2551,11 +2656,17 @@ class MemoryEngine {
2551
2656
  body.push(chunk)
2552
2657
  total += chunk.length
2553
2658
  }
2554
- await mkdir(p.handoffDir, { recursive: true })
2555
- await writeFile(outPath, body.join(NL), 'utf8')
2659
+ try {
2660
+ await mkdir(p.handoffDir, { recursive: true })
2661
+ await writeFile(outPath, body.join(NL), 'utf8')
2662
+ } catch (eW) {
2663
+ // 包落盘失败 → 回滚刚分配的序号,保证不跳号(carry 会用持久计数器重新兜底)
2664
+ if (contSeq) { try { this.rollbackContSeq(p.ws, contSeq) } catch (eR) {} }
2665
+ throw eW
2666
+ }
2556
2667
  const tail = msgs.slice(-20).map((m) => m.role + ': ' + m.text.slice(0, 700)).join(NL + '---' + NL)
2557
- diag('prev-session pack built: sid=' + sid + ' msgs=' + msgs.length + ' model=' + (model || '-') + '@' + (provider || '-') + ' effort=' + (reasoningEffort || '-') + ' -> ' + outPath)
2558
- return { sessionId: sid, transcriptPath: outPath, provider, model, reasoningEffort, agentPreset: preset, cwd: cwd || p.ws, tailText: tail, msgCount: msgs.length }
2668
+ diag('prev-session pack built: sid=' + sid + ' msgs=' + msgs.length + ' model=' + (model || '-') + '@' + (provider || '-') + ' effort=' + (reasoningEffort || '-') + ' contSeq=' + contSeq + ' -> ' + outPath)
2669
+ return { sessionId: sid, transcriptPath: outPath, contSeq, provider, model, reasoningEffort, agentPreset: preset, cwd: cwd || p.ws, tailText: tail, msgCount: msgs.length }
2559
2670
  } catch (e) { diag('buildPrevSessionPack error: ' + (e && e.message)); return null }
2560
2671
  }
2561
2672
 
@@ -2647,9 +2758,16 @@ class MemoryEngine {
2647
2758
  parts.push('【锚点表 · 记忆条目地图(按需下钻,不必通读)】' + NL + anchorSections.join(NL))
2648
2759
  }
2649
2760
  } catch (eAnchor) {}
2650
- // 接续序号:统计 handoff 目录里已有的 prev-session 包数量 +1(每次一键/自动接续产出一个)
2651
- let contSeq = 1
2652
- try { const prevs = (await readdir(p.handoffDir).catch(() => [])).filter((f) => /^prev-session-.*\.md$/.test(f)); contSeq = prevs.length + 1 } catch (eS) {}
2761
+ // 接续序号 v2(2026-09-13,NEXT-VERSION-TODO 改点2):持久计数器,全局单调(跨工作区不重号)。
2762
+ // 取值链:转写包路径已分配的(pack.contSeq)→ 包缺失/失败时用持久计数器兜底分配 → 计数器彻底不可用
2763
+ // 再退旧「文件数+1」口径 → 最后退 1。contSeq 必须有值:为空时两侧 rename 都被跳过、新会话标题
2764
+ // 退回自动生成(v2.4.2 前的老毛病),这里从源头保证非空。
2765
+ let contSeq = Number(pack && pack.contSeq) || 0
2766
+ if (!contSeq) { try { contSeq = await this.allocContSeq(p.ws) } catch (eSeq2) {} }
2767
+ if (!contSeq) {
2768
+ try { const prevs = (await readdir(p.handoffDir).catch(() => [])).filter((f) => /^prev-session-.*\.md$/.test(f)); contSeq = prevs.length + 1 } catch (eS) {}
2769
+ }
2770
+ if (!contSeq) contSeq = 1
2653
2771
  const wsBase = String((pack && pack.cwd) || p.ws || '').split(/[\\/]/).filter(Boolean).pop() || ''
2654
2772
  return {
2655
2773
  ok: true,