@juliantanx/aiusage 1.2.0 → 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.
- package/README.md +159 -26
- package/dist/index.js +2568 -1478
- package/dist/web/_app/immutable/assets/0.C5PeBlns.css +1 -0
- package/dist/web/_app/immutable/assets/10.D4XIqeEN.css +1 -0
- package/dist/web/_app/immutable/assets/11.CR85TxeS.css +1 -0
- package/dist/web/_app/immutable/assets/12.DAmfvZ5J.css +1 -0
- package/dist/web/_app/immutable/assets/2.G6nSchsK.css +1 -0
- package/dist/web/_app/immutable/assets/3.DQh9JikT.css +1 -0
- package/dist/web/_app/immutable/assets/4.CnA4GaE5.css +1 -0
- package/dist/web/_app/immutable/assets/5.D4N7n6x9.css +1 -0
- package/dist/web/_app/immutable/assets/6.CrsKlz6Y.css +1 -0
- package/dist/web/_app/immutable/assets/7.CQvfgmQf.css +1 -0
- package/dist/web/_app/immutable/assets/8.DDodt1Wn.css +1 -0
- package/dist/web/_app/immutable/assets/9.ApFevNUr.css +1 -0
- package/dist/web/_app/immutable/assets/ToolSelector.BRqFUj1Q.css +1 -0
- package/dist/web/_app/immutable/assets/ToolSelector.Bqx3ytrl.css +1 -0
- package/dist/web/_app/immutable/assets/_layout.C2s9Ry1h.css +1 -0
- package/dist/web/_app/immutable/assets/_page.ApFevNUr.css +1 -0
- package/dist/web/_app/immutable/assets/_page.CQvfgmQf.css +1 -0
- package/dist/web/_app/immutable/assets/_page.CR85TxeS.css +1 -0
- package/dist/web/_app/immutable/assets/_page.CmayGpOu.css +1 -0
- package/dist/web/_app/immutable/assets/_page.CnA4GaE5.css +1 -0
- package/dist/web/_app/immutable/assets/_page.CrsKlz6Y.css +1 -0
- package/dist/web/_app/immutable/assets/_page.D4N7n6x9.css +1 -0
- package/dist/web/_app/immutable/assets/_page.DAmfvZ5J.css +1 -0
- package/dist/web/_app/immutable/assets/_page.DDodt1Wn.css +1 -0
- package/dist/web/_app/immutable/assets/_page.DQh9JikT.css +1 -0
- package/dist/web/_app/immutable/assets/_page.DewAUGiJ.css +1 -0
- package/dist/web/_app/immutable/chunks/ToolSelector.CnQ-eszE.js +2 -0
- package/dist/web/_app/immutable/chunks/{api.Cv9iCeAW.js → api.OwJBmtEN.js} +1 -1
- package/dist/web/_app/immutable/chunks/entry.DWbp2XzC.js +3 -0
- package/dist/web/_app/immutable/chunks/i18n.BOq44pYI.js +1 -0
- package/dist/web/_app/immutable/chunks/{index.BjVwNvJa.js → index.CPsgU-fv.js} +1 -1
- package/dist/web/_app/immutable/chunks/{index.CUfK07C5.js → index.wix8pgil.js} +1 -1
- package/dist/web/_app/immutable/chunks/scheduler.DFD7qxcZ.js +1 -0
- package/dist/web/_app/immutable/chunks/stores.B1g8D8DV.js +1 -0
- package/dist/web/_app/immutable/chunks/{stores.Js_X6JN8.js → stores.C3ZtRBEM.js} +1 -1
- package/dist/web/_app/immutable/entry/{app.D2r0s5MX.js → app.7Tmcb_qs.js} +2 -2
- package/dist/web/_app/immutable/entry/start.CuMG7ijt.js +1 -0
- package/dist/web/_app/immutable/nodes/0.GiWuXD22.js +1 -0
- package/dist/web/_app/immutable/nodes/{1.CRZFpn26.js → 1.CNbcoUIj.js} +1 -1
- package/dist/web/_app/immutable/nodes/10.DgPr3L-S.js +1 -0
- package/dist/web/_app/immutable/nodes/11.TrX-RGQX.js +1 -0
- package/dist/web/_app/immutable/nodes/12.ZW7n1TLF.js +1 -0
- package/dist/web/_app/immutable/nodes/2.D0gEY9nh.js +3 -0
- package/dist/web/_app/immutable/nodes/3.CcnPu3DZ.js +1 -0
- package/dist/web/_app/immutable/nodes/4.CCaCZRNi.js +13 -0
- package/dist/web/_app/immutable/nodes/5.Bxx7TNGK.js +1 -0
- package/dist/web/_app/immutable/nodes/6.8zY9a_YX.js +1 -0
- package/dist/web/_app/immutable/nodes/7.bOnMUP4_.js +9 -0
- package/dist/web/_app/immutable/nodes/8.01X9rJRT.js +1 -0
- package/dist/web/_app/immutable/nodes/9.DabFUxt3.js +1 -0
- package/dist/web/_app/version.json +1 -1
- package/dist/web/index.html +12 -11
- package/package.json +5 -3
- package/dist/chunk-2DX56FB6.js +0 -522
- package/dist/dist-4I33E46P.js +0 -42
- package/dist/web/_app/immutable/assets/0.Cynllvu4.css +0 -1
- package/dist/web/_app/immutable/assets/10.Doxq00gF.css +0 -1
- package/dist/web/_app/immutable/assets/11.D5bUq0yE.css +0 -1
- package/dist/web/_app/immutable/assets/12.BA90S8v7.css +0 -1
- package/dist/web/_app/immutable/assets/2.fho1OPpm.css +0 -1
- package/dist/web/_app/immutable/assets/3.DwwXF_G1.css +0 -1
- package/dist/web/_app/immutable/assets/4.CVp0A8QV.css +0 -1
- package/dist/web/_app/immutable/assets/5.DM2hVWjt.css +0 -1
- package/dist/web/_app/immutable/assets/6.NdKfVCiG.css +0 -1
- package/dist/web/_app/immutable/assets/7.CeV8yHSX.css +0 -1
- package/dist/web/_app/immutable/assets/8.BCdjuhVX.css +0 -1
- package/dist/web/_app/immutable/assets/9.ClvwkhDe.css +0 -1
- package/dist/web/_app/immutable/assets/ToolSelector.BKgB2QVk.css +0 -1
- package/dist/web/_app/immutable/assets/ToolSelector.HSbC8-q9.css +0 -1
- package/dist/web/_app/immutable/assets/_layout.BpydV4rl.css +0 -1
- package/dist/web/_app/immutable/assets/_page.BA90S8v7.css +0 -1
- package/dist/web/_app/immutable/assets/_page.BCdjuhVX.css +0 -1
- package/dist/web/_app/immutable/assets/_page.CVp0A8QV.css +0 -1
- package/dist/web/_app/immutable/assets/_page.CeV8yHSX.css +0 -1
- package/dist/web/_app/immutable/assets/_page.ClvwkhDe.css +0 -1
- package/dist/web/_app/immutable/assets/_page.D5bUq0yE.css +0 -1
- package/dist/web/_app/immutable/assets/_page.DM2hVWjt.css +0 -1
- package/dist/web/_app/immutable/assets/_page.DTuZozBR.css +0 -1
- package/dist/web/_app/immutable/assets/_page.Doxq00gF.css +0 -1
- package/dist/web/_app/immutable/assets/_page.DwwXF_G1.css +0 -1
- package/dist/web/_app/immutable/assets/_page.NdKfVCiG.css +0 -1
- package/dist/web/_app/immutable/chunks/ToolSelector.RXWBkdpM.js +0 -2
- package/dist/web/_app/immutable/chunks/entry.C9S4R-C0.js +0 -3
- package/dist/web/_app/immutable/chunks/i18n.CrmYDWvg.js +0 -1
- package/dist/web/_app/immutable/chunks/scheduler.DukoKnjQ.js +0 -1
- package/dist/web/_app/immutable/entry/start.DosCYfdN.js +0 -1
- package/dist/web/_app/immutable/nodes/0.CQBqRzIr.js +0 -1
- package/dist/web/_app/immutable/nodes/10.Jb_wogsG.js +0 -1
- package/dist/web/_app/immutable/nodes/11.CpuiQa6R.js +0 -1
- package/dist/web/_app/immutable/nodes/12.BiWlfD7q.js +0 -1
- package/dist/web/_app/immutable/nodes/2.CQudsTBz.js +0 -3
- package/dist/web/_app/immutable/nodes/3.Dg9c_XQX.js +0 -1
- package/dist/web/_app/immutable/nodes/4.DE3OvqSg.js +0 -11
- package/dist/web/_app/immutable/nodes/5.sGu0NOiv.js +0 -1
- package/dist/web/_app/immutable/nodes/6.BOL9Fhgv.js +0 -1
- package/dist/web/_app/immutable/nodes/7.B2wRNntO.js +0 -1
- package/dist/web/_app/immutable/nodes/8.BVACtdPg.js +0 -1
- 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
|
|
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
|
-
"
|
|
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
|
-
|
|
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}
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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 (
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
847
|
-
json(res, { currentDeviceInstanceId: currentId, devices });
|
|
848
|
-
return;
|
|
1462
|
+
} catch {
|
|
849
1463
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
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
|
|
1719
|
+
function getDefaultSourcePaths() {
|
|
1720
|
+
const home = homedir2();
|
|
1025
1721
|
return {
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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/
|
|
1051
|
-
function
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
"
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
"hermes": {}
|
|
1789
|
+
where: "AND device_instance_id = @deviceId",
|
|
1790
|
+
params: { deviceId: device },
|
|
1791
|
+
useUnion: false,
|
|
1792
|
+
localOnly: false
|
|
1106
1793
|
};
|
|
1107
1794
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
if (
|
|
1117
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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 (
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
-
|
|
2195
|
+
return;
|
|
1260
2196
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
-
|
|
2366
|
+
const status = options?.getSyncStatus?.() ?? null;
|
|
2367
|
+
json(res, { status });
|
|
2368
|
+
return;
|
|
1443
2369
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
const
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
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
|
-
|
|
1597
|
-
|
|
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
|
-
|
|
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,
|
|
2806
|
+
for (const [index, path3] of paths.entries()) {
|
|
1819
2807
|
this.options.onProgress?.({
|
|
1820
2808
|
phase: "pulling",
|
|
1821
|
-
currentPath:
|
|
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(
|
|
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:
|
|
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(
|
|
1853
|
-
const existingContent = await this.backend.readFile(
|
|
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(
|
|
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
|
|
1867
|
-
if (!byPath.has(
|
|
1868
|
-
byPath.get(
|
|
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, [
|
|
2866
|
+
for (const [index, [path3, localRecords]] of uploads.entries()) {
|
|
1879
2867
|
this.options.onProgress?.({
|
|
1880
2868
|
phase: "uploading",
|
|
1881
|
-
currentPath:
|
|
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(
|
|
2875
|
+
const changedCount = await this.uploadFile(path3, localSyncRecords);
|
|
1888
2876
|
totalUploaded += changedCount;
|
|
1889
2877
|
this.options.onProgress?.({
|
|
1890
2878
|
phase: "uploading",
|
|
1891
|
-
currentPath:
|
|
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
|
|
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(
|
|
2977
|
+
async readFile(path3) {
|
|
1990
2978
|
try {
|
|
1991
|
-
const fullPath = join4(this.dataDir,
|
|
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(
|
|
1998
|
-
const fullPath = join4(this.dataDir,
|
|
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(
|
|
2061
|
-
return `${this.prefix}${
|
|
3048
|
+
getObjectKey(path3) {
|
|
3049
|
+
return `${this.prefix}${path3}`;
|
|
2062
3050
|
}
|
|
2063
|
-
async readFile(
|
|
2064
|
-
const key = this.getObjectKey(
|
|
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(
|
|
2081
|
-
const key = this.getObjectKey(
|
|
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(
|
|
2114
|
-
const key = this.getObjectKey(
|
|
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
|
|
2482
|
-
parseInterval: existingConfig?.parseInterval
|
|
2483
|
-
dashboardPollInterval: existingConfig?.dashboardPollInterval
|
|
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
|
|
2505
|
-
parseInterval: existingConfig?.parseInterval
|
|
2506
|
-
dashboardPollInterval: existingConfig?.dashboardPollInterval
|
|
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
|
|
2551
|
-
parseInterval: existingConfig?.parseInterval
|
|
2552
|
-
dashboardPollInterval: existingConfig?.dashboardPollInterval
|
|
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
|
|
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
|
|
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
|
|
3806
|
+
if (record.cost_source === "log") {
|
|
2781
3807
|
skippedCount++;
|
|
2782
3808
|
continue;
|
|
2783
3809
|
}
|
|
2784
|
-
const
|
|
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
|
-
|
|
2792
|
-
|
|
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(
|
|
3032
|
-
mkdirSync2(dirname2(
|
|
3033
|
-
const db = new Database2(
|
|
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
|
|
3040
|
-
var DB_PATH =
|
|
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.
|
|
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);
|