@juliantanx/aiusage 1.2.1 → 1.3.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 (99) hide show
  1. package/README.md +151 -26
  2. package/dist/index.js +2568 -1478
  3. package/dist/web/_app/immutable/assets/0.C5PeBlns.css +1 -0
  4. package/dist/web/_app/immutable/assets/10.D4XIqeEN.css +1 -0
  5. package/dist/web/_app/immutable/assets/11.CR85TxeS.css +1 -0
  6. package/dist/web/_app/immutable/assets/12.DAmfvZ5J.css +1 -0
  7. package/dist/web/_app/immutable/assets/2.G6nSchsK.css +1 -0
  8. package/dist/web/_app/immutable/assets/3.DQh9JikT.css +1 -0
  9. package/dist/web/_app/immutable/assets/4.CnA4GaE5.css +1 -0
  10. package/dist/web/_app/immutable/assets/5.D4N7n6x9.css +1 -0
  11. package/dist/web/_app/immutable/assets/6.CrsKlz6Y.css +1 -0
  12. package/dist/web/_app/immutable/assets/7.CQvfgmQf.css +1 -0
  13. package/dist/web/_app/immutable/assets/8.DDodt1Wn.css +1 -0
  14. package/dist/web/_app/immutable/assets/9.ApFevNUr.css +1 -0
  15. package/dist/web/_app/immutable/assets/ToolSelector.BRqFUj1Q.css +1 -0
  16. package/dist/web/_app/immutable/assets/ToolSelector.Bqx3ytrl.css +1 -0
  17. package/dist/web/_app/immutable/assets/_layout.C2s9Ry1h.css +1 -0
  18. package/dist/web/_app/immutable/assets/_page.ApFevNUr.css +1 -0
  19. package/dist/web/_app/immutable/assets/_page.CQvfgmQf.css +1 -0
  20. package/dist/web/_app/immutable/assets/_page.CR85TxeS.css +1 -0
  21. package/dist/web/_app/immutable/assets/_page.CmayGpOu.css +1 -0
  22. package/dist/web/_app/immutable/assets/_page.CnA4GaE5.css +1 -0
  23. package/dist/web/_app/immutable/assets/_page.CrsKlz6Y.css +1 -0
  24. package/dist/web/_app/immutable/assets/_page.D4N7n6x9.css +1 -0
  25. package/dist/web/_app/immutable/assets/_page.DAmfvZ5J.css +1 -0
  26. package/dist/web/_app/immutable/assets/_page.DDodt1Wn.css +1 -0
  27. package/dist/web/_app/immutable/assets/_page.DQh9JikT.css +1 -0
  28. package/dist/web/_app/immutable/assets/_page.DewAUGiJ.css +1 -0
  29. package/dist/web/_app/immutable/chunks/ToolSelector.CnQ-eszE.js +2 -0
  30. package/dist/web/_app/immutable/chunks/{api.Cv9iCeAW.js → api.OwJBmtEN.js} +1 -1
  31. package/dist/web/_app/immutable/chunks/{entry.CsncxtbZ.js → entry.DWbp2XzC.js} +1 -1
  32. package/dist/web/_app/immutable/chunks/i18n.BOq44pYI.js +1 -0
  33. package/dist/web/_app/immutable/chunks/{index.BjVwNvJa.js → index.CPsgU-fv.js} +1 -1
  34. package/dist/web/_app/immutable/chunks/{index.CUfK07C5.js → index.wix8pgil.js} +1 -1
  35. package/dist/web/_app/immutable/chunks/scheduler.DFD7qxcZ.js +1 -0
  36. package/dist/web/_app/immutable/chunks/stores.B1g8D8DV.js +1 -0
  37. package/dist/web/_app/immutable/chunks/{stores.UWwZtiEq.js → stores.C3ZtRBEM.js} +1 -1
  38. package/dist/web/_app/immutable/entry/{app.DOglqJ4_.js → app.7Tmcb_qs.js} +2 -2
  39. package/dist/web/_app/immutable/entry/start.CuMG7ijt.js +1 -0
  40. package/dist/web/_app/immutable/nodes/0.GiWuXD22.js +1 -0
  41. package/dist/web/_app/immutable/nodes/{1.DO29y7Dm.js → 1.CNbcoUIj.js} +1 -1
  42. package/dist/web/_app/immutable/nodes/10.DgPr3L-S.js +1 -0
  43. package/dist/web/_app/immutable/nodes/11.TrX-RGQX.js +1 -0
  44. package/dist/web/_app/immutable/nodes/12.ZW7n1TLF.js +1 -0
  45. package/dist/web/_app/immutable/nodes/2.D0gEY9nh.js +3 -0
  46. package/dist/web/_app/immutable/nodes/3.CcnPu3DZ.js +1 -0
  47. package/dist/web/_app/immutable/nodes/4.CCaCZRNi.js +13 -0
  48. package/dist/web/_app/immutable/nodes/5.Bxx7TNGK.js +1 -0
  49. package/dist/web/_app/immutable/nodes/6.8zY9a_YX.js +1 -0
  50. package/dist/web/_app/immutable/nodes/7.bOnMUP4_.js +9 -0
  51. package/dist/web/_app/immutable/nodes/8.01X9rJRT.js +1 -0
  52. package/dist/web/_app/immutable/nodes/9.DabFUxt3.js +1 -0
  53. package/dist/web/_app/version.json +1 -1
  54. package/dist/web/index.html +12 -11
  55. package/package.json +5 -3
  56. package/dist/chunk-2DX56FB6.js +0 -522
  57. package/dist/dist-4I33E46P.js +0 -42
  58. package/dist/web/_app/immutable/assets/0.Cynllvu4.css +0 -1
  59. package/dist/web/_app/immutable/assets/10.Doxq00gF.css +0 -1
  60. package/dist/web/_app/immutable/assets/11.D5bUq0yE.css +0 -1
  61. package/dist/web/_app/immutable/assets/12.BA90S8v7.css +0 -1
  62. package/dist/web/_app/immutable/assets/2.fho1OPpm.css +0 -1
  63. package/dist/web/_app/immutable/assets/3.DwwXF_G1.css +0 -1
  64. package/dist/web/_app/immutable/assets/4.CVp0A8QV.css +0 -1
  65. package/dist/web/_app/immutable/assets/5.DM2hVWjt.css +0 -1
  66. package/dist/web/_app/immutable/assets/6.NdKfVCiG.css +0 -1
  67. package/dist/web/_app/immutable/assets/7.CeV8yHSX.css +0 -1
  68. package/dist/web/_app/immutable/assets/8.BCdjuhVX.css +0 -1
  69. package/dist/web/_app/immutable/assets/9.ClvwkhDe.css +0 -1
  70. package/dist/web/_app/immutable/assets/ToolSelector.BKgB2QVk.css +0 -1
  71. package/dist/web/_app/immutable/assets/ToolSelector.HSbC8-q9.css +0 -1
  72. package/dist/web/_app/immutable/assets/_layout.BpydV4rl.css +0 -1
  73. package/dist/web/_app/immutable/assets/_page.BA90S8v7.css +0 -1
  74. package/dist/web/_app/immutable/assets/_page.BCdjuhVX.css +0 -1
  75. package/dist/web/_app/immutable/assets/_page.CVp0A8QV.css +0 -1
  76. package/dist/web/_app/immutable/assets/_page.CeV8yHSX.css +0 -1
  77. package/dist/web/_app/immutable/assets/_page.ClvwkhDe.css +0 -1
  78. package/dist/web/_app/immutable/assets/_page.D5bUq0yE.css +0 -1
  79. package/dist/web/_app/immutable/assets/_page.DM2hVWjt.css +0 -1
  80. package/dist/web/_app/immutable/assets/_page.DTuZozBR.css +0 -1
  81. package/dist/web/_app/immutable/assets/_page.Doxq00gF.css +0 -1
  82. package/dist/web/_app/immutable/assets/_page.DwwXF_G1.css +0 -1
  83. package/dist/web/_app/immutable/assets/_page.NdKfVCiG.css +0 -1
  84. package/dist/web/_app/immutable/chunks/ToolSelector.RXWBkdpM.js +0 -2
  85. package/dist/web/_app/immutable/chunks/i18n.CrmYDWvg.js +0 -1
  86. package/dist/web/_app/immutable/chunks/scheduler.DukoKnjQ.js +0 -1
  87. package/dist/web/_app/immutable/entry/start.BH01iOT0.js +0 -1
  88. package/dist/web/_app/immutable/nodes/0.eiglG3hs.js +0 -1
  89. package/dist/web/_app/immutable/nodes/10.Jb_wogsG.js +0 -1
  90. package/dist/web/_app/immutable/nodes/11.CpuiQa6R.js +0 -1
  91. package/dist/web/_app/immutable/nodes/12.BiWlfD7q.js +0 -1
  92. package/dist/web/_app/immutable/nodes/2.CQudsTBz.js +0 -3
  93. package/dist/web/_app/immutable/nodes/3.Dg9c_XQX.js +0 -1
  94. package/dist/web/_app/immutable/nodes/4.DE3OvqSg.js +0 -11
  95. package/dist/web/_app/immutable/nodes/5.sGu0NOiv.js +0 -1
  96. package/dist/web/_app/immutable/nodes/6.BOL9Fhgv.js +0 -1
  97. package/dist/web/_app/immutable/nodes/7.B2wRNntO.js +0 -1
  98. package/dist/web/_app/immutable/nodes/8.BVACtdPg.js +0 -1
  99. package/dist/web/_app/immutable/nodes/9.DXxXyKe4.js +0 -1
package/dist/index.js CHANGED
@@ -1,19 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- Aggregator,
4
- DEFAULT_PRICE_TABLE,
5
- calculateCost,
6
- generateRecordId,
7
- generateSessionKey,
8
- generateSyncRecordId,
9
- generateToolCallId,
10
- getPriceTable,
11
- getUserOverrides,
12
- inferProvider,
13
- removePriceOverride,
14
- resolvePrice,
15
- setPriceOverride
16
- } from "./chunk-2DX56FB6.js";
17
2
 
18
3
  // src/cli.ts
19
4
  import { Command } from "commander";
@@ -27,7 +12,667 @@ import { fileURLToPath } from "url";
27
12
 
28
13
  // src/api/server.ts
29
14
  import http from "http";
30
- import { hostname, platform } from "os";
15
+ import path2 from "path";
16
+ import { hostname as hostname2, platform as platform2 } from "os";
17
+
18
+ // ../core/dist/index.js
19
+ import { createHash } from "crypto";
20
+ var MODEL_PROVIDER_MAP = [
21
+ ["claude-", "anthropic"],
22
+ ["gpt-", "openai"],
23
+ ["o1-", "openai"],
24
+ ["o3-", "openai"],
25
+ ["o4-", "openai"],
26
+ ["o-", "openai"],
27
+ ["deepseek-", "deepseek"],
28
+ ["gemini-", "google"],
29
+ ["glm-", "zhipu"],
30
+ ["mimo-", "xiaomi"],
31
+ ["minimax-", "minimax"],
32
+ ["kimi-", "moonshot"],
33
+ ["qianfan-", "baidu"],
34
+ ["qwen", "alibaba"],
35
+ ["qoder-", "qoder"],
36
+ ["z-ai/", "zhipu"],
37
+ ["accounts/fireworks/", "fireworks"],
38
+ ["frank/", "zhipu"],
39
+ ["nvidia/", "nvidia"],
40
+ ["moonshotai/", "moonshot"],
41
+ ["zai-org/", "zhipu"]
42
+ ];
43
+ function inferProvider(model) {
44
+ for (const [prefix, provider] of MODEL_PROVIDER_MAP) {
45
+ if (model.startsWith(prefix)) return provider;
46
+ }
47
+ return "unknown";
48
+ }
49
+ var FALLBACK_RATE = 0.137;
50
+ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
51
+ function resolveExchangeRate(config) {
52
+ if (config.exchangeRate != null) return config.exchangeRate;
53
+ if (config.exchangeRateCache) {
54
+ const age = Date.now() - config.exchangeRateCache.fetchedAt;
55
+ if (age < CACHE_TTL_MS) return config.exchangeRateCache.CNY_USD;
56
+ }
57
+ return FALLBACK_RATE;
58
+ }
59
+ async function fetchExchangeRate() {
60
+ try {
61
+ const response = await fetch("https://open.er-api.com/v6/latest/CNY", {
62
+ signal: AbortSignal.timeout(1e4)
63
+ });
64
+ if (!response.ok) return null;
65
+ const data = await response.json();
66
+ const rate = data?.rates?.USD;
67
+ if (typeof rate !== "number" || rate <= 0) return null;
68
+ return rate;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ function convertToUSD(cny, rate) {
74
+ return cny * rate;
75
+ }
76
+ var DEFAULT_PRICE_TABLE = {
77
+ // Anthropic — https://platform.claude.com/docs/en/about-claude/pricing
78
+ "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
79
+ "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
80
+ "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
81
+ "claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
82
+ "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
83
+ // OpenAI — https://developers.openai.com/api/docs/pricing
84
+ "gpt-5.5-pro": { input: 30, output: 180 },
85
+ "gpt-5.5": { input: 5, output: 30 },
86
+ "gpt-5.4-pro": { input: 30, output: 180 },
87
+ "gpt-5.4-mini": { input: 0.75, output: 4.5 },
88
+ "gpt-5.4-nano": { input: 0.2, output: 1.25 },
89
+ "gpt-5.4": { input: 2.5, output: 15 },
90
+ "gpt-5.3-codex": { input: 1.75, output: 14 },
91
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
92
+ "gpt-4o": { input: 2.5, output: 10 },
93
+ "gpt-4.1": { input: 2, output: 8 },
94
+ "o4-mini": { input: 1.1, output: 4.4 },
95
+ "o3": { input: 2, output: 8 },
96
+ // Google Gemini — https://ai.google.dev/gemini-api/docs/pricing
97
+ "gemini-3.1-pro": { input: 2, output: 12 },
98
+ "gemini-3.1-flash-lite": { input: 0.25, output: 1.5 },
99
+ "gemini-3-flash": { input: 0.5, output: 3 },
100
+ "gemini-2.5-pro": { input: 1.25, output: 10 },
101
+ "gemini-2.5-flash-lite": { input: 0.1, output: 0.4 },
102
+ "gemini-2.5-flash": { input: 0.3, output: 2.5 },
103
+ "gemini-2.0-flash": { input: 0.1, output: 0.4 },
104
+ // DeepSeek — https://api-docs.deepseek.com/quick_start/pricing
105
+ "deepseek-v4-pro": { input: 12.6, output: 25.2, cacheRead: 0.105, currency: "CNY" },
106
+ "deepseek-v4-flash": { input: 1, output: 2, cacheRead: 0.02, currency: "CNY" },
107
+ // Kimi (Moonshot AI) — https://platform.kimi.ai/docs/pricing/chat
108
+ "kimi-k2.6": { input: 6.9, output: 29, cacheRead: 1.15, currency: "CNY" },
109
+ "kimi-k2.5": { input: 4.3, output: 21.6, cacheRead: 0.72, currency: "CNY" },
110
+ "kimi-k2-turbo": { input: 8.3, output: 57.6, cacheRead: 1.08, currency: "CNY" },
111
+ "kimi-k2": { input: 4.3, output: 18, cacheRead: 1.08, currency: "CNY" },
112
+ "moonshot-v1-128k": { input: 14.4, output: 36, currency: "CNY" },
113
+ "moonshot-v1-32k": { input: 7.2, output: 21.6, currency: "CNY" },
114
+ "moonshot-v1-8k": { input: 1.44, output: 14.4, currency: "CNY" },
115
+ // GLM (Z.ai / Zhipu AI) — https://docs.z.ai/guides/overview/pricing — TODO: verify against official pricing
116
+ "glm-5.1": { input: 10, output: 32, currency: "CNY" },
117
+ "glm-5p1": { input: 10, output: 32, currency: "CNY" },
118
+ // Fireworks alias (accounts/fireworks/models/glm-5p1)
119
+ "glm-5-turbo": { input: 8.5, output: 29, currency: "CNY" },
120
+ "glm-5": { input: 7, output: 23, currency: "CNY" },
121
+ "glm-4.7-flashx": { input: 0.5, output: 2.8, currency: "CNY" },
122
+ "glm-4.7": { input: 4, output: 16, currency: "CNY" },
123
+ "glm-4.6": { input: 4, output: 16, currency: "CNY" },
124
+ "glm-4.5-x": { input: 16, output: 64, currency: "CNY" },
125
+ "glm-4.5-airx": { input: 8, output: 32, currency: "CNY" },
126
+ "glm-4.5-air": { input: 1.4, output: 8, currency: "CNY" },
127
+ "glm-4.5": { input: 4, output: 16, currency: "CNY" },
128
+ // Qwen (Alibaba Cloud) — https://www.alibabacloud.com/help/en/model-studio/model-pricing
129
+ "qwen3.6-plus": { input: 2, output: 12, currency: "CNY" },
130
+ "qwen3-235b": { input: 5, output: 20, currency: "CNY" },
131
+ "qwen3-32b": { input: 1.12, output: 4.48, currency: "CNY" },
132
+ "qwen3-30b": { input: 1.4, output: 5.6, currency: "CNY" },
133
+ "qwen-max": { input: 11.5, output: 46, currency: "CNY" },
134
+ "qwen-plus": { input: 2.8, output: 8.4, currency: "CNY" },
135
+ "qwen-turbo": { input: 0.35, output: 1.4, currency: "CNY" },
136
+ "qwen-long": { input: 0.5, output: 2, currency: "CNY" },
137
+ "qwen2.5-72b": { input: 10, output: 40, currency: "CNY" },
138
+ "qwen2.5-7b": { input: 1.26, output: 5.04, currency: "CNY" },
139
+ // MiniMax — https://platform.minimaxi.com/docs/guides/pricing-paygo
140
+ "minimax-m2.7": { input: 2.1, output: 8.4, cacheRead: 0.42, cacheWrite: 2.6, currency: "CNY" },
141
+ "minimax-m2.5": { input: 2.1, output: 8.4, cacheRead: 0.21, cacheWrite: 2.6, currency: "CNY" },
142
+ // Mistral AI — https://mistral.ai/pricing
143
+ "mistral-large": { input: 0.5, output: 1.5 },
144
+ "mistral-medium": { input: 0.4, output: 2 },
145
+ "mistral-small": { input: 0.1, output: 0.3 },
146
+ "codestral": { input: 0.3, output: 0.9 },
147
+ "open-mistral-nemo": { input: 0.02, output: 0.03 },
148
+ "open-mixtral-8x22b": { input: 1.2, output: 1.2 },
149
+ "ministral-8b": { input: 0.1, output: 0.1 },
150
+ "ministral-3b": { input: 0.04, output: 0.04 },
151
+ "pixtral-12b": { input: 0.1, output: 0.1 },
152
+ // xAI Grok — https://docs.x.ai/developers/models
153
+ "grok-4-1-fast": { input: 0.2, output: 0.5 },
154
+ "grok-4": { input: 1.25, output: 2.5 },
155
+ // Cohere — https://cohere.com/pricing
156
+ "command-r-plus": { input: 2.5, output: 10 },
157
+ "command-r": { input: 0.15, output: 0.6 },
158
+ // Doubao (ByteDance) — https://www.volcengine.com/docs/82379/1544106
159
+ "doubao-seed-2.0-pro": { input: 3.7, output: 18.5, currency: "CNY" },
160
+ "doubao-seed-2.0-code": { input: 3.36, output: 16.8, currency: "CNY" },
161
+ "doubao-seed-2.0-lite": { input: 0.63, output: 3.78, currency: "CNY" },
162
+ "doubao-seed-2.0-mini": { input: 0.21, output: 2.1, currency: "CNY" },
163
+ "doubao-seed-1.6-flash": { input: 0.16, output: 1.58, currency: "CNY" },
164
+ "doubao-seed-1.6-lite": { input: 0.32, output: 2.52, currency: "CNY" },
165
+ "doubao-seed-1.6": { input: 0.84, output: 8.4, currency: "CNY" },
166
+ "doubao-1.5-pro": { input: 0.84, output: 2.1, currency: "CNY" },
167
+ "doubao-1.5-lite": { input: 0.32, output: 0.63, currency: "CNY" },
168
+ // Hunyuan (Tencent) — https://cloud.tencent.com/document/product/1729 — TODO: verify against official pricing
169
+ "hunyuan-t1": { input: 0.48, output: 1.87, cacheRead: 0.21, currency: "CNY" },
170
+ "hunyuan-a13b": { input: 1, output: 4.1, currency: "CNY" },
171
+ // ERNIE (Baidu) — https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Blfmc9dlf — TODO: verify against official pricing
172
+ "ernie-4.5-300b": { input: 2, output: 6.5, currency: "CNY" },
173
+ "ernie-4.5-21b": { input: 0.5, output: 2, currency: "CNY" },
174
+ "ernie-x1": { input: 2, output: 8, currency: "CNY" },
175
+ // Qoder credits — https://docs.qoder.com/user-guide/chat/model-tier-selector
176
+ // Qoder publishes credit multipliers and a $0.02 / credit pack price, but
177
+ // not a full per-token tariff. Estimate 1.0x as 1 credit / 10K tokens.
178
+ // Ultimate uses the current official limited-time 0.8x discount.
179
+ "qoder-auto": { input: 2, output: 2, cacheRead: 2, cacheWrite: 2 },
180
+ "qoder-ultimate": { input: 1.6, output: 1.6, cacheRead: 1.6, cacheWrite: 1.6 },
181
+ "qoder-performance": { input: 2.2, output: 2.2, cacheRead: 2.2, cacheWrite: 2.2 },
182
+ "qoder-efficient": { input: 0.6, output: 0.6, cacheRead: 0.6, cacheWrite: 0.6 },
183
+ "qoder-lite": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
184
+ };
185
+ var userOverrides = {};
186
+ var PRICE_TABLE = { ...DEFAULT_PRICE_TABLE };
187
+ function getPriceTable() {
188
+ return { ...DEFAULT_PRICE_TABLE, ...userOverrides };
189
+ }
190
+ function setPriceOverride(model, entry) {
191
+ userOverrides = { ...userOverrides, [model]: entry };
192
+ PRICE_TABLE = { ...DEFAULT_PRICE_TABLE, ...userOverrides };
193
+ }
194
+ function removePriceOverride(model) {
195
+ const { [model]: _, ...rest } = userOverrides;
196
+ userOverrides = rest;
197
+ PRICE_TABLE = { ...DEFAULT_PRICE_TABLE, ...userOverrides };
198
+ }
199
+ function getUserOverrides() {
200
+ return { ...userOverrides };
201
+ }
202
+ var PROVIDER_PREFIXES = [
203
+ "accounts/fireworks/models/",
204
+ "moonshotai/",
205
+ "z-ai/",
206
+ "zai-org/",
207
+ "frank/",
208
+ "nvidia/"
209
+ ];
210
+ function resolvePrice(model) {
211
+ if (PRICE_TABLE[model]) return PRICE_TABLE[model];
212
+ let stripped = model;
213
+ for (const prefix of PROVIDER_PREFIXES) {
214
+ if (stripped.startsWith(prefix)) {
215
+ stripped = stripped.slice(prefix.length);
216
+ break;
217
+ }
218
+ }
219
+ if (stripped !== model) {
220
+ const lc = stripped.toLowerCase();
221
+ if (PRICE_TABLE[lc]) return PRICE_TABLE[lc];
222
+ if (PRICE_TABLE[stripped]) return PRICE_TABLE[stripped];
223
+ }
224
+ let bestPrefix = "";
225
+ let bestEntry;
226
+ const candidates = [model, stripped, stripped.toLowerCase()];
227
+ for (const c of candidates) {
228
+ for (const [prefix, entry] of Object.entries(PRICE_TABLE)) {
229
+ if (c.startsWith(prefix) && prefix.length > bestPrefix.length) {
230
+ bestPrefix = prefix;
231
+ bestEntry = entry;
232
+ }
233
+ }
234
+ }
235
+ return bestEntry;
236
+ }
237
+ function calculateCost(model, tokens, exchangeRate) {
238
+ const price = resolvePrice(model);
239
+ if (!price) return 0;
240
+ const inputCost = tokens.inputTokens / 1e6 * price.input;
241
+ const outputCost = tokens.outputTokens / 1e6 * price.output;
242
+ const cacheReadCost = tokens.cacheReadTokens / 1e6 * (price.cacheRead ?? 0);
243
+ const cacheWriteCost = tokens.cacheWriteTokens / 1e6 * (price.cacheWrite ?? 0);
244
+ const thinkingCost = tokens.thinkingTokens / 1e6 * price.output;
245
+ const rawCost = inputCost + outputCost + cacheReadCost + cacheWriteCost + thinkingCost;
246
+ if (price.currency === "CNY") {
247
+ return convertToUSD(rawCost, exchangeRate ?? FALLBACK_RATE);
248
+ }
249
+ return rawCost;
250
+ }
251
+ var QODER_TIER_MODELS = /* @__PURE__ */ new Set([
252
+ "auto",
253
+ "ultimate",
254
+ "performance",
255
+ "efficient",
256
+ "lite"
257
+ ]);
258
+ function normalizeQoderModel(model) {
259
+ const raw = model.trim();
260
+ if (!raw || raw.toLowerCase() === "unknown") return "unknown";
261
+ const lower = raw.toLowerCase();
262
+ const tier = lower.startsWith("qoder-") ? lower.slice("qoder-".length) : lower;
263
+ if (!tier) return "unknown";
264
+ if (QODER_TIER_MODELS.has(tier)) return `qoder-${tier}`;
265
+ return raw;
266
+ }
267
+ function generateRecordId(deviceInstanceId, sourceFile, lineOffset) {
268
+ const hash = createHash("sha256").update(deviceInstanceId + "\0" + sourceFile + "\0" + lineOffset).digest("hex");
269
+ return hash.slice(0, 16);
270
+ }
271
+ function generateSyncRecordId(deviceInstanceId, sourceFile, lineOffset) {
272
+ return generateRecordId(deviceInstanceId, sourceFile, lineOffset);
273
+ }
274
+ function generateToolCallId(recordId, name, ts, callIndex) {
275
+ const hash = createHash("sha256").update(recordId + "\0" + name + "\0" + ts + "\0" + callIndex).digest("hex");
276
+ return hash.slice(0, 16);
277
+ }
278
+ function generateOrphanToolCallId(tool, name, ts, callIndex) {
279
+ const hash = createHash("sha256").update(tool + "\0" + name + "\0" + ts + "\0" + callIndex).digest("hex");
280
+ return hash.slice(0, 16);
281
+ }
282
+ function generateSessionKey(device, sessionId) {
283
+ const hash = createHash("sha256").update(device + "\0" + sessionId).digest("hex");
284
+ return hash.slice(0, 24);
285
+ }
286
+ var ClaudeCodeParser = class {
287
+ tool = "claude-code";
288
+ parseLine(line, context) {
289
+ let parsed;
290
+ try {
291
+ parsed = JSON.parse(line);
292
+ } catch {
293
+ return null;
294
+ }
295
+ if (!parsed.message?.usage) return null;
296
+ const usage = parsed.message.usage;
297
+ const model = parsed.message.model ?? "unknown";
298
+ if (model === "<synthetic>") return null;
299
+ const rawTs = parsed.message.timestamp ?? parsed.timestamp ?? context.now;
300
+ const ts = typeof rawTs === "number" ? rawTs : new Date(rawTs).getTime();
301
+ const inputTokens = usage.input_tokens ?? 0;
302
+ const outputTokens = usage.output_tokens ?? 0;
303
+ const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0;
304
+ const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
305
+ const thinkingTokens = usage.thinking_tokens ?? 0;
306
+ const cost = model === "unknown" ? 0 : calculateCost(model, {
307
+ inputTokens,
308
+ outputTokens,
309
+ cacheReadTokens,
310
+ cacheWriteTokens,
311
+ thinkingTokens
312
+ }, context.exchangeRate);
313
+ const costSource = model === "unknown" ? "unknown" : "pricing";
314
+ const provider = inferProvider(model);
315
+ const record = {
316
+ id: generateRecordId(context.deviceInstanceId, context.sourceFile, context.lineOffset),
317
+ ts,
318
+ ingestedAt: context.now,
319
+ updatedAt: context.now,
320
+ lineOffset: context.lineOffset,
321
+ tool: this.tool,
322
+ model,
323
+ provider,
324
+ inputTokens,
325
+ outputTokens,
326
+ cacheReadTokens,
327
+ cacheWriteTokens,
328
+ thinkingTokens,
329
+ cost,
330
+ costSource,
331
+ sessionId: context.sessionId,
332
+ sourceFile: context.sourceFile,
333
+ device: context.device,
334
+ deviceInstanceId: context.deviceInstanceId,
335
+ platform: context.platform
336
+ };
337
+ const toolCalls = [];
338
+ if (Array.isArray(parsed.message.content)) {
339
+ let callIndex = 0;
340
+ for (const block of parsed.message.content) {
341
+ if (block.type === "tool_use") {
342
+ const rawName = block.name ?? "";
343
+ const cleanName = rawName.replace(/[=:"'{\[\s].*$/s, "").replace(/[^a-zA-Z0-9_-]/g, "");
344
+ if (!cleanName) {
345
+ callIndex++;
346
+ continue;
347
+ }
348
+ toolCalls.push({
349
+ id: generateToolCallId(record.id, cleanName, ts, callIndex),
350
+ recordId: record.id,
351
+ name: cleanName,
352
+ ts,
353
+ callIndex
354
+ });
355
+ callIndex++;
356
+ }
357
+ }
358
+ }
359
+ return { record, toolCalls };
360
+ }
361
+ };
362
+ var CodexParser = class {
363
+ tool = "codex";
364
+ pendingToolCalls = [];
365
+ currentModel = null;
366
+ parseLine(line, context) {
367
+ let parsed;
368
+ try {
369
+ parsed = JSON.parse(line);
370
+ } catch {
371
+ return null;
372
+ }
373
+ const payload = parsed.event_msg?.payload ?? (parsed.type === "event_msg" ? parsed.payload : void 0);
374
+ const turnCtx = parsed.type === "turn_context" ? parsed.payload : void 0;
375
+ if (turnCtx?.model) {
376
+ this.currentModel = turnCtx.model;
377
+ }
378
+ if (payload?.type === "turn_context" && payload.model) {
379
+ this.currentModel = payload.model;
380
+ }
381
+ if (!payload) return null;
382
+ if (payload.type !== "token_count" && payload.type !== "function_call") return null;
383
+ if (payload.type === "function_call") {
384
+ const rawFcTs = parsed.event_msg?.timestamp ?? parsed.timestamp ?? context.now;
385
+ this.pendingToolCalls.push({
386
+ name: payload.function?.name ?? "unknown",
387
+ ts: typeof rawFcTs === "number" ? rawFcTs : new Date(rawFcTs).getTime()
388
+ });
389
+ return null;
390
+ }
391
+ const usage = payload.last_token_usage ?? payload.info?.last_token_usage;
392
+ if (!usage) {
393
+ return null;
394
+ }
395
+ const model = payload.model ?? parsed.model ?? this.currentModel ?? "unknown";
396
+ const rawTs = parsed.event_msg?.timestamp ?? parsed.timestamp ?? context.now;
397
+ const ts = typeof rawTs === "number" ? rawTs : new Date(rawTs).getTime();
398
+ const inputTokens = usage.input_tokens ?? 0;
399
+ const outputTokens = usage.output_tokens ?? 0;
400
+ const cacheReadTokens = usage.cached_input_tokens ?? 0;
401
+ const thinkingTokens = usage.reasoning_output_tokens ?? 0;
402
+ const cacheWriteTokens = 0;
403
+ const cost = model === "unknown" ? 0 : calculateCost(model, {
404
+ inputTokens,
405
+ outputTokens,
406
+ cacheReadTokens,
407
+ cacheWriteTokens,
408
+ thinkingTokens
409
+ }, context.exchangeRate);
410
+ const costSource = model === "unknown" ? "unknown" : "pricing";
411
+ const provider = inferProvider(model);
412
+ const recordId = generateRecordId(context.deviceInstanceId, context.sourceFile, context.lineOffset);
413
+ const record = {
414
+ id: recordId,
415
+ ts,
416
+ ingestedAt: context.now,
417
+ updatedAt: context.now,
418
+ lineOffset: context.lineOffset,
419
+ tool: this.tool,
420
+ model,
421
+ provider,
422
+ inputTokens,
423
+ outputTokens,
424
+ cacheReadTokens,
425
+ cacheWriteTokens,
426
+ thinkingTokens,
427
+ cost,
428
+ costSource,
429
+ sessionId: context.sessionId,
430
+ sourceFile: context.sourceFile,
431
+ device: context.device,
432
+ deviceInstanceId: context.deviceInstanceId,
433
+ platform: context.platform
434
+ };
435
+ const toolCalls = this.pendingToolCalls.map((tc, callIndex) => ({
436
+ id: generateToolCallId(recordId, tc.name, tc.ts, callIndex),
437
+ recordId,
438
+ name: tc.name,
439
+ ts: tc.ts,
440
+ callIndex
441
+ }));
442
+ this.pendingToolCalls = [];
443
+ return { record, toolCalls };
444
+ }
445
+ finalize() {
446
+ if (this.pendingToolCalls.length === 0) return [];
447
+ const toolCalls = this.pendingToolCalls.map((tc, callIndex) => ({
448
+ id: generateOrphanToolCallId(this.tool, tc.name, tc.ts, callIndex),
449
+ recordId: null,
450
+ name: tc.name,
451
+ ts: tc.ts,
452
+ callIndex
453
+ }));
454
+ this.pendingToolCalls = [];
455
+ return [{ record: null, toolCalls }];
456
+ }
457
+ };
458
+ var OpenClawParser = class {
459
+ tool = "openclaw";
460
+ parseLine(line, context) {
461
+ let parsed;
462
+ try {
463
+ parsed = JSON.parse(line);
464
+ } catch {
465
+ return null;
466
+ }
467
+ if (!parsed.message?.usage) return null;
468
+ const usage = parsed.message.usage;
469
+ const model = parsed.message.model ?? "unknown";
470
+ const rawTs = parsed.message.timestamp ?? context.now;
471
+ const ts = typeof rawTs === "number" ? rawTs : new Date(rawTs).getTime();
472
+ const inputTokens = usage.input ?? 0;
473
+ const outputTokens = usage.output ?? 0;
474
+ const cacheReadTokens = usage.cacheRead ?? 0;
475
+ const cacheWriteTokens = usage.cacheWrite ?? 0;
476
+ const thinkingTokens = 0;
477
+ let cost;
478
+ let costSource;
479
+ if (usage.cost != null && typeof usage.cost === "object") {
480
+ cost = usage.cost.total ?? 0;
481
+ costSource = "log";
482
+ } else if (typeof usage.cost === "number") {
483
+ cost = usage.cost;
484
+ costSource = "log";
485
+ } else if (model === "unknown") {
486
+ cost = 0;
487
+ costSource = "unknown";
488
+ } else {
489
+ cost = calculateCost(model, {
490
+ inputTokens,
491
+ outputTokens,
492
+ cacheReadTokens,
493
+ cacheWriteTokens,
494
+ thinkingTokens
495
+ }, context.exchangeRate);
496
+ costSource = "pricing";
497
+ }
498
+ const provider = parsed.message.provider ?? inferProvider(model);
499
+ const record = {
500
+ id: generateRecordId(context.deviceInstanceId, context.sourceFile, context.lineOffset),
501
+ ts,
502
+ ingestedAt: context.now,
503
+ updatedAt: context.now,
504
+ lineOffset: context.lineOffset,
505
+ tool: this.tool,
506
+ model,
507
+ provider,
508
+ inputTokens,
509
+ outputTokens,
510
+ cacheReadTokens,
511
+ cacheWriteTokens,
512
+ thinkingTokens,
513
+ cost,
514
+ costSource,
515
+ sessionId: context.sessionId,
516
+ sourceFile: context.sourceFile,
517
+ device: context.device,
518
+ deviceInstanceId: context.deviceInstanceId,
519
+ platform: context.platform
520
+ };
521
+ const toolCalls = [];
522
+ if (Array.isArray(parsed.message.content)) {
523
+ let callIndex = 0;
524
+ for (const block of parsed.message.content) {
525
+ if (block.type === "tool_use") {
526
+ toolCalls.push({
527
+ id: generateToolCallId(record.id, block.name, ts, callIndex),
528
+ recordId: record.id,
529
+ name: block.name,
530
+ ts,
531
+ callIndex
532
+ });
533
+ callIndex++;
534
+ }
535
+ }
536
+ }
537
+ return { record, toolCalls };
538
+ }
539
+ };
540
+ function toNumber(value) {
541
+ if (value == null) return 0;
542
+ const n = Number(value);
543
+ return Number.isFinite(n) ? n : 0;
544
+ }
545
+ function toTimestamp(value, fallback) {
546
+ if (typeof value === "number") return value;
547
+ if (typeof value !== "string") return fallback;
548
+ const ts = new Date(value).getTime();
549
+ return Number.isFinite(ts) ? ts : fallback;
550
+ }
551
+ var QoderParser = class {
552
+ tool = "qoder";
553
+ pendingToolCallsByLoopId = /* @__PURE__ */ new Map();
554
+ parseLine(line, context) {
555
+ let parsed;
556
+ try {
557
+ parsed = JSON.parse(line);
558
+ } catch {
559
+ return null;
560
+ }
561
+ if (parsed.type === "tool.requested") {
562
+ const loopId2 = parsed.loop_id ?? parsed.turn_id;
563
+ const toolName = parsed.data?.tool_name;
564
+ if (typeof loopId2 !== "string" || typeof toolName !== "string" || !toolName) {
565
+ return null;
566
+ }
567
+ const pending = this.pendingToolCallsByLoopId.get(loopId2) ?? [];
568
+ pending.push({
569
+ name: toolName,
570
+ ts: toTimestamp(parsed.ts, context.now)
571
+ });
572
+ this.pendingToolCallsByLoopId.set(loopId2, pending);
573
+ return null;
574
+ }
575
+ if (parsed.type !== "model.response.completed") return null;
576
+ const data = parsed.data;
577
+ if (!data || typeof data !== "object") return null;
578
+ const ts = toTimestamp(parsed.ts, context.now);
579
+ const rawModel = normalizeQoderModel(String(data.model ?? ""));
580
+ const model = rawModel === "unknown" ? "qoder-auto" : rawModel;
581
+ const inputTokens = toNumber(data.input_tokens);
582
+ const outputTokens = toNumber(data.output_tokens);
583
+ const cacheReadTokens = toNumber(data.cache_read_input_tokens ?? data.cache_read_tokens);
584
+ const cacheWriteTokens = toNumber(data.cache_creation_input_tokens ?? data.cache_write_input_tokens ?? data.cache_write_tokens);
585
+ const thinkingTokens = toNumber(data.thinking_tokens) || toNumber(data.reasoning_tokens) || toNumber(data.reasoning_output_tokens);
586
+ if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + thinkingTokens === 0) {
587
+ return null;
588
+ }
589
+ const hasPrice = resolvePrice(model) != null;
590
+ const cost = hasPrice ? calculateCost(model, {
591
+ inputTokens,
592
+ outputTokens,
593
+ cacheReadTokens,
594
+ cacheWriteTokens,
595
+ thinkingTokens
596
+ }, context.exchangeRate) : 0;
597
+ const costSource = hasPrice ? "pricing" : "unknown";
598
+ const recordId = generateRecordId(context.deviceInstanceId, context.sourceFile, context.lineOffset);
599
+ const record = {
600
+ id: recordId,
601
+ ts,
602
+ ingestedAt: context.now,
603
+ updatedAt: context.now,
604
+ lineOffset: context.lineOffset,
605
+ tool: this.tool,
606
+ model,
607
+ provider: inferProvider(model),
608
+ inputTokens,
609
+ outputTokens,
610
+ cacheReadTokens,
611
+ cacheWriteTokens,
612
+ thinkingTokens,
613
+ cost,
614
+ costSource,
615
+ sessionId: context.sessionId,
616
+ sourceFile: context.sourceFile,
617
+ device: context.device,
618
+ deviceInstanceId: context.deviceInstanceId,
619
+ platform: context.platform
620
+ };
621
+ const loopId = parsed.loop_id ?? parsed.turn_id;
622
+ const pendingToolCalls = typeof loopId === "string" ? this.pendingToolCallsByLoopId.get(loopId) ?? [] : [];
623
+ if (typeof loopId === "string") {
624
+ this.pendingToolCallsByLoopId.delete(loopId);
625
+ }
626
+ const toolCalls = pendingToolCalls.map((tc, callIndex) => ({
627
+ id: generateToolCallId(recordId, tc.name, tc.ts, callIndex),
628
+ recordId,
629
+ name: tc.name,
630
+ ts: tc.ts,
631
+ callIndex
632
+ }));
633
+ return { record, toolCalls };
634
+ }
635
+ finalize() {
636
+ this.pendingToolCallsByLoopId.clear();
637
+ return [];
638
+ }
639
+ };
640
+ var Aggregator = class {
641
+ parsers;
642
+ activeParser = null;
643
+ constructor() {
644
+ this.parsers = /* @__PURE__ */ new Map([
645
+ ["claude-code", new ClaudeCodeParser()],
646
+ ["codex", new CodexParser()],
647
+ ["openclaw", new OpenClawParser()],
648
+ ["qoder", new QoderParser()]
649
+ ]);
650
+ }
651
+ createContext(options) {
652
+ return {
653
+ ...options,
654
+ now: Date.now()
655
+ };
656
+ }
657
+ parseLine(line, context) {
658
+ if (!this.activeParser || this.activeParser.tool !== context.tool) {
659
+ this.activeParser = this.parsers.get(context.tool) ?? null;
660
+ }
661
+ if (!this.activeParser) {
662
+ return null;
663
+ }
664
+ return this.activeParser.parseLine(line, context);
665
+ }
666
+ finalize() {
667
+ const results = [];
668
+ for (const parser of this.parsers.values()) {
669
+ if (parser.finalize) {
670
+ results.push(...parser.finalize());
671
+ }
672
+ }
673
+ return results;
674
+ }
675
+ };
31
676
 
32
677
  // src/config.ts
33
678
  import { readFileSync, writeFileSync, existsSync } from "fs";
@@ -101,28 +746,39 @@ var GENERIC_DIRECTORY_NAMES = /* @__PURE__ */ new Set([
101
746
  ".codex",
102
747
  ".opencode",
103
748
  ".openclaw",
104
- "projects"
749
+ ".qoder",
750
+ "projects",
751
+ "runs"
105
752
  ]);
106
753
  var TOOL_DIRECTORY_NAMES = /* @__PURE__ */ new Set(["agents", "main"]);
107
754
  function extractProject(sourceFile) {
108
755
  if (!sourceFile) return "unknown";
109
756
  return extractProjectFromClaudePath(sourceFile) ?? extractProjectFromKnownToolPath(sourceFile) ?? extractProjectFromGenericPath(sourceFile) ?? "unknown";
110
757
  }
758
+ function decodeEncodedPath(raw) {
759
+ const parts = raw.split("-").filter(Boolean);
760
+ const WORKSPACE_ROOTS = ["WebstormProjects", "Documents", "Projects", "workspace", "Workspace"];
761
+ for (const root of WORKSPACE_ROOTS) {
762
+ const idx = parts.indexOf(root);
763
+ if (idx >= 0 && idx < parts.length - 1) {
764
+ return parts.slice(idx + 1).join("/");
765
+ }
766
+ }
767
+ const meaningful = parts.filter((p) => !looksMachineGenerated(p));
768
+ if (meaningful.length === 0) return raw;
769
+ if (meaningful.length <= 3) return meaningful[meaningful.length - 1] ?? "~";
770
+ return meaningful.slice(-2).join("/");
771
+ }
111
772
  function extractProjectFromClaudePath(sourceFile) {
112
773
  const normalized = sourceFile.replace(/\\/g, "/");
113
774
  const match = normalized.match(/\.claude\/projects\/([^/]+)/);
114
775
  if (!match) return null;
115
- const raw = match[1];
116
- const parts = raw.split("-").filter(Boolean);
117
- const webstormIndex = parts.indexOf("WebstormProjects");
118
- if (webstormIndex >= 0 && webstormIndex < parts.length - 1) return parts.slice(webstormIndex + 1).join("-");
119
- const documentsIndex = parts.indexOf("Documents");
120
- if (documentsIndex >= 0 && documentsIndex < parts.length - 1) return parts.slice(documentsIndex + 1).join("-");
121
- if (parts.length <= 3) return "~";
122
- return parts.slice(-2).join("-") || raw;
776
+ return decodeEncodedPath(match[1]);
123
777
  }
124
778
  function extractProjectFromKnownToolPath(sourceFile) {
125
779
  const normalized = sourceFile.replace(/\\/g, "/");
780
+ const qoderSessionMatch = normalized.match(/\/\.qoder\/logs\/sessions\/([^/]+)\//);
781
+ if (qoderSessionMatch) return decodeEncodedPath(qoderSessionMatch[1]);
126
782
  if (normalized.includes("/.openclaw/")) {
127
783
  const parts = normalized.split("/").filter(Boolean);
128
784
  const sessionsIndex = parts.indexOf("sessions");
@@ -134,6 +790,9 @@ function extractProjectFromKnownToolPath(sourceFile) {
134
790
  if (normalized.includes("/.codex/") || normalized.includes("/.opencode/")) {
135
791
  return extractProjectFromGenericPath(normalized);
136
792
  }
793
+ if (normalized.includes("/.qoder/logs/runs/")) {
794
+ return "unknown";
795
+ }
137
796
  return null;
138
797
  }
139
798
  function extractProjectFromGenericPath(sourceFile) {
@@ -144,6 +803,15 @@ function extractProjectFromGenericPath(sourceFile) {
144
803
  const candidate = parts[index];
145
804
  if (isSkippableDirectoryName(candidate)) continue;
146
805
  if (looksMachineGenerated(candidate)) continue;
806
+ if (isPurelyNumeric(candidate)) {
807
+ if (index > 0) {
808
+ const parent = parts[index - 1];
809
+ if (!isSkippableDirectoryName(parent) && !looksMachineGenerated(parent) && !isPurelyNumeric(parent)) {
810
+ return `${parent}/${candidate}`;
811
+ }
812
+ }
813
+ continue;
814
+ }
147
815
  return candidate;
148
816
  }
149
817
  return null;
@@ -152,1462 +820,1782 @@ function isSkippableDirectoryName(name) {
152
820
  return GENERIC_DIRECTORY_NAMES.has(name) || TOOL_DIRECTORY_NAMES.has(name);
153
821
  }
154
822
  function looksMachineGenerated(name) {
155
- return /^\d{4}$/.test(name) || /^\d{2}$/.test(name) || /^[0-9a-f]{8,}$/i.test(name) || /^[0-9a-f-]{32,}$/i.test(name);
156
- }
157
-
158
- // src/api/server.ts
159
- function getDateRangeFilter(range, from, to, prefix = "", weekStart = 1) {
160
- const ts = prefix ? `${prefix}.ts` : "ts";
161
- if (from && to) {
162
- const startMs = new Date(from).getTime();
163
- const endMs = (/* @__PURE__ */ new Date(to + "T23:59:59.999Z")).getTime();
164
- return { where: `AND ${ts} >= @start AND ${ts} < @end`, params: { start: startMs, end: endMs } };
165
- }
166
- const now = /* @__PURE__ */ new Date();
167
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
168
- if (range === "week") {
169
- const dayOfWeek = today.getDay();
170
- const diff = (dayOfWeek - weekStart + 7) % 7;
171
- const startOfWeek = new Date(today);
172
- startOfWeek.setDate(today.getDate() - diff);
173
- return { where: `AND ${ts} >= @start`, params: { start: startOfWeek.getTime() } };
174
- }
175
- if (range === "month") {
176
- const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
177
- return { where: `AND ${ts} >= @start`, params: { start: startOfMonth.getTime() } };
178
- }
179
- if (range === "last30") {
180
- const start = new Date(today);
181
- start.setDate(start.getDate() - 30);
182
- return { where: `AND ${ts} >= @start`, params: { start: start.getTime() } };
183
- }
184
- if (range === "all") {
185
- return { where: "", params: {} };
186
- }
187
- return { where: `AND ${ts} >= @start`, params: { start: today.getTime() } };
188
- }
189
- function json(res, data, status = 200) {
190
- res.writeHead(status, { "Content-Type": "application/json" });
191
- res.end(JSON.stringify(data));
823
+ return /^\d{4}-\d{2}-\d{2}T/.test(name) || /^[0-9a-f]{8,}$/i.test(name) || /^[0-9a-f-]{32,}$/i.test(name);
192
824
  }
193
- function getToolFilter(tool, prefix = "") {
194
- if (!tool) return { where: "", params: {} };
195
- const col = prefix ? `${prefix}.tool` : "tool";
196
- return { where: `AND ${col} = @tool`, params: { tool } };
197
- }
198
- var LOCAL_ONLY_FILTER = "AND source_file NOT LIKE 'synced/%'";
199
- function getDeviceFilter(device, currentDeviceInstanceId) {
200
- if (!currentDeviceInstanceId) {
201
- return { where: "", params: {}, useUnion: false, localOnly: false };
202
- }
203
- if (!device) {
204
- return {
205
- where: "",
206
- params: { currentDeviceId: currentDeviceInstanceId },
207
- useUnion: true,
208
- localOnly: true
209
- };
210
- }
211
- if (device === currentDeviceInstanceId) {
212
- return { where: "", params: {}, useUnion: false, localOnly: true };
213
- }
214
- return {
215
- where: "AND device_instance_id = @deviceId",
216
- params: { deviceId: device },
217
- useUnion: false,
218
- localOnly: false
219
- };
825
+ function isPurelyNumeric(name) {
826
+ return /^\d+$/.test(name);
220
827
  }
221
- function createApiServer(db, options) {
222
- const cfg = loadConfig();
223
- let weekStart = cfg?.weekStart ?? 1;
224
- const server = http.createServer(async (req, res) => {
225
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
226
- res.setHeader("Access-Control-Allow-Origin", "*");
227
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
228
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
229
- if (req.method === "OPTIONS") {
230
- res.writeHead(204);
231
- res.end();
232
- return;
233
- }
234
- const range = url.searchParams.get("range");
235
- const from = url.searchParams.get("from");
236
- const to = url.searchParams.get("to");
237
- const toolParam = url.searchParams.get("tool");
238
- const VALID_TOOLS = ["claude-code", "codex", "openclaw", "opencode", "hermes"];
239
- if (toolParam && !VALID_TOOLS.includes(toolParam)) {
240
- json(res, { error: { code: "INVALID_PARAM", message: "Invalid tool" } }, 400);
241
- return;
242
- }
243
- try {
244
- if (url.pathname === "/api/summary") {
245
- if (range && !["day", "week", "month", "last30", "all"].includes(range)) {
246
- json(res, { error: { code: "INVALID_PARAM", message: "Invalid range" } }, 400);
247
- return;
248
- }
249
- const device = url.searchParams.get("device");
250
- const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
251
- const dr = getDateRangeFilter(range, from, to, "", weekStart);
252
- const tool = url.searchParams.get("tool");
253
- const tf = getToolFilter(tool);
254
- let totals;
255
- let byToolRows;
256
- if (df.useUnion) {
257
- const unionSql = `
258
- SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, ts, session_id
259
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
260
- UNION ALL
261
- SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, ts, session_key AS session_id
262
- FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
263
- `;
264
- totals = db.prepare(`
265
- SELECT
266
- COALESCE(SUM(input_tokens), 0) AS inputTokens,
267
- COALESCE(SUM(output_tokens), 0) AS outputTokens,
268
- COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
269
- COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
270
- COALESCE(SUM(thinking_tokens), 0) AS thinkingTokens,
271
- COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
272
- COALESCE(SUM(cost), 0) AS totalCost,
273
- COUNT(DISTINCT strftime('%Y-%m-%d', ts/1000, 'unixepoch')) AS activeDays,
274
- COUNT(DISTINCT session_id) AS totalSessions
275
- FROM (${unionSql})
276
- `).get({ ...dr.params, ...df.params, ...tf.params });
277
- byToolRows = db.prepare(`
278
- SELECT tool, SUM(tokens) AS tokens, SUM(cost) AS cost FROM (
279
- SELECT tool,
280
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
281
- SUM(cost) AS cost
282
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
283
- GROUP BY tool
284
- UNION ALL
285
- SELECT tool,
286
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
287
- SUM(cost) AS cost
288
- FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
289
- GROUP BY tool
290
- ) GROUP BY tool ORDER BY cost DESC
291
- `).all({ ...dr.params, ...df.params, ...tf.params });
292
- } else if (df.where) {
293
- totals = db.prepare(`
294
- SELECT
295
- COALESCE(SUM(input_tokens), 0) AS inputTokens,
296
- COALESCE(SUM(output_tokens), 0) AS outputTokens,
297
- COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
298
- COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
299
- COALESCE(SUM(thinking_tokens), 0) AS thinkingTokens,
300
- COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
301
- COALESCE(SUM(cost), 0) AS totalCost,
302
- COUNT(DISTINCT strftime('%Y-%m-%d', ts/1000, 'unixepoch')) AS activeDays,
303
- COUNT(DISTINCT session_key) AS totalSessions
304
- FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
305
- `).get({ ...dr.params, ...df.params, ...tf.params });
306
- byToolRows = db.prepare(`
307
- SELECT tool,
308
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
309
- SUM(cost) AS cost
310
- FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
311
- GROUP BY tool ORDER BY cost DESC
312
- `).all({ ...dr.params, ...df.params, ...tf.params });
313
- } else {
314
- totals = db.prepare(`
315
- SELECT
316
- COALESCE(SUM(input_tokens), 0) AS inputTokens,
317
- COALESCE(SUM(output_tokens), 0) AS outputTokens,
318
- COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
319
- COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
320
- COALESCE(SUM(thinking_tokens), 0) AS thinkingTokens,
321
- COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
322
- COALESCE(SUM(cost), 0) AS totalCost,
323
- COUNT(DISTINCT strftime('%Y-%m-%d', ts/1000, 'unixepoch')) AS activeDays,
324
- COUNT(DISTINCT session_id) AS totalSessions
325
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
326
- `).get({ ...dr.params, ...tf.params });
327
- byToolRows = db.prepare(`
328
- SELECT tool,
329
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
330
- SUM(cost) AS cost
331
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
332
- GROUP BY tool ORDER BY cost DESC
333
- `).all({ ...dr.params, ...tf.params });
334
- }
335
- const byTool = {};
336
- for (const row of byToolRows) {
337
- byTool[row.tool] = { tokens: row.tokens, cost: row.cost };
338
- }
339
- const drJoin = getDateRangeFilter(range, from, to, "r", weekStart);
340
- const dfJoin = df.where ? df.where.replace(/device_instance_id/g, "r.device_instance_id") : "";
341
- const tfJoin = getToolFilter(tool, "r");
342
- const topToolCalls = db.prepare(`
343
- SELECT tc.name, COUNT(*) AS count
344
- FROM tool_calls tc
345
- JOIN records r ON r.id = tc.record_id
346
- WHERE 1=1 ${dfJoin} ${drJoin.where} ${tfJoin.where}
347
- GROUP BY tc.name ORDER BY count DESC LIMIT 10
348
- `).all({ ...drJoin.params, ...df.params, ...tfJoin.params });
349
- json(res, {
350
- inputTokens: totals.inputTokens,
351
- outputTokens: totals.outputTokens,
352
- cacheReadTokens: totals.cacheReadTokens,
353
- cacheWriteTokens: totals.cacheWriteTokens,
354
- thinkingTokens: totals.thinkingTokens,
355
- totalTokens: totals.totalTokens,
356
- totalCost: totals.totalCost,
357
- activeDays: totals.activeDays,
358
- totalSessions: totals.totalSessions,
359
- byTool,
360
- topToolCalls
361
- });
362
- return;
363
- }
364
- if (url.pathname === "/api/tokens") {
365
- const dr = getDateRangeFilter(range, from, to, "", weekStart);
366
- const device = url.searchParams.get("device");
367
- const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
368
- const tool = url.searchParams.get("tool");
369
- const tf = getToolFilter(tool);
370
- let sql;
371
- let params;
372
- if (df.useUnion) {
373
- sql = `
374
- SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
375
- SUM(input_tokens) AS inputTokens,
376
- SUM(output_tokens) AS outputTokens,
377
- SUM(cache_read_tokens) AS cacheReadTokens,
378
- SUM(cache_write_tokens) AS cacheWriteTokens,
379
- SUM(thinking_tokens) AS thinkingTokens
380
- FROM (
381
- SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, ts FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
382
- UNION ALL
383
- SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, ts FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
384
- )
385
- GROUP BY date ORDER BY date`;
386
- params = { ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params };
387
- } else if (device && device !== options?.currentDeviceInstanceId) {
388
- sql = `
389
- SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
390
- SUM(input_tokens) AS inputTokens,
391
- SUM(output_tokens) AS outputTokens,
392
- SUM(cache_read_tokens) AS cacheReadTokens,
393
- SUM(cache_write_tokens) AS cacheWriteTokens,
394
- SUM(thinking_tokens) AS thinkingTokens
395
- FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
396
- GROUP BY date ORDER BY date`;
397
- params = { ...df.params, ...dr.params, ...tf.params };
398
- } else {
399
- sql = `
400
- SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
401
- SUM(input_tokens) AS inputTokens,
402
- SUM(output_tokens) AS outputTokens,
403
- SUM(cache_read_tokens) AS cacheReadTokens,
404
- SUM(cache_write_tokens) AS cacheWriteTokens,
405
- SUM(thinking_tokens) AS thinkingTokens
406
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
407
- GROUP BY date ORDER BY date`;
408
- params = { ...dr.params, ...tf.params };
409
- }
410
- const rows = db.prepare(sql).all(params);
411
- json(res, { data: rows });
412
- return;
413
- }
414
- if (url.pathname === "/api/cost") {
415
- const dr = getDateRangeFilter(range, from, to, "", weekStart);
416
- const device = url.searchParams.get("device");
417
- const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
418
- const tool = url.searchParams.get("tool");
419
- const tf = getToolFilter(tool);
420
- let daily;
421
- let byToolRows;
422
- let byModelRows;
423
- if (df.useUnion) {
424
- daily = db.prepare(`
425
- SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
426
- SUM(cost) AS cost
427
- FROM (
428
- SELECT cost, ts FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
429
- UNION ALL
430
- SELECT cost, ts FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
431
- )
432
- GROUP BY date ORDER BY date
433
- `).all({ ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params });
434
- byToolRows = db.prepare(`
435
- SELECT tool, SUM(cost) AS cost FROM (
436
- SELECT tool, SUM(cost) AS cost FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where} GROUP BY tool
437
- UNION ALL
438
- SELECT tool, SUM(cost) AS cost FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where} GROUP BY tool
439
- ) GROUP BY tool ORDER BY cost DESC
440
- `).all({ ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params });
441
- byModelRows = db.prepare(`
442
- SELECT model, SUM(cost) AS cost FROM (
443
- SELECT model, SUM(cost) AS cost FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where} GROUP BY model
444
- UNION ALL
445
- SELECT model, SUM(cost) AS cost FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where} GROUP BY model
446
- ) GROUP BY model ORDER BY cost DESC
447
- `).all({ ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params });
448
- } else if (device && device !== options?.currentDeviceInstanceId) {
449
- daily = db.prepare(`
450
- SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
451
- SUM(cost) AS cost
452
- FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
453
- GROUP BY date ORDER BY date
454
- `).all({ ...df.params, ...dr.params, ...tf.params });
455
- byToolRows = db.prepare(`
456
- SELECT tool, SUM(cost) AS cost
457
- FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
458
- GROUP BY tool ORDER BY cost DESC
459
- `).all({ ...df.params, ...dr.params, ...tf.params });
460
- byModelRows = db.prepare(`
461
- SELECT model, SUM(cost) AS cost
462
- FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
463
- GROUP BY model ORDER BY cost DESC
464
- `).all({ ...df.params, ...dr.params, ...tf.params });
465
- } else {
466
- daily = db.prepare(`
467
- SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
468
- SUM(cost) AS cost
469
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
470
- GROUP BY date ORDER BY date
471
- `).all({ ...dr.params, ...tf.params });
472
- byToolRows = db.prepare(`
473
- SELECT tool, SUM(cost) AS cost
474
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
475
- GROUP BY tool ORDER BY cost DESC
476
- `).all({ ...dr.params, ...tf.params });
477
- byModelRows = db.prepare(`
478
- SELECT model, SUM(cost) AS cost
479
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
480
- GROUP BY model ORDER BY cost DESC
481
- `).all({ ...dr.params, ...tf.params });
482
- }
483
- const byTool = {};
484
- for (const r of byToolRows) byTool[r.tool] = r.cost;
485
- const byModel = {};
486
- for (const r of byModelRows) byModel[r.model] = r.cost;
487
- json(res, { data: daily, byTool, byModel });
488
- return;
489
- }
490
- if (url.pathname === "/api/models") {
491
- const dr = getDateRangeFilter(range, from, to, "", weekStart);
492
- const device = url.searchParams.get("device");
493
- const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
494
- const tool = url.searchParams.get("tool");
495
- const tf = getToolFilter(tool);
496
- let total;
497
- let rows;
498
- if (df.useUnion) {
499
- const unionSql = `
500
- SELECT model, provider, COUNT(*) AS callCount,
501
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
502
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
503
- GROUP BY model, provider
504
- UNION ALL
505
- SELECT model, provider, COUNT(*) AS callCount,
506
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
507
- FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
508
- GROUP BY model, provider
509
- `;
510
- const mergedRows = db.prepare(`
511
- SELECT model, provider, SUM(callCount) AS callCount, SUM(totalTokens) AS totalTokens
512
- FROM (${unionSql})
513
- GROUP BY model, provider ORDER BY callCount DESC
514
- `).all({ ...dr.params, ...df.params, ...tf.params });
515
- total = mergedRows.reduce((s, r) => s + r.callCount, 0) || 1;
516
- rows = mergedRows;
517
- } else if (device && device !== options?.currentDeviceInstanceId) {
518
- rows = db.prepare(`
519
- SELECT model, provider,
520
- COUNT(*) AS callCount,
521
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
522
- FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
523
- GROUP BY model, provider ORDER BY callCount DESC
524
- `).all({ ...df.params, ...dr.params, ...tf.params });
525
- total = rows.reduce((s, r) => s + r.callCount, 0) || 1;
526
- } else {
527
- rows = db.prepare(`
528
- SELECT model, provider,
529
- COUNT(*) AS callCount,
530
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
531
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
532
- GROUP BY model, provider ORDER BY callCount DESC
533
- `).all({ ...dr.params, ...tf.params });
534
- total = rows.reduce((s, r) => s + r.callCount, 0) || 1;
535
- }
536
- const models = rows.map((r) => ({
537
- model: r.model,
538
- provider: r.provider,
539
- callCount: r.callCount,
540
- totalTokens: r.totalTokens,
541
- percentage: Math.round(r.callCount / total * 1e3) / 10
542
- }));
543
- json(res, { models });
544
- return;
545
- }
546
- if (url.pathname === "/api/tool-calls") {
547
- const device = url.searchParams.get("device");
548
- if (device && device !== options?.currentDeviceInstanceId) {
549
- json(res, { toolCalls: [] });
550
- return;
551
- }
552
- const dr = getDateRangeFilter(range, from, to, "r", weekStart);
553
- const tool = url.searchParams.get("tool");
554
- const tf = getToolFilter(tool, "r");
555
- const totalRow = db.prepare(`
556
- SELECT COUNT(*) AS total FROM tool_calls tc
557
- JOIN records r ON r.id = tc.record_id
558
- WHERE 1=1 ${dr.where} ${tf.where}
559
- `).get({ ...dr.params, ...tf.params });
560
- const total = totalRow.total || 1;
561
- const rows = db.prepare(`
562
- SELECT tc.name, COUNT(*) AS count
563
- FROM tool_calls tc
564
- JOIN records r ON r.id = tc.record_id
565
- WHERE 1=1 ${dr.where} ${tf.where}
566
- GROUP BY tc.name ORDER BY count DESC
567
- `).all({ ...dr.params, ...tf.params });
568
- const toolCalls = rows.map((r) => ({
569
- name: r.name,
570
- count: r.count,
571
- percentage: Math.round(r.count / total * 1e3) / 10
572
- }));
573
- json(res, { toolCalls });
574
- return;
575
- }
576
- if (url.pathname === "/api/sessions") {
577
- const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
578
- const pageSize = Math.min(100, Math.max(1, parseInt(url.searchParams.get("pageSize") || "50", 10)));
579
- const dr = getDateRangeFilter(range, from, to, "", weekStart);
580
- const device = url.searchParams.get("device");
581
- const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
582
- const tool = url.searchParams.get("tool");
583
- if (device && !df.localOnly && !df.useUnion) {
584
- json(res, {
585
- sessions: [],
586
- total: 0,
587
- page,
588
- pageSize
589
- });
590
- return;
591
- }
592
- const tf = getToolFilter(tool);
593
- const params = { ...dr.params, ...tf.params };
594
- const totalRow = db.prepare(`
595
- SELECT COUNT(DISTINCT session_id) AS total
596
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
597
- `).get(params);
598
- const sessions = db.prepare(`
599
- SELECT session_id AS sessionId,
600
- tool,
601
- model,
602
- MIN(ts) AS ts,
603
- SUM(input_tokens) AS inputTokens,
604
- SUM(output_tokens) AS outputTokens,
605
- SUM(cache_read_tokens) AS cacheReadTokens,
606
- SUM(cache_write_tokens) AS cacheWriteTokens,
607
- SUM(cost) AS cost
608
- FROM records
609
- WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
610
- GROUP BY session_id
611
- ORDER BY ts DESC
612
- LIMIT @limit OFFSET @offset
613
- `).all({ ...params, limit: pageSize, offset: (page - 1) * pageSize });
614
- json(res, {
615
- sessions,
616
- total: totalRow.total,
617
- page,
618
- pageSize
619
- });
620
- return;
828
+
829
+ // src/commands/parse.ts
830
+ import Database from "better-sqlite3";
831
+ import { readFileSync as readFileSync4, readdirSync, statSync, existsSync as existsSync4, openSync, readSync, closeSync } from "fs";
832
+ import { join as join3, extname } from "path";
833
+ import { homedir as homedir2, hostname, platform } from "os";
834
+
835
+ // src/db/records.ts
836
+ function insertRecord(db, record) {
837
+ db.prepare(`
838
+ INSERT OR REPLACE INTO records (
839
+ id, ts, ingested_at, synced_at, updated_at, line_offset,
840
+ tool, model, provider, input_tokens, output_tokens,
841
+ cache_read_tokens, cache_write_tokens, thinking_tokens,
842
+ cost, cost_source, session_id, source_file, cwd, device, device_instance_id, platform
843
+ ) VALUES (
844
+ @id, @ts, @ingestedAt, @syncedAt, @updatedAt, @lineOffset,
845
+ @tool, @model, @provider, @inputTokens, @outputTokens,
846
+ @cacheReadTokens, @cacheWriteTokens, @thinkingTokens,
847
+ @cost, @costSource, @sessionId, @sourceFile, @cwd, @device, @deviceInstanceId, @platform
848
+ )
849
+ `).run({
850
+ id: record.id,
851
+ ts: record.ts,
852
+ ingestedAt: record.ingestedAt,
853
+ syncedAt: record.syncedAt ?? null,
854
+ updatedAt: record.updatedAt,
855
+ lineOffset: record.lineOffset,
856
+ tool: record.tool,
857
+ model: record.model,
858
+ provider: record.provider,
859
+ inputTokens: record.inputTokens,
860
+ outputTokens: record.outputTokens,
861
+ cacheReadTokens: record.cacheReadTokens,
862
+ cacheWriteTokens: record.cacheWriteTokens,
863
+ thinkingTokens: record.thinkingTokens,
864
+ cost: record.cost,
865
+ costSource: record.costSource,
866
+ sessionId: record.sessionId,
867
+ sourceFile: record.sourceFile,
868
+ cwd: record.cwd ?? "",
869
+ device: record.device,
870
+ deviceInstanceId: record.deviceInstanceId,
871
+ platform: record.platform ?? ""
872
+ });
873
+ }
874
+ function getUnsyncedRecords(db) {
875
+ const rows = db.prepare(
876
+ "SELECT * FROM records WHERE synced_at IS NULL OR updated_at > synced_at"
877
+ ).all();
878
+ return rows.map(mapRowToRecord);
879
+ }
880
+ function mapRowToRecord(row) {
881
+ return {
882
+ id: row.id,
883
+ ts: row.ts,
884
+ ingestedAt: row.ingested_at,
885
+ syncedAt: row.synced_at != null ? row.synced_at : void 0,
886
+ updatedAt: row.updated_at,
887
+ lineOffset: row.line_offset,
888
+ tool: row.tool,
889
+ model: row.model,
890
+ provider: row.provider,
891
+ inputTokens: row.input_tokens,
892
+ outputTokens: row.output_tokens,
893
+ cacheReadTokens: row.cache_read_tokens,
894
+ cacheWriteTokens: row.cache_write_tokens,
895
+ thinkingTokens: row.thinking_tokens,
896
+ cost: row.cost,
897
+ costSource: row.cost_source,
898
+ sessionId: row.session_id,
899
+ sourceFile: row.source_file,
900
+ cwd: row.cwd || void 0,
901
+ device: row.device,
902
+ deviceInstanceId: row.device_instance_id,
903
+ platform: row.platform || void 0
904
+ };
905
+ }
906
+
907
+ // src/db/tool-calls.ts
908
+ function insertToolCall(db, tc) {
909
+ db.prepare(`
910
+ INSERT OR REPLACE INTO tool_calls (id, record_id, tool, name, ts, call_index)
911
+ VALUES (@id, @recordId, @tool, @name, @ts, @callIndex)
912
+ `).run({
913
+ id: tc.id,
914
+ recordId: tc.recordId ?? null,
915
+ tool: tc.recordId ? null : tc.tool,
916
+ name: tc.name,
917
+ ts: tc.ts,
918
+ callIndex: tc.callIndex
919
+ });
920
+ }
921
+ function getToolCallStats(db) {
922
+ return db.prepare(`
923
+ SELECT name, COUNT(*) as count
924
+ FROM tool_calls
925
+ GROUP BY name
926
+ ORDER BY count DESC
927
+ `).all();
928
+ }
929
+
930
+ // src/init.ts
931
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
932
+ import { join as join2 } from "path";
933
+ import { randomUUID } from "crypto";
934
+ function getState(aiusageDir) {
935
+ const statePath = join2(aiusageDir, "state.json");
936
+ if (!existsSync2(statePath)) return null;
937
+ try {
938
+ const content = readFileSync2(statePath, "utf-8");
939
+ return JSON.parse(content);
940
+ } catch {
941
+ return null;
942
+ }
943
+ }
944
+ function setState(aiusageDir, updates) {
945
+ const statePath = join2(aiusageDir, "state.json");
946
+ const current = getState(aiusageDir) ?? {
947
+ deviceInstanceId: randomUUID(),
948
+ lastSyncStatus: "ok"
949
+ };
950
+ const updated = { ...current, ...updates };
951
+ writeFileSync2(statePath, JSON.stringify(updated, null, 2), "utf-8");
952
+ }
953
+
954
+ // src/watermark.ts
955
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3 } from "fs";
956
+ function defaultFileData() {
957
+ return {
958
+ "claude-code": {},
959
+ "codex": {},
960
+ "openclaw": {},
961
+ "opencode": {},
962
+ "hermes": {},
963
+ "qoder": {}
964
+ };
965
+ }
966
+ var WatermarkManager = class {
967
+ data;
968
+ path;
969
+ constructor(path3) {
970
+ this.path = path3;
971
+ this.data = this.load();
972
+ }
973
+ load() {
974
+ if (!existsSync3(this.path)) {
975
+ return { files: defaultFileData() };
976
+ }
977
+ try {
978
+ const content = readFileSync3(this.path, "utf-8");
979
+ const parsed = JSON.parse(content);
980
+ if (parsed && typeof parsed === "object" && !("files" in parsed)) {
981
+ return { files: { ...defaultFileData(), ...parsed } };
621
982
  }
622
- if (url.pathname === "/api/projects") {
623
- const dr = getDateRangeFilter(range, from, to, "", weekStart);
624
- const device = url.searchParams.get("device");
625
- const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
626
- const tool = url.searchParams.get("tool");
627
- const tf = getToolFilter(tool);
628
- if (df.where && !df.useUnion) {
629
- json(res, { projects: [] });
630
- return;
631
- }
632
- const rows = db.prepare(`
633
- SELECT source_file,
634
- COUNT(*) AS sessionCount,
635
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens,
636
- SUM(cost) AS cost
637
- FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
638
- GROUP BY source_file ORDER BY totalTokens DESC
639
- `).all({ ...dr.params, ...tf.params });
640
- const projectMap = {};
641
- for (const row of rows) {
642
- const project = extractProject(row.source_file);
643
- if (!projectMap[project]) projectMap[project] = { sessions: 0, tokens: 0, cost: 0 };
644
- projectMap[project].sessions += row.sessionCount;
645
- projectMap[project].tokens += row.totalTokens;
646
- projectMap[project].cost += row.cost;
983
+ return { files: { ...defaultFileData(), ...parsed.files ?? {} }, opencode: parsed.opencode ?? null, hermes: parsed.hermes ?? null, qoder: parsed.qoder ?? null };
984
+ } catch {
985
+ return { files: defaultFileData() };
986
+ }
987
+ }
988
+ save() {
989
+ writeFileSync3(this.path, JSON.stringify(this.data, null, 2), "utf-8");
990
+ }
991
+ getEntry(tool, filePath) {
992
+ return this.data.files[tool]?.[filePath] ?? null;
993
+ }
994
+ setEntry(tool, filePath, entry) {
995
+ if (!this.data.files[tool]) {
996
+ this.data.files[tool] = {};
997
+ }
998
+ this.data.files[tool][filePath] = entry;
999
+ }
1000
+ cleanup(existingFiles) {
1001
+ const existingSet = new Set(existingFiles);
1002
+ for (const tool of Object.keys(this.data.files)) {
1003
+ for (const filePath of Object.keys(this.data.files[tool])) {
1004
+ if (!existingSet.has(filePath)) {
1005
+ delete this.data.files[tool][filePath];
647
1006
  }
648
- const totalTokens = Object.values(projectMap).reduce((s, p) => s + p.tokens, 0) || 1;
649
- const projects = Object.entries(projectMap).map(([name, data]) => ({
650
- name,
651
- sessions: data.sessions,
652
- tokens: data.tokens,
653
- cost: data.cost,
654
- percentage: Math.round(data.tokens / totalTokens * 1e3) / 10
655
- })).sort((a, b) => b.tokens - a.tokens);
656
- json(res, { projects });
657
- return;
658
1007
  }
659
- if (url.pathname === "/api/pricing") {
660
- if (req.method === "GET") {
661
- const table = getPriceTable();
662
- const overrides = getUserOverrides();
663
- const dbModels = db.prepare("SELECT DISTINCT model FROM records ORDER BY model").all();
664
- const models = dbModels.map((r) => {
665
- const model = r.model;
666
- const exactPrice = table[model];
667
- const resolvedPrice = resolvePrice(model);
668
- const isOverride = model in overrides;
669
- const isDefault = model in DEFAULT_PRICE_TABLE && !isOverride;
670
- let matchedBy = null;
671
- if (!exactPrice && resolvedPrice) {
672
- for (const prefix of Object.keys(table)) {
673
- if (model.startsWith(prefix) || model.toLowerCase().startsWith(prefix)) {
674
- matchedBy = prefix;
675
- break;
676
- }
677
- }
678
- if (!matchedBy) {
679
- const stripped = model.replace(/^[^/]+\//, "").toLowerCase();
680
- for (const prefix of Object.keys(table)) {
681
- if (stripped.startsWith(prefix)) {
682
- matchedBy = prefix;
683
- break;
684
- }
685
- }
686
- }
687
- }
688
- return {
689
- model,
690
- price: resolvedPrice ?? null,
691
- isDefault,
692
- isOverride,
693
- matchedBy
694
- };
695
- });
696
- json(res, { models, overrides });
697
- return;
698
- }
699
- if (req.method === "PUT") {
700
- let body = "";
701
- for await (const chunk of req) body += chunk;
702
- try {
703
- const data = JSON.parse(body);
704
- if (!data.model || typeof data.input !== "number" || typeof data.output !== "number") {
705
- json(res, { error: { code: "INVALID_PARAM", message: "model, input, output required" } }, 400);
706
- return;
707
- }
708
- const entry = {
709
- input: data.input,
710
- output: data.output,
711
- cacheRead: data.cacheRead,
712
- cacheWrite: data.cacheWrite,
713
- thinking: data.thinking
714
- };
715
- setPriceOverride(data.model, entry);
716
- const cfg2 = loadConfig() ?? {};
717
- cfg2.priceOverrides = { ...cfg2.priceOverrides, [data.model]: entry };
718
- saveConfig(cfg2);
719
- json(res, { ok: true });
720
- } catch {
721
- json(res, { error: { code: "INVALID_JSON", message: "Invalid JSON body" } }, 400);
722
- }
723
- return;
724
- }
725
- if (req.method === "DELETE") {
726
- const model = url.searchParams.get("model");
727
- if (!model) {
728
- json(res, { error: { code: "INVALID_PARAM", message: "model param required" } }, 400);
729
- return;
730
- }
731
- removePriceOverride(model);
732
- const cfg2 = loadConfig() ?? {};
733
- if (cfg2.priceOverrides) {
734
- delete cfg2.priceOverrides[model];
735
- saveConfig(cfg2);
736
- }
737
- json(res, { ok: true });
738
- return;
739
- }
1008
+ }
1009
+ }
1010
+ getOpenCodeCursor() {
1011
+ return this.data.opencode ?? null;
1012
+ }
1013
+ setOpenCodeCursor(cursor) {
1014
+ this.data.opencode = cursor;
1015
+ }
1016
+ getHermesCursor() {
1017
+ return this.data.hermes ?? null;
1018
+ }
1019
+ setHermesCursor(cursor) {
1020
+ this.data.hermes = cursor;
1021
+ }
1022
+ getQoderCursor() {
1023
+ return this.data.qoder ?? null;
1024
+ }
1025
+ setQoderCursor(cursor) {
1026
+ this.data.qoder = cursor;
1027
+ }
1028
+ };
1029
+
1030
+ // src/commands/parse-opencode.ts
1031
+ function runParseOpenCode(db, options) {
1032
+ const { dbPath, device, deviceInstanceId, platform: platform4, now, cursor, exchangeRate } = options;
1033
+ const records = [];
1034
+ const toolCalls = [];
1035
+ const errors = [];
1036
+ let lastCursor = null;
1037
+ const messages = db.prepare(
1038
+ `SELECT m.id, m.session_id, m.time_created, m.data
1039
+ FROM message m
1040
+ WHERE m.time_created > ? OR (m.time_created = ? AND m.id > ?)
1041
+ ORDER BY m.time_created, m.id`
1042
+ ).all(
1043
+ cursor?.lastMessageCreatedAt ?? 0,
1044
+ cursor?.lastMessageCreatedAt ?? 0,
1045
+ cursor?.lastMessageId ?? ""
1046
+ );
1047
+ const partsStmt = db.prepare(
1048
+ `SELECT id, message_id, session_id, time_created, data
1049
+ FROM part
1050
+ WHERE message_id = ?
1051
+ ORDER BY time_created, id`
1052
+ );
1053
+ for (const message of messages) {
1054
+ lastCursor = {
1055
+ lastMessageCreatedAt: message.time_created,
1056
+ lastMessageId: message.id
1057
+ };
1058
+ let parsed;
1059
+ try {
1060
+ parsed = JSON.parse(message.data);
1061
+ } catch (e) {
1062
+ errors.push(`message ${message.id}: invalid JSON: ${e instanceof Error ? e.message : e}`);
1063
+ continue;
1064
+ }
1065
+ if (parsed.role !== "assistant" || !parsed.tokens) continue;
1066
+ const inputTokens = parsed.tokens.input ?? 0;
1067
+ const outputTokens = parsed.tokens.output ?? 0;
1068
+ const reasoningTokens = parsed.tokens.reasoning ?? 0;
1069
+ const cacheReadTokens = parsed.tokens.cache?.read ?? 0;
1070
+ const cacheWriteTokens = parsed.tokens.cache?.write ?? 0;
1071
+ if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) continue;
1072
+ const model = parsed.modelID ?? "unknown";
1073
+ const provider = parsed.providerID ?? inferProvider(model);
1074
+ const ts = parsed.time?.created ?? message.time_created;
1075
+ const recordId = generateRecordId(deviceInstanceId, dbPath + ":" + message.id, message.time_created);
1076
+ const tokenArgs = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, thinkingTokens: reasoningTokens };
1077
+ const calculatedCost = model !== "unknown" ? calculateCost(model, tokenArgs, exchangeRate) : 0;
1078
+ const logCostValid = parsed.cost != null && parsed.cost > 0;
1079
+ const cost = logCostValid ? parsed.cost : calculatedCost;
1080
+ const costSource = logCostValid ? "log" : calculatedCost > 0 ? "pricing" : "unknown";
1081
+ const record = {
1082
+ id: recordId,
1083
+ ts,
1084
+ ingestedAt: now,
1085
+ updatedAt: now,
1086
+ lineOffset: 0,
1087
+ // DB-sourced records have no byte offset; use 0 as sentinel
1088
+ tool: "opencode",
1089
+ model,
1090
+ provider,
1091
+ inputTokens,
1092
+ outputTokens,
1093
+ cacheReadTokens,
1094
+ cacheWriteTokens,
1095
+ thinkingTokens: reasoningTokens,
1096
+ cost,
1097
+ costSource,
1098
+ sessionId: message.session_id,
1099
+ sourceFile: dbPath,
1100
+ device,
1101
+ deviceInstanceId,
1102
+ platform: platform4
1103
+ };
1104
+ records.push(record);
1105
+ const parts = partsStmt.all(message.id);
1106
+ let callIndex = 0;
1107
+ for (const part of parts) {
1108
+ let partParsed;
1109
+ try {
1110
+ partParsed = JSON.parse(part.data);
1111
+ } catch (e) {
1112
+ errors.push(`message ${message.id} part ${part.id}: invalid JSON: ${e instanceof Error ? e.message : e}`);
1113
+ continue;
740
1114
  }
741
- if (url.pathname === "/api/pricing/recalc" && req.method === "POST") {
742
- const { resolvePrice: resolvePrice2 } = await import("./dist-4I33E46P.js");
743
- const BATCH_SIZE2 = 1e3;
744
- let updated = 0;
745
- let lastId = "";
746
- while (true) {
747
- const records = db.prepare(
748
- "SELECT id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens FROM records WHERE id > ? ORDER BY id LIMIT ?"
749
- ).all(lastId, BATCH_SIZE2);
750
- if (records.length === 0) break;
751
- const updateStmt = db.prepare("UPDATE records SET cost = ?, cost_source = ? WHERE id = ?");
752
- const tx = db.transaction((batch) => {
753
- for (const r of batch) {
754
- const price = resolvePrice2(r.model);
755
- if (!price) continue;
756
- const cost = r.input_tokens / 1e6 * price.input + r.output_tokens / 1e6 * price.output + r.cache_read_tokens / 1e6 * (price.cacheRead ?? 0) + r.cache_write_tokens / 1e6 * (price.cacheWrite ?? 0) + r.thinking_tokens / 1e6 * (price.thinking ?? price.output);
757
- updateStmt.run(cost, "pricing", r.id);
758
- updated++;
759
- }
760
- });
761
- tx(records);
762
- lastId = records[records.length - 1].id;
763
- }
764
- json(res, { updated });
765
- return;
1115
+ if (partParsed.type === "tool" && partParsed.tool) {
1116
+ toolCalls.push({
1117
+ id: generateToolCallId(recordId, partParsed.tool, part.time_created, callIndex),
1118
+ recordId,
1119
+ name: partParsed.tool,
1120
+ ts: part.time_created,
1121
+ callIndex
1122
+ });
1123
+ callIndex++;
766
1124
  }
767
- if (url.pathname === "/api/sync") {
768
- if (req.method === "POST") {
769
- if (!options?.onSyncStart) {
770
- json(res, { error: { code: "NOT_AVAILABLE", message: "Sync not configured" } }, 501);
771
- return;
772
- }
773
- const result = options.onSyncStart();
774
- json(res, result, result.accepted ? 202 : 200);
775
- return;
1125
+ }
1126
+ }
1127
+ return { records, toolCalls, nextCursor: records.length > 0 ? lastCursor : null, errors };
1128
+ }
1129
+
1130
+ // src/commands/parse-hermes.ts
1131
+ function runParseHermes(db, options) {
1132
+ const { dbPath, device, deviceInstanceId, platform: platform4, now, cursor, exchangeRate } = options;
1133
+ const records = [];
1134
+ const toolCalls = [];
1135
+ const errors = [];
1136
+ let lastCursor = null;
1137
+ const sessions = db.prepare(
1138
+ `SELECT id, model, billing_provider, started_at, ended_at,
1139
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
1140
+ reasoning_tokens, estimated_cost_usd, actual_cost_usd,
1141
+ COALESCE(ended_at, started_at) AS session_ts
1142
+ FROM sessions
1143
+ WHERE (ended_at IS NOT NULL OR input_tokens > 0 OR output_tokens > 0)
1144
+ AND (COALESCE(ended_at, started_at) > ? OR (COALESCE(ended_at, started_at) = ? AND id > ?))
1145
+ ORDER BY COALESCE(ended_at, started_at), id`
1146
+ ).all(
1147
+ cursor?.lastEndedAt ?? 0,
1148
+ cursor?.lastEndedAt ?? 0,
1149
+ cursor?.lastId ?? ""
1150
+ );
1151
+ const toolCallsStmt = db.prepare(
1152
+ `SELECT id, session_id, tool_calls, timestamp
1153
+ FROM messages
1154
+ WHERE session_id = ? AND tool_calls IS NOT NULL
1155
+ ORDER BY timestamp, id`
1156
+ );
1157
+ for (const session of sessions) {
1158
+ lastCursor = { lastEndedAt: session.session_ts, lastId: session.id };
1159
+ const model = session.model ?? "unknown";
1160
+ const ts = Math.round(session.started_at * 1e3);
1161
+ const rawProvider = session.billing_provider;
1162
+ const provider = rawProvider && rawProvider !== "custom" ? rawProvider : inferProvider(model);
1163
+ const inputTokens = session.input_tokens;
1164
+ const outputTokens = session.output_tokens;
1165
+ const cacheReadTokens = session.cache_read_tokens;
1166
+ const cacheWriteTokens = session.cache_write_tokens;
1167
+ const thinkingTokens = session.reasoning_tokens;
1168
+ const tokenArgs = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, thinkingTokens };
1169
+ let cost;
1170
+ let costSource;
1171
+ if (session.actual_cost_usd != null && session.actual_cost_usd > 0) {
1172
+ cost = session.actual_cost_usd;
1173
+ costSource = "log";
1174
+ } else if (session.estimated_cost_usd != null && session.estimated_cost_usd > 0) {
1175
+ cost = session.estimated_cost_usd;
1176
+ costSource = "log";
1177
+ } else if (model !== "unknown") {
1178
+ cost = calculateCost(model, tokenArgs, exchangeRate);
1179
+ costSource = cost > 0 ? "pricing" : "unknown";
1180
+ } else {
1181
+ cost = 0;
1182
+ costSource = "unknown";
1183
+ }
1184
+ const recordId = generateRecordId(deviceInstanceId, dbPath + ":" + session.id, ts);
1185
+ const record = {
1186
+ id: recordId,
1187
+ ts,
1188
+ ingestedAt: now,
1189
+ updatedAt: now,
1190
+ lineOffset: 0,
1191
+ tool: "hermes",
1192
+ model,
1193
+ provider,
1194
+ inputTokens,
1195
+ outputTokens,
1196
+ cacheReadTokens,
1197
+ cacheWriteTokens,
1198
+ thinkingTokens,
1199
+ cost,
1200
+ costSource,
1201
+ sessionId: session.id,
1202
+ sourceFile: dbPath,
1203
+ device,
1204
+ deviceInstanceId,
1205
+ platform: platform4
1206
+ };
1207
+ records.push(record);
1208
+ const messages = toolCallsStmt.all(session.id);
1209
+ let callIndex = 0;
1210
+ for (const message of messages) {
1211
+ let toolCallList;
1212
+ try {
1213
+ const parsed = JSON.parse(message.tool_calls);
1214
+ if (!Array.isArray(parsed)) {
1215
+ errors.push(`session ${session.id} message ${message.id}: tool_calls payload is not an array`);
1216
+ continue;
776
1217
  }
777
- const status = options?.getSyncStatus?.() ?? null;
778
- json(res, { status });
779
- return;
1218
+ toolCallList = parsed;
1219
+ } catch (e) {
1220
+ errors.push(`session ${session.id} message ${message.id}: invalid tool_calls JSON: ${e instanceof Error ? e.message : e}`);
1221
+ continue;
780
1222
  }
781
- if (url.pathname === "/api/refresh") {
782
- if (!options?.onRefresh) {
783
- json(res, { error: { code: "NOT_AVAILABLE", message: "Refresh not available" } }, 501);
784
- return;
785
- }
786
- const result = await options.onRefresh();
787
- json(res, result);
788
- return;
1223
+ for (const tc of toolCallList) {
1224
+ const name = tc.function?.name;
1225
+ if (!name) continue;
1226
+ toolCalls.push({
1227
+ id: generateToolCallId(recordId, name, Math.round(message.timestamp * 1e3), callIndex),
1228
+ recordId,
1229
+ name,
1230
+ ts: Math.round(message.timestamp * 1e3),
1231
+ callIndex
1232
+ });
1233
+ callIndex++;
789
1234
  }
790
- if (url.pathname === "/api/devices") {
791
- let getDisplayName2 = function(device, deviceInstanceId) {
792
- if (deviceInstanceId === currentId) return currentDeviceAlias;
793
- if (device && device !== "unknown" && !/^[0-9a-f]{8}$/.test(device)) return device;
794
- if (/^[0-9a-f]{8}-/.test(deviceInstanceId)) return deviceInstanceId.slice(0, 8);
795
- if (device && device !== "unknown") return device;
796
- return "Unknown Device";
797
- }, getPlatformLabel2 = function(p) {
798
- if (p === "win32") return "Windows";
799
- if (p === "darwin") return "macOS";
800
- if (p === "linux") return "Linux";
801
- return "";
802
- }, inferPlatform2 = function(device, deviceInstanceId) {
803
- const name = (device || "").toLowerCase();
804
- const id = (deviceInstanceId || "").toLowerCase();
805
- if (/^(desktop|laptop)-/.test(name)) return "Windows";
806
- if (name.endsWith(".local") || /macbook|imac|mac\s*mini|mac\s*pro|mac\s*studio/.test(name)) return "macOS";
807
- if (/^(ubuntu|debian|centos|fedora|arch|linux|server|node|prod|dev|staging)/.test(name)) return "Linux";
808
- return "";
809
- };
810
- var getDisplayName = getDisplayName2, getPlatformLabel = getPlatformLabel2, inferPlatform = inferPlatform2;
811
- const currentId = options?.currentDeviceInstanceId;
812
- if (!currentId) {
813
- json(res, { currentDeviceInstanceId: null, devices: [] });
814
- return;
815
- }
816
- const config = loadConfig();
817
- const currentDeviceAlias = config?.device || hostname();
818
- const localRows = db.prepare(`
819
- SELECT device, device_instance_id AS deviceInstanceId, COUNT(*) AS recordCount
820
- FROM records
821
- WHERE device_instance_id = @currentId AND source_file NOT LIKE 'synced/%'
822
- GROUP BY device_instance_id
823
- `).all({ currentId });
824
- const syncedRows = db.prepare(`
825
- SELECT device, device_instance_id AS deviceInstanceId, platform, COUNT(*) AS recordCount
826
- FROM synced_records
827
- WHERE device_instance_id != @currentId
828
- GROUP BY device_instance_id
829
- `).all({ currentId });
830
- const currentPlatform = config?.platform || platform();
831
- const deviceMap = /* @__PURE__ */ new Map();
832
- for (const row of localRows) {
833
- const displayName = getDisplayName2(row.device, row.deviceInstanceId);
834
- deviceMap.set(row.deviceInstanceId, { device: row.device, deviceInstanceId: row.deviceInstanceId, displayName, platform: getPlatformLabel2(currentPlatform), recordCount: row.recordCount });
1235
+ }
1236
+ }
1237
+ return { records, toolCalls, nextCursor: lastCursor, errors };
1238
+ }
1239
+
1240
+ // src/commands/parse-qoder.ts
1241
+ function parseModelKey(recordExtra) {
1242
+ if (!recordExtra) return "qoder-auto";
1243
+ try {
1244
+ const parsed = JSON.parse(recordExtra);
1245
+ const key = parsed?.modelConfig?.key?.trim() ?? "";
1246
+ const normalized = normalizeQoderModel(key);
1247
+ return normalized === "unknown" ? "qoder-auto" : normalized;
1248
+ } catch {
1249
+ return "qoder-auto";
1250
+ }
1251
+ }
1252
+ function runParseQoder(db, options) {
1253
+ const { dbPath, device, deviceInstanceId, platform: platform4, now, cursor, exchangeRate } = options;
1254
+ const records = [];
1255
+ const toolCalls = [];
1256
+ const errors = [];
1257
+ let lastCursor = null;
1258
+ const messages = db.prepare(
1259
+ `SELECT m.id, m.session_id, m.gmt_create, m.token_info,
1260
+ r.extra AS record_extra
1261
+ FROM chat_message m
1262
+ LEFT JOIN chat_record r ON m.request_id = r.request_id
1263
+ WHERE m.role = 'assistant'
1264
+ AND length(m.token_info) > 2
1265
+ AND (m.gmt_create > ? OR (m.gmt_create = ? AND m.id > ?))
1266
+ ORDER BY m.gmt_create, m.id`
1267
+ ).all(
1268
+ cursor?.lastGmtCreate ?? 0,
1269
+ cursor?.lastGmtCreate ?? 0,
1270
+ cursor?.lastId ?? ""
1271
+ );
1272
+ for (const row of messages) {
1273
+ lastCursor = { lastGmtCreate: row.gmt_create, lastId: row.id };
1274
+ let tokenInfo;
1275
+ try {
1276
+ tokenInfo = JSON.parse(row.token_info);
1277
+ } catch (e) {
1278
+ errors.push(`message ${row.id}: invalid token_info JSON: ${e instanceof Error ? e.message : e}`);
1279
+ continue;
1280
+ }
1281
+ const cacheReadTokens = tokenInfo.cached_tokens ?? 0;
1282
+ const inputTokens = Math.max(0, (tokenInfo.prompt_tokens ?? 0) - cacheReadTokens);
1283
+ const outputTokens = tokenInfo.completion_tokens ?? 0;
1284
+ if (inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0) continue;
1285
+ const model = parseModelKey(row.record_extra);
1286
+ const provider = inferProvider(model);
1287
+ const ts = row.gmt_create;
1288
+ const tokenArgs = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens: 0, thinkingTokens: 0 };
1289
+ const calculatedCost = calculateCost(model, tokenArgs, exchangeRate);
1290
+ const cost = calculatedCost;
1291
+ const costSource = calculatedCost > 0 ? "pricing" : "unknown";
1292
+ const recordId = generateRecordId(deviceInstanceId, dbPath + ":" + row.id, ts);
1293
+ records.push({
1294
+ id: recordId,
1295
+ ts,
1296
+ ingestedAt: now,
1297
+ updatedAt: now,
1298
+ lineOffset: 0,
1299
+ tool: "qoder",
1300
+ model,
1301
+ provider,
1302
+ inputTokens,
1303
+ outputTokens,
1304
+ cacheReadTokens,
1305
+ cacheWriteTokens: 0,
1306
+ thinkingTokens: 0,
1307
+ cost,
1308
+ costSource,
1309
+ sessionId: row.session_id,
1310
+ sourceFile: dbPath,
1311
+ device,
1312
+ deviceInstanceId,
1313
+ platform: platform4
1314
+ });
1315
+ }
1316
+ return { records, toolCalls, nextCursor: lastCursor, errors };
1317
+ }
1318
+
1319
+ // src/commands/parse.ts
1320
+ function unique(paths) {
1321
+ return [...new Set(paths)];
1322
+ }
1323
+ function findJsonlFiles(dir) {
1324
+ const results = [];
1325
+ try {
1326
+ const entries = readdirSync(dir, { withFileTypes: true });
1327
+ for (const entry of entries) {
1328
+ const fullPath = join3(dir, entry.name);
1329
+ if (entry.isDirectory()) {
1330
+ results.push(...findJsonlFiles(fullPath));
1331
+ } else if (entry.isFile() && extname(entry.name) === ".jsonl") {
1332
+ results.push(fullPath);
1333
+ }
1334
+ }
1335
+ } catch {
1336
+ }
1337
+ return results;
1338
+ }
1339
+ function findQoderSessionSegmentFiles(dir) {
1340
+ return findJsonlFiles(dir).filter((filePath) => {
1341
+ const parts = filePath.replace(/\\/g, "/").split("/").filter(Boolean);
1342
+ const sessionsIndex = parts.lastIndexOf("sessions");
1343
+ const segmentsIndex = parts.lastIndexOf("segments");
1344
+ return sessionsIndex >= 0 && segmentsIndex > sessionsIndex && segmentsIndex === parts.length - 2;
1345
+ });
1346
+ }
1347
+ function windowsUserHomesFromWsl() {
1348
+ const homes = [];
1349
+ if (!existsSync4("/mnt")) return homes;
1350
+ try {
1351
+ const drives = readdirSync("/mnt", { withFileTypes: true });
1352
+ for (const drive of drives) {
1353
+ if (!drive.isDirectory() || !/^[a-z]$/i.test(drive.name)) continue;
1354
+ const usersDir = join3("/mnt", drive.name, "Users");
1355
+ if (!existsSync4(usersDir)) continue;
1356
+ try {
1357
+ const users = readdirSync(usersDir, { withFileTypes: true });
1358
+ for (const user of users) {
1359
+ if (!user.isDirectory()) continue;
1360
+ if (["All Users", "Default", "Default User", "Public"].includes(user.name)) continue;
1361
+ homes.push(join3(usersDir, user.name));
835
1362
  }
836
- for (const row of syncedRows) {
837
- const displayName = getDisplayName2(row.device, row.deviceInstanceId);
838
- const platformLabel = getPlatformLabel2(row.platform) || inferPlatform2(row.device, row.deviceInstanceId);
839
- const existing = deviceMap.get(row.deviceInstanceId);
840
- if (existing) {
841
- existing.recordCount += row.recordCount;
842
- } else {
843
- deviceMap.set(row.deviceInstanceId, { device: row.device, deviceInstanceId: row.deviceInstanceId, displayName, platform: platformLabel, recordCount: row.recordCount });
1363
+ } catch {
1364
+ }
1365
+ }
1366
+ } catch {
1367
+ }
1368
+ return homes;
1369
+ }
1370
+ function defaultQoderSessionDirs(home) {
1371
+ const windowsHomes = [
1372
+ process.env.USERPROFILE,
1373
+ ...windowsUserHomesFromWsl()
1374
+ ].filter((value) => !!value);
1375
+ const dirs = [
1376
+ join3(home, ".qoder", "logs", "sessions"),
1377
+ ...windowsHomes.flatMap((windowsHome) => [
1378
+ join3(windowsHome, ".qoder", "logs", "sessions"),
1379
+ join3(windowsHome, "AppData", "Local", ".qoder", "logs", "sessions"),
1380
+ join3(windowsHome, "AppData", "Roaming", "Qoder", "logs", "sessions")
1381
+ ])
1382
+ ];
1383
+ if (process.env.LOCALAPPDATA) {
1384
+ dirs.push(join3(process.env.LOCALAPPDATA, ".qoder", "logs", "sessions"));
1385
+ }
1386
+ if (process.env.APPDATA) {
1387
+ dirs.push(join3(process.env.APPDATA, "Qoder", "logs", "sessions"));
1388
+ }
1389
+ return unique(dirs);
1390
+ }
1391
+ function qoderSessionDirs(source, home) {
1392
+ if (!source) return defaultQoderSessionDirs(home);
1393
+ const normalized = source.replace(/\\/g, "/").replace(/\/+$/, "");
1394
+ if (normalized.endsWith("/logs/sessions") || normalized.endsWith("/sessions") || normalized.endsWith("/segments")) {
1395
+ return [source];
1396
+ }
1397
+ if (normalized.endsWith("/logs")) return [join3(source, "sessions")];
1398
+ return [join3(source, "logs", "sessions")];
1399
+ }
1400
+ function defaultOpenCodeDbPath() {
1401
+ const home = homedir2();
1402
+ const plat = platform();
1403
+ if (plat === "win32") {
1404
+ const appData = process.env.APPDATA ?? join3(home, "AppData", "Roaming");
1405
+ return join3(appData, "opencode", "opencode.db");
1406
+ }
1407
+ if (plat === "darwin") {
1408
+ return join3(home, "Library", "Application Support", "opencode", "opencode.db");
1409
+ }
1410
+ const xdgDataHome = process.env.XDG_DATA_HOME ?? join3(home, ".local", "share");
1411
+ return join3(xdgDataHome, "opencode", "opencode.db");
1412
+ }
1413
+ function defaultHermesDbPath() {
1414
+ return join3(homedir2(), ".hermes", "state.db");
1415
+ }
1416
+ function defaultQoderDbPath() {
1417
+ const home = homedir2();
1418
+ const plat = platform();
1419
+ if (plat === "darwin") {
1420
+ return join3(home, "Library", "Application Support", "Qoder", "SharedClientCache", "cache", "db", "local.db");
1421
+ }
1422
+ if (plat === "win32") {
1423
+ const localAppData = process.env.LOCALAPPDATA ?? join3(home, "AppData", "Local");
1424
+ return join3(localAppData, "Qoder", "SharedClientCache", "cache", "db", "local.db");
1425
+ }
1426
+ const xdgDataHome = process.env.XDG_DATA_HOME ?? join3(home, ".local", "share");
1427
+ return join3(xdgDataHome, "Qoder", "SharedClientCache", "cache", "db", "local.db");
1428
+ }
1429
+ function discoverLogFiles(sources) {
1430
+ const home = homedir2();
1431
+ const results = [];
1432
+ const claudeDir = sources?.["claude-code"] ?? join3(home, ".claude", "projects");
1433
+ if (existsSync4(claudeDir)) {
1434
+ const claudePaths = findJsonlFiles(claudeDir);
1435
+ if (claudePaths.length > 0) {
1436
+ results.push({ tool: "claude-code", paths: claudePaths });
1437
+ }
1438
+ }
1439
+ const codexDir = sources?.["codex"] ?? join3(home, ".codex", "sessions");
1440
+ if (existsSync4(codexDir)) {
1441
+ const codexPaths = findJsonlFiles(codexDir);
1442
+ if (codexPaths.length > 0) {
1443
+ results.push({ tool: "codex", paths: codexPaths });
1444
+ }
1445
+ }
1446
+ const openclawBase = sources?.["openclaw"] ?? join3(home, ".openclaw", "agents");
1447
+ if (existsSync4(openclawBase)) {
1448
+ let openclawPaths;
1449
+ if (sources?.["openclaw"]) {
1450
+ openclawPaths = findJsonlFiles(openclawBase).filter((p) => !p.includes(".checkpoint."));
1451
+ } else {
1452
+ openclawPaths = [];
1453
+ try {
1454
+ const agentEntries = readdirSync(openclawBase, { withFileTypes: true });
1455
+ for (const agentEntry of agentEntries) {
1456
+ if (!agentEntry.isDirectory()) continue;
1457
+ const sessionsDir = join3(openclawBase, agentEntry.name, "sessions");
1458
+ if (existsSync4(sessionsDir)) {
1459
+ openclawPaths.push(...findJsonlFiles(sessionsDir).filter((p) => !p.includes(".checkpoint.")));
844
1460
  }
845
1461
  }
846
- const devices = [...deviceMap.values()].sort((a, b) => b.recordCount - a.recordCount);
847
- json(res, { currentDeviceInstanceId: currentId, devices });
848
- return;
1462
+ } catch {
849
1463
  }
850
- if (url.pathname === "/api/config/credential" && req.method === "GET") {
851
- const ref = url.searchParams.get("ref")?.trim();
852
- if (!ref) {
853
- json(res, { error: { code: "MISSING_CREDENTIAL_REF", message: "credential ref is required" } }, 400);
854
- return;
855
- }
856
- const value = loadCredential(ref);
857
- if (!value) {
858
- json(res, { error: { code: "CREDENTIAL_NOT_FOUND", message: "Credential not found" } }, 404);
859
- return;
1464
+ }
1465
+ if (openclawPaths.length > 0) {
1466
+ results.push({ tool: "openclaw", paths: openclawPaths });
1467
+ }
1468
+ }
1469
+ const qoderPaths = unique(
1470
+ qoderSessionDirs(sources?.["qoder"], home).filter((dir) => existsSync4(dir)).flatMap((dir) => findQoderSessionSegmentFiles(dir))
1471
+ );
1472
+ if (qoderPaths.length > 0) {
1473
+ results.push({ tool: "qoder", paths: qoderPaths });
1474
+ }
1475
+ return results;
1476
+ }
1477
+ function extractSessionId(filePath, tool) {
1478
+ if (tool === "claude-code") {
1479
+ const parts = filePath.split("/");
1480
+ const filename = parts[parts.length - 1];
1481
+ return filename.replace(".jsonl", "");
1482
+ }
1483
+ if (tool === "codex") {
1484
+ const filename = filePath.split("/").pop() ?? "";
1485
+ const match = filename.match(/rollout-(.+)\.jsonl$/);
1486
+ return match ? match[1] : filename.replace(".jsonl", "");
1487
+ }
1488
+ if (tool === "openclaw") {
1489
+ const filename = filePath.split("/").pop() ?? "";
1490
+ return filename.replace(".jsonl", "");
1491
+ }
1492
+ if (tool === "qoder") {
1493
+ const parts = filePath.replace(/\\/g, "/").split("/").filter(Boolean);
1494
+ const segmentsIndex = parts.lastIndexOf("segments");
1495
+ if (segmentsIndex > 0) return parts[segmentsIndex - 1];
1496
+ const filename = parts[parts.length - 1] ?? "";
1497
+ return filename.replace(".jsonl", "") || "unknown";
1498
+ }
1499
+ return "unknown";
1500
+ }
1501
+ async function runParse(db, filterTool, options) {
1502
+ const state = getState(AIUSAGE_DIR);
1503
+ const config = loadConfig();
1504
+ const exchangeRate = resolveExchangeRate(config ?? {});
1505
+ const device = config?.device || hostname() || state?.deviceInstanceId?.slice(0, 8) || "unknown";
1506
+ const deviceInstanceId = state?.deviceInstanceId ?? "unknown";
1507
+ const devicePlatform = config?.platform;
1508
+ const watermarkPath = join3(AIUSAGE_DIR, "watermark.json");
1509
+ const wm = new WatermarkManager(watermarkPath);
1510
+ const toolPaths = discoverLogFiles(config?.sources);
1511
+ const aggregator = new Aggregator();
1512
+ let parsedCount = 0;
1513
+ let toolCallCount = 0;
1514
+ const errors = [];
1515
+ const onProgress = options?.onProgress ?? (() => {
1516
+ });
1517
+ const totalFiles = toolPaths.filter(({ tool }) => !filterTool || tool === filterTool).reduce((sum, { paths }) => sum + paths.length, 0);
1518
+ let fileIndex = 0;
1519
+ let currentTool;
1520
+ for (const { tool, paths } of toolPaths) {
1521
+ if (filterTool && tool !== filterTool) continue;
1522
+ currentTool = tool;
1523
+ for (const filePath of paths) {
1524
+ fileIndex++;
1525
+ try {
1526
+ const stat2 = statSync(filePath);
1527
+ const entry = wm.getEntry(tool, filePath);
1528
+ const offset = entry?.offset ?? 0;
1529
+ if (offset >= stat2.size) {
1530
+ onProgress({ phase: "Parsing logs", tool, current: fileIndex, total: totalFiles, records: parsedCount, toolCalls: toolCallCount });
1531
+ continue;
860
1532
  }
861
- json(res, { value });
862
- return;
863
- }
864
- if (url.pathname === "/api/config") {
865
- if (req.method === "GET") {
866
- const currentCfg = loadConfig() ?? {};
867
- const { credentials, priceOverrides, platform: platform4, ...rest } = currentCfg;
868
- json(res, {
869
- device: rest.device ?? null,
870
- weekStart: rest.weekStart ?? 1,
871
- dashboardPollInterval: rest.dashboardPollInterval ?? null,
872
- parseInterval: rest.parseInterval ?? null,
873
- retentionDays: rest.retentionDays ?? null,
874
- sources: {
875
- "claude-code": rest.sources?.["claude-code"] ?? null,
876
- codex: rest.sources?.codex ?? null,
877
- openclaw: rest.sources?.openclaw ?? null,
878
- opencode: rest.sources?.opencode ?? null
879
- },
880
- sync: rest.sync ?? null,
881
- credentialKeys: credentials ? Object.keys(credentials) : []
882
- });
883
- return;
1533
+ const content = readFileSync4(filePath, "utf-8");
1534
+ const lines = content.split("\n");
1535
+ let byteOffset = 0;
1536
+ let fileCwd;
1537
+ try {
1538
+ const firstData = JSON.parse(lines[0]);
1539
+ if (typeof firstData.cwd === "string" && firstData.cwd) fileCwd = firstData.cwd;
1540
+ } catch {
884
1541
  }
885
- if (req.method === "PUT") {
886
- let body = "";
887
- for await (const chunk of req) body += chunk;
888
- try {
889
- const update = JSON.parse(body);
890
- const existing = loadConfig() ?? {};
891
- if ("device" in update) {
892
- if (!update.device) delete existing.device;
893
- else existing.device = String(update.device);
894
- }
895
- if ("weekStart" in update) {
896
- const ws = Number(update.weekStart);
897
- if (ws === 0 || ws === 1) {
898
- existing.weekStart = ws;
899
- weekStart = ws;
900
- } else {
901
- delete existing.weekStart;
902
- }
903
- }
904
- if ("dashboardPollInterval" in update) {
905
- if (!update.dashboardPollInterval) delete existing.dashboardPollInterval;
906
- else existing.dashboardPollInterval = Number(update.dashboardPollInterval);
907
- }
908
- if ("parseInterval" in update) {
909
- if (!update.parseInterval) delete existing.parseInterval;
910
- else existing.parseInterval = Number(update.parseInterval);
911
- }
912
- if ("retentionDays" in update) {
913
- if (!update.retentionDays) delete existing.retentionDays;
914
- else existing.retentionDays = Number(update.retentionDays);
915
- }
916
- if (update.sources && typeof update.sources === "object") {
917
- const src = update.sources;
918
- const s = existing.sources ?? {};
919
- for (const key of ["claude-code", "codex", "openclaw", "opencode"]) {
920
- if (key in src) {
921
- if (!src[key]) delete s[key];
922
- else s[key] = String(src[key]);
923
- }
924
- }
925
- if (Object.keys(s).length) existing.sources = s;
926
- else delete existing.sources;
927
- }
928
- if ("sync" in update) {
929
- const syncUpdate = update.sync;
930
- if (!syncUpdate?.backend) {
931
- delete existing.sync;
932
- } else {
933
- const backendVal = String(syncUpdate.backend);
934
- if (backendVal !== "github" && backendVal !== "s3") {
935
- json(res, { error: { code: "INVALID_BACKEND", message: "sync.backend must be github or s3" } }, 400);
936
- return;
937
- }
938
- const newSync = { backend: backendVal };
939
- for (const f of ["repo", "bucket", "prefix", "endpoint", "region", "credentialRef"]) {
940
- if (syncUpdate[f]) newSync[f] = String(syncUpdate[f]);
941
- }
942
- existing.sync = newSync;
943
- }
1542
+ const sessionId = extractSessionId(filePath, tool);
1543
+ for (const line of lines) {
1544
+ if (!line.trim()) {
1545
+ byteOffset += Buffer.byteLength(line, "utf-8") + 1;
1546
+ continue;
1547
+ }
1548
+ if (byteOffset < offset) {
1549
+ byteOffset += Buffer.byteLength(line, "utf-8") + 1;
1550
+ continue;
1551
+ }
1552
+ const context = aggregator.createContext({
1553
+ tool,
1554
+ sourceFile: filePath,
1555
+ lineOffset: byteOffset,
1556
+ sessionId,
1557
+ device,
1558
+ deviceInstanceId,
1559
+ platform: devicePlatform,
1560
+ exchangeRate
1561
+ });
1562
+ const result = aggregator.parseLine(line, context);
1563
+ if (result) {
1564
+ if (result.record) {
1565
+ insertRecord(db, result.record);
1566
+ parsedCount++;
944
1567
  }
945
- if (update.credentials && typeof update.credentials === "object") {
946
- const creds = update.credentials;
947
- const c = existing.credentials ?? {};
948
- for (const [key, val] of Object.entries(creds)) {
949
- if (val !== "" && val !== null && val !== void 0) {
950
- c[key] = String(val);
951
- }
952
- }
953
- if (Object.keys(c).length) existing.credentials = c;
954
- else delete existing.credentials;
1568
+ for (const tc of result.toolCalls) {
1569
+ insertToolCall(db, tc);
1570
+ toolCallCount++;
955
1571
  }
956
- saveConfig(existing);
957
- options?.onConfigUpdated?.();
958
- json(res, { ok: true });
959
- } catch {
960
- json(res, { error: { code: "INVALID_JSON", message: "Invalid JSON body" } }, 400);
961
1572
  }
962
- return;
1573
+ byteOffset += Buffer.byteLength(line, "utf-8") + 1;
1574
+ }
1575
+ const orphanResults = aggregator.finalize();
1576
+ for (const result of orphanResults) {
1577
+ for (const tc of result.toolCalls) {
1578
+ insertToolCall(db, tc);
1579
+ toolCallCount++;
1580
+ }
1581
+ }
1582
+ if (fileCwd) {
1583
+ db.prepare(`UPDATE records SET cwd = ? WHERE source_file = ? AND cwd = ''`).run(fileCwd, filePath);
963
1584
  }
1585
+ wm.setEntry(tool, filePath, {
1586
+ offset: stat2.size,
1587
+ size: stat2.size,
1588
+ mtime: stat2.mtimeMs
1589
+ });
1590
+ wm.save();
1591
+ onProgress({ phase: "Parsing logs", tool, current: fileIndex, total: totalFiles, records: parsedCount, toolCalls: toolCallCount });
1592
+ } catch (e) {
1593
+ errors.push(`${filePath}: ${e instanceof Error ? e.message : e}`);
964
1594
  }
965
- json(res, { error: { code: "NOT_FOUND", message: "Endpoint not found" } }, 404);
966
- } catch (error) {
967
- console.error("API error:", error);
968
- json(res, { error: { code: "INTERNAL", message: "Internal server error" } }, 500);
969
1595
  }
970
- });
971
- return server;
972
- }
973
-
974
- // src/commands/parse.ts
975
- import Database from "better-sqlite3";
976
- import { readFileSync as readFileSync4, readdirSync, statSync, existsSync as existsSync4 } from "fs";
977
- import { join as join3, extname } from "path";
978
- import { homedir as homedir2, hostname as hostname2, platform as platform2 } from "os";
979
-
980
- // src/db/records.ts
981
- function insertRecord(db, record) {
982
- db.prepare(`
983
- INSERT OR REPLACE INTO records (
984
- id, ts, ingested_at, synced_at, updated_at, line_offset,
985
- tool, model, provider, input_tokens, output_tokens,
986
- cache_read_tokens, cache_write_tokens, thinking_tokens,
987
- cost, cost_source, session_id, source_file, device, device_instance_id, platform
988
- ) VALUES (
989
- @id, @ts, @ingestedAt, @syncedAt, @updatedAt, @lineOffset,
990
- @tool, @model, @provider, @inputTokens, @outputTokens,
991
- @cacheReadTokens, @cacheWriteTokens, @thinkingTokens,
992
- @cost, @costSource, @sessionId, @sourceFile, @device, @deviceInstanceId, @platform
993
- )
994
- `).run({
995
- id: record.id,
996
- ts: record.ts,
997
- ingestedAt: record.ingestedAt,
998
- syncedAt: record.syncedAt ?? null,
999
- updatedAt: record.updatedAt,
1000
- lineOffset: record.lineOffset,
1001
- tool: record.tool,
1002
- model: record.model,
1003
- provider: record.provider,
1004
- inputTokens: record.inputTokens,
1005
- outputTokens: record.outputTokens,
1006
- cacheReadTokens: record.cacheReadTokens,
1007
- cacheWriteTokens: record.cacheWriteTokens,
1008
- thinkingTokens: record.thinkingTokens,
1009
- cost: record.cost,
1010
- costSource: record.costSource,
1011
- sessionId: record.sessionId,
1012
- sourceFile: record.sourceFile,
1013
- device: record.device,
1014
- deviceInstanceId: record.deviceInstanceId,
1015
- platform: record.platform ?? ""
1016
- });
1017
- }
1018
- function getUnsyncedRecords(db) {
1019
- const rows = db.prepare(
1020
- "SELECT * FROM records WHERE synced_at IS NULL OR updated_at > synced_at"
1596
+ }
1597
+ const openCodeDbPath = options?.openCodeDbPath ?? config?.sources?.["opencode"] ?? defaultOpenCodeDbPath();
1598
+ if ((!filterTool || filterTool === "opencode") && existsSync4(openCodeDbPath)) {
1599
+ try {
1600
+ const openCodeDb = new Database(openCodeDbPath, { readonly: true });
1601
+ try {
1602
+ const result = runParseOpenCode(openCodeDb, {
1603
+ dbPath: openCodeDbPath,
1604
+ device,
1605
+ deviceInstanceId,
1606
+ platform: devicePlatform,
1607
+ now: Date.now(),
1608
+ cursor: wm.getOpenCodeCursor(),
1609
+ exchangeRate
1610
+ });
1611
+ for (const record of result.records) insertRecord(db, record);
1612
+ for (const tc of result.toolCalls) insertToolCall(db, tc);
1613
+ if (result.nextCursor) {
1614
+ wm.setOpenCodeCursor(result.nextCursor);
1615
+ wm.save();
1616
+ }
1617
+ parsedCount += result.records.length;
1618
+ toolCallCount += result.toolCalls.length;
1619
+ errors.push(...result.errors);
1620
+ onProgress({ phase: "Parsing SQLite", tool: "opencode", current: 1, total: 1, records: parsedCount, toolCalls: toolCallCount });
1621
+ } finally {
1622
+ openCodeDb.close();
1623
+ }
1624
+ } catch (e) {
1625
+ errors.push(`${openCodeDbPath}: ${e instanceof Error ? e.message : e}`);
1626
+ }
1627
+ }
1628
+ const hermesDbPath = options?.hermesDbPath ?? config?.sources?.["hermes"] ?? defaultHermesDbPath();
1629
+ if ((!filterTool || filterTool === "hermes") && existsSync4(hermesDbPath)) {
1630
+ try {
1631
+ const hermesDb = new Database(hermesDbPath, { readonly: true });
1632
+ try {
1633
+ const result = runParseHermes(hermesDb, {
1634
+ dbPath: hermesDbPath,
1635
+ device,
1636
+ deviceInstanceId,
1637
+ platform: devicePlatform,
1638
+ now: Date.now(),
1639
+ cursor: wm.getHermesCursor(),
1640
+ exchangeRate
1641
+ });
1642
+ for (const record of result.records) insertRecord(db, record);
1643
+ for (const tc of result.toolCalls) insertToolCall(db, tc);
1644
+ if (result.nextCursor) {
1645
+ wm.setHermesCursor(result.nextCursor);
1646
+ wm.save();
1647
+ }
1648
+ parsedCount += result.records.length;
1649
+ toolCallCount += result.toolCalls.length;
1650
+ errors.push(...result.errors);
1651
+ onProgress({ phase: "Parsing SQLite", tool: "hermes", current: 1, total: 1, records: parsedCount, toolCalls: toolCallCount });
1652
+ } finally {
1653
+ hermesDb.close();
1654
+ }
1655
+ } catch (e) {
1656
+ errors.push(`${hermesDbPath}: ${e instanceof Error ? e.message : e}`);
1657
+ }
1658
+ }
1659
+ const qoderDbPath = options?.qoderDbPath ?? config?.sources?.["qoder-db"] ?? defaultQoderDbPath();
1660
+ if ((!filterTool || filterTool === "qoder") && existsSync4(qoderDbPath)) {
1661
+ try {
1662
+ const qoderDb = new Database(qoderDbPath, { readonly: true });
1663
+ try {
1664
+ const result = runParseQoder(qoderDb, {
1665
+ dbPath: qoderDbPath,
1666
+ device,
1667
+ deviceInstanceId,
1668
+ platform: devicePlatform,
1669
+ now: Date.now(),
1670
+ cursor: wm.getQoderCursor(),
1671
+ exchangeRate
1672
+ });
1673
+ for (const record of result.records) insertRecord(db, record);
1674
+ for (const tc of result.toolCalls) insertToolCall(db, tc);
1675
+ if (result.nextCursor) {
1676
+ wm.setQoderCursor(result.nextCursor);
1677
+ wm.save();
1678
+ }
1679
+ parsedCount += result.records.length;
1680
+ toolCallCount += result.toolCalls.length;
1681
+ errors.push(...result.errors);
1682
+ onProgress({ phase: "Parsing SQLite", tool: "qoder", current: 1, total: 1, records: parsedCount, toolCalls: toolCallCount });
1683
+ } finally {
1684
+ qoderDb.close();
1685
+ }
1686
+ } catch (e) {
1687
+ errors.push(`${qoderDbPath}: ${e instanceof Error ? e.message : e}`);
1688
+ }
1689
+ }
1690
+ if (deviceInstanceId !== "unknown") {
1691
+ db.prepare(
1692
+ `UPDATE records SET device_instance_id = ?, device = ? WHERE device_instance_id = 'unknown'`
1693
+ ).run(deviceInstanceId, device);
1694
+ }
1695
+ if (devicePlatform) {
1696
+ db.prepare(
1697
+ `UPDATE records SET platform = ? WHERE platform = '' AND source_file NOT LIKE 'synced/%'`
1698
+ ).run(devicePlatform);
1699
+ }
1700
+ const staleFiles = db.prepare(
1701
+ `SELECT DISTINCT source_file FROM records WHERE cwd = '' AND source_file NOT LIKE 'synced/%'`
1021
1702
  ).all();
1022
- return rows.map(mapRowToRecord);
1703
+ for (const { source_file } of staleFiles) {
1704
+ try {
1705
+ const fd = openSync(source_file, "r");
1706
+ const buf = Buffer.alloc(2048);
1707
+ const n = readSync(fd, buf, 0, 2048, 0);
1708
+ closeSync(fd);
1709
+ const firstLine = buf.subarray(0, n).toString("utf8").split("\n")[0];
1710
+ const data = JSON.parse(firstLine);
1711
+ if (typeof data.cwd === "string" && data.cwd) {
1712
+ db.prepare(`UPDATE records SET cwd = ? WHERE source_file = ? AND cwd = ''`).run(data.cwd, source_file);
1713
+ }
1714
+ } catch {
1715
+ }
1716
+ }
1717
+ return { parsedCount, toolCallCount, errors };
1023
1718
  }
1024
- function mapRowToRecord(row) {
1719
+ function getDefaultSourcePaths() {
1720
+ const home = homedir2();
1025
1721
  return {
1026
- id: row.id,
1027
- ts: row.ts,
1028
- ingestedAt: row.ingested_at,
1029
- syncedAt: row.synced_at != null ? row.synced_at : void 0,
1030
- updatedAt: row.updated_at,
1031
- lineOffset: row.line_offset,
1032
- tool: row.tool,
1033
- model: row.model,
1034
- provider: row.provider,
1035
- inputTokens: row.input_tokens,
1036
- outputTokens: row.output_tokens,
1037
- cacheReadTokens: row.cache_read_tokens,
1038
- cacheWriteTokens: row.cache_write_tokens,
1039
- thinkingTokens: row.thinking_tokens,
1040
- cost: row.cost,
1041
- costSource: row.cost_source,
1042
- sessionId: row.session_id,
1043
- sourceFile: row.source_file,
1044
- device: row.device,
1045
- deviceInstanceId: row.device_instance_id,
1046
- platform: row.platform || void 0
1722
+ "claude-code": join3(home, ".claude", "projects"),
1723
+ "codex": join3(home, ".codex", "sessions"),
1724
+ "openclaw": join3(home, ".openclaw", "agents"),
1725
+ "opencode": defaultOpenCodeDbPath(),
1726
+ "hermes": defaultHermesDbPath(),
1727
+ "qoder": join3(home, ".qoder", "logs", "sessions"),
1728
+ "qoder-db": defaultQoderDbPath()
1047
1729
  };
1048
1730
  }
1049
1731
 
1050
- // src/db/tool-calls.ts
1051
- function insertToolCall(db, tc) {
1052
- db.prepare(`
1053
- INSERT OR REPLACE INTO tool_calls (id, record_id, tool, name, ts, call_index)
1054
- VALUES (@id, @recordId, @tool, @name, @ts, @callIndex)
1055
- `).run({
1056
- id: tc.id,
1057
- recordId: tc.recordId ?? null,
1058
- tool: tc.recordId ? null : tc.tool,
1059
- name: tc.name,
1060
- ts: tc.ts,
1061
- callIndex: tc.callIndex
1062
- });
1063
- }
1064
- function getToolCallStats(db) {
1065
- return db.prepare(`
1066
- SELECT name, COUNT(*) as count
1067
- FROM tool_calls
1068
- GROUP BY name
1069
- ORDER BY count DESC
1070
- `).all();
1071
- }
1072
-
1073
- // src/init.ts
1074
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
1075
- import { join as join2 } from "path";
1076
- import { randomUUID } from "crypto";
1077
- function getState(aiusageDir) {
1078
- const statePath = join2(aiusageDir, "state.json");
1079
- if (!existsSync2(statePath)) return null;
1080
- try {
1081
- const content = readFileSync2(statePath, "utf-8");
1082
- return JSON.parse(content);
1083
- } catch {
1084
- return null;
1732
+ // src/api/server.ts
1733
+ function getDateRangeFilter(range, from, to, prefix = "", weekStart = 1) {
1734
+ const ts = prefix ? `${prefix}.ts` : "ts";
1735
+ if (from && to) {
1736
+ const startMs = new Date(from).getTime();
1737
+ const endMs = (/* @__PURE__ */ new Date(to + "T23:59:59.999Z")).getTime();
1738
+ return { where: `AND ${ts} >= @start AND ${ts} < @end`, params: { start: startMs, end: endMs } };
1085
1739
  }
1740
+ const now = /* @__PURE__ */ new Date();
1741
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1742
+ if (range === "week") {
1743
+ const dayOfWeek = today.getDay();
1744
+ const diff = (dayOfWeek - weekStart + 7) % 7;
1745
+ const startOfWeek = new Date(today);
1746
+ startOfWeek.setDate(today.getDate() - diff);
1747
+ return { where: `AND ${ts} >= @start`, params: { start: startOfWeek.getTime() } };
1748
+ }
1749
+ if (range === "month") {
1750
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
1751
+ return { where: `AND ${ts} >= @start`, params: { start: startOfMonth.getTime() } };
1752
+ }
1753
+ if (range === "last30") {
1754
+ const start = new Date(today);
1755
+ start.setDate(start.getDate() - 30);
1756
+ return { where: `AND ${ts} >= @start`, params: { start: start.getTime() } };
1757
+ }
1758
+ if (range === "all") {
1759
+ return { where: "", params: {} };
1760
+ }
1761
+ return { where: `AND ${ts} >= @start`, params: { start: today.getTime() } };
1086
1762
  }
1087
- function setState(aiusageDir, updates) {
1088
- const statePath = join2(aiusageDir, "state.json");
1089
- const current = getState(aiusageDir) ?? {
1090
- deviceInstanceId: randomUUID(),
1091
- lastSyncStatus: "ok"
1092
- };
1093
- const updated = { ...current, ...updates };
1094
- writeFileSync2(statePath, JSON.stringify(updated, null, 2), "utf-8");
1763
+ function json(res, data, status = 200) {
1764
+ res.writeHead(status, { "Content-Type": "application/json" });
1765
+ res.end(JSON.stringify(data));
1095
1766
  }
1096
-
1097
- // src/watermark.ts
1098
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3 } from "fs";
1099
- function defaultFileData() {
1767
+ function getToolFilter(tool, prefix = "") {
1768
+ if (!tool) return { where: "", params: {} };
1769
+ const col = prefix ? `${prefix}.tool` : "tool";
1770
+ return { where: `AND ${col} = @tool`, params: { tool } };
1771
+ }
1772
+ var LOCAL_ONLY_FILTER = "AND source_file NOT LIKE 'synced/%'";
1773
+ function getDeviceFilter(device, currentDeviceInstanceId) {
1774
+ if (!currentDeviceInstanceId) {
1775
+ return { where: "", params: {}, useUnion: false, localOnly: false };
1776
+ }
1777
+ if (!device) {
1778
+ return {
1779
+ where: "",
1780
+ params: { currentDeviceId: currentDeviceInstanceId },
1781
+ useUnion: true,
1782
+ localOnly: true
1783
+ };
1784
+ }
1785
+ if (device === currentDeviceInstanceId) {
1786
+ return { where: "", params: {}, useUnion: false, localOnly: true };
1787
+ }
1100
1788
  return {
1101
- "claude-code": {},
1102
- "codex": {},
1103
- "openclaw": {},
1104
- "opencode": {},
1105
- "hermes": {}
1789
+ where: "AND device_instance_id = @deviceId",
1790
+ params: { deviceId: device },
1791
+ useUnion: false,
1792
+ localOnly: false
1106
1793
  };
1107
1794
  }
1108
- var WatermarkManager = class {
1109
- data;
1110
- path;
1111
- constructor(path2) {
1112
- this.path = path2;
1113
- this.data = this.load();
1114
- }
1115
- load() {
1116
- if (!existsSync3(this.path)) {
1117
- return { files: defaultFileData() };
1795
+ function createApiServer(db, options) {
1796
+ const cfg = loadConfig();
1797
+ let weekStart = cfg?.weekStart ?? 1;
1798
+ const server = http.createServer(async (req, res) => {
1799
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1800
+ res.setHeader("Access-Control-Allow-Origin", "*");
1801
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
1802
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1803
+ if (req.method === "OPTIONS") {
1804
+ res.writeHead(204);
1805
+ res.end();
1806
+ return;
1807
+ }
1808
+ const range = url.searchParams.get("range");
1809
+ const from = url.searchParams.get("from");
1810
+ const to = url.searchParams.get("to");
1811
+ const toolParam = url.searchParams.get("tool");
1812
+ const VALID_TOOLS = ["claude-code", "codex", "openclaw", "opencode", "hermes", "qoder"];
1813
+ if (toolParam && !VALID_TOOLS.includes(toolParam)) {
1814
+ json(res, { error: { code: "INVALID_PARAM", message: "Invalid tool" } }, 400);
1815
+ return;
1118
1816
  }
1119
1817
  try {
1120
- const content = readFileSync3(this.path, "utf-8");
1121
- const parsed = JSON.parse(content);
1122
- if (parsed && typeof parsed === "object" && !("files" in parsed)) {
1123
- return { files: { ...defaultFileData(), ...parsed } };
1818
+ if (url.pathname === "/api/summary") {
1819
+ if (range && !["day", "week", "month", "last30", "all"].includes(range)) {
1820
+ json(res, { error: { code: "INVALID_PARAM", message: "Invalid range" } }, 400);
1821
+ return;
1822
+ }
1823
+ const device = url.searchParams.get("device");
1824
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
1825
+ const dr = getDateRangeFilter(range, from, to, "", weekStart);
1826
+ const tool = url.searchParams.get("tool");
1827
+ const tf = getToolFilter(tool);
1828
+ let totals;
1829
+ let byToolRows;
1830
+ if (df.useUnion) {
1831
+ const unionSql = `
1832
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, ts, session_id
1833
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
1834
+ UNION ALL
1835
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, ts, session_key AS session_id
1836
+ FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
1837
+ `;
1838
+ totals = db.prepare(`
1839
+ SELECT
1840
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
1841
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
1842
+ COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
1843
+ COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
1844
+ COALESCE(SUM(thinking_tokens), 0) AS thinkingTokens,
1845
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
1846
+ COALESCE(SUM(cost), 0) AS totalCost,
1847
+ COUNT(DISTINCT strftime('%Y-%m-%d', ts/1000, 'unixepoch')) AS activeDays,
1848
+ COUNT(DISTINCT session_id) AS totalSessions
1849
+ FROM (${unionSql})
1850
+ `).get({ ...dr.params, ...df.params, ...tf.params });
1851
+ byToolRows = db.prepare(`
1852
+ SELECT tool, SUM(tokens) AS tokens, SUM(cost) AS cost FROM (
1853
+ SELECT tool,
1854
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
1855
+ SUM(cost) AS cost
1856
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
1857
+ GROUP BY tool
1858
+ UNION ALL
1859
+ SELECT tool,
1860
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
1861
+ SUM(cost) AS cost
1862
+ FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
1863
+ GROUP BY tool
1864
+ ) GROUP BY tool ORDER BY cost DESC
1865
+ `).all({ ...dr.params, ...df.params, ...tf.params });
1866
+ } else if (df.where) {
1867
+ totals = db.prepare(`
1868
+ SELECT
1869
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
1870
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
1871
+ COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
1872
+ COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
1873
+ COALESCE(SUM(thinking_tokens), 0) AS thinkingTokens,
1874
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
1875
+ COALESCE(SUM(cost), 0) AS totalCost,
1876
+ COUNT(DISTINCT strftime('%Y-%m-%d', ts/1000, 'unixepoch')) AS activeDays,
1877
+ COUNT(DISTINCT session_key) AS totalSessions
1878
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
1879
+ `).get({ ...dr.params, ...df.params, ...tf.params });
1880
+ byToolRows = db.prepare(`
1881
+ SELECT tool,
1882
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
1883
+ SUM(cost) AS cost
1884
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
1885
+ GROUP BY tool ORDER BY cost DESC
1886
+ `).all({ ...dr.params, ...df.params, ...tf.params });
1887
+ } else {
1888
+ totals = db.prepare(`
1889
+ SELECT
1890
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
1891
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
1892
+ COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
1893
+ COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
1894
+ COALESCE(SUM(thinking_tokens), 0) AS thinkingTokens,
1895
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
1896
+ COALESCE(SUM(cost), 0) AS totalCost,
1897
+ COUNT(DISTINCT strftime('%Y-%m-%d', ts/1000, 'unixepoch')) AS activeDays,
1898
+ COUNT(DISTINCT session_id) AS totalSessions
1899
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
1900
+ `).get({ ...dr.params, ...tf.params });
1901
+ byToolRows = db.prepare(`
1902
+ SELECT tool,
1903
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
1904
+ SUM(cost) AS cost
1905
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
1906
+ GROUP BY tool ORDER BY cost DESC
1907
+ `).all({ ...dr.params, ...tf.params });
1908
+ }
1909
+ const byTool = {};
1910
+ for (const row of byToolRows) {
1911
+ byTool[row.tool] = { tokens: row.tokens, cost: row.cost };
1912
+ }
1913
+ const drJoin = getDateRangeFilter(range, from, to, "r", weekStart);
1914
+ const dfJoin = df.where ? df.where.replace(/device_instance_id/g, "r.device_instance_id") : "";
1915
+ const tfJoin = getToolFilter(tool, "r");
1916
+ const topToolCalls = db.prepare(`
1917
+ SELECT tc.name, COUNT(*) AS count
1918
+ FROM tool_calls tc
1919
+ JOIN records r ON r.id = tc.record_id
1920
+ WHERE 1=1 ${dfJoin} ${drJoin.where} ${tfJoin.where}
1921
+ GROUP BY tc.name ORDER BY count DESC LIMIT 10
1922
+ `).all({ ...drJoin.params, ...df.params, ...tfJoin.params });
1923
+ json(res, {
1924
+ inputTokens: totals.inputTokens,
1925
+ outputTokens: totals.outputTokens,
1926
+ cacheReadTokens: totals.cacheReadTokens,
1927
+ cacheWriteTokens: totals.cacheWriteTokens,
1928
+ thinkingTokens: totals.thinkingTokens,
1929
+ totalTokens: totals.totalTokens,
1930
+ totalCost: totals.totalCost,
1931
+ activeDays: totals.activeDays,
1932
+ totalSessions: totals.totalSessions,
1933
+ byTool,
1934
+ topToolCalls
1935
+ });
1936
+ return;
1937
+ }
1938
+ if (url.pathname === "/api/tokens") {
1939
+ const dr = getDateRangeFilter(range, from, to, "", weekStart);
1940
+ const device = url.searchParams.get("device");
1941
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
1942
+ const tool = url.searchParams.get("tool");
1943
+ const tf = getToolFilter(tool);
1944
+ let sql;
1945
+ let params;
1946
+ if (df.useUnion) {
1947
+ sql = `
1948
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
1949
+ SUM(input_tokens) AS inputTokens,
1950
+ SUM(output_tokens) AS outputTokens,
1951
+ SUM(cache_read_tokens) AS cacheReadTokens,
1952
+ SUM(cache_write_tokens) AS cacheWriteTokens,
1953
+ SUM(thinking_tokens) AS thinkingTokens
1954
+ FROM (
1955
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, ts FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
1956
+ UNION ALL
1957
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, ts FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
1958
+ )
1959
+ GROUP BY date ORDER BY date`;
1960
+ params = { ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params };
1961
+ } else if (device && device !== options?.currentDeviceInstanceId) {
1962
+ sql = `
1963
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
1964
+ SUM(input_tokens) AS inputTokens,
1965
+ SUM(output_tokens) AS outputTokens,
1966
+ SUM(cache_read_tokens) AS cacheReadTokens,
1967
+ SUM(cache_write_tokens) AS cacheWriteTokens,
1968
+ SUM(thinking_tokens) AS thinkingTokens
1969
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
1970
+ GROUP BY date ORDER BY date`;
1971
+ params = { ...df.params, ...dr.params, ...tf.params };
1972
+ } else {
1973
+ sql = `
1974
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
1975
+ SUM(input_tokens) AS inputTokens,
1976
+ SUM(output_tokens) AS outputTokens,
1977
+ SUM(cache_read_tokens) AS cacheReadTokens,
1978
+ SUM(cache_write_tokens) AS cacheWriteTokens,
1979
+ SUM(thinking_tokens) AS thinkingTokens
1980
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
1981
+ GROUP BY date ORDER BY date`;
1982
+ params = { ...dr.params, ...tf.params };
1983
+ }
1984
+ const rows = db.prepare(sql).all(params);
1985
+ json(res, { data: rows });
1986
+ return;
1124
1987
  }
1125
- return { files: parsed.files ?? defaultFileData(), opencode: parsed.opencode ?? null, hermes: parsed.hermes ?? null };
1126
- } catch {
1127
- return { files: defaultFileData() };
1128
- }
1129
- }
1130
- save() {
1131
- writeFileSync3(this.path, JSON.stringify(this.data, null, 2), "utf-8");
1132
- }
1133
- getEntry(tool, filePath) {
1134
- return this.data.files[tool]?.[filePath] ?? null;
1135
- }
1136
- setEntry(tool, filePath, entry) {
1137
- if (!this.data.files[tool]) {
1138
- this.data.files[tool] = {};
1139
- }
1140
- this.data.files[tool][filePath] = entry;
1141
- }
1142
- cleanup(existingFiles) {
1143
- const existingSet = new Set(existingFiles);
1144
- for (const tool of Object.keys(this.data.files)) {
1145
- for (const filePath of Object.keys(this.data.files[tool])) {
1146
- if (!existingSet.has(filePath)) {
1147
- delete this.data.files[tool][filePath];
1988
+ if (url.pathname === "/api/cost") {
1989
+ const dr = getDateRangeFilter(range, from, to, "", weekStart);
1990
+ const device = url.searchParams.get("device");
1991
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
1992
+ const tool = url.searchParams.get("tool");
1993
+ const tf = getToolFilter(tool);
1994
+ let daily;
1995
+ let byToolRows;
1996
+ let byModelRows;
1997
+ if (df.useUnion) {
1998
+ daily = db.prepare(`
1999
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
2000
+ SUM(cost) AS cost
2001
+ FROM (
2002
+ SELECT cost, ts FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
2003
+ UNION ALL
2004
+ SELECT cost, ts FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
2005
+ )
2006
+ GROUP BY date ORDER BY date
2007
+ `).all({ ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params });
2008
+ byToolRows = db.prepare(`
2009
+ SELECT tool, SUM(cost) AS cost FROM (
2010
+ SELECT tool, SUM(cost) AS cost FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where} GROUP BY tool
2011
+ UNION ALL
2012
+ SELECT tool, SUM(cost) AS cost FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where} GROUP BY tool
2013
+ ) GROUP BY tool ORDER BY cost DESC
2014
+ `).all({ ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params });
2015
+ byModelRows = db.prepare(`
2016
+ SELECT model, SUM(cost) AS cost FROM (
2017
+ SELECT model, SUM(cost) AS cost FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where} GROUP BY model
2018
+ UNION ALL
2019
+ SELECT model, SUM(cost) AS cost FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where} GROUP BY model
2020
+ ) GROUP BY model ORDER BY cost DESC
2021
+ `).all({ ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params });
2022
+ } else if (device && device !== options?.currentDeviceInstanceId) {
2023
+ daily = db.prepare(`
2024
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
2025
+ SUM(cost) AS cost
2026
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
2027
+ GROUP BY date ORDER BY date
2028
+ `).all({ ...df.params, ...dr.params, ...tf.params });
2029
+ byToolRows = db.prepare(`
2030
+ SELECT tool, SUM(cost) AS cost
2031
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
2032
+ GROUP BY tool ORDER BY cost DESC
2033
+ `).all({ ...df.params, ...dr.params, ...tf.params });
2034
+ byModelRows = db.prepare(`
2035
+ SELECT model, SUM(cost) AS cost
2036
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
2037
+ GROUP BY model ORDER BY cost DESC
2038
+ `).all({ ...df.params, ...dr.params, ...tf.params });
2039
+ } else {
2040
+ daily = db.prepare(`
2041
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
2042
+ SUM(cost) AS cost
2043
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
2044
+ GROUP BY date ORDER BY date
2045
+ `).all({ ...dr.params, ...tf.params });
2046
+ byToolRows = db.prepare(`
2047
+ SELECT tool, SUM(cost) AS cost
2048
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
2049
+ GROUP BY tool ORDER BY cost DESC
2050
+ `).all({ ...dr.params, ...tf.params });
2051
+ byModelRows = db.prepare(`
2052
+ SELECT model, SUM(cost) AS cost
2053
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
2054
+ GROUP BY model ORDER BY cost DESC
2055
+ `).all({ ...dr.params, ...tf.params });
1148
2056
  }
2057
+ const byTool = {};
2058
+ for (const r of byToolRows) byTool[r.tool] = r.cost;
2059
+ const byModel = {};
2060
+ for (const r of byModelRows) byModel[r.model] = r.cost;
2061
+ json(res, { data: daily, byTool, byModel });
2062
+ return;
1149
2063
  }
1150
- }
1151
- }
1152
- getOpenCodeCursor() {
1153
- return this.data.opencode ?? null;
1154
- }
1155
- setOpenCodeCursor(cursor) {
1156
- this.data.opencode = cursor;
1157
- }
1158
- getHermesCursor() {
1159
- return this.data.hermes ?? null;
1160
- }
1161
- setHermesCursor(cursor) {
1162
- this.data.hermes = cursor;
1163
- }
1164
- };
1165
-
1166
- // src/commands/parse-opencode.ts
1167
- function runParseOpenCode(db, options) {
1168
- const { dbPath, device, deviceInstanceId, platform: platform4, now, cursor } = options;
1169
- const records = [];
1170
- const toolCalls = [];
1171
- const errors = [];
1172
- let lastCursor = null;
1173
- const messages = db.prepare(
1174
- `SELECT m.id, m.session_id, m.time_created, m.data
1175
- FROM message m
1176
- WHERE m.time_created > ? OR (m.time_created = ? AND m.id > ?)
1177
- ORDER BY m.time_created, m.id`
1178
- ).all(
1179
- cursor?.lastMessageCreatedAt ?? 0,
1180
- cursor?.lastMessageCreatedAt ?? 0,
1181
- cursor?.lastMessageId ?? ""
1182
- );
1183
- const partsStmt = db.prepare(
1184
- `SELECT id, message_id, session_id, time_created, data
1185
- FROM part
1186
- WHERE message_id = ?
1187
- ORDER BY time_created, id`
1188
- );
1189
- for (const message of messages) {
1190
- lastCursor = {
1191
- lastMessageCreatedAt: message.time_created,
1192
- lastMessageId: message.id
1193
- };
1194
- let parsed;
1195
- try {
1196
- parsed = JSON.parse(message.data);
1197
- } catch (e) {
1198
- errors.push(`message ${message.id}: invalid JSON: ${e instanceof Error ? e.message : e}`);
1199
- continue;
1200
- }
1201
- if (parsed.role !== "assistant" || !parsed.tokens) continue;
1202
- const inputTokens = parsed.tokens.input ?? 0;
1203
- const outputTokens = parsed.tokens.output ?? 0;
1204
- const reasoningTokens = parsed.tokens.reasoning ?? 0;
1205
- const cacheReadTokens = parsed.tokens.cache?.read ?? 0;
1206
- const cacheWriteTokens = parsed.tokens.cache?.write ?? 0;
1207
- if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) continue;
1208
- const model = parsed.modelID ?? "unknown";
1209
- const provider = parsed.providerID ?? inferProvider(model);
1210
- const ts = parsed.time?.created ?? message.time_created;
1211
- const recordId = generateRecordId(deviceInstanceId, dbPath + ":" + message.id, message.time_created);
1212
- const tokenArgs = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, thinkingTokens: reasoningTokens };
1213
- const calculatedCost = model !== "unknown" ? calculateCost(model, tokenArgs) : 0;
1214
- const logCostValid = parsed.cost != null && parsed.cost > 0;
1215
- const cost = logCostValid ? parsed.cost : calculatedCost;
1216
- const costSource = logCostValid ? "log" : calculatedCost > 0 ? "pricing" : "unknown";
1217
- const record = {
1218
- id: recordId,
1219
- ts,
1220
- ingestedAt: now,
1221
- updatedAt: now,
1222
- lineOffset: 0,
1223
- // DB-sourced records have no byte offset; use 0 as sentinel
1224
- tool: "opencode",
1225
- model,
1226
- provider,
1227
- inputTokens,
1228
- outputTokens,
1229
- cacheReadTokens,
1230
- cacheWriteTokens,
1231
- thinkingTokens: reasoningTokens,
1232
- cost,
1233
- costSource,
1234
- sessionId: message.session_id,
1235
- sourceFile: dbPath,
1236
- device,
1237
- deviceInstanceId,
1238
- platform: platform4
1239
- };
1240
- records.push(record);
1241
- const parts = partsStmt.all(message.id);
1242
- let callIndex = 0;
1243
- for (const part of parts) {
1244
- let partParsed;
1245
- try {
1246
- partParsed = JSON.parse(part.data);
1247
- } catch (e) {
1248
- errors.push(`message ${message.id} part ${part.id}: invalid JSON: ${e instanceof Error ? e.message : e}`);
1249
- continue;
2064
+ if (url.pathname === "/api/models") {
2065
+ const dr = getDateRangeFilter(range, from, to, "", weekStart);
2066
+ const device = url.searchParams.get("device");
2067
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
2068
+ const tool = url.searchParams.get("tool");
2069
+ const tf = getToolFilter(tool);
2070
+ let total;
2071
+ let rows;
2072
+ if (df.useUnion) {
2073
+ const unionSql = `
2074
+ SELECT model, provider, COUNT(*) AS callCount,
2075
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
2076
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
2077
+ GROUP BY model, provider
2078
+ UNION ALL
2079
+ SELECT model, provider, COUNT(*) AS callCount,
2080
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
2081
+ FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
2082
+ GROUP BY model, provider
2083
+ `;
2084
+ const mergedRows = db.prepare(`
2085
+ SELECT model, provider, SUM(callCount) AS callCount, SUM(totalTokens) AS totalTokens
2086
+ FROM (${unionSql})
2087
+ WHERE model != 'unknown'
2088
+ GROUP BY model, provider ORDER BY callCount DESC
2089
+ `).all({ ...dr.params, ...df.params, ...tf.params });
2090
+ total = mergedRows.reduce((s, r) => s + r.callCount, 0) || 1;
2091
+ rows = mergedRows;
2092
+ } else if (device && device !== options?.currentDeviceInstanceId) {
2093
+ rows = db.prepare(`
2094
+ SELECT model, provider,
2095
+ COUNT(*) AS callCount,
2096
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
2097
+ FROM synced_records WHERE 1=1 AND model != 'unknown' ${df.where} ${dr.where} ${tf.where}
2098
+ GROUP BY model, provider ORDER BY callCount DESC
2099
+ `).all({ ...df.params, ...dr.params, ...tf.params });
2100
+ total = rows.reduce((s, r) => s + r.callCount, 0) || 1;
2101
+ } else {
2102
+ rows = db.prepare(`
2103
+ SELECT model, provider,
2104
+ COUNT(*) AS callCount,
2105
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
2106
+ FROM records WHERE 1=1 AND model != 'unknown' ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
2107
+ GROUP BY model, provider ORDER BY callCount DESC
2108
+ `).all({ ...dr.params, ...tf.params });
2109
+ total = rows.reduce((s, r) => s + r.callCount, 0) || 1;
2110
+ }
2111
+ const models = rows.map((r) => ({
2112
+ model: r.model,
2113
+ provider: r.provider,
2114
+ callCount: r.callCount,
2115
+ totalTokens: r.totalTokens,
2116
+ percentage: Math.round(r.callCount / total * 1e3) / 10
2117
+ }));
2118
+ json(res, { models });
2119
+ return;
1250
2120
  }
1251
- if (partParsed.type === "tool" && partParsed.tool) {
1252
- toolCalls.push({
1253
- id: generateToolCallId(recordId, partParsed.tool, part.time_created, callIndex),
1254
- recordId,
1255
- name: partParsed.tool,
1256
- ts: part.time_created,
1257
- callIndex
2121
+ if (url.pathname === "/api/tool-calls") {
2122
+ const device = url.searchParams.get("device");
2123
+ if (device && device !== options?.currentDeviceInstanceId) {
2124
+ json(res, { toolCalls: [] });
2125
+ return;
2126
+ }
2127
+ const dr = getDateRangeFilter(range, from, to, "r", weekStart);
2128
+ const tool = url.searchParams.get("tool");
2129
+ const tf = getToolFilter(tool, "r");
2130
+ const totalRow = db.prepare(`
2131
+ SELECT COUNT(*) AS total FROM tool_calls tc
2132
+ JOIN records r ON r.id = tc.record_id
2133
+ WHERE 1=1 ${dr.where} ${tf.where}
2134
+ `).get({ ...dr.params, ...tf.params });
2135
+ const total = totalRow.total || 1;
2136
+ const rows = db.prepare(`
2137
+ SELECT tc.name, COUNT(*) AS count
2138
+ FROM tool_calls tc
2139
+ JOIN records r ON r.id = tc.record_id
2140
+ WHERE 1=1 ${dr.where} ${tf.where}
2141
+ GROUP BY tc.name ORDER BY count DESC
2142
+ `).all({ ...dr.params, ...tf.params });
2143
+ const toolCalls = rows.map((r) => ({
2144
+ name: r.name,
2145
+ count: r.count,
2146
+ percentage: Math.round(r.count / total * 1e3) / 10
2147
+ }));
2148
+ json(res, { toolCalls });
2149
+ return;
2150
+ }
2151
+ if (url.pathname === "/api/sessions") {
2152
+ const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
2153
+ const pageSize = Math.min(100, Math.max(1, parseInt(url.searchParams.get("pageSize") || "50", 10)));
2154
+ const dr = getDateRangeFilter(range, from, to, "", weekStart);
2155
+ const device = url.searchParams.get("device");
2156
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
2157
+ const tool = url.searchParams.get("tool");
2158
+ if (device && !df.localOnly && !df.useUnion) {
2159
+ json(res, {
2160
+ sessions: [],
2161
+ total: 0,
2162
+ page,
2163
+ pageSize
2164
+ });
2165
+ return;
2166
+ }
2167
+ const tf = getToolFilter(tool);
2168
+ const params = { ...dr.params, ...tf.params };
2169
+ const totalRow = db.prepare(`
2170
+ SELECT COUNT(DISTINCT session_id) AS total
2171
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
2172
+ `).get(params);
2173
+ const sessions = db.prepare(`
2174
+ SELECT session_id AS sessionId,
2175
+ tool,
2176
+ model,
2177
+ MIN(ts) AS ts,
2178
+ SUM(input_tokens) AS inputTokens,
2179
+ SUM(output_tokens) AS outputTokens,
2180
+ SUM(cache_read_tokens) AS cacheReadTokens,
2181
+ SUM(cache_write_tokens) AS cacheWriteTokens,
2182
+ SUM(cost) AS cost
2183
+ FROM records
2184
+ WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
2185
+ GROUP BY session_id
2186
+ ORDER BY ts DESC
2187
+ LIMIT @limit OFFSET @offset
2188
+ `).all({ ...params, limit: pageSize, offset: (page - 1) * pageSize });
2189
+ json(res, {
2190
+ sessions,
2191
+ total: totalRow.total,
2192
+ page,
2193
+ pageSize
1258
2194
  });
1259
- callIndex++;
2195
+ return;
1260
2196
  }
1261
- }
1262
- }
1263
- return { records, toolCalls, nextCursor: records.length > 0 ? lastCursor : null, errors };
1264
- }
1265
-
1266
- // src/commands/parse-hermes.ts
1267
- function runParseHermes(db, options) {
1268
- const { dbPath, device, deviceInstanceId, platform: platform4, now, cursor } = options;
1269
- const records = [];
1270
- const toolCalls = [];
1271
- const errors = [];
1272
- let lastCursor = null;
1273
- const sessions = db.prepare(
1274
- `SELECT id, model, billing_provider, started_at, ended_at,
1275
- input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
1276
- reasoning_tokens, estimated_cost_usd, actual_cost_usd,
1277
- COALESCE(ended_at, started_at) AS session_ts
1278
- FROM sessions
1279
- WHERE (ended_at IS NOT NULL OR input_tokens > 0 OR output_tokens > 0)
1280
- AND (COALESCE(ended_at, started_at) > ? OR (COALESCE(ended_at, started_at) = ? AND id > ?))
1281
- ORDER BY COALESCE(ended_at, started_at), id`
1282
- ).all(
1283
- cursor?.lastEndedAt ?? 0,
1284
- cursor?.lastEndedAt ?? 0,
1285
- cursor?.lastId ?? ""
1286
- );
1287
- const toolCallsStmt = db.prepare(
1288
- `SELECT id, session_id, tool_calls, timestamp
1289
- FROM messages
1290
- WHERE session_id = ? AND tool_calls IS NOT NULL
1291
- ORDER BY timestamp, id`
1292
- );
1293
- for (const session of sessions) {
1294
- lastCursor = { lastEndedAt: session.session_ts, lastId: session.id };
1295
- const model = session.model ?? "unknown";
1296
- const ts = Math.round(session.started_at * 1e3);
1297
- const rawProvider = session.billing_provider;
1298
- const provider = rawProvider && rawProvider !== "custom" ? rawProvider : inferProvider(model);
1299
- const inputTokens = session.input_tokens;
1300
- const outputTokens = session.output_tokens;
1301
- const cacheReadTokens = session.cache_read_tokens;
1302
- const cacheWriteTokens = session.cache_write_tokens;
1303
- const thinkingTokens = session.reasoning_tokens;
1304
- const tokenArgs = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, thinkingTokens };
1305
- let cost;
1306
- let costSource;
1307
- if (session.actual_cost_usd != null && session.actual_cost_usd > 0) {
1308
- cost = session.actual_cost_usd;
1309
- costSource = "log";
1310
- } else if (session.estimated_cost_usd != null && session.estimated_cost_usd > 0) {
1311
- cost = session.estimated_cost_usd;
1312
- costSource = "log";
1313
- } else if (model !== "unknown") {
1314
- cost = calculateCost(model, tokenArgs);
1315
- costSource = cost > 0 ? "pricing" : "unknown";
1316
- } else {
1317
- cost = 0;
1318
- costSource = "unknown";
1319
- }
1320
- const recordId = generateRecordId(deviceInstanceId, dbPath + ":" + session.id, ts);
1321
- const record = {
1322
- id: recordId,
1323
- ts,
1324
- ingestedAt: now,
1325
- updatedAt: now,
1326
- lineOffset: 0,
1327
- tool: "hermes",
1328
- model,
1329
- provider,
1330
- inputTokens,
1331
- outputTokens,
1332
- cacheReadTokens,
1333
- cacheWriteTokens,
1334
- thinkingTokens,
1335
- cost,
1336
- costSource,
1337
- sessionId: session.id,
1338
- sourceFile: dbPath,
1339
- device,
1340
- deviceInstanceId,
1341
- platform: platform4
1342
- };
1343
- records.push(record);
1344
- const messages = toolCallsStmt.all(session.id);
1345
- let callIndex = 0;
1346
- for (const message of messages) {
1347
- let toolCallList;
1348
- try {
1349
- const parsed = JSON.parse(message.tool_calls);
1350
- if (!Array.isArray(parsed)) {
1351
- errors.push(`session ${session.id} message ${message.id}: tool_calls payload is not an array`);
1352
- continue;
2197
+ if (url.pathname === "/api/projects") {
2198
+ const dr = getDateRangeFilter(range, from, to, "", weekStart);
2199
+ const device = url.searchParams.get("device");
2200
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
2201
+ const tool = url.searchParams.get("tool");
2202
+ const tf = getToolFilter(tool);
2203
+ if (df.where && !df.useUnion) {
2204
+ json(res, { projects: [] });
2205
+ return;
2206
+ }
2207
+ const rows = db.prepare(`
2208
+ SELECT source_file, cwd,
2209
+ COUNT(*) AS sessionCount,
2210
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens,
2211
+ SUM(cost) AS cost
2212
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
2213
+ GROUP BY source_file ORDER BY totalTokens DESC
2214
+ `).all({ ...dr.params, ...tf.params });
2215
+ const projectMap = {};
2216
+ for (const row of rows) {
2217
+ const project = row.cwd ? path2.basename(row.cwd) : extractProject(row.source_file);
2218
+ if (!projectMap[project]) projectMap[project] = { sessions: 0, tokens: 0, cost: 0 };
2219
+ projectMap[project].sessions += row.sessionCount;
2220
+ projectMap[project].tokens += row.totalTokens;
2221
+ projectMap[project].cost += row.cost;
2222
+ }
2223
+ const totalTokens = Object.values(projectMap).reduce((s, p) => s + p.tokens, 0) || 1;
2224
+ const projects = Object.entries(projectMap).map(([name, data]) => ({
2225
+ name,
2226
+ sessions: data.sessions,
2227
+ tokens: data.tokens,
2228
+ cost: data.cost,
2229
+ percentage: Math.round(data.tokens / totalTokens * 1e3) / 10
2230
+ })).sort((a, b) => b.tokens - a.tokens);
2231
+ json(res, { projects });
2232
+ return;
2233
+ }
2234
+ if (url.pathname === "/api/pricing") {
2235
+ if (req.method === "GET") {
2236
+ const table = getPriceTable();
2237
+ const overrides = getUserOverrides();
2238
+ const dbModels = db.prepare("SELECT DISTINCT model FROM records WHERE model != 'unknown' ORDER BY model").all();
2239
+ const models = dbModels.map((r) => {
2240
+ const model = r.model;
2241
+ const exactPrice = table[model];
2242
+ const resolvedPrice = resolvePrice(model);
2243
+ const isOverride = model in overrides;
2244
+ const isDefault = model in DEFAULT_PRICE_TABLE && !isOverride;
2245
+ let matchedBy = null;
2246
+ if (!exactPrice && resolvedPrice) {
2247
+ for (const prefix of Object.keys(table)) {
2248
+ if (model.startsWith(prefix) || model.toLowerCase().startsWith(prefix)) {
2249
+ matchedBy = prefix;
2250
+ break;
2251
+ }
2252
+ }
2253
+ if (!matchedBy) {
2254
+ const stripped = model.replace(/^[^/]+\//, "").toLowerCase();
2255
+ for (const prefix of Object.keys(table)) {
2256
+ if (stripped.startsWith(prefix)) {
2257
+ matchedBy = prefix;
2258
+ break;
2259
+ }
2260
+ }
2261
+ }
2262
+ }
2263
+ return {
2264
+ model,
2265
+ price: resolvedPrice ?? null,
2266
+ currency: resolvedPrice?.currency ?? "USD",
2267
+ isDefault,
2268
+ isOverride,
2269
+ matchedBy
2270
+ };
2271
+ });
2272
+ json(res, { models, overrides });
2273
+ return;
2274
+ }
2275
+ if (req.method === "PUT") {
2276
+ let body = "";
2277
+ for await (const chunk of req) body += chunk;
2278
+ try {
2279
+ const data = JSON.parse(body);
2280
+ if (!data.model || typeof data.input !== "number" || typeof data.output !== "number") {
2281
+ json(res, { error: { code: "INVALID_PARAM", message: "model, input, output required" } }, 400);
2282
+ return;
2283
+ }
2284
+ const entry = {
2285
+ input: data.input,
2286
+ output: data.output,
2287
+ cacheRead: data.cacheRead,
2288
+ cacheWrite: data.cacheWrite
2289
+ };
2290
+ if (data.currency === "CNY") {
2291
+ entry.currency = "CNY";
2292
+ }
2293
+ setPriceOverride(data.model, entry);
2294
+ const cfg2 = loadConfig() ?? {};
2295
+ cfg2.priceOverrides = { ...cfg2.priceOverrides, [data.model]: entry };
2296
+ saveConfig(cfg2);
2297
+ json(res, { ok: true });
2298
+ } catch {
2299
+ json(res, { error: { code: "INVALID_JSON", message: "Invalid JSON body" } }, 400);
2300
+ }
2301
+ return;
2302
+ }
2303
+ if (req.method === "DELETE") {
2304
+ const model = url.searchParams.get("model");
2305
+ if (!model) {
2306
+ json(res, { error: { code: "INVALID_PARAM", message: "model param required" } }, 400);
2307
+ return;
2308
+ }
2309
+ removePriceOverride(model);
2310
+ const cfg2 = loadConfig() ?? {};
2311
+ if (cfg2.priceOverrides) {
2312
+ delete cfg2.priceOverrides[model];
2313
+ saveConfig(cfg2);
2314
+ }
2315
+ json(res, { ok: true });
2316
+ return;
1353
2317
  }
1354
- toolCallList = parsed;
1355
- } catch (e) {
1356
- errors.push(`session ${session.id} message ${message.id}: invalid tool_calls JSON: ${e instanceof Error ? e.message : e}`);
1357
- continue;
1358
- }
1359
- for (const tc of toolCallList) {
1360
- const name = tc.function?.name;
1361
- if (!name) continue;
1362
- toolCalls.push({
1363
- id: generateToolCallId(recordId, name, Math.round(message.timestamp * 1e3), callIndex),
1364
- recordId,
1365
- name,
1366
- ts: Math.round(message.timestamp * 1e3),
1367
- callIndex
1368
- });
1369
- callIndex++;
1370
2318
  }
1371
- }
1372
- }
1373
- return { records, toolCalls, nextCursor: lastCursor, errors };
1374
- }
1375
-
1376
- // src/commands/parse.ts
1377
- function findJsonlFiles(dir) {
1378
- const results = [];
1379
- try {
1380
- const entries = readdirSync(dir, { withFileTypes: true });
1381
- for (const entry of entries) {
1382
- const fullPath = join3(dir, entry.name);
1383
- if (entry.isDirectory()) {
1384
- results.push(...findJsonlFiles(fullPath));
1385
- } else if (entry.isFile() && extname(entry.name) === ".jsonl") {
1386
- results.push(fullPath);
2319
+ if (url.pathname === "/api/pricing/recalc" && req.method === "POST") {
2320
+ const BATCH_SIZE2 = 1e3;
2321
+ let updated = 0;
2322
+ let lastId = "";
2323
+ const exchangeRate = resolveExchangeRate(loadConfig() ?? {});
2324
+ while (true) {
2325
+ const records = db.prepare(
2326
+ "SELECT id, tool, model, provider, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, cost_source FROM records WHERE id > ? ORDER BY id LIMIT ?"
2327
+ ).all(lastId, BATCH_SIZE2);
2328
+ if (records.length === 0) break;
2329
+ const updateStmt = db.prepare("UPDATE records SET model = ?, provider = ?, cost = ?, cost_source = ?, updated_at = ? WHERE id = ?");
2330
+ const tx = db.transaction((batch) => {
2331
+ for (const r of batch) {
2332
+ if (r.cost_source === "log") continue;
2333
+ const rawModel = r.tool === "qoder" ? normalizeQoderModel(r.model) : r.model;
2334
+ const model = rawModel === "unknown" ? r.tool === "qoder" ? "qoder-auto" : r.model : rawModel;
2335
+ const provider = model !== r.model ? inferProvider(model) : r.provider;
2336
+ const hasPrice = resolvePrice(model) != null;
2337
+ const cost = hasPrice ? calculateCost(model, {
2338
+ inputTokens: r.input_tokens,
2339
+ outputTokens: r.output_tokens,
2340
+ cacheReadTokens: r.cache_read_tokens,
2341
+ cacheWriteTokens: r.cache_write_tokens,
2342
+ thinkingTokens: r.thinking_tokens
2343
+ }, exchangeRate) : 0;
2344
+ const costSource = hasPrice ? "pricing" : "unknown";
2345
+ if (model === r.model && provider === r.provider && cost === r.cost && costSource === r.cost_source) continue;
2346
+ updateStmt.run(model, provider, cost, costSource, Date.now(), r.id);
2347
+ updated++;
2348
+ }
2349
+ });
2350
+ tx(records);
2351
+ lastId = records[records.length - 1].id;
2352
+ }
2353
+ json(res, { updated });
2354
+ return;
1387
2355
  }
1388
- }
1389
- } catch {
1390
- }
1391
- return results;
1392
- }
1393
- function defaultOpenCodeDbPath() {
1394
- const home = homedir2();
1395
- const plat = platform2();
1396
- if (plat === "win32") {
1397
- const appData = process.env.APPDATA ?? join3(home, "AppData", "Roaming");
1398
- return join3(appData, "opencode", "opencode.db");
1399
- }
1400
- if (plat === "darwin") {
1401
- return join3(home, "Library", "Application Support", "opencode", "opencode.db");
1402
- }
1403
- const xdgDataHome = process.env.XDG_DATA_HOME ?? join3(home, ".local", "share");
1404
- return join3(xdgDataHome, "opencode", "opencode.db");
1405
- }
1406
- function defaultHermesDbPath() {
1407
- return join3(homedir2(), ".hermes", "state.db");
1408
- }
1409
- function discoverLogFiles(sources) {
1410
- const home = homedir2();
1411
- const results = [];
1412
- const claudeDir = sources?.["claude-code"] ?? join3(home, ".claude", "projects");
1413
- if (existsSync4(claudeDir)) {
1414
- const claudePaths = findJsonlFiles(claudeDir);
1415
- if (claudePaths.length > 0) {
1416
- results.push({ tool: "claude-code", paths: claudePaths });
1417
- }
1418
- }
1419
- const codexDir = sources?.["codex"] ?? join3(home, ".codex", "sessions");
1420
- if (existsSync4(codexDir)) {
1421
- const codexPaths = findJsonlFiles(codexDir);
1422
- if (codexPaths.length > 0) {
1423
- results.push({ tool: "codex", paths: codexPaths });
1424
- }
1425
- }
1426
- const openclawBase = sources?.["openclaw"] ?? join3(home, ".openclaw", "agents");
1427
- if (existsSync4(openclawBase)) {
1428
- let openclawPaths;
1429
- if (sources?.["openclaw"]) {
1430
- openclawPaths = findJsonlFiles(openclawBase).filter((p) => !p.includes(".checkpoint."));
1431
- } else {
1432
- openclawPaths = [];
1433
- try {
1434
- const agentEntries = readdirSync(openclawBase, { withFileTypes: true });
1435
- for (const agentEntry of agentEntries) {
1436
- if (!agentEntry.isDirectory()) continue;
1437
- const sessionsDir = join3(openclawBase, agentEntry.name, "sessions");
1438
- if (existsSync4(sessionsDir)) {
1439
- openclawPaths.push(...findJsonlFiles(sessionsDir).filter((p) => !p.includes(".checkpoint.")));
2356
+ if (url.pathname === "/api/sync") {
2357
+ if (req.method === "POST") {
2358
+ if (!options?.onSyncStart) {
2359
+ json(res, { error: { code: "NOT_AVAILABLE", message: "Sync not configured" } }, 501);
2360
+ return;
1440
2361
  }
2362
+ const result = options.onSyncStart();
2363
+ json(res, result, result.accepted ? 202 : 200);
2364
+ return;
1441
2365
  }
1442
- } catch {
2366
+ const status = options?.getSyncStatus?.() ?? null;
2367
+ json(res, { status });
2368
+ return;
1443
2369
  }
1444
- }
1445
- if (openclawPaths.length > 0) {
1446
- results.push({ tool: "openclaw", paths: openclawPaths });
1447
- }
1448
- }
1449
- return results;
1450
- }
1451
- function extractSessionId(filePath, tool) {
1452
- if (tool === "claude-code") {
1453
- const parts = filePath.split("/");
1454
- const filename = parts[parts.length - 1];
1455
- return filename.replace(".jsonl", "");
1456
- }
1457
- if (tool === "codex") {
1458
- const filename = filePath.split("/").pop() ?? "";
1459
- const match = filename.match(/rollout-(.+)\.jsonl$/);
1460
- return match ? match[1] : filename.replace(".jsonl", "");
1461
- }
1462
- if (tool === "openclaw") {
1463
- const filename = filePath.split("/").pop() ?? "";
1464
- return filename.replace(".jsonl", "");
1465
- }
1466
- return "unknown";
1467
- }
1468
- async function runParse(db, filterTool, options) {
1469
- const state = getState(AIUSAGE_DIR);
1470
- const config = loadConfig();
1471
- const device = config?.device || hostname2() || state?.deviceInstanceId?.slice(0, 8) || "unknown";
1472
- const deviceInstanceId = state?.deviceInstanceId ?? "unknown";
1473
- const devicePlatform = config?.platform;
1474
- const watermarkPath = join3(AIUSAGE_DIR, "watermark.json");
1475
- const wm = new WatermarkManager(watermarkPath);
1476
- const toolPaths = discoverLogFiles(config?.sources);
1477
- const aggregator = new Aggregator();
1478
- let parsedCount = 0;
1479
- let toolCallCount = 0;
1480
- const errors = [];
1481
- for (const { tool, paths } of toolPaths) {
1482
- if (filterTool && tool !== filterTool) continue;
1483
- for (const filePath of paths) {
1484
- try {
1485
- const stat2 = statSync(filePath);
1486
- const entry = wm.getEntry(tool, filePath);
1487
- const offset = entry?.offset ?? 0;
1488
- if (offset >= stat2.size) continue;
1489
- const content = readFileSync4(filePath, "utf-8");
1490
- const lines = content.split("\n");
1491
- let byteOffset = 0;
1492
- const sessionId = extractSessionId(filePath, tool);
1493
- for (const line of lines) {
1494
- if (!line.trim()) {
1495
- byteOffset += Buffer.byteLength(line, "utf-8") + 1;
1496
- continue;
1497
- }
1498
- if (byteOffset < offset) {
1499
- byteOffset += Buffer.byteLength(line, "utf-8") + 1;
1500
- continue;
2370
+ if (url.pathname === "/api/refresh") {
2371
+ if (!options?.onRefresh) {
2372
+ json(res, { error: { code: "NOT_AVAILABLE", message: "Refresh not available" } }, 501);
2373
+ return;
2374
+ }
2375
+ const result = await options.onRefresh();
2376
+ json(res, result);
2377
+ return;
2378
+ }
2379
+ if (url.pathname === "/api/devices") {
2380
+ let getDisplayName2 = function(device, deviceInstanceId) {
2381
+ if (deviceInstanceId === currentId) return currentDeviceAlias;
2382
+ if (device && device !== "unknown" && !/^[0-9a-f]{8}$/.test(device)) return device;
2383
+ if (/^[0-9a-f]{8}-/.test(deviceInstanceId)) return deviceInstanceId.slice(0, 8);
2384
+ if (device && device !== "unknown") return device;
2385
+ return "Unknown Device";
2386
+ }, getPlatformLabel2 = function(p) {
2387
+ if (p === "win32") return "Windows";
2388
+ if (p === "darwin") return "macOS";
2389
+ if (p === "linux") return "Linux";
2390
+ return "";
2391
+ }, inferPlatform2 = function(device, deviceInstanceId) {
2392
+ const name = (device || "").toLowerCase();
2393
+ const id = (deviceInstanceId || "").toLowerCase();
2394
+ if (/^(desktop|laptop)-/.test(name)) return "Windows";
2395
+ if (name.endsWith(".local") || /macbook|imac|mac\s*mini|mac\s*pro|mac\s*studio/.test(name)) return "macOS";
2396
+ if (/^(ubuntu|debian|centos|fedora|arch|linux|server|node|prod|dev|staging)/.test(name)) return "Linux";
2397
+ return "";
2398
+ };
2399
+ var getDisplayName = getDisplayName2, getPlatformLabel = getPlatformLabel2, inferPlatform = inferPlatform2;
2400
+ const currentId = options?.currentDeviceInstanceId;
2401
+ if (!currentId) {
2402
+ json(res, { currentDeviceInstanceId: null, devices: [] });
2403
+ return;
2404
+ }
2405
+ const config = loadConfig();
2406
+ const currentDeviceAlias = config?.device || hostname2();
2407
+ const localRows = db.prepare(`
2408
+ SELECT device, device_instance_id AS deviceInstanceId, COUNT(*) AS recordCount
2409
+ FROM records
2410
+ WHERE device_instance_id = @currentId AND source_file NOT LIKE 'synced/%'
2411
+ GROUP BY device_instance_id
2412
+ `).all({ currentId });
2413
+ const syncedRows = db.prepare(`
2414
+ SELECT device, device_instance_id AS deviceInstanceId, platform, COUNT(*) AS recordCount
2415
+ FROM synced_records
2416
+ WHERE device_instance_id != @currentId
2417
+ GROUP BY device_instance_id
2418
+ `).all({ currentId });
2419
+ const currentPlatform = config?.platform || platform2();
2420
+ const deviceMap = /* @__PURE__ */ new Map();
2421
+ for (const row of localRows) {
2422
+ const displayName = getDisplayName2(row.device, row.deviceInstanceId);
2423
+ deviceMap.set(row.deviceInstanceId, { device: row.device, deviceInstanceId: row.deviceInstanceId, displayName, platform: getPlatformLabel2(currentPlatform), recordCount: row.recordCount });
2424
+ }
2425
+ for (const row of syncedRows) {
2426
+ const displayName = getDisplayName2(row.device, row.deviceInstanceId);
2427
+ const platformLabel = getPlatformLabel2(row.platform) || inferPlatform2(row.device, row.deviceInstanceId);
2428
+ const existing = deviceMap.get(row.deviceInstanceId);
2429
+ if (existing) {
2430
+ existing.recordCount += row.recordCount;
2431
+ } else {
2432
+ deviceMap.set(row.deviceInstanceId, { device: row.device, deviceInstanceId: row.deviceInstanceId, displayName, platform: platformLabel, recordCount: row.recordCount });
1501
2433
  }
1502
- const context = aggregator.createContext({
1503
- tool,
1504
- sourceFile: filePath,
1505
- lineOffset: byteOffset,
1506
- sessionId,
1507
- device,
1508
- deviceInstanceId,
1509
- platform: devicePlatform
2434
+ }
2435
+ const devices = [...deviceMap.values()].sort((a, b) => b.recordCount - a.recordCount);
2436
+ json(res, { currentDeviceInstanceId: currentId, devices });
2437
+ return;
2438
+ }
2439
+ if (url.pathname === "/api/config/credential" && req.method === "GET") {
2440
+ const ref = url.searchParams.get("ref")?.trim();
2441
+ if (!ref) {
2442
+ json(res, { error: { code: "MISSING_CREDENTIAL_REF", message: "credential ref is required" } }, 400);
2443
+ return;
2444
+ }
2445
+ const value = loadCredential(ref);
2446
+ if (!value) {
2447
+ json(res, { error: { code: "CREDENTIAL_NOT_FOUND", message: "Credential not found" } }, 404);
2448
+ return;
2449
+ }
2450
+ json(res, { value });
2451
+ return;
2452
+ }
2453
+ if (url.pathname === "/api/config") {
2454
+ if (req.method === "GET") {
2455
+ const currentCfg = loadConfig() ?? {};
2456
+ const osPlatform = platform2();
2457
+ const { credentials, priceOverrides, platform: _cfgPlatform, ...rest } = currentCfg;
2458
+ json(res, {
2459
+ device: rest.device ?? null,
2460
+ weekStart: rest.weekStart ?? 1,
2461
+ dashboardPollInterval: rest.dashboardPollInterval ?? null,
2462
+ parseInterval: rest.parseInterval ?? null,
2463
+ retentionDays: rest.retentionDays ?? null,
2464
+ sources: {
2465
+ "claude-code": rest.sources?.["claude-code"] ?? null,
2466
+ codex: rest.sources?.codex ?? null,
2467
+ openclaw: rest.sources?.openclaw ?? null,
2468
+ opencode: rest.sources?.opencode ?? null,
2469
+ hermes: rest.sources?.hermes ?? null,
2470
+ qoder: rest.sources?.qoder ?? null,
2471
+ "qoder-db": rest.sources?.["qoder-db"] ?? null
2472
+ },
2473
+ sync: rest.sync ?? null,
2474
+ displayCurrency: rest.displayCurrency ?? "USD",
2475
+ exchangeRate: rest.exchangeRate ?? null,
2476
+ exchangeRateCache: rest.exchangeRateCache ?? null,
2477
+ credentialKeys: credentials ? Object.keys(credentials) : [],
2478
+ hostname: hostname2(),
2479
+ platform: osPlatform,
2480
+ defaultPaths: getDefaultSourcePaths()
1510
2481
  });
1511
- const result = aggregator.parseLine(line, context);
1512
- if (result) {
1513
- if (result.record) {
1514
- insertRecord(db, result.record);
1515
- parsedCount++;
2482
+ return;
2483
+ }
2484
+ if (req.method === "PUT") {
2485
+ let body = "";
2486
+ for await (const chunk of req) body += chunk;
2487
+ try {
2488
+ const update = JSON.parse(body);
2489
+ const existing = loadConfig() ?? {};
2490
+ if ("device" in update) {
2491
+ if (!update.device) delete existing.device;
2492
+ else existing.device = String(update.device);
1516
2493
  }
1517
- for (const tc of result.toolCalls) {
1518
- insertToolCall(db, tc);
1519
- toolCallCount++;
2494
+ if ("weekStart" in update) {
2495
+ const ws = Number(update.weekStart);
2496
+ if (ws === 0 || ws === 1) {
2497
+ existing.weekStart = ws;
2498
+ weekStart = ws;
2499
+ } else {
2500
+ delete existing.weekStart;
2501
+ }
1520
2502
  }
2503
+ if ("displayCurrency" in update) {
2504
+ const dc = update.displayCurrency;
2505
+ if (dc === "USD" || dc === "CNY") {
2506
+ existing.displayCurrency = dc;
2507
+ } else {
2508
+ delete existing.displayCurrency;
2509
+ }
2510
+ }
2511
+ if ("exchangeRate" in update) {
2512
+ const er = update.exchangeRate;
2513
+ if (er != null && typeof er === "number" && er > 0) {
2514
+ existing.exchangeRate = er;
2515
+ } else {
2516
+ delete existing.exchangeRate;
2517
+ }
2518
+ }
2519
+ if ("dashboardPollInterval" in update) {
2520
+ if (!update.dashboardPollInterval) delete existing.dashboardPollInterval;
2521
+ else existing.dashboardPollInterval = Number(update.dashboardPollInterval);
2522
+ }
2523
+ if ("parseInterval" in update) {
2524
+ if (!update.parseInterval) delete existing.parseInterval;
2525
+ else existing.parseInterval = Number(update.parseInterval);
2526
+ }
2527
+ if ("retentionDays" in update) {
2528
+ if (!update.retentionDays) delete existing.retentionDays;
2529
+ else existing.retentionDays = Number(update.retentionDays);
2530
+ }
2531
+ if (update.sources && typeof update.sources === "object") {
2532
+ const src = update.sources;
2533
+ const s = existing.sources ?? {};
2534
+ for (const key of ["claude-code", "codex", "openclaw", "opencode", "hermes", "qoder", "qoder-db"]) {
2535
+ if (key in src) {
2536
+ if (!src[key]) delete s[key];
2537
+ else s[key] = String(src[key]);
2538
+ }
2539
+ }
2540
+ if (Object.keys(s).length) existing.sources = s;
2541
+ else delete existing.sources;
2542
+ }
2543
+ if ("sync" in update) {
2544
+ const syncUpdate = update.sync;
2545
+ if (!syncUpdate?.backend) {
2546
+ delete existing.sync;
2547
+ } else {
2548
+ const backendVal = String(syncUpdate.backend);
2549
+ if (backendVal !== "github" && backendVal !== "s3") {
2550
+ json(res, { error: { code: "INVALID_BACKEND", message: "sync.backend must be github or s3" } }, 400);
2551
+ return;
2552
+ }
2553
+ const newSync = { backend: backendVal };
2554
+ for (const f of ["repo", "bucket", "prefix", "endpoint", "region", "credentialRef"]) {
2555
+ if (syncUpdate[f]) newSync[f] = String(syncUpdate[f]);
2556
+ }
2557
+ existing.sync = newSync;
2558
+ }
2559
+ }
2560
+ if (update.credentials && typeof update.credentials === "object") {
2561
+ const creds = update.credentials;
2562
+ const c = existing.credentials ?? {};
2563
+ for (const [key, val] of Object.entries(creds)) {
2564
+ if (val !== "" && val !== null && val !== void 0) {
2565
+ c[key] = String(val);
2566
+ }
2567
+ }
2568
+ if (Object.keys(c).length) existing.credentials = c;
2569
+ else delete existing.credentials;
2570
+ }
2571
+ saveConfig(existing);
2572
+ options?.onConfigUpdated?.();
2573
+ json(res, { ok: true });
2574
+ } catch {
2575
+ json(res, { error: { code: "INVALID_JSON", message: "Invalid JSON body" } }, 400);
1521
2576
  }
1522
- byteOffset += Buffer.byteLength(line, "utf-8") + 1;
1523
- }
1524
- const orphanResults = aggregator.finalize();
1525
- for (const result of orphanResults) {
1526
- for (const tc of result.toolCalls) {
1527
- insertToolCall(db, tc);
1528
- toolCallCount++;
1529
- }
1530
- }
1531
- wm.setEntry(tool, filePath, {
1532
- offset: stat2.size,
1533
- size: stat2.size,
1534
- mtime: stat2.mtimeMs
1535
- });
1536
- wm.save();
1537
- } catch (e) {
1538
- errors.push(`${filePath}: ${e instanceof Error ? e.message : e}`);
1539
- }
1540
- }
1541
- }
1542
- const openCodeDbPath = options?.openCodeDbPath ?? config?.sources?.["opencode"] ?? defaultOpenCodeDbPath();
1543
- if ((!filterTool || filterTool === "opencode") && existsSync4(openCodeDbPath)) {
1544
- try {
1545
- const openCodeDb = new Database(openCodeDbPath, { readonly: true });
1546
- try {
1547
- const result = runParseOpenCode(openCodeDb, {
1548
- dbPath: openCodeDbPath,
1549
- device,
1550
- deviceInstanceId,
1551
- platform: devicePlatform,
1552
- now: Date.now(),
1553
- cursor: wm.getOpenCodeCursor()
1554
- });
1555
- for (const record of result.records) insertRecord(db, record);
1556
- for (const tc of result.toolCalls) insertToolCall(db, tc);
1557
- if (result.nextCursor) {
1558
- wm.setOpenCodeCursor(result.nextCursor);
1559
- wm.save();
2577
+ return;
1560
2578
  }
1561
- parsedCount += result.records.length;
1562
- toolCallCount += result.toolCalls.length;
1563
- errors.push(...result.errors);
1564
- } finally {
1565
- openCodeDb.close();
1566
2579
  }
1567
- } catch (e) {
1568
- errors.push(`${openCodeDbPath}: ${e instanceof Error ? e.message : e}`);
1569
- }
1570
- }
1571
- const hermesDbPath = options?.hermesDbPath ?? config?.sources?.["hermes"] ?? defaultHermesDbPath();
1572
- if ((!filterTool || filterTool === "hermes") && existsSync4(hermesDbPath)) {
1573
- try {
1574
- const hermesDb = new Database(hermesDbPath, { readonly: true });
1575
- try {
1576
- const result = runParseHermes(hermesDb, {
1577
- dbPath: hermesDbPath,
1578
- device,
1579
- deviceInstanceId,
1580
- platform: devicePlatform,
1581
- now: Date.now(),
1582
- cursor: wm.getHermesCursor()
1583
- });
1584
- for (const record of result.records) insertRecord(db, record);
1585
- for (const tc of result.toolCalls) insertToolCall(db, tc);
1586
- if (result.nextCursor) {
1587
- wm.setHermesCursor(result.nextCursor);
1588
- wm.save();
2580
+ if (url.pathname === "/api/exchange-rate/refresh" && req.method === "POST") {
2581
+ const rate = await fetchExchangeRate();
2582
+ if (rate == null) {
2583
+ json(res, { error: { code: "FETCH_FAILED", message: "Failed to fetch exchange rate" } }, 502);
2584
+ return;
1589
2585
  }
1590
- parsedCount += result.records.length;
1591
- toolCallCount += result.toolCalls.length;
1592
- errors.push(...result.errors);
1593
- } finally {
1594
- hermesDb.close();
2586
+ const cfg2 = loadConfig() ?? {};
2587
+ cfg2.exchangeRateCache = { CNY_USD: rate, fetchedAt: Date.now() };
2588
+ saveConfig(cfg2);
2589
+ json(res, { rate, fetchedAt: cfg2.exchangeRateCache.fetchedAt });
2590
+ return;
1595
2591
  }
1596
- } catch (e) {
1597
- errors.push(`${hermesDbPath}: ${e instanceof Error ? e.message : e}`);
2592
+ json(res, { error: { code: "NOT_FOUND", message: "Endpoint not found" } }, 404);
2593
+ } catch (error) {
2594
+ console.error("API error:", error);
2595
+ json(res, { error: { code: "INTERNAL", message: "Internal server error" } }, 500);
1598
2596
  }
1599
- }
1600
- if (deviceInstanceId !== "unknown") {
1601
- db.prepare(
1602
- `UPDATE records SET device_instance_id = ?, device = ? WHERE device_instance_id = 'unknown'`
1603
- ).run(deviceInstanceId, device);
1604
- }
1605
- if (devicePlatform) {
1606
- db.prepare(
1607
- `UPDATE records SET platform = ? WHERE platform = '' AND source_file NOT LIKE 'synced/%'`
1608
- ).run(devicePlatform);
1609
- }
1610
- return { parsedCount, toolCallCount, errors };
2597
+ });
2598
+ return server;
1611
2599
  }
1612
2600
 
1613
2601
  // src/commands/sync.ts
@@ -1815,15 +2803,15 @@ var SyncOrchestrator = class {
1815
2803
  });
1816
2804
  if (paths.length === 0) return 0;
1817
2805
  let totalPulled = 0;
1818
- for (const [index, path2] of paths.entries()) {
2806
+ for (const [index, path3] of paths.entries()) {
1819
2807
  this.options.onProgress?.({
1820
2808
  phase: "pulling",
1821
- currentPath: path2,
2809
+ currentPath: path3,
1822
2810
  completedFiles: index,
1823
2811
  totalFiles: paths.length,
1824
2812
  pulledCount: totalPulled
1825
2813
  });
1826
- const content = await this.backend.readFile(path2);
2814
+ const content = await this.backend.readFile(path3);
1827
2815
  if (!content) continue;
1828
2816
  for (const line of content.split("\n").filter(Boolean)) {
1829
2817
  try {
@@ -1841,7 +2829,7 @@ var SyncOrchestrator = class {
1841
2829
  }
1842
2830
  this.options.onProgress?.({
1843
2831
  phase: "pulling",
1844
- currentPath: path2,
2832
+ currentPath: path3,
1845
2833
  completedFiles: index + 1,
1846
2834
  totalFiles: paths.length,
1847
2835
  pulledCount: totalPulled
@@ -1849,13 +2837,13 @@ var SyncOrchestrator = class {
1849
2837
  }
1850
2838
  return totalPulled;
1851
2839
  }
1852
- async uploadFile(path2, localSyncRecords) {
1853
- const existingContent = await this.backend.readFile(path2);
2840
+ async uploadFile(path3, localSyncRecords) {
2841
+ const existingContent = await this.backend.readFile(path3);
1854
2842
  const existing = existingContent ? parseNdjson(existingContent) : /* @__PURE__ */ new Map();
1855
2843
  const changedCount = mergeRecords(existing, localSyncRecords);
1856
2844
  if (changedCount === 0) return 0;
1857
2845
  const content = Array.from(existing.values()).map((r) => JSON.stringify(r)).join("\n") + "\n";
1858
- await this.backend.writeFile(path2, content);
2846
+ await this.backend.writeFile(path3, content);
1859
2847
  return changedCount;
1860
2848
  }
1861
2849
  async upload() {
@@ -1863,9 +2851,9 @@ var SyncOrchestrator = class {
1863
2851
  if (unsynced.length === 0) return 0;
1864
2852
  const byPath = /* @__PURE__ */ new Map();
1865
2853
  for (const record of unsynced) {
1866
- const path2 = getSyncPath(record.ts, this.options.deviceInstanceId);
1867
- if (!byPath.has(path2)) byPath.set(path2, []);
1868
- byPath.get(path2).push(record);
2854
+ const path3 = getSyncPath(record.ts, this.options.deviceInstanceId);
2855
+ if (!byPath.has(path3)) byPath.set(path3, []);
2856
+ byPath.get(path3).push(record);
1869
2857
  }
1870
2858
  let totalUploaded = 0;
1871
2859
  const uploads = Array.from(byPath.entries());
@@ -1875,20 +2863,20 @@ var SyncOrchestrator = class {
1875
2863
  totalFiles: uploads.length,
1876
2864
  uploadedCount: 0
1877
2865
  });
1878
- for (const [index, [path2, localRecords]] of uploads.entries()) {
2866
+ for (const [index, [path3, localRecords]] of uploads.entries()) {
1879
2867
  this.options.onProgress?.({
1880
2868
  phase: "uploading",
1881
- currentPath: path2,
2869
+ currentPath: path3,
1882
2870
  completedFiles: index,
1883
2871
  totalFiles: uploads.length,
1884
2872
  uploadedCount: totalUploaded
1885
2873
  });
1886
2874
  const localSyncRecords = localRecords.map(mapStatsRecordToSyncRecord);
1887
- const changedCount = await this.uploadFile(path2, localSyncRecords);
2875
+ const changedCount = await this.uploadFile(path3, localSyncRecords);
1888
2876
  totalUploaded += changedCount;
1889
2877
  this.options.onProgress?.({
1890
2878
  phase: "uploading",
1891
- currentPath: path2,
2879
+ currentPath: path3,
1892
2880
  completedFiles: index + 1,
1893
2881
  totalFiles: uploads.length,
1894
2882
  uploadedCount: totalUploaded
@@ -1905,7 +2893,7 @@ var SyncOrchestrator = class {
1905
2893
  };
1906
2894
 
1907
2895
  // src/sync/consent.ts
1908
- import { createHash } from "crypto";
2896
+ import { createHash as createHash2 } from "crypto";
1909
2897
  function generateConsentFingerprint(config) {
1910
2898
  const input = [
1911
2899
  config.backend,
@@ -1916,7 +2904,7 @@ function generateConsentFingerprint(config) {
1916
2904
  config.operations.join(","),
1917
2905
  config.schemaVersion
1918
2906
  ].join("|");
1919
- return createHash("sha256").update(input).digest("hex").slice(0, 16);
2907
+ return createHash2("sha256").update(input).digest("hex").slice(0, 16);
1920
2908
  }
1921
2909
  function verifyConsent(storedFingerprint, currentConfig) {
1922
2910
  const currentFingerprint = generateConsentFingerprint(currentConfig);
@@ -1986,16 +2974,16 @@ var GitSyncBackend = class _GitSyncBackend {
1986
2974
  }
1987
2975
  }
1988
2976
  }
1989
- async readFile(path2) {
2977
+ async readFile(path3) {
1990
2978
  try {
1991
- const fullPath = join4(this.dataDir, path2);
2979
+ const fullPath = join4(this.dataDir, path3);
1992
2980
  return await readFile(fullPath, "utf-8");
1993
2981
  } catch {
1994
2982
  return null;
1995
2983
  }
1996
2984
  }
1997
- async writeFile(path2, content) {
1998
- const fullPath = join4(this.dataDir, path2);
2985
+ async writeFile(path3, content) {
2986
+ const fullPath = join4(this.dataDir, path3);
1999
2987
  await mkdir(join4(fullPath, ".."), { recursive: true });
2000
2988
  await writeFile(fullPath, content, "utf-8");
2001
2989
  }
@@ -2057,11 +3045,11 @@ var S3SyncBackend = class {
2057
3045
  }
2058
3046
  });
2059
3047
  }
2060
- getObjectKey(path2) {
2061
- return `${this.prefix}${path2}`;
3048
+ getObjectKey(path3) {
3049
+ return `${this.prefix}${path3}`;
2062
3050
  }
2063
- async readFile(path2) {
2064
- const key = this.getObjectKey(path2);
3051
+ async readFile(path3) {
3052
+ const key = this.getObjectKey(path3);
2065
3053
  try {
2066
3054
  const command = new GetObjectCommand({
2067
3055
  Bucket: this.bucket,
@@ -2077,8 +3065,8 @@ var S3SyncBackend = class {
2077
3065
  throw error;
2078
3066
  }
2079
3067
  }
2080
- async writeFile(path2, content) {
2081
- const key = this.getObjectKey(path2);
3068
+ async writeFile(path3, content) {
3069
+ const key = this.getObjectKey(path3);
2082
3070
  const command = new PutObjectCommand({
2083
3071
  Bucket: this.bucket,
2084
3072
  Key: key,
@@ -2110,8 +3098,8 @@ var S3SyncBackend = class {
2110
3098
  } while (continuationToken);
2111
3099
  return files.sort();
2112
3100
  }
2113
- async fileExists(path2) {
2114
- const key = this.getObjectKey(path2);
3101
+ async fileExists(path3) {
3102
+ const key = this.getObjectKey(path3);
2115
3103
  try {
2116
3104
  const command = new HeadObjectCommand({
2117
3105
  Bucket: this.bucket,
@@ -2379,6 +3367,19 @@ function serve(options) {
2379
3367
  setPriceOverride(model, entry);
2380
3368
  }
2381
3369
  }
3370
+ if (config == null || config.exchangeRate == null) {
3371
+ const cacheAge = config?.exchangeRateCache ? Date.now() - config.exchangeRateCache.fetchedAt : Infinity;
3372
+ if (cacheAge >= CACHE_TTL_MS) {
3373
+ fetchExchangeRate().then((rate) => {
3374
+ if (rate != null) {
3375
+ const cfg = loadConfig() ?? {};
3376
+ cfg.exchangeRateCache = { CNY_USD: rate, fetchedAt: Date.now() };
3377
+ saveConfig(cfg);
3378
+ }
3379
+ }).catch(() => {
3380
+ });
3381
+ }
3382
+ }
2382
3383
  const syncRuntime = new SyncRuntimeController({
2383
3384
  runSync: (runtimeOptions) => runSync(options.db, runtimeOptions).then(() => void 0),
2384
3385
  getPersistedState: () => getState(AIUSAGE_DIR)
@@ -2478,9 +3479,9 @@ function runInit(options) {
2478
3479
  const config = {
2479
3480
  device: options.device ?? existingConfig?.device,
2480
3481
  platform: existingConfig?.platform ?? platform3(),
2481
- retentionDays: existingConfig?.retentionDays ?? 180,
2482
- parseInterval: existingConfig?.parseInterval ?? 60,
2483
- dashboardPollInterval: existingConfig?.dashboardPollInterval ?? 30,
3482
+ retentionDays: existingConfig?.retentionDays,
3483
+ parseInterval: existingConfig?.parseInterval,
3484
+ dashboardPollInterval: existingConfig?.dashboardPollInterval,
2484
3485
  credentials: existingConfig?.credentials
2485
3486
  };
2486
3487
  saveConfig(config);
@@ -2501,9 +3502,9 @@ function runInit(options) {
2501
3502
  },
2502
3503
  device: options.device ?? existingConfig?.device,
2503
3504
  platform: existingConfig?.platform ?? platform3(),
2504
- retentionDays: existingConfig?.retentionDays ?? 180,
2505
- parseInterval: existingConfig?.parseInterval ?? 60,
2506
- dashboardPollInterval: existingConfig?.dashboardPollInterval ?? 30,
3505
+ retentionDays: existingConfig?.retentionDays,
3506
+ parseInterval: existingConfig?.parseInterval,
3507
+ dashboardPollInterval: existingConfig?.dashboardPollInterval,
2507
3508
  credentials: {
2508
3509
  ...existingConfig?.credentials,
2509
3510
  [`github/${options.repo}/token`]: options.token
@@ -2547,9 +3548,9 @@ Consent recorded for schema v1.`
2547
3548
  },
2548
3549
  device: options.device ?? existingConfig?.device,
2549
3550
  platform: existingConfig?.platform ?? platform3(),
2550
- retentionDays: existingConfig?.retentionDays ?? 180,
2551
- parseInterval: existingConfig?.parseInterval ?? 60,
2552
- dashboardPollInterval: existingConfig?.dashboardPollInterval ?? 30,
3551
+ retentionDays: existingConfig?.retentionDays,
3552
+ parseInterval: existingConfig?.parseInterval,
3553
+ dashboardPollInterval: existingConfig?.dashboardPollInterval,
2553
3554
  credentials: {
2554
3555
  ...existingConfig?.credentials,
2555
3556
  [`s3/${options.bucket}/accessKeyId`]: options.accessKeyId,
@@ -2672,6 +3673,7 @@ function generateSummary(db, options) {
2672
3673
 
2673
3674
  // src/commands/status.ts
2674
3675
  import { statSync as statSync3, existsSync as existsSync6 } from "fs";
3676
+ import { hostname as hostname3 } from "os";
2675
3677
  import { join as join7 } from "path";
2676
3678
  function formatFileSize(bytes) {
2677
3679
  if (bytes < 1024) return `${bytes} B`;
@@ -2699,9 +3701,11 @@ function generateStatus(db) {
2699
3701
  } catch {
2700
3702
  }
2701
3703
  }
3704
+ const config = loadConfig();
3705
+ const deviceName = config?.device || hostname3() || state?.deviceInstanceId?.slice(0, 8) || "unknown";
2702
3706
  return {
2703
3707
  version: "0.0.1",
2704
- deviceName: state?.deviceInstanceId?.slice(0, 8) ?? "unknown",
3708
+ deviceName,
2705
3709
  dbPath,
2706
3710
  databaseSize,
2707
3711
  schemaVersion,
@@ -2764,32 +3768,58 @@ function exportData(db, format) {
2764
3768
  return lines.join("\n");
2765
3769
  }
2766
3770
 
3771
+ // src/commands/reset.ts
3772
+ import { unlinkSync, existsSync as existsSync7 } from "fs";
3773
+ import { join as join8 } from "path";
3774
+ function runReset(db) {
3775
+ const recordsResult = db.prepare("DELETE FROM records").run();
3776
+ const toolCallsResult = db.prepare("DELETE FROM tool_calls").run();
3777
+ const syncedResult = db.prepare("DELETE FROM synced_records").run();
3778
+ const watermarkPath = join8(AIUSAGE_DIR, "watermark.json");
3779
+ let watermarkRemoved = false;
3780
+ if (existsSync7(watermarkPath)) {
3781
+ unlinkSync(watermarkPath);
3782
+ watermarkRemoved = true;
3783
+ }
3784
+ return {
3785
+ deletedRecords: recordsResult.changes,
3786
+ deletedToolCalls: toolCallsResult.changes,
3787
+ deletedSyncedRecords: syncedResult.changes,
3788
+ watermarkRemoved
3789
+ };
3790
+ }
3791
+
2767
3792
  // src/commands/recalc.ts
2768
3793
  var BATCH_SIZE = 1e3;
2769
3794
  function recalcPricing(db) {
2770
3795
  let updatedCount = 0;
2771
3796
  let skippedCount = 0;
2772
3797
  let lastId = "";
2773
- const updateStmt = db.prepare("UPDATE records SET cost = ?, updated_at = ? WHERE id = ?");
3798
+ const exchangeRate = resolveExchangeRate(loadConfig() ?? {});
3799
+ const updateStmt = db.prepare("UPDATE records SET model = ?, provider = ?, cost = ?, cost_source = ?, updated_at = ? WHERE id = ?");
2774
3800
  while (true) {
2775
3801
  const records = db.prepare(
2776
- "SELECT id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, cost_source FROM records WHERE id > ? ORDER BY id LIMIT ?"
3802
+ "SELECT id, tool, model, provider, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, cost_source FROM records WHERE id > ? ORDER BY id LIMIT ?"
2777
3803
  ).all(lastId, BATCH_SIZE);
2778
3804
  if (records.length === 0) break;
2779
3805
  for (const record of records) {
2780
- if (record.cost_source !== "pricing") {
3806
+ if (record.cost_source === "log") {
2781
3807
  skippedCount++;
2782
3808
  continue;
2783
3809
  }
2784
- const newCost = calculateCost(record.model, {
3810
+ const model = record.tool === "qoder" ? normalizeQoderModel(record.model) : record.model;
3811
+ const provider = model !== record.model ? inferProvider(model) : record.provider;
3812
+ const hasPrice = resolvePrice(model) != null;
3813
+ const newCost = hasPrice ? calculateCost(model, {
2785
3814
  inputTokens: record.input_tokens,
2786
3815
  outputTokens: record.output_tokens,
2787
3816
  cacheReadTokens: record.cache_read_tokens,
2788
3817
  cacheWriteTokens: record.cache_write_tokens,
2789
3818
  thinkingTokens: record.thinking_tokens
2790
- });
2791
- if (newCost !== record.cost) {
2792
- updateStmt.run(newCost, Date.now(), record.id);
3819
+ }, exchangeRate) : 0;
3820
+ const costSource = hasPrice ? "pricing" : "unknown";
3821
+ if (model !== record.model || provider !== record.provider || newCost !== record.cost || costSource !== record.cost_source) {
3822
+ updateStmt.run(model, provider, newCost, costSource, Date.now(), record.id);
2793
3823
  updatedCount++;
2794
3824
  }
2795
3825
  }
@@ -2798,6 +3828,36 @@ function recalcPricing(db) {
2798
3828
  return { updatedCount, skippedCount };
2799
3829
  }
2800
3830
 
3831
+ // src/progress.ts
3832
+ var MIN_INTERVAL_MS = 100;
3833
+ var isTTY = process.stderr.isTTY === true;
3834
+ function formatBar(ratio, width = 20) {
3835
+ const filled = Math.round(ratio * width);
3836
+ return "[" + "\u2588".repeat(filled) + "\u2591".repeat(width - filled) + "]";
3837
+ }
3838
+ var ProgressReporter = class {
3839
+ lastWrite = 0;
3840
+ lastLine = "";
3841
+ update(info) {
3842
+ if (!isTTY) return;
3843
+ const now = Date.now();
3844
+ if (now - this.lastWrite < MIN_INTERVAL_MS) return;
3845
+ this.lastWrite = now;
3846
+ const pct = info.total > 0 ? info.current / info.total * 100 : 0;
3847
+ const bar = formatBar(info.total > 0 ? info.current / info.total : 0);
3848
+ const toolLabel = info.tool ? ` [${info.tool}]` : "";
3849
+ const line = `\r${info.phase}${toolLabel} ${bar} ${pct.toFixed(1)}% ${info.current}/${info.total} records: ${info.records} calls: ${info.toolCalls} `;
3850
+ if (line !== this.lastLine) {
3851
+ process.stderr.write(line);
3852
+ this.lastLine = line;
3853
+ }
3854
+ }
3855
+ done() {
3856
+ if (!isTTY) return;
3857
+ process.stderr.write("\r\x1B[K");
3858
+ }
3859
+ };
3860
+
2801
3861
  // src/db/index.ts
2802
3862
  import Database2 from "better-sqlite3";
2803
3863
  import { mkdirSync as mkdirSync2 } from "fs";
@@ -3001,12 +4061,22 @@ function migrateV4(db) {
3001
4061
  db.prepare("INSERT INTO schema_version (version) VALUES (4)").run();
3002
4062
  }
3003
4063
 
4064
+ // src/db/migrations/v5.ts
4065
+ function migrateV5(db) {
4066
+ const columns = db.prepare("PRAGMA table_info('records')").all();
4067
+ if (!columns.some((c) => c.name === "cwd")) {
4068
+ db.exec(`ALTER TABLE records ADD COLUMN cwd TEXT NOT NULL DEFAULT ''`);
4069
+ }
4070
+ db.prepare("INSERT INTO schema_version (version) VALUES (5)").run();
4071
+ }
4072
+
3004
4073
  // src/db/migrations/index.ts
3005
4074
  var MIGRATIONS = [
3006
4075
  { version: 1, migrate: migrateV1 },
3007
4076
  { version: 2, migrate: migrateV2 },
3008
4077
  { version: 3, migrate: migrateV3 },
3009
- { version: 4, migrate: migrateV4 }
4078
+ { version: 4, migrate: migrateV4 },
4079
+ { version: 5, migrate: migrateV5 }
3010
4080
  ];
3011
4081
  function runMigrations(db) {
3012
4082
  createSchemaVersionTable(db);
@@ -3028,18 +4098,18 @@ function initializeDatabase(db) {
3028
4098
  applyPragmas(db);
3029
4099
  runMigrations(db);
3030
4100
  }
3031
- function createDatabase(path2) {
3032
- mkdirSync2(dirname2(path2), { recursive: true, mode: 448 });
3033
- const db = new Database2(path2);
4101
+ function createDatabase(path3) {
4102
+ mkdirSync2(dirname2(path3), { recursive: true, mode: 448 });
4103
+ const db = new Database2(path3);
3034
4104
  initializeDatabase(db);
3035
4105
  return db;
3036
4106
  }
3037
4107
 
3038
4108
  // src/cli.ts
3039
- import { join as join8 } from "path";
3040
- var DB_PATH = join8(AIUSAGE_DIR, "cache.db");
4109
+ import { join as join9 } from "path";
4110
+ var DB_PATH = join9(AIUSAGE_DIR, "cache.db");
3041
4111
  var program = new Command();
3042
- program.name("aiusage").version(true ? "1.2.1" : "dev").description("CLI tool for AI usage statistics");
4112
+ program.name("aiusage").version(true ? "1.3.0" : "dev").description("CLI tool for AI usage statistics");
3043
4113
  program.action(() => {
3044
4114
  const db = createDatabase(DB_PATH);
3045
4115
  const state = getState(AIUSAGE_DIR);
@@ -3066,7 +4136,7 @@ program.action(() => {
3066
4136
  }
3067
4137
  db.close();
3068
4138
  });
3069
- program.command("summary").description("Show usage summary").option("--week", "Show this week").option("--month", "Show this month").option("--from <date>", "Start date (YYYY-MM-DD)").option("--to <date>", "End date (YYYY-MM-DD)").option("--device <id>", "Filter by device instance ID").option("--tool <tool>", "Filter by tool type (claude-code|codex|openclaw|opencode|hermes)").action((options) => {
4139
+ program.command("summary").description("Show usage summary").option("--week", "Show this week").option("--month", "Show this month").option("--from <date>", "Start date (YYYY-MM-DD)").option("--to <date>", "End date (YYYY-MM-DD)").option("--device <id>", "Filter by device instance ID").option("--tool <tool>", "Filter by tool type (claude-code|codex|openclaw|opencode|hermes|qoder)").action((options) => {
3070
4140
  const db = createDatabase(DB_PATH);
3071
4141
  const state = getState(AIUSAGE_DIR);
3072
4142
  const summary = generateSummary(db, {
@@ -3122,15 +4192,20 @@ program.command("export").description("Export data").requiredOption("--format <f
3122
4192
  }
3123
4193
  db.close();
3124
4194
  });
3125
- program.command("parse").description("Parse AI tool session logs").option("--tool <tool>", "Specific tool to parse (claude-code|codex|openclaw|opencode|hermes)").action(async (options) => {
4195
+ program.command("parse").description("Parse AI tool session logs").option("--tool <tool>", "Specific tool to parse (claude-code|codex|openclaw|opencode|hermes|qoder)").option("--progress", "Show real-time progress").action(async (options) => {
3126
4196
  const db = createDatabase(DB_PATH);
4197
+ const reporter = options.progress ? new ProgressReporter() : void 0;
3127
4198
  try {
3128
- const result = await runParse(db, options.tool);
4199
+ const result = await runParse(db, options.tool, {
4200
+ onProgress: reporter ? (info) => reporter.update(info) : void 0
4201
+ });
4202
+ reporter?.done();
3129
4203
  console.log(`Parsed ${result.parsedCount} records, ${result.toolCallCount} tool calls.`);
3130
4204
  if (result.errors.length > 0) {
3131
4205
  console.warn(`${result.errors.length} errors encountered.`);
3132
4206
  }
3133
4207
  } catch (e) {
4208
+ reporter?.done();
3134
4209
  console.error(`Parse failed: ${e instanceof Error ? e.message : e}`);
3135
4210
  process.exit(1);
3136
4211
  } finally {
@@ -3149,6 +4224,21 @@ program.command("clean").description("Clean old data").option("--before <duratio
3149
4224
  console.log(`Deleted ${result.deletedCount} records, ${result.deletedSyncedCount} synced records, ${result.deletedOrphanToolCalls} orphan tool calls.`);
3150
4225
  db.close();
3151
4226
  });
4227
+ program.command("reset").description("Delete all parsed data and watermark, then re-parse from scratch").option("--yes", "Skip confirmation").action((options) => {
4228
+ if (!options.yes) {
4229
+ console.error("This will delete ALL parsed records, tool calls, synced data, and the parse watermark.");
4230
+ console.error("Source log files (~/.claude, ~/.codex, etc.) will NOT be affected.");
4231
+ console.error("Use --yes to confirm.");
4232
+ process.exit(1);
4233
+ }
4234
+ const db = createDatabase(DB_PATH);
4235
+ const result = runReset(db);
4236
+ console.log(`Deleted ${result.deletedRecords} records, ${result.deletedToolCalls} tool calls, ${result.deletedSyncedRecords} synced records.`);
4237
+ if (result.watermarkRemoved) {
4238
+ console.log("Watermark removed \u2014 next parse will re-import all data from scratch.");
4239
+ }
4240
+ db.close();
4241
+ });
3152
4242
  program.command("recalc").description("Recalculate costs").option("--pricing", "Recalculate using latest pricing").action(() => {
3153
4243
  const db = createDatabase(DB_PATH);
3154
4244
  const result = recalcPricing(db);