tokentracker-cli 1.3.9 → 1.3.11
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.de.md +7 -6
- package/README.ja.md +6 -5
- package/README.ko.md +6 -5
- package/README.md +7 -6
- package/README.zh-CN.md +6 -5
- package/dashboard/dist/assets/{AchievementBadge-C6ZoXGzv.js → AchievementBadge-CjXQPC-W.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-BkHoOrUq.js → AchievementsPage-xb-g6Jws.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-BmK0_LQF.js → AchievementsSection-DI4NOt9D.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-DwoD1Nsr.js → BadgeDetailModal-Cn2rV5Bk.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-m4GXCXTJ.js → BotAnimated-CFb-gvO7.js} +1 -1
- package/dashboard/dist/assets/{Card-D_SqQIBC.js → Card-CB2-QAqP.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-BSYvxA_x.js → CloudCheckoutPage-beBzOiJA.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-Bwnj7Ocg.js → CloudPage-COYm4wdR.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-CBMCbek2.js → CommandPalette-kCa9Mfpj.js} +1 -1
- package/dashboard/dist/assets/CommunityStatsModal-BeUZhxxn.js +1 -0
- package/dashboard/dist/assets/{ConfirmModal-DAt5opxx.js → ConfirmModal-DiCm2JXX.js} +1 -1
- package/dashboard/dist/assets/{Controls-DsJLsMpr.js → Controls-BkRVScXl.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-B4hR6Jr1.js → DashboardPage-DiV0ynQc.js} +1 -1
- package/dashboard/dist/assets/{DateRangePopover-CyAT0F7i.js → DateRangePopover-DdmjfdFj.js} +1 -1
- package/dashboard/dist/assets/DevicePage-D7E9uy4l.js +1 -0
- package/dashboard/dist/assets/{DialogClose-Dg8Rrcbc.js → DialogClose-VDWLqY0o.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-BQ5VSHQi.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-CgQTzYDB.js → DialogTitle-DfBUUBYN.js} +1 -1
- package/dashboard/dist/assets/{ElectricLogo-PbWNE6Jw.js → ElectricLogo-Cr-WDplA.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-CgGVCeA7.js → FadeIn-DPdMW40K.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-D8I6O4H1.js → HeaderGithubStar-Ulqd6uC1.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-BQ0w0NcA.js → HoverTooltip-By6YzN89.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-D9vVoMNk.js → IpCheckPage-BLvWuVmL.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-DNV9PcRZ.js → LandingPage-BfgBmSGV.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardPage-BVfC39jk.js → LeaderboardPage-D7C91BEb.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProBadge-g6XLqOpA.js → LeaderboardProBadge-Mj_-HH8L.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfileModal-Dxwperqt.js → LeaderboardProfileModal-BfHTrqHf.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-C9552VgQ.js → LeaderboardProfilePage-CggAvx-w.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-CWaM8Ubc.js → LimitsPage-BR8hlK7t.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-DJ8t4CkI.js → LocalOnlyNotice-yW1orlU8.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-5cDUjlaP.js → LoginCard-B_7mpZ6Y.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-CaEskXOR.js → LoginModal-D1ZtIkRD.js} +1 -1
- package/dashboard/dist/assets/{LoginPage--buWhwDc.js → LoginPage-DRThDeqD.js} +1 -1
- package/dashboard/dist/assets/{PetPage-DZlGXdiW.js → PetPage-CsIkqyiR.js} +2 -2
- package/dashboard/dist/assets/{PopoverPopup-IQrgeWzz.js → PopoverPopup-dySzR9xR.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-CSKhjDnT.js → ProviderIcon-oBKAZ-WS.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-ClzIMLjQ.js → ResetPasswordPage-Bf0-zIl2.js} +1 -1
- package/dashboard/dist/assets/{Select-KHVnbrwD.js → Select-xyNGq71G.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-BqAFvX8b.js → SelectItemText-C8ZB19C7.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-DHA9hDnA.js → SelfHostPage-Cf7pLoVz.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-A4WcSrnG.js → ServiceStatusPage-BH3W_Wxq.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-CWu0gXwO.js → SessionsPage-u8RIKjx9.js} +3 -3
- package/dashboard/dist/assets/{SettingsPage-DzurIV6u.js → SettingsPage-Bnh-AxM9.js} +3 -3
- package/dashboard/dist/assets/{SkillsPage-CfM8w0xf.js → SkillsPage-CIP3Av7z.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-BiCXCnEH.js → TokenGalaxy-DnNhsfa-.js} +1 -1
- package/dashboard/dist/assets/{TooltipPopup-BcnMVc6a.js → TooltipPopup-BstAYPIH.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-PFSJn43N.js → TrendMonitor-D4CVzczb.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-CnH4zkP8.js → WidgetsPage-A_gMzap_.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-BpsnuM4e.js → WrappedPage-Bmm_WjA5.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-CbhiyufG.js → __vite-browser-external-DS-dnBDP.js} +3 -3
- package/dashboard/dist/assets/{__vite-browser-external-CbUj3l39.css → __vite-browser-external-kAUz3kFs.css} +1 -1
- package/dashboard/dist/assets/{agent-logos-DFupeEJz.js → agent-logos-BdU0GjQq.js} +1 -1
- package/dashboard/dist/assets/{arrow-left-Cu0jOgzo.js → arrow-left-Cl8AYaOh.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-DgFuyz9F.js → arrow-right-Cfi2pR1p.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-COoZfksW.js → arrow-up-right-DH7vnAf4.js} +1 -1
- package/dashboard/dist/assets/{calendar-days-B_QPPq3a.js → calendar-days-BJjjx5Vu.js} +1 -1
- package/dashboard/dist/assets/{check-D_enaDhp.js → check-DGTejNYt.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-Di_xdF9P.js → chevron-down-Brz4a22U.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-DNFMqoiZ.js → cloud-checkout-DZbiSnVr.js} +1 -1
- package/dashboard/dist/assets/{composite-CUbvhoGL.js → composite-rfjkpEna.js} +1 -1
- package/dashboard/dist/assets/{download-CrDgjohg.js → download-axaJjbTW.js} +1 -1
- package/dashboard/dist/assets/{external-link-DgnNV6zS.js → external-link-D9dvv4DN.js} +1 -1
- package/dashboard/dist/assets/{flame-Lsd5DkgX.js → flame-DuivUwnP.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-DSxX2imE.js → format-tokens-Dg_-K3cD.js} +1 -1
- package/dashboard/dist/assets/{gift-BA3WiW1-.js → gift-CgE1uDg4.js} +1 -1
- package/dashboard/dist/assets/{host-mode-BiJLkMdX.js → host-mode-C2QCLKjw.js} +1 -1
- package/dashboard/dist/assets/{icons-BxxxA-aw.js → icons-PNlyYW8C.js} +1 -1
- package/dashboard/dist/assets/{index-V-WILWJ0.js → index-BbroxaKr.js} +1 -1
- package/dashboard/dist/assets/{index-BTC0A20d.js → index-DFWE1vbu.js} +1 -1
- package/dashboard/dist/assets/{index-BlvkImKO.js → index-PiRGMAPd.js} +1 -1
- package/dashboard/dist/assets/{info-CluBWyTe.js → info-BjRBvuNu.js} +1 -1
- package/dashboard/dist/assets/{link-2-M880npIj.js → link-2-CCqYoK3V.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-BEowiVYF.js → loader-circle-IiB0vjDJ.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-pR4sKZep.js → modifiers.esm-DbjjBgLM.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-GOnwL7NC.js → monitor-smartphone-BUSQ3-U3.js} +1 -1
- package/dashboard/dist/assets/{play-C2Yih72t.js → play-CDU4bWoE.js} +1 -1
- package/dashboard/dist/assets/{plus-KF6U4UP_.js → plus-BY7jwS0R.js} +1 -1
- package/dashboard/dist/assets/{provider-display-Bio3tJX4.js → provider-display-endIONDM.js} +1 -1
- package/dashboard/dist/assets/{react-Dt52_FiQ.js → react-C0JpiKb9.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-D7wQNKcm.js → safePolygon-B5pqmyki.js} +1 -1
- package/dashboard/dist/assets/{search-CW0YCdKx.js → search-BWQCqCrK.js} +1 -1
- package/dashboard/dist/assets/{server-DKLG1DuM.js → server-DkDn4DQU.js} +1 -1
- package/dashboard/dist/assets/{shield-check-DkJ3sCHH.js → shield-check-BPOMBy-3.js} +1 -1
- package/dashboard/dist/assets/{skills-api-yS1uW-mw.js → skills-api-Btor2iNW.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-DZo9A4kG.js → sortable.esm-qA98E6si.js} +1 -1
- package/dashboard/dist/assets/{store-BbYtf4Vy.js → store-DRcycxJw.js} +1 -1
- package/dashboard/dist/assets/{terminal-FK6txO2c.js → terminal-BWOI_yxH.js} +1 -1
- package/dashboard/dist/assets/{trash-QX4FHz_p.js → trash-DgWVzyKs.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DaURJ5sU.js → use-community-stats-odyPsCDi.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-BZnnoQct.js → use-latest-request-guard-CBocPZjh.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-C77UTTya.js → use-limits-display-prefs-uKB639iI.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-DHS101SR.js → use-native-settings-EoDW6Dgk.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-h5iXqXbU.js → use-ordered-list-B4Gychu4.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-B-JhzSwL.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-DZVvvmoq.js → use-session-efficiency-pref-C0goPBNl.js} +1 -1
- package/dashboard/dist/assets/{use-transform-dcPyhMQK.js → use-transform-hwE19i1Y.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-BBAi4Wvb.js → use-usage-limits-DnsGayPX.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-CW5GHWIB.js → useCurrency-DiN1Jj_t.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-BQgBHX5s.js → usePositioner-CM1Isf5e.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-DntK8yNR.js → useTokenFormat-CGhMrRps.js} +1 -1
- package/dashboard/dist/assets/{zap-Brp6hC7u.js → zap-Bc2wd9eQ.js} +1 -1
- package/dashboard/dist/index.html +14 -12
- package/dashboard/dist/ip-check.html +2 -2
- package/dashboard/dist/leaderboard.html +2 -2
- package/dashboard/dist/llms.txt +3 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +2 -2
- package/src/commands/init.js +9 -0
- package/src/commands/status.js +11 -0
- package/src/commands/sync.js +197 -122
- package/src/lib/dumate.js +95 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/qoder-cn-cli-limits.js +117 -0
- package/src/lib/qoder-limits.js +24 -11
- package/src/lib/rollout.js +12 -9
- package/src/lib/session-analytics.js +210 -19
- package/src/lib/upload-queue.js +134 -0
- package/dashboard/dist/assets/CommunityStatsModal-DkMCREYc.js +0 -1
- package/dashboard/dist/assets/DevicePage-CaZzXt8j.js +0 -1
- package/dashboard/dist/assets/DialogDescription-gdxwXQBG.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-Bpc6LGag.js +0 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs/promises");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
|
|
8
|
+
const OPEN_API = "https://openapi.qoder.com.cn";
|
|
9
|
+
|
|
10
|
+
async function readQoderCnCliCredentials({ home = os.homedir(), env = process.env } = {}) {
|
|
11
|
+
const root = typeof env.QODERCN_CONFIG_DIR === "string" && env.QODERCN_CONFIG_DIR.trim()
|
|
12
|
+
? path.resolve(env.QODERCN_CONFIG_DIR.trim())
|
|
13
|
+
: path.join(home, ".qoder-cn");
|
|
14
|
+
try {
|
|
15
|
+
const [encrypted, machineId] = await Promise.all([
|
|
16
|
+
fs.readFile(path.join(root, ".auth", "user"), "utf8"),
|
|
17
|
+
fs.readFile(path.join(root, ".auth", "machine_id"), "utf8"),
|
|
18
|
+
]);
|
|
19
|
+
// Verified against the official CLI 1.1.68 WASM credential-storage codec.
|
|
20
|
+
const key = Buffer.from(machineId.trim().slice(0, 16), "utf8");
|
|
21
|
+
if (key.length !== 16) return null;
|
|
22
|
+
const decipher = crypto.createDecipheriv("aes-128-cbc", key, key);
|
|
23
|
+
const user = JSON.parse(Buffer.concat([
|
|
24
|
+
decipher.update(Buffer.from(encrypted.trim(), "base64")),
|
|
25
|
+
decipher.final(),
|
|
26
|
+
]).toString("utf8"));
|
|
27
|
+
const token = user.security_oauth_token || user.access_token;
|
|
28
|
+
if (typeof token !== "string" || !token.trim()) return null;
|
|
29
|
+
return { token, organizationId: user.organization_id || null };
|
|
30
|
+
} catch (_error) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cnAuthError() {
|
|
36
|
+
const error = new Error("Qoder CN login expired. Sign in to Qoder CN CLI again.");
|
|
37
|
+
error.code = "AUTH_EXPIRED";
|
|
38
|
+
return error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function cnRequest(url, options, fetchImpl, { tokenExchange = false } = {}) {
|
|
42
|
+
let response;
|
|
43
|
+
try {
|
|
44
|
+
response = await fetchImpl(url, {
|
|
45
|
+
...options,
|
|
46
|
+
redirect: "error",
|
|
47
|
+
signal: AbortSignal.timeout(15_000),
|
|
48
|
+
});
|
|
49
|
+
} catch (_error) {
|
|
50
|
+
throw new Error("Qoder CN usage request failed.");
|
|
51
|
+
}
|
|
52
|
+
if (response.status === 401 || response.status === 403 || (tokenExchange && response.status === 400)) throw cnAuthError();
|
|
53
|
+
if (!response.ok) throw new Error(`Qoder CN API returned HTTP ${response.status}.`);
|
|
54
|
+
try {
|
|
55
|
+
return await response.json();
|
|
56
|
+
} catch (_error) {
|
|
57
|
+
throw new Error("Qoder CN API returned invalid JSON.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeQoderCnCliUsage(response, normalizeRpcUsage) {
|
|
62
|
+
const usage = response?.displayMode === "qoder" ? response.qoderUsage : response;
|
|
63
|
+
const rawQuota = usage?.userQuota ?? usage?.user_quota;
|
|
64
|
+
if (!rawQuota || typeof rawQuota !== "object") {
|
|
65
|
+
throw new Error("Qoder CN usage response is missing the plan quota.");
|
|
66
|
+
}
|
|
67
|
+
const total = Number(rawQuota.total);
|
|
68
|
+
const used = Number(rawQuota.used);
|
|
69
|
+
const remaining = rawQuota.remaining == null ? Math.max(0, total - used) : Number(rawQuota.remaining);
|
|
70
|
+
if (rawQuota.total == null || rawQuota.used == null
|
|
71
|
+
|| ![total, used, remaining].every(Number.isFinite) || Math.min(total, used, remaining) < 0
|
|
72
|
+
|| (total === 0 && (used !== 0 || remaining !== 0))) {
|
|
73
|
+
throw new Error("Qoder CN usage response has invalid quota values.");
|
|
74
|
+
}
|
|
75
|
+
// The OpenAPI returns fractions; the old desktop IPC already returns percents.
|
|
76
|
+
const result = normalizeRpcUsage({
|
|
77
|
+
userType: usage.userType ?? usage.user_type,
|
|
78
|
+
expiresAt: usage.expiresAt ?? usage.expires_at,
|
|
79
|
+
isQuotaExceeded: usage.isQuotaExceeded ?? usage.is_quota_exceeded,
|
|
80
|
+
userQuota: { used, total, remaining, unit: rawQuota.unit },
|
|
81
|
+
});
|
|
82
|
+
result.source = "cli-api";
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function fetchQoderCnCliLimits({
|
|
87
|
+
home = os.homedir(),
|
|
88
|
+
env = process.env,
|
|
89
|
+
fetchImpl = fetch,
|
|
90
|
+
normalizeRpcUsage,
|
|
91
|
+
} = {}) {
|
|
92
|
+
const pat = typeof env.QODERCN_PERSONAL_ACCESS_TOKEN === "string"
|
|
93
|
+
? env.QODERCN_PERSONAL_ACCESS_TOKEN.trim()
|
|
94
|
+
: "";
|
|
95
|
+
let credentials;
|
|
96
|
+
if (pat) {
|
|
97
|
+
const exchanged = await cnRequest(`${OPEN_API}/api/v1/jobToken/exchange`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify({ personal_token: pat }),
|
|
101
|
+
}, fetchImpl, { tokenExchange: true });
|
|
102
|
+
const token = exchanged?.token || exchanged?.device_token || exchanged?.access_token;
|
|
103
|
+
if (typeof token !== "string" || !token.trim()) throw cnAuthError();
|
|
104
|
+
credentials = { token };
|
|
105
|
+
} else {
|
|
106
|
+
credentials = await readQoderCnCliCredentials({ home, env });
|
|
107
|
+
}
|
|
108
|
+
if (!credentials) return null;
|
|
109
|
+
const endpoint = credentials.organizationId ? "/sash/api/v2/me/usage" : "/api/v2/quota/usage";
|
|
110
|
+
const response = await cnRequest(`${OPEN_API}${endpoint}`, {
|
|
111
|
+
method: "GET",
|
|
112
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${credentials.token}` },
|
|
113
|
+
}, fetchImpl);
|
|
114
|
+
return normalizeQoderCnCliUsage(response, normalizeRpcUsage);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { readQoderCnCliCredentials, normalizeQoderCnCliUsage, fetchQoderCnCliLimits };
|
package/src/lib/qoder-limits.js
CHANGED
|
@@ -5,6 +5,7 @@ const fs = require("node:fs");
|
|
|
5
5
|
const net = require("node:net");
|
|
6
6
|
const os = require("node:os");
|
|
7
7
|
const path = require("node:path");
|
|
8
|
+
const { fetchQoderCnCliLimits } = require("./qoder-cn-cli-limits");
|
|
8
9
|
|
|
9
10
|
const QODER_SITES = {
|
|
10
11
|
international: {
|
|
@@ -16,10 +17,10 @@ const QODER_SITES = {
|
|
|
16
17
|
},
|
|
17
18
|
china: {
|
|
18
19
|
id: "china",
|
|
19
|
-
origin: "https://qoder.
|
|
20
|
-
usageUrl: "https://qoder.
|
|
20
|
+
origin: "https://qoder.cn",
|
|
21
|
+
usageUrl: "https://qoder.cn/api/v2/me/usages/big_model_credits",
|
|
21
22
|
activityUrl: "https://openapi.qoder.com.cn/algo/api/v2/activity",
|
|
22
|
-
domains: new Set(["qoder.com.cn", "www.qoder.com.cn"]),
|
|
23
|
+
domains: new Set(["qoder.cn", "www.qoder.cn", "qoder.com.cn", "www.qoder.com.cn"]),
|
|
23
24
|
},
|
|
24
25
|
};
|
|
25
26
|
|
|
@@ -670,6 +671,7 @@ async function fetchQoderLimits({
|
|
|
670
671
|
fetchImpl = fetch,
|
|
671
672
|
rpcRequest = qoderRpcRequest,
|
|
672
673
|
nowMs = Date.now(),
|
|
674
|
+
cliLimitsRequest,
|
|
673
675
|
} = {}) {
|
|
674
676
|
let rpcUsage = null;
|
|
675
677
|
let rpcAuth = null;
|
|
@@ -714,8 +716,22 @@ async function fetchQoderLimits({
|
|
|
714
716
|
return rpcUsage;
|
|
715
717
|
}
|
|
716
718
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
+
let cliError = null;
|
|
720
|
+
if (cliLimitsRequest) {
|
|
721
|
+
try {
|
|
722
|
+
const cliUsage = await cliLimitsRequest({ home, env, fetchImpl, normalizeRpcUsage: normalizeQoderRpcUsage });
|
|
723
|
+
if (cliUsage) {
|
|
724
|
+
if (activityWindow) cliUsage.secondary_window = activityWindow;
|
|
725
|
+
writeQoderLimitsCache(cliUsage, { home, nowMs, namespace: cacheNamespace });
|
|
726
|
+
return cliUsage;
|
|
727
|
+
}
|
|
728
|
+
} catch (error) {
|
|
729
|
+
cliError = error;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Once native sources are unavailable, prefer a bounded disk snapshot before
|
|
734
|
+
// the manual-cookie or local-log fallback.
|
|
719
735
|
const cachedLimits = readQoderLimitsCache({ home, nowMs, namespace: cacheNamespace });
|
|
720
736
|
if (cachedLimits) {
|
|
721
737
|
if (activityWindow) cachedLimits.secondary_window = activityWindow;
|
|
@@ -738,7 +754,7 @@ async function fetchQoderLimits({
|
|
|
738
754
|
}]
|
|
739
755
|
: [];
|
|
740
756
|
|
|
741
|
-
let lastError =
|
|
757
|
+
let lastError = cliError;
|
|
742
758
|
for (const session of sessions) {
|
|
743
759
|
try {
|
|
744
760
|
const result = await fetchQoderUsage(session.cookieHeader, session.site, fetchImpl);
|
|
@@ -800,11 +816,7 @@ async function fetchQoderLimits({
|
|
|
800
816
|
return { configured: false };
|
|
801
817
|
}
|
|
802
818
|
|
|
803
|
-
//
|
|
804
|
-
// separate QoderCN data directory (its own app install). Same mechanics as the
|
|
805
|
-
// international fetch, but with the china site, the QoderCN data root (own
|
|
806
|
-
// QODER_CN_HOME / QODER_CN_DB_PATH / QODER_CN_LOG_ROOT overrides), and a
|
|
807
|
-
// dedicated cache namespace so the two editions never clobber each other.
|
|
819
|
+
// CN has its own CLI credentials, legacy desktop root and cache namespace.
|
|
808
820
|
function fetchQoderCnLimits(opts = {}) {
|
|
809
821
|
return fetchQoderLimits({
|
|
810
822
|
...opts,
|
|
@@ -813,6 +825,7 @@ function fetchQoderCnLimits(opts = {}) {
|
|
|
813
825
|
site: QODER_SITES.china,
|
|
814
826
|
sitePinned: true,
|
|
815
827
|
cacheNamespace: "cn",
|
|
828
|
+
cliLimitsRequest: fetchQoderCnCliLimits,
|
|
816
829
|
});
|
|
817
830
|
}
|
|
818
831
|
|
package/src/lib/rollout.js
CHANGED
|
@@ -5171,7 +5171,7 @@ function isMissingSqliteTableError(error) {
|
|
|
5171
5171
|
return messages.some((m) => typeof m === "string" && m.includes("no such table"));
|
|
5172
5172
|
}
|
|
5173
5173
|
|
|
5174
|
-
function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqliteOptions = {}, { propagateReadFailure = false } = {}) {
|
|
5174
|
+
function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqliteOptions = {}, { propagateReadFailure = false, includeZeroMessage } = {}) {
|
|
5175
5175
|
if (!dbPath || !fssync.existsSync(dbPath)) return { messages: [], cursor: null };
|
|
5176
5176
|
|
|
5177
5177
|
const identity = opencodeDbIdentity(dbPath);
|
|
@@ -5215,16 +5215,19 @@ function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqlite
|
|
|
5215
5215
|
toNonNegativeInt(tokens.reasoning) > 0 ||
|
|
5216
5216
|
toNonNegativeInt(tokens.cache?.read) > 0 ||
|
|
5217
5217
|
toNonNegativeInt(tokens.cache?.write) > 0;
|
|
5218
|
-
|
|
5219
|
-
if (isV2 && typeof row.directory === "string" && row.directory.trim()) {
|
|
5220
|
-
data.path = { ...(typeof data.path === "object" && data.path ? data.path : {}), cwd: row.directory };
|
|
5221
|
-
}
|
|
5222
|
-
out.push({
|
|
5218
|
+
const message = {
|
|
5223
5219
|
id: row.id || data.id,
|
|
5224
5220
|
sessionID: row.session_id || data.sessionID,
|
|
5225
5221
|
timeUpdated: row.time_updated || 0,
|
|
5226
5222
|
data,
|
|
5227
|
-
}
|
|
5223
|
+
};
|
|
5224
|
+
// Providers can retain zero corrections for previously counted messages.
|
|
5225
|
+
// New empty records remain excluded unless the caller recognizes them.
|
|
5226
|
+
if (!hasTokens && !(typeof includeZeroMessage === "function" && includeZeroMessage(message))) continue;
|
|
5227
|
+
if (isV2 && typeof row.directory === "string" && row.directory.trim()) {
|
|
5228
|
+
data.path = { ...(typeof data.path === "object" && data.path ? data.path : {}), cwd: row.directory };
|
|
5229
|
+
}
|
|
5230
|
+
out.push(message);
|
|
5228
5231
|
}
|
|
5229
5232
|
};
|
|
5230
5233
|
|
|
@@ -5360,8 +5363,8 @@ function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqlite
|
|
|
5360
5363
|
}
|
|
5361
5364
|
}
|
|
5362
5365
|
|
|
5363
|
-
function readOpencodeDbMessages(dbPath, sqliteOptions = {}, { propagateReadFailure = false } = {}) {
|
|
5364
|
-
return readOpencodeDbMessagesIncremental(dbPath, null, sqliteOptions, { propagateReadFailure }).messages;
|
|
5366
|
+
function readOpencodeDbMessages(dbPath, sqliteOptions = {}, { propagateReadFailure = false, includeZeroMessage } = {}) {
|
|
5367
|
+
return readOpencodeDbMessagesIncremental(dbPath, null, sqliteOptions, { propagateReadFailure, includeZeroMessage }).messages;
|
|
5365
5368
|
}
|
|
5366
5369
|
|
|
5367
5370
|
// mimocode mirrors the user's Claude Code + claude-mem history into its own
|
|
@@ -28,12 +28,12 @@ const fsp = require("node:fs/promises");
|
|
|
28
28
|
const os = require("node:os");
|
|
29
29
|
const path = require("node:path");
|
|
30
30
|
const readline = require("node:readline");
|
|
31
|
-
const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage, readZcodeDbMessages, coerceEpochMs, normalizeOpencodeModelFields, deriveOpencodeMessageFingerprint } = require("./rollout");
|
|
31
|
+
const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage, readZcodeDbMessages, coerceEpochMs, normalizeOpencodeModelFields, normalizeOpencodeTokens, deriveOpencodeMessageFingerprint } = require("./rollout");
|
|
32
32
|
const { parseCodexRolloutFile } = require("./codex-rollout-parser");
|
|
33
33
|
const { computeRowCost, getModelPricingInfo } = require("./pricing");
|
|
34
34
|
const { normalizePerformance, mergePerformance, createClaudePerformanceCollector, createCodexPerformanceCollector, createZcodePerformanceCollector } = require("./session-performance");
|
|
35
|
-
const { readSqliteJsonRows } = require("./sqlite-reader");
|
|
36
|
-
const { resolveZcodeNativeDbPath } = require("./install-resolver");
|
|
35
|
+
const { readSqliteJsonRows, readSqliteJsonRowsAsync } = require("./sqlite-reader");
|
|
36
|
+
const { resolveZcodeNativeDbPath, resolveInstallPaths } = require("./install-resolver");
|
|
37
37
|
const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
|
|
38
38
|
const wsl = require("./wsl-probe");
|
|
39
39
|
const {
|
|
@@ -81,7 +81,10 @@ const {
|
|
|
81
81
|
// provider retries from repeated user requests.
|
|
82
82
|
// v20 also invalidates unchanged-stat caches after an observed database lock.
|
|
83
83
|
// v21 retains the request count when several ZCode events share a minute.
|
|
84
|
-
|
|
84
|
+
// v22 adds metadata-only OpenCode database sessions.
|
|
85
|
+
// v23 excludes database updates and user records from request durations.
|
|
86
|
+
// v24 keeps OpenCode usage dates at event time and fork history on its ancestor.
|
|
87
|
+
const SIDECAR_VERSION = 24;
|
|
85
88
|
const EDIT_TOOLS = new Set([
|
|
86
89
|
"apply_patch",
|
|
87
90
|
"edit",
|
|
@@ -1173,6 +1176,174 @@ async function scanGrokSession(filePath) {
|
|
|
1173
1176
|
});
|
|
1174
1177
|
}
|
|
1175
1178
|
|
|
1179
|
+
// Project and title metadata stay local. SQL projects only numeric usage and
|
|
1180
|
+
// identity fields; user/assistant bodies and tool arguments never enter JS.
|
|
1181
|
+
async function readOpencodeSessionInputs(dbPath) {
|
|
1182
|
+
const options = { label: "OpenCode", timeout: 30_000, maxBuffer: 50 * 1024 * 1024, readOnly: true, throwOnReadFailure: true };
|
|
1183
|
+
const query = (sql) => readSqliteJsonRowsAsync(dbPath, sql, options);
|
|
1184
|
+
const tables = new Set((await query("SELECT name FROM sqlite_master WHERE name IN ('session','session_v2','message','session_message')")).map((row) => row.name));
|
|
1185
|
+
const sessionRows = [];
|
|
1186
|
+
for (const table of ["session_v2", "session"]) {
|
|
1187
|
+
if (!tables.has(table)) continue;
|
|
1188
|
+
const columns = new Set((await query(`SELECT name FROM pragma_table_info('${table}')`)).map((row) => row.name));
|
|
1189
|
+
if (!columns.has("id")) continue;
|
|
1190
|
+
const selected = ["id", "parent_id", "directory", "title", "time_created", "time_updated"].filter((column) => columns.has(column));
|
|
1191
|
+
sessionRows.push(...await query(`SELECT ${selected.join(", ")} FROM ${table} ORDER BY id ASC`));
|
|
1192
|
+
}
|
|
1193
|
+
const messages = [];
|
|
1194
|
+
for (const table of ["message", "session_message"]) {
|
|
1195
|
+
if (!tables.has(table)) continue;
|
|
1196
|
+
const role = table === "session_message" ? "type" : "json_extract(data, '$.role')";
|
|
1197
|
+
messages.push(...await query(`SELECT id, session_id, time_created, time_updated, ${role} AS role,
|
|
1198
|
+
CASE WHEN ${role} = 'assistant' THEN json_object(
|
|
1199
|
+
'tokens', json_extract(data, '$.tokens'),
|
|
1200
|
+
'modelID', json_extract(data, '$.modelID'),
|
|
1201
|
+
'providerID', json_extract(data, '$.providerID'),
|
|
1202
|
+
'model', json_extract(data, '$.model'),
|
|
1203
|
+
'time', json_extract(data, '$.time'),
|
|
1204
|
+
'path', json_object('cwd', json_extract(data, '$.path.cwd'))
|
|
1205
|
+
) END AS metadata
|
|
1206
|
+
FROM ${table} WHERE ${role} IN ('user', 'assistant') ORDER BY time_created ASC, id ASC`));
|
|
1207
|
+
}
|
|
1208
|
+
return { sessionRows, messages };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async function scanOpencodeSessions(dbPaths) {
|
|
1212
|
+
const paths = [...new Set((Array.isArray(dbPaths) ? dbPaths : [dbPaths]).filter(Boolean))];
|
|
1213
|
+
const snapshots = [];
|
|
1214
|
+
const inputs = [];
|
|
1215
|
+
try {
|
|
1216
|
+
for (const dbPath of paths) {
|
|
1217
|
+
let effectivePath = dbPath;
|
|
1218
|
+
if (wsl.isUncPath(dbPath)) {
|
|
1219
|
+
try {
|
|
1220
|
+
const snapshot = wsl.snapshotSqliteDb(dbPath);
|
|
1221
|
+
snapshots.push(snapshot);
|
|
1222
|
+
effectivePath = snapshot.path;
|
|
1223
|
+
} catch { /* Direct read determines whether this mount supports SQLite. */ }
|
|
1224
|
+
}
|
|
1225
|
+
inputs.push(await readOpencodeSessionInputs(effectivePath));
|
|
1226
|
+
}
|
|
1227
|
+
const sessionRows = new Map();
|
|
1228
|
+
const messages = new Map();
|
|
1229
|
+
for (const input of inputs) {
|
|
1230
|
+
for (const row of input.sessionRows) if (!sessionRows.has(row.id)) sessionRows.set(row.id, row);
|
|
1231
|
+
for (const row of input.messages) {
|
|
1232
|
+
const key = `${row.session_id}\0${row.id}`;
|
|
1233
|
+
const previous = messages.get(key);
|
|
1234
|
+
if (!previous || Number(row.time_updated) >= Number(previous.time_updated)) messages.set(key, row);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const groups = new Map();
|
|
1238
|
+
const ancestors = new Map();
|
|
1239
|
+
const ancestorsOf = (id) => {
|
|
1240
|
+
if (ancestors.has(id)) return ancestors.get(id);
|
|
1241
|
+
const known = [];
|
|
1242
|
+
const seen = new Set([id]);
|
|
1243
|
+
let current = sessionRows.get(id);
|
|
1244
|
+
while (current?.parent_id && sessionRows.has(current.parent_id)) {
|
|
1245
|
+
if (seen.has(current.parent_id)) {
|
|
1246
|
+
ancestors.set(id, []);
|
|
1247
|
+
return [];
|
|
1248
|
+
}
|
|
1249
|
+
seen.add(current.parent_id);
|
|
1250
|
+
known.push(current.parent_id);
|
|
1251
|
+
current = sessionRows.get(current.parent_id);
|
|
1252
|
+
}
|
|
1253
|
+
ancestors.set(id, known);
|
|
1254
|
+
return known;
|
|
1255
|
+
};
|
|
1256
|
+
const sortedMessages = [...messages.values()]
|
|
1257
|
+
.sort((a, b) => Number(a.time_created) - Number(b.time_created))
|
|
1258
|
+
.map((row) => {
|
|
1259
|
+
const data = row.metadata ? JSON.parse(row.metadata) : {};
|
|
1260
|
+
const delta = normalizeOpencodeTokens(data.tokens);
|
|
1261
|
+
const fingerprint = delta && deriveOpencodeMessageFingerprint({ msg: data, totals: delta, source: "opencode" });
|
|
1262
|
+
return { row, data, delta, fingerprint };
|
|
1263
|
+
});
|
|
1264
|
+
// A fork may sort before its original when message creation times tie.
|
|
1265
|
+
// Select a known ancestor before aggregating; unknown/cyclic lineage keeps
|
|
1266
|
+
// the existing first-observed owner and never invents a parent connection.
|
|
1267
|
+
const owners = new Map();
|
|
1268
|
+
for (const { row, fingerprint } of sortedMessages) {
|
|
1269
|
+
if (!fingerprint) continue;
|
|
1270
|
+
const owner = owners.get(fingerprint);
|
|
1271
|
+
if (!owner || ancestorsOf(owner).includes(row.session_id)) owners.set(fingerprint, row.session_id);
|
|
1272
|
+
}
|
|
1273
|
+
for (const { row, data, delta, fingerprint } of sortedMessages) {
|
|
1274
|
+
const sessionId = String(row.session_id || "").trim();
|
|
1275
|
+
if (!sessionId) continue;
|
|
1276
|
+
let group = groups.get(sessionId);
|
|
1277
|
+
if (!group) {
|
|
1278
|
+
group = { bounds: emptyBounds(), tokens: emptyTotals(), models: new Map(), buckets: new Map(), spans: [], turns: 0, calls: 0, cwd: null };
|
|
1279
|
+
groups.set(sessionId, group);
|
|
1280
|
+
}
|
|
1281
|
+
if (delta && delta.total_tokens > 0) {
|
|
1282
|
+
const owner = fingerprint && owners.get(fingerprint);
|
|
1283
|
+
if (owner && owner !== sessionId) continue;
|
|
1284
|
+
const model = normalizeSessionModel(normalizeOpencodeModelFields(data).modelId) || "unknown";
|
|
1285
|
+
addTotals(group.tokens, delta);
|
|
1286
|
+
const usage = group.models.get(model) || { model, ...emptyTotals(), usage_events: 0 };
|
|
1287
|
+
addTotals(usage, delta);
|
|
1288
|
+
usage.usage_events += 1;
|
|
1289
|
+
group.models.set(model, usage);
|
|
1290
|
+
group.calls += 1;
|
|
1291
|
+
const timestampMs = coerceEpochMs(data.time?.completed) || coerceEpochMs(data.time?.created) || coerceEpochMs(row.time_created);
|
|
1292
|
+
if (timestampMs) {
|
|
1293
|
+
const timestamp = new Date(Math.floor(timestampMs / 60_000) * 60_000).toISOString();
|
|
1294
|
+
const key = `${timestamp}\0${model}`;
|
|
1295
|
+
const bucket = group.buckets.get(key) || { timestamp, model, ...emptyTotals(), usage_events: 0 };
|
|
1296
|
+
addTotals(bucket, delta);
|
|
1297
|
+
bucket.usage_events += 1;
|
|
1298
|
+
group.buckets.set(key, bucket);
|
|
1299
|
+
}
|
|
1300
|
+
if (!group.cwd && typeof data.path?.cwd === "string") group.cwd = data.path.cwd.trim() || null;
|
|
1301
|
+
}
|
|
1302
|
+
if (row.role === "user") group.turns += 1;
|
|
1303
|
+
const start = coerceEpochMs(data.time?.created) || coerceEpochMs(row.time_created);
|
|
1304
|
+
// DB time_updated can reflect a migration months after the request.
|
|
1305
|
+
// User records are instants; only an explicit assistant completion proves
|
|
1306
|
+
// execution time. Incomplete requests contribute their start point alone.
|
|
1307
|
+
const completed = row.role === "assistant" ? coerceEpochMs(data.time?.completed) : 0;
|
|
1308
|
+
const end = completed >= start ? completed : start;
|
|
1309
|
+
if (start) updateBounds(group.bounds, new Date(start).toISOString());
|
|
1310
|
+
if (end) updateBounds(group.bounds, new Date(end).toISOString());
|
|
1311
|
+
if (start && end >= start) group.spans.push([start, end]);
|
|
1312
|
+
}
|
|
1313
|
+
const records = [];
|
|
1314
|
+
for (const [sessionId, group] of groups) {
|
|
1315
|
+
const row = sessionRows.get(sessionId) || {};
|
|
1316
|
+
const cwd = typeof row.directory === "string" && row.directory.trim() ? row.directory.trim() : group.cwd;
|
|
1317
|
+
const modelUsage = [...group.models.values()];
|
|
1318
|
+
let activeMs = 0;
|
|
1319
|
+
let interval = null;
|
|
1320
|
+
for (const span of group.spans.sort((a, b) => a[0] - b[0])) {
|
|
1321
|
+
if (!interval) interval = [...span];
|
|
1322
|
+
else if (span[0] <= interval[1] + IDLE_GAP_MS) interval[1] = Math.max(interval[1], span[1]);
|
|
1323
|
+
else { activeMs += interval[1] - interval[0]; interval = [...span]; }
|
|
1324
|
+
}
|
|
1325
|
+
if (interval) activeMs += interval[1] - interval[0];
|
|
1326
|
+
records.push(finalizeRecord({
|
|
1327
|
+
version: SIDECAR_VERSION,
|
|
1328
|
+
session_hash: sessionHash("opencode", sessionId), session_id: sessionId,
|
|
1329
|
+
parent_session_id: typeof row.parent_id === "string" && row.parent_id.trim() ? row.parent_id.trim() : null,
|
|
1330
|
+
title: cleanSessionTitle(row.title), source: "opencode",
|
|
1331
|
+
project_key: projectKey(cwd && cwd.replace(/\\/g, "/"), paths[0]), project_ref: cwd || null,
|
|
1332
|
+
model: modelUsage.length > 1 ? "mixed" : (modelUsage[0]?.model || "unknown"),
|
|
1333
|
+
model_usage: modelUsage, usage_buckets: [...group.buckets.values()],
|
|
1334
|
+
...group.bounds, active_ms: activeMs, tokens: group.tokens,
|
|
1335
|
+
turns: group.turns, edit_turns: 0, retry_turns: 0,
|
|
1336
|
+
usage_events: group.calls, model_calls: group.calls,
|
|
1337
|
+
usage_precision: group.calls ? "reported" : "unavailable", cost_source: "model_pricing",
|
|
1338
|
+
provenance: { source: "local-session-log", confidence: "observed", retry_confidence: "unavailable", content_retained: false, usage: "opencode-db-metadata" },
|
|
1339
|
+
}));
|
|
1340
|
+
}
|
|
1341
|
+
return records;
|
|
1342
|
+
} finally {
|
|
1343
|
+
for (const snapshot of snapshots) snapshot.cleanup();
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1176
1347
|
// ZCode (Z.ai's CLI, an OpenCode fork) keeps everything in one SQLite DB at
|
|
1177
1348
|
// ~/.zcode/cli/db/db.sqlite: a `session` table (id/parent_id/directory/title),
|
|
1178
1349
|
// a `model_usage` table (per-request counters) and OpenCode's legacy `message`
|
|
@@ -1829,7 +2000,13 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
|
|
|
1829
2000
|
})
|
|
1830
2001
|
: { native: zcodeExisting(zcodeNativeDbPath), wsl: null };
|
|
1831
2002
|
const zcode = [...new Set(Object.values(zcodePaths).filter(Boolean))];
|
|
1832
|
-
|
|
2003
|
+
const opencodeNativeDir = env.OPENCODE_HOME || path.join(env.XDG_DATA_HOME || path.join(home, ".local", "share"), "opencode");
|
|
2004
|
+
const opencodeWslDir = zcodePlatform === "win32" && zcodeProbeWsl && wsl.shouldProbeWsl(env)
|
|
2005
|
+
? (deps.discoverWslHome || wsl.discoverWslHome)(".local/share/opencode", { env }) : null;
|
|
2006
|
+
const opencodePaths = resolveInstallPaths({ nativeValue: opencodeNativeDir, wslValue: opencodeWslDir }, env, { platform: zcodePlatform });
|
|
2007
|
+
const opencode = [...new Set(Object.values(opencodePaths).filter(Boolean)
|
|
2008
|
+
.map((dir) => path.join(dir, "opencode.db")).filter((dbPath) => fs.existsSync(dbPath)))];
|
|
2009
|
+
return { claude, codex: groupCodexFiles([...codex, ...archived]), grok, zcode, opencode, incomplete };
|
|
1833
2010
|
}
|
|
1834
2011
|
|
|
1835
2012
|
// Warn once per unreadable directory per process: the dashboard polls this
|
|
@@ -1934,7 +2111,7 @@ function analyticsEntryStatKey(source, filePath) {
|
|
|
1934
2111
|
const primary = filePaths[0];
|
|
1935
2112
|
return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(primary)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(primary)) || "missing"}`;
|
|
1936
2113
|
}
|
|
1937
|
-
if (source === "zcode") {
|
|
2114
|
+
if (source === "zcode" || source === "opencode") {
|
|
1938
2115
|
// A WAL-mode db can absorb writes without touching the main file's mtime,
|
|
1939
2116
|
// so the sidecar files are part of the db's identity for cache checks.
|
|
1940
2117
|
return `${sessionStat}|sidecars:${filePaths.map((filePath) =>
|
|
@@ -1967,13 +2144,15 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
1967
2144
|
// sidecar and metadata. TTL, signature and per-file reuse must all retry it.
|
|
1968
2145
|
const incompleteZcodePath = `${metaPath}.zcode-incomplete`;
|
|
1969
2146
|
const retryZcodeScan = fs.existsSync(incompleteZcodePath);
|
|
2147
|
+
const incompleteOpencodePath = `${metaPath}.opencode-incomplete`;
|
|
2148
|
+
const retryOpencodeScan = fs.existsSync(incompleteOpencodePath);
|
|
1970
2149
|
let previousMeta = null;
|
|
1971
2150
|
if (!force) {
|
|
1972
2151
|
try {
|
|
1973
2152
|
previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
|
1974
2153
|
const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
|
|
1975
2154
|
if (
|
|
1976
|
-
!retryZcodeScan && previousMeta.version === SIDECAR_VERSION &&
|
|
2155
|
+
!retryZcodeScan && !retryOpencodeScan && previousMeta.version === SIDECAR_VERSION &&
|
|
1977
2156
|
Number.isFinite(checkedAt) &&
|
|
1978
2157
|
Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
|
|
1979
2158
|
) {
|
|
@@ -2007,9 +2186,9 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2007
2186
|
...discovered.grok.map(grokSummaryPathFor),
|
|
2008
2187
|
...discovered.grok.map(grokSignalsPathFor),
|
|
2009
2188
|
// db + its WAL sidecars: the trio is one logical file for change detection.
|
|
2010
|
-
...(discovered.zcode || []).flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
|
|
2189
|
+
...[...(discovered.zcode || []), ...(discovered.opencode || [])].flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
|
|
2011
2190
|
]);
|
|
2012
|
-
if (!force && !retryZcodeScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
|
|
2191
|
+
if (!force && !retryZcodeScan && !retryOpencodeScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
|
|
2013
2192
|
await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
|
|
2014
2193
|
return readSidecar(sidecarPath);
|
|
2015
2194
|
}
|
|
@@ -2030,26 +2209,29 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2030
2209
|
...discovered.codex.map((filePaths) => ({ source: "codex", filePath: filePaths, scan: scanCodexSession })),
|
|
2031
2210
|
...discovered.grok.map((filePath) => ({ source: "grok", filePath, scan: scanGrokSession })),
|
|
2032
2211
|
...(discovered.zcode.length ? [{ source: "zcode", filePath: discovered.zcode, scan: scanZcodeSessions }] : []),
|
|
2212
|
+
...(discovered.opencode.length ? [{ source: "opencode", filePath: discovered.opencode, scan: scanOpencodeSessions }] : []),
|
|
2033
2213
|
];
|
|
2034
2214
|
// Files we could not turn into a row (permission denied, half-written line,
|
|
2035
2215
|
// vanished mid-scan). Swallowing these silently made sessions disappear with
|
|
2036
2216
|
// no signal at all; count them so the API can say so.
|
|
2037
2217
|
let skippedFiles = 0;
|
|
2038
2218
|
let incompleteZcodeScan = false;
|
|
2219
|
+
let incompleteOpencodeScan = false;
|
|
2039
2220
|
for (const entry of entries) {
|
|
2040
2221
|
const cacheKey = sessionFileCacheKey(entry.source, entry.filePath);
|
|
2041
2222
|
const statKey = analyticsEntryStatKey(entry.source, entry.filePath);
|
|
2042
2223
|
if (!statKey) {
|
|
2043
2224
|
skippedFiles += 1;
|
|
2044
2225
|
if (entry.source === "zcode") incompleteZcodeScan = true;
|
|
2226
|
+
if (entry.source === "opencode") incompleteOpencodeScan = true;
|
|
2045
2227
|
continue;
|
|
2046
2228
|
}
|
|
2047
2229
|
let rows = null;
|
|
2048
|
-
if (!force && !(entry.source === "zcode" && retryZcodeScan) && previousFiles[cacheKey]?.stat_key === statKey) {
|
|
2049
|
-
if (entry.source === "zcode") {
|
|
2230
|
+
if (!force && !(entry.source === "zcode" && retryZcodeScan) && !(entry.source === "opencode" && retryOpencodeScan) && previousFiles[cacheKey]?.stat_key === statKey) {
|
|
2231
|
+
if (entry.source === "zcode" || entry.source === "opencode") {
|
|
2050
2232
|
// One db scans to many cached rows; recover them all from the previous
|
|
2051
2233
|
// pass. An empty recovery means the sidecar lost its rows — rescan.
|
|
2052
|
-
const cached = previousRows.filter((row) => row?.source ===
|
|
2234
|
+
const cached = previousRows.filter((row) => row?.source === entry.source);
|
|
2053
2235
|
rows = cached.length ? cached : null;
|
|
2054
2236
|
} else {
|
|
2055
2237
|
const row = previousRowsByFile.get(cacheKey) || null;
|
|
@@ -2065,6 +2247,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2065
2247
|
// pretend it never existed either.
|
|
2066
2248
|
skippedFiles += 1;
|
|
2067
2249
|
if (entry.source === "zcode") incompleteZcodeScan = true;
|
|
2250
|
+
if (entry.source === "opencode") incompleteOpencodeScan = true;
|
|
2068
2251
|
if (!process.env.NODE_TEST_CONTEXT) {
|
|
2069
2252
|
console.warn(`[session-analytics] skipped ${entry.source} session file: ${error?.message || error}`);
|
|
2070
2253
|
}
|
|
@@ -2078,8 +2261,8 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2078
2261
|
// so key each by the db path plus its session id; a bare session id as a
|
|
2079
2262
|
// separate path element would be path.resolve()d against the process cwd
|
|
2080
2263
|
// and never match across runs.
|
|
2081
|
-
row._cache_key = entry.source === "zcode" && row.session_id
|
|
2082
|
-
? `${cacheKey}#${sessionHash(
|
|
2264
|
+
row._cache_key = (entry.source === "zcode" || entry.source === "opencode") && row.session_id
|
|
2265
|
+
? `${cacheKey}#${sessionHash(entry.source, row.session_id)}`
|
|
2083
2266
|
: cacheKey;
|
|
2084
2267
|
sessions.push(row);
|
|
2085
2268
|
}
|
|
@@ -2093,10 +2276,15 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2093
2276
|
sessions.push(...readSidecar(sidecarPath).filter((row) =>
|
|
2094
2277
|
row.source === "zcode" && row.version === SIDECAR_VERSION));
|
|
2095
2278
|
}
|
|
2279
|
+
if (incompleteOpencodeScan) {
|
|
2280
|
+
await writeAtomic(incompleteOpencodePath, "1\n");
|
|
2281
|
+
sessions.push(...readSidecar(sidecarPath).filter((row) =>
|
|
2282
|
+
row.source === "opencode" && row.version === SIDECAR_VERSION));
|
|
2283
|
+
}
|
|
2096
2284
|
sessions.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
|
|
2097
2285
|
// A failed database scan is an incomplete inventory. Keep the last complete
|
|
2098
2286
|
// snapshot and signature so an unchanged database is retried next build.
|
|
2099
|
-
if (incompleteDirs.length === 0 && !incompleteZcodeScan) {
|
|
2287
|
+
if (incompleteDirs.length === 0 && !incompleteZcodeScan && !incompleteOpencodeScan) {
|
|
2100
2288
|
const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
|
|
2101
2289
|
await writeAtomic(sidecarPath, content);
|
|
2102
2290
|
const generatedAt = new Date().toISOString();
|
|
@@ -2108,6 +2296,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2108
2296
|
files: nextFiles,
|
|
2109
2297
|
})}\n`);
|
|
2110
2298
|
await fsp.rm(incompleteZcodePath, { force: true });
|
|
2299
|
+
await fsp.rm(incompleteOpencodePath, { force: true });
|
|
2111
2300
|
}
|
|
2112
2301
|
// Non-enumerable so the array still behaves exactly like a plain row list
|
|
2113
2302
|
// for every existing caller (map/filter/JSON of the rows is unaffected).
|
|
@@ -2285,7 +2474,7 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
2285
2474
|
if (key === primaryModel) agg.retries += finite(row.retry_turns);
|
|
2286
2475
|
byModel.set(key, agg);
|
|
2287
2476
|
}
|
|
2288
|
-
if ((row.source === "codex" || row.source === "zcode") && row.parent_session_hash) {
|
|
2477
|
+
if ((row.source === "codex" || row.source === "zcode" || row.source === "opencode") && row.parent_session_hash) {
|
|
2289
2478
|
const name = row.agent_role || row.agent_nickname || "subagent";
|
|
2290
2479
|
const sub = subagents.get(name) || {
|
|
2291
2480
|
name,
|
|
@@ -2304,7 +2493,7 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
2304
2493
|
}
|
|
2305
2494
|
// Codex and ZCode children are first-class rows. A spawn call in the parent is
|
|
2306
2495
|
// a control-plane event, not token usage, so do not mix in an estimate.
|
|
2307
|
-
if (row.source === "codex" || row.source === "zcode") continue;
|
|
2496
|
+
if (row.source === "codex" || row.source === "zcode" || row.source === "opencode") continue;
|
|
2308
2497
|
for (const [name, calls] of Object.entries(row.subagent_types || {})) {
|
|
2309
2498
|
const sub = subagents.get(name) || { name, calls: 0, sessions: 0, total_tokens: 0, cost_usd: 0 };
|
|
2310
2499
|
sub.calls += finite(calls);
|
|
@@ -2388,6 +2577,7 @@ function resumeCommandFor(source, sessionId) {
|
|
|
2388
2577
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(id)) return null;
|
|
2389
2578
|
if (source === "claude") return `claude --resume ${id}`;
|
|
2390
2579
|
if (source === "codex") return `codex resume ${id}`;
|
|
2580
|
+
if (source === "opencode") return `opencode --session ${id}`;
|
|
2391
2581
|
// Grok Build: `grok --resume <session-id>` (also accepts a title). The
|
|
2392
2582
|
// command must still be run from the session's project_ref cwd.
|
|
2393
2583
|
if (source === "grok") return `grok --resume ${id}`;
|
|
@@ -2578,7 +2768,7 @@ function annotateThreadUsage(rows) {
|
|
|
2578
2768
|
const identity = (row, id = row.session_id) => `${row.source}\u0000${id}`;
|
|
2579
2769
|
|
|
2580
2770
|
for (const row of rows || []) {
|
|
2581
|
-
if ((row?.source !== "codex" && row?.source !== "zcode") || typeof row.session_id !== "string" || !row.session_id) continue;
|
|
2771
|
+
if ((row?.source !== "codex" && row?.source !== "zcode" && row?.source !== "opencode") || typeof row.session_id !== "string" || !row.session_id) continue;
|
|
2582
2772
|
const existing = byId.get(identity(row));
|
|
2583
2773
|
if (existing && existing !== row) {
|
|
2584
2774
|
existing.parent_link_conflict = true;
|
|
@@ -2656,7 +2846,7 @@ function annotateThreadUsage(rows) {
|
|
|
2656
2846
|
}
|
|
2657
2847
|
|
|
2658
2848
|
for (const row of rows || []) {
|
|
2659
|
-
const threadAware = (row.source === "codex" || row.source === "zcode")
|
|
2849
|
+
const threadAware = (row.source === "codex" || row.source === "zcode" || row.source === "opencode")
|
|
2660
2850
|
&& typeof row.session_id === "string" && row.session_id;
|
|
2661
2851
|
row.own_total_tokens = finite(row.total_tokens);
|
|
2662
2852
|
row.own_cost_usd = finite(row.cost_usd);
|
|
@@ -2730,6 +2920,7 @@ module.exports = {
|
|
|
2730
2920
|
scanCodexSession,
|
|
2731
2921
|
scanGrokSession,
|
|
2732
2922
|
scanZcodeSessions,
|
|
2923
|
+
scanOpencodeSessions,
|
|
2733
2924
|
buildSessionAnalytics,
|
|
2734
2925
|
summarizeSessions,
|
|
2735
2926
|
listSessionsForBrowser,
|