dsh-auto-model-routing 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@
12
12
  - appropriate/too-high/too-low分别统计;unknown不是满意,null撤销不计有效评价;未评价不能计为合适。多条消息评价冲突也应单独报告。
13
13
  - 按策略版本/映射版本/服务模型及depth/实际路由分组;旧缺字段独立unknown组,不混进新策略精确对照。
14
14
  - 以requestSeq和消息ID关联实际usage/延迟/返工证据;拿不到价格或token就未知。用户主观反馈不等于正确性oracle,适合率也不能单独证明质量或节省。
15
- - 本期只提供采集与可追溯性,不做报表、自动阈值调整、自动训练或换模重试。
15
+ - 按需观察报表见包内 skill `auto-model-routing-report`(`lib/usage-report.js`,只读);仍不做自动阈值调整、自动训练或换模重试。
16
16
 
17
17
  ## 确定度
18
18
  UI称“判定确定度”,Choice公式为`(pmax - 1/n)/(1 - 1/n)`;n为该维候选数。三选一最高概率70%得到55%,不等于选中概率或任务成功率。插件直接展示服务返回值,分布独立保留,不从四舍五入后的百分比反推。官方定义:https://docs.typesafe.ai/confidence.md 。
@@ -46,15 +46,15 @@
46
46
 
47
47
  以下为核验时已安装的官方发布包位置,不是上游改动要求。临时 prefix 可按下方命令重建;其中源码行号仅适用于此 pin。
48
48
 
49
- - [pre-step / request 类型](../../../node_modules/@deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts#L293-L332):公开事件输入和 waterfall 返回契约。
50
- - [Loop 请求链](../../../node_modules/@deepseek-ai/dsh-agent-loop/lib/index.js#L1164-L1220):实际调用 waterfall 后才 `llm.prepareCall`,记录 resolved request header。
51
- - [scopeTarget](../../../node_modules/@deepseek-ai/dsh-scope/lib/index.js#L315-L337):无 Agent 作用域标记的全局 listener 可观察不同子身份。
52
- - [SubagentStartRequest](../../../node_modules/@deepseek-ai/dsh-subagent/lib/types/types.d.ts#L136-L162):公开 `agentOptions`,不必通过工具 schema 推断宿主能力。
53
- - [continuation 创建](../../../node_modules/@deepseek-ai/dsh-subagent/lib/types/continuation.js#L101-L176)、[冷恢复](../../../node_modules/@deepseek-ai/dsh-subagent/lib/types/continuation.js#L354-L374):descriptor 与恢复选项。
54
- - [官方 Team 创建转发](/tmp/dsh-official-routing-probe.HImElm/node_modules/@deepseek-ai/dsh-experimental-agent-team/lib/index.js#L573-L582):只转发 prompt/parent,未传 agentOptions;并不妨碍插件在 child request 入口配置。
55
- - [默认选择副作用](../../../node_modules/@deepseek-ai/dsh-api-session-controller/lib/index.js#L720-L739):`selectModel` 还会 `saveSelection`。
56
-
57
- `resolveChildAgentOptions` 是 [官方公开导出](../../../node_modules/@deepseek-ai/dsh-subagent/lib/types/index.d.ts#L55),前期函数级实验使用内部文件路径不意味着函数本身是私有 API。本次整链探针没有导入任何内部 helper。
49
+ - pre-step / request 类型(`node_modules/@deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts#L293-L332`):公开事件输入和 waterfall 返回契约。
50
+ - Loop 请求链(`node_modules/@deepseek-ai/dsh-agent-loop/lib/index.js#L1164-L1220`):实际调用 waterfall 后才 `llm.prepareCall`,记录 resolved request header。
51
+ - scopeTarget(`node_modules/@deepseek-ai/dsh-scope/lib/index.js#L315-L337`):无 Agent 作用域标记的全局 listener 可观察不同子身份。
52
+ - SubagentStartRequest(`node_modules/@deepseek-ai/dsh-subagent/lib/types/types.d.ts#L136-L162`):公开 `agentOptions`,不必通过工具 schema 推断宿主能力。
53
+ - continuation 创建(`node_modules/@deepseek-ai/dsh-subagent/lib/types/continuation.js#L101-L176`)、冷恢复(`node_modules/@deepseek-ai/dsh-subagent/lib/types/continuation.js#L354-L374`):descriptor 与恢复选项。
54
+ - 官方 Team 创建转发(`/tmp/dsh-official-routing-probe.HImElm/node_modules/@deepseek-ai/dsh-experimental-agent-team/lib/index.js#L573-L582`):只转发 prompt/parent,未传 agentOptions;并不妨碍插件在 child request 入口配置。
55
+ - 默认选择副作用(`node_modules/@deepseek-ai/dsh-api-session-controller/lib/index.js#L720-L739`):`selectModel` 还会 `saveSelection`。
56
+
57
+ `resolveChildAgentOptions` 是 官方公开导出(`node_modules/@deepseek-ai/dsh-subagent/lib/types/index.d.ts#L55`),前期函数级实验使用内部文件路径不意味着函数本身是私有 API。本次整链探针没有导入任何内部 helper。
58
58
 
59
59
  ## 重跑
60
60
 
@@ -12,10 +12,10 @@
12
12
 
13
13
  ## 当前证据
14
14
 
15
- - 当前规范:../../../openspec/specs/auto-model-routing/spec.md,固定主模型、共享四档 rubric、childRoutes 与派发规则。
15
+ - 当前规范:[主仓规范](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/auto-model-routing/spec.md)(本节是历史调研快照,不代表当前默认策略),固定主模型、共享四档 rubric、childRoutes 与派发规则。
16
16
  - 默认主模型起点与偏移:../src/effort.ts;Claude 子表 Haiku/Sonnet/Opus/Opus;GPT 子表 Luna/Sol/Sol/Sol。
17
17
  - 子工具:../src/subagent-tool.ts,单独判断子任务;指定模型时只调 effort;校验目录与允许列表。
18
- - 包内指导:../skills/subagent-model-routing/SKILL.md,主要约束选模、调用与复用;是否派发的场景主要位于 ../../../instructions/dsh-home.md。历史提交 14d68627(2026-10-22)已明确加入较大编码、独立复核、大量探索、可并行工作的派发规则,以及小修复/讨论不派发;这确认用户记忆中的委派要求确实曾落地,但不是所有简单任务强制派发。
18
+ - 包内指导:../skills/subagent-model-routing/SKILL.md,主要约束选模、调用与复用;是否派发的场景主要位于 [主仓环境指令](https://github.com/prgrmrwy/ohmydsh/blob/main/instructions/dsh-home.md)(可选背景,不是插件加载依赖)。历史提交 14d68627(2026-10-22)已明确加入较大编码、独立复核、大量探索、可并行工作的派发规则,以及小修复/讨论不派发;这确认用户记忆中的委派要求确实曾落地,但不是所有简单任务强制派发。
19
19
  - 来源与评估范围:../eval/README.md,现有回归是判档一致性,不是最终任务质量/速度/费用基准。
20
20
 
21
21
  ## Gap 与建议(待调研校准,不是实施授权)
@@ -95,7 +95,7 @@ Jev 的价值不仅是体验和性能;误判会损害质量。但策略质量
95
95
 
96
96
  - [核心诉求与差距](routing-intent-and-gaps.md)
97
97
  - [外部调研](routing-community-research-2026-10-24.md):历史调研建议,实施优先级以本修订为准。
98
- - [现行规范](../../../openspec/specs/auto-model-routing/spec.md):仍描述当前行为,本稿不静默替换它。
98
+ - [现行规范](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/auto-model-routing/spec.md):仍描述当前行为,本稿不静默替换它。
99
99
  - [Claude Code model configuration](https://code.claude.com/docs/en/model-config.md):opusplan 是规划阶段 Opus、执行阶段 Sonnet,不等于固定强主监督轻子。
100
100
  - [Claude Code subagents](https://code.claude.com/docs/en/sub-agents.md):主模型根据职责描述委派。
101
101
  - [Claude Code Agent Teams](https://code.claude.com/docs/en/agent-teams.md):Lead 组织共享任务与协作,不天然保证异构模型或低成本。
package/lib/feedback.d.ts CHANGED
@@ -62,6 +62,32 @@ export declare class FeedbackBook {
62
62
  feedback?: "appropriate" | "too-high" | "too-low" | "unknown" | null | undefined;
63
63
  feedbackAt?: number | undefined;
64
64
  } | undefined;
65
+ /** All folded bindings in file order (read-only; used by the usage report). */
66
+ entries(): {
67
+ sessionId: string;
68
+ messageId: string;
69
+ decisionId: number;
70
+ depth: string;
71
+ model: string;
72
+ effort: string;
73
+ snapshot?: {
74
+ decidedAt: number;
75
+ appliedAt: number;
76
+ policyVersion: string;
77
+ mappingVersion: string;
78
+ decisionProvider: string;
79
+ decisionModel: string;
80
+ ms: number;
81
+ provider: string;
82
+ requestSeq: number;
83
+ confidence?: number | undefined;
84
+ probabilities?: Record<string, number> | undefined;
85
+ requestTurn?: number | undefined;
86
+ } | undefined;
87
+ boundAt?: number | undefined;
88
+ feedback?: "appropriate" | "too-high" | "too-low" | "unknown" | null | undefined;
89
+ feedbackAt?: number | undefined;
90
+ }[];
65
91
  bind(record: FeedbackRecord): void;
66
92
  set(sessionId: string, messageId: string, feedback: typeof allocationFeedback[number] | null): void;
67
93
  private append;
package/lib/feedback.js CHANGED
@@ -51,6 +51,8 @@ export class FeedbackBook {
51
51
  }
52
52
  key(sessionId, messageId) { return JSON.stringify([sessionId, messageId]); }
53
53
  lookup(sessionId, messageId) { const r = this.records.get(this.key(sessionId, messageId)); return r && structuredClone(r); }
54
+ /** All folded bindings in file order (read-only; used by the usage report). */
55
+ entries() { return [...this.records.values()].map(r => structuredClone(r)); }
54
56
  bind(record) { if (this.lookup(record.sessionId, record.messageId))
55
57
  return; const r = schema.parse({ ...record, boundAt: Date.now() }); this.append(r); this.records.set(this.key(r.sessionId, r.messageId), r); }
56
58
  set(sessionId, messageId, feedback) {
@@ -0,0 +1,59 @@
1
+ type Failure = 'decode-failed' | 'tail-incomplete' | 'parse-failed' | 'missing-log';
2
+ export declare function buildReport(home: string, options?: {
3
+ since?: string;
4
+ }): {
5
+ schema: number;
6
+ since: string | null;
7
+ failures: Record<Failure, number>;
8
+ excluded: {
9
+ child: number;
10
+ seeded: number;
11
+ };
12
+ coverage: {
13
+ userTurns: number;
14
+ turnsWithoutRecord: number;
15
+ unlinkedBindings: number;
16
+ autoSessionsWithoutRecord: number;
17
+ };
18
+ groups: {
19
+ version: string;
20
+ subgroups: {
21
+ model: string;
22
+ depth: string;
23
+ decisions: number;
24
+ sessions: number;
25
+ calls: number;
26
+ callsPerDecision: {
27
+ median: number;
28
+ max: number;
29
+ };
30
+ tokens: Record<string, {
31
+ sum: number | null;
32
+ mean: number | null;
33
+ present: number;
34
+ calls: number;
35
+ }>;
36
+ byEffort: {
37
+ [k: string]: {
38
+ decisions: number;
39
+ calls: number;
40
+ };
41
+ };
42
+ feedback: Record<string, number>;
43
+ conclusion: string;
44
+ }[];
45
+ }[];
46
+ referenceBeforeAuto: {
47
+ model: string;
48
+ calls: number;
49
+ sessions: number;
50
+ effort: {
51
+ [k: string]: number;
52
+ };
53
+ autoEffort: {
54
+ [k: string]: number;
55
+ };
56
+ }[];
57
+ caveats: string[];
58
+ };
59
+ export {};
@@ -0,0 +1,332 @@
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
+ ];
22
+ const sorted = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
23
+ const round = (n) => Math.round(n * 100) / 100;
24
+ const bump = (record, key, by = 1) => { record[key] = (record[key] ?? 0) + by; };
25
+ /** Split concatenated zstd frames; only a failing last segment counts as an unfinished tail. */
26
+ function decode(bytes) {
27
+ const starts = [];
28
+ for (let i = bytes.indexOf(MAGIC); i !== -1; i = bytes.indexOf(MAGIC, i + 1))
29
+ starts.push(i);
30
+ if (starts[0] !== 0)
31
+ return undefined;
32
+ let text = '';
33
+ for (let i = 0; i < starts.length; i++) {
34
+ try {
35
+ text += zstdDecompressSync(bytes.subarray(starts[i], starts[i + 1])).toString('utf8');
36
+ }
37
+ catch {
38
+ if (i === starts.length - 1)
39
+ return { text, tail: true };
40
+ return undefined;
41
+ }
42
+ }
43
+ return { text, tail: false };
44
+ }
45
+ function lines(file, failures) {
46
+ let text;
47
+ try {
48
+ text = readFileSync(file, 'utf8');
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ const out = [];
54
+ for (const line of text.split('\n')) {
55
+ if (!line.trim())
56
+ continue;
57
+ try {
58
+ out.push(JSON.parse(line));
59
+ }
60
+ catch {
61
+ failures['parse-failed']++;
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ function logPaths(home) {
67
+ const paths = new Map();
68
+ const root = join(home, 'sessions');
69
+ let dirs = [];
70
+ try {
71
+ dirs = readdirSync(root);
72
+ }
73
+ catch {
74
+ return paths;
75
+ }
76
+ for (const dir of dirs) {
77
+ let ids = [];
78
+ try {
79
+ ids = readdirSync(join(root, dir));
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ for (const id of ids) {
85
+ const file = join(root, dir, id, 'session.v4.jsonl.zstd');
86
+ if (existsSync(file))
87
+ paths.set(id, file);
88
+ }
89
+ }
90
+ return paths;
91
+ }
92
+ /** Calls in one session log, with the user turn each belongs to and the config in effect. */
93
+ function readSession(file, failures) {
94
+ let decoded;
95
+ try {
96
+ decoded = decode(readFileSync(file));
97
+ }
98
+ catch {
99
+ decoded = undefined;
100
+ }
101
+ if (!decoded) {
102
+ failures['decode-failed']++;
103
+ return undefined;
104
+ }
105
+ if (decoded.tail)
106
+ failures['tail-incomplete']++;
107
+ const events = [];
108
+ for (const line of decoded.text.split('\n')) {
109
+ if (!line.trim())
110
+ continue;
111
+ try {
112
+ events.push(JSON.parse(line));
113
+ }
114
+ catch {
115
+ failures['parse-failed']++;
116
+ }
117
+ }
118
+ const header = events.shift();
119
+ if (header?.type !== 'session') {
120
+ failures['decode-failed']++;
121
+ return undefined;
122
+ }
123
+ const calls = [];
124
+ const seen = new Set();
125
+ let turn, userTurn;
126
+ let config = {};
127
+ for (const event of events) {
128
+ const data = event.data ?? {};
129
+ if (event.type === 'turn/start')
130
+ turn = data.turn;
131
+ else if (event.type === 'user/message' && data.source?.kind === 'user' && turn !== undefined)
132
+ userTurn = turn;
133
+ else if (event.type === 'request/header' && data.header?.config)
134
+ config = data.header.config;
135
+ else if (event.type === 'assistant/message') {
136
+ const op = event.surfaceOp;
137
+ const append = op === undefined || op === 'append' || (typeof op === 'object' && op?.op === 'append');
138
+ const id = data.message?.id;
139
+ if (!append || typeof id !== 'string' || seen.has(id))
140
+ continue;
141
+ seen.add(id);
142
+ const usage = {};
143
+ for (const field of FIELDS)
144
+ if (typeof data.usage?.[field] === 'number')
145
+ usage[field] = data.usage[field];
146
+ calls.push({ id, ...(userTurn === undefined ? {} : { userTurn }), time: event.time, usage, ...(config.model ? { model: config.model } : {}), ...(config.reasoningEffort ? { effort: config.reasoningEffort } : {}) });
147
+ }
148
+ }
149
+ return { header, calls };
150
+ }
151
+ function localMidnight(date) {
152
+ const [y, m, d] = date.split('-').map(Number);
153
+ const at = new Date(y, m - 1, d);
154
+ const offset = -at.getTimezoneOffset();
155
+ const sign = offset >= 0 ? '+' : '-';
156
+ const hh = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0'), mm = String(Math.abs(offset) % 60).padStart(2, '0');
157
+ return { ms: at.getTime(), label: `${date}T00:00:00${sign}${hh}:${mm}` };
158
+ }
159
+ function tokens(calls) {
160
+ const out = {};
161
+ for (const field of FIELDS) {
162
+ const values = calls.map(c => c.usage[field]).filter((v) => v !== undefined);
163
+ const sum = values.reduce((a, b) => a + b, 0);
164
+ 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 };
165
+ }
166
+ return out;
167
+ }
168
+ function median(values) {
169
+ const s = [...values].sort((a, b) => a - b), mid = Math.floor(s.length / 2);
170
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
171
+ }
172
+ export function buildReport(home, options = {}) {
173
+ const failures = { 'decode-failed': 0, 'tail-incomplete': 0, 'parse-failed': 0, 'missing-log': 0 };
174
+ const state = join(home, 'state/auto-model-routing');
175
+ const since = options.since ? localMidnight(options.since) : undefined;
176
+ const after = (time) => !since || time >= since.ms;
177
+ const feedbackFile = join(state, 'allocation-feedback.jsonl');
178
+ lines(feedbackFile, failures); // counts unparsable lines; FeedbackBook skips them silently
179
+ const book = new FeedbackBook(feedbackFile);
180
+ const bindings = book.entries();
181
+ const legacyFirst = new Map();
182
+ for (const line of lines(join(state, 'judgements.jsonl'), failures)) {
183
+ if (typeof line?.sessionId !== 'string' || !Number.isInteger(line.turn) || typeof line.tier !== 'string')
184
+ continue;
185
+ legacyFirst.set(line.sessionId, Math.min(legacyFirst.get(line.sessionId) ?? Infinity, line.turn));
186
+ }
187
+ const ledgerSessions = [...new Set([...bindings.map(b => b.sessionId), ...legacyFirst.keys()])].sort();
188
+ const paths = logPaths(home);
189
+ const excluded = { child: 0, seeded: 0 };
190
+ const decisions = new Map();
191
+ const reference = {};
192
+ const autoEffort = {};
193
+ let unlinkedBindings = 0, userTurns = 0, turnsWithoutRecord = 0;
194
+ for (const session of ledgerSessions) {
195
+ const path = paths.get(session);
196
+ if (!path) {
197
+ failures['missing-log']++;
198
+ continue;
199
+ }
200
+ const log = readSession(path, failures);
201
+ if (!log)
202
+ continue;
203
+ if (log.header.origin) {
204
+ excluded.child++;
205
+ continue;
206
+ }
207
+ if (log.header.isSeeded === true) {
208
+ excluded.seeded++;
209
+ continue;
210
+ }
211
+ const linked = new Set();
212
+ let v2First;
213
+ for (const call of log.calls) {
214
+ const b = book.lookup(session, call.id);
215
+ if (!b)
216
+ continue;
217
+ linked.add(call.id);
218
+ if (call.userTurn !== undefined)
219
+ v2First = Math.min(v2First ?? Infinity, call.userTurn);
220
+ const key = JSON.stringify([session, b.decisionId]);
221
+ let d = decisions.get(key);
222
+ if (!d) {
223
+ d = { session, version: b.snapshot ? `${b.snapshot.policyVersion}/${b.snapshot.mappingVersion}` : 'unknown', model: b.model, depth: b.depth, effort: b.effort, calls: [], feedback: new Set() };
224
+ decisions.set(key, d);
225
+ }
226
+ d.calls.push(call);
227
+ if (b.feedback)
228
+ d.feedback.add(b.feedback);
229
+ }
230
+ unlinkedBindings += bindings.filter(b => b.sessionId === session && !linked.has(b.messageId)).length;
231
+ // Coverage: user turns from the first v2 record on, with calls in range but none linked.
232
+ if (v2First !== undefined) {
233
+ const turns = new Map();
234
+ for (const call of log.calls) {
235
+ if (call.userTurn === undefined || call.userTurn < v2First || !after(call.time))
236
+ continue;
237
+ turns.set(call.userTurn, (turns.get(call.userTurn) ?? false) || linked.has(call.id));
238
+ }
239
+ userTurns += turns.size;
240
+ turnsWithoutRecord += [...turns.values()].filter(v => !v).length;
241
+ }
242
+ // Reference: calls before the earliest record in either ledger (effort only, never tokens).
243
+ const first = Math.min(legacyFirst.get(session) ?? Infinity, v2First ?? Infinity);
244
+ for (const call of log.calls) {
245
+ if (call.userTurn === undefined || call.userTurn >= first || !after(call.time) || !call.model)
246
+ continue;
247
+ const r = (reference[call.model] ??= { calls: 0, sessions: new Set(), effort: {} });
248
+ r.calls++;
249
+ r.sessions.add(session);
250
+ bump(r.effort, call.effort ?? 'unknown');
251
+ }
252
+ }
253
+ const kept = [...decisions.values()].filter(d => after(Math.min(...d.calls.map(c => c.time))));
254
+ for (const d of kept)
255
+ bump((autoEffort[d.model] ??= {}), d.effort, d.calls.length);
256
+ const versions = new Map();
257
+ for (const d of kept) {
258
+ const byVersion = versions.get(d.version) ?? new Map();
259
+ versions.set(d.version, byVersion);
260
+ const key = JSON.stringify([d.model, d.depth]);
261
+ byVersion.set(key, [...(byVersion.get(key) ?? []), d]);
262
+ }
263
+ const groups = [...versions].sort(([a], [b]) => a.localeCompare(b)).map(([version, subs]) => ({
264
+ version,
265
+ subgroups: [...subs].sort(([a], [b]) => a.localeCompare(b)).map(([key, list]) => {
266
+ const [model, depth] = JSON.parse(key);
267
+ const calls = list.flatMap(d => d.calls);
268
+ const sessions = new Set(list.map(d => d.session)).size;
269
+ const feedback = { ...Object.fromEntries(FEEDBACK.map(f => [f, 0])), conflict: 0, unrated: 0 };
270
+ for (const d of list)
271
+ bump(feedback, d.feedback.size === 0 ? 'unrated' : d.feedback.size > 1 ? 'conflict' : [...d.feedback][0]);
272
+ const byEffort = {};
273
+ for (const d of list) {
274
+ const e = (byEffort[d.effort] ??= { decisions: 0, calls: 0 });
275
+ e.decisions++;
276
+ e.calls += d.calls.length;
277
+ }
278
+ return {
279
+ model, depth, decisions: list.length, sessions, calls: calls.length,
280
+ callsPerDecision: { median: median(list.map(d => d.calls.length)), max: Math.max(...list.map(d => d.calls.length)) },
281
+ tokens: tokens(calls), byEffort: sorted(byEffort), feedback,
282
+ conclusion: list.length >= 20 && sessions >= 3 ? 'observed' : 'insufficient-sample',
283
+ };
284
+ }),
285
+ }));
286
+ const recorded = new Set(ledgerSessions.map(s => createHash('sha256').update(s).digest('hex')));
287
+ let autoSessionsWithoutRecord = 0;
288
+ let stateFiles = [];
289
+ try {
290
+ stateFiles = readdirSync(join(state, 'sessions')).filter(f => f.endsWith('.json')).sort();
291
+ }
292
+ catch { /* none */ }
293
+ for (const file of stateFiles) {
294
+ let mode;
295
+ try {
296
+ mode = JSON.parse(readFileSync(join(state, 'sessions', file), 'utf8'))?.mode;
297
+ }
298
+ catch {
299
+ failures['parse-failed']++;
300
+ continue;
301
+ }
302
+ if (mode === 'AUTO' && !recorded.has(file.slice(0, -5)))
303
+ autoSessionsWithoutRecord++;
304
+ }
305
+ return {
306
+ schema: 1,
307
+ since: since?.label ?? null,
308
+ failures, excluded,
309
+ coverage: { userTurns, turnsWithoutRecord, unlinkedBindings, autoSessionsWithoutRecord },
310
+ groups,
311
+ referenceBeforeAuto: Object.keys(reference).sort().map(model => ({
312
+ model, calls: reference[model].calls, sessions: reference[model].sessions.size,
313
+ effort: sorted(reference[model].effort), autoEffort: sorted(autoEffort[model] ?? {}),
314
+ })),
315
+ caveats: CAVEATS,
316
+ };
317
+ }
318
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
319
+ try {
320
+ const { values } = parseArgs({ options: { since: { type: 'string' } } });
321
+ if (values.since !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(values.since)) {
322
+ process.stderr.write('usage-report: --since must be YYYY-MM-DD\n');
323
+ process.exit(2);
324
+ }
325
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
326
+ process.stdout.write(`${JSON.stringify(buildReport(home, values.since ? { since: values.since } : {}), null, 2)}\n`);
327
+ }
328
+ catch {
329
+ process.stderr.write('usage-report: failed\n');
330
+ process.exit(1);
331
+ }
332
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-auto-model-routing",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",
@@ -0,0 +1,50 @@
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
+ - 不要自行计算节省百分比,不要说“质量不变”,也不要把“首个记录之前”称为 MANUAL。
43
+ - token 均值的分母是**出现该字段的调用数**(`present`);值为 `null` 表示该字段从未出现,不是 0。
44
+
45
+ 4. 如果要把报告原文贴到当前会话之外,例如飞书或 issue,**先请用户确认**。模型名、provider 名可能包含内部信息。
46
+
47
+ ## 前提
48
+
49
+ - 报告每次都从原始记录和会话日志重新计算,不保存快照。用户删除旧会话后,对应历史无法再统计。
50
+ - 旧版策略(legacy)的判档一致率请用同一插件目录下的 `scripts/label-report.mjs`。