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/README.md +120 -42
- package/dist/agent.js +11 -0
- package/dist/commands.js +35 -0
- package/dist/index.js +53 -6
- package/dist/llm.js +374 -12
- package/dist/models.js +111 -1
- package/dist/onboard.js +270 -31
- package/dist/pricing.js +35 -2
- package/dist/prompt.js +94 -26
- package/dist/routes.js +33 -0
- package/dist/sigv4.js +50 -2
- package/dist/usage.js +127 -82
- package/package.json +1 -1
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: "
|
|
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
|
|
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
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
154
|
-
const midnight = new Date();
|
|
176
|
+
function windows(now) {
|
|
177
|
+
const midnight = new Date(now);
|
|
155
178
|
midnight.setHours(0, 0, 0, 0);
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
let
|
|
166
|
-
let
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
lines.push(
|
|
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
|
-
|
|
234
|
-
|
|
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("
|
|
237
|
-
for (const
|
|
238
|
-
const totalIn =
|
|
239
|
-
lines.push(
|
|
240
|
-
|
|
241
|
-
totalIn > 0 ? Math.round((
|
|
242
|
-
k(
|
|
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.
|
|
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",
|