@xerg/cli 0.1.10 → 0.3.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/dist/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync7 } from "fs";
5
4
  import { styleText as styleText2 } from "util";
6
5
 
7
6
  // src/command-display.ts
@@ -111,9 +110,11 @@ function normalizeSignal(value) {
111
110
  }
112
111
 
113
112
  // src/commands/audit.ts
114
- import { readFileSync as readFileSync5 } from "fs";
115
113
  import { rmSync as rmSync4 } from "fs";
116
- import { hostname } from "os";
114
+
115
+ // ../core/src/cursor/usage-csv.ts
116
+ import { readFileSync, statSync } from "fs";
117
+ import { resolve } from "path";
117
118
 
118
119
  // ../core/src/utils/hash.ts
119
120
  import { createHash } from "crypto";
@@ -177,6 +178,563 @@ function toIsoOrNow(value) {
177
178
  return isoNow();
178
179
  }
179
180
 
181
+ // ../core/src/cursor/usage-csv.ts
182
+ var REQUIRED_HEADERS = [
183
+ "Date",
184
+ "Kind",
185
+ "Model",
186
+ "Max Mode",
187
+ "Input (w/ Cache Write)",
188
+ "Input (w/o Cache Write)",
189
+ "Cache Read",
190
+ "Output Tokens",
191
+ "Total Tokens",
192
+ "Cost"
193
+ ];
194
+ var CURSOR_ALIAS_PRICING = {
195
+ "claude-4.6-opus-high-thinking": {
196
+ provider: "anthropic",
197
+ canonicalModel: "claude-opus-4",
198
+ inputPer1m: 15,
199
+ outputPer1m: 75,
200
+ cacheWritePer1m: 18.75,
201
+ cachedInputPer1m: 1.5
202
+ },
203
+ "claude-4.5-sonnet": {
204
+ provider: "anthropic",
205
+ canonicalModel: "claude-sonnet-4-5",
206
+ inputPer1m: 3,
207
+ outputPer1m: 15,
208
+ cacheWritePer1m: 3.75,
209
+ cachedInputPer1m: 0.3
210
+ },
211
+ "claude-4.5-sonnet-thinking": {
212
+ provider: "anthropic",
213
+ canonicalModel: "claude-sonnet-4-5",
214
+ inputPer1m: 3,
215
+ outputPer1m: 15,
216
+ cacheWritePer1m: 3.75,
217
+ cachedInputPer1m: 0.3
218
+ },
219
+ "claude-4.5-opus-high-thinking": {
220
+ provider: "anthropic",
221
+ canonicalModel: "claude-opus-4-5",
222
+ inputPer1m: 5,
223
+ outputPer1m: 25,
224
+ cacheWritePer1m: 6.25,
225
+ cachedInputPer1m: 0.5
226
+ },
227
+ "gpt-5.1-codex": {
228
+ provider: "openai",
229
+ canonicalModel: "gpt-5.1-codex",
230
+ inputPer1m: 1.25,
231
+ outputPer1m: 10,
232
+ cacheWritePer1m: 1.25,
233
+ cachedInputPer1m: 0.125
234
+ },
235
+ "gpt-5-high-fast": {
236
+ provider: "openai",
237
+ canonicalModel: "gpt-5-high-fast",
238
+ inputPer1m: 1.25,
239
+ outputPer1m: 10,
240
+ cacheWritePer1m: 1.25,
241
+ cachedInputPer1m: 0.125
242
+ },
243
+ "gpt-5": {
244
+ provider: "openai",
245
+ canonicalModel: "gpt-5",
246
+ inputPer1m: 1.25,
247
+ outputPer1m: 10,
248
+ cacheWritePer1m: 1.25,
249
+ cachedInputPer1m: 0.125
250
+ }
251
+ };
252
+ function round(value) {
253
+ return Number(value.toFixed(6));
254
+ }
255
+ function parseCsvLine(line) {
256
+ const values = [];
257
+ let current = "";
258
+ let inQuotes = false;
259
+ for (let index = 0; index < line.length; index += 1) {
260
+ const char = line[index];
261
+ if (char === '"') {
262
+ const next = line[index + 1];
263
+ if (inQuotes && next === '"') {
264
+ current += '"';
265
+ index += 1;
266
+ } else {
267
+ inQuotes = !inQuotes;
268
+ }
269
+ continue;
270
+ }
271
+ if (char === "," && !inQuotes) {
272
+ values.push(current);
273
+ current = "";
274
+ continue;
275
+ }
276
+ current += char;
277
+ }
278
+ values.push(current);
279
+ return values.map((value) => value.trim());
280
+ }
281
+ function parseInteger(raw, column, rowNumber) {
282
+ const parsed = Number.parseInt(raw, 10);
283
+ if (!Number.isFinite(parsed) || parsed < 0) {
284
+ throw new Error(`Invalid ${column} value "${raw}" on row ${rowNumber}.`);
285
+ }
286
+ return parsed;
287
+ }
288
+ function parseTimestamp(raw, rowNumber) {
289
+ const parsed = new Date(raw);
290
+ if (Number.isNaN(parsed.getTime())) {
291
+ throw new Error(`Invalid Date value "${raw}" on row ${rowNumber}.`);
292
+ }
293
+ return parsed.toISOString();
294
+ }
295
+ function parseMaxMode(raw) {
296
+ return raw.trim().toLowerCase() === "yes";
297
+ }
298
+ function parseObservedCost(raw) {
299
+ const value = raw.trim();
300
+ if (value.length === 0 || value === "-" || value.toLowerCase() === "included") {
301
+ return null;
302
+ }
303
+ const parsed = Number.parseFloat(value);
304
+ return Number.isFinite(parsed) ? parsed : null;
305
+ }
306
+ function createDetectedSource(path) {
307
+ const resolvedPath = resolve(path);
308
+ try {
309
+ const stats = statSync(resolvedPath);
310
+ if (!stats.isFile()) {
311
+ throw new Error(`Cursor usage CSV path is not a file: ${resolvedPath}`);
312
+ }
313
+ return {
314
+ kind: "cursor-usage-csv",
315
+ runtime: "cursor",
316
+ path: resolvedPath,
317
+ sizeBytes: stats.size,
318
+ mtimeMs: stats.mtimeMs
319
+ };
320
+ } catch (error) {
321
+ const message = error instanceof Error ? error.message : `Cursor usage CSV not found: ${path}`;
322
+ throw new Error(message);
323
+ }
324
+ }
325
+ function validateHeaders(headers) {
326
+ const missing = REQUIRED_HEADERS.filter((header) => !headers.includes(header));
327
+ if (missing.length > 0) {
328
+ throw new Error(`Cursor usage CSV is missing required headers: ${missing.join(", ")}.`);
329
+ }
330
+ }
331
+ function parseRow(values, headers, rowNumber) {
332
+ const record = Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""]));
333
+ const costLabel = record.Cost ?? "";
334
+ return {
335
+ timestamp: parseTimestamp(record.Date ?? "", rowNumber),
336
+ kind: record.Kind ?? "",
337
+ modelAlias: record.Model ?? "",
338
+ maxMode: parseMaxMode(record["Max Mode"] ?? ""),
339
+ inputWithCacheWriteTokens: parseInteger(
340
+ record["Input (w/ Cache Write)"] ?? "",
341
+ "Input (w/ Cache Write)",
342
+ rowNumber
343
+ ),
344
+ inputWithoutCacheWriteTokens: parseInteger(
345
+ record["Input (w/o Cache Write)"] ?? "",
346
+ "Input (w/o Cache Write)",
347
+ rowNumber
348
+ ),
349
+ cacheReadTokens: parseInteger(record["Cache Read"] ?? "", "Cache Read", rowNumber),
350
+ outputTokens: parseInteger(record["Output Tokens"] ?? "", "Output Tokens", rowNumber),
351
+ totalTokens: parseInteger(record["Total Tokens"] ?? "", "Total Tokens", rowNumber),
352
+ costLabel,
353
+ observedCostUsd: parseObservedCost(costLabel)
354
+ };
355
+ }
356
+ function parseRows(lines, headers) {
357
+ const rows = [];
358
+ for (let index = 0; index < lines.length; index += 1) {
359
+ const line = lines[index];
360
+ if (!line.trim()) {
361
+ continue;
362
+ }
363
+ rows.push(parseRow(parseCsvLine(line), headers, index + 2));
364
+ }
365
+ return rows;
366
+ }
367
+ function readCursorUsageCsv(path) {
368
+ const source = createDetectedSource(path);
369
+ const content = readFileSync(source.path, "utf8");
370
+ const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
371
+ if (lines.length === 0) {
372
+ throw new Error(`Cursor usage CSV is empty: ${source.path}`);
373
+ }
374
+ const headers = parseCsvLine(lines[0]);
375
+ validateHeaders(headers);
376
+ const rows = parseRows(lines.slice(1), headers);
377
+ return {
378
+ source,
379
+ rows,
380
+ headers,
381
+ hasObservedCostRows: rows.some((row) => row.observedCostUsd !== null)
382
+ };
383
+ }
384
+ function isErroredNoCharge(kind) {
385
+ const normalized = kind.trim().toLowerCase();
386
+ return normalized.includes("errored") && normalized.includes("no charge") || normalized.includes("not charged");
387
+ }
388
+ function inferProvider(modelAlias) {
389
+ const normalized = modelAlias.trim().toLowerCase();
390
+ if (normalized.startsWith("claude-")) {
391
+ return "anthropic";
392
+ }
393
+ if (normalized.startsWith("gpt-")) {
394
+ return "openai";
395
+ }
396
+ return "cursor";
397
+ }
398
+ function buildModelKey(modelAlias, pricing) {
399
+ if (pricing) {
400
+ return `${pricing.provider}/${pricing.canonicalModel}`;
401
+ }
402
+ return `${inferProvider(modelAlias)}/${modelAlias}`;
403
+ }
404
+ function getWorkflowKey(row) {
405
+ const kind = row.kind.trim().toLowerCase();
406
+ if (kind.includes("on-demand")) {
407
+ return row.maxMode ? "on-demand / max mode" : "on-demand / standard mode";
408
+ }
409
+ if (kind.includes("included")) {
410
+ return row.maxMode ? "included / max mode" : "included / standard mode";
411
+ }
412
+ if (kind.includes("error") || kind.includes("not charged")) {
413
+ return "not charged / failed or aborted";
414
+ }
415
+ return row.maxMode ? "other / max mode" : "other / standard mode";
416
+ }
417
+ function estimateCursorRowCost(row, options) {
418
+ const pricing = CURSOR_ALIAS_PRICING[row.modelAlias.trim().toLowerCase()] ?? null;
419
+ if (isErroredNoCharge(row.kind)) {
420
+ return {
421
+ costUsd: 0,
422
+ costSource: "observed",
423
+ cacheCostUsd: 0,
424
+ cacheWriteCostUsd: 0,
425
+ pricing,
426
+ canonicalModelKey: buildModelKey(row.modelAlias, pricing)
427
+ };
428
+ }
429
+ if (options.preferObservedCost) {
430
+ if (row.observedCostUsd !== null) {
431
+ const cacheCost2 = row.cacheReadTokens > 0 && pricing?.cachedInputPer1m !== void 0 ? round(row.cacheReadTokens / 1e6 * pricing.cachedInputPer1m) : null;
432
+ const cacheWriteCost2 = row.inputWithCacheWriteTokens > 0 && pricing ? round(
433
+ row.inputWithCacheWriteTokens / 1e6 * (pricing.cacheWritePer1m ?? pricing.inputPer1m)
434
+ ) : null;
435
+ return {
436
+ costUsd: row.observedCostUsd,
437
+ costSource: "observed",
438
+ cacheCostUsd: cacheCost2,
439
+ cacheWriteCostUsd: cacheWriteCost2,
440
+ pricing,
441
+ canonicalModelKey: buildModelKey(row.modelAlias, pricing)
442
+ };
443
+ }
444
+ return {
445
+ costUsd: 0,
446
+ costSource: "observed",
447
+ cacheCostUsd: 0,
448
+ cacheWriteCostUsd: 0,
449
+ pricing,
450
+ canonicalModelKey: buildModelKey(row.modelAlias, pricing)
451
+ };
452
+ }
453
+ if (!pricing) {
454
+ return {
455
+ costUsd: 0,
456
+ costSource: "unpriced",
457
+ cacheCostUsd: null,
458
+ cacheWriteCostUsd: null,
459
+ pricing: null,
460
+ canonicalModelKey: buildModelKey(row.modelAlias, pricing)
461
+ };
462
+ }
463
+ if (row.cacheReadTokens > 0 && pricing.cachedInputPer1m === void 0) {
464
+ return {
465
+ costUsd: 0,
466
+ costSource: "unpriced",
467
+ cacheCostUsd: null,
468
+ cacheWriteCostUsd: null,
469
+ pricing: null,
470
+ canonicalModelKey: buildModelKey(row.modelAlias, pricing)
471
+ };
472
+ }
473
+ const inputCost = row.inputWithoutCacheWriteTokens / 1e6 * pricing.inputPer1m;
474
+ const cacheWriteCost = row.inputWithCacheWriteTokens / 1e6 * (pricing.cacheWritePer1m ?? pricing.inputPer1m);
475
+ const outputCost = row.outputTokens / 1e6 * pricing.outputPer1m;
476
+ const cacheCost = row.cacheReadTokens > 0 && pricing.cachedInputPer1m !== void 0 ? row.cacheReadTokens / 1e6 * pricing.cachedInputPer1m : 0;
477
+ return {
478
+ costUsd: round(inputCost + cacheWriteCost + outputCost + cacheCost),
479
+ costSource: "estimated",
480
+ cacheCostUsd: round(cacheCost),
481
+ cacheWriteCostUsd: round(cacheWriteCost),
482
+ pricing,
483
+ canonicalModelKey: buildModelKey(row.modelAlias, pricing)
484
+ };
485
+ }
486
+ function buildCall(row, source, runId, index, options) {
487
+ const cost = estimateCursorRowCost(row, options);
488
+ const totalInputTokens = Math.max(row.totalTokens - row.outputTokens, 0);
489
+ return {
490
+ cost,
491
+ call: {
492
+ id: sha1(`${runId}:${source.path}:${index}:${row.modelAlias}:${row.timestamp}`),
493
+ runId,
494
+ timestamp: row.timestamp,
495
+ provider: cost.pricing?.provider ?? inferProvider(row.modelAlias),
496
+ model: cost.pricing?.canonicalModel ?? row.modelAlias,
497
+ inputTokens: totalInputTokens,
498
+ outputTokens: row.outputTokens,
499
+ costUsd: cost.costUsd,
500
+ costSource: cost.costSource,
501
+ latencyMs: null,
502
+ toolCalls: 0,
503
+ retries: 0,
504
+ attempt: null,
505
+ iteration: null,
506
+ status: isErroredNoCharge(row.kind) ? "error" : null,
507
+ taskClass: null,
508
+ cacheHit: row.cacheReadTokens > 0,
509
+ cacheCostUsd: cost.cacheCostUsd,
510
+ metadata: {
511
+ source: "cursor-usage-csv",
512
+ kind: row.kind,
513
+ maxMode: row.maxMode,
514
+ modelAlias: row.modelAlias,
515
+ costLabel: row.costLabel,
516
+ totalTokens: row.totalTokens,
517
+ inputWithCacheWriteTokens: row.inputWithCacheWriteTokens,
518
+ inputWithoutCacheWriteTokens: row.inputWithoutCacheWriteTokens,
519
+ cacheReadTokens: row.cacheReadTokens,
520
+ pricingProvider: cost.pricing?.provider ?? null,
521
+ pricingModel: cost.pricing?.canonicalModel ?? null,
522
+ canonicalModelKey: cost.canonicalModelKey,
523
+ observedCostUsd: row.observedCostUsd,
524
+ cacheWriteCostUsd: cost.cacheWriteCostUsd
525
+ }
526
+ }
527
+ };
528
+ }
529
+ function normalizeCursorUsageCsv(input) {
530
+ const cutoff = parseSince(input.since);
531
+ const runs = [];
532
+ const modelCoverage = /* @__PURE__ */ new Map();
533
+ const modes = /* @__PURE__ */ new Map();
534
+ const models = /* @__PURE__ */ new Map();
535
+ let pricedCallCount = 0;
536
+ let unpricedCallCount = 0;
537
+ let pricedTokenCount = 0;
538
+ let unpricedTokenCount = 0;
539
+ let totalTokens = 0;
540
+ let totalOutputTokens = 0;
541
+ let totalCacheReadTokens = 0;
542
+ let totalInputWithCacheWriteTokens = 0;
543
+ let totalInputWithoutCacheWriteTokens = 0;
544
+ input.rows.forEach((row, index) => {
545
+ const timestampMs = new Date(row.timestamp).getTime();
546
+ if (cutoff && timestampMs < cutoff) {
547
+ return;
548
+ }
549
+ const workflow = getWorkflowKey(row);
550
+ const runId = sha1(`${input.source.path}:${row.timestamp}:${row.modelAlias}:${index}`);
551
+ const { call, cost } = buildCall(row, input.source, runId, index, {
552
+ preferObservedCost: input.hasObservedCostRows ?? false
553
+ });
554
+ const run2 = {
555
+ id: runId,
556
+ sourceKind: input.source.kind,
557
+ sourcePath: input.source.path,
558
+ timestamp: row.timestamp,
559
+ workflow,
560
+ environment: "local",
561
+ tags: {
562
+ sourceKind: input.source.kind,
563
+ maxMode: row.maxMode,
564
+ kind: row.kind
565
+ },
566
+ calls: [call],
567
+ totalCostUsd: call.costUsd,
568
+ totalTokens: row.totalTokens,
569
+ observedCostUsd: call.costSource === "observed" ? call.costUsd : 0,
570
+ estimatedCostUsd: call.costSource === "estimated" ? call.costUsd : 0
571
+ };
572
+ runs.push(run2);
573
+ totalTokens += row.totalTokens;
574
+ totalOutputTokens += row.outputTokens;
575
+ totalCacheReadTokens += row.cacheReadTokens;
576
+ totalInputWithCacheWriteTokens += row.inputWithCacheWriteTokens;
577
+ totalInputWithoutCacheWriteTokens += row.inputWithoutCacheWriteTokens;
578
+ const totalRowTokens = row.totalTokens;
579
+ if (cost.costSource === "unpriced") {
580
+ unpricedCallCount += 1;
581
+ unpricedTokenCount += totalRowTokens;
582
+ const current = modelCoverage.get(row.modelAlias) ?? { callCount: 0, totalTokens: 0 };
583
+ current.callCount += 1;
584
+ current.totalTokens += totalRowTokens;
585
+ modelCoverage.set(row.modelAlias, current);
586
+ } else {
587
+ pricedCallCount += 1;
588
+ pricedTokenCount += totalRowTokens;
589
+ }
590
+ const modeBucket = modes.get(workflow) ?? {
591
+ callCount: 0,
592
+ totalTokens: 0,
593
+ estimatedSpendUsd: 0
594
+ };
595
+ modeBucket.callCount += 1;
596
+ modeBucket.totalTokens += totalRowTokens;
597
+ modeBucket.estimatedSpendUsd = round(modeBucket.estimatedSpendUsd + call.costUsd);
598
+ modes.set(workflow, modeBucket);
599
+ const modelBucket = models.get(cost.canonicalModelKey) ?? {
600
+ callCount: 0,
601
+ totalTokens: 0,
602
+ estimatedSpendUsd: 0,
603
+ pricedCallCount: 0,
604
+ unpricedCallCount: 0
605
+ };
606
+ modelBucket.callCount += 1;
607
+ modelBucket.totalTokens += totalRowTokens;
608
+ modelBucket.estimatedSpendUsd = round(modelBucket.estimatedSpendUsd + call.costUsd);
609
+ if (cost.costSource === "unpriced") {
610
+ modelBucket.unpricedCallCount += 1;
611
+ } else {
612
+ modelBucket.pricedCallCount += 1;
613
+ }
614
+ models.set(cost.canonicalModelKey, modelBucket);
615
+ });
616
+ runs.sort(
617
+ (left, right) => new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime()
618
+ );
619
+ return {
620
+ runs,
621
+ pricingCoverage: {
622
+ pricedCallCount,
623
+ unpricedCallCount,
624
+ pricedTokenCount,
625
+ unpricedTokenCount,
626
+ topUnpricedModels: Array.from(modelCoverage.entries()).map(([key, value]) => ({
627
+ key,
628
+ callCount: value.callCount,
629
+ totalTokens: value.totalTokens
630
+ })).sort((left, right) => right.totalTokens - left.totalTokens).slice(0, 5)
631
+ },
632
+ cursorUsage: {
633
+ totalTokens,
634
+ totalInputTokens: Math.max(totalTokens - totalOutputTokens, 0),
635
+ totalOutputTokens,
636
+ totalCacheReadTokens,
637
+ totalInputWithCacheWriteTokens,
638
+ totalInputWithoutCacheWriteTokens,
639
+ modes: Array.from(modes.entries()).map(([key, value]) => ({
640
+ key,
641
+ callCount: value.callCount,
642
+ totalTokens: value.totalTokens,
643
+ estimatedSpendUsd: value.estimatedSpendUsd
644
+ })).sort((left, right) => right.totalTokens - left.totalTokens),
645
+ models: Array.from(models.entries()).map(([key, value]) => ({
646
+ key,
647
+ callCount: value.callCount,
648
+ totalTokens: value.totalTokens,
649
+ estimatedSpendUsd: value.estimatedSpendUsd,
650
+ pricedCallCount: value.pricedCallCount,
651
+ unpricedCallCount: value.unpricedCallCount
652
+ })).sort((left, right) => right.totalTokens - left.totalTokens)
653
+ }
654
+ };
655
+ }
656
+ function buildDoctorNotes(report) {
657
+ const notes = ["Cursor usage CSV headers validated."];
658
+ if (report.rowCount === 0) {
659
+ notes.push("The CSV contains no usage rows.");
660
+ }
661
+ if (report.pricingCoverage.unpricedCallCount > 0) {
662
+ const aliases = report.pricingCoverage.topUnpricedModels.map((model) => model.key).join(", ");
663
+ notes.push(
664
+ `Some Cursor aliases do not have full local pricing coverage: ${aliases || "unknown aliases"}.`
665
+ );
666
+ } else {
667
+ notes.push("All rows in this CSV have local pricing coverage.");
668
+ }
669
+ notes.push("Cursor CSV audits use exported usage rows rather than raw session transcripts.");
670
+ return notes;
671
+ }
672
+ async function inspectCursorUsageCsv(options) {
673
+ const filePath = options.cursorUsageCsv ? resolve(options.cursorUsageCsv) : "";
674
+ options.onProgress?.("Inspecting Cursor usage CSV...");
675
+ if (!filePath) {
676
+ return {
677
+ canAudit: false,
678
+ filePath,
679
+ source: null,
680
+ rowCount: 0,
681
+ dateRange: null,
682
+ pricingCoverage: {
683
+ pricedCallCount: 0,
684
+ unpricedCallCount: 0,
685
+ pricedTokenCount: 0,
686
+ unpricedTokenCount: 0,
687
+ topUnpricedModels: []
688
+ },
689
+ notes: ["No Cursor usage CSV path was provided."]
690
+ };
691
+ }
692
+ try {
693
+ const parsed = readCursorUsageCsv(filePath);
694
+ const normalized = normalizeCursorUsageCsv({
695
+ source: parsed.source,
696
+ rows: parsed.rows,
697
+ hasObservedCostRows: parsed.hasObservedCostRows
698
+ });
699
+ const dateRange = parsed.rows.length === 0 ? null : {
700
+ start: parsed.rows.map((row) => row.timestamp).sort((left, right) => new Date(left).getTime() - new Date(right).getTime())[0],
701
+ end: parsed.rows.map((row) => row.timestamp).sort((left, right) => new Date(left).getTime() - new Date(right).getTime()).at(-1)
702
+ };
703
+ const report = {
704
+ canAudit: true,
705
+ filePath: parsed.source.path,
706
+ source: parsed.source,
707
+ rowCount: parsed.rows.length,
708
+ dateRange,
709
+ pricingCoverage: normalized.pricingCoverage,
710
+ notes: []
711
+ };
712
+ report.notes = buildDoctorNotes(report);
713
+ options.onProgress?.(
714
+ `Cursor usage CSV is ready (${report.rowCount} row${report.rowCount === 1 ? "" : "s"}).`
715
+ );
716
+ return report;
717
+ } catch (error) {
718
+ const message = error instanceof Error ? error.message : "Unknown error";
719
+ options.onProgress?.(`Cursor usage CSV is not ready: ${message}`);
720
+ return {
721
+ canAudit: false,
722
+ filePath,
723
+ source: null,
724
+ rowCount: 0,
725
+ dateRange: null,
726
+ pricingCoverage: {
727
+ pricedCallCount: 0,
728
+ unpricedCallCount: 0,
729
+ pricedTokenCount: 0,
730
+ unpricedTokenCount: 0,
731
+ topUnpricedModels: []
732
+ },
733
+ notes: [message]
734
+ };
735
+ }
736
+ }
737
+
180
738
  // ../core/src/db/client.ts
181
739
  import { mkdirSync } from "fs";
182
740
  import { dirname } from "path";
@@ -536,7 +1094,7 @@ var FINDING_KIND_LABELS = {
536
1094
  "candidate-downgrade": "Downgrade candidates",
537
1095
  "idle-spend": "Idle waste"
538
1096
  };
539
- function round(value) {
1097
+ function round2(value) {
540
1098
  return Number(value.toFixed(6));
541
1099
  }
542
1100
  function normalizeSinceValue(since) {
@@ -581,6 +1139,7 @@ function buildComparisonKey(input) {
581
1139
  ).sort();
582
1140
  return sha1(
583
1141
  JSON.stringify({
1142
+ runtime: input.runtime,
584
1143
  kinds,
585
1144
  roots,
586
1145
  since: normalizeSinceValue(input.since)
@@ -600,7 +1159,7 @@ function buildTaxonomyBuckets(findings, classification) {
600
1159
  spendUsd: 0,
601
1160
  findingCount: 0
602
1161
  };
603
- current.spendUsd = round(current.spendUsd + finding.costImpactUsd);
1162
+ current.spendUsd = round2(current.spendUsd + finding.costImpactUsd);
604
1163
  current.findingCount += 1;
605
1164
  buckets.set(finding.kind, current);
606
1165
  }
@@ -618,9 +1177,9 @@ function buildTopSpendDeltas(currentRows, baselineRows) {
618
1177
  const currentSpendUsd = currentMap.get(key) ?? 0;
619
1178
  return {
620
1179
  key,
621
- baselineSpendUsd: round(baselineSpendUsd),
622
- currentSpendUsd: round(currentSpendUsd),
623
- deltaSpendUsd: round(currentSpendUsd - baselineSpendUsd)
1180
+ baselineSpendUsd: round2(baselineSpendUsd),
1181
+ currentSpendUsd: round2(currentSpendUsd),
1182
+ deltaSpendUsd: round2(currentSpendUsd - baselineSpendUsd)
624
1183
  };
625
1184
  }).filter((row) => row.deltaSpendUsd !== 0).sort((left, right) => Math.abs(right.deltaSpendUsd) - Math.abs(left.deltaSpendUsd)).slice(0, 3);
626
1185
  }
@@ -655,11 +1214,11 @@ function buildFindingChanges(currentFindings, baselineFindings) {
655
1214
  scope: current.scope,
656
1215
  scopeId: current.scopeId,
657
1216
  currentCostImpactUsd: current.costImpactUsd,
658
- deltaCostImpactUsd: round(current.costImpactUsd)
1217
+ deltaCostImpactUsd: round2(current.costImpactUsd)
659
1218
  });
660
1219
  continue;
661
1220
  }
662
- const deltaCostImpactUsd = round(current.costImpactUsd - baseline.costImpactUsd);
1221
+ const deltaCostImpactUsd = round2(current.costImpactUsd - baseline.costImpactUsd);
663
1222
  if (deltaCostImpactUsd > 0) {
664
1223
  worsenedHighConfidenceWaste.push({
665
1224
  kind: current.kind,
@@ -682,7 +1241,7 @@ function buildFindingChanges(currentFindings, baselineFindings) {
682
1241
  scope: baseline.scope,
683
1242
  scopeId: baseline.scopeId,
684
1243
  baselineCostImpactUsd: baseline.costImpactUsd,
685
- deltaCostImpactUsd: round(-baseline.costImpactUsd)
1244
+ deltaCostImpactUsd: round2(-baseline.costImpactUsd)
686
1245
  });
687
1246
  }
688
1247
  return {
@@ -691,17 +1250,39 @@ function buildFindingChanges(currentFindings, baselineFindings) {
691
1250
  worsenedHighConfidenceWaste: sortFindingChanges(worsenedHighConfidenceWaste)
692
1251
  };
693
1252
  }
1253
+ function inferSummaryRuntime(summary) {
1254
+ if ("runtime" in summary && summary.runtime) {
1255
+ return summary.runtime;
1256
+ }
1257
+ if (summary.sourceFiles.some((source) => source.kind === "cursor-usage-csv")) {
1258
+ return "cursor";
1259
+ }
1260
+ return "openclaw";
1261
+ }
694
1262
  function hydrateAuditSummary(summary) {
1263
+ const runtime = inferSummaryRuntime(summary);
1264
+ const shouldRebuildComparisonKey = !("runtime" in summary) || !summary.runtime || !summary.comparisonKey;
1265
+ const hydratedSources = summary.sourceFiles.map((source) => ({
1266
+ ...source,
1267
+ runtime: source.runtime ?? (source.kind === "cursor-usage-csv" ? "cursor" : runtime === "cursor" ? "openclaw" : runtime)
1268
+ }));
695
1269
  return {
696
1270
  ...summary,
697
- comparisonKey: summary.comparisonKey ?? buildComparisonKey({
698
- sources: summary.sourceFiles,
1271
+ runtime,
1272
+ sourceFiles: hydratedSources,
1273
+ comparisonKey: shouldRebuildComparisonKey ? buildComparisonKey({
1274
+ runtime,
1275
+ sources: hydratedSources,
699
1276
  since: summary.since
700
- }),
1277
+ }) : summary.comparisonKey,
701
1278
  comparison: summary.comparison ?? null,
702
1279
  wasteByKind: summary.wasteByKind?.length > 0 ? summary.wasteByKind : buildTaxonomyBuckets(summary.findings, "waste"),
703
1280
  opportunityByKind: summary.opportunityByKind?.length > 0 ? summary.opportunityByKind : buildTaxonomyBuckets(summary.findings, "opportunity"),
704
- notes: summary.notes ?? []
1281
+ spendByDay: summary.spendByDay ?? [],
1282
+ wasteByDay: summary.wasteByDay ?? [],
1283
+ notes: summary.notes ?? [],
1284
+ pricingCoverage: summary.pricingCoverage ?? null,
1285
+ cursorUsage: summary.cursorUsage ?? null
705
1286
  };
706
1287
  }
707
1288
  function buildAuditComparison(current, baseline) {
@@ -718,12 +1299,12 @@ function buildAuditComparison(current, baseline) {
718
1299
  baselineWasteSpendUsd: baseline.wasteSpendUsd,
719
1300
  baselineOpportunitySpendUsd: baseline.opportunitySpendUsd,
720
1301
  baselineStructuralWasteRate: baseline.structuralWasteRate,
721
- deltaTotalSpendUsd: round(current.totalSpendUsd - baseline.totalSpendUsd),
722
- deltaObservedSpendUsd: round(current.observedSpendUsd - baseline.observedSpendUsd),
723
- deltaEstimatedSpendUsd: round(current.estimatedSpendUsd - baseline.estimatedSpendUsd),
724
- deltaWasteSpendUsd: round(current.wasteSpendUsd - baseline.wasteSpendUsd),
725
- deltaOpportunitySpendUsd: round(current.opportunitySpendUsd - baseline.opportunitySpendUsd),
726
- deltaStructuralWasteRate: round(current.structuralWasteRate - baseline.structuralWasteRate),
1302
+ deltaTotalSpendUsd: round2(current.totalSpendUsd - baseline.totalSpendUsd),
1303
+ deltaObservedSpendUsd: round2(current.observedSpendUsd - baseline.observedSpendUsd),
1304
+ deltaEstimatedSpendUsd: round2(current.estimatedSpendUsd - baseline.estimatedSpendUsd),
1305
+ deltaWasteSpendUsd: round2(current.wasteSpendUsd - baseline.wasteSpendUsd),
1306
+ deltaOpportunitySpendUsd: round2(current.opportunitySpendUsd - baseline.opportunitySpendUsd),
1307
+ deltaStructuralWasteRate: round2(current.structuralWasteRate - baseline.structuralWasteRate),
727
1308
  deltaRunCount: current.runCount - baseline.runCount,
728
1309
  deltaCallCount: current.callCount - baseline.callCount,
729
1310
  workflowDeltas,
@@ -767,189 +1348,137 @@ function readLatestComparableAuditSummary(input) {
767
1348
  });
768
1349
  }
769
1350
 
770
- // ../core/src/detect/openclaw.ts
771
- import { readdirSync, statSync } from "fs";
772
- import { isAbsolute, join as join2, resolve, sep } from "path";
773
-
774
- // ../core/src/utils/paths.ts
775
- import { mkdirSync as mkdirSync2 } from "fs";
776
- import { homedir } from "os";
777
- import { join } from "path";
778
- import { platform } from "process";
779
- function getAppPaths() {
780
- const home = homedir();
781
- return platform === "darwin" ? {
782
- data: join(home, "Library", "Application Support", "xerg"),
783
- config: join(home, "Library", "Preferences", "xerg"),
784
- cache: join(home, "Library", "Caches", "xerg")
785
- } : platform === "win32" ? {
786
- data: join(process.env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "xerg", "Data"),
787
- config: join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "xerg", "Config"),
788
- cache: join(process.env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "xerg", "Cache")
789
- } : {
790
- data: join(process.env.XDG_DATA_HOME ?? join(home, ".local", "share"), "xerg"),
791
- config: join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "xerg"),
792
- cache: join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "xerg")
793
- };
794
- }
795
- function getDefaultDbPath() {
796
- return join(getAppPaths().data, "xerg.db");
1351
+ // ../core/src/findings/cursor.ts
1352
+ function round3(value) {
1353
+ return Number(value.toFixed(6));
797
1354
  }
798
- function getDefaultSessionsPattern() {
799
- return join(homedir(), ".openclaw", "agents", "*", "sessions", "*.jsonl");
1355
+ function createFinding(input) {
1356
+ return {
1357
+ ...input,
1358
+ id: sha1(
1359
+ `${input.kind}:${input.scope}:${input.scopeId}:${input.title}:${input.costImpactUsd}:${input.summary}`
1360
+ )
1361
+ };
800
1362
  }
801
- function getDefaultGatewayPattern() {
802
- return "/tmp/openclaw/openclaw-*.log";
1363
+ function asNumber(value) {
1364
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
803
1365
  }
804
-
805
- // ../core/src/detect/openclaw.ts
806
- function toDetected(path, kind) {
807
- try {
808
- const stats = statSync(path);
809
- if (!stats.isFile()) {
810
- return null;
811
- }
812
- return {
813
- kind,
814
- path,
815
- sizeBytes: stats.size,
816
- mtimeMs: stats.mtimeMs
817
- };
818
- } catch {
819
- return null;
820
- }
1366
+ function asBoolean(value) {
1367
+ return value === true;
821
1368
  }
822
- async function detectOpenClawSources(options) {
823
- const explicitSources = [];
824
- if (options.logFile) {
825
- const detected2 = toDetected(options.logFile, "gateway");
826
- if (detected2) {
827
- explicitSources.push(detected2);
828
- }
1369
+ function buildCursorUsageFindings(runs) {
1370
+ const calls = runs.flatMap((run2) => run2.calls);
1371
+ const billableCalls = calls.filter((call) => call.costUsd > 0);
1372
+ if (billableCalls.length === 0) {
1373
+ return { findings: [], wasteAttributions: [] };
829
1374
  }
830
- if (options.sessionsDir) {
831
- const matches = await collectGlobMatches("**/*.jsonl", {
832
- cwd: options.sessionsDir,
833
- resolveWith: options.sessionsDir
834
- });
835
- for (const match of matches) {
836
- const detected2 = toDetected(match, "sessions");
837
- if (detected2) {
838
- explicitSources.push(detected2);
1375
+ const cacheAwareCalls = billableCalls.filter((call) => {
1376
+ return asNumber(call.metadata.cacheReadTokens) > 0;
1377
+ });
1378
+ if (cacheAwareCalls.length === 0) {
1379
+ return { findings: [], wasteAttributions: [] };
1380
+ }
1381
+ const totalSpendUsd = billableCalls.reduce((sum, call) => sum + call.costUsd, 0);
1382
+ const totalInputTokens = billableCalls.reduce((sum, call) => sum + call.inputTokens, 0);
1383
+ const totalCacheReadTokens = cacheAwareCalls.reduce(
1384
+ (sum, call) => sum + asNumber(call.metadata.cacheReadTokens),
1385
+ 0
1386
+ );
1387
+ const totalCacheWriteTokens = billableCalls.reduce(
1388
+ (sum, call) => sum + asNumber(call.metadata.inputWithCacheWriteTokens),
1389
+ 0
1390
+ );
1391
+ const cacheSpendUsd = cacheAwareCalls.reduce((sum, call) => sum + (call.cacheCostUsd ?? 0), 0);
1392
+ const cacheWriteSpendUsd = billableCalls.reduce(
1393
+ (sum, call) => sum + asNumber(call.metadata.cacheWriteCostUsd),
1394
+ 0
1395
+ );
1396
+ const coveredSpendUsd = billableCalls.filter((call) => call.cacheCostUsd !== null).reduce((sum, call) => sum + call.costUsd, 0);
1397
+ const maxModeSpendUsd = billableCalls.filter((call) => asBoolean(call.metadata.maxMode)).reduce((sum, call) => sum + call.costUsd, 0);
1398
+ const cacheReadShare = totalInputTokens === 0 ? 0 : totalCacheReadTokens / totalInputTokens;
1399
+ const cacheCoverageShare = totalSpendUsd === 0 ? 0 : coveredSpendUsd / totalSpendUsd;
1400
+ const maxModeSpendShare = totalSpendUsd === 0 ? 0 : maxModeSpendUsd / totalSpendUsd;
1401
+ const cacheImpactUsd = round3(cacheSpendUsd + cacheWriteSpendUsd);
1402
+ const meetsWasteBar = cacheImpactUsd >= 25 && cacheReadShare >= 0.6 && cacheAwareCalls.length >= 20 && cacheCoverageShare >= 0.4;
1403
+ const meetsOpportunityBar = cacheImpactUsd >= 5 && cacheReadShare >= 0.35 && cacheAwareCalls.length >= 10 && cacheCoverageShare >= 0.25;
1404
+ if (!meetsWasteBar && !meetsOpportunityBar) {
1405
+ return { findings: [], wasteAttributions: [] };
1406
+ }
1407
+ const classification = meetsWasteBar ? "waste" : "opportunity";
1408
+ const confidence = cacheReadShare >= 0.8 && cacheAwareCalls.length >= 50 && cacheCoverageShare >= 0.5 ? "high" : cacheReadShare >= 0.5 && cacheAwareCalls.length >= 20 ? "medium" : "low";
1409
+ const summary = classification === "waste" ? `Xerg estimated ${cacheImpactUsd.toFixed(2)} USD of billed spend was driven by repeatedly replaying cached context across ${cacheAwareCalls.length} paid row${cacheAwareCalls.length === 1 ? "" : "s"}. This pattern is consistent with long chats carrying more history than needed.` : `Xerg estimated ${cacheImpactUsd.toFixed(2)} USD of billed spend was tied to cached context replay across ${cacheAwareCalls.length} paid row${cacheAwareCalls.length === 1 ? "" : "s"}. Summarizing and resetting long chats could reduce this carryover cost.`;
1410
+ const findings = [
1411
+ createFinding({
1412
+ classification,
1413
+ confidence,
1414
+ kind: "cache-carryover",
1415
+ title: classification === "waste" ? "Cached context carryover is driving avoidable spend" : "Cached context carryover looks like a strong cost-reduction opportunity",
1416
+ summary,
1417
+ scope: "global",
1418
+ scopeId: "all",
1419
+ costImpactUsd: cacheImpactUsd,
1420
+ details: {
1421
+ cacheReadShare: round3(cacheReadShare),
1422
+ cacheCoverageShare: round3(cacheCoverageShare),
1423
+ totalCacheReadTokens,
1424
+ totalCacheWriteTokens,
1425
+ billableCallCount: billableCalls.length,
1426
+ cacheAwareCallCount: cacheAwareCalls.length,
1427
+ maxModeSpendShare: round3(maxModeSpendShare),
1428
+ estimatedCacheReadSpendUsd: round3(cacheSpendUsd),
1429
+ estimatedCacheWriteSpendUsd: round3(cacheWriteSpendUsd)
839
1430
  }
840
- }
841
- }
842
- if (explicitSources.length > 0) {
843
- return explicitSources.sort((left, right) => right.mtimeMs - left.mtimeMs);
844
- }
845
- const [gatewayMatches, sessionMatches] = await Promise.all([
846
- collectGlobMatches(getDefaultGatewayPattern()),
847
- collectGlobMatches(getDefaultSessionsPattern())
848
- ]);
849
- const detected = [
850
- ...gatewayMatches.map((path) => toDetected(path, "gateway")).filter(Boolean),
851
- ...sessionMatches.map((path) => toDetected(path, "sessions")).filter(Boolean)
1431
+ })
852
1432
  ];
853
- return detected.sort((left, right) => right.mtimeMs - left.mtimeMs);
1433
+ const maxModeCalls = billableCalls.filter((call) => asBoolean(call.metadata.maxMode));
1434
+ const maxModeCallShare = billableCalls.length === 0 ? 0 : maxModeCalls.length / billableCalls.length;
1435
+ if (maxModeSpendShare >= 0.6 && maxModeSpendUsd >= 25 && maxModeCalls.length >= 10) {
1436
+ const maxModeConfidence = maxModeSpendShare >= 0.85 && maxModeCalls.length >= 50 ? "high" : maxModeSpendShare >= 0.7 && maxModeCalls.length >= 20 ? "medium" : "low";
1437
+ findings.push(
1438
+ createFinding({
1439
+ classification: "opportunity",
1440
+ confidence: maxModeConfidence,
1441
+ kind: "max-mode-concentration",
1442
+ title: "Max mode is concentrated in the billed spend mix",
1443
+ summary: `Max mode accounts for ${(maxModeSpendShare * 100).toFixed(0)}% of billed spend across ${maxModeCalls.length} paid row${maxModeCalls.length === 1 ? "" : "s"}. This is a strong candidate for splitting work between premium and standard passes.`,
1444
+ scope: "global",
1445
+ scopeId: "all",
1446
+ costImpactUsd: round3(maxModeSpendUsd * 0.2),
1447
+ details: {
1448
+ maxModeSpendUsd: round3(maxModeSpendUsd),
1449
+ maxModeSpendShare: round3(maxModeSpendShare),
1450
+ maxModeCallCount: maxModeCalls.length,
1451
+ maxModeCallShare: round3(maxModeCallShare)
1452
+ }
1453
+ })
1454
+ );
1455
+ }
1456
+ const wasteAttributions = classification === "waste" ? billableCalls.map((call) => ({
1457
+ kind: "cache-carryover",
1458
+ timestamp: call.timestamp,
1459
+ wasteUsd: round3((call.cacheCostUsd ?? 0) + asNumber(call.metadata.cacheWriteCostUsd))
1460
+ })).filter((attribution) => attribution.wasteUsd > 0) : [];
1461
+ return {
1462
+ findings: findings.sort((left, right) => right.costImpactUsd - left.costImpactUsd),
1463
+ wasteAttributions
1464
+ };
854
1465
  }
855
- async function collectGlobMatches(pattern, options) {
856
- const baseDir = options?.cwd ? resolve(options.cwd) : isAbsolute(pattern) ? sep : process.cwd();
857
- const relativePattern = options?.cwd ? pattern : isAbsolute(pattern) ? pattern.slice(baseDir.length) : pattern;
858
- const segments = relativePattern.split("/").filter(Boolean);
859
- const matches = collectMatchesFromSegments(baseDir, segments);
860
- return matches.map(
861
- (match) => options?.resolveWith ? resolve(options.resolveWith, match) : match
862
- );
1466
+
1467
+ // ../core/src/findings/engine.ts
1468
+ function createFinding2(input) {
1469
+ return {
1470
+ ...input,
1471
+ id: sha1(
1472
+ `${input.kind}:${input.scope}:${input.scopeId}:${input.title}:${input.costImpactUsd}:${input.summary}`
1473
+ )
1474
+ };
863
1475
  }
864
- function collectMatchesFromSegments(currentPath, segments) {
865
- if (segments.length === 0) {
866
- return [currentPath];
867
- }
868
- const [segment, ...rest] = segments;
869
- if (segment === "**") {
870
- const matches2 = collectMatchesFromSegments(currentPath, rest);
871
- for (const entry of readDirSafe(currentPath)) {
872
- if (entry.isDirectory()) {
873
- matches2.push(...collectMatchesFromSegments(join2(currentPath, entry.name), segments));
874
- }
875
- }
876
- return matches2;
877
- }
878
- const matches = [];
879
- const matcher = segmentToRegExp(segment);
880
- for (const entry of readDirSafe(currentPath)) {
881
- if (!matcher.test(entry.name)) {
882
- continue;
883
- }
884
- const nextPath = join2(currentPath, entry.name);
885
- if (rest.length === 0) {
886
- matches.push(nextPath);
887
- continue;
888
- }
889
- if (entry.isDirectory()) {
890
- matches.push(...collectMatchesFromSegments(nextPath, rest));
891
- }
892
- }
893
- return matches;
894
- }
895
- function readDirSafe(path) {
896
- try {
897
- return readdirSync(path, { withFileTypes: true });
898
- } catch {
899
- return [];
900
- }
901
- }
902
- function segmentToRegExp(segment) {
903
- const escaped = segment.replaceAll(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
904
- return new RegExp(`^${escaped}$`);
905
- }
906
- async function inspectOpenClawSources(options) {
907
- options.onProgress?.("Checking local OpenClaw defaults...");
908
- const sources = await detectOpenClawSources(options);
909
- const notes = [];
910
- options.onProgress?.(
911
- sources.length > 0 ? `Detected ${sources.length} local source file${sources.length === 1 ? "" : "s"}.` : "No local OpenClaw source files were detected."
912
- );
913
- if (sources.length === 0) {
914
- notes.push("No OpenClaw gateway logs or session files were detected.");
915
- notes.push(
916
- "Doctor checks local defaults by default. Use --remote or --railway to inspect remote targets."
917
- );
918
- notes.push(
919
- "Use --log-file or --sessions-dir if your OpenClaw data lives outside the defaults."
920
- );
921
- }
922
- if (sources.some((source) => source.kind === "gateway")) {
923
- notes.push("Gateway logs detected. These are preferred when cost metadata is present.");
924
- }
925
- if (sources.some((source) => source.kind === "sessions")) {
926
- notes.push("Session transcript fallback detected. Xerg will extract usage metadata only.");
927
- }
928
- return {
929
- canAudit: sources.length > 0,
930
- sources,
931
- defaults: {
932
- gatewayPattern: getDefaultGatewayPattern(),
933
- sessionsPattern: getDefaultSessionsPattern()
934
- },
935
- notes
936
- };
937
- }
938
-
939
- // ../core/src/findings/engine.ts
940
- function createFinding(input) {
941
- return {
942
- ...input,
943
- id: sha1(
944
- `${input.kind}:${input.scope}:${input.scopeId}:${input.title}:${input.costImpactUsd}:${input.summary}`
945
- )
946
- };
947
- }
948
- function round2(value) {
1476
+ function round4(value) {
949
1477
  return Number(value.toFixed(6));
950
1478
  }
951
1479
  function buildFindings(runs) {
952
1480
  const findings = [];
1481
+ const wasteAttributions = [];
953
1482
  const allCalls = runs.flatMap((run2) => run2.calls.map((call) => ({ run: run2, call })));
954
1483
  const retryCandidates = allCalls.filter(({ call }) => {
955
1484
  const status = (call.status ?? "").toLowerCase();
@@ -957,8 +1486,15 @@ function buildFindings(runs) {
957
1486
  });
958
1487
  const retryCost = retryCandidates.reduce((sum, item) => sum + item.call.costUsd, 0);
959
1488
  if (retryCost > 0) {
1489
+ wasteAttributions.push(
1490
+ ...retryCandidates.map(({ call }) => ({
1491
+ kind: "retry-waste",
1492
+ timestamp: call.timestamp,
1493
+ wasteUsd: call.costUsd
1494
+ }))
1495
+ );
960
1496
  findings.push(
961
- createFinding({
1497
+ createFinding2({
962
1498
  classification: "waste",
963
1499
  confidence: "high",
964
1500
  kind: "retry-waste",
@@ -966,7 +1502,7 @@ function buildFindings(runs) {
966
1502
  summary: `${retryCandidates.length} failed call${retryCandidates.length === 1 ? "" : "s"} were followed by additional work, making their spend pure retry overhead.`,
967
1503
  scope: "global",
968
1504
  scopeId: "all",
969
- costImpactUsd: round2(retryCost),
1505
+ costImpactUsd: round4(retryCost),
970
1506
  details: {
971
1507
  failedCallCount: retryCandidates.length
972
1508
  }
@@ -978,8 +1514,15 @@ function buildFindings(runs) {
978
1514
  if (maxIteration >= 7) {
979
1515
  const loopCalls = run2.calls.filter((call) => (call.iteration ?? 0) > 5);
980
1516
  const loopCost = loopCalls.reduce((sum, call) => sum + call.costUsd, 0);
1517
+ wasteAttributions.push(
1518
+ ...loopCalls.map((call) => ({
1519
+ kind: "loop-waste",
1520
+ timestamp: call.timestamp,
1521
+ wasteUsd: call.costUsd
1522
+ }))
1523
+ );
981
1524
  findings.push(
982
- createFinding({
1525
+ createFinding2({
983
1526
  classification: "waste",
984
1527
  confidence: "high",
985
1528
  kind: "loop-waste",
@@ -987,7 +1530,7 @@ function buildFindings(runs) {
987
1530
  summary: `This run reached ${maxIteration} iterations. Xerg treats the spend after iteration 5 as likely loop waste.`,
988
1531
  scope: "run",
989
1532
  scopeId: run2.id,
990
- costImpactUsd: round2(loopCost),
1533
+ costImpactUsd: round4(loopCost),
991
1534
  details: {
992
1535
  workflow: run2.workflow,
993
1536
  maxIteration
@@ -1015,7 +1558,7 @@ function buildFindings(runs) {
1015
1558
  if (outlierRuns.length > 0) {
1016
1559
  const outlierCost = outlierRuns.reduce((sum, run2) => sum + run2.totalCostUsd, 0);
1017
1560
  findings.push(
1018
- createFinding({
1561
+ createFinding2({
1019
1562
  classification: "opportunity",
1020
1563
  confidence: "medium",
1021
1564
  kind: "context-outlier",
@@ -1023,10 +1566,10 @@ function buildFindings(runs) {
1023
1566
  summary: `Xerg found ${outlierRuns.length} run${outlierRuns.length === 1 ? "" : "s"} in this workflow with input token volume far above the workflow average.`,
1024
1567
  scope: "workflow",
1025
1568
  scopeId: workflow,
1026
- costImpactUsd: round2(outlierCost),
1569
+ costImpactUsd: round4(outlierCost),
1027
1570
  details: {
1028
1571
  workflow,
1029
- averageInputTokens: round2(average),
1572
+ averageInputTokens: round4(average),
1030
1573
  outlierRunCount: outlierRuns.length
1031
1574
  }
1032
1575
  })
@@ -1039,7 +1582,7 @@ function buildFindings(runs) {
1039
1582
  if (idleRuns.length > 0) {
1040
1583
  const idleCost = idleRuns.reduce((sum, run2) => sum + run2.totalCostUsd, 0);
1041
1584
  findings.push(
1042
- createFinding({
1585
+ createFinding2({
1043
1586
  classification: "opportunity",
1044
1587
  confidence: "medium",
1045
1588
  kind: "idle-spend",
@@ -1047,7 +1590,7 @@ function buildFindings(runs) {
1047
1590
  summary: "This workflow name looks like a recurring heartbeat or monitoring loop. Review whether the cadence and model tier are justified.",
1048
1591
  scope: "workflow",
1049
1592
  scopeId: workflow,
1050
- costImpactUsd: round2(idleCost),
1593
+ costImpactUsd: round4(idleCost),
1051
1594
  details: {
1052
1595
  workflow
1053
1596
  }
@@ -1060,7 +1603,7 @@ function buildFindings(runs) {
1060
1603
  if (downgradeCalls.length > 0) {
1061
1604
  const spend = downgradeCalls.reduce((sum, call) => sum + call.costUsd, 0);
1062
1605
  findings.push(
1063
- createFinding({
1606
+ createFinding2({
1064
1607
  classification: "opportunity",
1065
1608
  confidence: "low",
1066
1609
  kind: "candidate-downgrade",
@@ -1068,23 +1611,22 @@ function buildFindings(runs) {
1068
1611
  summary: "An expensive model is being used on a workflow that looks operationally simple. Treat this as an A/B test candidate, not proven waste.",
1069
1612
  scope: "workflow",
1070
1613
  scopeId: workflow,
1071
- costImpactUsd: round2(spend * 0.3),
1614
+ costImpactUsd: round4(spend * 0.3),
1072
1615
  details: {
1073
1616
  workflow,
1074
1617
  expensiveCallCount: downgradeCalls.length,
1075
- inspectedSpendUsd: round2(spend)
1618
+ inspectedSpendUsd: round4(spend)
1076
1619
  }
1077
1620
  })
1078
1621
  );
1079
1622
  }
1080
1623
  }
1081
- return findings.sort((left, right) => right.costImpactUsd - left.costImpactUsd);
1624
+ return {
1625
+ findings: findings.sort((left, right) => right.costImpactUsd - left.costImpactUsd),
1626
+ wasteAttributions
1627
+ };
1082
1628
  }
1083
1629
 
1084
- // ../core/src/normalize/openclaw.ts
1085
- import { readFileSync } from "fs";
1086
- import { basename } from "path";
1087
-
1088
1630
  // ../core/src/pricing-catalog.ts
1089
1631
  var PRICING_CATALOG = [
1090
1632
  {
@@ -1161,6 +1703,223 @@ function estimateCostUsd(provider, model, inputTokens, outputTokens) {
1161
1703
  return Number((inputCost + outputCost).toFixed(8));
1162
1704
  }
1163
1705
 
1706
+ // ../core/src/report/timeseries.ts
1707
+ function round5(value) {
1708
+ return Number(value.toFixed(6));
1709
+ }
1710
+ function toUtcDay(timestamp) {
1711
+ const candidate = new Date(timestamp);
1712
+ if (Number.isNaN(candidate.getTime())) {
1713
+ return null;
1714
+ }
1715
+ return candidate.toISOString().slice(0, 10);
1716
+ }
1717
+ function incrementUtcDay(date) {
1718
+ const candidate = /* @__PURE__ */ new Date(`${date}T00:00:00.000Z`);
1719
+ candidate.setUTCDate(candidate.getUTCDate() + 1);
1720
+ return candidate.toISOString().slice(0, 10);
1721
+ }
1722
+ function buildObservedUtcDayRange(runs) {
1723
+ const days = runs.flatMap((run2) => run2.calls).map((call) => toUtcDay(call.timestamp)).filter((day) => day !== null).sort();
1724
+ if (days.length === 0) {
1725
+ return [];
1726
+ }
1727
+ const range = [];
1728
+ let current = days[0];
1729
+ const last = days[days.length - 1];
1730
+ while (current <= last) {
1731
+ range.push(current);
1732
+ current = incrementUtcDay(current);
1733
+ }
1734
+ return range;
1735
+ }
1736
+ function reconcileDailyTotal(rows, key, expected) {
1737
+ if (rows.length === 0) {
1738
+ return;
1739
+ }
1740
+ const actual = round5(
1741
+ rows.reduce((sum, row) => sum + (typeof row[key] === "number" ? row[key] : 0), 0)
1742
+ );
1743
+ const delta = round5(expected - actual);
1744
+ if (delta === 0) {
1745
+ return;
1746
+ }
1747
+ const last = rows[rows.length - 1];
1748
+ const current = last[key];
1749
+ if (typeof current === "number") {
1750
+ last[key] = round5(current + delta);
1751
+ }
1752
+ }
1753
+ function buildSpendByDay(runs) {
1754
+ const days = buildObservedUtcDayRange(runs);
1755
+ if (days.length === 0) {
1756
+ return [];
1757
+ }
1758
+ const byDay = new Map(
1759
+ days.map((day) => [
1760
+ day,
1761
+ { date: day, observedSpendUsd: 0, estimatedSpendUsd: 0, callCount: 0 }
1762
+ ])
1763
+ );
1764
+ for (const run2 of runs) {
1765
+ for (const call of run2.calls) {
1766
+ const day = toUtcDay(call.timestamp);
1767
+ if (!day) {
1768
+ continue;
1769
+ }
1770
+ const bucket = byDay.get(day);
1771
+ if (!bucket) {
1772
+ continue;
1773
+ }
1774
+ bucket.callCount += 1;
1775
+ if (call.costSource === "observed") {
1776
+ bucket.observedSpendUsd += call.costUsd;
1777
+ } else if (call.costSource === "estimated") {
1778
+ bucket.estimatedSpendUsd += call.costUsd;
1779
+ }
1780
+ }
1781
+ }
1782
+ const rows = days.map((day) => {
1783
+ const bucket = byDay.get(day);
1784
+ const observedSpendUsd = round5(bucket?.observedSpendUsd ?? 0);
1785
+ const estimatedSpendUsd = round5(bucket?.estimatedSpendUsd ?? 0);
1786
+ return {
1787
+ date: day,
1788
+ observedSpendUsd,
1789
+ estimatedSpendUsd,
1790
+ spendUsd: round5(observedSpendUsd + estimatedSpendUsd),
1791
+ callCount: bucket?.callCount ?? 0
1792
+ };
1793
+ });
1794
+ reconcileDailyTotal(
1795
+ rows,
1796
+ "observedSpendUsd",
1797
+ round5(runs.reduce((sum, run2) => sum + run2.observedCostUsd, 0))
1798
+ );
1799
+ reconcileDailyTotal(
1800
+ rows,
1801
+ "estimatedSpendUsd",
1802
+ round5(runs.reduce((sum, run2) => sum + run2.estimatedCostUsd, 0))
1803
+ );
1804
+ for (const row of rows) {
1805
+ row.spendUsd = round5(row.observedSpendUsd + row.estimatedSpendUsd);
1806
+ }
1807
+ return rows;
1808
+ }
1809
+ function buildWasteByDay(wasteAttributions, days, expectedWasteUsd) {
1810
+ if (days.length === 0) {
1811
+ return [];
1812
+ }
1813
+ const byDay = new Map(days.map((day) => [day, 0]));
1814
+ for (const attribution of wasteAttributions) {
1815
+ const day = toUtcDay(attribution.timestamp);
1816
+ if (!day || !byDay.has(day)) {
1817
+ continue;
1818
+ }
1819
+ byDay.set(day, (byDay.get(day) ?? 0) + attribution.wasteUsd);
1820
+ }
1821
+ const rows = days.map((day) => ({
1822
+ date: day,
1823
+ wasteUsd: round5(byDay.get(day) ?? 0)
1824
+ }));
1825
+ reconcileDailyTotal(rows, "wasteUsd", round5(expectedWasteUsd));
1826
+ return rows;
1827
+ }
1828
+
1829
+ // ../core/src/report/summary.ts
1830
+ function buildBreakdown(items) {
1831
+ const buckets = /* @__PURE__ */ new Map();
1832
+ for (const item of items) {
1833
+ const current = buckets.get(item.key) ?? { spendUsd: 0, observedSpendUsd: 0, callCount: 0 };
1834
+ current.spendUsd += item.spendUsd;
1835
+ current.observedSpendUsd += item.observedSpendUsd;
1836
+ current.callCount += 1;
1837
+ buckets.set(item.key, current);
1838
+ }
1839
+ return Array.from(buckets.entries()).map(([key, value]) => {
1840
+ const observedShare = value.spendUsd === 0 ? 0 : value.observedSpendUsd / value.spendUsd;
1841
+ return {
1842
+ key,
1843
+ spendUsd: Number(value.spendUsd.toFixed(6)),
1844
+ callCount: value.callCount,
1845
+ observedShare: Number(observedShare.toFixed(4))
1846
+ };
1847
+ }).sort((left, right) => right.spendUsd - left.spendUsd);
1848
+ }
1849
+ function buildAuditSummary(input) {
1850
+ const callCount = input.runs.reduce((sum, run2) => sum + run2.calls.length, 0);
1851
+ const totalSpendUsd = input.runs.reduce((sum, run2) => sum + run2.totalCostUsd, 0);
1852
+ const observedSpendUsd = input.runs.reduce((sum, run2) => sum + run2.observedCostUsd, 0);
1853
+ const estimatedSpendUsd = input.runs.reduce((sum, run2) => sum + run2.estimatedCostUsd, 0);
1854
+ const wasteSpendUsd = input.findings.filter((finding) => finding.classification === "waste").reduce((sum, finding) => sum + finding.costImpactUsd, 0);
1855
+ const opportunitySpendUsd = input.findings.filter((finding) => finding.classification === "opportunity").reduce((sum, finding) => sum + finding.costImpactUsd, 0);
1856
+ const generatedAt = isoNow();
1857
+ const spendByDay = buildSpendByDay(input.runs);
1858
+ const observedDays = buildObservedUtcDayRange(input.runs);
1859
+ return {
1860
+ auditId: sha1(
1861
+ `${generatedAt}:${input.runs.length}:${input.sources.map((source) => source.path).join("|")}`
1862
+ ),
1863
+ generatedAt,
1864
+ runtime: input.runtime,
1865
+ comparisonKey: input.comparisonKeyOverride ?? buildComparisonKey({
1866
+ runtime: input.runtime,
1867
+ sources: input.sources,
1868
+ since: input.since
1869
+ }),
1870
+ comparison: null,
1871
+ since: input.since,
1872
+ runCount: input.runs.length,
1873
+ callCount,
1874
+ totalSpendUsd: Number(totalSpendUsd.toFixed(6)),
1875
+ observedSpendUsd: Number(observedSpendUsd.toFixed(6)),
1876
+ estimatedSpendUsd: Number(estimatedSpendUsd.toFixed(6)),
1877
+ wasteSpendUsd: Number(wasteSpendUsd.toFixed(6)),
1878
+ opportunitySpendUsd: Number(opportunitySpendUsd.toFixed(6)),
1879
+ structuralWasteRate: Number(
1880
+ (totalSpendUsd === 0 ? 0 : wasteSpendUsd / totalSpendUsd).toFixed(4)
1881
+ ),
1882
+ wasteByKind: buildTaxonomyBuckets(input.findings, "waste"),
1883
+ opportunityByKind: buildTaxonomyBuckets(input.findings, "opportunity"),
1884
+ spendByWorkflow: buildBreakdown(
1885
+ input.runs.map((run2) => ({
1886
+ key: run2.workflow,
1887
+ spendUsd: run2.totalCostUsd,
1888
+ observedSpendUsd: run2.observedCostUsd
1889
+ }))
1890
+ ),
1891
+ spendByModel: buildBreakdown(
1892
+ input.runs.flatMap(
1893
+ (run2) => run2.calls.map((call) => ({
1894
+ key: `${call.provider}/${call.model}`,
1895
+ spendUsd: call.costUsd,
1896
+ observedSpendUsd: call.costSource === "observed" ? call.costUsd : 0
1897
+ }))
1898
+ )
1899
+ ),
1900
+ spendByDay,
1901
+ wasteByDay: buildWasteByDay(input.wasteAttributions, observedDays, wasteSpendUsd),
1902
+ findings: input.findings,
1903
+ notes: [
1904
+ "Cost per outcome is intentionally unavailable in v0. Xerg is measuring waste intelligence only.",
1905
+ "Opportunity findings are directional recommendations, not proven waste."
1906
+ ],
1907
+ sourceFiles: input.sources,
1908
+ dbPath: input.dbPath
1909
+ };
1910
+ }
1911
+
1912
+ // ../core/src/runtime.ts
1913
+ import { basename as basename4 } from "path";
1914
+
1915
+ // ../core/src/detect/hermes.ts
1916
+ import { homedir as homedir2 } from "os";
1917
+ import { basename as basename2, join as join3 } from "path";
1918
+
1919
+ // ../core/src/normalize/hermes.ts
1920
+ import { readFileSync as readFileSync2 } from "fs";
1921
+ import { basename } from "path";
1922
+
1164
1923
  // ../core/src/utils/records.ts
1165
1924
  function getNestedValue(input, paths) {
1166
1925
  if (!input || typeof input !== "object") {
@@ -1182,7 +1941,7 @@ function getNestedValue(input, paths) {
1182
1941
  }
1183
1942
  return null;
1184
1943
  }
1185
- function asNumber(value) {
1944
+ function asNumber2(value) {
1186
1945
  if (typeof value === "number" && Number.isFinite(value)) {
1187
1946
  return value;
1188
1947
  }
@@ -1198,7 +1957,7 @@ function asString(value) {
1198
1957
  }
1199
1958
  return null;
1200
1959
  }
1201
- function asBoolean(value) {
1960
+ function asBoolean2(value) {
1202
1961
  if (typeof value === "boolean") {
1203
1962
  return value;
1204
1963
  }
@@ -1221,39 +1980,119 @@ function pickMetadata(input, keys) {
1221
1980
  return output;
1222
1981
  }
1223
1982
 
1224
- // ../core/src/normalize/openclaw.ts
1983
+ // ../core/src/normalize/hermes.ts
1984
+ var PRICING_PROVIDER_PREFIXES = /* @__PURE__ */ new Set(["anthropic", "openai", "google", "meta"]);
1985
+ function parseJsonLine(line) {
1986
+ const trimmed = line.trim();
1987
+ if (trimmed.length === 0) {
1988
+ return null;
1989
+ }
1990
+ try {
1991
+ return JSON.parse(trimmed);
1992
+ } catch {
1993
+ }
1994
+ const jsonStart = trimmed.indexOf("{");
1995
+ const jsonEnd = trimmed.lastIndexOf("}");
1996
+ if (jsonStart < 0 || jsonEnd <= jsonStart) {
1997
+ return null;
1998
+ }
1999
+ try {
2000
+ return JSON.parse(trimmed.slice(jsonStart, jsonEnd + 1));
2001
+ } catch {
2002
+ return null;
2003
+ }
2004
+ }
1225
2005
  function parseJsonLines(path) {
1226
- const content = readFileSync(path, "utf8");
2006
+ const content = readFileSync2(path, "utf8");
1227
2007
  const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1228
2008
  const records = [];
1229
2009
  for (const line of lines) {
1230
- try {
1231
- const parsed = JSON.parse(line);
2010
+ const parsed = parseJsonLine(line);
2011
+ if (parsed) {
1232
2012
  records.push(parsed);
1233
- } catch {
1234
2013
  }
1235
2014
  }
1236
2015
  return records;
1237
2016
  }
1238
- function inferProvider(record) {
1239
- return asString(
1240
- getNestedValue(record, [["provider"], ["message", "provider"], ["usage", "provider"]])
1241
- ) ?? "unknown";
2017
+ function flattenSourceRecords(source) {
2018
+ const records = parseJsonLines(source.path);
2019
+ if (source.kind !== "sessions") {
2020
+ return records;
2021
+ }
2022
+ return records.flatMap((record) => expandSessionRecord(record));
2023
+ }
2024
+ function expandSessionRecord(record) {
2025
+ const messages = getNestedValue(record, [["messages"]]);
2026
+ if (!Array.isArray(messages)) {
2027
+ return [record];
2028
+ }
2029
+ return messages.filter((message) => Boolean(message) && typeof message === "object").map((message, index) => ({
2030
+ ...record,
2031
+ message,
2032
+ timestamp: asString(
2033
+ getNestedValue(message, [["timestamp"], ["created_at"], ["createdAt"], ["time"]])
2034
+ ) ?? asString(
2035
+ getNestedValue(record, [["timestamp"], ["created_at"], ["createdAt"], ["updated_at"]])
2036
+ ) ?? `${sourceTimestampSeed(record)}:${index}`
2037
+ }));
1242
2038
  }
1243
- function inferModel(record) {
1244
- return asString(getNestedValue(record, [["model"], ["message", "model"], ["usage", "model"]])) ?? "unknown-model";
2039
+ function sourceTimestampSeed(record) {
2040
+ return asString(getNestedValue(record, [["updated_at"], ["created_at"], ["timestamp"], ["id"]])) ?? "session";
2041
+ }
2042
+ function normalizeProviderAndModel(record) {
2043
+ const rawProvider = asString(
2044
+ getNestedValue(record, [
2045
+ ["provider"],
2046
+ ["provider_name"],
2047
+ ["runtime", "provider"],
2048
+ ["usage", "provider"],
2049
+ ["token_usage", "provider"],
2050
+ ["message", "provider"],
2051
+ ["message", "usage", "provider"]
2052
+ ])
2053
+ ) ?? "unknown";
2054
+ const rawModel = asString(
2055
+ getNestedValue(record, [
2056
+ ["model"],
2057
+ ["model_name"],
2058
+ ["runtime", "model"],
2059
+ ["usage", "model"],
2060
+ ["token_usage", "model"],
2061
+ ["message", "model"],
2062
+ ["message", "usage", "model"]
2063
+ ])
2064
+ ) ?? "unknown-model";
2065
+ if (rawModel.includes("/")) {
2066
+ const [providerPrefix, ...rest] = rawModel.split("/");
2067
+ if (PRICING_PROVIDER_PREFIXES.has(providerPrefix.toLowerCase()) && rest.length > 0) {
2068
+ return {
2069
+ provider: providerPrefix.toLowerCase(),
2070
+ model: rest.join("/")
2071
+ };
2072
+ }
2073
+ }
2074
+ return {
2075
+ provider: rawProvider.toLowerCase(),
2076
+ model: rawModel
2077
+ };
1245
2078
  }
1246
2079
  function inferWorkflow(record, sourcePath) {
1247
2080
  return asString(
1248
2081
  getNestedValue(record, [
1249
2082
  ["workflow"],
1250
- ["session", "workflow"],
1251
- ["metadata", "workflow"],
2083
+ ["job_name"],
2084
+ ["task_class"],
2085
+ ["taskClass"],
2086
+ ["agent_name"],
1252
2087
  ["agent", "name"],
1253
- ["agentId"],
1254
- ["sessionId"]
2088
+ ["source"],
2089
+ ["session", "source"],
2090
+ ["session_key"],
2091
+ ["session_id"],
2092
+ ["sessionId"],
2093
+ ["title"]
1255
2094
  ])
1256
- ) ?? basename(sourcePath, ".jsonl");
2095
+ ) ?? basename(sourcePath).replace(/\.(jsonl|json|log)(\.\d+)?$/i, "");
1257
2096
  }
1258
2097
  function inferEnvironment(record) {
1259
2098
  return asString(getNestedValue(record, [["environment"], ["env"], ["metadata", "environment"]])) ?? "local";
@@ -1263,18 +2102,506 @@ function inferRunKey(record, workflow, index, sourcePath) {
1263
2102
  getNestedValue(record, [
1264
2103
  ["run_id"],
1265
2104
  ["runId"],
2105
+ ["session_id"],
2106
+ ["sessionId"],
2107
+ ["session_key"],
2108
+ ["thread_id"],
2109
+ ["threadId"],
1266
2110
  ["trace_id"],
1267
2111
  ["traceId"],
1268
- ["sessionId"],
1269
- ["thread_id"]
2112
+ ["conversation_id"],
2113
+ ["conversationId"],
2114
+ ["id"]
1270
2115
  ])
1271
2116
  ) ?? `${sourcePath}:${workflow}:${index}`;
1272
2117
  }
1273
2118
  function inferTaskClass(record, workflow) {
1274
- return asString(getNestedValue(record, [["task_class"], ["taskClass"], ["metadata", "taskClass"]])) ?? workflow.toLowerCase();
2119
+ return asString(
2120
+ getNestedValue(record, [
2121
+ ["task_class"],
2122
+ ["taskClass"],
2123
+ ["event"],
2124
+ ["source"],
2125
+ ["message", "source"]
2126
+ ])
2127
+ ) ?? workflow.toLowerCase();
2128
+ }
2129
+ function inferToolCalls(record) {
2130
+ const direct = asNumber2(
2131
+ getNestedValue(record, [
2132
+ ["tool_calls"],
2133
+ ["toolCalls"],
2134
+ ["usage", "tool_calls"],
2135
+ ["message", "usage", "tool_calls"]
2136
+ ])
2137
+ ) ?? null;
2138
+ if (direct !== null) {
2139
+ return direct;
2140
+ }
2141
+ const toolCalls = getNestedValue(record, [
2142
+ ["tool_calls"],
2143
+ ["toolCalls"],
2144
+ ["message", "tool_calls"]
2145
+ ]);
2146
+ return Array.isArray(toolCalls) ? toolCalls.length : 0;
1275
2147
  }
1276
2148
  function extractUsage(record) {
1277
- const inputTokens = asNumber(
2149
+ const inputTokens = asNumber2(
2150
+ getNestedValue(record, [
2151
+ ["input_tokens"],
2152
+ ["inputTokens"],
2153
+ ["usage", "input_tokens"],
2154
+ ["usage", "inputTokens"],
2155
+ ["usage", "prompt_tokens"],
2156
+ ["token_usage", "input_tokens"],
2157
+ ["token_usage", "prompt_tokens"],
2158
+ ["token_usage", "input"],
2159
+ ["message", "usage", "input_tokens"],
2160
+ ["message", "usage", "prompt_tokens"],
2161
+ ["message", "token_usage", "input_tokens"]
2162
+ ])
2163
+ ) ?? 0;
2164
+ const outputTokens = asNumber2(
2165
+ getNestedValue(record, [
2166
+ ["output_tokens"],
2167
+ ["outputTokens"],
2168
+ ["usage", "output_tokens"],
2169
+ ["usage", "outputTokens"],
2170
+ ["usage", "completion_tokens"],
2171
+ ["token_usage", "output_tokens"],
2172
+ ["token_usage", "completion_tokens"],
2173
+ ["token_usage", "output"],
2174
+ ["message", "usage", "output_tokens"],
2175
+ ["message", "usage", "completion_tokens"],
2176
+ ["message", "token_usage", "output_tokens"]
2177
+ ])
2178
+ ) ?? 0;
2179
+ const observedCost = asNumber2(
2180
+ getNestedValue(record, [
2181
+ ["cost_usd"],
2182
+ ["costUsd"],
2183
+ ["estimated_cost_usd"],
2184
+ ["estimatedCostUsd"],
2185
+ ["total_cost_usd"],
2186
+ ["usage", "cost_usd"],
2187
+ ["usage", "costUsd"],
2188
+ ["usage", "estimated_cost_usd"],
2189
+ ["usage", "total_cost_usd"],
2190
+ ["token_usage", "cost_usd"],
2191
+ ["token_usage", "total_cost_usd"],
2192
+ ["cost", "usd"],
2193
+ ["cost", "total_usd"],
2194
+ ["pricing", "total_usd"],
2195
+ ["message", "usage", "cost_usd"],
2196
+ ["message", "cost_usd"]
2197
+ ])
2198
+ ) ?? null;
2199
+ return {
2200
+ inputTokens,
2201
+ outputTokens,
2202
+ observedCost
2203
+ };
2204
+ }
2205
+ function shouldTreatAsCall(record) {
2206
+ const { inputTokens, outputTokens, observedCost } = extractUsage(record);
2207
+ return inputTokens > 0 || outputTokens > 0 || observedCost !== null;
2208
+ }
2209
+ function buildCall2(source, record, runId, index) {
2210
+ const { provider, model } = normalizeProviderAndModel(record);
2211
+ const workflow = inferWorkflow(record, source.path);
2212
+ const { inputTokens, outputTokens, observedCost } = extractUsage(record);
2213
+ const estimatedCost = estimateCostUsd(provider, model, inputTokens, outputTokens);
2214
+ const timestamp = toIsoOrNow(
2215
+ getNestedValue(record, [
2216
+ ["timestamp"],
2217
+ ["createdAt"],
2218
+ ["created_at"],
2219
+ ["time"],
2220
+ ["updated_at"]
2221
+ ])
2222
+ );
2223
+ const attempt = asNumber2(
2224
+ getNestedValue(record, [["attempt"], ["usage", "attempt"], ["metadata", "attempt"]])
2225
+ ) ?? null;
2226
+ const iteration = asNumber2(
2227
+ getNestedValue(record, [["iteration"], ["loop_iteration"], ["metadata", "iteration"]])
2228
+ ) ?? null;
2229
+ const retries = asNumber2(getNestedValue(record, [["retries"], ["retry_count"], ["metadata", "retries"]])) ?? 0;
2230
+ const costUsd = observedCost ?? estimatedCost ?? 0;
2231
+ return {
2232
+ id: sha1(`${runId}:${source.path}:${index}:${model}:${timestamp}:${costUsd}`),
2233
+ runId,
2234
+ timestamp,
2235
+ provider,
2236
+ model,
2237
+ inputTokens,
2238
+ outputTokens,
2239
+ costUsd,
2240
+ costSource: observedCost !== null ? "observed" : estimatedCost !== null ? "estimated" : "unpriced",
2241
+ latencyMs: asNumber2(getNestedValue(record, [["latency_ms"], ["latencyMs"], ["usage", "latency_ms"]])) ?? null,
2242
+ toolCalls: inferToolCalls(record),
2243
+ retries,
2244
+ attempt,
2245
+ iteration,
2246
+ status: asString(getNestedValue(record, [["status"], ["level"], ["result"], ["error", "type"]])) ?? null,
2247
+ taskClass: inferTaskClass(record, workflow),
2248
+ cacheHit: asBoolean2(
2249
+ getNestedValue(record, [["cache_hit"], ["cacheHit"], ["usage", "cache_hit"]])
2250
+ ),
2251
+ cacheCostUsd: asNumber2(
2252
+ getNestedValue(record, [["cache_cost_usd"], ["cacheCostUsd"], ["usage", "cache_cost_usd"]])
2253
+ ) ?? null,
2254
+ metadata: pickMetadata(record, [
2255
+ "event",
2256
+ "type",
2257
+ "source",
2258
+ "session_id",
2259
+ "sessionId",
2260
+ "thread_id",
2261
+ "trace_id"
2262
+ ])
2263
+ };
2264
+ }
2265
+ function logFileHasBillableRecords(path) {
2266
+ return flattenSourceRecords({
2267
+ kind: "gateway",
2268
+ runtime: "hermes",
2269
+ path,
2270
+ sizeBytes: 0,
2271
+ mtimeMs: 0
2272
+ }).some((record) => shouldTreatAsCall(record));
2273
+ }
2274
+ function normalizeHermesSources(sources, since) {
2275
+ const cutoff = parseSince(since);
2276
+ const runsById = /* @__PURE__ */ new Map();
2277
+ for (const source of sources) {
2278
+ const records = flattenSourceRecords(source);
2279
+ records.forEach((record, index) => {
2280
+ if (!shouldTreatAsCall(record)) {
2281
+ return;
2282
+ }
2283
+ const workflow = inferWorkflow(record, source.path);
2284
+ const timestamp = toIsoOrNow(
2285
+ getNestedValue(record, [
2286
+ ["timestamp"],
2287
+ ["createdAt"],
2288
+ ["created_at"],
2289
+ ["time"],
2290
+ ["updated_at"]
2291
+ ])
2292
+ );
2293
+ if (cutoff && new Date(timestamp).getTime() < cutoff) {
2294
+ return;
2295
+ }
2296
+ const runKey = inferRunKey(record, workflow, index, source.path);
2297
+ const runId = sha1(`${source.path}:${runKey}`);
2298
+ const call = buildCall2(source, record, runId, index);
2299
+ const existing = runsById.get(runId);
2300
+ if (!existing) {
2301
+ runsById.set(runId, {
2302
+ id: runId,
2303
+ sourceKind: source.kind,
2304
+ sourcePath: source.path,
2305
+ timestamp,
2306
+ workflow,
2307
+ environment: inferEnvironment(record),
2308
+ tags: {
2309
+ sourceKind: source.kind
2310
+ },
2311
+ calls: [call],
2312
+ totalCostUsd: call.costUsd,
2313
+ totalTokens: call.inputTokens + call.outputTokens,
2314
+ observedCostUsd: call.costSource === "observed" ? call.costUsd : 0,
2315
+ estimatedCostUsd: call.costSource === "estimated" ? call.costUsd : 0
2316
+ });
2317
+ return;
2318
+ }
2319
+ existing.calls.push(call);
2320
+ existing.totalCostUsd = Number((existing.totalCostUsd + call.costUsd).toFixed(8));
2321
+ existing.totalTokens += call.inputTokens + call.outputTokens;
2322
+ existing.observedCostUsd += call.costSource === "observed" ? call.costUsd : 0;
2323
+ existing.estimatedCostUsd += call.costSource === "estimated" ? call.costUsd : 0;
2324
+ if (timestamp < existing.timestamp) {
2325
+ existing.timestamp = timestamp;
2326
+ }
2327
+ });
2328
+ }
2329
+ return Array.from(runsById.values()).sort(
2330
+ (left, right) => left.timestamp < right.timestamp ? -1 : 1
2331
+ );
2332
+ }
2333
+
2334
+ // ../core/src/utils/paths.ts
2335
+ import { mkdirSync as mkdirSync2 } from "fs";
2336
+ import { homedir } from "os";
2337
+ import { join } from "path";
2338
+ import { platform } from "process";
2339
+ function getUserHome() {
2340
+ return process.env.HOME ?? homedir();
2341
+ }
2342
+ function getAppPaths() {
2343
+ const home = getUserHome();
2344
+ return platform === "darwin" ? {
2345
+ data: join(home, "Library", "Application Support", "xerg"),
2346
+ config: join(home, "Library", "Preferences", "xerg"),
2347
+ cache: join(home, "Library", "Caches", "xerg")
2348
+ } : platform === "win32" ? {
2349
+ data: join(process.env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "xerg", "Data"),
2350
+ config: join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "xerg", "Config"),
2351
+ cache: join(process.env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "xerg", "Cache")
2352
+ } : {
2353
+ data: join(process.env.XDG_DATA_HOME ?? join(home, ".local", "share"), "xerg"),
2354
+ config: join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "xerg"),
2355
+ cache: join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "xerg")
2356
+ };
2357
+ }
2358
+ function getDefaultDbPath() {
2359
+ return join(getAppPaths().data, "xerg.db");
2360
+ }
2361
+ function getDefaultOpenClawSessionsPattern() {
2362
+ return join(getUserHome(), ".openclaw", "agents", "*", "sessions", "*.jsonl");
2363
+ }
2364
+ function getDefaultOpenClawGatewayPattern() {
2365
+ return "/tmp/openclaw/openclaw-*.log";
2366
+ }
2367
+ function getDefaultHermesSessionsPattern() {
2368
+ return join(getUserHome(), ".hermes", "sessions", "**", "*.{json,jsonl}");
2369
+ }
2370
+ function getDefaultHermesGatewayPattern() {
2371
+ return join(getUserHome(), ".hermes", "logs", "agent.log* (fallback: gateway.log*)");
2372
+ }
2373
+
2374
+ // ../core/src/detect/shared.ts
2375
+ import { readdirSync, statSync as statSync2 } from "fs";
2376
+ import { isAbsolute, join as join2, resolve as resolve2, sep } from "path";
2377
+ function toDetected(path, kind, runtime) {
2378
+ try {
2379
+ const stats = statSync2(path);
2380
+ if (!stats.isFile()) {
2381
+ return null;
2382
+ }
2383
+ return {
2384
+ kind,
2385
+ runtime,
2386
+ path,
2387
+ sizeBytes: stats.size,
2388
+ mtimeMs: stats.mtimeMs
2389
+ };
2390
+ } catch {
2391
+ return null;
2392
+ }
2393
+ }
2394
+ async function collectGlobMatches(pattern, options) {
2395
+ const baseDir = options?.cwd ? resolve2(options.cwd) : isAbsolute(pattern) ? sep : process.cwd();
2396
+ const relativePattern = options?.cwd ? pattern : isAbsolute(pattern) ? pattern.slice(baseDir.length) : pattern;
2397
+ const segments = relativePattern.split("/").filter(Boolean);
2398
+ const matches = collectMatchesFromSegments(baseDir, segments);
2399
+ return matches.map(
2400
+ (match) => options?.resolveWith ? resolve2(options.resolveWith, match) : match
2401
+ );
2402
+ }
2403
+ function collectMatchesFromSegments(currentPath, segments) {
2404
+ if (segments.length === 0) {
2405
+ return [currentPath];
2406
+ }
2407
+ const [segment, ...rest] = segments;
2408
+ if (segment === "**") {
2409
+ const matches2 = collectMatchesFromSegments(currentPath, rest);
2410
+ for (const entry of readDirSafe(currentPath)) {
2411
+ if (entry.isDirectory()) {
2412
+ matches2.push(...collectMatchesFromSegments(join2(currentPath, entry.name), segments));
2413
+ }
2414
+ }
2415
+ return matches2;
2416
+ }
2417
+ const matches = [];
2418
+ const matcher = segmentToRegExp(segment);
2419
+ for (const entry of readDirSafe(currentPath)) {
2420
+ if (!matcher.test(entry.name)) {
2421
+ continue;
2422
+ }
2423
+ const nextPath = join2(currentPath, entry.name);
2424
+ if (rest.length === 0) {
2425
+ matches.push(nextPath);
2426
+ continue;
2427
+ }
2428
+ if (entry.isDirectory()) {
2429
+ matches.push(...collectMatchesFromSegments(nextPath, rest));
2430
+ }
2431
+ }
2432
+ return matches;
2433
+ }
2434
+ function readDirSafe(path) {
2435
+ try {
2436
+ return readdirSync(path, { withFileTypes: true });
2437
+ } catch {
2438
+ return [];
2439
+ }
2440
+ }
2441
+ function segmentToRegExp(segment) {
2442
+ const escaped = segment.replaceAll(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
2443
+ return new RegExp(`^${escaped}$`);
2444
+ }
2445
+
2446
+ // ../core/src/detect/hermes.ts
2447
+ function getDefaultAgentLogPattern() {
2448
+ return join3(process.env.HOME ?? homedir2(), ".hermes", "logs", "agent.log*");
2449
+ }
2450
+ function getDefaultGatewayLogPattern() {
2451
+ return join3(process.env.HOME ?? homedir2(), ".hermes", "logs", "gateway.log*");
2452
+ }
2453
+ function getDefaultSessionsDirPattern() {
2454
+ return join3(process.env.HOME ?? homedir2(), ".hermes", "sessions", "**", "*");
2455
+ }
2456
+ function isHermesSessionTranscript(path) {
2457
+ const lowerName = basename2(path).toLowerCase();
2458
+ if (lowerName === "sessions.json") {
2459
+ return false;
2460
+ }
2461
+ return lowerName.endsWith(".jsonl") || lowerName.endsWith(".json");
2462
+ }
2463
+ async function collectHermesSessionMatches(baseDir) {
2464
+ const matches = await collectGlobMatches("**/*", {
2465
+ cwd: baseDir,
2466
+ resolveWith: baseDir
2467
+ });
2468
+ return matches.filter((match) => isHermesSessionTranscript(match));
2469
+ }
2470
+ async function pickPreferredLogFamily() {
2471
+ const [agentMatches, gatewayMatches] = await Promise.all([
2472
+ collectGlobMatches(getDefaultAgentLogPattern()),
2473
+ collectGlobMatches(getDefaultGatewayLogPattern())
2474
+ ]);
2475
+ if (agentMatches.some((path) => logFileHasBillableRecords(path))) {
2476
+ return agentMatches;
2477
+ }
2478
+ if (gatewayMatches.some((path) => logFileHasBillableRecords(path))) {
2479
+ return gatewayMatches;
2480
+ }
2481
+ return [...agentMatches, ...gatewayMatches];
2482
+ }
2483
+ async function detectHermesSources(options) {
2484
+ const explicitSources = [];
2485
+ if (options.logFile) {
2486
+ const detected2 = toDetected(options.logFile, "gateway", "hermes");
2487
+ if (detected2) {
2488
+ explicitSources.push(detected2);
2489
+ }
2490
+ }
2491
+ if (options.sessionsDir) {
2492
+ const matches = await collectHermesSessionMatches(options.sessionsDir);
2493
+ for (const match of matches) {
2494
+ const detected2 = toDetected(match, "sessions", "hermes");
2495
+ if (detected2) {
2496
+ explicitSources.push(detected2);
2497
+ }
2498
+ }
2499
+ }
2500
+ if (explicitSources.length > 0) {
2501
+ return explicitSources.sort((left, right) => right.mtimeMs - left.mtimeMs);
2502
+ }
2503
+ const [gatewayMatches, sessionMatches] = await Promise.all([
2504
+ pickPreferredLogFamily(),
2505
+ collectGlobMatches(getDefaultSessionsDirPattern())
2506
+ ]);
2507
+ const detected = [
2508
+ ...gatewayMatches.map((path) => toDetected(path, "gateway", "hermes")).filter(Boolean),
2509
+ ...sessionMatches.filter((path) => isHermesSessionTranscript(path)).map((path) => toDetected(path, "sessions", "hermes")).filter(Boolean)
2510
+ ];
2511
+ return detected.sort((left, right) => right.mtimeMs - left.mtimeMs);
2512
+ }
2513
+
2514
+ // ../core/src/detect/openclaw.ts
2515
+ async function detectOpenClawSources(options) {
2516
+ const explicitSources = [];
2517
+ if (options.logFile) {
2518
+ const detected2 = toDetected(options.logFile, "gateway", "openclaw");
2519
+ if (detected2) {
2520
+ explicitSources.push(detected2);
2521
+ }
2522
+ }
2523
+ if (options.sessionsDir) {
2524
+ const matches = await collectGlobMatches("**/*.jsonl", {
2525
+ cwd: options.sessionsDir,
2526
+ resolveWith: options.sessionsDir
2527
+ });
2528
+ for (const match of matches) {
2529
+ const detected2 = toDetected(match, "sessions", "openclaw");
2530
+ if (detected2) {
2531
+ explicitSources.push(detected2);
2532
+ }
2533
+ }
2534
+ }
2535
+ if (explicitSources.length > 0) {
2536
+ return explicitSources.sort((left, right) => right.mtimeMs - left.mtimeMs);
2537
+ }
2538
+ const [gatewayMatches, sessionMatches] = await Promise.all([
2539
+ collectGlobMatches(getDefaultOpenClawGatewayPattern()),
2540
+ collectGlobMatches(getDefaultOpenClawSessionsPattern())
2541
+ ]);
2542
+ const detected = [
2543
+ ...gatewayMatches.map((path) => toDetected(path, "gateway", "openclaw")).filter(Boolean),
2544
+ ...sessionMatches.map((path) => toDetected(path, "sessions", "openclaw")).filter(Boolean)
2545
+ ];
2546
+ return detected.sort((left, right) => right.mtimeMs - left.mtimeMs);
2547
+ }
2548
+
2549
+ // ../core/src/normalize/openclaw.ts
2550
+ import { readFileSync as readFileSync3 } from "fs";
2551
+ import { basename as basename3 } from "path";
2552
+ function parseJsonLines2(path) {
2553
+ const content = readFileSync3(path, "utf8");
2554
+ const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2555
+ const records = [];
2556
+ for (const line of lines) {
2557
+ try {
2558
+ const parsed = JSON.parse(line);
2559
+ records.push(parsed);
2560
+ } catch {
2561
+ }
2562
+ }
2563
+ return records;
2564
+ }
2565
+ function inferProvider2(record) {
2566
+ return asString(
2567
+ getNestedValue(record, [["provider"], ["message", "provider"], ["usage", "provider"]])
2568
+ ) ?? "unknown";
2569
+ }
2570
+ function inferModel(record) {
2571
+ return asString(getNestedValue(record, [["model"], ["message", "model"], ["usage", "model"]])) ?? "unknown-model";
2572
+ }
2573
+ function inferWorkflow2(record, sourcePath) {
2574
+ return asString(
2575
+ getNestedValue(record, [
2576
+ ["workflow"],
2577
+ ["session", "workflow"],
2578
+ ["metadata", "workflow"],
2579
+ ["agent", "name"],
2580
+ ["agentId"],
2581
+ ["sessionId"]
2582
+ ])
2583
+ ) ?? basename3(sourcePath, ".jsonl");
2584
+ }
2585
+ function inferEnvironment2(record) {
2586
+ return asString(getNestedValue(record, [["environment"], ["env"], ["metadata", "environment"]])) ?? "local";
2587
+ }
2588
+ function inferRunKey2(record, workflow, index, sourcePath) {
2589
+ return asString(
2590
+ getNestedValue(record, [
2591
+ ["run_id"],
2592
+ ["runId"],
2593
+ ["trace_id"],
2594
+ ["traceId"],
2595
+ ["sessionId"],
2596
+ ["thread_id"]
2597
+ ])
2598
+ ) ?? `${sourcePath}:${workflow}:${index}`;
2599
+ }
2600
+ function inferTaskClass2(record, workflow) {
2601
+ return asString(getNestedValue(record, [["task_class"], ["taskClass"], ["metadata", "taskClass"]])) ?? workflow.toLowerCase();
2602
+ }
2603
+ function extractUsage2(record) {
2604
+ const inputTokens = asNumber2(
1278
2605
  getNestedValue(record, [
1279
2606
  ["input_tokens"],
1280
2607
  ["inputTokens"],
@@ -1286,7 +2613,7 @@ function extractUsage(record) {
1286
2613
  ["message", "usage", "prompt_tokens"]
1287
2614
  ])
1288
2615
  ) ?? 0;
1289
- const outputTokens = asNumber(
2616
+ const outputTokens = asNumber2(
1290
2617
  getNestedValue(record, [
1291
2618
  ["output_tokens"],
1292
2619
  ["outputTokens"],
@@ -1298,7 +2625,7 @@ function extractUsage(record) {
1298
2625
  ["message", "usage", "completion_tokens"]
1299
2626
  ])
1300
2627
  ) ?? 0;
1301
- const observedCost = asNumber(
2628
+ const observedCost = asNumber2(
1302
2629
  getNestedValue(record, [
1303
2630
  ["cost_usd"],
1304
2631
  ["costUsd"],
@@ -1316,22 +2643,22 @@ function extractUsage(record) {
1316
2643
  observedCost
1317
2644
  };
1318
2645
  }
1319
- function buildCall(source, record, runId, index) {
1320
- const provider = inferProvider(record);
2646
+ function buildCall3(source, record, runId, index) {
2647
+ const provider = inferProvider2(record);
1321
2648
  const model = inferModel(record);
1322
- const workflow = inferWorkflow(record, source.path);
1323
- const { inputTokens, outputTokens, observedCost } = extractUsage(record);
2649
+ const workflow = inferWorkflow2(record, source.path);
2650
+ const { inputTokens, outputTokens, observedCost } = extractUsage2(record);
1324
2651
  const estimatedCost = estimateCostUsd(provider, model, inputTokens, outputTokens);
1325
2652
  const timestamp = toIsoOrNow(
1326
2653
  getNestedValue(record, [["timestamp"], ["createdAt"], ["created_at"]])
1327
2654
  );
1328
- const attempt = asNumber(
2655
+ const attempt = asNumber2(
1329
2656
  getNestedValue(record, [["attempt"], ["usage", "attempt"], ["metadata", "attempt"]])
1330
2657
  ) ?? null;
1331
- const iteration = asNumber(
2658
+ const iteration = asNumber2(
1332
2659
  getNestedValue(record, [["iteration"], ["loop_iteration"], ["metadata", "iteration"]])
1333
2660
  ) ?? null;
1334
- const retries = asNumber(getNestedValue(record, [["retries"], ["retry_count"], ["metadata", "retries"]])) ?? 0;
2661
+ const retries = asNumber2(getNestedValue(record, [["retries"], ["retry_count"], ["metadata", "retries"]])) ?? 0;
1335
2662
  const costUsd = observedCost ?? estimatedCost ?? 0;
1336
2663
  return {
1337
2664
  id: sha1(`${runId}:${source.path}:${index}:${model}:${timestamp}:${costUsd}`),
@@ -1343,45 +2670,45 @@ function buildCall(source, record, runId, index) {
1343
2670
  outputTokens,
1344
2671
  costUsd,
1345
2672
  costSource: observedCost !== null ? "observed" : "estimated",
1346
- latencyMs: asNumber(getNestedValue(record, [["latency_ms"], ["latencyMs"], ["usage", "latency_ms"]])) ?? null,
1347
- toolCalls: asNumber(getNestedValue(record, [["tool_calls"], ["toolCalls"], ["usage", "tool_calls"]])) ?? 0,
2673
+ latencyMs: asNumber2(getNestedValue(record, [["latency_ms"], ["latencyMs"], ["usage", "latency_ms"]])) ?? null,
2674
+ toolCalls: asNumber2(getNestedValue(record, [["tool_calls"], ["toolCalls"], ["usage", "tool_calls"]])) ?? 0,
1348
2675
  retries,
1349
2676
  attempt,
1350
2677
  iteration,
1351
2678
  status: asString(getNestedValue(record, [["status"], ["level"], ["result"], ["error", "type"]])) ?? null,
1352
- taskClass: inferTaskClass(record, workflow),
1353
- cacheHit: asBoolean(
2679
+ taskClass: inferTaskClass2(record, workflow),
2680
+ cacheHit: asBoolean2(
1354
2681
  getNestedValue(record, [["cache_hit"], ["cacheHit"], ["usage", "cache_hit"]])
1355
2682
  ),
1356
- cacheCostUsd: asNumber(
2683
+ cacheCostUsd: asNumber2(
1357
2684
  getNestedValue(record, [["cache_cost_usd"], ["cacheCostUsd"], ["usage", "cache_cost_usd"]])
1358
2685
  ) ?? null,
1359
2686
  metadata: pickMetadata(record, ["event", "type", "sessionId", "agentId"])
1360
2687
  };
1361
2688
  }
1362
- function shouldTreatAsCall(record) {
1363
- const hasUsage = extractUsage(record).inputTokens > 0 || extractUsage(record).outputTokens > 0 || extractUsage(record).observedCost !== null;
2689
+ function shouldTreatAsCall2(record) {
2690
+ const hasUsage = extractUsage2(record).inputTokens > 0 || extractUsage2(record).outputTokens > 0 || extractUsage2(record).observedCost !== null;
1364
2691
  return hasUsage;
1365
2692
  }
1366
2693
  function normalizeOpenClawSources(sources, since) {
1367
2694
  const cutoff = parseSince(since);
1368
2695
  const runsById = /* @__PURE__ */ new Map();
1369
2696
  for (const source of sources) {
1370
- const records = parseJsonLines(source.path);
2697
+ const records = parseJsonLines2(source.path);
1371
2698
  records.forEach((record, index) => {
1372
- if (!shouldTreatAsCall(record)) {
2699
+ if (!shouldTreatAsCall2(record)) {
1373
2700
  return;
1374
2701
  }
1375
- const workflow = inferWorkflow(record, source.path);
2702
+ const workflow = inferWorkflow2(record, source.path);
1376
2703
  const timestamp = toIsoOrNow(
1377
2704
  getNestedValue(record, [["timestamp"], ["createdAt"], ["created_at"]])
1378
2705
  );
1379
2706
  if (cutoff && new Date(timestamp).getTime() < cutoff) {
1380
2707
  return;
1381
2708
  }
1382
- const runKey = inferRunKey(record, workflow, index, source.path);
2709
+ const runKey = inferRunKey2(record, workflow, index, source.path);
1383
2710
  const runId = sha1(`${source.path}:${runKey}`);
1384
- const call = buildCall(source, record, runId, index);
2711
+ const call = buildCall3(source, record, runId, index);
1385
2712
  const existing = runsById.get(runId);
1386
2713
  if (!existing) {
1387
2714
  runsById.set(runId, {
@@ -1390,7 +2717,7 @@ function normalizeOpenClawSources(sources, since) {
1390
2717
  sourcePath: source.path,
1391
2718
  timestamp,
1392
2719
  workflow,
1393
- environment: inferEnvironment(record),
2720
+ environment: inferEnvironment2(record),
1394
2721
  tags: {
1395
2722
  sourceKind: source.kind
1396
2723
  },
@@ -1414,147 +2741,431 @@ function normalizeOpenClawSources(sources, since) {
1414
2741
  });
1415
2742
  }
1416
2743
 
1417
- // ../core/src/report/summary.ts
1418
- function buildBreakdown(items) {
1419
- const buckets = /* @__PURE__ */ new Map();
1420
- for (const item of items) {
1421
- const current = buckets.get(item.key) ?? { spendUsd: 0, observedSpendUsd: 0, callCount: 0 };
1422
- current.spendUsd += item.spendUsd;
1423
- current.observedSpendUsd += item.observedSpendUsd;
1424
- current.callCount += 1;
1425
- buckets.set(item.key, current);
2744
+ // ../core/src/runtime.ts
2745
+ var RUNTIME_ADAPTERS = {
2746
+ openclaw: {
2747
+ runtime: "openclaw",
2748
+ productName: "OpenClaw",
2749
+ detectSources: detectOpenClawSources,
2750
+ normalizeSources: normalizeOpenClawSources,
2751
+ noSourceNotes: [
2752
+ "No OpenClaw gateway logs or session files were detected.",
2753
+ "Doctor checks local defaults by default. Use --log-file or --sessions-dir if your OpenClaw data lives outside the defaults.",
2754
+ "Use --remote or --railway to inspect remote OpenClaw targets."
2755
+ ],
2756
+ gatewayNote: "Gateway logs detected. These are preferred when cost metadata is present.",
2757
+ sessionNote: "Session transcript fallback detected. Xerg will extract usage metadata only.",
2758
+ noDataError: (commandPrefix) => `No OpenClaw sources were detected. Run \`${commandPrefix} doctor --runtime openclaw\` or provide --log-file / --sessions-dir.`,
2759
+ defaultPaths: () => ({
2760
+ runtime: "openclaw",
2761
+ gatewayPattern: getDefaultOpenClawGatewayPattern(),
2762
+ sessionsPattern: getDefaultOpenClawSessionsPattern()
2763
+ })
2764
+ },
2765
+ hermes: {
2766
+ runtime: "hermes",
2767
+ productName: "Hermes",
2768
+ detectSources: detectHermesSources,
2769
+ normalizeSources: normalizeHermesSources,
2770
+ noSourceNotes: [
2771
+ "No Hermes gateway logs or session files were detected.",
2772
+ "Doctor checks local defaults by default. Use --log-file or --sessions-dir if your Hermes data lives outside the defaults.",
2773
+ "Hermes remote transport is not part of this rollout yet."
2774
+ ],
2775
+ gatewayNote: "Hermes gateway logs detected. Xerg prefers agent.log entries when billable model-call records are present.",
2776
+ sessionNote: "Hermes session transcripts detected. Xerg will extract usage metadata only.",
2777
+ noDataError: (commandPrefix) => `No Hermes sources were detected. Run \`${commandPrefix} doctor --runtime hermes\` or provide --log-file / --sessions-dir.`,
2778
+ defaultPaths: () => ({
2779
+ runtime: "hermes",
2780
+ gatewayPattern: getDefaultHermesGatewayPattern(),
2781
+ sessionsPattern: getDefaultHermesSessionsPattern()
2782
+ })
1426
2783
  }
1427
- return Array.from(buckets.entries()).map(([key, value]) => {
1428
- const observedShare = value.spendUsd === 0 ? 0 : value.observedSpendUsd / value.spendUsd;
2784
+ };
2785
+ function getRuntimeAdapter(runtime) {
2786
+ return RUNTIME_ADAPTERS[runtime];
2787
+ }
2788
+ function hasExplicitLocalPaths(options) {
2789
+ return Boolean(options.logFile || options.sessionsDir);
2790
+ }
2791
+ function inferRuntimeFromExplicitPaths(options) {
2792
+ const paths = [options.logFile, options.sessionsDir].filter((path) => Boolean(path)).map((path) => path.replace(/\\/g, "/").toLowerCase());
2793
+ if (paths.length === 0) {
2794
+ return null;
2795
+ }
2796
+ const hints = /* @__PURE__ */ new Set();
2797
+ for (const path of paths) {
2798
+ const name = basename4(path).toLowerCase();
2799
+ if (path.includes("/.openclaw/") || path.includes("/openclaw/") || path.includes("/tmp/openclaw") || name.includes("openclaw")) {
2800
+ hints.add("openclaw");
2801
+ }
2802
+ if (path.includes("/.hermes/") || path.includes("/hermes/") || path.includes("/.hermes") || name.includes("hermes") || name.startsWith("agent.log")) {
2803
+ hints.add("hermes");
2804
+ }
2805
+ }
2806
+ return hints.size === 1 ? Array.from(hints)[0] : null;
2807
+ }
2808
+ async function probeRuntimeCandidate(adapter, options) {
2809
+ const sources = await adapter.detectSources(options);
2810
+ return {
2811
+ adapter,
2812
+ sources,
2813
+ usable: sources.length > 0
2814
+ };
2815
+ }
2816
+ function buildResolvedDoctorNotes(adapter, sources) {
2817
+ if (sources.length === 0) {
2818
+ return adapter.noSourceNotes;
2819
+ }
2820
+ const notes = [];
2821
+ if (sources.some((source) => source.kind === "gateway")) {
2822
+ notes.push(adapter.gatewayNote);
2823
+ }
2824
+ if (sources.some((source) => source.kind === "sessions")) {
2825
+ notes.push(adapter.sessionNote);
2826
+ }
2827
+ return notes;
2828
+ }
2829
+ function buildResolvedDoctorReport(adapter, sources) {
2830
+ return {
2831
+ canAudit: sources.length > 0,
2832
+ mode: sources.length > 0 ? "resolved" : "none",
2833
+ runtime: sources.length > 0 ? adapter.runtime : null,
2834
+ sources,
2835
+ defaults: [adapter.defaultPaths()],
2836
+ notes: buildResolvedDoctorNotes(adapter, sources)
2837
+ };
2838
+ }
2839
+ function buildAutoNoDataDoctorReport(candidates) {
2840
+ return {
2841
+ canAudit: false,
2842
+ mode: "none",
2843
+ runtime: null,
2844
+ sources: candidates.flatMap((candidate) => candidate.sources),
2845
+ defaults: Object.values(RUNTIME_ADAPTERS).map((adapter) => adapter.defaultPaths()),
2846
+ notes: [
2847
+ "No supported local runtime sources were detected.",
2848
+ "Auto-detection checked both OpenClaw and Hermes local defaults.",
2849
+ "Use --runtime openclaw or --runtime hermes with --log-file / --sessions-dir when you want to point Xerg at explicit local paths."
2850
+ ]
2851
+ };
2852
+ }
2853
+ function buildAutoAmbiguousDoctorReport(candidates) {
2854
+ return {
2855
+ canAudit: false,
2856
+ mode: "ambiguous",
2857
+ runtime: null,
2858
+ sources: candidates.flatMap((candidate) => candidate.sources),
2859
+ defaults: Object.values(RUNTIME_ADAPTERS).map((adapter) => adapter.defaultPaths()),
2860
+ notes: [
2861
+ "Both OpenClaw and Hermes local sources were detected.",
2862
+ "Re-run doctor with --runtime openclaw or --runtime hermes to choose the local runtime explicitly."
2863
+ ]
2864
+ };
2865
+ }
2866
+ function buildExplicitNoDataError(options, hintedRuntime) {
2867
+ const commandPrefix = options.commandPrefix ?? "xerg";
2868
+ if (hintedRuntime) {
2869
+ return getRuntimeAdapter(hintedRuntime).noDataError(commandPrefix);
2870
+ }
2871
+ return `No supported local runtime sources were detected. Run \`${commandPrefix} doctor\`, or use --runtime openclaw / --runtime hermes with --log-file / --sessions-dir.`;
2872
+ }
2873
+ function buildExplicitNoDataDoctorReport(candidates, hintedRuntime) {
2874
+ if (hintedRuntime) {
2875
+ const adapter = getRuntimeAdapter(hintedRuntime);
1429
2876
  return {
1430
- key,
1431
- spendUsd: Number(value.spendUsd.toFixed(6)),
1432
- callCount: value.callCount,
1433
- observedShare: Number(observedShare.toFixed(4))
2877
+ canAudit: false,
2878
+ mode: "none",
2879
+ runtime: null,
2880
+ sources: [],
2881
+ defaults: [adapter.defaultPaths()],
2882
+ notes: [
2883
+ `No ${adapter.productName} sources were detected from the provided local paths.`,
2884
+ `Verify --log-file / --sessions-dir and re-run doctor with --runtime ${adapter.runtime} if needed.`
2885
+ ]
1434
2886
  };
1435
- }).sort((left, right) => right.spendUsd - left.spendUsd);
1436
- }
1437
- function buildAuditSummary(input) {
1438
- const callCount = input.runs.reduce((sum, run2) => sum + run2.calls.length, 0);
1439
- const totalSpendUsd = input.runs.reduce((sum, run2) => sum + run2.totalCostUsd, 0);
1440
- const observedSpendUsd = input.runs.reduce((sum, run2) => sum + run2.observedCostUsd, 0);
1441
- const estimatedSpendUsd = input.runs.reduce((sum, run2) => sum + run2.estimatedCostUsd, 0);
1442
- const wasteSpendUsd = input.findings.filter((finding) => finding.classification === "waste").reduce((sum, finding) => sum + finding.costImpactUsd, 0);
1443
- const opportunitySpendUsd = input.findings.filter((finding) => finding.classification === "opportunity").reduce((sum, finding) => sum + finding.costImpactUsd, 0);
1444
- const generatedAt = isoNow();
2887
+ }
1445
2888
  return {
1446
- auditId: sha1(
1447
- `${generatedAt}:${input.runs.length}:${input.sources.map((source) => source.path).join("|")}`
1448
- ),
1449
- generatedAt,
1450
- comparisonKey: input.comparisonKeyOverride ?? buildComparisonKey({
1451
- sources: input.sources,
1452
- since: input.since
1453
- }),
1454
- comparison: null,
1455
- since: input.since,
1456
- runCount: input.runs.length,
1457
- callCount,
1458
- totalSpendUsd: Number(totalSpendUsd.toFixed(6)),
1459
- observedSpendUsd: Number(observedSpendUsd.toFixed(6)),
1460
- estimatedSpendUsd: Number(estimatedSpendUsd.toFixed(6)),
1461
- wasteSpendUsd: Number(wasteSpendUsd.toFixed(6)),
1462
- opportunitySpendUsd: Number(opportunitySpendUsd.toFixed(6)),
1463
- structuralWasteRate: Number(
1464
- (totalSpendUsd === 0 ? 0 : wasteSpendUsd / totalSpendUsd).toFixed(4)
1465
- ),
1466
- wasteByKind: buildTaxonomyBuckets(input.findings, "waste"),
1467
- opportunityByKind: buildTaxonomyBuckets(input.findings, "opportunity"),
1468
- spendByWorkflow: buildBreakdown(
1469
- input.runs.map((run2) => ({
1470
- key: run2.workflow,
1471
- spendUsd: run2.totalCostUsd,
1472
- observedSpendUsd: run2.observedCostUsd
1473
- }))
1474
- ),
1475
- spendByModel: buildBreakdown(
1476
- input.runs.flatMap(
1477
- (run2) => run2.calls.map((call) => ({
1478
- key: `${call.provider}/${call.model}`,
1479
- spendUsd: call.costUsd,
1480
- observedSpendUsd: call.costSource === "observed" ? call.costUsd : 0
1481
- }))
1482
- )
1483
- ),
1484
- findings: input.findings,
2889
+ canAudit: false,
2890
+ mode: "none",
2891
+ runtime: null,
2892
+ sources: candidates.flatMap((candidate) => candidate.sources),
2893
+ defaults: Object.values(RUNTIME_ADAPTERS).map((adapter) => adapter.defaultPaths()),
1485
2894
  notes: [
1486
- "Cost per outcome is intentionally unavailable in v0. Xerg is measuring waste intelligence only.",
1487
- "Opportunity findings are directional recommendations, not proven waste."
1488
- ],
1489
- sourceFiles: input.sources,
1490
- dbPath: input.dbPath
2895
+ "No supported local runtime sources were detected from the provided local paths.",
2896
+ "Verify --log-file / --sessions-dir and re-run doctor with --runtime openclaw or --runtime hermes if needed."
2897
+ ]
2898
+ };
2899
+ }
2900
+ function getRuntimeProductName(runtime) {
2901
+ return getRuntimeAdapter(runtime).productName;
2902
+ }
2903
+ async function resolveRuntimeCandidates(options) {
2904
+ return Promise.all(
2905
+ Object.values(RUNTIME_ADAPTERS).map(
2906
+ (adapter) => probeRuntimeCandidate(adapter, options)
2907
+ )
2908
+ );
2909
+ }
2910
+ async function resolveLocalAgentRuntime(options) {
2911
+ const requestedRuntime = options.runtime ?? "auto";
2912
+ if (requestedRuntime !== "auto") {
2913
+ const adapter = getRuntimeAdapter(requestedRuntime);
2914
+ const sources = await adapter.detectSources(options);
2915
+ return {
2916
+ adapter,
2917
+ sources
2918
+ };
2919
+ }
2920
+ const candidates = await resolveRuntimeCandidates(options);
2921
+ const usableCandidates = candidates.filter((candidate) => candidate.usable);
2922
+ if (hasExplicitLocalPaths(options)) {
2923
+ const hintedRuntime = inferRuntimeFromExplicitPaths(options);
2924
+ if (hintedRuntime) {
2925
+ const hintedCandidate = usableCandidates.find(
2926
+ (candidate) => candidate.adapter.runtime === hintedRuntime
2927
+ );
2928
+ if (hintedCandidate) {
2929
+ return {
2930
+ adapter: hintedCandidate.adapter,
2931
+ sources: hintedCandidate.sources
2932
+ };
2933
+ }
2934
+ }
2935
+ if (usableCandidates.length === 0) {
2936
+ throw new Error(buildExplicitNoDataError(options, hintedRuntime));
2937
+ }
2938
+ if (usableCandidates.length === 1) {
2939
+ return {
2940
+ adapter: usableCandidates[0].adapter,
2941
+ sources: usableCandidates[0].sources
2942
+ };
2943
+ }
2944
+ throw new Error(
2945
+ "Could not determine whether the provided local files belong to OpenClaw or Hermes. Re-run with --runtime openclaw or --runtime hermes."
2946
+ );
2947
+ }
2948
+ if (usableCandidates.length === 0) {
2949
+ throw new Error(
2950
+ `No supported local runtime sources were detected. Run \`${options.commandPrefix ?? "xerg"} doctor\`, or use --runtime openclaw / --runtime hermes with --log-file / --sessions-dir.`
2951
+ );
2952
+ }
2953
+ if (usableCandidates.length > 1) {
2954
+ throw new Error(
2955
+ "Both OpenClaw and Hermes local sources were detected. Re-run with --runtime openclaw or --runtime hermes."
2956
+ );
2957
+ }
2958
+ return {
2959
+ adapter: usableCandidates[0].adapter,
2960
+ sources: usableCandidates[0].sources
1491
2961
  };
1492
2962
  }
2963
+ async function doctorAgentRuntime(options) {
2964
+ const requestedRuntime = options.runtime ?? "auto";
2965
+ if (requestedRuntime !== "auto") {
2966
+ options.onProgress?.(`Checking local ${getRuntimeProductName(requestedRuntime)} defaults...`);
2967
+ const adapter = getRuntimeAdapter(requestedRuntime);
2968
+ const sources = await adapter.detectSources(options);
2969
+ options.onProgress?.(
2970
+ sources.length > 0 ? `Detected ${sources.length} local source file${sources.length === 1 ? "" : "s"}.` : `No local ${adapter.productName} source files were detected.`
2971
+ );
2972
+ return buildResolvedDoctorReport(adapter, sources);
2973
+ }
2974
+ options.onProgress?.("Checking local runtime defaults...");
2975
+ const candidates = await resolveRuntimeCandidates(options);
2976
+ const usableCandidates = candidates.filter((candidate) => candidate.usable);
2977
+ const detectedCount = candidates.reduce((sum, candidate) => sum + candidate.sources.length, 0);
2978
+ options.onProgress?.(
2979
+ detectedCount > 0 ? `Detected ${detectedCount} local source file${detectedCount === 1 ? "" : "s"} across supported runtimes.` : "No local runtime source files were detected."
2980
+ );
2981
+ if (hasExplicitLocalPaths(options)) {
2982
+ const hintedRuntime = inferRuntimeFromExplicitPaths(options);
2983
+ if (hintedRuntime) {
2984
+ const hintedCandidate = usableCandidates.find(
2985
+ (candidate) => candidate.adapter.runtime === hintedRuntime
2986
+ );
2987
+ if (hintedCandidate) {
2988
+ return buildResolvedDoctorReport(hintedCandidate.adapter, hintedCandidate.sources);
2989
+ }
2990
+ }
2991
+ if (usableCandidates.length === 0) {
2992
+ return buildExplicitNoDataDoctorReport(candidates, hintedRuntime);
2993
+ }
2994
+ if (usableCandidates.length === 1) {
2995
+ return buildResolvedDoctorReport(usableCandidates[0].adapter, usableCandidates[0].sources);
2996
+ }
2997
+ return {
2998
+ canAudit: false,
2999
+ mode: "ambiguous",
3000
+ runtime: null,
3001
+ sources: candidates.flatMap((candidate) => candidate.sources),
3002
+ defaults: Object.values(RUNTIME_ADAPTERS).map((adapter) => adapter.defaultPaths()),
3003
+ notes: [
3004
+ "Could not determine whether the provided local files belong to OpenClaw or Hermes.",
3005
+ "Re-run doctor with --runtime openclaw or --runtime hermes to choose the local runtime explicitly."
3006
+ ]
3007
+ };
3008
+ }
3009
+ if (usableCandidates.length === 0) {
3010
+ return buildAutoNoDataDoctorReport(candidates);
3011
+ }
3012
+ if (usableCandidates.length > 1) {
3013
+ return buildAutoAmbiguousDoctorReport(usableCandidates);
3014
+ }
3015
+ return buildResolvedDoctorReport(usableCandidates[0].adapter, usableCandidates[0].sources);
3016
+ }
1493
3017
 
1494
3018
  // ../core/src/audit.ts
1495
- async function doctorOpenClaw(options) {
1496
- return inspectOpenClawSources(options);
3019
+ async function doctorCursorUsageCsv(options) {
3020
+ return inspectCursorUsageCsv(options);
1497
3021
  }
1498
- async function auditOpenClaw(options) {
1499
- options.onProgress?.("Scanning for OpenClaw source files...");
3022
+ function validateCompareOptions(options) {
1500
3023
  if (options.compare && options.noDb) {
1501
3024
  throw new Error(
1502
3025
  "The --compare flag needs local snapshot history. Remove --no-db or provide --db <path>."
1503
3026
  );
1504
3027
  }
1505
- const sources = await detectOpenClawSources(options);
3028
+ }
3029
+ function maybeAttachComparison(options, dbPath, summary) {
3030
+ if (!options.compare || !dbPath) {
3031
+ return;
3032
+ }
3033
+ options.onProgress?.("Looking for a comparable baseline audit...");
3034
+ const baseline = readLatestComparableAuditSummary({
3035
+ dbPath,
3036
+ comparisonKey: summary.comparisonKey,
3037
+ currentAuditId: summary.auditId
3038
+ });
3039
+ if (!baseline) {
3040
+ summary.notes = [
3041
+ ...summary.notes,
3042
+ "No prior comparable audit was found. Run the same audit again after a fix to unlock before/after deltas."
3043
+ ];
3044
+ return;
3045
+ }
3046
+ summary.comparison = buildAuditComparison(summary, baseline);
3047
+ if (hasPricingCoverageChange(summary.pricingCoverage, baseline.pricingCoverage)) {
3048
+ summary.notes = [
3049
+ ...summary.notes,
3050
+ "Pricing coverage changed versus the baseline audit. Spend deltas are directional because different Cursor aliases were priced in each run."
3051
+ ];
3052
+ }
3053
+ }
3054
+ function persistLocalSnapshot(summary, runs, dbPath, onProgress) {
3055
+ if (!dbPath) {
3056
+ onProgress?.("Skipping local snapshot persistence (--no-db).");
3057
+ return;
3058
+ }
3059
+ onProgress?.(`Persisting local snapshot to ${dbPath}...`);
3060
+ persistAudit(
3061
+ {
3062
+ summary,
3063
+ runs,
3064
+ pricingCatalog: PRICING_CATALOG
3065
+ },
3066
+ dbPath
3067
+ );
3068
+ onProgress?.("Local snapshot stored.");
3069
+ }
3070
+ function hasPricingCoverageChange(current, baseline) {
3071
+ if (!current && !baseline) {
3072
+ return false;
3073
+ }
3074
+ return (current?.pricedCallCount ?? 0) !== (baseline?.pricedCallCount ?? 0) || (current?.unpricedCallCount ?? 0) !== (baseline?.unpricedCallCount ?? 0) || (current?.pricedTokenCount ?? 0) !== (baseline?.pricedTokenCount ?? 0) || (current?.unpricedTokenCount ?? 0) !== (baseline?.unpricedTokenCount ?? 0);
3075
+ }
3076
+ async function auditResolvedRuntime(runtime, options, detectedSources) {
3077
+ const adapter = getRuntimeAdapter(runtime);
3078
+ options.onProgress?.(`Scanning for ${adapter.productName} source files...`);
3079
+ validateCompareOptions(options);
3080
+ const sources = detectedSources ?? await adapter.detectSources(options);
1506
3081
  if (sources.length === 0) {
1507
- options.onProgress?.("No OpenClaw source files were detected.");
1508
- throw new Error(
1509
- `No OpenClaw sources were detected. Run \`${options.commandPrefix ?? "xerg"} doctor\` or provide --log-file / --sessions-dir.`
1510
- );
3082
+ options.onProgress?.(`No ${adapter.productName} source files were detected.`);
3083
+ throw new Error(adapter.noDataError(options.commandPrefix ?? "xerg"));
1511
3084
  }
1512
3085
  options.onProgress?.(`Detected ${sources.length} source file${sources.length === 1 ? "" : "s"}.`);
1513
- options.onProgress?.("Normalizing OpenClaw source files...");
1514
- const runs = normalizeOpenClawSources(sources, options.since);
3086
+ options.onProgress?.(`Normalizing ${adapter.productName} source files...`);
3087
+ const runs = adapter.normalizeSources(sources, options.since);
1515
3088
  options.onProgress?.(`Normalized ${runs.length} run${runs.length === 1 ? "" : "s"}.`);
1516
3089
  options.onProgress?.("Computing waste and savings findings...");
1517
- const findings = buildFindings(runs);
3090
+ const { findings, wasteAttributions } = buildFindings(runs);
1518
3091
  const dbPath = options.noDb ? void 0 : options.dbPath ?? getDefaultDbPath();
1519
3092
  options.onProgress?.("Building audit summary...");
1520
3093
  const summary = buildAuditSummary({
3094
+ runtime,
1521
3095
  runs,
1522
3096
  findings,
3097
+ wasteAttributions,
1523
3098
  sources,
1524
3099
  since: options.since,
1525
3100
  dbPath,
1526
3101
  comparisonKeyOverride: options.comparisonKeyOverride
1527
3102
  });
1528
- if (options.compare && dbPath) {
1529
- options.onProgress?.("Looking for a comparable baseline audit...");
1530
- const baseline = readLatestComparableAuditSummary({
1531
- dbPath,
1532
- comparisonKey: summary.comparisonKey,
1533
- currentAuditId: summary.auditId
1534
- });
1535
- if (baseline) {
1536
- summary.comparison = buildAuditComparison(summary, baseline);
1537
- } else {
1538
- summary.notes = [
1539
- ...summary.notes,
1540
- "No prior comparable audit was found. Run the same audit again after a fix to unlock before/after deltas."
1541
- ];
1542
- }
1543
- }
1544
- if (dbPath) {
1545
- options.onProgress?.(`Persisting local snapshot to ${dbPath}...`);
1546
- persistAudit(
1547
- {
1548
- summary,
1549
- runs,
1550
- pricingCatalog: PRICING_CATALOG
1551
- },
1552
- dbPath
1553
- );
1554
- options.onProgress?.("Local snapshot stored.");
1555
- } else {
1556
- options.onProgress?.("Skipping local snapshot persistence (--no-db).");
1557
- }
3103
+ maybeAttachComparison(options, dbPath, summary);
3104
+ persistLocalSnapshot(summary, runs, dbPath, options.onProgress);
3105
+ return summary;
3106
+ }
3107
+ async function auditAgentRuntime(options) {
3108
+ const runtime = options.runtime ?? "auto";
3109
+ if (runtime !== "auto") {
3110
+ return auditResolvedRuntime(runtime, options);
3111
+ }
3112
+ const resolved = await resolveLocalAgentRuntime(options);
3113
+ return auditResolvedRuntime(resolved.adapter.runtime, options, resolved.sources);
3114
+ }
3115
+ async function auditCursorUsageCsv(options) {
3116
+ options.onProgress?.("Reading Cursor usage CSV...");
3117
+ validateCompareOptions(options);
3118
+ if (!options.cursorUsageCsv) {
3119
+ throw new Error("No Cursor usage CSV was provided. Use --cursor-usage-csv <path>.");
3120
+ }
3121
+ const parsed = readCursorUsageCsv(options.cursorUsageCsv);
3122
+ options.onProgress?.(`Loaded Cursor usage CSV: ${parsed.source.path}`);
3123
+ options.onProgress?.("Normalizing Cursor usage rows...");
3124
+ const normalized = normalizeCursorUsageCsv({
3125
+ source: parsed.source,
3126
+ rows: parsed.rows,
3127
+ hasObservedCostRows: parsed.hasObservedCostRows,
3128
+ since: options.since
3129
+ });
3130
+ options.onProgress?.(
3131
+ `Normalized ${normalized.runs.length} usage row${normalized.runs.length === 1 ? "" : "s"}.`
3132
+ );
3133
+ options.onProgress?.("Computing Cursor-specific findings...");
3134
+ const { findings, wasteAttributions } = buildCursorUsageFindings(normalized.runs);
3135
+ const dbPath = options.noDb ? void 0 : options.dbPath ?? getDefaultDbPath();
3136
+ options.onProgress?.("Building audit summary...");
3137
+ const summary = buildAuditSummary({
3138
+ runtime: "cursor",
3139
+ runs: normalized.runs,
3140
+ findings,
3141
+ wasteAttributions,
3142
+ sources: [parsed.source],
3143
+ since: options.since,
3144
+ dbPath,
3145
+ comparisonKeyOverride: options.comparisonKeyOverride
3146
+ });
3147
+ summary.pricingCoverage = normalized.pricingCoverage;
3148
+ summary.cursorUsage = normalized.cursorUsage;
3149
+ summary.notes = [
3150
+ "Cursor CSV audits analyze exported usage rows rather than raw session transcripts.",
3151
+ "OpenClaw-specific retry, loop, and session-level workflow findings are still unavailable for Cursor CSV inputs because the export does not include retries, iterations, or real workflow IDs.",
3152
+ parsed.hasObservedCostRows ? "Numeric Cost values were used as observed spend. Included rows are treated as zero billed spend in this export mode." : "This CSV did not include numeric Cost values, so spend was estimated from local model pricing where possible."
3153
+ ];
3154
+ if (parsed.rows.length > 0 && normalized.runs.length === 0 && options.since) {
3155
+ summary.notes = [
3156
+ ...summary.notes,
3157
+ `No Cursor usage rows matched the --since window (${options.since}).`
3158
+ ];
3159
+ }
3160
+ if (normalized.pricingCoverage.unpricedCallCount > 0) {
3161
+ const aliases = normalized.pricingCoverage.topUnpricedModels.map((model) => model.key).join(", ");
3162
+ summary.notes = [
3163
+ ...summary.notes,
3164
+ `Some Cursor aliases do not have full local pricing coverage: ${aliases || "unknown aliases"}.`
3165
+ ];
3166
+ }
3167
+ maybeAttachComparison(options, dbPath, summary);
3168
+ persistLocalSnapshot(summary, normalized.runs, dbPath, options.onProgress);
1558
3169
  return summary;
1559
3170
  }
1560
3171
 
@@ -1605,6 +3216,26 @@ var templatesByKind = {
1605
3216
  suggestedChangeFn: () => ({
1606
3217
  strategy: "cadence-review"
1607
3218
  })
3219
+ },
3220
+ "cache-carryover": {
3221
+ actionType: "prompt-trim",
3222
+ titleFn: () => "Summarize and reset long Cursor chats",
3223
+ descriptionFn: (f) => `${f.summary} Create a compact recall summary, start a fresh chat, and carry forward only the facts the model actually needs.`,
3224
+ suggestedChangeFn: (f) => ({
3225
+ strategy: "conversation-reset",
3226
+ cacheReadShare: f.details.cacheReadShare,
3227
+ totalCacheReadTokens: f.details.totalCacheReadTokens
3228
+ })
3229
+ },
3230
+ "max-mode-concentration": {
3231
+ actionType: "model-switch",
3232
+ titleFn: () => "Reserve max mode for the hardest Cursor turns",
3233
+ descriptionFn: (f) => `${f.summary} Try a two-pass workflow: standard mode first, then escalate only the prompts that truly need max mode.`,
3234
+ suggestedChangeFn: (f) => ({
3235
+ strategy: "tiered-routing",
3236
+ maxModeSpendShare: f.details.maxModeSpendShare,
3237
+ maxModeCallCount: f.details.maxModeCallCount
3238
+ })
1608
3239
  }
1609
3240
  };
1610
3241
  function extractWorkflow(finding) {
@@ -1658,10 +3289,16 @@ function formatPercentDelta(value) {
1658
3289
  const sign = points > 0 ? "+" : "";
1659
3290
  return `${sign}${points.toFixed(0)} pts`;
1660
3291
  }
3292
+ function formatCount(value) {
3293
+ return new Intl.NumberFormat("en-US").format(value);
3294
+ }
1661
3295
  function formatUsdDelta(value) {
1662
3296
  const sign = value > 0 ? "+" : "";
1663
3297
  return `${sign}${formatUsd(value)}`;
1664
3298
  }
3299
+ function isCursorUsageSummary(summary) {
3300
+ return summary.sourceFiles.some((source) => source.kind === "cursor-usage-csv");
3301
+ }
1665
3302
  function topRows(rows, limit = 5) {
1666
3303
  return rows.slice(0, limit).map((row) => {
1667
3304
  return `- ${row.key}: ${formatUsd(row.spendUsd)} (${formatPercent(row.observedShare)} observed)`;
@@ -1763,27 +3400,51 @@ function renderCompareBlock(summary) {
1763
3400
  ...findingChanges.length > 0 ? findingChanges : ["- High-confidence waste changes: none"]
1764
3401
  ];
1765
3402
  }
3403
+ function renderDailyTrendRows(spendByDay, wasteByDay) {
3404
+ if (spendByDay.length <= 1) {
3405
+ return [];
3406
+ }
3407
+ const wasteByDate = new Map(wasteByDay.map((row) => [row.date, row.wasteUsd]));
3408
+ return [
3409
+ "## Daily trend",
3410
+ ...spendByDay.map((row) => {
3411
+ const wasteUsd = wasteByDate.get(row.date) ?? 0;
3412
+ return `- ${row.date}: ${formatUsd(row.spendUsd)} spend, ${formatUsd(wasteUsd)} waste`;
3413
+ })
3414
+ ];
3415
+ }
1766
3416
  function renderDoctorReport(report, options) {
1767
3417
  const commandPrefix = options?.commandPrefix ?? "xerg";
1768
- const nextSteps = report.canAudit ? [] : [
3418
+ const status = report.mode === "resolved" && report.runtime ? `${report.runtime === "hermes" ? "Hermes" : "OpenClaw"} sources detected.` : report.mode === "ambiguous" ? "Multiple supported local runtimes detected." : "No supported local sources detected.";
3419
+ const nextSteps = report.canAudit ? [] : report.mode === "ambiguous" ? [
3420
+ "",
3421
+ "## Next steps",
3422
+ `- Inspect OpenClaw only: ${commandPrefix} doctor --runtime openclaw`,
3423
+ `- Inspect Hermes only: ${commandPrefix} doctor --runtime hermes`,
3424
+ `- Try explicit local paths: ${commandPrefix} doctor --runtime hermes --log-file /path/to/log --sessions-dir /path/to/sessions`
3425
+ ] : [
1769
3426
  "",
1770
3427
  "## Next steps",
1771
- `- Try explicit local paths: ${commandPrefix} doctor --log-file /path/to/openclaw.log --sessions-dir /path/to/sessions`,
1772
- `- Inspect an SSH host: ${commandPrefix} doctor --remote user@host`,
1773
- `- Inspect a Railway service: ${commandPrefix} doctor --railway`,
3428
+ `- Inspect OpenClaw locally: ${commandPrefix} doctor --runtime openclaw`,
3429
+ `- Inspect Hermes locally: ${commandPrefix} doctor --runtime hermes`,
3430
+ `- Try explicit local paths: ${commandPrefix} doctor --runtime hermes --log-file /path/to/log --sessions-dir /path/to/sessions`,
3431
+ `- Inspect an SSH host for OpenClaw: ${commandPrefix} doctor --remote user@host`,
3432
+ `- Inspect a Railway service for OpenClaw: ${commandPrefix} doctor --railway`,
1774
3433
  "- Remote audits still analyze locally after Xerg pulls the source files to your machine."
1775
3434
  ];
1776
3435
  const sections = [
1777
3436
  "# Xerg doctor",
1778
3437
  "",
1779
- report.canAudit ? "OpenClaw sources detected." : "No OpenClaw sources detected.",
3438
+ status,
1780
3439
  "",
1781
3440
  "## Defaults",
1782
- `- gateway logs: ${report.defaults.gatewayPattern}`,
1783
- `- session files: ${report.defaults.sessionsPattern}`,
3441
+ ...report.defaults.flatMap((defaults) => [
3442
+ `- ${defaults.runtime === "hermes" ? "Hermes" : "OpenClaw"} gateway logs: ${defaults.gatewayPattern}`,
3443
+ `- ${defaults.runtime === "hermes" ? "Hermes" : "OpenClaw"} session files: ${defaults.sessionsPattern}`
3444
+ ]),
1784
3445
  "",
1785
3446
  "## Sources",
1786
- ...report.sources.length > 0 ? report.sources.map((source) => `- [${source.kind}] ${source.path}`) : ["- none"],
3447
+ ...report.sources.length > 0 ? report.sources.map((source) => `- [${source.runtime}/${source.kind}] ${source.path}`) : ["- none"],
1787
3448
  "",
1788
3449
  "## Notes",
1789
3450
  ...report.notes.map((note) => `- ${note}`),
@@ -1791,7 +3452,177 @@ function renderDoctorReport(report, options) {
1791
3452
  ];
1792
3453
  return sections.join("\n");
1793
3454
  }
3455
+ function renderCursorDoctorReport(report) {
3456
+ const status = report.canAudit ? "Cursor usage CSV detected." : "Cursor usage CSV is not ready.";
3457
+ return [
3458
+ "# Xerg doctor [cursor csv]",
3459
+ "",
3460
+ status,
3461
+ "",
3462
+ `File: ${report.filePath || "(not provided)"}`,
3463
+ `Rows: ${formatCount(report.rowCount)}`,
3464
+ `Date range: ${report.dateRange ? `${report.dateRange.start} -> ${report.dateRange.end}` : "unavailable"}`,
3465
+ "",
3466
+ "## Pricing coverage",
3467
+ `- Priced rows: ${formatCount(report.pricingCoverage.pricedCallCount)}`,
3468
+ `- Unpriced rows: ${formatCount(report.pricingCoverage.unpricedCallCount)}`,
3469
+ `- Priced tokens: ${formatCount(report.pricingCoverage.pricedTokenCount)}`,
3470
+ `- Unpriced tokens: ${formatCount(report.pricingCoverage.unpricedTokenCount)}`,
3471
+ ...report.pricingCoverage.topUnpricedModels.length > 0 ? report.pricingCoverage.topUnpricedModels.map(
3472
+ (model) => `- Unpriced model: ${model.key} (${formatCount(model.totalTokens)} tokens across ${formatCount(model.callCount)} row${model.callCount === 1 ? "" : "s"})`
3473
+ ) : ["- Unpriced model: none"],
3474
+ "",
3475
+ "## Notes",
3476
+ ...report.notes.map((note) => `- ${note}`)
3477
+ ].join("\n");
3478
+ }
3479
+ function renderCursorModeRows(rows) {
3480
+ if (rows.length === 0) {
3481
+ return ["- none"];
3482
+ }
3483
+ return rows.map((row) => {
3484
+ return `- ${row.key}: ${formatCount(row.callCount)} row${row.callCount === 1 ? "" : "s"}, ${formatCount(row.totalTokens)} tokens, ${formatUsd(row.estimatedSpendUsd)} spend`;
3485
+ });
3486
+ }
3487
+ function renderCursorModelRows(rows) {
3488
+ if (rows.length === 0) {
3489
+ return ["- none"];
3490
+ }
3491
+ return rows.slice(0, 8).map((row) => {
3492
+ const coverage = row.unpricedCallCount === 0 ? `${formatUsd(row.estimatedSpendUsd)} spend` : row.pricedCallCount === 0 ? "unpriced" : `${formatUsd(row.estimatedSpendUsd)} spend, ${formatCount(row.unpricedCallCount)} unpriced row${row.unpricedCallCount === 1 ? "" : "s"}`;
3493
+ return `- ${row.key}: ${formatCount(row.callCount)} row${row.callCount === 1 ? "" : "s"}, ${formatCount(row.totalTokens)} tokens, ${coverage}`;
3494
+ });
3495
+ }
3496
+ function renderCursorPricingCoverage(summary) {
3497
+ const coverage = summary.pricingCoverage;
3498
+ if (!coverage) {
3499
+ return ["- Pricing coverage unavailable"];
3500
+ }
3501
+ return [
3502
+ `- Priced rows: ${formatCount(coverage.pricedCallCount)}`,
3503
+ `- Unpriced rows: ${formatCount(coverage.unpricedCallCount)}`,
3504
+ `- Priced tokens: ${formatCount(coverage.pricedTokenCount)}`,
3505
+ `- Unpriced tokens: ${formatCount(coverage.unpricedTokenCount)}`,
3506
+ ...coverage.topUnpricedModels.length > 0 ? coverage.topUnpricedModels.map(
3507
+ (model) => `- Unpriced model: ${model.key} (${formatCount(model.totalTokens)} tokens across ${formatCount(model.callCount)} row${model.callCount === 1 ? "" : "s"})`
3508
+ ) : ["- Unpriced model: none"]
3509
+ ];
3510
+ }
3511
+ function renderCursorCompareBlock(summary) {
3512
+ if (!summary.comparison) {
3513
+ return [];
3514
+ }
3515
+ const comparison = summary.comparison;
3516
+ const modeSwing = comparison.workflowDeltas[0];
3517
+ const modelSwing = comparison.modelDeltas[0];
3518
+ return [
3519
+ "## Before / after",
3520
+ `Compared against ${comparison.baselineGeneratedAt}`,
3521
+ `- Total spend: ${formatUsd(comparison.baselineTotalSpendUsd)} -> ${formatUsd(summary.totalSpendUsd)} (${formatUsdDelta(comparison.deltaTotalSpendUsd)})`,
3522
+ `- Rows analyzed: ${formatCount(comparison.baselineRunCount)} -> ${formatCount(summary.runCount)} (${comparison.deltaRunCount > 0 ? "+" : ""}${comparison.deltaRunCount})`,
3523
+ `- Usage rows with pricing: ${formatCount(summary.pricingCoverage?.pricedCallCount ?? 0)}`,
3524
+ modeSwing ? `- Mode swing to inspect: ${describeSpendDelta(modeSwing)}` : "- Mode swing to inspect: none",
3525
+ modelSwing ? `- Model swing to inspect: ${describeSpendDelta(modelSwing)}` : "- Model swing to inspect: none"
3526
+ ];
3527
+ }
3528
+ function renderCursorTerminalSummary(summary) {
3529
+ const usage = summary.cursorUsage;
3530
+ return [
3531
+ "# Xerg audit [cursor csv]",
3532
+ "",
3533
+ `Total spend: ${formatUsd(summary.totalSpendUsd)}`,
3534
+ `Observed spend: ${formatUsd(summary.observedSpendUsd)}`,
3535
+ `Estimated spend: ${formatUsd(summary.estimatedSpendUsd)}`,
3536
+ `Rows analyzed: ${formatCount(summary.runCount)}`,
3537
+ `Usage rows with pricing: ${formatCount(summary.pricingCoverage?.pricedCallCount ?? 0)} / ${formatCount(summary.runCount)}`,
3538
+ `Total tokens: ${formatCount(usage?.totalTokens ?? 0)}`,
3539
+ `Structural waste identified: ${formatUsd(summary.wasteSpendUsd)} (${formatPercent(summary.structuralWasteRate)})`,
3540
+ `Potential impact surfaced: ${formatUsd(summary.opportunitySpendUsd)}`,
3541
+ "",
3542
+ "## Token mix",
3543
+ `- Input tokens: ${formatCount(usage?.totalInputTokens ?? 0)}`,
3544
+ `- Output tokens: ${formatCount(usage?.totalOutputTokens ?? 0)}`,
3545
+ `- Cache read tokens: ${formatCount(usage?.totalCacheReadTokens ?? 0)}`,
3546
+ `- Input (cache write): ${formatCount(usage?.totalInputWithCacheWriteTokens ?? 0)}`,
3547
+ `- Input (no cache write): ${formatCount(usage?.totalInputWithoutCacheWriteTokens ?? 0)}`,
3548
+ "",
3549
+ "## Max mode usage",
3550
+ ...renderCursorModeRows(usage?.modes ?? []),
3551
+ "",
3552
+ "## Model mix",
3553
+ ...renderCursorModelRows(usage?.models ?? []),
3554
+ "",
3555
+ "## Pricing coverage",
3556
+ ...renderCursorPricingCoverage(summary),
3557
+ "",
3558
+ ...renderDailyTrendRows(summary.spendByDay, summary.wasteByDay),
3559
+ ...summary.spendByDay.length > 1 ? [""] : [],
3560
+ "## Waste taxonomy",
3561
+ "Structural waste",
3562
+ ...renderTaxonomyRows(summary.wasteByKind, "No confirmed waste buckets detected."),
3563
+ "Savings opportunities",
3564
+ ...renderTaxonomyRows(
3565
+ summary.opportunityByKind,
3566
+ "No opportunity buckets detected.",
3567
+ "(directional)"
3568
+ ),
3569
+ "",
3570
+ "## Findings",
3571
+ ...renderFindingList(summary.findings, "none detected"),
3572
+ "",
3573
+ ...renderCursorCompareBlock(summary),
3574
+ ...summary.comparison ? [""] : [],
3575
+ "## Notes",
3576
+ ...summary.notes.map((note) => `- ${note}`)
3577
+ ].join("\n");
3578
+ }
3579
+ function renderCursorMarkdownSummary(summary) {
3580
+ const usage = summary.cursorUsage;
3581
+ return [
3582
+ "# Xerg Cursor CSV Audit",
3583
+ "",
3584
+ `- Generated: ${summary.generatedAt}`,
3585
+ `- Total spend: ${formatUsd(summary.totalSpendUsd)}`,
3586
+ `- Observed spend: ${formatUsd(summary.observedSpendUsd)}`,
3587
+ `- Estimated spend: ${formatUsd(summary.estimatedSpendUsd)}`,
3588
+ `- Structural waste identified: ${formatUsd(summary.wasteSpendUsd)} (${formatPercent(summary.structuralWasteRate)})`,
3589
+ `- Potential impact surfaced: ${formatUsd(summary.opportunitySpendUsd)}`,
3590
+ `- Rows analyzed: ${formatCount(summary.runCount)}`,
3591
+ `- Usage rows with pricing: ${formatCount(summary.pricingCoverage?.pricedCallCount ?? 0)} / ${formatCount(summary.runCount)}`,
3592
+ `- Total tokens: ${formatCount(usage?.totalTokens ?? 0)}`,
3593
+ "",
3594
+ "## Token mix",
3595
+ `- Input tokens: ${formatCount(usage?.totalInputTokens ?? 0)}`,
3596
+ `- Output tokens: ${formatCount(usage?.totalOutputTokens ?? 0)}`,
3597
+ `- Cache read tokens: ${formatCount(usage?.totalCacheReadTokens ?? 0)}`,
3598
+ "",
3599
+ "## Max mode usage",
3600
+ ...renderCursorModeRows(usage?.modes ?? []),
3601
+ "",
3602
+ "## Model mix",
3603
+ ...renderCursorModelRows(usage?.models ?? []),
3604
+ "",
3605
+ "## Pricing coverage",
3606
+ ...renderCursorPricingCoverage(summary),
3607
+ "",
3608
+ ...renderDailyTrendRows(summary.spendByDay, summary.wasteByDay),
3609
+ ...summary.spendByDay.length > 1 ? [""] : [],
3610
+ ...renderTaxonomyBlock(summary),
3611
+ "",
3612
+ "## Findings",
3613
+ ...summary.findings.slice(0, 10).map((finding) => {
3614
+ return `- **${finding.title}** (${finding.classification}, ${finding.confidence}) \u2014 ${finding.summary} Estimated impact: ${formatUsd(finding.costImpactUsd)}.`;
3615
+ }),
3616
+ ...summary.comparison ? ["", ...renderCursorCompareBlock(summary)] : [],
3617
+ "",
3618
+ "## Notes",
3619
+ ...summary.notes.map((note) => `- ${note}`)
3620
+ ].join("\n");
3621
+ }
1794
3622
  function renderTerminalSummary(summary) {
3623
+ if (isCursorUsageSummary(summary)) {
3624
+ return renderCursorTerminalSummary(summary);
3625
+ }
1795
3626
  const wasteFindings = summary.findings.filter((finding) => finding.classification === "waste");
1796
3627
  const opportunityFindings = summary.findings.filter(
1797
3628
  (finding) => finding.classification === "opportunity"
@@ -1817,6 +3648,8 @@ function renderTerminalSummary(summary) {
1817
3648
  "## Top models",
1818
3649
  ...topRows(summary.spendByModel),
1819
3650
  "",
3651
+ ...renderDailyTrendRows(summary.spendByDay, summary.wasteByDay),
3652
+ ...summary.spendByDay.length > 1 ? [""] : [],
1820
3653
  "## High-confidence waste",
1821
3654
  ...renderFindingList(wasteFindings, "none detected"),
1822
3655
  "",
@@ -1838,6 +3671,9 @@ function renderTerminalSummary(summary) {
1838
3671
  ].join("\n");
1839
3672
  }
1840
3673
  function renderMarkdownSummary(summary) {
3674
+ if (isCursorUsageSummary(summary)) {
3675
+ return renderCursorMarkdownSummary(summary);
3676
+ }
1841
3677
  const lines = [
1842
3678
  "# Xerg Audit Report",
1843
3679
  "",
@@ -1855,6 +3691,9 @@ function renderMarkdownSummary(summary) {
1855
3691
  "## Top workflows",
1856
3692
  ...topRows(summary.spendByWorkflow),
1857
3693
  "",
3694
+ ...renderDailyTrendRows(summary.spendByDay, summary.wasteByDay),
3695
+ ...summary.spendByDay.length > 1 ? [""] : [],
3696
+ "",
1858
3697
  "## Findings",
1859
3698
  ...summary.findings.slice(0, 10).map((finding) => {
1860
3699
  return `- **${finding.title}** (${finding.classification}, ${finding.confidence}) \u2014 ${finding.summary} Estimated impact: ${formatUsd(finding.costImpactUsd)}.`;
@@ -1924,6 +3763,8 @@ function toWirePayload(summary, meta) {
1924
3763
  opportunityByKind: summary.opportunityByKind,
1925
3764
  spendByWorkflow: summary.spendByWorkflow,
1926
3765
  spendByModel: summary.spendByModel,
3766
+ spendByDay: summary.spendByDay,
3767
+ wasteByDay: summary.wasteByDay,
1927
3768
  findings: summary.findings.map(toWireFinding),
1928
3769
  notes: summary.notes,
1929
3770
  comparison: summary.comparison ? toWireComparison(summary.comparison) : null
@@ -1996,17 +3837,17 @@ async function pushAudit(payload, config) {
1996
3837
  }
1997
3838
 
1998
3839
  // src/push/config.ts
1999
- import { readFileSync as readFileSync3 } from "fs";
2000
- import { homedir as homedir3 } from "os";
2001
- import { join as join4 } from "path";
3840
+ import { readFileSync as readFileSync5 } from "fs";
3841
+ import { homedir as homedir4 } from "os";
3842
+ import { join as join5 } from "path";
2002
3843
 
2003
3844
  // src/auth/credentials.ts
2004
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
2005
- import { homedir as homedir2 } from "os";
2006
- import { dirname as dirname2, join as join3 } from "path";
3845
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync } from "fs";
3846
+ import { homedir as homedir3 } from "os";
3847
+ import { dirname as dirname2, join as join4 } from "path";
2007
3848
  function getCredentialsPath() {
2008
- const xdgConfig = process.env.XDG_CONFIG_HOME || join3(homedir2(), ".config");
2009
- return join3(xdgConfig, "xerg", "credentials.json");
3849
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join4(homedir3(), ".config");
3850
+ return join4(xdgConfig, "xerg", "credentials.json");
2010
3851
  }
2011
3852
  function storeCredentials(token) {
2012
3853
  const credPath = getCredentialsPath();
@@ -2019,7 +3860,7 @@ function loadStoredCredentials() {
2019
3860
  const credPath = getCredentialsPath();
2020
3861
  try {
2021
3862
  if (!existsSync(credPath)) return null;
2022
- const raw = readFileSync2(credPath, "utf8");
3863
+ const raw = readFileSync4(credPath, "utf8");
2023
3864
  const parsed = JSON.parse(raw);
2024
3865
  return parsed.token || null;
2025
3866
  } catch {
@@ -2039,7 +3880,7 @@ function clearCredentials() {
2039
3880
 
2040
3881
  // src/push/config.ts
2041
3882
  var DEFAULT_API_URL = "https://api.xerg.ai";
2042
- var CONFIG_PATH = join4(homedir3(), ".xerg", "config.json");
3883
+ var CONFIG_PATH = join5(homedir4(), ".xerg", "config.json");
2043
3884
  function loadPushConfig() {
2044
3885
  const envKey = process.env.XERG_API_KEY;
2045
3886
  const envUrl = process.env.XERG_API_URL;
@@ -2050,7 +3891,7 @@ function loadPushConfig() {
2050
3891
  };
2051
3892
  }
2052
3893
  try {
2053
- const raw = readFileSync3(CONFIG_PATH, "utf8");
3894
+ const raw = readFileSync5(CONFIG_PATH, "utf8");
2054
3895
  const parsed = JSON.parse(raw);
2055
3896
  if (parsed.apiKey) {
2056
3897
  return {
@@ -2073,12 +3914,15 @@ Get your key at https://xerg.ai/dashboard/settings`
2073
3914
  );
2074
3915
  }
2075
3916
 
3917
+ // src/source-meta.ts
3918
+ import { hostname } from "os";
3919
+
2076
3920
  // src/transport/ssh.ts
2077
3921
  import { execSync, spawnSync } from "child_process";
2078
3922
  import { createHash as createHash2 } from "crypto";
2079
3923
  import { mkdirSync as mkdirSync4, rmSync as rmSync2 } from "fs";
2080
- import { homedir as homedir4, tmpdir } from "os";
2081
- import { join as join5 } from "path";
3924
+ import { homedir as homedir5, tmpdir } from "os";
3925
+ import { join as join6 } from "path";
2082
3926
  var DEFAULT_GATEWAY_DIR = "/tmp/openclaw";
2083
3927
  var DEFAULT_SESSIONS_DIR = "~/.openclaw/agents";
2084
3928
  function hashString(input) {
@@ -2087,7 +3931,7 @@ function hashString(input) {
2087
3931
  function sshArgs(source) {
2088
3932
  const args = [];
2089
3933
  if (source.identityFile) {
2090
- const resolved = source.identityFile.replace(/^~/, homedir4());
3934
+ const resolved = source.identityFile.replace(/^~/, homedir5());
2091
3935
  args.push("-i", resolved);
2092
3936
  }
2093
3937
  args.push("-o", "BatchMode=yes", "-o", "ConnectTimeout=10");
@@ -2096,7 +3940,7 @@ function sshArgs(source) {
2096
3940
  function rsyncSshCommand(source) {
2097
3941
  const parts = ["ssh"];
2098
3942
  if (source.identityFile) {
2099
- const resolved = source.identityFile.replace(/^~/, homedir4());
3943
+ const resolved = source.identityFile.replace(/^~/, homedir5());
2100
3944
  parts.push(`-i "${resolved}"`);
2101
3945
  }
2102
3946
  parts.push("-o BatchMode=yes", "-o ConnectTimeout=10");
@@ -2184,7 +4028,7 @@ function rsyncPull(opts) {
2184
4028
  if (status !== 0 || !stdout) return false;
2185
4029
  const files = stdout.split("\n").filter(Boolean);
2186
4030
  if (files.length === 0) return false;
2187
- const tmpFile = join5(tmpdir(), `xerg-filelist-${hashString(opts.remotePath)}`);
4031
+ const tmpFile = join6(tmpdir(), `xerg-filelist-${hashString(opts.remotePath)}`);
2188
4032
  const relativePaths = files.map(
2189
4033
  (f) => f.startsWith(opts.remotePath) ? f.slice(opts.remotePath.length).replace(/^\//, "") : f
2190
4034
  );
@@ -2233,12 +4077,12 @@ function pullDirectory(opts) {
2233
4077
  }
2234
4078
  function resolveLocalPath(source, keepFiles) {
2235
4079
  if (keepFiles) {
2236
- const cacheDir = join5(homedir4(), ".xerg", "remote-cache", source.name);
4080
+ const cacheDir = join6(homedir5(), ".xerg", "remote-cache", source.name);
2237
4081
  mkdirSync4(cacheDir, { recursive: true });
2238
4082
  return cacheDir;
2239
4083
  }
2240
4084
  const hash = hashString(`${source.host}:${Date.now()}`);
2241
- const tmpPath = join5(tmpdir(), `xerg-remote-${hash}`);
4085
+ const tmpPath = join6(tmpdir(), `xerg-remote-${hash}`);
2242
4086
  mkdirSync4(tmpPath, { recursive: true });
2243
4087
  return tmpPath;
2244
4088
  }
@@ -2290,8 +4134,8 @@ async function pullRemoteFiles(opts) {
2290
4134
  useRsync ? "Local rsync detected. Xerg will prefer rsync and fall back to tar over SSH if needed." : "Local rsync not detected. Xerg will pull files with tar over SSH."
2291
4135
  );
2292
4136
  const localBase = resolveLocalPath(source, keepFiles);
2293
- const gatewayDir = join5(localBase, "gateway");
2294
- const sessionsDir = join5(localBase, "sessions");
4137
+ const gatewayDir = join6(localBase, "gateway");
4138
+ const sessionsDir = join6(localBase, "sessions");
2295
4139
  const remoteLogPath = source.logFile ?? DEFAULT_GATEWAY_DIR;
2296
4140
  const remoteSessionsPath = source.sessionsDir ?? DEFAULT_SESSIONS_DIR;
2297
4141
  const { stdout: expandedSessions } = sshExec(source, `eval echo ${remoteSessionsPath}`);
@@ -2483,8 +4327,8 @@ function formatBytes(bytes) {
2483
4327
  import { execSync as execSync2, spawnSync as spawnSync2 } from "child_process";
2484
4328
  import { createHash as createHash3 } from "crypto";
2485
4329
  import { mkdirSync as mkdirSync5, rmSync as rmSync3 } from "fs";
2486
- import { homedir as homedir5, tmpdir as tmpdir2 } from "os";
2487
- import { join as join6 } from "path";
4330
+ import { homedir as homedir6, tmpdir as tmpdir2 } from "os";
4331
+ import { join as join7 } from "path";
2488
4332
  var DEFAULT_GATEWAY_DIR2 = "/tmp/openclaw";
2489
4333
  var DEFAULT_SESSIONS_DIR2 = "~/.openclaw/agents";
2490
4334
  var ALTERNATE_SESSION_PATHS = ["/data/.clawdbot/agents/main/sessions"];
@@ -2557,13 +4401,13 @@ function tarRailwayPull(opts) {
2557
4401
  }
2558
4402
  function resolveLocalPath2(source, keepFiles) {
2559
4403
  if (keepFiles) {
2560
- const cacheDir = join6(homedir5(), ".xerg", "remote-cache", source.name);
4404
+ const cacheDir = join7(homedir6(), ".xerg", "remote-cache", source.name);
2561
4405
  mkdirSync5(cacheDir, { recursive: true });
2562
4406
  return cacheDir;
2563
4407
  }
2564
4408
  const identity = source.railway ? `railway:${source.railway.projectId}:${Date.now()}` : `${source.name}:${Date.now()}`;
2565
4409
  const hash = hashString2(identity);
2566
- const tmpPath = join6(tmpdir2(), `xerg-remote-${hash}`);
4410
+ const tmpPath = join7(tmpdir2(), `xerg-remote-${hash}`);
2567
4411
  mkdirSync5(tmpPath, { recursive: true });
2568
4412
  return tmpPath;
2569
4413
  }
@@ -2634,8 +4478,8 @@ async function pullRemoteFilesRailway(opts) {
2634
4478
  }
2635
4479
  onProgress?.("Railway service reachable.");
2636
4480
  const localBase = resolveLocalPath2(source, keepFiles);
2637
- const gatewayDir = join6(localBase, "gateway");
2638
- const sessionsDir = join6(localBase, "sessions");
4481
+ const gatewayDir = join7(localBase, "gateway");
4482
+ const sessionsDir = join7(localBase, "sessions");
2639
4483
  const remoteLogPath = source.logFile ?? DEFAULT_GATEWAY_DIR2;
2640
4484
  onProgress?.("Checking Railway default paths for gateway logs and sessions...");
2641
4485
  const logCheck = checkRemotePath(remoteLogPath, target);
@@ -2864,13 +4708,13 @@ function formatBytes2(bytes) {
2864
4708
  }
2865
4709
 
2866
4710
  // src/transport/config.ts
2867
- import { readFileSync as readFileSync4 } from "fs";
2868
- import { resolve as resolve2 } from "path";
4711
+ import { readFileSync as readFileSync6 } from "fs";
4712
+ import { resolve as resolve3 } from "path";
2869
4713
  function loadRemoteConfig(configPath) {
2870
- const resolved = resolve2(configPath);
4714
+ const resolved = resolve3(configPath);
2871
4715
  let raw;
2872
4716
  try {
2873
- raw = readFileSync4(resolved, "utf8");
4717
+ raw = readFileSync6(resolved, "utf8");
2874
4718
  } catch {
2875
4719
  throw new Error(`Cannot read remote config at ${resolved}`);
2876
4720
  }
@@ -2941,11 +4785,98 @@ function validateRailwayEntry(entry) {
2941
4785
  };
2942
4786
  }
2943
4787
 
4788
+ // src/source-meta.ts
4789
+ var RAILWAY_SOURCE_ID = "OpenClaw - Railway";
4790
+ function buildLocalPushSourceMeta(kind, localHost = hostname()) {
4791
+ const productName = kind === "cursor" ? "Cursor" : kind === "hermes" ? "Hermes" : "OpenClaw";
4792
+ return {
4793
+ environment: "local",
4794
+ sourceId: `${productName} - ${localHost}`,
4795
+ sourceHost: localHost
4796
+ };
4797
+ }
4798
+ function buildRemotePushSourceMeta(source) {
4799
+ if (source.transport === "railway") {
4800
+ return {
4801
+ environment: "railway",
4802
+ sourceId: isGeneratedRailwayName(source.name) ? RAILWAY_SOURCE_ID : `OpenClaw - ${source.name}`,
4803
+ sourceHost: isGeneratedRailwayName(source.host) ? "Railway" : source.host
4804
+ };
4805
+ }
4806
+ return {
4807
+ environment: "remote",
4808
+ sourceId: `OpenClaw - ${source.name}`,
4809
+ sourceHost: resolveRemoteHost(source.host)
4810
+ };
4811
+ }
4812
+ function buildCachedPushSourceMeta(summary, localHost = hostname()) {
4813
+ if (summary.runtime === "cursor") {
4814
+ return buildLocalPushSourceMeta("cursor", localHost);
4815
+ }
4816
+ const sourceFiles = summary.sourceFiles ?? [];
4817
+ const comparisonKey = summary.comparisonKey ?? "";
4818
+ if (sourceFiles.some((sourceFile) => sourceFile.kind === "cursor-usage-csv")) {
4819
+ return buildLocalPushSourceMeta("cursor", localHost);
4820
+ }
4821
+ if (isRailwayComparisonKey(comparisonKey)) {
4822
+ return {
4823
+ environment: "railway",
4824
+ sourceId: RAILWAY_SOURCE_ID,
4825
+ sourceHost: "Railway"
4826
+ };
4827
+ }
4828
+ const remoteHost = parseRemoteHostFromComparisonKey(comparisonKey);
4829
+ if (remoteHost) {
4830
+ return {
4831
+ environment: "remote",
4832
+ sourceId: `OpenClaw - ${remoteHost}`,
4833
+ sourceHost: remoteHost
4834
+ };
4835
+ }
4836
+ if (summary.runtime === "hermes") {
4837
+ return buildLocalPushSourceMeta("hermes", localHost);
4838
+ }
4839
+ return buildLocalPushSourceMeta("openclaw", localHost);
4840
+ }
4841
+ function isGeneratedRailwayName(name) {
4842
+ return name === "railway-linked" || /^railway-[a-z0-9]{8}$/i.test(name);
4843
+ }
4844
+ function isRailwayComparisonKey(comparisonKey) {
4845
+ return comparisonKey.startsWith("railway:") || comparisonKey.startsWith("railway-linked:");
4846
+ }
4847
+ function parseRemoteHostFromComparisonKey(comparisonKey) {
4848
+ const parts = comparisonKey.split(":");
4849
+ if (parts.length < 3) {
4850
+ return null;
4851
+ }
4852
+ const target = parts.slice(0, -2).join(":");
4853
+ if (!target) {
4854
+ return null;
4855
+ }
4856
+ return resolveRemoteHost(target);
4857
+ }
4858
+ function resolveRemoteHost(target) {
4859
+ const parsed = parseRemoteTarget(target);
4860
+ return parsed.host || target;
4861
+ }
4862
+
4863
+ // src/version.ts
4864
+ import { readFileSync as readFileSync7 } from "fs";
4865
+ function getCliVersion() {
4866
+ try {
4867
+ const packageJsonPath = new URL("../package.json", import.meta.url);
4868
+ const packageJson = JSON.parse(readFileSync7(packageJsonPath, "utf8"));
4869
+ return packageJson.version ?? "0.0.0";
4870
+ } catch {
4871
+ return "0.0.0";
4872
+ }
4873
+ }
4874
+
2944
4875
  // src/commands/audit.ts
2945
- var NO_DATA_PATTERN = /no openclaw sources were detected/i;
4876
+ var NO_DATA_PATTERN = /no (openclaw|hermes|supported local runtime) sources? were detected/i;
2946
4877
  async function auditOrNoData(...args) {
2947
4878
  try {
2948
- return await auditOpenClaw(...args);
4879
+ return await auditAgentRuntime(...args);
2949
4880
  } catch (err) {
2950
4881
  if (err instanceof Error && NO_DATA_PATTERN.test(err.message)) {
2951
4882
  throw new NoDataError(err.message);
@@ -2958,6 +4889,9 @@ async function runAuditCommand(options) {
2958
4889
  if (options.dryRun && !options.push) {
2959
4890
  throw new Error("--dry-run requires --push.");
2960
4891
  }
4892
+ validateRuntimeOption(options.runtime);
4893
+ validateCursorUsageCsvOptions(options);
4894
+ validateHermesLocalOnly(options);
2961
4895
  const remoteFlags = [options.remote, options.remoteConfig, options.railway].filter(
2962
4896
  Boolean
2963
4897
  ).length;
@@ -3002,7 +4936,29 @@ function buildRailwayTarget(options) {
3002
4936
  return void 0;
3003
4937
  }
3004
4938
  async function runLocalAudit(options, logger) {
3005
- logger.verbose("Running a local audit.");
4939
+ if (options.cursorUsageCsv) {
4940
+ logger.verbose("Running a local Cursor usage CSV audit.");
4941
+ logger.verbose(`Using Cursor usage CSV: ${options.cursorUsageCsv}`);
4942
+ const summary2 = await auditCursorUsageCsv({
4943
+ cursorUsageCsv: options.cursorUsageCsv,
4944
+ since: options.since,
4945
+ compare: options.compare,
4946
+ dbPath: options.db,
4947
+ noDb: options.noDb,
4948
+ commandPrefix: options.commandPrefix,
4949
+ onProgress: logger.verbose
4950
+ });
4951
+ renderOutput(summary2, options);
4952
+ if (options.push) {
4953
+ const meta = buildMeta(buildLocalPushSourceMeta("cursor"));
4954
+ await handlePush(summary2, meta, options);
4955
+ }
4956
+ checkThresholds(summary2, options);
4957
+ return;
4958
+ }
4959
+ logger.verbose(
4960
+ options.runtime ? `Running a local ${options.runtime === "hermes" ? "Hermes" : "OpenClaw"} audit.` : "Running a local runtime audit with auto-detection."
4961
+ );
3006
4962
  if (options.logFile) {
3007
4963
  logger.verbose(`Using explicit local log file: ${options.logFile}`);
3008
4964
  }
@@ -3010,6 +4966,7 @@ async function runLocalAudit(options, logger) {
3010
4966
  logger.verbose(`Using explicit local sessions directory: ${options.sessionsDir}`);
3011
4967
  }
3012
4968
  const summary = await auditOrNoData({
4969
+ runtime: options.runtime ?? "auto",
3013
4970
  logFile: options.logFile,
3014
4971
  sessionsDir: options.sessionsDir,
3015
4972
  since: options.since,
@@ -3021,10 +4978,63 @@ async function runLocalAudit(options, logger) {
3021
4978
  });
3022
4979
  renderOutput(summary, options);
3023
4980
  if (options.push) {
3024
- const meta = buildMeta({ environment: "local", sourceId: hostname(), sourceHost: hostname() });
4981
+ const meta = buildMeta(buildLocalPushSourceMeta(summary.runtime));
3025
4982
  await handlePush(summary, meta, options);
3026
4983
  }
3027
- checkThresholds(summary, options);
4984
+ checkThresholds(summary, options);
4985
+ }
4986
+ function validateRuntimeOption(runtime) {
4987
+ if (!runtime) {
4988
+ return;
4989
+ }
4990
+ if (runtime !== "openclaw" && runtime !== "hermes") {
4991
+ throw new Error(
4992
+ `Unsupported runtime "${runtime}". Use --runtime openclaw or --runtime hermes.`
4993
+ );
4994
+ }
4995
+ }
4996
+ function validateCursorUsageCsvOptions(options) {
4997
+ if (!options.cursorUsageCsv) {
4998
+ return;
4999
+ }
5000
+ const conflicts = [
5001
+ options.runtime ? "--runtime" : null,
5002
+ options.logFile ? "--log-file" : null,
5003
+ options.sessionsDir ? "--sessions-dir" : null,
5004
+ options.remote ? "--remote" : null,
5005
+ options.remoteLogFile ? "--remote-log-file" : null,
5006
+ options.remoteSessionsDir ? "--remote-sessions-dir" : null,
5007
+ options.remoteConfig ? "--remote-config" : null,
5008
+ options.keepRemoteFiles ? "--keep-remote-files" : null,
5009
+ options.railway ? "--railway" : null,
5010
+ options.railwayProject ? "--project" : null,
5011
+ options.railwayEnvironment ? "--environment" : null,
5012
+ options.railwayService ? "--service" : null
5013
+ ].filter((flag) => flag !== null);
5014
+ if (conflicts.length > 0) {
5015
+ throw new Error(`The --cursor-usage-csv flag cannot be combined with ${conflicts.join(", ")}.`);
5016
+ }
5017
+ }
5018
+ function validateHermesLocalOnly(options) {
5019
+ if (options.runtime !== "hermes") {
5020
+ return;
5021
+ }
5022
+ const conflicts = [
5023
+ options.remote ? "--remote" : null,
5024
+ options.remoteLogFile ? "--remote-log-file" : null,
5025
+ options.remoteSessionsDir ? "--remote-sessions-dir" : null,
5026
+ options.remoteConfig ? "--remote-config" : null,
5027
+ options.keepRemoteFiles ? "--keep-remote-files" : null,
5028
+ options.railway ? "--railway" : null,
5029
+ options.railwayProject ? "--project" : null,
5030
+ options.railwayEnvironment ? "--environment" : null,
5031
+ options.railwayService ? "--service" : null
5032
+ ].filter((flag) => flag !== null);
5033
+ if (conflicts.length > 0) {
5034
+ throw new Error(
5035
+ `Hermes remote transport is not supported yet. Remove ${conflicts.join(", ")} or switch to --runtime openclaw.`
5036
+ );
5037
+ }
3028
5038
  }
3029
5039
  function getComparisonKey(source) {
3030
5040
  if (source.transport === "railway") {
@@ -3044,9 +5054,6 @@ function describeSource(source) {
3044
5054
  }
3045
5055
  return source.host;
3046
5056
  }
3047
- function sourceEnvironment(source) {
3048
- return source.transport === "railway" ? "railway" : "remote";
3049
- }
3050
5057
  async function runSingleRemoteAudit(source, options, logger) {
3051
5058
  logger.info(`Pulling files from ${describeSource(source)}...`);
3052
5059
  const pullResult = await pullFiles(
@@ -3059,6 +5066,7 @@ async function runSingleRemoteAudit(source, options, logger) {
3059
5066
  try {
3060
5067
  const comparisonKeyOverride = getComparisonKey(source);
3061
5068
  const summary = await auditOrNoData({
5069
+ runtime: "openclaw",
3062
5070
  logFile: pullResult.logFile,
3063
5071
  sessionsDir: pullResult.sessionsDir,
3064
5072
  since: options.since,
@@ -3071,11 +5079,7 @@ async function runSingleRemoteAudit(source, options, logger) {
3071
5079
  });
3072
5080
  renderOutput(summary, options);
3073
5081
  if (options.push) {
3074
- const meta = buildMeta({
3075
- environment: sourceEnvironment(source),
3076
- sourceId: source.name,
3077
- sourceHost: source.host
3078
- });
5082
+ const meta = buildMeta(buildRemotePushSourceMeta(source));
3079
5083
  await handlePush(summary, meta, options);
3080
5084
  }
3081
5085
  checkThresholds(summary, options);
@@ -3113,6 +5117,7 @@ ${errorMessages}`);
3113
5117
  for (const { source, pullResult } of results) {
3114
5118
  const comparisonKeyOverride = getComparisonKey(source);
3115
5119
  const summary = await auditOrNoData({
5120
+ runtime: "openclaw",
3116
5121
  logFile: pullResult.logFile,
3117
5122
  sessionsDir: pullResult.sessionsDir,
3118
5123
  since: options.since,
@@ -3163,11 +5168,7 @@ ${"\u2550".repeat(60)}
3163
5168
  }
3164
5169
  if (options.push) {
3165
5170
  for (const { source, summary } of summaries) {
3166
- const meta = buildMeta({
3167
- environment: sourceEnvironment(source),
3168
- sourceId: source.name,
3169
- sourceHost: source.host
3170
- });
5171
+ const meta = buildMeta(buildRemotePushSourceMeta(source));
3171
5172
  await handlePush(summary, meta, options);
3172
5173
  }
3173
5174
  }
@@ -3180,18 +5181,9 @@ ${"\u2550".repeat(60)}
3180
5181
  }
3181
5182
  }
3182
5183
  }
3183
- function readCliVersion() {
3184
- try {
3185
- const packageJsonPath = new URL("../../package.json", import.meta.url);
3186
- const pkg = JSON.parse(readFileSync5(packageJsonPath, "utf8"));
3187
- return pkg.version ?? "0.0.0";
3188
- } catch {
3189
- return "0.0.0";
3190
- }
3191
- }
3192
5184
  function buildMeta(input) {
3193
5185
  return {
3194
- cliVersion: readCliVersion(),
5186
+ cliVersion: getCliVersion(),
3195
5187
  sourceId: input.sourceId,
3196
5188
  sourceHost: input.sourceHost,
3197
5189
  environment: input.environment
@@ -3266,6 +5258,9 @@ function cleanupPullResult(pullResult, keepFiles) {
3266
5258
  // src/commands/doctor.ts
3267
5259
  async function runDoctorCommand(options) {
3268
5260
  const logger = createCliLogger({ verbose: options.verbose });
5261
+ validateRuntimeOption2(options.runtime);
5262
+ validateCursorUsageCsvOptions2(options);
5263
+ validateHermesLocalOnly2(options);
3269
5264
  if (options.railway) {
3270
5265
  logger.verbose("Inspecting Railway audit readiness.");
3271
5266
  const railwayTarget = buildRailwayTarget2(options);
@@ -3291,14 +5286,28 @@ async function runDoctorCommand(options) {
3291
5286
  `);
3292
5287
  return;
3293
5288
  }
3294
- logger.verbose("Inspecting local OpenClaw audit readiness.");
5289
+ if (options.cursorUsageCsv) {
5290
+ logger.verbose("Inspecting local Cursor usage CSV audit readiness.");
5291
+ logger.verbose(`Using Cursor usage CSV: ${options.cursorUsageCsv}`);
5292
+ const report2 = await doctorCursorUsageCsv({
5293
+ cursorUsageCsv: options.cursorUsageCsv,
5294
+ onProgress: logger.verbose
5295
+ });
5296
+ process.stdout.write(`${renderCursorDoctorReport(report2)}
5297
+ `);
5298
+ return;
5299
+ }
5300
+ logger.verbose(
5301
+ options.runtime ? `Inspecting local ${options.runtime === "hermes" ? "Hermes" : "OpenClaw"} audit readiness.` : "Inspecting local runtime audit readiness."
5302
+ );
3295
5303
  if (options.logFile) {
3296
5304
  logger.verbose(`Using explicit local log file: ${options.logFile}`);
3297
5305
  }
3298
5306
  if (options.sessionsDir) {
3299
5307
  logger.verbose(`Using explicit local sessions directory: ${options.sessionsDir}`);
3300
5308
  }
3301
- const report = await doctorOpenClaw({
5309
+ const report = await doctorAgentRuntime({
5310
+ runtime: options.runtime ?? "auto",
3302
5311
  logFile: options.logFile,
3303
5312
  sessionsDir: options.sessionsDir,
3304
5313
  onProgress: logger.verbose
@@ -3306,6 +5315,55 @@ async function runDoctorCommand(options) {
3306
5315
  process.stdout.write(`${renderDoctorReport(report, { commandPrefix: options.commandPrefix })}
3307
5316
  `);
3308
5317
  }
5318
+ function validateRuntimeOption2(runtime) {
5319
+ if (!runtime) {
5320
+ return;
5321
+ }
5322
+ if (runtime !== "openclaw" && runtime !== "hermes") {
5323
+ throw new Error(
5324
+ `Unsupported runtime "${runtime}". Use --runtime openclaw or --runtime hermes.`
5325
+ );
5326
+ }
5327
+ }
5328
+ function validateCursorUsageCsvOptions2(options) {
5329
+ if (!options.cursorUsageCsv) {
5330
+ return;
5331
+ }
5332
+ const conflicts = [
5333
+ options.runtime ? "--runtime" : null,
5334
+ options.logFile ? "--log-file" : null,
5335
+ options.sessionsDir ? "--sessions-dir" : null,
5336
+ options.remote ? "--remote" : null,
5337
+ options.remoteLogFile ? "--remote-log-file" : null,
5338
+ options.remoteSessionsDir ? "--remote-sessions-dir" : null,
5339
+ options.railway ? "--railway" : null,
5340
+ options.railwayProject ? "--project" : null,
5341
+ options.railwayEnvironment ? "--environment" : null,
5342
+ options.railwayService ? "--service" : null
5343
+ ].filter((flag) => flag !== null);
5344
+ if (conflicts.length > 0) {
5345
+ throw new Error(`The --cursor-usage-csv flag cannot be combined with ${conflicts.join(", ")}.`);
5346
+ }
5347
+ }
5348
+ function validateHermesLocalOnly2(options) {
5349
+ if (options.runtime !== "hermes") {
5350
+ return;
5351
+ }
5352
+ const conflicts = [
5353
+ options.remote ? "--remote" : null,
5354
+ options.remoteLogFile ? "--remote-log-file" : null,
5355
+ options.remoteSessionsDir ? "--remote-sessions-dir" : null,
5356
+ options.railway ? "--railway" : null,
5357
+ options.railwayProject ? "--project" : null,
5358
+ options.railwayEnvironment ? "--environment" : null,
5359
+ options.railwayService ? "--service" : null
5360
+ ].filter((flag) => flag !== null);
5361
+ if (conflicts.length > 0) {
5362
+ throw new Error(
5363
+ `Hermes remote transport is not supported yet. Remove ${conflicts.join(", ")} or switch to --runtime openclaw.`
5364
+ );
5365
+ }
5366
+ }
3309
5367
  function buildRailwayTarget2(options) {
3310
5368
  if (options.railwayProject && options.railwayEnvironment && options.railwayService) {
3311
5369
  return {
@@ -3515,12 +5573,12 @@ async function openBrowser(url) {
3515
5573
  };
3516
5574
  const cmd = commands[platform2()];
3517
5575
  if (!cmd) return;
3518
- return new Promise((resolve3) => {
3519
- exec(`${cmd} ${JSON.stringify(url)}`, () => resolve3());
5576
+ return new Promise((resolve4) => {
5577
+ exec(`${cmd} ${JSON.stringify(url)}`, () => resolve4());
3520
5578
  });
3521
5579
  }
3522
5580
  function sleep(ms) {
3523
- return new Promise((resolve3) => setTimeout(resolve3, ms));
5581
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
3524
5582
  }
3525
5583
  function colorBold(text) {
3526
5584
  return process.stderr.isTTY ? styleText("bold", text) : text;
@@ -3541,8 +5599,7 @@ function runLogoutCommand() {
3541
5599
  }
3542
5600
 
3543
5601
  // src/commands/push.ts
3544
- import { readFileSync as readFileSync6 } from "fs";
3545
- import { hostname as hostname2 } from "os";
5602
+ import { readFileSync as readFileSync8 } from "fs";
3546
5603
  async function runPushCommand(options) {
3547
5604
  const payload = options.file ? loadPayloadFromFile(options.file) : loadPayloadFromCache();
3548
5605
  if (options.dryRun) {
@@ -3566,7 +5623,7 @@ async function runPushCommand(options) {
3566
5623
  function loadPayloadFromFile(filePath) {
3567
5624
  let raw;
3568
5625
  try {
3569
- raw = readFileSync6(filePath, "utf8");
5626
+ raw = readFileSync8(filePath, "utf8");
3570
5627
  } catch {
3571
5628
  throw new Error(`Cannot read file: ${filePath}`);
3572
5629
  }
@@ -3600,38 +5657,155 @@ function loadPayloadFromCache() {
3600
5657
  );
3601
5658
  }
3602
5659
  const latest = summaries[0];
3603
- const meta = buildMeta2();
5660
+ const meta = buildMeta2(latest);
3604
5661
  process.stderr.write(
3605
5662
  `Using most recent cached audit: ${latest.auditId} (${latest.generatedAt})
3606
5663
  `
3607
5664
  );
3608
5665
  return toWirePayload(latest, meta);
3609
5666
  }
3610
- function readCliVersion2() {
3611
- try {
3612
- const packageJsonPath = new URL("../../package.json", import.meta.url);
3613
- const pkg = JSON.parse(readFileSync6(packageJsonPath, "utf8"));
3614
- return pkg.version ?? "0.0.0";
3615
- } catch {
3616
- return "0.0.0";
3617
- }
3618
- }
3619
- function buildMeta2() {
5667
+ function buildMeta2(summary) {
5668
+ const sourceMeta = buildCachedPushSourceMeta(summary);
3620
5669
  return {
3621
- cliVersion: readCliVersion2(),
3622
- sourceId: hostname2(),
3623
- sourceHost: hostname2(),
3624
- environment: "local"
5670
+ cliVersion: getCliVersion(),
5671
+ sourceId: sourceMeta.sourceId,
5672
+ sourceHost: sourceMeta.sourceHost,
5673
+ environment: sourceMeta.environment
3625
5674
  };
3626
5675
  }
3627
5676
 
5677
+ // src/help.ts
5678
+ function renderRootHelp(version, display) {
5679
+ return `${display.name} ${version}
5680
+
5681
+ Waste intelligence for OpenClaw and Hermes workflows plus local Cursor usage CSVs.
5682
+
5683
+ Usage:
5684
+ ${formatCommand("<command> [options]", display.prefix)}
5685
+
5686
+ Commands:
5687
+ audit Analyze OpenClaw or Hermes logs, or a local Cursor usage CSV.
5688
+ doctor Inspect OpenClaw or Hermes sources, or a local Cursor usage CSV.
5689
+ push Push a cached audit snapshot to the Xerg API.
5690
+ login Authenticate with the Xerg API via browser.
5691
+ logout Remove stored Xerg API credentials.
5692
+
5693
+ Global options:
5694
+ -h, --help Show help
5695
+ -v, --version Show version
5696
+ `;
5697
+ }
5698
+ function renderAuditHelp(commandPrefix) {
5699
+ return `${formatCommand("audit", commandPrefix)}
5700
+
5701
+ Analyze OpenClaw or Hermes logs, or a local Cursor usage CSV, and produce an audit report.
5702
+
5703
+ Usage:
5704
+ ${formatCommand("audit [options]", commandPrefix)}
5705
+
5706
+ Options:
5707
+ --runtime <name> Local runtime to inspect: openclaw or hermes
5708
+ --log-file <path> Explicit local gateway log file to analyze
5709
+ --sessions-dir <path> Explicit local sessions directory to analyze
5710
+ --cursor-usage-csv <path> Local Cursor usage CSV export to analyze
5711
+ --since <duration> Look back window such as 24h, 7d, or 30m
5712
+ --compare Compare this audit to the newest compatible prior local snapshot
5713
+ --json Render the report as JSON
5714
+ --markdown Render the report as Markdown
5715
+ --db <path> Custom SQLite database path
5716
+ --no-db Skip local persistence
5717
+
5718
+ Remote options (SSH, OpenClaw only):
5719
+ --remote <user@host> SSH target in user@host or user@host:port format
5720
+ --remote-log-file <path> Override the default gateway log path on the remote host
5721
+ --remote-sessions-dir <path> Override the default sessions directory on the remote host
5722
+ --remote-config <path> Path to a JSON file defining multiple remote sources
5723
+ --keep-remote-files Retain pulled files in ~/.xerg/remote-cache/ instead of using a temp directory
5724
+
5725
+ Prerequisites:
5726
+ SSH remote audits require ssh and rsync on your PATH.
5727
+
5728
+ Railway options (OpenClaw only):
5729
+ --railway Audit a Railway service (uses linked project by default)
5730
+ --project <id> Railway project ID
5731
+ --environment <id> Railway environment ID
5732
+ --service <id> Railway service ID
5733
+
5734
+ Railway audits require the railway CLI on your PATH.
5735
+
5736
+ Push options:
5737
+ --push Push the audit summary to the Xerg API after computing it
5738
+ --dry-run With --push: print the payload to stdout without sending it
5739
+ --verbose Print progress updates to stderr while the audit runs
5740
+
5741
+ Threshold options:
5742
+ --fail-above-waste-rate <n> Exit with code 3 if structural waste rate exceeds threshold (e.g. 0.30)
5743
+ --fail-above-waste-usd <n> Exit with code 3 if waste spend exceeds threshold in USD (e.g. 50)
5744
+
5745
+ -h, --help Show help
5746
+ `;
5747
+ }
5748
+ function renderPushHelp(commandPrefix) {
5749
+ return `${formatCommand("push", commandPrefix)}
5750
+
5751
+ Push a cached audit snapshot to the Xerg API.
5752
+
5753
+ Usage:
5754
+ ${formatCommand("push [options]", commandPrefix)}
5755
+
5756
+ Options:
5757
+ --file <path> Push a specific snapshot file instead of the most recent cached audit
5758
+ --dry-run Print the payload to stdout without sending it
5759
+
5760
+ -h, --help Show help
5761
+
5762
+ Authentication:
5763
+ Set XERG_API_KEY in your environment, add "apiKey" to ~/.xerg/config.json,
5764
+ or run \`${formatCommand("login", commandPrefix)}\` to authenticate via browser.
5765
+ Browser login stores a token at ~/.config/xerg/credentials.json by default.
5766
+ `;
5767
+ }
5768
+ function renderDoctorHelp(commandPrefix) {
5769
+ return `${formatCommand("doctor", commandPrefix)}
5770
+
5771
+ Inspect OpenClaw or Hermes sources, or a local Cursor usage CSV, before you audit.
5772
+
5773
+ Usage:
5774
+ ${formatCommand("doctor [options]", commandPrefix)}
5775
+
5776
+ Options:
5777
+ --runtime <name> Local runtime to inspect: openclaw or hermes
5778
+ --log-file <path> Explicit local gateway log file to inspect
5779
+ --sessions-dir <path> Explicit local sessions directory to inspect
5780
+ --cursor-usage-csv <path> Local Cursor usage CSV export to inspect
5781
+ --verbose Print progress updates to stderr while doctor runs
5782
+
5783
+ Remote options (SSH, OpenClaw only):
5784
+ --remote <user@host> SSH target in user@host or user@host:port format
5785
+ --remote-log-file <path> Override the default gateway log path on the remote host
5786
+ --remote-sessions-dir <path> Override the default sessions directory on the remote host
5787
+
5788
+ SSH checks require ssh and rsync on your PATH.
5789
+
5790
+ Railway options (OpenClaw only):
5791
+ --railway Check a Railway service (uses linked project by default)
5792
+ --project <id> Railway project ID
5793
+ --environment <id> Railway environment ID
5794
+ --service <id> Railway service ID
5795
+
5796
+ Railway checks require the railway CLI on your PATH.
5797
+
5798
+ -h, --help Show help
5799
+ `;
5800
+ }
5801
+
3628
5802
  // src/index.ts
3629
- var VERSION = readVersion();
5803
+ var VERSION = getCliVersion();
3630
5804
  var argv = process.argv.slice(2);
3631
5805
  var commandDisplay = resolveCommandDisplay();
3632
5806
  var command = argv[0];
3633
5807
  if (!command || command === "--help" || command === "-h" || command === "help") {
3634
- process.stdout.write(renderRootHelp(commandDisplay));
5808
+ process.stdout.write(renderRootHelp(VERSION, commandDisplay));
3635
5809
  process.exit(0);
3636
5810
  }
3637
5811
  if (command === "--version" || command === "-v" || command === "version") {
@@ -3697,10 +5871,18 @@ function parseAuditOptions(raw) {
3697
5871
  options.logFile = readValue(arg, argv2[index + 1]);
3698
5872
  index += 1;
3699
5873
  break;
5874
+ case "--runtime":
5875
+ options.runtime = readValue(arg, argv2[index + 1]);
5876
+ index += 1;
5877
+ break;
3700
5878
  case "--sessions-dir":
3701
5879
  options.sessionsDir = readValue(arg, argv2[index + 1]);
3702
5880
  index += 1;
3703
5881
  break;
5882
+ case "--cursor-usage-csv":
5883
+ options.cursorUsageCsv = readValue(arg, argv2[index + 1]);
5884
+ index += 1;
5885
+ break;
3704
5886
  case "--since":
3705
5887
  options.since = readValue(arg, argv2[index + 1]);
3706
5888
  index += 1;
@@ -3821,10 +6003,18 @@ function parseDoctorOptions(raw) {
3821
6003
  options.logFile = readValue(arg, argv2[index + 1]);
3822
6004
  index += 1;
3823
6005
  break;
6006
+ case "--runtime":
6007
+ options.runtime = readValue(arg, argv2[index + 1]);
6008
+ index += 1;
6009
+ break;
3824
6010
  case "--sessions-dir":
3825
6011
  options.sessionsDir = readValue(arg, argv2[index + 1]);
3826
6012
  index += 1;
3827
6013
  break;
6014
+ case "--cursor-usage-csv":
6015
+ options.cursorUsageCsv = readValue(arg, argv2[index + 1]);
6016
+ index += 1;
6017
+ break;
3828
6018
  case "--remote":
3829
6019
  options.remote = readValue(arg, argv2[index + 1]);
3830
6020
  index += 1;
@@ -3889,131 +6079,7 @@ function readFloat(flag, value) {
3889
6079
  }
3890
6080
  return num;
3891
6081
  }
3892
- function renderRootHelp(display = commandDisplay) {
3893
- return `${display.name} ${VERSION}
3894
-
3895
- Waste intelligence for OpenClaw workflows.
3896
-
3897
- Usage:
3898
- ${formatCommand("<command> [options]", display.prefix)}
3899
-
3900
- Commands:
3901
- audit Analyze OpenClaw logs and produce a waste intelligence report.
3902
- doctor Inspect your machine for OpenClaw sources and audit readiness.
3903
- push Push a cached audit snapshot to the Xerg API.
3904
- login Authenticate with the Xerg API via browser.
3905
- logout Remove stored Xerg API credentials.
3906
-
3907
- Global options:
3908
- -h, --help Show help
3909
- -v, --version Show version
3910
- `;
3911
- }
3912
- function renderAuditHelp(commandPrefix = commandDisplay.prefix) {
3913
- return `${formatCommand("audit", commandPrefix)}
3914
-
3915
- Analyze OpenClaw logs and produce a waste intelligence report.
3916
-
3917
- Usage:
3918
- ${formatCommand("audit [options]", commandPrefix)}
3919
-
3920
- Options:
3921
- --log-file <path> Explicit OpenClaw gateway log file to analyze
3922
- --sessions-dir <path> Explicit OpenClaw sessions directory to analyze
3923
- --since <duration> Look back window such as 24h, 7d, or 30m
3924
- --compare Compare this audit to the newest compatible prior local snapshot
3925
- --json Render the report as JSON
3926
- --markdown Render the report as Markdown
3927
- --db <path> Custom SQLite database path
3928
- --no-db Skip local persistence
3929
-
3930
- Remote options (SSH):
3931
- --remote <user@host> SSH target in user@host or user@host:port format
3932
- --remote-log-file <path> Override the default gateway log path on the remote host
3933
- --remote-sessions-dir <path> Override the default sessions directory on the remote host
3934
- --remote-config <path> Path to a JSON file defining multiple remote sources
3935
- --keep-remote-files Retain pulled files in ~/.xerg/remote-cache/ instead of using a temp directory
3936
-
3937
- Prerequisites:
3938
- SSH remote audits require ssh and rsync on your PATH.
3939
-
3940
- Railway options:
3941
- --railway Audit a Railway service (uses linked project by default)
3942
- --project <id> Railway project ID
3943
- --environment <id> Railway environment ID
3944
- --service <id> Railway service ID
3945
-
3946
- Railway audits require the railway CLI on your PATH.
3947
-
3948
- Push options:
3949
- --push Push the audit summary to the Xerg API after computing it
3950
- --dry-run With --push: print the payload to stdout without sending it
3951
- --verbose Print progress updates to stderr while the audit runs
3952
-
3953
- Threshold options:
3954
- --fail-above-waste-rate <n> Exit with code 3 if structural waste rate exceeds threshold (e.g. 0.30)
3955
- --fail-above-waste-usd <n> Exit with code 3 if waste spend exceeds threshold in USD (e.g. 50)
3956
-
3957
- -h, --help Show help
3958
- `;
3959
- }
3960
- function renderPushHelp(commandPrefix = commandDisplay.prefix) {
3961
- return `${formatCommand("push", commandPrefix)}
3962
-
3963
- Push a cached audit snapshot to the Xerg API.
3964
-
3965
- Usage:
3966
- ${formatCommand("push [options]", commandPrefix)}
3967
-
3968
- Options:
3969
- --file <path> Push a specific snapshot file instead of the most recent cached audit
3970
- --dry-run Print the payload to stdout without sending it
3971
-
3972
- -h, --help Show help
3973
-
3974
- Authentication:
3975
- Set XERG_API_KEY in your environment, add "apiKey" to ~/.xerg/config.json,
3976
- or run \`${formatCommand("login", commandPrefix)}\` to authenticate via browser.
3977
- Browser login stores a token at ~/.config/xerg/credentials.json by default.
3978
- `;
3979
- }
3980
- function renderDoctorHelp(commandPrefix = commandDisplay.prefix) {
3981
- return `${formatCommand("doctor", commandPrefix)}
3982
-
3983
- Inspect your machine for OpenClaw sources and audit readiness.
3984
-
3985
- Usage:
3986
- ${formatCommand("doctor [options]", commandPrefix)}
3987
-
3988
- Options:
3989
- --log-file <path> Explicit OpenClaw gateway log file to inspect
3990
- --sessions-dir <path> Explicit OpenClaw sessions directory to inspect
3991
- --verbose Print progress updates to stderr while doctor runs
3992
-
3993
- Remote options (SSH):
3994
- --remote <user@host> SSH target in user@host or user@host:port format
3995
- --remote-log-file <path> Override the default gateway log path on the remote host
3996
- --remote-sessions-dir <path> Override the default sessions directory on the remote host
3997
-
3998
- SSH checks require ssh and rsync on your PATH.
3999
-
4000
- Railway options:
4001
- --railway Check a Railway service (uses linked project by default)
4002
- --project <id> Railway project ID
4003
- --environment <id> Railway environment ID
4004
- --service <id> Railway service ID
4005
-
4006
- Railway checks require the railway CLI on your PATH.
4007
-
4008
- -h, --help Show help
4009
- `;
4010
- }
4011
6082
  function colorError(message) {
4012
6083
  return process.stderr.isTTY ? styleText2("red", message) : message;
4013
6084
  }
4014
- function readVersion() {
4015
- const packageJsonPath = new URL("../package.json", import.meta.url);
4016
- const packageJson = JSON.parse(readFileSync7(packageJsonPath, "utf8"));
4017
- return packageJson.version ?? "0.0.0";
4018
- }
4019
6085
  //# sourceMappingURL=index.js.map