dsh-auto-model-routing 0.4.0 → 0.4.2

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,388 @@
1
+ // Read-only, on-demand AUTO usage report (change auto-routing-usage-report).
2
+ // Never writes, spawns, or touches the network; never echoes message text, paths or session ids.
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { parseArgs } from 'node:util';
9
+ import { zstdDecompressSync } from 'node:zlib';
10
+ import { FeedbackBook } from './feedback.js';
11
+ const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
12
+ const FIELDS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'];
13
+ const FEEDBACK = ['appropriate', 'too-high', 'too-low', 'unknown'];
14
+ const CAVEATS = [
15
+ 'observational-not-controlled',
16
+ 'scope-main-session-assistant-calls-only',
17
+ 'only-applied-decisions-recorded',
18
+ 'token-semantics-differ-by-provider',
19
+ 'reference-before-auto-effort-only-confounded',
20
+ 'mode-history-not-persisted',
21
+ 'implicit-ok-not-appropriateness',
22
+ 'exposure-not-proof-of-reading',
23
+ 'historical-exposure-unknown',
24
+ 'exposure-after-continuation-excluded',
25
+ ];
26
+ const sorted = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
27
+ const round = (n) => Math.round(n * 100) / 100;
28
+ const bump = (record, key, by = 1) => { record[key] = (record[key] ?? 0) + by; };
29
+ /** Split concatenated zstd frames; only a failing last segment counts as an unfinished tail. */
30
+ function decode(bytes) {
31
+ const starts = [];
32
+ for (let i = bytes.indexOf(MAGIC); i !== -1; i = bytes.indexOf(MAGIC, i + 1))
33
+ starts.push(i);
34
+ if (starts[0] !== 0)
35
+ return undefined;
36
+ let text = '';
37
+ for (let i = 0; i < starts.length; i++) {
38
+ try {
39
+ text += zstdDecompressSync(bytes.subarray(starts[i], starts[i + 1])).toString('utf8');
40
+ }
41
+ catch {
42
+ if (i === starts.length - 1)
43
+ return { text, tail: true };
44
+ return undefined;
45
+ }
46
+ }
47
+ return { text, tail: false };
48
+ }
49
+ function lines(file, failures) {
50
+ let text;
51
+ try {
52
+ text = readFileSync(file, 'utf8');
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ const out = [];
58
+ for (const line of text.split('\n')) {
59
+ if (!line.trim())
60
+ continue;
61
+ try {
62
+ out.push(JSON.parse(line));
63
+ }
64
+ catch {
65
+ failures['parse-failed']++;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ function logPaths(home) {
71
+ const paths = new Map();
72
+ const root = join(home, 'sessions');
73
+ let dirs = [];
74
+ try {
75
+ dirs = readdirSync(root);
76
+ }
77
+ catch {
78
+ return paths;
79
+ }
80
+ for (const dir of dirs) {
81
+ let ids = [];
82
+ try {
83
+ ids = readdirSync(join(root, dir));
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ for (const id of ids) {
89
+ const file = join(root, dir, id, 'session.v4.jsonl.zstd');
90
+ if (existsSync(file))
91
+ paths.set(id, file);
92
+ }
93
+ }
94
+ return paths;
95
+ }
96
+ /** Calls in one session log, with the user turn each belongs to and the config in effect. */
97
+ function readSession(file, failures) {
98
+ let decoded;
99
+ try {
100
+ decoded = decode(readFileSync(file));
101
+ }
102
+ catch {
103
+ decoded = undefined;
104
+ }
105
+ if (!decoded) {
106
+ failures['decode-failed']++;
107
+ return undefined;
108
+ }
109
+ if (decoded.tail)
110
+ failures['tail-incomplete']++;
111
+ const events = [];
112
+ for (const line of decoded.text.split('\n')) {
113
+ if (!line.trim())
114
+ continue;
115
+ try {
116
+ events.push(JSON.parse(line));
117
+ }
118
+ catch {
119
+ failures['parse-failed']++;
120
+ }
121
+ }
122
+ const header = events.shift();
123
+ if (header?.type !== 'session') {
124
+ failures['decode-failed']++;
125
+ return undefined;
126
+ }
127
+ const calls = [];
128
+ const seen = new Set();
129
+ const ends = new Map(), userStarts = new Map();
130
+ let startedAt;
131
+ let turn, userTurn;
132
+ let config = {};
133
+ for (const event of events) {
134
+ const data = event.data ?? {};
135
+ if (event.type === 'turn/start') {
136
+ turn = data.turn;
137
+ startedAt = event.time;
138
+ }
139
+ else if (event.type === 'user/message' && data.source?.kind === 'user' && turn !== undefined) {
140
+ userTurn = turn;
141
+ if (typeof startedAt === 'number')
142
+ userStarts.set(turn, startedAt);
143
+ }
144
+ else if (event.type === 'turn/end' && typeof data.turn === 'number') {
145
+ const reason = data.reason;
146
+ ends.set(data.turn, reason?.kind === 'aborted' ? reason.reason?.kind === 'user' ? 'aborted-user' : 'aborted-other' : ['completed', 'blocked'].includes(reason?.kind) ? reason.kind : 'other');
147
+ }
148
+ else if (event.type === 'request/header' && data.header?.config)
149
+ config = data.header.config;
150
+ else if (event.type === 'assistant/message') {
151
+ const op = event.surfaceOp;
152
+ const append = op === undefined || op === 'append' || (typeof op === 'object' && op?.op === 'append');
153
+ const id = data.message?.id;
154
+ if (!append || typeof id !== 'string' || seen.has(id))
155
+ continue;
156
+ seen.add(id);
157
+ const usage = {};
158
+ for (const field of FIELDS)
159
+ if (typeof data.usage?.[field] === 'number')
160
+ usage[field] = data.usage[field];
161
+ calls.push({ id, ...(typeof data.turn === 'number' ? { turn: data.turn } : turn === undefined ? {} : { turn }), ...(userTurn === undefined ? {} : { userTurn }), time: event.time, usage, ...(config.model ? { model: config.model } : {}), ...(config.reasoningEffort ? { effort: config.reasoningEffort } : {}) });
162
+ }
163
+ }
164
+ return { header, calls, ends, userStarts };
165
+ }
166
+ function localMidnight(date) {
167
+ const [y, m, d] = date.split('-').map(Number);
168
+ const at = new Date(y, m - 1, d);
169
+ const offset = -at.getTimezoneOffset();
170
+ const sign = offset >= 0 ? '+' : '-';
171
+ const hh = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0'), mm = String(Math.abs(offset) % 60).padStart(2, '0');
172
+ return { ms: at.getTime(), label: `${date}T00:00:00${sign}${hh}:${mm}` };
173
+ }
174
+ function tokens(calls) {
175
+ const out = {};
176
+ for (const field of FIELDS) {
177
+ const values = calls.map(c => c.usage[field]).filter((v) => v !== undefined);
178
+ const sum = values.reduce((a, b) => a + b, 0);
179
+ out[field] = values.length ? { sum, mean: round(sum / values.length), present: values.length, calls: calls.length } : { sum: null, mean: null, present: 0, calls: calls.length };
180
+ }
181
+ return out;
182
+ }
183
+ function median(values) {
184
+ const s = [...values].sort((a, b) => a - b), mid = Math.floor(s.length / 2);
185
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
186
+ }
187
+ export function buildReport(home, options = {}) {
188
+ const failures = { 'decode-failed': 0, 'tail-incomplete': 0, 'parse-failed': 0, 'missing-log': 0 };
189
+ const state = join(home, 'state/auto-model-routing');
190
+ const since = options.since ? localMidnight(options.since) : undefined;
191
+ const after = (time) => !since || time >= since.ms;
192
+ const feedbackFile = join(state, 'allocation-feedback.jsonl');
193
+ lines(feedbackFile, failures); // counts unparsable lines; FeedbackBook skips them silently
194
+ const book = new FeedbackBook(feedbackFile);
195
+ const bindings = book.entries();
196
+ const legacyFirst = new Map();
197
+ for (const line of lines(join(state, 'judgements.jsonl'), failures)) {
198
+ if (typeof line?.sessionId !== 'string' || !Number.isInteger(line.turn) || typeof line.tier !== 'string')
199
+ continue;
200
+ legacyFirst.set(line.sessionId, Math.min(legacyFirst.get(line.sessionId) ?? Infinity, line.turn));
201
+ }
202
+ const ledgerSessions = [...new Set([...bindings.map(b => b.sessionId), ...legacyFirst.keys()])].sort();
203
+ const paths = logPaths(home);
204
+ const excluded = { child: 0, seeded: 0 };
205
+ const decisions = new Map();
206
+ const reference = {};
207
+ const autoEffort = {};
208
+ let unlinkedBindings = 0, userTurns = 0, turnsWithoutRecord = 0;
209
+ for (const session of ledgerSessions) {
210
+ const path = paths.get(session);
211
+ if (!path) {
212
+ failures['missing-log']++;
213
+ continue;
214
+ }
215
+ const log = readSession(path, failures);
216
+ if (!log)
217
+ continue;
218
+ if (log.header.origin) {
219
+ excluded.child++;
220
+ continue;
221
+ }
222
+ if (log.header.isSeeded === true) {
223
+ excluded.seeded++;
224
+ continue;
225
+ }
226
+ const nextUserStart = new Map();
227
+ let next;
228
+ const turns = [...new Set([...log.calls.flatMap(call => call.turn === undefined ? [] : [call.turn]), ...log.userStarts.keys()])].sort((a, b) => b - a);
229
+ for (const turn of turns) {
230
+ if (next !== undefined)
231
+ nextUserStart.set(turn, next);
232
+ if (log.userStarts.has(turn))
233
+ next = log.userStarts.get(turn);
234
+ }
235
+ const linked = new Set();
236
+ let v2First;
237
+ for (const call of log.calls) {
238
+ const b = book.lookup(session, call.id);
239
+ if (!b)
240
+ continue;
241
+ linked.add(call.id);
242
+ if (call.userTurn !== undefined)
243
+ v2First = Math.min(v2First ?? Infinity, call.userTurn);
244
+ const key = JSON.stringify([session, b.decisionId]);
245
+ let d = decisions.get(key);
246
+ if (!d) {
247
+ d = { session, id: b.decisionId, version: b.snapshot ? `${b.snapshot.policyVersion}/${b.snapshot.mappingVersion}` : 'unknown', model: b.model, depth: b.depth, effort: b.effort, calls: [], feedback: new Set() };
248
+ decisions.set(key, d);
249
+ }
250
+ d.calls.push(call);
251
+ d.end = call.turn === undefined ? 'unknown' : log.ends.get(call.turn) ?? 'unknown';
252
+ d.continuation = call.turn === undefined ? undefined : nextUserStart.get(call.turn);
253
+ if (b.feedback)
254
+ d.feedback.add(b.feedback);
255
+ }
256
+ unlinkedBindings += bindings.filter(b => b.sessionId === session && !linked.has(b.messageId)).length;
257
+ // Coverage: user turns from the first v2 record on, with calls in range but none linked.
258
+ if (v2First !== undefined) {
259
+ const turns = new Map();
260
+ for (const call of log.calls) {
261
+ if (call.userTurn === undefined || call.userTurn < v2First || !after(call.time))
262
+ continue;
263
+ turns.set(call.userTurn, (turns.get(call.userTurn) ?? false) || linked.has(call.id));
264
+ }
265
+ userTurns += turns.size;
266
+ turnsWithoutRecord += [...turns.values()].filter(v => !v).length;
267
+ }
268
+ // Reference: calls before the earliest record in either ledger (effort only, never tokens).
269
+ const first = Math.min(legacyFirst.get(session) ?? Infinity, v2First ?? Infinity);
270
+ for (const call of log.calls) {
271
+ if (call.userTurn === undefined || call.userTurn >= first || !after(call.time) || !call.model)
272
+ continue;
273
+ const r = (reference[call.model] ??= { calls: 0, sessions: new Set(), effort: {} });
274
+ r.calls++;
275
+ r.sessions.add(session);
276
+ bump(r.effort, call.effort ?? 'unknown');
277
+ }
278
+ }
279
+ const kept = [...decisions.values()].filter(d => after(Math.min(...d.calls.map(c => c.time))));
280
+ for (const d of kept)
281
+ bump((autoEffort[d.model] ??= {}), d.effort, d.calls.length);
282
+ const versions = new Map();
283
+ for (const d of kept) {
284
+ const byVersion = versions.get(d.version) ?? new Map();
285
+ versions.set(d.version, byVersion);
286
+ const key = JSON.stringify([d.model, d.depth]);
287
+ byVersion.set(key, [...(byVersion.get(key) ?? []), d]);
288
+ }
289
+ const groups = [...versions].sort(([a], [b]) => a.localeCompare(b)).map(([version, subs]) => ({
290
+ version,
291
+ subgroups: [...subs].sort(([a], [b]) => a.localeCompare(b)).map(([key, list]) => {
292
+ const [model, depth] = JSON.parse(key);
293
+ const calls = list.flatMap(d => d.calls);
294
+ const sessions = new Set(list.map(d => d.session)).size;
295
+ const feedback = { ...Object.fromEntries(FEEDBACK.map(f => [f, 0])), conflict: 0, unrated: 0 };
296
+ for (const d of list)
297
+ bump(feedback, d.feedback.size === 0 ? 'unrated' : d.feedback.size > 1 ? 'conflict' : [...d.feedback][0]);
298
+ const implicit = {
299
+ counts: { explicit: 0, negative: 0, 'implicit-ok': 0, inconclusive: 0, unobserved: 0 },
300
+ explicit: { ...Object.fromEntries(FEEDBACK.map(f => [f, 0])), conflict: 0 },
301
+ exposedExplicit: { ...Object.fromEntries(FEEDBACK.map(f => [f, 0])), conflict: 0 },
302
+ negative: { 'aborted-user': 0, 'manual-switch': 0 },
303
+ };
304
+ for (const d of list) {
305
+ const facts = book.facts(d.session, d.id);
306
+ const exposed = facts.exposed !== undefined && (d.continuation === undefined || facts.exposed < d.continuation);
307
+ const label = d.feedback.size > 1 ? 'conflict' : [...d.feedback][0];
308
+ if (label) {
309
+ implicit.counts.explicit++;
310
+ bump(implicit.explicit, label);
311
+ if (exposed)
312
+ bump(implicit.exposedExplicit, label);
313
+ }
314
+ else if (d.end === 'aborted-user' || facts['manual-switch'] !== undefined) {
315
+ implicit.counts.negative++;
316
+ if (d.end === 'aborted-user')
317
+ implicit.negative['aborted-user']++;
318
+ if (facts['manual-switch'] !== undefined)
319
+ implicit.negative['manual-switch']++;
320
+ }
321
+ else if (exposed && d.end === 'completed' && d.continuation !== undefined)
322
+ implicit.counts['implicit-ok']++;
323
+ else if (exposed)
324
+ implicit.counts.inconclusive++;
325
+ else
326
+ implicit.counts.unobserved++;
327
+ }
328
+ const byEffort = {};
329
+ for (const d of list) {
330
+ const e = (byEffort[d.effort] ??= { decisions: 0, calls: 0 });
331
+ e.decisions++;
332
+ e.calls += d.calls.length;
333
+ }
334
+ return {
335
+ model, depth, decisions: list.length, sessions, calls: calls.length,
336
+ callsPerDecision: { median: median(list.map(d => d.calls.length)), max: Math.max(...list.map(d => d.calls.length)) },
337
+ tokens: tokens(calls), byEffort: sorted(byEffort), feedback, implicit,
338
+ conclusion: list.length >= 20 && sessions >= 3 ? 'observed' : 'insufficient-sample',
339
+ };
340
+ }),
341
+ }));
342
+ const recorded = new Set(ledgerSessions.map(s => createHash('sha256').update(s).digest('hex')));
343
+ let autoSessionsWithoutRecord = 0;
344
+ let stateFiles = [];
345
+ try {
346
+ stateFiles = readdirSync(join(state, 'sessions')).filter(f => f.endsWith('.json')).sort();
347
+ }
348
+ catch { /* none */ }
349
+ for (const file of stateFiles) {
350
+ let mode;
351
+ try {
352
+ mode = JSON.parse(readFileSync(join(state, 'sessions', file), 'utf8'))?.mode;
353
+ }
354
+ catch {
355
+ failures['parse-failed']++;
356
+ continue;
357
+ }
358
+ if (mode === 'AUTO' && !recorded.has(file.slice(0, -5)))
359
+ autoSessionsWithoutRecord++;
360
+ }
361
+ return {
362
+ schema: 1,
363
+ since: since?.label ?? null,
364
+ failures, excluded,
365
+ coverage: { userTurns, turnsWithoutRecord, unlinkedBindings, autoSessionsWithoutRecord },
366
+ groups,
367
+ referenceBeforeAuto: Object.keys(reference).sort().map(model => ({
368
+ model, calls: reference[model].calls, sessions: reference[model].sessions.size,
369
+ effort: sorted(reference[model].effort), autoEffort: sorted(autoEffort[model] ?? {}),
370
+ })),
371
+ caveats: CAVEATS,
372
+ };
373
+ }
374
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
375
+ try {
376
+ const { values } = parseArgs({ options: { since: { type: 'string' } } });
377
+ if (values.since !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(values.since)) {
378
+ process.stderr.write('usage-report: --since must be YYYY-MM-DD\n');
379
+ process.exit(2);
380
+ }
381
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
382
+ process.stdout.write(`${JSON.stringify(buildReport(home, values.since ? { since: values.since } : {}), null, 2)}\n`);
383
+ }
384
+ catch {
385
+ process.stderr.write('usage-report: failed\n');
386
+ process.exit(1);
387
+ }
388
+ }
package/package.json CHANGED
@@ -1,13 +1,26 @@
1
1
  {
2
2
  "name": "dsh-auto-model-routing",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "DeepSeek Harness plugin: independent Jev capability and effort decisions, enforced fresh-child routes, decision visibility and allocation feedback",
5
5
  "license": "MIT",
6
6
  "author": "prgrmrwy",
7
- "repository": { "type": "git", "url": "git+https://github.com/prgrmrwy/dsh-auto-model-routing.git" },
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/prgrmrwy/dsh-auto-model-routing.git"
10
+ },
8
11
  "homepage": "https://github.com/prgrmrwy/dsh-auto-model-routing#readme",
9
- "bugs": { "url": "https://github.com/prgrmrwy/dsh-auto-model-routing/issues" },
10
- "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "reasoning-effort", "model-routing", "jev", "subagent"],
12
+ "bugs": {
13
+ "url": "https://github.com/prgrmrwy/dsh-auto-model-routing/issues"
14
+ },
15
+ "keywords": [
16
+ "dsh",
17
+ "dsh-plugin",
18
+ "deepseek-harness",
19
+ "reasoning-effort",
20
+ "model-routing",
21
+ "jev",
22
+ "subagent"
23
+ ],
11
24
  "type": "module",
12
25
  "main": "./lib/index.js",
13
26
  "types": "./lib/index.d.ts",
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: auto-model-routing-report
3
+ description: 仅当用户明确要求评估 AUTO 效果、观察 AUTO 期间的 token 用量或判断分布,或直接点名 auto-model-routing-report 时使用:只读汇总本地 AUTO 决策记录与会话实际用量。不得主动执行或创建定时任务;会话处于 AUTO、普通 token 问题、路由判断或日常任务都不是触发条件。
4
+ ---
5
+
6
+ # AUTO 用量观察报告
7
+
8
+ **仅当用户明确要求时使用。** 不得主动执行,也不得创建定时任务或后台采集。会话处于 AUTO、用户询问 token 计价、讨论路由策略,都不是使用本 skill 的理由。
9
+
10
+ ## 它能回答什么
11
+
12
+ - AUTO 期间做了哪些决策,按策略版本、模型和推理深度(depth)分组,以及这些决策实际消耗了多少 token。
13
+ - 数据覆盖情况:哪些回合没有记录,以及有多少会话处于 AUTO 但完全没有记录。
14
+ - 对照首个 AUTO 记录之前的回合,effort 起点发生了什么变化。
15
+
16
+ **它不能回答“AUTO 省了多少”**:这是观察数据,不是受控对照。
17
+
18
+ ## 步骤
19
+
20
+ 1. 运行(只读:不写文件、不联网、不调用模型):
21
+
22
+ ```bash
23
+ node "<插件目录>/lib/usage-report.js" # 全部数据
24
+ node "<插件目录>/lib/usage-report.js" --since 2026-11-01 # 从本地时区该日 00:00 起
25
+ ```
26
+
27
+ `<插件目录>` 是 `dsh-auto-model-routing` 包所在的目录。`$DSH_HOME` 未设置时,默认读取 `~/.dsh`。
28
+
29
+ 2. **先检查数据质量,再看数字:**
30
+ - `failures` 中任一项明显不为 0,或 `coverage.unlinkedBindings` 偏高时,说明日志格式可能已随 DSH 升级而变化。此时停止解读,先告诉用户需要核对。
31
+ - `failures.tail-incomplete` 为 1 通常是正常的,因为当前会话的日志还在写入。
32
+
33
+ 3. 转述结果时**保留以下含义,不得放大**:
34
+ - `conclusion: insufficient-sample` → “样本不足,尚不能判断”。门槛是 ≥20 个决策,且来自 ≥3 个会话。
35
+ - `caveats` 中的每一项都要用一句话说明:
36
+ - `observational-not-controlled`:观察数据,不是受控对照;判断因果需要专门对照实验。
37
+ - `scope-main-session-assistant-calls-only`:不含子会话、压缩摘要、标题生成、Jev 判档和失败重试的用量,因此不能推出“节省”。
38
+ - `only-applied-decisions-recorded`:只记录实际生效的决策,已统计部分可能偏向某类回合;`coverage.turnsWithoutRecord` 显示缺口大小。
39
+ - `token-semantics-differ-by-provider`:不同 provider 的 token 口径可能不同,不要跨模型比较。
40
+ - `reference-before-auto-effort-only-confounded`:`referenceBeforeAuto` 只用于看 effort 起点的变化;它受会话阶段、任务类型、模型切换等因素混杂,不能当作对照组。
41
+ - `mode-history-not-persisted`:会话模式的切换历史没有保存,会话计数只是粗略参考。
42
+ - `implicit-ok-not-appropriateness`:隐式无异议不等于分配合适,不能计作显式好评。
43
+ - `exposure-not-proof-of-reading`:前台可见 3 秒只是曝光,不证明用户读过。
44
+ - `historical-exposure-unknown`:上线前的曝光未知,不回填。
45
+ - `exposure-after-continuation-excluded`:下一用户回合之后才出现的曝光不算,避免翻看旧记录制造认可。
46
+ - `implicit.counts` 按优先级把决策分为显式评价、负向行为、隐式无异议、无定论、未曝光,五类合计为决策数;`implicit.negative` 的两个原因可重叠。`implicit.exposedExplicit` 是有效曝光且有显式评价的参照,不是准确率。
47
+ - 不要自行计算节省百分比,不要说“质量不变”,也不要把“首个记录之前”称为 MANUAL。
48
+ - token 均值的分母是**出现该字段的调用数**(`present`);值为 `null` 表示该字段从未出现,不是 0。
49
+
50
+ 4. 如果要把报告原文贴到当前会话之外,例如飞书或 issue,**先请用户确认**。模型名、provider 名可能包含内部信息。
51
+
52
+ ## 前提
53
+
54
+ - 报告每次都从原始记录和会话日志重新计算,不保存快照。用户删除旧会话后,对应历史无法再统计。
55
+ - 旧版策略(legacy)的判档一致率请用同一插件目录下的 `scripts/label-report.mjs`。