@cad0p/pi-usage 0.11.3 → 0.11.4

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.
@@ -7,10 +7,9 @@
7
7
  * Omarchy's own `claude.json` / `codex.json` records are never touched.
8
8
  */
9
9
 
10
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
- import { fileURLToPath } from "node:url";
14
13
 
15
14
  import { isPiRecordId, RECORD_PREFIX } from "./record.js";
16
15
 
@@ -57,68 +56,6 @@ function writeRecord(dir, id, record) {
57
56
  return target;
58
57
  }
59
58
 
60
- /**
61
- * Copy a file only if the target does not exist or its content differs.
62
- * This is critical when writing to directories watched by inotify, such as
63
- * Omarchy's plugins directory, where redundant writes trigger full shell reloads.
64
- *
65
- * @param {string} srcPath
66
- * @param {string} dstPath
67
- * @returns {boolean} Whether the file was written.
68
- */
69
- function copyIfChanged(srcPath, dstPath) {
70
- try {
71
- if (existsSync(dstPath)) {
72
- const srcBuf = readFileSync(srcPath);
73
- const dstBuf = readFileSync(dstPath);
74
- if (srcBuf.equals(dstBuf)) return false;
75
- }
76
- copyFileSync(srcPath, dstPath);
77
- return true;
78
- } catch {
79
- return false;
80
- }
81
- }
82
-
83
- /**
84
- * Synchronize provider SVG icons into Omarchy plugin assets directories if present.
85
- *
86
- * @param {{ env?: Record<string, string | undefined>, home?: string }} [options]
87
- * @returns {string[]} Paths of written assets.
88
- */
89
- export function syncOmarchyAssets(options = {}) {
90
- const env = options.env ?? process.env;
91
- const home = options.home ?? (env.HOME || homedir());
92
- const pluginsDir = join(home, ".config", "omarchy", "plugins");
93
- if (!existsSync(pluginsDir)) return [];
94
-
95
- const assetsSrc = fileURLToPath(new URL("assets", import.meta.url));
96
- if (!existsSync(assetsSrc)) return [];
97
-
98
- const copied = [];
99
- try {
100
- for (const plugin of readdirSync(pluginsDir)) {
101
- const pluginDir = join(pluginsDir, plugin);
102
- const manifestPath = join(pluginDir, "manifest.json");
103
- if (!existsSync(manifestPath)) continue;
104
- const targetAssetsDir = join(pluginDir, "assets");
105
- if (!existsSync(targetAssetsDir)) {
106
- mkdirSync(targetAssetsDir, { recursive: true });
107
- }
108
-
109
- for (const file of readdirSync(assetsSrc)) {
110
- if (!file.endsWith(".svg")) continue;
111
- const srcPath = join(assetsSrc, file);
112
- const dstPath = join(targetAssetsDir, file);
113
- const piDstPath = join(targetAssetsDir, `pi-${file}`);
114
- if (copyIfChanged(srcPath, dstPath)) copied.push(dstPath);
115
- if (copyIfChanged(srcPath, piDstPath)) copied.push(piDstPath);
116
- }
117
- }
118
- } catch {}
119
- return copied;
120
- }
121
-
122
59
  /**
123
60
  * Write the given records and remove stale `pi-*.json` files whose provider is
124
61
  * no longer present.
@@ -146,7 +83,6 @@ export function publishRecords(records, options = {}) {
146
83
  removed.push(join(dir, name));
147
84
  }
148
85
 
149
- syncOmarchyAssets(options);
150
86
 
151
87
  return { dir, written, removed };
152
88
  }
@@ -10,9 +10,15 @@
10
10
  *
11
11
  * Record contract (see Omarchy's plugins/agents/Main.qml):
12
12
  * id, name, ready, updatedAt, schemaVersion, tierLabel, usageStatusText,
13
- * authHelpText and `limits[{label, title, percent, resetsAt}]`. The local
14
- * token statistics are optional and are deliberately left out; the panel
15
- * treats their absence as "no local stats".
13
+ * authHelpText and `limits[{label, title, percent, resetsAt}]`. When the
14
+ * report carries local token statistics for the family, the panel's optional
15
+ * stats fields are merged in (today's tokens and prompts, the last seven
16
+ * days, all-time per-model buckets) and `hasLocalStats` says they are there.
17
+ *
18
+ * A family with several accounts also carries `accounts[{id, ok, status,
19
+ * limits}]`: every window of every account, for panels that can draw them
20
+ * (this package's own Omarchy plugin). Omarchy's built-in panel ignores the
21
+ * key and keeps reading the flattened `limits`.
16
22
  *
17
23
  * `percent` is a FRACTION in 0..1 (the panel multiplies it by 100), so 40.3% used
18
24
  * is written as 0.403.
@@ -296,6 +302,18 @@ function compactLabels(labels) {
296
302
  return parts.join(", ");
297
303
  }
298
304
 
305
+ /**
306
+ * The per-account status line: the same reason as the compact summary, as a
307
+ * sentence-case label ("Key rejected", "No Go plan").
308
+ *
309
+ * @param {string | null | undefined} error
310
+ * @returns {string}
311
+ */
312
+ function accountStatus(error) {
313
+ const reason = failureReason(error);
314
+ return reason.charAt(0).toUpperCase() + reason.slice(1);
315
+ }
316
+
299
317
  /**
300
318
  * Explain failures compactly, grouped by reason in slot order, e.g.
301
319
  * "#8–#10 key rejected · #11–#14 no Go plan".
@@ -330,6 +348,9 @@ function compactFailureList(failures) {
330
348
  * summarised in the status and the compact help text, which is what makes a
331
349
  * revoked key distinguishable from a workspace without a plan.
332
350
  *
351
+ * `accounts` repeats the family per account with every window, untitled by
352
+ * slot (the account id already says which one), for panels that show them all.
353
+ *
333
354
  * @param {string} family
334
355
  * @param {import("../model.js").QuotaResult[]} providers
335
356
  * @param {{ now?: number }} [options]
@@ -345,6 +366,8 @@ function buildFamilyRecord(family, providers, options = {}) {
345
366
  const limits = [];
346
367
  /** @type {Array<{ label: string, reason: string }>} */
347
368
  const failures = [];
369
+ /** @type {Array<{ id: string, ok: boolean, status: string, limits: Array<{ label: string, title: string, percent: number, resetsAt: string }> }>} */
370
+ const accounts = [];
348
371
  let okCount = 0;
349
372
 
350
373
  providers.forEach((provider, index) => {
@@ -354,9 +377,11 @@ function buildFamilyRecord(family, providers, options = {}) {
354
377
  const window = bindingWindow(provider.windows);
355
378
  const mapped = window ? mapLimits([window], provider, `${label} · `)[0] : null;
356
379
  if (mapped) limits.push(mapped);
380
+ accounts.push({ id: redact(label), ok: true, status: "", limits: mapLimits(provider.windows, provider) });
357
381
  return;
358
382
  }
359
383
  failures.push({ label, reason: failureReason(provider.error) });
384
+ accounts.push({ id: redact(label), ok: false, status: accountStatus(provider.error), limits: [] });
360
385
  });
361
386
 
362
387
  const failedCount = providers.length - okCount;
@@ -378,22 +403,68 @@ function buildFamilyRecord(family, providers, options = {}) {
378
403
  ready: okCount > 0,
379
404
  tierLabel: `${okCount} of ${providers.length} accounts`,
380
405
  limits: limits.length > 0 ? limits : [{ ...PLACEHOLDER_LIMIT }],
406
+ accounts,
381
407
  usageStatusText: status.usageStatusText,
382
408
  authHelpText: failures.length > 0 ? compactFailureList(failures) : status.authHelpText,
383
409
  hasLocalStats: false,
384
410
  hasPromptStats: false,
411
+ ...localStatsFields(options.localStats),
385
412
  updatedAt: new Date(now).toISOString(),
386
413
  },
387
414
  };
388
415
  }
389
416
 
417
+ /**
418
+ * The optional token fields Omarchy's Agents panel reads. Only aggregate counts
419
+ * are copied: no prompt text, no session path, no account identity.
420
+ *
421
+ * `totalSessions` counts session-DAYS, not distinct session files: a session
422
+ * that runs past midnight is one session in 20 files of `sessions/` but two
423
+ * rows here, because history is frozen per local day and a session's identity is
424
+ * gone once its day is (see the README's token-stats section).
425
+ *
426
+ * @param {import("../local/pi-sessions.js").LocalFamilyStats | undefined} stats
427
+ * @returns {Record<string, unknown>}
428
+ */
429
+ function localStatsFields(stats) {
430
+ if (!stats || stats.hasLocalStats !== true) return {};
431
+ const modelUsage = {};
432
+ for (const [model, bucket] of Object.entries(stats.modelUsage ?? {})) {
433
+ modelUsage[model] = {
434
+ inputTokens: Number(bucket?.inputTokens) || 0,
435
+ outputTokens: Number(bucket?.outputTokens) || 0,
436
+ cacheReadInputTokens: Number(bucket?.cacheReadInputTokens) || 0,
437
+ cacheCreationInputTokens: Number(bucket?.cacheCreationInputTokens) || 0,
438
+ };
439
+ }
440
+ return {
441
+ hasLocalStats: true,
442
+ hasPromptStats: true,
443
+ todayPrompts: Number(stats.todayPrompts) || 0,
444
+ todaySessions: Number(stats.todaySessions) || 0,
445
+ todayTotalTokens: Number(stats.todayTotalTokens) || 0,
446
+ todayTokensByModel: Object.fromEntries(
447
+ Object.entries(stats.todayTokensByModel ?? {}).map(([model, tokens]) => [model, Number(tokens) || 0]),
448
+ ),
449
+ recentDays: (stats.recentDays ?? []).map((day) => ({
450
+ date: String(day?.date ?? ""),
451
+ messageCount: Number(day?.messageCount) || 0,
452
+ })),
453
+ totalPrompts: Number(stats.totalPrompts) || 0,
454
+ totalSessions: Number(stats.totalSessions) || 0,
455
+ activeDays: Number(stats.activeDays) || 0,
456
+ activeDates: [...(stats.activeDates ?? [])],
457
+ modelUsage,
458
+ };
459
+ }
460
+
390
461
  /**
391
462
  * Build the record for one provider, or `null` when the provider is simply not
392
463
  * configured: a tab that only says "not configured" is noise, so it is omitted
393
464
  * (and any record from an earlier run is removed by the publisher).
394
465
  *
395
466
  * @param {import("../model.js").QuotaResult} provider
396
- * @param {{ now?: number }} [options]
467
+ * @param {{ now?: number, localStats?: import("../local/pi-sessions.js").LocalFamilyStats }} [options]
397
468
  * @returns {{ id: string, record: Record<string, unknown> } | null}
398
469
  */
399
470
  export function buildRecord(provider, options = {}) {
@@ -421,6 +492,7 @@ export function buildRecord(provider, options = {}) {
421
492
  authHelpText: status.authHelpText,
422
493
  hasLocalStats: false,
423
494
  hasPromptStats: false,
495
+ ...localStatsFields(options.localStats),
424
496
  updatedAt: new Date(now).toISOString(),
425
497
  },
426
498
  };
@@ -434,7 +506,7 @@ export function buildRecord(provider, options = {}) {
434
506
  * Never throws: a malformed provider entry is skipped.
435
507
  *
436
508
  * @param {import("../engine.js").PiQuotaReport} report
437
- * @param {{ now?: number }} [options]
509
+ * @param {{ now?: number, localStats?: Record<string, import("../local/pi-sessions.js").LocalFamilyStats> }} [options]
438
510
  * @returns {Array<{ id: string, record: Record<string, unknown> }>}
439
511
  */
440
512
  export function buildRecords(report, options = {}) {
@@ -450,9 +522,12 @@ export function buildRecords(report, options = {}) {
450
522
  const records = [];
451
523
  for (const [family, providers] of groups) {
452
524
  try {
525
+ // Local stats belong to the family, not to one account: Omarchy's panel
526
+ // draws one token section per tab.
527
+ const familyOptions = { ...options, localStats: report?.localStats?.[family] };
453
528
  const built = providers.length > 1
454
- ? buildFamilyRecord(family, providers, options)
455
- : buildRecord(providers[0], options);
529
+ ? buildFamilyRecord(family, providers, familyOptions)
530
+ : buildRecord(providers[0], familyOptions);
456
531
  if (built) records.push(built);
457
532
  } catch {
458
533
  // A broken entry must not take the other providers' records down with it.