@chrysb/alphaclaw 0.3.5-beta.1 → 0.4.1-beta.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/bin/alphaclaw.js +1 -31
- package/lib/public/assets/icons/google_icon.svg +8 -0
- package/lib/public/css/explorer.css +53 -0
- package/lib/public/css/shell.css +21 -19
- package/lib/public/css/theme.css +17 -0
- package/lib/public/js/app.js +205 -109
- package/lib/public/js/components/credentials-modal.js +36 -8
- package/lib/public/js/components/file-tree.js +212 -22
- package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
- package/lib/public/js/components/file-viewer/index.js +47 -6
- package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
- package/lib/public/js/components/file-viewer/status-banners.js +11 -6
- package/lib/public/js/components/file-viewer/toolbar.js +56 -1
- package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
- package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
- package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
- package/lib/public/js/components/file-viewer/use-file-viewer.js +142 -15
- package/lib/public/js/components/google/account-row.js +131 -0
- package/lib/public/js/components/google/add-account-modal.js +93 -0
- package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
- package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
- package/lib/public/js/components/google/index.js +553 -0
- package/lib/public/js/components/google/use-gmail-watch.js +140 -0
- package/lib/public/js/components/google/use-google-accounts.js +41 -0
- package/lib/public/js/components/icons.js +26 -0
- package/lib/public/js/components/scope-picker.js +1 -1
- package/lib/public/js/components/sidebar-git-panel.js +48 -20
- package/lib/public/js/components/sidebar.js +93 -75
- package/lib/public/js/components/toast.js +11 -7
- package/lib/public/js/components/usage-tab/constants.js +31 -0
- package/lib/public/js/components/usage-tab/formatters.js +24 -0
- package/lib/public/js/components/usage-tab/index.js +72 -0
- package/lib/public/js/components/usage-tab/overview-section.js +147 -0
- package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
- package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
- package/lib/public/js/components/webhooks.js +182 -129
- package/lib/public/js/lib/api.js +178 -9
- package/lib/public/js/lib/browse-file-policies.js +29 -11
- package/lib/public/js/lib/format.js +71 -0
- package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
- package/lib/public/shared/browse-file-policies.json +13 -0
- package/lib/server/constants.js +47 -7
- package/lib/server/gmail-push.js +109 -0
- package/lib/server/gmail-serve.js +254 -0
- package/lib/server/gmail-watch.js +725 -0
- package/lib/server/google-state.js +317 -0
- package/lib/server/helpers.js +17 -11
- package/lib/server/internal-files-migration.js +31 -3
- package/lib/server/onboarding/github.js +21 -2
- package/lib/server/onboarding/index.js +1 -3
- package/lib/server/onboarding/openclaw.js +3 -0
- package/lib/server/onboarding/workspace.js +40 -0
- package/lib/server/routes/browse/index.js +90 -2
- package/lib/server/routes/gmail.js +128 -0
- package/lib/server/routes/google.js +433 -213
- package/lib/server/routes/system.js +107 -0
- package/lib/server/routes/usage.js +29 -2
- package/lib/server/routes/webhooks.js +52 -17
- package/lib/server/usage-db.js +283 -15
- package/lib/server/watchdog.js +66 -0
- package/lib/server/webhook-middleware.js +99 -1
- package/lib/server/webhooks.js +214 -65
- package/lib/server.js +27 -0
- package/lib/setup/gitignore +6 -0
- package/lib/setup/hourly-git-sync.sh +29 -2
- package/package.json +1 -1
- package/lib/public/js/components/google.js +0 -228
- package/lib/public/js/components/usage-tab.js +0 -531
|
@@ -50,6 +50,63 @@ const registerSystemRoutes = ({
|
|
|
50
50
|
`${schedule} root bash "${kSystemCronScriptPath}" >> /var/log/openclaw-hourly-sync.log 2>&1`,
|
|
51
51
|
"",
|
|
52
52
|
].join("\n");
|
|
53
|
+
const shellEscapeArg = (value) => {
|
|
54
|
+
const safeValue = String(value || "");
|
|
55
|
+
return `'${safeValue.replace(/'/g, `'\\''`)}'`;
|
|
56
|
+
};
|
|
57
|
+
const parseJsonFromStdout = (stdout) => {
|
|
58
|
+
const raw = String(stdout || "").trim();
|
|
59
|
+
if (!raw) return null;
|
|
60
|
+
const candidateStarts = [raw.indexOf("{"), raw.indexOf("[")].filter((idx) => idx >= 0);
|
|
61
|
+
for (const start of candidateStarts) {
|
|
62
|
+
const candidate = raw.slice(start);
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(candidate);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
const buildSessionLabel = (sessionRow = {}) => {
|
|
70
|
+
const key = String(sessionRow?.key || "");
|
|
71
|
+
if (key === "agent:main:main") return "Main agent thread";
|
|
72
|
+
const telegramMatch = key.match(/:telegram:direct:([^:]+)$/);
|
|
73
|
+
if (telegramMatch) {
|
|
74
|
+
return `Telegram ${telegramMatch[1]}`;
|
|
75
|
+
}
|
|
76
|
+
const directMatch = key.match(/:direct:([^:]+)$/);
|
|
77
|
+
if (directMatch) {
|
|
78
|
+
return `Direct ${directMatch[1]}`;
|
|
79
|
+
}
|
|
80
|
+
return key || "Session";
|
|
81
|
+
};
|
|
82
|
+
const listSendableAgentSessions = async () => {
|
|
83
|
+
const result = await clawCmd("sessions --json", { quiet: true });
|
|
84
|
+
if (!result.ok) {
|
|
85
|
+
throw new Error(result.stderr || "Could not load agent sessions");
|
|
86
|
+
}
|
|
87
|
+
const payload = parseJsonFromStdout(result.stdout);
|
|
88
|
+
const sessions = Array.isArray(payload?.sessions) ? payload.sessions : [];
|
|
89
|
+
return sessions
|
|
90
|
+
.filter((sessionRow) => {
|
|
91
|
+
const key = String(sessionRow?.key || "").toLowerCase();
|
|
92
|
+
if (!key) return false;
|
|
93
|
+
if (key.includes(":hook:") || key.includes(":cron:")) return false;
|
|
94
|
+
return true;
|
|
95
|
+
})
|
|
96
|
+
.map((sessionRow) => {
|
|
97
|
+
const key = String(sessionRow?.key || "");
|
|
98
|
+
const telegramMatch = key.match(/:telegram:direct:([^:]+)$/);
|
|
99
|
+
return {
|
|
100
|
+
key,
|
|
101
|
+
sessionId: String(sessionRow?.sessionId || ""),
|
|
102
|
+
updatedAt: Number(sessionRow?.updatedAt) || 0,
|
|
103
|
+
label: buildSessionLabel(sessionRow),
|
|
104
|
+
replyChannel: telegramMatch ? "telegram" : "",
|
|
105
|
+
replyTo: telegramMatch ? String(telegramMatch[1] || "") : "",
|
|
106
|
+
};
|
|
107
|
+
})
|
|
108
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
109
|
+
};
|
|
53
110
|
const readSystemCronConfig = () => {
|
|
54
111
|
try {
|
|
55
112
|
const raw = fs.readFileSync(kSystemCronConfigPath, "utf8");
|
|
@@ -258,6 +315,56 @@ const registerSystemRoutes = ({
|
|
|
258
315
|
res.json(result);
|
|
259
316
|
});
|
|
260
317
|
|
|
318
|
+
app.get("/api/agent/sessions", async (req, res) => {
|
|
319
|
+
try {
|
|
320
|
+
const sessions = await listSendableAgentSessions();
|
|
321
|
+
return res.json({ ok: true, sessions });
|
|
322
|
+
} catch (err) {
|
|
323
|
+
return res.status(502).json({ ok: false, error: err.message });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
app.post("/api/agent/message", async (req, res) => {
|
|
328
|
+
const rawMessage = String(req.body?.message || "");
|
|
329
|
+
const message = rawMessage.trim();
|
|
330
|
+
const sessionKey = String(req.body?.sessionKey || "").trim();
|
|
331
|
+
if (!message) {
|
|
332
|
+
return res.status(400).json({ ok: false, error: "message is required" });
|
|
333
|
+
}
|
|
334
|
+
if (message.length > 4000) {
|
|
335
|
+
return res
|
|
336
|
+
.status(400)
|
|
337
|
+
.json({ ok: false, error: "message must be 4000 characters or fewer" });
|
|
338
|
+
}
|
|
339
|
+
let command = `agent --agent main --message ${shellEscapeArg(message)}`;
|
|
340
|
+
if (sessionKey) {
|
|
341
|
+
let selectedSession = null;
|
|
342
|
+
try {
|
|
343
|
+
const sessions = await listSendableAgentSessions();
|
|
344
|
+
selectedSession = sessions.find((sessionRow) => sessionRow.key === sessionKey) || null;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
return res.status(502).json({ ok: false, error: err.message });
|
|
347
|
+
}
|
|
348
|
+
if (!selectedSession) {
|
|
349
|
+
return res.status(400).json({ ok: false, error: "Selected session was not found" });
|
|
350
|
+
}
|
|
351
|
+
if (selectedSession.replyChannel && selectedSession.replyTo) {
|
|
352
|
+
command +=
|
|
353
|
+
` --deliver --reply-channel ${shellEscapeArg(selectedSession.replyChannel)}` +
|
|
354
|
+
` --reply-to ${shellEscapeArg(selectedSession.replyTo)}`;
|
|
355
|
+
} else if (selectedSession.sessionId) {
|
|
356
|
+
command += ` --session-id ${shellEscapeArg(selectedSession.sessionId)}`;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const result = await clawCmd(command, { quiet: true });
|
|
360
|
+
if (!result.ok) {
|
|
361
|
+
return res
|
|
362
|
+
.status(502)
|
|
363
|
+
.json({ ok: false, error: result.stderr || "Could not send message to agent" });
|
|
364
|
+
}
|
|
365
|
+
return res.json({ ok: true, stdout: result.stdout || "" });
|
|
366
|
+
});
|
|
367
|
+
|
|
261
368
|
app.get("/api/gateway/dashboard", async (req, res) => {
|
|
262
369
|
if (!isOnboarded()) return res.json({ ok: false, url: "/openclaw" });
|
|
263
370
|
const result = await clawCmd("dashboard --no-open");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const topicRegistry = require("../topic-registry");
|
|
2
2
|
|
|
3
3
|
const kSummaryCacheTtlMs = 60 * 1000;
|
|
4
|
+
const kClientTimeZoneHeader = "x-client-timezone";
|
|
4
5
|
|
|
5
6
|
const parsePositiveInt = (value, fallbackValue) => {
|
|
6
7
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
@@ -8,6 +9,15 @@ const parsePositiveInt = (value, fallbackValue) => {
|
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
const createSummaryCache = () => new Map();
|
|
12
|
+
const toTitleLabel = (value) => {
|
|
13
|
+
const raw = String(value || "").trim();
|
|
14
|
+
if (!raw) return "";
|
|
15
|
+
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
16
|
+
};
|
|
17
|
+
const isUuidLike = (value) =>
|
|
18
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
19
|
+
String(value || "").trim(),
|
|
20
|
+
);
|
|
11
21
|
|
|
12
22
|
// Parse "agent:main:telegram:group:-123:topic:42" into structured labels.
|
|
13
23
|
const parseSessionLabels = (sessionKey) => {
|
|
@@ -56,6 +66,20 @@ const parseSessionLabels = (sessionKey) => {
|
|
|
56
66
|
});
|
|
57
67
|
}
|
|
58
68
|
}
|
|
69
|
+
const hookIndex = parts.indexOf("hook");
|
|
70
|
+
if (hookIndex !== -1) {
|
|
71
|
+
labels.push({ label: "Hook", tone: "purple" });
|
|
72
|
+
const hookName = String(parts[hookIndex + 1] || "").trim();
|
|
73
|
+
if (hookName && !isUuidLike(hookName)) {
|
|
74
|
+
labels.push({
|
|
75
|
+
label: toTitleLabel(hookName),
|
|
76
|
+
tone: "gray",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (parts.includes("cron")) {
|
|
81
|
+
labels.push({ label: "Cron", tone: "blue" });
|
|
82
|
+
}
|
|
59
83
|
|
|
60
84
|
return labels.length > 0 ? labels : null;
|
|
61
85
|
};
|
|
@@ -78,14 +102,17 @@ const registerUsageRoutes = ({
|
|
|
78
102
|
app.get("/api/usage/summary", requireAuth, (req, res) => {
|
|
79
103
|
try {
|
|
80
104
|
const days = parsePositiveInt(req.query.days, 30);
|
|
81
|
-
const
|
|
105
|
+
const timeZone = String(
|
|
106
|
+
req.get(kClientTimeZoneHeader) || req.query.timeZone || "",
|
|
107
|
+
).trim();
|
|
108
|
+
const cacheKey = `${days}:${timeZone || "UTC"}`;
|
|
82
109
|
const cached = summaryCache.get(cacheKey);
|
|
83
110
|
const now = Date.now();
|
|
84
111
|
if (cached && now - cached.cachedAt <= kSummaryCacheTtlMs) {
|
|
85
112
|
res.json({ ok: true, ...cached.payload, cached: true });
|
|
86
113
|
return;
|
|
87
114
|
}
|
|
88
|
-
const summary = getDailySummary({ days });
|
|
115
|
+
const summary = getDailySummary({ days, timeZone });
|
|
89
116
|
const payload = { summary };
|
|
90
117
|
summaryCache.set(cacheKey, { payload, cachedAt: now });
|
|
91
118
|
res.json({ ok: true, ...payload, cached: false });
|
|
@@ -6,9 +6,12 @@ const {
|
|
|
6
6
|
validateWebhookName,
|
|
7
7
|
} = require("../webhooks");
|
|
8
8
|
|
|
9
|
-
const isFiniteInteger = (value) =>
|
|
9
|
+
const isFiniteInteger = (value) =>
|
|
10
|
+
Number.isFinite(value) && Number.isInteger(value);
|
|
10
11
|
const parseBooleanFlag = (value) => {
|
|
11
|
-
const normalized = String(value == null ? "" : value)
|
|
12
|
+
const normalized = String(value == null ? "" : value)
|
|
13
|
+
.trim()
|
|
14
|
+
.toLowerCase();
|
|
12
15
|
return ["1", "true", "yes", "on"].includes(normalized);
|
|
13
16
|
};
|
|
14
17
|
|
|
@@ -40,20 +43,24 @@ const mergeWebhookAndSummary = ({ webhook, summary }) => {
|
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
const normalizeStatusFilter = (rawStatus) => {
|
|
43
|
-
const status = String(rawStatus || "all")
|
|
46
|
+
const status = String(rawStatus || "all")
|
|
47
|
+
.trim()
|
|
48
|
+
.toLowerCase();
|
|
44
49
|
if (["all", "success", "error"].includes(status)) return status;
|
|
45
50
|
return "all";
|
|
46
51
|
};
|
|
47
52
|
|
|
48
53
|
const buildWebhookUrls = ({ baseUrl, name }) => {
|
|
49
54
|
const fullUrl = `${baseUrl}/hooks/${name}`;
|
|
50
|
-
const token = String(
|
|
55
|
+
const token = String(
|
|
56
|
+
process.env.OPENCLAW_HOOKS_TOKEN || process.env.WEBHOOK_TOKEN || "",
|
|
57
|
+
).trim();
|
|
51
58
|
const queryStringUrl = token
|
|
52
59
|
? `${fullUrl}?token=${encodeURIComponent(token)}`
|
|
53
|
-
: `${fullUrl}?token=<
|
|
60
|
+
: `${fullUrl}?token=<OPENCLAW_HOOKS_TOKEN>`;
|
|
54
61
|
const authHeaderValue = token
|
|
55
62
|
? `Authorization: Bearer ${token}`
|
|
56
|
-
: "Authorization: Bearer <
|
|
63
|
+
: "Authorization: Bearer <OPENCLAW_HOOKS_TOKEN>";
|
|
57
64
|
return { fullUrl, queryStringUrl, authHeaderValue, hasRuntimeToken: !!token };
|
|
58
65
|
};
|
|
59
66
|
|
|
@@ -71,7 +78,8 @@ const registerWebhookRoutes = ({
|
|
|
71
78
|
getSnapshot: async () => ({ restartRequired: false }),
|
|
72
79
|
};
|
|
73
80
|
const resolvedRestartState = restartRequiredState || fallbackRestartState;
|
|
74
|
-
const { markRequired: markRestartRequired, getSnapshot: getRestartSnapshot } =
|
|
81
|
+
const { markRequired: markRestartRequired, getSnapshot: getRestartSnapshot } =
|
|
82
|
+
resolvedRestartState;
|
|
75
83
|
const runWebhookGitSync = async (action, name) => {
|
|
76
84
|
if (typeof shellCmd !== "function") return null;
|
|
77
85
|
const safeName = String(name || "").trim();
|
|
@@ -95,7 +103,8 @@ const registerWebhookRoutes = ({
|
|
|
95
103
|
mergeWebhookAndSummary({
|
|
96
104
|
webhook,
|
|
97
105
|
summary: summaryByHook.get(webhook.name),
|
|
98
|
-
})
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
99
108
|
res.json({ ok: true, webhooks });
|
|
100
109
|
} catch (err) {
|
|
101
110
|
res.status(500).json({ ok: false, error: err.message });
|
|
@@ -106,8 +115,11 @@ const registerWebhookRoutes = ({
|
|
|
106
115
|
try {
|
|
107
116
|
const name = validateWebhookName(req.params.name);
|
|
108
117
|
const detail = getWebhookDetail({ fs, constants, name });
|
|
109
|
-
if (!detail)
|
|
110
|
-
|
|
118
|
+
if (!detail)
|
|
119
|
+
return res.status(404).json({ ok: false, error: "Webhook not found" });
|
|
120
|
+
const summary = webhooksDb
|
|
121
|
+
.getHookSummaries()
|
|
122
|
+
.find((item) => item.hookName === name);
|
|
111
123
|
const merged = mergeWebhookAndSummary({ webhook: detail, summary });
|
|
112
124
|
const baseUrl = getBaseUrl(req);
|
|
113
125
|
const urls = buildWebhookUrls({ baseUrl, name });
|
|
@@ -151,7 +163,9 @@ const registerWebhookRoutes = ({
|
|
|
151
163
|
syncWarning,
|
|
152
164
|
});
|
|
153
165
|
} catch (err) {
|
|
154
|
-
const status = String(err.message || "").includes("already exists")
|
|
166
|
+
const status = String(err.message || "").includes("already exists")
|
|
167
|
+
? 409
|
|
168
|
+
: 400;
|
|
155
169
|
return res.status(status).json({ ok: false, error: err.message });
|
|
156
170
|
}
|
|
157
171
|
});
|
|
@@ -159,9 +173,23 @@ const registerWebhookRoutes = ({
|
|
|
159
173
|
app.delete("/api/webhooks/:name", async (req, res) => {
|
|
160
174
|
try {
|
|
161
175
|
const name = validateWebhookName(req.params.name);
|
|
162
|
-
const deleteTransformDir = parseBooleanFlag(
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
const deleteTransformDir = parseBooleanFlag(
|
|
177
|
+
req?.body?.deleteTransformDir,
|
|
178
|
+
);
|
|
179
|
+
const deletion = deleteWebhook({
|
|
180
|
+
fs,
|
|
181
|
+
constants,
|
|
182
|
+
name,
|
|
183
|
+
deleteTransformDir,
|
|
184
|
+
});
|
|
185
|
+
if (deletion?.managed) {
|
|
186
|
+
return res.status(409).json({
|
|
187
|
+
ok: false,
|
|
188
|
+
error: `Webhook "${name}" is managed by system setup and cannot be deleted`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (!deletion?.removed)
|
|
192
|
+
return res.status(404).json({ ok: false, error: "Webhook not found" });
|
|
165
193
|
const deletedRequestCount = webhooksDb.deleteRequestsByHook(name);
|
|
166
194
|
const syncWarning = await runWebhookGitSync("delete", name);
|
|
167
195
|
markRestartRequired("webhooks");
|
|
@@ -184,9 +212,15 @@ const registerWebhookRoutes = ({
|
|
|
184
212
|
const limit = Number.parseInt(String(req.query.limit || 50), 10);
|
|
185
213
|
const offset = Number.parseInt(String(req.query.offset || 0), 10);
|
|
186
214
|
const status = normalizeStatusFilter(req.query.status);
|
|
187
|
-
const hasBadPaging =
|
|
215
|
+
const hasBadPaging =
|
|
216
|
+
!isFiniteInteger(limit) ||
|
|
217
|
+
limit <= 0 ||
|
|
218
|
+
!isFiniteInteger(offset) ||
|
|
219
|
+
offset < 0;
|
|
188
220
|
if (hasBadPaging) {
|
|
189
|
-
return res
|
|
221
|
+
return res
|
|
222
|
+
.status(400)
|
|
223
|
+
.json({ ok: false, error: "Invalid limit/offset" });
|
|
190
224
|
}
|
|
191
225
|
const requests = webhooksDb.getRequests(name, { limit, offset, status });
|
|
192
226
|
return res.json({ ok: true, requests });
|
|
@@ -203,7 +237,8 @@ const registerWebhookRoutes = ({
|
|
|
203
237
|
return res.status(400).json({ ok: false, error: "Invalid request id" });
|
|
204
238
|
}
|
|
205
239
|
const request = webhooksDb.getRequestById(name, requestId);
|
|
206
|
-
if (!request)
|
|
240
|
+
if (!request)
|
|
241
|
+
return res.status(404).json({ ok: false, error: "Request not found" });
|
|
207
242
|
return res.json({ ok: true, request });
|
|
208
243
|
} catch (err) {
|
|
209
244
|
return res.status(400).json({ ok: false, error: err.message });
|
package/lib/server/usage-db.js
CHANGED
|
@@ -7,7 +7,10 @@ const kMaxSessionLimit = 200;
|
|
|
7
7
|
const kDefaultDays = 30;
|
|
8
8
|
const kDefaultMaxPoints = 100;
|
|
9
9
|
const kMaxMaxPoints = 1000;
|
|
10
|
+
const kDayMs = 24 * 60 * 60 * 1000;
|
|
10
11
|
const kTokensPerMillion = 1_000_000;
|
|
12
|
+
const kUtcTimeZone = "UTC";
|
|
13
|
+
const kDayKeyFormatterCache = new Map();
|
|
11
14
|
const kGlobalModelPricing = {
|
|
12
15
|
"claude-opus-4-6": { input: 15.0, output: 75.0 },
|
|
13
16
|
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
|
@@ -31,6 +34,39 @@ const coerceInt = (value, fallbackValue = 0) => {
|
|
|
31
34
|
const clampInt = (value, minValue, maxValue, fallbackValue) =>
|
|
32
35
|
Math.min(maxValue, Math.max(minValue, coerceInt(value, fallbackValue)));
|
|
33
36
|
|
|
37
|
+
const normalizeTimeZone = (value) => {
|
|
38
|
+
const raw = String(value || "").trim();
|
|
39
|
+
if (!raw) return kUtcTimeZone;
|
|
40
|
+
try {
|
|
41
|
+
new Intl.DateTimeFormat("en-US", { timeZone: raw });
|
|
42
|
+
return raw;
|
|
43
|
+
} catch {
|
|
44
|
+
return kUtcTimeZone;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const getDayKeyFormatter = (timeZone) => {
|
|
49
|
+
if (kDayKeyFormatterCache.has(timeZone)) {
|
|
50
|
+
return kDayKeyFormatterCache.get(timeZone);
|
|
51
|
+
}
|
|
52
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
53
|
+
timeZone,
|
|
54
|
+
year: "numeric",
|
|
55
|
+
month: "2-digit",
|
|
56
|
+
day: "2-digit",
|
|
57
|
+
});
|
|
58
|
+
kDayKeyFormatterCache.set(timeZone, formatter);
|
|
59
|
+
return formatter;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const toTimeZoneDayKey = (timestampMs, timeZone) => {
|
|
63
|
+
const parts = getDayKeyFormatter(timeZone).formatToParts(new Date(timestampMs));
|
|
64
|
+
const year = parts.find((part) => part.type === "year")?.value || "0000";
|
|
65
|
+
const month = parts.find((part) => part.type === "month")?.value || "01";
|
|
66
|
+
const day = parts.find((part) => part.type === "day")?.value || "01";
|
|
67
|
+
return `${year}-${month}-${day}`;
|
|
68
|
+
};
|
|
69
|
+
|
|
34
70
|
const resolvePricing = (model) => {
|
|
35
71
|
const normalized = String(model || "").toLowerCase();
|
|
36
72
|
if (!normalized) return null;
|
|
@@ -174,11 +210,15 @@ const initUsageDb = ({ rootDir }) => {
|
|
|
174
210
|
|
|
175
211
|
const toDayKey = (timestampMs) => new Date(timestampMs).toISOString().slice(0, 10);
|
|
176
212
|
|
|
177
|
-
const getPeriodRange = (days) => {
|
|
213
|
+
const getPeriodRange = (days, timeZone = kUtcTimeZone) => {
|
|
178
214
|
const now = Date.now();
|
|
179
215
|
const safeDays = clampInt(days, 1, 3650, kDefaultDays);
|
|
180
|
-
const startMs = now - safeDays *
|
|
181
|
-
|
|
216
|
+
const startMs = now - safeDays * kDayMs;
|
|
217
|
+
const normalizedTimeZone = normalizeTimeZone(timeZone);
|
|
218
|
+
const startDay = normalizedTimeZone === kUtcTimeZone
|
|
219
|
+
? toDayKey(startMs)
|
|
220
|
+
: toTimeZoneDayKey(startMs, normalizedTimeZone);
|
|
221
|
+
return { now, safeDays, startDay, timeZone: normalizedTimeZone };
|
|
182
222
|
};
|
|
183
223
|
|
|
184
224
|
const appendCostToRows = (rows) =>
|
|
@@ -208,27 +248,253 @@ const appendCostToRows = (rows) =>
|
|
|
208
248
|
};
|
|
209
249
|
});
|
|
210
250
|
|
|
211
|
-
const
|
|
251
|
+
const parseAgentAndSourceFromSessionRef = (sessionRef) => {
|
|
252
|
+
const raw = String(sessionRef || "").trim();
|
|
253
|
+
if (!raw) {
|
|
254
|
+
return { agent: "unknown", source: "chat" };
|
|
255
|
+
}
|
|
256
|
+
const parts = raw.split(":");
|
|
257
|
+
const agent =
|
|
258
|
+
parts[0] === "agent" && String(parts[1] || "").trim()
|
|
259
|
+
? String(parts[1] || "").trim()
|
|
260
|
+
: "unknown";
|
|
261
|
+
const source = parts.includes("hook")
|
|
262
|
+
? "hooks"
|
|
263
|
+
: parts.includes("cron")
|
|
264
|
+
? "cron"
|
|
265
|
+
: "chat";
|
|
266
|
+
return { agent, source };
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const getAgentCostDistribution = ({
|
|
270
|
+
eventsRows = [],
|
|
271
|
+
startDay = "",
|
|
272
|
+
timeZone = kUtcTimeZone,
|
|
273
|
+
}) => {
|
|
274
|
+
const byAgent = new Map();
|
|
275
|
+
const ensureAgentBucket = (agent) => {
|
|
276
|
+
if (byAgent.has(agent)) return byAgent.get(agent);
|
|
277
|
+
const bucket = {
|
|
278
|
+
agent,
|
|
279
|
+
inputTokens: 0,
|
|
280
|
+
outputTokens: 0,
|
|
281
|
+
cacheReadTokens: 0,
|
|
282
|
+
cacheWriteTokens: 0,
|
|
283
|
+
totalTokens: 0,
|
|
284
|
+
totalCost: 0,
|
|
285
|
+
turnCount: 0,
|
|
286
|
+
sourceBreakdown: {
|
|
287
|
+
chat: {
|
|
288
|
+
source: "chat",
|
|
289
|
+
inputTokens: 0,
|
|
290
|
+
outputTokens: 0,
|
|
291
|
+
cacheReadTokens: 0,
|
|
292
|
+
cacheWriteTokens: 0,
|
|
293
|
+
totalTokens: 0,
|
|
294
|
+
totalCost: 0,
|
|
295
|
+
turnCount: 0,
|
|
296
|
+
},
|
|
297
|
+
hooks: {
|
|
298
|
+
source: "hooks",
|
|
299
|
+
inputTokens: 0,
|
|
300
|
+
outputTokens: 0,
|
|
301
|
+
cacheReadTokens: 0,
|
|
302
|
+
cacheWriteTokens: 0,
|
|
303
|
+
totalTokens: 0,
|
|
304
|
+
totalCost: 0,
|
|
305
|
+
turnCount: 0,
|
|
306
|
+
},
|
|
307
|
+
cron: {
|
|
308
|
+
source: "cron",
|
|
309
|
+
inputTokens: 0,
|
|
310
|
+
outputTokens: 0,
|
|
311
|
+
cacheReadTokens: 0,
|
|
312
|
+
cacheWriteTokens: 0,
|
|
313
|
+
totalTokens: 0,
|
|
314
|
+
totalCost: 0,
|
|
315
|
+
turnCount: 0,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
byAgent.set(agent, bucket);
|
|
320
|
+
return bucket;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
for (const eventRow of eventsRows) {
|
|
324
|
+
const timestamp = coerceInt(eventRow.timestamp);
|
|
325
|
+
const dayKey = timeZone === kUtcTimeZone
|
|
326
|
+
? toDayKey(timestamp)
|
|
327
|
+
: toTimeZoneDayKey(timestamp, timeZone);
|
|
328
|
+
if (dayKey < startDay) continue;
|
|
329
|
+
|
|
330
|
+
const inputTokens = coerceInt(eventRow.input_tokens);
|
|
331
|
+
const outputTokens = coerceInt(eventRow.output_tokens);
|
|
332
|
+
const cacheReadTokens = coerceInt(eventRow.cache_read_tokens);
|
|
333
|
+
const cacheWriteTokens = coerceInt(eventRow.cache_write_tokens);
|
|
334
|
+
const totalTokens =
|
|
335
|
+
coerceInt(eventRow.total_tokens) ||
|
|
336
|
+
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
|
337
|
+
const { totalCost } = deriveCostBreakdown({
|
|
338
|
+
inputTokens,
|
|
339
|
+
outputTokens,
|
|
340
|
+
cacheReadTokens,
|
|
341
|
+
cacheWriteTokens,
|
|
342
|
+
model: eventRow.model,
|
|
343
|
+
});
|
|
344
|
+
const sessionRef = String(eventRow.session_key || eventRow.session_id || "");
|
|
345
|
+
const { agent, source } = parseAgentAndSourceFromSessionRef(sessionRef);
|
|
346
|
+
const agentBucket = ensureAgentBucket(agent);
|
|
347
|
+
const sourceBucket = agentBucket.sourceBreakdown[source];
|
|
348
|
+
|
|
349
|
+
agentBucket.inputTokens += inputTokens;
|
|
350
|
+
agentBucket.outputTokens += outputTokens;
|
|
351
|
+
agentBucket.cacheReadTokens += cacheReadTokens;
|
|
352
|
+
agentBucket.cacheWriteTokens += cacheWriteTokens;
|
|
353
|
+
agentBucket.totalTokens += totalTokens;
|
|
354
|
+
agentBucket.totalCost += totalCost;
|
|
355
|
+
agentBucket.turnCount += 1;
|
|
356
|
+
|
|
357
|
+
sourceBucket.inputTokens += inputTokens;
|
|
358
|
+
sourceBucket.outputTokens += outputTokens;
|
|
359
|
+
sourceBucket.cacheReadTokens += cacheReadTokens;
|
|
360
|
+
sourceBucket.cacheWriteTokens += cacheWriteTokens;
|
|
361
|
+
sourceBucket.totalTokens += totalTokens;
|
|
362
|
+
sourceBucket.totalCost += totalCost;
|
|
363
|
+
sourceBucket.turnCount += 1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const agents = Array.from(byAgent.values())
|
|
367
|
+
.map((bucket) => ({
|
|
368
|
+
agent: bucket.agent,
|
|
369
|
+
inputTokens: bucket.inputTokens,
|
|
370
|
+
outputTokens: bucket.outputTokens,
|
|
371
|
+
cacheReadTokens: bucket.cacheReadTokens,
|
|
372
|
+
cacheWriteTokens: bucket.cacheWriteTokens,
|
|
373
|
+
totalTokens: bucket.totalTokens,
|
|
374
|
+
totalCost: bucket.totalCost,
|
|
375
|
+
turnCount: bucket.turnCount,
|
|
376
|
+
sourceBreakdown: ["chat", "hooks", "cron"].map(
|
|
377
|
+
(source) => bucket.sourceBreakdown[source],
|
|
378
|
+
),
|
|
379
|
+
}))
|
|
380
|
+
.sort((a, b) => b.totalCost - a.totalCost);
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
agents,
|
|
384
|
+
totals: agents.reduce(
|
|
385
|
+
(acc, agentBucket) => {
|
|
386
|
+
acc.totalCost += Number(agentBucket.totalCost || 0);
|
|
387
|
+
acc.totalTokens += Number(agentBucket.totalTokens || 0);
|
|
388
|
+
acc.turnCount += Number(agentBucket.turnCount || 0);
|
|
389
|
+
return acc;
|
|
390
|
+
},
|
|
391
|
+
{ totalCost: 0, totalTokens: 0, turnCount: 0 },
|
|
392
|
+
),
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const getDailySummary = ({ days = kDefaultDays, timeZone = kUtcTimeZone } = {}) => {
|
|
212
397
|
const database = ensureDb();
|
|
213
|
-
const { safeDays, startDay } = getPeriodRange(
|
|
214
|
-
|
|
398
|
+
const { now, safeDays, startDay, timeZone: normalizedTimeZone } = getPeriodRange(
|
|
399
|
+
days,
|
|
400
|
+
timeZone,
|
|
401
|
+
);
|
|
402
|
+
let rows = [];
|
|
403
|
+
if (normalizedTimeZone === kUtcTimeZone) {
|
|
404
|
+
rows = database
|
|
405
|
+
.prepare(`
|
|
406
|
+
SELECT
|
|
407
|
+
date,
|
|
408
|
+
model,
|
|
409
|
+
provider,
|
|
410
|
+
input_tokens,
|
|
411
|
+
output_tokens,
|
|
412
|
+
cache_read_tokens,
|
|
413
|
+
cache_write_tokens,
|
|
414
|
+
total_tokens,
|
|
415
|
+
turn_count
|
|
416
|
+
FROM usage_daily
|
|
417
|
+
WHERE date >= $startDay
|
|
418
|
+
ORDER BY date ASC, total_tokens DESC
|
|
419
|
+
`)
|
|
420
|
+
.all({ $startDay: startDay });
|
|
421
|
+
} else {
|
|
422
|
+
const lookbackMs = now - (safeDays + 2) * kDayMs;
|
|
423
|
+
const eventRows = database
|
|
424
|
+
.prepare(`
|
|
425
|
+
SELECT
|
|
426
|
+
timestamp,
|
|
427
|
+
provider,
|
|
428
|
+
model,
|
|
429
|
+
input_tokens,
|
|
430
|
+
output_tokens,
|
|
431
|
+
cache_read_tokens,
|
|
432
|
+
cache_write_tokens,
|
|
433
|
+
total_tokens
|
|
434
|
+
FROM usage_events
|
|
435
|
+
WHERE timestamp >= $lookbackMs
|
|
436
|
+
ORDER BY timestamp ASC
|
|
437
|
+
`)
|
|
438
|
+
.all({ $lookbackMs: lookbackMs });
|
|
439
|
+
const byDateModel = new Map();
|
|
440
|
+
for (const eventRow of eventRows) {
|
|
441
|
+
const dayKey = toTimeZoneDayKey(coerceInt(eventRow.timestamp), normalizedTimeZone);
|
|
442
|
+
if (dayKey < startDay) continue;
|
|
443
|
+
const model = String(eventRow.model || "unknown");
|
|
444
|
+
const mapKey = `${dayKey}\u0000${model}`;
|
|
445
|
+
if (!byDateModel.has(mapKey)) {
|
|
446
|
+
byDateModel.set(mapKey, {
|
|
447
|
+
date: dayKey,
|
|
448
|
+
model,
|
|
449
|
+
provider: String(eventRow.provider || "unknown"),
|
|
450
|
+
input_tokens: 0,
|
|
451
|
+
output_tokens: 0,
|
|
452
|
+
cache_read_tokens: 0,
|
|
453
|
+
cache_write_tokens: 0,
|
|
454
|
+
total_tokens: 0,
|
|
455
|
+
turn_count: 0,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const aggregate = byDateModel.get(mapKey);
|
|
459
|
+
aggregate.input_tokens += coerceInt(eventRow.input_tokens);
|
|
460
|
+
aggregate.output_tokens += coerceInt(eventRow.output_tokens);
|
|
461
|
+
aggregate.cache_read_tokens += coerceInt(eventRow.cache_read_tokens);
|
|
462
|
+
aggregate.cache_write_tokens += coerceInt(eventRow.cache_write_tokens);
|
|
463
|
+
aggregate.total_tokens += coerceInt(eventRow.total_tokens);
|
|
464
|
+
aggregate.turn_count += 1;
|
|
465
|
+
if (!aggregate.provider && eventRow.provider) {
|
|
466
|
+
aggregate.provider = String(eventRow.provider || "unknown");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
rows = Array.from(byDateModel.values()).sort((a, b) => {
|
|
470
|
+
if (a.date === b.date) return b.total_tokens - a.total_tokens;
|
|
471
|
+
return a.date.localeCompare(b.date);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
const enriched = appendCostToRows(rows);
|
|
475
|
+
const lookbackMs = now - (safeDays + 2) * kDayMs;
|
|
476
|
+
const eventsRows = database
|
|
215
477
|
.prepare(`
|
|
216
478
|
SELECT
|
|
217
|
-
|
|
479
|
+
timestamp,
|
|
480
|
+
session_id,
|
|
481
|
+
session_key,
|
|
218
482
|
model,
|
|
219
|
-
provider,
|
|
220
483
|
input_tokens,
|
|
221
484
|
output_tokens,
|
|
222
485
|
cache_read_tokens,
|
|
223
486
|
cache_write_tokens,
|
|
224
|
-
total_tokens
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
ORDER BY date ASC, total_tokens DESC
|
|
487
|
+
total_tokens
|
|
488
|
+
FROM usage_events
|
|
489
|
+
WHERE timestamp >= $lookbackMs
|
|
490
|
+
ORDER BY timestamp ASC
|
|
229
491
|
`)
|
|
230
|
-
.all({ $
|
|
231
|
-
const
|
|
492
|
+
.all({ $lookbackMs: lookbackMs });
|
|
493
|
+
const costByAgent = getAgentCostDistribution({
|
|
494
|
+
eventsRows,
|
|
495
|
+
startDay,
|
|
496
|
+
timeZone: normalizedTimeZone,
|
|
497
|
+
});
|
|
232
498
|
const byDate = new Map();
|
|
233
499
|
for (const row of enriched) {
|
|
234
500
|
if (!byDate.has(row.date)) byDate.set(row.date, []);
|
|
@@ -294,8 +560,10 @@ const getDailySummary = ({ days = kDefaultDays } = {}) => {
|
|
|
294
560
|
return {
|
|
295
561
|
updatedAt: Date.now(),
|
|
296
562
|
days: safeDays,
|
|
563
|
+
timeZone: normalizedTimeZone,
|
|
297
564
|
daily,
|
|
298
565
|
totals,
|
|
566
|
+
costByAgent,
|
|
299
567
|
};
|
|
300
568
|
};
|
|
301
569
|
|