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 CHANGED
@@ -1,96 +1,216 @@
1
1
  # Faber
2
2
 
3
- **An agentic AI coding assistant for your terminal.** Give it a task in plain English; it explores your repository, edits files with your approval, runs your tests, and remembers your project across sessions.
3
+ **A cost and performance efficient agentic AI coding assistant for your terminal.** Give it a task in plain English; it explores your repository, edits files with your approval, runs your tests, and remembers your project across sessions.
4
4
 
5
5
  *Faber — Latin for craftsman. Yours lives at `faber`.*
6
+ * Read: [Faber Medium](https://medium.com/@jshial25/why-should-an-ai-coding-agent-read-hundreds-of-files-to-answer-one-question-d6369d29dfa5?postPublishedType=repub)
7
+ ## Code Graph
8
+ <img width="636" height="573" alt="Screenshot 2026-08-09 at 2 58 30 PM" src="https://github.com/user-attachments/assets/176e5093-8e29-452f-a749-d06a561c583a" />
9
+
10
+ ## Demo
11
+ <img width="1240" height="700" alt="terminal_demo_compressed" src="https://github.com/user-attachments/assets/f78a8f68-130e-4f37-90f2-7a7089fa2653" />
6
12
 
7
- ```
8
- $ faber "add input validation to the signup endpoint and cover it with tests"
9
- ⚙ trace_path (from=main, to=handleSignup)
10
- ⚙ read_file (path=app/routes/auth.ts)
11
-
12
- Proposed change to app/routes/auth.ts:
13
- @@ -12,6 +12,9 @@
14
- + if (!isEmail(req.body.email)) return res.status(400)...
15
- Apply? ↑/↓ then Enter
16
- ❯ Yes
17
- No
18
- Always this session
19
-
20
- ⚙ run_shell (command=npm test)
21
- ─ result ────────────────────────────
22
- Added email/password validation to /signup, verified with 4 new passing tests.
23
- ─────────────────────────────────────
24
- tokens: 31.2k in (78% cached) / 1.9k out · 7 calls
25
- ```
26
13
 
27
14
  ## Your first session (2 minutes)
28
15
 
29
16
  ```bash
30
- cd your-project # any repo Faber works on the folder you're in
17
+ cd your-project # any repo; Faber works on the folder you're in
31
18
  faber # start the REPL (approval mode is on by default)
32
19
  ```
33
20
 
34
- 1. **Give it a real task** `add a comment explaining what the main entry file does`
35
- 2. **Approve the diff** arrow keys, Enter. Cursor starts on Yes; "Always this session" grants trust. Shell commands ask too.
36
- 3. **See the safety net** `/history` lists every task with the files it touched.
37
- 4. **Undo it** `/undo` reverts the task; changed your mind? `/redo` brings it back. Nothing is ever lost in either direction.
38
- 5. **Come back tomorrow** `faber --resume` continues the conversation, or just ask *"what did we do last time?"* — it searches past sessions itself.
21
+ 1. **Give it a real task.** Try `add a comment explaining what the main entry file does`
22
+ 2. **Approve the diff** with the arrow keys and Enter. Cursor starts on Yes; "Always this session" grants trust. Shell commands ask too.
23
+ 3. **See the safety net.** `/history` lists every task with the files it touched.
24
+ 4. **Undo it.** `/undo` reverts the task, and if you change your mind changed your mind? `/redo` brings it back. Nothing is ever lost in either direction.
25
+ 5. **Come back tomorrow.** `faber --resume` continues the conversation, or just ask *"what did we do last time?"* — it searches past sessions itself.
39
26
 
40
- That loop **task, approve, inspect, revert** is the whole trust model. Everything else is detail.
27
+ That loop of task, approve, inspect, revert is the whole trust model. Everything else is detail.
41
28
 
42
29
  ## Why Faber
43
30
 
44
31
  **It has a map, not just eyes.** The code graph stores call and import *edges*, incrementally updated in milliseconds. One ~50-token query (`trace_path(main, saveUser)` → `main → startServer → handleSignup → saveUser`) replaces reading thousands of tokens of files. `/map main` prints the call tree — the "trace it from main" ritual every programmer does, automated.
45
32
 
46
- **It's honest about money.** Prompt caching marks the stable prefix of every request, so repeat loop iterations pay ~10% for tokens already sent. And after every task you see exactly what happened: `tokens: 31.2k in (78% cached) / 1.9k out · 7 calls`.
33
+ **Every model you can reach, including the coding ones.** Claude through the Anthropic API or your own AWS account, OpenAI through both of its APIs — chat completions and the Responses API that the codex family requires — plus anything OpenAI-compatible, and local models through Ollama for no key and no cost. Faber reads which endpoint each model needs and routes there itself, so `gpt-5.3-codex` and `claude-opus-5` are both just entries in the same list, priced side by side.
34
+
35
+ **It's honest about money.** Prompt caching marks the stable prefix of every request, so repeat loop iterations pay ~10% for tokens already sent. Every task is costed and recorded as it runs, so `/usage` is a ledger of what you actually spent rather than an estimate — broken down by time window and by model, with the cheapest option always visible next to the one you're using.
47
36
 
48
37
  **Nothing is irreversible.** Every task is checkpointed before it touches a file. Undo is undoable. Approvals gate both edits *and* shell commands. Rejecting a change tells the model to change course, not retry.
49
38
 
50
39
  **It remembers.** Facts, decisions, and gotchas persist per-project in SQLite. Sessions survive crashes (append-only JSONL). New sessions start with a digest of the last one, and the agent searches past conversations when you reference them.
51
40
 
52
- **You can steer it mid-flight.** See it going the wrong way? Just type `use TypeScript, not JavaScript` — and your guidance is injected at the next loop iteration. No cancelling, no wasted tokens.
53
-
54
- ## Upgrading from Codewright
55
-
56
- Faber is Codewright renamed (the npm name was taken between build and release). Existing projects keep working untouched: an existing `.codewright/` state directory is adopted as-is, so memory, code graph, sessions, and usage history all survive. `CW_*` environment variables still work alongside the canonical `FABER_*` names. New projects get `.faber/`.
41
+ **You can steer it mid-flight.** See it going the wrong way? Just type `use TypeScript, not JavaScript` — and your guidance is injected at the next loop iteration. No cancelling, no wasted tokens.
57
42
 
58
43
  ## Requirements & install
59
44
 
60
- Node.js 22.5 (uses built-in `node:sqlite` **zero native dependencies**; installs never fail on compilation). Two pure-JS runtime deps: `diff`, `picocolors`.
45
+ You need Node.js 22.5 or newer. Faber uses Node's built-in SQLite, so there are no native dependencies to compile and installs don't fail on a missing toolchain. The only runtime dependencies are two small pure-JS packages.
61
46
 
62
47
  ```bash
63
48
  npm install -g faberwright
64
- export ANTHROPIC_API_KEY=sk-ant-... # add to ~/.zshrc to persist
65
- faber # you're in
49
+ faber
50
+ ```
51
+
52
+ The first time you run it, Faber asks three questions and remembers the answers:
53
+
54
+ ```
55
+ Welcome to Faber.
56
+ Let's pick where your model comes from. You can change this any time with /route or /model.
57
+
58
+ Who provides the model?
59
+ ❯ Anthropic
60
+ OpenAI
61
+ Local
62
+ Other
63
+
64
+ How should Faber reach it?
65
+ ❯ Anthropic API direct, pay per token with your own key
66
+ Amazon Bedrock your AWS account owns auth and billing
67
+ Google Vertex AI your GCP project owns auth and billing (not yet wired)
68
+
69
+ Faber needs an API key for Anthropic API.
70
+ Get one at https://console.anthropic.com/settings/keys
71
+ ANTHROPIC_API_KEY (hidden): ••••••••••••
72
+
73
+ Default model
74
+ ❯ sonnet balanced, a good default for daily work
75
+ opus most capable, for complex multi-step work
76
+ haiku fastest and cheapest, for simple tasks
77
+ ```
78
+
79
+ Nothing is written until setup finishes. Quit halfway through and the next run starts over from the first question, so you can never end up half-configured. Every later launch checks that your setup can actually reach a model before it opens the prompt, which means a missing key gets caught at startup rather than surfacing as a confusing error partway through a task.
80
+
81
+ If a key is already in your environment, Faber says so and asks what to do with it: use it and remember it, use it without saving, or replace it with a different one. A key you deliberately paste wins over the environment variable, because an explicit choice shouldn't be silently overridden.
82
+
83
+ Your key never leaves your machine. Faber has no server and no account of its own, and it talks only to the model provider you pick. Keys you give it are written to `~/.faber/credentials.json` with owner-only permissions, never into your project folder, where a stray `git add` could publish them.
84
+
85
+ Environment variables still work and still take precedence, so existing setups and CI keep running unchanged:
86
+
87
+ ```bash
88
+ export ANTHROPIC_API_KEY=sk-ant-...
89
+ faber
66
90
  ```
67
91
 
68
- Any OpenAI-compatible provider: `FABER_PROVIDER=openai`, `OPENAI_API_KEY`, `FABER_BASE_URL`, `FABER_MODEL`.
92
+ Setup is skipped entirely when there's no terminal attached, so scripts and CI never block on a prompt. And if you don't have an API key at all, pick **Local → Ollama** during setup and Faber runs against a model on your own machine, no key and no cost.
69
93
 
70
94
  ## Features
71
95
 
72
- | | |
96
+ | Feature|Description |
73
97
  |---|---|
74
98
  | **Streaming agent loop** | Text renders as generated, with a live heartbeat (`✳ Working… 14s`) that never interleaves with output and ends in `✳ Worked for 14s`. Plan → tool → observe → adjust, until done. Hard iteration cap. |
75
99
  | **Code graph** | Symbols *and* call/import edges, incrementally maintained (only changed files re-parse). Agent tools: `who_calls` (blast radius), `calls_from` (dependencies), `trace_path` (workflow chain). A compact repo map of the most-connected symbols orients every task. Edges are static hints — dynamic dispatch/DI/events aren't captured; the agent reads code where precision matters. |
76
- | **Approval by default** | Arrow-key menu on every file edit (colored diff) and every shell command. `--auto` / `/auto` / `FABER_APPROVAL=auto` opts into autonomy. |
77
- | **Interactive choices** | Genuinely ambiguous request? The agent presents 2–4 options plus "Chat more about this instead" before writing code. |
78
- | **Mid-task steering** | Type while it works; guidance is injected at the next loop boundary. If the model finishes while steering is queued, the task continues instead. Steering markers carry a per-task nonce, so hostile file contents can't impersonate you. |
79
- | **Paste as chips** | Raw-mode composer: pasting a 50-line block renders only a chip — `[pasted #1 +50 lines]` — never the code itself, while the full text is expanded into the message on Enter. Paste the same block again to expand it visibly. Paste, type, paste again: one submission. Full line editing: arrows, Home/End, forward-delete, Ctrl-A/E/K/U, Up/Down history; long drafts wrap across rows with exact cursor tracking — backspace and arrows travel across wrap boundaries. Chips are atomic — one arrow step, one backspace. Works at the prompt and while steering. |
80
- | **Reversible history** | `/history` lists tasks with files touched; `/restore <id>` jumps anywhere; `/undo` / `/redo` — restores are never destructive. |
81
100
  | **Git-aware** | Warns about uncommitted changes at startup. Optional `FABER_GIT=commit`: one commit per completed *task* (never per edit). The agent never commits by default. |
82
101
  | **Two-layer memory** | Short-term: token-budgeted window, auto-summarized past 60k (tool pairs never split). Long-term: SQLite+FTS5 facts/decisions/gotchas + per-file notes, with full lifecycle (`/forget`, `/archive`, `/prune`). |
83
102
  | **Sessions** | Crash-safe JSONL transcripts; `--resume`; last-session digest injected at startup; `recall_sessions` keyword search across history. |
84
103
  | **Prompt caching** | Cache breakpoints on system prompt, tools, and sliding conversation history. Typical tasks: 60–90% of input from cache. |
85
104
  | **Output discipline** | Research-informed brevity (Chain-of-Draft style): short prose answers, shorthand inter-tool notes, no postamble — with an explicit exception that code correctness and completeness are never sacrificed for token count. `/verbose` toggles full-depth explanations. `FABER_WEAK_MODEL` routes internal summarization (compaction, digests) to a cheap model. |
86
105
  | **Token transparency** | Per-task footer with cache rate. Set `FABER_PRICE_IN` / `FABER_PRICE_OUT` ($/Mtok) for cost estimates. |
87
- | **Usage dashboard** | `/usage` shows a persistent ledger: tasks, tokens, cache rate, cost, and cache *savings* — for this session, today, and all time, plus totals across every project on the machine. Records live in `.faber/usage.db` per project and survive restarts. |
106
+ | **Usage dashboard** | `/usage` shows a persistent ledger: tasks, tokens, cache rate, cost, and cache *savings* — for this session, today, and all time, plus totals across every project on the machine. Costs use each model's own rates, including its exact cache read/write prices, so a history spanning a model switch stays accurate. Prices ship built in and update with `/usage --refresh-prices`. Records live in `.faber/usage.db` per project and survive restarts. |
88
107
  | **Error recovery** | Transient API errors: backoff + jitter, honors Retry-After. Tool errors return to the model to self-correct. Identical call failing twice → warning; three times → clean abort. Ctrl-C aborts streams *and* running commands; checkpoints survive. |
89
108
  | **Safety rails** | Symlink-resolved path jail, shell denylist, timeouts, output truncation, atomic writes (temp+rename), stale-edit guard. Guardrails, not a sandbox — use a container for untrusted code. |
109
+ | **Approval by default** | Arrow-key menu on every file edit (colored diff) and every shell command. `--auto` / `/auto` / `FABER_APPROVAL=auto` opts into autonomy. |
110
+ | **Guided setup** | First run walks through vendor, route, credential and model, then remembers it. Later launches verify the setup can reach a model before opening the prompt. Nothing is saved until setup finishes, so an interrupted run leaves no half-configured state. |
111
+ | **Credentials** | Keys live in `~/.faber/credentials.json`, owner-only, outside every repository. Faber checks that what you paste looks like a key before storing it, hides it as you type, and never prints more than a masked fragment. Environment variables keep working and take precedence. |
112
+ | **Mid-task steering** | Type while it works; guidance is injected at the next loop boundary. If the model finishes while steering is queued, the task continues instead. Steering markers carry a per-task nonce, so hostile file contents can't impersonate you. |
113
+ | **Reversible history** | `/history` lists tasks with files touched; `/restore <id>` jumps anywhere; `/undo` / `/redo` — restores are never destructive. |
114
+
115
+ ## Vendors, routes, and models
116
+
117
+ Faber separates three choices, so you can change one without redoing the others:
118
+
119
+ | Level | What it decides | How you set it |
120
+ |---|---|---|
121
+ | **Vendor** | who makes the model | `/route` |
122
+ | **Route** | how you reach it and who owns auth | `/route` |
123
+ | **Model** | which model on that route | `/model` (searchable model options) |
124
+
125
+ Routes available today: **Anthropic API**, **Amazon Bedrock**, **OpenAI API**, **Ollama** (local, no key and no cost), and any **OpenAI-compatible endpoint** (OpenRouter, Groq, Together, vLLM, a gateway). Google Vertex is defined but not yet wired up, and `/route` says so rather than failing at request time.
126
+
127
+ On OpenAI, Faber speaks both APIs. Chat completions for most models, and the **Responses API** for the ones that require it, which includes the codex family — the coding-tuned models a coding agent actually wants. You don't choose: each model's endpoint is recorded in the price dataset Faber already downloads, so `gpt-5.3-codex` routes to `/v1/responses` and `gpt-5.5` to `/v1/chat/completions` automatically. If a model is too new to appear in that dataset and the API says it belongs elsewhere, Faber retries on the endpoint it names rather than failing.
128
+
129
+ ### Amazon Bedrock
130
+
131
+ Faber authenticates two ways, and tries them in this order.
132
+
133
+ **With an IAM role, which needs no key at all.** In SageMaker Studio, on EC2, in ECS or Lambda, or on a laptop where you've run `aws configure`, Faber finds your AWS credentials the same way the SDKs do and signs each request with SigV4. Pick Amazon Bedrock during setup, give it a region, and that's the whole configuration. The signing is implemented directly against Node's crypto module rather than pulling in the AWS SDK, so the zero-dependency install still holds, and it's verified against the signature AWS publishes for its own worked example.
134
+
135
+ **With a Bedrock API key**, if you'd rather use one:
136
+
137
+ ```bash
138
+ export BEDROCK_API_KEY=...
139
+ ```
140
+
141
+ Model ids on Bedrock differ by region and deployment, so pin them per alias in your profile instead of relying on the built-in names:
142
+
143
+ ```json
144
+ { "route": "bedrock", "region": "us-west-2",
145
+ "modelPins": { "sonnet": "anthropic.claude-sonnet-4-6-v1" },
146
+ "apiKeyEnv": "BEDROCK_API_KEY" }
147
+ ```
148
+
149
+ ### Costs
150
+
151
+ `/usage` is a ledger, not an estimate. Every task's cost is worked out and written down when the task runs, at the rates in effect then, and never recalculated. If a vendor raises prices next month, last month's tasks still show what they actually cost.
152
+
153
+ `/usage` breaks spend down across seven rolling windows — today, 7 days, 14 days, 30 days, 3 months, 6 months, all time — each showing tasks, tokens, cache rate, cost and savings, with a per-model table underneath showing where the money actually goes.
154
+
155
+ Windows are rolling rather than calendar, so "last 30 days" always means thirty days instead of resetting to one on the first of the month. All seven are shown even when they hold identical numbers: a missing row would read as "no data" when it actually means "nothing new since," and that distinction is the whole point for someone coming back after a break.
156
+
157
+ The two columns nobody can interpret unaided are defined in the panel itself. **Cached** is the share of input served from a cache rather than billed at full price. **Saved** is the counterfactual: what those same tasks would have cost without prompt caching. The by-model table is the actionable part, since switching routine work to a cheaper model is usually the largest saving available.
158
+
159
+ **Where prices come from**, best source first: whatever you set explicitly, then the provider's own published rates (OpenRouter publishes per-token prices including cache reads and writes, and needs no key for it), then a community dataset covering everyone else, then a small table built into Faber so it works offline. Anthropic and OpenAI don't publish prices through their APIs at all, which is why the last two exist.
160
+
161
+ Keeping those rates current matters more than it sounds, precisely because costs are frozen when recorded: a stale table would bake a wrong number into your history permanently. So Faber fetches prices during setup and re-checks on every launch with a conditional request. When nothing has changed the server answers `304` with no body — about 70ms in the background — so there's no window where an out-of-date rate can enter your records. The full download happens only when the list actually changes, and after a failed attempt Faber waits a day before retrying so an offline machine isn't making a doomed request on every start.
162
+
163
+ Turn the automatic refresh off with `FABER_AUTO_PRICES=0` or `"autoRefreshPrices": false` in your profile. This is the only network request Faber makes that isn't an inference call; it fetches a public price list and sends nothing about you.
164
+
165
+ Costs use each model's own input, output, cache-read and cache-write rates rather than a flat multiplier, and cloud model ids like `us.anthropic.claude-sonnet-5` normalise onto the same entry as the direct one. `FABER_PRICE_IN` and `FABER_PRICE_OUT`, or `priceIn` and `priceOut` in a profile, override everything. A model with no known price shows a dash rather than a guess.
166
+
167
+ **What gets recorded.** One row per task in `<project>/.faber/usage.db`:
168
+
169
+ ```
170
+ ts 1785979475047 when it ran
171
+ input 27000 fresh input tokens
172
+ cache_read 46000 served from cache
173
+ cache_write 500 stored into the cache for reuse
174
+ output 3600 generated
175
+ calls 3 API round-trips inside that task
176
+ model gpt-5.3-codex which model ran it
177
+ cost 0.0412 dollars, frozen at run time
178
+ saved 0.0231 what caching avoided
179
+ ```
180
+ **Cost metrics `/usage`:
181
+
182
+ <img width="636" height="388" alt="Screenshot 2026-08-10 at 12 25 57 AM" src="https://github.com/user-attachments/assets/33ba06ce-69ad-4afe-afcc-6119a897e67a" />
183
+
184
+
185
+ Counters and a timestamp. No prompts, no code, no file contents. Around 80 bytes a row, so a year of heavy use is roughly a megabyte, and nothing is ever pruned.
186
+
187
+ Keeping the raw rows rather than rolling them into running totals is deliberate: any question you think of later can still be answered about the past. Spend for a particular month, which model a given week ran on, cost per task before and after a prompt change. It's a plain SQLite file, so `sqlite3 .faber/usage.db` will answer anything Faber doesn't print — including an expense report of your own shape.
188
+
189
+ Settings live in `~/.faber/settings.json` as named **profiles**:
190
+
191
+ ```json
192
+ {
193
+ "activeProfile": "work",
194
+ "profiles": {
195
+ "work": { "route": "anthropic-api", "model": "sonnet", "apiKeyEnv": "ANTHROPIC_API_KEY_WORK" },
196
+ "local": { "route": "ollama", "model": "qwen2.5-coder" }
197
+ }
198
+ }
199
+ ```
200
+
201
+ Profiles store the name of the environment variable holding your credential, never the credential itself, so the file is safe to sync between machines or keep in a dotfiles repo. Switch between them with `/profile local`.
202
+
203
+ Settings resolve in this order, highest first: environment variables, then a project's `.faber/config.json`, then the active profile, then defaults. So a repo can pin its own model while your machine default stays whatever you picked, and an environment variable still overrides both.
90
204
 
91
205
  ## Commands
92
206
 
93
207
  ```
208
+ /setup run setup again (vendor, route, credential, model)
209
+ /route choose a vendor and route
210
+ /model [id] switch model; lists what your key can use, with prices
211
+ /key [set|rm] show, save or remove an API key
212
+ /profile [name] list or switch saved profiles
213
+ /usage cost ledger by time window and model (--refresh-prices)
94
214
  /index rebuild the code graph (symbols + call edges)
95
215
  /map <symbol> print the call tree from any entry point
96
216
  /memory [archived] long-term memories (+ file notes)
@@ -101,6 +221,7 @@ Any OpenAI-compatible provider: `FABER_PROVIDER=openai`, `OPENAI_API_KEY`, `FABE
101
221
  /history task checkpoints /restore <id>
102
222
  /undo /redo revert last task / bring it back
103
223
  /ask /auto toggle approval mode
224
+ /verbose /concise full-depth answers, or short ones (default concise)
104
225
  /clear /help /exit
105
226
  ```
106
227
 
@@ -115,19 +236,23 @@ Flags: `faber [task] [--workspace|-w <dir>] [--resume] [--ask|--auto] [--version
115
236
  | `FABER_MODEL`, `FABER_BASE_URL` | model + endpoint overrides |
116
237
  | `FABER_APPROVAL` | `ask` \| `auto` (ask) |
117
238
  | `FABER_GIT` | `commit` = one commit per task (off) |
118
- | `FABER_PRICE_IN`, `FABER_PRICE_OUT` | $/Mtok cost estimate in footer |
239
+ | `FABER_PRICE_IN`, `FABER_PRICE_OUT` | $/Mtok, overrides the built-in price table |
119
240
  | `FABER_WEAK_MODEL` | cheap model for internal summarization (e.g. a Haiku-class model) |
120
241
  | `FABER_MAX_ITERATIONS` | loop cap (40) |
121
242
  | `FABER_CONTEXT_BUDGET` | compaction threshold, tokens (60000) |
122
243
  | `FABER_SHELL_TIMEOUT` | ms (120000) |
244
+ | `FABER_ROUTE`, `FABER_REGION` | route id and region, overriding the profile |
245
+ | `FABER_AUTO_PRICES` | `0` disables the background price refresh |
246
+ | `BEDROCK_API_KEY` | Bedrock, when you'd rather use a key than an IAM role |
247
+ | `AWS_ACCESS_KEY_ID` etc. | picked up automatically for Bedrock's SigV4 signing |
123
248
 
124
- Per-project overrides: `.faber/config.json`.
249
+ Faber keeps three files. `~/.faber/settings.json` holds your profiles, which is the route, model and which environment variable a credential comes from. `~/.faber/credentials.json` holds the keys themselves, owner-only and outside every repository. A project can override the profile with its own `.faber/config.json`, which is useful when one repo should use a cheaper model than the rest.
125
250
 
126
- **Commit protection is automatic:** on startup in a git repo, Faber writes `.faber/` into `.git/info/exclude` (a local-only ignore no diff, nothing committed), so state can never reach GitHub even if you forget `.gitignore`. If `.faber/` was already committed in the past, you get a loud red warning with the exact `git rm --cached` fix. Sessions and checkpoints can contain file contents this protection exists so they never leak with production code. Teams: also add `.faber/` to the shared `.gitignore` so teammates are covered from their first run.
251
+ Commit protection happens on its own. When Faber starts inside a git repo it writes `.faber/` into `.git/info/exclude`, a local ignore that produces no diff and is never committed, so project state can't reach GitHub even if you forget your `.gitignore`. If `.faber/` was already committed at some point in the past, you get a loud warning with the exact `git rm --cached` command to fix it. This matters because sessions and checkpoints can contain the contents of files the agent read. If teammates will use Faber too, add `.faber/` to the shared `.gitignore` so they're covered from their first run.
127
252
 
128
- ## Failure-mode matrix
253
+ ## What happens when things go wrong
129
254
 
130
- | Failure | Behavior |
255
+ | Situation | What Faber does |
131
256
  |---|---|
132
257
  | API 429/5xx/network drop | retry with backoff+jitter; Retry-After honored; clear fatal after N attempts |
133
258
  | Bad API key | immediate fatal naming the env var to set |
@@ -148,13 +273,13 @@ Per-project overrides: `.faber/config.json`.
148
273
  ## Testing
149
274
 
150
275
  ```bash
151
- npm test # 30 offline tests no API key needed
276
+ npm test # offline test suite, no API key needed
152
277
  node selftest.mjs # installation self-check + live agent verification (report file to share)
153
278
  ```
154
279
 
155
- The suite covers the awkward stuff on purpose: undo/redo round-trips, checkpoint ID collisions within one millisecond, session files torn mid-write, stopword-polluted recall queries, cyclic call graphs, CRLF + emoji surgical edits, mid-stream cancellation, steering-message role alternation, paste markers split across stream chunks, multi-paste + typed-text composition, doom-loop abort plus three end-to-end tests that drive the real agent loop (and one that drives the real CLI binary) against a mock streaming API server.
280
+ The suite deliberately covers the awkward cases rather than the easy ones: undo and redo round-trips, checkpoint ids colliding within the same millisecond, session files torn mid-write, cyclic call graphs, surgical edits through CRLF and emoji, cancellation mid-stream, steering messages keeping the API's role alternation valid, paste markers split across stream chunks, AWS signatures checked against the vector AWS publishes, credential files landing with owner-only permissions, a recorded cost staying put when prices later change, Responses-API tool calls assembled from their argument deltas, and usage windows that put a 45-day-old task in "last 3 months" but not "last 30 days". Several tests drive the real agent loop against a mock streaming server, and one drives the actual CLI binary.
156
281
 
157
- CI (`.github/workflows/ci.yml`): Ubuntu / macOS / Windows × Node 22 / 24 tests, build, and CLI smoke on every push.
282
+ CI runs on Ubuntu, macOS and Windows across Node 22 and 24: tests, build, and CLI smoke on every push.
158
283
 
159
284
  ## Architecture
160
285
 
@@ -163,18 +288,21 @@ CLI/REPL (index.ts) streaming render · arrow-key approvals · steering captur
163
288
 
164
289
  Agent loop (agent.ts) recall memory + repo map → [LLM ⇄ tools] → verify → summarize
165
290
  │ doom-loop breaker · iteration cap · compaction · session log · steering drain
166
- ├─ LLM (llm.ts) Anthropic + OpenAI-compatible · SSE streaming · prompt caching · usage · retry
291
+ ├─ LLM (llm.ts) three wires: Anthropic · OpenAI chat · OpenAI Responses · SSE · caching · retry
292
+ ├─ Routes (routes.ts) vendor → route → model · SigV4 for Bedrock (sigv4.ts) · live model discovery
293
+ ├─ Pricing (pricing.ts) per-model rates, modes and endpoints · conditional refresh · frozen at spend
167
294
  ├─ Tools (tools/) validated dispatch · mutations staged as diffs · shell gated by approval
168
295
  ├─ Graph (indexer.ts) symbols + call/import edges · incremental · who_calls / trace_path / map
169
296
  ├─ Memory (memory/) shortTerm (window+compaction) · longTerm (SQLite+FTS) · sessions (JSONL)
170
297
  └─ Checkpoints snapshot-before-write · /history · reversible restore
171
298
 
172
- All state in <repo>/.faber/ (memory.db, index.db, sessions/, checkpoints/)
299
+ Per project <repo>/.faber/ memory.db · index.db · usage.db · sessions/ · checkpoints/
300
+ Per machine ~/.faber/ settings.json (profiles) · credentials.json (0600) · cache/
173
301
  ```
174
302
 
175
303
  ## Roadmap
176
304
 
177
- Tree-sitter edges (compiler-grade graph) · Graphify `graph.json` integration · stale tool-result eviction · secret redaction in session logs · global user profile (~/.faber) · embedding recall · branch-per-task git mode · VS Code extension on this core.
305
+ Google Vertex is defined but not yet wired up, since its OAuth flow is a bigger piece than Bedrock turned out to be. Beyond that: tree-sitter for compiler-grade graph edges, evicting stale tool results from long conversations, redacting secrets in session logs, team-shared project settings that can live in a repo, embedding-based recall, a branch-per-task git mode, a `/usage --all` view comparing every project on the machine, and a VS Code extension built on this core.
178
306
 
179
307
  ## License
180
308
 
package/dist/agent.js CHANGED
@@ -42,6 +42,9 @@ export class Agent {
42
42
  events;
43
43
  /** Verbose mode: user asked for full-depth explanations (/verbose). */
44
44
  verbose = false;
45
+ /** Active model. Switching mid-session invalidates the prompt cache, since
46
+ * cached prefixes are per-model — the next task pays full price once. */
47
+ model;
45
48
  /** Mid-task steering: lines typed while the agent works, injected at the
46
49
  * next loop boundary so the model course-corrects without cancelling. */
47
50
  steerQueue = [];
@@ -57,6 +60,7 @@ export class Agent {
57
60
  constructor(config, approve, events = {}, resumeSessionId, askUser) {
58
61
  this.config = config;
59
62
  this.events = events;
63
+ this.model = config.model;
60
64
  this.llm = new LLMClient(config);
61
65
  this.usage = new UsageLedger(config.usageDb, config.workspace);
62
66
  this.checkpoints = new CheckpointManager(config.stateDir, config.workspace);
@@ -73,6 +77,21 @@ export class Agent {
73
77
  this.events.onInfo?.(`Resumed session ${resumeSessionId} (${restored.length} messages).`);
74
78
  }
75
79
  }
80
+ setModel(id) {
81
+ this.model = id;
82
+ this.llm.setModel(id);
83
+ }
84
+ /**
85
+ * Adopt a new configuration in the running session, after /setup or /route
86
+ * changed the route. Without this the session keeps talking to the old
87
+ * provider while the settings file says otherwise, so /model would list
88
+ * models for a route the user thought they had left.
89
+ */
90
+ reconfigure(next) {
91
+ this.config = next;
92
+ this.model = next.model;
93
+ this.llm = new LLMClient(next);
94
+ }
76
95
  steer(text) {
77
96
  const t = text.trim();
78
97
  if (t)
@@ -191,7 +210,7 @@ export class Agent {
191
210
  finally {
192
211
  this.checkpoints.commit();
193
212
  if (total.calls > 0) {
194
- this.usage.record(total, this.config.model);
213
+ this.usage.record(total, this.model, { in: this.config.priceIn, out: this.config.priceOut });
195
214
  this.events.onUsage?.(total);
196
215
  }
197
216
  }
package/dist/config.js CHANGED
@@ -7,6 +7,9 @@
7
7
  */
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
+ import { loadSettings, activeProfile } from "./settings.js";
11
+ import { getRoute, resolveModel, baseUrlFor, ROUTES } from "./routes.js";
12
+ import { resolveCredential, getCredential } from "./credentials.js";
10
13
  export const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5";
11
14
  export const DEFAULT_OPENAI_MODEL = "gpt-4o";
12
15
  /** Accept both FABER_* (canonical) and CW_* (legacy) env var names. */
@@ -42,16 +45,45 @@ export function loadConfig(workspace) {
42
45
  catch { /* ignore malformed */ }
43
46
  }
44
47
  const s = (k) => typeof fileCfg[k] === "string" ? fileCfg[k] : undefined;
45
- const provider = (process.env.CW_PROVIDER ?? s("provider") ?? "anthropic").toLowerCase();
48
+ // Global profile sits below project config and above defaults.
49
+ const settings = loadSettings();
50
+ const prof = activeProfile(settings);
51
+ const route = getRoute(process.env.FABER_ROUTE ?? s("route") ?? prof.route) ?? ROUTES[0];
52
+ const pins = { ...(prof.modelPins ?? {}), ...(fileCfg["modelPins"] ?? {}) };
53
+ // The route decides the wire format unless a provider is set explicitly.
54
+ const provider = (process.env.CW_PROVIDER ?? s("provider") ?? route.wire).toLowerCase();
46
55
  const anthropic = provider === "anthropic";
56
+ const rawModel = process.env.CW_MODEL ?? s("model") ?? prof.model
57
+ ?? (anthropic ? DEFAULT_ANTHROPIC_MODEL : DEFAULT_OPENAI_MODEL);
58
+ // Env var first, then the 0600 credential store — unless the profile says
59
+ // the stored key was chosen deliberately, in which case honour that.
60
+ const keyFromProfile = prof.apiKeyEnv
61
+ ? (prof.preferStoredKey
62
+ ? (getCredential(prof.apiKeyEnv) ?? resolveCredential(prof.apiKeyEnv))
63
+ : resolveCredential(prof.apiKeyEnv))
64
+ : undefined;
65
+ const region = process.env.FABER_REGION ?? s("region") ?? prof.region;
66
+ const routeUrl = baseUrlFor(route, region);
67
+ // A route-specific credential (e.g. BEDROCK_API_KEY) wins over the generic one.
68
+ const routeKey = route.keyEnv
69
+ ? (prof.preferStoredKey ? (getCredential(route.keyEnv) ?? resolveCredential(route.keyEnv))
70
+ : resolveCredential(route.keyEnv))
71
+ : undefined;
47
72
  return {
48
73
  workspace: ws,
49
74
  stateDir,
50
75
  provider,
51
- model: process.env.CW_MODEL ?? s("model") ?? (anthropic ? DEFAULT_ANTHROPIC_MODEL : DEFAULT_OPENAI_MODEL),
52
- baseUrl: process.env.CW_BASE_URL ?? s("baseUrl") ?? (anthropic ? "https://api.anthropic.com" : "https://api.openai.com/v1"),
53
- apiKey: anthropic ? (process.env.ANTHROPIC_API_KEY ?? s("apiKey")) : (process.env.OPENAI_API_KEY ?? s("apiKey")),
54
- weakModel: process.env.CW_WEAK_MODEL ?? s("weakModel"),
76
+ model: resolveModel(rawModel, route, pins),
77
+ baseUrl: process.env.CW_BASE_URL ?? s("baseUrl") ?? prof.baseUrl ?? routeUrl
78
+ ?? (anthropic ? "https://api.anthropic.com" : "https://api.openai.com/v1"),
79
+ // A route that names its own credential (BEDROCK_API_KEY) must NOT fall
80
+ // back to the generic one: sending an Anthropic key to Bedrock produces a
81
+ // confusing 401 and hides the fact that AWS signing was available.
82
+ apiKey: routeKey ?? keyFromProfile ?? s("apiKey")
83
+ ?? (route.keyEnv
84
+ ? undefined
85
+ : resolveCredential(anthropic ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY")),
86
+ weakModel: process.env.CW_WEAK_MODEL ?? s("weakModel") ?? prof.weakModel,
55
87
  maxTokens: 4096,
56
88
  maxIterations: Number(process.env.CW_MAX_ITERATIONS ?? fileCfg["maxIterations"] ?? 40),
57
89
  contextTokenBudget: Number(process.env.CW_CONTEXT_BUDGET ?? fileCfg["contextTokenBudget"] ?? 60_000),
@@ -64,5 +96,16 @@ export function loadConfig(workspace) {
64
96
  indexDb: path.join(stateDir, "index.db"),
65
97
  sessionsDir: path.join(stateDir, "sessions"),
66
98
  usageDb: path.join(stateDir, "usage.db"),
99
+ route: route.id,
100
+ region,
101
+ modelPins: pins,
102
+ profileName: settings.activeProfile,
103
+ priceIn: Number(process.env.FABER_PRICE_IN) || prof.priceIn,
104
+ // On by default: costs are frozen per task, so silently stale prices would
105
+ // corrupt history permanently. Opt out with FABER_AUTO_PRICES=0.
106
+ autoRefreshPrices: process.env.FABER_AUTO_PRICES === "0" ? false
107
+ : process.env.FABER_AUTO_PRICES === "1" ? true
108
+ : prof.autoRefreshPrices !== false,
109
+ priceOut: Number(process.env.FABER_PRICE_OUT) || prof.priceOut,
67
110
  };
68
111
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Credential storage: ~/.faber/credentials.json, mode 0600.
3
+ *
4
+ * Deliberately OUTSIDE any project directory. A key written next to your code
5
+ * is one `git add -f`, one bad .gitignore, or one shared zip away from being
6
+ * public — and scanning bots find committed keys in minutes. Every tool that
7
+ * handles this well (aws, gcloud, npm, git) keeps secrets in the home
8
+ * directory and lets project config reference them by name.
9
+ *
10
+ * Resolution order for a credential:
11
+ * 1. the environment variable (CI, one-offs, and existing setups keep working)
12
+ * 2. this file, keyed by the same variable name
13
+ * Nothing else. Faber never writes a secret into a workspace.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+ export function credentialsPath() {
19
+ return path.join(os.homedir(), ".faber", "credentials.json");
20
+ }
21
+ function read(file = credentialsPath()) {
22
+ try {
23
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
24
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
25
+ return {};
26
+ const out = {};
27
+ for (const [k, v] of Object.entries(raw)) {
28
+ if (typeof v === "string")
29
+ out[k] = v;
30
+ }
31
+ return out;
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ }
37
+ /** Look up a stored credential by the env var name it stands in for. */
38
+ export function getCredential(envName, file = credentialsPath()) {
39
+ return read(file)[envName];
40
+ }
41
+ /** Env var wins, so CI and existing shells are never overridden by a saved key. */
42
+ export function resolveCredential(envName, file = credentialsPath()) {
43
+ return process.env[envName] ?? getCredential(envName, file);
44
+ }
45
+ /** Where a credential came from — so Faber can say so instead of using it silently. */
46
+ export function credentialSource(envName, file = credentialsPath()) {
47
+ const env = process.env[envName];
48
+ if (env)
49
+ return { value: env, from: "environment" };
50
+ const stored = getCredential(envName, file);
51
+ return stored ? { value: stored, from: "store" } : undefined;
52
+ }
53
+ /**
54
+ * Save a credential with owner-only permissions. Written to a temp file first
55
+ * and renamed, so a crash can't leave a half-written store — and chmod happens
56
+ * before the secret lands, never after.
57
+ */
58
+ export function saveCredential(envName, value, file = credentialsPath()) {
59
+ const dir = path.dirname(file);
60
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
61
+ const store = read(file);
62
+ store[envName] = value;
63
+ const tmp = `${file}.tmp-${process.pid}`;
64
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
65
+ try {
66
+ fs.rmSync(file, { force: true });
67
+ }
68
+ catch { /* absent is fine */ }
69
+ fs.renameSync(tmp, file); // rename onto an existing file fails on Windows
70
+ try {
71
+ fs.chmodSync(file, 0o600);
72
+ }
73
+ catch { /* no-op where modes don't apply */ }
74
+ }
75
+ export function deleteCredential(envName, file = credentialsPath()) {
76
+ const store = read(file);
77
+ if (!(envName in store))
78
+ return false;
79
+ delete store[envName];
80
+ const tmp = `${file}.tmp-${process.pid}`;
81
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
82
+ try {
83
+ fs.rmSync(file, { force: true });
84
+ }
85
+ catch { /* absent is fine */ }
86
+ fs.renameSync(tmp, file);
87
+ return true;
88
+ }
89
+ /** Names of stored credentials — never the values. */
90
+ export function listCredentialNames(file = credentialsPath()) {
91
+ return Object.keys(read(file)).sort();
92
+ }
93
+ /**
94
+ * Does this look like a real key for that variable?
95
+ * Deliberately loose — formats change, and refusing a valid key is worse than
96
+ * accepting an odd one. This only catches obvious mistakes: a typo, a pasted
97
+ * filename, an accidental keystroke. Returns a reason when it looks wrong.
98
+ */
99
+ export function looksLikeKey(envName, value) {
100
+ const v = value.trim();
101
+ if (v.length < 20)
102
+ return "that looks too short for an API key";
103
+ if (/\s/.test(v))
104
+ return "that contains spaces";
105
+ if (envName === "ANTHROPIC_API_KEY" && !v.startsWith("sk-ant-")) {
106
+ return "Anthropic keys normally start with sk-ant-";
107
+ }
108
+ if (envName === "OPENAI_API_KEY" && !v.startsWith("sk-")) {
109
+ return "OpenAI keys normally start with sk-";
110
+ }
111
+ return undefined;
112
+ }
113
+ /** Show a key without exposing it: sk-ant-…4f2a */
114
+ export function maskCredential(value) {
115
+ if (value.length <= 12)
116
+ return "…".repeat(Math.max(1, value.length - 2)) + value.slice(-2);
117
+ return `${value.slice(0, 7)}…${value.slice(-4)}`;
118
+ }
119
+ /**
120
+ * True when the file is readable by anyone but the owner.
121
+ * Windows has no POSIX mode bits — Node reports a synthetic value there, so
122
+ * checking it would warn every Windows user on every launch. Access control
123
+ * on that platform comes from NTFS ACLs, which this can't inspect.
124
+ */
125
+ export function permissionsAreLoose(file = credentialsPath()) {
126
+ if (process.platform === "win32")
127
+ return false;
128
+ try {
129
+ return (fs.statSync(file).mode & 0o077) !== 0;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }