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
|
@@ -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
|
+
}
|