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,415 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { calculateOfficialCost } from './pricing.mjs';
|
|
4
|
+
import { recordRun, upsertDaily, upsertSession, upsertTokenEvent } from './db.mjs';
|
|
5
|
+
|
|
6
|
+
const UNSAFE_KEYS = new Set([
|
|
7
|
+
'prompt',
|
|
8
|
+
'response',
|
|
9
|
+
'messages',
|
|
10
|
+
'message',
|
|
11
|
+
'transcript',
|
|
12
|
+
'conversation',
|
|
13
|
+
'diff',
|
|
14
|
+
'patch',
|
|
15
|
+
'content',
|
|
16
|
+
'text'
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export function readCcusageImportInput(file) {
|
|
20
|
+
if (!file || file === '-') {
|
|
21
|
+
return readFileSync(0, 'utf8');
|
|
22
|
+
}
|
|
23
|
+
return readFileSync(file, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseCcusageJsonText(text) {
|
|
27
|
+
let payload;
|
|
28
|
+
try {
|
|
29
|
+
payload = JSON.parse(String(text || ''));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error(`Invalid ccusage JSON: ${error.message}`);
|
|
32
|
+
}
|
|
33
|
+
const unsafePath = firstUnsafeKeyPath(payload);
|
|
34
|
+
if (unsafePath) {
|
|
35
|
+
throw new Error(`ccusage JSON contains conversation-like field: ${unsafePath}`);
|
|
36
|
+
}
|
|
37
|
+
return payload;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function planCcusageImport(payload, options = {}) {
|
|
41
|
+
const device = cleanText(options.device, 120) || hostname();
|
|
42
|
+
const now = options.now || new Date();
|
|
43
|
+
const importSource = cleanText(options.importSource, 80) || 'import:ccusage-json';
|
|
44
|
+
const command = cleanText(options.command, 240) || 'import-usage --format=ccusage-json';
|
|
45
|
+
const toolCategory = cleanText(options.toolCategory, 80) || importSource;
|
|
46
|
+
const detectedShape = detectShape(payload);
|
|
47
|
+
const rows = extractUsageRows(payload, detectedShape);
|
|
48
|
+
if (!rows.length) {
|
|
49
|
+
throw new Error('No supported ccusage usage rows found');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const dailyByKey = new Map();
|
|
53
|
+
const sessionsByKey = new Map();
|
|
54
|
+
const eventsByKey = new Map();
|
|
55
|
+
const warnings = [];
|
|
56
|
+
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
const parts = expandModelBreakdowns(row);
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
const source = sourceFromRow(part);
|
|
61
|
+
const usageDate = usageDateFromRow(part);
|
|
62
|
+
const timestamp = timestampFromRow(part, usageDate, now);
|
|
63
|
+
const model = cleanText(part.model, 160) || '<unknown>';
|
|
64
|
+
const projectPath = cleanText(part.projectPath || part.project || part.projectName, 240) || null;
|
|
65
|
+
const sessionId = sessionIdFromRow(part, detectedShape, usageDate, model, projectPath);
|
|
66
|
+
const tokens = tokenFields(part);
|
|
67
|
+
const cost = calculateOfficialCost(model, {
|
|
68
|
+
input: tokens.inputTokens,
|
|
69
|
+
output: tokens.outputTokens,
|
|
70
|
+
cacheRead: tokens.cacheReadTokens,
|
|
71
|
+
cacheWrite: tokens.cacheCreationTokens,
|
|
72
|
+
reasoning: tokens.reasoningOutputTokens
|
|
73
|
+
}, { provider: providerFromSource(source) });
|
|
74
|
+
|
|
75
|
+
if (!cost.priced && number(part.costUSD ?? part.totalCost) > 0) {
|
|
76
|
+
warnings.push({
|
|
77
|
+
type: 'ignored-imported-cost',
|
|
78
|
+
model,
|
|
79
|
+
reason: 'ccusage cost was present but Token Studio keeps official-price conversion only.'
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const usageRow = {
|
|
84
|
+
device,
|
|
85
|
+
source,
|
|
86
|
+
usageDate,
|
|
87
|
+
model,
|
|
88
|
+
...tokens,
|
|
89
|
+
totalTokens: tokens.totalTokens,
|
|
90
|
+
costUSD: cost.totalUSD
|
|
91
|
+
};
|
|
92
|
+
const dailyKey = [usageRow.device, usageRow.source, usageRow.usageDate, usageRow.model].join('::');
|
|
93
|
+
mergeUsageRow(dailyByKey, dailyKey, usageRow);
|
|
94
|
+
|
|
95
|
+
const sessionRow = {
|
|
96
|
+
device,
|
|
97
|
+
source,
|
|
98
|
+
sessionId,
|
|
99
|
+
lastActivity: timestamp,
|
|
100
|
+
projectPath,
|
|
101
|
+
...tokens,
|
|
102
|
+
totalTokens: tokens.totalTokens,
|
|
103
|
+
costUSD: cost.totalUSD
|
|
104
|
+
};
|
|
105
|
+
const sessionKey = [sessionRow.device, sessionRow.source, sessionRow.sessionId].join('::');
|
|
106
|
+
mergeSessionRow(sessionsByKey, sessionKey, sessionRow);
|
|
107
|
+
|
|
108
|
+
const eventRow = {
|
|
109
|
+
eventId: eventIdFor({ detectedShape, source, usageDate, sessionId, model, timestamp }),
|
|
110
|
+
device,
|
|
111
|
+
source,
|
|
112
|
+
sessionId,
|
|
113
|
+
timestamp,
|
|
114
|
+
model,
|
|
115
|
+
inputTokens: tokens.inputTokens,
|
|
116
|
+
outputTokens: tokens.outputTokens,
|
|
117
|
+
cacheReadTokens: tokens.cacheReadTokens,
|
|
118
|
+
cacheCreationTokens: tokens.cacheCreationTokens,
|
|
119
|
+
reasoningTokens: tokens.reasoningOutputTokens,
|
|
120
|
+
toolCategory,
|
|
121
|
+
privacyLevel: 'safe'
|
|
122
|
+
};
|
|
123
|
+
eventsByKey.set(eventRow.eventId, eventRow);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
detectedShape,
|
|
129
|
+
device,
|
|
130
|
+
daily: [...dailyByKey.values()],
|
|
131
|
+
sessions: [...sessionsByKey.values()],
|
|
132
|
+
tokenEvents: [...eventsByKey.values()],
|
|
133
|
+
warnings: dedupeWarnings(warnings),
|
|
134
|
+
run: {
|
|
135
|
+
device,
|
|
136
|
+
source: importSource,
|
|
137
|
+
status: 'ok',
|
|
138
|
+
message: `shape=${detectedShape}, daily=${dailyByKey.size}, sessions=${sessionsByKey.size}, token_events=${eventsByKey.size}`,
|
|
139
|
+
collectedAt: new Date(now).toISOString(),
|
|
140
|
+
command
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function applyCcusageImport(db, plan) {
|
|
146
|
+
db.exec('BEGIN');
|
|
147
|
+
try {
|
|
148
|
+
for (const row of plan.daily) upsertDaily(db, row);
|
|
149
|
+
for (const row of plan.sessions) upsertSession(db, row);
|
|
150
|
+
for (const row of plan.tokenEvents) upsertTokenEvent(db, row);
|
|
151
|
+
recordRun(db, plan.run);
|
|
152
|
+
db.exec('COMMIT');
|
|
153
|
+
} catch (error) {
|
|
154
|
+
db.exec('ROLLBACK');
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
daily: plan.daily.length,
|
|
159
|
+
sessions: plan.sessions.length,
|
|
160
|
+
tokenEvents: plan.tokenEvents.length,
|
|
161
|
+
warnings: plan.warnings.length
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function detectShape(payload) {
|
|
166
|
+
if (Array.isArray(payload?.daily)) return 'daily';
|
|
167
|
+
if (payload?.projects && typeof payload.projects === 'object' && !Array.isArray(payload.projects)) return 'project-daily';
|
|
168
|
+
if (Array.isArray(payload?.data) && payload.type) {
|
|
169
|
+
const type = String(payload.type).toLowerCase();
|
|
170
|
+
if (['daily', 'weekly', 'session', 'blocks', 'monthly'].includes(type)) return type;
|
|
171
|
+
}
|
|
172
|
+
throw new Error('Unsupported ccusage JSON shape. Expected daily, project daily, weekly, session, blocks, or monthly output.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extractUsageRows(payload, shape) {
|
|
176
|
+
if (shape === 'daily') return payload.daily.map(row => ({ ...row }));
|
|
177
|
+
if (shape === 'project-daily') {
|
|
178
|
+
const rows = [];
|
|
179
|
+
for (const [project, entries] of Object.entries(payload.projects || {})) {
|
|
180
|
+
if (!Array.isArray(entries)) continue;
|
|
181
|
+
for (const row of entries) rows.push({ ...row, projectPath: project });
|
|
182
|
+
}
|
|
183
|
+
return rows;
|
|
184
|
+
}
|
|
185
|
+
return (payload.data || []).map(row => ({ ...row }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function expandModelBreakdowns(row) {
|
|
189
|
+
const breakdown = row.modelBreakdowns || row.modelBreakdown || row.breakdowns;
|
|
190
|
+
if (!breakdown) {
|
|
191
|
+
return [{ ...row, model: primaryModel(row) }];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (Array.isArray(breakdown)) {
|
|
195
|
+
const usable = breakdown.filter(item => item && typeof item === 'object' && hasTokenField(item));
|
|
196
|
+
if (!usable.length) return [{ ...row, model: primaryModel(row) }];
|
|
197
|
+
return usable.map((item, index) => ({
|
|
198
|
+
...row,
|
|
199
|
+
...item,
|
|
200
|
+
model: item.model || item.modelName || primaryModel(row, index)
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (typeof breakdown === 'object') {
|
|
205
|
+
const usable = Object.entries(breakdown)
|
|
206
|
+
.filter(([, item]) => item && typeof item === 'object' && hasTokenField(item));
|
|
207
|
+
if (!usable.length) return [{ ...row, model: primaryModel(row) }];
|
|
208
|
+
return usable.map(([model, item]) => ({
|
|
209
|
+
...row,
|
|
210
|
+
...item,
|
|
211
|
+
model
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return [{ ...row, model: primaryModel(row) }];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function primaryModel(row, index = 0) {
|
|
219
|
+
const models = row.modelsUsed || row.models || row.model;
|
|
220
|
+
if (Array.isArray(models)) return models[index] || models[0] || '<unknown>';
|
|
221
|
+
return models || row.modelName || '<unknown>';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function tokenFields(row) {
|
|
225
|
+
const inputTokens = integer(row.inputTokens ?? row.input_tokens ?? row.input);
|
|
226
|
+
const outputTokens = integer(row.outputTokens ?? row.output_tokens ?? row.output);
|
|
227
|
+
const cacheCreationTokens = integer(
|
|
228
|
+
row.cacheCreationTokens
|
|
229
|
+
?? row.cacheCreationInputTokens
|
|
230
|
+
?? row.cache_creation_tokens
|
|
231
|
+
?? row.cacheWriteTokens
|
|
232
|
+
);
|
|
233
|
+
const cacheReadTokens = integer(
|
|
234
|
+
row.cacheReadTokens
|
|
235
|
+
?? row.cacheReadInputTokens
|
|
236
|
+
?? row.cache_read_tokens
|
|
237
|
+
?? row.cachedInputTokens
|
|
238
|
+
);
|
|
239
|
+
const reasoningOutputTokens = integer(row.reasoningTokens ?? row.reasoningOutputTokens ?? row.reasoning_output_tokens);
|
|
240
|
+
const explicitTotal = integer(row.totalTokens ?? row.total_tokens);
|
|
241
|
+
const computedTotal = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + reasoningOutputTokens;
|
|
242
|
+
return {
|
|
243
|
+
inputTokens,
|
|
244
|
+
outputTokens,
|
|
245
|
+
cacheCreationTokens,
|
|
246
|
+
cacheReadTokens,
|
|
247
|
+
cachedInputTokens: 0,
|
|
248
|
+
reasoningOutputTokens,
|
|
249
|
+
totalTokens: explicitTotal || computedTotal
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hasTokenField(row) {
|
|
254
|
+
return [
|
|
255
|
+
'inputTokens',
|
|
256
|
+
'input_tokens',
|
|
257
|
+
'outputTokens',
|
|
258
|
+
'output_tokens',
|
|
259
|
+
'cacheCreationTokens',
|
|
260
|
+
'cacheCreationInputTokens',
|
|
261
|
+
'cacheReadTokens',
|
|
262
|
+
'cacheReadInputTokens',
|
|
263
|
+
'reasoningTokens',
|
|
264
|
+
'totalTokens',
|
|
265
|
+
'total_tokens'
|
|
266
|
+
].some(key => row[key] != null);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sourceFromRow(row) {
|
|
270
|
+
return cleanText(row.source || row.tool || row.instance || row.provider, 80) || 'ccusage';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function usageDateFromRow(row) {
|
|
274
|
+
const raw = row.date || row.usageDate || row.week || row.weekStart || row.startDate || row.month || row.blockStart || row.firstActivity || row.lastActivity;
|
|
275
|
+
const date = parseDate(raw);
|
|
276
|
+
if (!date) throw new Error('ccusage row is missing a usable date/month/activity field');
|
|
277
|
+
return formatDate(date);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function timestampFromRow(row, usageDate, now) {
|
|
281
|
+
const raw = row.lastActivity || row.blockEnd || row.firstActivity || row.blockStart || row.date || row.week || row.weekStart || row.startDate || row.month;
|
|
282
|
+
const date = parseDate(raw) || parseDate(usageDate) || new Date(now);
|
|
283
|
+
return date.toISOString();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sessionIdFromRow(row, shape, usageDate, model, projectPath) {
|
|
287
|
+
const raw = row.session || row.sessionId || row.session_id || row.id || null;
|
|
288
|
+
if (raw) return cleanText(raw, 240);
|
|
289
|
+
const project = projectPath ? hashable(projectPath) : 'all';
|
|
290
|
+
return `ccusage:${shape}:${project}:${usageDate}:${hashable(model)}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function eventIdFor({ detectedShape, source, usageDate, sessionId, model, timestamp }) {
|
|
294
|
+
return [
|
|
295
|
+
'ccusage',
|
|
296
|
+
detectedShape,
|
|
297
|
+
hashable(source),
|
|
298
|
+
usageDate,
|
|
299
|
+
hashable(sessionId),
|
|
300
|
+
hashable(model),
|
|
301
|
+
timestamp
|
|
302
|
+
].join(':');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function mergeUsageRow(map, key, row) {
|
|
306
|
+
const existing = map.get(key);
|
|
307
|
+
if (!existing) {
|
|
308
|
+
map.set(key, { ...row });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
addTokenFields(existing, row);
|
|
312
|
+
existing.costUSD += row.costUSD || 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function mergeSessionRow(map, key, row) {
|
|
316
|
+
const existing = map.get(key);
|
|
317
|
+
if (!existing) {
|
|
318
|
+
map.set(key, { ...row });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
addTokenFields(existing, row);
|
|
322
|
+
existing.costUSD += row.costUSD || 0;
|
|
323
|
+
if (row.lastActivity && (!existing.lastActivity || row.lastActivity > existing.lastActivity)) {
|
|
324
|
+
existing.lastActivity = row.lastActivity;
|
|
325
|
+
}
|
|
326
|
+
if (!existing.projectPath && row.projectPath) existing.projectPath = row.projectPath;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function addTokenFields(target, row) {
|
|
330
|
+
target.inputTokens += row.inputTokens || 0;
|
|
331
|
+
target.outputTokens += row.outputTokens || 0;
|
|
332
|
+
target.cacheCreationTokens += row.cacheCreationTokens || 0;
|
|
333
|
+
target.cacheReadTokens += row.cacheReadTokens || 0;
|
|
334
|
+
target.cachedInputTokens += row.cachedInputTokens || 0;
|
|
335
|
+
target.reasoningOutputTokens += row.reasoningOutputTokens || 0;
|
|
336
|
+
target.totalTokens += row.totalTokens || 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function firstUnsafeKeyPath(value, path = '$') {
|
|
340
|
+
if (Array.isArray(value)) {
|
|
341
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
342
|
+
const found = firstUnsafeKeyPath(value[index], `${path}[${index}]`);
|
|
343
|
+
if (found) return found;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
if (!value || typeof value !== 'object') return null;
|
|
348
|
+
for (const [key, child] of Object.entries(value)) {
|
|
349
|
+
if (UNSAFE_KEYS.has(String(key).toLowerCase())) return `${path}.${key}`;
|
|
350
|
+
const found = firstUnsafeKeyPath(child, `${path}.${key}`);
|
|
351
|
+
if (found) return found;
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function parseDate(value) {
|
|
357
|
+
const text = String(value || '').trim();
|
|
358
|
+
if (!text) return null;
|
|
359
|
+
const normalized = /^\d{4}-\d{2}$/.test(text) ? `${text}-01` : text;
|
|
360
|
+
const date = new Date(normalized);
|
|
361
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function formatDate(date) {
|
|
365
|
+
return [
|
|
366
|
+
date.getUTCFullYear(),
|
|
367
|
+
String(date.getUTCMonth() + 1).padStart(2, '0'),
|
|
368
|
+
String(date.getUTCDate()).padStart(2, '0')
|
|
369
|
+
].join('-');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function cleanText(value, maxLength) {
|
|
373
|
+
const text = String(value ?? '').trim();
|
|
374
|
+
if (!text) return '';
|
|
375
|
+
return text.length > maxLength ? text.slice(0, maxLength) : text;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function integer(value) {
|
|
379
|
+
const number = Number(value || 0);
|
|
380
|
+
if (!Number.isFinite(number) || number < 0) return 0;
|
|
381
|
+
return Math.round(number);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function number(value) {
|
|
385
|
+
const n = Number(value || 0);
|
|
386
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function hashable(value) {
|
|
390
|
+
return String(value || '')
|
|
391
|
+
.trim()
|
|
392
|
+
.toLowerCase()
|
|
393
|
+
.replace(/[^a-z0-9_.-]+/g, '-')
|
|
394
|
+
.replace(/^-+|-+$/g, '')
|
|
395
|
+
.slice(0, 80) || 'unknown';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function providerFromSource(source) {
|
|
399
|
+
const value = String(source || '').toLowerCase();
|
|
400
|
+
if (value.includes('codex') || value.includes('openai')) return 'openai';
|
|
401
|
+
if (value.includes('claude') || value.includes('anthropic')) return 'anthropic';
|
|
402
|
+
if (value.includes('deepseek')) return 'deepseek';
|
|
403
|
+
if (value.includes('mimo') || value.includes('xiaomi')) return 'xiaomi';
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function dedupeWarnings(warnings) {
|
|
408
|
+
const seen = new Set();
|
|
409
|
+
return warnings.filter(warning => {
|
|
410
|
+
const key = `${warning.type}:${warning.model}:${warning.reason}`;
|
|
411
|
+
if (seen.has(key)) return false;
|
|
412
|
+
seen.add(key);
|
|
413
|
+
return true;
|
|
414
|
+
});
|
|
415
|
+
}
|