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/LICENSE +201 -0
- package/README.md +181 -0
- package/dist/agent.js +221 -0
- package/dist/checkpoints.js +147 -0
- package/dist/config.js +68 -0
- package/dist/editor.js +417 -0
- package/dist/errors.js +52 -0
- package/dist/git.js +77 -0
- package/dist/index.js +432 -0
- package/dist/indexer.js +292 -0
- package/dist/input.js +97 -0
- package/dist/llm.js +260 -0
- package/dist/markdown.js +197 -0
- package/dist/memory/longTerm.js +105 -0
- package/dist/memory/sessions.js +128 -0
- package/dist/memory/shortTerm.js +56 -0
- package/dist/prompt.js +80 -0
- package/dist/status.js +63 -0
- package/dist/tools/fs.js +177 -0
- package/dist/tools/registry.js +206 -0
- package/dist/tools/shell.js +68 -0
- package/dist/usage.js +147 -0
- package/package.json +49 -0
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); });
|