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/tools/fs.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem tools. Hardening beyond v0.1:
|
|
3
|
+
* - Path jail on every operation (symlink-resolved).
|
|
4
|
+
* - ATOMIC writes: write to temp file then rename — a crash mid-write can
|
|
5
|
+
* never leave a half-written source file.
|
|
6
|
+
* - Every mutation produces a unified diff, enabling approval mode.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { createTwoFilesPatch } from "diff";
|
|
11
|
+
import { ToolError } from "../errors.js";
|
|
12
|
+
const SKIP_DIRS = new Set([".git", ".faber", ".codewright", "node_modules", ".venv", "venv", "__pycache__", "dist", "build"]);
|
|
13
|
+
export function resolveInWorkspace(workspace, p) {
|
|
14
|
+
const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(workspace, p);
|
|
15
|
+
// resolve symlinks on the deepest existing ancestor to prevent escapes
|
|
16
|
+
let probe = abs;
|
|
17
|
+
while (!fs.existsSync(probe)) {
|
|
18
|
+
const parent = path.dirname(probe);
|
|
19
|
+
if (parent === probe)
|
|
20
|
+
break;
|
|
21
|
+
probe = parent;
|
|
22
|
+
}
|
|
23
|
+
const real = fs.realpathSync(probe) + abs.slice(probe.length);
|
|
24
|
+
const rel = path.relative(fs.realpathSync(workspace), real);
|
|
25
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
26
|
+
throw new ToolError(`Path escapes the workspace: ${p}`);
|
|
27
|
+
}
|
|
28
|
+
return abs;
|
|
29
|
+
}
|
|
30
|
+
function atomicWrite(abs, content) {
|
|
31
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
32
|
+
const tmp = `${abs}.faber-tmp-${process.pid}`;
|
|
33
|
+
fs.writeFileSync(tmp, content);
|
|
34
|
+
fs.renameSync(tmp, content.length >= 0 ? abs : abs); // rename is atomic on POSIX
|
|
35
|
+
}
|
|
36
|
+
export class FsTools {
|
|
37
|
+
config;
|
|
38
|
+
checkpoints;
|
|
39
|
+
constructor(config, checkpoints) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
this.checkpoints = checkpoints;
|
|
42
|
+
}
|
|
43
|
+
readFile(p, startLine = 1, endLine) {
|
|
44
|
+
const abs = resolveInWorkspace(this.config.workspace, p);
|
|
45
|
+
if (!fs.existsSync(abs))
|
|
46
|
+
throw new ToolError(`File not found: ${p}`);
|
|
47
|
+
const size = fs.statSync(abs).size;
|
|
48
|
+
if (size > this.config.maxFileReadBytes) {
|
|
49
|
+
throw new ToolError(`File too large (${size} bytes). Read a line range instead (start_line/end_line).`);
|
|
50
|
+
}
|
|
51
|
+
const lines = fs.readFileSync(abs, "utf8").split("\n");
|
|
52
|
+
const chunk = lines.slice(Math.max(0, startLine - 1), endLine ?? lines.length);
|
|
53
|
+
return chunk.map((l, i) => `${i + startLine}\t${l}`).join("\n") || "(empty file)";
|
|
54
|
+
}
|
|
55
|
+
listDir(p = ".", depth = 2) {
|
|
56
|
+
const root = resolveInWorkspace(this.config.workspace, p);
|
|
57
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
58
|
+
throw new ToolError(`Not a directory: ${p}`);
|
|
59
|
+
}
|
|
60
|
+
const out = [];
|
|
61
|
+
const walk = (d, level) => {
|
|
62
|
+
if (level > depth || out.length > 500)
|
|
63
|
+
return;
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
entries.sort((a, b) => Number(a.isFile()) - Number(b.isFile()) || a.name.localeCompare(b.name));
|
|
72
|
+
for (const e of entries) {
|
|
73
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith("."))
|
|
74
|
+
continue;
|
|
75
|
+
out.push(`${" ".repeat(level)}${e.name}${e.isDirectory() ? "/" : ""}`);
|
|
76
|
+
if (e.isDirectory())
|
|
77
|
+
walk(path.join(d, e.name), level + 1);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
walk(root, 0);
|
|
81
|
+
return out.join("\n") || "(empty)";
|
|
82
|
+
}
|
|
83
|
+
grep(pattern, p = ".", maxResults = 50) {
|
|
84
|
+
let rx;
|
|
85
|
+
try {
|
|
86
|
+
rx = new RegExp(pattern);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
throw new ToolError(`Invalid regex: ${e}`);
|
|
90
|
+
}
|
|
91
|
+
const root = resolveInWorkspace(this.config.workspace, p);
|
|
92
|
+
const hits = [];
|
|
93
|
+
const files = fs.statSync(root).isFile() ? [root] : [...walkFiles(root)];
|
|
94
|
+
for (const f of files) {
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
if (fs.statSync(f).size > 1_000_000)
|
|
98
|
+
continue;
|
|
99
|
+
text = fs.readFileSync(f, "utf8");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const lines = text.split("\n");
|
|
105
|
+
for (let i = 0; i < lines.length; i++) {
|
|
106
|
+
if (rx.test(lines[i])) {
|
|
107
|
+
hits.push(`${path.relative(this.config.workspace, f)}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
|
|
108
|
+
if (hits.length >= maxResults)
|
|
109
|
+
return hits.join("\n") + "\n(truncated)";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return hits.join("\n") || "No matches.";
|
|
114
|
+
}
|
|
115
|
+
/** Returns a PendingWrite: diff for preview + apply() that commits it. */
|
|
116
|
+
stageWrite(p, content) {
|
|
117
|
+
const abs = resolveInWorkspace(this.config.workspace, p);
|
|
118
|
+
const before = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : "";
|
|
119
|
+
const diff = createTwoFilesPatch(p, p, before, content, "before", "after");
|
|
120
|
+
return {
|
|
121
|
+
path: p,
|
|
122
|
+
diff,
|
|
123
|
+
apply: () => {
|
|
124
|
+
this.checkpoints.snapshot(abs);
|
|
125
|
+
atomicWrite(abs, content);
|
|
126
|
+
return `Wrote ${Buffer.byteLength(content)} bytes to ${p}`;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** Exact, unique string replacement — staged for approval like stageWrite. */
|
|
131
|
+
stageEdit(p, oldStr, newStr) {
|
|
132
|
+
const abs = resolveInWorkspace(this.config.workspace, p);
|
|
133
|
+
if (!fs.existsSync(abs))
|
|
134
|
+
throw new ToolError(`File not found: ${p}`);
|
|
135
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
136
|
+
const count = text.split(oldStr).length - 1;
|
|
137
|
+
if (count === 0) {
|
|
138
|
+
throw new ToolError("old_str not found in file. Re-read the file — it may have changed, or your string may not match exactly (check whitespace).");
|
|
139
|
+
}
|
|
140
|
+
if (count > 1) {
|
|
141
|
+
throw new ToolError(`old_str appears ${count} times; it must be unique. Include more surrounding context.`);
|
|
142
|
+
}
|
|
143
|
+
const after = text.replace(oldStr, newStr);
|
|
144
|
+
const diff = createTwoFilesPatch(p, p, text, after, "before", "after");
|
|
145
|
+
return {
|
|
146
|
+
path: p,
|
|
147
|
+
diff,
|
|
148
|
+
apply: () => {
|
|
149
|
+
// guard against the file changing between stage and apply
|
|
150
|
+
const current = fs.readFileSync(abs, "utf8");
|
|
151
|
+
if (current !== text)
|
|
152
|
+
throw new ToolError(`${p} changed since the edit was staged; re-read and retry.`);
|
|
153
|
+
this.checkpoints.snapshot(abs);
|
|
154
|
+
atomicWrite(abs, after);
|
|
155
|
+
return `Edited ${p} (1 replacement).`;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function* walkFiles(dir) {
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
for (const e of entries) {
|
|
169
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith("."))
|
|
170
|
+
continue;
|
|
171
|
+
const full = path.join(dir, e.name);
|
|
172
|
+
if (e.isDirectory())
|
|
173
|
+
yield* walkFiles(full);
|
|
174
|
+
else if (e.isFile())
|
|
175
|
+
yield full;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { ToolError } from "../errors.js";
|
|
2
|
+
import { FsTools } from "./fs.js";
|
|
3
|
+
import { SessionStore } from "../memory/sessions.js";
|
|
4
|
+
import { ShellTool } from "./shell.js";
|
|
5
|
+
export class ToolRegistry {
|
|
6
|
+
config;
|
|
7
|
+
ltm;
|
|
8
|
+
indexer;
|
|
9
|
+
approve;
|
|
10
|
+
askUser;
|
|
11
|
+
fs;
|
|
12
|
+
shell;
|
|
13
|
+
defs;
|
|
14
|
+
currentSessionId;
|
|
15
|
+
constructor(config, checkpoints, ltm, indexer, approve, askUser = async () => 0) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.ltm = ltm;
|
|
18
|
+
this.indexer = indexer;
|
|
19
|
+
this.approve = approve;
|
|
20
|
+
this.askUser = askUser;
|
|
21
|
+
this.fs = new FsTools(config, checkpoints);
|
|
22
|
+
this.shell = new ShellTool(config);
|
|
23
|
+
this.defs = {
|
|
24
|
+
read_file: {
|
|
25
|
+
description: "Read a file with line numbers. Use start_line/end_line for large files.",
|
|
26
|
+
fields: { path: { type: "string", required: true }, start_line: { type: "integer" }, end_line: { type: "integer" } },
|
|
27
|
+
handler: (i) => this.fs.readFile(i.path, i.start_line ?? 1, i.end_line),
|
|
28
|
+
},
|
|
29
|
+
write_file: {
|
|
30
|
+
description: "Create or overwrite a file. A checkpoint is taken automatically; in ask-mode the user previews a diff first.",
|
|
31
|
+
fields: { path: { type: "string", required: true }, content: { type: "string", required: true } },
|
|
32
|
+
handler: (i) => this.applyPending(this.fs.stageWrite(i.path, i.content)),
|
|
33
|
+
},
|
|
34
|
+
edit_file: {
|
|
35
|
+
description: "Surgically edit a file by replacing old_str (must match exactly once, including whitespace) with new_str. Prefer over write_file for existing files.",
|
|
36
|
+
fields: { path: { type: "string", required: true }, old_str: { type: "string", required: true }, new_str: { type: "string", required: true } },
|
|
37
|
+
handler: (i) => this.applyPending(this.fs.stageEdit(i.path, i.old_str, i.new_str)),
|
|
38
|
+
},
|
|
39
|
+
list_dir: {
|
|
40
|
+
description: "List directory contents as a tree (default depth 2).",
|
|
41
|
+
fields: { path: { type: "string" }, depth: { type: "integer" } },
|
|
42
|
+
handler: (i) => this.fs.listDir(i.path ?? ".", i.depth ?? 2),
|
|
43
|
+
},
|
|
44
|
+
grep: {
|
|
45
|
+
description: "Regex search across files. Returns path:line: match.",
|
|
46
|
+
fields: { pattern: { type: "string", required: true }, path: { type: "string" }, max_results: { type: "integer" } },
|
|
47
|
+
handler: (i) => this.fs.grep(i.pattern, i.path ?? ".", i.max_results ?? 50),
|
|
48
|
+
},
|
|
49
|
+
run_shell: {
|
|
50
|
+
description: "Run a shell command in the workspace (tests, builds, git, installs). Output includes exit code. Destructive commands are blocked; in ask-mode the user approves each command first.",
|
|
51
|
+
fields: { command: { type: "string", required: true }, timeout: { type: "integer" } },
|
|
52
|
+
handler: async (i, signal) => {
|
|
53
|
+
const command = i.command;
|
|
54
|
+
const ok = await this.approve({
|
|
55
|
+
kind: "shell",
|
|
56
|
+
path: `$ ${command}`,
|
|
57
|
+
diff: command,
|
|
58
|
+
apply: () => "",
|
|
59
|
+
});
|
|
60
|
+
if (!ok) {
|
|
61
|
+
throw new ToolError(`User REJECTED running: ${command}. Do not retry the same command; ask the user or take a different approach.`);
|
|
62
|
+
}
|
|
63
|
+
return this.shell.run(command, i.timeout != null ? i.timeout * 1000 : undefined, signal);
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
search_code: {
|
|
67
|
+
description: "Search the symbol index for function/class definitions by name.",
|
|
68
|
+
fields: { query: { type: "string", required: true } },
|
|
69
|
+
handler: (i) => {
|
|
70
|
+
this.freshIndex();
|
|
71
|
+
const results = this.indexer.search(i.query);
|
|
72
|
+
return results.length
|
|
73
|
+
? results.map((r) => `${r.path}:${r.line} [${r.kind}] ${r.signature}`).join("\n")
|
|
74
|
+
: "No symbols matched. Try `grep` for full-text search.";
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
who_calls: {
|
|
78
|
+
description: "Code graph: list every place a function/symbol is called FROM (its blast radius). Edges are static-analysis hints — verify by reading where precision matters.",
|
|
79
|
+
fields: { name: { type: "string", required: true } },
|
|
80
|
+
handler: (i) => {
|
|
81
|
+
this.freshIndex();
|
|
82
|
+
const edges = this.indexer.whoCalls(i.name);
|
|
83
|
+
return edges.length
|
|
84
|
+
? edges.map((e) => `${e.caller} -> ${i.name} (${e.path}:${e.line})`).join("\n")
|
|
85
|
+
: `No recorded callers of ${i.name}. It may be an entry point, called dynamically, or unindexed.`;
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
calls_from: {
|
|
89
|
+
description: "Code graph: list everything a function calls (its dependencies). Static hints — verify by reading where precision matters.",
|
|
90
|
+
fields: { name: { type: "string", required: true } },
|
|
91
|
+
handler: (i) => {
|
|
92
|
+
this.freshIndex();
|
|
93
|
+
const edges = this.indexer.callsFrom(i.name);
|
|
94
|
+
return edges.length
|
|
95
|
+
? edges.map((e) => `${i.name} -> ${e.callee} (${e.path}:${e.line})`).join("\n")
|
|
96
|
+
: `${i.name} calls no indexed symbols (leaf function, or calls only external/dynamic code).`;
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
trace_path: {
|
|
100
|
+
description: "Code graph: shortest call chain connecting two symbols, e.g. trace_path(main, saveUser) -> main -> startServer -> handleSignup -> saveUser. Use to understand workflow before editing.",
|
|
101
|
+
fields: { from: { type: "string", required: true }, to: { type: "string", required: true } },
|
|
102
|
+
handler: (i) => {
|
|
103
|
+
this.freshIndex();
|
|
104
|
+
const chain = this.indexer.tracePath(i.from, i.to);
|
|
105
|
+
return chain
|
|
106
|
+
? chain.join(" -> ")
|
|
107
|
+
: `No static call path found from ${i.from} to ${i.to} (may be connected dynamically, via events, or not at all).`;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
remember: {
|
|
111
|
+
description: "Store a durable memory for future sessions: project facts, architecture decisions, user preferences, gotchas. kind is one of fact|decision|preference|gotcha.",
|
|
112
|
+
fields: { kind: { type: "string", required: true }, content: { type: "string", required: true }, tags: { type: "string" } },
|
|
113
|
+
handler: (i) => `Stored memory #${this.ltm.remember(i.kind, i.content, i.tags ?? "")} (${i.kind}).`,
|
|
114
|
+
},
|
|
115
|
+
ask_user: {
|
|
116
|
+
description: "Present 2-4 mutually exclusive options to the user and get their pick (arrow-key menu). Use BEFORE implementing when there are meaningfully different approaches, designs, or interpretations of the request. Keep each option short (one line). A 'discuss instead' choice is added automatically — never add your own.",
|
|
117
|
+
fields: { question: { type: "string", required: true }, options: { type: "string_array", required: true } },
|
|
118
|
+
handler: async (i) => {
|
|
119
|
+
const opts = i.options.slice(0, 4);
|
|
120
|
+
const withEscape = [...opts, "Chat more about this instead"];
|
|
121
|
+
const pick = await this.askUser(i.question, withEscape);
|
|
122
|
+
if (pick < 0 || pick >= opts.length) {
|
|
123
|
+
return "User did not pick an option — they want to discuss further. Ask a clarifying question instead of implementing.";
|
|
124
|
+
}
|
|
125
|
+
return `User chose option ${pick + 1}: "${opts[pick]}". Proceed with this approach.`;
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
recall_sessions: {
|
|
129
|
+
description: "Search PAST conversation sessions in this project. Use when the user references a previous conversation ('last time', 'what did I ask before', 'the thing we discussed') or when past context would clearly help. Returns matching snippets with dates.",
|
|
130
|
+
fields: { query: { type: "string", required: true } },
|
|
131
|
+
handler: (i) => {
|
|
132
|
+
const hits = SessionStore.search(this.config.sessionsDir, i.query, 5, this.currentSessionId);
|
|
133
|
+
return hits.length
|
|
134
|
+
? hits.map((h) => `[${h.when}] (${h.role}) ...${h.snippet}...`).join("\n---\n")
|
|
135
|
+
: "No matching past conversations found for that query.";
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
note_file: {
|
|
139
|
+
description: "Save/update a one-line summary of what a file does (persisted across sessions).",
|
|
140
|
+
fields: { path: { type: "string", required: true }, summary: { type: "string", required: true } },
|
|
141
|
+
handler: (i) => { this.ltm.noteFile(i.path, i.summary); return `Noted ${i.path}.`; },
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async applyPending(pending) {
|
|
146
|
+
const ok = await this.approve(pending);
|
|
147
|
+
if (!ok) {
|
|
148
|
+
throw new ToolError(`User REJECTED the change to ${pending.path}. Do not retry the same change; ask the user what they want instead or take a different approach.`);
|
|
149
|
+
}
|
|
150
|
+
return pending.apply();
|
|
151
|
+
}
|
|
152
|
+
freshIndex() {
|
|
153
|
+
if (!this.indexer.isBuilt())
|
|
154
|
+
this.indexer.build();
|
|
155
|
+
else
|
|
156
|
+
this.indexer.refresh(); // incremental: only changed files re-parsed
|
|
157
|
+
}
|
|
158
|
+
/** Canonical signature used by the agent's doom-loop detector. */
|
|
159
|
+
static signature(name, input) {
|
|
160
|
+
return `${name}:${JSON.stringify(input, Object.keys(input).sort())}`;
|
|
161
|
+
}
|
|
162
|
+
async execute(name, input, signal) {
|
|
163
|
+
const def = this.defs[name];
|
|
164
|
+
if (!def)
|
|
165
|
+
return { output: `Unknown tool: ${name}. Available: ${Object.keys(this.defs).join(", ")}`, isError: true };
|
|
166
|
+
// validate before executing
|
|
167
|
+
for (const [field, spec] of Object.entries(def.fields)) {
|
|
168
|
+
const v = input[field];
|
|
169
|
+
if (v == null) {
|
|
170
|
+
if (spec.required)
|
|
171
|
+
return { output: `Missing required argument '${field}' for ${name}.`, isError: true };
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (spec.type === "string" && typeof v !== "string") {
|
|
175
|
+
return { output: `Argument '${field}' of ${name} must be a string.`, isError: true };
|
|
176
|
+
}
|
|
177
|
+
if (spec.type === "integer" && (!Number.isFinite(v) || !Number.isInteger(v))) {
|
|
178
|
+
return { output: `Argument '${field}' of ${name} must be an integer.`, isError: true };
|
|
179
|
+
}
|
|
180
|
+
if (spec.type === "string_array" && (!Array.isArray(v) || !v.every((x) => typeof x === "string") || v.length === 0)) {
|
|
181
|
+
return { output: `Argument '${field}' of ${name} must be a non-empty array of strings.`, isError: true };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
return { output: String(await def.handler(input, signal)), isError: false };
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (err instanceof ToolError)
|
|
189
|
+
return { output: `Tool error: ${err.message}`, isError: true };
|
|
190
|
+
throw err; // CancelledError and unexpected errors propagate to the agent
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
schemas() {
|
|
194
|
+
return Object.entries(this.defs).map(([name, def]) => ({
|
|
195
|
+
name,
|
|
196
|
+
description: def.description,
|
|
197
|
+
input_schema: {
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: Object.fromEntries(Object.entries(def.fields).map(([f, s]) => [f,
|
|
200
|
+
s.type === "string_array" ? { type: "array", items: { type: "string" } } : { type: s.type },
|
|
201
|
+
])),
|
|
202
|
+
required: Object.entries(def.fields).filter(([, s]) => s.required).map(([f]) => f),
|
|
203
|
+
},
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell tool: timeout (SIGKILL fallback), denylist, output truncation,
|
|
3
|
+
* cancellation via AbortSignal. Guardrails — not a security boundary; run
|
|
4
|
+
* Faber in a container for untrusted code (see README).
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { ToolError, CancelledError } from "../errors.js";
|
|
8
|
+
const DENY = [
|
|
9
|
+
/\brm\s+(-[a-zA-Z]*\s+)*[/~]\s*$/,
|
|
10
|
+
/\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+\/(?:\s|$)/,
|
|
11
|
+
/\bmkfs\b/,
|
|
12
|
+
/\bdd\s+.*of=\/dev\//,
|
|
13
|
+
/:\(\)\s*\{.*\};\s*:/,
|
|
14
|
+
/\bsudo\b/,
|
|
15
|
+
/\bshutdown\b|\breboot\b/,
|
|
16
|
+
/>\s*\/dev\/sd[a-z]/,
|
|
17
|
+
/\bchmod\s+-R\s+777\s+\//,
|
|
18
|
+
];
|
|
19
|
+
const MAX_OUTPUT = 20_000;
|
|
20
|
+
export class ShellTool {
|
|
21
|
+
config;
|
|
22
|
+
constructor(config) {
|
|
23
|
+
this.config = config;
|
|
24
|
+
}
|
|
25
|
+
run(command, timeoutMs, signal) {
|
|
26
|
+
for (const rx of DENY) {
|
|
27
|
+
if (rx.test(command)) {
|
|
28
|
+
throw new ToolError("Command blocked by safety policy (destructive or privileged pattern). Use a narrower, non-destructive command.");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const timeout = Math.min(timeoutMs ?? this.config.shellTimeoutMs, 600_000);
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn(command, { shell: true, cwd: this.config.workspace });
|
|
34
|
+
let out = "";
|
|
35
|
+
let killed = false;
|
|
36
|
+
const kill = (why) => {
|
|
37
|
+
killed = true;
|
|
38
|
+
child.kill("SIGTERM");
|
|
39
|
+
setTimeout(() => child.kill("SIGKILL"), 3000).unref();
|
|
40
|
+
if (why === "timeout") {
|
|
41
|
+
reject(new ToolError(`Command timed out after ${timeout / 1000}s. Consider a faster variant or run it in the background.`));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
reject(new CancelledError());
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const timer = setTimeout(() => kill("timeout"), timeout);
|
|
48
|
+
signal?.addEventListener("abort", () => { clearTimeout(timer); kill("cancel"); }, { once: true });
|
|
49
|
+
const collect = (chunk, label = "") => {
|
|
50
|
+
if (out.length < MAX_OUTPUT * 2)
|
|
51
|
+
out += (label && !out.endsWith(label) ? label : "") + chunk.toString();
|
|
52
|
+
};
|
|
53
|
+
child.stdout.on("data", (c) => collect(c));
|
|
54
|
+
child.stderr.on("data", (c) => collect(c, "\n[stderr]\n"));
|
|
55
|
+
child.on("error", (err) => { clearTimeout(timer); reject(new ToolError(`Failed to start command: ${err.message}`)); });
|
|
56
|
+
child.on("close", (code) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (killed)
|
|
59
|
+
return;
|
|
60
|
+
let text = out;
|
|
61
|
+
if (text.length > MAX_OUTPUT) {
|
|
62
|
+
text = text.slice(0, MAX_OUTPUT / 2) + "\n...[output truncated]...\n" + text.slice(-MAX_OUTPUT / 2);
|
|
63
|
+
}
|
|
64
|
+
resolve(`[exit code: ${code ?? "?"}]\n${text.trim() || "(no output)"}`);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent usage ledger — every task's token/cost record, per project.
|
|
3
|
+
* Stored in <project>/.faber/usage.db (per-project, like all state). Powers the
|
|
4
|
+
* /usage panel (session / today / all-time) and cross-project totals via a
|
|
5
|
+
* small registry of project paths in ~/.faber/projects.json — the first
|
|
6
|
+
* resident of the global home directory from the roadmap.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { DatabaseSync } from "node:sqlite";
|
|
12
|
+
export class UsageLedger {
|
|
13
|
+
projectPath;
|
|
14
|
+
db;
|
|
15
|
+
session = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, tasks: 0 };
|
|
16
|
+
constructor(dbPath, projectPath) {
|
|
17
|
+
this.projectPath = projectPath;
|
|
18
|
+
this.db = new DatabaseSync(dbPath);
|
|
19
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
20
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS tasks (
|
|
21
|
+
ts INTEGER, input INTEGER, cache_read INTEGER, cache_write INTEGER,
|
|
22
|
+
output INTEGER, calls INTEGER, model TEXT)`);
|
|
23
|
+
this.registerProject();
|
|
24
|
+
}
|
|
25
|
+
record(u, model) {
|
|
26
|
+
this.db.prepare("INSERT INTO tasks VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
27
|
+
.run(Date.now(), u.input, u.cacheRead, u.cacheWrite, u.output, u.calls, model);
|
|
28
|
+
this.session.input += u.input;
|
|
29
|
+
this.session.cacheRead += u.cacheRead;
|
|
30
|
+
this.session.cacheWrite += u.cacheWrite;
|
|
31
|
+
this.session.output += u.output;
|
|
32
|
+
this.session.calls += u.calls;
|
|
33
|
+
this.session.tasks += 1;
|
|
34
|
+
}
|
|
35
|
+
totals(sinceMs) {
|
|
36
|
+
return this.db.prepare(`SELECT COUNT(*) tasks, COALESCE(SUM(input),0) input, COALESCE(SUM(cache_read),0) cacheRead,
|
|
37
|
+
COALESCE(SUM(cache_write),0) cacheWrite, COALESCE(SUM(output),0) output,
|
|
38
|
+
COALESCE(SUM(calls),0) calls
|
|
39
|
+
FROM tasks WHERE ts >= ?`).get(sinceMs ?? 0);
|
|
40
|
+
}
|
|
41
|
+
/** Cost in USD; undefined when CW_PRICE_IN/OUT aren't configured. */
|
|
42
|
+
static cost(u, priceIn, priceOut) {
|
|
43
|
+
if (!priceIn || !priceOut)
|
|
44
|
+
return undefined;
|
|
45
|
+
return (u.input * priceIn + u.cacheRead * priceIn * 0.1 +
|
|
46
|
+
u.cacheWrite * priceIn * 1.25 + u.output * priceOut) / 1e6;
|
|
47
|
+
}
|
|
48
|
+
/** Net savings from caching vs paying full input price (reads at 0.1x minus write premium). */
|
|
49
|
+
static saved(u, priceIn) {
|
|
50
|
+
if (!priceIn)
|
|
51
|
+
return undefined;
|
|
52
|
+
return (u.cacheRead * priceIn * 0.9 - u.cacheWrite * priceIn * 0.25) / 1e6;
|
|
53
|
+
}
|
|
54
|
+
// ---- global registry so usage can be summed across every project ----
|
|
55
|
+
registerProject() {
|
|
56
|
+
try {
|
|
57
|
+
const dir = path.join(os.homedir(), ".faber");
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
+
const reg = path.join(dir, "projects.json");
|
|
60
|
+
let list = [];
|
|
61
|
+
try {
|
|
62
|
+
list = JSON.parse(fs.readFileSync(reg, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch { /* fresh */ }
|
|
65
|
+
if (!list.includes(this.projectPath)) {
|
|
66
|
+
list.push(this.projectPath);
|
|
67
|
+
fs.writeFileSync(reg, JSON.stringify(list, null, 2));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch { /* registry is best-effort */ }
|
|
71
|
+
}
|
|
72
|
+
static allProjects() {
|
|
73
|
+
const home = os.homedir();
|
|
74
|
+
let list = [];
|
|
75
|
+
for (const reg of [path.join(home, ".faber", "projects.json"),
|
|
76
|
+
path.join(home, ".codewright", "projects.json")]) {
|
|
77
|
+
try {
|
|
78
|
+
for (const p of JSON.parse(fs.readFileSync(reg, "utf8"))) {
|
|
79
|
+
if (!list.includes(p))
|
|
80
|
+
list.push(p);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch { /* absent registry is fine */ }
|
|
84
|
+
}
|
|
85
|
+
if (!list.length)
|
|
86
|
+
return [];
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const p of list) {
|
|
89
|
+
const dbPath = [path.join(p, ".faber", "usage.db"), path.join(p, ".codewright", "usage.db")]
|
|
90
|
+
.find((d) => fs.existsSync(d));
|
|
91
|
+
if (!dbPath)
|
|
92
|
+
continue;
|
|
93
|
+
try {
|
|
94
|
+
const led = new UsageLedger(dbPath, p);
|
|
95
|
+
out.push({ project: p, totals: led.totals() });
|
|
96
|
+
led.close();
|
|
97
|
+
}
|
|
98
|
+
catch { /* skip unreadable */ }
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
close() { this.db.close(); }
|
|
103
|
+
}
|
|
104
|
+
const k = (n) => n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + "M"
|
|
105
|
+
: n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
|
|
106
|
+
const money = (v) => v === undefined ? "—" : `$${v.toFixed(2)}`;
|
|
107
|
+
/** Render the /usage stats panel. */
|
|
108
|
+
export function renderUsagePanel(ledger, priceIn, priceOut) {
|
|
109
|
+
const midnight = new Date();
|
|
110
|
+
midnight.setHours(0, 0, 0, 0);
|
|
111
|
+
const rows = [
|
|
112
|
+
["this session", ledger.session],
|
|
113
|
+
["today", ledger.totals(midnight.getTime())],
|
|
114
|
+
["all time", ledger.totals()],
|
|
115
|
+
];
|
|
116
|
+
const lines = [];
|
|
117
|
+
const W = [13, 7, 12, 9, 7, 9, 9];
|
|
118
|
+
const cells = (c) => c.map((s, i) => s.padEnd(W[i])).join(" ");
|
|
119
|
+
lines.push(cells(["", "tasks", "in", "cached", "out", "cost", "saved"]));
|
|
120
|
+
for (const [label, t] of rows) {
|
|
121
|
+
const totalIn = t.input + t.cacheRead + t.cacheWrite;
|
|
122
|
+
const pct = totalIn > 0 ? Math.round((t.cacheRead / totalIn) * 100) + "%" : "0%";
|
|
123
|
+
lines.push(cells([
|
|
124
|
+
label, String(t.tasks), k(totalIn), pct, k(t.output),
|
|
125
|
+
money(UsageLedger.cost(t, priceIn, priceOut)),
|
|
126
|
+
money(UsageLedger.saved(t, priceIn)),
|
|
127
|
+
]));
|
|
128
|
+
}
|
|
129
|
+
if (!priceIn || !priceOut) {
|
|
130
|
+
lines.push("");
|
|
131
|
+
lines.push("set CW_PRICE_IN and CW_PRICE_OUT ($/Mtok) to see cost and savings");
|
|
132
|
+
}
|
|
133
|
+
const others = UsageLedger.allProjects().filter((p) => p.totals.tasks > 0);
|
|
134
|
+
if (others.length > 1) {
|
|
135
|
+
lines.push("");
|
|
136
|
+
lines.push("across all projects:");
|
|
137
|
+
for (const { project, totals: t } of others) {
|
|
138
|
+
const totalIn = t.input + t.cacheRead + t.cacheWrite;
|
|
139
|
+
lines.push(cells([
|
|
140
|
+
" " + path.basename(project).slice(0, 11), String(t.tasks), k(totalIn),
|
|
141
|
+
totalIn > 0 ? Math.round((t.cacheRead / totalIn) * 100) + "%" : "0%",
|
|
142
|
+
k(t.output), money(UsageLedger.cost(t, priceIn, priceOut)), "",
|
|
143
|
+
]));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "faberwright",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Faber: an agentic AI coding assistant for your terminal — streams, edits with diff approval, runs your tests, and remembers your project across sessions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.5"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"faber": "dist/index.js",
|
|
12
|
+
"fb": "dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.build.json",
|
|
21
|
+
"test": "tsc && node --test dist-test/test/*.test.js",
|
|
22
|
+
"prepublishOnly": "npm test && npm run build",
|
|
23
|
+
"dev": "tsc -p tsconfig.build.json --watch",
|
|
24
|
+
"postbuild": "chmod +x dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"diff": "^7.0.0",
|
|
28
|
+
"picocolors": "^1.1.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/diff": "^6.0.0",
|
|
32
|
+
"@types/node": "^22.0.0",
|
|
33
|
+
"typescript": "^5.6.0"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"ai",
|
|
37
|
+
"cli",
|
|
38
|
+
"coding-assistant",
|
|
39
|
+
"agent",
|
|
40
|
+
"anthropic",
|
|
41
|
+
"claude",
|
|
42
|
+
"openai",
|
|
43
|
+
"ollama",
|
|
44
|
+
"code-graph",
|
|
45
|
+
"developer-tools",
|
|
46
|
+
"terminal",
|
|
47
|
+
"llm"
|
|
48
|
+
]
|
|
49
|
+
}
|