faberwright 0.3.0

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/git.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Git awareness (opt-in, never surprising):
3
+ * - Detect repo + dirty tree so the CLI can warn before the agent edits.
4
+ * - CW_GIT=commit -> one commit per completed task (never per edit).
5
+ * All failures degrade silently to "not a repo" — git is optional.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ function git(ws, args) {
11
+ try {
12
+ return execFileSync("git", args, { cwd: ws, stdio: ["ignore", "pipe", "ignore"] })
13
+ .toString().trim();
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ export function isRepo(ws) {
20
+ return git(ws, ["rev-parse", "--is-inside-work-tree"]) === "true";
21
+ }
22
+ export function isDirty(ws) {
23
+ const out = git(ws, ["status", "--porcelain"]);
24
+ return out !== undefined && out.length > 0;
25
+ }
26
+ /**
27
+ * Make sure the state directory can never be committed.
28
+ * Strategy: write to .git/info/exclude — a LOCAL ignore file (never committed,
29
+ * never shows as a diff), so protection is automatic without modifying the
30
+ * user's tracked .gitignore. Returns what happened so the CLI can inform.
31
+ */
32
+ export function ensureStateIgnored(ws) {
33
+ if (!isRepo(ws))
34
+ return "not-repo";
35
+ // worst case: state was committed in the past — ignoring won't untrack it
36
+ const tracked = git(ws, ["ls-files", ".faber", ".codewright"]);
37
+ if (tracked)
38
+ return "TRACKED";
39
+ // git check-ignore exits 0 (returns output) when the path IS ignored
40
+ if (git(ws, ["check-ignore", ".faber"]) !== undefined)
41
+ return "already-ignored";
42
+ // check-ignore only matches a directory-only pattern (".faber/") when the
43
+ // directory exists, so also look for our own line — otherwise a first run
44
+ // before the state dir is created would append a duplicate every time.
45
+ const gitDirEarly = git(ws, ["rev-parse", "--git-dir"]);
46
+ if (gitDirEarly) {
47
+ try {
48
+ const excl = fs.readFileSync(path.resolve(ws, gitDirEarly, "info", "exclude"), "utf8");
49
+ if (excl.includes(".faber/"))
50
+ return "already-ignored";
51
+ }
52
+ catch { /* no exclude file yet */ }
53
+ }
54
+ try {
55
+ const gitDir = git(ws, ["rev-parse", "--git-dir"]);
56
+ if (!gitDir)
57
+ return "not-repo";
58
+ const infoDir = path.resolve(ws, gitDir, "info");
59
+ fs.mkdirSync(infoDir, { recursive: true });
60
+ fs.appendFileSync(path.join(infoDir, "exclude"), "\n# added by faber (local-only ignore)\n.faber/\n.codewright/\n");
61
+ return "excluded-now";
62
+ }
63
+ catch {
64
+ return "not-repo";
65
+ }
66
+ }
67
+ /** Commit everything with a task message. Returns short hash, or undefined if nothing to commit / no repo. */
68
+ export function commitTask(ws, task) {
69
+ if (!isRepo(ws) || !isDirty(ws))
70
+ return undefined;
71
+ const msg = `faber: ${task.replace(/\s+/g, " ").trim().slice(0, 72)}`;
72
+ if (git(ws, ["add", "-A"]) === undefined)
73
+ return undefined;
74
+ if (git(ws, ["commit", "-m", msg, "--no-verify"]) === undefined)
75
+ return undefined;
76
+ return git(ws, ["rev-parse", "--short", "HEAD"]);
77
+ }
package/dist/index.js ADDED
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Faber CLI.
4
+ *
5
+ * faber interactive REPL (current directory)
6
+ * faber "fix the bug" one-shot task
7
+ * faber --resume continue the latest session
8
+ * faber --ask approval mode: preview every diff before applying
9
+ * faber -w /path/to/repo choose workspace
10
+ *
11
+ * REPL commands:
12
+ * /index /memory [archived] /forget <id> /archive <id> /unarchive <id>
13
+ * /prune <days> /compact /undo /sessions /resume /clear /ask /auto /help /exit
14
+ *
15
+ * During a task: press Esc or Ctrl-C once to cancel cleanly (checkpoints kept).
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import * as readline from "node:readline/promises";
21
+ import pc from "picocolors";
22
+ // Suppress ONLY the node:sqlite ExperimentalWarning (stable in newer Node);
23
+ // every other warning still surfaces. Remove when engines bumps to >=23.
24
+ process.removeAllListeners("warning");
25
+ process.on("warning", (w) => {
26
+ if (w.name !== "ExperimentalWarning" || !String(w.message).includes("SQLite")) {
27
+ console.warn(`${w.name}: ${w.message}`);
28
+ }
29
+ });
30
+ import { loadConfig } from "./config.js";
31
+ import { Agent } from "./agent.js";
32
+ import { FatalError, CancelledError } from "./errors.js";
33
+ import { SessionStore } from "./memory/sessions.js";
34
+ import { isRepo, isDirty, ensureStateIgnored } from "./git.js";
35
+ import { select, setSelectGuard } from "./prompt.js";
36
+ import { createComposedInput, restore, describeComposed } from "./input.js";
37
+ import { Composer } from "./editor.js";
38
+ import { StatusLine } from "./status.js";
39
+ import { renderMarkdown, StreamRenderer } from "./markdown.js";
40
+ import { renderUsagePanel } from "./usage.js";
41
+ /** Version comes from package.json — one source of truth for banner and --version. */
42
+ const VERSION = (() => {
43
+ try {
44
+ const here = path.dirname(fileURLToPath(import.meta.url));
45
+ return JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8")).version;
46
+ }
47
+ catch {
48
+ return "0.0.0";
49
+ }
50
+ })();
51
+ function renderDiff(diff) {
52
+ return diff.split("\n").map((l) => l.startsWith("+") && !l.startsWith("+++") ? pc.green(l)
53
+ : l.startsWith("-") && !l.startsWith("---") ? pc.red(l)
54
+ : l.startsWith("@@") ? pc.cyan(l)
55
+ : pc.dim(l)).join("\n");
56
+ }
57
+ async function main() {
58
+ const argv = process.argv.slice(2);
59
+ const flags = new Set(argv.filter((a) => a.startsWith("-")));
60
+ if (flags.has("--help") || flags.has("-h")) {
61
+ console.log("faber [task] [--workspace|-w <dir>] [--resume] [--ask|--auto] [--version]\n" + HELP);
62
+ return;
63
+ }
64
+ if (flags.has("--version") || flags.has("-v")) {
65
+ console.log(`faber ${VERSION}`);
66
+ return;
67
+ }
68
+ const wsIdx = argv.findIndex((a) => a === "--workspace" || a === "-w");
69
+ const workspace = wsIdx >= 0 ? argv[wsIdx + 1] : undefined;
70
+ const task = argv.filter((a, i) => !a.startsWith("-") && (wsIdx === -1 || i !== wsIdx + 1)).join(" ");
71
+ let config;
72
+ try {
73
+ config = loadConfig(workspace);
74
+ }
75
+ catch (e) {
76
+ console.error(pc.red(`Error: ${e instanceof Error ? e.message : e}`));
77
+ process.exit(1);
78
+ }
79
+ if (flags.has("--ask"))
80
+ config.approvalMode = "ask";
81
+ if (flags.has("--auto"))
82
+ config.approvalMode = "auto";
83
+ const isTTY = process.stdin.isTTY && process.stdout.isTTY;
84
+ const composer = isTTY ? new Composer(process.stdin, process.stdout) : null;
85
+ const composed = isTTY ? null : createComposedInput(process.stdin);
86
+ setSelectGuard(composer
87
+ ? (on) => (on ? composer.pause() : composer.resume())
88
+ : composed.setGuard);
89
+ const rl = readline.createInterface({
90
+ input: composed ? composed.stream : process.stdin,
91
+ output: process.stdout,
92
+ terminal: false,
93
+ });
94
+ if (composer) {
95
+ composer.start();
96
+ process.on("exit", () => composer.stop());
97
+ }
98
+ let approvalMode = config.approvalMode;
99
+ const approve = async (pending) => {
100
+ if (approvalMode === "auto")
101
+ return true;
102
+ if (pending.kind === "shell") {
103
+ console.log(pc.bold(`\nAgent wants to run:`) + ` ${pc.yellow(pending.path)}`);
104
+ }
105
+ else {
106
+ console.log(pc.bold(`\nProposed change to ${pending.path}:`));
107
+ console.log(renderDiff(pending.diff));
108
+ }
109
+ const choice = await select(rl, "Apply?", ["Yes", "No", "Always this session"], 0);
110
+ if (choice === 2) {
111
+ approvalMode = "auto";
112
+ return true;
113
+ }
114
+ return choice === 0;
115
+ };
116
+ const askUser = async (question, options) => {
117
+ console.log("");
118
+ return select(rl, question, options, 0);
119
+ };
120
+ let streaming = false;
121
+ let status = null;
122
+ let streamR = null;
123
+ const events = {
124
+ onTextDelta: (d) => {
125
+ if (!streaming) {
126
+ status?.suspend();
127
+ streamR = new StreamRenderer();
128
+ }
129
+ streaming = true;
130
+ const rendered = streamR.feed(d);
131
+ if (rendered)
132
+ process.stdout.write(pc.dim(rendered));
133
+ },
134
+ onTextDone: () => {
135
+ if (streaming) {
136
+ const tail = streamR?.flush();
137
+ if (tail)
138
+ process.stdout.write(pc.dim(tail));
139
+ streaming = false;
140
+ status?.resume();
141
+ }
142
+ },
143
+ onTool: (name, brief) => { status?.clear(); console.log(pc.cyan(` ⚙ ${name} ${brief}`)); },
144
+ onToolResult: (summary) => { status?.clear(); console.log(pc.dim(` ⎿ ${summary}`)); },
145
+ onToolError: (msg) => { status?.clear(); console.log(pc.red(` ✗ ${msg.split("\n")[0]?.slice(0, 160)}`)); },
146
+ onInfo: (msg) => { status?.clear(); console.log(pc.yellow(` ℹ ${msg}`)); },
147
+ onUsage: (u) => {
148
+ status?.clear();
149
+ const k = (n) => n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
150
+ const totalIn = u.input + u.cacheRead + u.cacheWrite;
151
+ const cachePct = totalIn > 0 ? Math.round((u.cacheRead / totalIn) * 100) : 0;
152
+ let line = `tokens: ${k(totalIn)} in (${cachePct}% cached) / ${k(u.output)} out · ${u.calls} call${u.calls === 1 ? "" : "s"}`;
153
+ const pIn = Number(process.env.CW_PRICE_IN), pOut = Number(process.env.CW_PRICE_OUT);
154
+ if (pIn > 0 && pOut > 0) { // $/Mtok: cache reads ~0.1x, cache writes ~1.25x
155
+ const usd = (u.input * pIn + u.cacheRead * pIn * 0.1 + u.cacheWrite * pIn * 1.25 + u.output * pOut) / 1e6;
156
+ line += ` · ~$${usd.toFixed(3)}`;
157
+ }
158
+ console.log(pc.dim(line));
159
+ },
160
+ };
161
+ const resumeId = flags.has("--resume") ? SessionStore.latestId(config.sessionsDir) : undefined;
162
+ let agent;
163
+ try {
164
+ agent = new Agent(config, approve, events, resumeId, askUser);
165
+ }
166
+ catch (e) {
167
+ console.error(pc.red(`Error: ${e instanceof Error ? e.message : e}`));
168
+ rl.close();
169
+ process.exit(1);
170
+ }
171
+ const runTask = async (text) => {
172
+ const controller = new AbortController();
173
+ const cancel = () => controller.abort();
174
+ process.once("SIGINT", cancel);
175
+ // STEERING: anything typed while the agent works is queued and injected
176
+ // at the next loop boundary (not available inside an approval prompt).
177
+ const onLine = (line) => {
178
+ if (!line.trim())
179
+ return;
180
+ agent.steer(restore(line).trim());
181
+ console.log(pc.magenta(` ↳ queued for the agent: ${describeComposed(line)}`));
182
+ };
183
+ status = new StatusLine(process.stdout, !!composer);
184
+ status.start();
185
+ if (composer) {
186
+ composer.onInterrupt(cancel);
187
+ composer.enterSteerMode((text) => {
188
+ agent.steer(text.trim());
189
+ console.log(pc.magenta(` ↳ queued for the agent: ${Composer.describe(text)}`));
190
+ });
191
+ }
192
+ else {
193
+ rl.on("line", onLine);
194
+ }
195
+ try {
196
+ const answer = await agent.runTask(text, controller.signal);
197
+ console.log(pc.green("\n─ result ────────────────────────────"));
198
+ console.log(renderMarkdown(answer));
199
+ console.log(pc.green("─────────────────────────────────────"));
200
+ }
201
+ catch (e) {
202
+ if (e instanceof CancelledError) {
203
+ console.log(pc.yellow("\nInterrupted. Partial changes kept — /undo to revert."));
204
+ }
205
+ else if (e instanceof FatalError) {
206
+ console.log(pc.red(`\nFatal: ${e.message}`));
207
+ }
208
+ else {
209
+ console.log(pc.red(`\nUnexpected error: ${e instanceof Error ? e.stack ?? e.message : e}`));
210
+ }
211
+ }
212
+ finally {
213
+ status.stop();
214
+ status = null;
215
+ if (composer)
216
+ composer.exitSteerMode();
217
+ else
218
+ rl.removeListener("line", onLine);
219
+ process.removeListener("SIGINT", cancel);
220
+ }
221
+ };
222
+ const command = async (line) => {
223
+ const [name = "", ...args] = line.trim().split(/\s+/);
224
+ const id = Number(args[0]);
225
+ switch (name.toLowerCase()) {
226
+ case "/exit":
227
+ case "/quit": return false;
228
+ case "/help":
229
+ console.log(HELP);
230
+ break;
231
+ case "/index": {
232
+ const s = agent.indexer.build();
233
+ console.log(`Indexed ${s.files} files, ${s.symbols} symbols, ${s.edges} call edges.`);
234
+ break;
235
+ }
236
+ case "/map": {
237
+ if (!args[0]) {
238
+ console.log("Usage: /map <function-or-symbol> e.g. /map main");
239
+ break;
240
+ }
241
+ if (!agent.indexer.isBuilt())
242
+ agent.indexer.build();
243
+ else
244
+ agent.indexer.refresh();
245
+ console.log(agent.indexer.callTree(args[0]));
246
+ break;
247
+ }
248
+ case "/memory": {
249
+ const archived = args[0]?.toLowerCase() === "archived";
250
+ const rows = archived ? agent.longTerm.archivedMemories() : agent.longTerm.allMemories();
251
+ if (!rows.length)
252
+ console.log(`No ${archived ? "archived" : "active"} memories.`);
253
+ for (const m of rows) {
254
+ const when = new Date(m.created * 1000).toISOString().slice(0, 10);
255
+ console.log(` #${m.id} [${m.kind}] (${when}) ${m.content}`);
256
+ }
257
+ if (!archived)
258
+ for (const n of agent.longTerm.fileNotes())
259
+ console.log(` file: ${n.path} — ${n.summary}`);
260
+ break;
261
+ }
262
+ case "/forget":
263
+ console.log(Number.isInteger(id) && agent.longTerm.forget(id) ? `Deleted memory #${id}.` : "Usage: /forget <id>");
264
+ break;
265
+ case "/archive":
266
+ console.log(Number.isInteger(id) && agent.longTerm.archive(id, true) ? `Archived memory #${id}.` : "Usage: /archive <id>");
267
+ break;
268
+ case "/unarchive":
269
+ console.log(Number.isInteger(id) && agent.longTerm.archive(id, false) ? `Restored memory #${id}.` : "Usage: /unarchive <id>");
270
+ break;
271
+ case "/prune": {
272
+ const days = Number(args[0]);
273
+ if (!Number.isFinite(days)) {
274
+ console.log("Usage: /prune <days>");
275
+ break;
276
+ }
277
+ console.log(`Archived ${agent.longTerm.archiveOlderThan(days)} memories older than ${days} days. See /memory archived.`);
278
+ break;
279
+ }
280
+ case "/compact": {
281
+ const before = agent.shortTerm.tokens();
282
+ const did = await agent.shortTerm.maybeCompact(agent.llm, true);
283
+ console.log(did ? `Compacted: ~${before} → ~${agent.shortTerm.tokens()} tokens.` : "Nothing to compact.");
284
+ break;
285
+ }
286
+ case "/sessions":
287
+ for (const s of SessionStore.list(config.sessionsDir)) {
288
+ console.log(` ${s.id} (${s.messages} messages, ${(s.bytes / 1024).toFixed(1)} KB)`);
289
+ }
290
+ break;
291
+ case "/undo": {
292
+ const restored = agent.checkpoints.undoLast();
293
+ console.log(restored.length
294
+ ? `Reverted ${restored.length} file(s): ${restored.join(", ")} (/redo to bring the change back)`
295
+ : "Nothing to undo.");
296
+ break;
297
+ }
298
+ case "/redo": {
299
+ const redone = agent.checkpoints.redo();
300
+ console.log(redone ? `Re-applied ${redone.length} file(s): ${redone.join(", ")}` : "Nothing to redo (redo follows an /undo or /restore in this session).");
301
+ break;
302
+ }
303
+ case "/history": {
304
+ const cps = agent.checkpoints.list();
305
+ if (!cps.length) {
306
+ console.log("No checkpoints yet.");
307
+ break;
308
+ }
309
+ for (const c of cps.slice(-15)) {
310
+ const label = c.kind === "restore-point" ? pc.dim(c.label || "restore point") : (c.label || "(unlabeled task)");
311
+ console.log(` ${c.id} ${label} [${c.files.length} file(s): ${c.files.slice(0, 3).join(", ")}${c.files.length > 3 ? ", …" : ""}]`);
312
+ }
313
+ console.log(pc.dim(" /restore <id> to jump to the state before that task."));
314
+ break;
315
+ }
316
+ case "/restore": {
317
+ if (!args[0]) {
318
+ console.log("Usage: /restore <checkpoint-id> (ids from /history)");
319
+ break;
320
+ }
321
+ const restored = agent.checkpoints.restore(args[0]);
322
+ console.log(restored ? `Restored ${restored.length} file(s) to before ${args[0]}. (/redo reverses this.)` : `No checkpoint ${args[0]}.`);
323
+ break;
324
+ }
325
+ case "/clear":
326
+ agent.shortTerm.clear();
327
+ console.log("Short-term memory cleared.");
328
+ break;
329
+ case "/usage": {
330
+ console.log(renderUsagePanel(agent.usage, Number(process.env.CW_PRICE_IN) || undefined, Number(process.env.CW_PRICE_OUT) || undefined));
331
+ break;
332
+ }
333
+ case "/verbose":
334
+ agent.verbose = true;
335
+ console.log("Verbose mode ON: full-depth explanations (higher token cost).");
336
+ break;
337
+ case "/concise":
338
+ agent.verbose = false;
339
+ console.log("Concise mode ON (default): short answers, code never truncated.");
340
+ break;
341
+ case "/ask":
342
+ approvalMode = "ask";
343
+ console.log("Approval mode ON: diffs previewed before applying.");
344
+ break;
345
+ case "/auto":
346
+ approvalMode = "auto";
347
+ console.log("Approval mode OFF: changes apply automatically.");
348
+ break;
349
+ default: console.log(`Unknown command: ${name} (try /help)`);
350
+ }
351
+ return true;
352
+ };
353
+ try {
354
+ if (task) {
355
+ await runTask(task);
356
+ return;
357
+ }
358
+ console.log(pc.cyan(`Faber v${VERSION} — agentic coding assistant`));
359
+ console.log(pc.dim(`workspace: ${config.workspace}\nmodel: ${config.model} (${config.provider}) approval: ${approvalMode}`));
360
+ if (isRepo(config.workspace) && isDirty(config.workspace)) {
361
+ console.log(pc.yellow("Git: you have uncommitted changes — consider committing before letting me edit."));
362
+ }
363
+ switch (ensureStateIgnored(config.workspace)) {
364
+ case "excluded-now":
365
+ console.log(pc.dim(`Protected ${path.basename(config.stateDir)}/ from commits via .git/info/exclude (local). Add it to .gitignore too if teammates will use Faber.`));
366
+ break;
367
+ case "TRACKED":
368
+ console.log(pc.red("WARNING: the state directory is TRACKED by git — sessions and checkpoints may contain file contents/secrets. Run: git rm -r --cached .faber .codewright && add '.faber/' to .gitignore, then commit."));
369
+ break;
370
+ }
371
+ if (!resumeId) {
372
+ const last = SessionStore.lastSessionInfo(config.sessionsDir, agent.session.id);
373
+ if (last) {
374
+ const { humanAge } = await import("./agent.js");
375
+ console.log(pc.yellow(`Previous session found (${last.messages} messages, ${humanAge(last.ageMs)} ago) — ` +
376
+ `run \`faber --resume\` to continue it, or just ask: I can search past sessions.`));
377
+ }
378
+ }
379
+ console.log(pc.dim(`Type a task, or /help for commands. While the agent works, keep typing to steer it.\n`));
380
+ while (true) {
381
+ let line;
382
+ if (composer) {
383
+ const got = await composer.readLine(pc.cyan("faber> "));
384
+ if (got === null)
385
+ break;
386
+ line = got.trim();
387
+ }
388
+ else {
389
+ try {
390
+ line = restore(await rl.question(pc.cyan("faber> "))).trim();
391
+ }
392
+ catch {
393
+ break;
394
+ }
395
+ }
396
+ if (!line)
397
+ continue;
398
+ if (line.startsWith("/")) {
399
+ if (!(await command(line)))
400
+ break;
401
+ continue;
402
+ }
403
+ await runTask(line);
404
+ }
405
+ }
406
+ finally {
407
+ rl.close();
408
+ agent.close();
409
+ console.log(pc.dim("bye."));
410
+ }
411
+ }
412
+ const HELP = `
413
+ /index rebuild the code graph (symbols + call edges)
414
+ /map <symbol> print the call tree from any entry point (e.g. /map main)
415
+ /memory show active long-term memories (+ file notes)
416
+ /memory archived show archived memories
417
+ /forget <id> permanently delete a memory
418
+ /archive <id> hide a memory from recall (kept on disk)
419
+ /unarchive <id> restore an archived memory
420
+ /prune <days> archive all memories older than <days>
421
+ /compact force-summarize the conversation now
422
+ /sessions list saved sessions (resume latest with: faber --resume)
423
+ /undo revert files changed by the last task (reversible)
424
+ /redo reverse the last /undo or /restore
425
+ /history list task checkpoints with files touched
426
+ /restore <id> jump files back to before a specific task
427
+ /clear clear short-term conversation memory
428
+ /ask | /auto approval mode: preview diffs and commands (default) / apply freely
429
+ env: CW_APPROVAL=auto to change default; CW_GIT=commit for one git commit per task
430
+ /exit quit
431
+ `;
432
+ main().catch((e) => { console.error(pc.red(String(e?.stack ?? e))); process.exit(1); });