@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.
Files changed (68) hide show
  1. package/bin/alphaclaw.js +1 -31
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +53 -0
  4. package/lib/public/css/shell.css +21 -19
  5. package/lib/public/css/theme.css +17 -0
  6. package/lib/public/js/app.js +205 -109
  7. package/lib/public/js/components/credentials-modal.js +36 -8
  8. package/lib/public/js/components/file-tree.js +212 -22
  9. package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
  10. package/lib/public/js/components/file-viewer/index.js +47 -6
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
  12. package/lib/public/js/components/file-viewer/status-banners.js +11 -6
  13. package/lib/public/js/components/file-viewer/toolbar.js +56 -1
  14. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
  15. package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
  16. package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
  17. package/lib/public/js/components/file-viewer/use-file-viewer.js +142 -15
  18. package/lib/public/js/components/google/account-row.js +131 -0
  19. package/lib/public/js/components/google/add-account-modal.js +93 -0
  20. package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
  21. package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
  22. package/lib/public/js/components/google/index.js +553 -0
  23. package/lib/public/js/components/google/use-gmail-watch.js +140 -0
  24. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  25. package/lib/public/js/components/icons.js +26 -0
  26. package/lib/public/js/components/scope-picker.js +1 -1
  27. package/lib/public/js/components/sidebar-git-panel.js +48 -20
  28. package/lib/public/js/components/sidebar.js +93 -75
  29. package/lib/public/js/components/toast.js +11 -7
  30. package/lib/public/js/components/usage-tab/constants.js +31 -0
  31. package/lib/public/js/components/usage-tab/formatters.js +24 -0
  32. package/lib/public/js/components/usage-tab/index.js +72 -0
  33. package/lib/public/js/components/usage-tab/overview-section.js +147 -0
  34. package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
  35. package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
  36. package/lib/public/js/components/webhooks.js +182 -129
  37. package/lib/public/js/lib/api.js +178 -9
  38. package/lib/public/js/lib/browse-file-policies.js +29 -11
  39. package/lib/public/js/lib/format.js +71 -0
  40. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  41. package/lib/public/shared/browse-file-policies.json +13 -0
  42. package/lib/server/constants.js +47 -7
  43. package/lib/server/gmail-push.js +109 -0
  44. package/lib/server/gmail-serve.js +254 -0
  45. package/lib/server/gmail-watch.js +725 -0
  46. package/lib/server/google-state.js +317 -0
  47. package/lib/server/helpers.js +17 -11
  48. package/lib/server/internal-files-migration.js +31 -3
  49. package/lib/server/onboarding/github.js +21 -2
  50. package/lib/server/onboarding/index.js +1 -3
  51. package/lib/server/onboarding/openclaw.js +3 -0
  52. package/lib/server/onboarding/workspace.js +40 -0
  53. package/lib/server/routes/browse/index.js +90 -2
  54. package/lib/server/routes/gmail.js +128 -0
  55. package/lib/server/routes/google.js +433 -213
  56. package/lib/server/routes/system.js +107 -0
  57. package/lib/server/routes/usage.js +29 -2
  58. package/lib/server/routes/webhooks.js +52 -17
  59. package/lib/server/usage-db.js +283 -15
  60. package/lib/server/watchdog.js +66 -0
  61. package/lib/server/webhook-middleware.js +99 -1
  62. package/lib/server/webhooks.js +214 -65
  63. package/lib/server.js +27 -0
  64. package/lib/setup/gitignore +6 -0
  65. package/lib/setup/hourly-git-sync.sh +29 -2
  66. package/package.json +1 -1
  67. package/lib/public/js/components/google.js +0 -228
  68. 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 cacheKey = String(days);
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) => Number.isFinite(value) && Number.isInteger(value);
9
+ const isFiniteInteger = (value) =>
10
+ Number.isFinite(value) && Number.isInteger(value);
10
11
  const parseBooleanFlag = (value) => {
11
- const normalized = String(value == null ? "" : value).trim().toLowerCase();
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").trim().toLowerCase();
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(process.env.WEBHOOK_TOKEN || "").trim();
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=<WEBHOOK_TOKEN>`;
60
+ : `${fullUrl}?token=<OPENCLAW_HOOKS_TOKEN>`;
54
61
  const authHeaderValue = token
55
62
  ? `Authorization: Bearer ${token}`
56
- : "Authorization: Bearer <WEBHOOK_TOKEN>";
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 } = resolvedRestartState;
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) return res.status(404).json({ ok: false, error: "Webhook not found" });
110
- const summary = webhooksDb.getHookSummaries().find((item) => item.hookName === name);
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") ? 409 : 400;
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(req?.body?.deleteTransformDir);
163
- const deletion = deleteWebhook({ fs, constants, name, deleteTransformDir });
164
- if (!deletion?.removed) return res.status(404).json({ ok: false, error: "Webhook not found" });
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 = !isFiniteInteger(limit) || limit <= 0 || !isFiniteInteger(offset) || offset < 0;
215
+ const hasBadPaging =
216
+ !isFiniteInteger(limit) ||
217
+ limit <= 0 ||
218
+ !isFiniteInteger(offset) ||
219
+ offset < 0;
188
220
  if (hasBadPaging) {
189
- return res.status(400).json({ ok: false, error: "Invalid limit/offset" });
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) return res.status(404).json({ ok: false, error: "Request not found" });
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 });
@@ -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 * 24 * 60 * 60 * 1000;
181
- return { now, safeDays, startDay: toDayKey(startMs) };
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 getDailySummary = ({ days = kDefaultDays } = {}) => {
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(days);
214
- const rows = database
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
- date,
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
- turn_count
226
- FROM usage_daily
227
- WHERE date >= $startDay
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({ $startDay: startDay });
231
- const enriched = appendCostToRows(rows);
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