faberwright 0.4.0 → 0.4.2

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/sigv4.js CHANGED
@@ -93,7 +93,7 @@ export async function discoverAwsCredentials(profileName = process.env.AWS_PROFI
93
93
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
94
94
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
95
95
  sessionToken: process.env.AWS_SESSION_TOKEN,
96
- source: "environment",
96
+ source: "your environment",
97
97
  };
98
98
  }
99
99
  // 2. container credential endpoint (ECS, SageMaker, CodeBuild)
@@ -130,7 +130,7 @@ async function fetchContainerCredentials(url) {
130
130
  accessKeyId: b.AccessKeyId,
131
131
  secretAccessKey: b.SecretAccessKey,
132
132
  sessionToken: b.Token,
133
- source: "container credentials endpoint",
133
+ source: "this environment's role",
134
134
  };
135
135
  }
136
136
  catch {
@@ -175,3 +175,51 @@ export function readSharedCredentials(profileName = "default", file = path.join(
175
175
  source: `~/.aws/credentials [${wanted}]`,
176
176
  };
177
177
  }
178
+ /**
179
+ * Find the AWS region the way the SDKs do.
180
+ *
181
+ * Credentials don't carry a region, but the endpoint and the signature both
182
+ * need one — so it has to come from somewhere. In SageMaker, Lambda and ECS
183
+ * it's already in the environment, and on a laptop it's usually in the config
184
+ * file, which means asking is normally an unnecessary question.
185
+ */
186
+ export function discoverAwsRegion(profileName = process.env.AWS_PROFILE ?? "default", file = path.join(os.homedir(), ".aws", "config")) {
187
+ const env = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION;
188
+ if (env) {
189
+ return {
190
+ region: env,
191
+ source: process.env.AWS_REGION ? "AWS_REGION" : "AWS_DEFAULT_REGION",
192
+ };
193
+ }
194
+ // ~/.aws/config uses "[profile name]" for everything except default
195
+ let text;
196
+ try {
197
+ text = fs.readFileSync(file, "utf8");
198
+ }
199
+ catch {
200
+ return undefined;
201
+ }
202
+ const wanted = profileName === "default" ? "default" : `profile ${profileName}`;
203
+ let current = "";
204
+ for (const raw of text.split("\n")) {
205
+ const line = raw.split(/[#;]/)[0].trim();
206
+ if (!line)
207
+ continue;
208
+ const header = /^\[(.+)\]$/.exec(line);
209
+ if (header) {
210
+ current = header[1].trim();
211
+ continue;
212
+ }
213
+ if (current !== wanted)
214
+ continue;
215
+ const eq = line.indexOf("=");
216
+ if (eq === -1)
217
+ continue;
218
+ if (line.slice(0, eq).trim().toLowerCase() === "region") {
219
+ const region = line.slice(eq + 1).trim();
220
+ if (region)
221
+ return { region, source: `~/.aws/config [${profileName}]` };
222
+ }
223
+ }
224
+ return undefined;
225
+ }
package/dist/usage.js CHANGED
@@ -9,7 +9,7 @@ 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, priceAgeDays, STALE_AFTER_DAYS } from "./pricing.js";
12
+ import { priceFor, cacheReadPrice, cacheWritePrice, PRICES_AS_OF, readPriceCache } from "./pricing.js";
13
13
  export class UsageLedger {
14
14
  projectPath;
15
15
  db;
@@ -34,6 +34,11 @@ export class UsageLedger {
34
34
  }
35
35
  catch { /* already migrated */ }
36
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 */ }
37
42
  this.registerProject();
38
43
  }
39
44
  /**
@@ -146,100 +151,140 @@ export class UsageLedger {
146
151
  const k = (n) => n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + "M"
147
152
  : n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
148
153
  const money = (v) => v === undefined ? "—" : `$${v.toFixed(2)}`;
154
+ /** Cents below a dollar — 3.4¢ 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;
149
169
  /**
150
- * Render the /usage panel. Each row is priced per model using that model's own
151
- * rates, so a history that spans a switch from Sonnet to Opus stays accurate.
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.
152
175
  */
153
- export function renderUsagePanel(ledger, override) {
154
- const midnight = new Date();
176
+ function windows(now) {
177
+ const midnight = new Date(now);
155
178
  midnight.setHours(0, 0, 0, 0);
156
- const windows = [
157
- ["this session", undefined],
179
+ return [
158
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],
159
186
  ["all time", 0],
160
187
  ];
161
- const lines = [];
162
- const W = [13, 7, 12, 9, 7, 9, 9];
163
- const cells = (c) => c.map((s, i) => s.padEnd(W[i])).join(" ");
164
- lines.push(cells(["", "tasks", "in", "cached", "out", "cost", "saved"]));
165
- let anyPriced = false;
166
- let legacyRows = 0;
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;
167
195
  let estimated = false;
168
- for (const [label, since] of windows) {
169
- // session totals come from memory; the rest are priced per model from disk
170
- // Every window is priced per model — including the session, which would
171
- // otherwise apply the last-used model's rates to earlier tasks.
172
- const bands = ledger.totalsByModel(since ?? ledger.startedAt);
173
- const agg = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, tasks: 0 };
174
- let cost;
175
- let saved;
176
- for (const b of bands) {
177
- agg.input += b.totals.input;
178
- agg.cacheRead += b.totals.cacheRead;
179
- agg.cacheWrite += b.totals.cacheWrite;
180
- agg.output += b.totals.output;
181
- agg.calls += b.totals.calls;
182
- agg.tasks += b.totals.tasks;
183
- // Historical cost is whatever was charged at the time — never repriced.
184
- if (b.billed.cost !== null) {
185
- cost = (cost ?? 0) + b.billed.cost;
186
- anyPriced = true;
187
- }
188
- if (b.billed.saved !== null)
189
- saved = (saved ?? 0) + b.billed.saved;
190
- if (b.billed.unpriced > 0) {
191
- // Rows recorded before costs were stored have nothing to preserve, so
192
- // estimate them at today's prices and flag the row as an estimate.
193
- legacyRows += b.billed.unpriced;
194
- const p = priceFor(b.model, override);
195
- const c = UsageLedger.cost(b.totals, p);
196
- const s = UsageLedger.saved(b.totals, p);
197
- if (c !== undefined) {
198
- cost = (cost ?? 0) + c;
199
- anyPriced = true;
200
- estimated = true;
201
- }
202
- if (s !== undefined)
203
- saved = (saved ?? 0) + s;
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;
204
217
  }
218
+ if (s !== undefined)
219
+ saved = (saved ?? 0) + s;
205
220
  }
206
- const totalIn = agg.input + agg.cacheRead + agg.cacheWrite;
207
- const pct = totalIn > 0 ? Math.round((agg.cacheRead / totalIn) * 100) + "%" : "0%";
208
- lines.push(cells([label, String(agg.tasks), k(totalIn), pct, k(agg.output),
209
- money(cost), money(saved)]));
210
221
  }
211
- if (anyPriced) {
212
- const src = readPriceCache();
213
- lines.push("");
214
- lines.push("cost is what each task was charged when it ran — later price changes don't rewrite it");
215
- const age = priceAgeDays();
216
- if (src && age !== undefined) {
217
- const when = new Date(src.fetchedAt).toISOString().slice(0, 10);
218
- lines.push(age > STALE_AFTER_DAYS
219
- ? `new tasks priced from rates fetched ${when} (${Math.round(age)} days ago — consider /usage --refresh-prices)`
220
- : `new tasks priced from rates fetched ${when}`);
221
- }
222
- else {
223
- lines.push(`new tasks priced from built-in rates, as of ${PRICES_AS_OF} — /usage --refresh-prices for current`);
224
- }
225
- if (legacyRows) {
226
- lines.push(`${legacyRows} older task(s) predate cost tracking${estimated ? " — estimated at today's rates" : ""}`);
227
- }
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),
248
+ ]));
249
+ if (label === "all time")
250
+ allTime = r;
228
251
  }
229
- else {
230
- lines.push("");
231
- lines.push("no price known for this model — set FABER_PRICE_IN / FABER_PRICE_OUT");
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" : ""}`);
232
268
  }
233
- const others = UsageLedger.allProjects().filter((p) => p.totals.tasks > 0);
234
- 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) {
235
279
  lines.push("");
236
- lines.push("across all projects:");
237
- for (const { project, totals: t } of others) {
238
- const totalIn = t.input + t.cacheRead + t.cacheWrite;
239
- lines.push(cells([
240
- " " + path.basename(project).slice(0, 11), String(t.tasks), k(totalIn),
241
- totalIn > 0 ? Math.round((t.cacheRead / totalIn) * 100) + "%" : "0%",
242
- k(t.output), "", "",
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) : "—",
243
288
  ]));
244
289
  }
245
290
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberwright",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",