dsh-compact-relay 0.3.5 → 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.
@@ -0,0 +1,527 @@
1
+ /**
2
+ * 诊断包构造函数(dcr 0.4 P0-2)—— 纯函数、零 IO、零网络。
3
+ *
4
+ * 设计红线(方案 §2):
5
+ * 1. **allowlist 序列化**:只从已知字段逐项 pick,禁止整对象展开/整对象序列化扩散;
6
+ * 2. 产物只含「数值 / 枚举 / 布尔 / 错误码」:不带消息、摘要、交接单正文、工具参数、
7
+ * 路径、用户名、hostname、cwd、sessionId、密钥、cookie;
8
+ * 3. 本机生成:Markdown/JSON 由宿主进程构造,客户端只展示/复制;
9
+ * 4. 不引入依赖、不发起任何网络请求(本文件内不出现任何网络 API 的名字)。
10
+ *
11
+ * 数据流:buildDiagnostics(input) → allowlist 对象 → JSON 文本清洗 → 截断 →
12
+ * 同源渲染 Markdown → Markdown 文本清洗 → `{json, markdown}`。
13
+ * 三道清洗里只有「正则兜底」会改内容,且只把命中串替换成 `[REDACTED]`、只把数量写进
14
+ * `json.redactions`(可选字段,不保留原文)。
15
+ */
16
+ import { localIso } from './diag-counters.js';
17
+
18
+ /** 产物 schema 版本(客户端/issue 模板以此识别格式)。 */
19
+ export const DIAG_SCHEMA = 'dcr-diagnostics/v1';
20
+
21
+ /** JSON 产物硬上限(方案 A7:≤ 64KB)。 */
22
+ export const JSON_BYTE_LIMIT = 64 * 1024;
23
+
24
+ /** 错误条目上限与单条长度上限(方案 A7)。 */
25
+ const ERROR_ENTRY_LIMIT = 20;
26
+ const ERROR_ENTRY_MAX_CHARS = 200;
27
+ const ERROR_CODE_MAX_CHARS = 60;
28
+ const ERROR_PHASE_MAX_CHARS = 20;
29
+
30
+ /** GitHub issue 报告页(只预填短字段,绝不把 Markdown 塞进 URL)。 */
31
+ const ISSUE_URL_BASE = 'https://github.com/cfanzx/dsh-compact-relay/issues/new';
32
+ /** issue URL 长度上限(方案 A3:< 7500)。 */
33
+ export const ISSUE_URL_LIMIT = 7500;
34
+
35
+ const PLUGIN_NAME = 'dsh-compact-relay';
36
+
37
+ /**
38
+ * 敏感串检测集(方案 §8)。`label` 只用于测试/计数,**不会**把命中的原文带出去。
39
+ * 顺序从具体到宽泛,避免同一条串被多个模式重复计数。
40
+ */
41
+ const LEAK_PATTERNS = Object.freeze([
42
+ Object.freeze({ label: 'github-pat', pattern: /github_pat_[A-Za-z0-9_]{10,}/gu }),
43
+ Object.freeze({ label: 'github-token', pattern: /ghp_[A-Za-z0-9]{10,}/gu }),
44
+ Object.freeze({ label: 'openai-key', pattern: /\bsk-[A-Za-z0-9_-]{10,}/gu }),
45
+ Object.freeze({ label: 'slack-token', pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/gu }),
46
+ Object.freeze({ label: 'google-api-key', pattern: /AIza[0-9A-Za-z_-]{20,}/gu }),
47
+ Object.freeze({ label: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/gu }),
48
+ Object.freeze({ label: 'authorization-header', pattern: /\bauthorization\s*:\s*[^\r\n]{4,}/giu }),
49
+ Object.freeze({ label: 'bearer-token', pattern: /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/giu }),
50
+ Object.freeze({ label: 'cookie', pattern: /\bcookie\s*[:=]\s*[^\s;,]{3,}/giu }),
51
+ Object.freeze({ label: 'email', pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/gu }),
52
+ Object.freeze({ label: 'unix-abs-path', pattern: /(?<![\w.-])\/(?:Users|home|root|private|var|opt|etc|tmp)\/[^\s"'`]+/gu }),
53
+ Object.freeze({ label: 'windows-abs-path', pattern: /\b[A-Za-z]:\\[^\s"'`<>|]*/gu }),
54
+ Object.freeze({
55
+ label: 'private-ip',
56
+ pattern:
57
+ /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b/gu,
58
+ }),
59
+ Object.freeze({ label: 'session-id', pattern: /\bsession-[0-9a-z]{8,}\b/gu }),
60
+ ]);
61
+
62
+ /** `[REDACTED]` 占位符(测试断言用同一份常量,避免手写两处不一致)。 */
63
+ export const REDACTION_PLACEHOLDER = '[REDACTED]';
64
+
65
+ function isObject(value) {
66
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
67
+ }
68
+
69
+ function toDate(value) {
70
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
71
+ if (typeof value === 'number' && Number.isFinite(value)) return new Date(value);
72
+ if (typeof value === 'string') {
73
+ const parsed = new Date(value);
74
+ if (!Number.isNaN(parsed.getTime())) return parsed;
75
+ }
76
+ return new Date();
77
+ }
78
+
79
+ /** 单行化 + 截断;非字符串 / 空串 → null(`undefined` 一律归一为 null)。 */
80
+ function pickText(value, maxChars) {
81
+ if (typeof value !== 'string') return null;
82
+ const trimmed = value.replace(/\s+/gu, ' ').trim();
83
+ if (trimmed.length === 0) return null;
84
+ return trimmed.length > maxChars ? `${trimmed.slice(0, Math.max(0, maxChars - 1))}…` : trimmed;
85
+ }
86
+
87
+ function pickInt(value) {
88
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.round(value)) : null;
89
+ }
90
+
91
+ function pickMs(value) {
92
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.round(value) : null;
93
+ }
94
+
95
+ function pickRatio(value) {
96
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
97
+ const clamped = Math.min(1, Math.max(0, value));
98
+ return Math.round(clamped * 1000) / 1000;
99
+ }
100
+
101
+ function pickBool(value) {
102
+ return typeof value === 'boolean' ? value : null;
103
+ }
104
+
105
+ /* ------------------------------------------------------------------ *
106
+ * 各数据块:逐字段 allowlist(未知字段一律不读、不透传)
107
+ * ------------------------------------------------------------------ */
108
+
109
+ function pickRuntime(runtime) {
110
+ const source = isObject(runtime) ? runtime : {};
111
+ return {
112
+ dshVersion: pickText(source.dshVersion, 80),
113
+ node: pickText(source.node, 40),
114
+ platform: pickText(source.platform, 40),
115
+ arch: pickText(source.arch, 40),
116
+ osRelease: pickText(source.osRelease, 80),
117
+ locale: pickText(source.locale, 40),
118
+ };
119
+ }
120
+
121
+ function pickConfig(config) {
122
+ const source = isObject(config) ? config : {};
123
+ const thresholds = isObject(source.thresholds) ? source.thresholds : {};
124
+ const retention = isObject(source.retention) ? source.retention : {};
125
+ const timeouts = isObject(source.timeouts) ? source.timeouts : {};
126
+ const colors = isObject(source.colors) ? source.colors : {};
127
+ return {
128
+ thresholds: {
129
+ compactRatio: pickRatio(thresholds.compactRatio),
130
+ handoffRatio: pickRatio(thresholds.handoffRatio),
131
+ maxSummaries: pickInt(thresholds.maxSummaries),
132
+ },
133
+ retention: { recentTailTokens: pickInt(retention.recentTailTokens) },
134
+ timeouts: {
135
+ compactMs: pickMs(timeouts.compactMs),
136
+ handoffMs: pickMs(timeouts.handoffMs),
137
+ },
138
+ colors: { mode: pickText(colors.mode, 20), autoAdapt: pickBool(colors.autoAdapt) },
139
+ handoffDirCustomized: pickBool(source.handoffDirCustomized),
140
+ linksCustomized: pickBool(source.linksCustomized),
141
+ };
142
+ }
143
+
144
+ function pickState(state) {
145
+ if (!isObject(state)) return null;
146
+ const range = isObject(state.compactRange) ? state.compactRange : null;
147
+ const nativeAuto = isObject(state.nativeAuto) ? state.nativeAuto : null;
148
+ return {
149
+ window: pickInt(state.window),
150
+ tokens: pickInt(state.tokens),
151
+ ratio: pickRatio(state.ratio),
152
+ summaries: pickInt(state.summaries),
153
+ pins: pickInt(state.pins),
154
+ advice: pickText(state.advice, 20),
155
+ compactRange:
156
+ range === null ? null : { nodes: pickInt(range.nodes), tokens: pickInt(range.tokens) },
157
+ nativeAuto:
158
+ nativeAuto === null ? null : { enabled: pickBool(nativeAuto.enabled), lineRatio: pickRatio(nativeAuto.lineRatio) },
159
+ };
160
+ }
161
+
162
+ function pickCounters(counters) {
163
+ const source = isObject(counters) ? counters : {};
164
+ return {
165
+ compactOk: pickInt(source.compactOk) ?? 0,
166
+ compactFail: pickInt(source.compactFail) ?? 0,
167
+ handoffOk: pickInt(source.handoffOk) ?? 0,
168
+ handoffFail: pickInt(source.handoffFail) ?? 0,
169
+ pins: pickInt(source.pins) ?? 0,
170
+ // 0.4 P0-1:判断机制计量(cue 注入 / mark 调用 / 接受 / 拒绝)。
171
+ cueInjected: pickInt(source.cueInjected) ?? 0,
172
+ markCalls: pickInt(source.markCalls) ?? 0,
173
+ markAccepted: pickInt(source.markAccepted) ?? 0,
174
+ markRejected: pickInt(source.markRejected) ?? 0,
175
+ };
176
+ }
177
+
178
+ function emptyLatency() {
179
+ return { n: 0, p50: null, p95: null };
180
+ }
181
+
182
+ function pickLatencyEntry(entry) {
183
+ const source = isObject(entry) ? entry : {};
184
+ const n = pickInt(source.n) ?? 0;
185
+ if (n === 0) return emptyLatency();
186
+ return { n, p50: pickMs(source.p50), p95: pickMs(source.p95) };
187
+ }
188
+
189
+ function pickLatency(latency) {
190
+ const source = isObject(latency) ? latency : {};
191
+ return { compact: pickLatencyEntry(source.compact), handoff: pickLatencyEntry(source.handoff) };
192
+ }
193
+
194
+ /** 单条错误码 entry 的序列化长度压到 ≤ 200 字符(方案 A7)。 */
195
+ function fitErrorEntry(entry) {
196
+ let picked = entry;
197
+ while (JSON.stringify(picked).length > ERROR_ENTRY_MAX_CHARS && picked.code.length > 0) {
198
+ picked = { ...picked, code: picked.code.slice(0, Math.max(0, picked.code.length - 10)) };
199
+ }
200
+ return picked;
201
+ }
202
+
203
+ function pickErrors(errors) {
204
+ if (!Array.isArray(errors)) return [];
205
+ const out = [];
206
+ for (const entry of errors) {
207
+ if (out.length >= ERROR_ENTRY_LIMIT) break;
208
+ if (!isObject(entry)) continue;
209
+ out.push(
210
+ fitErrorEntry({
211
+ code: pickText(entry.code, ERROR_CODE_MAX_CHARS) ?? 'error',
212
+ phase: pickText(entry.phase, ERROR_PHASE_MAX_CHARS) ?? 'unknown',
213
+ at: pickText(entry.at, 40),
214
+ count: pickInt(entry.count) ?? 1,
215
+ }),
216
+ );
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /* ------------------------------------------------------------------ *
222
+ * 清洗:检测 / 替换(只出数量,不出原文)
223
+ * ------------------------------------------------------------------ */
224
+
225
+ /**
226
+ * 扫描文本里的疑似敏感串。
227
+ * @param {string} text
228
+ * @returns {string[]} 命中的模式 label(去重;**不返回命中的原文**)
229
+ */
230
+ function scanLeakLabels(text) {
231
+ if (typeof text !== 'string' || text.length === 0) return [];
232
+ const hits = [];
233
+ for (const { label, pattern } of LEAK_PATTERNS) {
234
+ pattern.lastIndex = 0;
235
+ if (pattern.test(text) && !hits.includes(label)) hits.push(label);
236
+ }
237
+ return hits;
238
+ }
239
+
240
+ /**
241
+ * 把文本里命中的疑似敏感串替换成 `[REDACTED]`。
242
+ * @param {string} text
243
+ * @returns {{text:string, count:number}}
244
+ */
245
+ function redactText(text) {
246
+ let out = text;
247
+ let count = 0;
248
+ for (const { pattern } of LEAK_PATTERNS) {
249
+ pattern.lastIndex = 0;
250
+ out = out.replace(pattern, () => {
251
+ count += 1;
252
+ return REDACTION_PLACEHOLDER;
253
+ });
254
+ }
255
+ return { text: out, count };
256
+ }
257
+
258
+ /**
259
+ * 对 allowlist 对象里的每个字符串值做清洗(JSON 结构安全)。
260
+ *
261
+ * 为什么不直接在 `JSON.stringify` 之后替换:有些检测模式会跨 JSON 语法(例如
262
+ * `authorization: <直到行尾>`),文本级替换可能破坏 JSON、甚至把后续字段整段吞掉。
263
+ * 逐字段清洗后重新序列化,既保证产物不含命中串,也不会产生解析失败。
264
+ * @returns {{value:*, count:number}}
265
+ */
266
+ function redactDeep(value) {
267
+ if (typeof value === 'string') {
268
+ const cleaned = redactText(value);
269
+ return { value: cleaned.text, count: cleaned.count };
270
+ }
271
+ if (Array.isArray(value)) {
272
+ let count = 0;
273
+ const out = [];
274
+
275
+ for (const item of value) {
276
+ const cleaned = redactDeep(item);
277
+ count += cleaned.count;
278
+ out.push(cleaned.value);
279
+ }
280
+ return { value: out, count };
281
+ }
282
+ if (isObject(value)) {
283
+ let count = 0;
284
+ const out = {};
285
+ for (const [key, item] of Object.entries(value)) {
286
+ const cleaned = redactDeep(item);
287
+ count += cleaned.count;
288
+ out[key] = cleaned.value;
289
+ }
290
+ return { value: out, count };
291
+ }
292
+ return { value, count: 0 };
293
+ }
294
+
295
+ /**
296
+ * 单测用:断言一段文本里没有敏感串(生产路径不抛,只清洗 + 计数)。
297
+ * @param {string} text
298
+ * @param {string[]} [extraDeny] 额外禁止出现的字面串(例如测试里的「聊天原文」fixture)
299
+ * @returns {{ok:boolean, hits:string[]}}
300
+ */
301
+ export function assertNoLeak(text, extraDeny = []) {
302
+ const hits = scanLeakLabels(typeof text === 'string' ? text : '');
303
+ if (Array.isArray(extraDeny)) {
304
+ extraDeny.forEach((item, index) => {
305
+ if (typeof item === 'string' && item.length > 0 && typeof text === 'string' && text.includes(item)) {
306
+ hits.push(`custom#${index}`);
307
+ }
308
+ });
309
+ }
310
+ return { ok: hits.length === 0, hits };
311
+ }
312
+
313
+ /* ------------------------------------------------------------------ *
314
+ * 尺寸保护
315
+ * ------------------------------------------------------------------ */
316
+
317
+ function byteLengthOfJson(value) {
318
+ return Buffer.byteLength(JSON.stringify(value), 'utf8');
319
+ }
320
+
321
+ function diagnosticsError(code, message) {
322
+ const error = new Error(message);
323
+ error.code = code;
324
+ return error;
325
+ }
326
+
327
+ /**
328
+ * JSON ≤ 64KB 的硬截断:先砍半错误码,再清空错误码 + 耗时归零,仍超则报错(由 HTTP 层回短文案)。
329
+ * 正常产物 ~2KB,这两刀只是「手滑/异常输入」的兜底,属不可达分支的可测保护。
330
+ */
331
+ function capToJsonLimit(json) {
332
+ let candidate = json;
333
+ if (byteLengthOfJson(candidate) <= JSON_BYTE_LIMIT) return candidate;
334
+ candidate = { ...candidate, errors: candidate.errors.slice(0, Math.floor(candidate.errors.length / 2)) };
335
+ if (byteLengthOfJson(candidate) <= JSON_BYTE_LIMIT) return candidate;
336
+ candidate = { ...candidate, errors: [], latency: { compact: emptyLatency(), handoff: emptyLatency() } };
337
+ if (byteLengthOfJson(candidate) <= JSON_BYTE_LIMIT) return candidate;
338
+ throw diagnosticsError('toolarge', '诊断信息超过 64KB 上限,无法生成');
339
+ }
340
+
341
+ /* ------------------------------------------------------------------ *
342
+ * Markdown 渲染(与 JSON 同源,人读版 2–4KB)
343
+ * ------------------------------------------------------------------ */
344
+
345
+ const DASH = '—';
346
+
347
+ function groupDigits(value) {
348
+ return String(value).replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
349
+ }
350
+
351
+ function intText(value) {
352
+ return typeof value === 'number' && Number.isFinite(value) ? groupDigits(Math.round(value)) : DASH;
353
+ }
354
+
355
+ function percentText(ratio) {
356
+ if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return DASH;
357
+ return `${(ratio * 100).toFixed(1).replace(/\.0$/u, '')}%`;
358
+ }
359
+
360
+ function boolText(value, trueText, falseText) {
361
+ if (value === true) return trueText;
362
+ if (value === false) return falseText;
363
+ return DASH;
364
+ }
365
+
366
+ function osLabel(runtime) {
367
+ const platform = runtime.platform;
368
+ const name =
369
+ platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform === 'linux' ? 'Linux' : platform ?? '未知系统';
370
+ return runtime.osRelease === null ? `${name} ${DASH}` : `${name} ${runtime.osRelease}`;
371
+ }
372
+
373
+ function generatedAtText(generatedAt) {
374
+ if (typeof generatedAt !== 'string' || generatedAt.length < 19) return DASH;
375
+ return generatedAt.slice(0, 19).replace('T', ' ');
376
+ }
377
+
378
+ function nativeAutoText(state) {
379
+ if (state === null || state.nativeAuto === null) return `原生自动压缩:${DASH}(无活动会话)`;
380
+ const enabled = state.nativeAuto.enabled === true ? '开' : state.nativeAuto.enabled === false ? '关' : DASH;
381
+ return `原生自动压缩:${enabled}(线 ${percentText(state.nativeAuto.lineRatio)})`;
382
+ }
383
+
384
+ /** 渲染人读 Markdown;输入是已清洗的 json 对象(同源,不二次取数)。 */
385
+ function renderMarkdown(json) {
386
+ const { plugin, runtime, config, state, counters, latency, errors } = json;
387
+ const lines = [];
388
+ lines.push(`### ${PLUGIN_NAME} 诊断信息(${json.schema})`);
389
+ lines.push(`生成时间:${generatedAtText(json.generatedAt)}`);
390
+ lines.push('');
391
+ lines.push(`**版本**:${PLUGIN_NAME} ${plugin.version ?? DASH} · DSH ${runtime.dshVersion ?? DASH} ·`);
392
+ lines.push(
393
+ `**环境**:${runtime.platform ?? DASH} ${runtime.arch ?? DASH} · ${osLabel(runtime)} · Node ${runtime.node ?? DASH} · locale ${runtime.locale ?? DASH}`,
394
+ );
395
+ lines.push('');
396
+ lines.push('**配置**');
397
+ lines.push(
398
+ `- 压缩线 ${percentText(config.thresholds.compactRatio)} / 交接线 ${percentText(config.thresholds.handoffRatio)} / 最多摘要 ${intText(config.thresholds.maxSummaries)}`,
399
+ );
400
+ lines.push(
401
+ `- 保留尾巴 ${intText(config.retention.recentTailTokens)} tokens · 颜色模式 ${config.colors.mode ?? DASH}(自适应${boolText(config.colors.autoAdapt, '开', '关')})`,
402
+ );
403
+ lines.push(
404
+ `- ${nativeAutoText(state)} · 交接目录${boolText(config.handoffDirCustomized, '已自定义', '默认')} · 自定义链接${boolText(config.linksCustomized, '已改', '未改')}`,
405
+ );
406
+ if (typeof json.redactions === 'number' && json.redactions > 0) {
407
+ lines.push(`- 脱敏:已替换 ${intText(json.redactions)} 处疑似敏感串(不保留原文)`);
408
+ }
409
+ lines.push('');
410
+ if (state === null) {
411
+ lines.push('**当前状态**(无活动会话)');
412
+ } else {
413
+ lines.push('**当前状态**');
414
+ lines.push(
415
+ `- 窗口 ${intText(state.window)} · 已用 ${intText(state.tokens)}(${percentText(state.ratio)})· 摘要 ${intText(state.summaries)} · 锁定 ${intText(state.pins)}`,
416
+ );
417
+ const rangeText =
418
+ state.compactRange === null
419
+ ? '暂无可压范围'
420
+ : `可压范围 ${intText(state.compactRange.nodes)} 条 / ${intText(state.compactRange.tokens)} tokens`;
421
+ lines.push(`- 建议 ${state.advice ?? DASH} · ${rangeText}`);
422
+ }
423
+ lines.push('');
424
+ lines.push('**计数(本次加载后累计)**');
425
+ lines.push(
426
+ `- 压缩 ${intText(counters.compactOk)} 成功 / ${intText(counters.compactFail)} 失败 · 交接 ${intText(counters.handoffOk)} 成功 / ${intText(counters.handoffFail)} 失败 · 锁定 ${intText(counters.pins)}`,
427
+ );
428
+ lines.push(
429
+ `- cue 注入 ${intText(counters.cueInjected)} · mark 调用 ${intText(counters.markCalls)}(接受 ${intText(counters.markAccepted)} / 拒绝 ${intText(counters.markRejected)})`,
430
+ );
431
+ const latencyText = (label, entry) =>
432
+ entry.n === 0
433
+ ? `${label} n=0`
434
+ : `${label} n=${intText(entry.n)}(p50 ${intText(entry.p50)}ms / p95 ${intText(entry.p95)}ms)`;
435
+ lines.push(`- 耗时:${latencyText('压缩', latency.compact)} · ${latencyText('交接', latency.handoff)}`);
436
+ lines.push('');
437
+ lines.push(`**错误码**(最多 ${ERROR_ENTRY_LIMIT} 条)`);
438
+ if (errors.length === 0) {
439
+ lines.push('- (无)');
440
+ } else {
441
+ for (const entry of errors) {
442
+ const line = `- ${entry.code} · ${entry.phase} · ${entry.at ?? DASH} ×${intText(entry.count)}`;
443
+ lines.push(line.length > ERROR_ENTRY_MAX_CHARS ? `${line.slice(0, ERROR_ENTRY_MAX_CHARS - 1)}…` : line);
444
+ }
445
+ }
446
+ lines.push('');
447
+ return lines.join('\n');
448
+ }
449
+
450
+ /* ------------------------------------------------------------------ *
451
+ * 公开 API
452
+ * ------------------------------------------------------------------ */
453
+
454
+ /**
455
+ * 构造诊断包。
456
+ *
457
+ * @param {object} [input]
458
+ * @param {string} [input.version] 插件版本(settings.view().version)
459
+ * @param {object} [input.runtime] {dshVersion,node,platform,arch,osRelease,locale}
460
+ * @param {object} [input.config] {thresholds,retention,timeouts,colors,handoffDirCustomized,linksCustomized}
461
+ * @param {object|null} [input.state] 无活动会话传 null
462
+ * @param {object} [input.counters] diag-counters.snapshot().counters
463
+ * @param {object} [input.latency] diag-counters.snapshot().latency
464
+ * @param {Array} [input.errors] diag-counters.snapshot().errors(≤20 条)
465
+ * @param {Date|number|string} [input.now] 仅测试注入固定时间;运行时省略
466
+ * @returns {{json:object, markdown:string}}
467
+ */
468
+ export function buildDiagnostics(input = {}) {
469
+ const source = isObject(input) ? input : {};
470
+ const raw = {
471
+ schema: DIAG_SCHEMA,
472
+ generatedAt: localIso(toDate(source.now)),
473
+ plugin: {
474
+ name: PLUGIN_NAME,
475
+ version: pickText(source.version, 40),
476
+ },
477
+ runtime: pickRuntime(source.runtime),
478
+ config: pickConfig(source.config),
479
+ state: pickState(source.state),
480
+ counters: pickCounters(source.counters),
481
+ latency: pickLatency(source.latency),
482
+ errors: pickErrors(source.errors),
483
+ };
484
+
485
+ // 第二道防线:allowlist 之后仍对产物里的字符串值逐字段清洗(防后续手滑加字段),
486
+ // 并把命中数量写进 `json.redactions`(只出数量、不出原文)。
487
+ const jsonRedaction = redactDeep(raw);
488
+ let json = capToJsonLimit(jsonRedaction.value);
489
+
490
+ // Markdown 与 JSON 同源;再对 Markdown 文本跑一遍检测(理论上 0,防御渲染拼接)。
491
+ let markdown = renderMarkdown(json);
492
+ const markdownRedaction = redactText(markdown);
493
+ markdown = markdownRedaction.text;
494
+ const redactions = jsonRedaction.count + markdownRedaction.count;
495
+ if (redactions > 0) {
496
+ json = capToJsonLimit({ ...json, redactions });
497
+ markdown = renderMarkdown(json);
498
+ }
499
+
500
+ return { json, markdown };
501
+ }
502
+
503
+ /**
504
+ * 生成 GitHub issue 预填 URL —— 只带短字段(版本 / 平台 / DSH 版本),绝不带 Markdown。
505
+ *
506
+ * 参数只允许 `version` 与 `runtime.platform/osRelease/dshVersion`;传进来的其它东西一律无视。
507
+ * @param {{version?:string, runtime?:object}} [input]
508
+ * @returns {string} 长度 < ISSUE_URL_LIMIT(7500)
509
+ */
510
+ export function renderIssueUrl({ version, runtime } = {}) {
511
+ const pluginVersion = pickText(version, 40) ?? 'unknown';
512
+ const source = isObject(runtime) ? runtime : {};
513
+ const dshVersion = pickText(source.dshVersion, 80);
514
+ const platform = pickText(source.platform, 40);
515
+ const osRelease = pickText(source.osRelease, 80);
516
+ const platformText = [dshVersion === null ? 'DSH 版本未知' : `DSH ${dshVersion}`, [platform, osRelease].filter((part) => part !== null).join(' ')]
517
+ .filter((part) => part.length > 0)
518
+ .join(' / ');
519
+ const params = new URLSearchParams({
520
+ template: 'bug_report.yml',
521
+ title: `[bug] ${PLUGIN_NAME} ${pluginVersion} 诊断`,
522
+ 'dcr-version': pluginVersion,
523
+ 'dsh-version': platformText,
524
+ });
525
+ const url = `${ISSUE_URL_BASE}?${params.toString()}`;
526
+ return url.length < ISSUE_URL_LIMIT ? url : url.slice(0, ISSUE_URL_LIMIT - 1);
527
+ }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { actionError } from '../core/errors.js';
13
13
  import { HANDOFF_INSTRUCTION, handoffRecoveryHint } from '../core/prompts.js';
14
- import { HANDOFF_MODEL_SECTIONS, HANDOFF_SPEC } from '../core/handoff-spec.js';
14
+ import { HANDOFF_MODEL_SECTIONS, composeHandoffText } from '../core/handoff-spec.js';
15
15
  import { describeHandoffProblems, validateHandoff } from '../core/validate-handoff.js';
16
16
  import { surfaceMessages } from './prefix.js';
17
17
  import { cacheAlignedControls, callModelAttempt, modelAttemptResult, prepareModelCall } from './model-task.js';
@@ -99,10 +99,12 @@ export async function generateHandoffDoc({ ctx, engine, agent, maxTokens, inputT
99
99
  // 模型只负责 7 个 model 段;程序段(当前进度/改过的文件)不信任模型输出。
100
100
  const validation = validateHandoff(text, { titles: HANDOFF_MODEL_SECTIONS });
101
101
  if (validation.ok) {
102
- const assembled = HANDOFF_SPEC.map((spec) => {
103
- const body = spec.owner === 'program' ? programSections[spec.title] : validation.sections[spec.title];
104
- return `## ${spec.title}\n${String(body ?? '').trim()}`;
105
- }).join('\n\n');
102
+ const mergedSections = {
103
+ ...Object.fromEntries(HANDOFF_MODEL_SECTIONS.map((title) => [title, validation.sections[title]])),
104
+ 当前进度: programSections['当前进度'],
105
+ 改过的文件: programSections['改过的文件'],
106
+ };
107
+ const assembled = composeHandoffText(mergedSections);
106
108
  const full = validateHandoff(assembled);
107
109
  if (!full.ok) {
108
110
  lastProblems = full.problems;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 轻账本(0.4 P1-3)—— 文件读写层(JSONL,存 `~/.dsh/compact-relay-ledger.jsonl`)。
3
+ *
4
+ * 设计红线:
5
+ * - **不影响本体**:所有 IO 都 try/catch 吞掉,`record()` 永不抛;写失败只返回 `{ok:false}`,
6
+ * 压缩/交接照常成功(磁盘满、目录不可写、文件被改成目录都不影响动作);
7
+ * - **损坏静默降级**:读侧跳过坏行/坏记录(`core/ledger.js` 的 parse),不让一行坏数据毁掉全部历史;
8
+ * - **有界**:只保留最近 `maxEntries`(默认 200)条,超过就整文件重写成最近 N 条(先写 tmp 再 rename);
9
+ * - 只记数值/枚举/时间/交接单路径;不记消息、摘要、交接单正文、sessionId、cwd(见 core/ledger.js)。
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { defaultDshHome } from '../core/config-schema.js';
14
+ import {
15
+ LEDGER_MAX_ENTRIES,
16
+ buildLedgerRecord,
17
+ parseLedgerLines,
18
+ summarizeLedgerRecords,
19
+ } from '../core/ledger.js';
20
+ import { localIso } from './diag-counters.js';
21
+
22
+ /** 账本文件名(放 DSH_HOME 下,与 compact-relay.json 同级)。 */
23
+ export const LEDGER_FILE = 'compact-relay-ledger.jsonl';
24
+
25
+ /** 默认账本路径:`$DSH_HOME/compact-relay-ledger.jsonl`(缺省 ~/.dsh)。 */
26
+ export function defaultLedgerPath() {
27
+ return path.join(defaultDshHome(), LEDGER_FILE);
28
+ }
29
+
30
+ /**
31
+ * 读取账本(损坏行跳过;任何 IO 异常 → 空数组,不抛)。
32
+ * @param {string} [file]
33
+ * @param {{maxEntries?:number}} [options]
34
+ * @returns {object[]}
35
+ */
36
+ export function readLedgerFile(file = defaultLedgerPath(), { maxEntries = LEDGER_MAX_ENTRIES } = {}) {
37
+ try {
38
+ const text = fs.readFileSync(file, 'utf8');
39
+ const records = parseLedgerLines(text);
40
+ const cap = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : LEDGER_MAX_ENTRIES;
41
+ return records.length > cap ? records.slice(-cap) : records;
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 追加一条记录,并在超过上限时裁剪到最近 N 条。
49
+ * 永不抛:失败返回 `{ok:false}`(调用方无需处理,也不能因此改变压缩/交接结果)。
50
+ * @param {object} record buildLedgerRecord 的产物
51
+ * @param {string} [file]
52
+ * @param {{maxEntries?:number}} [options]
53
+ * @returns {{ok:boolean}}
54
+ */
55
+ export function appendLedgerRecord(record, file = defaultLedgerPath(), { maxEntries = LEDGER_MAX_ENTRIES } = {}) {
56
+ try {
57
+ if (record === null || typeof record !== 'object') return { ok: false };
58
+ const cap = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : LEDGER_MAX_ENTRIES;
59
+ fs.mkdirSync(path.dirname(file), { recursive: true });
60
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
61
+ // 有界:条目超过上限时,整文件重写成最近 cap 条(原子替换,避免读到半截文件)。
62
+ const all = readLedgerFile(file, { maxEntries: Number.MAX_SAFE_INTEGER });
63
+ if (all.length > cap) {
64
+ const kept = all.slice(-cap);
65
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
66
+ fs.writeFileSync(tmp, kept.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
67
+ fs.renameSync(tmp, file);
68
+ }
69
+ return { ok: true };
70
+ } catch {
71
+ // 静默降级:账本不可写绝不能影响压缩/交接本体。
72
+ return { ok: false };
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 账本存储单例(lib/index.js 装配到 deps.ledger;actions 通过 deps.ledger 记录)。
78
+ *
79
+ * @param {{file?:string, maxEntries?:number}} [options]
80
+ * @returns {{filePath:string, record:Function, records:Function, summary:Function}}
81
+ */
82
+ export function createLedgerStore({ file = defaultLedgerPath(), maxEntries = LEDGER_MAX_ENTRIES } = {}) {
83
+ return {
84
+ filePath: file,
85
+ /**
86
+ * 记一条流水(自动补本地时间);构造非法/写失败都静默返回 {ok:false}。
87
+ * @param {object} input buildLedgerRecord 的入参(不含 at,由这里注入)
88
+ * @returns {{ok:boolean}}
89
+ */
90
+ record(input) {
91
+ try {
92
+ const record = buildLedgerRecord({ ...(input !== null && typeof input === 'object' ? input : {}), at: localIso() });
93
+ if (record === null) return { ok: false };
94
+ return appendLedgerRecord(record, file, { maxEntries });
95
+ } catch {
96
+ return { ok: false };
97
+ }
98
+ },
99
+ /** 最近 N 条(默认全部,最多 maxEntries);读失败返回 []。 */
100
+ records({ limit } = {}) {
101
+ return readLedgerFile(file, { maxEntries: Number.isInteger(limit) && limit > 0 ? limit : maxEntries });
102
+ },
103
+ /** 设置页一行累计数字;读失败按空账本处理(全 0 / null,不抛)。 */
104
+ summary() {
105
+ return summarizeLedgerRecords(readLedgerFile(file, { maxEntries }));
106
+ },
107
+ };
108
+ }