tokenphage 2026.7.0-BETA

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/src/parser.js ADDED
@@ -0,0 +1,63 @@
1
+ import { glob } from 'glob';
2
+ import { homedir } from 'os';
3
+ import { parseClaudeFiles } from './parsers/claude.js';
4
+ import { parseCodexFiles } from './parsers/codex.js';
5
+ import { resolveClaudeDirs } from './lib/paths.js';
6
+ import { getCustomClaudePaths } from './auth.js';
7
+
8
+ /**
9
+ * 소스별 파서가 돌려준 항목들을 "날짜|모델" 단위 레코드로 합산한다.
10
+ * @param {Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>} entries
11
+ * @param {string|null} fromDate YYYY-MM-DD — 이 날짜(포함) 이후만 집계
12
+ */
13
+ export function aggregateRecords(entries, fromDate = null) {
14
+ const agg = new Map();
15
+ for (const entry of entries) {
16
+ if (fromDate && entry.date < fromDate) continue;
17
+ const key = `${entry.date}|${entry.model}`;
18
+ const cur = agg.get(key) ?? { date: entry.date, model: entry.model, inputTok: 0, outputTok: 0, cacheReadTok: 0, cacheCreateTok: 0 };
19
+ agg.set(key, {
20
+ ...cur,
21
+ inputTok: cur.inputTok + entry.inputTok,
22
+ outputTok: cur.outputTok + entry.outputTok,
23
+ cacheReadTok: cur.cacheReadTok + entry.cacheReadTok,
24
+ cacheCreateTok: cur.cacheCreateTok + entry.cacheCreateTok,
25
+ });
26
+ }
27
+ return Array.from(agg.values());
28
+ }
29
+
30
+ async function globSorted(pattern) {
31
+ const files = await glob(pattern, { nodir: true });
32
+ return files.sort(); // 파일 간 dedup(first-wins)의 기준 순서를 고정한다
33
+ }
34
+
35
+ /** Claude 설정 디렉터리 후보(기본 → XDG → config.json 지정)를 우선순위 순으로 반환한다. */
36
+ export function claudeDirCandidates() {
37
+ return resolveClaudeDirs({
38
+ home: homedir(),
39
+ xdgConfigHome: process.env.XDG_CONFIG_HOME,
40
+ customPaths: getCustomClaudePaths(),
41
+ });
42
+ }
43
+
44
+ export async function parseAll(fromDate = null) {
45
+ // 경로가 겹쳐도 파일 간 first-wins dedup이 흡수하므로 순서만 보장하면 된다.
46
+ const claudeFiles = [];
47
+ for (const dir of claudeDirCandidates()) {
48
+ claudeFiles.push(...await globSorted(`${dir.replaceAll('\\', '/')}/projects/**/*.jsonl`));
49
+ }
50
+
51
+ // sessions를 archived_sessions보다 먼저 — 같은 세션의 사본은 원본이 이긴다.
52
+ const codexHome = process.env.CODEX_HOME || `${homedir()}/.codex`;
53
+ const codexFiles = [
54
+ ...(await globSorted(`${codexHome}/sessions/**/*.jsonl`)),
55
+ ...(await globSorted(`${codexHome}/archived_sessions/**/*.jsonl`)),
56
+ ];
57
+
58
+ const [claudeEntries, codexEntries] = await Promise.all([
59
+ parseClaudeFiles(claudeFiles),
60
+ parseCodexFiles(codexFiles),
61
+ ]);
62
+ return aggregateRecords([...claudeEntries, ...codexEntries], fromDate);
63
+ }
@@ -0,0 +1,93 @@
1
+ import { createReadStream } from 'fs';
2
+ import { createInterface } from 'readline';
3
+ import { localDateOf } from '../lib/dates.js';
4
+
5
+ // Claude Code 트랜스크립트(~/.claude/projects/**/*.jsonl) 파서.
6
+ // 전략:
7
+ // - 스트리밍 응답은 같은 응답(messageId:requestId)을 여러 줄에 usage 반복 기록하므로 dedup이 필수다.
8
+ // - dedup 키: messageId:requestId → 없으면 message:messageId → 둘 다 없으면 dedup 없이 집계.
9
+ // - 파일 내 중복: 필드별 max로 병합한다 (뒤 라인일수록 누적이 완전한 값).
10
+ // - 파일 간 중복: 첫 파일 승리 (resume 시 파일 간 복제 대비, 정렬 순서가 기준).
11
+ // - 필드 매핑: Claude usage는 input_tokens가 cache_read/cache_creation을 포함하지 않는 별도 카운트라,
12
+ // codex(cached가 input의 부분집합)와 달리 빼지 않고 4필드를 그대로 매핑한다.
13
+ // 캐시 쓰기를 구분하므로 cacheCreateTok는 0이 아닐 수 있다 (codex는 구분이 없어 항상 0).
14
+ // - model이 없거나 '<'로 시작하는 라인(<synthetic> 등)과 usage 없는 라인은 집계에서 제외한다.
15
+
16
+ function dedupKeyOf(messageId, requestId) {
17
+ if (messageId && requestId) return `${messageId}:${requestId}`;
18
+ if (messageId) return `message:${messageId}`;
19
+ return null;
20
+ }
21
+
22
+ // Claude usage 4필드를 그대로 매핑한다 — input_tokens가 캐시분을 포함하지 않는 별도 카운트라
23
+ // codex(tokensOf)처럼 cached를 input에서 빼는 보정이 필요 없다. 음수 방어로만 max(…,0)를 건다.
24
+ function entryOf(date, model, usage) {
25
+ return {
26
+ date,
27
+ model,
28
+ inputTok: Math.max(usage.input_tokens ?? 0, 0),
29
+ outputTok: Math.max(usage.output_tokens ?? 0, 0),
30
+ cacheReadTok: Math.max(usage.cache_read_input_tokens ?? 0, 0),
31
+ cacheCreateTok: Math.max(usage.cache_creation_input_tokens ?? 0, 0),
32
+ };
33
+ }
34
+
35
+ // 같은 응답의 후속 스트리밍 스냅샷을 필드별 max로 병합한 새 entry를 만든다.
36
+ function mergedEntry(existing, usage, timestamp) {
37
+ const candidate = entryOf(existing.date, existing.model, usage);
38
+ const isNewer = timestamp && timestamp > existing.ts;
39
+ return {
40
+ ...existing,
41
+ ts: isNewer ? timestamp : existing.ts,
42
+ date: isNewer ? localDateOf(timestamp) : existing.date,
43
+ inputTok: Math.max(existing.inputTok, candidate.inputTok),
44
+ outputTok: Math.max(existing.outputTok, candidate.outputTok),
45
+ cacheReadTok: Math.max(existing.cacheReadTok, candidate.cacheReadTok),
46
+ cacheCreateTok: Math.max(existing.cacheCreateTok, candidate.cacheCreateTok),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Claude 트랜스크립트 파일들을 응답(dedup된 메시지) 단위 usage 목록으로 파싱한다.
52
+ * @param {string[]} files JSONL 파일 경로 목록 (정렬된 순서 = 파일 간 first-wins 기준)
53
+ * @returns {Promise<Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>>}
54
+ */
55
+ export async function parseClaudeFiles(files) {
56
+ const deduped = new Map(); // key → { file, ts, ...entry }
57
+ const noKey = []; // dedup 키를 만들 수 없는 라인은 전부 집계
58
+
59
+ for (const file of files) {
60
+ const rl = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
61
+ for await (const line of rl) {
62
+ if (!line.trim()) continue;
63
+ try {
64
+ const obj = JSON.parse(line);
65
+ if (obj.type !== 'assistant') continue;
66
+ const msg = obj.message;
67
+ if (!msg?.usage) continue;
68
+
69
+ const key = dedupKeyOf(msg.id, obj.requestId);
70
+ if (key && deduped.has(key)) {
71
+ const existing = deduped.get(key);
72
+ // 파일 간 중복(resume 복제)은 첫 파일 것을 유지하고 버린다.
73
+ if (existing.file !== file) continue;
74
+ deduped.set(key, mergedEntry(existing, msg.usage, obj.timestamp));
75
+ continue;
76
+ }
77
+
78
+ if (!msg.model || msg.model.startsWith('<')) continue; // <synthetic> 등 제외
79
+ const date = localDateOf(obj.timestamp);
80
+ if (!date) continue;
81
+
82
+ const entry = entryOf(date, msg.model, msg.usage);
83
+ if (key) {
84
+ deduped.set(key, { ...entry, file, ts: obj.timestamp });
85
+ } else {
86
+ noKey.push(entry);
87
+ }
88
+ } catch { /* 손상 라인 skip */ }
89
+ }
90
+ }
91
+
92
+ return [...Array.from(deduped.values(), ({ file: _f, ts: _t, ...entry }) => entry), ...noKey];
93
+ }
@@ -0,0 +1,213 @@
1
+ import { createReadStream } from 'fs';
2
+ import { createInterface } from 'readline';
3
+ import { localDateOf } from '../lib/dates.js';
4
+
5
+ // OpenAI Codex CLI rollout(~/.codex/sessions/**/*.jsonl) 파서.
6
+ // 전략:
7
+ // - token_count 이벤트의 last_token_usage를 증분으로 합산한다.
8
+ // total_token_usage는 누적 스냅샷이라 중복 판정·단조성 검사에만 쓴다.
9
+ // - 세션 첫 이벤트도 last만 집계한다 (resume 시 이전 세션 누적분 이월 방지).
10
+ // - fork/서브에이전트 자식 파일은 부모 히스토리를 그대로 replay하므로,
11
+ // (fork 부모 세션, 모델, 누적 total) 키로 전역 dedup해 1회만 집계한다.
12
+ // - cached_input_tokens는 input_tokens의 부분집합 → input은 비캐시분만,
13
+ // cache_read에 cached를 둔다. OpenAI는 캐시 쓰기 구분이 없어 cache_create=0.
14
+ // - reasoning_output_tokens(Codex rollout 내부 필드명; OpenAI 공식 API에서는
15
+ // output_tokens_details.reasoning_tokens)는 output_tokens에 이미 포함된 부분집합이라
16
+ // 별도 합산하지 않는다. 근거: OpenAI 공식 문서 "reasoning tokens ... are billed as
17
+ // output tokens" (developers.openai.com/api/docs/guides/reasoning).
18
+
19
+ function totalsOf(u) {
20
+ if (!u) return null;
21
+ return {
22
+ input: Math.max(u.input_tokens ?? 0, 0),
23
+ output: Math.max(u.output_tokens ?? 0, 0),
24
+ cached: Math.max(u.cached_input_tokens ?? 0, u.cache_read_input_tokens ?? 0, 0),
25
+ // reasoning_output_tokens: Codex rollout 내부 필드명 (OpenAI 공식 API는 reasoning_tokens).
26
+ // 단조성·dedup 키 판정용으로만 추적하고 출력 레코드에는 넣지 않는다.
27
+ reasoning: Math.max(u.reasoning_output_tokens ?? 0, 0),
28
+ };
29
+ }
30
+
31
+ function totalsEqual(a, b) {
32
+ return a.input === b.input && a.output === b.output
33
+ && a.cached === b.cached && a.reasoning === b.reasoning;
34
+ }
35
+
36
+ // 모든 필드가 단조 증가했을 때만 증분을 돌려준다. 하나라도 줄었으면 null (리셋/회귀).
37
+ function deltaFrom(current, previous) {
38
+ if (current.input < previous.input || current.output < previous.output
39
+ || current.cached < previous.cached || current.reasoning < previous.reasoning) {
40
+ return null;
41
+ }
42
+ return {
43
+ input: current.input - previous.input,
44
+ output: current.output - previous.output,
45
+ cached: current.cached - previous.cached,
46
+ reasoning: current.reasoning - previous.reasoning,
47
+ };
48
+ }
49
+
50
+ function totalSum(t) {
51
+ return t.input + t.output + t.cached + t.reasoning;
52
+ }
53
+
54
+ // 누적 total이 살짝 뒤로 간 스냅샷이 순서 뒤바뀜(stale)인지 판정한다.
55
+ // 진짜 리셋과 달리 직전 누적의 98% 이상이거나 최근 증분 2회분 이내로만 줄어든다.
56
+ function looksLikeStaleRegression(current, previous, last) {
57
+ const prevSum = totalSum(previous);
58
+ const curSum = totalSum(current);
59
+ const lastSum = totalSum(last);
60
+ if (prevSum <= 0 || curSum <= 0 || lastSum <= 0) return false;
61
+ return curSum * 100 >= prevSum * 98 || curSum + lastSum * 2 >= prevSum;
62
+ }
63
+
64
+ // 증분 totals를 tokenphage 레코드 토큰 필드로 변환한다.
65
+ function tokensOf(t) {
66
+ const clampedCached = Math.min(t.cached, t.input);
67
+ return {
68
+ inputTok: Math.max(t.input - clampedCached, 0),
69
+ outputTok: t.output,
70
+ cacheReadTok: Math.max(clampedCached, 0),
71
+ cacheCreateTok: 0,
72
+ };
73
+ }
74
+
75
+ function isZeroTotals(t) {
76
+ return t.input === 0 && t.output === 0 && t.cached === 0 && t.reasoning === 0;
77
+ }
78
+
79
+ // fork 자식이 replay한 행은 부모와 같은 키가 되도록 fork 부모 id로 스코프를 잡는다.
80
+ function dedupKeyOf(scopeId, model, totalUsage, tokens, date) {
81
+ if (totalUsage) {
82
+ return `total:${scopeId}:${model}:${totalUsage.input}:${totalUsage.output}:${totalUsage.cached}:${totalUsage.reasoning}`;
83
+ }
84
+ return `tok:${scopeId}:${model}:${date}:${tokens.inputTok}:${tokens.outputTok}:${tokens.cacheReadTok}`;
85
+ }
86
+
87
+ function forkedFromIdOf(payload) {
88
+ return payload.forked_from_id
89
+ ?? payload.source?.subagent?.thread_spawn?.parent_thread_id
90
+ ?? null;
91
+ }
92
+
93
+ // token_count 이벤트 하나에서 (집계할 증분, 다음 baseline)을 결정한다.
94
+ function resolveIncrement(total, last, previous) {
95
+ if (total && last) {
96
+ if (previous) {
97
+ if (totalsEqual(total, previous)) return null; // 변화 없는 중복 스냅샷
98
+ if (deltaFrom(total, previous) === null && looksLikeStaleRegression(total, previous, last)) {
99
+ return null; // 순서 뒤바뀐 구식 스냅샷 — last를 두 번 세지 않는다
100
+ }
101
+ }
102
+ return { tokens: last, next: total };
103
+ }
104
+ if (total) {
105
+ if (previous) {
106
+ if (totalsEqual(total, previous)) return null;
107
+ const delta = deltaFrom(total, previous);
108
+ if (delta === null) return { tokens: null, next: total }; // 리셋 → baseline만 갱신
109
+ return { tokens: delta, next: total };
110
+ }
111
+ return { tokens: total, next: total }; // last 없는 구버전 첫 이벤트
112
+ }
113
+ if (last) {
114
+ if (previous) {
115
+ return {
116
+ tokens: last,
117
+ next: {
118
+ input: previous.input + last.input,
119
+ output: previous.output + last.output,
120
+ cached: previous.cached + last.cached,
121
+ reasoning: previous.reasoning + last.reasoning,
122
+ },
123
+ };
124
+ }
125
+ return { tokens: last, next: null };
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /**
131
+ * Codex rollout 파일들을 턴 증분 단위 usage 목록으로 파싱한다.
132
+ * @param {string[]} files JSONL 파일 경로 목록 (정렬된 순서 — sessions를 archived보다 먼저)
133
+ * @returns {Promise<Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>>}
134
+ */
135
+ export async function parseCodexFiles(files) {
136
+ const entries = [];
137
+ const seenKeys = new Set(); // 파일 간 replay/복제 dedup
138
+
139
+ for (const file of files) {
140
+ let currentModel = null;
141
+ let previousTotals = null;
142
+ let sessionId = null;
143
+ let forkedFromId = null;
144
+ const pendingNoModel = []; // 모델을 아직 모를 때 도착한 증분 — 모델 발견 시 플러시
145
+
146
+ const flushPending = (model) => {
147
+ for (const pending of pendingNoModel) {
148
+ const key = dedupKeyOf(pending.scopeId, model, pending.totalUsage, pending.tokens, pending.date);
149
+ if (seenKeys.has(key)) continue;
150
+ seenKeys.add(key);
151
+ entries.push({ date: pending.date, model, ...pending.tokens });
152
+ }
153
+ pendingNoModel.length = 0;
154
+ };
155
+
156
+ const rl = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
157
+ for await (const line of rl) {
158
+ if (!line.trim()) continue;
159
+ try {
160
+ const obj = JSON.parse(line);
161
+ const payload = obj.payload;
162
+ if (!payload) continue;
163
+
164
+ if (obj.type === 'session_meta') {
165
+ sessionId = payload.id ?? sessionId;
166
+ forkedFromId = forkedFromIdOf(payload) ?? forkedFromId;
167
+ continue;
168
+ }
169
+
170
+ if (obj.type === 'turn_context') {
171
+ if (payload.model) {
172
+ currentModel = payload.model;
173
+ flushPending(currentModel);
174
+ }
175
+ continue;
176
+ }
177
+
178
+ if (obj.type !== 'event_msg' || payload.type !== 'token_count') continue;
179
+ const info = payload.info;
180
+ if (!info) continue;
181
+
182
+ const model = payload.model ?? info.model ?? info.model_name ?? currentModel;
183
+ if (model) currentModel = model;
184
+
185
+ const total = totalsOf(info.total_token_usage);
186
+ const last = totalsOf(info.last_token_usage);
187
+ const resolved = resolveIncrement(total, last, previousTotals);
188
+ if (resolved === null) continue;
189
+ if (resolved.tokens === null) { previousTotals = resolved.next; continue; }
190
+ // 전부 0인 스냅샷은 baseline을 옮기지 않고 버린다 (compaction 직후 0 total 대비).
191
+ if (isZeroTotals(resolved.tokens)) continue;
192
+ previousTotals = resolved.next;
193
+
194
+ const date = localDateOf(obj.timestamp);
195
+ if (!date) continue;
196
+
197
+ const tokens = tokensOf(resolved.tokens);
198
+ const scopeId = forkedFromId ?? sessionId ?? file;
199
+ if (model) {
200
+ const key = dedupKeyOf(scopeId, model, total, tokens, date);
201
+ if (seenKeys.has(key)) continue;
202
+ seenKeys.add(key);
203
+ entries.push({ date, model, ...tokens });
204
+ } else {
205
+ pendingNoModel.push({ date, tokens, totalUsage: total, scopeId });
206
+ }
207
+ } catch { /* 손상 라인 skip */ }
208
+ }
209
+ // 파일이 끝나도 모델을 못 찾은 증분은 모델 귀속이 불가능해 버린다.
210
+ }
211
+
212
+ return entries;
213
+ }