@zhangferry-dev/tokendash 1.2.1 → 1.4.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.
Files changed (36) hide show
  1. package/README.md +31 -3
  2. package/bin/tokendash.js +5 -1
  3. package/dist/client/assets/{index-DohuMiQc.js → index-B4YgU_cb.js} +49 -49
  4. package/dist/client/assets/index-iYDpTV63.css +1 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/popover.html +1105 -0
  7. package/dist/electron-server.cjs +2162 -0
  8. package/dist/electron-server.cjs.map +7 -0
  9. package/dist/server/agentDetection.d.ts +2 -0
  10. package/dist/server/agentDetection.js +4 -0
  11. package/dist/server/index.d.ts +4 -1
  12. package/dist/server/index.js +64 -22
  13. package/dist/server/opencodeParser.d.ts +22 -0
  14. package/dist/server/opencodeParser.js +292 -0
  15. package/dist/server/routes/analytics.js +1 -1
  16. package/dist/server/routes/api.js +3 -0
  17. package/dist/server/routes/blocks.js +4 -0
  18. package/dist/server/routes/daily.js +4 -0
  19. package/dist/server/routes/projects.js +4 -0
  20. package/electron/main.cjs +450 -0
  21. package/electron/main.js +291 -0
  22. package/electron/preload.cjs +22 -0
  23. package/electron/trayBadge.cjs +25 -0
  24. package/electron/trayBadge.js +30 -0
  25. package/electron/trayHelper +0 -0
  26. package/electron/trayHelper.swift +130 -0
  27. package/electron-builder.yml +17 -0
  28. package/package.json +17 -5
  29. package/resources/cache_diagram.html +456 -0
  30. package/resources/cache_diagram.png +0 -0
  31. package/resources/entitlements.mac.plist +10 -0
  32. package/resources/icon.png +0 -0
  33. package/resources/pr1_preview.png +0 -0
  34. package/resources/product_screenshoot.png +0 -0
  35. package/resources/test_single_agent.png +0 -0
  36. package/dist/client/assets/index-92lvfG3S.css +0 -1
@@ -0,0 +1,292 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { execSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ // ---------------------------------------------------------------------------
6
+ // OpenCode SQLite format
7
+ //
8
+ // Database: ~/.local/share/opencode/opencode.db
9
+ // Table: message
10
+ // Column: data (JSON) with structure:
11
+ // {
12
+ // "role": "assistant",
13
+ // "time": { "created": <ms>, "completed": <ms> },
14
+ // "modelID": "glm-4.7",
15
+ // "providerID": "zhipuai-coding-plan",
16
+ // "tokens": { "input": N, "output": N, "reasoning": N, "cache": { "read": N, "write": N } },
17
+ // "cost": N,
18
+ // "path": { "cwd": "/path/to/project" }
19
+ // }
20
+ // ---------------------------------------------------------------------------
21
+ const OPENCODE_DB = join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
22
+ // ---------------------------------------------------------------------------
23
+ // Detection
24
+ // ---------------------------------------------------------------------------
25
+ export function isOpencodeAccessible() {
26
+ return existsSync(OPENCODE_DB);
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // SQLite query helper
30
+ // ---------------------------------------------------------------------------
31
+ function queryOpenCodeDB(sql) {
32
+ return execSync(`sqlite3 -json "${OPENCODE_DB}" "${sql}"`, {
33
+ encoding: 'utf-8',
34
+ maxBuffer: 50 * 1024 * 1024,
35
+ timeout: 10000,
36
+ });
37
+ }
38
+ export function parseAllOpenCodeEvents(project) {
39
+ let sql = `SELECT data FROM message WHERE json_extract(data, '$.role') = 'assistant'`;
40
+ if (project) {
41
+ sql += ` AND json_extract(data, '$.path.cwd') = '${project.replace(/'/g, "''")}'`;
42
+ }
43
+ let raw;
44
+ try {
45
+ raw = queryOpenCodeDB(sql);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ let rows;
51
+ try {
52
+ rows = JSON.parse(raw);
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ const events = [];
58
+ for (const row of rows) {
59
+ let data;
60
+ try {
61
+ data = JSON.parse(row.data);
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ const tokens = data.tokens || {};
67
+ const cache = tokens.cache || {};
68
+ const time = data.time || {};
69
+ const path = data.path || {};
70
+ const input = Number(tokens.input ?? 0);
71
+ const output = Number(tokens.output ?? 0);
72
+ const cacheRead = Number(cache.read ?? 0);
73
+ const cacheWrite = Number(cache.write ?? 0);
74
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0)
75
+ continue;
76
+ events.push({
77
+ timestampMs: Number(time.created ?? 0),
78
+ inputTokens: Math.max(0, input),
79
+ outputTokens: Math.max(0, output),
80
+ cacheReadTokens: Math.max(0, cacheRead),
81
+ cacheWriteTokens: Math.max(0, cacheWrite),
82
+ totalTokens: Math.max(0, input + output + cacheRead),
83
+ cost: Math.max(0, Number(data.cost ?? 0)),
84
+ model: String(data.modelID ?? 'unknown'),
85
+ project: String(path.cwd ?? ''),
86
+ });
87
+ }
88
+ return events;
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Date / timezone helpers (same logic as openclawParser)
92
+ // ---------------------------------------------------------------------------
93
+ const TZ_OFFSETS = {
94
+ 'Asia/Shanghai': 8,
95
+ 'Asia/Tokyo': 9,
96
+ 'America/New_York': -5,
97
+ 'America/Los_Angeles': -8,
98
+ 'Europe/London': 0,
99
+ 'UTC': 0,
100
+ };
101
+ function getTzOffsetHours(tz) {
102
+ return TZ_OFFSETS[tz] ?? 8;
103
+ }
104
+ function msToLocalDate(ms, tz) {
105
+ return new Date(ms + getTzOffsetHours(tz) * 3_600_000);
106
+ }
107
+ function getDateKey(ms, tz) {
108
+ return msToLocalDate(ms, tz).toISOString().slice(0, 10);
109
+ }
110
+ function getHourKey(ms, tz) {
111
+ const d = msToLocalDate(ms, tz);
112
+ return d.toISOString().slice(0, 13).replace('T', ' ') + ':00';
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Aggregation helpers
116
+ // ---------------------------------------------------------------------------
117
+ function emptyAcc() {
118
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0, cost: 0 };
119
+ }
120
+ function addEvent(acc, ev) {
121
+ acc.inputTokens += ev.inputTokens;
122
+ acc.outputTokens += ev.outputTokens;
123
+ acc.cacheReadTokens += ev.cacheReadTokens;
124
+ acc.cacheWriteTokens += ev.cacheWriteTokens;
125
+ acc.totalTokens += ev.totalTokens;
126
+ acc.cost += ev.cost;
127
+ }
128
+ export function getDailyResponse(options) {
129
+ const events = parseAllOpenCodeEvents(options?.project);
130
+ const tz = options?.timezone || 'Asia/Shanghai';
131
+ const grouped = new Map();
132
+ for (const ev of events) {
133
+ const key = getDateKey(ev.timestampMs, tz);
134
+ if (!grouped.has(key))
135
+ grouped.set(key, { totals: emptyAcc(), models: new Map() });
136
+ const g = grouped.get(key);
137
+ addEvent(g.totals, ev);
138
+ if (!g.models.has(ev.model)) {
139
+ g.models.set(ev.model, { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0 });
140
+ }
141
+ const m = g.models.get(ev.model);
142
+ m.inputTokens += ev.inputTokens;
143
+ m.outputTokens += ev.outputTokens;
144
+ m.cacheCreationTokens += ev.cacheWriteTokens;
145
+ m.cacheReadTokens += ev.cacheReadTokens;
146
+ m.cost += ev.cost;
147
+ }
148
+ const totalsAcc = emptyAcc();
149
+ const daily = [];
150
+ for (const [date, g] of grouped) {
151
+ mergeAcc(totalsAcc, g.totals);
152
+ const modelList = [...g.models.keys()];
153
+ daily.push({
154
+ date,
155
+ inputTokens: g.totals.inputTokens,
156
+ outputTokens: g.totals.outputTokens,
157
+ cacheCreationTokens: g.totals.cacheWriteTokens,
158
+ cacheReadTokens: g.totals.cacheReadTokens,
159
+ totalTokens: g.totals.totalTokens,
160
+ totalCost: g.totals.cost,
161
+ modelsUsed: modelList,
162
+ modelBreakdowns: modelList.map(name => {
163
+ const m = g.models.get(name);
164
+ return {
165
+ modelName: name,
166
+ inputTokens: m.inputTokens,
167
+ outputTokens: m.outputTokens,
168
+ cacheCreationTokens: m.cacheCreationTokens,
169
+ cacheReadTokens: m.cacheReadTokens,
170
+ cost: m.cost,
171
+ };
172
+ }),
173
+ });
174
+ }
175
+ daily.sort((a, b) => a.date.localeCompare(b.date));
176
+ return {
177
+ daily,
178
+ totals: {
179
+ inputTokens: totalsAcc.inputTokens,
180
+ outputTokens: totalsAcc.outputTokens,
181
+ cacheCreationTokens: totalsAcc.cacheWriteTokens,
182
+ cacheReadTokens: totalsAcc.cacheReadTokens,
183
+ totalTokens: totalsAcc.totalTokens,
184
+ totalCost: totalsAcc.cost,
185
+ },
186
+ };
187
+ }
188
+ function mergeAcc(a, b) {
189
+ a.inputTokens += b.inputTokens;
190
+ a.outputTokens += b.outputTokens;
191
+ a.cacheReadTokens += b.cacheReadTokens;
192
+ a.cacheWriteTokens += b.cacheWriteTokens;
193
+ a.totalTokens += b.totalTokens;
194
+ a.cost += b.cost;
195
+ }
196
+ export function getProjectsResponse(options) {
197
+ const events = parseAllOpenCodeEvents();
198
+ const tz = options?.timezone || 'Asia/Shanghai';
199
+ const projects = {};
200
+ for (const ev of events) {
201
+ const projectName = ev.project || 'unknown';
202
+ const dayKey = getDateKey(ev.timestampMs, tz);
203
+ if (!projects[projectName])
204
+ projects[projectName] = [];
205
+ // Find or create entry for this date in this project
206
+ let dayEntry = projects[projectName].find(d => d.date === dayKey);
207
+ if (!dayEntry) {
208
+ dayEntry = {
209
+ date: dayKey,
210
+ inputTokens: 0,
211
+ outputTokens: 0,
212
+ cacheCreationTokens: 0,
213
+ cacheReadTokens: 0,
214
+ totalTokens: 0,
215
+ totalCost: 0,
216
+ modelsUsed: [],
217
+ modelBreakdowns: [],
218
+ };
219
+ projects[projectName].push(dayEntry);
220
+ }
221
+ dayEntry.inputTokens += ev.inputTokens;
222
+ dayEntry.outputTokens += ev.outputTokens;
223
+ dayEntry.cacheCreationTokens += ev.cacheWriteTokens;
224
+ dayEntry.cacheReadTokens += ev.cacheReadTokens;
225
+ dayEntry.totalTokens += ev.totalTokens;
226
+ dayEntry.totalCost += ev.cost;
227
+ if (!dayEntry.modelsUsed.includes(ev.model)) {
228
+ dayEntry.modelsUsed.push(ev.model);
229
+ }
230
+ // Update or add model breakdown
231
+ let breakdown = dayEntry.modelBreakdowns.find(b => b.modelName === ev.model);
232
+ if (!breakdown) {
233
+ breakdown = {
234
+ modelName: ev.model,
235
+ inputTokens: 0,
236
+ outputTokens: 0,
237
+ cacheCreationTokens: 0,
238
+ cacheReadTokens: 0,
239
+ cost: 0,
240
+ };
241
+ dayEntry.modelBreakdowns.push(breakdown);
242
+ }
243
+ breakdown.inputTokens += ev.inputTokens;
244
+ breakdown.outputTokens += ev.outputTokens;
245
+ breakdown.cacheCreationTokens += ev.cacheWriteTokens;
246
+ breakdown.cacheReadTokens += ev.cacheReadTokens;
247
+ breakdown.cost += ev.cost;
248
+ }
249
+ for (const key of Object.keys(projects)) {
250
+ projects[key].sort((a, b) => a.date.localeCompare(b.date));
251
+ }
252
+ return { projects };
253
+ }
254
+ export function getBlocksResponse(options) {
255
+ const events = parseAllOpenCodeEvents(options?.project);
256
+ const tz = options?.timezone || 'Asia/Shanghai';
257
+ const grouped = new Map();
258
+ for (const ev of events) {
259
+ const key = getHourKey(ev.timestampMs, tz);
260
+ if (!grouped.has(key))
261
+ grouped.set(key, { acc: emptyAcc(), models: new Set() });
262
+ addEvent(grouped.get(key).acc, ev);
263
+ grouped.get(key).models.add(ev.model);
264
+ }
265
+ const blocks = [];
266
+ let idx = 0;
267
+ for (const [hourKey, { acc, models }] of grouped) {
268
+ const [datePart, timePart] = hourKey.split(' ');
269
+ const hour = timePart.split(':')[0];
270
+ blocks.push({
271
+ id: `opencode-hour-${idx}`,
272
+ startTime: `${datePart}T${hour}:00:00`,
273
+ endTime: `${datePart}T${hour}:59:59`,
274
+ actualEndTime: null,
275
+ isActive: false,
276
+ isGap: false,
277
+ entries: acc.totalTokens > 0 ? 1 : 0,
278
+ tokenCounts: {
279
+ inputTokens: acc.inputTokens,
280
+ outputTokens: acc.outputTokens,
281
+ cacheCreationInputTokens: acc.cacheWriteTokens,
282
+ cacheReadInputTokens: acc.cacheReadTokens,
283
+ },
284
+ totalTokens: acc.totalTokens,
285
+ costUSD: acc.cost,
286
+ models: [...models],
287
+ });
288
+ idx++;
289
+ }
290
+ blocks.sort((a, b) => a.startTime.localeCompare(b.startTime));
291
+ return { blocks };
292
+ }
@@ -10,7 +10,7 @@ const EMPTY_ANALYTICS = {
10
10
  export async function getAnalytics(req, res) {
11
11
  const agent = req.query.agent || 'claude';
12
12
  const project = req.query.project || undefined;
13
- if (agent === 'codex') {
13
+ if (agent === 'codex' || agent === 'opencode') {
14
14
  res.json(EMPTY_ANALYTICS);
15
15
  return;
16
16
  }
@@ -6,6 +6,7 @@ import { getBlocks } from './blocks.js';
6
6
  import { getAnalytics } from './analytics.js';
7
7
  import { detectAvailableAgents } from '../agentDetection.js';
8
8
  import { isOpenClawAccessible } from '../openclawParser.js';
9
+ import { isOpencodeAccessible } from '../opencodeParser.js';
9
10
  function getAgents(_req, res) {
10
11
  try {
11
12
  const agents = detectAvailableAgents();
@@ -16,6 +17,8 @@ function getAgents(_req, res) {
16
17
  available.push('codex');
17
18
  if (isOpenClawAccessible())
18
19
  available.push('openclaw');
20
+ if (isOpencodeAccessible())
21
+ available.push('opencode');
19
22
  res.json({ available, default: available[0] || null });
20
23
  }
21
24
  catch (error) {
@@ -2,6 +2,7 @@ import { cache } from '../cache.js';
2
2
  import { validateBlocks } from '../../shared/schemas.js';
3
3
  import { getBlocksResponse as getCodexBlocksResponse } from '../codexParser.js';
4
4
  import { getBlocksResponse as getOpenClawBlocksResponse } from '../openclawParser.js';
5
+ import { getBlocksResponse as getOpencodeBlocksResponse } from '../opencodeParser.js';
5
6
  import { getBlocksResponse as getClaudeBlocksResponse } from '../claudeJsonlParser.js';
6
7
  export async function getBlocks(req, res) {
7
8
  const agent = req.query.agent || 'claude';
@@ -37,6 +38,9 @@ function fetchBlocksData(agent, project) {
37
38
  if (agent === 'openclaw') {
38
39
  return validateBlocks(getOpenClawBlocksResponse({ project: project || null }));
39
40
  }
41
+ else if (agent === 'opencode') {
42
+ return validateBlocks(getOpencodeBlocksResponse({ project: project || null }));
43
+ }
40
44
  else if (agent === 'codex') {
41
45
  return validateBlocks(getCodexBlocksResponse({ project: project || null }));
42
46
  }
@@ -2,6 +2,7 @@ import { cache } from '../cache.js';
2
2
  import { validateDaily } from '../../shared/schemas.js';
3
3
  import { getDailyResponse as getCodexDailyResponse } from '../codexParser.js';
4
4
  import { getDailyResponse as getOpenClawDailyResponse } from '../openclawParser.js';
5
+ import { getDailyResponse as getOpencodeDailyResponse } from '../opencodeParser.js';
5
6
  import { getDailyResponse as getClaudeDailyResponse } from '../claudeJsonlParser.js';
6
7
  export async function getDaily(req, res) {
7
8
  const agent = req.query.agent || 'claude';
@@ -39,6 +40,9 @@ function fetchDailyData(agent) {
39
40
  else if (agent === 'openclaw') {
40
41
  return Promise.resolve(validateDaily(getOpenClawDailyResponse()));
41
42
  }
43
+ else if (agent === 'opencode') {
44
+ return Promise.resolve(validateDaily(getOpencodeDailyResponse()));
45
+ }
42
46
  else {
43
47
  // Claude Code: parse JSONL directly (fast, no CLI)
44
48
  return Promise.resolve(validateDaily(getClaudeDailyResponse()));
@@ -2,6 +2,7 @@ import { cache } from '../cache.js';
2
2
  import { validateProjects } from '../../shared/schemas.js';
3
3
  import { getProjectsResponse as getCodexProjectsResponse } from '../codexParser.js';
4
4
  import { getProjectsResponse as getOpenClawProjectsResponse } from '../openclawParser.js';
5
+ import { getProjectsResponse as getOpencodeProjectsResponse } from '../opencodeParser.js';
5
6
  import { getProjectsResponse as getClaudeProjectsResponse } from '../claudeJsonlParser.js';
6
7
  export async function getProjects(req, res) {
7
8
  const agent = req.query.agent || 'claude';
@@ -39,6 +40,9 @@ function fetchProjectsData(agent) {
39
40
  else if (agent === 'openclaw') {
40
41
  return validateProjects(getOpenClawProjectsResponse());
41
42
  }
43
+ else if (agent === 'opencode') {
44
+ return validateProjects(getOpencodeProjectsResponse());
45
+ }
42
46
  else {
43
47
  // Claude Code: parse JSONL directly (fast, no CLI)
44
48
  return validateProjects(getClaudeProjectsResponse());