faberwright 0.4.0 → 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/pricing.js CHANGED
@@ -50,6 +50,14 @@ export function normalizeModelId(id) {
50
50
  function cacheFile() {
51
51
  return path.join(os.homedir(), ".faber", "cache", "prices.json");
52
52
  }
53
+ /**
54
+ * Bumped whenever the cached shape gains a field Faber relies on. A cache
55
+ * written by an older version is discarded rather than trusted: after adding
56
+ * `mode` and `supported_endpoints`, a stale file looked complete but carried
57
+ * neither, so Responses-only models were routed to the wrong endpoint and
58
+ * failed with a 404 that looked like a Faber bug.
59
+ */
60
+ export const PRICE_CACHE_VERSION = 2;
53
61
  /** Wait this long before retrying after a failed refresh. */
54
62
  export const RETRY_AFTER_FAILURE_MS = 24 * 60 * 60 * 1000;
55
63
  /** How old the cached prices are, in days. undefined = never fetched. */
@@ -66,7 +74,11 @@ export function pricesAreStale(now = Date.now()) {
66
74
  export function readPriceCache() {
67
75
  try {
68
76
  const raw = JSON.parse(fs.readFileSync(cacheFile(), "utf8"));
69
- return raw.prices && typeof raw.prices === "object" ? raw : undefined;
77
+ if (!raw.prices || typeof raw.prices !== "object")
78
+ return undefined;
79
+ if ((raw.version ?? 1) < PRICE_CACHE_VERSION)
80
+ return undefined; // stale shape
81
+ return raw;
70
82
  }
71
83
  catch {
72
84
  return undefined;
@@ -77,7 +89,7 @@ export function writePriceCache(prices, source, etag) {
77
89
  const f = cacheFile();
78
90
  fs.mkdirSync(path.dirname(f), { recursive: true });
79
91
  const now = Date.now();
80
- fs.writeFileSync(f, JSON.stringify({ fetchedAt: now, lastAttempt: now, source, etag, prices }, null, 2));
92
+ fs.writeFileSync(f, JSON.stringify({ version: PRICE_CACHE_VERSION, fetchedAt: now, lastAttempt: now, source, etag, prices }, null, 2));
81
93
  }
82
94
  catch { /* best effort */ }
83
95
  }
@@ -103,6 +115,7 @@ export function markRefreshAttempt(now = Date.now()) {
103
115
  fs.mkdirSync(path.dirname(f), { recursive: true });
104
116
  const existing = readPriceCache();
105
117
  fs.writeFileSync(f, JSON.stringify({
118
+ version: PRICE_CACHE_VERSION,
106
119
  fetchedAt: existing?.fetchedAt ?? 0,
107
120
  lastAttempt: now,
108
121
  source: existing?.source ?? "none",
@@ -214,6 +227,11 @@ export async function refreshPrices(url = DATASET_URL, opts = {}) {
214
227
  // and these values get written to a file people read.
215
228
  const perM = (n) => Math.round(n * 1e6 * 1e6) / 1e6;
216
229
  const price = { in: perM(inC), out: perM(outC) };
230
+ if (typeof v["mode"] === "string")
231
+ price.mode = v["mode"];
232
+ const eps = v["supported_endpoints"];
233
+ if (Array.isArray(eps))
234
+ price.endpoints = eps.filter((e) => typeof e === "string");
217
235
  const cr = v["cache_read_input_token_cost"], cw = v["cache_creation_input_token_cost"];
218
236
  if (typeof cr === "number")
219
237
  price.cacheRead = perM(cr);
package/dist/prompt.js CHANGED
@@ -17,17 +17,48 @@ async function selectRaw(rl, question, options, defaultIndex = 0) {
17
17
  const n = Number.parseInt(ans, 10);
18
18
  return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : defaultIndex;
19
19
  }
20
- console.log(pc.bold(question) + pc.dim(" ↑/↓ then Enter, or 1-9"));
20
+ // Long lists (a provider can return dozens of models) are unusable with
21
+ // arrow keys alone, so typing filters the list as you go.
22
+ const searchable = options.length > 8;
23
+ console.log(pc.bold(question) +
24
+ pc.dim(searchable ? " ↑/↓ then Enter · type to filter" : " ↑/↓ then Enter, or 1-9"));
21
25
  return new Promise((resolve) => {
22
- let idx = defaultIndex;
23
- let firstRender = true;
26
+ let filter = "";
27
+ let view = options.map((_, i) => i); // indices currently shown
28
+ let cursor = Math.max(0, view.indexOf(defaultIndex));
29
+ let painted = 0; // rows drawn last time
30
+ const applyFilter = () => {
31
+ const q = filter.toLowerCase();
32
+ const next = options
33
+ .map((o, i) => [o, i])
34
+ .filter(([o]) => o.toLowerCase().includes(q))
35
+ .map(([, i]) => i);
36
+ view = next.length ? next : [];
37
+ cursor = 0;
38
+ };
24
39
  const render = () => {
25
- if (!firstRender)
26
- process.stdout.write(`\x1b[${options.length}A`);
27
- firstRender = false;
28
- for (let i = 0; i < options.length; i++) {
40
+ if (painted)
41
+ process.stdout.write(`\x1b[${painted}A`);
42
+ const rows = view.length ? view.length : 1;
43
+ const extra = filter ? 1 : 0;
44
+ for (let r = 0; r < view.length; r++) {
29
45
  process.stdout.write("\x1b[2K");
30
- process.stdout.write((i === idx ? pc.cyan(`❯ ${options[i]}`) : pc.dim(` ${options[i]}`)) + "\n");
46
+ const i = view[r];
47
+ process.stdout.write((r === cursor ? pc.cyan(`❯ ${options[i]}`) : pc.dim(` ${options[i]}`)) + "\n");
48
+ }
49
+ if (!view.length) {
50
+ process.stdout.write("\x1b[2K" + pc.yellow(` no match for "${filter}"`) + "\n");
51
+ }
52
+ if (filter) {
53
+ process.stdout.write("\x1b[2K" + pc.dim(` filter: ${filter}`) + "\n");
54
+ }
55
+ // clear any rows the previous, longer render left behind
56
+ for (let r = rows + extra; r < painted; r++)
57
+ process.stdout.write("\x1b[2K\n");
58
+ painted = Math.max(rows + extra, painted);
59
+ if (painted > rows + extra) {
60
+ process.stdout.write(`\x1b[${painted - (rows + extra)}A`);
61
+ painted = rows + extra;
31
62
  }
32
63
  };
33
64
  const stdin = process.stdin;
@@ -58,24 +89,45 @@ async function selectRaw(rl, question, options, defaultIndex = 0) {
58
89
  key = s[i];
59
90
  i += 1;
60
91
  }
61
- if (key === "\x1b[A" || key === "k") {
62
- idx = (idx - 1 + options.length) % options.length;
92
+ if (key === "\x1b[A") {
93
+ if (view.length)
94
+ cursor = (cursor - 1 + view.length) % view.length;
63
95
  render();
64
96
  }
65
- else if (key === "\x1b[B" || key === "j") {
66
- idx = (idx + 1) % options.length;
97
+ else if (key === "\x1b[B") {
98
+ if (view.length)
99
+ cursor = (cursor + 1) % view.length;
67
100
  render();
68
101
  }
69
- else if (key >= "1" && key <= "9" && Number(key) <= options.length) {
70
- idx = Number(key) - 1;
71
- render();
72
- finish(idx);
73
- done = true;
74
- }
75
102
  else if (key === "\r" || key === "\n") {
76
- finish(idx);
103
+ if (view.length) {
104
+ finish(view[cursor]);
105
+ done = true;
106
+ }
107
+ }
108
+ else if (key === "\x7f" || key === "\b") { // backspace edits the filter
109
+ if (filter) {
110
+ filter = filter.slice(0, -1);
111
+ applyFilter();
112
+ render();
113
+ }
114
+ }
115
+ // Number shortcuts only while unfiltered; once you're typing, digits
116
+ // are part of the search term (model ids are full of them).
117
+ else if (!filter && !searchable && key >= "1" && key <= "9" && Number(key) <= options.length) {
118
+ finish(Number(key) - 1);
77
119
  done = true;
78
120
  }
121
+ else if (searchable && key >= " " && key !== "\x1b") {
122
+ filter += key;
123
+ applyFilter();
124
+ render();
125
+ }
126
+ else if (!searchable && (key === "k" || key === "j")) {
127
+ if (view.length)
128
+ cursor = (cursor + (key === "k" ? -1 : 1) + view.length) % view.length;
129
+ render();
130
+ }
79
131
  // Ctrl-C or Esc cancels. Returning -1 used to leak out as an array
80
132
  // index, crashing the caller with "cannot read properties of
81
133
  // undefined" — a cancel must be a clean exit, not a bad index.
@@ -118,7 +170,16 @@ export async function readSecret(rl, promptText, io) {
118
170
  resolve(value.trim());
119
171
  };
120
172
  const onData = (buf) => {
121
- for (const ch of buf.toString("utf8")) {
173
+ // Terminals wrap pasted text in bracketed-paste markers, ESC[200~ before
174
+ // and ESC[201~ after. The ESC byte is below space and gets dropped by the
175
+ // printable test below, but "[200~" is ordinary text and would be glued
176
+ // onto the secret — which is how a pasted key ends up rejected as
177
+ // malformed. Strip the markers, and any other escape sequence, first.
178
+ const chunk = buf.toString("utf8")
179
+ .replace(/\x1b\[20[01]~/g, "")
180
+ .replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "")
181
+ .replace(/\x1b./g, "");
182
+ for (const ch of chunk) {
122
183
  if (ch === "\r" || ch === "\n")
123
184
  return done();
124
185
  if (ch === "\x03") {
@@ -126,16 +187,15 @@ export async function readSecret(rl, promptText, io) {
126
187
  return done();
127
188
  } // Ctrl-C
128
189
  if (ch === "\x7f" || ch === "\b") { // backspace
129
- if (value.length) {
190
+ if (value.length)
130
191
  value = value.slice(0, -1);
131
- stdout.write("\b \b");
132
- }
133
192
  continue;
134
193
  }
135
- if (ch >= " ") {
194
+ // Echo nothing at all, the way sudo and ssh do. Masking characters
195
+ // would still reveal the key's length, and a hundred dots for a long
196
+ // key looks like something went wrong.
197
+ if (ch >= " ")
136
198
  value += ch;
137
- stdout.write("•");
138
- }
139
199
  }
140
200
  };
141
201
  stdin.on("data", onData);
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.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",