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/CHANGELOG.md +72 -0
- package/README.fr.md +243 -0
- package/README.ja.md +144 -49
- package/README.md +160 -52
- package/README.zh-CN.md +144 -49
- package/package.json +5 -2
- package/src/cli/caps.js +2 -0
- package/src/cli/configure.js +70 -172
- package/src/cli/doctor.js +12 -0
- package/src/cli/export.js +82 -0
- package/src/cli/gradient.js +121 -0
- package/src/cli/import.js +273 -0
- package/src/cli/init.js +186 -7
- package/src/cli/layout.js +2 -0
- package/src/cli/preset.js +2 -0
- package/src/cli/repo-url.js +7 -0
- package/src/cli/theme.js +2 -0
- package/src/cli/uninstall.js +6 -2
- package/src/index.js +53 -1
- package/src/lib/catalog.js +54 -2
- package/src/lib/colors.js +144 -14
- package/src/lib/config-check.js +74 -0
- package/src/lib/config.js +89 -21
- package/src/lib/exec.js +140 -21
- package/src/lib/fit.js +7 -0
- package/src/lib/formatters.js +67 -0
- package/src/lib/git.js +385 -4
- package/src/lib/layouts/lines.js +175 -28
- package/src/lib/layouts/pills.js +232 -33
- package/src/lib/paths.js +73 -13
- package/src/lib/prompt.js +30 -0
- package/src/lib/run.js +2 -0
- package/src/lib/session.js +17 -3
- package/src/lib/shared.js +13 -2
- package/src/lib/themes.js +79 -5
- package/src/lib/usage.js +524 -0
- package/src/lib/widget-options.js +194 -0
- package/src/lib/widget-style.js +45 -0
- package/src/runtime/statusline.js +15 -1
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(
|
|
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
|
-
|
|
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
|
|
13
|
-
// (credited below). A few colors are adjusted where the
|
|
14
|
-
// it: darkened or lightened for contrast, and gitDirty
|
|
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
|
};
|
package/src/lib/usage.js
ADDED
|
@@ -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
|
+
};
|