dsh-compact-relay 0.3.6 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,8 +1,81 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
4
-
5
- (暂无新改动)
3
+ ## 0.4.0(2026-10-24)
4
+
5
+ ### 修复:压缩/交接后「压缩判断记录」从未失效(BUG-013,0.3.1 起存在)
6
+
7
+ - **问题(真机发现,2026-10-24)**:压缩/交接后旧的 `compressHint` 本该自动失效(环上断点消失、不再抑制 cue),实际一直判定「有效」。P0-1 上线后真机诊断包里 `counters.cueInjected=0` 的直接原因就是它——旧 hint 的 ratio(26.4%)高于当前 ratio(18.5%),`ratio - hint.ratio < 10%` 的 hint 抑制规则把 cue 全部挡掉;指示灯环上也残留旧断点(26%)。
8
+ - **根因**:`lib/actions/state.js` 调 `lastCheckpointSurfaceSeq(session)` 时**漏传第二个参数** `isCheckpointSource`;`lib/dsh/session-view.js` 里对 `undefined` 调用判定函数抛 TypeError,又被事件循环里的 try/catch 吞成「不是 checkpoint」→ 永远返回 -1 → `checkpointSeq = undefined` → `compressHintIsActive(hint, undefined)` 恒为 true。(对照:`lib/dsh/select-range.js` 传参正确,所以选段/不二压一直正常。)
9
+ - **修复**:① `state.js` 显式传 `deps.engine?.isCompactCheckpointSource`;② `session-view.js` 的 `lastCheckpointSurfaceIdx` / `lastCheckpointSurfaceSeq` 给 `isCheckpointSource` 加默认值 `engine.isCompactCheckpointSource`(dsh→dsh 同层、显式传参优先;纯 node 单测不注册 DSH 包解析钩子时安全降级为「无 checkpoint」,不抛);③ 回归测试加进 `test/state-passive.mjs`(旧 hint + 更新的 checkpoint → `compressHint: null`;hint 比 checkpoint 新 → 保留)。
10
+ - **影响面**:只影响「判断记录新鲜度」的显示与 cue 抑制;不改压缩/交接行为、不改拒绝条件、不动 client。此 bug 自 0.3.1 起存在,随 0.4.0 修复(不回溯 0.3.6)。
11
+
12
+ ### P2-3 原生自动压缩「真实触发线」(按路由容量算,不再只显示窗口比例)
13
+
14
+ - **问题**:指示灯一直把 `compaction-basic` 的 `thresholdRatio`(默认 80%)当触发线显示,但 DSH 实际按 token 数触发:`thresholdTokens = floor(min(W × rate, W − 输出预留 − headroom))`,触发点 = `thresholdTokens / W`。当路由请求给输出预留较多或 `headroomTokens` 被改过时,真实触发点会明显低于 80%(作者在 Windows 真实会话里问过「原生压缩线为什么降到了 50」)。
15
+ - **修复**:新增 `lib/core/native-line.js`(纯函数,公式与 dsh-compaction-basic 源码一致);`lib/dsh/native-compaction.js` 增读 `config.headroomTokens`(默认 65536)与嵌套 `modelPolicies`(按 provider+model 精确覆盖 `thresholdRatio`/`headroomTokens`,支持列表与映射两种 YAML 写法;**BUG-012 保持**:嵌套值不会变成全局线);`lib/dsh/pressure.js` 新增 `routeBudgetOf()`:输出预留 = `session.requestHeader().config.maxTokens ?? 适配器 defaultMaxTokens ?? 0`,窗口复用 `llm.resolveModelInfo` 缓存;`readState` 的 `native` 新增 `ratio/thresholdTokens/contextWindow/reservedCompletionTokens/headroomTokens/overridden/route/unavailable`(`thresholdRatio` 仍下发生效比例,老客户端可回落;算不出时 `ratio=null + unavailable=原因`)。
16
+ - 客户端:环上原生刻度/越线闪烁改用真实线;悬停标题显示 `原生自动压缩线 X%(thresholdTokens / 窗口,provider/model,按路由覆盖)`;首次引导文案同步。
17
+ - 测试:`test/native-line.mjs`(公式 80% vs 67.8%、边界/无预算、解析与 BUG-012 回归、路由覆盖匹配、readState 下发与老字段兼容);并入 `npm test`。
18
+ - 不改任何触发/拒绝条件,只改「显示与数据」;版本仍 0.3.6,未发布。
19
+
20
+ ### P2-1 交接预览「两段可编辑」(只允许 约束 / 下一步)
21
+
22
+ - 预览接口新增 `editable: { titles: ['约束','下一步'], maxChars: 4000, note, sections }` 与 `ordinal`(第 N 次交接);客户端交接预览卡只给这两段 textarea(`data-cr-handoff-edit`),其余段只读并显示「程序生成段不可改」。
23
+ - 确认时客户端只提交「用户真的改过且非空」的段;宿主在压缩/写盘前校验:非法字段(程序生成段 / 拼错段名 / 非对象)→ `code='edits'`(HTTP 400,消息点名不能改的段);单段 >4000 字符拒绝(不静默截断);空值/未改回落重新生成的原文。应用方式:生成九段后覆盖这两段再渲染(`composeHandoffText` 仍按九段固定顺序),程序段永远来自程序/模型。
24
+ - 九段结构不变;必须用户点「确认交接」才写盘(预览/校验过程不写盘)。action 路由 body 上限从 8KB 放宽到 48KB(容纳两段编辑)。
25
+ - 测试:`test/handoff-p2.mjs`(白名单/空值/超长/非法字段/九段顺序/客户端入口静态守卫)+ `test/handoff.mjs` 集成(编辑后交接单落盘内容生效、程序段被拒且零副作用、空编辑回落)。
26
+
27
+ ### P2-2 接力链元数据补全
28
+
29
+ - relay 记录 canonical 字段补齐:`parentId` / `childId` / `ordinal` / `at`(ms)/ `atIso`(本地 ISO);0.3 旧字段 `parentSessionId` / `index` 继续写入,读侧兼容旧记录并补齐 canonical 字段。
30
+ - 链式序号:首次交接 `ordinal=1`;从子会话继续交接 `ordinal=2` 且 `parentId` 指向真正父会话;下一次交接标题序号改用 `ordinal` 推导。
31
+ - 交接单头新增 `- 交接序号:第 N 次交接`(预览草稿里也能看到);接力链视图留 0.5,不做。
32
+ - 测试:`test/handoff-p2.mjs`(canonical/旧记录兼容)+ `test/handoff.mjs`(首次 ordinal=1 + 文档头、链式第 2 次 ordinal=2 / parent 正确)。
33
+
34
+ ### P1-3 压缩/交接可审计流水(轻账本)+ 设置页累计数字
35
+
36
+ - 新增轻账本 `~/.dsh/compact-relay-ledger.jsonl`(JSONL,一行一条,schema `dcr-ledger/v1`):每次**压缩/交接成功**后追加一条——`kind`、本地时间、耗时 ms、压前/压后 tokens + ratio、腾出 tokens、pin 命中(`inRange/total`)、交接单落盘路径(仅路径)与 `childId`;交接内部那次压缩不重复记账(一次 `/handoff` 一条)。
37
+ - 隐私:allowlist 构造,只记数值/枚举/时间/路径,**不含消息原文、摘要、交接单正文、sessionId、cwd/用户名**;测试用敏感 fixture 断言产物中不存在。
38
+ - 有界与降级:只保留最近 200 条(超限整文件重写为最近 200 条,tmp+rename 原子替换);文件损坏按行跳过;任何读写失败静默返回 `{ok:false}`,**绝不影响压缩/交接本体**(测试覆盖:账本 record 抛错时交接仍成功;账本路径不可写时动作照常、文件损坏仍可读可写)。
39
+ - 设置页:`GET /api/compact-relay-settings`(含保存/恢复默认响应)新增 `ledger` 累计字段(`count/compactCount/handoffCount/freedTokens/avgDurationMs/lastAt`),设置页「关于与危险操作」显示一行 `累计:已压缩 X 次 · 交接 Y 次 · 腾出约 Z tokens · 平均耗时 W`;无记录时显示引导文案。数字与账本文件同源(测试断言相等),不做大盘。
40
+ - 新增 `test/ledger.mjs`(字段完整 / allowlist + 敏感 fixture / JSONL 容错 / 上限裁剪 / 不可写降级 / 设置数字一致 / 客户端累计行静态守卫)+ `test/handoff.mjs` 压缩与交接成功后的账本落盘断言 + `test/plugin-load.mjs` 设置端点累计断言;并入 `npm test`。
41
+ - 不改压缩引擎/拒绝条件/阈值;版本仍 0.3.6,未发布。
42
+
43
+ ### P1-1 压缩预览「建议锁定」提示(只提示、不自动锁)
44
+
45
+ - 预览接口新增 `preview.pinSuggest = { count, items: [{seq, role, reason, snippet}] }`:宿主侧纯本地启发式(`lib/core/pin-suggest.js`)对「本次将被压掉的历史」识别三类信号——`明确要求别改`(用户消息里「不要改 / 别动 / 保持原样 / 必须保留」等)、`报错堆栈`(异常类名 / 堆栈帧 / 堆栈 / 报错等)、`关键决策`(决定 / 拍板 / 采用 / 改用 / 结论等);最多 5 条、每条一句摘要 ≤60 字符、不贴全文;短句、系统消息、运行时合成的 user/message(checkpoint 等)、普通工具输出不进入候选。
46
+ - 客户端预览卡只渲染「建议锁定 N 条(先 /pin 再压)」+ 每条理由/摘要;**绝不自动锁定**(预览不写任何会话事件,测试断言 `session.log` 不变)。`/bc` 预览 message 里也带一句建议。
47
+ - 收敛实现:消息角色/摘要抽取挪到 `lib/core/pin-anchors.js`,`/pin list`、建议锁定、交接单锚点共用一份(删除 `actions/pin.js` 内私有 `previewOf`)。
48
+
49
+ ### P1-2 pin 作为「保留契约」(声明与展示,不承诺第三方引擎)
50
+
51
+ - 交接单九段结构不变:锚点写进程序段「当前进度」——`- 锁定锚点(保留契约,接手者请原样保留这些消息)` + 每条 `seq N · 角色 · 「一句摘要(≤60 字符)」`(最多列 10 条,其余提示 `/pin list` 看全)+ 一行「跨引擎压缩器是否遵守取决于其实现」。
52
+ - 压缩预览新增 `preview.pinHits = { inRange, total }`:`inRange` = 本次候选范围里被锁定保护而跳过的条数,`total` = 会话仍活着的锁定总数;客户端预览卡显示「pin 命中 N 条」并区分范围外锁定。
53
+ - 不回归:锁定消息仍在压缩范围之前截断(`clipRangeByPins` 既有逻辑),压缩后原样保留在 surface。
54
+ - 测试:新增 `test/pin-contract.mjs`(候选识别含反例、建议条数/上限、交接单锚点、pin 命中数、预览只读不自动锁)+ `test/handoff.mjs` A1b 预览断言补锚点;并入 `npm test`。
55
+ - 边界:只做「声明与展示」,不承诺其他压缩器遵守 pin;不改压缩引擎/拒绝条件/阈值;版本仍 0.3.6,未发布。
56
+
57
+ ### P0-1 判断机制加强(未发布,代码已落地,待 3 次真实任务验收)
58
+
59
+ - **背景(BUG-010 后续)**:Windows 173 个真实会话里 cue 注入 18 次、`mark_compress_point` 调用 **0 次**;Mac 仅在「脚本写完 + 真机验证通过」这种明确节点自发调用过。归因:旧 cue 的「不确定就不用调」让模型倾向保守跳过、工具描述强调拒绝风险、每轮注入变成背景噪声。
60
+ - **cue 文案 v2**(`lib/core/compress-cue.js`):把抽象「自然节点」换成 4 个可自检的完成信号——功能已实现并验证 / 文档已交付 / 问题已定位并修复 / 准备交接;明确「不确定也建议调一次让工具判断:被拒绝不会改动历史、不影响会话」;保留「不处于阶段收尾就不用管」边界,删除「不确定就不用调」。
61
+ - **注入门控**(`lib/adapters/prompt.js` + `lib/core/policy.js` 常量 `CUE_MIN_RATIO=0.10` / `CUE_MAX_RATIO=0.40` / `CUE_MIN_TURNS=3`):只有全满足才注入——① 容量落在 `[max(10%, 动态最小线), min(40%, 压缩线))`(到压缩线不注入,避免诱导注定被拒的调用);② 距上次注入 ≥3 轮(按 text 回调调用次数计,进程内 Map、不新增持久状态、不落盘);③ 一轮内最多一次。异常/无 agent/无缓存行为不变(返回空串)。
62
+ - **工具描述 v2**(`lib/adapters/tool.js`):明确「用户和模型都可以调用」;「被拒不需要致歉、不会改动历史、也不影响会话,不需要重试」;补充典型调用时机(阶段完成 / 准备交接前 / 长任务中途确认可回退点);把「不需要每个小步骤都调用」弱化为句尾的「小步骤不用调用」。
63
+ - **计量接入**(`lib/dsh/diag-counters.js`、`lib/actions/compress-hint.js`、`lib/adapters/prompt.js`):新增 `cueInjected` / `markCalls` / `markAccepted` / `markRejected` 四个计数(被拒也算调用)。只记次数与结果、不记 cue 原文/任务内容(隐私口径与 P0-2 一致);P0-2 诊断包的 `counters` 与 Markdown 直接带出这四个数 → 真实使用后看数字即可判断「标准 B:3 次真实任务 ≥2 次模型自发调用」,不用翻会话日志。
64
+ - **测试**:`test/compress-cue.mjs` 重写覆盖 U1(文案 v2:4 信号 + 被拒无副作用 + 不含旧措辞)、U2(区间 0.08 不注入 / 0.12 注入 / 0.40、0.45 不注入 + 第 4 轮再注入)、U3(无 agent 不注入 / 同轮不重复 / 异常降级)、U6(5% 桶 / 动态最小线 / hint 抑制 / state-hub 原有断言随 v2 同步);`test/smoke.mjs` 增 U4(markCompressHint 成功/被拒计数 + 不变式);`test/plugin-load.mjs` 增 U5(工具描述关键词)+ cue 门控下的 20% 注入断言;`test/diagnostics.mjs` 同步 counters 白名单/渲染。
65
+ - **范围红线(未违反)**:不改压缩/交接行为、不改拒绝条件(最小线 5% / 压缩线 40% 不动)、不自动动作、不动指示灯客户端、零新依赖零网络、版本号仍 **0.3.6**。
66
+ - **未完成(发布前)**:需 3 次真实长任务验证标准 B;soak 记录模板已补「判断机制读数方法」。
67
+
68
+ ### P0-2 诊断包最小版(已完成)
69
+
70
+ - **新增「诊断信息」入口**(设置 → 压缩接力 → 帮助):【生成并预览】在本机生成一份 Markdown + JSON 诊断信息,弹层里先预览,再【复制 Markdown】/【复制 JSON】;【打开问题报告页】打开预填版本/平台短字段的 GitHub issue 页。**不做任何自动上传、遥测或后台外发,也不加网络能力。**
71
+ - **新增只读端点** `GET /api/compact-relay-diagnostics?sessionId=`:沿用宿主 `connection.requestRejection` 鉴权(401/403);只支持 GET(非 GET 405);无活动会话时 `state: null`;返回 `{ok, schema, version, generatedAt, markdown, json, issueUrl}`,响应不含正文/堆栈(失败只回短文案)。
72
+ - **新增 `lib/dsh/diag-counters.js`**:进程内计数/耗时/错误码(重启清零)。压缩/交接成功失败计数、最近 20 个耗时样本的 p50/p95、按 `(code, phase)` 聚合的最近 20 条错误码(只收 code/phase/at/count,**不收 message**),锁定动作计数。
73
+ - **新增 `lib/dsh/diagnostics.js`**:`dcr-diagnostics/v1` 产物构造。allowlist 逐字段取值(禁止整对象透传),产物只含数值/枚举/布尔/错误码;JSON ≤ 64KB、errors ≤ 20 条且单条 ≤ 200 字符;额外正则脱敏(GitHub token / `sk-` / Bearer / cookie / 邮箱 / 绝对路径 / 内网 IP / session id 等命中替换为 `[REDACTED]`,只记数量不保留原文);issue URL 只预填短字段、长度 < 7500。
74
+ - **埋点(不改行为与文案)**:`actions/compact-run.js` 与 `actions/handoff.js` 记成功/失败/耗时/错误码;`actions/pin.js` 只在成功锁定时计数(解锁不计)。
75
+ - **新增 `lib/actions/diagnostics.js`**:诊断组装的动作层入口(仓库 A9 架构守卫要求 adapters 不直接 import `dsh/`,故组装落在 actions,HTTP 适配层只做协议转换)。
76
+ - **文档/模板**:README(中英)新增「诊断信息与隐私 / Diagnostics and privacy」节;`.github/ISSUE_TEMPLATE/bug_report.yml` 文案对齐「生成 → 预览 → 复制」;设置页按钮只用「生成 / 预览 / 复制」,不出现「发送 / 上传 / 上报」。
77
+ - **测试**:新增 `test/diagnostics.mjs`(T1–T9:形状白名单、敏感 fixture 脱敏、禁止字段、零网络(fetch 抛错 + 源码扫描)、URL < 7500、64KB/20 条/200 字符上限、p50/p95、计数器、文案),并入 `npm test`;`test/plugin-load.mjs` 增加只读端点用例(鉴权 / 405 / 白名单产物 / 无会话 `state=null`);`test/e2e/settings-ui.mjs` 增加诊断信息真机检查(帮助区入口按钮、点【生成并预览】后的弹层内容含 `dcr-diagnostics/v1` 与「脱敏」、不含 sessionId/绝对路径、点【复制 Markdown】必须有成功或失败反馈)。
78
+ - 版本号仍保持 **0.3.6**(0.4.0 未发布、不 bump);`package.json` 无新增依赖,`capabilities` 未新增 network。
6
79
 
7
80
  ## 0.3.6(2026-10-24,原生压缩线解析作用域修复)
8
81
 
package/README.en.md CHANGED
@@ -91,7 +91,7 @@ Configuration is available under **Settings → 压缩接力** (in the account m
91
91
  - **Compaction behaviour**: configure the summary retention limit, the recent-token retention count, and the handoff document directory.
92
92
  - **DSH native auto-compaction**: read the host's current threshold and enable or disable DSH native auto-compaction (a backup is created before the profile configuration is modified; a restart is required for the change to take effect).
93
93
  - **Colors & appearance**: configure five base colors; the system derives adapted shades for the light and dark themes automatically.
94
- - **Help**: ring legend, usage notes, feedback links, and a "show the onboarding again" button.
94
+ - **Help**: ring legend, usage notes, feedback links, a "show the onboarding again" button, and **Diagnostics** (generated locally, preview-then-copy — see the next section).
95
95
  - **About / dangerous operations**: version number, configuration file path, and factory reset.
96
96
 
97
97
  ![Settings](assets/settings.jpg)
@@ -104,6 +104,23 @@ The first time a session is opened, the ring plays a brief highlight animation w
104
104
 
105
105
  ![First-run onboarding](assets/onboarding.jpg)
106
106
 
107
+ ## Diagnostics and privacy
108
+
109
+ **Settings → 压缩接力 → Help → Diagnostics**:
110
+
111
+ - **Generate and preview**: creates a Markdown + JSON diagnostics report **locally**, shows it in a preview dialog first; then **Copy Markdown** / **Copy JSON** and paste it wherever you choose (for example a GitHub issue).
112
+ - **Open issue page**: opens a GitHub issue page with the short version/platform fields pre-filled. It does **not** paste the diagnostics report for you — you decide whether and where to paste it.
113
+
114
+ **What the report contains**: plugin version, DSH version (empty when unavailable), Node / OS / architecture / locale; configured thresholds, retention, timeouts and color mode; a numeric snapshot of the current state (window, used tokens, summaries, pinned count); per-load counts for compaction/handoff/pinning, p50/p95 durations, and the last 20 error codes.
115
+
116
+ **What it never contains**: chat content, summaries, handoff document bodies, tool arguments/results, file paths, usernames, hostname, session ids, keys/tokens/cookies, other plugin lists or custom links. The report is built field-by-field from an allowlist inside the host process, then run through a second regex redaction pass (matches are replaced with `[REDACTED]` and only the count is recorded — never the original text).
117
+
118
+ **Three privacy notes**:
119
+
120
+ 1. The report is **generated locally only**: the plugin has no automatic upload, telemetry or outbound network requests, and the diagnostics endpoint is a read-only GET with no upload channel.
121
+ 2. **No conversation content is touched**: only numbers, enums, booleans and error codes are collected; messages, summaries and handoff bodies are never read.
122
+ 3. Opening the issue page only opens a browser. Issues are public by default; whether and where you paste is up to you. The plugin adds no network capability and reports nothing.
123
+
107
124
  ## Commands
108
125
 
109
126
  | Command | Description |
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/dsh-compact-relay?label=npm&color=cb3837)](https://www.npmjs.com/package/dsh-compact-relay)
10
10
  [![DSH](https://img.shields.io/badge/DSH-0.2.x-4d6bfe)](https://www.deepseek.com/harness/)
11
11
  [![license](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)
12
- [![version](https://img.shields.io/badge/version-0.3.4-blue)](CHANGELOG.md)
12
+ [![version](https://img.shields.io/badge/version-0.4.0-blue)](CHANGELOG.md)
13
13
 
14
14
  </div>
15
15
 
@@ -91,7 +91,7 @@ dsh plugin --profile desktop add github:cfanzx/dsh-compact-relay
91
91
  - **压缩行为**:配置摘要保留份数上限、近期原文保留 Token 数及交接文档存储目录。
92
92
  - **DSH 原生自动压缩**:读取宿主当前阈值,支持开启或关闭 DSH 原生自动压缩(修改 profile 配置前自动生成备份,重启后生效)。
93
93
  - **颜色与外观**:配置五项基准色,系统将根据浅色/深色主题自动派生适配色阶。
94
- - **帮助**:提供指示环图例、使用说明、问题反馈入口及「再看一次初次引导」按钮。
94
+ - **帮助**:提供指示环图例、使用说明、问题反馈入口、「再看一次初次引导」按钮,以及「诊断信息」(只在本机生成、可预览/复制,见下节)。
95
95
  - **关于与危险操作**:查看版本号、配置文件路径,支持恢复出厂默认设置。
96
96
 
97
97
  ![设置页](assets/settings.jpg)
@@ -104,6 +104,23 @@ dsh plugin --profile desktop add github:cfanzx/dsh-compact-relay
104
104
 
105
105
  ![首次引导](assets/onboarding.jpg)
106
106
 
107
+ ## 诊断信息与隐私
108
+
109
+ 设置 → 压缩接力 → **帮助** →「诊断信息」:
110
+
111
+ - 点【生成并预览】:**只在本机**生成一份 Markdown + JSON 诊断信息,先弹层预览;再点【复制 Markdown】/【复制 JSON】复制,自己决定粘贴到哪(例如 GitHub issue)。
112
+ - 点【打开问题报告页】:打开预填了版本/平台短字段的 GitHub issue 页,**不会**把诊断信息直接贴进去——是否粘贴、粘贴什么由你决定。
113
+
114
+ **诊断包里有什么**:插件版本、DSH 版本(拿不到就留空)、Node / 操作系统 / 架构 / 语言环境;配置的阈值、保留量、超时与颜色模式;当前状态的数值摘要(窗口、已用、摘要数、锁定数等);本次加载后的压缩/交接/锁定计数、耗时 p50/p95,以及最近 20 条错误码。
115
+
116
+ **绝不包含**:聊天内容、摘要、交接单正文、工具参数/结果、文件路径、用户名、hostname、会话 id、密钥/token/cookie、其他插件清单或自定义链接。产物在宿主进程内按白名单逐字段构造,并额外做一遍正则脱敏(命中只替换为 `[REDACTED]`、只记数量,不保留原文)。
117
+
118
+ **三条隐私说明**:
119
+
120
+ 1. 诊断信息**只在本机生成**:插件没有任何自动上传、遥测或外发网络请求;诊断端点只提供只读 GET,没有上传通道。
121
+ 2. **不碰对话内容**:只采集数值、枚举、布尔与错误码,不读取消息、摘要或交接单正文。
122
+ 3. 打开 issue 页只是打开浏览器;issue 默认公开,是否粘贴、粘贴到哪由你决定。插件不加网络能力,也不会上报任何数据。
123
+
107
124
  ## 命令列表
108
125
 
109
126
  | 命令 | 功能说明 |
@@ -126,7 +143,7 @@ dsh plugin --profile desktop add github:cfanzx/dsh-compact-relay
126
143
 
127
144
  ## 版本状态与开源许可
128
145
 
129
- - **当前版本**:0.3.4(0.3.1 为首个公开版本;0.3.2 修正设置页链接与联系方式;0.3.3 完成 Windows 适配;0.3.4 修复「适合压缩的节点几乎不触发」的判断机制,并补交接单 Markdown 渲染与列表加载更多)。已在 macOS(DSH 0.2.1-rc.2)与 Windows 11(Node 24.22)完成测试,仅调用 DSH 公开导出接口。变更记录详见 [CHANGELOG.md](CHANGELOG.md),后续规划与已知限制详见 [ROADMAP.md](ROADMAP.md),构建与发布流程详见 [DEVELOPMENT.md](https://github.com/cfanzx/dsh-compact-relay/blob/main/DEVELOPMENT.md)。
146
+ - **当前版本**:0.4.0(0.3.1 为首个公开版本;0.3.2 修正设置页链接与联系方式;0.3.3 完成 Windows 适配;0.3.4 修复判断机制并补交接单渲染;0.3.5 修无活 agent 时压缩按钮误禁;0.3.6 修原生压缩线解析作用域(BUG-012);**0.4.0** 判断机制加强(cue 文案 v2 + 注入门控 + 计量)、诊断包(脱敏诊断信息,一键复制/预览)、压缩预览建议锁定、pin 保留契约、轻账本累计、交接预览两段可编辑、接力链元数据、原生自动压缩真实触发线)。已在 macOS(DSH 0.2.1-rc.2)与 Windows 11(Node 24.22)完成测试,仅调用 DSH 公开导出接口。变更记录详见 [CHANGELOG.md](CHANGELOG.md),后续规划与已知限制详见 [ROADMAP.md](ROADMAP.md),构建与发布流程详见 [DEVELOPMENT.md](https://github.com/cfanzx/dsh-compact-relay/blob/main/DEVELOPMENT.md)。
130
147
  - **开源许可**:采用 [MIT](LICENSE) 许可证,欢迎通过 [GitHub Issues](https://github.com/cfanzx/dsh-compact-relay/issues) 与 Pull Requests 提交反馈。
131
148
 
132
149
  ## 参考与致谢
package/ROADMAP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 压缩接力 · 目标与路线图
2
2
 
3
- > 版本基准:0.3.6(2026-10-24)|状态:0.3 已发布(npm + GitHub),0.3.5 已发布、0.3.6 发布中
3
+ > 版本基准:0.4.0(2026-10-24)|状态:0.3 已发布(npm + GitHub),0.3.6 已发布,0.4.0 已准备(只差上传)
4
4
  > 维护方式:每次发布前更新"当前状态"与勾选进度;方向变化必须同时改本文件和 memory 项目页。
5
5
 
6
6
  ## 一、最终目标(North Star)
@@ -54,6 +54,11 @@
54
54
 
55
55
  **这是我们的差异化,不碰压缩算法。**
56
56
 
57
+ **0.4.0 未发布在途(2026-10-24)**
58
+
59
+ - ✅ **P0-2 诊断包最小版**:本机生成、白名单 + 脱敏的诊断信息(设置页「生成并预览 / 复制 / 打开问题报告页」+ 只读 `GET /api/compact-relay-diagnostics`),并接入压缩/交接/锁定计数与耗时(commit `41699c8`;`npm test` exit 0,含 T1–T9 与真机 e2e 断言)。
60
+ - 🚧 **P0-1 判断机制加强**(代码已落地,待真实任务验收):cue 文案 v2(4 类完成信号 + 被拒无副作用)、注入门控(10%–40%、距上次注入 ≥3 轮、按会话不落盘)、工具描述 v2(用户/模型都可调用、被拒不需要致歉、典型调用时机)、cue/mark 计量(`cueInjected` / `markCalls` / `markAccepted` / `markRejected` 直接进诊断包 counters)。验收口径不变(标准 B):3 次真实长任务里 ≥2 次模型自发调用 `mark_compress_point`。
61
+
57
62
  1. ✅ **交接单六段 → 九段**(0.3 已提前交付):总体目标 / 约束 / 已完成 / 当前进度 / 改过的文件 / 关键决策 / 已知问题 / 未完成 / 下一步。
58
63
  - 文件改动、关键决策由程序收集(`core/handoff-facts.js`),模型只写 7 段、编不了。
59
64
  2. ✅ **交接就绪检查**(0.3 已提前交付):九段齐全/占位阻断,路径核对只警告;预览卡里先看再确认。
@@ -144,7 +149,9 @@
144
149
  - ✅ 0.3.1 已发布:npm `dsh-compact-relay@0.3.1` + GitHub tag/Release;0.3.2 补设置页链接与联系方式;0.3.3 完成 Windows 适配(Win11 + Node 24.22 全测试通过)。
145
150
  - ✅ 0.3.4 判断机制修复(BUG-010:`mark_compress_point` + systemPrompt 可压缩区间提示 + 状态缓存;427 会话数据佐证旧工具 0 调用)+ BUG-005/BUG-006 小 bug;npm latest 待本次发布。
146
151
  - ✅ 0.3.5 补丁:修复「无活 agent 时压缩按钮误禁 + 文案误导」(state 增加 `compactRangeReason='no_agent'`;客户端提示「会话未激活:发一条消息唤醒 agent 后可压 N 条」)。
147
- - 🔧 0.3.6 修复中(BUG-012):原生压缩线解析只认 `config:` 直接子键(按缩进判断),修复 `modelPolicies` 嵌套 `thresholdRatio` 被误报为全局线(Windows 真实会话误显示 50%);真实触发线按模型 route 计算留 0.4。
152
+ - 🟡 0.4.0 已准备、只差上传(2026-10-24):P0-1 判断机制加强(cue 文案 v2 + 10%–40% 注入门控 + cue/mark 计量)、P0-2 诊断包(脱敏诊断信息预览/复制/问题报告页,零网络)、P1-1 压缩预览建议锁定、P1-2 pin 保留契约、P1-3 轻账本累计、P2-1 交接预览两段可编辑、P2-2 接力链元数据、P2-3 原生自动压缩真实触发线(按 route 算)。真机验证已全绿(Mac 0.3.6→0.4.0 开发版);发布动作待办。
153
+ - ✅ 0.3.6 已发布(BUG-012):原生压缩线解析只认 `config:` 直接子键(按缩进判断),修复 `modelPolicies` 嵌套 `thresholdRatio` 被误报为全局线(Windows 真实会话误显示 50%);真实触发线按模型 route 计算留 0.4。
154
+ - 🚧 0.4.0 未发布(进行中):P0-2 诊断包最小版已交付(commit `41699c8`,`npm test` 全绿 + 隔离 e2e 真机验证);P0-1 判断机制加强(cue 文案 v2 + 注入门控 10%–40%/≥3 轮 + 工具描述 v2 + cue/mark 计量)代码已落地、测试全绿,**等 3 次真实长任务数据判定标准 B 后再决定是否发布 0.4.0**。
148
155
  - ✅ 真实压缩 e2e:`test/e2e/real-compact.mjs`(真实模型 + 真实压缩事务 + `maxSummaries=1` 合并旧摘要分支)6 项断言通过。
149
156
  - ✅ P3-01~14(13 修/1 确认无需修)+ BUG-004 + P2-T1 已收口。
150
157
  - ⏳ 市场收录 PR:仓库满 1 天(2026-10-21 19:09 后)提,yml 与说明已备。
@@ -18,6 +18,7 @@ import { createHash } from 'node:crypto';
18
18
  import { actionError } from '../core/errors.js';
19
19
  import { livePinnedSeqs } from '../dsh/session-view.js';
20
20
  import { compactionPolicy } from '../core/policy.js';
21
+ import { suggestPinCandidates } from '../core/pin-suggest.js';
21
22
  import { SUMMARY_INSTRUCTION } from '../core/prompts.js';
22
23
  import { sumShadowedTokens, tokenOf } from '../core/tokens.js';
23
24
  import { selectCompactableRange } from '../dsh/select-range.js';
@@ -126,7 +127,8 @@ function noRangeMessage(pinned) {
126
127
  * @param {{ctx:object, cfg:object, lock:object, engine:object}} deps
127
128
  * @param {{agent:object, signal?:AbortSignal, commandId?:string}} input
128
129
  */
129
- export async function betterCompactLocked(deps, { agent, signal, commandId, expected }) {
130
+ export async function betterCompactLocked(deps, { agent, signal, commandId, expected, ledgerRecord = true }) {
131
+ const startedAt = Date.now();
130
132
  if (agent === undefined || agent === null) throw actionError('nosession', '没有活动会话');
131
133
  const session = agent.session;
132
134
 
@@ -169,11 +171,31 @@ export async function betterCompactLocked(deps, { agent, signal, commandId, expe
169
171
  ? ',旧摘要保持原样'
170
172
  : '';
171
173
  const pinNote = range.pinnedCut === true ? `,已跳过 ${range.pinnedKept} 条锁定的消息` : '';
174
+ // 0.4 P1-3:轻账本(只记数值,不含消息/摘要/交接单正文)。任何异常静默——
175
+ // 账本绝不能影响压缩本体。交接内部复用本函数时传 ledgerRecord=false,避免一次交接记两条。
176
+ if (ledgerRecord === true) {
177
+ try {
178
+ const afterMeasured = await measurePressure(deps.ctx, agent, { windowFallback: deps.cfg.contextWindowFallback }).catch(() => null);
179
+ deps.ledger?.record?.({
180
+ kind: 'compact',
181
+ durationMs: Date.now() - startedAt,
182
+ beforeTokens: plan.measured.tokens,
183
+ afterTokens: afterMeasured === null ? null : afterMeasured.tokens,
184
+ beforeRatio: plan.measured.ratio,
185
+ afterRatio: afterMeasured === null ? null : afterMeasured.ratio,
186
+ pinHits: { inRange: range.pinnedCut === true ? range.pinnedKept : 0, total: pinned.length },
187
+ });
188
+ } catch {
189
+ /* 账本静默降级:磁盘满/不可写都不影响压缩结果 */
190
+ }
191
+ }
172
192
  return {
173
193
  ok: true,
174
194
  compacted: true,
175
195
  // A3/A5:把被批准的范围 id 带出动作层(交接链记录/0.4 diff 可引用)。
176
196
  planId,
197
+ // 0.4 P1-3:交接记录要复用「压缩时跳过了多少锁定消息」。
198
+ ...(range.pinnedCut === true ? { pinnedKept: range.pinnedKept } : {}),
177
199
  ...outcome,
178
200
  message: `压缩完成:${outcome.shadowedSeqs.length} 条旧会话 → 1 条摘要(腾出约 ${outcome.shadowedTokenCount} 容量${note}${pinNote})。`,
179
201
  };
@@ -201,6 +223,14 @@ export async function previewBetterCompact(deps, { agent } = {}) {
201
223
  }
202
224
 
203
225
  const shadowedTokenCount = sumShadowedTokens(nodes, range.shadowedSeqs);
226
+ // 0.4 P1-1:对「本次将被压掉的历史」做本地启发式,指出值得先 /pin 的消息。
227
+ // 只提示、绝不自动锁:这里不写任何 pin 记录,锁定动作仍由用户 /pin 触发。
228
+ const pinSuggestItems = suggestPinCandidates(
229
+ range.shadowedSeqs.map((seq) => (typeof agent.session?.eventAt === 'function' ? agent.session.eventAt(seq) : undefined)),
230
+ );
231
+ // 0.4 P1-2:pin 保留契约命中数——inRange = 本次候选范围里被锁定保护而跳过的条数;
232
+ // total = 当前会话仍活着的锁定总数(范围外也可能有锁定)。
233
+ const pinHits = { inRange: range.pinnedCut === true ? range.pinnedKept : 0, total: pinned.length };
204
234
  rememberPreviewPlan(agent.id, {
205
235
  planId,
206
236
  startSeq: range.startSeq,
@@ -220,11 +250,18 @@ export async function previewBetterCompact(deps, { agent } = {}) {
220
250
  endSeq: range.endSeq,
221
251
  planId,
222
252
  tooSmall,
253
+ // 0.4 P1-1:建议锁定候选(客户端只渲染;不含原文全文,snippet ≤60 字符)。
254
+ pinSuggest: { count: pinSuggestItems.length, items: pinSuggestItems },
255
+ // 0.4 P1-2:pin 命中数(范围内/范围外分开说明)。
256
+ pinHits,
223
257
  ...(range.pinnedCut === true ? { pinnedKept: range.pinnedKept } : {}),
224
258
  },
225
259
  message:
226
260
  `压缩预估:将把 ${range.shadowedSeqs.length} 条旧会话压成 1 条摘要` +
227
261
  `(腾出约 ${fmtTokens(shadowedTokenCount)} tokens${range.pinnedCut === true ? `,已跳过 ${range.pinnedKept} 条锁定的消息` : ''})。` +
262
+ (pinSuggestItems.length > 0
263
+ ? `建议先 /pin 再压:这 ${pinSuggestItems.length} 条看起来是关键决策/报错堆栈/明确要求别改(只提示,不会自动锁定)。`
264
+ : '') +
228
265
  (tooSmall
229
266
  ? `这段偏小,压了可能反而更大;建议再聊几轮或把「保留近期原文」调小后再压缩。`
230
267
  : '只估算范围和容量,摘要内容确认后才生成。') +
@@ -16,13 +16,19 @@
16
16
  * 参数的拼装),改一处漏一处就会让 /bctest 与正式压缩行为分叉。
17
17
  */
18
18
  import { replaceRangeWithSummary } from '../dsh/compact-transaction.js';
19
+ import { errorCodeOf } from '../dsh/diag-counters.js';
19
20
  import { runIdle } from '../dsh/idle.js';
20
21
  import { summarize } from '../dsh/summarizer.js';
21
22
  import { combinedSignal } from './support.js';
22
23
 
23
24
  /**
24
- * 执行一次压缩替换事务。
25
- * @param {{ctx:object, cfg:object, engine:object}} deps
25
+ * 执行一次压缩替换事务(对外入口 = 诊断埋点的唯一采集点)。
26
+ *
27
+ * 0.4 P0-2:只在这里数「压缩成功/失败 / 耗时 / 错误码」——正式精压与 /bctest
28
+ * 走的是同一条执行路径,所以两边的计数器口径天然一致;失败原因只记 code,
29
+ * **不记 message**(message 可能带路径/会话内容)。
30
+ *
31
+ * @param {{ctx:object, cfg:object, engine:object, diag?:object}} deps
26
32
  * @param {object} input
27
33
  * @param {object} input.agent
28
34
  * @param {object} input.session
@@ -35,7 +41,27 @@ import { combinedSignal } from './support.js';
35
41
  * @param {number} [input.timeoutMs] 单次模型调用超时;**由调用方决定**(省略 = llm-call 的 180s 默认)
36
42
  * @returns {Promise<{compactionId:string, startSeq:number, summarySeq:number, endSeq:number, shadowedSeqs:number[], shadowedTokenCount:number}>}
37
43
  */
38
- export async function runCompaction(
44
+ export async function runCompaction(deps, input) {
45
+ const startedAt = Date.now();
46
+ try {
47
+ const outcome = await runCompactionProcess(deps, input);
48
+ deps.diag?.bumpAction('compact', true);
49
+ return outcome;
50
+ } catch (error) {
51
+ deps.diag?.bumpAction('compact', false);
52
+ deps.diag?.recordError(errorCodeOf(error), 'compact');
53
+ throw error;
54
+ } finally {
55
+ deps.diag?.recordDuration('compact', Date.now() - startedAt);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 压缩执行流程本体(跑 idle 事务 → 替换历史 + 调摘要)。
61
+ * @param {{ctx:object, cfg:object, engine:object}} deps
62
+ * @param {object} input 见 runCompaction
63
+ */
64
+ async function runCompactionProcess(
39
65
  deps,
40
66
  { agent, session, range, instruction, requiredSections, signal, commandId, inputTokens, timeoutMs },
41
67
  ) {
@@ -6,18 +6,38 @@
6
6
  *
7
7
  * 服务端仍复核区间:太低(压缩不划算)/已经到压缩线都拒绝,不改历史。
8
8
  * 返回文案是给模型看的,措辞要和工具描述一致(0.3.4 起不再说「系统会告诉你」)。
9
+ *
10
+ * 0.4 P0-1 计量(方案 §3.4):每次调用(无论接受/被拒)都记一笔到 deps.diag:
11
+ * `markCalls` / `markAccepted` / `markRejected` —— 诊断包直接可见,真实使用后
12
+ * 看数字就能判断「标准 B:3 次真实任务里 ≥2 次模型自发调用」是否达标。
13
+ * 只记次数与结果,不记参数/任务内容(隐私口径与 P0-2 一致)。
9
14
  */
10
15
  import { COMPRESS_HINT_RECORD_TYPE } from '../core/compress-hint.js';
11
16
  import { compressionMinRatio } from '../core/policy.js';
12
17
  import { measurePressure } from '../dsh/pressure.js';
13
18
 
14
19
  /**
15
- * 记录一次「适合压缩」的判断。
20
+ * 记录一次「适合压缩」的判断(对外入口 = 诊断计量采集点)。
21
+ *
22
+ * 计量在包装层做:所有 return 路径统一记 `markCalls`,并按 `ok` 记
23
+ * accepted/rejected;不改任何拒绝条件、不改返回文案与历史写入行为。
24
+ *
16
25
  * @param {object} deps
17
26
  * @param {{agent: object}} input
18
27
  * @returns {Promise<{ok:boolean, ratio:number, at:number, seq?:number, reason?:string, message:string}>}
19
28
  */
20
29
  export async function markCompressHint(deps, { agent } = {}) {
30
+ const result = await markCompressHintProcess(deps, { agent });
31
+ deps.diag?.bumpMark?.(result.ok === true);
32
+ return result;
33
+ }
34
+
35
+ /**
36
+ * 判断并落记录本体(成功/被拒语义见上方文件头)。
37
+ * @param {object} deps
38
+ * @param {{agent: object}} input
39
+ */
40
+ async function markCompressHintProcess(deps, { agent } = {}) {
21
41
  const session = agent?.session;
22
42
  if (session === null || session === undefined) {
23
43
  return { ok: false, ratio: 0, at: 0, reason: 'no-session', message: '没有活动会话,无法记录压缩节点' };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 诊断信息动作(dcr 0.4 P0-2)—— HTTP 适配层与(将来的)命令入口共用的唯一组装实现。
3
+ *
4
+ * 为什么放在 actions 层:仓库 A9 架构红线是「adapters 不能直接 import dsh/」,
5
+ * 而诊断产物的构造(白名单裁剪 + 脱敏 + 尺寸上限)在 dsh/diagnostics.js。
6
+ * 这里只做「取数 + 裁剪」:deps.cfg / settings.view() / readState / deps.diag.snapshot(),
7
+ * 然后把白名单输入交给 dsh 层构造,适配层只负责协议转换。
8
+ *
9
+ * 只读、幂等、无副作用:不写文件、不写日志、不改任何插件状态。
10
+ */
11
+ import os from 'node:os';
12
+ import { buildDiagnostics, renderIssueUrl } from '../dsh/diagnostics.js';
13
+ import { readState } from './state.js';
14
+
15
+ /** 运行时环境事实(只取进程/系统信息,不读会话、不读路径)。 */
16
+ function runtimeOf() {
17
+ let osRelease = null;
18
+ try {
19
+ if (typeof os.release === 'function') osRelease = os.release();
20
+ } catch {
21
+ osRelease = null;
22
+ }
23
+ let locale = null;
24
+ try {
25
+ locale = Intl.DateTimeFormat().resolvedOptions().locale;
26
+ } catch {
27
+ locale = null;
28
+ }
29
+ return {
30
+ // 现有代码没有稳定的 DSH 版本来源:不猜,保持 null(方案 §4)。
31
+ dshVersion: null,
32
+ node: typeof process.version === 'string' ? process.version : null,
33
+ platform: typeof process.platform === 'string' ? process.platform : null,
34
+ arch: typeof process.arch === 'string' ? process.arch : null,
35
+ osRelease,
36
+ locale,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * 配置白名单:阈值/保留量/超时/颜色 + 两个「是否被用户改过」的布尔。
42
+ * 交接目录与自定义链接**只出改没改,绝不出值**(方案 §2)。
43
+ */
44
+ function configOf(deps) {
45
+ const cfg = deps.cfg !== null && typeof deps.cfg === 'object' ? deps.cfg : {};
46
+ let view = null;
47
+ try {
48
+ view = typeof deps.settings?.view === 'function' ? deps.settings.view() : null;
49
+ } catch {
50
+ view = null;
51
+ }
52
+ const current = view !== null && view.config !== null && typeof view.config === 'object' ? view.config : null;
53
+ const defaults = view !== null && view.defaults !== null && typeof view.defaults === 'object' ? view.defaults : null;
54
+ const changed = (read) => (current === null || defaults === null ? false : read(current) !== read(defaults));
55
+ const timeouts = cfg.timeouts !== null && typeof cfg.timeouts === 'object' ? cfg.timeouts : {};
56
+ return {
57
+ version: view !== null && typeof view.version === 'string' ? view.version : null,
58
+ config: {
59
+ thresholds: {
60
+ compactRatio: cfg.thresholds?.compactRatio,
61
+ handoffRatio: cfg.thresholds?.handoffRatio,
62
+ maxSummaries: cfg.thresholds?.maxSummaries,
63
+ },
64
+ retention: { recentTailTokens: cfg.retention?.recentTailTokens },
65
+ // 仓库只有一个模型调用超时(llmMs);契约的分项字段没有独立来源时复用它。
66
+ timeouts: {
67
+ compactMs: timeouts.compactMs ?? timeouts.llmMs,
68
+ handoffMs: timeouts.handoffMs ?? timeouts.llmMs,
69
+ },
70
+ colors: { mode: cfg.colors?.mode, autoAdapt: cfg.colors?.autoAdapt },
71
+ handoffDirCustomized: changed((value) => value.handoffDir),
72
+ linksCustomized: changed((value) => `${value.links?.tutorial ?? ''}\u0000${value.links?.feedback ?? ''}`),
73
+ },
74
+ };
75
+ }
76
+
77
+ /** 把 readState 快照裁成契约白名单;无活动会话返回 null(契约里 state 整块 null)。 */
78
+ function stateOf(state) {
79
+ if (state === null || typeof state !== 'object' || typeof state.sessionId !== 'string') return null;
80
+ const range = state.compactRange !== null && typeof state.compactRange === 'object' ? state.compactRange : null;
81
+ const native = state.native !== null && typeof state.native === 'object' ? state.native : null;
82
+ return {
83
+ window: state.window,
84
+ tokens: state.pressure,
85
+ ratio: state.ratio,
86
+ summaries: state.summaries,
87
+ pins: state.pins !== null && typeof state.pins === 'object' ? state.pins.count : state.pins,
88
+ advice: state.advice,
89
+ compactRange: range === null ? null : { nodes: range.nodes, tokens: range.tokens },
90
+ nativeAuto: native === null ? null : { enabled: native.autoEnabled, lineRatio: native.thresholdRatio },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * 组装一份诊断信息(HTTP 端点 / 未来命令入口共用)。
96
+ *
97
+ * @param {{ctx:object, cfg:object, settings?:object, diag?:object}} deps
98
+ * @param {{sessionId?:string}} [input]
99
+ * @returns {Promise<{schema:string, version:string|null, generatedAt:string, markdown:string, json:object, issueUrl:string}>}
100
+ */
101
+ export async function readDiagnostics(deps, { sessionId } = {}) {
102
+ const meta = configOf(deps);
103
+ const runtime = runtimeOf();
104
+ // 无活动会话时 readState 仍返回合法对象(sessionId=null)→ 契约里 state 整块 null。
105
+ let state = null;
106
+ try {
107
+ state = stateOf(await readState(deps, { sessionId }));
108
+ } catch {
109
+ state = null;
110
+ }
111
+ const snapshot = deps.diag?.snapshot?.() ?? {};
112
+ const built = buildDiagnostics({
113
+ version: meta.version,
114
+ runtime,
115
+ config: meta.config,
116
+ state,
117
+ counters: snapshot.counters,
118
+ latency: snapshot.latency,
119
+ errors: snapshot.errors,
120
+ });
121
+ return {
122
+ schema: built.json.schema,
123
+ version: meta.version,
124
+ generatedAt: built.json.generatedAt,
125
+ markdown: built.markdown,
126
+ json: built.json,
127
+ // issue URL 由宿主拼(短字段),客户端只负责 window.open。
128
+ issueUrl: renderIssueUrl({ version: meta.version, runtime }),
129
+ };
130
+ }