clawatch 1.0.13 → 1.0.20

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 (116) hide show
  1. package/backend/dist/alertChecker.d.ts.map +1 -1
  2. package/backend/dist/alertChecker.js +130 -1
  3. package/backend/dist/alertChecker.js.map +1 -1
  4. package/backend/dist/db.d.ts.map +1 -1
  5. package/backend/dist/db.js +1 -0
  6. package/backend/dist/db.js.map +1 -1
  7. package/backend/dist/projects.d.ts +9 -0
  8. package/backend/dist/projects.d.ts.map +1 -1
  9. package/backend/dist/projects.js +87 -23
  10. package/backend/dist/projects.js.map +1 -1
  11. package/backend/dist/routes.d.ts.map +1 -1
  12. package/backend/dist/routes.js +687 -54
  13. package/backend/dist/routes.js.map +1 -1
  14. package/backend/dist/sessions.d.ts +14 -7
  15. package/backend/dist/sessions.d.ts.map +1 -1
  16. package/backend/dist/sessions.js +199 -43
  17. package/backend/dist/sessions.js.map +1 -1
  18. package/backend/dist/sync.d.ts.map +1 -1
  19. package/backend/dist/sync.js +293 -33
  20. package/backend/dist/sync.js.map +1 -1
  21. package/dist/cli.js +225 -93
  22. package/dist/cli.js.map +1 -1
  23. package/dist/collector.d.ts.map +1 -1
  24. package/dist/collector.js +67 -62
  25. package/dist/collector.js.map +1 -1
  26. package/dist/config.d.ts +16 -1
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +69 -5
  29. package/dist/config.js.map +1 -1
  30. package/frontend/.next/BUILD_ID +1 -1
  31. package/frontend/.next/build-manifest.json +4 -4
  32. package/frontend/.next/server/app/_global-error/page/build-manifest.json +2 -2
  33. package/frontend/.next/server/app/_global-error/page.js.nft.json +1 -1
  34. package/frontend/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  35. package/frontend/.next/server/app/_global-error.html +2 -2
  36. package/frontend/.next/server/app/_global-error.rsc +8 -8
  37. package/frontend/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  38. package/frontend/.next/server/app/_global-error.segments/_full.segment.rsc +8 -8
  39. package/frontend/.next/server/app/_global-error.segments/_head.segment.rsc +4 -4
  40. package/frontend/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  41. package/frontend/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  42. package/frontend/.next/server/app/_not-found/page/build-manifest.json +2 -2
  43. package/frontend/.next/server/app/_not-found/page.js.nft.json +1 -1
  44. package/frontend/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  45. package/frontend/.next/server/app/_not-found.html +1 -1
  46. package/frontend/.next/server/app/_not-found.rsc +9 -9
  47. package/frontend/.next/server/app/_not-found.segments/_full.segment.rsc +9 -9
  48. package/frontend/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  49. package/frontend/.next/server/app/_not-found.segments/_index.segment.rsc +4 -4
  50. package/frontend/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  51. package/frontend/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  52. package/frontend/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  53. package/frontend/.next/server/app/dashboard/page/build-manifest.json +2 -2
  54. package/frontend/.next/server/app/dashboard/page.js.nft.json +1 -1
  55. package/frontend/.next/server/app/dashboard/page_client-reference-manifest.js +1 -1
  56. package/frontend/.next/server/app/dashboard/projects/[id]/page/build-manifest.json +2 -2
  57. package/frontend/.next/server/app/dashboard/projects/[id]/page.js.nft.json +1 -1
  58. package/frontend/.next/server/app/dashboard/projects/[id]/page_client-reference-manifest.js +1 -1
  59. package/frontend/.next/server/app/dashboard/sessions/[id]/page/build-manifest.json +2 -2
  60. package/frontend/.next/server/app/dashboard/sessions/[id]/page.js.nft.json +1 -1
  61. package/frontend/.next/server/app/dashboard/sessions/[id]/page_client-reference-manifest.js +1 -1
  62. package/frontend/.next/server/app/dashboard.html +1 -1
  63. package/frontend/.next/server/app/dashboard.rsc +10 -10
  64. package/frontend/.next/server/app/dashboard.segments/_full.segment.rsc +10 -10
  65. package/frontend/.next/server/app/dashboard.segments/_head.segment.rsc +4 -4
  66. package/frontend/.next/server/app/dashboard.segments/_index.segment.rsc +4 -4
  67. package/frontend/.next/server/app/dashboard.segments/_tree.segment.rsc +2 -2
  68. package/frontend/.next/server/app/dashboard.segments/dashboard/__PAGE__.segment.rsc +4 -4
  69. package/frontend/.next/server/app/dashboard.segments/dashboard.segment.rsc +3 -3
  70. package/frontend/.next/server/app/index.html +1 -1
  71. package/frontend/.next/server/app/index.rsc +10 -10
  72. package/frontend/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  73. package/frontend/.next/server/app/index.segments/_full.segment.rsc +10 -10
  74. package/frontend/.next/server/app/index.segments/_head.segment.rsc +4 -4
  75. package/frontend/.next/server/app/index.segments/_index.segment.rsc +4 -4
  76. package/frontend/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  77. package/frontend/.next/server/app/page/build-manifest.json +2 -2
  78. package/frontend/.next/server/app/page.js.nft.json +1 -1
  79. package/frontend/.next/server/app/page_client-reference-manifest.js +1 -1
  80. package/frontend/.next/server/chunks/ssr/[root-of-the-server]__008d27c3._.js +1 -1
  81. package/frontend/.next/server/chunks/ssr/{[root-of-the-server]__bdeac1dc._.js → [root-of-the-server]__32661fd1._.js} +2 -2
  82. package/frontend/.next/server/chunks/ssr/{[root-of-the-server]__8511f4a3._.js → [root-of-the-server]__9b38782c._.js} +2 -2
  83. package/frontend/.next/server/chunks/ssr/[root-of-the-server]__a64655ed._.js +2 -2
  84. package/frontend/.next/server/chunks/ssr/[root-of-the-server]__d2832b3e._.js +3 -0
  85. package/frontend/.next/server/chunks/ssr/[root-of-the-server]__f437da88._.js +1 -1
  86. package/frontend/.next/server/chunks/ssr/_2bfdd77b._.js +1 -1
  87. package/frontend/.next/server/chunks/ssr/_b0ae6d33._.js +1 -1
  88. package/frontend/.next/server/chunks/ssr/_e17fe96b._.js +3 -0
  89. package/frontend/.next/server/chunks/ssr/_f26b1aca._.js +1 -1
  90. package/frontend/.next/server/chunks/ssr/node_modules_next_dist_client_components_9774470f._.js +1 -1
  91. package/frontend/.next/server/chunks/ssr/{node_modules_next_dist_27457240._.js → node_modules_next_dist_esm_eedfc1fd._.js} +2 -2
  92. package/frontend/.next/server/middleware-build-manifest.js +2 -2
  93. package/frontend/.next/server/pages/404.html +1 -1
  94. package/frontend/.next/server/pages/500.html +2 -2
  95. package/frontend/.next/static/chunks/641e855850e79de9.css +3 -0
  96. package/frontend/.next/static/chunks/{d702a24e2b6c48fa.js → 6b50f8d2ee1d2bea.js} +2 -2
  97. package/frontend/.next/static/chunks/88faea50dcf8f778.js +1 -0
  98. package/frontend/.next/static/chunks/8ffedcb68f4a998f.js +1 -0
  99. package/frontend/.next/static/chunks/{d2be314c3ece3fbe.js → a2dfb6fc5208ab9b.js} +1 -1
  100. package/frontend/.next/static/chunks/a909e37955d0604e.js +1 -0
  101. package/frontend/.next/static/chunks/ba72b58eb8cb3f4e.js +1 -0
  102. package/frontend/.next/static/chunks/e975763f7a359fb5.js +1 -0
  103. package/frontend/.next/static/chunks/{turbopack-0df1acbb994b2a74.js → turbopack-1e12e8d4e4225e2f.js} +1 -1
  104. package/frontend/package.json +1 -0
  105. package/frontend/server-with-proxy.js +82 -0
  106. package/package.json +4 -3
  107. package/frontend/.next/server/chunks/ssr/[root-of-the-server]__48e09bc8._.js +0 -3
  108. package/frontend/.next/server/chunks/ssr/src_app_dashboard_page_tsx_196c74b5._.js +0 -3
  109. package/frontend/.next/static/chunks/284aee0eb323076c.js +0 -1
  110. package/frontend/.next/static/chunks/2b7d8037bb74445e.css +0 -3
  111. package/frontend/.next/static/chunks/520be2943f8ae266.js +0 -1
  112. package/frontend/.next/static/chunks/58873fd2347d1c88.js +0 -1
  113. package/frontend/.next/static/chunks/a308a3cc32b8bc4a.js +0 -1
  114. /package/frontend/.next/static/{P4g0K_y2ksljZD88vEDAA → bKa8vfUmSWQxqmsnhQLtj}/_buildManifest.js +0 -0
  115. /package/frontend/.next/static/{P4g0K_y2ksljZD88vEDAA → bKa8vfUmSWQxqmsnhQLtj}/_clientMiddlewareManifest.json +0 -0
  116. /package/frontend/.next/static/{P4g0K_y2ksljZD88vEDAA → bKa8vfUmSWQxqmsnhQLtj}/_ssgManifest.js +0 -0
@@ -1,21 +1,95 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
39
  const express_1 = require("express");
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
7
42
  const db_1 = __importDefault(require("./db"));
8
43
  const sessions_1 = require("./sessions");
9
44
  const projects_1 = require("./projects");
10
45
  const router = (0, express_1.Router)();
46
+ // ---------- Profiles ----------
47
+ router.get("/profiles", (_req, res) => {
48
+ const profiles = (0, sessions_1.discoverProfiles)();
49
+ res.json({ profiles });
50
+ });
51
+ // ---------- Version ----------
52
+ router.get("/version", (_req, res) => {
53
+ const candidates = [
54
+ path.join(__dirname, "..", "..", "cli", "package.json"), // dev/source
55
+ path.join(__dirname, "..", "package.json"), // bundled in CLI
56
+ ];
57
+ for (const candidate of candidates) {
58
+ try {
59
+ if (fs.existsSync(candidate)) {
60
+ const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8"));
61
+ if (pkg.version) {
62
+ res.json({ version: pkg.version });
63
+ return;
64
+ }
65
+ }
66
+ }
67
+ catch {
68
+ // try next
69
+ }
70
+ }
71
+ res.json({ version: "unknown" });
72
+ });
11
73
  // ---------- Agents ----------
12
- router.get("/agents", (_req, res) => {
74
+ router.get("/agents", async (_req, res) => {
13
75
  const statusFilter = _req.query.status || "active";
76
+ const profileFilter = _req.query.profile;
14
77
  const agents = db_1.default.prepare("SELECT * FROM agents ORDER BY costUsd DESC").all();
15
78
  // Filter by status
16
- const filtered = statusFilter === "all"
79
+ let filtered = statusFilter === "all"
17
80
  ? agents
18
81
  : agents.filter((a) => a.status === statusFilter);
82
+ // Filter by profile: only return agents that have sessions in the selected profile
83
+ if (profileFilter) {
84
+ try {
85
+ const sessions = await (0, sessions_1.listSessions)(profileFilter);
86
+ const agentIdsInProfile = new Set(sessions.map((s) => s.agentId));
87
+ filtered = filtered.filter((a) => agentIdsInProfile.has(a.id));
88
+ }
89
+ catch {
90
+ // If profile lookup fails, return unfiltered
91
+ }
92
+ }
19
93
  res.json({ agents: filtered });
20
94
  });
21
95
  router.get("/agents/:id", (req, res) => {
@@ -97,53 +171,139 @@ router.post("/events", (req, res) => {
97
171
  res.status(201).json({ ok: true });
98
172
  });
99
173
  // ---------- Costs ----------
100
- router.get("/costs", (req, res) => {
101
- const { agentId, from, to } = req.query;
102
- let agentFilter = "";
103
- const params = [];
104
- if (agentId) {
105
- agentFilter = " WHERE agentId = ?";
106
- params.push(agentId);
107
- }
108
- // By agent
109
- const byAgent = db_1.default.prepare(`
110
- SELECT a.id as agentId, a.name, a.costUsd, a.tokenCount
111
- FROM agents a ${agentId ? "WHERE a.id = ?" : ""}
112
- ORDER BY a.costUsd DESC
113
- `).all(...(agentId ? [agentId] : []));
114
- // By model — query events with cost data
115
- let modelQuery = `
116
- SELECT json_extract(data, '$.model') as model,
117
- SUM(json_extract(data, '$.costUsd')) as costUsd,
118
- SUM(json_extract(data, '$.tokenCount')) as tokenCount
119
- FROM events
120
- WHERE type = 'cost'
121
- `;
122
- const modelParams = [];
123
- if (agentId) {
124
- modelQuery += " AND agentId = ?";
125
- modelParams.push(agentId);
126
- }
127
- if (from) {
128
- modelQuery += " AND timestamp >= ?";
129
- modelParams.push(from);
130
- }
131
- if (to) {
132
- modelQuery += " AND timestamp <= ?";
133
- modelParams.push(to);
134
- }
135
- modelQuery += " GROUP BY model";
136
- const byModel = db_1.default.prepare(modelQuery).all(...modelParams);
137
- const totalUsd = byAgent.reduce((sum, a) => sum + (a.costUsd || 0), 0);
138
- res.json({ totalUsd, byAgent, byModel });
174
+ router.get("/costs", async (req, res) => {
175
+ try {
176
+ const { agentId, from, to, profile } = req.query;
177
+ // Get sessions (cached, authoritative source from JSONL files)
178
+ let sessions = await (0, sessions_1.listSessions)(profile);
179
+ // Apply filters
180
+ if (agentId) {
181
+ sessions = sessions.filter((s) => s.agentId === agentId);
182
+ }
183
+ if (from) {
184
+ sessions = sessions.filter((s) => s.lastActivityAt >= from);
185
+ }
186
+ if (to) {
187
+ sessions = sessions.filter((s) => s.startedAt <= to);
188
+ }
189
+ // Aggregate by agent
190
+ const agentMap = new Map();
191
+ // Aggregate by model
192
+ const modelMap = new Map();
193
+ for (const session of sessions) {
194
+ // By agent
195
+ const existing = agentMap.get(session.agentId);
196
+ if (existing) {
197
+ existing.costUsd += session.costUsd;
198
+ existing.tokenCount += session.tokenCount;
199
+ }
200
+ else {
201
+ agentMap.set(session.agentId, {
202
+ agentId: session.agentId,
203
+ name: session.agentId,
204
+ costUsd: session.costUsd,
205
+ tokenCount: session.tokenCount,
206
+ });
207
+ }
208
+ // By model — use costByModel from session summary
209
+ for (const mc of session.costByModel) {
210
+ const em = modelMap.get(mc.model);
211
+ if (em) {
212
+ em.costUsd += mc.costUsd;
213
+ em.tokenCount += mc.tokenCount;
214
+ }
215
+ else {
216
+ modelMap.set(mc.model, { model: mc.model, costUsd: mc.costUsd, tokenCount: mc.tokenCount });
217
+ }
218
+ }
219
+ }
220
+ const byAgent = Array.from(agentMap.values()).sort((a, b) => b.costUsd - a.costUsd);
221
+ const byModel = Array.from(modelMap.values()).sort((a, b) => b.costUsd - a.costUsd);
222
+ const totalUsd = byAgent.reduce((sum, a) => sum + a.costUsd, 0);
223
+ res.json({ totalUsd, byAgent, byModel });
224
+ }
225
+ catch (err) {
226
+ res.status(500).json({ error: err.message || "Failed to get costs" });
227
+ }
139
228
  });
140
229
  // ---------- Alerts ----------
141
- router.get("/alerts", (_req, res) => {
142
- const alerts = db_1.default.prepare("SELECT * FROM alerts ORDER BY timestamp DESC LIMIT 100").all().map((a) => ({
230
+ router.get("/alerts", async (req, res) => {
231
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 5, 1), 100);
232
+ const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
233
+ const severityParam = req.query.severity;
234
+ const acknowledgedParam = req.query.acknowledged;
235
+ const agentIdParam = req.query.agentId;
236
+ const profileParam = req.query.profile;
237
+ const conditions = [];
238
+ const params = [];
239
+ // Profile filter: restrict to agents that belong to this profile
240
+ if (profileParam) {
241
+ const sessions = await (0, sessions_1.listSessions)(profileParam);
242
+ const agentIds = [...new Set(sessions.map((s) => s.agentId))];
243
+ if (agentIds.length > 0) {
244
+ conditions.push(`agentId IN (${agentIds.map(() => "?").join(", ")})`);
245
+ params.push(...agentIds);
246
+ }
247
+ else {
248
+ // No agents in this profile — return empty
249
+ res.json({ alerts: [], total: 0 });
250
+ return;
251
+ }
252
+ }
253
+ if (severityParam) {
254
+ const severities = severityParam.split(",").map((s) => s.trim());
255
+ conditions.push(`severity IN (${severities.map(() => "?").join(", ")})`);
256
+ params.push(...severities);
257
+ }
258
+ if (acknowledgedParam === "true") {
259
+ conditions.push("acknowledged = 1");
260
+ }
261
+ else if (acknowledgedParam === "false") {
262
+ conditions.push("acknowledged = 0");
263
+ }
264
+ if (agentIdParam) {
265
+ conditions.push("agentId = ?");
266
+ params.push(agentIdParam);
267
+ }
268
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
269
+ const total = db_1.default.prepare(`SELECT COUNT(*) as cnt FROM alerts${where}`).get(...params).cnt;
270
+ const alerts = db_1.default.prepare(`SELECT * FROM alerts${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`).all(...params, limit, offset).map((a) => ({
143
271
  ...a,
144
272
  acknowledged: Boolean(a.acknowledged),
145
273
  }));
146
- res.json({ alerts });
274
+ res.json({ alerts, total });
275
+ });
276
+ router.post("/alerts/acknowledge-all", async (req, res) => {
277
+ const severityParam = req.query.severity;
278
+ const agentIdParam = req.query.agentId;
279
+ const profileParam = req.query.profile;
280
+ const conditions = ["acknowledged = 0"];
281
+ const params = [];
282
+ // Profile filter
283
+ if (profileParam) {
284
+ const sessions = await (0, sessions_1.listSessions)(profileParam);
285
+ const agentIds = [...new Set(sessions.map((s) => s.agentId))];
286
+ if (agentIds.length > 0) {
287
+ conditions.push(`agentId IN (${agentIds.map(() => "?").join(", ")})`);
288
+ params.push(...agentIds);
289
+ }
290
+ else {
291
+ res.json({ ok: true, count: 0 });
292
+ return;
293
+ }
294
+ }
295
+ if (severityParam) {
296
+ const severities = severityParam.split(",").map((s) => s.trim());
297
+ conditions.push(`severity IN (${severities.map(() => "?").join(", ")})`);
298
+ params.push(...severities);
299
+ }
300
+ if (agentIdParam) {
301
+ conditions.push("agentId = ?");
302
+ params.push(agentIdParam);
303
+ }
304
+ const where = ` WHERE ${conditions.join(" AND ")}`;
305
+ const result = db_1.default.prepare(`UPDATE alerts SET acknowledged = 1${where}`).run(...params);
306
+ res.json({ ok: true, count: result.changes });
147
307
  });
148
308
  router.post("/alerts/:id/acknowledge", (req, res) => {
149
309
  const result = db_1.default.prepare("UPDATE alerts SET acknowledged = 1 WHERE id = ?").run(req.params.id);
@@ -153,12 +313,309 @@ router.post("/alerts/:id/acknowledge", (req, res) => {
153
313
  }
154
314
  res.json({ ok: true });
155
315
  });
316
+ // --- Alert summary generation helpers ---
317
+ function isMeaningfulError(error) {
318
+ const cleaned = stripLogPrefix(error).trim();
319
+ // Filter out JSON fragments, single chars, pure punctuation, etc.
320
+ if (cleaned.length < 5)
321
+ return false;
322
+ if (/^[{}\[\],;:."'\s]+$/.test(cleaned))
323
+ return false;
324
+ if (/^\w+:\s*\d+[,}]?$/.test(cleaned))
325
+ return false; // "key: 123"
326
+ return true;
327
+ }
328
+ function generateErrorSummary(relatedErrors, agentName) {
329
+ if (relatedErrors.length === 0) {
330
+ return { summary: "Errors detected", description: `${agentName} encountered errors recently.` };
331
+ }
332
+ // Group errors by message, filtering out meaningless fragments
333
+ const groups = new Map();
334
+ for (const e of relatedErrors) {
335
+ const key = e.error;
336
+ const existing = groups.get(key);
337
+ if (existing) {
338
+ existing.count++;
339
+ if (e.timestamp > existing.latest)
340
+ existing.latest = e.timestamp;
341
+ }
342
+ else {
343
+ groups.set(key, { count: 1, latest: e.timestamp });
344
+ }
345
+ }
346
+ // Find the most frequent *meaningful* error
347
+ let topError = "";
348
+ let topCount = 0;
349
+ for (const [msg, info] of groups) {
350
+ if (info.count > topCount && isMeaningfulError(msg)) {
351
+ topError = msg;
352
+ topCount = info.count;
353
+ }
354
+ }
355
+ // If no meaningful error found, fall back to any error
356
+ if (!topError) {
357
+ for (const [msg, info] of groups) {
358
+ if (info.count > topCount) {
359
+ topError = msg;
360
+ topCount = info.count;
361
+ }
362
+ }
363
+ }
364
+ // Pattern-match the top error for a human-readable summary
365
+ const summary = humanizeError(topError, agentName);
366
+ // Generate a plain-English description — no raw error text, no counts (frontend shows ×N)
367
+ const description = generatePlainDescription(summary, agentName, topError);
368
+ return { summary, description };
369
+ }
370
+ function generatePlainDescription(title, agentName, _topError) {
371
+ // Map known titles/patterns to plain-English impact descriptions
372
+ if (/can't connect|connection refused/i.test(title))
373
+ return `${agentName} is unable to reach a service it depends on. This may prevent it from completing its tasks until the service is back online.`;
374
+ if (/can't reach|DNS failure/i.test(title))
375
+ return `${agentName} can't look up a server address. The remote service may be down or there could be a network issue.`;
376
+ if (/connection lost|connection reset/i.test(title))
377
+ return `${agentName} keeps losing its connection to an external service. This usually means the remote server is unstable or overloaded.`;
378
+ if (/timed out/i.test(title))
379
+ return `${agentName} waited too long for a response. The target service may be slow or unresponsive.`;
380
+ if (/rate limit/i.test(title))
381
+ return `${agentName} is making too many API calls and being throttled. It needs to slow down or wait before retrying.`;
382
+ if (/authentication failed|auth token expired/i.test(title))
383
+ return `${agentName} can't authenticate with an external service. Its credentials may need to be refreshed or reconfigured.`;
384
+ if (/access denied|permission/i.test(title))
385
+ return `${agentName} tried to do something it doesn't have permission for. Check its access rights.`;
386
+ if (/Slack credentials not configured/i.test(title))
387
+ return `${agentName} can't send messages to Slack because the bot token isn't set up. Configure the Slack integration to fix this.`;
388
+ if (/message delivery failing/i.test(title))
389
+ return `${agentName} is failing to deliver messages. This may be caused by missing credentials or a service outage.`;
390
+ if (/Slack connection/i.test(title))
391
+ return `${agentName} is having trouble staying connected to Slack. The connection keeps dropping or timing out.`;
392
+ if (/crashed|unhandled/i.test(title))
393
+ return `${agentName} crashed unexpectedly. It may need to be restarted or the underlying bug needs to be fixed.`;
394
+ if (/misconfigured tool|configuration error/i.test(title))
395
+ return `${agentName} has a configuration issue that may cause some features to not work correctly. Review its settings.`;
396
+ if (/skill path/i.test(title))
397
+ return `${agentName} has a skill that points to an invalid location. The skill may not load correctly.`;
398
+ if (/can't find a required file|missing file/i.test(title))
399
+ return `${agentName} is looking for a file that doesn't exist. A dependency may be missing or a path may be wrong.`;
400
+ if (/invalid file operation/i.test(title))
401
+ return `${agentName} tried to read a directory as a file. There may be a path configuration issue.`;
402
+ if (/missing required command/i.test(title))
403
+ return `${agentName} needs a system command that isn't installed. Install the missing dependency.`;
404
+ if (/process was killed/i.test(title))
405
+ return `${agentName} was forcefully stopped. This could be due to resource limits or a manual intervention.`;
406
+ if (/code bug|null reference|type error/i.test(title))
407
+ return `${agentName} hit a bug in its code. This is likely a software issue that needs a fix.`;
408
+ if (/malformed data|invalid data/i.test(title))
409
+ return `${agentName} received data it couldn't understand. The data source may have changed format.`;
410
+ if (/database/i.test(title))
411
+ return `${agentName} is having trouble accessing its database. It may be locked by another process or corrupted.`;
412
+ if (/hostname conflict/i.test(title))
413
+ return `${agentName} detected a network naming conflict. Multiple services may be competing for the same name.`;
414
+ if (/spending exceeded/i.test(title))
415
+ return `${agentName} has gone over its budget. Consider reviewing its usage or adjusting the threshold.`;
416
+ // Generic fallback — still meaningful
417
+ return `${agentName} ran into a problem that may affect its ability to work properly. Check the technical details for more information.`;
418
+ }
419
+ // Strip common log prefixes: timestamps, log levels, bracketed tags
420
+ function stripLogPrefix(error) {
421
+ let cleaned = error;
422
+ // Strip ISO/custom timestamps at start: "2026-03-10T11:40:54.880+02:00 " or "[2026-03-10 ...]"
423
+ cleaned = cleaned.replace(/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[\d.+:ZT-]*\s*/g, "");
424
+ // Strip bracketed tags: [tools], [ERROR], [warn], etc.
425
+ cleaned = cleaned.replace(/^\[[\w.-]+\]\s*/g, "");
426
+ // Strip again (sometimes multiple tags)
427
+ cleaned = cleaned.replace(/^\[[\w.-]+\]\s*/g, "");
428
+ // Strip log levels
429
+ cleaned = cleaned.replace(/^(ERROR|WARN|INFO|DEBUG|FATAL|TRACE)[:\s]+/i, "");
430
+ return cleaned.trim();
431
+ }
432
+ function humanizeError(error, agentName) {
433
+ // Preprocess: strip log timestamps/tags to get the actual error content
434
+ const cleanedError = stripLogPrefix(error);
435
+ const patterns = [
436
+ // Network errors
437
+ [/ECONNREFUSED/i, `${agentName} can't connect to a service`],
438
+ [/ECONNRESET/i, `${agentName} lost connection unexpectedly`],
439
+ [/ETIMEDOUT/i, `${agentName} connection timed out`],
440
+ [/ENOTFOUND/i, `${agentName} can't reach a remote server`],
441
+ [/EADDRINUSE/i, `${agentName} port already in use`],
442
+ [/EPERM|EACCES/i, `${agentName} permission denied`],
443
+ [/ENOMEM|out of memory/i, `${agentName} ran out of memory`],
444
+ // HTTP errors
445
+ [/rate.?limit/i, `${agentName} hit API rate limit`],
446
+ [/401|unauthorized/i, `${agentName} authentication failed`],
447
+ [/403|forbidden/i, `${agentName} access denied`],
448
+ [/500|internal server error/i, `Remote server error for ${agentName}`],
449
+ [/502|bad gateway/i, `Bad gateway error for ${agentName}`],
450
+ [/503|service unavailable/i, `Service unavailable for ${agentName}`],
451
+ [/504|gateway timeout/i, `Gateway timeout for ${agentName}`],
452
+ // Code errors
453
+ [/Cannot read propert/i, `${agentName} hit a code bug (null reference)`],
454
+ [/is not a function/i, `${agentName} hit a code bug (type error)`],
455
+ [/JSON\.parse|Unexpected token/i, `${agentName} received malformed data`],
456
+ [/SQLITE_BUSY/i, `${agentName} database is locked`],
457
+ [/SQLITE_CORRUPT/i, `${agentName} database corruption detected`],
458
+ // Auth/cert
459
+ [/token.*expir/i, `${agentName} auth token expired`],
460
+ [/CERT_|certificate/i, `${agentName} SSL certificate error`],
461
+ // File/path errors
462
+ [/ENOENT|no such file/i, `${agentName} can't find a required file`],
463
+ [/EISDIR/i, `${agentName} invalid file operation`],
464
+ [/spawn.*ENOENT|command not found/i, `${agentName} missing required command`],
465
+ [/killed|SIGKILL|SIGTERM/i, `${agentName} process was killed`],
466
+ // OpenClaw / gateway specific
467
+ [/[Ss]lack\s*bot\s*token\s*missing/i, `${agentName} Slack credentials not configured`],
468
+ [/[Rr]etry failed for delivery/i, `${agentName} message delivery failing`],
469
+ [/delivery.*failed|failed.*delivery/i, `${agentName} message delivery failing`],
470
+ [/socket.?mode failed/i, `${agentName} Slack connection failing`],
471
+ [/pong wasn't received|pong.*timeout/i, `${agentName} Slack connection timing out`],
472
+ [/[Uu]nhandled promise rejection/i, `${agentName} crashed (unhandled error)`],
473
+ [/allowlist contains unknown/i, `${agentName} has misconfigured tool settings`],
474
+ [/[Ss]kipping skill path/i, `${agentName} has a skill path issue`],
475
+ [/hostname conflict/i, `${agentName} network hostname conflict`],
476
+ // Generic patterns (broad — keep last)
477
+ [/timeout/i, `${agentName} operation timed out`],
478
+ [/connection refused/i, `${agentName} can't connect to a service`],
479
+ [/connection reset/i, `${agentName} lost connection`],
480
+ [/missing.*config|config.*missing/i, `${agentName} missing configuration`],
481
+ [/crash|fatal|panic/i, `${agentName} crashed`],
482
+ ];
483
+ for (const [pattern, summary] of patterns) {
484
+ if (pattern.test(cleanedError))
485
+ return summary;
486
+ }
487
+ // Smart fallback: interpret the error instead of truncating
488
+ // Try "ErrorType: message" format
489
+ const typeMatch = cleanedError.match(/^(\w+Error):\s*(.+?)(?:\n|$)/);
490
+ if (typeMatch) {
491
+ const shortMsg = typeMatch[2].trim();
492
+ return shortMsg.length > 50 ? `${agentName}: ${shortMsg.slice(0, 47)}...` : `${agentName}: ${shortMsg}`;
493
+ }
494
+ // Look for a verb phrase
495
+ const actionMatch = cleanedError.match(/(failed to \w+|cannot \w+|unable to \w+|could not \w+)/i);
496
+ if (actionMatch) {
497
+ return `${agentName} ${actionMatch[1].toLowerCase()}`;
498
+ }
499
+ // Keyword-based categorization — produce a real summary, not a truncation
500
+ const lower = cleanedError.toLowerCase();
501
+ if (lower.includes("connect") || lower.includes("socket"))
502
+ return `${agentName} connection issue`;
503
+ if (lower.includes("timeout") || lower.includes("timed out"))
504
+ return `${agentName} operation timed out`;
505
+ if (lower.includes("permission") || lower.includes("denied") || lower.includes("access"))
506
+ return `${agentName} permission error`;
507
+ if (lower.includes("invalid") || lower.includes("unexpected") || lower.includes("unknown"))
508
+ return `${agentName} configuration error`;
509
+ if (lower.includes("missing") || lower.includes("not found"))
510
+ return `${agentName} missing resource`;
511
+ if (lower.includes("failed") || lower.includes("error") || lower.includes("crash"))
512
+ return `${agentName} operation failed`;
513
+ // Last resort: first clause only, very short
514
+ const clean = cleanedError.replace(/\n.*/s, "").trim();
515
+ if (!clean || clean.length < 5) {
516
+ return `${agentName} encountered errors`;
517
+ }
518
+ const clause = clean.split(/[,;(]/)[0].trim();
519
+ return clause.length > 40 ? `${agentName} error` : `${agentName}: ${clause}`;
520
+ }
521
+ function cleanErrorForDisplay(error) {
522
+ // Strip log prefixes and stack trace, keep just the meaningful error
523
+ const cleaned = stripLogPrefix(error.split("\n")[0].trim());
524
+ return cleaned.length > 120 ? cleaned.slice(0, 117) + "..." : cleaned;
525
+ }
526
+ function generateStuckSummary(agentName, durationMinutes) {
527
+ return {
528
+ summary: `${agentName} stopped responding`,
529
+ description: `${agentName} hasn't sent a heartbeat in ${durationMinutes} minutes. The agent may have crashed, frozen, or lost its connection. It needs to be restarted or investigated.`,
530
+ };
531
+ }
532
+ function generateCostSummary(agentName, currentCost, threshold) {
533
+ const overage = currentCost - threshold;
534
+ return {
535
+ summary: `${agentName} spending exceeded $${threshold}`,
536
+ description: `${agentName} has spent $${currentCost.toFixed(2)}, which is $${overage.toFixed(2)} over the $${threshold.toFixed(2)} threshold. This could mean the agent is running longer than expected or processing more data than usual.`,
537
+ };
538
+ }
539
+ router.get("/alerts/:id/details", (req, res) => {
540
+ const alert = db_1.default.prepare("SELECT * FROM alerts WHERE id = ?").get(req.params.id);
541
+ if (!alert) {
542
+ res.status(404).json({ error: "Alert not found" });
543
+ return;
544
+ }
545
+ alert.acknowledged = Boolean(alert.acknowledged);
546
+ // Get agent info
547
+ const agent = db_1.default.prepare("SELECT id, name, status, lastHeartbeat, costUsd FROM agents WHERE id = ?")
548
+ .get(alert.agentId);
549
+ const agentName = agent?.name || alert.agentId;
550
+ let relatedErrors = [];
551
+ let context = {};
552
+ let summary = "";
553
+ let description = "";
554
+ if (alert.type === "error") {
555
+ // Error spike: get the actual error events within the spike window before the alert
556
+ const ERROR_SPIKE_WINDOW_MS = parseInt(process.env.ERROR_SPIKE_WINDOW_MS || "60000", 10);
557
+ const windowStart = new Date(new Date(alert.timestamp).getTime() - ERROR_SPIKE_WINDOW_MS).toISOString();
558
+ relatedErrors = db_1.default.prepare(`
559
+ SELECT type, timestamp, data FROM events
560
+ WHERE agentId = ? AND type = 'error' AND timestamp > ? AND timestamp <= ?
561
+ ORDER BY timestamp DESC
562
+ `).all(alert.agentId, windowStart, alert.timestamp).map((e) => {
563
+ const parsed = JSON.parse(e.data);
564
+ return {
565
+ timestamp: e.timestamp,
566
+ error: parsed.error || parsed.message || "Unknown error",
567
+ raw: parsed,
568
+ };
569
+ });
570
+ const gen = generateErrorSummary(relatedErrors, agentName);
571
+ summary = gen.summary;
572
+ description = gen.description;
573
+ }
574
+ else if (alert.type === "stuck") {
575
+ // Stuck agent: show how long it's been stuck and last heartbeat
576
+ if (agent) {
577
+ const stuckSince = new Date(agent.lastHeartbeat);
578
+ const stuckDurationMs = new Date(alert.timestamp).getTime() - stuckSince.getTime();
579
+ const stuckMinutes = Math.round(stuckDurationMs / 60000);
580
+ context = {
581
+ lastHeartbeat: agent.lastHeartbeat,
582
+ stuckDurationMs,
583
+ stuckDurationMinutes: stuckMinutes,
584
+ agentStatus: agent.status,
585
+ };
586
+ const gen = generateStuckSummary(agentName, stuckMinutes);
587
+ summary = gen.summary;
588
+ description = gen.description;
589
+ }
590
+ }
591
+ else if (alert.type === "cost_spike") {
592
+ // Cost threshold: show current spend and threshold
593
+ const COST_THRESHOLD_USD = parseFloat(process.env.COST_THRESHOLD_USD || "10");
594
+ context = {
595
+ currentCostUsd: agent?.costUsd || 0,
596
+ thresholdUsd: COST_THRESHOLD_USD,
597
+ overage: (agent?.costUsd || 0) - COST_THRESHOLD_USD,
598
+ };
599
+ const gen = generateCostSummary(agentName, agent?.costUsd || 0, COST_THRESHOLD_USD);
600
+ summary = gen.summary;
601
+ description = gen.description;
602
+ }
603
+ res.json({
604
+ alert,
605
+ agent: agent ? { id: agent.id, name: agent.name, status: agent.status } : null,
606
+ relatedErrors,
607
+ context,
608
+ title: summary,
609
+ description,
610
+ });
611
+ });
156
612
  // ---------- Sessions (from JSONL files) ----------
157
613
  router.get("/sessions", async (req, res) => {
158
614
  try {
159
- let sessions = await (0, sessions_1.listSessions)();
615
+ const profileFilter = req.query.profile;
616
+ let sessions = await (0, sessions_1.listSessions)(profileFilter);
160
617
  // Filter by agentId
161
- const { agentId, status, sort, limit } = req.query;
618
+ const { agentId, status, sort, limit, offset } = req.query;
162
619
  if (agentId) {
163
620
  sessions = sessions.filter((s) => s.agentId === agentId);
164
621
  }
@@ -176,11 +633,20 @@ router.get("/sessions", async (req, res) => {
176
633
  sessions.sort((a, b) => b.tokenCount - a.tokenCount);
177
634
  }
178
635
  // default: already sorted by lastActivityAt DESC
179
- // Limit
180
- const limitStr = Array.isArray(limit) ? limit[0] : limit;
181
- const max = Math.min(parseInt(limitStr, 10) || 50, 500);
182
- sessions = sessions.slice(0, max);
183
- res.json({ sessions });
636
+ // Total count (after filtering, before pagination)
637
+ const total = sessions.length;
638
+ // Pagination
639
+ const limitVal = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 500);
640
+ const offsetVal = Math.max(parseInt(offset, 10) || 0, 0);
641
+ sessions = sessions.slice(offsetVal, offsetVal + limitVal);
642
+ // Attach project tags to each session
643
+ const sessionIds = sessions.map((s) => s.id);
644
+ const projectTags = (0, projects_1.bulkGetSessionProjects)(sessionIds);
645
+ const sessionsWithProjects = sessions.map((s) => ({
646
+ ...s,
647
+ projects: projectTags.get(s.id) || [],
648
+ }));
649
+ res.json({ sessions: sessionsWithProjects, total });
184
650
  }
185
651
  catch (err) {
186
652
  res.status(500).json({ error: err.message || "Failed to list sessions" });
@@ -188,7 +654,8 @@ router.get("/sessions", async (req, res) => {
188
654
  });
189
655
  router.get("/sessions/:id", async (req, res) => {
190
656
  try {
191
- const detail = await (0, sessions_1.getSessionDetail)(req.params.id);
657
+ const profileFilter = req.query.profile;
658
+ const detail = await (0, sessions_1.getSessionDetail)(req.params.id, profileFilter);
192
659
  if (!detail) {
193
660
  res.status(404).json({ error: "Session not found" });
194
661
  return;
@@ -209,6 +676,162 @@ router.get("/sessions/:id/suggestions", async (req, res) => {
209
676
  res.status(500).json({ error: err.message || "Failed to get suggestions" });
210
677
  }
211
678
  });
679
+ // ---------- Session Project Tags ----------
680
+ router.put("/sessions/:id/projects", (req, res) => {
681
+ const { projectIds } = req.body;
682
+ if (!Array.isArray(projectIds)) {
683
+ res.status(400).json({ error: "projectIds must be an array" });
684
+ return;
685
+ }
686
+ (0, projects_1.setSessionProjects)(req.params.id, projectIds);
687
+ res.json({ ok: true });
688
+ });
689
+ router.delete("/sessions/:id/projects/:projectId", (req, res) => {
690
+ const ok = (0, projects_1.removeSessionFromProject)(req.params.projectId, req.params.id);
691
+ if (!ok) {
692
+ res.status(404).json({ error: "Project tag not found on session" });
693
+ return;
694
+ }
695
+ res.json({ ok: true });
696
+ });
697
+ router.get("/sessions/:id/projects", (req, res) => {
698
+ const projects = (0, projects_1.getSessionProjects)(req.params.id);
699
+ res.json({ projects });
700
+ });
701
+ // ---------- Analytics ----------
702
+ router.get("/analytics", async (req, res) => {
703
+ try {
704
+ const profile = req.query.profile;
705
+ const groupBy = req.query.groupBy || "day";
706
+ const from = req.query.from;
707
+ const to = req.query.to;
708
+ let sessions = await (0, sessions_1.listSessions)(profile);
709
+ // For hourly view, default to last 3 days if no from specified
710
+ let effectiveFrom = from;
711
+ if (groupBy === "hour" && !from) {
712
+ const threeDaysAgo = new Date();
713
+ threeDaysAgo.setUTCDate(threeDaysAgo.getUTCDate() - 3);
714
+ effectiveFrom = threeDaysAgo.toISOString();
715
+ }
716
+ // Filter by date range using startedAt
717
+ if (effectiveFrom)
718
+ sessions = sessions.filter((s) => s.startedAt >= effectiveFrom);
719
+ if (to)
720
+ sessions = sessions.filter((s) => s.startedAt <= to);
721
+ // Get project tags for all sessions
722
+ const sessionIds = sessions.map((s) => s.id);
723
+ const projectTags = (0, projects_1.bulkGetSessionProjects)(sessionIds);
724
+ // Helper: get bucket date key for a session
725
+ function getBucketKey(dateStr) {
726
+ const d = new Date(dateStr);
727
+ if (groupBy === "hour") {
728
+ return d.toISOString().slice(0, 13) + ":00"; // "2026-03-10T14:00"
729
+ }
730
+ if (groupBy === "week") {
731
+ // ISO week starts on Monday
732
+ const day = d.getUTCDay();
733
+ const diff = (day === 0 ? -6 : 1) - day; // adjust to Monday
734
+ const monday = new Date(d);
735
+ monday.setUTCDate(monday.getUTCDate() + diff);
736
+ return monday.toISOString().slice(0, 10);
737
+ }
738
+ return d.toISOString().slice(0, 10);
739
+ }
740
+ // Helper: generate all date keys between min and max
741
+ function generateAllKeys(minDate, maxDate) {
742
+ const keys = [];
743
+ const current = new Date(minDate);
744
+ const end = new Date(maxDate);
745
+ while (current <= end) {
746
+ if (groupBy === "hour") {
747
+ keys.push(current.toISOString().slice(0, 13) + ":00");
748
+ current.setUTCHours(current.getUTCHours() + 1);
749
+ }
750
+ else if (groupBy === "week") {
751
+ keys.push(current.toISOString().slice(0, 10));
752
+ current.setUTCDate(current.getUTCDate() + 7);
753
+ }
754
+ else {
755
+ keys.push(current.toISOString().slice(0, 10));
756
+ current.setUTCDate(current.getUTCDate() + 1);
757
+ }
758
+ }
759
+ return keys;
760
+ }
761
+ const totalMap = new Map();
762
+ const agentMap = new Map();
763
+ const projectMap = new Map();
764
+ for (const s of sessions) {
765
+ const key = getBucketKey(s.startedAt);
766
+ // Total
767
+ const tb = totalMap.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 };
768
+ tb.costUsd += s.costUsd;
769
+ tb.tokenCount += s.tokenCount;
770
+ tb.sessionCount += 1;
771
+ totalMap.set(key, tb);
772
+ // By agent
773
+ if (!agentMap.has(s.agentId))
774
+ agentMap.set(s.agentId, new Map());
775
+ const ab = agentMap.get(s.agentId).get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 };
776
+ ab.costUsd += s.costUsd;
777
+ ab.tokenCount += s.tokenCount;
778
+ ab.sessionCount += 1;
779
+ agentMap.get(s.agentId).set(key, ab);
780
+ // By project (session can belong to multiple projects)
781
+ const projects = projectTags.get(s.id) || [];
782
+ for (const p of projects) {
783
+ if (!projectMap.has(p.id))
784
+ projectMap.set(p.id, { name: p.name, buckets: new Map() });
785
+ const pb = projectMap.get(p.id).buckets.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 };
786
+ pb.costUsd += s.costUsd;
787
+ pb.tokenCount += s.tokenCount;
788
+ pb.sessionCount += 1;
789
+ projectMap.get(p.id).buckets.set(key, pb);
790
+ }
791
+ }
792
+ // Determine date range for zero-filling
793
+ const allKeys = [...totalMap.keys()].sort();
794
+ let rangeStart = effectiveFrom ? getBucketKey(effectiveFrom) : (from ? getBucketKey(from) : allKeys[0]);
795
+ let rangeEnd = to ? getBucketKey(to) : allKeys[allKeys.length - 1];
796
+ // For hourly view, always extend to current time so chart isn't cut off
797
+ if (groupBy === "hour" && !to) {
798
+ const nowKey = getBucketKey(new Date().toISOString());
799
+ if (!rangeEnd || nowKey > rangeEnd)
800
+ rangeEnd = nowKey;
801
+ }
802
+ if (!rangeStart || !rangeEnd) {
803
+ res.json({ buckets: [], byAgent: [], byProject: [] });
804
+ return;
805
+ }
806
+ const allDates = generateAllKeys(rangeStart, rangeEnd);
807
+ const zeroBucket = { costUsd: 0, tokenCount: 0, sessionCount: 0 };
808
+ // Build response with zero-filled buckets
809
+ const buckets = allDates.map((date) => ({
810
+ date,
811
+ ...(totalMap.get(date) || zeroBucket),
812
+ }));
813
+ const byAgent = [...agentMap.entries()].map(([agentId, bMap]) => ({
814
+ agentId,
815
+ name: agentId,
816
+ buckets: allDates.map((date) => ({
817
+ date,
818
+ ...(bMap.get(date) || zeroBucket),
819
+ })),
820
+ }));
821
+ const byProject = [...projectMap.entries()].map(([projectId, { name, buckets: bMap }]) => ({
822
+ projectId,
823
+ name,
824
+ buckets: allDates.map((date) => ({
825
+ date,
826
+ ...(bMap.get(date) || zeroBucket),
827
+ })),
828
+ }));
829
+ res.json({ buckets, byAgent, byProject });
830
+ }
831
+ catch (err) {
832
+ res.status(500).json({ error: err.message || "Failed to get analytics" });
833
+ }
834
+ });
212
835
  // ---------- Projects ----------
213
836
  router.post("/projects", (req, res) => {
214
837
  const { name, description } = req.body;
@@ -219,8 +842,18 @@ router.post("/projects", (req, res) => {
219
842
  const project = (0, projects_1.createProject)(name, description);
220
843
  res.status(201).json(project);
221
844
  });
222
- router.get("/projects", (_req, res) => {
223
- const projects = (0, projects_1.listProjects)();
845
+ router.get("/projects", async (_req, res) => {
846
+ const profileParam = _req.query.profile;
847
+ let projects = (0, projects_1.listProjects)();
848
+ // Filter projects to only include those with sessions in the selected profile
849
+ if (profileParam) {
850
+ const sessions = await (0, sessions_1.listSessions)(profileParam);
851
+ const profileSessionIds = new Set(sessions.map((s) => s.id));
852
+ projects = projects.filter((p) => {
853
+ const projectSessionIds = db_1.default.prepare("SELECT sessionId FROM project_sessions WHERE projectId = ?").all(p.id);
854
+ return projectSessionIds.some((ps) => profileSessionIds.has(ps.sessionId));
855
+ });
856
+ }
224
857
  res.json({ projects });
225
858
  });
226
859
  router.get("/projects/:id", async (req, res) => {