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/README.md +183 -55
- package/dist/agent.js +20 -1
- package/dist/config.js +48 -5
- package/dist/credentials.js +134 -0
- package/dist/index.js +252 -9
- package/dist/llm.js +344 -6
- package/dist/models.js +180 -0
- package/dist/onboard.js +364 -0
- package/dist/pricing.js +272 -0
- package/dist/prompt.js +158 -21
- package/dist/routes.js +143 -0
- package/dist/settings.js +55 -0
- package/dist/sigv4.js +177 -0
- package/dist/usage.js +187 -42
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -32,12 +32,18 @@ import { Agent } from "./agent.js";
|
|
|
32
32
|
import { FatalError, CancelledError } from "./errors.js";
|
|
33
33
|
import { SessionStore } from "./memory/sessions.js";
|
|
34
34
|
import { isRepo, isDirty, ensureStateIgnored } from "./git.js";
|
|
35
|
-
import { select, setSelectGuard } from "./prompt.js";
|
|
35
|
+
import { select, setSelectGuard, readSecret } from "./prompt.js";
|
|
36
36
|
import { createComposedInput, restore, describeComposed } from "./input.js";
|
|
37
37
|
import { Composer } from "./editor.js";
|
|
38
38
|
import { StatusLine } from "./status.js";
|
|
39
39
|
import { renderMarkdown, StreamRenderer } from "./markdown.js";
|
|
40
|
-
import { renderUsagePanel } from "./usage.js";
|
|
40
|
+
import { renderUsagePanel, UsageLedger } from "./usage.js";
|
|
41
|
+
import { ROUTES, getRoute, describeModel, resolveModel, vendors, baseUrlFor, DEFAULT_REGION } from "./routes.js";
|
|
42
|
+
import { loadSettings, saveSettings, updateActive, settingsPath } from "./settings.js";
|
|
43
|
+
import { needsOnboarding, interactive, runOnboarding, reportSetup, setupComplete } from "./onboard.js";
|
|
44
|
+
import { saveCredential, deleteCredential, listCredentialNames, getCredential, maskCredential, credentialsPath, resolveCredential, looksLikeKey, } from "./credentials.js";
|
|
45
|
+
import { readCache, writeCache, clearCache, buildPicker } from "./models.js";
|
|
46
|
+
import { refreshPrices, priceFor, readPriceCache } from "./pricing.js";
|
|
41
47
|
/** Version comes from package.json — one source of truth for banner and --version. */
|
|
42
48
|
const VERSION = (() => {
|
|
43
49
|
try {
|
|
@@ -150,14 +156,64 @@ async function main() {
|
|
|
150
156
|
const totalIn = u.input + u.cacheRead + u.cacheWrite;
|
|
151
157
|
const cachePct = totalIn > 0 ? Math.round((u.cacheRead / totalIn) * 100) : 0;
|
|
152
158
|
let line = `tokens: ${k(totalIn)} in (${cachePct}% cached) / ${k(u.output)} out · ${u.calls} call${u.calls === 1 ? "" : "s"}`;
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
155
|
-
const usd = (u.input * pIn + u.cacheRead * pIn * 0.1 + u.cacheWrite * pIn * 1.25 + u.output * pOut) / 1e6;
|
|
159
|
+
const usd = UsageLedger.cost(u, priceFor(agent.model, { in: config.priceIn, out: config.priceOut }));
|
|
160
|
+
if (usd !== undefined)
|
|
156
161
|
line += ` · ~$${usd.toFixed(3)}`;
|
|
157
|
-
}
|
|
158
162
|
console.log(pc.dim(line));
|
|
159
163
|
},
|
|
160
164
|
};
|
|
165
|
+
// Setup must be verified BEFORE the agent is built: the LLM client throws
|
|
166
|
+
// on a missing key, which would pre-empt the guided fix with a raw error.
|
|
167
|
+
// Every launch checks that setup is COMPLETE, not merely that a settings
|
|
168
|
+
// file exists. What counts depends on the route, so a Bedrock profile with
|
|
169
|
+
// an IAM role passes while an Anthropic profile with no key does not —
|
|
170
|
+
// and the gap is fixed here rather than surfacing as a 401 mid-task.
|
|
171
|
+
if (needsOnboarding() && interactive()) {
|
|
172
|
+
console.log(pc.cyan(`Faber v${VERSION}`));
|
|
173
|
+
const setup = await runOnboarding(rl, { first: true });
|
|
174
|
+
reportSetup(setup);
|
|
175
|
+
// Aborted setup saved nothing, so there is no model to reach — exit here
|
|
176
|
+
// rather than letting the LLM client throw a less useful error.
|
|
177
|
+
if (setup.aborted) {
|
|
178
|
+
rl.close();
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
config = loadConfig(config.workspace);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const state = await setupComplete(config);
|
|
186
|
+
if (!state.complete) {
|
|
187
|
+
if (!interactive()) {
|
|
188
|
+
console.error(pc.red(`Setup isn't finished — ${state.missing}.`));
|
|
189
|
+
console.error(pc.dim(` Run faber interactively to complete setup, or: ${state.fix}`));
|
|
190
|
+
rl.close();
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Start over from the top rather than resuming at the missing step:
|
|
195
|
+
// someone without a key for this route usually wants a DIFFERENT route,
|
|
196
|
+
// and resuming would trap them on the one that just failed.
|
|
197
|
+
console.log(pc.cyan(`Faber v${VERSION}`));
|
|
198
|
+
console.log(pc.yellow(`Setup isn't finished — ${state.missing}. Starting over.`));
|
|
199
|
+
const setup = await runOnboarding(rl, { first: true });
|
|
200
|
+
reportSetup(setup);
|
|
201
|
+
if (setup.aborted) {
|
|
202
|
+
rl.close();
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
config = loadConfig(config.workspace);
|
|
207
|
+
const after = await setupComplete(config);
|
|
208
|
+
if (!after.complete) {
|
|
209
|
+
console.log(pc.yellow(`Setup still incomplete — ${after.missing}.`));
|
|
210
|
+
console.log(pc.dim(` ${after.fix}, then run faber again.`));
|
|
211
|
+
rl.close();
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
161
217
|
const resumeId = flags.has("--resume") ? SessionStore.latestId(config.sessionsDir) : undefined;
|
|
162
218
|
let agent;
|
|
163
219
|
try {
|
|
@@ -326,8 +382,191 @@ async function main() {
|
|
|
326
382
|
agent.shortTerm.clear();
|
|
327
383
|
console.log("Short-term memory cleared.");
|
|
328
384
|
break;
|
|
385
|
+
case "/model": {
|
|
386
|
+
const route = getRoute(config.route) ?? ROUTES[0];
|
|
387
|
+
if (args[0] === "--save") {
|
|
388
|
+
updateActive({ model: agent.model });
|
|
389
|
+
console.log(`Saved ${agent.model} as the default for profile "${config.profileName}".`);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
if (args[0] && args[0] !== "--refresh") { // direct: /model haiku
|
|
393
|
+
const id = resolveModel(args[0], route, config.modelPins);
|
|
394
|
+
agent.setModel(id);
|
|
395
|
+
console.log(`Model for this session: ${describeModel(id, route, config.modelPins)}`);
|
|
396
|
+
console.log(pc.dim(" /model --save make it the profile default"));
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
// With no price data at all — first run, or the cache was discarded
|
|
400
|
+
// because an older Faber wrote it — wait for the fetch rather than
|
|
401
|
+
// rendering a menu that says "price unknown" for everything. The
|
|
402
|
+
// background refresh is fine when we already have rates to show.
|
|
403
|
+
if (!readPriceCache()) {
|
|
404
|
+
process.stdout.write(pc.dim(" fetching prices… "));
|
|
405
|
+
await refreshPrices(undefined, { baseUrl: config.baseUrl });
|
|
406
|
+
process.stdout.write("\r\x1b[2K");
|
|
407
|
+
}
|
|
408
|
+
// Ask the provider what this key can actually use; cached for a day.
|
|
409
|
+
if (args[0] === "--refresh")
|
|
410
|
+
clearCache(route.id);
|
|
411
|
+
let discovered = readCache(route.id);
|
|
412
|
+
if (!discovered) {
|
|
413
|
+
process.stdout.write(pc.dim(" fetching available models… "));
|
|
414
|
+
discovered = await agent.llm.listModels();
|
|
415
|
+
process.stdout.write("\r\x1b[2K");
|
|
416
|
+
if (discovered.length)
|
|
417
|
+
writeCache(route.id, discovered);
|
|
418
|
+
}
|
|
419
|
+
const entries = buildPicker(route, discovered ?? [], agent.model);
|
|
420
|
+
if (!entries.length) {
|
|
421
|
+
console.log(`No model list available for ${route.label}. Set one with: /model <id>`);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
// Show the rate here too. /model is where people switch models to save
|
|
425
|
+
// money, so hiding the price is exactly backwards.
|
|
426
|
+
const width = Math.min(34, Math.max(...entries.map((e) => e.label.length)) + 2);
|
|
427
|
+
const labels = entries.map((e) => {
|
|
428
|
+
const id = resolveModel(e.value, route, config.modelPins);
|
|
429
|
+
const p = priceFor(id, { in: config.priceIn, out: config.priceOut });
|
|
430
|
+
const cost = p ? `$${p.in}/$${p.out} per Mtok` : "price unknown";
|
|
431
|
+
return `${e.label.padEnd(width)}${pc.dim(cost.padEnd(22))}${pc.dim(e.blurb)}`;
|
|
432
|
+
});
|
|
433
|
+
const curIdx = entries.findIndex((e) => e.value === agent.model || resolveModel(e.value, route, config.modelPins) === agent.model);
|
|
434
|
+
const pick = await select(rl, "Select model (this session)", labels, curIdx < 0 ? 0 : curIdx);
|
|
435
|
+
const chosen = entries[pick];
|
|
436
|
+
const id = resolveModel(chosen.value, route, config.modelPins);
|
|
437
|
+
agent.setModel(id);
|
|
438
|
+
console.log(`Model for this session: ${describeModel(id, route, config.modelPins)}`);
|
|
439
|
+
console.log(pc.dim(" /model --save keep it · /model --refresh re-check the provider"));
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case "/key": {
|
|
443
|
+
const route = getRoute(config.route) ?? ROUTES[0];
|
|
444
|
+
const name = args[1] ?? route.keyEnv ?? "ANTHROPIC_API_KEY";
|
|
445
|
+
if (args[0] === "set") {
|
|
446
|
+
const val = await readSecret(rl, ` ${name} (hidden): `);
|
|
447
|
+
if (!val) {
|
|
448
|
+
console.log("Nothing entered.");
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
const problem = looksLikeKey(name, val);
|
|
452
|
+
if (problem) {
|
|
453
|
+
console.log(pc.yellow(` ${problem}.`));
|
|
454
|
+
const ok = await select(rl, "Save it anyway?", ["No, discard it", "Yes, save it"]);
|
|
455
|
+
if (ok === 0) {
|
|
456
|
+
console.log("Discarded.");
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
saveCredential(name, val);
|
|
461
|
+
console.log(`Saved ${name} (${maskCredential(val)}) — kept on this computer only.`);
|
|
462
|
+
console.log(pc.dim(` ${credentialsPath()}, readable only by you — restart faber to use it`));
|
|
463
|
+
}
|
|
464
|
+
else if (args[0] === "rm") {
|
|
465
|
+
console.log(deleteCredential(name) ? `Removed ${name}.` : `No stored ${name}.`);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
const names = listCredentialNames();
|
|
469
|
+
if (!names.length)
|
|
470
|
+
console.log(`No stored credentials. Add one with: /key set [${name}]`);
|
|
471
|
+
for (const n of names) {
|
|
472
|
+
const stored = getCredential(n);
|
|
473
|
+
const shadowed = process.env[n] ? pc.dim(" (env var takes precedence)") : "";
|
|
474
|
+
console.log(` ${n.padEnd(24)} ${maskCredential(stored)}${shadowed}`);
|
|
475
|
+
}
|
|
476
|
+
console.log(pc.dim(` ${credentialsPath()}`));
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
case "/setup": {
|
|
481
|
+
const setup = await runOnboarding(rl);
|
|
482
|
+
reportSetup(setup);
|
|
483
|
+
if (!setup.aborted) {
|
|
484
|
+
// Apply in this session. Telling the user to restart left the
|
|
485
|
+
// running agent on the old route, so /model would then list models
|
|
486
|
+
// for the provider they had just switched away from.
|
|
487
|
+
config = loadConfig(config.workspace);
|
|
488
|
+
agent.reconfigure(config);
|
|
489
|
+
console.log(pc.dim(` now using ${describeModel(config.model, getRoute(config.route) ?? ROUTES[0], config.modelPins)} on ${getRoute(config.route)?.label}`));
|
|
490
|
+
}
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
case "/route": {
|
|
494
|
+
const groups = vendors();
|
|
495
|
+
const vi = await select(rl, "Select vendor", groups.map((g) => g.vendor));
|
|
496
|
+
const rs = groups[vi].routes;
|
|
497
|
+
const ri = await select(rl, "Select route", rs.map((r) => `${r.label.padEnd(28)} ${pc.dim(r.hint)}${r.implemented ? "" : pc.yellow(" (not yet wired)")}`));
|
|
498
|
+
const chosen = rs[ri];
|
|
499
|
+
if (!chosen.implemented) {
|
|
500
|
+
console.log(pc.yellow(`${chosen.label} isn't wired up yet — see the roadmap in the README.`));
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
let region;
|
|
504
|
+
if (chosen.needsRegion) {
|
|
505
|
+
const ans = (await rl.question(`AWS region [${DEFAULT_REGION}]: `)).trim();
|
|
506
|
+
region = ans || DEFAULT_REGION;
|
|
507
|
+
}
|
|
508
|
+
let baseUrl = chosen.baseUrl;
|
|
509
|
+
if (chosen.needsBaseUrl) {
|
|
510
|
+
baseUrl = (await rl.question("Base URL (OpenAI-compatible): ")).trim() || undefined;
|
|
511
|
+
}
|
|
512
|
+
updateActive({ route: chosen.id, baseUrl, region, apiKeyEnv: chosen.keyEnv });
|
|
513
|
+
console.log(`Route set to ${chosen.label}.`);
|
|
514
|
+
const url = baseUrl ?? baseUrlFor(chosen, region);
|
|
515
|
+
if (url)
|
|
516
|
+
console.log(pc.dim(` endpoint: ${url}`));
|
|
517
|
+
if (chosen.keyEnv && !resolveCredential(chosen.keyEnv)) {
|
|
518
|
+
if (chosen.id === "bedrock") {
|
|
519
|
+
const { discoverAwsCredentials } = await import("./sigv4.js");
|
|
520
|
+
const aws = await discoverAwsCredentials();
|
|
521
|
+
console.log(aws
|
|
522
|
+
? pc.dim(` no ${chosen.keyEnv} needed — signing with AWS credentials from ${aws.source}`)
|
|
523
|
+
: pc.yellow(` set ${chosen.keyEnv}, or run where an IAM role is available (SageMaker, ECS, EC2)`));
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
console.log(pc.yellow(` ${chosen.keyEnv} is not set.`));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (chosen.aliasesArePinned) {
|
|
530
|
+
console.log(pc.dim(" model ids differ on this route — set one with: /model <id>"));
|
|
531
|
+
}
|
|
532
|
+
config = loadConfig(config.workspace);
|
|
533
|
+
agent.reconfigure(config);
|
|
534
|
+
console.log(pc.dim(` now on ${getRoute(config.route)?.label}`));
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
case "/profile": {
|
|
538
|
+
const st = loadSettings();
|
|
539
|
+
if (!args[0]) {
|
|
540
|
+
for (const [name, p] of Object.entries(st.profiles)) {
|
|
541
|
+
const mark = name === st.activeProfile ? pc.cyan("❯") : " ";
|
|
542
|
+
const r = getRoute(p.route);
|
|
543
|
+
console.log(`${mark} ${name.padEnd(12)} ${(r?.label ?? p.route).padEnd(26)} ${pc.dim(p.model ?? "")}`);
|
|
544
|
+
}
|
|
545
|
+
console.log(pc.dim(` ${settingsPath()}`));
|
|
546
|
+
console.log(pc.dim(" switch with: /profile <name>"));
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
if (!st.profiles[args[0]]) {
|
|
550
|
+
console.log(`No profile "${args[0]}".`);
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
st.activeProfile = args[0];
|
|
554
|
+
saveSettings(st);
|
|
555
|
+
config = loadConfig(config.workspace);
|
|
556
|
+
agent.reconfigure(config);
|
|
557
|
+
console.log(`Active profile: ${args[0]} — ${getRoute(config.route)?.label}, ${config.model}.`);
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
329
560
|
case "/usage": {
|
|
330
|
-
|
|
561
|
+
if (args[0] === "--refresh-prices") {
|
|
562
|
+
process.stdout.write(pc.dim(" fetching current prices… "));
|
|
563
|
+
const n = await refreshPrices(undefined, { baseUrl: config.baseUrl, force: true });
|
|
564
|
+
process.stdout.write("\r\x1b[2K");
|
|
565
|
+
console.log(n === "unchanged" ? "Prices confirmed current."
|
|
566
|
+
: typeof n === "number" ? `Prices updated (${n} models).`
|
|
567
|
+
: pc.yellow("Couldn't fetch prices — keeping the rates already known."));
|
|
568
|
+
}
|
|
569
|
+
console.log(renderUsagePanel(agent.usage, { in: config.priceIn, out: config.priceOut }));
|
|
331
570
|
break;
|
|
332
571
|
}
|
|
333
572
|
case "/verbose":
|
|
@@ -356,7 +595,11 @@ async function main() {
|
|
|
356
595
|
return;
|
|
357
596
|
}
|
|
358
597
|
console.log(pc.cyan(`Faber v${VERSION} — agentic coding assistant`));
|
|
359
|
-
console.log(pc.dim(`workspace: ${config.workspace}\
|
|
598
|
+
console.log(pc.dim(`workspace: ${config.workspace}\n` +
|
|
599
|
+
`model: ${describeModel(config.model, getRoute(config.route) ?? ROUTES[0], config.modelPins)}` +
|
|
600
|
+
` route: ${getRoute(config.route)?.label ?? config.route}` +
|
|
601
|
+
(config.profileName !== "default" ? ` profile: ${config.profileName}` : "") +
|
|
602
|
+
` approval: ${approvalMode}`));
|
|
360
603
|
if (isRepo(config.workspace) && isDirty(config.workspace)) {
|
|
361
604
|
console.log(pc.yellow("Git: you have uncommitted changes — consider committing before letting me edit."));
|
|
362
605
|
}
|
|
@@ -426,7 +669,7 @@ const HELP = `
|
|
|
426
669
|
/restore <id> jump files back to before a specific task
|
|
427
670
|
/clear clear short-term conversation memory
|
|
428
671
|
/ask | /auto approval mode: preview diffs and commands (default) / apply freely
|
|
429
|
-
env:
|
|
672
|
+
env: FABER_APPROVAL=auto to change default; FABER_GIT=commit for one git commit per task
|
|
430
673
|
/exit quit
|
|
431
674
|
`;
|
|
432
675
|
main().catch((e) => { console.error(pc.red(String(e?.stack ?? e))); process.exit(1); });
|