token-studio 4.8.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/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
importAnnotationData,
|
|
5
|
+
openDb,
|
|
6
|
+
upsertDaily,
|
|
7
|
+
upsertSession
|
|
8
|
+
} from './db.mjs';
|
|
9
|
+
|
|
10
|
+
export function seedDemoDatabase({
|
|
11
|
+
dbPath = resolve(process.cwd(), 'data', 'demo.sqlite'),
|
|
12
|
+
demoPath = resolve(process.cwd(), 'docs', 'demo-data', 'token-studio-v2-demo.json')
|
|
13
|
+
} = {}) {
|
|
14
|
+
const payload = JSON.parse(readFileSync(demoPath, 'utf8'));
|
|
15
|
+
if (!payload.synthetic) {
|
|
16
|
+
throw new Error('Demo seed refused non-synthetic data');
|
|
17
|
+
}
|
|
18
|
+
const db = openDb(dbPath);
|
|
19
|
+
const daily = payload.usageSeed?.daily || [];
|
|
20
|
+
const sessions = payload.usageSeed?.sessions || [];
|
|
21
|
+
db.exec('BEGIN');
|
|
22
|
+
try {
|
|
23
|
+
for (const row of daily) upsertDaily(db, row);
|
|
24
|
+
for (const row of sessions) upsertSession(db, row);
|
|
25
|
+
db.exec('COMMIT');
|
|
26
|
+
} catch (error) {
|
|
27
|
+
db.exec('ROLLBACK');
|
|
28
|
+
db.close();
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
const imported = importAnnotationData(db, payload.annotationBackup || {});
|
|
32
|
+
db.close();
|
|
33
|
+
return {
|
|
34
|
+
dbPath,
|
|
35
|
+
daily: daily.length,
|
|
36
|
+
sessions: sessions.length,
|
|
37
|
+
imported
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/dev.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const nodeCmd = process.execPath;
|
|
5
|
+
const viteBin = resolve(process.cwd(), 'node_modules', 'vite', 'bin', 'vite.js');
|
|
6
|
+
|
|
7
|
+
const children = [
|
|
8
|
+
spawn(nodeCmd, ['src/server.mjs'], {
|
|
9
|
+
cwd: process.cwd(),
|
|
10
|
+
env: { ...process.env, PORT: process.env.API_PORT || '4173' },
|
|
11
|
+
stdio: 'inherit'
|
|
12
|
+
}),
|
|
13
|
+
spawn(nodeCmd, [viteBin, '--host', '127.0.0.1', '--port', '5173'], {
|
|
14
|
+
cwd: process.cwd(),
|
|
15
|
+
env: process.env,
|
|
16
|
+
stdio: 'inherit'
|
|
17
|
+
})
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
let shuttingDown = false;
|
|
21
|
+
|
|
22
|
+
for (const child of children) {
|
|
23
|
+
child.on('exit', (code, signal) => {
|
|
24
|
+
if (shuttingDown) return;
|
|
25
|
+
shuttingDown = true;
|
|
26
|
+
for (const other of children) {
|
|
27
|
+
if (other !== child && !other.killed) other.kill();
|
|
28
|
+
}
|
|
29
|
+
if (signal) process.kill(process.pid, signal);
|
|
30
|
+
process.exit(code ?? 0);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function shutdown() {
|
|
35
|
+
if (shuttingDown) return;
|
|
36
|
+
shuttingDown = true;
|
|
37
|
+
for (const child of children) {
|
|
38
|
+
if (!child.killed) child.kill();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.on('SIGINT', shutdown);
|
|
43
|
+
process.on('SIGTERM', shutdown);
|
package/src/live.mjs
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { calculateOfficialCost, resolveOfficialPricing } from './pricing.mjs';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_WINDOW_MINUTES = 15;
|
|
4
|
+
const DEFAULT_TOKEN_BUDGET_PER_HOUR = 50_000;
|
|
5
|
+
const DEFAULT_MIN_CACHE_HIT_RATE = 10;
|
|
6
|
+
const DEFAULT_MIN_OUTPUT_INPUT_RATIO = 0.15;
|
|
7
|
+
const DEFAULT_HIGH_INPUT_TOKENS = 10_000;
|
|
8
|
+
|
|
9
|
+
export function buildLiveSnapshot({
|
|
10
|
+
sessions = [],
|
|
11
|
+
tokenEvents = [],
|
|
12
|
+
runs = [],
|
|
13
|
+
budgetProfiles = [],
|
|
14
|
+
now = new Date(),
|
|
15
|
+
windowMinutes = DEFAULT_WINDOW_MINUTES,
|
|
16
|
+
guardrailConfig = liveGuardrailConfig()
|
|
17
|
+
} = {}) {
|
|
18
|
+
const nowMs = new Date(now).getTime();
|
|
19
|
+
const windowMs = Math.max(1, Number(windowMinutes) || DEFAULT_WINDOW_MINUTES) * 60 * 1000;
|
|
20
|
+
const sinceMs = nowMs - windowMs;
|
|
21
|
+
const normalizedEvents = tokenEvents.map(normalizeEvent);
|
|
22
|
+
const normalizedSessions = sessions.map(normalizeSession);
|
|
23
|
+
const recentEvents = normalizedEvents
|
|
24
|
+
.filter(event => event.timestampMs >= sinceMs && event.timestampMs <= nowMs);
|
|
25
|
+
const recentSessions = normalizedSessions
|
|
26
|
+
.filter(session => session.lastActivityMs >= sinceMs && session.lastActivityMs <= nowMs);
|
|
27
|
+
|
|
28
|
+
const sourceRows = aggregate(recentEvents.length ? recentEvents : recentSessions, 'source');
|
|
29
|
+
const modelRows = aggregate(recentEvents.length ? recentEvents : recentSessions, 'model');
|
|
30
|
+
const activeSessions = recentSessions
|
|
31
|
+
.sort((a, b) => b.lastActivityMs - a.lastActivityMs)
|
|
32
|
+
.slice(0, 12)
|
|
33
|
+
.map(session => ({
|
|
34
|
+
device: session.device,
|
|
35
|
+
source: session.source,
|
|
36
|
+
sessionId: session.sessionId,
|
|
37
|
+
model: session.model,
|
|
38
|
+
projectPath: session.projectPath,
|
|
39
|
+
lastActivity: session.lastActivity,
|
|
40
|
+
totalTokens: session.totalTokens,
|
|
41
|
+
costUSD: session.costUSD
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const totals = sumRows(recentEvents.length ? recentEvents : recentSessions);
|
|
45
|
+
const cacheDenominator = totals.inputTokens + totals.cacheReadTokens + totals.cacheCreationTokens;
|
|
46
|
+
|
|
47
|
+
const budgetWindows = buildBudgetWindows({
|
|
48
|
+
rows: normalizedEvents.length ? normalizedEvents : normalizedSessions,
|
|
49
|
+
budgetProfiles,
|
|
50
|
+
nowMs
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const snapshot = {
|
|
54
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
55
|
+
windowMinutes,
|
|
56
|
+
status: activeSessions.length || recentEvents.length ? 'active' : 'idle',
|
|
57
|
+
totals: {
|
|
58
|
+
...totals,
|
|
59
|
+
burnRateTokensPerHour: Math.round((totals.totalTokens / windowMinutes) * 60),
|
|
60
|
+
cacheHitRate: cacheDenominator ? (totals.cacheReadTokens / cacheDenominator) * 100 : 0
|
|
61
|
+
},
|
|
62
|
+
activeSessions,
|
|
63
|
+
bySource: sourceRows,
|
|
64
|
+
byModel: modelRows,
|
|
65
|
+
budgetWindows,
|
|
66
|
+
recentEvents: recentEvents.slice(0, 25).map(stripRuntimeFields),
|
|
67
|
+
latestRun: runs[0] || null
|
|
68
|
+
};
|
|
69
|
+
const guardrails = liveGuardrailConfig(guardrailConfig);
|
|
70
|
+
return {
|
|
71
|
+
...snapshot,
|
|
72
|
+
guardrails,
|
|
73
|
+
warnings: buildLiveGuardrails(snapshot, guardrails)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildLiveGuardrails(snapshot = {}, config = {}) {
|
|
78
|
+
const guardrails = liveGuardrailConfig(config);
|
|
79
|
+
const totals = snapshot.totals || {};
|
|
80
|
+
const warnings = [];
|
|
81
|
+
const inputTokens = number(totals.inputTokens);
|
|
82
|
+
const outputTokens = number(totals.outputTokens);
|
|
83
|
+
const burnRate = number(totals.burnRateTokensPerHour);
|
|
84
|
+
const cacheHitRate = Number(totals.cacheHitRate || 0);
|
|
85
|
+
const outputInputRatio = inputTokens ? outputTokens / inputTokens : 0;
|
|
86
|
+
|
|
87
|
+
if (burnRate > guardrails.tokenBudgetPerHour) {
|
|
88
|
+
warnings.push({
|
|
89
|
+
type: 'high-burn-rate',
|
|
90
|
+
level: burnRate > guardrails.tokenBudgetPerHour * 1.5 ? 'high' : 'medium',
|
|
91
|
+
message: '最近窗口 token burn rate 超过预算线',
|
|
92
|
+
evidence: `${formatInt(burnRate)} tokens/hour > ${formatInt(guardrails.tokenBudgetPerHour)} tokens/hour`,
|
|
93
|
+
action: '暂停大上下文任务,拆成更小的验证步骤后再继续。'
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (inputTokens >= guardrails.highInputTokens && cacheHitRate < guardrails.minCacheHitRate) {
|
|
98
|
+
warnings.push({
|
|
99
|
+
type: 'low-cache-hit',
|
|
100
|
+
level: 'medium',
|
|
101
|
+
message: '输入 token 高但 cache 命中偏低',
|
|
102
|
+
evidence: `input ${formatInt(inputTokens)} tokens,cache hit ${cacheHitRate.toFixed(1)}% < ${guardrails.minCacheHitRate}%`,
|
|
103
|
+
action: '沉淀项目上下文摘要,避免每轮重复喂相同文件。'
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (inputTokens >= guardrails.highInputTokens && outputInputRatio < guardrails.minOutputInputRatio) {
|
|
108
|
+
warnings.push({
|
|
109
|
+
type: 'low-output-input-ratio',
|
|
110
|
+
level: 'medium',
|
|
111
|
+
message: '输出/input 比偏低,可能在读过多上下文',
|
|
112
|
+
evidence: `output/input ${outputInputRatio.toFixed(2)} < ${guardrails.minOutputInputRatio}`,
|
|
113
|
+
action: '只保留当前问题直接相关的文件、错误和验收标准。'
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const unpricedModels = (snapshot.byModel || [])
|
|
118
|
+
.map(row => row.key)
|
|
119
|
+
.filter(model => isUnpricedModel(model));
|
|
120
|
+
if (unpricedModels.length) {
|
|
121
|
+
warnings.push({
|
|
122
|
+
type: 'unpriced-model-active',
|
|
123
|
+
level: 'low',
|
|
124
|
+
message: '最近窗口存在未公开官方美元价模型',
|
|
125
|
+
evidence: `${unpricedModels.slice(0, 3).join('、')} 不纳入官方价成本判断`,
|
|
126
|
+
action: '用 token、产出状态和价值判断这些模型,不把 $0 当成免费。'
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const window of snapshot.budgetWindows || []) {
|
|
131
|
+
if (window.status === 'exceeded') {
|
|
132
|
+
warnings.push({
|
|
133
|
+
type: 'budget-exceeded',
|
|
134
|
+
level: 'high',
|
|
135
|
+
message: `${window.label} 已超过自定义预算`,
|
|
136
|
+
evidence: budgetEvidence(window),
|
|
137
|
+
action: '暂停当前高消耗任务,先拆分上下文并复查是否仍需要继续。'
|
|
138
|
+
});
|
|
139
|
+
} else if (window.status === 'over-pace') {
|
|
140
|
+
warnings.push({
|
|
141
|
+
type: 'over-budget-pace',
|
|
142
|
+
level: 'high',
|
|
143
|
+
message: `${window.label} 按当前 burn rate 会超预算`,
|
|
144
|
+
evidence: budgetEvidence(window),
|
|
145
|
+
action: '降低模型层级或缩小输入范围,把大任务拆成验证步骤。'
|
|
146
|
+
});
|
|
147
|
+
} else if (window.status === 'near-limit') {
|
|
148
|
+
warnings.push({
|
|
149
|
+
type: 'near-budget-limit',
|
|
150
|
+
level: 'medium',
|
|
151
|
+
message: `${window.label} 接近自定义预算`,
|
|
152
|
+
evidence: budgetEvidence(window),
|
|
153
|
+
action: '优先做收尾和验证,暂缓新的大上下文探索。'
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return warnings;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function buildBudgetWindows({ rows = [], budgetProfiles = [], nowMs = Date.now() } = {}) {
|
|
162
|
+
return budgetProfiles
|
|
163
|
+
.filter(profile => profile && profile.enabled !== false)
|
|
164
|
+
.map(profile => {
|
|
165
|
+
const windowMinutes = positiveNumber(profile.windowMinutes, 300);
|
|
166
|
+
const frame = budgetWindowFrame(profile, nowMs, windowMinutes);
|
|
167
|
+
const source = String(profile.source || '').trim();
|
|
168
|
+
const matching = rows.filter(row => {
|
|
169
|
+
const timestampMs = row.timestampMs ?? row.lastActivityMs ?? 0;
|
|
170
|
+
return timestampMs >= frame.startMs
|
|
171
|
+
&& timestampMs <= nowMs
|
|
172
|
+
&& (!source || row.source === source);
|
|
173
|
+
});
|
|
174
|
+
const totals = sumRows(matching);
|
|
175
|
+
const firstMs = frame.windowType === 'fixed'
|
|
176
|
+
? frame.startMs
|
|
177
|
+
: matching.length
|
|
178
|
+
? Math.min(...matching.map(row => row.timestampMs ?? row.lastActivityMs ?? nowMs).filter(Number.isFinite))
|
|
179
|
+
: frame.startMs;
|
|
180
|
+
const elapsedMinutes = Math.max(1, Math.min(windowMinutes, (nowMs - firstMs) / 60000 || windowMinutes));
|
|
181
|
+
const burnRateTokensPerHour = Math.round((totals.totalTokens / elapsedMinutes) * 60);
|
|
182
|
+
const projectedTokens = Math.round((totals.totalTokens / elapsedMinutes) * windowMinutes);
|
|
183
|
+
const projectedCostUSD = (totals.costUSD / elapsedMinutes) * windowMinutes;
|
|
184
|
+
const tokenBudget = number(profile.tokenBudget);
|
|
185
|
+
const costBudgetUSD = number(profile.costBudgetUSD);
|
|
186
|
+
const warningThreshold = threshold(profile.warningThreshold, 0.75);
|
|
187
|
+
const tokenShare = tokenBudget ? totals.totalTokens / tokenBudget : 0;
|
|
188
|
+
const costShare = costBudgetUSD ? totals.costUSD / costBudgetUSD : 0;
|
|
189
|
+
const projectedTokenShare = tokenBudget ? projectedTokens / tokenBudget : 0;
|
|
190
|
+
const projectedCostShare = costBudgetUSD ? projectedCostUSD / costBudgetUSD : 0;
|
|
191
|
+
const currentShare = Math.max(tokenShare, costShare);
|
|
192
|
+
const projectedShare = Math.max(projectedTokenShare, projectedCostShare);
|
|
193
|
+
const status = currentShare >= 1 ? 'exceeded'
|
|
194
|
+
: projectedShare >= 1 ? 'over-pace'
|
|
195
|
+
: currentShare >= warningThreshold ? 'near-limit'
|
|
196
|
+
: 'ok';
|
|
197
|
+
return {
|
|
198
|
+
id: profile.id ?? null,
|
|
199
|
+
source,
|
|
200
|
+
label: profile.label || (source ? `${source} budget` : 'Token budget'),
|
|
201
|
+
windowType: frame.windowType,
|
|
202
|
+
windowMinutes,
|
|
203
|
+
resetAnchor: profile.resetAnchor || null,
|
|
204
|
+
warningThreshold,
|
|
205
|
+
windowStart: new Date(frame.startMs).toISOString(),
|
|
206
|
+
windowEnd: new Date(frame.endMs).toISOString(),
|
|
207
|
+
resetInMinutes: frame.resetInMinutes,
|
|
208
|
+
totalTokens: totals.totalTokens,
|
|
209
|
+
costUSD: totals.costUSD,
|
|
210
|
+
burnRateTokensPerHour,
|
|
211
|
+
projectedTokens,
|
|
212
|
+
projectedCostUSD,
|
|
213
|
+
tokenBudget,
|
|
214
|
+
costBudgetUSD,
|
|
215
|
+
tokenShare,
|
|
216
|
+
costShare,
|
|
217
|
+
projectedTokenShare,
|
|
218
|
+
projectedCostShare,
|
|
219
|
+
status
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function budgetWindowFrame(profile, nowMs, windowMinutes) {
|
|
225
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
226
|
+
const windowType = profile.windowType === 'fixed' ? 'fixed' : 'rolling';
|
|
227
|
+
if (windowType === 'fixed') {
|
|
228
|
+
const anchorMs = new Date(profile.resetAnchor || 0).getTime();
|
|
229
|
+
if (Number.isFinite(anchorMs) && anchorMs > 0) {
|
|
230
|
+
const index = Math.floor((nowMs - anchorMs) / windowMs);
|
|
231
|
+
const startMs = anchorMs + index * windowMs;
|
|
232
|
+
const endMs = startMs + windowMs;
|
|
233
|
+
return {
|
|
234
|
+
windowType,
|
|
235
|
+
startMs,
|
|
236
|
+
endMs,
|
|
237
|
+
resetInMinutes: Math.max(0, Math.ceil((endMs - nowMs) / 60000))
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const startMs = nowMs - windowMs;
|
|
242
|
+
return {
|
|
243
|
+
windowType: 'rolling',
|
|
244
|
+
startMs,
|
|
245
|
+
endMs: nowMs,
|
|
246
|
+
resetInMinutes: windowMinutes
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function liveGuardrailConfig(overrides = {}) {
|
|
251
|
+
return {
|
|
252
|
+
tokenBudgetPerHour: positiveNumber(
|
|
253
|
+
overrides.tokenBudgetPerHour,
|
|
254
|
+
envPositive('TOKEN_STUDIO_LIVE_TOKEN_BUDGET_PER_HOUR', DEFAULT_TOKEN_BUDGET_PER_HOUR)
|
|
255
|
+
),
|
|
256
|
+
minCacheHitRate: positiveNumber(
|
|
257
|
+
overrides.minCacheHitRate,
|
|
258
|
+
envPositive('TOKEN_STUDIO_LIVE_MIN_CACHE_HIT', DEFAULT_MIN_CACHE_HIT_RATE)
|
|
259
|
+
),
|
|
260
|
+
minOutputInputRatio: positiveNumber(
|
|
261
|
+
overrides.minOutputInputRatio,
|
|
262
|
+
envPositive('TOKEN_STUDIO_LIVE_MIN_OUTPUT_INPUT_RATIO', DEFAULT_MIN_OUTPUT_INPUT_RATIO)
|
|
263
|
+
),
|
|
264
|
+
highInputTokens: positiveNumber(overrides.highInputTokens, DEFAULT_HIGH_INPUT_TOKENS)
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function normalizeSession(session) {
|
|
269
|
+
const lastActivity = session.lastActivity || session.last_activity || null;
|
|
270
|
+
return {
|
|
271
|
+
device: session.device || '',
|
|
272
|
+
source: session.source || 'unknown',
|
|
273
|
+
sessionId: session.sessionId || session.session_id || 'unknown-session',
|
|
274
|
+
model: session.model || 'unknown',
|
|
275
|
+
projectPath: session.projectPath || session.project_path || null,
|
|
276
|
+
lastActivity,
|
|
277
|
+
lastActivityMs: dateMs(lastActivity),
|
|
278
|
+
inputTokens: number(session.inputTokens ?? session.input_tokens),
|
|
279
|
+
outputTokens: number(session.outputTokens ?? session.output_tokens),
|
|
280
|
+
cacheReadTokens: number(session.cacheReadTokens ?? session.cache_read_tokens),
|
|
281
|
+
cacheCreationTokens: number(session.cacheCreationTokens ?? session.cache_creation_tokens),
|
|
282
|
+
reasoningTokens: number(session.reasoningOutputTokens ?? session.reasoningTokens ?? session.reasoning_output_tokens),
|
|
283
|
+
totalTokens: number(session.totalTokens ?? session.total_tokens),
|
|
284
|
+
costUSD: number(session.costUSD ?? session.cost_usd)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function normalizeEvent(event) {
|
|
289
|
+
const timestamp = event.timestamp || event.createdAt || event.created_at || null;
|
|
290
|
+
const inputTokens = number(event.inputTokens ?? event.input_tokens);
|
|
291
|
+
const outputTokens = number(event.outputTokens ?? event.output_tokens);
|
|
292
|
+
const cacheReadTokens = number(event.cacheReadTokens ?? event.cache_read_tokens);
|
|
293
|
+
const cacheCreationTokens = number(event.cacheCreationTokens ?? event.cache_creation_tokens);
|
|
294
|
+
const reasoningTokens = number(event.reasoningTokens ?? event.reasoning_tokens);
|
|
295
|
+
const model = event.model || 'unknown';
|
|
296
|
+
const costUSD = number(event.costUSD ?? event.cost_usd)
|
|
297
|
+
|| calculateOfficialCost(model, {
|
|
298
|
+
input: inputTokens,
|
|
299
|
+
output: outputTokens,
|
|
300
|
+
cacheRead: cacheReadTokens,
|
|
301
|
+
cacheWrite: cacheCreationTokens,
|
|
302
|
+
reasoning: reasoningTokens
|
|
303
|
+
}, { provider: providerFromSource(event.source) }).totalUSD;
|
|
304
|
+
return {
|
|
305
|
+
eventId: event.eventId || event.event_id || null,
|
|
306
|
+
device: event.device || '',
|
|
307
|
+
source: event.source || 'unknown',
|
|
308
|
+
sessionId: event.sessionId || event.session_id || 'unknown-session',
|
|
309
|
+
timestamp,
|
|
310
|
+
timestampMs: dateMs(timestamp),
|
|
311
|
+
model,
|
|
312
|
+
inputTokens,
|
|
313
|
+
outputTokens,
|
|
314
|
+
cacheReadTokens,
|
|
315
|
+
cacheCreationTokens,
|
|
316
|
+
reasoningTokens,
|
|
317
|
+
totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens,
|
|
318
|
+
costUSD,
|
|
319
|
+
toolCategory: event.toolCategory || event.tool_category || null,
|
|
320
|
+
fileExtension: event.fileExtension || event.file_extension || null
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function budgetEvidence(window) {
|
|
325
|
+
const pieces = [];
|
|
326
|
+
if (window.tokenBudget) {
|
|
327
|
+
pieces.push(`${formatInt(window.totalTokens)} / ${formatInt(window.tokenBudget)} tokens`);
|
|
328
|
+
pieces.push(`projected ${formatInt(window.projectedTokens)} tokens`);
|
|
329
|
+
}
|
|
330
|
+
if (window.costBudgetUSD) {
|
|
331
|
+
pieces.push(`$${window.costUSD.toFixed(4)} / $${window.costBudgetUSD.toFixed(4)}`);
|
|
332
|
+
pieces.push(`projected $${window.projectedCostUSD.toFixed(4)}`);
|
|
333
|
+
}
|
|
334
|
+
return pieces.join(' · ');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function providerFromSource(source) {
|
|
338
|
+
const value = String(source || '').toLowerCase();
|
|
339
|
+
if (value.includes('codex') || value.includes('openai')) return 'openai';
|
|
340
|
+
if (value.includes('claude') || value.includes('anthropic')) return 'anthropic';
|
|
341
|
+
if (value.includes('deepseek')) return 'deepseek';
|
|
342
|
+
if (value.includes('mimo') || value.includes('xiaomi')) return 'xiaomi';
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function aggregate(rows, field) {
|
|
347
|
+
const byKey = new Map();
|
|
348
|
+
for (const row of rows) {
|
|
349
|
+
const key = row[field] || 'unknown';
|
|
350
|
+
if (!byKey.has(key)) {
|
|
351
|
+
byKey.set(key, { key, sessions: new Set(), totalTokens: 0, costUSD: 0 });
|
|
352
|
+
}
|
|
353
|
+
const target = byKey.get(key);
|
|
354
|
+
target.sessions.add(row.sessionId);
|
|
355
|
+
target.totalTokens += row.totalTokens;
|
|
356
|
+
target.costUSD += row.costUSD;
|
|
357
|
+
}
|
|
358
|
+
return [...byKey.values()]
|
|
359
|
+
.map(row => ({
|
|
360
|
+
key: row.key,
|
|
361
|
+
sessions: row.sessions.size,
|
|
362
|
+
totalTokens: row.totalTokens,
|
|
363
|
+
costUSD: row.costUSD
|
|
364
|
+
}))
|
|
365
|
+
.sort((a, b) => b.totalTokens - a.totalTokens)
|
|
366
|
+
.slice(0, 10);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function sumRows(rows) {
|
|
370
|
+
return rows.reduce((sum, row) => ({
|
|
371
|
+
inputTokens: sum.inputTokens + row.inputTokens,
|
|
372
|
+
outputTokens: sum.outputTokens + row.outputTokens,
|
|
373
|
+
cacheReadTokens: sum.cacheReadTokens + row.cacheReadTokens,
|
|
374
|
+
cacheCreationTokens: sum.cacheCreationTokens + row.cacheCreationTokens,
|
|
375
|
+
reasoningTokens: sum.reasoningTokens + row.reasoningTokens,
|
|
376
|
+
totalTokens: sum.totalTokens + row.totalTokens,
|
|
377
|
+
costUSD: sum.costUSD + row.costUSD
|
|
378
|
+
}), {
|
|
379
|
+
inputTokens: 0,
|
|
380
|
+
outputTokens: 0,
|
|
381
|
+
cacheReadTokens: 0,
|
|
382
|
+
cacheCreationTokens: 0,
|
|
383
|
+
reasoningTokens: 0,
|
|
384
|
+
totalTokens: 0,
|
|
385
|
+
costUSD: 0
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function stripRuntimeFields(row) {
|
|
390
|
+
const { timestampMs, ...rest } = row;
|
|
391
|
+
return rest;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function dateMs(value) {
|
|
395
|
+
const ms = new Date(value || 0).getTime();
|
|
396
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function number(value) {
|
|
400
|
+
const n = Number(value || 0);
|
|
401
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function envPositive(name, fallback) {
|
|
405
|
+
const value = Number(globalThis.process?.env?.[name]);
|
|
406
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function positiveNumber(value, fallback) {
|
|
410
|
+
const number = Number(value);
|
|
411
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function threshold(value, fallback) {
|
|
415
|
+
const number = Number(value);
|
|
416
|
+
return Number.isFinite(number) && number > 0 && number <= 1 ? number : fallback;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function isUnpricedModel(model) {
|
|
420
|
+
const value = String(model || '').trim();
|
|
421
|
+
if (!value || value === 'unknown') return false;
|
|
422
|
+
const pricing = resolveOfficialPricing(value);
|
|
423
|
+
return !pricing || !pricing.priced;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatInt(value) {
|
|
427
|
+
return Math.round(Number(value || 0)).toLocaleString('en-US');
|
|
428
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const LOW_COST_PURPOSES = new Set(['测试验证', '技术调研', '上下文整理', '需求澄清']);
|
|
2
|
+
const HEAVY_VALUE_LEVELS = new Set(['高', '关键']);
|
|
3
|
+
const PRODUCTIVE_STATUSES = new Set(['已完成', '已发布']);
|
|
4
|
+
|
|
5
|
+
export function buildModelPolicy({ sessions = [], generatedAt = new Date() } = {}) {
|
|
6
|
+
const rows = aggregateByPurposeStage(sessions);
|
|
7
|
+
const rules = [
|
|
8
|
+
{
|
|
9
|
+
name: '测试验证和上下文整理默认轻量模型',
|
|
10
|
+
when: 'workPurpose in 测试验证/技术调研/上下文整理/需求澄清',
|
|
11
|
+
policy: '先用轻量模型完成验证、归纳和低风险试错,只有阻塞实现时再升级到中模型。',
|
|
12
|
+
evidence: evidenceFor(rows, row => LOW_COST_PURPOSES.has(row.workPurpose))
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: '复杂实现默认中模型',
|
|
16
|
+
when: 'workStage in 实现/维护 and valueLevel not in 高/关键',
|
|
17
|
+
policy: '功能实现和常规调试优先用中模型,避免在不确定任务上直接消耗重模型。',
|
|
18
|
+
evidence: evidenceFor(rows, row => ['实现', '维护'].includes(row.workStage) && !HEAVY_VALUE_LEVELS.has(row.valueLevel))
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: '高价值发布前再上重模型',
|
|
22
|
+
when: 'valueLevel in 高/关键 or outputStatus in 已完成/已发布',
|
|
23
|
+
policy: '重模型用于高价值产出、发布前审查和关键决策,不用于早期大范围探索。',
|
|
24
|
+
evidence: evidenceFor(rows, row => HEAVY_VALUE_LEVELS.has(row.valueLevel) || PRODUCTIVE_STATUSES.has(row.outputStatus))
|
|
25
|
+
}
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
generatedAt: generatedAt.toISOString(),
|
|
30
|
+
sessionCount: sessions.length,
|
|
31
|
+
rules
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatModelPolicyMarkdown(policy) {
|
|
36
|
+
return [
|
|
37
|
+
'# Token Studio Model Policy / 模型策略',
|
|
38
|
+
'',
|
|
39
|
+
`Generated at: ${policy.generatedAt}`,
|
|
40
|
+
`Reviewed sessions: ${policy.sessionCount}`,
|
|
41
|
+
'',
|
|
42
|
+
'## Recommended Rules',
|
|
43
|
+
'',
|
|
44
|
+
...policy.rules.flatMap((rule, index) => [
|
|
45
|
+
`### ${index + 1}. ${rule.name}`,
|
|
46
|
+
'',
|
|
47
|
+
`- When: ${rule.when}`,
|
|
48
|
+
`- Policy: ${rule.policy}`,
|
|
49
|
+
`- Evidence: ${rule.evidence}`,
|
|
50
|
+
''
|
|
51
|
+
]),
|
|
52
|
+
'## Scope Notes',
|
|
53
|
+
'',
|
|
54
|
+
'- This policy is generated from local structured metadata and annotations.',
|
|
55
|
+
'- It does not read or export conversation content.',
|
|
56
|
+
'- Cost is official public token-price conversion, not a provider invoice.'
|
|
57
|
+
].join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatModelPolicy(policy, format = 'markdown') {
|
|
61
|
+
if (format === 'markdown') return formatModelPolicyMarkdown(policy);
|
|
62
|
+
if (format === 'claude-md') return formatClaudePolicySnippet(policy);
|
|
63
|
+
if (format === 'agents-md') return formatAgentsPolicySnippet(policy);
|
|
64
|
+
throw new Error('Unsupported model policy format');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatClaudePolicySnippet(policy) {
|
|
68
|
+
return [
|
|
69
|
+
'# Token Studio ROI Model Policy',
|
|
70
|
+
'',
|
|
71
|
+
'Use this as a local operating guide for Claude Code work. It is generated from Token Studio structured metadata and does not include prompts, responses, transcripts, diffs, or full file paths.',
|
|
72
|
+
'',
|
|
73
|
+
'## Model Use',
|
|
74
|
+
'',
|
|
75
|
+
...policy.rules.flatMap(rule => [
|
|
76
|
+
`- ${rule.name}: ${rule.policy}`,
|
|
77
|
+
` Evidence: ${rule.evidence}`
|
|
78
|
+
]),
|
|
79
|
+
'',
|
|
80
|
+
'## Guardrails',
|
|
81
|
+
'',
|
|
82
|
+
'- Start testing, exploration, context cleanup, and low-risk validation with lightweight models.',
|
|
83
|
+
'- Use mid-tier models for normal implementation and debugging after the task is scoped.',
|
|
84
|
+
'- Reserve heavy models for high-value output, release review, and critical decisions.',
|
|
85
|
+
'- When input is high and output is low, reduce context before upgrading the model.',
|
|
86
|
+
'- Treat official-price conversion as a review signal, not a provider invoice.'
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatAgentsPolicySnippet(policy) {
|
|
91
|
+
return [
|
|
92
|
+
'# Token Studio ROI Agent Policy',
|
|
93
|
+
'',
|
|
94
|
+
'This project uses local ROI guardrails from Token Studio. Follow these rules before spending heavy-model tokens.',
|
|
95
|
+
'',
|
|
96
|
+
'## Operating Rules',
|
|
97
|
+
'',
|
|
98
|
+
...policy.rules.flatMap(rule => [
|
|
99
|
+
`- Rule: ${rule.name}`,
|
|
100
|
+
` - When: ${rule.when}`,
|
|
101
|
+
` - Do: ${rule.policy}`,
|
|
102
|
+
` - Evidence: ${rule.evidence}`
|
|
103
|
+
]),
|
|
104
|
+
'',
|
|
105
|
+
'## Non-Goals',
|
|
106
|
+
'',
|
|
107
|
+
'- Do not paste or export conversation content into reports.',
|
|
108
|
+
'- Do not claim official-price conversion is an exact vendor bill.',
|
|
109
|
+
'- Do not automatically edit CLAUDE.md, AGENTS.md, or project files from this export.'
|
|
110
|
+
].join('\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function aggregateByPurposeStage(sessions) {
|
|
114
|
+
const rows = new Map();
|
|
115
|
+
for (const session of sessions) {
|
|
116
|
+
const key = [
|
|
117
|
+
session.workPurpose || '未说明',
|
|
118
|
+
session.workStage || '未说明',
|
|
119
|
+
session.valueLevel || '未评估',
|
|
120
|
+
session.outputStatus || '未标注'
|
|
121
|
+
].join('::');
|
|
122
|
+
if (!rows.has(key)) {
|
|
123
|
+
rows.set(key, {
|
|
124
|
+
workPurpose: session.workPurpose || '未说明',
|
|
125
|
+
workStage: session.workStage || '未说明',
|
|
126
|
+
valueLevel: session.valueLevel || '未评估',
|
|
127
|
+
outputStatus: session.outputStatus || '未标注',
|
|
128
|
+
sessions: 0,
|
|
129
|
+
tokens: 0,
|
|
130
|
+
costUSD: 0
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const row = rows.get(key);
|
|
134
|
+
row.sessions += 1;
|
|
135
|
+
row.tokens += session.totalTokens || 0;
|
|
136
|
+
row.costUSD += session.costUSD || 0;
|
|
137
|
+
}
|
|
138
|
+
return Array.from(rows.values());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function evidenceFor(rows, predicate) {
|
|
142
|
+
const matched = rows.filter(predicate);
|
|
143
|
+
const sessions = matched.reduce((sum, row) => sum + row.sessions, 0);
|
|
144
|
+
const tokens = matched.reduce((sum, row) => sum + row.tokens, 0);
|
|
145
|
+
if (!sessions) return 'No matching annotated sessions yet; keep this as the default operating policy.';
|
|
146
|
+
return `${sessions} sessions, ${tokens.toLocaleString('en-US')} tokens in matching annotated work.`;
|
|
147
|
+
}
|