tokenphage 2026.8.1 → 2026.11.1

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 CHANGED
@@ -11,16 +11,16 @@ Claude Code, Codex, opencode의 토큰 사용량을 모아 GitHub README에 붙
11
11
 
12
12
  ## 📋 요구 사항
13
13
 
14
- - **Node.js 20.17 이상**
14
+ - **Node.js 22.14 이상**
15
15
  - macOS 또는 Windows (자동 동기화 사용 시)
16
16
  - GitHub 계정
17
17
 
18
18
  ## 🖼️ 이런 배지가 만들어져요
19
19
 
20
- GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한 줄만 넣으면 됩니다. `kobenlys`를 본인 GitHub 아이디로 바꾸세요.
20
+ GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한 줄만 넣으면 됩니다. `<your-github-name>`을 본인 GitHub 아이디로 바꾸세요.
21
21
 
22
22
  ```markdown
23
- [![TokenPhage](https://api.tokenphage.com/badge/kobenlys)](https://github.com/TOKENPHAGE)
23
+ [![Tokenphage](https://api.tokenphage.com/badge/<your-github-name>)](https://github.com/TOKENPHAGE)
24
24
  ```
25
25
 
26
26
  **결과 ↓**
@@ -33,16 +33,34 @@ GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한
33
33
  <tr>
34
34
  <td align="center">
35
35
  <a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=gpu&amp;mode=dark" alt="TokenPhage gpu dark 배지"></a><br/>
36
- <code>https://api.tokenphage.com/badge/kobenlys?theme=gpu&amp;mode=dark</code>
36
+ <code>[![Tokenphage](https://api.tokenphage.com/badge/&lt;your-github-name&gt;?theme=gpu&amp;mode=dark)](https://github.com/TOKENPHAGE)</code>
37
37
  </td>
38
38
  <td align="center">
39
39
  <a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=claude&amp;mode=light" alt="TokenPhage claude light 배지"></a><br/>
40
- <code>https://api.tokenphage.com/badge/kobenlys?theme=claude&amp;mode=light</code>
40
+ <code>[![Tokenphage](https://api.tokenphage.com/badge/&lt;your-github-name&gt;?theme=claude&amp;mode=light)](https://github.com/TOKENPHAGE)</code>
41
+ </td>
42
+ </tr>
43
+ <tr>
44
+ <td align="center" colspan="2"><strong>grass-claude · light</strong></td>
45
+ </tr>
46
+ <tr>
47
+ <td align="center" colspan="2">
48
+ <a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=grass-claude" alt="TokenPhage grass-claude light 배지"></a><br/>
49
+ <code>[![Tokenphage](https://api.tokenphage.com/badge/&lt;your-github-name&gt;?theme=grass-claude)](https://github.com/TOKENPHAGE)</code>
50
+ </td>
51
+ </tr>
52
+ <tr>
53
+ <td align="center" colspan="2"><strong>grass-claude · dark</strong></td>
54
+ </tr>
55
+ <tr>
56
+ <td align="center" colspan="2">
57
+ <a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=grass-claude&amp;mode=dark" alt="TokenPhage grass-claude dark 배지"></a><br/>
58
+ <code>[![Tokenphage](https://api.tokenphage.com/badge/&lt;your-github-name&gt;?theme=grass-claude&amp;mode=dark)](https://github.com/TOKENPHAGE)</code>
41
59
  </td>
42
60
  </tr>
43
61
  </table>
44
62
 
45
- > 🎨 URL 뒤에 옵션을 붙일 수 있어요 — 테마 `?theme=gpu`(기본) 또는 `?theme=claude`, 색상 `?mode=light`(기본) 또는 `?mode=dark`.
63
+ > 🎨 URL 뒤에 옵션을 붙일 수 있어요 — 테마 `?theme=gpu`(기본) · `?theme=claude` · `?theme=grass-claude`, 색상 `?mode=light`(기본) 또는 `?mode=dark`.
46
64
 
47
65
  ## 📦 설치
48
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenphage",
3
- "version": "2026.8.1",
3
+ "version": "2026.11.1",
4
4
  "description": "AI 토큰 사용량 GitHub README 뱃지",
5
5
  "author": "kobenlys <kobenlys@gmail.com>",
6
6
  "license": "MIT",
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@inquirer/prompts": "^8.5.0",
27
+ "better-sqlite3": "^13.0.3",
27
28
  "chalk": "^5.6.2",
28
29
  "commander": "^12.1.0",
29
30
  "glob": "^11.0.0",
@@ -37,6 +38,6 @@
37
38
  "mute-stream": "^3.0.0"
38
39
  },
39
40
  "engines": {
40
- "node": "^20.17.0 || ^22.13.0 || >=23.5.0"
41
+ "node": "^22.14.0 || >=23.6.0"
41
42
  }
42
43
  }
package/src/auth.js CHANGED
@@ -4,6 +4,7 @@ import { join } from 'path';
4
4
  import { randomUUID } from 'crypto';
5
5
  import { normalizeConfig } from './lib/config-schema.js';
6
6
  import { localDateOf } from './lib/dates.js';
7
+ import { apiFetch } from './sync.js';
7
8
 
8
9
  // 로컬 설정 파일 경로: ~/.tokenphage/config.json (JWT·deviceId·sync 상태 보관)
9
10
  const CONFIG_DIR = join(homedir(), '.tokenphage');
@@ -79,18 +80,10 @@ export function getCustomClaudePaths() {
79
80
  return Array.isArray(paths) ? paths : [];
80
81
  }
81
82
 
82
- /** 마지막 sync 완료 날짜(YYYY-MM-DD)를 반환한다. 없으면 null. */
83
- export function getLastSyncDate() { return loadConfig().lastSyncDate ?? null; }
84
-
85
- /** 마지막 sync 날짜를 저장한다. 다음 sync는 이 날짜 이후만 증분 파싱한다. */
86
- export function saveLastSyncDate(date) {
87
- saveConfig({ ...loadConfig(), lastSyncDate: date });
88
- }
89
-
90
- /** lastSyncDate를 제거해 다음 sync가 전체 히스토리를 재파싱하도록 한다. token/deviceId는 보존. */
91
- export function clearLastSyncDate() {
92
- const { lastSyncDate: _omit, ...rest } = loadConfig();
93
- saveConfig(rest);
83
+ /** reset용: lastSyncDate와 lastSyncVersion을 지워 다음 sync가 토큰·실행시간을 처음부터 보내게 한다. 나머지는 보존. */
84
+ export function clearSyncState({ readConfig = loadConfig, writeConfig = saveConfig } = {}) {
85
+ const { lastSyncDate: _date, lastSyncVersion: _version, ...rest } = readConfig();
86
+ writeConfig(rest);
94
87
  }
95
88
 
96
89
  // Gist 소유권 검증용 파일명 — 사용자가 이 이름으로 Gist를 만들어야 한다.
@@ -113,8 +106,8 @@ export function parseGistId(input) {
113
106
 
114
107
  /** url에 JSON body를 POST하고 {ok, status, body}를 반환한다. 응답이 JSON이 아니면 {error: 원문}으로 감싼다. */
115
108
  async function postJson(url, body) {
116
- // Node 18+ 전역 fetch 사용 (node-fetch 의존성 불필요).
117
- const res = await fetch(url, {
109
+ // 이름표를 붙이고 지원 종료 헤더를 안내하는 공통 요청
110
+ const res = await apiFetch(url, {
118
111
  method: 'POST',
119
112
  headers: { 'Content-Type': 'application/json' },
120
113
  body: JSON.stringify(body),
package/src/index.js CHANGED
@@ -5,14 +5,14 @@
5
5
  * 맨 끝 program.parse()가 argv를 해석해 실제 시동을 건다.
6
6
  */
7
7
  import { Command } from 'commander';
8
- import { parseAll } from './parser.js';
9
- import { loadConfig, loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled, getLastSyncDate, saveLastSyncDate, clearLastSyncDate } from './auth.js';
10
- import { syncRecords, resetData } from './sync.js';
8
+ import { parseLogs } from './parser.js';
9
+ import { loadConfig, loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled, clearSyncState } from './auth.js';
10
+ import { resetData } from './sync.js';
11
+ import { runSyncFlow } from './sync-flow.js';
11
12
  import { installHook, uninstallHook } from './scheduler.js';
12
13
  import { reconcileHookRegistration } from './hook-reconcile.js';
13
14
  import { promptFirstRun, promptDashboard, promptAdvanced, confirmReset, pauseForEnter, showError } from './ui.js';
14
15
  import { accent } from './gradient.js';
15
- import { localDateOf } from './lib/dates.js';
16
16
  import { claudeDirCandidates } from './parser.js';
17
17
  import { checkClaudeRetention, applyRetentionSetting, DEFAULT_CLEANUP_DAYS } from './lib/retention.js';
18
18
  import { checkLatestVersion, getUpdateInfo } from './lib/update-check.js';
@@ -126,30 +126,11 @@ async function promptRetentionSetup() {
126
126
  }
127
127
  }
128
128
 
129
- /** sync 명령 본체: 로컬 로그를 파싱(최초=전체 / 이후=증분)해 서버로 업로드하고, 마지막 sync 날짜를 갱신한다. */
129
+ /** sync 명령 본체: 인증·기기 ID를 확인하고 sync 흐름(sync-flow.js)을 실행한다. */
130
130
  async function runSync() {
131
131
  const token = requireToken();
132
132
  const deviceId = getOrCreateDeviceId();
133
- const today = localDateOf(new Date());
134
-
135
- // 최초(lastSyncDate 없음)면 fromDate=null → 전체 히스토리 파싱.
136
- // 이후엔 직전 sync일부터 오늘까지 증분만 파싱.
137
- const lastSyncDate = getLastSyncDate();
138
- const fromDate = lastSyncDate ?? null;
139
-
140
- console.log(`Parsing local logs... (${fromDate ?? 'beginning'} ~ ${today})`);
141
- const records = await parseAll(fromDate);
142
- console.log(`${records.length} records aggregated (date×model)`);
143
- if (records.length === 0) {
144
- console.log('Nothing to sync.');
145
- saveLastSyncDate(today);
146
- return;
147
- }
148
-
149
- console.log('Uploading to server...');
150
- await syncRecords(API_BASE, token, deviceId, records);
151
- saveLastSyncDate(today);
152
- console.log('Done! Badge will update shortly.');
133
+ await runSyncFlow({ apiBase: API_BASE, token, deviceId, parseLogs });
153
134
  }
154
135
 
155
136
  /**
@@ -183,8 +164,8 @@ async function runAdvanced(apiBase, cfg) {
183
164
  await pauseForEnter();
184
165
  return;
185
166
  }
186
- // 2단계: 로컬 워터마크 제거 후 전체 재-sync. 재-sync가 실패해도 서버 삭제는 이미 완료됐음을 명확히 안내.
187
- clearLastSyncDate();
167
+ // 2단계: 로컬 워터마크(lastSyncDate·lastSyncVersion) 제거 후 전체 재-sync. 재-sync가 실패해도 서버 삭제는 이미 완료됐음을 명확히 안내.
168
+ clearSyncState();
188
169
  console.log(accent(' [OK] Server data reset.') + ' Re-syncing from scratch...');
189
170
  try {
190
171
  await runSync();
@@ -83,6 +83,7 @@ export function normalizeConfig(raw, today) {
83
83
  dropIf('token', isJwtShape);
84
84
  dropIf('deviceId', isUuid); // 없으면 getOrCreateDeviceId가 재발급
85
85
  dropIf('lastSyncDate', (v) => isYmdDate(v, today)); // 없으면 전체 재파싱
86
+ dropIf('lastSyncVersion', (v) => typeof v === 'string' && v !== ''); // 없으면 실행시간 전체 재전송
86
87
  dropIf('hookInstalledAt', isIsoDate);
87
88
  dropIf('hookInstalled', (v) => typeof v === 'boolean');
88
89
  dropIf('claudePaths', isStringArray);
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
2
- import { join } from 'path';
1
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
3
 
4
4
  // Claude Code의 cleanupPeriodDays(트랜스크립트 보존 기간) 검사·설정.
5
5
  // 규칙은 하나: 99999 이상이면 통과, 아니면 동의를 받아 무조건 99999로 설정한다.
@@ -41,9 +41,9 @@ export function checkClaudeRetention(claudeDirs) {
41
41
  }
42
42
 
43
43
  /**
44
- * settings.json의 cleanupPeriodDays를 무조건 99999로 설정한다 (다른 필드 보존, 원자 쓰기).
44
+ * settings.json의 cleanupPeriodDays를 무조건 99999로 설정한다 (다른 필드 보존, 원자 쓰기, 상위 폴더가 없으면 0700으로 생성).
45
45
  * @param {string} settingsPath checkClaudeRetention()이 돌려준 settings.json 경로
46
- * @returns {boolean} 성공 여부 — 손상 JSON이면 수정하지 않고 false
46
+ * @returns {boolean} 성공 여부 — 손상 JSON이면 수정하지 않고 false, 쓰기(폴더 생성 포함)에 실패해도 false
47
47
  */
48
48
  export function applyRetentionSetting(settingsPath) {
49
49
  let config = {};
@@ -57,6 +57,7 @@ export function applyRetentionSetting(settingsPath) {
57
57
 
58
58
  try {
59
59
  const next = { ...config, cleanupPeriodDays: FOREVER_DAYS };
60
+ mkdirSync(dirname(settingsPath), { recursive: true, mode: 0o700 }); // Claude Code가 이 폴더에 대화 기록을 쌓는다
60
61
  const tmpPath = settingsPath + '.tmp';
61
62
  writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\n');
62
63
  renameSync(tmpPath, settingsPath); // 같은 디렉터리 내 rename = 원자적 교체
package/src/parser.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import { glob } from 'glob';
2
2
  import { homedir } from 'os';
3
- import { parseClaudeFiles } from './parsers/claude.js';
4
- import { parseCodexFiles } from './parsers/codex.js';
5
- import { parseOpencodeFiles } from './parsers/opencode.js';
6
3
  import { resolveClaudeDirs, resolveOpencodeDirs } from './lib/paths.js';
7
4
  import { getCustomClaudePaths } from './auth.js';
5
+ import { readLogs } from './runtime/read-logs.js';
8
6
 
9
7
  /**
10
8
  * 소스별 파서가 돌려준 항목들을 "날짜|모델" 단위 레코드로 합산한다.
@@ -50,7 +48,8 @@ export function opencodeDirCandidates() {
50
48
  });
51
49
  }
52
50
 
53
- export async function parseAll(fromDate = null) {
51
+ /** 세 도구의 로그 경로를 기존 탐색 순서대로 찾는다. */
52
+ async function discoverLogPaths() {
54
53
  // 경로가 겹쳐도 파일 간 first-wins dedup이 흡수하므로 순서만 보장하면 된다.
55
54
  const claudeFiles = [];
56
55
  for (const dir of claudeDirCandidates()) {
@@ -64,19 +63,33 @@ export async function parseAll(fromDate = null) {
64
63
  ...(await globSorted(`${codexHome}/archived_sessions/**/*.jsonl`)),
65
64
  ];
66
65
 
67
- // opencode는 message(모델·시각)와 part(step-finish 토큰)를 모두 읽어야 조인할 수 있다.
68
- // parseOpencodeFiles가 내용으로 분류하므로 두 목록을 합쳐 넘긴다(순서 무관).
66
+ // 최신 opencode는 data home 바로 아래 opencode.db에 저장한다.
67
+ // DB가 있으면 그 파일만 직접 읽고(구버전에서 마이그레이션된 JSON과의 이중 집계 방지),
68
+ // DB가 없는 구버전만 storage/message·part JSON을 읽는다.
69
+ let opencodeDatabase = null;
69
70
  const opencodeFiles = [];
70
71
  for (const dir of opencodeDirCandidates()) {
71
72
  const base = dir.replaceAll('\\', '/');
72
- opencodeFiles.push(...await globSorted(`${base}/storage/message/**/*.json`));
73
- opencodeFiles.push(...await globSorted(`${base}/storage/part/**/*.json`));
73
+ if (!opencodeDatabase) {
74
+ [opencodeDatabase = null] = await globSorted(`${base}/opencode.db`);
75
+ }
76
+ if (!opencodeDatabase) {
77
+ opencodeFiles.push(...await globSorted(`${base}/storage/message/**/*.json`));
78
+ opencodeFiles.push(...await globSorted(`${base}/storage/part/**/*.json`));
79
+ }
74
80
  }
75
81
 
76
- const [claudeEntries, codexEntries, opencodeEntries] = await Promise.all([
77
- parseClaudeFiles(claudeFiles),
78
- parseCodexFiles(codexFiles),
79
- parseOpencodeFiles(opencodeFiles),
80
- ]);
81
- return aggregateRecords([...claudeEntries, ...codexEntries, ...opencodeEntries], fromDate);
82
+ return { claudeFiles, codexFiles, opencodeDatabase, opencodeFiles };
83
+ }
84
+
85
+ /**
86
+ * 로그를 한 번 읽어 토큰 레코드와 실행시간을 함께 계산한다.
87
+ * @param {{ tokenFrom: string|null, runtimeFrom: string|null }} range YYYY-MM-DD(포함) 또는 null(전체 기간)
88
+ * @returns {Promise<{ records: Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>,
89
+ * runtimes: Array<{ date: string, source: 'claude'|'codex'|'opencode', model: string, runtimeMs: number }>,
90
+ * allSourcesRead: boolean, failedSources: string[] }>}
91
+ */
92
+ export async function parseLogs({ tokenFrom = null, runtimeFrom = null } = {}) {
93
+ const { entries, runtimes, allSourcesRead, failedSources } = await readLogs(await discoverLogPaths(), runtimeFrom);
94
+ return { records: aggregateRecords(entries, tokenFrom), runtimes, allSourcesRead, failedSources };
82
95
  }
@@ -50,9 +50,10 @@ function mergedEntry(existing, usage, timestamp) {
50
50
  /**
51
51
  * Claude 트랜스크립트 파일들을 응답(dedup된 메시지) 단위 usage 목록으로 파싱한다.
52
52
  * @param {string[]} files JSONL 파일 경로 목록 (정렬된 순서 = 파일 간 first-wins 기준)
53
+ * @param {((record: object, file: string) => void)|null} [onRecord] 파싱한 줄마다 부르는 수집기(예외를 던지지 않아야 한다)
53
54
  * @returns {Promise<Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>>}
54
55
  */
55
- export async function parseClaudeFiles(files) {
56
+ export async function parseClaudeFiles(files, onRecord = null) {
56
57
  const deduped = new Map(); // key → { file, ts, ...entry }
57
58
  const noKey = []; // dedup 키를 만들 수 없는 라인은 전부 집계
58
59
 
@@ -62,6 +63,7 @@ export async function parseClaudeFiles(files) {
62
63
  if (!line.trim()) continue;
63
64
  try {
64
65
  const obj = JSON.parse(line);
66
+ onRecord?.(obj, file);
65
67
  if (obj.type !== 'assistant') continue;
66
68
  const msg = obj.message;
67
69
  if (!msg?.usage) continue;
@@ -130,9 +130,10 @@ function resolveIncrement(total, last, previous) {
130
130
  /**
131
131
  * Codex rollout 파일들을 턴 증분 단위 usage 목록으로 파싱한다.
132
132
  * @param {string[]} files JSONL 파일 경로 목록 (정렬된 순서 — sessions를 archived보다 먼저)
133
+ * @param {((record: object, file: string) => void)|null} [onRecord] 파싱한 줄마다 부르는 수집기(예외를 던지지 않아야 한다)
133
134
  * @returns {Promise<Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>>}
134
135
  */
135
- export async function parseCodexFiles(files) {
136
+ export async function parseCodexFiles(files, onRecord = null) {
136
137
  const entries = [];
137
138
  const seenKeys = new Set(); // 파일 간 replay/복제 dedup
138
139
 
@@ -158,6 +159,7 @@ export async function parseCodexFiles(files) {
158
159
  if (!line.trim()) continue;
159
160
  try {
160
161
  const obj = JSON.parse(line);
162
+ onRecord?.(obj, file);
161
163
  const payload = obj.payload;
162
164
  if (!payload) continue;
163
165
 
@@ -1,9 +1,16 @@
1
1
  import { readFile } from 'fs/promises';
2
+ import { existsSync } from 'fs';
2
3
  import { localDateOf } from '../lib/dates.js';
3
4
 
4
5
  // opencode(sst) 세션 저장 파서.
6
+ // 최신 opencode는 사용량을 data home 아래 opencode.db(SQLite)에 저장하고,
7
+ // 구버전은 storage/message·part JSON 파일에 저장한다. 두 형태를 모두 지원한다.
8
+ //
9
+ // DB는 외부 App(`opencode` CLI)을 거치지 않고 read-only로 직접 조회한다.
10
+ // OpenCode의 설치 여부·버전·오작동이 tokenphage sync에 영향을 주지 않아야 하기 때문이다.
11
+ //
5
12
  // opencode는 턴당 assistant 메시지 1개에 스텝마다 tokens를 덮어써(=) 마지막 스텝만 남기므로,
6
- // 스텝별 토큰이 보존된 step-finish part(storage/part/<messageID>/*.json)를 전부 합산해야 정확하다.
13
+ // 스텝별 토큰이 보존된 step-finish part를 전부 합산해야 정확하다.
7
14
  // 전략:
8
15
  // - message(role==='assistant')에서 id→{model, date}를 모으고, step-finish part를 messageID로 조인한다.
9
16
  // - 입력 파일 목록에 message/part가 섞여 오고 순서 보장이 없으므로, 두 맵을 모은 뒤 마지막에 조인한다.
@@ -12,6 +19,25 @@ import { localDateOf } from '../lib/dates.js';
12
19
  // cache.write는 cacheCreate로 둔다(opencode는 Anthropic cacheCreation을 여기 담는다).
13
20
  // - 손상 파일·부모 없는 part·모델/날짜 없는 메시지는 집계에서 제외한다.
14
21
 
22
+ // DB 스키마도 JSON 파일과 같은 구조(message.data / part.data)를 담고 있어 동일 규칙으로 집계한다.
23
+ const USAGE_QUERY = `
24
+ SELECT
25
+ strftime('%Y-%m-%d', m.time_created / 1000, 'unixepoch', 'localtime') AS date,
26
+ json_extract(m.data, '$.modelID') AS model,
27
+ SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.input'), 0), 0)) AS input,
28
+ SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.output'), 0), 0)) AS output,
29
+ SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.reasoning'), 0), 0)) AS reasoning,
30
+ SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.cache.read'), 0), 0)) AS cache_read,
31
+ SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.cache.write'), 0), 0)) AS cache_write
32
+ FROM message m
33
+ JOIN part p ON p.message_id = m.id
34
+ WHERE json_extract(m.data, '$.role') = 'assistant'
35
+ AND json_extract(p.data, '$.type') = 'step-finish'
36
+ AND json_type(p.data, '$.tokens') = 'object'
37
+ AND json_extract(m.data, '$.modelID') IS NOT NULL
38
+ GROUP BY date, model
39
+ `;
40
+
15
41
  // step-finish part의 tokens를 tokenphage 레코드 4필드로 매핑한다. 음수 방어로 max(…,0).
16
42
  function entryOf(date, model, tokens) {
17
43
  const cache = tokens.cache ?? {};
@@ -26,6 +52,81 @@ function entryOf(date, model, tokens) {
26
52
  };
27
53
  }
28
54
 
55
+ // USAGE_QUERY가 내보내는 7개 별칭(date·model·input·output·reasoning·cache_read·cache_write)만 읽는다.
56
+ // date는 strftime 결과라 'YYYY-MM-DD' 아니면 NULL이다(m.time_created가 NULL인 경우).
57
+ // 날짜나 모델을 못 만든 행은 어느 날짜·모델에 귀속시킬지 알 수 없으므로 집계에서 뺀다.
58
+ function entryOfRow(row) {
59
+ if (!row || typeof row.model !== 'string' || !row.model) return null;
60
+ if (typeof row.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(row.date)) return null;
61
+ return entryOf(row.date, row.model, {
62
+ input: row.input,
63
+ output: row.output,
64
+ reasoning: row.reasoning,
65
+ cache: { read: row.cache_read, write: row.cache_write },
66
+ });
67
+ }
68
+
69
+ // OpenCode를 지운 뒤 남은 빈 DB와, 스키마가 바뀐 DB를 구분하기 위한 조회.
70
+ function tableCount(db, filter = '') {
71
+ return db.prepare(`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'${filter}`).get().n;
72
+ }
73
+ function hasUsageTables(db) {
74
+ return tableCount(db, " AND name IN ('message', 'part')") === 2;
75
+ }
76
+ function hasAnyTable(db) {
77
+ return tableCount(db, " AND name NOT LIKE 'sqlite_%'") > 0;
78
+ }
79
+
80
+ // opencode.db를 read-only로 직접 연다. OpenCode CLI/App은 호출하지 않는다.
81
+ // OpenCode가 쓰는 중이어도 WAL 기반이라 읽기는 막히지 않는다.
82
+ // DB 삭제·손상·권한·스키마 변경 등 어떤 실패도 claude/codex sync를 막지 않도록
83
+ // 예외를 올리지 않고 빈 목록으로 격리한다(경고만 남긴다).
84
+ // better-sqlite3는 네이티브 모듈이라 로드 자체가 실패할 수 있다
85
+ // 정적 import면 그 실패가 CLI 전체를 죽이므로 격리 대상인 이 try 안에서 동적으로 불러온다.
86
+ async function queryOpencodeRows(dbPath, onDatabase = null) {
87
+ let db;
88
+ try {
89
+ const { default: Database } = await import('better-sqlite3');
90
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
91
+ onDatabase?.(db); // 실행시간 수집도 같은 연결로 읽는다(수집기는 예외를 던지지 않는다)
92
+ // 기록이 아직 없는 빈 DB는 정상 상태다. 반대로 다른 테이블은 있는데 message/part만
93
+ // 없다면 OpenCode 스키마가 바뀐 것이므로 집계가 0으로 새지 않게 알린다.
94
+ if (!hasUsageTables(db)) {
95
+ if (hasAnyTable(db)) console.warn(`Skipping OpenCode usage: ${dbPath} has no message/part table (schema changed?)`);
96
+ return [];
97
+ }
98
+ return db.prepare(USAGE_QUERY).all();
99
+ } catch (err) {
100
+ // 파일이 사라졌으면 집계할 기록이 없을 뿐이라 알릴 것이 없다.
101
+ // 파일이 남아 있는데 실패했다면 손상·권한 문제로 사용량이 누락되는 중이다.
102
+ if (existsSync(dbPath)) console.warn(`Skipping OpenCode usage: could not read ${dbPath} (${err.message})`);
103
+ return [];
104
+ } finally {
105
+ try { db?.close(); } catch { /* close 실패는 이미 읽은 결과에 영향이 없다 */ }
106
+ }
107
+ }
108
+
109
+ /** OpenCode SQLite 조회 결과를 tokenphage usage 목록으로 변환한다. */
110
+ export function parseOpencodeRows(rows) {
111
+ if (!Array.isArray(rows)) return [];
112
+ const entries = [];
113
+ for (const row of rows) {
114
+ const entry = entryOfRow(row);
115
+ if (entry) entries.push(entry);
116
+ }
117
+ return entries;
118
+ }
119
+
120
+ /**
121
+ * opencode.db(SQLite)를 직접 조회해 tokenphage usage 목록으로 변환한다.
122
+ * @param {string} dbPath opencode.db 절대 경로
123
+ * @param {(dbPath: string, onDatabase?: Function|null) => Array<object>} [queryRows] 조회 구현(테스트 주입용)
124
+ * @param {((db: object) => void)|null} [onDatabase] 연 DB로 실행시간을 모으는 수집기(예외를 던지지 않아야 한다)
125
+ */
126
+ export async function parseOpencodeDatabase(dbPath, queryRows = queryOpencodeRows, onDatabase = null) {
127
+ return parseOpencodeRows(await queryRows(dbPath, onDatabase));
128
+ }
129
+
29
130
  /**
30
131
  * opencode message/part 파일들을 스텝(step-finish) 단위 usage 목록으로 파싱한다.
31
132
  * @param {string[]} files 메시지·파트 JSON 파일 경로 목록 (섞여 있어도 됨)
@@ -0,0 +1,161 @@
1
+ import { sumRuntimes, addRuntimeRows } from './timeline.js';
2
+
3
+ // Claude Code 실행시간 추출기. parseClaudeFiles가 읽은 줄을 받아 실행 단위별로 계산한다.
4
+ // - 실행 단위: 메인 세션 파일, subagents/ 아래 에이전트 파일(파일 전체가 한 턴). journal.jsonl은 제외한다.
5
+ // - 이벤트: timestamp가 있는 user·assistant·system·attachment 레코드.
6
+ // - 턴 시작: 메인 파일에서 처음 나온 promptId의 user 레코드. promptId가 없는 옛 파일은 사람이 입력한 질문.
7
+ // - 중복 제거: forkedFrom을 원본까지 따라간 uuid로 한 번만 센다. 원본이 있으면 복사본은 버린다.
8
+
9
+ const SOURCE = 'claude';
10
+ const EVENT_TYPES = new Set(['user', 'assistant', 'system', 'attachment']);
11
+ const INTERRUPTED_PREFIX = '[Request interrupted';
12
+ const LOCAL_STDOUT_PREFIX = '<local-command-stdout>';
13
+ const MAX_FORK_DEPTH = 100;
14
+
15
+ function firstText(content) {
16
+ if (typeof content === 'string') return content;
17
+ if (!Array.isArray(content)) return '';
18
+ const text = content.find((c) => c?.type === 'text')?.text;
19
+ return typeof text === 'string' ? text : '';
20
+ }
21
+
22
+ function isToolResult(obj) {
23
+ const content = obj.message?.content;
24
+ return obj.toolUseResult != null || (Array.isArray(content) && content.some((c) => c?.type === 'tool_result'));
25
+ }
26
+
27
+ /** promptId가 없는 옛 버전의 턴 시작: 사람이 입력한 질문, 작업 완료 알림, 다른 세션 메시지. */
28
+ function isLegacyTurnStart(obj) {
29
+ const kind = obj.origin?.kind;
30
+ if (kind === 'task-notification' || kind === 'peer') return true;
31
+ if (obj.isMeta || obj.isCompactSummary || isToolResult(obj)) return false;
32
+ const text = firstText(obj.message?.content);
33
+ return !text.startsWith(INTERRUPTED_PREFIX) && !text.startsWith(LOCAL_STDOUT_PREFIX);
34
+ }
35
+
36
+ function unitKindOf(file) {
37
+ const path = String(file).replaceAll('\\', '/');
38
+ if (path.split('/').pop() === 'journal.jsonl') return 'journal';
39
+ // 사용자 폴더 이름에 subagents가 있어도 영향받지 않게 projects/ 아래 경로만 본다
40
+ return path.slice(Math.max(path.lastIndexOf('/projects/'), 0)).includes('/subagents/') ? 'agent' : 'main';
41
+ }
42
+
43
+ function nonEmptyString(value) {
44
+ return typeof value === 'string' && value !== '' ? value : undefined;
45
+ }
46
+
47
+ // 에이전트 파일은 가장 이른 기록이, 메인 파일은 처음 나온 promptId(옛 파일은 사람의 질문)가 턴 시작이다.
48
+ function markTurnStarts(unit, events) {
49
+ if (unit.kind === 'agent') {
50
+ let first = null;
51
+ for (const e of events) if (!first || e.t < first.t) first = e;
52
+ if (first) first.start = true;
53
+ return;
54
+ }
55
+ const seen = new Set();
56
+ for (const e of events) {
57
+ if (!unit.hasPromptId) {
58
+ e.start = e.legacyStart === true;
59
+ } else if (e.promptId !== undefined && !seen.has(e.promptId)) {
60
+ seen.add(e.promptId);
61
+ e.start = true;
62
+ }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Claude 실행시간 수집기를 만든다. onRecord를 parseClaudeFiles에 넘겨 같은 읽기에서 계산한다.
68
+ * @returns {{ onRecord: (record: object, file: string) => void,
69
+ * result: () => { source: 'claude', rows: Array<{ date: string, source: string, model: string, runtimeMs: number }>, ok: boolean } }}
70
+ */
71
+ export function createClaudeRuntimeCollector() {
72
+ const totals = new Map();
73
+ const originals = new Set(); // 원본 레코드 uuid
74
+ const copyParents = new Map(); // 복사본 uuid → 가리키는 uuid
75
+ const pending = []; // 복사본이 있어 끝에 원본과 대조할 실행 단위(나머지는 읽는 즉시 합산)
76
+ let unit = null;
77
+ let failed = false;
78
+
79
+ const sumUnit = (target, events) => {
80
+ markTurnStarts(target, events);
81
+ addRuntimeRows(totals, sumRuntimes([{ source: SOURCE, events }]));
82
+ };
83
+
84
+ // 파일이 바뀌면 앞 단위를 마무리한다(parseClaudeFiles는 파일을 하나씩 순서대로 읽는다).
85
+ const closeUnit = () => {
86
+ if (!unit) return;
87
+ if (unit.hasCopies) pending.push(unit);
88
+ else if (unit.kind !== 'journal') sumUnit(unit, unit.events);
89
+ unit = null;
90
+ };
91
+
92
+ // 포크의 포크는 앞 복사본을 가리키므로 원본 uuid까지 따라간다.
93
+ const rootOf = (key) => {
94
+ let current = key;
95
+ for (let hop = 0; hop < MAX_FORK_DEPTH && copyParents.has(current); hop++) current = copyParents.get(current);
96
+ return current;
97
+ };
98
+
99
+ const onRecord = (obj, file) => {
100
+ try {
101
+ if (!EVENT_TYPES.has(obj?.type)) return;
102
+ const t = Date.parse(obj.timestamp);
103
+ if (!Number.isFinite(t)) return;
104
+ if (unit?.file !== file) {
105
+ closeUnit();
106
+ unit = { file, kind: unitKindOf(file), events: [], hasPromptId: false, hasCopies: false };
107
+ }
108
+ if (unit.kind === 'journal') return;
109
+
110
+ const isUser = obj.type === 'user';
111
+ const promptId = isUser ? nonEmptyString(obj.promptId) : undefined;
112
+ if (promptId) unit.hasPromptId = true;
113
+
114
+ const uuid = nonEmptyString(obj.uuid);
115
+ const copyOf = nonEmptyString(obj.forkedFrom?.messageUuid);
116
+ if (copyOf) {
117
+ if (uuid) copyParents.set(uuid, copyOf);
118
+ unit.hasCopies = true;
119
+ } else if (uuid) {
120
+ if (originals.has(uuid)) return; // resume 복사본(같은 uuid)
121
+ originals.add(uuid);
122
+ }
123
+
124
+ const isAssistant = obj.type === 'assistant';
125
+ const event = { t, start: false, reply: isAssistant || (isUser && isToolResult(obj)), model: isAssistant ? obj.message?.model : undefined };
126
+ if (isUser) {
127
+ event.promptId = promptId;
128
+ event.legacyStart = isLegacyTurnStart(obj);
129
+ }
130
+ if (copyOf) event.copyOf = copyOf;
131
+ unit.events.push(event);
132
+ } catch {
133
+ failed = true;
134
+ }
135
+ };
136
+
137
+ const result = () => {
138
+ try {
139
+ closeUnit();
140
+ const keptCopies = new Set();
141
+ for (const waiting of pending) {
142
+ const kept = [];
143
+ for (const e of waiting.events) {
144
+ if (e.copyOf !== undefined) {
145
+ const root = rootOf(e.copyOf);
146
+ if (originals.has(root) || keptCopies.has(root)) continue;
147
+ keptCopies.add(root);
148
+ }
149
+ kept.push(e);
150
+ }
151
+ sumUnit(waiting, kept);
152
+ }
153
+ pending.length = 0;
154
+ return { source: SOURCE, rows: [...totals.values()], ok: !failed };
155
+ } catch {
156
+ return { source: SOURCE, rows: [], ok: false };
157
+ }
158
+ };
159
+
160
+ return { onRecord, result };
161
+ }
@@ -0,0 +1,128 @@
1
+ import { sumRuntimes } from './timeline.js';
2
+
3
+ // Codex 실행시간 추출기. parseCodexFiles가 읽은 rollout 줄을 받아 파일(실행 단위)별 턴으로 모은다.
4
+ // - 파일 신원은 첫 session_meta. 턴은 task_started(turn_started)부터, 그 이벤트가 없는 구버전은 user_message부터.
5
+ // - task_complete(turn_complete)·turn_aborted 뒤 기록은 다음 턴 시작 전까지 세지 않는다.
6
+ // - 포크 복사본은 turn_id가 같아 첫 session_meta가 가장 이른 파일 것만 쓴다.
7
+ // - turn_id가 없는 구버전은 다른 id의 session_meta가 나온 뒤를 복사로 본다.
8
+
9
+ const SOURCE = 'codex';
10
+ const START_EVENTS = new Set(['task_started', 'turn_started']);
11
+ const END_EVENTS = new Set(['task_complete', 'turn_complete', 'turn_aborted']);
12
+ // 모델 응답·도구 호출과 그 결과(response_item.payload.type)
13
+ const REPLY_ITEMS = new Set([
14
+ 'reasoning', 'agent_message', 'function_call', 'function_call_output', 'custom_tool_call', 'custom_tool_call_output',
15
+ 'local_shell_call', 'web_search_call', 'tool_search_call', 'tool_search_output', 'image_generation_call',
16
+ ]);
17
+ // 응답·도구 결과를 알리는 event_msg(legacy 기록 방식)
18
+ const REPLY_EVENTS = new Set([
19
+ 'agent_message', 'agent_reasoning', 'agent_reasoning_raw_content', 'exec_command_end', 'patch_apply_end',
20
+ 'mcp_tool_call_end', 'web_search_end', 'image_generation_end',
21
+ ]);
22
+ const INPUT_ITEMS = new Set(['UserMessage', 'HookPrompt']);
23
+
24
+ function isReply(obj) {
25
+ const payload = obj.payload;
26
+ if (obj.type === 'response_item') {
27
+ return REPLY_ITEMS.has(payload.type) || (payload.type === 'message' && payload.role === 'assistant');
28
+ }
29
+ if (obj.type !== 'event_msg') return false;
30
+ if (payload.type === 'item_completed') return typeof payload.item?.type === 'string' && !INPUT_ITEMS.has(payload.item.type);
31
+ return REPLY_EVENTS.has(payload.type);
32
+ }
33
+
34
+ function nonEmptyString(value) {
35
+ return typeof value === 'string' && value !== '' ? value : null;
36
+ }
37
+
38
+ /**
39
+ * Codex 실행시간 수집기를 만든다. onRecord를 parseCodexFiles에 넘겨 같은 읽기에서 이벤트를 모은다.
40
+ * @returns {{ onRecord: (record: object, file: string) => void,
41
+ * result: () => { source: 'codex', rows: Array<{ date: string, source: string, model: string, runtimeMs: number }>, ok: boolean } }}
42
+ */
43
+ export function createCodexRuntimeCollector() {
44
+ const files = []; // { path, metaId, metaTime, copying, model, inTask, segments, open }
45
+ let current = null;
46
+ let failed = false;
47
+
48
+ const fileOf = (path) => {
49
+ if (current?.path !== path) {
50
+ current = { path, metaId: null, metaTime: Infinity, copying: false, model: null, inTask: false, segments: [], open: null };
51
+ files.push(current);
52
+ }
53
+ return current;
54
+ };
55
+
56
+ const openSegment = (file, t, turnId) => {
57
+ file.open = { turnId, copy: turnId === null && file.copying, events: [{ t, start: true }] };
58
+ file.segments.push(file.open);
59
+ };
60
+
61
+ const onRecord = (obj, path) => {
62
+ try {
63
+ const payload = obj?.payload;
64
+ if (!payload || typeof obj.type !== 'string') return;
65
+ const file = fileOf(path);
66
+ const t = Date.parse(obj.timestamp);
67
+
68
+ if (obj.type === 'session_meta') {
69
+ if (file.metaId === null) {
70
+ file.metaId = nonEmptyString(payload.id) ?? '';
71
+ const metaTime = Date.parse(payload.timestamp);
72
+ file.metaTime = Number.isFinite(metaTime) ? metaTime : (Number.isFinite(t) ? t : Infinity);
73
+ } else if (nonEmptyString(payload.id) && payload.id !== file.metaId) {
74
+ file.copying = true; // 부모 기록 복사가 시작된다
75
+ if (file.open?.turnId === null) file.open = null;
76
+ }
77
+ return;
78
+ }
79
+ if (!Number.isFinite(t)) return;
80
+ if (obj.type === 'turn_context' && nonEmptyString(payload.model)) file.model = payload.model;
81
+
82
+ if (obj.type === 'event_msg' && START_EVENTS.has(payload.type)) {
83
+ openSegment(file, t, nonEmptyString(payload.turn_id));
84
+ file.inTask = true;
85
+ return;
86
+ }
87
+ if (obj.type === 'event_msg' && payload.type === 'user_message' && !file.inTask) {
88
+ openSegment(file, t, null);
89
+ return;
90
+ }
91
+ if (!file.open) return;
92
+
93
+ const reply = isReply(obj);
94
+ file.open.events.push({ t, start: false, reply, model: reply ? file.model : undefined });
95
+ if (obj.type === 'event_msg' && END_EVENTS.has(payload.type)) {
96
+ file.open = null;
97
+ file.inTask = false;
98
+ }
99
+ } catch {
100
+ failed = true;
101
+ }
102
+ };
103
+
104
+ const result = () => {
105
+ try {
106
+ // 같은 turn_id는 첫 session_meta가 가장 이른 파일(같으면 먼저 읽은 파일)이 주인이다.
107
+ const owners = new Map();
108
+ files.forEach((file, index) => {
109
+ for (const segment of file.segments) {
110
+ if (segment.turnId === null) continue;
111
+ const owner = owners.get(segment.turnId);
112
+ if (owner === undefined || file.metaTime < files[owner].metaTime) owners.set(segment.turnId, index);
113
+ }
114
+ });
115
+ const units = files.map((file, index) => ({
116
+ source: SOURCE,
117
+ events: file.segments
118
+ .filter((segment) => (segment.turnId === null ? !segment.copy : owners.get(segment.turnId) === index))
119
+ .flatMap((segment) => segment.events),
120
+ }));
121
+ return { source: SOURCE, rows: sumRuntimes(units), ok: !failed };
122
+ } catch {
123
+ return { source: SOURCE, rows: [], ok: false };
124
+ }
125
+ };
126
+
127
+ return { onRecord, result };
128
+ }
@@ -0,0 +1,97 @@
1
+ import { sumRuntimes } from './timeline.js';
2
+
3
+ // opencode 실행시간 추출기. 토큰 파서가 연 opencode.db 연결로 assistant 메시지의 생성·완료 시각을 읽는다.
4
+ // - 실행 단위는 세션(자식 세션 포함). 턴은 같은 parentID의 assistant 묶음이고 첫 assistant 생성 시각에 시작한다.
5
+ // - step-start 파트가 없는 메시지(사용자 셸 명령)와 time.completed가 없는 메시지는 뺀다.
6
+ // - 포크 복사본은 (role, 생성·완료 시각, modelID)가 같아 가장 먼저 만들어진 세션 것만 쓴다.
7
+
8
+ const SOURCE = 'opencode';
9
+ const REQUIRED_TABLES = ['session', 'message', 'part'];
10
+
11
+ const MESSAGE_QUERY = `
12
+ SELECT
13
+ m.session_id AS sessionId,
14
+ s.time_created AS sessionCreated,
15
+ json_extract(m.data, '$.parentID') AS parentId,
16
+ json_extract(m.data, '$.modelID') AS model,
17
+ json_extract(m.data, '$.time.created') AS created,
18
+ json_extract(m.data, '$.time.completed') AS completed
19
+ FROM message m
20
+ LEFT JOIN session s ON s.id = m.session_id
21
+ WHERE json_extract(m.data, '$.role') = 'assistant'
22
+ AND json_extract(m.data, '$.time.completed') IS NOT NULL
23
+ AND EXISTS (SELECT 1 FROM part p WHERE p.message_id = m.id AND json_extract(p.data, '$.type') = 'step-start')
24
+ `;
25
+
26
+ function tablesOf(db) {
27
+ const rows = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
28
+ return new Set(rows.map((row) => row.name));
29
+ }
30
+
31
+ function isEarlierSession(a, b) {
32
+ const ta = a.sessionCreated ?? Infinity;
33
+ const tb = b.sessionCreated ?? Infinity;
34
+ return ta < tb || (ta === tb && String(a.sessionId) < String(b.sessionId));
35
+ }
36
+
37
+ // 복사본을 걸러 세션별 실행 단위로 묶는다. 턴마다 메시지 생성(시작)·완료(응답) 이벤트를 만든다.
38
+ function unitsOf(rows) {
39
+ const owners = new Map();
40
+ for (const row of rows) {
41
+ if (!Number.isFinite(row.created) || !Number.isFinite(row.completed)) continue;
42
+ const key = `assistant\u0000${row.created}\u0000${row.completed}\u0000${row.model}`;
43
+ const owner = owners.get(key);
44
+ if (!owner || isEarlierSession(row, owner)) owners.set(key, row);
45
+ }
46
+
47
+ const sessions = new Map(); // 세션 → (parentID → 메시지 목록)
48
+ for (const row of owners.values()) {
49
+ let turns = sessions.get(row.sessionId);
50
+ if (!turns) sessions.set(row.sessionId, (turns = new Map()));
51
+ const turnKey = row.parentId ?? `\u0000${row.created}`; // parentID가 없으면 메시지 하나가 한 턴
52
+ const messages = turns.get(turnKey);
53
+ if (messages) messages.push(row);
54
+ else turns.set(turnKey, [row]);
55
+ }
56
+
57
+ return Array.from(sessions.values(), (turns) => ({
58
+ source: SOURCE,
59
+ events: [...turns.values()].flatMap((messages) => messages
60
+ .sort((a, b) => a.created - b.created)
61
+ .flatMap((m, i) => [
62
+ { t: m.created, start: i === 0, model: m.model },
63
+ { t: m.completed, reply: true, model: m.model },
64
+ ])),
65
+ }));
66
+ }
67
+
68
+ /**
69
+ * opencode 실행시간 수집기를 만든다. onDatabase를 parseOpencodeDatabase에 넘겨 같은 연결로 읽는다.
70
+ * @returns {{ onDatabase: (db: object) => void,
71
+ * result: () => { source: 'opencode', rows: Array<{ date: string, source: string, model: string, runtimeMs: number }>, ok: boolean } }}
72
+ */
73
+ export function createOpencodeRuntimeCollector() {
74
+ let rows = null; // DB를 열지 못했으면 null로 남는다
75
+ let ok = false;
76
+
77
+ const onDatabase = (db) => {
78
+ try {
79
+ const tables = tablesOf(db);
80
+ if (tables.size === 0) {
81
+ rows = []; // 기록이 아직 없는 빈 DB
82
+ } else if (REQUIRED_TABLES.some((name) => !tables.has(name))) {
83
+ throw new Error('opencode.db schema changed');
84
+ } else {
85
+ rows = sumRuntimes(unitsOf(db.prepare(MESSAGE_QUERY).all()));
86
+ }
87
+ ok = true;
88
+ } catch {
89
+ rows = [];
90
+ ok = false;
91
+ }
92
+ };
93
+
94
+ const result = () => ({ source: SOURCE, rows: rows ?? [], ok: rows !== null && ok });
95
+
96
+ return { onDatabase, result };
97
+ }
@@ -0,0 +1,46 @@
1
+ import { parseClaudeFiles } from '../parsers/claude.js';
2
+ import { parseCodexFiles } from '../parsers/codex.js';
3
+ import { parseOpencodeDatabase, parseOpencodeFiles } from '../parsers/opencode.js';
4
+ import { createClaudeRuntimeCollector } from './claude.js';
5
+ import { createCodexRuntimeCollector } from './codex.js';
6
+ import { createOpencodeRuntimeCollector } from './opencode.js';
7
+
8
+ /**
9
+ * 소스별 실행시간 줄을 이어 붙이고 읽기 성공 여부와 읽지 못한 소스를 모은다. 줄마다 source가 달라 합칠 줄은 없다.
10
+ * @param {Array<{ source: string, rows: Array<{ date: string, source: string, model: string, runtimeMs: number }>, ok: boolean }>} results 로그가 있는 소스의 결과
11
+ * @param {string|null} fromDate YYYY-MM-DD(포함). null이면 전체 기간
12
+ */
13
+ function mergeRuntimeResults(results, fromDate = null) {
14
+ const runtimes = results.flatMap((result) => result.rows).filter((row) => !fromDate || row.date >= fromDate);
15
+ const failedSources = results.filter((result) => !result.ok).map((result) => result.source);
16
+ return { runtimes, allSourcesRead: failedSources.length === 0, failedSources };
17
+ }
18
+
19
+ /**
20
+ * 찾은 로그를 소스마다 한 번씩 읽어 토큰 항목과 실행시간을 함께 모은다.
21
+ * Claude·Codex 파일을 열 수 없으면 예외를 올린다.
22
+ * @param {{ claudeFiles: string[], codexFiles: string[], opencodeDatabase: string|null, opencodeFiles: string[] }} paths
23
+ * @param {string|null} [runtimeFrom] 실행시간 시작 날짜 YYYY-MM-DD(포함). null이면 전체 기간
24
+ * @returns {Promise<{ entries: Array<object>, runtimes: Array<{ date: string, source: string, model: string, runtimeMs: number }>, allSourcesRead: boolean }>}
25
+ */
26
+ export async function readLogs(paths, runtimeFrom = null) {
27
+ const claude = createClaudeRuntimeCollector();
28
+ const codex = createCodexRuntimeCollector();
29
+ const opencode = createOpencodeRuntimeCollector();
30
+
31
+ const [claudeEntries, codexEntries, opencodeEntries] = await Promise.all([
32
+ parseClaudeFiles(paths.claudeFiles, claude.onRecord),
33
+ parseCodexFiles(paths.codexFiles, codex.onRecord),
34
+ paths.opencodeDatabase
35
+ ? parseOpencodeDatabase(paths.opencodeDatabase, undefined, opencode.onDatabase)
36
+ : parseOpencodeFiles(paths.opencodeFiles),
37
+ ]);
38
+
39
+ // 로그가 없는 소스(미설치)는 성공 조건에서 뺀다. opencode 옛 JSON 저장소는 실행시간 범위 밖이다.
40
+ const results = [];
41
+ if (paths.claudeFiles.length > 0) results.push(claude.result());
42
+ if (paths.codexFiles.length > 0) results.push(codex.result());
43
+ if (paths.opencodeDatabase) results.push(opencode.result());
44
+
45
+ return { entries: [...claudeEntries, ...codexEntries, ...opencodeEntries], ...mergeRuntimeResults(results, runtimeFrom) };
46
+ }
@@ -0,0 +1,117 @@
1
+ import { localDateOf } from '../lib/dates.js';
2
+
3
+ // 공통 타임라인 합산기. 세 소스의 추출기가 만든 실행 단위를 같은 규칙으로 더한다.
4
+ // - 실행 단위마다 이벤트를 시간순으로 정렬하고, 턴(시작 ~ 마지막 응답·도구 결과) 안의 간격만 센다.
5
+ // - 간격 하나는 시작 시점부터 GAP_CAP_MS까지만 센다.
6
+ // - 로컬 자정에서 나눠 날짜별로 넣고, 간격이 시작될 때 일하던 모델에 귀속한다.
7
+
8
+ /** 간격 하나에서 세는 최대 길이(ms). */
9
+ const GAP_CAP_MS = 300_000;
10
+
11
+ /**
12
+ * 실행시간 계산용 이벤트.
13
+ * @typedef {object} RuntimeEvent
14
+ * @property {number} t 시각(epoch ms)
15
+ * @property {boolean} [start] 턴 시작
16
+ * @property {boolean} [reply] AI 응답 또는 도구 결과(턴 끝 후보)
17
+ * @property {string|null} [model] 이 이벤트에서 일한 모델
18
+ */
19
+
20
+ /**
21
+ * 메인 세션·서브에이전트처럼 따로 계산해서 더하는 실행 단위.
22
+ * @typedef {{ source: 'claude'|'codex'|'opencode', events: RuntimeEvent[] }} RuntimeUnit
23
+ */
24
+
25
+ /** <synthetic>처럼 '<'로 시작하는 값은 모델로 치지 않는다(토큰 파서와 같은 기준). */
26
+ function isRealModel(model) {
27
+ return typeof model === 'string' && model !== '' && !model.startsWith('<');
28
+ }
29
+
30
+ // (날짜, 소스, 모델)별 누적기. 직전에 쓴 날짜 범위를 캐시해 자정 계산을 줄인다.
31
+ function createBuckets() {
32
+ const totals = new Map();
33
+ let dayStart = Infinity;
34
+ let dayEnd = -Infinity;
35
+ let date = '';
36
+
37
+ // 자정은 달력 연산으로 구해 서머타임 전환일(23·25시간)도 맞게 나눈다.
38
+ const moveTo = (ms) => {
39
+ const d = new Date(ms);
40
+ dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
41
+ dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
42
+ date = localDateOf(d);
43
+ };
44
+
45
+ const add = (source, model, from, to) => {
46
+ for (let a = from; a < to;) {
47
+ if (a < dayStart || a >= dayEnd) moveTo(a);
48
+ const b = dayEnd > a ? Math.min(to, dayEnd) : to; // 날짜 계산이 어긋나도 반복이 멈추게 한다
49
+ const key = `${date}\u0000${source}\u0000${model}`;
50
+ const row = totals.get(key);
51
+ if (row) row.runtimeMs += b - a;
52
+ else totals.set(key, { date, source, model, runtimeMs: b - a });
53
+ a = b;
54
+ }
55
+ };
56
+
57
+ const rows = () => Array.from(totals.values(), (row) => ({ ...row, runtimeMs: Math.round(row.runtimeMs) }))
58
+ .filter((row) => row.runtimeMs > 0);
59
+
60
+ return { add, rows };
61
+ }
62
+
63
+ // 턴 [from, to] 안의 간격을 더하고, 다음 턴에 넘길 마지막 실제 모델을 돌려준다.
64
+ // 첫 간격은 첫 응답의 모델, 이후 간격은 간격이 시작될 때의 모델에 넣는다.
65
+ function sumTurn(events, from, to, carried, source, buckets) {
66
+ let model = carried;
67
+ for (let k = from; k <= to; k++) {
68
+ if (isRealModel(events[k].model)) { model = events[k].model; break; }
69
+ }
70
+ for (let k = from; k < to; k++) {
71
+ if (isRealModel(events[k].model)) model = events[k].model;
72
+ if (model === null) continue; // 모델을 알 수 없는 시간은 귀속할 곳이 없다
73
+ const begin = events[k].t;
74
+ buckets.add(source, model, begin, Math.min(events[k + 1].t, begin + GAP_CAP_MS));
75
+ }
76
+ return isRealModel(events[to].model) ? events[to].model : model;
77
+ }
78
+
79
+ function sumUnit(unit, buckets) {
80
+ const events = (unit.events ?? []).filter((e) => Number.isFinite(e?.t)).sort((x, y) => x.t - y.t);
81
+ let carried = null;
82
+ let i = events.findIndex((e) => e.start);
83
+ if (i < 0) return;
84
+ while (i < events.length) {
85
+ let next = i + 1;
86
+ while (next < events.length && !events[next].start) next++;
87
+ let last = next - 1;
88
+ while (last >= i && !events[last].reply) last--;
89
+ if (last >= i) carried = sumTurn(events, i, last, carried, unit.source, buckets);
90
+ i = next;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 실행시간 줄을 (날짜, 소스, 모델) 키로 더해 모은다.
96
+ * @param {Map<string, { date: string, source: string, model: string, runtimeMs: number }>} totals 누적 대상
97
+ * @param {Array<{ date: string, source: string, model: string, runtimeMs: number }>} rows
98
+ */
99
+ export function addRuntimeRows(totals, rows) {
100
+ for (const row of rows) {
101
+ const key = `${row.date}\u0000${row.source}\u0000${row.model}`;
102
+ const current = totals.get(key);
103
+ if (current) current.runtimeMs += row.runtimeMs;
104
+ else totals.set(key, { ...row });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * 실행 단위들의 이벤트로 (날짜, 소스, 모델)별 실행시간을 계산한다.
110
+ * @param {RuntimeUnit[]} units
111
+ * @returns {Array<{ date: string, source: string, model: string, runtimeMs: number }>} runtimeMs는 0 초과 정수
112
+ */
113
+ export function sumRuntimes(units) {
114
+ const buckets = createBuckets();
115
+ for (const unit of units) sumUnit(unit, buckets);
116
+ return buckets.rows();
117
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * sync 흐름: 기준 날짜·버전 판단 → 파싱 → 전송 → 설정 저장.
3
+ * index.js는 불러오는 순간 CLI가 시동되어 테스트할 수 없으므로 흐름을 이 모듈로 분리했다.
4
+ */
5
+ import { loadConfig, saveConfig } from './auth.js';
6
+ import { syncRecords, printWarning, CLI_VERSION } from './sync.js';
7
+ import { localDateOf } from './lib/dates.js';
8
+
9
+ /**
10
+ * 로컬 로그를 파싱해 토큰·실행시간을 서버로 보내고, 모든 묶음이 끝난 뒤 설정을 한 번 저장한다.
11
+ * 토큰은 lastSyncDate부터, 실행시간은 lastSyncVersion이 지금 버전과 같을 때만 lastSyncDate부터(다르면 전체) 보낸다.
12
+ * parseLogs는 호출부가 넘기고 나머지 의존성은 실제 구현이 기본값이다.
13
+ * @param {{apiBase: string, token: string, deviceId: string, parseLogs: Function,
14
+ * readConfig?: Function, writeConfig?: Function, fetchImpl?: Function, version?: string,
15
+ * today?: string, log?: Function, interactive?: boolean}} deps
16
+ * @throws {Error} 파싱 실패나 첫 요청 실패 시(설정은 바꾸지 않는다)
17
+ */
18
+ export async function runSyncFlow({
19
+ apiBase,
20
+ token,
21
+ deviceId,
22
+ parseLogs,
23
+ readConfig = loadConfig,
24
+ writeConfig = saveConfig,
25
+ fetchImpl = globalThis.fetch,
26
+ version = CLI_VERSION,
27
+ today = localDateOf(new Date()), // 시작 시 1회 계산: 자정을 넘겨도 시작일을 저장한다
28
+ log = console.log,
29
+ interactive = Boolean(process.stdin.isTTY),
30
+ }) {
31
+ const warn = (msg) => printWarning(msg, { interactive, log });
32
+ const { lastSyncDate = null, lastSyncVersion } = readConfig();
33
+ const tokenFrom = lastSyncDate;
34
+ const runtimeFrom = lastSyncVersion === version ? tokenFrom : null; // 버전이 다르면 실행시간은 전체 기간
35
+
36
+ log(`Parsing local logs... (${tokenFrom ?? 'beginning'} ~ ${today})`);
37
+ const { records, runtimes, allSourcesRead, failedSources = [] } = await parseLogs({ tokenFrom, runtimeFrom });
38
+ log(`${records.length} records aggregated (date×model)`);
39
+ log(`${runtimes.length} runtime records aggregated (date×source×model)`);
40
+ if (!allSourcesRead) warn(`Could not read ${failedSources.join(', ') || 'some'} logs. They were skipped in this sync.`);
41
+
42
+ const hasData = records.length > 0 || runtimes.length > 0;
43
+ let runtimesOk = true;
44
+ if (hasData) {
45
+ log('Uploading to server...');
46
+ const result = await syncRecords(apiBase, token, deviceId, records, runtimes, { fetchImpl, onNotice: warn });
47
+ if (result.rejected > 0) warn(`${result.rejected} runtime record(s) were rejected by the server.`);
48
+ if (result.failure) warn(`Runtime upload incomplete, it will be resent on the next sync: ${result.failure}`);
49
+ else if (!result.runtimesOk) warn('The server did not confirm runtime records. They will be resent on the next sync.'); // API 롤백으로 실행시간을 모르는 옛 서버를 만난 경우
50
+ runtimesOk = result.runtimesOk;
51
+ } else {
52
+ log('Nothing to sync.');
53
+ }
54
+
55
+ // 모든 묶음 뒤 1회 저장. 전송이 성공하면 버전을 남긴다(못 읽은 도구로 지우면 매일 전체 재전송)
56
+ const { lastSyncVersion: _previous, ...rest } = readConfig();
57
+ writeConfig(runtimesOk
58
+ ? { ...rest, lastSyncDate: today, lastSyncVersion: version }
59
+ : { ...rest, lastSyncDate: today });
60
+ if (hasData) log('Done! Badge will update shortly.');
61
+ }
package/src/sync.js CHANGED
@@ -1,3 +1,17 @@
1
+ import chalk from 'chalk';
2
+ import { createRequire } from 'node:module';
3
+
4
+ // package.json의 version: 이름표와 lastSyncVersion에 쓴다
5
+ export const CLI_VERSION = createRequire(import.meta.url)('../package.json').version;
6
+
7
+ // 모든 API 요청에 붙이는 이름표
8
+ const USER_AGENT = `tokenphage-cli/${CLI_VERSION} (${process.platform}; ${process.arch}) node/${process.versions.node}`;
9
+
10
+ // 서버가 한 요청에 받는 실행시간 최대 개수(SyncRequest.MAX_RECORDS)
11
+ const MAX_RUNTIMES_PER_REQUEST = 10_000;
12
+
13
+ const UPDATE_HINT = 'Update: npm i -g tokenphage@latest';
14
+
1
15
  /**
2
16
  * 서버 HTTP 상태코드를 사용자 대면 메시지로 매핑한다(응답 원문은 노출하지 않음).
3
17
  * @param {number} status HTTP 상태코드
@@ -11,27 +25,105 @@ export function mapServerError(status, bodyText) {
11
25
  if (lower.includes('deviceid')) return 'Your config file looks corrupted. Run `tokenphage login` to re-authenticate.';
12
26
  return 'Request format error. Update the CLI: npm i -g tokenphage@latest';
13
27
  }
28
+ if (status === 410 || status === 426) return `This CLI version is no longer supported. ${UPDATE_HINT}`; // 지원 종료
14
29
  if (status >= 500) return 'Server temporarily unavailable. Your existing badge data is safe.';
15
30
  return `Server error: ${status}`; // 매핑되지 않은 상태 폴백
16
31
  }
17
32
 
33
+ /** 경고 1줄을 남긴다. 대화형은 노란색, 헤드리스는 평문(스케줄러 래퍼가 stdout을 sync.log로 보낸다). */
34
+ export function printWarning(msg, { interactive = Boolean(process.stdin.isTTY), log = console.log } = {}) {
35
+ const line = `! ${msg}`;
36
+ log(interactive ? chalk.yellow(line) : line);
37
+ }
38
+
39
+ /** Link 헤더에서 rel="deprecation" 주소를 찾는다. 없으면 null. */
40
+ function deprecationLink(link) {
41
+ for (const part of (link ?? '').split(/,(?=\s*<)/)) {
42
+ const [, url, params = ''] = part.match(/<([^>]*)>(.*)/) ?? [];
43
+ const rel = params.match(/;\s*rel\s*=\s*"?([^";]*)/i)?.[1] ?? '';
44
+ if (url && rel.toLowerCase().split(/\s+/).includes('deprecation')) return url;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** 응답의 Deprecation·Sunset·Link(rel="deprecation") 헤더로 지원 종료 안내 1줄을 만든다. 없으면 null. */
50
+ function deprecationNotice(headers) {
51
+ if (typeof headers?.get !== 'function') return null; // 헤더가 없는 응답도 통과
52
+ const deprecation = headers.get('deprecation');
53
+ const sunset = headers.get('sunset');
54
+ const link = deprecationLink(headers.get('link'));
55
+ if (!deprecation && !sunset && !link) return null;
56
+ const until = sunset ? ` and stops working after ${sunset}` : '';
57
+ const details = link ? ` (details: ${link})` : '';
58
+ return `This CLI version is deprecated${until}. ${UPDATE_HINT}${details}`;
59
+ }
60
+
18
61
  /**
19
- * 집계된 토큰 사용 기록을 서버에 업로드한다 (POST /api/sync).
20
- * 사용자는 서버가 JWT(Bearer)에서 식별하고, 본문에 deviceId와 records를 담아 보낸다.
62
+ * API 공통 요청: 이름표(User-Agent)를 붙이고, 응답에 지원 종료 헤더가 있으면 onNotice로 안내한다.
63
+ * @param {string} url
64
+ * @param {object} init fetch 옵션
65
+ * @param {{fetchImpl?: Function, onNotice?: (msg: string) => void}} [deps]
66
+ * @returns {Promise<Response>}
67
+ */
68
+ export async function apiFetch(url, init, { fetchImpl = globalThis.fetch, onNotice = printWarning } = {}) {
69
+ const res = await fetchImpl(url, { ...init, headers: { ...init.headers, 'User-Agent': USER_AGENT } });
70
+ const notice = deprecationNotice(res.headers);
71
+ if (notice) onNotice(notice);
72
+ return res;
73
+ }
74
+
75
+ /** 2xx 응답 본문의 실행시간 결과({accepted, rejected})를 읽는다. 없거나 형식이 다르면 null. */
76
+ async function runtimeResult(res) {
77
+ try {
78
+ const result = JSON.parse(await res.text())?.runtimes;
79
+ return Number.isInteger(result?.accepted) && Number.isInteger(result?.rejected) ? result : null;
80
+ } catch {
81
+ return null; // 빈 본문(API 롤백으로 만난 옛 API)·비JSON
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 토큰 레코드와 실행시간을 서버에 업로드한다 (POST /api/sync).
87
+ * 토큰은 첫 요청에 모두 싣고, 실행시간은 10,000개씩 나눠 남은 묶음을 records: []와 함께 보낸다.
88
+ * 실행시간이 없으면 runtimes 칸을 빼 지금과 같은 {deviceId, records}를 보낸다.
21
89
  * @param {string} apiBase API 서버 base URL
22
90
  * @param {string} token Bearer JWT
23
91
  * @param {string} deviceId 기기 식별 UUID
24
92
  * @param {Array<{date:string, model:string, inputTok:number, outputTok:number, cacheReadTok:number, cacheCreateTok:number}>} records 날짜·모델별 집계 레코드
25
- * @throws {Error} 2xx가 아니면 mapServerError 메시지로 throw
93
+ * @param {Array<{date:string, source:string, model:string, runtimeMs:number}>} [runtimes] 날짜·도구·모델별 실행시간
94
+ * @param {{fetchImpl?: Function, onNotice?: (msg: string) => void}} [deps]
95
+ * @returns {Promise<{runtimesOk: boolean, rejected: number, failure: string|null}>}
96
+ * runtimesOk는 실행시간을 모두 보냈고 응답마다 결과가 있을 때 true, failure는 후속 요청 실패 사유
97
+ * @throws {Error} 첫 요청이 2xx가 아니면 mapServerError 메시지로 throw
26
98
  */
27
- export async function syncRecords(apiBase, token, deviceId, records) {
28
- // Node 18+ 전역 fetch 사용 (node-fetch 의존성 불필요).
29
- const res = await fetch(`${apiBase}/api/sync`, {
99
+ export async function syncRecords(apiBase, token, deviceId, records, runtimes = [], { fetchImpl, onNotice = printWarning } = {}) {
100
+ let noticed = false; // 묶음이 여러 개여도 지원 종료 안내는 1줄
101
+ const post = (body) => apiFetch(`${apiBase}/api/sync`, {
30
102
  method: 'POST',
31
103
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
32
- body: JSON.stringify({ deviceId, records }),
33
- });
104
+ body: JSON.stringify(body),
105
+ }, { fetchImpl, onNotice: (msg) => { if (!noticed) onNotice(msg); noticed = true; } });
106
+
107
+ // 첫 요청: 토큰 전부와 첫 실행시간 묶음. 실패하면 지금처럼 throw
108
+ const first = runtimes.slice(0, MAX_RUNTIMES_PER_REQUEST);
109
+ const res = await post(first.length ? { deviceId, records, runtimes: first } : { deviceId, records });
34
110
  if (!res.ok) throw new Error(mapServerError(res.status, await res.text()));
111
+ if (!first.length) return { runtimesOk: true, rejected: 0, failure: null };
112
+
113
+ let result = await runtimeResult(res);
114
+ let rejected = result?.rejected ?? 0;
115
+ // 후속 요청: 남은 실행시간만. 결과가 없거나 실패하면 멈추고 미완료로 돌려준다
116
+ for (let i = MAX_RUNTIMES_PER_REQUEST; result && i < runtimes.length; i += MAX_RUNTIMES_PER_REQUEST) {
117
+ try {
118
+ const next = await post({ deviceId, records: [], runtimes: runtimes.slice(i, i + MAX_RUNTIMES_PER_REQUEST) });
119
+ if (!next.ok) return { runtimesOk: false, rejected, failure: mapServerError(next.status, await next.text()) };
120
+ result = await runtimeResult(next);
121
+ } catch (err) {
122
+ return { runtimesOk: false, rejected, failure: err.message };
123
+ }
124
+ rejected += result?.rejected ?? 0;
125
+ }
126
+ return { runtimesOk: result !== null, rejected, failure: null };
35
127
  }
36
128
 
37
129
  /**
@@ -40,7 +132,7 @@ export async function syncRecords(apiBase, token, deviceId, records) {
40
132
  * @param {string} token Bearer JWT
41
133
  */
42
134
  export async function resetData(apiBase, token) {
43
- const res = await fetch(`${apiBase}/api/reset`, {
135
+ const res = await apiFetch(`${apiBase}/api/reset`, {
44
136
  method: 'POST',
45
137
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
46
138
  });