tokenbill-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # Tokenbill (토큰빌) — AI API 비용 대시보드
2
+
3
+ OpenAI·Anthropic API 사용 비용과 토큰을 한 화면에서 추적하는 서비스.
4
+ FastAPI + SQLite + 바닐라 JS 프론트엔드(단일 HTML).
5
+
6
+ ## 실행 방법
7
+
8
+ ```bash
9
+ pip install -r requirements.txt
10
+ cp .env.example .env # SECRET_KEY를 긴 랜덤 문자열로 변경
11
+ export $(cat .env | xargs) # 또는 환경변수로 직접 설정
12
+ uvicorn app.main:app --reload
13
+ ```
14
+
15
+ http://localhost:8000 접속 → 회원가입 → 프로바이더 키 등록.
16
+
17
+ - **체험(데모)**: 키에 `demo` 로 시작하는 아무 값이나 넣으면 가짜 사용 데이터가 생성됩니다.
18
+ - **실제 연동**: OpenAI는 조직 **Admin 키**(`sk-admin-…`, platform.openai.com → Organization → Admin Keys),
19
+ Anthropic도 **Admin 키**(`sk-ant-admin-…`, console.anthropic.com → Settings → Admin Keys)가 필요합니다.
20
+ 일반 API 키로는 사용량 조회가 안 됩니다.
21
+ - Google AI는 Cloud Billing 연동이 필요해서 MVP에서는 미지원(데모만 가능).
22
+ - **다중 조직**: 프로바이더당 키를 여러 개(조직별 이름 붙여서) 등록할 수 있고,
23
+ 대시보드에서 조직별 이번 달 비용이 나뉘어 보입니다. 차트·합계는 전체 조직 합산 기준.
24
+ - **프로젝트 드릴다운**: 조직 행을 클릭하면 프로젝트(OpenAI Project / Anthropic
25
+ Workspace) 단위로 펼쳐지고, 프로젝트마다 모델별 비용·토큰이 표시됩니다.
26
+ - **구글 로그인** (선택): `GOOGLE_CLIENT_ID` 환경변수를 설정하면 로그인 화면에
27
+ "Google로 계속하기" 버튼이 나타납니다. Google Cloud Console에서 OAuth 클라이언트
28
+ ID(웹)를 만들고, 승인된 JavaScript 출처에 서비스 도메인(https)을 등록해야 합니다.
29
+ 도메인 없이 공인 IP로는 Google 정책상 동작하지 않습니다.
30
+
31
+ API 문서: http://localhost:8000/docs (FastAPI 자동 생성)
32
+
33
+ ## 구조
34
+
35
+ ```
36
+ app/
37
+ main.py # FastAPI 앱, 라우트, 스케줄러(매일 03시 KST 자동 수집)
38
+ models.py # User / ProviderKey / UsageDaily (날짜×프로바이더×모델 요약)
39
+ security.py # JWT 인증, bcrypt 해시, API 키 Fernet 암호화
40
+ collector.py # 수집 오케스트레이션, 수동 갱신 쿨다운(10분)
41
+ providers/
42
+ collectors.py # OpenAI/Anthropic usage API 호출 + 데모 생성기
43
+ prices.py # 모델별 단가표 (비용 = 토큰 × 단가 근사)
44
+ static/index.html # 프론트엔드 (로그인 + 대시보드)
45
+ ```
46
+
47
+ ## 설계 메모
48
+
49
+ - **저장 최소화**: 원본 로그는 프로바이더에 두고, "날짜 × 프로바이더 × 모델 × 비용/토큰"
50
+ 요약 행만 보관. 사용자당 하루 수십 행 수준.
51
+ - **갱신 정책**: 매일 1회 자동(APScheduler) + "지금 갱신" 수동(10분 쿨다운).
52
+ 프로바이더 과금 데이터 자체가 지연 반영이라 실시간성은 목표가 아님.
53
+ - **비용 계산**: 금액은 프로바이더 **cost API 실측값** 기준.
54
+ usage API(토큰·모델별)로 분해를 만들고, 모델별 근사 비용을 cost API의 일 총액에 맞게
55
+ 비례 보정한다 → 합계는 항상 실제 청구 금액과 일치. cost API 호출이 실패하면
56
+ `prices.py` 단가표 근사값으로 폴백. 단가표는 "싼 모델 절약 시뮬레이션" 등에 계속 사용
57
+ (자동 수집으로 대체 예정 — LiteLLM의 model_prices JSON 참고).
58
+ - **키 보안**: API 키는 SECRET_KEY에서 유도한 Fernet 키로 암호화 저장, 화면에는 마스킹만 노출.
59
+
60
+ ## 배포 — 실제 운영 구성 (EC2 + Docker + GitHub Actions)
61
+
62
+ 현재 운영: AWS EC2(Ubuntu 24.04, `ubuntu@52.79.213.236`)에서 **docker**로 실행.
63
+ `main`에 푸시하면 GitHub Actions([build.yml](.github/workflows/build.yml))가
64
+ 이미지를 빌드해 `ghcr.io/jonghoon5922/tokenbill:latest`로 올린다 — 서버에서 빌드하지 않는다.
65
+
66
+ - SECRET_KEY: 서버의 `~/.tokenbill-secret` 파일에 보관 (한 줄)
67
+ - DB: `tokenbill-data` 도커 볼륨 → 컨테이너 `/data/tokenbill.db` (재배포해도 유지)
68
+
69
+ ### 새 버전 배포 절차
70
+
71
+ ```bash
72
+ # 0) (스키마 변경이 있는 배포면) DB 백업
73
+ docker cp tokenbill:/data /home/ubuntu/tokenbill-data-backup-$(date +%Y%m%d)
74
+
75
+ # 1) GitHub Actions 빌드 완료 확인 후 pull
76
+ docker pull ghcr.io/jonghoon5922/tokenbill:latest
77
+
78
+ # 2) 컨테이너 교체 (볼륨·시크릿 유지)
79
+ docker stop tokenbill && docker rm tokenbill
80
+ docker run -d --name tokenbill -p 8000:8000 -v tokenbill-data:/data \
81
+ -e SECRET_KEY="$(cat ~/.tokenbill-secret)" --restart unless-stopped \
82
+ ghcr.io/jonghoon5922/tokenbill:latest
83
+
84
+ # 3) 확인
85
+ docker logs --tail 30 tokenbill
86
+ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/
87
+ ```
88
+
89
+ 롤백: `ghcr.io/jonghoon5922/tokenbill:<커밋 SHA>` 태그로 같은 절차 반복 + 백업 복원.
90
+
91
+ 주의: `SECRET_KEY`는 한 번 정하면 **바꾸지 말 것** — 이 키로 프로바이더 API 키를
92
+ 암호화하므로, 바뀌면 저장된 키를 복호화할 수 없다.
93
+
94
+ ### 첫 서버 세팅 (참고)
95
+
96
+ ```bash
97
+ openssl rand -hex 32 > ~/.tokenbill-secret && chmod 600 ~/.tokenbill-secret
98
+ docker volume create tokenbill-data
99
+ # 이후 위 "컨테이너 교체" 절차의 run 명령과 동일
100
+ ```
101
+
102
+ ### HTTPS
103
+
104
+ 외부 공개 시 앞단에 Caddy나 nginx를 두는 것을 권장.
105
+ Caddy면 `Caddyfile`에 `내도메인.com { reverse_proxy localhost:8000 }` 두 줄로 끝.
106
+
107
+ 사용자가 늘면 `DATABASE_URL` 환경변수로 Postgres 전환 가능 (드라이버 추가 필요).
108
+
109
+ ## 다음 단계 아이디어
110
+
111
+ - 예산 초과 시 이메일/텔레그램 알림 (스케줄러에서 체크)
112
+ - 프로바이더 cost API 연동으로 정확한 청구 금액 표시
113
+ - "한 단계 싼 모델로 바꾸면 월 $X 절약" 시뮬레이션
114
+ - Google (Cloud Billing), 기타 프로바이더 추가
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "tokenbill-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Tokenbill MCP — Claude Code·Codex·Gemini CLI 구독 토큰을 tokenbill.my 리더보드에 자동 집계하고, 로컬 대화 뷰어(--viewer)를 제공합니다. Auto-uploads your AI subscription token usage to the tokenbill.my leaderboard, with a local transcript viewer.",
5
+ "bin": {
6
+ "tokenbill-mcp": "./uploader/index.js"
7
+ },
8
+ "files": [
9
+ "uploader/"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/Jonghoon5922/tokenbill.git"
14
+ },
15
+ "homepage": "https://tokenbill.my",
16
+ "keywords": ["mcp", "claude-code", "codex-cli", "gemini-cli", "cursor", "tokens", "leaderboard", "usage"],
17
+ "engines": { "node": ">=18" },
18
+ "license": "MIT"
19
+ }
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tokenbill MCP 업로더
4
+ *
5
+ * - 시작 시 로컬 AI 사용 로그(~/.claude, ~/.codex)를 스캔해 tokenbill.my로 업로드
6
+ * - MCP(stdio) 서버로 동작: sync_usage(수동 동기화), my_rank(내 순위 조회) 도구 제공
7
+ *
8
+ * 등록 예:
9
+ * claude mcp add tokenbill -- npx -y tokenbill-mcp@latest --token tbu_...
10
+ * codex mcp add tokenbill -- npx -y tokenbill-mcp@latest --token tbu_...
11
+ */
12
+ "use strict";
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const os = require("os");
16
+ const https = require("https");
17
+ const http = require("http");
18
+ const readline = require("readline");
19
+
20
+ // ── 설정 ────────────────────────────────────────────────────
21
+ function arg(name) {
22
+ const i = process.argv.indexOf(name);
23
+ return i >= 0 ? process.argv[i + 1] : undefined;
24
+ }
25
+ const TOKEN = arg("--token") || process.env.TOKENBILL_TOKEN || "";
26
+ const SERVER = (arg("--server") || process.env.TOKENBILL_SERVER || "https://tokenbill.my").replace(/\/$/, "");
27
+ const LOOKBACK_DAYS = 60;
28
+
29
+ // ── 로컬 뷰어 모드 (--viewer): 업로드·MCP 없이 대화 열람 웹 UI만 실행 ──
30
+ if (process.argv.includes("--viewer")) {
31
+ require("./viewer").start(Number(arg("--port")) || 8377);
32
+ return;
33
+ }
34
+
35
+ function log(msg) { process.stderr.write(`[tokenbill] ${msg}\n`); }
36
+
37
+ // ── HTTP ────────────────────────────────────────────────────
38
+ function request(method, urlPath, body) {
39
+ return new Promise((resolve, reject) => {
40
+ const url = new URL(SERVER + urlPath);
41
+ const mod = url.protocol === "http:" ? http : https;
42
+ const data = body ? JSON.stringify(body) : null;
43
+ const req = mod.request(url, {
44
+ method,
45
+ headers: {
46
+ "Content-Type": "application/json",
47
+ "X-Upload-Token": TOKEN,
48
+ ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}),
49
+ },
50
+ timeout: 30000,
51
+ }, (res) => {
52
+ let buf = "";
53
+ res.on("data", (c) => (buf += c));
54
+ res.on("end", () => {
55
+ try { resolve({ status: res.statusCode, json: JSON.parse(buf || "{}") }); }
56
+ catch { resolve({ status: res.statusCode, json: {} }); }
57
+ });
58
+ });
59
+ req.on("error", reject);
60
+ req.on("timeout", () => req.destroy(new Error("timeout")));
61
+ if (data) req.write(data);
62
+ req.end();
63
+ });
64
+ }
65
+
66
+ // ── 로그 스캔 공통 ──────────────────────────────────────────
67
+ function* walkFiles(dir, ext) {
68
+ let entries;
69
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
70
+ for (const e of entries) {
71
+ const p = path.join(dir, e.name);
72
+ if (e.isDirectory()) yield* walkFiles(p, ext);
73
+ else if (e.isFile() && e.name.endsWith(ext)) yield p;
74
+ }
75
+ }
76
+ function cutoffDay() {
77
+ const d = new Date(Date.now() - LOOKBACK_DAYS * 86400000);
78
+ return d.toISOString().slice(0, 10);
79
+ }
80
+ function addRow(agg, day, model, inTok, outTok) {
81
+ if (!day || day < cutoffDay()) return;
82
+ const k = `${day}|${model}`;
83
+ const a = agg.get(k) || { day, model, input_tokens: 0, output_tokens: 0 };
84
+ a.input_tokens += inTok;
85
+ a.output_tokens += outTok;
86
+ agg.set(k, a);
87
+ }
88
+
89
+ // ── Claude Code (~/.claude/projects/**/*.jsonl) ─────────────
90
+ function collectClaudeCode() {
91
+ const agg = new Map();
92
+ const seen = new Set();
93
+ const base = path.join(os.homedir(), ".claude", "projects");
94
+ for (const file of walkFiles(base, ".jsonl")) {
95
+ let lines;
96
+ try { lines = fs.readFileSync(file, "utf8").split("\n"); } catch { continue; }
97
+ for (const line of lines) {
98
+ if (!line.includes('"usage"')) continue;
99
+ let e;
100
+ try { e = JSON.parse(line); } catch { continue; }
101
+ const u = e && e.message && e.message.usage;
102
+ if (e.type !== "assistant" || !u || !e.timestamp) continue;
103
+ const model = (e.message.model || "claude").slice(0, 128);
104
+ if (model.includes("synthetic")) continue;
105
+ const dedup = `${e.message.id || ""}:${e.requestId || e.uuid || ""}`;
106
+ if (dedup !== ":" && seen.has(dedup)) continue;
107
+ seen.add(dedup);
108
+ const inTok = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
109
+ addRow(agg, e.timestamp.slice(0, 10), model, inTok, u.output_tokens || 0);
110
+ }
111
+ }
112
+ return [...agg.values()];
113
+ }
114
+
115
+ // ── Codex CLI (~/.codex/sessions/**/*.jsonl) — 형식이 자주 바뀌어 최선 노력 ──
116
+ function collectCodex() {
117
+ const agg = new Map();
118
+ const base = path.join(os.homedir(), ".codex", "sessions");
119
+ for (const file of walkFiles(base, ".jsonl")) {
120
+ let lines;
121
+ try { lines = fs.readFileSync(file, "utf8").split("\n"); } catch { continue; }
122
+ let model = "codex";
123
+ let prev = null; // total 누적치를 주는 형식 대응: 직전 총계와의 차이를 사용
124
+ for (const line of lines) {
125
+ if (!line.trim()) continue;
126
+ let e;
127
+ try { e = JSON.parse(line); } catch { continue; }
128
+ const p = e.payload || e;
129
+ if (p && p.type === "turn_context" && p.model) model = String(p.model).slice(0, 128);
130
+ const info = p && p.info;
131
+ const usage = (info && (info.last_token_usage || info.total_token_usage)) ||
132
+ (p && p.type === "token_count" && p.usage) || null;
133
+ if (!usage) continue;
134
+ const day = (e.timestamp || e.ts || "").slice(0, 10);
135
+ let inTok = (usage.input_tokens || 0) + (usage.cached_input_tokens || 0);
136
+ let outTok = usage.output_tokens || 0;
137
+ if (info && info.total_token_usage && !info.last_token_usage) {
138
+ // 누적 총계 형식이면 직전 값과의 증가분만 반영
139
+ const t = info.total_token_usage;
140
+ const cur = { i: (t.input_tokens || 0) + (t.cached_input_tokens || 0), o: t.output_tokens || 0 };
141
+ inTok = Math.max(0, cur.i - (prev ? prev.i : 0));
142
+ outTok = Math.max(0, cur.o - (prev ? prev.o : 0));
143
+ prev = cur;
144
+ }
145
+ if (inTok || outTok) addRow(agg, day, model, inTok, outTok);
146
+ }
147
+ }
148
+ return [...agg.values()];
149
+ }
150
+
151
+ // ── Gemini CLI (~/.gemini/tmp/**/*.json|.jsonl) ─────────────
152
+ function geminiTokens(t) {
153
+ if (!t || typeof t !== "object") return null;
154
+ const n = (keys) => { for (const k of keys) { const v = t[k]; if (typeof v === "number" && v > 0) return Math.floor(v); } return 0; };
155
+ const input = n(["input", "prompt", "input_tokens", "prompt_tokens"]); // cached 포함값
156
+ const output = n(["output", "candidates", "output_tokens", "candidates_tokens"]);
157
+ const extra = n(["thoughts", "reasoning", "thoughts_tokens", "reasoning_tokens"]) + n(["tool", "tool_tokens"]);
158
+ if (!input && !output && !extra) return null;
159
+ return { input, output: output + extra };
160
+ }
161
+ function collectGemini() {
162
+ const agg = new Map();
163
+ const base = path.join(os.homedir(), ".gemini", "tmp");
164
+ const handleMsg = (msg, fallbackDay, fallbackModel) => {
165
+ if (!msg || msg.type !== "gemini") return;
166
+ const tok = geminiTokens(msg.tokens);
167
+ if (!tok) return;
168
+ const day = (msg.timestamp || "").slice(0, 10) || fallbackDay;
169
+ const model = String(msg.model || fallbackModel || "gemini").slice(0, 128);
170
+ addRow(agg, day, model, tok.input, tok.output);
171
+ };
172
+ for (const ext of [".json", ".jsonl"]) {
173
+ for (const file of walkFiles(base, ext)) {
174
+ let content;
175
+ try { content = fs.readFileSync(file, "utf8"); } catch { continue; }
176
+ let fallbackDay = "";
177
+ try { fallbackDay = fs.statSync(file).mtime.toISOString().slice(0, 10); } catch {}
178
+ if (ext === ".json") {
179
+ let rec;
180
+ try { rec = JSON.parse(content); } catch { continue; }
181
+ const day = ((rec.startTime || rec.lastUpdated || "").slice(0, 10)) || fallbackDay;
182
+ if (Array.isArray(rec.messages)) rec.messages.forEach((m) => handleMsg(m, day, rec.model));
183
+ else handleMsg(rec, day, rec.model);
184
+ } else {
185
+ for (const line of content.split("\n")) {
186
+ if (!line.includes('"tokens"')) continue;
187
+ try { handleMsg(JSON.parse(line), fallbackDay); } catch {}
188
+ }
189
+ }
190
+ }
191
+ }
192
+ return [...agg.values()];
193
+ }
194
+
195
+ // ── 계정 식별 (소스별 로컬 설정에서 이메일 읽기) ────────────
196
+ function claudeAccountEmail() {
197
+ try {
198
+ const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude.json"), "utf8"));
199
+ return (j.oauthAccount && j.oauthAccount.emailAddress) || null;
200
+ } catch { return null; }
201
+ }
202
+ function geminiAccountEmail() {
203
+ try {
204
+ const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".gemini", "google_accounts.json"), "utf8"));
205
+ return j.active || (Array.isArray(j.accounts) && j.accounts[0]) || null;
206
+ } catch { return null; }
207
+ }
208
+ const ACCOUNT_OF = { "claude-code": claudeAccountEmail, "codex": () => null, "gemini": geminiAccountEmail };
209
+
210
+ // ── 업로드 ──────────────────────────────────────────────────
211
+ async function syncAll() {
212
+ if (!TOKEN) return "업로드 토큰이 없습니다 — --token 또는 TOKENBILL_TOKEN을 설정하세요.";
213
+ const sources = [
214
+ ["claude-code", collectClaudeCode],
215
+ ["codex", collectCodex],
216
+ ["gemini", collectGemini],
217
+ ];
218
+ const results = [];
219
+ for (const [source, collect] of sources) {
220
+ let rows = [];
221
+ try { rows = collect(); } catch (e) { results.push(`${source}: 스캔 실패 (${e.message})`); continue; }
222
+ if (!rows.length) { results.push(`${source}: 사용 기록 없음`); continue; }
223
+ let account = null;
224
+ try { account = ACCOUNT_OF[source] ? ACCOUNT_OF[source]() : null; } catch {}
225
+ try {
226
+ const r = await request("POST", "/api/usage/import", { source, account, rows: rows.slice(0, 2000) });
227
+ results.push(r.status === 200
228
+ ? `${source}: ${r.json.rows}일×모델 업로드 (${r.json.from}~${r.json.to})`
229
+ : `${source}: 업로드 실패 (HTTP ${r.status}${r.json.detail ? " — " + r.json.detail : ""})`);
230
+ } catch (e) { results.push(`${source}: 업로드 실패 (${e.message})`); }
231
+ }
232
+ return results.join("\n");
233
+ }
234
+
235
+ async function myRank() {
236
+ try {
237
+ const r = await request("GET", "/api/uploader/me");
238
+ if (r.status !== 200) return `조회 실패 (HTTP ${r.status}${r.json.detail ? " — " + r.json.detail : ""})`;
239
+ const m = r.json;
240
+ const next = m.tier.next ? `다음 티어 '${m.tier.next.name}'까지 ${(m.tier.next.at - m.tokens).toLocaleString()} tok` : "최고 티어!";
241
+ return `${m.tier.emoji} ${m.nickname} — ${m.tier.name}\n이번 달 ${m.tokens.toLocaleString()} tok · $${m.cost_usd} · ${m.rank}위/${m.total_users}명\n${next}\nhttps://tokenbill.my`;
242
+ } catch (e) { return `조회 실패 (${e.message})`; }
243
+ }
244
+
245
+ // ── MCP (stdio, 개행 구분 JSON-RPC) ────────────────────────
246
+ const TOOLS = [
247
+ { name: "sync_usage", description: "로컬 AI 사용 로그(Claude Code·Codex·Gemini CLI)를 스캔해 Tokenbill에 지금 업로드합니다.", inputSchema: { type: "object", properties: {} } },
248
+ { name: "my_rank", description: "Tokenbill 토큰 리더보드에서 내 이번 달 순위·티어·사용량을 조회합니다.", inputSchema: { type: "object", properties: {} } },
249
+ ];
250
+
251
+ function reply(id, result) { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n"); }
252
+ function replyErr(id, code, message) { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n"); }
253
+
254
+ async function handle(msg) {
255
+ const { id, method, params } = msg;
256
+ if (method === "initialize") {
257
+ return reply(id, {
258
+ protocolVersion: (params && params.protocolVersion) || "2024-11-05",
259
+ capabilities: { tools: {} },
260
+ serverInfo: { name: "tokenbill", version: "0.1.0" },
261
+ });
262
+ }
263
+ if (method === "notifications/initialized" || (method && method.startsWith("notifications/"))) return;
264
+ if (method === "ping") return reply(id, {});
265
+ if (method === "tools/list") return reply(id, { tools: TOOLS });
266
+ if (method === "tools/call") {
267
+ const name = params && params.name;
268
+ let text;
269
+ if (name === "sync_usage") text = await syncAll();
270
+ else if (name === "my_rank") text = await myRank();
271
+ else return replyErr(id, -32602, `unknown tool: ${name}`);
272
+ return reply(id, { content: [{ type: "text", text }] });
273
+ }
274
+ if (id !== undefined) return replyErr(id, -32601, `method not found: ${method}`);
275
+ }
276
+
277
+ // 시작 시 1회 자동 업로드 (백그라운드)
278
+ syncAll().then((r) => log("자동 동기화:\n" + r)).catch((e) => log("동기화 오류: " + e.message));
279
+
280
+ // MCP로 떠 있는 동안 로컬 뷰어도 함께 서빙 — tokenbill.my의 '대화 뷰어' 버튼이 이 주소를 연다.
281
+ // 127.0.0.1 전용이며 이미 다른 인스턴스가 포트를 쓰면 조용히 넘어간다.
282
+ try { require("./viewer").start(8377, { openBrowser: false, silent: true }); } catch (e) { log("뷰어 자동 실행 생략: " + e.message); }
283
+
284
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
285
+ rl.on("line", (line) => {
286
+ if (!line.trim()) return;
287
+ let msg;
288
+ try { msg = JSON.parse(line); } catch { return; }
289
+ handle(msg).catch((e) => { if (msg.id !== undefined) replyErr(msg.id, -32603, e.message); });
290
+ });
291
+ rl.on("close", () => process.exit(0));
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Tokenbill 로컬 대화 뷰어
3
+ *
4
+ * `npx -y tokenbill-mcp@latest --viewer` 로 실행하면 127.0.0.1 전용 웹 뷰어가 뜬다.
5
+ * Claude Code·Codex·Gemini CLI의 로컬 로그를 읽어 세션별 대화(입력/출력/도구 호출)를 보여준다.
6
+ * 어떤 데이터도 외부로 전송하지 않는다 — 전부 이 PC 안에서만 읽고 표시한다.
7
+ */
8
+ "use strict";
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const os = require("os");
12
+ const http = require("http");
13
+
14
+ const BASES = {
15
+ "claude-code": path.join(os.homedir(), ".claude", "projects"),
16
+ "codex": path.join(os.homedir(), ".codex", "sessions"),
17
+ "gemini": path.join(os.homedir(), ".gemini", "tmp"),
18
+ };
19
+
20
+ function* walkFiles(dir, exts) {
21
+ let entries;
22
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
23
+ for (const e of entries) {
24
+ const p = path.join(dir, e.name);
25
+ if (e.isDirectory()) yield* walkFiles(p, exts);
26
+ else if (e.isFile() && exts.some((x) => e.name.endsWith(x))) yield p;
27
+ }
28
+ }
29
+
30
+ // 경로 검증 — 허용된 로그 디렉터리 밖 파일은 절대 열지 않는다
31
+ function allowedFile(file) {
32
+ const r = path.resolve(file);
33
+ return Object.values(BASES).some((b) => r.startsWith(path.resolve(b) + path.sep));
34
+ }
35
+
36
+ // ── Claude Code 파서 ────────────────────────────────────────
37
+ function claudeProjectName(file) {
38
+ // ~/.claude/projects/<경로를-대시로-인코딩한-폴더>/<세션>.jsonl → 마지막 경로 조각만 표시
39
+ const folder = path.basename(path.dirname(file));
40
+ const parts = folder.split("-").filter(Boolean);
41
+ return parts.length ? parts[parts.length - 1] : folder;
42
+ }
43
+ function claudeText(content) {
44
+ if (typeof content === "string") return content;
45
+ if (!Array.isArray(content)) return "";
46
+ return content.filter((b) => b && b.type === "text").map((b) => b.text).join("\n");
47
+ }
48
+ function parseClaudeSession(file, full) {
49
+ let lines;
50
+ try { lines = fs.readFileSync(file, "utf8").split("\n"); } catch { return null; }
51
+ const s = { source: "claude-code", file, project: claudeProjectName(file),
52
+ start: "", end: "", msgs: 0, tokens: 0, title: "", model: "", entries: [] };
53
+ const seen = new Set();
54
+ for (const line of lines) {
55
+ if (!line.trim()) continue;
56
+ let e;
57
+ try { e = JSON.parse(line); } catch { continue; }
58
+ if (e.timestamp) { if (!s.start) s.start = e.timestamp; s.end = e.timestamp; }
59
+ if (e.cwd && !s.cwdSeen) { s.project = path.basename(e.cwd) || s.project; s.cwdSeen = true; }
60
+ if (e.type === "user" && e.message && !e.isMeta) {
61
+ const c = e.message.content;
62
+ const isToolResult = Array.isArray(c) && c.some((b) => b && b.type === "tool_result");
63
+ const text = claudeText(c);
64
+ if (isToolResult) {
65
+ if (full && text) s.entries.push({ role: "tool_result", time: e.timestamp, text: text.slice(0, 4000) });
66
+ continue;
67
+ }
68
+ if (!text || text.startsWith("<command-name>") || text.startsWith("<local-command")) continue;
69
+ s.msgs++;
70
+ if (!s.title) s.title = text.slice(0, 80).replace(/\s+/g, " ");
71
+ if (full) s.entries.push({ role: "user", time: e.timestamp, text });
72
+ } else if (e.type === "assistant" && e.message) {
73
+ const m = e.message;
74
+ const dedup = `${m.id || ""}:${e.requestId || e.uuid || ""}`;
75
+ const dup = dedup !== ":" && seen.has(dedup);
76
+ if (!dup) seen.add(dedup);
77
+ const u = m.usage;
78
+ if (u && !dup) {
79
+ s.tokens += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) +
80
+ (u.cache_read_input_tokens || 0) + (u.output_tokens || 0);
81
+ }
82
+ if (m.model && !m.model.includes("synthetic")) s.model = m.model;
83
+ if (full && Array.isArray(m.content)) {
84
+ for (const b of m.content) {
85
+ if (!b) continue;
86
+ if (b.type === "text" && b.text) s.entries.push({ role: "assistant", time: e.timestamp, model: m.model, text: b.text });
87
+ else if (b.type === "thinking" && b.thinking) s.entries.push({ role: "thinking", time: e.timestamp, text: b.thinking.slice(0, 4000) });
88
+ else if (b.type === "tool_use") s.entries.push({ role: "tool_use", time: e.timestamp, tool: b.name,
89
+ text: JSON.stringify(b.input || {}, null, 1).slice(0, 2000) });
90
+ }
91
+ }
92
+ }
93
+ }
94
+ return s.msgs || s.tokens ? s : null;
95
+ }
96
+
97
+ // ── Codex 파서 (형식이 자주 바뀌어 최선 노력) ───────────────
98
+ function codexText(c) {
99
+ if (typeof c === "string") return c;
100
+ if (!Array.isArray(c)) return "";
101
+ return c.map((b) => (b && (b.text || b.content)) || "").filter(Boolean).join("\n");
102
+ }
103
+ function parseCodexSession(file, full) {
104
+ let lines;
105
+ try { lines = fs.readFileSync(file, "utf8").split("\n"); } catch { return null; }
106
+ const s = { source: "codex", file, project: "", start: "", end: "", msgs: 0, tokens: 0, title: "", model: "codex", entries: [] };
107
+ let prev = null;
108
+ for (const line of lines) {
109
+ if (!line.trim()) continue;
110
+ let e;
111
+ try { e = JSON.parse(line); } catch { continue; }
112
+ const ts = e.timestamp || e.ts || "";
113
+ if (ts) { if (!s.start) s.start = ts; s.end = ts; }
114
+ const p = e.payload || e;
115
+ if (p.type === "turn_context") { if (p.model) s.model = String(p.model); if (p.cwd) s.project = path.basename(p.cwd); }
116
+ if (p.type === "session_meta" && p.cwd) s.project = path.basename(p.cwd);
117
+ // 대화: response_item(message) / user_message / agent_message 형식 모두 시도
118
+ let role = null, text = "";
119
+ if (p.type === "message" && p.role) { role = p.role; text = codexText(p.content); }
120
+ else if (p.type === "response_item" && p.payload && p.payload.type === "message") { role = p.payload.role; text = codexText(p.payload.content); }
121
+ else if (p.type === "user_message") { role = "user"; text = p.message || codexText(p.content); }
122
+ else if (p.type === "agent_message") { role = "assistant"; text = p.message || codexText(p.content); }
123
+ if (role && text && !text.startsWith("<user_instructions>") && !text.startsWith("<environment_context>")) {
124
+ if (role === "user") { s.msgs++; if (!s.title) s.title = text.slice(0, 80).replace(/\s+/g, " "); }
125
+ if (full) s.entries.push({ role: role === "user" ? "user" : "assistant", time: ts, model: s.model, text });
126
+ }
127
+ const info = p.info;
128
+ const usage = (info && (info.last_token_usage || info.total_token_usage)) || (p.type === "token_count" && p.usage) || null;
129
+ if (usage) {
130
+ let inTok = (usage.input_tokens || 0) + (usage.cached_input_tokens || 0);
131
+ let outTok = usage.output_tokens || 0;
132
+ if (info && info.total_token_usage && !info.last_token_usage) {
133
+ const t = info.total_token_usage;
134
+ const cur = { i: (t.input_tokens || 0) + (t.cached_input_tokens || 0), o: t.output_tokens || 0 };
135
+ inTok = Math.max(0, cur.i - (prev ? prev.i : 0));
136
+ outTok = Math.max(0, cur.o - (prev ? prev.o : 0));
137
+ prev = cur;
138
+ }
139
+ s.tokens += inTok + outTok;
140
+ }
141
+ }
142
+ return s.msgs || s.tokens ? s : null;
143
+ }
144
+
145
+ // ── Gemini 파서 ─────────────────────────────────────────────
146
+ function parseGeminiSession(file, full) {
147
+ let content;
148
+ try { content = fs.readFileSync(file, "utf8"); } catch { return null; }
149
+ let rec;
150
+ try { rec = JSON.parse(content); } catch { return null; }
151
+ if (!Array.isArray(rec.messages)) return null;
152
+ const s = { source: "gemini", file, project: rec.projectHash ? rec.projectHash.slice(0, 8) : "",
153
+ start: rec.startTime || "", end: rec.lastUpdated || "", msgs: 0, tokens: 0, title: "",
154
+ model: rec.model || "gemini", entries: [] };
155
+ for (const m of rec.messages) {
156
+ if (!m) continue;
157
+ const text = typeof m.content === "string" ? m.content : (m.text || "");
158
+ if (m.type === "user") {
159
+ s.msgs++;
160
+ if (!s.title && text) s.title = text.slice(0, 80).replace(/\s+/g, " ");
161
+ if (full && text) s.entries.push({ role: "user", time: m.timestamp || "", text });
162
+ } else if (m.type === "gemini") {
163
+ if (m.tokens) {
164
+ const t = m.tokens;
165
+ s.tokens += (t.input || t.prompt || 0) + (t.output || t.candidates || 0) + (t.thoughts || 0) + (t.tool || 0);
166
+ }
167
+ if (full && text) s.entries.push({ role: "assistant", time: m.timestamp || "", model: m.model || s.model, text });
168
+ }
169
+ }
170
+ return s.msgs || s.tokens ? s : null;
171
+ }
172
+
173
+ // ── Cursor 파서 (SQLite — Node 22.5+ 내장 node:sqlite 필요, 없으면 조용히 생략) ──
174
+ let nodeSqlite = null;
175
+ try { nodeSqlite = require("node:sqlite"); } catch {}
176
+ function cursorDbPath() {
177
+ if (process.env.TOKENBILL_CURSOR_DB) return process.env.TOKENBILL_CURSOR_DB; // 테스트용 오버라이드
178
+ if (process.platform === "win32") return path.join(process.env.APPDATA || "", "Cursor", "User", "globalStorage", "state.vscdb");
179
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
180
+ return path.join(os.homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb");
181
+ }
182
+ function cursorOpen() {
183
+ const p = cursorDbPath();
184
+ if (!nodeSqlite || !fs.existsSync(p)) return null;
185
+ try { return new nodeSqlite.DatabaseSync(p, { readOnly: true }); }
186
+ catch {
187
+ // Cursor 실행 중 잠금 대비 — 임시 복사본으로 열기
188
+ try {
189
+ const tmp = path.join(os.tmpdir(), "tokenbill-cursor.vscdb");
190
+ fs.copyFileSync(p, tmp);
191
+ try { fs.copyFileSync(p + "-wal", tmp + "-wal"); } catch {}
192
+ return new nodeSqlite.DatabaseSync(tmp, { readOnly: true });
193
+ } catch { return null; }
194
+ }
195
+ }
196
+ function cursorIso(ms) { return typeof ms === "number" && ms > 0 ? new Date(ms).toISOString() : ""; }
197
+ function cursorSessions(full, onlyId) {
198
+ const db = cursorOpen();
199
+ if (!db) return [];
200
+ const out = [];
201
+ let rows = [];
202
+ try { rows = db.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'").all(); } catch {}
203
+ let bubbleStmt = null;
204
+ try { bubbleStmt = db.prepare("SELECT value FROM cursorDiskKV WHERE key = ?"); } catch {}
205
+ for (const r of rows) {
206
+ let c;
207
+ try { c = JSON.parse(r.value); } catch { continue; }
208
+ const id = c.composerId || String(r.key).slice("composerData:".length);
209
+ if (onlyId && id !== onlyId) continue;
210
+ const s = { source: "cursor", file: "cursor:" + id, project: "", start: cursorIso(c.createdAt),
211
+ end: cursorIso(c.lastUpdatedAt || c.createdAt), msgs: 0, tokens: 0,
212
+ title: (c.name || "").slice(0, 80), model: "cursor", entries: [] };
213
+ // 구형: conversation 배열에 버블 인라인 / 신형: 헤더만 있고 버블은 bubbleId:* 키로 분리 저장
214
+ let bubbles = Array.isArray(c.conversation) ? c.conversation : [];
215
+ const headers = Array.isArray(c.fullConversationHeadersOnly) ? c.fullConversationHeadersOnly : [];
216
+ if (!bubbles.length && headers.length && bubbleStmt && (full || !s.title)) {
217
+ for (const h of headers) {
218
+ try {
219
+ const row = bubbleStmt.get("bubbleId:" + id + ":" + h.bubbleId);
220
+ if (row) { const b = JSON.parse(row.value); if (b.type === undefined) b.type = h.type; bubbles.push(b); }
221
+ } catch {}
222
+ if (!full && bubbles.length >= 3) break; // 목록에서는 제목 추출용으로 앞부분만
223
+ }
224
+ }
225
+ for (const b of bubbles) {
226
+ if (!b) continue;
227
+ const text = typeof b.text === "string" ? b.text : "";
228
+ const tc = b.tokenCount;
229
+ if (tc) s.tokens += (tc.inputTokens || 0) + (tc.outputTokens || 0);
230
+ if (b.type === 1) {
231
+ s.msgs++;
232
+ if (!s.title && text) s.title = text.slice(0, 80).replace(/\s+/g, " ");
233
+ if (full && text) s.entries.push({ role: "user", time: cursorIso(b.createdAt) || s.start, text });
234
+ } else if (b.type === 2 && full && text) {
235
+ s.entries.push({ role: "assistant", time: cursorIso(b.createdAt) || s.start, model: (b.modelType || "cursor"), text });
236
+ }
237
+ }
238
+ if (!s.msgs && headers.length) s.msgs = headers.filter((h) => h && h.type === 1).length;
239
+ if (s.msgs) out.push(s);
240
+ }
241
+ try { db.close(); } catch {}
242
+ return out;
243
+ }
244
+ function cursorMeta(s) {
245
+ return { source: s.source, file: s.file, project: s.project, start: s.start, end: s.end,
246
+ msgs: s.msgs, tokens: s.tokens, title: s.title, model: s.model };
247
+ }
248
+
249
+ const PARSERS = { "claude-code": parseClaudeSession, "codex": parseCodexSession, "gemini": parseGeminiSession };
250
+ const EXTS = { "claude-code": [".jsonl"], "codex": [".jsonl"], "gemini": [".json"] };
251
+
252
+ // 목록은 파일 mtime 기준으로 캐시 (대화 열람은 항상 새로 읽음)
253
+ const listCache = new Map();
254
+ function sessionMeta(source, file) {
255
+ let mtime = 0;
256
+ try { mtime = fs.statSync(file).mtimeMs; } catch { return null; }
257
+ const c = listCache.get(file);
258
+ if (c && c.mtime === mtime) return c.meta;
259
+ const s = PARSERS[source](file, false);
260
+ const meta = s ? { source: s.source, file: s.file, project: s.project, start: s.start, end: s.end,
261
+ msgs: s.msgs, tokens: s.tokens, title: s.title, model: s.model } : null;
262
+ listCache.set(file, { mtime, meta });
263
+ return meta;
264
+ }
265
+ function listSessions() {
266
+ const out = [];
267
+ for (const source of Object.keys(BASES)) {
268
+ for (const file of walkFiles(BASES[source], EXTS[source])) {
269
+ const m = sessionMeta(source, file);
270
+ if (m && m.msgs > 0) out.push(m);
271
+ }
272
+ }
273
+ try { cursorSessions(false).forEach((s) => out.push(cursorMeta(s))); } catch {}
274
+ out.sort((a, b) => (b.start || "").localeCompare(a.start || ""));
275
+ return out.slice(0, 500);
276
+ }
277
+ function grepSession(s, q, hits) {
278
+ for (const e of s.entries) {
279
+ if ((e.role !== "user" && e.role !== "assistant") || !e.text) continue;
280
+ const idx = e.text.toLowerCase().indexOf(q);
281
+ if (idx < 0) continue;
282
+ hits.push({ source: s.source, file: s.file, project: s.project, start: s.start, role: e.role, time: e.time,
283
+ snippet: e.text.slice(Math.max(0, idx - 60), idx + q.length + 120).replace(/\s+/g, " ") });
284
+ if (hits.length >= 50) return;
285
+ }
286
+ }
287
+ function searchSessions(q) {
288
+ q = q.toLowerCase();
289
+ const hits = [];
290
+ for (const source of Object.keys(BASES)) {
291
+ for (const file of walkFiles(BASES[source], EXTS[source])) {
292
+ if (hits.length >= 50) return hits;
293
+ const s = PARSERS[source](file, true);
294
+ if (s) grepSession(s, q, hits);
295
+ }
296
+ }
297
+ try {
298
+ for (const s of cursorSessions(true)) {
299
+ if (hits.length >= 50) break;
300
+ grepSession(s, q, hits);
301
+ }
302
+ } catch {}
303
+ return hits;
304
+ }
305
+
306
+ // ── HTML (인라인 단일 페이지 — 렌더링은 클라이언트 DOM API로만, innerHTML 미사용) ──
307
+ const PAGE = `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">
308
+ <meta name="viewport" content="width=device-width, initial-scale=1">
309
+ <title>Tokenbill 로컬 뷰어</title>
310
+ <style>
311
+ :root{--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink2:#52514e;--muted:#898781;--grid:#e1e0d9;
312
+ --border:rgba(11,11,11,.1);--accent:#2a78d6;--accent-ink:#1c5cab;--chip:#f0efec;
313
+ --cc:#8a63d2;--codex:#66707d;--gem:#2a78d6}
314
+ @media(prefers-color-scheme:dark){:root{--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink2:#c3c2b7;
315
+ --muted:#898781;--grid:#2c2c2a;--border:rgba(255,255,255,.1);--accent:#3987e5;--accent-ink:#86b6ef;--chip:#262624}}
316
+ *{box-sizing:border-box;margin:0}
317
+ body{background:var(--page);color:var(--ink);font-family:"IBM Plex Sans KR","Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;line-height:1.5;height:100vh;display:flex;flex-direction:column}
318
+ header{padding:12px 18px 10px;border-bottom:1px solid var(--grid);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
319
+ header h1{font-size:1.05rem;font-weight:700}header h1 b{color:var(--accent)}
320
+ header .note{font-size:.72rem;color:var(--muted)}
321
+ #q{margin-left:auto;width:300px;max-width:100%;padding:7px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--ink);font:inherit;font-size:.84rem}
322
+ #filters{display:flex;gap:8px;padding:9px 18px;border-bottom:1px solid var(--grid);align-items:center;flex-wrap:wrap}
323
+ .chip{display:inline-flex;align-items:center;padding:4px 12px;border-radius:999px;border:1px solid var(--border);background:var(--surface);cursor:pointer;font:inherit;font-size:.75rem;color:var(--ink2)}
324
+ .chip:hover{background:var(--chip)}
325
+ .chip.on{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
326
+ .chip .dot2{width:8px;height:8px;border-radius:2px;margin-right:6px}
327
+ .chip.on .dot2{background:#fff!important}
328
+ #filters select{padding:5px 9px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--ink);font:inherit;font-size:.75rem;max-width:220px}
329
+ #sum{margin-left:auto;font-size:.73rem;color:var(--muted);font-variant-numeric:tabular-nums}
330
+ main{flex:1;display:flex;min-height:0}
331
+ #side{width:360px;flex:none;border-right:1px solid var(--grid);overflow-y:auto;padding:8px;background:var(--page)}
332
+ .sess{display:flex;gap:10px;align-items:flex-start;padding:9px 12px;border-radius:9px;border:1px solid var(--border);border-left:3px solid var(--grid);cursor:pointer;margin-bottom:5px;background:var(--surface)}
333
+ .sess:hover{background:var(--chip)}.sess.on{background:color-mix(in srgb,var(--accent) 10%,var(--surface));border-color:color-mix(in srgb,var(--accent) 40%,var(--border))}
334
+ .sess .body{flex:1;min-width:0}
335
+ .sess .t{font-size:.8rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
336
+ .sess .m{font-size:.68rem;color:var(--muted);display:flex;gap:6px;margin-top:3px;align-items:center;flex-wrap:wrap}
337
+ .sess .proj{background:var(--chip);border-radius:5px;padding:0 6px;color:var(--ink2);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
338
+ .sess .tok{font-size:.73rem;font-weight:700;color:var(--accent-ink);white-space:nowrap;font-variant-numeric:tabular-nums;padding-top:2px}
339
+ .src{display:inline-block;padding:0 6px;border-radius:999px;color:#fff;font-size:.64rem;font-weight:600;line-height:1.5;white-space:nowrap;flex:none}
340
+ #view{flex:1;overflow-y:auto;padding:20px 26px}
341
+ .msg{max-width:860px;margin:0 auto 14px}
342
+ .msg .hd{font-size:.7rem;color:var(--muted);margin-bottom:3px;display:flex;gap:10px}
343
+ .msg .bd{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:11px 14px;font-size:.85rem;white-space:pre-wrap;word-break:break-word}
344
+ .msg.user .bd{border-left:3px solid var(--accent)}
345
+ .msg.tool .bd,.msg.think .bd{font-size:.74rem;color:var(--ink2);font-family:ui-monospace,Consolas,monospace;background:var(--chip)}
346
+ .msg details summary{cursor:pointer;font-size:.72rem;color:var(--muted)}
347
+ .empty{color:var(--muted);text-align:center;padding:60px 20px;font-size:.88rem}
348
+ .sumcard{max-width:860px;margin:0 auto 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 16px;display:flex;gap:6px 16px;align-items:center;flex-wrap:wrap;font-size:.78rem;color:var(--ink2)}
349
+ .sumcard b{color:var(--ink)}
350
+ .sumcard .bigtok{margin-left:auto;font-size:.95rem;font-weight:700;color:var(--accent-ink);font-variant-numeric:tabular-nums}
351
+ @media(max-width:760px){#side{width:220px}}
352
+ </style></head><body>
353
+ <header><h1>Token<b>bill</b> 로컬 뷰어</h1>
354
+ <span class="note">이 PC의 로그만 읽습니다 — 아무것도 업로드되지 않아요</span>
355
+ <input id="q" placeholder="대화 내용 검색 (Enter)"></header>
356
+ <div id="filters">
357
+ <span id="chips"></span>
358
+ <select id="projSel"><option value="">모든 프로젝트</option></select>
359
+ <select id="perSel">
360
+ <option value="">전체 기간</option><option value="today">오늘</option>
361
+ <option value="7d">최근 7일</option><option value="30d">최근 30일</option>
362
+ <option value="month">이번 달</option><option value="prev">지난 달</option>
363
+ </select>
364
+ <span id="sum"></span>
365
+ </div>
366
+ <main><div id="side"></div><div id="view"><p class="empty">왼쪽에서 세션을 선택하세요</p></div></main>
367
+ <script>
368
+ (function(){
369
+ "use strict";
370
+ var SRC={"claude-code":["Claude Code","var(--cc)"],"codex":["Codex","var(--codex)"],"gemini":["Gemini","var(--gem)"],"cursor":["Cursor","#4f8f6b"]};
371
+ var ROLE={user:"나",assistant:"AI",tool_use:"도구 호출",tool_result:"도구 결과",thinking:"생각"};
372
+ function el(tag,cls,text){var e=document.createElement(tag);if(cls)e.className=cls;if(text!==undefined)e.textContent=text;return e}
373
+ function fmtTok(n){return n>=1e9?(n/1e9).toFixed(1)+"B":n>=1e6?(n/1e6).toFixed(1)+"M":n>=1e3?Math.round(n/1e3)+"K":String(n)}
374
+ function dt(s){return s?s.slice(0,16).replace("T"," "):""}
375
+ function pad(n){return String(n).length<2?"0"+n:String(n)}
376
+ function isoD(d){return d.getFullYear()+"-"+pad(d.getMonth()+1)+"-"+pad(d.getDate())}
377
+ function srcBadge(s){var b=el("span","src",SRC[s]?SRC[s][0]:s);b.style.background=SRC[s]?SRC[s][1]:"var(--accent)";return b}
378
+ var side=document.getElementById("side"),view=document.getElementById("view");
379
+ var chips=document.getElementById("chips"),projSel=document.getElementById("projSel"),
380
+ perSel=document.getElementById("perSel"),sum=document.getElementById("sum"),qIn=document.getElementById("q");
381
+ var ALL=[],F={src:"",proj:"",per:""};
382
+
383
+ // ── 필터 (검색 결과에도 동일하게 적용) ──
384
+ function inPeriod(startIso){
385
+ if(!F.per)return true;
386
+ var st=(startIso||"").slice(0,10);if(!st)return false;
387
+ var now=new Date();
388
+ if(F.per==="today")return st===isoD(now);
389
+ if(F.per==="7d")return st>=isoD(new Date(now.getFullYear(),now.getMonth(),now.getDate()-6));
390
+ if(F.per==="30d")return st>=isoD(new Date(now.getFullYear(),now.getMonth(),now.getDate()-29));
391
+ if(F.per==="month")return st.slice(0,7)===isoD(now).slice(0,7);
392
+ if(F.per==="prev")return st.slice(0,7)===isoD(new Date(now.getFullYear(),now.getMonth()-1,1)).slice(0,7);
393
+ return true}
394
+ function passes(x){return(!F.src||x.source===F.src)&&(!F.proj||x.project===F.proj)&&inPeriod(x.time||x.start)}
395
+ function refresh(){var q=qIn.value.trim();if(q)runSearch(q);else renderList()}
396
+
397
+ function renderChips(){
398
+ chips.textContent="";
399
+ var present=[];ALL.forEach(function(s){if(present.indexOf(s.source)<0)present.push(s.source)});
400
+ function chip(label,val,color){
401
+ var b=el("button","chip"+(F.src===val?" on":""));
402
+ if(color){var d=el("span","dot2");d.style.background=color;b.appendChild(d)}
403
+ b.appendChild(document.createTextNode(label));
404
+ b.addEventListener("click",function(){F.src=val;renderChips();refresh()});
405
+ chips.appendChild(b)}
406
+ chip("전체","",null);
407
+ present.forEach(function(p){chip(SRC[p]?SRC[p][0]:p,p,SRC[p]?SRC[p][1]:"var(--accent)")})}
408
+ function renderProjects(){
409
+ var cnt={};ALL.forEach(function(s){if(s.project)cnt[s.project]=(cnt[s.project]||0)+1});
410
+ while(projSel.options.length>1)projSel.remove(1);
411
+ Object.keys(cnt).sort().forEach(function(p){projSel.appendChild(new Option(p+" ("+cnt[p]+")",p))})}
412
+
413
+ function sessCard(s,titleText,metaExtra){
414
+ var d=el("div","sess");
415
+ d.style.borderLeftColor=SRC[s.source]?SRC[s.source][1]:"var(--accent)";
416
+ var body=el("div","body");
417
+ body.appendChild(el("div","t",titleText));
418
+ var m=el("div","m");m.appendChild(srcBadge(s.source));
419
+ if(s.project)m.appendChild(el("span","proj",s.project));
420
+ m.appendChild(el("span","",metaExtra));
421
+ body.appendChild(m);d.appendChild(body);
422
+ if(s.tokens!==undefined)d.appendChild(el("span","tok",fmtTok(s.tokens)));
423
+ d.addEventListener("click",function(){
424
+ Array.prototype.forEach.call(side.children,function(c){c.classList.remove("on")});
425
+ d.classList.add("on");loadSession(s)});
426
+ return d}
427
+
428
+ function renderList(){
429
+ var list=ALL.filter(passes);
430
+ var tot=0;list.forEach(function(s){tot+=s.tokens});
431
+ sum.textContent=list.length+"개 세션 · "+fmtTok(tot)+" tok";
432
+ side.textContent="";
433
+ if(!list.length){side.appendChild(el("p","empty","조건에 맞는 세션이 없습니다"));return}
434
+ list.forEach(function(s){side.appendChild(sessCard(s,s.title||"(제목 없음)",dt(s.start)+" · "+s.msgs+"건"))})}
435
+
436
+ function runSearch(q){
437
+ side.textContent="";side.appendChild(el("p","empty","검색 중…"));
438
+ fetch("/api/search?q="+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(hits){
439
+ hits=hits.filter(passes);
440
+ sum.textContent="검색 "+hits.length+"건";
441
+ side.textContent="";
442
+ if(!hits.length){side.appendChild(el("p","empty","조건에 맞는 검색 결과가 없습니다"));return}
443
+ hits.forEach(function(h){side.appendChild(sessCard(h,"…"+h.snippet+"…",dt(h.time||h.start)))})})
444
+ .catch(function(){side.textContent="";side.appendChild(el("p","empty","검색 실패"))})}
445
+
446
+ function loadList(){
447
+ fetch("/api/sessions").then(function(r){return r.json()}).then(function(list){
448
+ ALL=list;renderChips();renderProjects();renderList()})
449
+ .catch(function(){side.textContent="";side.appendChild(el("p","empty","목록을 불러오지 못했습니다"))})}
450
+
451
+ function loadSession(s){
452
+ view.textContent="";view.appendChild(el("p","empty","불러오는 중…"));
453
+ fetch("/api/session?file="+encodeURIComponent(s.file)).then(function(r){return r.json()}).then(function(d){
454
+ view.textContent="";
455
+ var sc=el("div","sumcard");sc.appendChild(srcBadge(d.source));
456
+ if(d.project){var b1=el("span");b1.appendChild(el("b","",d.project));sc.appendChild(b1)}
457
+ sc.appendChild(el("span","",dt(d.start)+" ~ "+dt(d.end).slice(11)));
458
+ if(d.model)sc.appendChild(el("span","",d.model));
459
+ sc.appendChild(el("span","bigtok",fmtTok(d.tokens)+" tok"));
460
+ view.appendChild(sc);
461
+ d.entries.forEach(function(e){
462
+ var role=e.role==="tool_use"||e.role==="tool_result"?"tool":e.role==="thinking"?"think":e.role;
463
+ var m=el("div","msg "+role);
464
+ if(e.role==="tool_use"||e.role==="tool_result"||e.role==="thinking"){
465
+ var det=el("details");var summ=el("summary","",ROLE[e.role]+(e.tool?" — "+e.tool:"")+" ("+dt(e.time).slice(11)+")");
466
+ det.appendChild(summ);var bd=el("div","bd",e.text);det.appendChild(bd);m.appendChild(det)}
467
+ else{var hd=el("div","hd");hd.appendChild(el("span","",ROLE[e.role]||e.role));
468
+ if(e.model)hd.appendChild(el("span","",e.model));hd.appendChild(el("span","",dt(e.time)));
469
+ m.appendChild(hd);m.appendChild(el("div","bd",e.text))}
470
+ view.appendChild(m)});
471
+ view.scrollTop=0})
472
+ .catch(function(){view.textContent="";view.appendChild(el("p","empty","세션을 불러오지 못했습니다"))})}
473
+
474
+ projSel.addEventListener("change",function(){F.proj=this.value;refresh()});
475
+ perSel.addEventListener("change",function(){F.per=this.value;refresh()});
476
+ qIn.addEventListener("keydown",function(ev){if(ev.key==="Enter")refresh()});
477
+ loadList();
478
+ })();
479
+ </script></body></html>`;
480
+
481
+ function json(res, obj) {
482
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
483
+ res.end(JSON.stringify(obj));
484
+ }
485
+
486
+ function start(port, opts) {
487
+ port = port || 8377;
488
+ opts = opts || {};
489
+ const server = http.createServer((req, res) => {
490
+ const u = new URL(req.url, "http://127.0.0.1");
491
+ // DNS 리바인딩 방지 — 로컬 호스트명이 아닌 Host 헤더는 거부
492
+ const hostHdr = String(req.headers.host || "");
493
+ if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(hostHdr)) { res.writeHead(403); return res.end("forbidden"); }
494
+ try {
495
+ if (req.method === "OPTIONS") {
496
+ // 공개 사이트 → 로컬 주소 fetch에 필요한 PNA/CORS preflight 응답
497
+ res.writeHead(204, {
498
+ "Access-Control-Allow-Origin": "*",
499
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
500
+ "Access-Control-Allow-Headers": "*",
501
+ "Access-Control-Allow-Private-Network": "true",
502
+ });
503
+ return res.end();
504
+ }
505
+ if (u.pathname === "/api/ping") {
506
+ // 포탈(tokenbill.my)의 '뷰어 실행 중' 감지용 — 민감 정보 없음이라 CORS 허용
507
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
508
+ return res.end('{"ok":true,"app":"tokenbill-viewer"}');
509
+ }
510
+ if (u.pathname === "/") {
511
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
512
+ return res.end(PAGE);
513
+ }
514
+ if (u.pathname === "/api/sessions") return json(res, listSessions());
515
+ if (u.pathname === "/api/session") {
516
+ const file = u.searchParams.get("file") || "";
517
+ if (/^cursor:[A-Za-z0-9-]{1,64}$/.test(file)) {
518
+ return json(res, cursorSessions(true, file.slice(7))[0] || { entries: [] });
519
+ }
520
+ if (!allowedFile(file)) { res.writeHead(403); return res.end("forbidden"); }
521
+ const source = Object.keys(BASES).find((s) => path.resolve(file).startsWith(path.resolve(BASES[s]) + path.sep));
522
+ const s = PARSERS[source](file, true);
523
+ return json(res, s || { entries: [] });
524
+ }
525
+ if (u.pathname === "/api/search") {
526
+ const q = (u.searchParams.get("q") || "").trim();
527
+ return json(res, q.length >= 2 ? searchSessions(q) : []);
528
+ }
529
+ res.writeHead(404); res.end("not found");
530
+ } catch (e) {
531
+ res.writeHead(500); res.end(String(e.message || e));
532
+ }
533
+ });
534
+ server.listen(port, "127.0.0.1", () => {
535
+ const url = `http://127.0.0.1:${port}`;
536
+ process.stderr.write(`[tokenbill] 로컬 뷰어 실행 중: ${url}\n`);
537
+ process.stderr.write(`[tokenbill] 이 PC의 로그만 읽으며, 어떤 데이터도 외부로 전송하지 않습니다.\n`);
538
+ if (opts.openBrowser !== false) {
539
+ const opener = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
540
+ try { require("child_process").exec(opener); } catch {}
541
+ }
542
+ });
543
+ server.on("error", (e) => {
544
+ if (opts.silent) { process.stderr.write(`[tokenbill] 뷰어 자동 실행 생략: ${e.message}\n`); return; }
545
+ process.stderr.write(`[tokenbill] 뷰어 시작 실패: ${e.message} (다른 포트: --port 8378)\n`);
546
+ process.exit(1);
547
+ });
548
+ }
549
+
550
+ module.exports = { start };