ai-usage-analyzer 0.3.2 → 0.5.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/src/loaders.js CHANGED
@@ -15,11 +15,28 @@
15
15
  // model: string,
16
16
  // }
17
17
 
18
- import { readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
19
- import { join } from 'node:path';
20
- import { DatabaseSync } from 'node:sqlite';
21
- import { createInterface } from 'node:readline';
22
- import { createReadStream } from 'node:fs';
18
+ import { createReadStream, readdirSync } from "node:fs";
19
+ import { basename, dirname, join } from "node:path";
20
+ import { createInterface } from "node:readline";
21
+ import { DatabaseSync } from "node:sqlite";
22
+ import { estimateUsage } from "./pricing.js";
23
+
24
+ // Run fn over every item in items with at most `limit` in-flight at once.
25
+ async function mapConcurrent(items, fn, limit) {
26
+ if (items.length === 0) return [];
27
+ const results = new Array(items.length);
28
+ let next = 0;
29
+ async function worker() {
30
+ while (next < items.length) {
31
+ const i = next++;
32
+ results[i] = await fn(items[i]);
33
+ }
34
+ }
35
+ await Promise.all(
36
+ Array.from({ length: Math.min(limit, items.length) }, worker),
37
+ );
38
+ return results;
39
+ }
23
40
 
24
41
  // ---------------------------------------------------------------------------
25
42
  // Date helpers
@@ -28,17 +45,24 @@ import { createReadStream } from 'node:fs';
28
45
  function isoWeekKey(ts) {
29
46
  const d = new Date(ts);
30
47
  // ISO week: Thu in current week decides the year
31
- const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
48
+ const target = new Date(
49
+ Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
50
+ );
32
51
  const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0..Sun=6
33
52
  target.setUTCDate(target.getUTCDate() - dayNum + 3); // Thu
34
53
  const firstThu = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
35
- const week = 1 + Math.round(((target - firstThu) / 86400000 - 3 + ((firstThu.getUTCDay() + 6) % 7)) / 7);
36
- return `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
54
+ const week =
55
+ 1 +
56
+ Math.round(
57
+ ((target - firstThu) / 86400000 - 3 + ((firstThu.getUTCDay() + 6) % 7)) /
58
+ 7,
59
+ );
60
+ return `${target.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
37
61
  }
38
62
 
39
63
  function monthKey(ts) {
40
64
  const d = new Date(ts);
41
- return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
65
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
42
66
  }
43
67
 
44
68
  function parseIsoMs(s) {
@@ -47,10 +71,15 @@ function parseIsoMs(s) {
47
71
  return Number.isFinite(t) ? t : null;
48
72
  }
49
73
 
74
+ function tokenCount(value) {
75
+ const n = Number(value);
76
+ return Number.isFinite(n) && n >= 0 ? n : 0;
77
+ }
78
+
50
79
  function compactHome(p) {
51
- if (!p) return '(unknown)';
52
- const home = process.env.HOME || '';
53
- return p.startsWith(home) ? '~' + p.slice(home.length) : p;
80
+ if (!p) return "(unknown)";
81
+ const home = process.env.HOME || "";
82
+ return p.startsWith(home) ? "~" + p.slice(home.length) : p;
54
83
  }
55
84
 
56
85
  // ---------------------------------------------------------------------------
@@ -67,40 +96,53 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
67
96
 
68
97
  // Introspect session table for token columns (MimoCode schema is a subset)
69
98
  const cols = db.prepare(`PRAGMA table_info(session)`).all();
70
- const colSet = new Set(cols.map(c => c.name));
71
- const hasTokens = colSet.has('tokens_input')
72
- && colSet.has('tokens_output')
73
- && colSet.has('tokens_cache_read')
74
- && colSet.has('tokens_cache_write');
99
+ const colSet = new Set(cols.map((c) => c.name));
100
+ const hasTokens =
101
+ colSet.has("tokens_input") &&
102
+ colSet.has("tokens_output") &&
103
+ colSet.has("tokens_cache_read") &&
104
+ colSet.has("tokens_cache_write");
75
105
 
76
106
  if (!hasTokens) {
77
107
  db.close();
78
- return { records: [], info: 'session table has no token columns' };
108
+ return { records: [], info: "session table has no token columns" };
79
109
  }
80
110
 
81
- const rows = db.prepare(`
111
+ const hasTimeUpdated = colSet.has("time_updated");
112
+ const rows = db
113
+ .prepare(
114
+ `
82
115
  SELECT id, directory, title, time_created,
116
+ ${hasTimeUpdated ? "time_updated," : ""}
83
117
  tokens_input, tokens_output, tokens_reasoning,
84
118
  tokens_cache_read, tokens_cache_write, cost, model
85
119
  FROM session
86
120
  WHERE time_archived IS NULL
87
- `).all();
121
+ `,
122
+ )
123
+ .all();
88
124
  db.close();
89
125
 
90
126
  const records = [];
91
127
  for (const r of rows) {
92
- const tot = (r.tokens_input || 0) + (r.tokens_output || 0)
93
- + (r.tokens_reasoning || 0) + (r.tokens_cache_read || 0)
94
- + (r.tokens_cache_write || 0);
128
+ const tot =
129
+ (r.tokens_input || 0) +
130
+ (r.tokens_output || 0) +
131
+ (r.tokens_reasoning || 0) +
132
+ (r.tokens_cache_read || 0) +
133
+ (r.tokens_cache_write || 0);
95
134
  if (tot === 0) continue;
96
135
  records.push({
97
136
  tool: toolKey,
98
137
  sessionId: r.id,
99
138
  project: compactHome(r.directory),
100
- title: r.title || '',
139
+ title: r.title || "",
101
140
  week: isoWeekKey(r.time_created),
102
141
  month: monthKey(r.time_created),
103
142
  ts: r.time_created,
143
+ durationMs: hasTimeUpdated
144
+ ? Math.max(0, (r.time_updated || r.time_created) - r.time_created)
145
+ : 0,
104
146
  tokensInput: r.tokens_input || 0,
105
147
  tokensOutput: r.tokens_output || 0,
106
148
  tokensCacheRead: r.tokens_cache_read || 0,
@@ -108,7 +150,8 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
108
150
  tokensReasoning: r.tokens_reasoning || 0,
109
151
  tokensTotal: tot,
110
152
  cost: r.cost || 0,
111
- model: r.model || '',
153
+ costStatus: r.cost == null ? "unavailable" : "recorded",
154
+ model: r.model || "",
112
155
  });
113
156
  }
114
157
  return { records };
@@ -119,74 +162,135 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
119
162
  // ---------------------------------------------------------------------------
120
163
 
121
164
  async function loadCodexRollouts(rootDir) {
122
- const records = [];
123
165
  const errors = [];
124
- const files = walkJsonl(rootDir, 'rollout-');
125
-
126
- for (const f of files) {
127
- try {
128
- const rec = await parseCodexRollout(f);
129
- if (rec) records.push(rec);
130
- } catch (e) {
131
- errors.push(`${f}: ${e.message}`);
132
- }
133
- }
134
- return { records, errors };
166
+ const roots = basename(rootDir) === "sessions" ? [rootDir, join(dirname(rootDir), "archived_sessions")] : [rootDir];
167
+ const files = [...new Set(roots.flatMap((root) => walkJsonl(root, "rollout-")))];
168
+ files.sort((a, b) => Number(a.split(/[\\/]/).includes("archived_sessions")) - Number(b.split(/[\\/]/).includes("archived_sessions")) || a.localeCompare(b));
169
+ const results = await mapConcurrent(
170
+ files,
171
+ async (f) => {
172
+ try {
173
+ return await parseCodexRollout(f);
174
+ } catch (e) {
175
+ errors.push(`${f}: ${e.message}`);
176
+ return null;
177
+ }
178
+ },
179
+ 48,
180
+ );
181
+ const sessions = new Map();
182
+ for (const record of results) if (record && !sessions.has(record.sessionId)) sessions.set(record.sessionId, record);
183
+ return { records: [...sessions.values()], errors };
135
184
  }
136
185
 
137
186
  async function parseCodexRollout(path) {
138
187
  // Use a streaming reader to avoid loading huge files into memory.
139
- // The 'token_count' event accumulates usage; we take the LAST one (final snapshot).
140
188
  let sessionMeta = null;
141
- let lastUsage = null;
142
- let lastModel = '';
143
-
144
- const stream = createReadStream(path, { encoding: 'utf8' });
189
+ let previous = null;
190
+ let forkWaiting = false;
191
+ let inheritedTotal = null;
192
+ const usageEvents = [];
193
+ let lastModel = "";
194
+ let provider = "openai";
195
+ let lastEvTs = null;
196
+
197
+ const stream = createReadStream(path, { encoding: "utf8" });
145
198
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
146
199
  for await (const line of rl) {
147
200
  if (!line) continue;
148
201
  let ev;
149
- try { ev = JSON.parse(line); } catch { continue; }
202
+ try {
203
+ ev = JSON.parse(line);
204
+ } catch {
205
+ continue;
206
+ }
150
207
  const t = ev.type;
151
208
  const p = ev.payload || {};
152
- if (t === 'session_meta') {
153
- sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || '' };
154
- } else if (t === 'event_msg' && p.type === 'token_count') {
209
+ const evTs = parseIsoMs(ev.timestamp);
210
+ if (evTs) lastEvTs = evTs;
211
+ if (t === "session_meta") {
212
+ sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || "", id: typeof p.id === "string" ? p.id : null };
213
+ forkWaiting = Boolean(p.forked_from_id);
214
+ if (typeof p.model_provider === "string" && p.model_provider) provider = p.model_provider;
215
+ } else if (t === "turn_context") {
216
+ forkWaiting = false;
217
+ lastModel = p.model_info?.slug || p.model || p.model_name || lastModel;
218
+ } else if (t === "event_msg" && p.type === "token_count") {
155
219
  const info = p.info || {};
156
- const u = info.total_token_usage;
157
- if (u) lastUsage = u;
158
- const m = info.model || p.model;
220
+ const fields = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'reasoning_output_tokens'];
221
+ const normalize = (u) => u && Object.fromEntries(fields.map((field) => [field, tokenCount(u[field] ?? (field === 'cached_input_tokens' ? u.cache_read_input_tokens : 0))]));
222
+ const total = normalize(info.total_token_usage);
223
+ const last = normalize(info.last_token_usage);
224
+ if (forkWaiting) {
225
+ if (total) {
226
+ previous = total;
227
+ inheritedTotal = info.total_token_usage.total_tokens ?? null;
228
+ }
229
+ continue;
230
+ }
231
+ if (inheritedTotal !== null && info.total_token_usage?.total_tokens === inheritedTotal) {
232
+ if (total) previous = total;
233
+ inheritedTotal = null;
234
+ continue;
235
+ }
236
+ inheritedTotal = null;
237
+ let delta = null;
238
+ if (total && previous) {
239
+ if (fields.every((field) => total[field] === previous[field])) continue;
240
+ const regressed = fields.some((field) => total[field] < previous[field]);
241
+ if (regressed) {
242
+ const sum = (u) => fields.reduce((n, field) => n + u[field], 0);
243
+ if (last && sum(total) > 0 && sum(last) > 0 && (sum(total) * 100 >= sum(previous) * 98 || sum(total) + sum(last) * 2 >= sum(previous))) continue;
244
+ delta = last;
245
+ } else {
246
+ delta = last || Object.fromEntries(fields.map((field) => [field, total[field] - previous[field]]));
247
+ }
248
+ } else {
249
+ delta = last || total;
250
+ }
251
+ if (total) previous = total;
252
+ else if (last && previous) previous = Object.fromEntries(fields.map((field) => [field, previous[field] + last[field]]));
253
+ const m = info.model || info.model_name || p.model;
159
254
  if (m) lastModel = m;
255
+ if (delta) usageEvents.push({ model: lastModel, provider, aggregated: !last, usage: delta });
160
256
  }
161
257
  }
162
- if (!sessionMeta || !lastUsage) return null;
258
+ if (!sessionMeta || !usageEvents.length) return null;
163
259
 
164
260
  const ts = parseIsoMs(sessionMeta.ts);
165
261
  if (!ts) return null;
166
-
167
- const tokensInput = lastUsage.input_tokens || 0;
168
- const tokensOutput = lastUsage.output_tokens || 0;
169
- const tokensCacheRead = lastUsage.cached_input_tokens || 0;
170
- const tokensReasoning = lastUsage.reasoning_output_tokens || 0;
262
+ const durationMs = lastEvTs && lastEvTs > ts ? lastEvTs - ts : 0;
263
+
264
+ let tokensInput = 0, tokensOutput = 0, tokensCacheRead = 0, tokensReasoning = 0;
265
+ for (const { usage } of usageEvents) {
266
+ const cached = Math.min(usage.input_tokens, usage.cached_input_tokens);
267
+ const reasoning = Math.min(usage.output_tokens, usage.reasoning_output_tokens);
268
+ tokensInput += usage.input_tokens - cached;
269
+ tokensOutput += usage.output_tokens - reasoning;
270
+ tokensCacheRead += cached;
271
+ tokensReasoning += reasoning;
272
+ }
171
273
  const tot = tokensInput + tokensOutput + tokensCacheRead + tokensReasoning;
172
274
  if (tot === 0) return null;
173
275
 
174
276
  return {
175
- tool: 'codex',
176
- sessionId: path.split('-').pop().replace('.jsonl', ''),
277
+ tool: "codex",
278
+ sessionId: sessionMeta.id || basename(path).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\.jsonl$)/i)?.[0] || basename(path, ".jsonl"),
177
279
  project: compactHome(sessionMeta.cwd),
178
- title: '', // not available in codex rollouts
280
+ title: "",
179
281
  week: isoWeekKey(ts),
180
282
  month: monthKey(ts),
181
283
  ts,
284
+ durationMs,
182
285
  tokensInput,
183
286
  tokensOutput,
184
287
  tokensCacheRead,
185
- tokensCacheWrite: 0, // codex doesn't expose cache_write separately
288
+ tokensCacheWrite: 0, // codex doesn't expose cache_write separately
186
289
  tokensReasoning,
187
290
  tokensTotal: tot,
188
- cost: 0,
291
+ ...estimateUsage("codex", usageEvents),
189
292
  model: lastModel,
293
+ usageEvents,
190
294
  };
191
295
  }
192
296
 
@@ -194,11 +298,19 @@ function walkJsonl(root, prefix) {
194
298
  const out = [];
195
299
  function walk(dir) {
196
300
  let entries;
197
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
301
+ try {
302
+ entries = readdirSync(dir, { withFileTypes: true });
303
+ } catch {
304
+ return;
305
+ }
198
306
  for (const e of entries) {
199
307
  const full = join(dir, e.name);
200
308
  if (e.isDirectory()) walk(full);
201
- else if (e.isFile() && e.name.startsWith(prefix) && e.name.endsWith('.jsonl')) {
309
+ else if (
310
+ e.isFile() &&
311
+ e.name.startsWith(prefix) &&
312
+ e.name.endsWith(".jsonl")
313
+ ) {
202
314
  out.push(full);
203
315
  }
204
316
  }
@@ -214,13 +326,17 @@ function walkClaudeSessions(root) {
214
326
  const out = [];
215
327
  function walk(dir) {
216
328
  let entries;
217
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
329
+ try {
330
+ entries = readdirSync(dir, { withFileTypes: true });
331
+ } catch {
332
+ return;
333
+ }
218
334
  for (const e of entries) {
219
335
  const full = join(dir, e.name);
220
336
  if (e.isDirectory()) {
221
- if (e.name === 'subagents') continue;
337
+ if (e.name === "subagents") continue;
222
338
  walk(full);
223
- } else if (e.isFile() && e.name.endsWith('.jsonl')) {
339
+ } else if (e.isFile() && e.name.endsWith(".jsonl")) {
224
340
  out.push(full);
225
341
  }
226
342
  }
@@ -235,56 +351,75 @@ function walkClaudeSessions(root) {
235
351
  //
236
352
  // Each top-level file under projects/<encoded-cwd>/<UUID>.jsonl is one session.
237
353
  // Per assistant line, `message.usage` carries the per-turn token counts; we
238
- // dedupe by `message.id` (Claude Code emits streaming duplicates) and sum
239
- // across unique messages. cwd / model / timestamp come from the line itself.
354
+ // merge streamed usage by message and request, then sum unique responses. cwd / model / timestamp come from the line itself.
240
355
 
241
356
  async function loadClaudeJsonl(rootDir) {
242
- const records = [];
243
357
  const errors = [];
244
358
  const files = walkClaudeSessions(rootDir);
245
-
246
- for (const f of files) {
247
- try {
248
- const rec = await parseClaudeSession(f);
249
- if (rec) records.push(rec);
250
- } catch (e) {
251
- errors.push(`${f}: ${e.message}`);
252
- }
253
- }
254
- return { records, errors };
359
+ const results = await mapConcurrent(
360
+ files,
361
+ async (f) => {
362
+ try {
363
+ return await parseClaudeSession(f);
364
+ } catch (e) {
365
+ errors.push(`${f}: ${e.message}`);
366
+ return null;
367
+ }
368
+ },
369
+ 48,
370
+ );
371
+ return { records: results.filter(Boolean), errors };
255
372
  }
256
373
 
257
374
  async function parseClaudeSession(path) {
258
- const sessionId = path.split('/').pop().replace('.jsonl', '');
259
- const seenIds = new Set();
260
- let cwd = '';
261
- let model = '';
375
+ const sessionId = path.split("/").pop().replace(".jsonl", "");
376
+ const usages = new Map();
377
+ const usageModels = new Map();
378
+ let anonymous = 0;
379
+ let cwd = "";
380
+ let model = "";
262
381
  let firstTs = null;
382
+ let lastTs = null;
383
+ let title = "";
263
384
 
264
385
  let tokensInput = 0;
265
386
  let tokensOutput = 0;
266
387
  let tokensCacheRead = 0;
267
388
  let tokensCacheWrite = 0;
268
389
 
269
- const stream = createReadStream(path, { encoding: 'utf8' });
390
+ const stream = createReadStream(path, { encoding: "utf8" });
270
391
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
271
392
  for await (const line of rl) {
272
393
  if (!line) continue;
273
394
  let ev;
274
- try { ev = JSON.parse(line); } catch { continue; }
275
- if (ev.type !== 'assistant') continue;
395
+ try {
396
+ ev = JSON.parse(line);
397
+ } catch {
398
+ continue;
399
+ }
400
+ // Grab summary title from the first human message that has one.
401
+ if (!title && ev.type === "summary" && typeof ev.summary === "string")
402
+ title = ev.summary;
403
+ if (ev.type !== "assistant") continue;
276
404
  const msg = ev.message || {};
277
405
  const usage = msg.usage;
278
406
  if (!usage) continue;
279
407
  const id = msg.id;
280
- if (id) {
281
- if (seenIds.has(id)) continue;
282
- seenIds.add(id);
408
+ const key = id ? JSON.stringify([id, ev.requestId || null]) : `anonymous:${anonymous++}`;
409
+ const previous = usages.get(key) || {};
410
+ const merged = {};
411
+ for (const field of ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"]) {
412
+ merged[field] = Math.max(tokenCount(previous[field]), tokenCount(usage[field]));
283
413
  }
284
- if (!cwd && typeof ev.cwd === 'string') cwd = ev.cwd;
285
- if (!model && typeof msg.model === 'string') model = msg.model;
286
- const ts = parseIsoMs(ev.timestamp);
287
- if (firstTs === null && ts !== null) firstTs = ts;
414
+ usages.set(key, merged);
415
+ if (msg.model) usageModels.set(key, msg.model);
416
+ if (!cwd && typeof ev.cwd === "string") cwd = ev.cwd;
417
+ if (!model && typeof msg.model === "string") model = msg.model;
418
+ const evTs = parseIsoMs(ev.timestamp);
419
+ if (firstTs === null && evTs !== null) firstTs = evTs;
420
+ if (evTs !== null) lastTs = evTs;
421
+ }
422
+ for (const usage of usages.values()) {
288
423
  tokensInput += usage.input_tokens || 0;
289
424
  tokensOutput += usage.output_tokens || 0;
290
425
  tokensCacheRead += usage.cache_read_input_tokens || 0;
@@ -294,23 +429,27 @@ async function parseClaudeSession(path) {
294
429
  const tot = tokensInput + tokensOutput + tokensCacheRead + tokensCacheWrite;
295
430
  if (tot === 0) return null;
296
431
  const ts = firstTs ?? Date.now();
432
+ const durationMs = firstTs !== null && lastTs !== null ? lastTs - firstTs : 0;
297
433
 
434
+ const usageEvents = [...usages].map(([key, usage]) => ({ model: usageModels.get(key) || "", usage }));
298
435
  return {
299
- tool: 'claude',
436
+ tool: "claude",
300
437
  sessionId,
301
438
  project: compactHome(cwd),
302
- title: '',
439
+ title,
303
440
  week: isoWeekKey(ts),
304
441
  month: monthKey(ts),
305
442
  ts,
443
+ durationMs,
306
444
  tokensInput,
307
445
  tokensOutput,
308
446
  tokensCacheRead,
309
447
  tokensCacheWrite,
310
448
  tokensReasoning: 0,
311
449
  tokensTotal: tot,
312
- cost: 0,
450
+ ...estimateUsage("claude", usageEvents),
313
451
  model,
452
+ usageEvents,
314
453
  };
315
454
  }
316
455
 
@@ -322,24 +461,29 @@ export async function loadAll(detections) {
322
461
  const all = [];
323
462
  const errors = [];
324
463
 
325
- for (const d of detections) {
326
- if (d.status !== 'present') continue;
327
- if (d.key === 'opencode' || d.key === 'mimocode') {
328
- const r = loadOpencodeStyleSqlite(d.path, d.key);
329
- all.push(...r.records);
330
- if (r.error) errors.push(`${d.name}: ${r.error}`);
331
- if (r.info) errors.push(`${d.name}: ${r.info}`);
332
- } else if (d.key === 'codex') {
333
- const r = await loadCodexRollouts(d.path);
334
- all.push(...r.records);
335
- if (r.errors) errors.push(...r.errors);
336
- } else if (d.key === 'claude') {
337
- const r = await loadClaudeJsonl(d.path);
338
- all.push(...r.records);
339
- if (r.errors) errors.push(...r.errors);
340
- }
341
- // For other tools (copilot, antigravity, gemini), we only have
342
- // presence info — no token data to load.
464
+ const tasks = detections
465
+ .filter((d) => d.status === "present")
466
+ .map((d) => {
467
+ if (d.key === "opencode" || d.key === "mimocode") {
468
+ return Promise.resolve({
469
+ d,
470
+ r: loadOpencodeStyleSqlite(d.path, d.key),
471
+ });
472
+ } else if (d.key === "codex") {
473
+ return loadCodexRollouts(d.path).then((r) => ({ d, r }));
474
+ } else if (d.key === "claude") {
475
+ return loadClaudeJsonl(d.path).then((r) => ({ d, r }));
476
+ }
477
+ return null;
478
+ })
479
+ .filter(Boolean);
480
+
481
+ for (const item of await Promise.all(tasks)) {
482
+ const { d, r } = item;
483
+ all.push(...r.records);
484
+ if (r.error) errors.push(`${d.name}: ${r.error}`);
485
+ if (r.info) errors.push(`${d.name}: ${r.info}`);
486
+ if (r.errors) errors.push(...r.errors);
343
487
  }
344
488
  return { records: all, errors };
345
489
  }
@@ -350,7 +494,8 @@ export async function loadAll(detections) {
350
494
 
351
495
  export function dateRange(records) {
352
496
  if (records.length === 0) return [null, null];
353
- let min = Infinity, max = -Infinity;
497
+ let min = Infinity,
498
+ max = -Infinity;
354
499
  for (const r of records) {
355
500
  if (r.ts < min) min = r.ts;
356
501
  if (r.ts > max) max = r.ts;