agy-powerline 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/shared.js CHANGED
@@ -54,6 +54,7 @@ const {
54
54
  } = require("./formatters");
55
55
 
56
56
  const { extractSession } = require("./session");
57
+ const { execPayloadJson } = require("./exec");
57
58
  const { fitWidgets, truncateLines, widestLine } = require("./fit");
58
59
 
59
60
  const {
@@ -92,12 +93,21 @@ function renderBar(data, config, overrides) {
92
93
  // sequences from every string before any layout prints it.
93
94
  // Valid JSON can still be null, a number or an array; read those as empty.
94
95
  const payload = data && typeof data === "object" && !Array.isArray(data) ? data : {};
95
- const session = sanitizeDeep(extractSession(payload, resolved));
96
+ const session = sanitizeDeep(
97
+ extractSession(payload, resolved, { readUsage: overrides.usage === "read" })
98
+ );
96
99
  // Exec widgets only run when the caller opts in ("run"); CLI previews keep
97
100
  // the default placeholder so rendering a config never executes it.
98
101
  session.execMode =
99
102
  overrides.exec === "run" || overrides.exec === "blocked" ? overrides.exec : "preview";
100
103
  session.execMemo = new Map();
104
+ // Built only when a command will run and asked for it.
105
+ if (
106
+ session.execMode === "run" &&
107
+ resolved.widgets.some((widget) => widget.id === "exec" && widget.payload === true)
108
+ ) {
109
+ session.execPayload = execPayloadJson(payload);
110
+ }
101
111
 
102
112
  const prevDepth = getActiveDepth();
103
113
 
@@ -129,7 +139,8 @@ function renderBar(data, config, overrides) {
129
139
  }
130
140
 
131
141
  function renderLayout(session, config, emojis, ascii, colors) {
132
- if (config.layout === "lines") {
142
+ // Classic is Lines with block meters and | dividers.
143
+ if (config.layout === "lines" || config.layout === "classic") {
133
144
  return renderLinesLayout(session, emojis, ascii, colors, config);
134
145
  }
135
146
 
package/src/lib/themes.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- const { parseColor } = require("./colors");
3
+ const { parseColor, parseGradientStops, luminance } = require("./colors");
4
4
 
5
5
  // Every theme targets one terminal background:
6
6
  // dark-* bright colors, dark pill text, for dark terminals
@@ -9,10 +9,10 @@ const { parseColor } = require("./colors");
9
9
  // protanopia, deuteranopia and tritanopia
10
10
  // Most dark-* and light-* colors come from VS Code's default themes (Dark+
11
11
  // and Light+ syntax, git decorations, warning/error and terminal colors);
12
- // nocturne, onyx, metro and cream take their palettes from community themes
13
- // (credited below). A few colors are adjusted where the checks below need
14
- // it: darkened or lightened for contrast, and gitDirty moved toward orange so
15
- // a dirty branch never looks like the caution state.
12
+ // nocturne, onyx, metro, cream and macchiato take their palettes from
13
+ // community themes (credited below). A few colors are adjusted where the
14
+ // checks below need it: darkened or lightened for contrast, and gitDirty
15
+ // moved toward orange so a dirty branch never looks like the caution state.
16
16
  // Palettes are checked by tests/shared/themes.test.js: pill text and lines
17
17
  // text reach WCAG AA (4.5:1), the ok / caution / danger states are clearly
18
18
  // different from each other, and so are gitDirty and caution.
@@ -175,6 +175,22 @@ const THEMES = {
175
175
  warn: "#f7768e",
176
176
  }),
177
177
 
178
+ // Palette from Catppuccin Macchiato (catppuccin.com, MIT).
179
+ "dark-macchiato": theme("dark", "Dark Macchiato", "Pastel mauve, teal, peach and green", {
180
+ text: DARK_TEXT,
181
+ track: "#494d64",
182
+ model: "#c6a0f6",
183
+ fiveHour: "#a6da95",
184
+ weekly: "#7dc4e4",
185
+ thirdParty: "#f5a97f",
186
+ context: "#8aadf4",
187
+ git: "#8bd5ca",
188
+ gitDirty: "#f0c6c6",
189
+ meta: "#b8c0e0",
190
+ caution: "#eed49f",
191
+ warn: "#ed8796",
192
+ }),
193
+
178
194
  "light-paper": theme("light", "Light Paper", "VS Code Light+ colors", {
179
195
  text: LIGHT_TEXT,
180
196
  track: "#c8c8c8",
@@ -311,6 +327,7 @@ const THEME_ORDER = [
311
327
  "dark-nocturne",
312
328
  "dark-onyx",
313
329
  "dark-metro",
330
+ "dark-macchiato",
314
331
  "light-paper",
315
332
  "light-sky",
316
333
  "light-sunset",
@@ -398,6 +415,57 @@ function themeColors(themeId, customThemeConfig) {
398
415
  return resolveTheme(themeId, customThemeConfig).colors;
399
416
  }
400
417
 
418
+ // Row gradients. Pill text and Lines text are adjusted per color to stay
419
+ // readable, so a preset works on dark and light terminals alike.
420
+ const GRADIENTS = {
421
+ aurora: { name: "Aurora", stops: ["#00c9a7", "#4facfe", "#a18cd1"] },
422
+ sunset: { name: "Sunset", stops: ["#ffd166", "#ff8c61", "#e8457c"] },
423
+ ocean: { name: "Ocean", stops: ["#2af598", "#009efd"] },
424
+ forest: { name: "Forest", stops: ["#d4fc79", "#3fb68b", "#1d7a5f"] },
425
+ neon: { name: "Neon", stops: ["#f72585", "#7209b7", "#4cc9f0"] },
426
+ candy: { name: "Candy", stops: ["#ff9a9e", "#fbc2eb", "#a1c4fd"] },
427
+ rainbow: { name: "Rainbow", stops: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"] },
428
+ mono: { name: "Mono", stops: ["#e0e0e0", "#6e6e6e"] },
429
+ };
430
+
431
+ const GRADIENT_ORDER = Object.keys(GRADIENTS);
432
+
433
+ const toHex = (rgb) => `#${rgb.map((n) => n.toString(16).padStart(2, "0")).join("")}`;
434
+
435
+ // A preset name, or hex stops written as "#rrggbb-#rrggbb"; null when the
436
+ // value is anything else (or "off").
437
+ function normalizeGradient(value) {
438
+ if (typeof value !== "string") {
439
+ return null;
440
+ }
441
+ const key = value.trim().toLowerCase();
442
+ if (GRADIENTS[key]) {
443
+ return key;
444
+ }
445
+ const stops = parseGradientStops(key);
446
+ return stops ? stops.map(toHex).join("-") : null;
447
+ }
448
+
449
+ function gradientStops(value) {
450
+ const key = normalizeGradient(value);
451
+ if (!key) {
452
+ return null;
453
+ }
454
+ return GRADIENTS[key]
455
+ ? GRADIENTS[key].stops.map((stop) => parseColor(stop))
456
+ : parseGradientStops(key);
457
+ }
458
+
459
+ // Typical terminal backgrounds, a little toward the hard side. Themes for
460
+ // light terminals have light pill text on deep colors; dark themes the reverse.
461
+ const TERMINAL_BACKGROUNDS = { dark: [40, 42, 54], light: [245, 245, 240] };
462
+
463
+ function terminalBackground(colors) {
464
+ return luminance(colors?.text ?? [0, 0, 0]) > 0.5
465
+ ? TERMINAL_BACKGROUNDS.light
466
+ : TERMINAL_BACKGROUNDS.dark;
467
+ }
468
+
401
469
  module.exports = {
402
470
  THEMES,
403
471
  THEME_ORDER,
@@ -411,4 +479,10 @@ module.exports = {
411
479
  resolveThemeId,
412
480
  resolveTheme,
413
481
  themeColors,
482
+ GRADIENTS,
483
+ GRADIENT_ORDER,
484
+ normalizeGradient,
485
+ gradientStops,
486
+ TERMINAL_BACKGROUNDS,
487
+ terminalBackground,
414
488
  };
@@ -0,0 +1,524 @@
1
+ "use strict";
2
+
3
+ // Tokens used today and over the last 7 days, for the tokensToday and
4
+ // tokensWeek widgets. agy keeps each conversation in a SQLite database of its
5
+ // own; only the usage numbers, response IDs (to drop duplicates) and
6
+ // timestamps are read from it, never a message, a model prompt or a name.
7
+ // The field numbers and the way duplicates are dropped follow ccusage's
8
+ // Antigravity adapter (MIT, rust/adapters/antigravity/src/parser.rs).
9
+
10
+ const fs = require("fs");
11
+ const os = require("os");
12
+ const path = require("path");
13
+ const crypto = require("crypto");
14
+ const { logWarning } = require("./log");
15
+ const { writeFileAtomic, PRIVATE_FILE_MODE } = require("./paths");
16
+
17
+ const USAGE_CACHE_NAME = ".usage-cache.json";
18
+ // Reading databases stops for this refresh past this budget; the cache keeps
19
+ // the totals and the rest is read on the next refresh.
20
+ const READ_BUDGET_MS = 100;
21
+ const DAY_MS = 86400000;
22
+ // How many days back each widget looks, today included.
23
+ const USAGE_PERIODS = { tokensToday: 1, tokensWeek: 7 };
24
+ // Usage metadata is a few hundred bytes; anything this big isn't.
25
+ const MAX_BLOB_BYTES = 4 * 1024 * 1024;
26
+ const MAX_DEPTH = 8;
27
+ const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
28
+
29
+ // Database opens, so tests can check that unchanged files aren't read again.
30
+ const stats = { opens: 0 };
31
+ let memoryCache = null;
32
+ let sqlite;
33
+
34
+ // node:sqlite is built into Node 22.13 / 23.4 and newer. Node 22 prints an
35
+ // ExperimentalWarning on stderr when it loads, which must not reach the bar's
36
+ // stderr: that one warning is swallowed while it loads, every other one goes on.
37
+ function loadSqlite() {
38
+ if (sqlite !== undefined) {
39
+ return sqlite;
40
+ }
41
+ const emitWarning = process.emitWarning;
42
+ process.emitWarning = function (warning, ...rest) {
43
+ const type = typeof rest[0] === "string" ? rest[0] : rest[0]?.type;
44
+ const name = type || warning?.name;
45
+ if (name === "ExperimentalWarning" && /sqlite/i.test(String(warning?.message ?? warning))) {
46
+ return undefined;
47
+ }
48
+ return emitWarning.call(this, warning, ...rest);
49
+ };
50
+ try {
51
+ sqlite = require("node:sqlite");
52
+ } catch {
53
+ sqlite = null;
54
+ } finally {
55
+ process.emitWarning = emitWarning;
56
+ }
57
+ return sqlite;
58
+ }
59
+
60
+ function usageAvailable() {
61
+ return Boolean(loadSqlite()?.DatabaseSync);
62
+ }
63
+
64
+ // A minimal protobuf reader: varints and length-delimited fields, fixed-width
65
+ // fields skipped. Truncated or oversized input, groups and nesting deeper
66
+ // than MAX_DEPTH throw; the database is then skipped for this refresh.
67
+ function readVarint(buf, pos) {
68
+ let value = 0;
69
+ for (let shift = 0; shift < 64; shift += 7) {
70
+ if (pos >= buf.length) {
71
+ throw new Error("truncated varint");
72
+ }
73
+ const byte = buf[pos++];
74
+ value += (byte & 0x7f) * 2 ** shift;
75
+ if (byte < 0x80) {
76
+ return [value, pos];
77
+ }
78
+ }
79
+ throw new Error("varint too long");
80
+ }
81
+
82
+ function decodeMessage(buf, depth = 0) {
83
+ if (depth > MAX_DEPTH) {
84
+ throw new Error("message nested too deep");
85
+ }
86
+ if (!(buf instanceof Uint8Array) || buf.length > MAX_BLOB_BYTES) {
87
+ throw new Error("not a protobuf message");
88
+ }
89
+ const fields = new Map();
90
+ let pos = 0;
91
+ while (pos < buf.length) {
92
+ let key;
93
+ [key, pos] = readVarint(buf, pos);
94
+ const field = Math.floor(key / 8);
95
+ const wire = key % 8;
96
+ let value;
97
+ if (wire === 0) {
98
+ [value, pos] = readVarint(buf, pos);
99
+ } else if (wire === 2) {
100
+ let length;
101
+ [length, pos] = readVarint(buf, pos);
102
+ if (pos + length > buf.length) {
103
+ throw new Error("truncated field");
104
+ }
105
+ value = buf.subarray(pos, pos + length);
106
+ pos += length;
107
+ } else if (wire === 1 || wire === 5) {
108
+ pos += wire === 1 ? 8 : 4;
109
+ if (pos > buf.length) {
110
+ throw new Error("truncated field");
111
+ }
112
+ continue;
113
+ } else {
114
+ throw new Error("unsupported wire type");
115
+ }
116
+ if (field < 1) {
117
+ throw new Error("bad field number");
118
+ }
119
+ if (!fields.has(field)) {
120
+ fields.set(field, []);
121
+ }
122
+ fields.get(field).push(value);
123
+ }
124
+ return { fields, depth };
125
+ }
126
+
127
+ function nested(msg, field) {
128
+ return (msg?.fields.get(field) ?? [])
129
+ .filter((value) => value instanceof Uint8Array)
130
+ .map((value) => decodeMessage(value, msg.depth + 1));
131
+ }
132
+
133
+ function child(msg, field) {
134
+ return nested(msg, field)[0] ?? null;
135
+ }
136
+
137
+ function number(msg, field) {
138
+ const value = msg?.fields.get(field)?.[0];
139
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
140
+ }
141
+
142
+ function text(msg, field) {
143
+ const value = msg?.fields.get(field)?.[0];
144
+ return value instanceof Uint8Array ? Buffer.from(value).toString("utf8") : "";
145
+ }
146
+
147
+ // Timestamp message: 1 = seconds, 2 = nanoseconds.
148
+ function timestamp(msg) {
149
+ const seconds = number(msg, 1);
150
+ return seconds > 0 ? seconds * 1000 + Math.floor(number(msg, 2) / 1e6) : null;
151
+ }
152
+
153
+ // ModelUsage: 2 = input, 3 = total output; 11, 12 and 7 = response, provider
154
+ // message and message IDs, the first one present names the response.
155
+ function usageEntry(msg, ts) {
156
+ if (!msg) {
157
+ return null;
158
+ }
159
+ return {
160
+ input: number(msg, 2),
161
+ output: number(msg, 3),
162
+ id: text(msg, 11) || text(msg, 12) || text(msg, 7),
163
+ ts,
164
+ };
165
+ }
166
+
167
+ // gen_metadata.data: GeneratorMetadata. 1 = chat-model message, whose 4 is
168
+ // the usage, 17 the retries (each with its usage in 2) and 9 the generation
169
+ // info (timestamp in 4).
170
+ function generatorEntries(data) {
171
+ const chat = child(decodeMessage(data), 1);
172
+ const ts = timestamp(child(child(chat, 9), 4));
173
+ return [
174
+ usageEntry(child(chat, 4), ts),
175
+ ...nested(chat, 17).map((r) => usageEntry(child(r, 2), ts)),
176
+ ];
177
+ }
178
+
179
+ // steps.metadata: CortexStepMetadata. 9 = usage, 28 = retries (usage in 2),
180
+ // 8 or else 1 = timestamp.
181
+ function stepEntries(data) {
182
+ const step = decodeMessage(data);
183
+ const ts = timestamp(child(step, 8)) ?? timestamp(child(step, 1));
184
+ return [
185
+ usageEntry(child(step, 9), ts),
186
+ ...nested(step, 28).map((r) => usageEntry(child(r, 2), ts)),
187
+ ];
188
+ }
189
+
190
+ function blob(value) {
191
+ if (value === null || value === undefined) {
192
+ return null;
193
+ }
194
+ if (!(value instanceof Uint8Array)) {
195
+ throw new Error("not a blob");
196
+ }
197
+ return value;
198
+ }
199
+
200
+ const dayFormats = new Map();
201
+
202
+ // "2026-10-18" for a moment, in the time zone given (the machine's own when "").
203
+ function dayKey(ms, timeZone = "") {
204
+ if (!dayFormats.has(timeZone)) {
205
+ dayFormats.set(
206
+ timeZone,
207
+ new Intl.DateTimeFormat("en-US", {
208
+ timeZone: timeZone || undefined,
209
+ year: "numeric",
210
+ month: "2-digit",
211
+ day: "2-digit",
212
+ })
213
+ );
214
+ }
215
+ const parts = {};
216
+ for (const part of dayFormats.get(timeZone).formatToParts(new Date(ms))) {
217
+ parts[part.type] = part.value;
218
+ }
219
+ return `${parts.year}-${parts.month}-${parts.day}`;
220
+ }
221
+
222
+ // Today and the days before it, `count` in all. Half-day steps never skip a
223
+ // day, even across a daylight saving change.
224
+ function recentDays(now, count, timeZone) {
225
+ const keys = new Set();
226
+ for (let t = now; keys.size < count; t -= DAY_MS / 2) {
227
+ keys.add(dayKey(t, timeZone));
228
+ }
229
+ return [...keys];
230
+ }
231
+
232
+ // Input and output tokens per day in one conversation database.
233
+ function readDatabase(file, timeZone, fallbackMs) {
234
+ const db = new sqlite.DatabaseSync(file, { readOnly: true });
235
+ stats.opens += 1;
236
+ try {
237
+ const tables = new Set(
238
+ db
239
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
240
+ .all()
241
+ .map((row) => row.name)
242
+ );
243
+ if (!tables.has("gen_metadata")) {
244
+ throw new Error("no gen_metadata table");
245
+ }
246
+
247
+ // The conversation's own time, for rows that carry none.
248
+ let conversationTs = null;
249
+ if (tables.has("trajectory_metadata_blob")) {
250
+ for (const row of db.prepare("SELECT data FROM trajectory_metadata_blob").all()) {
251
+ const data = blob(row.data);
252
+ conversationTs = (data && timestamp(child(decodeMessage(data), 2))) ?? conversationTs;
253
+ }
254
+ }
255
+
256
+ const entries = [];
257
+ for (const row of db.prepare("SELECT idx, data FROM gen_metadata ORDER BY idx ASC").all()) {
258
+ const data = blob(row.data);
259
+ if (data) {
260
+ entries.push(...generatorEntries(data));
261
+ }
262
+ }
263
+ if (tables.has("steps")) {
264
+ for (const row of db.prepare("SELECT idx, metadata FROM steps ORDER BY idx ASC").all()) {
265
+ const data = blob(row.metadata);
266
+ if (data) {
267
+ entries.push(...stepEntries(data));
268
+ }
269
+ }
270
+ }
271
+
272
+ // The same response can be in both tables and among the retries.
273
+ const seen = new Set();
274
+ const days = {};
275
+ for (const entry of entries) {
276
+ if (!entry || entry.input + entry.output <= 0) {
277
+ continue;
278
+ }
279
+ if (entry.id) {
280
+ if (seen.has(entry.id)) {
281
+ continue;
282
+ }
283
+ seen.add(entry.id);
284
+ }
285
+ const day = dayKey(entry.ts ?? conversationTs ?? fallbackMs, timeZone);
286
+ days[day] = days[day] || { input: 0, output: 0 };
287
+ days[day].input += entry.input;
288
+ days[day].output += entry.output;
289
+ }
290
+ return days;
291
+ } finally {
292
+ db.close();
293
+ }
294
+ }
295
+
296
+ function conversationsDir() {
297
+ return path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
298
+ }
299
+
300
+ // agy may still have recent writes in the -wal file next to the database.
301
+ function signature(file) {
302
+ try {
303
+ const stat = fs.statSync(file);
304
+ if (!stat.isFile()) {
305
+ return null;
306
+ }
307
+ let mtimeMs = stat.mtimeMs;
308
+ let size = stat.size;
309
+ try {
310
+ const wal = fs.statSync(`${file}-wal`);
311
+ mtimeMs = Math.max(mtimeMs, wal.mtimeMs);
312
+ size += wal.size;
313
+ } catch {
314
+ // No write-ahead log.
315
+ }
316
+ return { mtimeMs, size };
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ // The file name is a conversation ID: the cache only keeps a hash of the path.
323
+ function fileKey(file) {
324
+ return crypto.createHash("sha256").update(file).digest("hex").slice(0, 16);
325
+ }
326
+
327
+ // Only next to a real install's config, like the git caches.
328
+ function cachePath() {
329
+ try {
330
+ const { getConfigDir } = require("./config");
331
+ const dir = getConfigDir();
332
+ return dir && fs.existsSync(path.join(dir, "statusline.config.json"))
333
+ ? path.join(dir, USAGE_CACHE_NAME)
334
+ : null;
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ function validDays(days) {
341
+ return (
342
+ days !== null &&
343
+ typeof days === "object" &&
344
+ !Array.isArray(days) &&
345
+ Object.entries(days).every(
346
+ ([day, tokens]) =>
347
+ DAY_KEY.test(day) &&
348
+ ["input", "output"].every(
349
+ (key) => Number.isFinite(tokens?.[key]) && tokens[key] >= 0 && tokens[key] <= 1e15
350
+ )
351
+ )
352
+ );
353
+ }
354
+
355
+ function validEntry(entry) {
356
+ return Number.isFinite(entry?.mtimeMs) && Number.isFinite(entry?.size) && validDays(entry.days);
357
+ }
358
+
359
+ // The cache file can be edited or torn like any file: bad entries are dropped.
360
+ function readCache(file) {
361
+ let data = memoryCache;
362
+ if (file) {
363
+ try {
364
+ data = JSON.parse(fs.readFileSync(file, "utf8"));
365
+ } catch {
366
+ data = null;
367
+ }
368
+ }
369
+ const files = {};
370
+ if (data?.files && typeof data.files === "object") {
371
+ for (const [key, entry] of Object.entries(data.files)) {
372
+ if (/^[0-9a-f]{16}$/.test(key) && validEntry(entry)) {
373
+ files[key] = entry;
374
+ }
375
+ }
376
+ }
377
+ return { tz: typeof data?.tz === "string" ? data.tz : "", files };
378
+ }
379
+
380
+ function saveCache(file, cache) {
381
+ memoryCache = cache;
382
+ if (!file) {
383
+ return;
384
+ }
385
+ try {
386
+ writeFileAtomic(file, JSON.stringify(cache), { mode: PRIVATE_FILE_MODE });
387
+ } catch {
388
+ // Best effort: the next refresh reads the databases again.
389
+ }
390
+ }
391
+
392
+ // Reads the databases changed since the cache was written, within the time
393
+ // budget. Returns whether the cache changed.
394
+ function refresh(cache, periodDays, timeZone) {
395
+ let names;
396
+ try {
397
+ names = fs.readdirSync(conversationsDir());
398
+ } catch {
399
+ names = [];
400
+ }
401
+
402
+ const now = Date.now();
403
+ // A day of margin for time zones.
404
+ const cutoff = now - (periodDays + 1) * DAY_MS;
405
+ const files = {};
406
+ let changed = false;
407
+
408
+ for (const name of names) {
409
+ if (!name.endsWith(".db")) {
410
+ continue;
411
+ }
412
+ const file = path.join(conversationsDir(), name);
413
+ const sig = signature(file);
414
+ if (!sig || sig.mtimeMs < cutoff) {
415
+ continue;
416
+ }
417
+ const key = fileKey(file);
418
+ const old = cache.files[key];
419
+ if (old && old.mtimeMs === sig.mtimeMs && old.size === sig.size) {
420
+ files[key] = old;
421
+ continue;
422
+ }
423
+ if (Date.now() - now > READ_BUDGET_MS) {
424
+ if (old) {
425
+ files[key] = old;
426
+ }
427
+ continue;
428
+ }
429
+ try {
430
+ files[key] = { ...sig, days: readDatabase(file, timeZone, sig.mtimeMs) };
431
+ changed = true;
432
+ } catch (err) {
433
+ // Busy, locked, not agy's format: skipped until it changes again. No
434
+ // path or ID in the log.
435
+ const code = typeof err?.code === "string" && /^[A-Z_]{1,40}$/.test(err.code) ? err.code : "";
436
+ logWarning("USAGE_DB_UNREADABLE", code ? { code } : {});
437
+ if (old) {
438
+ files[key] = old;
439
+ }
440
+ }
441
+ }
442
+
443
+ changed = changed || Object.keys(files).length !== Object.keys(cache.files).length;
444
+ cache.files = files;
445
+ return changed;
446
+ }
447
+
448
+ function sum(cache, days) {
449
+ const total = { input: 0, output: 0 };
450
+ for (const entry of Object.values(cache.files)) {
451
+ for (const day of days) {
452
+ total.input += entry.days[day]?.input ?? 0;
453
+ total.output += entry.days[day]?.output ?? 0;
454
+ }
455
+ }
456
+ return total;
457
+ }
458
+
459
+ function machineZone() {
460
+ try {
461
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
462
+ } catch {
463
+ return "";
464
+ }
465
+ }
466
+
467
+ // { today, week } tokens ({ input, output } each), or null when no usage
468
+ // widget is on the bar or node:sqlite is missing. Only the statusline itself
469
+ // reads databases (read: true); previews use what the cache holds.
470
+ function resolveUsage(config, { read = false } = {}) {
471
+ const periodDays = Math.max(
472
+ 0,
473
+ ...(config?.widgets ?? []).map((widget) => USAGE_PERIODS[widget?.id] ?? 0)
474
+ );
475
+ if (!periodDays) {
476
+ return null;
477
+ }
478
+
479
+ try {
480
+ const timeZone = config?.clock?.timeZone || "";
481
+ // Days are counted in this zone, so a cache made in another one is stale.
482
+ const zone = timeZone || machineZone();
483
+ const file = cachePath();
484
+ let cache = readCache(file);
485
+ if (cache.tz !== zone) {
486
+ cache = { tz: zone, files: {} };
487
+ }
488
+
489
+ if (read) {
490
+ if (!usageAvailable()) {
491
+ return null;
492
+ }
493
+ if (refresh(cache, periodDays, timeZone)) {
494
+ saveCache(file, cache);
495
+ }
496
+ }
497
+
498
+ const now = Date.now();
499
+ return {
500
+ today: sum(cache, recentDays(now, 1, timeZone)),
501
+ week: sum(cache, recentDays(now, 7, timeZone)),
502
+ };
503
+ } catch {
504
+ return null;
505
+ }
506
+ }
507
+
508
+ function clearUsageCache() {
509
+ memoryCache = null;
510
+ }
511
+
512
+ module.exports = {
513
+ USAGE_CACHE_NAME,
514
+ USAGE_PERIODS,
515
+ READ_BUDGET_MS,
516
+ stats,
517
+ loadSqlite,
518
+ usageAvailable,
519
+ decodeMessage,
520
+ dayKey,
521
+ recentDays,
522
+ resolveUsage,
523
+ clearUsageCache,
524
+ };