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.
- package/CHANGELOG.md +86 -3
- package/README.en.md +18 -1
- package/README.md +20 -3
- package/ROADMAP.md +9 -1
- package/lib/actions/better-compact.js +38 -1
- package/lib/actions/compact-run.js +29 -3
- package/lib/actions/compress-hint.js +21 -1
- package/lib/actions/diagnostics.js +130 -0
- package/lib/actions/handoff.js +90 -11
- package/lib/actions/pin.js +6 -23
- package/lib/actions/state.js +47 -5
- package/lib/adapters/http.js +70 -7
- package/lib/adapters/prompt.js +71 -10
- package/lib/adapters/tool.js +9 -4
- package/lib/client.js +485 -13
- package/lib/core/compress-cue.js +48 -16
- package/lib/core/handoff-doc.js +4 -0
- package/lib/core/handoff-edit.js +86 -0
- package/lib/core/handoff-facts.js +17 -2
- package/lib/core/handoff-spec.js +18 -0
- package/lib/core/ledger.js +176 -0
- package/lib/core/native-line.js +65 -0
- package/lib/core/pin-anchors.js +93 -0
- package/lib/core/pin-suggest.js +129 -0
- package/lib/core/policy.js +16 -0
- package/lib/dsh/diag-counters.js +204 -0
- package/lib/dsh/diagnostics.js +527 -0
- package/lib/dsh/handoff-writer.js +7 -5
- package/lib/dsh/ledger.js +108 -0
- package/lib/dsh/native-compaction.js +294 -57
- package/lib/dsh/pressure.js +62 -10
- package/lib/dsh/relay.js +23 -6
- package/lib/dsh/session-view.js +32 -4
- package/lib/dsh/sessions.js +1 -1
- package/lib/index.js +6 -0
- package/package.json +2 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 压缩预览「建议锁定」候选识别(0.4 P1-1)—— 纯本地启发式,零依赖、不调模型。
|
|
3
|
+
*
|
|
4
|
+
* 目标:在压缩预览里指出「这次要压掉的历史中,哪些消息看起来值得先 /pin 再压」,
|
|
5
|
+
* 让用户在不可逆的压缩前把「关键决策 / 报错堆栈 / 明确要求别改」保留下来。
|
|
6
|
+
*
|
|
7
|
+
* 红线:
|
|
8
|
+
* - **只提示、绝不自动锁**:本模块只返回候选(seq/角色/理由/一句摘要),不写任何记录;
|
|
9
|
+
* 客户端只渲染提示,锁定动作仍由用户执行 `/pin`;
|
|
10
|
+
* - 隐私:只产出截断的一句摘要(默认 60 字符,不贴全文),用于本机 UI;
|
|
11
|
+
* - 启发式宁可少报:长度太短、没有明确信号的消息不进入候选。
|
|
12
|
+
*
|
|
13
|
+
* 三类信号(优先级从高到低):
|
|
14
|
+
* 1. `明确要求别改`:用户消息里说「不要改 / 别动 / 保持原样 / 必须保留 …」;
|
|
15
|
+
* 2. `报错堆栈`:异常类名 / 堆栈帧 / 中文「堆栈/报错」等;
|
|
16
|
+
* 3. `关键决策`:决定 / 拍板 / 采用 / 改用 / 结论 / 定稿等取舍用语。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
isUserAuthoredMessage,
|
|
21
|
+
messageSnippetOf,
|
|
22
|
+
messageTextOf,
|
|
23
|
+
roleLabelOf,
|
|
24
|
+
} from './pin-anchors.js';
|
|
25
|
+
|
|
26
|
+
/** 一次预览最多建议几条(太多会变成噪声)。 */
|
|
27
|
+
export const PIN_SUGGEST_LIMIT = 5;
|
|
28
|
+
|
|
29
|
+
/** 文本至少这么长才参与识别(避免「好」「继续」这类短句误报)。 */
|
|
30
|
+
export const PIN_SUGGEST_MIN_CHARS = 12;
|
|
31
|
+
|
|
32
|
+
/** 摘要截断长度(与 pin 列表一致)。 */
|
|
33
|
+
export const PIN_SUGGEST_SNIPPET_CHARS = 60;
|
|
34
|
+
|
|
35
|
+
/** 三类理由的唯一文案(客户端直接展示)。 */
|
|
36
|
+
export const PIN_SUGGEST_REASONS = Object.freeze({
|
|
37
|
+
error: '报错堆栈',
|
|
38
|
+
keep: '明确要求别改',
|
|
39
|
+
decision: '关键决策',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** 用户明确要求「别改 / 保留」的措辞。 */
|
|
43
|
+
const KEEP_RE =
|
|
44
|
+
/(?:(?:不要|别|勿|禁止|不能|不许|请勿|切勿)\s*(?:再)?\s*(?:修改|改动|改|动|变更|碰|删|删除))|(?:保持原样|保留原样|原样保留|必须保留|务必保留|千万别改|不要动)/iu;
|
|
45
|
+
|
|
46
|
+
/** 报错 / 堆栈信号(异常类名、堆栈帧、中文「堆栈」等)。 */
|
|
47
|
+
const ERROR_RE =
|
|
48
|
+
/(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|AggregateError|Error:|Exception|Traceback|panic:|stack trace|UnhandledPromiseRejection|at\s+[^\s]+\s*\([^)]*:\d+:\d+\)|堆栈|错误日志|报错)/iu;
|
|
49
|
+
|
|
50
|
+
/** 决策 / 取舍信号。 */
|
|
51
|
+
const DECISION_RE =
|
|
52
|
+
/(?:决定|决策|拍板|结论|定稿|选型|采用|改用|改成|就按|最终方案|确定用|方案定为|取舍)/iu;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 单条消息的候选理由;不是候选返回 null。
|
|
56
|
+
* @param {object} event
|
|
57
|
+
* @returns {'报错堆栈'|'明确要求别改'|'关键决策'|null}
|
|
58
|
+
*/
|
|
59
|
+
function reasonOf(event) {
|
|
60
|
+
const type = event?.type;
|
|
61
|
+
const text = messageTextOf(event);
|
|
62
|
+
if (text.length < PIN_SUGGEST_MIN_CHARS) return null;
|
|
63
|
+
|
|
64
|
+
// 用户消息优先判「明确要求别改」:这是最该原样保留的意图。
|
|
65
|
+
if (isUserAuthoredMessage(event) && KEEP_RE.test(text)) return PIN_SUGGEST_REASONS.keep;
|
|
66
|
+
// 报错堆栈/错误日志(用户贴的、助手复述的、工具结果里的都算)。
|
|
67
|
+
if (ERROR_RE.test(text)) return PIN_SUGGEST_REASONS.error;
|
|
68
|
+
// 决策/取舍:只认真实对话消息,不把工具结果里的普通输出当决策。
|
|
69
|
+
if (type !== 'tool/result' && DECISION_RE.test(text)) return PIN_SUGGEST_REASONS.decision;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 单条事件是否可能成为候选(类型过滤):用户真实消息 / 助手消息 / 工具结果。
|
|
75
|
+
* 系统消息与运行时合成的 user/message(source.kind 非 user,如 checkpoint/时间上下文)
|
|
76
|
+
* 不参与建议。
|
|
77
|
+
* @param {object} event
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
function eligibleOf(event) {
|
|
81
|
+
const type = event?.type;
|
|
82
|
+
if (type === 'assistant/message' || type === 'tool/result') return true;
|
|
83
|
+
if (type === 'user/message') return isUserAuthoredMessage(event);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const REASON_PRIORITY = Object.freeze({
|
|
88
|
+
[PIN_SUGGEST_REASONS.keep]: 0,
|
|
89
|
+
[PIN_SUGGEST_REASONS.error]: 1,
|
|
90
|
+
[PIN_SUGGEST_REASONS.decision]: 2,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 从「即将被压掉」的事件里挑值得先锁定的候选。
|
|
95
|
+
*
|
|
96
|
+
* @param {object[]} events 按历史顺序(旧→新)的事件列表;通常传 range.shadowedSeqs 对应的事件
|
|
97
|
+
* @param {{limit?:number}} [options]
|
|
98
|
+
* @returns {Array<{seq:number, role:string, reason:string, snippet:string}>}
|
|
99
|
+
* 按 seq 升序(文档顺序)返回;同一 seq 只出现一次。
|
|
100
|
+
*/
|
|
101
|
+
export function suggestPinCandidates(events, { limit = PIN_SUGGEST_LIMIT } = {}) {
|
|
102
|
+
if (!Array.isArray(events) || events.length === 0) return [];
|
|
103
|
+
const max = Number.isInteger(limit) && limit > 0 ? limit : PIN_SUGGEST_LIMIT;
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const matched = [];
|
|
106
|
+
|
|
107
|
+
for (const event of events) {
|
|
108
|
+
if (event === null || typeof event !== 'object') continue;
|
|
109
|
+
const seq = event.seq;
|
|
110
|
+
if (!Number.isInteger(seq) || seq < 0 || seen.has(seq)) continue;
|
|
111
|
+
if (!eligibleOf(event)) continue;
|
|
112
|
+
const reason = reasonOf(event);
|
|
113
|
+
if (reason === null) continue;
|
|
114
|
+
seen.add(seq);
|
|
115
|
+
matched.push({
|
|
116
|
+
seq,
|
|
117
|
+
role: roleLabelOf(event.type),
|
|
118
|
+
reason,
|
|
119
|
+
snippet: messageSnippetOf(event, PIN_SUGGEST_SNIPPET_CHARS),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (matched.length <= max) return matched.sort((left, right) => left.seq - right.seq);
|
|
124
|
+
// 超过上限:先保「明确要求别改」,再报错堆栈,再关键决策;同级取更早的(更在压缩范围里)。
|
|
125
|
+
const picked = [...matched]
|
|
126
|
+
.sort((left, right) => REASON_PRIORITY[left.reason] - REASON_PRIORITY[right.reason] || left.seq - right.seq)
|
|
127
|
+
.slice(0, max);
|
|
128
|
+
return picked.sort((left, right) => left.seq - right.seq);
|
|
129
|
+
}
|
package/lib/core/policy.js
CHANGED
|
@@ -58,6 +58,22 @@ export function ratioOf(pressure, window) {
|
|
|
58
58
|
/** 压缩范围至少要比摘要大这么多 token 才划算(摘要 ~1K,留 4 倍安全系数)。 */
|
|
59
59
|
export const MIN_COMPRESS_TOKENS = 4_000;
|
|
60
60
|
|
|
61
|
+
/* ------------------------------------------------------------------ *
|
|
62
|
+
* 0.4 P0-1:cue 注入门控常量(方案 §3.2)
|
|
63
|
+
*
|
|
64
|
+
* cue 从「每轮都发」改成「合适时机发」:容量落在 10%–40%、且距上次注入
|
|
65
|
+
* ≥3 轮才发一次。三项都是可调参数,0.4.x 看真实数据再调,不改压缩行为。
|
|
66
|
+
* ------------------------------------------------------------------ */
|
|
67
|
+
|
|
68
|
+
/** cue 注入区间的下限:10% 以下压缩不划算(且会被工具拒绝),不发 cue。 */
|
|
69
|
+
export const CUE_MIN_RATIO = 0.1;
|
|
70
|
+
|
|
71
|
+
/** cue 注入区间的上限:40% 及以上该直接建议压缩,不该再记节点(与默认压缩线一致)。 */
|
|
72
|
+
export const CUE_MAX_RATIO = 0.4;
|
|
73
|
+
|
|
74
|
+
/** 距上次注入至少这么多轮才再次注入(防每轮都发变成背景噪声)。 */
|
|
75
|
+
export const CUE_MIN_TURNS = 3;
|
|
76
|
+
|
|
61
77
|
/**
|
|
62
78
|
* 「值得压缩」的最小容量比例(最小线,0.3.1)。
|
|
63
79
|
*
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 诊断计数 / 耗时 / 错误码(dcr 0.4 P0-2)—— 纯内存,进程内累计,重启清零。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:诊断包只允许「数值 / 枚举 / 布尔 / 错误码」,计数必须在动作层
|
|
5
|
+
* 顺手记,但上界、聚合口径与产物形状要有一处唯一实现。本模块:
|
|
6
|
+
* - 不读会话、不读文件、不发起任何网络请求;
|
|
7
|
+
* - 只保留最近 20 个耗时样本 / 最近 20 条错误码,长时间运行不膨胀;
|
|
8
|
+
* - `snapshot()` 每次返回全新对象,调用方改不坏内部状态;
|
|
9
|
+
* - 错误只收 `code` / `phase` / `at` / `count`,**绝不收 message**(可能含聊天/路径)。
|
|
10
|
+
*
|
|
11
|
+
* 单例在 lib/index.js 装配到 `deps.diag`;actions 通过 `deps.diag.*` 埋点。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 耗时样本上限(最近 20 次)。 */
|
|
15
|
+
export const LATENCY_SAMPLE_LIMIT = 20;
|
|
16
|
+
/** 错误码条目上限(按 code+phase 聚合后的条数)。 */
|
|
17
|
+
export const ERROR_ENTRY_LIMIT = 20;
|
|
18
|
+
/** 错误码 / 阶段名截断长度(防止异常长字符串撑大诊断包)。 */
|
|
19
|
+
const CODE_MAX_CHARS = 60;
|
|
20
|
+
const PHASE_MAX_CHARS = 20;
|
|
21
|
+
|
|
22
|
+
/** 会被计数的动作类别(只统计这两条执行路径)。 */
|
|
23
|
+
const ACTION_KINDS = Object.freeze(['compact', 'handoff']);
|
|
24
|
+
|
|
25
|
+
function pad2(value) {
|
|
26
|
+
return String(value).padStart(2, '0');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 本地 ISO 时间(带时区偏移,例如 2026-10-24T15:40:00+08:00)。
|
|
31
|
+
*
|
|
32
|
+
* 不用 `toISOString()`:那是 UTC,用户看诊断包时要能对得上本地时间。
|
|
33
|
+
* @param {Date|number|string} [date]
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function localIso(date = new Date()) {
|
|
37
|
+
const time = date instanceof Date ? date.getTime() : new Date(date).getTime();
|
|
38
|
+
const safe = Number.isNaN(time) ? new Date() : new Date(time);
|
|
39
|
+
const offsetMinutes = -safe.getTimezoneOffset();
|
|
40
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
41
|
+
const absolute = Math.abs(offsetMinutes);
|
|
42
|
+
return (
|
|
43
|
+
`${safe.getFullYear()}-${pad2(safe.getMonth() + 1)}-${pad2(safe.getDate())}` +
|
|
44
|
+
`T${pad2(safe.getHours())}:${pad2(safe.getMinutes())}:${pad2(safe.getSeconds())}` +
|
|
45
|
+
`${sign}${pad2(Math.floor(absolute / 60))}:${pad2(absolute % 60)}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 把任意文本规整成「单行 + 截断」的短标识;空串返回 null。 */
|
|
50
|
+
function normalizeToken(value, maxChars) {
|
|
51
|
+
if (typeof value !== 'string') return null;
|
|
52
|
+
const trimmed = value.replace(/\s+/gu, ' ').trim();
|
|
53
|
+
if (trimmed.length === 0) return null;
|
|
54
|
+
return trimmed.length > maxChars ? `${trimmed.slice(0, Math.max(0, maxChars - 1))}…` : trimmed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 错误 → 稳定的短 code(只用于诊断计数,不进用户文案)。
|
|
59
|
+
*
|
|
60
|
+
* 优先 `error.code`(本插件动作层自己抛的稳定 code),其次 `error.name`
|
|
61
|
+
* (AbortError / TypeError 等),都没有就 `'error'`。
|
|
62
|
+
* @param {unknown} error
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function errorCodeOf(error) {
|
|
66
|
+
if (error !== null && typeof error === 'object') {
|
|
67
|
+
const code = normalizeToken(error.code, CODE_MAX_CHARS);
|
|
68
|
+
if (code !== null) return code;
|
|
69
|
+
const name = normalizeToken(error.name, CODE_MAX_CHARS);
|
|
70
|
+
if (name !== null && name !== 'Error') return name;
|
|
71
|
+
}
|
|
72
|
+
return 'error';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 最近 rank 口径的百分位(nearest-rank):p 分位 = 排序后第 ceil(p*n) 个。
|
|
77
|
+
* 样本为空返回 null;样本 < 3 时也照实给值(n 分别是 1 / 2)。
|
|
78
|
+
* @param {number[]} sorted 已升序排序的样本
|
|
79
|
+
* @param {number} fraction 0–1
|
|
80
|
+
* @returns {number|null}
|
|
81
|
+
*/
|
|
82
|
+
function percentile(sorted, fraction) {
|
|
83
|
+
if (!Array.isArray(sorted) || sorted.length === 0) return null;
|
|
84
|
+
const rank = Math.ceil(fraction * sorted.length);
|
|
85
|
+
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
|
86
|
+
return sorted[index];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 进程内诊断计数器。
|
|
91
|
+
*
|
|
92
|
+
* 返回:
|
|
93
|
+
* - `bumpAction(kind, ok)`:压缩/交接成功或失败 +1(kind: 'compact' | 'handoff');
|
|
94
|
+
* - `recordDuration(kind, ms)`:记录一次耗时(只留最近 20 个样本);
|
|
95
|
+
* - `bumpPin()`:一次成功的「锁定」动作 +1(toggle 走一次只记一次);
|
|
96
|
+
* - `bumpCueInjected()`:0.4 P0-1 —— cue 实际注入一次 +1(只记次数,不记原文);
|
|
97
|
+
* - `bumpMark(ok)`:0.4 P0-1 —— `mark_compress_point` 调用一次:markCalls 必 +1,
|
|
98
|
+
* ok=true 时 markAccepted +1,否则 markRejected +1(被拒也算调用);
|
|
99
|
+
* - `recordError(code, phase)`:按 (code, phase) 聚合并保留最近 20 条;丢弃 message;
|
|
100
|
+
* - `snapshot()`:返回 `{ counters, latency, errors }`(形状严格白名单,见方案 §3)。
|
|
101
|
+
*
|
|
102
|
+
* @returns {{bumpAction:Function, recordDuration:Function, bumpPin:Function, bumpCueInjected:Function, bumpMark:Function, recordError:Function, snapshot:Function}}
|
|
103
|
+
*/
|
|
104
|
+
export function createDiagCounters() {
|
|
105
|
+
const action = {
|
|
106
|
+
compact: { ok: 0, fail: 0 },
|
|
107
|
+
handoff: { ok: 0, fail: 0 },
|
|
108
|
+
};
|
|
109
|
+
let pins = 0;
|
|
110
|
+
// 0.4 P0-1:判断机制的四个计量(cue 注入 / 工具调用 / 接受 / 拒绝),只记次数。
|
|
111
|
+
let cueInjected = 0;
|
|
112
|
+
let markCalls = 0;
|
|
113
|
+
let markAccepted = 0;
|
|
114
|
+
let markRejected = 0;
|
|
115
|
+
const durations = { compact: [], handoff: [] };
|
|
116
|
+
/** key = `${phase}:${code}`;Map 的插入顺序即最近使用顺序(命中时删除再插入)。 */
|
|
117
|
+
const errors = new Map();
|
|
118
|
+
|
|
119
|
+
const isKind = (kind) => ACTION_KINDS.includes(kind);
|
|
120
|
+
|
|
121
|
+
function bumpAction(kind, ok) {
|
|
122
|
+
if (!isKind(kind)) return;
|
|
123
|
+
if (ok === true) action[kind].ok += 1;
|
|
124
|
+
else action[kind].fail += 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function recordDuration(kind, ms) {
|
|
128
|
+
if (!isKind(kind)) return;
|
|
129
|
+
const value = typeof ms === 'number' && Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : null;
|
|
130
|
+
if (value === null) return;
|
|
131
|
+
const samples = durations[kind];
|
|
132
|
+
samples.push(value);
|
|
133
|
+
if (samples.length > LATENCY_SAMPLE_LIMIT) samples.splice(0, samples.length - LATENCY_SAMPLE_LIMIT);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function bumpPin() {
|
|
137
|
+
pins += 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function bumpCueInjected() {
|
|
141
|
+
cueInjected += 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `mark_compress_point` 调用一次:被拒也算调用(被拒是零副作用的正常结果)。
|
|
146
|
+
* @param {boolean} ok 工具是否接受并写入记录
|
|
147
|
+
*/
|
|
148
|
+
function bumpMark(ok) {
|
|
149
|
+
markCalls += 1;
|
|
150
|
+
if (ok === true) markAccepted += 1;
|
|
151
|
+
else markRejected += 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function recordError(code, phase) {
|
|
155
|
+
const codeText = normalizeToken(code, CODE_MAX_CHARS) ?? 'error';
|
|
156
|
+
const phaseText = normalizeToken(phase, PHASE_MAX_CHARS) ?? 'unknown';
|
|
157
|
+
const key = `${phaseText}:${codeText}`;
|
|
158
|
+
const existing = errors.get(key);
|
|
159
|
+
if (existing === undefined) {
|
|
160
|
+
errors.set(key, { code: codeText, phase: phaseText, at: localIso(), count: 1 });
|
|
161
|
+
// 只保留最近 20 条:最久没再出现的那条先淘汰(Map 头部)。
|
|
162
|
+
while (errors.size > ERROR_ENTRY_LIMIT) {
|
|
163
|
+
const oldestKey = errors.keys().next().value;
|
|
164
|
+
errors.delete(oldestKey);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
existing.count += 1;
|
|
169
|
+
existing.at = localIso();
|
|
170
|
+
// 命中即「最近出现过」:挪到 Map 尾部,淘汰顺序才准确。
|
|
171
|
+
errors.delete(key);
|
|
172
|
+
errors.set(key, existing);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function latencyOf(kind) {
|
|
176
|
+
const sorted = [...durations[kind]].sort((left, right) => left - right);
|
|
177
|
+
return {
|
|
178
|
+
n: sorted.length,
|
|
179
|
+
p50: percentile(sorted, 0.5),
|
|
180
|
+
p95: percentile(sorted, 0.95),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function snapshot() {
|
|
185
|
+
return {
|
|
186
|
+
counters: {
|
|
187
|
+
compactOk: action.compact.ok,
|
|
188
|
+
compactFail: action.compact.fail,
|
|
189
|
+
handoffOk: action.handoff.ok,
|
|
190
|
+
handoffFail: action.handoff.fail,
|
|
191
|
+
pins,
|
|
192
|
+
cueInjected,
|
|
193
|
+
markCalls,
|
|
194
|
+
markAccepted,
|
|
195
|
+
markRejected,
|
|
196
|
+
},
|
|
197
|
+
latency: { compact: latencyOf('compact'), handoff: latencyOf('handoff') },
|
|
198
|
+
// 最近出现的在前(诊断包最关心「刚炸了什么」)。
|
|
199
|
+
errors: [...errors.values()].reverse().map(({ code, phase, at, count }) => ({ code, phase, at, count })),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { bumpAction, recordDuration, bumpPin, bumpCueInjected, bumpMark, recordError, snapshot };
|
|
204
|
+
}
|