triflux 4.0.3 → 4.0.5
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/bin/triflux.mjs +10 -3
- package/package.json +1 -1
- package/scripts/mcp-check.mjs +1 -0
- package/scripts/notion-read.mjs +2 -1
- package/scripts/token-snapshot.mjs +561 -0
package/bin/triflux.mjs
CHANGED
|
@@ -249,7 +249,7 @@ function isDevUpdateRequested(argv = process.argv) {
|
|
|
249
249
|
function checkShellAvailable(shell) {
|
|
250
250
|
const cmds = { bash: "bash --version", cmd: "cmd /c echo ok", pwsh: "pwsh -NoProfile -c echo ok" };
|
|
251
251
|
try {
|
|
252
|
-
execSync(cmds[shell], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
|
|
252
|
+
execSync(cmds[shell], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
|
|
253
253
|
return true;
|
|
254
254
|
} catch { return false; }
|
|
255
255
|
}
|
|
@@ -1529,6 +1529,7 @@ function cmdUpdate() {
|
|
|
1529
1529
|
encoding: "utf8",
|
|
1530
1530
|
timeout: 10000,
|
|
1531
1531
|
stdio: ["pipe", "pipe", "ignore"],
|
|
1532
|
+
windowsHide: true,
|
|
1532
1533
|
});
|
|
1533
1534
|
if (npmList.includes("triflux")) installMode = "npm-global";
|
|
1534
1535
|
} catch {}
|
|
@@ -1560,6 +1561,7 @@ function cmdUpdate() {
|
|
|
1560
1561
|
encoding: "utf8",
|
|
1561
1562
|
timeout: 30000,
|
|
1562
1563
|
cwd: gitDir,
|
|
1564
|
+
windowsHide: true,
|
|
1563
1565
|
}).trim();
|
|
1564
1566
|
ok(`git pull — ${result}`);
|
|
1565
1567
|
updated = true;
|
|
@@ -1577,6 +1579,7 @@ function cmdUpdate() {
|
|
|
1577
1579
|
encoding: "utf8",
|
|
1578
1580
|
timeout: 90000,
|
|
1579
1581
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1582
|
+
windowsHide: true,
|
|
1580
1583
|
}).trim().split(/\r?\n/)[0];
|
|
1581
1584
|
} catch (retryErr) {
|
|
1582
1585
|
// Windows: 자기 자신의 파일 잠금으로 첫 시도 실패 가능 → 1회 재시도
|
|
@@ -1585,6 +1588,7 @@ function cmdUpdate() {
|
|
|
1585
1588
|
encoding: "utf8",
|
|
1586
1589
|
timeout: 90000,
|
|
1587
1590
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1591
|
+
windowsHide: true,
|
|
1588
1592
|
}).trim().split(/\r?\n/)[0];
|
|
1589
1593
|
}
|
|
1590
1594
|
ok(`${npmCmd} — ${result || "완료"}`);
|
|
@@ -1598,6 +1602,7 @@ function cmdUpdate() {
|
|
|
1598
1602
|
timeout: 60000,
|
|
1599
1603
|
cwd: process.cwd(),
|
|
1600
1604
|
stdio: ["pipe", "pipe", "ignore"],
|
|
1605
|
+
windowsHide: true,
|
|
1601
1606
|
}).trim().split(/\r?\n/)[0];
|
|
1602
1607
|
ok(`${isDev ? "npm install triflux@dev" : "npm update triflux"} — ${result || "완료"}`);
|
|
1603
1608
|
updated = true;
|
|
@@ -1608,6 +1613,7 @@ function cmdUpdate() {
|
|
|
1608
1613
|
encoding: "utf8",
|
|
1609
1614
|
timeout: 30000,
|
|
1610
1615
|
cwd: PKG_ROOT,
|
|
1616
|
+
windowsHide: true,
|
|
1611
1617
|
}).trim();
|
|
1612
1618
|
ok(`git pull — ${result}`);
|
|
1613
1619
|
updated = true;
|
|
@@ -1799,6 +1805,7 @@ function checkForUpdate() {
|
|
|
1799
1805
|
encoding: "utf8",
|
|
1800
1806
|
timeout: 5000,
|
|
1801
1807
|
stdio: ["pipe", "pipe", "ignore"],
|
|
1808
|
+
windowsHide: true,
|
|
1802
1809
|
}).trim();
|
|
1803
1810
|
|
|
1804
1811
|
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
@@ -2007,11 +2014,11 @@ function autoRegisterMcp(mcpUrl) {
|
|
|
2007
2014
|
if (which("codex")) {
|
|
2008
2015
|
try {
|
|
2009
2016
|
// 이미 등록됐는지 확인
|
|
2010
|
-
const list = execSync("codex mcp list 2>&1", { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
|
|
2017
|
+
const list = execSync("codex mcp list 2>&1", { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
2011
2018
|
if (list.includes("tfx-hub")) {
|
|
2012
2019
|
ok("Codex: 이미 등록됨");
|
|
2013
2020
|
} else {
|
|
2014
|
-
execFileSync("codex", ["mcp", "add", "tfx-hub", "--url", mcpUrl], { timeout: 10000, stdio: "ignore" });
|
|
2021
|
+
execFileSync("codex", ["mcp", "add", "tfx-hub", "--url", mcpUrl], { timeout: 10000, stdio: "ignore", windowsHide: true });
|
|
2015
2022
|
ok("Codex: MCP 등록 완료");
|
|
2016
2023
|
}
|
|
2017
2024
|
} catch {
|
package/package.json
CHANGED
package/scripts/mcp-check.mjs
CHANGED
package/scripts/notion-read.mjs
CHANGED
|
@@ -85,7 +85,7 @@ function getNotionMcpClis(useGuest) {
|
|
|
85
85
|
function cliExists(name) {
|
|
86
86
|
try {
|
|
87
87
|
const cmd = process.platform === "win32" ? `where ${name} 2>nul` : `which ${name} 2>/dev/null`;
|
|
88
|
-
const result = execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
|
|
88
|
+
const result = execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
|
|
89
89
|
return !!result.trim();
|
|
90
90
|
} catch {
|
|
91
91
|
return false;
|
|
@@ -205,6 +205,7 @@ function runWithCli(cliType, prompt, timeout, runMode = 'fg') {
|
|
|
205
205
|
maxBuffer: 10 * 1024 * 1024,
|
|
206
206
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
207
207
|
cwd: process.cwd(),
|
|
208
|
+
windowsHide: true,
|
|
208
209
|
});
|
|
209
210
|
} catch (e) {
|
|
210
211
|
exitCode = e.status || (e.killed ? 124 : 1);
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cx-auto Token Savings Tracker
|
|
4
|
+
* 스냅샷 기반 Codex/Gemini 토큰 사용량 추적 + Claude 절약액 계산
|
|
5
|
+
*
|
|
6
|
+
* 사용법:
|
|
7
|
+
* node token-snapshot.mjs snapshot <label>
|
|
8
|
+
* node token-snapshot.mjs diff <pre> <post> [--agent <agent>] [--cli <cli>] [--id <id>]
|
|
9
|
+
* node token-snapshot.mjs report <session-id|all>
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, statSync } from "node:fs";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
|
|
16
|
+
const HOME = homedir();
|
|
17
|
+
const STATE_DIR = join(HOME, ".omc", "state", "cx-auto-tokens");
|
|
18
|
+
const SNAPSHOTS_DIR = join(STATE_DIR, "snapshots");
|
|
19
|
+
const DIFFS_DIR = join(STATE_DIR, "diffs");
|
|
20
|
+
const REPORTS_DIR = join(STATE_DIR, "reports");
|
|
21
|
+
|
|
22
|
+
// ── 가격 모델 ($/MTok, 비캐시 기준, 보수적 추정) ──
|
|
23
|
+
const PRICING = {
|
|
24
|
+
claude_sonnet: { input: 3, output: 15 },
|
|
25
|
+
claude_opus: { input: 15, output: 75 },
|
|
26
|
+
codex: { input: 0, output: 0 },
|
|
27
|
+
gemini_flash: { input: 0.10, output: 0.40 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Claude 캐시 가격 ($/MTok) — 오케스트레이션 비용 정밀 계산용
|
|
31
|
+
const CLAUDE_CACHE_PRICING = {
|
|
32
|
+
claude_sonnet: { cache_write: 3.75, cache_read: 0.30 },
|
|
33
|
+
claude_opus: { cache_write: 18.75, cache_read: 1.50 },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// 에이전트 → Claude 대체 모델
|
|
37
|
+
const AGENT_CLAUDE_MAP = {
|
|
38
|
+
executor: "claude_sonnet",
|
|
39
|
+
debugger: "claude_sonnet",
|
|
40
|
+
"build-fixer": "claude_sonnet",
|
|
41
|
+
"code-reviewer": "claude_sonnet",
|
|
42
|
+
"security-reviewer": "claude_sonnet",
|
|
43
|
+
"quality-reviewer": "claude_sonnet",
|
|
44
|
+
designer: "claude_sonnet",
|
|
45
|
+
writer: "claude_sonnet",
|
|
46
|
+
scientist: "claude_sonnet",
|
|
47
|
+
"document-specialist": "claude_sonnet",
|
|
48
|
+
"deep-executor": "claude_opus",
|
|
49
|
+
architect: "claude_opus",
|
|
50
|
+
planner: "claude_opus",
|
|
51
|
+
critic: "claude_opus",
|
|
52
|
+
analyst: "claude_opus",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// CLI → 실제 비용 모델
|
|
56
|
+
const CLI_COST_MAP = {
|
|
57
|
+
codex: "codex",
|
|
58
|
+
gemini: "gemini_flash",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ── 유틸리티 ──
|
|
62
|
+
function readJson(filePath, fallback = null) {
|
|
63
|
+
if (!existsSync(filePath)) return fallback;
|
|
64
|
+
try { return JSON.parse(readFileSync(filePath, "utf-8")); }
|
|
65
|
+
catch { return fallback; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeJsonSafe(filePath, data) {
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
71
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
72
|
+
} catch (e) { console.error(`[token-snapshot] 쓰기 실패: ${e.message}`); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatTokenCount(n) {
|
|
76
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
77
|
+
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
|
78
|
+
return String(n);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatCost(dollars) {
|
|
82
|
+
if (dollars < 0.01) return "$0.00";
|
|
83
|
+
return `$${dollars.toFixed(2)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function calcCost(tokens, pricing) {
|
|
87
|
+
return (tokens.input * pricing.input + tokens.output * pricing.output) / 1_000_000;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Codex 세션 스캔 ──
|
|
91
|
+
// ~/.codex/sessions/YYYY/MM/DD/*.jsonl 에서 파일별 토큰 합산
|
|
92
|
+
function scanCodexSessions() {
|
|
93
|
+
const sessions = {};
|
|
94
|
+
const baseDir = join(HOME, ".codex", "sessions");
|
|
95
|
+
if (!existsSync(baseDir)) return sessions;
|
|
96
|
+
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
for (let d = 0; d < 30; d++) {
|
|
99
|
+
const date = new Date(now - d * 86_400_000);
|
|
100
|
+
const dayDir = join(
|
|
101
|
+
baseDir,
|
|
102
|
+
String(date.getFullYear()),
|
|
103
|
+
String(date.getMonth() + 1).padStart(2, "0"),
|
|
104
|
+
String(date.getDate()).padStart(2, "0"),
|
|
105
|
+
);
|
|
106
|
+
if (!existsSync(dayDir)) continue;
|
|
107
|
+
|
|
108
|
+
let files;
|
|
109
|
+
try { files = readdirSync(dayDir).filter(f => f.endsWith(".jsonl")); }
|
|
110
|
+
catch { continue; }
|
|
111
|
+
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
const filepath = join(dayDir, file);
|
|
114
|
+
try {
|
|
115
|
+
const stat = statSync(filepath);
|
|
116
|
+
const content = readFileSync(filepath, "utf-8");
|
|
117
|
+
const lines = content.trim().split("\n").reverse();
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
try {
|
|
120
|
+
const evt = JSON.parse(line);
|
|
121
|
+
const t = evt?.payload?.info?.total_token_usage;
|
|
122
|
+
if (t) {
|
|
123
|
+
sessions[filepath] = {
|
|
124
|
+
input: t.input_tokens || t.input || 0,
|
|
125
|
+
output: t.output_tokens || t.output || 0,
|
|
126
|
+
total: t.total_tokens || t.total || ((t.input_tokens || t.input || 0) + (t.output_tokens || t.output || 0)),
|
|
127
|
+
timestamp: evt.timestamp || stat.mtimeMs,
|
|
128
|
+
};
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
} catch { /* 라인 파싱 실패 */ }
|
|
132
|
+
}
|
|
133
|
+
// 토큰 이벤트 없는 파일도 기록 (존재 추적용)
|
|
134
|
+
if (!sessions[filepath]) {
|
|
135
|
+
sessions[filepath] = { input: 0, output: 0, total: 0, timestamp: stat.mtimeMs };
|
|
136
|
+
}
|
|
137
|
+
} catch { /* 파일 읽기 실패 */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return sessions;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Gemini 세션 스캔 ──
|
|
144
|
+
// ~/.gemini/tmp/*/chats/*.json 에서 파일별 토큰 합산
|
|
145
|
+
function scanGeminiSessions() {
|
|
146
|
+
const sessions = {};
|
|
147
|
+
const tmpDir = join(HOME, ".gemini", "tmp");
|
|
148
|
+
if (!existsSync(tmpDir)) return sessions;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const dirs = readdirSync(tmpDir);
|
|
152
|
+
for (const dir of dirs) {
|
|
153
|
+
const chatsDir = join(tmpDir, dir, "chats");
|
|
154
|
+
if (!existsSync(chatsDir)) continue;
|
|
155
|
+
|
|
156
|
+
let files;
|
|
157
|
+
try { files = readdirSync(chatsDir).filter(f => f.endsWith(".json")); }
|
|
158
|
+
catch { continue; }
|
|
159
|
+
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
const filepath = join(chatsDir, file);
|
|
162
|
+
try {
|
|
163
|
+
const data = JSON.parse(readFileSync(filepath, "utf-8"));
|
|
164
|
+
let input = 0, output = 0, model = "unknown";
|
|
165
|
+
for (const msg of data.messages || []) {
|
|
166
|
+
if (msg.tokens) {
|
|
167
|
+
input += msg.tokens.input || 0;
|
|
168
|
+
output += msg.tokens.output || 0;
|
|
169
|
+
}
|
|
170
|
+
if (msg.model) model = msg.model;
|
|
171
|
+
}
|
|
172
|
+
sessions[filepath] = {
|
|
173
|
+
input, output, total: input + output,
|
|
174
|
+
model, lastUpdated: data.lastUpdated || null,
|
|
175
|
+
};
|
|
176
|
+
} catch { /* 무시 */ }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch { /* 무시 */ }
|
|
180
|
+
return sessions;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Claude 세션 스캔 ──
|
|
184
|
+
// ~/.claude/projects/*/*.jsonl 에서 requestId별 마지막 이벤트의 usage 합산
|
|
185
|
+
function scanClaudeSessions() {
|
|
186
|
+
const sessions = {};
|
|
187
|
+
const projectsDir = join(HOME, ".claude", "projects");
|
|
188
|
+
if (!existsSync(projectsDir)) return sessions;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const projects = readdirSync(projectsDir);
|
|
192
|
+
for (const proj of projects) {
|
|
193
|
+
const projDir = join(projectsDir, proj);
|
|
194
|
+
let stat;
|
|
195
|
+
try { stat = statSync(projDir); } catch { continue; }
|
|
196
|
+
if (!stat.isDirectory()) continue;
|
|
197
|
+
|
|
198
|
+
let files;
|
|
199
|
+
try { files = readdirSync(projDir).filter(f => f.endsWith(".jsonl")); }
|
|
200
|
+
catch { continue; }
|
|
201
|
+
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
const filepath = join(projDir, file);
|
|
204
|
+
try {
|
|
205
|
+
const fileStat = statSync(filepath);
|
|
206
|
+
// 최근 7일 내 파일만 스캔 (성능)
|
|
207
|
+
if (Date.now() - fileStat.mtimeMs > 7 * 86_400_000) continue;
|
|
208
|
+
|
|
209
|
+
const content = readFileSync(filepath, "utf-8");
|
|
210
|
+
const lines = content.trim().split("\n");
|
|
211
|
+
|
|
212
|
+
// requestId별 마지막 이벤트의 usage만 수집 (중복 방지)
|
|
213
|
+
const reqUsage = {};
|
|
214
|
+
let model = "unknown";
|
|
215
|
+
|
|
216
|
+
for (const line of lines) {
|
|
217
|
+
try {
|
|
218
|
+
const evt = JSON.parse(line);
|
|
219
|
+
if (evt.type !== "assistant") continue;
|
|
220
|
+
const msg = evt.message;
|
|
221
|
+
if (!msg?.usage) continue;
|
|
222
|
+
|
|
223
|
+
const reqId = evt.requestId || msg.id;
|
|
224
|
+
if (!reqId) continue;
|
|
225
|
+
if (msg.model) model = msg.model;
|
|
226
|
+
|
|
227
|
+
reqUsage[reqId] = {
|
|
228
|
+
input: msg.usage.input_tokens || 0,
|
|
229
|
+
output: msg.usage.output_tokens || 0,
|
|
230
|
+
cache_creation: msg.usage.cache_creation_input_tokens || 0,
|
|
231
|
+
cache_read: msg.usage.cache_read_input_tokens || 0,
|
|
232
|
+
};
|
|
233
|
+
} catch { /* 라인 파싱 실패 */ }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// requestId별 usage 합산
|
|
237
|
+
let input = 0, output = 0, cache_creation = 0, cache_read = 0;
|
|
238
|
+
for (const u of Object.values(reqUsage)) {
|
|
239
|
+
input += u.input;
|
|
240
|
+
output += u.output;
|
|
241
|
+
cache_creation += u.cache_creation;
|
|
242
|
+
cache_read += u.cache_read;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const total = input + output + cache_creation + cache_read;
|
|
246
|
+
if (total > 0) {
|
|
247
|
+
sessions[filepath] = {
|
|
248
|
+
input, output, cache_creation, cache_read,
|
|
249
|
+
total, model,
|
|
250
|
+
timestamp: fileStat.mtimeMs,
|
|
251
|
+
requests: Object.keys(reqUsage).length,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
} catch { /* 파일 읽기 실패 */ }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch { /* 무시 */ }
|
|
258
|
+
return sessions;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── 스냅샷 캡처 ──
|
|
262
|
+
function takeSnapshot(label) {
|
|
263
|
+
const codex = scanCodexSessions();
|
|
264
|
+
const gemini = scanGeminiSessions();
|
|
265
|
+
const claude = scanClaudeSessions();
|
|
266
|
+
const snapshot = {
|
|
267
|
+
label,
|
|
268
|
+
timestamp: new Date().toISOString(),
|
|
269
|
+
codex,
|
|
270
|
+
gemini,
|
|
271
|
+
claude,
|
|
272
|
+
summary: {
|
|
273
|
+
codex_files: Object.keys(codex).length,
|
|
274
|
+
gemini_files: Object.keys(gemini).length,
|
|
275
|
+
claude_files: Object.keys(claude).length,
|
|
276
|
+
codex_total: Object.values(codex).reduce((s, v) => s + v.total, 0),
|
|
277
|
+
gemini_total: Object.values(gemini).reduce((s, v) => s + v.total, 0),
|
|
278
|
+
claude_total: Object.values(claude).reduce((s, v) => s + v.total, 0),
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const outPath = join(SNAPSHOTS_DIR, `${label}.json`);
|
|
283
|
+
writeJsonSafe(outPath, snapshot);
|
|
284
|
+
console.log(`[snapshot] ${label} 저장 완료`);
|
|
285
|
+
console.log(` Codex: ${snapshot.summary.codex_files}파일, ${formatTokenCount(snapshot.summary.codex_total)} tokens`);
|
|
286
|
+
console.log(` Gemini: ${snapshot.summary.gemini_files}파일, ${formatTokenCount(snapshot.summary.gemini_total)} tokens`);
|
|
287
|
+
console.log(` Claude: ${snapshot.summary.claude_files}파일, ${formatTokenCount(snapshot.summary.claude_total)} tokens`);
|
|
288
|
+
return snapshot;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── 두 스냅샷 간 Diff ──
|
|
292
|
+
function computeDiff(preLabel, postLabel, options = {}) {
|
|
293
|
+
const pre = readJson(join(SNAPSHOTS_DIR, `${preLabel}.json`));
|
|
294
|
+
const post = readJson(join(SNAPSHOTS_DIR, `${postLabel}.json`));
|
|
295
|
+
if (!pre || !post) {
|
|
296
|
+
console.error(`[diff] 스냅샷 없음: ${!pre ? preLabel : postLabel}`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const delta = { codex: {}, gemini: {}, claude: {}, total: { input: 0, output: 0, total: 0 } };
|
|
301
|
+
|
|
302
|
+
// Claude diff — 오케스트레이션 오버헤드 측정
|
|
303
|
+
const preClaude = pre.claude || {};
|
|
304
|
+
const postClaude = post.claude || {};
|
|
305
|
+
const claudeOverhead = { input: 0, output: 0, cache_creation: 0, cache_read: 0, total: 0 };
|
|
306
|
+
for (const [fp, postData] of Object.entries(postClaude)) {
|
|
307
|
+
const preData = preClaude[fp];
|
|
308
|
+
if (!preData) {
|
|
309
|
+
if (postData.total > 0) {
|
|
310
|
+
delta.claude[fp] = { ...postData, type: "new" };
|
|
311
|
+
claudeOverhead.input += postData.input || 0;
|
|
312
|
+
claudeOverhead.output += postData.output || 0;
|
|
313
|
+
claudeOverhead.cache_creation += postData.cache_creation || 0;
|
|
314
|
+
claudeOverhead.cache_read += postData.cache_read || 0;
|
|
315
|
+
claudeOverhead.total += postData.total;
|
|
316
|
+
}
|
|
317
|
+
} else if (postData.total > preData.total) {
|
|
318
|
+
const d = {
|
|
319
|
+
input: (postData.input || 0) - (preData.input || 0),
|
|
320
|
+
output: (postData.output || 0) - (preData.output || 0),
|
|
321
|
+
cache_creation: (postData.cache_creation || 0) - (preData.cache_creation || 0),
|
|
322
|
+
cache_read: (postData.cache_read || 0) - (preData.cache_read || 0),
|
|
323
|
+
total: postData.total - preData.total,
|
|
324
|
+
type: "increased",
|
|
325
|
+
};
|
|
326
|
+
delta.claude[fp] = d;
|
|
327
|
+
claudeOverhead.input += d.input;
|
|
328
|
+
claudeOverhead.output += d.output;
|
|
329
|
+
claudeOverhead.cache_creation += d.cache_creation;
|
|
330
|
+
claudeOverhead.cache_read += d.cache_read;
|
|
331
|
+
claudeOverhead.total += d.total;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
delta.claudeOverhead = claudeOverhead;
|
|
335
|
+
|
|
336
|
+
// Codex diff — 새 파일 또는 증가분 감지
|
|
337
|
+
for (const [fp, postData] of Object.entries(post.codex)) {
|
|
338
|
+
const preData = pre.codex[fp];
|
|
339
|
+
if (!preData) {
|
|
340
|
+
if (postData.total > 0) {
|
|
341
|
+
delta.codex[fp] = { ...postData, type: "new" };
|
|
342
|
+
delta.total.input += postData.input;
|
|
343
|
+
delta.total.output += postData.output;
|
|
344
|
+
delta.total.total += postData.total;
|
|
345
|
+
}
|
|
346
|
+
} else if (postData.total > preData.total) {
|
|
347
|
+
const d = {
|
|
348
|
+
input: postData.input - preData.input,
|
|
349
|
+
output: postData.output - preData.output,
|
|
350
|
+
total: postData.total - preData.total,
|
|
351
|
+
type: "increased",
|
|
352
|
+
};
|
|
353
|
+
delta.codex[fp] = d;
|
|
354
|
+
delta.total.input += d.input;
|
|
355
|
+
delta.total.output += d.output;
|
|
356
|
+
delta.total.total += d.total;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Gemini diff
|
|
361
|
+
for (const [fp, postData] of Object.entries(post.gemini)) {
|
|
362
|
+
const preData = pre.gemini[fp];
|
|
363
|
+
if (!preData) {
|
|
364
|
+
if (postData.total > 0) {
|
|
365
|
+
delta.gemini[fp] = { ...postData, type: "new" };
|
|
366
|
+
delta.total.input += postData.input;
|
|
367
|
+
delta.total.output += postData.output;
|
|
368
|
+
delta.total.total += postData.total;
|
|
369
|
+
}
|
|
370
|
+
} else if (postData.total > preData.total) {
|
|
371
|
+
const d = {
|
|
372
|
+
input: postData.input - preData.input,
|
|
373
|
+
output: postData.output - preData.output,
|
|
374
|
+
total: postData.total - preData.total,
|
|
375
|
+
model: postData.model,
|
|
376
|
+
type: "increased",
|
|
377
|
+
};
|
|
378
|
+
delta.gemini[fp] = d;
|
|
379
|
+
delta.total.input += d.input;
|
|
380
|
+
delta.total.output += d.output;
|
|
381
|
+
delta.total.total += d.total;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 절약 계산 (Claude 오버헤드 반영)
|
|
386
|
+
const agent = options.agent || "executor";
|
|
387
|
+
const cli = options.cli || "codex";
|
|
388
|
+
const savings = estimateSavings(delta.total, agent, cli, claudeOverhead);
|
|
389
|
+
|
|
390
|
+
const result = {
|
|
391
|
+
preLabel, postLabel, agent, cli,
|
|
392
|
+
timestamp: new Date().toISOString(),
|
|
393
|
+
delta,
|
|
394
|
+
savings,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const diffId = options.id || `${preLabel}__${postLabel}`;
|
|
398
|
+
writeJsonSafe(join(DIFFS_DIR, `${diffId}.json`), result);
|
|
399
|
+
|
|
400
|
+
// 누적 절약액 업데이트 (HUD ts: 표시용)
|
|
401
|
+
const accPath = join(STATE_DIR, "savings-total.json");
|
|
402
|
+
const acc = readJson(accPath, { totalSaved: 0, totalClaudeCost: 0, totalActualCost: 0, diffCount: 0 });
|
|
403
|
+
acc.totalSaved += savings.saved;
|
|
404
|
+
acc.totalClaudeCost += savings.claudeCost;
|
|
405
|
+
acc.totalActualCost += savings.actualCost;
|
|
406
|
+
acc.diffCount += 1;
|
|
407
|
+
acc.lastUpdated = new Date().toISOString();
|
|
408
|
+
writeJsonSafe(accPath, acc);
|
|
409
|
+
|
|
410
|
+
console.log(`[diff] ${preLabel} → ${postLabel}`);
|
|
411
|
+
console.log(` Agent: ${agent} (${cli})`);
|
|
412
|
+
console.log(` 외부 CLI 토큰: ${formatTokenCount(delta.total.input)} input, ${formatTokenCount(delta.total.output)} output`);
|
|
413
|
+
console.log(` Claude 오케스트레이션: ${formatTokenCount(claudeOverhead.total)} tokens (오버헤드 ${formatCost(savings.overheadCost)})`);
|
|
414
|
+
console.log(` Claude-only 비용(추정): ${formatCost(savings.claudeCost)}`);
|
|
415
|
+
console.log(` 실제 비용: ${formatCost(savings.actualCost)} (외부 CLI ${formatCost(savings.cliCost)} + 오케스트레이션 ${formatCost(savings.overheadCost)})`);
|
|
416
|
+
console.log(` 순절약: ${formatCost(savings.saved)}`);
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── 절약액 계산 ──
|
|
421
|
+
// claudeOverhead: { input, output, cache_creation, cache_read } — 오케스트레이션에 쓴 Claude 토큰
|
|
422
|
+
function estimateSavings(tokens, agent, cli, claudeOverhead = null) {
|
|
423
|
+
const claudeModel = AGENT_CLAUDE_MAP[agent] || "claude_sonnet";
|
|
424
|
+
const claudePricing = PRICING[claudeModel];
|
|
425
|
+
// Claude가 직접 했다면의 추정 비용
|
|
426
|
+
const claudeCost = calcCost(tokens, claudePricing);
|
|
427
|
+
|
|
428
|
+
const costModel = CLI_COST_MAP[cli] || "codex";
|
|
429
|
+
const actualPricing = PRICING[costModel];
|
|
430
|
+
// 외부 CLI 실비용
|
|
431
|
+
const cliCost = calcCost(tokens, actualPricing);
|
|
432
|
+
|
|
433
|
+
// Claude 오케스트레이션 오버헤드 비용 계산
|
|
434
|
+
let overheadCost = 0;
|
|
435
|
+
if (claudeOverhead && claudeOverhead.total > 0) {
|
|
436
|
+
// 일반 input/output 비용
|
|
437
|
+
overheadCost += calcCost(
|
|
438
|
+
{ input: claudeOverhead.input, output: claudeOverhead.output },
|
|
439
|
+
claudePricing,
|
|
440
|
+
);
|
|
441
|
+
// 캐시 비용 (cache_creation은 write 가격, cache_read는 read 가격)
|
|
442
|
+
const cachePricing = CLAUDE_CACHE_PRICING[claudeModel] || CLAUDE_CACHE_PRICING.claude_sonnet;
|
|
443
|
+
overheadCost += (claudeOverhead.cache_creation * cachePricing.cache_write) / 1_000_000;
|
|
444
|
+
overheadCost += (claudeOverhead.cache_read * cachePricing.cache_read) / 1_000_000;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 실제 총비용 = 외부 CLI 비용 + Claude 오케스트레이션 비용
|
|
448
|
+
const actualCost = cliCost + overheadCost;
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
claudeModel,
|
|
452
|
+
claudeCost,
|
|
453
|
+
actualModel: costModel,
|
|
454
|
+
cliCost,
|
|
455
|
+
overheadCost,
|
|
456
|
+
actualCost,
|
|
457
|
+
saved: claudeCost - actualCost,
|
|
458
|
+
tokens: { ...tokens },
|
|
459
|
+
orchestration: claudeOverhead ? { ...claudeOverhead } : null,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── 종합 보고서 ──
|
|
464
|
+
function generateReport(sessionId) {
|
|
465
|
+
if (!existsSync(DIFFS_DIR)) {
|
|
466
|
+
console.error("[report] diff 데이터 없음");
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const files = readdirSync(DIFFS_DIR).filter(f => f.endsWith(".json"));
|
|
471
|
+
const diffs = [];
|
|
472
|
+
for (const file of files) {
|
|
473
|
+
const data = readJson(join(DIFFS_DIR, file));
|
|
474
|
+
if (!data) continue;
|
|
475
|
+
if (sessionId === "all" || file.includes(sessionId)) {
|
|
476
|
+
diffs.push(data);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (diffs.length === 0) {
|
|
481
|
+
console.log(`[report] ${sessionId}에 해당하는 diff 없음`);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let totalClaudeCost = 0, totalActualCost = 0, totalSaved = 0, totalOverhead = 0;
|
|
486
|
+
const rows = diffs.map((d, i) => {
|
|
487
|
+
const s = d.savings;
|
|
488
|
+
totalClaudeCost += s.claudeCost;
|
|
489
|
+
totalActualCost += s.actualCost;
|
|
490
|
+
totalSaved += s.saved;
|
|
491
|
+
totalOverhead += s.overheadCost || 0;
|
|
492
|
+
const overhead = s.overheadCost ? formatCost(s.overheadCost) : "-";
|
|
493
|
+
return `| ${i + 1} | ${d.preLabel}→${d.postLabel} | ${d.agent} | ${d.cli} | ${formatTokenCount(s.tokens.input)} | ${formatTokenCount(s.tokens.output)} | ${formatCost(s.claudeCost)} | ${overhead} | ${formatCost(s.actualCost)} | ${formatCost(s.saved)} |`;
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const report = [
|
|
497
|
+
"### Token Savings Report",
|
|
498
|
+
"",
|
|
499
|
+
"| # | 서브태스크 | Agent | CLI | Input | Output | Claude-only(추정) | 오케스트레이션 | 실제 비용 | 순절약 |",
|
|
500
|
+
"|---|----------|-------|-----|-------|--------|------------------|-------------|---------|--------|",
|
|
501
|
+
...rows,
|
|
502
|
+
"",
|
|
503
|
+
`**순절약: ${formatCost(totalSaved)}** (Claude-only 추정 ${formatCost(totalClaudeCost)}, 실제 ${formatCost(totalActualCost)}, 오케스트레이션 ${formatCost(totalOverhead)})`,
|
|
504
|
+
].join("\n");
|
|
505
|
+
|
|
506
|
+
console.log(report);
|
|
507
|
+
|
|
508
|
+
const reportData = {
|
|
509
|
+
sessionId,
|
|
510
|
+
timestamp: new Date().toISOString(),
|
|
511
|
+
diffs: diffs.map(d => ({
|
|
512
|
+
...d.savings, agent: d.agent, cli: d.cli,
|
|
513
|
+
labels: `${d.preLabel}→${d.postLabel}`,
|
|
514
|
+
})),
|
|
515
|
+
totals: { claudeCost: totalClaudeCost, actualCost: totalActualCost, saved: totalSaved },
|
|
516
|
+
markdown: report,
|
|
517
|
+
};
|
|
518
|
+
writeJsonSafe(join(REPORTS_DIR, `${sessionId}.json`), reportData);
|
|
519
|
+
return reportData;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ── CLI 핸들러 ──
|
|
523
|
+
const [,, command, ...args] = process.argv;
|
|
524
|
+
|
|
525
|
+
switch (command) {
|
|
526
|
+
case "snapshot": {
|
|
527
|
+
const label = args[0];
|
|
528
|
+
if (!label) { console.error("사용법: token-snapshot.mjs snapshot <label>"); process.exit(1); }
|
|
529
|
+
takeSnapshot(label);
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
case "diff": {
|
|
533
|
+
const [preLabel, postLabel, ...rest] = args;
|
|
534
|
+
if (!preLabel || !postLabel) {
|
|
535
|
+
console.error("사용법: token-snapshot.mjs diff <pre> <post> [--agent X] [--cli Y] [--id Z]");
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
const options = {};
|
|
539
|
+
for (let i = 0; i < rest.length; i++) {
|
|
540
|
+
if (rest[i] === "--agent" && rest[i + 1]) options.agent = rest[++i];
|
|
541
|
+
else if (rest[i] === "--cli" && rest[i + 1]) options.cli = rest[++i];
|
|
542
|
+
else if (rest[i] === "--id" && rest[i + 1]) options.id = rest[++i];
|
|
543
|
+
}
|
|
544
|
+
computeDiff(preLabel, postLabel, options);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
case "report": {
|
|
548
|
+
const sessionId = args[0] || "all";
|
|
549
|
+
generateReport(sessionId);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
default:
|
|
553
|
+
console.log(`cx-auto Token Savings Tracker
|
|
554
|
+
|
|
555
|
+
사용법:
|
|
556
|
+
node token-snapshot.mjs snapshot <label> 스냅샷 캡처
|
|
557
|
+
node token-snapshot.mjs diff <pre> <post> 두 스냅샷 비교
|
|
558
|
+
[--agent <agent>] [--cli <cli>] [--id <id>]
|
|
559
|
+
node token-snapshot.mjs report <session-id> 종합 보고서 생성
|
|
560
|
+
(session-id 대신 "all"로 전체 보고서)`);
|
|
561
|
+
}
|