faberwright 0.3.1 → 0.4.1

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/usage.js CHANGED
@@ -9,10 +9,15 @@ import * as fs from "node:fs";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import { DatabaseSync } from "node:sqlite";
12
+ import { priceFor, cacheReadPrice, cacheWritePrice, PRICES_AS_OF, readPriceCache } from "./pricing.js";
12
13
  export class UsageLedger {
13
14
  projectPath;
14
15
  db;
15
16
  session = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, tasks: 0 };
17
+ /** Model used by the most recent task. */
18
+ currentModel = "";
19
+ /** When this process started, so the session row can be priced per model. */
20
+ sessionStart = Date.now();
16
21
  constructor(dbPath, projectPath) {
17
22
  this.projectPath = projectPath;
18
23
  this.db = new DatabaseSync(dbPath);
@@ -20,11 +25,33 @@ export class UsageLedger {
20
25
  this.db.exec(`CREATE TABLE IF NOT EXISTS tasks (
21
26
  ts INTEGER, input INTEGER, cache_read INTEGER, cache_write INTEGER,
22
27
  output INTEGER, calls INTEGER, model TEXT)`);
28
+ // Cost is recorded when the task runs, at the prices in effect then.
29
+ // Prices change; money already spent does not. Ledgers created before this
30
+ // column existed keep NULL and are priced at display time as a fallback.
31
+ for (const col of ["cost REAL", "saved REAL"]) {
32
+ try {
33
+ this.db.exec(`ALTER TABLE tasks ADD COLUMN ${col}`);
34
+ }
35
+ catch { /* already migrated */ }
36
+ }
37
+ // Seven time windows each filter on ts, so make that lookup cheap.
38
+ try {
39
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_ts ON tasks(ts)");
40
+ }
41
+ catch { /* fine */ }
23
42
  this.registerProject();
24
43
  }
25
- record(u, model) {
26
- this.db.prepare("INSERT INTO tasks VALUES (?, ?, ?, ?, ?, ?, ?)")
27
- .run(Date.now(), u.input, u.cacheRead, u.cacheWrite, u.output, u.calls, model);
44
+ /**
45
+ * Record a completed task. Cost is computed and stored NOW, using the prices
46
+ * in effect at this moment — later price changes never rewrite it.
47
+ */
48
+ record(u, model, override) {
49
+ this.currentModel = model;
50
+ const p = priceFor(model, override);
51
+ const cost = UsageLedger.cost(u, p) ?? null;
52
+ const saved = UsageLedger.saved(u, p) ?? null;
53
+ this.db.prepare("INSERT INTO tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
54
+ .run(Date.now(), u.input, u.cacheRead, u.cacheWrite, u.output, u.calls, model, cost, saved);
28
55
  this.session.input += u.input;
29
56
  this.session.cacheRead += u.cacheRead;
30
57
  this.session.cacheWrite += u.cacheWrite;
@@ -38,18 +65,38 @@ export class UsageLedger {
38
65
  COALESCE(SUM(calls),0) calls
39
66
  FROM tasks WHERE ts >= ?`).get(sinceMs ?? 0);
40
67
  }
41
- /** Cost in USD; undefined when CW_PRICE_IN/OUT aren't configured. */
42
- static cost(u, priceIn, priceOut) {
43
- if (!priceIn || !priceOut)
68
+ /** Cost in USD for one usage record at a given model's prices. */
69
+ static cost(u, p) {
70
+ if (!p)
44
71
  return undefined;
45
- return (u.input * priceIn + u.cacheRead * priceIn * 0.1 +
46
- u.cacheWrite * priceIn * 1.25 + u.output * priceOut) / 1e6;
72
+ return (u.input * p.in + u.cacheRead * cacheReadPrice(p) +
73
+ u.cacheWrite * cacheWritePrice(p) + u.output * p.out) / 1e6;
47
74
  }
48
- /** Net savings from caching vs paying full input price (reads at 0.1x minus write premium). */
49
- static saved(u, priceIn) {
50
- if (!priceIn)
75
+ /** What caching saved vs paying full input price, net of the write premium. */
76
+ static saved(u, p) {
77
+ if (!p)
51
78
  return undefined;
52
- return (u.cacheRead * priceIn * 0.9 - u.cacheWrite * priceIn * 0.25) / 1e6;
79
+ const readSaving = u.cacheRead * (p.in - cacheReadPrice(p));
80
+ const writePremium = u.cacheWrite * (cacheWritePrice(p) - p.in);
81
+ return (readSaving - writePremium) / 1e6;
82
+ }
83
+ /**
84
+ * Totals split by model, so a history spanning several models is priced with
85
+ * each model's own rates rather than whatever is loaded right now.
86
+ */
87
+ get startedAt() { return this.sessionStart; }
88
+ totalsByModel(sinceMs) {
89
+ const rows = this.db.prepare(`SELECT model, COUNT(*) tasks, COALESCE(SUM(input),0) input,
90
+ COALESCE(SUM(cache_read),0) cacheRead, COALESCE(SUM(cache_write),0) cacheWrite,
91
+ COALESCE(SUM(output),0) output, COALESCE(SUM(calls),0) calls,
92
+ SUM(cost) billedCost, SUM(saved) billedSaved,
93
+ SUM(CASE WHEN cost IS NULL THEN 1 ELSE 0 END) unpriced
94
+ FROM tasks WHERE ts >= ? GROUP BY model`).all(sinceMs ?? 0);
95
+ return rows.map((r) => ({
96
+ model: r.model,
97
+ totals: r,
98
+ billed: { cost: r.billedCost, saved: r.billedSaved, unpriced: r.unpriced },
99
+ }));
53
100
  }
54
101
  // ---- global registry so usage can be summed across every project ----
55
102
  registerProject() {
@@ -104,42 +151,140 @@ export class UsageLedger {
104
151
  const k = (n) => n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + "M"
105
152
  : n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
106
153
  const money = (v) => v === undefined ? "—" : `$${v.toFixed(2)}`;
107
- /** Render the /usage stats panel. */
108
- export function renderUsagePanel(ledger, priceIn, priceOut) {
109
- const midnight = new Date();
154
+ /** Cents below a dollar — 3. reads better than $0.034. */
155
+ const cents = (usd) => usd < 1 ? `${(usd * 100).toFixed(1)}¢` : `$${usd.toFixed(2)}`;
156
+ /** "gpt-5.3-codex", not a 40-character regional deployment id. */
157
+ function shortModel(id) {
158
+ return id
159
+ .replace(/^(global|us|eu|apac|au|jp)\./, "")
160
+ .replace(/^anthropic\./, "")
161
+ .replace(/-v\d+:\d+$/, "")
162
+ // "claude-haiku-4-5-20251001" -> "haiku-4-5": the vendor is already known
163
+ // from the route, and a mid-word truncation reads as a different model.
164
+ .replace(/^claude-/, "")
165
+ .replace(/-\d{8}$/, "")
166
+ .slice(0, 15);
167
+ }
168
+ const DAY = 86_400_000;
169
+ /**
170
+ * Rolling windows, not calendar ones.
171
+ *
172
+ * "This month" means one day on the 1st and thirty on the 31st, and it resets
173
+ * overnight — two figures side by side aren't comparable. Rolling windows
174
+ * always cover the span they name.
175
+ */
176
+ function windows(now) {
177
+ const midnight = new Date(now);
110
178
  midnight.setHours(0, 0, 0, 0);
111
- const rows = [
112
- ["this session", ledger.session],
113
- ["today", ledger.totals(midnight.getTime())],
114
- ["all time", ledger.totals()],
179
+ return [
180
+ ["today", midnight.getTime()],
181
+ ["last 7 days", now - 7 * DAY],
182
+ ["last 14 days", now - 14 * DAY],
183
+ ["last 30 days", now - 30 * DAY],
184
+ ["last 3 months", now - 91 * DAY],
185
+ ["last 6 months", now - 182 * DAY],
186
+ ["all time", 0],
115
187
  ];
116
- const lines = [];
117
- const W = [13, 7, 12, 9, 7, 9, 9];
118
- const cells = (c) => c.map((s, i) => s.padEnd(W[i])).join(" ");
119
- lines.push(cells(["", "tasks", "in", "cached", "out", "cost", "saved"]));
120
- for (const [label, t] of rows) {
121
- const totalIn = t.input + t.cacheRead + t.cacheWrite;
122
- const pct = totalIn > 0 ? Math.round((t.cacheRead / totalIn) * 100) + "%" : "0%";
123
- lines.push(cells([
124
- label, String(t.tasks), k(totalIn), pct, k(t.output),
125
- money(UsageLedger.cost(t, priceIn, priceOut)),
126
- money(UsageLedger.saved(t, priceIn)),
188
+ }
189
+ /** Sum a set of per-model bands into one row, pricing anything unrecorded. */
190
+ function rollUp(bands, override) {
191
+ const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, tasks: 0 };
192
+ let cost;
193
+ let saved;
194
+ let unpriced = 0;
195
+ let estimated = false;
196
+ for (const b of bands) {
197
+ totals.input += b.totals.input;
198
+ totals.cacheRead += b.totals.cacheRead;
199
+ totals.cacheWrite += b.totals.cacheWrite;
200
+ totals.output += b.totals.output;
201
+ totals.calls += b.totals.calls;
202
+ totals.tasks += b.totals.tasks;
203
+ if (b.billed.cost !== null)
204
+ cost = (cost ?? 0) + b.billed.cost;
205
+ if (b.billed.saved !== null)
206
+ saved = (saved ?? 0) + b.billed.saved;
207
+ if (b.billed.unpriced > 0) {
208
+ // Tasks recorded before costs were stored have nothing to preserve, so
209
+ // estimate them at today's rates rather than showing a gap.
210
+ unpriced += b.billed.unpriced;
211
+ const p = priceFor(b.model, override);
212
+ const c = UsageLedger.cost(b.totals, p);
213
+ const s = UsageLedger.saved(b.totals, p);
214
+ if (c !== undefined) {
215
+ cost = (cost ?? 0) + c;
216
+ estimated = true;
217
+ }
218
+ if (s !== undefined)
219
+ saved = (saved ?? 0) + s;
220
+ }
221
+ }
222
+ return { totals, cost, saved, unpriced, estimated };
223
+ }
224
+ /**
225
+ * The /usage panel for this project.
226
+ *
227
+ * Seven fixed windows, never collapsed even when identical: a missing row
228
+ * would read as "no data" when it actually means "nothing new since", and
229
+ * that distinction is exactly what someone returning after a break wants.
230
+ *
231
+ * The two columns nobody can interpret unaided — cached and saved — are
232
+ * defined underneath. A metric a reader has to guess at gets ignored.
233
+ */
234
+ export function renderUsagePanel(ledger, override, now = Date.now()) {
235
+ const W = [15, 7, 9, 8, 7, 8, 9];
236
+ const cell = (c) => c.map((s, i) => (i === c.length - 1 ? s : s.padEnd(W[i]))).join(" ").trimEnd();
237
+ const pad = " ".repeat(W[0]);
238
+ const lines = [path.basename(ledger.projectPath), ""];
239
+ lines.push(cell(["", "tasks", "in", "cached", "out", "cost", "saved"]));
240
+ let allTime = rollUp([], override);
241
+ for (const [label, since] of windows(now)) {
242
+ const r = rollUp(ledger.totalsByModel(since), override);
243
+ const totalIn = r.totals.input + r.totals.cacheRead + r.totals.cacheWrite;
244
+ lines.push(cell([
245
+ label, String(r.totals.tasks), k(totalIn),
246
+ totalIn > 0 ? `${Math.round((r.totals.cacheRead / totalIn) * 100)}%` : "0%",
247
+ k(r.totals.output), money(r.cost), money(r.saved),
127
248
  ]));
249
+ if (label === "all time")
250
+ allTime = r;
128
251
  }
129
- if (!priceIn || !priceOut) {
130
- lines.push("");
131
- lines.push("set CW_PRICE_IN and CW_PRICE_OUT ($/Mtok) to see cost and savings");
252
+ lines.push("");
253
+ if (allTime.cost !== undefined && allTime.totals.tasks > 0) {
254
+ lines.push(pad + `${cents(allTime.cost / allTime.totals.tasks)} per task`);
255
+ }
256
+ lines.push(pad + "cached = input reused from cache, billed at ~10%");
257
+ if (allTime.cost !== undefined && allTime.saved !== undefined && allTime.saved > 0) {
258
+ lines.push(pad +
259
+ `saved = caching cost you ${money(allTime.cost)} instead of ${money(allTime.cost + allTime.saved)}`);
260
+ }
261
+ const src = readPriceCache();
262
+ lines.push(pad + (src
263
+ ? `rates ${new Date(src.fetchedAt).toISOString().slice(0, 10)} · /usage --refresh-prices`
264
+ : `built-in rates, as of ${PRICES_AS_OF} · /usage --refresh-prices`));
265
+ if (allTime.unpriced > 0) {
266
+ lines.push(pad +
267
+ `${allTime.unpriced} older task(s) predate cost tracking${allTime.estimated ? ", estimated" : ""}`);
132
268
  }
133
- const others = UsageLedger.allProjects().filter((p) => p.totals.tasks > 0);
134
- if (others.length > 1) {
269
+ // Where the money goes. Only models actually used appear, because this is
270
+ // built from recorded tasks rather than a catalogue.
271
+ const byModel = ledger.totalsByModel(0)
272
+ .filter((b) => b.totals.tasks > 0)
273
+ .map((b) => {
274
+ const r = rollUp([b], override);
275
+ return { model: b.model, totals: r.totals, cost: r.cost };
276
+ })
277
+ .sort((a, b) => (b.cost ?? 0) - (a.cost ?? 0));
278
+ if (byModel.length > 1) {
135
279
  lines.push("");
136
- lines.push("across all projects:");
137
- for (const { project, totals: t } of others) {
138
- const totalIn = t.input + t.cacheRead + t.cacheWrite;
139
- lines.push(cells([
140
- " " + path.basename(project).slice(0, 11), String(t.tasks), k(totalIn),
141
- totalIn > 0 ? Math.round((t.cacheRead / totalIn) * 100) + "%" : "0%",
142
- k(t.output), money(UsageLedger.cost(t, priceIn, priceOut)), "",
280
+ lines.push(cell(["by model", "tasks", "in", "cached", "out", "cost", "per task"]));
281
+ for (const b of byModel) {
282
+ const totalIn = b.totals.input + b.totals.cacheRead + b.totals.cacheWrite;
283
+ lines.push(cell([
284
+ shortModel(b.model), String(b.totals.tasks), k(totalIn),
285
+ totalIn > 0 ? `${Math.round((b.totals.cacheRead / totalIn) * 100)}%` : "0%",
286
+ k(b.totals.output), money(b.cost),
287
+ b.cost !== undefined ? cents(b.cost / b.totals.tasks) : "—",
143
288
  ]));
144
289
  }
145
290
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberwright",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Faber: an agentic AI coding assistant for your terminal — streams, edits with diff approval, runs your tests, and remembers your project across sessions.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",