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/indexer.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code graph indexer v2 — nodes AND edges, incrementally maintained.
|
|
3
|
+
*
|
|
4
|
+
* Nodes: symbols (functions, classes, ...) as before.
|
|
5
|
+
* Edges: calls (enclosing function -> called name, resolved against the
|
|
6
|
+
* symbol table) and imports (file -> file/module).
|
|
7
|
+
* Incremental: file mtimes are stored; refresh() re-parses only changed
|
|
8
|
+
* files and prunes deleted ones — milliseconds on large repos after the
|
|
9
|
+
* first build. Tasks call refresh() at start so the map is never stale.
|
|
10
|
+
*
|
|
11
|
+
* HONESTY NOTE (also stated in tool descriptions): edges come from
|
|
12
|
+
* line-based static parsing. Dynamic dispatch, callbacks, DI and
|
|
13
|
+
* metaprogramming produce missing or extra edges. Treat the graph as strong
|
|
14
|
+
* hints for navigation, and read the code where precision matters.
|
|
15
|
+
* Tree-sitter is the documented upgrade path.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { DatabaseSync } from "node:sqlite";
|
|
20
|
+
const SKIP_DIRS = new Set([
|
|
21
|
+
".git", ".faber", ".codewright", "node_modules", ".venv", "venv", "__pycache__",
|
|
22
|
+
"dist", "build", ".mypy_cache", ".pytest_cache", "target", ".next", "dist-test",
|
|
23
|
+
]);
|
|
24
|
+
const LANG = {
|
|
25
|
+
".py": [["function", /^\s*(?:async\s+)?def\s+(\w+)/], ["class", /^\s*class\s+(\w+)/]],
|
|
26
|
+
".js": [["function", /function\s+(\w+)/], ["class", /class\s+(\w+)/],
|
|
27
|
+
["function", /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(/]],
|
|
28
|
+
".ts": [["function", /function\s+(\w+)/], ["class", /class\s+(\w+)/],
|
|
29
|
+
["interface", /interface\s+(\w+)/], ["type", /^\s*type\s+(\w+)\s*=/],
|
|
30
|
+
["function", /(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(/]],
|
|
31
|
+
".go": [["function", /func\s+(?:\([^)]*\)\s*)?(\w+)/], ["type", /type\s+(\w+)/]],
|
|
32
|
+
".rs": [["function", /fn\s+(\w+)/], ["struct", /struct\s+(\w+)/],
|
|
33
|
+
["enum", /enum\s+(\w+)/], ["trait", /trait\s+(\w+)/]],
|
|
34
|
+
".java": [["class", /class\s+(\w+)/], ["interface", /interface\s+(\w+)/],
|
|
35
|
+
["method", /(?:public|private|protected)\s+[\w<>[\]]+\s+(\w+)\s*\(/]],
|
|
36
|
+
".c": [["function", /^[\w*]+\s+(\w+)\s*\([^;]*\)\s*\{/]],
|
|
37
|
+
".cpp": [["function", /^[\w*:<>]+\s+(\w+)\s*\([^;]*\)\s*\{/], ["class", /class\s+(\w+)/]],
|
|
38
|
+
".rb": [["function", /^\s*def\s+(\w+)/], ["class", /^\s*class\s+(\w+)/], ["module", /^\s*module\s+(\w+)/]],
|
|
39
|
+
".php": [["function", /function\s+(\w+)/], ["class", /class\s+(\w+)/]],
|
|
40
|
+
};
|
|
41
|
+
LANG[".jsx"] = LANG[".js"];
|
|
42
|
+
LANG[".tsx"] = LANG[".ts"];
|
|
43
|
+
LANG[".mjs"] = LANG[".js"];
|
|
44
|
+
LANG[".cjs"] = LANG[".js"];
|
|
45
|
+
LANG[".h"] = LANG[".c"];
|
|
46
|
+
LANG[".hpp"] = LANG[".cpp"];
|
|
47
|
+
const IMPORT_RX = {
|
|
48
|
+
".py": [/^\s*from\s+([\w.]+)\s+import/, /^\s*import\s+([\w.]+)/],
|
|
49
|
+
".js": [/from\s+["']([^"']+)["']/, /require\(\s*["']([^"']+)["']\s*\)/],
|
|
50
|
+
".go": [/^\s*"([\w./-]+)"/],
|
|
51
|
+
".rs": [/^\s*use\s+([\w:]+)/],
|
|
52
|
+
".java": [/^\s*import\s+([\w.]+)/],
|
|
53
|
+
".rb": [/^\s*require(?:_relative)?\s+["']([^"']+)["']/],
|
|
54
|
+
".php": [/^\s*use\s+([\w\\]+)/],
|
|
55
|
+
};
|
|
56
|
+
IMPORT_RX[".ts"] = IMPORT_RX[".js"];
|
|
57
|
+
IMPORT_RX[".jsx"] = IMPORT_RX[".js"];
|
|
58
|
+
IMPORT_RX[".tsx"] = IMPORT_RX[".js"];
|
|
59
|
+
IMPORT_RX[".mjs"] = IMPORT_RX[".js"];
|
|
60
|
+
// identifiers that look like calls but aren't
|
|
61
|
+
const CALL_KEYWORDS = new Set([
|
|
62
|
+
"if", "for", "while", "switch", "catch", "return", "function", "typeof",
|
|
63
|
+
"super", "constructor", "def", "class", "print", "await", "async", "new",
|
|
64
|
+
"assert", "yield", "match", "case", "elif", "except", "raise", "sizeof",
|
|
65
|
+
]);
|
|
66
|
+
const CALL_RX = /(\w+)\s*\(/g;
|
|
67
|
+
export class CodeIndexer {
|
|
68
|
+
workspace;
|
|
69
|
+
db;
|
|
70
|
+
constructor(dbPath, workspace) {
|
|
71
|
+
this.workspace = workspace;
|
|
72
|
+
this.db = new DatabaseSync(dbPath);
|
|
73
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
74
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS symbols (name TEXT, kind TEXT, path TEXT, line INTEGER, signature TEXT)");
|
|
75
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_name ON symbols(name)");
|
|
76
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS edges (caller TEXT, callee TEXT, path TEXT, line INTEGER)");
|
|
77
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_callee ON edges(callee)");
|
|
78
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_caller ON edges(caller)");
|
|
79
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS imports (src TEXT, target TEXT, line INTEGER)");
|
|
80
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime_ms REAL)");
|
|
81
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
|
|
82
|
+
}
|
|
83
|
+
// ------------------------------------------------------------- building
|
|
84
|
+
/** Full rebuild. */
|
|
85
|
+
build() {
|
|
86
|
+
this.db.exec("DELETE FROM symbols; DELETE FROM edges; DELETE FROM imports; DELETE FROM files;");
|
|
87
|
+
return this.refresh();
|
|
88
|
+
}
|
|
89
|
+
/** Incremental: (re)parse only new/changed files, prune deleted ones. */
|
|
90
|
+
refresh() {
|
|
91
|
+
const known = new Map(this.db.prepare("SELECT path, mtime_ms FROM files").all()
|
|
92
|
+
.map((r) => [r.path, r.mtime_ms]));
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
const changed = [];
|
|
95
|
+
for (const abs of walk(this.workspace)) {
|
|
96
|
+
if (!LANG[path.extname(abs)])
|
|
97
|
+
continue;
|
|
98
|
+
const rel = path.relative(this.workspace, abs);
|
|
99
|
+
seen.add(rel);
|
|
100
|
+
let mtime;
|
|
101
|
+
try {
|
|
102
|
+
mtime = fs.statSync(abs).mtimeMs;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (known.get(rel) !== mtime)
|
|
108
|
+
changed.push({ rel, abs, mtime });
|
|
109
|
+
}
|
|
110
|
+
const removed = [...known.keys()].filter((p) => !seen.has(p));
|
|
111
|
+
const delSym = this.db.prepare("DELETE FROM symbols WHERE path = ?");
|
|
112
|
+
const delEdge = this.db.prepare("DELETE FROM edges WHERE path = ?");
|
|
113
|
+
const delImp = this.db.prepare("DELETE FROM imports WHERE src = ?");
|
|
114
|
+
const delFile = this.db.prepare("DELETE FROM files WHERE path = ?");
|
|
115
|
+
for (const p of removed) {
|
|
116
|
+
delSym.run(p);
|
|
117
|
+
delEdge.run(p);
|
|
118
|
+
delImp.run(p);
|
|
119
|
+
delFile.run(p);
|
|
120
|
+
}
|
|
121
|
+
const insSym = this.db.prepare("INSERT INTO symbols VALUES (?, ?, ?, ?, ?)");
|
|
122
|
+
const insEdge = this.db.prepare("INSERT INTO edges VALUES (?, ?, ?, ?)");
|
|
123
|
+
const insImp = this.db.prepare("INSERT INTO imports VALUES (?, ?, ?)");
|
|
124
|
+
const upFile = this.db.prepare("INSERT OR REPLACE INTO files VALUES (?, ?)");
|
|
125
|
+
// pass 1: symbols for changed files (so calls in pass 2 can resolve)
|
|
126
|
+
const parsed = new Map();
|
|
127
|
+
for (const f of changed) {
|
|
128
|
+
delSym.run(f.rel);
|
|
129
|
+
delEdge.run(f.rel);
|
|
130
|
+
delImp.run(f.rel);
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = fs.readFileSync(f.abs, "utf8");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const lines = text.split("\n");
|
|
139
|
+
const syms = [];
|
|
140
|
+
const patterns = LANG[path.extname(f.abs)];
|
|
141
|
+
for (let i = 0; i < lines.length; i++) {
|
|
142
|
+
for (const [kind, rx] of patterns) {
|
|
143
|
+
const m = rx.exec(lines[i]);
|
|
144
|
+
if (m?.[1] && !CALL_KEYWORDS.has(m[1])) {
|
|
145
|
+
syms.push([m[1], kind, i + 1, lines[i].trim().slice(0, 120)]);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const [name, kind, line, sig] of syms)
|
|
151
|
+
insSym.run(name, kind, f.rel, line, sig);
|
|
152
|
+
parsed.set(f.rel, { symbols: syms, lines });
|
|
153
|
+
upFile.run(f.rel, f.mtime);
|
|
154
|
+
}
|
|
155
|
+
// known symbol names across the whole repo (for call resolution)
|
|
156
|
+
const knownNames = new Set(this.db.prepare("SELECT DISTINCT name FROM symbols").all().map((r) => r.name));
|
|
157
|
+
// pass 2: edges + imports for changed files
|
|
158
|
+
for (const [rel, { symbols, lines }] of parsed) {
|
|
159
|
+
const importRxs = IMPORT_RX[path.extname(rel)] ?? [];
|
|
160
|
+
// caller attribution: nearest preceding symbol definition in the file
|
|
161
|
+
const defLines = symbols.map(([name, , line]) => ({ name, line })).sort((a, b) => a.line - b.line);
|
|
162
|
+
const callerAt = (lineNo) => {
|
|
163
|
+
let cur = "<module>";
|
|
164
|
+
for (const d of defLines) {
|
|
165
|
+
if (d.line <= lineNo)
|
|
166
|
+
cur = d.name;
|
|
167
|
+
else
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
return cur;
|
|
171
|
+
};
|
|
172
|
+
for (let i = 0; i < lines.length; i++) {
|
|
173
|
+
const line = lines[i];
|
|
174
|
+
for (const rx of importRxs) {
|
|
175
|
+
const m = rx.exec(line);
|
|
176
|
+
if (m?.[1])
|
|
177
|
+
insImp.run(rel, m[1], i + 1);
|
|
178
|
+
}
|
|
179
|
+
const defHere = new Set(defLines.filter((d) => d.line === i + 1).map((d) => d.name));
|
|
180
|
+
CALL_RX.lastIndex = 0;
|
|
181
|
+
let m;
|
|
182
|
+
while ((m = CALL_RX.exec(line))) {
|
|
183
|
+
const name = m[1];
|
|
184
|
+
if (CALL_KEYWORDS.has(name) || defHere.has(name) || !knownNames.has(name))
|
|
185
|
+
continue;
|
|
186
|
+
const caller = callerAt(i + 1);
|
|
187
|
+
if (caller === name)
|
|
188
|
+
continue; // skip trivial self-attribution noise
|
|
189
|
+
insEdge.run(caller, name, rel, i + 1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this.db.prepare("INSERT OR REPLACE INTO meta VALUES ('built_at', ?)").run(String(Date.now()));
|
|
194
|
+
const nSym = this.db.prepare("SELECT COUNT(*) c FROM symbols").get().c;
|
|
195
|
+
const nEdge = this.db.prepare("SELECT COUNT(*) c FROM edges").get().c;
|
|
196
|
+
return { files: changed.length, symbols: nSym, edges: nEdge };
|
|
197
|
+
}
|
|
198
|
+
// -------------------------------------------------------------- queries
|
|
199
|
+
search(query, limit = 20) {
|
|
200
|
+
return this.db.prepare(`SELECT * FROM symbols WHERE name LIKE ?
|
|
201
|
+
ORDER BY CASE WHEN name = ? THEN 0 ELSE 1 END, name LIMIT ?`).all(`%${query}%`, query, limit);
|
|
202
|
+
}
|
|
203
|
+
whoCalls(name, limit = 30) {
|
|
204
|
+
return this.db.prepare("SELECT * FROM edges WHERE callee = ? ORDER BY path, line LIMIT ?").all(name, limit);
|
|
205
|
+
}
|
|
206
|
+
callsFrom(name, limit = 30) {
|
|
207
|
+
return this.db.prepare("SELECT DISTINCT caller, callee, path, line FROM edges WHERE caller = ? ORDER BY line LIMIT ?").all(name, limit);
|
|
208
|
+
}
|
|
209
|
+
/** BFS shortest path over call edges: from -> ... -> to. */
|
|
210
|
+
tracePath(from, to, maxDepth = 12) {
|
|
211
|
+
const next = this.db.prepare("SELECT DISTINCT callee FROM edges WHERE caller = ?");
|
|
212
|
+
const prev = new Map([[from, ""]]);
|
|
213
|
+
let frontier = [from];
|
|
214
|
+
for (let d = 0; d < maxDepth && frontier.length; d++) {
|
|
215
|
+
const upcoming = [];
|
|
216
|
+
for (const node of frontier) {
|
|
217
|
+
for (const row of next.all(node)) {
|
|
218
|
+
const callee = row.callee;
|
|
219
|
+
if (prev.has(callee))
|
|
220
|
+
continue;
|
|
221
|
+
prev.set(callee, node);
|
|
222
|
+
if (callee === to) {
|
|
223
|
+
const chain = [to];
|
|
224
|
+
let cur = to;
|
|
225
|
+
while (prev.get(cur)) {
|
|
226
|
+
cur = prev.get(cur);
|
|
227
|
+
chain.unshift(cur);
|
|
228
|
+
}
|
|
229
|
+
return chain;
|
|
230
|
+
}
|
|
231
|
+
upcoming.push(callee);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
frontier = upcoming;
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
/** Call tree from an entry symbol, cycle-safe, for `faber map`. */
|
|
239
|
+
callTree(entry, maxDepth = 4) {
|
|
240
|
+
const out = [];
|
|
241
|
+
const seen = new Set();
|
|
242
|
+
const walkTree = (name, depth) => {
|
|
243
|
+
const mark = seen.has(name) ? " ↩ (seen)" : "";
|
|
244
|
+
out.push(`${" ".repeat(depth)}${name}${mark}`);
|
|
245
|
+
if (seen.has(name) || depth >= maxDepth || out.length > 200)
|
|
246
|
+
return;
|
|
247
|
+
seen.add(name);
|
|
248
|
+
for (const e of this.callsFrom(name, 15))
|
|
249
|
+
walkTree(e.callee, depth + 1);
|
|
250
|
+
};
|
|
251
|
+
walkTree(entry, 0);
|
|
252
|
+
return out.join("\n");
|
|
253
|
+
}
|
|
254
|
+
/** Compact repo map (most-connected symbols) for system-prompt orientation. */
|
|
255
|
+
repoMap(limit = 15) {
|
|
256
|
+
const rows = this.db.prepare(`SELECT s.name, s.kind, s.path,
|
|
257
|
+
(SELECT COUNT(*) FROM edges e WHERE e.callee = s.name) +
|
|
258
|
+
(SELECT COUNT(*) FROM edges e WHERE e.caller = s.name) AS degree
|
|
259
|
+
FROM symbols s GROUP BY s.name, s.path
|
|
260
|
+
ORDER BY degree DESC LIMIT ?`).all(limit);
|
|
261
|
+
return rows.filter((r) => r.degree > 0)
|
|
262
|
+
.map((r) => `${r.name} [${r.kind}] ${r.path} (${r.degree} connections)`)
|
|
263
|
+
.join("\n");
|
|
264
|
+
}
|
|
265
|
+
isBuilt() {
|
|
266
|
+
return this.db.prepare("SELECT value FROM meta WHERE key='built_at'").get() != null;
|
|
267
|
+
}
|
|
268
|
+
close() { this.db.close(); }
|
|
269
|
+
}
|
|
270
|
+
function* walk(dir) {
|
|
271
|
+
let entries;
|
|
272
|
+
try {
|
|
273
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
for (const e of entries) {
|
|
279
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith("."))
|
|
280
|
+
continue;
|
|
281
|
+
const full = path.join(dir, e.name);
|
|
282
|
+
if (e.isDirectory())
|
|
283
|
+
yield* walk(full);
|
|
284
|
+
else if (e.isFile()) {
|
|
285
|
+
try {
|
|
286
|
+
if (fs.statSync(full).size <= 1_000_000)
|
|
287
|
+
yield full;
|
|
288
|
+
}
|
|
289
|
+
catch { /* skip */ }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
package/dist/input.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composed input: bracketed-paste handling for the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Problem: readline emits one 'line' event per newline, so pasting a 50-line
|
|
5
|
+
* code block becomes 50 separate submissions — each line misread as its own
|
|
6
|
+
* instruction.
|
|
7
|
+
*
|
|
8
|
+
* Mechanism: we enable BRACKETED PASTE (ESC[?2004h) so the terminal wraps
|
|
9
|
+
* pastes in ESC[200~ ... ESC[201~ markers, and interpose a transform between
|
|
10
|
+
* stdin and readline that replaces newlines INSIDE a paste with a sentinel
|
|
11
|
+
* byte (\x00). Readline then sees the entire paste as part of ONE line, the
|
|
12
|
+
* user can keep typing before/after it (a paste without a trailing newline
|
|
13
|
+
* just sits in the buffer), and only a real Enter submits. restore() swaps
|
|
14
|
+
* sentinels back to newlines afterward.
|
|
15
|
+
*
|
|
16
|
+
* Terminals without bracketed paste degrade to the old line-per-line
|
|
17
|
+
* behavior — steering still coalesces those at the drain boundary.
|
|
18
|
+
*/
|
|
19
|
+
import { PassThrough } from "node:stream";
|
|
20
|
+
export const SENTINEL = "\x00";
|
|
21
|
+
const PASTE_START = "\x1b[200~";
|
|
22
|
+
const PASTE_END = "\x1b[201~";
|
|
23
|
+
/** Replace \x00 sentinels with real newlines in a submitted line. */
|
|
24
|
+
export function restore(line) {
|
|
25
|
+
return line.split(SENTINEL).join("\n");
|
|
26
|
+
}
|
|
27
|
+
/** Human display for a composed submission. */
|
|
28
|
+
export function describeComposed(raw) {
|
|
29
|
+
const restored = restore(raw);
|
|
30
|
+
const lines = restored.split("\n");
|
|
31
|
+
if (lines.length <= 1)
|
|
32
|
+
return restored.slice(0, 80);
|
|
33
|
+
const typedTail = lines.at(-1)?.trim();
|
|
34
|
+
return `[pasted ${lines.length} lines]${typedTail && typedTail.length < 60 ? ` + "${typedTail}"` : ""}`;
|
|
35
|
+
}
|
|
36
|
+
export function createComposedInput(source) {
|
|
37
|
+
const out = new PassThrough();
|
|
38
|
+
let inPaste = false;
|
|
39
|
+
let guard = false;
|
|
40
|
+
let tail = ""; // carry partial escape markers across chunks
|
|
41
|
+
const onData = (buf) => {
|
|
42
|
+
if (guard)
|
|
43
|
+
return; // selector owns stdin; drop (it consumes keys)
|
|
44
|
+
let s = tail + buf.toString("utf8");
|
|
45
|
+
tail = "";
|
|
46
|
+
// keep a possible partial marker for the next chunk
|
|
47
|
+
for (let keep = Math.min(PASTE_START.length - 1, s.length); keep > 0; keep--) {
|
|
48
|
+
const suffix = s.slice(-keep);
|
|
49
|
+
if (PASTE_START.startsWith(suffix) || PASTE_END.startsWith(suffix)) {
|
|
50
|
+
tail = suffix;
|
|
51
|
+
s = s.slice(0, -keep);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
let result = "";
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < s.length) {
|
|
58
|
+
if (!inPaste && s.startsWith(PASTE_START, i)) {
|
|
59
|
+
inPaste = true;
|
|
60
|
+
i += PASTE_START.length;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (inPaste && s.startsWith(PASTE_END, i)) {
|
|
64
|
+
inPaste = false;
|
|
65
|
+
i += PASTE_END.length;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const ch = s[i];
|
|
69
|
+
if (inPaste && (ch === "\n" || ch === "\r")) {
|
|
70
|
+
result += SENTINEL;
|
|
71
|
+
if (ch === "\r" && s[i + 1] === "\n")
|
|
72
|
+
i++; // CRLF -> one sentinel
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
result += ch;
|
|
76
|
+
}
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
if (result)
|
|
80
|
+
out.write(result);
|
|
81
|
+
};
|
|
82
|
+
source.on("data", onData);
|
|
83
|
+
source.on("end", () => out.end());
|
|
84
|
+
return {
|
|
85
|
+
stream: out,
|
|
86
|
+
setGuard: (on) => { guard = on; },
|
|
87
|
+
detach: () => source.removeListener("data", onData),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function enableBracketedPaste() {
|
|
91
|
+
if (process.stdout.isTTY)
|
|
92
|
+
process.stdout.write("\x1b[?2004h");
|
|
93
|
+
}
|
|
94
|
+
export function disableBracketedPaste() {
|
|
95
|
+
if (process.stdout.isTTY)
|
|
96
|
+
process.stdout.write("\x1b[?2004l");
|
|
97
|
+
}
|
package/dist/llm.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { FatalError, TransientAPIError, withRetries, CancelledError } from "./errors.js";
|
|
2
|
+
const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504, 529]);
|
|
3
|
+
export class LLMClient {
|
|
4
|
+
config;
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.config = config;
|
|
7
|
+
if (!config.apiKey) {
|
|
8
|
+
const v = config.provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
|
9
|
+
throw new FatalError(`No API key found. Set ${v} or add it to .faber/config.json.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
complete(system, messages, tools, onText, signal, modelOverride) {
|
|
13
|
+
return withRetries(() => this.config.provider === "anthropic"
|
|
14
|
+
? this.anthropicStream(system, messages, tools, onText, signal, modelOverride)
|
|
15
|
+
: this.openaiStream(system, messages, tools, onText, signal, modelOverride), { maxAttempts: this.config.retryMaxAttempts, signal });
|
|
16
|
+
}
|
|
17
|
+
/** Internal summarization: routed to the cheap weak model when configured. */
|
|
18
|
+
async summarize(text, instruction) {
|
|
19
|
+
const resp = await this.complete("You are a precise summarizer. Output only the summary.", [{ role: "user", content: [{ type: "text", text: `${instruction}\n\n${text}` }] }], [], undefined, undefined, this.config.weakModel);
|
|
20
|
+
return resp.text;
|
|
21
|
+
}
|
|
22
|
+
// ------------------------------------------------------------- anthropic
|
|
23
|
+
async anthropicStream(system, messages, tools, onText, signal, modelOverride) {
|
|
24
|
+
// PROMPT CACHING: mark the stable prefix so repeated loop iterations pay
|
|
25
|
+
// ~10% for everything already sent. Three breakpoints (max 4 allowed):
|
|
26
|
+
// last tool schema, the system prompt, and the last message — the message
|
|
27
|
+
// breakpoint moves forward each iteration, caching the growing history.
|
|
28
|
+
const cachedTools = tools.map((t, i) => i === tools.length - 1 ? { ...t, cache_control: { type: "ephemeral" } } : t);
|
|
29
|
+
const cachedMessages = messages.map((m, i) => {
|
|
30
|
+
if (i !== messages.length - 1 || !Array.isArray(m.content) || m.content.length === 0)
|
|
31
|
+
return m;
|
|
32
|
+
const content = m.content.map((b, j) => j === m.content.length - 1 ? { ...b, cache_control: { type: "ephemeral" } } : b);
|
|
33
|
+
return { ...m, content };
|
|
34
|
+
});
|
|
35
|
+
const body = {
|
|
36
|
+
model: modelOverride ?? this.config.model,
|
|
37
|
+
max_tokens: this.config.maxTokens,
|
|
38
|
+
system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
|
|
39
|
+
messages: cachedMessages,
|
|
40
|
+
stream: true,
|
|
41
|
+
};
|
|
42
|
+
if (tools.length)
|
|
43
|
+
body.tools = cachedTools;
|
|
44
|
+
const res = await this.post(`${this.config.baseUrl}/v1/messages`, {
|
|
45
|
+
"x-api-key": this.config.apiKey,
|
|
46
|
+
"anthropic-version": "2023-06-01",
|
|
47
|
+
"content-type": "application/json",
|
|
48
|
+
}, body, signal);
|
|
49
|
+
const blocks = [];
|
|
50
|
+
let stopReason = "end_turn";
|
|
51
|
+
const usage = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
52
|
+
// accumulate streamed tool_use JSON per block index
|
|
53
|
+
const partialJson = new Map();
|
|
54
|
+
for await (const evt of sseEvents(res, signal)) {
|
|
55
|
+
const data = evt;
|
|
56
|
+
switch (data.type) {
|
|
57
|
+
case "content_block_start": {
|
|
58
|
+
const cb = data.content_block;
|
|
59
|
+
if (cb.type === "text")
|
|
60
|
+
blocks[data.index] = { type: "text", text: "" };
|
|
61
|
+
else if (cb.type === "tool_use") {
|
|
62
|
+
blocks[data.index] = { type: "tool_use", id: cb.id, name: cb.name, input: {} };
|
|
63
|
+
partialJson.set(data.index, "");
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "content_block_delta": {
|
|
68
|
+
const d = data.delta;
|
|
69
|
+
const blk = blocks[data.index];
|
|
70
|
+
if (d.type === "text_delta" && blk?.type === "text") {
|
|
71
|
+
blk.text += d.text;
|
|
72
|
+
onText?.(d.text);
|
|
73
|
+
}
|
|
74
|
+
else if (d.type === "input_json_delta") {
|
|
75
|
+
partialJson.set(data.index, (partialJson.get(data.index) ?? "") + d.partial_json);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "content_block_stop": {
|
|
80
|
+
const blk = blocks[data.index];
|
|
81
|
+
if (blk?.type === "tool_use") {
|
|
82
|
+
const raw = partialJson.get(data.index) ?? "";
|
|
83
|
+
try {
|
|
84
|
+
blk.input = raw ? JSON.parse(raw) : {};
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new TransientAPIError("Malformed tool JSON in stream; retrying.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "message_start": {
|
|
93
|
+
const u = data.message?.usage;
|
|
94
|
+
if (u) {
|
|
95
|
+
usage.input = u.input_tokens ?? 0;
|
|
96
|
+
usage.cacheRead = u.cache_read_input_tokens ?? 0;
|
|
97
|
+
usage.cacheWrite = u.cache_creation_input_tokens ?? 0;
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "message_delta":
|
|
102
|
+
if (data.delta?.stop_reason)
|
|
103
|
+
stopReason = data.delta.stop_reason;
|
|
104
|
+
if (data.usage?.output_tokens != null)
|
|
105
|
+
usage.output = data.usage.output_tokens;
|
|
106
|
+
break;
|
|
107
|
+
case "error":
|
|
108
|
+
throw new TransientAPIError(`Stream error: ${JSON.stringify(data.error).slice(0, 300)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const content = blocks.filter(Boolean);
|
|
112
|
+
return {
|
|
113
|
+
text: content.filter((b) => b.type === "text").map((b) => b.text).join(""),
|
|
114
|
+
toolCalls: content.filter((b) => b.type === "tool_use").map((b) => ({ id: b.id, name: b.name, input: b.input })),
|
|
115
|
+
rawContent: content,
|
|
116
|
+
stopReason,
|
|
117
|
+
usage,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------- openai
|
|
121
|
+
async openaiStream(system, messages, tools, onText, signal, modelOverride) {
|
|
122
|
+
const oaMessages = [{ role: "system", content: system }];
|
|
123
|
+
for (const m of messages)
|
|
124
|
+
oaMessages.push(...toOpenAI(m));
|
|
125
|
+
const body = { model: modelOverride ?? this.config.model, messages: oaMessages, stream: true, stream_options: { include_usage: true } };
|
|
126
|
+
if (tools.length) {
|
|
127
|
+
body.tools = tools.map((t) => ({
|
|
128
|
+
type: "function",
|
|
129
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
const res = await this.post(`${this.config.baseUrl}/chat/completions`, {
|
|
133
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
134
|
+
"content-type": "application/json",
|
|
135
|
+
}, body, signal);
|
|
136
|
+
let text = "";
|
|
137
|
+
const usage = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
138
|
+
const calls = new Map();
|
|
139
|
+
for await (const evt of sseEvents(res, signal)) {
|
|
140
|
+
const u = evt.usage;
|
|
141
|
+
if (u) {
|
|
142
|
+
usage.input = (u.prompt_tokens ?? 0) - (u.prompt_tokens_details?.cached_tokens ?? 0);
|
|
143
|
+
usage.cacheRead = u.prompt_tokens_details?.cached_tokens ?? 0;
|
|
144
|
+
usage.output = u.completion_tokens ?? 0;
|
|
145
|
+
}
|
|
146
|
+
const delta = evt.choices?.[0]?.delta;
|
|
147
|
+
if (!delta)
|
|
148
|
+
continue;
|
|
149
|
+
if (delta.content) {
|
|
150
|
+
text += delta.content;
|
|
151
|
+
onText?.(delta.content);
|
|
152
|
+
}
|
|
153
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
154
|
+
const cur = calls.get(tc.index) ?? { id: "", name: "", args: "" };
|
|
155
|
+
if (tc.id)
|
|
156
|
+
cur.id = tc.id;
|
|
157
|
+
if (tc.function?.name)
|
|
158
|
+
cur.name += tc.function.name;
|
|
159
|
+
if (tc.function?.arguments)
|
|
160
|
+
cur.args += tc.function.arguments;
|
|
161
|
+
calls.set(tc.index, cur);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const toolCalls = [...calls.values()].map((c) => {
|
|
165
|
+
let input = {};
|
|
166
|
+
try {
|
|
167
|
+
input = c.args ? JSON.parse(c.args) : {};
|
|
168
|
+
}
|
|
169
|
+
catch { /* leave empty */ }
|
|
170
|
+
return { id: c.id, name: c.name, input };
|
|
171
|
+
});
|
|
172
|
+
const rawContent = [];
|
|
173
|
+
if (text)
|
|
174
|
+
rawContent.push({ type: "text", text });
|
|
175
|
+
for (const c of toolCalls)
|
|
176
|
+
rawContent.push({ type: "tool_use", id: c.id, name: c.name, input: c.input });
|
|
177
|
+
return { text, toolCalls, rawContent, stopReason: toolCalls.length ? "tool_use" : "end_turn", usage };
|
|
178
|
+
}
|
|
179
|
+
// ------------------------------------------------------------------ http
|
|
180
|
+
async post(url, headers, body, signal) {
|
|
181
|
+
let res;
|
|
182
|
+
try {
|
|
183
|
+
res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if (err?.name === "AbortError")
|
|
187
|
+
throw new CancelledError();
|
|
188
|
+
throw new TransientAPIError(`network error: ${err?.message ?? err}`);
|
|
189
|
+
}
|
|
190
|
+
if (RETRYABLE.has(res.status)) {
|
|
191
|
+
const ra = res.headers.get("retry-after");
|
|
192
|
+
throw new TransientAPIError(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`, ra ? Number(ra) || undefined : undefined);
|
|
193
|
+
}
|
|
194
|
+
if (res.status === 401 || res.status === 403) {
|
|
195
|
+
throw new FatalError(`Authentication failed (HTTP ${res.status}). Check your API key.`);
|
|
196
|
+
}
|
|
197
|
+
if (!res.ok)
|
|
198
|
+
throw new FatalError(`API error HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`);
|
|
199
|
+
return res;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Parse an SSE response body into JSON events; tolerates chunk boundaries mid-line. */
|
|
203
|
+
async function* sseEvents(res, signal) {
|
|
204
|
+
if (!res.body)
|
|
205
|
+
throw new TransientAPIError("Empty response body.");
|
|
206
|
+
const reader = res.body.getReader();
|
|
207
|
+
const decoder = new TextDecoder();
|
|
208
|
+
let buffer = "";
|
|
209
|
+
try {
|
|
210
|
+
while (true) {
|
|
211
|
+
if (signal?.aborted)
|
|
212
|
+
throw new CancelledError();
|
|
213
|
+
const { done, value } = await reader.read();
|
|
214
|
+
if (done)
|
|
215
|
+
break;
|
|
216
|
+
buffer += decoder.decode(value, { stream: true });
|
|
217
|
+
let nl;
|
|
218
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
219
|
+
const line = buffer.slice(0, nl).trim();
|
|
220
|
+
buffer = buffer.slice(nl + 1);
|
|
221
|
+
if (!line.startsWith("data:"))
|
|
222
|
+
continue;
|
|
223
|
+
const payload = line.slice(5).trim();
|
|
224
|
+
if (payload === "[DONE]")
|
|
225
|
+
return;
|
|
226
|
+
try {
|
|
227
|
+
yield JSON.parse(payload);
|
|
228
|
+
}
|
|
229
|
+
catch { /* skip malformed keep-alive lines */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
reader.releaseLock();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function toOpenAI(m) {
|
|
238
|
+
const out = [];
|
|
239
|
+
const texts = [];
|
|
240
|
+
const toolCalls = [];
|
|
241
|
+
const toolResults = [];
|
|
242
|
+
for (const b of m.content) {
|
|
243
|
+
if (b.type === "text")
|
|
244
|
+
texts.push(b.text);
|
|
245
|
+
else if (b.type === "tool_use") {
|
|
246
|
+
toolCalls.push({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input) } });
|
|
247
|
+
}
|
|
248
|
+
else if (b.type === "tool_result") {
|
|
249
|
+
toolResults.push({ role: "tool", tool_call_id: b.tool_use_id, content: b.content });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (texts.length || toolCalls.length) {
|
|
253
|
+
const msg = { role: m.role, content: texts.join("\n") || null };
|
|
254
|
+
if (toolCalls.length)
|
|
255
|
+
msg.tool_calls = toolCalls;
|
|
256
|
+
out.push(msg);
|
|
257
|
+
}
|
|
258
|
+
out.push(...toolResults);
|
|
259
|
+
return out;
|
|
260
|
+
}
|