tokenphage 2026.7.2-BETA → 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.
@@ -0,0 +1,166 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { existsSync } from 'fs';
3
+ import { localDateOf } from '../lib/dates.js';
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
+ //
12
+ // opencode는 턴당 assistant 메시지 1개에 스텝마다 tokens를 덮어써(=) 마지막 스텝만 남기므로,
13
+ // 스텝별 토큰이 보존된 step-finish part를 전부 합산해야 정확하다.
14
+ // 전략:
15
+ // - message(role==='assistant')에서 id→{model, date}를 모으고, step-finish part를 messageID로 조인한다.
16
+ // - 입력 파일 목록에 message/part가 섞여 오고 순서 보장이 없으므로, 두 맵을 모은 뒤 마지막에 조인한다.
17
+ // - 필드 매핑(claude/codex와 정합): opencode getUsage가 input은 캐시 제외로, output은 reasoning 제외로
18
+ // 저장하므로 → inputTok은 그대로, outputTok은 output+reasoning으로 reasoning을 되살린다.
19
+ // cache.write는 cacheCreate로 둔다(opencode는 Anthropic cacheCreation을 여기 담는다).
20
+ // - 손상 파일·부모 없는 part·모델/날짜 없는 메시지는 집계에서 제외한다.
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
+
41
+ // step-finish part의 tokens를 tokenphage 레코드 4필드로 매핑한다. 음수 방어로 max(…,0).
42
+ function entryOf(date, model, tokens) {
43
+ const cache = tokens.cache ?? {};
44
+ return {
45
+ date,
46
+ model,
47
+ inputTok: Math.max(tokens.input ?? 0, 0),
48
+ // opencode는 output에서 reasoning을 미리 뺐으므로 되살려 청구 기준(claude/codex)과 맞춘다.
49
+ outputTok: Math.max(tokens.output ?? 0, 0) + Math.max(tokens.reasoning ?? 0, 0),
50
+ cacheReadTok: Math.max(cache.read ?? 0, 0),
51
+ cacheCreateTok: Math.max(cache.write ?? 0, 0),
52
+ };
53
+ }
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
+
130
+ /**
131
+ * opencode message/part 파일들을 스텝(step-finish) 단위 usage 목록으로 파싱한다.
132
+ * @param {string[]} files 메시지·파트 JSON 파일 경로 목록 (섞여 있어도 됨)
133
+ * @returns {Promise<Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>>}
134
+ */
135
+ export async function parseOpencodeFiles(files) {
136
+ const messageMeta = new Map(); // messageID → { model, date }
137
+ const stepParts = []; // { messageID, tokens }
138
+
139
+ for (const file of files) {
140
+ let obj;
141
+ try {
142
+ obj = JSON.parse(await readFile(file, 'utf8'));
143
+ } catch { continue; } // 읽기 실패·손상 JSON skip
144
+ if (!obj) continue;
145
+
146
+ if (obj.role === 'assistant') {
147
+ // 모델·날짜를 못 만들면 이 메시지의 파트는 귀속 불가 → 맵에 넣지 않는다.
148
+ if (!obj.id || !obj.modelID) continue;
149
+ const date = localDateOf(obj.time?.created);
150
+ if (!date) continue;
151
+ messageMeta.set(obj.id, { model: obj.modelID, date });
152
+ } else if (obj.type === 'step-finish') {
153
+ if (!obj.messageID || !obj.tokens) continue;
154
+ stepParts.push({ messageID: obj.messageID, tokens: obj.tokens });
155
+ }
156
+ // 그 외(user 메시지, 다른 part, session/project 파일)는 무시
157
+ }
158
+
159
+ const entries = [];
160
+ for (const part of stepParts) {
161
+ const meta = messageMeta.get(part.messageID);
162
+ if (!meta) continue; // 부모 메시지 없음/모델·날짜 무효 → skip
163
+ entries.push(entryOf(meta.date, meta.model, part.tokens));
164
+ }
165
+ return entries;
166
+ }
@@ -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
+ }