@yangdcm/dsh-expert-team 1.1.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/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +135 -0
- package/client.js +2473 -0
- package/cordis.patch.yml +24 -0
- package/lib/artifact-writer.js +379 -0
- package/lib/command-parse.js +181 -0
- package/lib/command.js +5100 -0
- package/lib/dispatch-ledger.js +229 -0
- package/lib/index.js +15 -0
- package/lib/interception.js +266 -0
- package/lib/lead-toolface.js +179 -0
- package/lib/log-parse.js +181 -0
- package/lib/loop-guard.js +165 -0
- package/lib/metrics/collect.js +70 -0
- package/lib/metrics/render.js +100 -0
- package/lib/metrics/session-usage.js +319 -0
- package/lib/metrics/timing.js +188 -0
- package/lib/metrics/token-usage.js +352 -0
- package/lib/metrics/tokens.js +271 -0
- package/lib/routes/shared.js +83 -0
- package/lib/settings.js +289 -0
- package/lib/tier.js +190 -0
- package/lib/validate.js +681 -0
- package/lib/vocab.js +121 -0
- package/lib/write-tracer.js +58 -0
- package/package.json +119 -0
- package/presets/expert-team/agent.cordis.yml +542 -0
- package/presets/expert-team/preset.yml +3 -0
- package/skills/expert-team/SKILL.md +328 -0
- package/skills/expert-team/assets/templates/AUTHORITY.md +32 -0
- package/skills/expert-team/assets/templates/PLAN.md +27 -0
- package/skills/expert-team/assets/templates/RESEARCH.md +13 -0
- package/skills/expert-team/assets/templates/RETRO.md +24 -0
- package/skills/expert-team/assets/templates/REVIEW.md +10 -0
- package/skills/expert-team/assets/templates/ROSTER.json +6 -0
- package/skills/expert-team/assets/templates/SPEC.md +62 -0
- package/skills/expert-team/assets/templates/STATE.json +10 -0
- package/skills/expert-team/assets/templates/SUMMARY.md +25 -0
- package/skills/expert-team/assets/templates/TASK.md +23 -0
- package/skills/expert-team/assets/templates/TASKS.json +3 -0
- package/skills/expert-team/assets/templates/TEST.md +9 -0
- package/skills/expert-team/assets/templates//344/273/273/345/212/241/347/234/213/346/235/277.md +23 -0
- package/skills/expert-team/references/EFFICIENCY.md +79 -0
- package/skills/expert-team/references/LOGGING.md +82 -0
- package/skills/expert-team/references/PERSIST.md +57 -0
- package/skills/expert-team/references/PIPELINE.md +58 -0
- package/skills/expert-team/references/ROLES.md +297 -0
- package/skills/expert-team/references/WORKSPACE.md +123 -0
- package/skills/expert-team/references/workflow.team.js +97 -0
- package/skills/expert-team/scripts/scan-authority.mjs +114 -0
- package/skills/expert-team/scripts/scan-single-source.mjs +292 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// token 记账的**编排层**:把「run 目录 / 日志 / 会话遥测」三处的原始输入,算成 `renderTokenSection` 能吃的数据。
|
|
2
|
+
//
|
|
3
|
+
// 分工(本包对"一个事实多份拷贝"的既定纪律,逐条沿用):
|
|
4
|
+
// · `tokens.js` —— 纯计算与文案(可纯单测,零 IO)
|
|
5
|
+
// · `session-usage.js` —— 会话遥测的读取(zstd 解压、按 mtime 缓存)
|
|
6
|
+
// · **本文件** —— 把两者接起来:run 时间窗、角色归属、两种来源的取舍
|
|
7
|
+
// · `command.js` —— 只调 `collectTokenUsage()` 并把结果交给渲染器
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ **会话归属靠时间窗,不靠任何持久标记**。为什么:会话文件里没有 runId,而 run 目录里没有
|
|
10
|
+
// sessionId(`STATE.members` 只记**角色**成员,不记 lead 自己)。所以归属规则必须是可解释的:
|
|
11
|
+
// 「会话创建时刻落在哪个 run 的窗口内 ⇒ 属于那个 run」,窗口 = 本 run 起点 → 下一个 run 起点。
|
|
12
|
+
// 落在所有窗口之外的会话**不猜**,计数后如实报告(`unattributed`)。
|
|
13
|
+
|
|
14
|
+
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { parseLogLine, eventFamily, truncateCodepoints } from '../log-parse.js';
|
|
17
|
+
import { parseTokenEvent, TOKEN_EVENT_FAMILY, summarizeTokenUsage } from './tokens.js';
|
|
18
|
+
import { listWorkspaceSessions, readSessionUsage, lastListError } from './session-usage.js';
|
|
19
|
+
|
|
20
|
+
/** 从 `【中文角色】…` 里精确取角色标签(与 host/client 的 `ROLE_LABELS_ZH` 同一套中文标签)。 */
|
|
21
|
+
const ZH_ROLE_LABELS = [
|
|
22
|
+
['产品经理', 'pm'], ['架构师', 'architect'], ['调研员', 'researcher'], ['研究员', 'researcher'],
|
|
23
|
+
['界面设计师', 'ui'], ['设计师', 'ui'], ['后端工程师', 'backend'], ['后端', 'backend'],
|
|
24
|
+
['前端工程师', 'frontend'], ['前端', 'frontend'], ['数据库工程师', 'dba'], ['安全工程师', 'sec'],
|
|
25
|
+
['代码审查员', 'reviewer'], ['审查员', 'reviewer'], ['测试工程师', 'qa'], ['测试', 'qa'],
|
|
26
|
+
['运维工程师', 'devops'], ['运维', 'devops'], ['文档工程师', 'docs'], ['文档', 'docs'],
|
|
27
|
+
['竞品分析师', 'competitive-analyst'], ['产品分析师', 'product-analyst'], ['编排者', 'lead'], ['领队', 'lead'],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 从一段文本里取**中文角色标签**(`【测试工程师】T-04:…` → `qa`)。
|
|
32
|
+
* 取不到返回 `''`(**不猜**):兜底会退化成把任意任务名当角色。
|
|
33
|
+
*/
|
|
34
|
+
export function roleFromBracketLabel(text) {
|
|
35
|
+
const m = String(text ?? '').match(/【([^】]{1,12})】/);
|
|
36
|
+
if (!m) return '';
|
|
37
|
+
const label = m[1].trim();
|
|
38
|
+
for (const [zh, id] of ZH_ROLE_LABELS) if (label === zh) return id;
|
|
39
|
+
// 近似匹配:标签里含角色词(`前端工程师-A` 这类带后缀的写法)
|
|
40
|
+
for (const [zh, id] of ZH_ROLE_LABELS) if (label.includes(zh)) return id;
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 解析 `RUN.log.md` 全部行里的 token 事件。
|
|
46
|
+
*
|
|
47
|
+
* 两种写法都收(都是**已经落地过的**记法):
|
|
48
|
+
* · `tokens:backend — steps=62 in=2621 cache=177955 out=1132 peak=528246`
|
|
49
|
+
* · `role:backend tokens steps=62 in=… cache=… out=…`(把用量挂在既有的 `role:` 行上)
|
|
50
|
+
* 后者用 `eventFamily === 'role'` + detail 里出现 `tokens` 判定。
|
|
51
|
+
*
|
|
52
|
+
* @returns `{events: [{scope, source:'log', ...values}], broken: number}`
|
|
53
|
+
* `broken` = 是 token 事件、但**一个合法字段都没解析出来**的行数(写错字段名会静默消失,必须可见)。
|
|
54
|
+
*/
|
|
55
|
+
export function parseTokenLogEvents(log) {
|
|
56
|
+
const events = [];
|
|
57
|
+
let broken = 0;
|
|
58
|
+
for (const line of String(log ?? '').split('\n')) {
|
|
59
|
+
const ev = parseLogLine(line);
|
|
60
|
+
if (!ev) continue;
|
|
61
|
+
const fam = eventFamily(ev.type);
|
|
62
|
+
let hit = null;
|
|
63
|
+
if (fam === TOKEN_EVENT_FAMILY) {
|
|
64
|
+
hit = parseTokenEvent(ev.type, ev.detail);
|
|
65
|
+
} else if (fam === 'role' && /(^|\s)tokens(\s|$|=)/.test(String(ev.detail ?? ''))) {
|
|
66
|
+
// `role:<角色> … tokens …`:scope 取角色名(与 `role:` 同一套写法)
|
|
67
|
+
const scope = ev.type.slice('role:'.length).split(/[(\s]/)[0] || 'unknown';
|
|
68
|
+
const parsed = parseTokenEvent(`${TOKEN_EVENT_FAMILY}:${scope}`, ev.detail);
|
|
69
|
+
hit = parsed ? { ...parsed, scope } : null;
|
|
70
|
+
}
|
|
71
|
+
if (!hit) continue;
|
|
72
|
+
if (!hit.ok) { broken += 1; continue; }
|
|
73
|
+
events.push({ scope: hit.scope, source: 'log', ...hit.values, line: truncateCodepoints(ev.detail, 80) });
|
|
74
|
+
}
|
|
75
|
+
return { events, broken };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `STATE.members` → `Map<sessionId, role>`(两种历史写法都认:`sid:role` 与 `role:name`)。 */
|
|
79
|
+
export function membersBySession(state) {
|
|
80
|
+
const map = new Map();
|
|
81
|
+
const arr = Array.isArray(state && state.members) ? state.members : [];
|
|
82
|
+
for (const raw of arr) {
|
|
83
|
+
const s = String(raw ?? '').trim();
|
|
84
|
+
if (!s) continue;
|
|
85
|
+
const m = s.match(/^([0-9a-fA-F-]{8,}):([^:]+)$/);
|
|
86
|
+
if (m) { map.set(m[1], m[2].trim()); continue; }
|
|
87
|
+
const legacy = s.match(/^([^:]+):/); // `pm:Mia` —— 没有 sessionId,无法归属(有意不进 map)
|
|
88
|
+
void legacy;
|
|
89
|
+
}
|
|
90
|
+
return map;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 目录名的尾巴 `…-235644` → `{h:23,m:56,s:44}`(`/team` 建 run 时的时间戳;取不到返回 null)。 */
|
|
94
|
+
export function clockFromDirName(name) {
|
|
95
|
+
const m = String(name ?? '').match(/-(\d{2})(\d{2})(\d{2})$/);
|
|
96
|
+
if (!m) return null;
|
|
97
|
+
const h = Number(m[1]); const mi = Number(m[2]); const sec = Number(m[3]);
|
|
98
|
+
if (h > 23 || mi > 59 || sec > 59) return null;
|
|
99
|
+
return { h, m: mi, s: sec };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 求一个 run 的**时间窗** `{start, end, basis}`。
|
|
104
|
+
*
|
|
105
|
+
* 起点优先级:`STATE.startedAt` → `RUN.log.md` 首条 `run:started` 的 `HH:MM:SS` → 目录名尾 6 位时间戳。
|
|
106
|
+
* 只有 `HH:MM:SS` 时,**日期取 run 目录的 mtime**(同一天内建目录,与真实情况一致)。
|
|
107
|
+
* 终点:下一个 run 的起点(调用方传入);没有下一个 ⇒ `mtime + 6h`(足够覆盖一次长 run 的尾巴)。
|
|
108
|
+
* 三种都取不到 ⇒ `{start: null}`,该 run **不做遥测归属**(但日志事件仍可用)。
|
|
109
|
+
*/
|
|
110
|
+
export function runWindow({ state, log, dirName, dirMtimeMs, nextStart = null }) {
|
|
111
|
+
let clock = null; let basis = '';
|
|
112
|
+
if (state && typeof state.startedAt === 'string' && state.startedAt) {
|
|
113
|
+
const t = Date.parse(state.startedAt);
|
|
114
|
+
if (Number.isFinite(t)) return { start: t, end: nextStart ?? (dirMtimeMs + 6 * 3600 * 1000), basis: 'STATE.startedAt' };
|
|
115
|
+
}
|
|
116
|
+
for (const line of String(log ?? '').split('\n')) {
|
|
117
|
+
const ev = parseLogLine(line);
|
|
118
|
+
if (!ev) continue;
|
|
119
|
+
if (eventFamily(ev.type) === 'run' && String(ev.type).endsWith('started')) {
|
|
120
|
+
const m = String(ev.time).match(/^(\d{2}):(\d{2}):(\d{2})$/);
|
|
121
|
+
if (m) { clock = { h: +m[1], m: +m[2], s: +m[3] }; basis = 'run:started'; break; }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!clock) {
|
|
125
|
+
const c = clockFromDirName(dirName);
|
|
126
|
+
if (c) { clock = c; basis = 'dir-name'; }
|
|
127
|
+
}
|
|
128
|
+
if (!clock) return { start: null, end: null, basis: 'none' };
|
|
129
|
+
const d = new Date(dirMtimeMs);
|
|
130
|
+
d.setHours(clock.h, clock.m, clock.s, 0);
|
|
131
|
+
let start = d.getTime();
|
|
132
|
+
// 目录 mtime 的日期可能比 run 实际开始晚一天(跨零点建/改)⇒ 若起点晚于 mtime,往前退一天。
|
|
133
|
+
if (start > dirMtimeMs) start -= 24 * 3600 * 1000;
|
|
134
|
+
return { start, end: nextStart ?? (dirMtimeMs + 6 * 3600 * 1000), basis };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* token 记账的总入口(`aggregate()` 调用)。
|
|
139
|
+
*
|
|
140
|
+
* @param root - `<cwd>/team`。
|
|
141
|
+
* @param names - run 目录名(已按名称排序,含时间戳)。
|
|
142
|
+
* @param readRun - `(name) => Promise<{state, log, dir}>`(复用采集层的 `readRunInputs` + 目录 stat,避免重复读盘)。
|
|
143
|
+
* @param opts - `{cwd, dshHome, workspace, now}`。
|
|
144
|
+
* @returns `{runs: [{name, source, byScope, totals, avgPromptPerStep, ...}], broken, unattributed, skipped, notes}`
|
|
145
|
+
* —— **遥测可用时不叠加日志事件**(两个来源权威性不同,相加会得到一个谁也不是的数)。
|
|
146
|
+
*/
|
|
147
|
+
export async function collectTokenUsage(root, names, readRun, opts = {}) {
|
|
148
|
+
const list = Array.isArray(names) ? names : [];
|
|
149
|
+
const notes = [];
|
|
150
|
+
const sessionsInfo = await listWorkspaceSessions(opts.workspace, { dshHome: opts.dshHome });
|
|
151
|
+
const usageSessions = sessionsInfo.ok ? sessionsInfo.sessions.filter((s) => s.createdAt > 0) : [];
|
|
152
|
+
if (!sessionsInfo.ok) {
|
|
153
|
+
notes.push(lastListError() || '会话遥测目录不可用');
|
|
154
|
+
if (sessionsInfo.candidates && sessionsInfo.candidates.length) {
|
|
155
|
+
notes.push(`sessions 根下现有 ${sessionsInfo.candidates.length} 个工作区目录(本工作区 slug 不在其中:${sessionsInfo.slug})`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── 第一趟:读每个 run 的输入(state / log / 目录 mtime)──
|
|
160
|
+
// 注意:**这里算出的日志时间戳不用于归属**(理由见第二趟的长注释:`run:started` 可能是
|
|
161
|
+
// 几天后补记的)。`runWindow` 只作为**参考信息**保留在报告里(`logWindowBasis`),
|
|
162
|
+
// 让读者知道"日志里那个起点是怎么推出来的"。
|
|
163
|
+
const inputs = [];
|
|
164
|
+
for (const name of list) {
|
|
165
|
+
let read = { state: null, log: '', dir: join(root, name) };
|
|
166
|
+
try { read = { ...read, ...(await readRun(name)) }; } catch { /* 缺件按空 */ }
|
|
167
|
+
let dirMtimeMs = opts.now ?? Date.now();
|
|
168
|
+
try { dirMtimeMs = (await stat(join(root, name))).mtimeMs; } catch { /* 用 now */ }
|
|
169
|
+
inputs.push({ name, state: read.state, log: read.log ?? '', dirMtimeMs });
|
|
170
|
+
}
|
|
171
|
+
const logWindows = inputs.map((it) => runWindow({ state: it.state, log: it.log, dirName: it.name, dirMtimeMs: it.dirMtimeMs, nextStart: null }));
|
|
172
|
+
|
|
173
|
+
// ── 第二趟:**标记优先**地把会话归属到 run(时间**锚在会话证据上**)────────────────
|
|
174
|
+
//
|
|
175
|
+
// 为什么不用 `RUN.log.md` 的 `run:started` 当窗口起点(首轮实测踩过,代价很大):
|
|
176
|
+
// 那个时间戳是**当天那次写入**记的 —— 一个 run 被 `/team resume` 或事后补记时,
|
|
177
|
+
// `run:started` 会被写成**几天后**的时刻。实测「做竞品分析」的 run 起点因此落到
|
|
178
|
+
// 09-12T14:56,而它的会话创建于 **09-10T14:55**(差两天)⇒ 36 个明明带标记的会话
|
|
179
|
+
// 被"更晚的 run"抢走、真正的归属反而靠最弱的"就近"档,数字全错。
|
|
180
|
+
//
|
|
181
|
+
// 所以归属**只用会话自己的证据**(这是读出来的事实,不是推断):
|
|
182
|
+
// ① 派工提示词里带 runId / run 目录名 ⇒ 标记命中(强证据);
|
|
183
|
+
// ② 一个 run 的**锚点** = 命中它标记的**最早会话**的创建时刻(而不是日志时间戳);
|
|
184
|
+
// ③ 命中多个标记的会话(任务里引用了别的 run 的工件,实测存在)用锚点消歧:
|
|
185
|
+
// 取「不晚于该会话」里锚点最晚的那个 run;
|
|
186
|
+
// ④ 没命中任何标记的会话(老 run 的子会话、lead 会话本身)落到**不晚于它、锚点最晚**的 run。
|
|
187
|
+
//
|
|
188
|
+
// 结果里每条 run 都带 `anchorMs`(锚点)与各档计数,报告里如实写明依据强度。
|
|
189
|
+
// 标记要**带路径形态**才算命中,不能只匹配裸 runId。
|
|
190
|
+
// 为什么(实测踩过):runId 会被截断/口号化成很短的名字(`设计一个极简的番茄钟…不`),
|
|
191
|
+
// 而任何"讨论到这个目标"的会话提示词里都会出现这串字 ⇒ 裸匹配会把**无关会话**当成它的成员,
|
|
192
|
+
// 进而把它的锚点算早、让它吞掉后面一整段无标记会话(实测那个空 run 因此吃进 15 个会话、
|
|
193
|
+
// 凭空变成最贵的 run)。派工词里引用 run 目录是**路径形态**(`team/<runId>/`),据此匹配。
|
|
194
|
+
const markers = inputs.map((it, i) => {
|
|
195
|
+
const id = it.state && it.state.runId ? String(it.state.runId) : '';
|
|
196
|
+
const name = String(it.name || '');
|
|
197
|
+
return { key: id || name, name, pathForm: `team/${id || name}` , index: i };
|
|
198
|
+
});
|
|
199
|
+
const markerHit = (blob, m) => {
|
|
200
|
+
const key = String(m.key || '');
|
|
201
|
+
if (!key) return false;
|
|
202
|
+
// `team/<key>` 命中即算(派工词里写的是 run 目录路径;带不带结尾斜杠都收)
|
|
203
|
+
if (blob.includes(m.pathForm)) return true;
|
|
204
|
+
// 裸 key 只在**足够长**时才当证据(短名极易误配 —— 见上面的实测)
|
|
205
|
+
return key.length >= 16 && blob.includes(key);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// 先把所有会话的用量读出来(同一份缓存,后面不再重复解压)
|
|
209
|
+
const hydrated = [];
|
|
210
|
+
for (const s of usageSessions) {
|
|
211
|
+
const u = await readSessionUsage(s.file);
|
|
212
|
+
if (!u || !(u.steps > 0)) continue; // 读不到 / 没跑过一步的空壳会话不计
|
|
213
|
+
const blob = (u.promptSample || []).join('\n');
|
|
214
|
+
const hitRuns = [];
|
|
215
|
+
for (let i = 0; i < markers.length; i += 1) {
|
|
216
|
+
if (markerHit(blob, markers[i])) hitRuns.push(i);
|
|
217
|
+
}
|
|
218
|
+
hydrated.push({ s, u, hitRuns });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ① 锚点:每个 run 的「最早命中自身标记的会话」创建时刻
|
|
222
|
+
const anchorMs = inputs.map(() => null);
|
|
223
|
+
for (const h of hydrated) {
|
|
224
|
+
for (const i of h.hitRuns) {
|
|
225
|
+
if (anchorMs[i] === null || h.s.createdAt < anchorMs[i]) anchorMs[i] = h.s.createdAt;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ② 每个 run 的**参考区间**上界 = min(下一个更晚的锚点, 本锚点 + 12h)。
|
|
230
|
+
//
|
|
231
|
+
// 两道约束各自的理由(都是实测出来的):
|
|
232
|
+
// · **下一个锚点**:最老的 run 若没有上界,会吞掉它之后所有无标记会话
|
|
233
|
+
// (实测:09-07 那个 `番茄钟` run 吃进 15 个 09-11 的会话、凭空变成最贵的 run)。
|
|
234
|
+
// · **+12h 时长上限**:只有"下一个锚点"还不够 —— 两个 run 之间可能空出一整天,
|
|
235
|
+
// 期间的无标记会话(别的机器、被中断的 run 留下的孤儿)会被硬塞进前一个 run。
|
|
236
|
+
// 12h 的依据:本包观测到的最长 run ≈ 10h,留一点余量;**宁可判"未归属"也不硬塞**。
|
|
237
|
+
const RUN_SPAN_CAP_MS = 12 * 3600 * 1000;
|
|
238
|
+
const refEndMs = inputs.map((_, i) => {
|
|
239
|
+
let best = null;
|
|
240
|
+
for (let j = 0; j < anchorMs.length; j += 1) {
|
|
241
|
+
const a = anchorMs[j];
|
|
242
|
+
// 只把**严格更晚**的锚点当上界;`anchorMs[i] === null` 时(本 run 没有锚点)
|
|
243
|
+
// 取全局最早的锚点当上界(那种 run 本来就分不到"就近"档的会话)。
|
|
244
|
+
if (a === null) continue;
|
|
245
|
+
if (anchorMs[i] !== null && a <= anchorMs[i]) continue;
|
|
246
|
+
if (best === null || a < best) best = a;
|
|
247
|
+
}
|
|
248
|
+
const cap = anchorMs[i] === null ? null : anchorMs[i] + RUN_SPAN_CAP_MS;
|
|
249
|
+
if (best === null) return cap; // 最晚的锚点 ⇒ 只受时长上限约束
|
|
250
|
+
if (cap === null) return best;
|
|
251
|
+
return Math.min(best, cap);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const perRunSessions = inputs.map(() => []);
|
|
255
|
+
const attrTally = { marker: 0, nearest: 0, none: 0, outOfRange: 0 };
|
|
256
|
+
for (const { s, u, hitRuns } of hydrated) {
|
|
257
|
+
let hit = -1;
|
|
258
|
+
let basis = 'marker';
|
|
259
|
+
if (hitRuns.length === 1) {
|
|
260
|
+
hit = hitRuns[0];
|
|
261
|
+
} else if (hitRuns.length > 1) {
|
|
262
|
+
// ③ 消歧:取「锚点不晚于该会话」里最晚的那个(会话必然创建于其 run 开始之后)
|
|
263
|
+
for (const i of hitRuns) {
|
|
264
|
+
const a = anchorMs[i];
|
|
265
|
+
if (a !== null && s.createdAt >= a && (hit < 0 || a > anchorMs[hit])) hit = i;
|
|
266
|
+
}
|
|
267
|
+
if (hit < 0) {
|
|
268
|
+
// 锚点全都晚于该会话(如它正是某 run 的最早会话,而同时引用了更早 run 的工件):
|
|
269
|
+
// 取标记更长(更具体)的那个,并如实降级为"就近"档。
|
|
270
|
+
for (const i of hitRuns) if (hit < 0 || String(markers[i].key).length > String(markers[hit].key).length) hit = i;
|
|
271
|
+
basis = 'nearest';
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
// ④ 无标记:落到「锚点不晚于它、锚点最晚」的 run,且必须**落在该 run 的参考区间内**
|
|
275
|
+
for (let i = 0; i < anchorMs.length; i += 1) {
|
|
276
|
+
const a = anchorMs[i];
|
|
277
|
+
if (a === null) continue;
|
|
278
|
+
if (s.createdAt < a) continue;
|
|
279
|
+
const upper = refEndMs[i];
|
|
280
|
+
if (upper !== null && s.createdAt >= upper) continue;
|
|
281
|
+
if (hit < 0 || a > anchorMs[hit]) hit = i;
|
|
282
|
+
}
|
|
283
|
+
if (hit >= 0) basis = 'nearest';
|
|
284
|
+
else { attrTally.outOfRange += 1; attrTally.none += 1; continue; }
|
|
285
|
+
}
|
|
286
|
+
if (hit < 0) { attrTally.none += 1; continue; }
|
|
287
|
+
attrTally[basis] += 1;
|
|
288
|
+
perRunSessions[hit].push({ ...s, usage: u, basis });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── 第三趟:算每个 run 的 rows(遥测优先;遥测空才回落日志事件)──
|
|
292
|
+
const runs = [];
|
|
293
|
+
let brokenTotal = 0;
|
|
294
|
+
for (let i = 0; i < inputs.length; i += 1) {
|
|
295
|
+
const it = inputs[i];
|
|
296
|
+
const parsed = parseTokenLogEvents(it.log);
|
|
297
|
+
brokenTotal += parsed.broken;
|
|
298
|
+
const rows = [];
|
|
299
|
+
const bySession = membersBySession(it.state);
|
|
300
|
+
for (const s of perRunSessions[i]) {
|
|
301
|
+
const u = s.usage;
|
|
302
|
+
// 角色归属三档:① 提示词里的**中文角色标签**(精确表,扫前几条)→ ② `STATE.members` 的
|
|
303
|
+
// sessionId→role 绑定 → ③ 退化成会话 id 前缀(**标注出来**,不假装知道它是谁)。
|
|
304
|
+
let label = '';
|
|
305
|
+
for (const t of (u.promptSample || [])) { label = roleFromBracketLabel(t); if (label) break; }
|
|
306
|
+
const bound = bySession.get(s.id) || '';
|
|
307
|
+
const role = label || bound || (s.depth === 0 ? 'lead' : '');
|
|
308
|
+
rows.push({
|
|
309
|
+
scope: role || `session-${String(s.id).slice(0, 8)}`,
|
|
310
|
+
roleKnown: !!(label || bound || s.depth === 0),
|
|
311
|
+
source: 'telemetry',
|
|
312
|
+
in: u.in, cache: u.cache, out: u.out, steps: u.steps, peak: u.peak, first: u.first,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
let source = 'telemetry';
|
|
316
|
+
if (!rows.length && parsed.events.length) {
|
|
317
|
+
source = 'log';
|
|
318
|
+
for (const e of parsed.events) rows.push({ ...e, source: 'log' });
|
|
319
|
+
} else if (!rows.length) {
|
|
320
|
+
continue; // 两者都没有 ⇒ 该 run 不进报告("无记账"由空节统一说明)
|
|
321
|
+
}
|
|
322
|
+
const summary = summarizeTokenUsage(rows, opts.weights ? { weights: opts.weights } : {});
|
|
323
|
+
// 归属依据按**这个 run 实际用到的档次**汇总(强 → 弱排序)。`marker` = 提示词里带 runId
|
|
324
|
+
// (强证据);`nearest` = 无标记、落到"锚点不晚于它且锚点最晚"的 run(弱证据,如实标出)。
|
|
325
|
+
const basisCount = new Map();
|
|
326
|
+
for (const s of perRunSessions[i]) basisCount.set(s.basis, (basisCount.get(s.basis) ?? 0) + 1);
|
|
327
|
+
const order = ['marker', 'nearest'];
|
|
328
|
+
const basisText = source === 'log'
|
|
329
|
+
? '日志事件'
|
|
330
|
+
: (order.filter((b) => basisCount.has(b)).map((b) => `${b}×${basisCount.get(b)}`).join(' / ') || '—');
|
|
331
|
+
runs.push({
|
|
332
|
+
name: it.name, source, windowBasis: basisText,
|
|
333
|
+
anchorMs: anchorMs[i], logWindowBasis: logWindows[i].basis,
|
|
334
|
+
...summary,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const unknownRoles = runs.reduce((a, r) => a + r.byScope.filter((s) => s.roleKnown === false).length, 0);
|
|
339
|
+
if (attrTally.marker > 0) notes.push(`${attrTally.marker} 个会话按提示词里的 **runId 标记**归属(依据最强)`);
|
|
340
|
+
if (attrTally.nearest > 0) notes.push(`${attrTally.nearest} 个会话无 run 标记,落到**参考区间内锚点最晚**的 run(依据较弱,如实标出)`);
|
|
341
|
+
const outOfAll = attrTally.none - attrTally.outOfRange;
|
|
342
|
+
if (outOfAll > 0) notes.push(`${outOfAll} 个会话早于所有 run 锚点 ⇒ 未归属(**不猜**)`);
|
|
343
|
+
if (attrTally.outOfRange > 0) notes.push(`${attrTally.outOfRange} 个会话晚于所有 run 的参考区间(不落在任何相邻锚点之间)⇒ 未归属(**不猜**)`);
|
|
344
|
+
if (unknownRoles > 0) notes.push(`${unknownRoles} 个会话的角色认不出(提示词无中文角色标签、STATE.members 也无绑定)⇒ 以会话 id 显示`);
|
|
345
|
+
return { runs, broken: brokenTotal, unattributed: attrTally.none, attrTally, notes };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 会话目录里的目录名清单(诊断用:`/team tokens` 报告"读不到哪个目录"时要列出来)。 */
|
|
349
|
+
export async function sessionSlugCandidates(dshHome) {
|
|
350
|
+
const root = join(dshHome || process.env.DSH_HOME || join(process.env.HOME || '', '.dsh'), 'sessions');
|
|
351
|
+
try { return (await readdir(root)).filter((d) => d.startsWith('--')); } catch { return []; }
|
|
352
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// token 记账(P5 线):把「一次 run 烧掉多少 token、上下文涨到多大」变成**可读的数**。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它(本能力的由来,逐条可核):
|
|
4
|
+
// 对 10 个真实 expert-team 顶层会话 + 57 个角色子代理会话做全量遥测审计的结果是:
|
|
5
|
+
// · 输出 tokens 只占总量的 **0.24%**(所以「让模型少说点」不是杠杆);
|
|
6
|
+
// · **99.2%** 的输入是缓存读,而每步 prompt 中位数 **426K**、单会话峰值 ~800K;
|
|
7
|
+
// · lead 层累计 prompt **3.29B**,是角色层(639M)的 **5.1 倍** —— 贵的不是"人多",
|
|
8
|
+
// 而是"编排者自己干活"(某 run 的 lead 在主上下文里调了 3,244 次工具、只派工 11 次)。
|
|
9
|
+
// 而技能里只有**时间/返工**维度的定额(首产物 10 分钟、收尾 ≤ 实现期 50%),
|
|
10
|
+
// **没有任何 token/上下文维度的定额**,METRICS 也从不渲染 token ⇒ 这件事在整个体系里隐形,
|
|
11
|
+
// 没人看得见也就没人踩刹车。本模块就是那道「让它可见」的接线。
|
|
12
|
+
//
|
|
13
|
+
// 数据来源有两条(**两条都留着**,不是二选一):
|
|
14
|
+
// ① **权威(自动)**:DSH 会话遥测 —— `~/.dsh/sessions/<workspace-slug>/<sid>/session.v3.jsonl.zstd`
|
|
15
|
+
// 里每条 `assistant/message` 都带 `usage`。按 **run 的时间窗**把 lead 会话(delegationDepth 0)
|
|
16
|
+
// 与角色子代理会话(delegationDepth 1)归属到 run,不需要任何人写日志。
|
|
17
|
+
// ② **兜底(可选)**:`RUN.log.md` 里的 `tokens:<scope>` 事件(格式见 LOGGING.md)。
|
|
18
|
+
// 会话文件被清理、或跑在别的机器上时用它。
|
|
19
|
+
//
|
|
20
|
+
// ⚠️ 两条来源的**权威性不同**,所以本模块**不合并、不相加**:遥测在则用遥测,遥测不在才回落到日志事件,
|
|
21
|
+
// 并在文案里注明来源。把"实测"与"自报"混成一个数,正是本仓在撤销率上吃过亏的那种失真。
|
|
22
|
+
//
|
|
23
|
+
// 零依赖、纯计算(IO 在 `session-usage.js`)⇒ 解析与汇总可纯单测。
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 事件族名。`tokens:lead` / `tokens:backend` → 族 `tokens`,scope `lead`/`backend`。
|
|
27
|
+
* 与 `role:` / `phase:` / `scan:` 同一套「冒号前是族」的约定(`log-parse.eventFamily`)。
|
|
28
|
+
*/
|
|
29
|
+
export const TOKEN_EVENT_FAMILY = 'tokens';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `tokens:<scope> — <键值对>` 里允许的字段。
|
|
33
|
+
*
|
|
34
|
+
* 字段名**故意写全**(`in` / `cache` / `out` / `steps` / `peak`)而不是 `i/o/c`:
|
|
35
|
+
* 一行日志要能被人读懂,缩写省下的字节远不如"读错字段"的代价。
|
|
36
|
+
* `in` = **未缓存输入**、`cache` = **缓存命中输入**、`out` = 输出、`peak` = 单步 prompt 峰值。
|
|
37
|
+
*/
|
|
38
|
+
export const TOKEN_FIELDS = ['in', 'cache', 'out', 'steps', 'peak', 'first'];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 成本折算权重的**默认值**(相对权重,不是价格)。
|
|
42
|
+
*
|
|
43
|
+
* 为什么不写死绝对价格:供应商价格会变(2026-09-10 Flash 系列刚降过一轮),写死会过期;
|
|
44
|
+
* 而"钱花在哪一类 token 上"这个**结构**不随降价改变。所以这里用相对权重:
|
|
45
|
+
* 未缓存输入 = 1(基准)· 缓存输入 = 1/50 · 输出 = 2。
|
|
46
|
+
* 该比例来自 DeepSeek 各代公开定价的**共同结构**(缓存命中输入约为未命中输入的 1/50~1/10,
|
|
47
|
+
* 输出约为未命中输入的 2 倍);真实换算成钱时按当期价格替换 `weights` 即可。
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_TOKEN_WEIGHTS = { in: 1, cache: 1 / 50, out: 2 };
|
|
50
|
+
|
|
51
|
+
/** 解析一行 `tokens:<scope>` 事件。非该族 ⇒ `null`;该族但**没有可用的合法字段** ⇒ `{scope, ok:false}`。 */
|
|
52
|
+
export function parseTokenEvent(type, detail) {
|
|
53
|
+
const t = String(type ?? '');
|
|
54
|
+
if (!t.startsWith(`${TOKEN_EVENT_FAMILY}:`)) return null;
|
|
55
|
+
const scope = (t.slice(TOKEN_EVENT_FAMILY.length + 1).split(/[\s(]/)[0] || '').trim();
|
|
56
|
+
const s = String(detail ?? '');
|
|
57
|
+
const vals = {};
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
// 只认 `键=数字`(允许千位下划线与 `k`/`m` 后缀,便于人写 `peak=426k`)。
|
|
60
|
+
// 不认的键一律记进 `unknownKeys`,**并且把整行判为不可用**(见下面的 `ok`)。
|
|
61
|
+
const re = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([0-9][0-9_,.]*[kKmM]?)/g;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = re.exec(s)) !== null) {
|
|
64
|
+
const key = m[1].toLowerCase();
|
|
65
|
+
seen.add(key);
|
|
66
|
+
if (!TOKEN_FIELDS.includes(key)) continue;
|
|
67
|
+
let raw = m[2].replace(/[_,]/g, '');
|
|
68
|
+
let mult = 1;
|
|
69
|
+
const suf = raw.slice(-1).toLowerCase();
|
|
70
|
+
if (suf === 'k') { mult = 1e3; raw = raw.slice(0, -1); }
|
|
71
|
+
else if (suf === 'm') { mult = 1e6; raw = raw.slice(0, -1); }
|
|
72
|
+
const n = Number(raw) * mult;
|
|
73
|
+
if (Number.isFinite(n) && n >= 0) vals[key] = n;
|
|
74
|
+
}
|
|
75
|
+
const unknownKeys = [...seen].filter((k) => !TOKEN_FIELDS.includes(k));
|
|
76
|
+
// ⚠️ `ok` 的判据是「**有合法字段 且 没有未知字段**」,不是"有合法字段就行"。
|
|
77
|
+
// 为什么这么严:`steps=62 in=2 typo_x=5` 这种"一个字段名写错、其余都对"的行,
|
|
78
|
+
// 若按"有合法字段"放行,那个写错的字段会**静默消失**(数字少一块,而没人知道)——
|
|
79
|
+
// 正是本包反复出现的那类"静默失败"。宁可整行判坏、让它出现在报告里被修掉。
|
|
80
|
+
const ok = Object.keys(vals).length > 0 && unknownKeys.length === 0;
|
|
81
|
+
return { scope: scope || 'unknown', ok, values: vals, unknownKeys };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 汇总一个 run 的 token 记账。
|
|
86
|
+
*
|
|
87
|
+
* @param rows - `[{scope, source, first, in, cache, out, steps, peak}]`
|
|
88
|
+
* `scope`: `'lead'` 或角色名;`source`: `'telemetry'`(会话遥测,权威)或 `'log'`(日志事件)。
|
|
89
|
+
* @param opts - `{weights}` 覆盖成本权重(默认 `DEFAULT_TOKEN_WEIGHTS`)。
|
|
90
|
+
* @returns 结构化汇总(见下方字段),**不含任何 IO 与文案**。
|
|
91
|
+
*
|
|
92
|
+
* 设计要点(三条,都是为了不骗人):
|
|
93
|
+
* ① **两个来源分开计**(`bySource`)—— 实测与自报相加会得到一个谁也不是的数;
|
|
94
|
+
* ② **缺项与零分开**:`missingSteps`(没记 steps ⇒ 算不出重放倍数)与 `steps:0` 不是一回事;
|
|
95
|
+
* ③ **成本是相对占比**,并且**在文案里写明基准** —— 绝不冒充"花了多少钱"。
|
|
96
|
+
*/
|
|
97
|
+
export function summarizeTokenUsage(rows, opts = {}) {
|
|
98
|
+
const weights = { ...DEFAULT_TOKEN_WEIGHTS, ...(opts && opts.weights ? opts.weights : {}) };
|
|
99
|
+
const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r === 'object') : [];
|
|
100
|
+
const zero = { in: 0, cache: 0, out: 0, steps: 0 };
|
|
101
|
+
const acc = { ...zero, peak: 0, first: 0 };
|
|
102
|
+
const bySource = new Map(); // source -> {in,cache,out,steps,runs}
|
|
103
|
+
let missingSteps = 0;
|
|
104
|
+
let missingPeak = 0;
|
|
105
|
+
|
|
106
|
+
// 逐个会话先收集,**再按 scope 合并**:同一个角色在一次 run 里可能有多个会话
|
|
107
|
+
//(重派、复验、并行腿),逐条列出会得到 `lead 976,506,715 · lead 49,453,574 · lead 12,040,639`
|
|
108
|
+
// 这种"同一个名字重复出现"的清单 —— 读者看不出这是三个会话还是三个不同角色。
|
|
109
|
+
// 合并后带 `sessions` 计数,并按 `in+cache` 降序(谁贵谁在前)。
|
|
110
|
+
const scopeAgg = new Map(); // scope -> 合并项
|
|
111
|
+
for (const r of list) {
|
|
112
|
+
const src = String(r.source ?? 'log');
|
|
113
|
+
const num = (v) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : 0);
|
|
114
|
+
const inTok = num(r.in);
|
|
115
|
+
const cacheTok = num(r.cache);
|
|
116
|
+
const outTok = num(r.out);
|
|
117
|
+
const steps = num(r.steps);
|
|
118
|
+
const peak = num(r.peak);
|
|
119
|
+
const first = num(r.first);
|
|
120
|
+
acc.in += inTok; acc.cache += cacheTok; acc.out += outTok; acc.steps += steps;
|
|
121
|
+
if (peak > acc.peak) acc.peak = peak;
|
|
122
|
+
if (first > acc.first) acc.first = first;
|
|
123
|
+
if (!(steps > 0)) missingSteps += 1;
|
|
124
|
+
if (!(peak > 0)) missingPeak += 1;
|
|
125
|
+
const b = bySource.get(src) ?? { in: 0, cache: 0, out: 0, steps: 0, rows: 0 };
|
|
126
|
+
b.in += inTok; b.cache += cacheTok; b.out += outTok; b.steps += steps; b.rows += 1;
|
|
127
|
+
bySource.set(src, b);
|
|
128
|
+
// `roleKnown` 必须**穿透**到 byScope:渲染时要如实标明"这个 scope 是会话 id,不是角色名"。
|
|
129
|
+
// 不知道角色却把它显示成一个角色名,会让读者以为"某个角色烧了 1400 万 token"(而事实是"某个会话")。
|
|
130
|
+
const key = String(r.scope ?? 'unknown');
|
|
131
|
+
const known = r.roleKnown === undefined ? true : !!r.roleKnown;
|
|
132
|
+
const cur = scopeAgg.get(key);
|
|
133
|
+
if (cur) {
|
|
134
|
+
cur.in += inTok; cur.cache += cacheTok; cur.out += outTok; cur.steps += steps; cur.sessions += 1;
|
|
135
|
+
if (peak > cur.peak) cur.peak = peak;
|
|
136
|
+
if (first > cur.first) cur.first = first;
|
|
137
|
+
// 只要其中任一条认不出角色,就整体标为"角色不可靠"(宁可保守)。
|
|
138
|
+
cur.roleKnown = cur.roleKnown && known;
|
|
139
|
+
} else {
|
|
140
|
+
scopeAgg.set(key, { scope: key, source: src, roleKnown: known, in: inTok, cache: cacheTok, out: outTok, steps, peak, first, sessions: 1 });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const byScope = [...scopeAgg.values()];
|
|
144
|
+
|
|
145
|
+
const cost = {
|
|
146
|
+
in: acc.in * weights.in,
|
|
147
|
+
cache: acc.cache * weights.cache,
|
|
148
|
+
out: acc.out * weights.out,
|
|
149
|
+
};
|
|
150
|
+
const costTotal = cost.in + cost.cache + cost.out;
|
|
151
|
+
const promptTotal = acc.in + acc.cache;
|
|
152
|
+
const grand = promptTotal + acc.out;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
rows: list.length,
|
|
156
|
+
byScope: byScope.sort((a, b) => (b.in + b.cache) - (a.in + a.cache) || String(a.scope).localeCompare(String(b.scope))),
|
|
157
|
+
bySource: [...bySource.entries()].map(([source, v]) => ({ source, ...v })).sort((a, b) => String(a.source).localeCompare(String(b.source))),
|
|
158
|
+
totals: { ...acc, prompt: promptTotal, grand },
|
|
159
|
+
cost: { ...cost, total: costTotal },
|
|
160
|
+
// 成本占比(百分点,四舍五入到整数)—— 三个桶**必然**相加为 100(costTotal>0 时)。
|
|
161
|
+
costShare: costTotal > 0 ? {
|
|
162
|
+
in: Math.round((cost.in / costTotal) * 100),
|
|
163
|
+
cache: Math.round((cost.cache / costTotal) * 100),
|
|
164
|
+
out: Math.round((cost.out / costTotal) * 100),
|
|
165
|
+
} : { in: 0, cache: 0, out: 0 },
|
|
166
|
+
// 输出 tokens 占**总量**的比例(这就是"少说点没用"的那个数)。
|
|
167
|
+
outputTokenShare: grand > 0 ? (acc.out / grand) * 100 : 0,
|
|
168
|
+
// 「重放放大」= 累计 prompt /(未缓存输入 + 缓存输入)… 分母缺失时返回 null(**不猜 0**)。
|
|
169
|
+
replayAmplification: promptTotal > 0 && acc.steps > 0 ? acc.steps : null,
|
|
170
|
+
missingSteps,
|
|
171
|
+
missingPeak,
|
|
172
|
+
// 每步平均 prompt(= 重放的成本基数)。steps 缺失 ⇒ null,"算不出"≠"很小"。
|
|
173
|
+
avgPromptPerStep: acc.steps > 0 ? Math.round(promptTotal / acc.steps) : null,
|
|
174
|
+
weights,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 千分位(渲染用;不引入 Intl 依赖以免测试环境差异)。 */
|
|
179
|
+
export function groupThousands(n) {
|
|
180
|
+
const v = Number(n);
|
|
181
|
+
if (!Number.isFinite(v)) return '0';
|
|
182
|
+
const neg = v < 0;
|
|
183
|
+
const s = String(Math.round(Math.abs(v)));
|
|
184
|
+
const out = s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
185
|
+
return neg ? `-${out}` : out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 渲染 METRICS.md 的「token 成本与上下文峰值」小节(纯文案,可单测)。
|
|
190
|
+
*
|
|
191
|
+
* 三种状态**必须可区分**(本包在撤销率上吃过亏的那条纪律,逐字沿用):
|
|
192
|
+
* ① 有数据 → 逐 run 明细 + 汇总 + 口径说明;
|
|
193
|
+
* ② 事件写坏了 → 单列「N 行 tokens 事件没有一个解析出合法字段」(写错字段名会静默消失);
|
|
194
|
+
* ③ 一条都没有 → 如实写「无 token 记账」,并**说明这不等于消耗低**。
|
|
195
|
+
*/
|
|
196
|
+
export function renderTokenSection({ runs = [], broken = 0, unattributed = 0, weights = DEFAULT_TOKEN_WEIGHTS } = {}) {
|
|
197
|
+
const head = `> 口径:未缓存输入 / 缓存输入 / 输出 / 步数 / 首步 prompt(每会话固定开销)/ 峰值 prompt。` +
|
|
198
|
+
`成本按**相对权重**折算(未缓存输入 1 · 缓存输入 ${weights.cache} · 输出 ${weights.out}),**不是价格** —— ` +
|
|
199
|
+
`换当期价格只需替换权重;要的是"钱花在哪一类 token 上"这个结构。`;
|
|
200
|
+
if (!runs.length) {
|
|
201
|
+
const lines = [
|
|
202
|
+
'- **无 token 记账**:没有任何 run 有可用的 token 数据(会话遥测读不到,且日志里没有 `tokens:<scope>` 事件)。',
|
|
203
|
+
' · ⚠️ 这一行**不代表消耗低** —— 它只说明"没测到"。本包实测过的真实 run 里,单条 lead 会话累计 prompt 达 **9.7 亿**、',
|
|
204
|
+
' 每步 prompt 中位数 **426K**;**没有数就没有刹车**,这正是本小节存在的原因。',
|
|
205
|
+
' · 数据来源:DSH 会话遥测(`~/.dsh/sessions/<workspace-slug>/...`)或 `RUN.log.md` 的 `tokens:<scope>` 事件(见 LOGGING.md)。',
|
|
206
|
+
];
|
|
207
|
+
if (broken > 0) lines.push(`- ⚠️ 另有 ${broken} 行 \`tokens:*\` 事件**没有一个解析出合法字段**(字段名写错会静默消失,合法字段:in / cache / out / steps / peak / first)。`);
|
|
208
|
+
return lines.join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const out = [head, ''];
|
|
212
|
+
const total = runs.reduce((a, r) => ({
|
|
213
|
+
in: a.in + r.totals.in, cache: a.cache + r.totals.cache, out: a.out + r.totals.out,
|
|
214
|
+
steps: a.steps + r.totals.steps, prompt: a.prompt + r.totals.prompt,
|
|
215
|
+
}), { in: 0, cache: 0, out: 0, steps: 0, prompt: 0 });
|
|
216
|
+
const costOf = (r) => r.totals.in * weights.in + r.totals.cache * weights.cache + r.totals.out * weights.out;
|
|
217
|
+
const totalCost = total.in * weights.in + total.cache * weights.cache + total.out * weights.out;
|
|
218
|
+
const grand = total.prompt + total.out;
|
|
219
|
+
|
|
220
|
+
out.push(`- 有 token 记账的 run:**${runs.length}** 个`);
|
|
221
|
+
const byLog = runs.filter((r) => r.source === 'log').length;
|
|
222
|
+
if (byLog > 0) out.push(`- 其中 **${byLog}** 个来自日志事件(\`tokens:<scope>\`),其余来自 DSH 会话遥测(**两种来源不混算**)`);
|
|
223
|
+
// 归属口径必须**随行渲染**:表里每一行都可能是"标记命中"与"就近推定"的混合,
|
|
224
|
+
// 而这两档的可靠度差一个量级。只给数字不给依据,等于让读者把推定当实测用。
|
|
225
|
+
const marked = runs.reduce((a, r) => a + (r.byScope || []).length, 0) > 0
|
|
226
|
+
? runs.map((r) => /marker×(\d+)/.exec(r.windowBasis || '')).filter(Boolean).reduce((a, m) => a + Number(m[1]), 0)
|
|
227
|
+
: 0;
|
|
228
|
+
const guessed = runs.map((r) => /nearest×(\d+)/.exec(r.windowBasis || '')).filter(Boolean).reduce((a, m) => a + Number(m[1]), 0);
|
|
229
|
+
if (marked || guessed || unattributed) {
|
|
230
|
+
out.push(`- 会话归属:**标记命中 ${groupThousands(marked)} 个**(提示词里带 run 目录路径,强证据)· 就近推定 ${groupThousands(guessed)} 个(弱证据)· 未归属 ${groupThousands(unattributed)} 个(**不猜**,故未计入任何 run)`);
|
|
231
|
+
}
|
|
232
|
+
out.push(`- 合计:未缓存输入 ${groupThousands(total.in)} · 缓存输入 ${groupThousands(total.cache)} · 输出 ${groupThousands(total.out)} · LLM 步数 ${groupThousands(total.steps)}`);
|
|
233
|
+
if (grand > 0) out.push(`- 输出 tokens 只占总量的 **${(total.out / grand * 100).toFixed(2)}%**(⇒「让模型少说点 / 降 effort」不是杠杆,**prompt 体积 × 步数**才是)`);
|
|
234
|
+
if (totalCost > 0) {
|
|
235
|
+
out.push(`- 成本占比:未缓存输入 **${Math.round((total.in * weights.in) / totalCost * 100)}%** · 缓存输入 **${Math.round((total.cache * weights.cache) / totalCost * 100)}%** · 输出 **${Math.round((total.out * weights.out) / totalCost * 100)}%**`);
|
|
236
|
+
}
|
|
237
|
+
out.push('');
|
|
238
|
+
out.push('| run | 来源 | 归属依据 | 步数 | 未缓存输入 | 缓存输入 | 输出 | 首步 prompt | 峰值 prompt | 每步均 prompt | lead 占比 |');
|
|
239
|
+
out.push('|---|---|---|---|---|---|---|---|---|---|---|');
|
|
240
|
+
for (const r of [...runs].sort((a, b) => costOf(b) - costOf(a))) {
|
|
241
|
+
const lead = r.byScope.filter((s) => s.scope === 'lead').reduce((a, s) => a + s.in + s.cache, 0);
|
|
242
|
+
const leadShare = r.totals.prompt > 0 ? `${Math.round((lead / r.totals.prompt) * 100)}%` : '—';
|
|
243
|
+
out.push(`| ${r.name} | ${r.source} | ${r.windowBasis || '—'} | ${groupThousands(r.totals.steps)} | ${groupThousands(r.totals.in)} | ${groupThousands(r.totals.cache)} | ${groupThousands(r.totals.out)} | ${r.totals.first ? groupThousands(r.totals.first) : '—'} | ${r.totals.peak ? groupThousands(r.totals.peak) : '—'} | ${r.avgPromptPerStep === null ? '—' : groupThousands(r.avgPromptPerStep)} | ${leadShare} |`);
|
|
244
|
+
}
|
|
245
|
+
// 逐 run 的角色明细只在**有多个 scope**时展开,否则这一节会长到没人读(规则:可见 ≠ 倾倒)。
|
|
246
|
+
// 每个 run 最多列 **8** 个 scope:真实 run 的 scope 数可达 36(长 run + 多个重派会话),
|
|
247
|
+
// 全列出来会把这一节变成一堵墙 —— 那时读者会跳过整节,**可见性反而归零**。
|
|
248
|
+
// 截断必须**说出来**(`另有 N 个`),否则会被读成"只有这 8 个"。
|
|
249
|
+
const SCOPE_SHOWN = 8;
|
|
250
|
+
const multi = runs.filter((r) => r.byScope.length > 1);
|
|
251
|
+
if (multi.length) {
|
|
252
|
+
out.push('');
|
|
253
|
+
out.push('**按 scope 明细(每 run 取最贵的 8 个)**(prompt 总输入 = 未缓存 + 缓存;`×N` = 该 scope 有 N 个会话;`session-xxxx` = 角色认不出的会话,**不是**角色名):');
|
|
254
|
+
for (const r of multi) {
|
|
255
|
+
const shown = r.byScope.slice(0, SCOPE_SHOWN);
|
|
256
|
+
const parts = shown.map((s) => `${s.scope}${s.sessions > 1 ? `×${s.sessions}` : ''} ${groupThousands(s.in + s.cache)}${s.roleKnown ? '' : '(角色未识别)'}`);
|
|
257
|
+
const rest = r.byScope.length - shown.length;
|
|
258
|
+
const restSum = r.byScope.slice(SCOPE_SHOWN).reduce((a, s) => a + s.in + s.cache, 0);
|
|
259
|
+
out.push(`- ${r.name}:${parts.join(' · ')}${rest > 0 ? ` · 另有 ${rest} 个 scope 合计 ${groupThousands(restSum)}` : ''}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const noSteps = runs.filter((r) => r.missingSteps > 0).length;
|
|
263
|
+
const noPeak = runs.filter((r) => r.missingPeak > 0).length;
|
|
264
|
+
if (noSteps > 0 || noPeak > 0 || broken > 0) {
|
|
265
|
+
out.push('');
|
|
266
|
+
if (noSteps > 0) out.push(`- ⚠️ ${noSteps} 个 run 缺 \`steps\` ⇒ **算不出**每步均 prompt 与重放倍数("算不出" ≠ "很小")。`);
|
|
267
|
+
if (noPeak > 0) out.push(`- ⚠️ ${noPeak} 个 run 缺 \`peak\` ⇒ 上下文峰值未登记。`);
|
|
268
|
+
if (broken > 0) out.push(`- ⚠️ ${broken} 行 \`tokens:*\` 事件**没有一个解析出合法字段**(字段名写错会静默消失:in / cache / out / steps / peak / first)。`);
|
|
269
|
+
}
|
|
270
|
+
return out.join('\n');
|
|
271
|
+
}
|