ai-heatmap 1.14.7 → 1.17.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/README.md +15 -1
- package/bin/cli.mjs +113 -5
- package/bin/init.mjs +1 -1
- package/package.json +1 -1
- package/scripts/generate.mjs +169 -51
package/README.md
CHANGED
|
@@ -13,7 +13,8 @@ Powered by [ccusage](https://github.com/ryoppippi/ccusage) + [react-activity-cal
|
|
|
13
13
|
## Quick Start
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
16
|
+
curl -sS https://webi.sh/node | sh # Node.js (includes npx)
|
|
17
|
+
curl -sS https://webi.sh/gh | sh # GitHub CLI
|
|
17
18
|
gh auth login
|
|
18
19
|
```
|
|
19
20
|
|
|
@@ -189,6 +190,15 @@ GH_TOKEN=ghp_xxx
|
|
|
189
190
|
0 0 * * * npx --yes ai-heatmap@latest update
|
|
190
191
|
```
|
|
191
192
|
|
|
193
|
+
> **`npx: not found`?** cron uses a minimal PATH. Fix with:
|
|
194
|
+
> ```bash
|
|
195
|
+
> # Find npx path
|
|
196
|
+
> which npx # e.g. /home/user/.local/bin/npx
|
|
197
|
+
>
|
|
198
|
+
> # Use full path in cron
|
|
199
|
+
> 0 0 * * * PATH=$HOME/.local/bin:$PATH npx --yes ai-heatmap@latest update
|
|
200
|
+
> ```
|
|
201
|
+
|
|
192
202
|
## Upgrade
|
|
193
203
|
|
|
194
204
|
To use the latest version of ai-heatmap:
|
|
@@ -199,6 +209,10 @@ npx --yes ai-heatmap@latest update
|
|
|
199
209
|
|
|
200
210
|
## Deployment
|
|
201
211
|
|
|
212
|
+
```bash
|
|
213
|
+
npm publish
|
|
214
|
+
```
|
|
215
|
+
|
|
202
216
|
### GitHub Pages
|
|
203
217
|
|
|
204
218
|
1. Enable GitHub Pages (Settings > Pages > Source: GitHub Actions)
|
package/bin/cli.mjs
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { existsSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { resolve, dirname } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import os from "node:os";
|
|
6
7
|
|
|
7
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const root = resolve(__dirname, "..");
|
|
9
10
|
|
|
11
|
+
function getMachineName() {
|
|
12
|
+
try {
|
|
13
|
+
if (process.platform === "darwin") {
|
|
14
|
+
const serial = execSync(
|
|
15
|
+
"ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformSerialNumber/ {print $NF}' | tr -d '\"'",
|
|
16
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] },
|
|
17
|
+
).trim();
|
|
18
|
+
if (serial) return serial;
|
|
19
|
+
} else if (process.platform === "linux") {
|
|
20
|
+
const id = readFileSync("/etc/machine-id", "utf-8").trim();
|
|
21
|
+
if (id) return id.slice(0, 12);
|
|
22
|
+
} else if (process.platform === "win32") {
|
|
23
|
+
const uuid = execSync("wmic csproduct get UUID /value", {
|
|
24
|
+
encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"],
|
|
25
|
+
}).match(/UUID=([^\r\n]+)/)?.[1]?.trim();
|
|
26
|
+
if (uuid) return uuid.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12);
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
return os.hostname().replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
const [command, ...args] = process.argv.slice(2);
|
|
11
33
|
|
|
12
34
|
const HELP = `
|
|
@@ -15,12 +37,14 @@ ai-heatmap - AI usage cost heatmap
|
|
|
15
37
|
Commands:
|
|
16
38
|
init [--repo <name>] Create a new heatmap repo and generate initial data
|
|
17
39
|
update [--repo <owner/repo>] Generate data + push to repo
|
|
40
|
+
[--name <machine>]
|
|
18
41
|
generate-svg Generate heatmap.svg from data.json
|
|
19
42
|
delete [--repo <name>] Delete the heatmap GitHub repo
|
|
20
43
|
deploy [--repo <name>] Deploy to Vercel (SVG API endpoint)
|
|
21
44
|
|
|
22
45
|
Options:
|
|
23
46
|
--repo <name> Target repo (default: {user}-ai-heatmap)
|
|
47
|
+
--name <machine> Machine name for per-device data file (default: serial number)
|
|
24
48
|
--since YYYYMMDD Start date (update only)
|
|
25
49
|
--until YYYYMMDD End date (update only)
|
|
26
50
|
|
|
@@ -28,6 +52,7 @@ Examples:
|
|
|
28
52
|
npx ai-heatmap init
|
|
29
53
|
npx ai-heatmap init --repo my-heatmap
|
|
30
54
|
npx ai-heatmap update
|
|
55
|
+
npx ai-heatmap update --name=mac
|
|
31
56
|
npx ai-heatmap update --repo owner/repo-name
|
|
32
57
|
npx ai-heatmap delete
|
|
33
58
|
npx ai-heatmap deploy
|
|
@@ -38,6 +63,45 @@ function getArg(flag) {
|
|
|
38
63
|
return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
|
|
39
64
|
}
|
|
40
65
|
|
|
66
|
+
function resolveRepo(repoArg) {
|
|
67
|
+
try {
|
|
68
|
+
const owner = execSync("gh api user --jq .login", { encoding: "utf-8" }).trim();
|
|
69
|
+
if (!repoArg) return `${owner}/${owner}-ai-heatmap`;
|
|
70
|
+
if (!repoArg.includes("/")) return `${owner}/${repoArg}`;
|
|
71
|
+
return repoArg;
|
|
72
|
+
} catch {
|
|
73
|
+
if (repoArg && repoArg.includes("/")) return repoArg;
|
|
74
|
+
console.error("Could not determine repo. Use --repo <owner/repo>");
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pushFile(repo, repoPath, localPath) {
|
|
80
|
+
const content = readFileSync(localPath, "utf-8");
|
|
81
|
+
const base64 = Buffer.from(content).toString("base64");
|
|
82
|
+
|
|
83
|
+
let sha = "";
|
|
84
|
+
try {
|
|
85
|
+
sha = execSync(
|
|
86
|
+
`gh api repos/${repo}/contents/${repoPath} --jq .sha`,
|
|
87
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] },
|
|
88
|
+
).trim();
|
|
89
|
+
} catch { /* file doesn't exist yet */ }
|
|
90
|
+
|
|
91
|
+
const payload = {
|
|
92
|
+
message: `Update ${repoPath} (${new Date().toISOString().slice(0, 10)})`,
|
|
93
|
+
content: base64,
|
|
94
|
+
};
|
|
95
|
+
if (sha) payload.sha = sha;
|
|
96
|
+
|
|
97
|
+
const payloadStr = JSON.stringify(JSON.stringify(payload));
|
|
98
|
+
execSync(
|
|
99
|
+
`echo ${payloadStr} | gh api repos/${repo}/contents/${repoPath} -X PUT --input -`,
|
|
100
|
+
{ stdio: ["pipe", "inherit", "inherit"] },
|
|
101
|
+
);
|
|
102
|
+
console.log(` Pushed ${repoPath} to ${repo}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
41
105
|
switch (command) {
|
|
42
106
|
case "init": {
|
|
43
107
|
const script = resolve(__dirname, "init.mjs");
|
|
@@ -47,13 +111,57 @@ switch (command) {
|
|
|
47
111
|
}
|
|
48
112
|
case "update": {
|
|
49
113
|
const genScript = resolve(root, "scripts/generate.mjs");
|
|
50
|
-
const
|
|
114
|
+
const outDir = resolve(root, "public");
|
|
51
115
|
const genArgs = args.filter(
|
|
52
|
-
(a) => a.startsWith("--since") || a.startsWith("--until"),
|
|
116
|
+
(a) => a.startsWith("--since") || a.startsWith("--until") || a.startsWith("--name"),
|
|
53
117
|
);
|
|
54
|
-
|
|
118
|
+
|
|
119
|
+
const repo = resolveRepo(getArg("--repo"));
|
|
120
|
+
|
|
121
|
+
// 머신 이름 결정 (generate.mjs와 동일한 로직)
|
|
122
|
+
const nameFlag = genArgs.find((a) => a.startsWith("--name="));
|
|
123
|
+
const machineName = nameFlag
|
|
124
|
+
? nameFlag.slice("--name=".length)
|
|
125
|
+
: getMachineName();
|
|
126
|
+
|
|
127
|
+
// 1. GitHub API로 다른 컴퓨터의 data-*.json 파일들을 로컬에 다운로드
|
|
128
|
+
console.log(`Fetching machine data files from ${repo}...`);
|
|
129
|
+
mkdirSync(outDir, { recursive: true });
|
|
130
|
+
try {
|
|
131
|
+
const raw = execSync(
|
|
132
|
+
`gh api repos/${repo}/contents/public --jq '[.[] | select(.name | test("^data-.+\\.json$"))]'`,
|
|
133
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] },
|
|
134
|
+
);
|
|
135
|
+
const files = JSON.parse(raw);
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
// 현재 컴퓨터 파일은 generate에서 새로 생성하므로 건너뜀
|
|
138
|
+
if (file.name === `data-${machineName}.json`) continue;
|
|
139
|
+
const decoded = Buffer.from(file.content.replace(/\n/g, ""), "base64").toString("utf-8");
|
|
140
|
+
writeFileSync(resolve(outDir, file.name), decoded);
|
|
141
|
+
console.log(` Fetched ${file.name}`);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
console.log(" No existing machine data files found (first run).");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2. generate: 이 컴퓨터 데이터 수집 + 모든 data-*.json 합산 → data.json 생성
|
|
55
148
|
execSync(`node ${genScript} ${genArgs.join(" ")}`, { stdio: "inherit" });
|
|
56
|
-
|
|
149
|
+
|
|
150
|
+
// 3. data-{name}.json push (이 컴퓨터 개별 파일)
|
|
151
|
+
const machineFile = `data-${machineName}.json`;
|
|
152
|
+
const machineFilePath = resolve(outDir, machineFile);
|
|
153
|
+
if (existsSync(machineFilePath)) {
|
|
154
|
+
console.log(`Pushing machine data file...`);
|
|
155
|
+
pushFile(repo, `public/${machineFile}`, machineFilePath);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 4. data.json push (합산 결과)
|
|
159
|
+
const dataPath = resolve(outDir, "data.json");
|
|
160
|
+
if (existsSync(dataPath)) {
|
|
161
|
+
console.log(`Pushing merged data.json...`);
|
|
162
|
+
pushFile(repo, "public/data.json", dataPath);
|
|
163
|
+
}
|
|
164
|
+
|
|
57
165
|
break;
|
|
58
166
|
}
|
|
59
167
|
case "delete": {
|
package/bin/init.mjs
CHANGED
|
@@ -135,7 +135,7 @@ readmeLines.push(
|
|
|
135
135
|
"0 0 * * * npx --yes ai-heatmap@latest update",
|
|
136
136
|
"```",
|
|
137
137
|
"",
|
|
138
|
-
|
|
138
|
+
`## [Dynamic SVG (by Vercel)](https://${repoName}.vercel.app/)`,
|
|
139
139
|
"",
|
|
140
140
|
``,
|
|
141
141
|
"",
|
package/package.json
CHANGED
package/scripts/generate.mjs
CHANGED
|
@@ -1,73 +1,191 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
|
-
import { writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { writeFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { resolve, dirname } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import os from "node:os";
|
|
6
7
|
|
|
7
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const root = resolve(__dirname, "..");
|
|
9
10
|
|
|
11
|
+
function getMachineName() {
|
|
12
|
+
try {
|
|
13
|
+
if (process.platform === "darwin") {
|
|
14
|
+
const serial = execSync(
|
|
15
|
+
"ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformSerialNumber/ {print $NF}' | tr -d '\"'",
|
|
16
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] },
|
|
17
|
+
).trim();
|
|
18
|
+
if (serial) return serial;
|
|
19
|
+
} else if (process.platform === "linux") {
|
|
20
|
+
const id = readFileSync("/etc/machine-id", "utf-8").trim();
|
|
21
|
+
if (id) return id.slice(0, 12);
|
|
22
|
+
} else if (process.platform === "win32") {
|
|
23
|
+
const uuid = execSync("wmic csproduct get UUID /value", {
|
|
24
|
+
encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"],
|
|
25
|
+
}).match(/UUID=([^\r\n]+)/)?.[1]?.trim();
|
|
26
|
+
if (uuid) return uuid.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12);
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
return os.hostname().replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
const args = process.argv.slice(2);
|
|
11
33
|
const sinceFlag = args.find((a) => a.startsWith("--since"));
|
|
12
34
|
const untilFlag = args.find((a) => a.startsWith("--until"));
|
|
35
|
+
const nameFlag = args.find((a) => a.startsWith("--name="));
|
|
36
|
+
const dirFlag = args.find((a) => a.startsWith("--dir="));
|
|
37
|
+
const mergeOnly = args.includes("--merge-only");
|
|
38
|
+
const machineName = nameFlag
|
|
39
|
+
? nameFlag.slice("--name=".length)
|
|
40
|
+
: getMachineName();
|
|
13
41
|
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
console.log(`Running: ${cmd}`);
|
|
19
|
-
const raw = execSync(cmd, { encoding: "utf-8", timeout: 300000 });
|
|
20
|
-
const { daily } = JSON.parse(raw);
|
|
21
|
-
|
|
22
|
-
const costs = daily.map((d) => d.totalCost);
|
|
23
|
-
const maxCost = Math.max(...costs);
|
|
24
|
-
|
|
25
|
-
function toLevel(cost) {
|
|
26
|
-
if (cost === 0 || maxCost === 0) return 0;
|
|
27
|
-
const ratio = cost / maxCost;
|
|
42
|
+
function toLevel(cost, max) {
|
|
43
|
+
if (cost === 0 || max === 0) return 0;
|
|
44
|
+
const ratio = cost / max;
|
|
28
45
|
if (ratio <= 0.25) return 1;
|
|
29
46
|
if (ratio <= 0.5) return 2;
|
|
30
47
|
if (ratio <= 0.75) return 3;
|
|
31
48
|
return 4;
|
|
32
49
|
}
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
51
|
+
const outDir = dirFlag
|
|
52
|
+
? resolve(dirFlag.slice("--dir=".length))
|
|
53
|
+
: resolve(root, "public");
|
|
54
|
+
mkdirSync(outDir, { recursive: true });
|
|
55
|
+
|
|
56
|
+
if (!mergeOnly) {
|
|
57
|
+
let cmd = "npx --yes ccusage@latest daily --json";
|
|
58
|
+
if (sinceFlag) cmd += ` ${sinceFlag}`;
|
|
59
|
+
if (untilFlag) cmd += ` ${untilFlag}`;
|
|
60
|
+
|
|
61
|
+
console.log(`Running: ${cmd}`);
|
|
62
|
+
const raw = execSync(cmd, { encoding: "utf-8", timeout: 300000 });
|
|
63
|
+
const { daily } = JSON.parse(raw);
|
|
64
|
+
|
|
65
|
+
const costs = daily.map((d) => d.totalCost);
|
|
66
|
+
const maxCost = Math.max(...costs);
|
|
67
|
+
|
|
68
|
+
// Build a map of existing data
|
|
69
|
+
const dataMap = new Map(daily.map((d) => [d.date, d]));
|
|
70
|
+
|
|
71
|
+
// Fill 365 days (from today back 364 days)
|
|
72
|
+
const today = new Date();
|
|
73
|
+
const activities = [];
|
|
74
|
+
for (let i = 364; i >= 0; i--) {
|
|
75
|
+
const d = new Date(today);
|
|
76
|
+
d.setDate(d.getDate() - i);
|
|
77
|
+
const date = d.toISOString().slice(0, 10);
|
|
78
|
+
const entry = dataMap.get(date);
|
|
79
|
+
if (entry) {
|
|
80
|
+
const cacheReadTokens = entry.cacheReadTokens ?? 0;
|
|
81
|
+
const cacheCreationTokens = entry.cacheCreationTokens ?? 0;
|
|
82
|
+
const cacheTotal = cacheCreationTokens + cacheReadTokens;
|
|
83
|
+
const cacheHitRate = cacheTotal > 0
|
|
84
|
+
? Math.round((cacheReadTokens / cacheTotal) * 100)
|
|
85
|
+
: 0;
|
|
86
|
+
activities.push({
|
|
87
|
+
date,
|
|
88
|
+
count: Math.round(entry.totalCost * 100) / 100,
|
|
89
|
+
level: toLevel(entry.totalCost, maxCost),
|
|
90
|
+
inputTokens: entry.inputTokens,
|
|
91
|
+
outputTokens: entry.outputTokens,
|
|
92
|
+
totalTokens: entry.totalTokens,
|
|
93
|
+
cacheReadTokens,
|
|
94
|
+
cacheCreationTokens,
|
|
95
|
+
cacheHitRate,
|
|
96
|
+
modelsUsed: entry.modelsUsed,
|
|
97
|
+
modelBreakdowns: entry.modelBreakdowns.map((m) => ({
|
|
98
|
+
model: m.modelName,
|
|
99
|
+
cost: Math.round(m.cost * 100) / 100,
|
|
100
|
+
})),
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
activities.push({ date, count: 0, level: 0 });
|
|
104
|
+
}
|
|
66
105
|
}
|
|
106
|
+
|
|
107
|
+
// 1. 컴퓨터별 개별 파일 저장
|
|
108
|
+
const machineFile = resolve(outDir, `data-${machineName}.json`);
|
|
109
|
+
writeFileSync(machineFile, JSON.stringify(activities, null, 2));
|
|
110
|
+
console.log(`Generated ${machineFile} (${activities.length} days)`);
|
|
67
111
|
}
|
|
68
112
|
|
|
69
|
-
|
|
70
|
-
|
|
113
|
+
// 2. 모든 data-{name}.json 파일을 읽어서 합산
|
|
114
|
+
const dataFiles = readdirSync(outDir)
|
|
115
|
+
.filter((f) => f.match(/^data-.+\.json$/))
|
|
116
|
+
.sort()
|
|
117
|
+
.map((f) => resolve(outDir, f));
|
|
118
|
+
|
|
119
|
+
console.log(`Merging ${dataFiles.length} file(s): ${dataFiles.map((f) => f.split("/").pop()).join(", ")}`);
|
|
120
|
+
|
|
121
|
+
const mergeMap = new Map();
|
|
122
|
+
|
|
123
|
+
for (const file of dataFiles) {
|
|
124
|
+
const fileData = JSON.parse(readFileSync(file, "utf-8"));
|
|
125
|
+
for (const entry of fileData) {
|
|
126
|
+
if (!mergeMap.has(entry.date)) {
|
|
127
|
+
mergeMap.set(entry.date, {
|
|
128
|
+
date: entry.date,
|
|
129
|
+
count: 0,
|
|
130
|
+
inputTokens: 0,
|
|
131
|
+
outputTokens: 0,
|
|
132
|
+
totalTokens: 0,
|
|
133
|
+
cacheReadTokens: 0,
|
|
134
|
+
cacheCreationTokens: 0,
|
|
135
|
+
modelsUsed: new Set(),
|
|
136
|
+
modelBreakdowns: new Map(),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const m = mergeMap.get(entry.date);
|
|
140
|
+
m.count = Math.round((m.count + (entry.count ?? 0)) * 100) / 100;
|
|
141
|
+
m.inputTokens += entry.inputTokens ?? 0;
|
|
142
|
+
m.outputTokens += entry.outputTokens ?? 0;
|
|
143
|
+
m.totalTokens += entry.totalTokens ?? 0;
|
|
144
|
+
m.cacheReadTokens += entry.cacheReadTokens ?? 0;
|
|
145
|
+
m.cacheCreationTokens += entry.cacheCreationTokens ?? 0;
|
|
146
|
+
|
|
147
|
+
for (const model of (entry.modelsUsed ?? [])) {
|
|
148
|
+
m.modelsUsed.add(model);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const mb of (entry.modelBreakdowns ?? [])) {
|
|
152
|
+
const prev = m.modelBreakdowns.get(mb.model) ?? 0;
|
|
153
|
+
m.modelBreakdowns.set(mb.model, Math.round((prev + mb.cost) * 100) / 100);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 합산된 최대값 기준으로 level 재계산
|
|
159
|
+
const allCounts = [...mergeMap.values()].map((m) => m.count);
|
|
160
|
+
const mergedMax = Math.max(...allCounts);
|
|
161
|
+
|
|
162
|
+
const merged = [...mergeMap.values()]
|
|
163
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
164
|
+
.map((m) => {
|
|
165
|
+
const cacheTotal = m.cacheReadTokens + m.cacheCreationTokens;
|
|
166
|
+
const result = {
|
|
167
|
+
date: m.date,
|
|
168
|
+
count: m.count,
|
|
169
|
+
level: toLevel(m.count, mergedMax),
|
|
170
|
+
};
|
|
171
|
+
if (m.inputTokens || m.outputTokens || m.totalTokens) {
|
|
172
|
+
result.inputTokens = m.inputTokens;
|
|
173
|
+
result.outputTokens = m.outputTokens;
|
|
174
|
+
result.totalTokens = m.totalTokens;
|
|
175
|
+
}
|
|
176
|
+
if (cacheTotal > 0) {
|
|
177
|
+
result.cacheHitRate = Math.round((m.cacheReadTokens / cacheTotal) * 100);
|
|
178
|
+
}
|
|
179
|
+
if (m.modelsUsed.size > 0) {
|
|
180
|
+
result.modelsUsed = [...m.modelsUsed];
|
|
181
|
+
}
|
|
182
|
+
const mbs = [...m.modelBreakdowns.entries()].map(([model, cost]) => ({ model, cost }));
|
|
183
|
+
if (mbs.length > 0) {
|
|
184
|
+
result.modelBreakdowns = mbs;
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
});
|
|
188
|
+
|
|
71
189
|
const outPath = resolve(outDir, "data.json");
|
|
72
|
-
writeFileSync(outPath, JSON.stringify(
|
|
73
|
-
console.log(`
|
|
190
|
+
writeFileSync(outPath, JSON.stringify(merged, null, 2));
|
|
191
|
+
console.log(`Merged into ${outPath} (${merged.length} days)`);
|