metrascope 0.1.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.
@@ -0,0 +1,173 @@
1
+ // Codex CLI adapter — reads ~/.codex session JSONL with token_count events.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { expandHome, parseJSONLFile, readJSONLMap, walkJSONL, textFromContent, projectFromCwd } = require('./shared');
6
+ const { buildResult, emptyResult, buildPromptBreakdown } = require('./aggregate');
7
+
8
+ const id = 'codex';
9
+ const label = 'Codex';
10
+ const mark = 'CX';
11
+ const accent = '#d4a44a';
12
+ const capabilities = { cost: false, reasoning: true, rateLimit: true, cache: true, tools: true, contextWindow: true };
13
+
14
+ function home(options = {}) {
15
+ return expandHome(options.home || options.codexHome || process.env.CODEX_HOME) || path.join(os.homedir(), '.codex');
16
+ }
17
+
18
+ function detect(options = {}) {
19
+ const h = home(options);
20
+ return fs.existsSync(path.join(h, 'sessions')) || fs.existsSync(path.join(h, 'archived_sessions'));
21
+ }
22
+
23
+ function isHumanPrompt(text) {
24
+ if (!text) return false;
25
+ const value = String(text).trim();
26
+ if (!value) return false;
27
+ if (value.startsWith('<environment_context>')) return false;
28
+ if (value.startsWith('The following is the Codex agent history')) return false;
29
+ if (value.startsWith('Continue the same review conversation')) return false;
30
+ if (value.startsWith('You are reviewing an already-completed Codex turn')) return false;
31
+ return true;
32
+ }
33
+
34
+ function extractSessionId(filePath, metaId) {
35
+ if (metaId) return metaId;
36
+ const base = path.basename(filePath, '.jsonl');
37
+ const match = base.match(/([0-9a-f]{8}-[0-9a-f-]{27,})$/i);
38
+ return match ? match[1] : base;
39
+ }
40
+
41
+ function tokenUsageFromPayload(payload) {
42
+ if (!payload || payload.type !== 'token_count') return null;
43
+ const info = payload.info || {};
44
+ const last = info.last_token_usage || {};
45
+ const total = info.total_token_usage || {};
46
+ return {
47
+ inputTokens: last.input_tokens || 0,
48
+ cachedInputTokens: last.cached_input_tokens || 0,
49
+ outputTokens: last.output_tokens || 0,
50
+ reasoningOutputTokens: last.reasoning_output_tokens || 0,
51
+ totalTokens: last.total_tokens || 0,
52
+ cumulativeTokens: total.total_tokens || 0,
53
+ cumulativeInputTokens: total.input_tokens || 0,
54
+ cumulativeCachedInputTokens: total.cached_input_tokens || 0,
55
+ cumulativeOutputTokens: total.output_tokens || 0,
56
+ cumulativeReasoningOutputTokens: total.reasoning_output_tokens || 0,
57
+ contextWindow: info.model_context_window || null,
58
+ rateLimits: payload.rate_limits || null,
59
+ };
60
+ }
61
+
62
+ function latestRateLimit(rateLimits, previous) {
63
+ if (!rateLimits) return previous || null;
64
+ return {
65
+ planType: rateLimits.plan_type || previous?.planType || null,
66
+ limitId: rateLimits.limit_id || previous?.limitId || null,
67
+ primaryUsedPercent: rateLimits.primary?.used_percent ?? previous?.primaryUsedPercent ?? null,
68
+ primaryWindowMinutes: rateLimits.primary?.window_minutes ?? previous?.primaryWindowMinutes ?? null,
69
+ primaryResetsAt: rateLimits.primary?.resets_at ?? previous?.primaryResetsAt ?? null,
70
+ secondaryUsedPercent: rateLimits.secondary?.used_percent ?? previous?.secondaryUsedPercent ?? null,
71
+ secondaryWindowMinutes: rateLimits.secondary?.window_minutes ?? previous?.secondaryWindowMinutes ?? null,
72
+ secondaryResetsAt: rateLimits.secondary?.resets_at ?? previous?.secondaryResetsAt ?? null,
73
+ reachedType: rateLimits.rate_limit_reached_type || previous?.reachedType || null,
74
+ };
75
+ }
76
+
77
+ function sumTurns(turns, key) {
78
+ return turns.reduce((t, x) => t + (x[key] || 0), 0);
79
+ }
80
+
81
+ function extractSession(entries, filePath, titleMap, promptMap) {
82
+ let meta = {};
83
+ let model = 'unknown';
84
+ let currentUserPrompt = null;
85
+ let currentTurnId = null;
86
+ let latestRate = null;
87
+ const turns = [];
88
+ const toolCounts = {};
89
+ const toolEvents = [];
90
+ let agentMessages = 0;
91
+
92
+ for (const entry of entries) {
93
+ const payload = entry.payload || {};
94
+ if (entry.type === 'session_meta') { meta = payload; model = payload.model || model; continue; }
95
+ if (entry.type === 'turn_context') { currentTurnId = payload.turn_id || currentTurnId; model = payload.model || model; continue; }
96
+ if (payload.type === 'user_message') { if (isHumanPrompt(payload.message)) currentUserPrompt = payload.message; currentTurnId = payload.turn_id || currentTurnId; continue; }
97
+ if (entry.type === 'response_item' && payload.type === 'message' && payload.role === 'user') {
98
+ const text = textFromContent(payload.content);
99
+ if (isHumanPrompt(text)) currentUserPrompt = text;
100
+ continue;
101
+ }
102
+ if (entry.type === 'response_item' && payload.type === 'function_call') {
103
+ const name = payload.name || 'tool';
104
+ toolCounts[name] = (toolCounts[name] || 0) + 1;
105
+ toolEvents.push({ name, timestamp: entry.timestamp, prompt: currentUserPrompt || null, turnId: currentTurnId || null });
106
+ continue;
107
+ }
108
+ if (payload.type === 'agent_message') { agentMessages += 1; continue; }
109
+ const usage = tokenUsageFromPayload(payload);
110
+ if (usage) {
111
+ latestRate = latestRateLimit(usage.rateLimits, latestRate);
112
+ turns.push({ turnId: payload.turn_id || currentTurnId || `turn-${turns.length + 1}`, timestamp: entry.timestamp, prompt: currentUserPrompt || null, model, ...usage });
113
+ }
114
+ }
115
+
116
+ const sessionId = extractSessionId(filePath, meta.id);
117
+ if (turns.length === 0 && agentMessages === 0 && Object.keys(toolCounts).length === 0) return null;
118
+
119
+ const lastTurn = turns[turns.length - 1] || {};
120
+ const peakInputTokens = turns.reduce((max, t) => Math.max(max, t.inputTokens || 0), 0);
121
+ const peakTurnTokens = turns.reduce((max, t) => Math.max(max, t.totalTokens || 0), 0);
122
+ const firstTimestamp = meta.timestamp || entries.find((e) => e.timestamp)?.timestamp || null;
123
+ const updatedTimestamp = entries.slice().reverse().find((e) => e.timestamp)?.timestamp || firstTimestamp;
124
+ const date = firstTimestamp ? firstTimestamp.split('T')[0] : 'unknown';
125
+ const titleCandidate = titleMap[sessionId];
126
+ const historyPrompt = promptMap[sessionId];
127
+ const title = (isHumanPrompt(titleCandidate) && titleCandidate)
128
+ || (isHumanPrompt(historyPrompt) && historyPrompt)
129
+ || (isHumanPrompt(currentUserPrompt) && currentUserPrompt)
130
+ || '(untitled Codex session)';
131
+ const project = projectFromCwd(meta.cwd || entries.find((e) => e.payload?.cwd)?.payload.cwd);
132
+ const promptBreakdown = buildPromptBreakdown(turns, toolEvents, title);
133
+
134
+ return {
135
+ sessionId, filePath,
136
+ archived: filePath.includes(`${path.sep}archived_sessions${path.sep}`),
137
+ title: String(title).slice(0, 240), project, cwd: meta.cwd || null, date,
138
+ timestamp: firstTimestamp, updatedTimestamp, model,
139
+ turnCount: turns.length, agentMessages,
140
+ toolCount: Object.values(toolCounts).reduce((s, n) => s + n, 0), toolCounts,
141
+ promptCount: promptBreakdown.length, promptBreakdown, turns,
142
+ inputTokens: lastTurn.cumulativeInputTokens || sumTurns(turns, 'inputTokens'),
143
+ cachedInputTokens: lastTurn.cumulativeCachedInputTokens || sumTurns(turns, 'cachedInputTokens'),
144
+ outputTokens: lastTurn.cumulativeOutputTokens || sumTurns(turns, 'outputTokens'),
145
+ reasoningOutputTokens: lastTurn.cumulativeReasoningOutputTokens || sumTurns(turns, 'reasoningOutputTokens'),
146
+ totalTokens: lastTurn.cumulativeTokens || sumTurns(turns, 'totalTokens'),
147
+ contextWindow: lastTurn.contextWindow || null, peakInputTokens, peakTurnTokens, rateLimit: latestRate,
148
+ };
149
+ }
150
+
151
+ async function parse(options = {}) {
152
+ const h = home(options);
153
+ const source = { id, label, mark, accent, home: h };
154
+ if (!fs.existsSync(h)) return emptyResult(source, capabilities, [{ type: 'missing-dir', message: `Codex home not found at ${h}` }]);
155
+
156
+ const titleMap = await readJSONLMap(path.join(h, 'session_index.jsonl'), (e) => e.id, (e) => e.thread_name || e.id);
157
+ const promptMap = await readJSONLMap(path.join(h, 'history.jsonl'), (e) => e.session_id, (e) => e.text);
158
+ const files = [...walkJSONL(path.join(h, 'sessions')), ...walkJSONL(path.join(h, 'archived_sessions'))];
159
+ if (files.length === 0) return emptyResult(source, capabilities, [{ type: 'no-sessions', message: 'No Codex session JSONL files found.' }]);
160
+
161
+ const warnings = [];
162
+ const sessions = [];
163
+ for (const filePath of files) {
164
+ let entries;
165
+ try { entries = await parseJSONLFile(filePath); }
166
+ catch (err) { warnings.push({ type: 'read-failed', message: `Could not read ${filePath}: ${err.message}` }); continue; }
167
+ const session = extractSession(entries, filePath, titleMap, promptMap);
168
+ if (session) sessions.push(session);
169
+ }
170
+ return buildResult(sessions, source, capabilities, warnings);
171
+ }
172
+
173
+ module.exports = { id, label, mark, accent, capabilities, home, detect, parse };
@@ -0,0 +1,30 @@
1
+ // Gemini CLI adapter — detected for completeness, but Gemini's local chat logs
2
+ // are an event-sourced format that does not record per-turn token usage, so
3
+ // there is nothing to chart. Reported as detected-but-unsupported.
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { expandHome } = require('./shared');
8
+ const { emptyResult } = require('./aggregate');
9
+
10
+ const id = 'gemini';
11
+ const label = 'Gemini CLI';
12
+ const mark = 'GE';
13
+ const accent = '#5a8fd6';
14
+ const capabilities = { cost: false, reasoning: false, rateLimit: false, cache: false, tools: false, contextWindow: false };
15
+
16
+ function home(options = {}) {
17
+ return expandHome(options.home || process.env.GEMINI_HOME) || path.join(os.homedir(), '.gemini');
18
+ }
19
+ function detect(options = {}) {
20
+ return fs.existsSync(path.join(home(options), 'tmp')) || fs.existsSync(path.join(home(options), 'history'));
21
+ }
22
+ async function parse(options = {}) {
23
+ const h = home(options);
24
+ return emptyResult({ id, label, mark, accent, home: h }, capabilities, [{
25
+ type: 'no-token-data',
26
+ message: 'Gemini CLI does not record per-turn token usage in its local logs, so there is nothing to chart yet.',
27
+ }]);
28
+ }
29
+
30
+ module.exports = { id, label, mark, accent, capabilities, home, detect, parse };
@@ -0,0 +1,29 @@
1
+ // Agent adapter registry. Add new coding agents by dropping an adapter module
2
+ // here that exports { id, label, mark, accent, capabilities, home, detect, parse }.
3
+ const codex = require('./codex');
4
+ const claude = require('./claude');
5
+ const qwen = require('./qwen');
6
+ const opencode = require('./opencode');
7
+ const gemini = require('./gemini');
8
+
9
+ const ADAPTERS = [codex, claude, qwen, opencode, gemini];
10
+
11
+ function list() {
12
+ return ADAPTERS.map((a) => ({
13
+ id: a.id, label: a.label, mark: a.mark, accent: a.accent,
14
+ capabilities: a.capabilities, home: a.home(), available: a.detect(),
15
+ }));
16
+ }
17
+
18
+ function get(sourceId) {
19
+ return ADAPTERS.find((a) => a.id === sourceId) || null;
20
+ }
21
+
22
+ // First available adapter, preferring Codex, then anything detected.
23
+ function defaultSourceId() {
24
+ const available = list().filter((s) => s.available);
25
+ if (available.find((s) => s.id === 'codex')) return 'codex';
26
+ return (available[0] || list()[0]).id;
27
+ }
28
+
29
+ module.exports = { list, get, defaultSourceId, ADAPTERS };
@@ -0,0 +1,155 @@
1
+ // OpenCode adapter — reads the SQLite store at ~/.local/share/opencode/opencode.db
2
+ // (session/message/part tables; assistant messages carry tokens + cost).
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { expandHome, projectFromCwd } = require('./shared');
7
+ const { buildResult, emptyResult, buildPromptBreakdown } = require('./aggregate');
8
+
9
+ const id = 'opencode';
10
+ const label = 'OpenCode';
11
+ const mark = 'OC';
12
+ const accent = '#3fb6a8';
13
+ const capabilities = { cost: true, reasoning: true, rateLimit: false, cache: true, tools: true, contextWindow: false };
14
+
15
+ function dataDir(options = {}) {
16
+ const explicit = expandHome(options.home || process.env.OPENCODE_DATA);
17
+ if (explicit) return explicit;
18
+ const xdg = process.env.XDG_DATA_HOME;
19
+ return xdg ? path.join(xdg, 'opencode') : path.join(os.homedir(), '.local', 'share', 'opencode');
20
+ }
21
+ function dbPath(options = {}) {
22
+ return path.join(dataDir(options), 'opencode.db');
23
+ }
24
+ function home(options = {}) {
25
+ return dbPath(options);
26
+ }
27
+ function detect(options = {}) {
28
+ return fs.existsSync(dbPath(options));
29
+ }
30
+
31
+ function loadSqlite() {
32
+ try { return require('node:sqlite').DatabaseSync; } catch { return null; }
33
+ }
34
+
35
+ function safeParse(json) {
36
+ try { return JSON.parse(json); } catch { return {}; }
37
+ }
38
+
39
+ function toolNameFromPart(d) {
40
+ if (!d) return null;
41
+ const t = String(d.type || '');
42
+ if (t === 'tool' || t.includes('tool')) {
43
+ return d.tool || d.name || d.toolName || (d.toolInvocation && d.toolInvocation.toolName) || 'tool';
44
+ }
45
+ return null;
46
+ }
47
+
48
+ async function parse(options = {}) {
49
+ const file = dbPath(options);
50
+ const source = { id, label, mark, accent, home: file };
51
+ if (!fs.existsSync(file)) return emptyResult(source, capabilities, [{ type: 'missing-dir', message: `OpenCode database not found at ${file}` }]);
52
+ const DatabaseSync = loadSqlite();
53
+ if (!DatabaseSync) return emptyResult(source, capabilities, [{ type: 'no-sqlite', message: 'node:sqlite is unavailable (needs Node 22.5+). Cannot read the OpenCode database.' }]);
54
+
55
+ let db;
56
+ try { db = new DatabaseSync(file, { readOnly: true }); }
57
+ catch (err) { return emptyResult(source, capabilities, [{ type: 'read-failed', message: `Could not open OpenCode database: ${err.message}` }]); }
58
+
59
+ const warnings = [];
60
+ let sessions = [];
61
+ try {
62
+ const sessionRows = db.prepare('SELECT * FROM session ORDER BY time_updated DESC').all();
63
+ const messageRows = db.prepare('SELECT id, session_id, time_created, data FROM message ORDER BY time_created ASC').all();
64
+ const partRows = db.prepare('SELECT message_id, session_id, data FROM part').all();
65
+
66
+ const msgBySession = {};
67
+ for (const m of messageRows) (msgBySession[m.session_id] = msgBySession[m.session_id] || []).push(m);
68
+ const partsByMessage = {};
69
+ for (const p of partRows) (partsByMessage[p.message_id] = partsByMessage[p.message_id] || []).push(safeParse(p.data));
70
+
71
+ for (const s of sessionRows) {
72
+ const messages = msgBySession[s.id] || [];
73
+ const turns = [];
74
+ const toolCounts = {};
75
+ const toolEvents = [];
76
+ let pendingPrompt = null;
77
+
78
+ for (const m of messages) {
79
+ const d = safeParse(m.data);
80
+ const parts = partsByMessage[m.id] || [];
81
+ if (d.role === 'user') {
82
+ const text = parts.filter((p) => p.type === 'text').map((p) => p.text).filter(Boolean).join('\n').trim();
83
+ if (text) pendingPrompt = text;
84
+ continue;
85
+ }
86
+ if (d.role === 'assistant') {
87
+ for (const p of parts) {
88
+ const name = toolNameFromPart(p);
89
+ if (name) {
90
+ toolCounts[name] = (toolCounts[name] || 0) + 1;
91
+ toolEvents.push({ name, prompt: pendingPrompt, timestamp: m.time_created });
92
+ }
93
+ }
94
+ const tk = d.tokens || {};
95
+ const cache = tk.cache || {};
96
+ const input = (tk.input || 0) + (cache.read || 0) + (cache.write || 0);
97
+ const reasoning = tk.reasoning || 0;
98
+ const output = (tk.output || 0) + reasoning;
99
+ turns.push({
100
+ turnId: m.id,
101
+ timestamp: new Date(m.time_created).toISOString(),
102
+ prompt: pendingPrompt,
103
+ model: d.modelID || d.model?.modelID || 'unknown',
104
+ inputTokens: input,
105
+ cachedInputTokens: cache.read || 0,
106
+ outputTokens: output,
107
+ reasoningOutputTokens: reasoning,
108
+ totalTokens: input + output,
109
+ contextWindow: null,
110
+ cost: d.cost || 0,
111
+ });
112
+ }
113
+ }
114
+
115
+ if (!turns.length && Object.keys(toolCounts).length === 0) continue;
116
+
117
+ const firstTs = new Date(s.time_created).toISOString();
118
+ const lastTs = new Date(s.time_updated || s.time_created).toISOString();
119
+ const date = firstTs.split('T')[0];
120
+ const sessionModel = safeParse(s.model).id || safeParse(s.model).modelID;
121
+ const modelCounts = {};
122
+ for (const t of turns) if (t.model && t.model !== 'unknown') modelCounts[t.model] = (modelCounts[t.model] || 0) + 1;
123
+ const primaryModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || sessionModel || 'unknown';
124
+ const title = turns.find((t) => t.prompt)?.prompt || (s.title && !s.title.startsWith('New session') ? s.title : null) || s.title || '(untitled OpenCode session)';
125
+ const promptBreakdown = buildPromptBreakdown(turns, toolEvents, title);
126
+
127
+ sessions.push({
128
+ sessionId: s.id, filePath: file, archived: !!s.time_archived,
129
+ title: String(title).slice(0, 240),
130
+ project: projectFromCwd(s.directory || s.path), cwd: s.directory || null, date,
131
+ timestamp: firstTs, updatedTimestamp: lastTs, model: primaryModel,
132
+ turnCount: turns.length, agentMessages: turns.length,
133
+ toolCount: Object.values(toolCounts).reduce((a, n) => a + n, 0), toolCounts,
134
+ promptCount: promptBreakdown.length, promptBreakdown, turns,
135
+ inputTokens: turns.reduce((a, t) => a + t.inputTokens, 0),
136
+ cachedInputTokens: turns.reduce((a, t) => a + t.cachedInputTokens, 0),
137
+ outputTokens: turns.reduce((a, t) => a + t.outputTokens, 0),
138
+ reasoningOutputTokens: turns.reduce((a, t) => a + t.reasoningOutputTokens, 0),
139
+ totalTokens: turns.reduce((a, t) => a + t.totalTokens, 0),
140
+ cost: turns.reduce((a, t) => a + (t.cost || 0), 0),
141
+ contextWindow: null, peakInputTokens: turns.reduce((mx, t) => Math.max(mx, t.inputTokens || 0), 0),
142
+ peakTurnTokens: turns.reduce((mx, t) => Math.max(mx, t.totalTokens || 0), 0), rateLimit: null,
143
+ });
144
+ }
145
+ } catch (err) {
146
+ warnings.push({ type: 'query-failed', message: `OpenCode database query failed: ${err.message}` });
147
+ } finally {
148
+ try { db.close(); } catch { /* */ }
149
+ }
150
+
151
+ if (!sessions.length) return emptyResult(source, capabilities, warnings.length ? warnings : [{ type: 'no-sessions', message: 'No OpenCode sessions found yet. Use OpenCode and refresh.' }]);
152
+ return buildResult(sessions, source, capabilities, warnings);
153
+ }
154
+
155
+ module.exports = { id, label, mark, accent, capabilities, home, detect, parse };
@@ -0,0 +1,138 @@
1
+ // Qwen Code adapter — reads ~/.qwen/projects/**/chats/*.jsonl
2
+ // (Claude-style transcript lines carrying Gemini-style usageMetadata).
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { expandHome, parseJSONLFile, walkJSONL, textFromContent, projectFromCwd } = require('./shared');
7
+ const { buildResult, emptyResult, buildPromptBreakdown } = require('./aggregate');
8
+
9
+ const id = 'qwen';
10
+ const label = 'Qwen Code';
11
+ const mark = 'QC';
12
+ const accent = '#7c6bd6';
13
+ const capabilities = { cost: false, reasoning: true, rateLimit: false, cache: true, tools: true, contextWindow: true };
14
+
15
+ function home(options = {}) {
16
+ return expandHome(options.home || process.env.QWEN_HOME) || path.join(os.homedir(), '.qwen');
17
+ }
18
+ function detect(options = {}) {
19
+ return fs.existsSync(path.join(home(options), 'projects'));
20
+ }
21
+
22
+ function partsText(parts) {
23
+ if (typeof parts === 'string') return parts;
24
+ if (!Array.isArray(parts)) return '';
25
+ return parts.map((p) => (typeof p === 'string' ? p : p.text || '')).filter(Boolean).join('\n');
26
+ }
27
+ function isHumanPrompt(text) {
28
+ if (!text) return false;
29
+ const v = String(text).trim();
30
+ if (!v) return false;
31
+ if (v.startsWith('<environment') || v.startsWith('This is the Qwen Code') || v.startsWith('You are')) return false;
32
+ return true;
33
+ }
34
+
35
+ function extractSession(entries, filePath) {
36
+ let model = 'unknown';
37
+ let cwd = null;
38
+ let pendingPrompt = null;
39
+ let contextWindow = null;
40
+ const turns = [];
41
+ const toolCounts = {};
42
+ const toolEvents = [];
43
+
44
+ for (const entry of entries) {
45
+ if (entry.cwd && !cwd) cwd = entry.cwd;
46
+ if (entry.contextWindowSize) contextWindow = entry.contextWindowSize;
47
+ const msg = entry.message || {};
48
+ if (entry.type === 'user' || msg.role === 'user') {
49
+ const text = partsText(msg.parts != null ? msg.parts : msg.content) || textFromContent(msg.content) || entry.text;
50
+ if (isHumanPrompt(text)) pendingPrompt = text;
51
+ }
52
+ // Tool calls appear as functionCall parts on assistant/model messages.
53
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
54
+ for (const p of parts) {
55
+ const fc = p.functionCall || p.toolCall;
56
+ if (fc && fc.name) {
57
+ toolCounts[fc.name] = (toolCounts[fc.name] || 0) + 1;
58
+ toolEvents.push({ name: fc.name, prompt: pendingPrompt, timestamp: entry.timestamp });
59
+ }
60
+ }
61
+ const um = entry.usageMetadata || msg.usageMetadata;
62
+ if (um && (um.totalTokenCount || um.promptTokenCount || um.candidatesTokenCount)) {
63
+ const m = entry.model || msg.model || model;
64
+ model = m || model;
65
+ const input = um.promptTokenCount || 0;
66
+ const cached = um.cachedContentTokenCount || 0;
67
+ const reasoning = um.thoughtsTokenCount || 0;
68
+ const output = (um.candidatesTokenCount || 0) + reasoning; // visible + thoughts
69
+ const total = um.totalTokenCount || input + output;
70
+ turns.push({
71
+ turnId: entry.uuid || `turn-${turns.length + 1}`,
72
+ timestamp: entry.timestamp,
73
+ prompt: pendingPrompt,
74
+ model: m,
75
+ inputTokens: input,
76
+ cachedInputTokens: cached,
77
+ outputTokens: output,
78
+ reasoningOutputTokens: reasoning,
79
+ totalTokens: total,
80
+ contextWindow: entry.contextWindowSize || contextWindow || null,
81
+ });
82
+ }
83
+ }
84
+
85
+ if (!turns.length && Object.keys(toolCounts).length === 0) return null;
86
+ const sessionId = entries.find((e) => e.sessionId)?.sessionId || path.basename(filePath, '.jsonl');
87
+ const firstTs = entries.find((e) => e.timestamp)?.timestamp || null;
88
+ const lastTs = entries.slice().reverse().find((e) => e.timestamp)?.timestamp || firstTs;
89
+ const date = firstTs ? firstTs.split('T')[0] : 'unknown';
90
+ const project = projectFromCwd(cwd);
91
+ const modelCounts = {};
92
+ for (const t of turns) if (t.model) modelCounts[t.model] = (modelCounts[t.model] || 0) + 1;
93
+ const primaryModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || model;
94
+ const title = turns.find((t) => isHumanPrompt(t.prompt))?.prompt
95
+ || entries.find((e) => isHumanPrompt(partsText(e.message?.parts) || e.text))?.text
96
+ || '(untitled Qwen session)';
97
+ const promptBreakdown = buildPromptBreakdown(turns, toolEvents, title);
98
+ const peakInputTokens = turns.reduce((mx, t) => Math.max(mx, t.inputTokens || 0), 0);
99
+
100
+ return {
101
+ sessionId, filePath, archived: false,
102
+ title: String(title).slice(0, 240), project, cwd, date,
103
+ timestamp: firstTs, updatedTimestamp: lastTs, model: primaryModel,
104
+ turnCount: turns.length, agentMessages: turns.length,
105
+ toolCount: Object.values(toolCounts).reduce((s, n) => s + n, 0), toolCounts,
106
+ promptCount: promptBreakdown.length, promptBreakdown, turns,
107
+ inputTokens: turns.reduce((s, t) => s + t.inputTokens, 0),
108
+ cachedInputTokens: turns.reduce((s, t) => s + t.cachedInputTokens, 0),
109
+ outputTokens: turns.reduce((s, t) => s + t.outputTokens, 0),
110
+ reasoningOutputTokens: turns.reduce((s, t) => s + t.reasoningOutputTokens, 0),
111
+ totalTokens: turns.reduce((s, t) => s + t.totalTokens, 0),
112
+ contextWindow, peakInputTokens, peakTurnTokens: turns.reduce((mx, t) => Math.max(mx, t.totalTokens || 0), 0),
113
+ rateLimit: null,
114
+ };
115
+ }
116
+
117
+ async function parse(options = {}) {
118
+ const h = home(options);
119
+ const source = { id, label, mark, accent, home: h };
120
+ const projectsDir = path.join(h, 'projects');
121
+ if (!fs.existsSync(projectsDir)) return emptyResult(source, capabilities, [{ type: 'missing-dir', message: `Qwen Code data not found at ${projectsDir}` }]);
122
+ const files = walkJSONL(projectsDir);
123
+ if (!files.length) return emptyResult(source, capabilities, [{ type: 'no-sessions', message: 'No Qwen Code chat JSONL files found.' }]);
124
+
125
+ const warnings = [];
126
+ const sessions = [];
127
+ for (const filePath of files) {
128
+ let entries;
129
+ try { entries = await parseJSONLFile(filePath); } catch { continue; }
130
+ if (!entries.length) continue;
131
+ const session = extractSession(entries, filePath);
132
+ if (session) sessions.push(session);
133
+ }
134
+ if (!sessions.length) return emptyResult(source, capabilities, [{ type: 'no-usage', message: 'No Qwen Code sessions with token usage found.' }]);
135
+ return buildResult(sessions, source, capabilities, warnings);
136
+ }
137
+
138
+ module.exports = { id, label, mark, accent, capabilities, home, detect, parse };
@@ -0,0 +1,87 @@
1
+ // Generic helpers shared by every agent adapter.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const readline = require('readline');
6
+
7
+ function expandHome(inputPath) {
8
+ if (!inputPath) return null;
9
+ if (inputPath === '~') return os.homedir();
10
+ if (inputPath.startsWith('~/')) return path.join(os.homedir(), inputPath.slice(2));
11
+ return inputPath;
12
+ }
13
+
14
+ async function parseJSONLFile(filePath) {
15
+ const entries = [];
16
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
17
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
18
+ for await (const line of rl) {
19
+ if (!line.trim()) continue;
20
+ try {
21
+ entries.push(JSON.parse(line));
22
+ } catch {
23
+ // A single bad line should not hide the rest of the dashboard.
24
+ }
25
+ }
26
+ return entries;
27
+ }
28
+
29
+ async function readJSONLMap(filePath, getKey, getValue) {
30
+ const map = {};
31
+ if (!fs.existsSync(filePath)) return map;
32
+ const entries = await parseJSONLFile(filePath);
33
+ for (const entry of entries) {
34
+ const key = getKey(entry);
35
+ if (!key || map[key]) continue;
36
+ map[key] = getValue(entry);
37
+ }
38
+ return map;
39
+ }
40
+
41
+ function walkJSONL(dir, files = []) {
42
+ if (!fs.existsSync(dir)) return files;
43
+ for (const name of fs.readdirSync(dir)) {
44
+ const filePath = path.join(dir, name);
45
+ let stat;
46
+ try {
47
+ stat = fs.statSync(filePath);
48
+ } catch {
49
+ continue;
50
+ }
51
+ if (stat.isDirectory()) walkJSONL(filePath, files);
52
+ else if (name.endsWith('.jsonl')) files.push(filePath);
53
+ }
54
+ return files;
55
+ }
56
+
57
+ function textFromContent(content) {
58
+ if (typeof content === 'string') return content;
59
+ if (!Array.isArray(content)) return '';
60
+ return content.map((block) => {
61
+ if (typeof block === 'string') return block;
62
+ return block.text || block.message || '';
63
+ }).filter(Boolean).join('\n');
64
+ }
65
+
66
+ function projectFromCwd(cwd) {
67
+ if (!cwd) return 'unknown';
68
+ const home = os.homedir();
69
+ let label = cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
70
+ const parts = label.split(path.sep).filter(Boolean);
71
+ if (parts.length <= 2) return label || 'unknown';
72
+ return parts.slice(-2).join(path.sep);
73
+ }
74
+
75
+ function sum(items, key) {
76
+ return items.reduce((total, item) => total + (item[key] || 0), 0);
77
+ }
78
+
79
+ function fmt(n) {
80
+ n = Number(n || 0);
81
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
82
+ if (n >= 10_000) return Math.round(n / 1000) + 'K';
83
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
84
+ return n.toLocaleString();
85
+ }
86
+
87
+ module.exports = { expandHome, parseJSONLFile, readJSONLMap, walkJSONL, textFromContent, projectFromCwd, sum, fmt };