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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Checkpoints v2 — reversible, inspectable recovery.
3
+ *
4
+ * Each task begins a checkpoint capturing files BEFORE modification.
5
+ * New in v2:
6
+ * - restore(id) is NON-DESTRUCTIVE: before restoring, the CURRENT state of
7
+ * the same files is saved as a new "restore-point" checkpoint — so every
8
+ * restore can itself be undone (that's redo). Nothing is ever lost.
9
+ * - list() exposes history: id, kind, label, files touched, timestamp.
10
+ * - undoLast() = restore(newest); redo = restore the restore-point that the
11
+ * last restore created (tracked per session).
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ let seq = 0;
16
+ /** Timestamp + monotonic counter: unique even within the same millisecond. */
17
+ function newId(suffix = "") {
18
+ seq = (seq + 1) % 10_000;
19
+ return new Date().toISOString().replace(/[:.]/g, "-") +
20
+ "-" + String(seq).padStart(4, "0") + suffix;
21
+ }
22
+ export class CheckpointManager {
23
+ workspace;
24
+ root;
25
+ current;
26
+ manifest = [];
27
+ label = "";
28
+ lastRestorePointId;
29
+ constructor(stateDir, workspace) {
30
+ this.workspace = workspace;
31
+ this.root = path.join(stateDir, "checkpoints");
32
+ fs.mkdirSync(this.root, { recursive: true });
33
+ }
34
+ begin(label = "") {
35
+ const id = newId();
36
+ this.current = path.join(this.root, id);
37
+ fs.mkdirSync(this.current, { recursive: true });
38
+ this.manifest = [];
39
+ this.label = label.replace(/\s+/g, " ").trim().slice(0, 80);
40
+ return id;
41
+ }
42
+ snapshot(filePath) {
43
+ if (!this.current)
44
+ this.begin();
45
+ const abs = path.resolve(filePath);
46
+ const rel = path.relative(this.workspace, abs);
47
+ if (this.manifest.some((e) => e.path === rel))
48
+ return; // keep earliest state
49
+ const existed = fs.existsSync(abs);
50
+ if (existed) {
51
+ const dest = path.join(this.current, "files", rel);
52
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
53
+ fs.copyFileSync(abs, dest);
54
+ }
55
+ this.manifest.push({ path: rel, existed });
56
+ this.writeManifest(this.current, { kind: "task", label: this.label, entries: this.manifest });
57
+ }
58
+ commit() {
59
+ if (this.current && this.manifest.length === 0) {
60
+ fs.rmSync(this.current, { recursive: true, force: true });
61
+ }
62
+ this.current = undefined;
63
+ this.manifest = [];
64
+ }
65
+ list() {
66
+ if (!fs.existsSync(this.root))
67
+ return [];
68
+ const out = [];
69
+ for (const d of fs.readdirSync(this.root).sort()) {
70
+ const mPath = path.join(this.root, d, "manifest.json");
71
+ if (!fs.existsSync(mPath))
72
+ continue;
73
+ try {
74
+ const m = JSON.parse(fs.readFileSync(mPath, "utf8"));
75
+ out.push({
76
+ id: d, kind: m.kind ?? "task", label: m.label ?? "",
77
+ files: m.entries.map((e) => e.path), mtimeMs: fs.statSync(mPath).mtimeMs,
78
+ });
79
+ }
80
+ catch { /* skip corrupt */ }
81
+ }
82
+ return out;
83
+ }
84
+ /**
85
+ * Restore files to their state in checkpoint `id`.
86
+ * Current state of those files is first saved as a restore-point, so this
87
+ * operation is always reversible. Returns restored paths, or undefined if
88
+ * the checkpoint does not exist.
89
+ */
90
+ restore(id) {
91
+ const cpDir = path.join(this.root, id);
92
+ const mPath = path.join(cpDir, "manifest.json");
93
+ if (!fs.existsSync(mPath))
94
+ return undefined;
95
+ const manifest = JSON.parse(fs.readFileSync(mPath, "utf8"));
96
+ // 1. save current state of the same files as a restore-point (the redo data)
97
+ const rpId = newId("-rp");
98
+ const rpDir = path.join(this.root, rpId);
99
+ fs.mkdirSync(rpDir, { recursive: true });
100
+ const rpEntries = [];
101
+ for (const entry of manifest.entries) {
102
+ const abs = path.join(this.workspace, entry.path);
103
+ const existsNow = fs.existsSync(abs);
104
+ if (existsNow) {
105
+ const dest = path.join(rpDir, "files", entry.path);
106
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
107
+ fs.copyFileSync(abs, dest);
108
+ }
109
+ rpEntries.push({ path: entry.path, existed: existsNow });
110
+ }
111
+ this.writeManifest(rpDir, { kind: "restore-point", label: `before restoring ${id}`, entries: rpEntries });
112
+ this.lastRestorePointId = rpId;
113
+ // 2. restore from the target checkpoint (data kept — restore is repeatable)
114
+ const restored = [];
115
+ for (const entry of manifest.entries) {
116
+ const target = path.join(this.workspace, entry.path);
117
+ if (entry.existed) {
118
+ fs.mkdirSync(path.dirname(target), { recursive: true });
119
+ fs.copyFileSync(path.join(cpDir, "files", entry.path), target);
120
+ }
121
+ else if (fs.existsSync(target)) {
122
+ fs.unlinkSync(target);
123
+ }
124
+ restored.push(entry.path);
125
+ }
126
+ return restored;
127
+ }
128
+ /** Undo the most recent task (non-destructive; see restore). */
129
+ undoLast() {
130
+ const all = this.list();
131
+ const last = all.at(-1);
132
+ if (!last)
133
+ return [];
134
+ return this.restore(last.id) ?? [];
135
+ }
136
+ /** Redo: revert the last restore performed in this session. */
137
+ redo() {
138
+ if (!this.lastRestorePointId)
139
+ return undefined;
140
+ const id = this.lastRestorePointId;
141
+ this.lastRestorePointId = undefined;
142
+ return this.restore(id);
143
+ }
144
+ writeManifest(dir, m) {
145
+ fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(m, null, 2));
146
+ }
147
+ }
package/dist/config.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Configuration: env vars > .faber/config.json > defaults.
3
+ * State lives in <workspace>/.faber/ — except in projects created before the
4
+ * rename, where an existing .codewright/ directory is adopted as-is so memory,
5
+ * code graph, sessions and usage history survive the upgrade untouched.
6
+ * FABER_* env vars are canonical; legacy CW_* names still work.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ export const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5";
11
+ export const DEFAULT_OPENAI_MODEL = "gpt-4o";
12
+ /** Accept both FABER_* (canonical) and CW_* (legacy) env var names. */
13
+ export function normalizeEnv() {
14
+ for (const [key, value] of Object.entries(process.env)) {
15
+ if (value === undefined)
16
+ continue;
17
+ if (key.startsWith("CW_")) {
18
+ const canonical = "FABER_" + key.slice(3);
19
+ if (process.env[canonical] === undefined)
20
+ process.env[canonical] = value;
21
+ }
22
+ else if (key.startsWith("FABER_")) {
23
+ const legacy = "CW_" + key.slice(6);
24
+ if (process.env[legacy] === undefined)
25
+ process.env[legacy] = value;
26
+ }
27
+ }
28
+ }
29
+ export function loadConfig(workspace) {
30
+ normalizeEnv();
31
+ const ws = path.resolve(workspace ?? process.cwd());
32
+ const legacyDir = path.join(ws, ".codewright");
33
+ const stateDir = fs.existsSync(legacyDir) ? legacyDir : path.join(ws, ".faber");
34
+ fs.mkdirSync(path.join(stateDir, "checkpoints"), { recursive: true });
35
+ fs.mkdirSync(path.join(stateDir, "sessions"), { recursive: true });
36
+ let fileCfg = {};
37
+ const cfgPath = path.join(stateDir, "config.json");
38
+ if (fs.existsSync(cfgPath)) {
39
+ try {
40
+ fileCfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
41
+ }
42
+ catch { /* ignore malformed */ }
43
+ }
44
+ const s = (k) => typeof fileCfg[k] === "string" ? fileCfg[k] : undefined;
45
+ const provider = (process.env.CW_PROVIDER ?? s("provider") ?? "anthropic").toLowerCase();
46
+ const anthropic = provider === "anthropic";
47
+ return {
48
+ workspace: ws,
49
+ stateDir,
50
+ 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"),
55
+ maxTokens: 4096,
56
+ maxIterations: Number(process.env.CW_MAX_ITERATIONS ?? fileCfg["maxIterations"] ?? 40),
57
+ contextTokenBudget: Number(process.env.CW_CONTEXT_BUDGET ?? fileCfg["contextTokenBudget"] ?? 60_000),
58
+ keepRecentMessages: 12,
59
+ shellTimeoutMs: Number(process.env.CW_SHELL_TIMEOUT ?? fileCfg["shellTimeoutMs"] ?? 120_000),
60
+ maxFileReadBytes: 200_000,
61
+ retryMaxAttempts: 5,
62
+ approvalMode: (process.env.CW_APPROVAL ?? s("approvalMode") ?? "ask"),
63
+ memoryDb: path.join(stateDir, "memory.db"),
64
+ indexDb: path.join(stateDir, "index.db"),
65
+ sessionsDir: path.join(stateDir, "sessions"),
66
+ usageDb: path.join(stateDir, "usage.db"),
67
+ };
68
+ }
package/dist/editor.js ADDED
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Raw-mode line editor with paste chips.
3
+ *
4
+ * v2: full cursor editing. Left/Right/Home/End/Delete, Ctrl-A/E/K/U,
5
+ * Up/Down history. Rendering is SINGLE-ROW WINDOWED: long input scrolls
6
+ * horizontally behind `…` markers instead of wrapping — so cursor math is
7
+ * always exact and the "backspace can't cross a wrapped line" bug class
8
+ * cannot exist. Multi-line pastes are atomic chips: one arrow-key step,
9
+ * one backspace, never split.
10
+ */
11
+ import pc from "picocolors";
12
+ const PASTE_START = "\x1b[200~";
13
+ const PASTE_END = "\x1b[201~";
14
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
15
+ export class Composer {
16
+ stdin;
17
+ stdout;
18
+ buf = [];
19
+ cursor = 0; // index into buf (0..buf.length)
20
+ history = [];
21
+ histIdx = null;
22
+ draftBeforeHistory = "";
23
+ inPaste = false;
24
+ pasteBuf = "";
25
+ tail = "";
26
+ pasteCount = 0;
27
+ active = "off";
28
+ promptText = "";
29
+ resolveLine;
30
+ steerCb;
31
+ interruptCb;
32
+ lastRender = "";
33
+ boundData = (b) => this.onData(b);
34
+ constructor(stdin, stdout) {
35
+ this.stdin = stdin;
36
+ this.stdout = stdout;
37
+ }
38
+ start() {
39
+ this.stdout.write("\x1b[?2004h");
40
+ this.stdin.setRawMode(true);
41
+ this.stdin.resume();
42
+ this.stdin.on("data", this.boundData);
43
+ }
44
+ stop() {
45
+ this.stdin.removeListener("data", this.boundData);
46
+ if (this.stdin.isTTY)
47
+ this.stdin.setRawMode(false);
48
+ this.stdout.write("\x1b[?2004l");
49
+ }
50
+ pause() { this.stdin.removeListener("data", this.boundData); }
51
+ resume() { this.stdin.on("data", this.boundData); }
52
+ onInterrupt(cb) { this.interruptCb = cb; }
53
+ readLine(prompt) {
54
+ this.active = "prompt";
55
+ this.promptText = prompt;
56
+ this.reset();
57
+ this.render();
58
+ return new Promise((resolve) => { this.resolveLine = resolve; });
59
+ }
60
+ enterSteerMode(cb) {
61
+ this.active = "steer";
62
+ this.reset();
63
+ this.steerCb = cb;
64
+ }
65
+ exitSteerMode() { this.active = "off"; this.steerCb = undefined; this.reset(); }
66
+ // ---------------------------------------------------------------- model
67
+ reset() {
68
+ this.buf = [];
69
+ this.cursor = 0;
70
+ this.inPaste = false;
71
+ this.pasteBuf = "";
72
+ this.pasteCount = 0;
73
+ this.histIdx = null;
74
+ this.lastRender = "";
75
+ this.lastCursorRow = 0;
76
+ }
77
+ compose() {
78
+ return this.buf.map((it) => (it.kind === "ch" ? it.ch : it.value)).join("");
79
+ }
80
+ setFromString(s) {
81
+ this.buf = [...s].map((ch) => ({ kind: "ch", ch }));
82
+ this.cursor = this.buf.length;
83
+ }
84
+ // --------------------------------------------------------------- render
85
+ lastCursorRow = 0; // row (0-based) where cursor sat after last render
86
+ /**
87
+ * TRUE MULTI-ROW render: the draft wraps across
88
+ * terminal rows and the cursor is placed exactly, including across wrap
89
+ * boundaries. Algorithm (same family as GNU readline / linenoise):
90
+ * 1. return to the render origin (up lastCursorRow rows, column 0)
91
+ * 2. clear everything below (ESC[J)
92
+ * 3. print prompt + full colored content; if the content ends exactly at
93
+ * the last column, emit \n to COMMIT the pending wrap so row math is
94
+ * deterministic across terminals
95
+ * 4. compute cursor (row, col) from display-cell width and move there
96
+ */
97
+ render() {
98
+ if (this.active !== "prompt")
99
+ return;
100
+ const cols = Math.max(8, this.stdout.columns || 80);
101
+ const promptW = stripAnsi(this.promptText).length;
102
+ const cells = [];
103
+ const itemStartCell = [];
104
+ for (const it of this.buf) {
105
+ itemStartCell.push(cells.length);
106
+ if (it.kind === "ch")
107
+ cells.push({ ch: it.ch, chip: false });
108
+ else
109
+ for (const ch of it.label)
110
+ cells.push({ ch, chip: true });
111
+ }
112
+ itemStartCell.push(cells.length);
113
+ let colored = "";
114
+ let inChip = false;
115
+ for (const c of cells) {
116
+ if (c.chip && !inChip) {
117
+ colored += "\x1b[36m";
118
+ inChip = true;
119
+ }
120
+ if (!c.chip && inChip) {
121
+ colored += "\x1b[39m";
122
+ inChip = false;
123
+ }
124
+ colored += c.ch;
125
+ }
126
+ if (inChip)
127
+ colored += "\x1b[39m";
128
+ const total = promptW + cells.length;
129
+ const endCommitsWrap = total > 0 && total % cols === 0;
130
+ const cursorCell = promptW + itemStartCell[this.cursor];
131
+ const tRow = Math.floor(cursorCell / cols);
132
+ const tCol = cursorCell % cols;
133
+ const endRow = total === 0 ? 0 : Math.floor(total / cols) - (endCommitsWrap ? 0 : 0);
134
+ // after printing (+ committed wrap \n when needed), terminal cursor is at:
135
+ const printedEndRow = endCommitsWrap ? total / cols : Math.floor(total / cols);
136
+ let out = "\r";
137
+ if (this.lastCursorRow > 0)
138
+ out += `\x1b[${this.lastCursorRow}A`;
139
+ out += "\x1b[J";
140
+ out += this.promptText + colored;
141
+ if (endCommitsWrap)
142
+ out += "\n";
143
+ // move from printed end position to the target cursor cell
144
+ const up = printedEndRow - tRow;
145
+ if (up > 0)
146
+ out += `\x1b[${up}A`;
147
+ out += "\r";
148
+ if (tCol > 0)
149
+ out += `\x1b[${tCol}C`;
150
+ this.lastCursorRow = tRow;
151
+ this.lastRender = out;
152
+ this.stdout.write(out);
153
+ void endRow;
154
+ }
155
+ /** Move the terminal cursor to the end of the draft (before submit/clear). */
156
+ gotoEnd() {
157
+ if (this.active !== "prompt")
158
+ return;
159
+ const cols = Math.max(8, this.stdout.columns || 80);
160
+ const promptW = stripAnsi(this.promptText).length;
161
+ const contentW = this.buf.reduce((n, it) => n + (it.kind === "ch" ? 1 : it.label.length), 0);
162
+ const total = promptW + contentW;
163
+ const endRow = total === 0 ? 0 : Math.floor((total % cols === 0 && total > 0 ? total - 1 : total) / cols);
164
+ const down = endRow - this.lastCursorRow;
165
+ if (down > 0)
166
+ this.stdout.write(`\x1b[${down}B`);
167
+ this.lastCursorRow = 0;
168
+ }
169
+ echoSteer(s) { this.stdout.write(pc.magenta(s)); }
170
+ // ----------------------------------------------------------------- keys
171
+ onData(buf) {
172
+ let s = this.tail + buf.toString("utf8");
173
+ this.tail = "";
174
+ for (let keep = Math.min(PASTE_START.length - 1, s.length); keep > 0; keep--) {
175
+ const suffix = s.slice(-keep);
176
+ if (PASTE_START.startsWith(suffix) || PASTE_END.startsWith(suffix)) {
177
+ this.tail = suffix;
178
+ s = s.slice(0, -keep);
179
+ break;
180
+ }
181
+ }
182
+ let i = 0;
183
+ let dirty = false;
184
+ const done = () => this.active === "off";
185
+ while (i < s.length && !done()) {
186
+ if (!this.inPaste && s.startsWith(PASTE_START, i)) {
187
+ this.inPaste = true;
188
+ this.pasteBuf = "";
189
+ i += PASTE_START.length;
190
+ continue;
191
+ }
192
+ if (this.inPaste) {
193
+ const end = s.indexOf(PASTE_END, i);
194
+ if (end === -1) {
195
+ this.pasteBuf += s.slice(i);
196
+ return;
197
+ }
198
+ this.pasteBuf += s.slice(i, end);
199
+ i = end + PASTE_END.length;
200
+ this.inPaste = false;
201
+ this.finishPaste();
202
+ dirty = true;
203
+ continue;
204
+ }
205
+ const ch = s[i];
206
+ if (ch === "\x1b") { // escape sequences
207
+ const seq = this.readEscape(s, i);
208
+ i += seq.len;
209
+ dirty = this.handleEscape(seq.code) || dirty;
210
+ continue;
211
+ }
212
+ i++;
213
+ switch (ch) {
214
+ case "\r":
215
+ case "\n":
216
+ this.submit();
217
+ break;
218
+ case "\x03":
219
+ this.ctrlC();
220
+ break;
221
+ case "\x04":
222
+ this.ctrlD();
223
+ break;
224
+ case "\x7f":
225
+ case "\b":
226
+ dirty = this.backspace() || dirty;
227
+ break;
228
+ case "\x01":
229
+ this.cursor = 0;
230
+ dirty = true;
231
+ break; // Ctrl-A home
232
+ case "\x05":
233
+ this.cursor = this.buf.length;
234
+ dirty = true;
235
+ break; // Ctrl-E end
236
+ case "\x0b":
237
+ this.buf.splice(this.cursor);
238
+ dirty = true;
239
+ break; // Ctrl-K kill->end
240
+ case "\x15":
241
+ this.buf.splice(0, this.cursor);
242
+ this.cursor = 0;
243
+ dirty = true;
244
+ break; // Ctrl-U
245
+ default:
246
+ if (ch >= " " || ch === "\t") {
247
+ this.buf.splice(this.cursor, 0, { kind: "ch", ch });
248
+ this.cursor++;
249
+ if (this.active === "steer")
250
+ this.echoSteer(ch);
251
+ dirty = true;
252
+ }
253
+ }
254
+ }
255
+ if (dirty && this.active === "prompt")
256
+ this.render();
257
+ }
258
+ readEscape(s, i) {
259
+ if (s[i + 1] === "[" || s[i + 1] === "O") {
260
+ let j = i + 2;
261
+ while (j < s.length && !/[A-Za-z~]/.test(s[j]))
262
+ j++;
263
+ return { code: s.slice(i + 1, j + 1), len: j + 1 - i };
264
+ }
265
+ return { code: "", len: 1 }; // bare Esc
266
+ }
267
+ handleEscape(code) {
268
+ switch (code) {
269
+ case "[D":
270
+ this.cursor = Math.max(0, this.cursor - 1);
271
+ return true;
272
+ case "[C":
273
+ this.cursor = Math.min(this.buf.length, this.cursor + 1);
274
+ return true;
275
+ case "[H":
276
+ case "OH":
277
+ case "[1~":
278
+ this.cursor = 0;
279
+ return true;
280
+ case "[F":
281
+ case "OF":
282
+ case "[4~":
283
+ this.cursor = this.buf.length;
284
+ return true;
285
+ case "[3~": // forward delete
286
+ if (this.cursor < this.buf.length) {
287
+ this.buf.splice(this.cursor, 1);
288
+ return true;
289
+ }
290
+ return false;
291
+ case "[A": return this.historyStep(-1); // up
292
+ case "[B": return this.historyStep(+1); // down
293
+ case "":
294
+ this.ctrlC();
295
+ return false; // bare Esc = cancel, like Ctrl-C
296
+ default: return false;
297
+ }
298
+ }
299
+ historyStep(dir) {
300
+ if (this.active !== "prompt" || !this.history.length)
301
+ return false;
302
+ if (this.histIdx === null) {
303
+ if (dir === 1)
304
+ return false;
305
+ this.draftBeforeHistory = this.compose();
306
+ this.histIdx = this.history.length - 1;
307
+ }
308
+ else {
309
+ this.histIdx += dir;
310
+ if (this.histIdx >= this.history.length) { // walked past newest -> restore draft
311
+ this.histIdx = null;
312
+ this.setFromString(this.draftBeforeHistory);
313
+ return true;
314
+ }
315
+ this.histIdx = Math.max(0, this.histIdx);
316
+ }
317
+ this.setFromString(this.history[this.histIdx] ?? "");
318
+ return true;
319
+ }
320
+ finishPaste() {
321
+ const raw = this.pasteBuf.replace(/\r\n?/g, "\n");
322
+ this.pasteBuf = "";
323
+ if (!raw)
324
+ return;
325
+ const lines = raw.split("\n").length;
326
+ if (lines === 1 && raw.length < 200) { // small paste: inline as chars
327
+ for (const ch of raw)
328
+ this.buf.splice(this.cursor++, 0, { kind: "ch", ch });
329
+ if (this.active === "steer")
330
+ this.echoSteer(raw);
331
+ return;
332
+ }
333
+ // paste-again-to-expand: same content as the chip just before the cursor
334
+ const prev = this.buf[this.cursor - 1];
335
+ if (prev?.kind === "chip" && prev.value === raw) {
336
+ this.buf.splice(this.cursor - 1, 1);
337
+ this.cursor--;
338
+ for (const ch of raw)
339
+ this.buf.splice(this.cursor++, 0, { kind: "ch", ch });
340
+ if (this.active === "steer")
341
+ this.echoSteer(raw);
342
+ return;
343
+ }
344
+ this.pasteCount++;
345
+ const hint = this.pasteCount === 1 ? " — paste again to expand" : "";
346
+ const label = `[pasted #${this.pasteCount} +${lines} lines${hint}]`;
347
+ this.buf.splice(this.cursor++, 0, { kind: "chip", value: raw, label });
348
+ if (this.active === "steer")
349
+ this.stdout.write(pc.cyan(label));
350
+ }
351
+ backspace() {
352
+ if (this.cursor === 0)
353
+ return false;
354
+ const removed = this.buf.splice(this.cursor - 1, 1)[0];
355
+ this.cursor--;
356
+ if (this.active === "steer") {
357
+ const w = removed.kind === "ch" ? 1 : removed.label.length;
358
+ this.stdout.write("\b \b".repeat(w));
359
+ }
360
+ return true;
361
+ }
362
+ submit() {
363
+ const text = this.compose();
364
+ this.gotoEnd();
365
+ this.stdout.write("\n");
366
+ if (this.active === "prompt") {
367
+ if (text.trim())
368
+ this.history.push(text);
369
+ const r = this.resolveLine;
370
+ this.resolveLine = undefined;
371
+ this.active = "off";
372
+ r?.(text);
373
+ }
374
+ else if (this.active === "steer" && text.trim()) {
375
+ this.steerCb?.(text);
376
+ this.reset();
377
+ }
378
+ else {
379
+ this.reset();
380
+ }
381
+ }
382
+ ctrlC() {
383
+ if (this.active === "steer") {
384
+ this.interruptCb?.();
385
+ return;
386
+ }
387
+ if (this.active === "prompt") {
388
+ if (this.buf.length) {
389
+ this.gotoEnd();
390
+ this.stdout.write("^C\n");
391
+ this.lastCursorRow = 0;
392
+ this.reset();
393
+ this.render();
394
+ }
395
+ else {
396
+ const r = this.resolveLine;
397
+ this.resolveLine = undefined;
398
+ this.active = "off";
399
+ this.stdout.write("\n");
400
+ r?.(null);
401
+ }
402
+ }
403
+ }
404
+ ctrlD() {
405
+ if (this.active === "prompt" && !this.buf.length) {
406
+ const r = this.resolveLine;
407
+ this.resolveLine = undefined;
408
+ this.active = "off";
409
+ this.stdout.write("\n");
410
+ r?.(null);
411
+ }
412
+ }
413
+ static describe(text) {
414
+ const lines = text.split("\n");
415
+ return lines.length <= 1 ? text.slice(0, 80) : `[${lines.length} lines]`;
416
+ }
417
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Error taxonomy — three layers of defense:
3
+ * 1. TransientAPIError -> retried with exponential backoff + jitter
4
+ * 2. ToolError -> returned to the model (is_error) so it self-corrects
5
+ * 3. FatalError -> surfaced to the user; checkpoints stay intact for /undo
6
+ */
7
+ export class ForgeError extends Error {
8
+ }
9
+ export class ToolError extends ForgeError {
10
+ }
11
+ export class TransientAPIError extends ForgeError {
12
+ retryAfter;
13
+ constructor(message, retryAfter) {
14
+ super(message);
15
+ this.retryAfter = retryAfter;
16
+ }
17
+ }
18
+ export class FatalError extends ForgeError {
19
+ }
20
+ export class CancelledError extends ForgeError {
21
+ constructor() { super("Task cancelled by user."); }
22
+ }
23
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
24
+ const t = setTimeout(resolve, ms);
25
+ signal?.addEventListener("abort", () => { clearTimeout(t); reject(new CancelledError()); }, { once: true });
26
+ });
27
+ /** Retry `fn` on TransientAPIError with exponential backoff + jitter; honors Retry-After. */
28
+ export async function withRetries(fn, opts = {}) {
29
+ const { maxAttempts = 5, baseDelayMs = 1500, maxDelayMs = 60_000, signal, onRetry } = opts;
30
+ let lastErr;
31
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
32
+ if (signal?.aborted)
33
+ throw new CancelledError();
34
+ try {
35
+ return await fn();
36
+ }
37
+ catch (err) {
38
+ if (!(err instanceof TransientAPIError))
39
+ throw err;
40
+ lastErr = err;
41
+ if (attempt === maxAttempts)
42
+ break;
43
+ let delay = err.retryAfter != null
44
+ ? err.retryAfter * 1000
45
+ : Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
46
+ delay *= 0.8 + 0.4 * Math.random(); // jitter
47
+ onRetry?.(attempt, err, delay);
48
+ await sleep(delay, signal);
49
+ }
50
+ }
51
+ throw new FatalError(`API request failed after ${maxAttempts} attempts: ${lastErr?.message}`);
52
+ }