roforge-cli 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/README.md +58 -0
- package/bin/roforge.js +384 -0
- package/demo/e2e-demo.mjs +53 -0
- package/package.json +44 -0
- package/src/agent.js +137 -0
- package/src/bridge/server.js +154 -0
- package/src/bridge/wire.js +10 -0
- package/src/config.js +227 -0
- package/src/mcp.js +159 -0
- package/src/providers/anthropic.js +161 -0
- package/src/providers/gemini.js +141 -0
- package/src/providers/groq.js +16 -0
- package/src/providers/openai.js +138 -0
- package/src/providers/openrouter.js +17 -0
- package/src/session.js +192 -0
- package/src/tools/index.js +49 -0
- package/src/tools/project.js +212 -0
- package/src/tools/roblox.js +95 -0
- package/src/tools/studio.js +296 -0
- package/src/tools/web.js +155 -0
- package/src/tui/ansi.js +41 -0
- package/src/tui/markdown.js +67 -0
- package/src/tui/tui.js +463 -0
- package/src/util.js +117 -0
package/src/tools/web.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Web tools, running locally (no backend needed): web_search + url_fetch.
|
|
2
|
+
// Search provider order: serper → brave → wikipedia (keyless fallback).
|
|
3
|
+
import dns from "node:dns/promises";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { htmlToText, truncate } from "../util.js";
|
|
6
|
+
|
|
7
|
+
const UA = "RoForge/0.2 (+local; roblox studio agent)";
|
|
8
|
+
|
|
9
|
+
async function serper(q, max, key) {
|
|
10
|
+
const res = await fetch("https://google.serper.dev/search", {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "X-API-KEY": key, "Content-Type": "application/json" },
|
|
13
|
+
body: JSON.stringify({ q, num: max }),
|
|
14
|
+
signal: AbortSignal.timeout(15000),
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) throw new Error(`Serper HTTP ${res.status}`);
|
|
17
|
+
const data = await res.json();
|
|
18
|
+
return (data.organic || []).slice(0, max).map((r) => ({ title: r.title, url: r.link, snippet: r.snippet }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function brave(q, max, key) {
|
|
22
|
+
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(q)}&count=${max}`;
|
|
23
|
+
const res = await fetch(url, {
|
|
24
|
+
headers: { "X-Subscription-Token": key, Accept: "application/json" },
|
|
25
|
+
signal: AbortSignal.timeout(15000),
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) throw new Error(`Brave HTTP ${res.status}`);
|
|
28
|
+
const data = await res.json();
|
|
29
|
+
const results = (data && data.web && data.web.results) || [];
|
|
30
|
+
return results.slice(0, max).map((r) => ({ title: r.title, url: r.url, snippet: r.description }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function wikipedia(q, max) {
|
|
34
|
+
const url = `https://en.wikipedia.org/w/api.php?action=opensearch&format=json&limit=${max}&search=${encodeURIComponent(q)}`;
|
|
35
|
+
const res = await fetch(url, { headers: { "User-Agent": UA }, signal: AbortSignal.timeout(15000) });
|
|
36
|
+
if (!res.ok) throw new Error(`Wikipedia HTTP ${res.status}`);
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
const titles = data[1] || [];
|
|
39
|
+
const urls = data[3] || [];
|
|
40
|
+
return titles.map((t, i) => ({ title: t, url: urls[i] || "", snippet: "Wikipedia (keyless fallback)" }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pickProvider(cfg) {
|
|
44
|
+
if (cfg.searchProvider && cfg.searchProvider !== "auto") return cfg.searchProvider;
|
|
45
|
+
if (cfg.serperKey) return "serper";
|
|
46
|
+
if (cfg.braveKey) return "brave";
|
|
47
|
+
return "wikipedia";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function webSearch(cfg, query, max = 6) {
|
|
51
|
+
const provider = pickProvider(cfg);
|
|
52
|
+
const impl = { serper, brave, wikipedia }[provider];
|
|
53
|
+
if (!impl) throw new Error(`unknown search provider '${provider}'`);
|
|
54
|
+
const results = await impl(String(query), max, cfg.serperKey || cfg.braveKey);
|
|
55
|
+
if (!results.length) return "No results found.";
|
|
56
|
+
let out = `Web search results for: ${query} (provider: ${provider}`;
|
|
57
|
+
if (provider === "wikipedia") out += ", keyless fallback";
|
|
58
|
+
out += ")\n";
|
|
59
|
+
results.forEach((r, i) => {
|
|
60
|
+
out += `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet || ""}\n`;
|
|
61
|
+
});
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ipIsPrivate(ip) {
|
|
66
|
+
if (net.isIPv4(ip)) {
|
|
67
|
+
const [a, b] = ip.split(".").map(Number);
|
|
68
|
+
return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
|
|
69
|
+
}
|
|
70
|
+
const lower = ip.toLowerCase();
|
|
71
|
+
if (lower === "::1" || lower === "::" || lower === "::ffff:0.0.0.0") return true;
|
|
72
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
73
|
+
if (lower.startsWith("fe80:")) return true;
|
|
74
|
+
if (lower.startsWith("::ffff:")) return ipIsPrivate(lower.slice(7));
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function assertPublicHost(hostname) {
|
|
79
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
80
|
+
if (net.isIP(host)) {
|
|
81
|
+
if (ipIsPrivate(host)) throw new Error(`Blocked: ${host} is a private/loopback address`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const addresses = await dns.lookup(host, { all: true });
|
|
85
|
+
if (!addresses.length || addresses.some((a) => ipIsPrivate(a.address))) {
|
|
86
|
+
throw new Error(`Blocked: ${host} resolves to a private/loopback address`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function fetchUrl(rawUrl, { maxBytes = 512 * 1024, maxChars = 60000, timeoutMs = 15000 } = {}) {
|
|
91
|
+
let current;
|
|
92
|
+
try {
|
|
93
|
+
current = new URL(String(rawUrl));
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error("Invalid URL");
|
|
96
|
+
}
|
|
97
|
+
for (let hop = 0; ; hop++) {
|
|
98
|
+
if (!/^https?:$/.test(current.protocol)) throw new Error("Only http/https URLs are allowed");
|
|
99
|
+
await assertPublicHost(current.hostname);
|
|
100
|
+
const res = await fetch(current.toString(), {
|
|
101
|
+
redirect: "manual",
|
|
102
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
103
|
+
headers: { "User-Agent": UA, Accept: "text/html,application/json,text/plain,application/xml,*/*" },
|
|
104
|
+
}).catch((e) => {
|
|
105
|
+
throw new Error(`Fetch failed: ${e.cause?.code || e.message}`);
|
|
106
|
+
});
|
|
107
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
|
|
108
|
+
if (hop + 1 > 5) throw new Error("Too many redirects");
|
|
109
|
+
current = new URL(res.headers.get("location"), current);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!res.ok) throw new Error(`Upstream HTTP ${res.status}`);
|
|
113
|
+
const ctype = String(res.headers.get("content-type") || "").toLowerCase();
|
|
114
|
+
if (ctype.includes("octet-stream") || ctype.includes("zip") || ctype.includes("pdf")) {
|
|
115
|
+
throw new Error(`Refusing binary content (${ctype || "unknown type"})`);
|
|
116
|
+
}
|
|
117
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
118
|
+
if (buf.byteLength > maxBytes) throw new Error(`Content too large (${buf.byteLength} bytes)`);
|
|
119
|
+
let text = buf.toString("utf8");
|
|
120
|
+
if (ctype.includes("html")) text = htmlToText(text);
|
|
121
|
+
text = text.replace(/\r\n/g, "\n").trim();
|
|
122
|
+
if (!text) throw new Error("Fetched content was empty");
|
|
123
|
+
return truncate(text, maxChars);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function webTools() {
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
name: "web_search",
|
|
131
|
+
description: "Search the web and get top results with titles, URLs, and snippets. Use for documentation, APIs, and up-to-date facts about Roblox, Luau, or anything else.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
query: { type: "string", description: "The search query" },
|
|
136
|
+
max_results: { type: "integer", description: "1-10. Default 6." },
|
|
137
|
+
},
|
|
138
|
+
required: ["query"],
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
},
|
|
141
|
+
execute: async (args, ctx) => webSearch(ctx.cfg, args.query, Math.min(Math.max(1, Number(args.max_results) || 6), 10)),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "url_fetch",
|
|
145
|
+
description: "Fetch a URL and return its content as text (HTML is converted to plain text). Use to read documentation pages or JSON APIs.",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: { url: { type: "string", description: "Absolute http(s) URL" } },
|
|
149
|
+
required: ["url"],
|
|
150
|
+
additionalProperties: false,
|
|
151
|
+
},
|
|
152
|
+
execute: async (args) => fetchUrl(args.url),
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
}
|
package/src/tui/ansi.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Minimal ANSI helpers.
|
|
2
|
+
// Color can be disabled with --no-color, ROFORGE_NO_COLOR=1, or the standard
|
|
3
|
+
// NO_COLOR env var (checked at call time so flags work even after import).
|
|
4
|
+
function colorEnabled() {
|
|
5
|
+
if (process.env.NO_COLOR || process.env.ROFORGE_NO_COLOR) return false;
|
|
6
|
+
if (process.argv.includes("--no-color")) return false;
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
const c = (n) => (s) => (colorEnabled() ? `\x1b[${n}m${s}\x1b[0m` : String(s));
|
|
10
|
+
export const bold = c(1);
|
|
11
|
+
export const dim = c(2);
|
|
12
|
+
export const red = c(31);
|
|
13
|
+
export const green = c(32);
|
|
14
|
+
export const yellow = c(33);
|
|
15
|
+
export const blue = c(34);
|
|
16
|
+
export const magenta = c(35);
|
|
17
|
+
export const cyan = c(36);
|
|
18
|
+
export const gray = c(90);
|
|
19
|
+
|
|
20
|
+
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "", "⠼", "⠴", "", "", "⠇", "⠏"];
|
|
21
|
+
|
|
22
|
+
export const CLEAR_LINE = "\x1b[2K";
|
|
23
|
+
export const MOVE_UP = (n) => `\x1b[${n}A`;
|
|
24
|
+
export const COLUMNS = () => (typeof process.stdout.columns === "number" ? process.stdout.columns : 80);
|
|
25
|
+
|
|
26
|
+
// Wrap plain text to terminal width (word-aware).
|
|
27
|
+
export function wrap(text, width) {
|
|
28
|
+
width = Math.max(20, width - 2);
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const rawLine of String(text).split("\n")) {
|
|
31
|
+
let line = rawLine;
|
|
32
|
+
while (line.length > width) {
|
|
33
|
+
let cut = line.lastIndexOf(" ", width);
|
|
34
|
+
if (cut < 20) cut = width;
|
|
35
|
+
out.push(line.slice(0, cut));
|
|
36
|
+
line = line.slice(cut).replace(/^\s+/, "");
|
|
37
|
+
}
|
|
38
|
+
out.push(line);
|
|
39
|
+
}
|
|
40
|
+
return out.join("\n");
|
|
41
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Lightweight streaming Markdown renderer for assistant output.
|
|
2
|
+
// Renders line-by-line as lines complete (safe for token streaming), keeps
|
|
3
|
+
// code-fence state across deltas, and resets cleanly per assistant segment.
|
|
4
|
+
// Deliberately small: headings, bullets, numbered lists, bold, inline code,
|
|
5
|
+
// fences, blockquotes, rules. Everything else passes through untouched.
|
|
6
|
+
import { bold, dim, cyan, gray } from "./ansi.js";
|
|
7
|
+
|
|
8
|
+
export class MarkdownStream {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.buf = "";
|
|
11
|
+
this.inFence = false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Consume a chunk; returns the renderable output for completed lines.
|
|
15
|
+
push(delta) {
|
|
16
|
+
this.buf += String(delta ?? "");
|
|
17
|
+
let out = "";
|
|
18
|
+
let idx;
|
|
19
|
+
while ((idx = this.buf.indexOf("\n")) !== -1) {
|
|
20
|
+
const line = this.buf.slice(0, idx);
|
|
21
|
+
this.buf = this.buf.slice(idx + 1);
|
|
22
|
+
out += this._renderLine(line) + "\n";
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Flush the incomplete trailing line (at end of an assistant segment).
|
|
28
|
+
finish() {
|
|
29
|
+
if (!this.buf.length) return "";
|
|
30
|
+
const line = this.buf;
|
|
31
|
+
this.buf = "";
|
|
32
|
+
return this._renderLine(line) + "\n";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_renderLine(line) {
|
|
36
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
37
|
+
this.inFence = !this.inFence;
|
|
38
|
+
return dim(" " + line.trim());
|
|
39
|
+
}
|
|
40
|
+
if (this.inFence) {
|
|
41
|
+
return dim(" " + line);
|
|
42
|
+
}
|
|
43
|
+
return this._renderPlain(line);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_renderPlain(line) {
|
|
47
|
+
const h = line.match(/^(#{1,4})\s+(.*)$/);
|
|
48
|
+
if (h) return bold(h[1] + " " + this._inline(h[2]));
|
|
49
|
+
if (/^\s*([-*+])\s+/.test(line)) {
|
|
50
|
+
return cyan("• ") + this._inline(line.replace(/^\s*[-*+]\s+/, ""));
|
|
51
|
+
}
|
|
52
|
+
const num = line.match(/^\s*(\d+)[.)]\s+(.*)$/);
|
|
53
|
+
if (num) return gray(num[1] + ".") + " " + this._inline(num[2]);
|
|
54
|
+
if (/^\s*>\s?/.test(line)) {
|
|
55
|
+
return dim("│ ") + dim(this._inline(line.replace(/^\s*>\s?/, "")));
|
|
56
|
+
}
|
|
57
|
+
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) return dim("────────────────────────────────");
|
|
58
|
+
if (!line.trim()) return "";
|
|
59
|
+
return this._inline(line);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_inline(s) {
|
|
63
|
+
s = s.replace(/`([^`]+)`/g, (_, c) => cyan(c));
|
|
64
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, (_, b) => bold(b));
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
}
|