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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Local Studio bridge: a tiny HTTP server on 127.0.0.1 that the RoForge
|
|
2
|
+
// Bridge plugin (installed in Studio) polls for commands.
|
|
3
|
+
//
|
|
4
|
+
// Protocol (see docs/BRIDGE.md):
|
|
5
|
+
// GET /v1/bridge/ping → {ok, time}
|
|
6
|
+
// GET /v1/bridge/jobs → {jobs: [ ...pending ]} (first job is claimed)
|
|
7
|
+
// POST /v1/bridge/jobs/:id/result → {ok}
|
|
8
|
+
// Auth: Authorization: Bearer <bridge token> (loopback-only by design).
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import { json } from "./wire.js";
|
|
11
|
+
|
|
12
|
+
export class BridgeServer {
|
|
13
|
+
constructor({ port, host = "127.0.0.1", token, jobTimeoutMs = 60000 }) {
|
|
14
|
+
this.port = port;
|
|
15
|
+
this.host = host;
|
|
16
|
+
this.token = token;
|
|
17
|
+
this.jobTimeoutMs = jobTimeoutMs;
|
|
18
|
+
this.jobs = new Map(); // id → {id, tool, args, status, resolve, timer}
|
|
19
|
+
this.lastPingAt = null;
|
|
20
|
+
this.lastSeenAt = null;
|
|
21
|
+
this.nextJobId = 1;
|
|
22
|
+
this.server = null;
|
|
23
|
+
this.onEvent = null; // (kind, payload) hook for UI
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get connected() {
|
|
27
|
+
return this.lastSeenAt !== null && Date.now() - this.lastSeenAt < 10_000;
|
|
28
|
+
}
|
|
29
|
+
get lastSeen() {
|
|
30
|
+
return this.lastSeenAt;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
start() {
|
|
34
|
+
this.server = http.createServer((req, res) => this._handle(req, res));
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
this.server.once("error", reject);
|
|
37
|
+
this.server.listen(this.port, this.host, () => {
|
|
38
|
+
const { port } = this.server.address();
|
|
39
|
+
this.port = port;
|
|
40
|
+
resolve(this);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
stop() {
|
|
46
|
+
for (const job of this.jobs.values()) {
|
|
47
|
+
clearTimeout(job.timer);
|
|
48
|
+
job.resolve({ ok: false, error: "bridge shut down" });
|
|
49
|
+
}
|
|
50
|
+
this.jobs.clear();
|
|
51
|
+
if (this.server) {
|
|
52
|
+
const s = this.server;
|
|
53
|
+
this.server = null;
|
|
54
|
+
s.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_authorized(req) {
|
|
59
|
+
const h = String(req.headers["authorization"] || "");
|
|
60
|
+
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
61
|
+
return Boolean(m && m[1].trim() === this.token);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_handle(req, res) {
|
|
65
|
+
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
66
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
67
|
+
const emit = (kind, payload) => this.onEvent && this.onEvent(kind, payload);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
if (req.method === "GET" && path === "/health") {
|
|
71
|
+
return json(res, 200, { ok: true, service: "roforge-bridge", connected: this.connected, port: this.port });
|
|
72
|
+
}
|
|
73
|
+
if (!this._authorized(req)) {
|
|
74
|
+
return json(res, 401, { error: "unauthorized — use the token printed by roforge (or `roforge studio`)", code: "UNAUTHORIZED" });
|
|
75
|
+
}
|
|
76
|
+
this.lastSeenAt = Date.now();
|
|
77
|
+
|
|
78
|
+
if (req.method === "GET" && path === "/v1/bridge/ping") {
|
|
79
|
+
this.lastPingAt = Date.now();
|
|
80
|
+
emit("connected", this.lastPingAt);
|
|
81
|
+
return json(res, 200, { ok: true, time: new Date().toISOString(), jobsPending: this.jobs.size });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (req.method === "GET" && path === "/v1/bridge/jobs") {
|
|
85
|
+
// claim the first pending job (FIFO)
|
|
86
|
+
for (const job of this.jobs.values()) {
|
|
87
|
+
if (job.status === "pending") {
|
|
88
|
+
job.status = "claimed";
|
|
89
|
+
job.claimedAt = Date.now();
|
|
90
|
+
emit("job_claimed", job);
|
|
91
|
+
return json(res, 200, { jobs: [{ id: job.id, tool: job.tool, args: job.args }] });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return json(res, 200, { jobs: [] });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const m = /^\/v1\/bridge\/jobs\/(\w+)\/result$/.exec(path);
|
|
98
|
+
if (req.method === "POST" && m) {
|
|
99
|
+
const job = this.jobs.get(m[1]);
|
|
100
|
+
if (!job) return json(res, 404, { error: "unknown job", code: "JOB_NOT_FOUND" });
|
|
101
|
+
let body = {};
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let size = 0;
|
|
104
|
+
req.on("data", (c) => {
|
|
105
|
+
size += c.length;
|
|
106
|
+
// 16MB cap: a 1024x576 viewport PNG is ~3MB in base64 inside JSON.
|
|
107
|
+
if (size < 16 * 1024 * 1024) chunks.push(c);
|
|
108
|
+
});
|
|
109
|
+
req.on("end", () => {
|
|
110
|
+
try {
|
|
111
|
+
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
|
|
112
|
+
} catch {
|
|
113
|
+
body = { error: "bad json" };
|
|
114
|
+
}
|
|
115
|
+
clearTimeout(job.timer);
|
|
116
|
+
const out = body.error ? { ok: false, error: body.error } : { ok: true, result: body.result ?? "" };
|
|
117
|
+
this.jobs.delete(job.id);
|
|
118
|
+
job.resolve(out);
|
|
119
|
+
emit("job_done", { id: job.id, ...out });
|
|
120
|
+
json(res, 200, { ok: true });
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return json(res, 404, { error: `no route: ${req.method} ${path}`, code: "NOT_FOUND" });
|
|
126
|
+
} catch (e) {
|
|
127
|
+
json(res, 500, { error: e.message, code: "INTERNAL" });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Submit a tool job and wait for the plugin's result. Resolves to
|
|
132
|
+
// {ok:true, result:string} or {ok:false, error:string}.
|
|
133
|
+
submit(tool, args, { timeoutMs = this.jobTimeoutMs } = {}) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
if (!this.connected) {
|
|
136
|
+
return resolve({
|
|
137
|
+
ok: false,
|
|
138
|
+
error:
|
|
139
|
+
"Studio bridge is not connected. Open Roblox Studio with the RoForge Bridge plugin active " +
|
|
140
|
+
`(bridge: http://${this.host}:${this.port}).`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const id = `job_${this.nextJobId++}`;
|
|
144
|
+
const job = { id, tool, args, status: "pending", resolve, claimedAt: null, timer: null };
|
|
145
|
+
job.timer = setTimeout(() => {
|
|
146
|
+
if (this.jobs.get(id) === job) {
|
|
147
|
+
this.jobs.delete(id);
|
|
148
|
+
job.resolve({ ok: false, error: `studio job timed out after ${Math.round(timeoutMs / 1000)}s` });
|
|
149
|
+
}
|
|
150
|
+
}, timeoutMs);
|
|
151
|
+
this.jobs.set(id, job);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
|
|
3
|
+
export function json(res, status, obj) {
|
|
4
|
+
const body = JSON.stringify(obj);
|
|
5
|
+
res.writeHead(status, {
|
|
6
|
+
"content-type": "application/json; charset=utf-8",
|
|
7
|
+
"content-length": Buffer.byteLength(body),
|
|
8
|
+
});
|
|
9
|
+
res.end(body);
|
|
10
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// RoForge CLI configuration.
|
|
2
|
+
// Precedence: env vars > ~/.roforge/config.json > defaults.
|
|
3
|
+
//
|
|
4
|
+
// Privacy: API keys live ONLY in this local config (or env). They are sent
|
|
5
|
+
// ONLY to the model provider. The Studio bridge and MCP traffic are local-only.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { randomToken } from "./util.js";
|
|
10
|
+
|
|
11
|
+
export const CONFIG_DIR = process.env.ROFORGE_CONFIG_DIR || path.join(os.homedir(), ".roforge");
|
|
12
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
13
|
+
|
|
14
|
+
// Provider registry. "auto" (default) picks the first configured provider,
|
|
15
|
+
// free-tier providers first (gemini → groq → openrouter → anthropic → openai).
|
|
16
|
+
export const PROVIDERS = {
|
|
17
|
+
gemini: {
|
|
18
|
+
label: "Google Gemini",
|
|
19
|
+
keyField: "geminiKey",
|
|
20
|
+
env: "GEMINI_API_KEY",
|
|
21
|
+
baseField: "geminiBaseUrl",
|
|
22
|
+
defaultModel: "gemini-2.5-flash",
|
|
23
|
+
freeModel: "gemini-2.5-flash", // free tier: ~1,500 req/day, no card
|
|
24
|
+
hasFreeTier: true,
|
|
25
|
+
},
|
|
26
|
+
groq: {
|
|
27
|
+
label: "Groq",
|
|
28
|
+
keyField: "groqKey",
|
|
29
|
+
env: "GROQ_API_KEY",
|
|
30
|
+
baseField: "groqBaseUrl",
|
|
31
|
+
defaultModel: "llama-3.3-70b-versatile",
|
|
32
|
+
freeModel: "llama-3.3-70b-versatile", // free tier: ~1,000 req/day per model
|
|
33
|
+
hasFreeTier: true,
|
|
34
|
+
},
|
|
35
|
+
openrouter: {
|
|
36
|
+
label: "OpenRouter",
|
|
37
|
+
keyField: "openrouterKey",
|
|
38
|
+
env: "OPENROUTER_API_KEY",
|
|
39
|
+
baseField: "openrouterBaseUrl",
|
|
40
|
+
defaultModel: "qwen/qwen3-coder",
|
|
41
|
+
freeModel: "qwen/qwen3-coder:free", // :free models: req/day limit, no billing
|
|
42
|
+
hasFreeTier: true,
|
|
43
|
+
},
|
|
44
|
+
anthropic: {
|
|
45
|
+
label: "Anthropic",
|
|
46
|
+
keyField: "anthropicKey",
|
|
47
|
+
env: "ANTHROPIC_API_KEY",
|
|
48
|
+
baseField: "anthropicBaseUrl",
|
|
49
|
+
defaultModel: "claude-sonnet-4-5",
|
|
50
|
+
freeModel: null,
|
|
51
|
+
hasFreeTier: false,
|
|
52
|
+
},
|
|
53
|
+
openai: {
|
|
54
|
+
label: "OpenAI",
|
|
55
|
+
keyField: "openaiKey",
|
|
56
|
+
env: "OPENAI_API_KEY",
|
|
57
|
+
baseField: "openaiBaseUrl",
|
|
58
|
+
defaultModel: "gpt-4.1",
|
|
59
|
+
freeModel: null,
|
|
60
|
+
hasFreeTier: false,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// auto-routing order. freeFirst (default) prefers the free tiers so the CLI
|
|
65
|
+
// works out of the box with a free AI Studio key.
|
|
66
|
+
const ORDER_FREE_FIRST = ["gemini", "groq", "openrouter", "anthropic", "openai"];
|
|
67
|
+
const ORDER_PAID_FIRST = ["anthropic", "openai", "gemini", "groq", "openrouter"];
|
|
68
|
+
|
|
69
|
+
const DEFAULTS = {
|
|
70
|
+
provider: "auto", // auto | gemini | groq | openrouter | anthropic | openai
|
|
71
|
+
model: "", // empty = provider default; "provider:model" pins a provider
|
|
72
|
+
freeFirst: true, // auto mode: prefer free-tier providers/models
|
|
73
|
+
geminiKey: "",
|
|
74
|
+
groqKey: "",
|
|
75
|
+
openrouterKey: "",
|
|
76
|
+
anthropicKey: "",
|
|
77
|
+
openaiKey: "",
|
|
78
|
+
geminiBaseUrl: "https://generativelanguage.googleapis.com",
|
|
79
|
+
groqBaseUrl: "https://api.groq.com/openai",
|
|
80
|
+
openrouterBaseUrl: "https://openrouter.ai/api",
|
|
81
|
+
anthropicBaseUrl: "https://api.anthropic.com",
|
|
82
|
+
openaiBaseUrl: "https://api.openai.com",
|
|
83
|
+
maxTokens: 8000,
|
|
84
|
+
maxIterations: 12,
|
|
85
|
+
// web search
|
|
86
|
+
searchProvider: "auto", // auto | serper | brave | wikipedia
|
|
87
|
+
serperKey: "",
|
|
88
|
+
braveKey: "",
|
|
89
|
+
// studio connection
|
|
90
|
+
studioMode: "auto", // auto | mcp | bridge
|
|
91
|
+
mcpUrl: "http://localhost:3004/mcp",
|
|
92
|
+
bridge: {
|
|
93
|
+
port: 8790,
|
|
94
|
+
host: "127.0.0.1",
|
|
95
|
+
token: "",
|
|
96
|
+
},
|
|
97
|
+
// approvals: "ask" (default) or "yolo"
|
|
98
|
+
approve: "ask",
|
|
99
|
+
// approximate pricing per 1M tokens (USD) for the cost display.
|
|
100
|
+
// Free-tier models are 0/0.
|
|
101
|
+
pricing: {
|
|
102
|
+
"claude-sonnet-4-5": { input: 3, output: 15 },
|
|
103
|
+
"claude-opus-4-5": { input: 5, output: 25 },
|
|
104
|
+
"claude-haiku-4-5": { input: 1, output: 5 },
|
|
105
|
+
"gpt-4.1": { input: 2, output: 8 },
|
|
106
|
+
"gpt-4o": { input: 2.5, output: 10 },
|
|
107
|
+
"gemini-2.5-flash": { input: 0, output: 0 },
|
|
108
|
+
"llama-3.3-70b-versatile": { input: 0, output: 0 },
|
|
109
|
+
"qwen/qwen3-coder:free": { input: 0, output: 0 },
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function loadFileConfig() {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
116
|
+
} catch {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function saveFileConfig(patch) {
|
|
122
|
+
const current = loadFileConfig();
|
|
123
|
+
const next = deepMerge(current, patch);
|
|
124
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
125
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2));
|
|
126
|
+
return next;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function deepMerge(a, b) {
|
|
130
|
+
const out = { ...a };
|
|
131
|
+
for (const [k, v] of Object.entries(b)) {
|
|
132
|
+
out[k] = v && typeof v === "object" && !Array.isArray(v) && a[k] && typeof a[k] === "object" ? deepMerge(a[k], v) : v;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolved, runtime config (env overrides applied, defaults filled).
|
|
138
|
+
export function resolveConfig() {
|
|
139
|
+
const file = loadFileConfig();
|
|
140
|
+
const cfg = deepMerge(DEFAULTS, file);
|
|
141
|
+
|
|
142
|
+
cfg.geminiKey = process.env.GEMINI_API_KEY || cfg.geminiKey || "";
|
|
143
|
+
cfg.groqKey = process.env.GROQ_API_KEY || cfg.groqKey || "";
|
|
144
|
+
cfg.openrouterKey = process.env.OPENROUTER_API_KEY || cfg.openrouterKey || "";
|
|
145
|
+
cfg.anthropicKey = process.env.ANTHROPIC_API_KEY || cfg.anthropicKey || "";
|
|
146
|
+
cfg.openaiKey = process.env.OPENAI_API_KEY || cfg.openaiKey || "";
|
|
147
|
+
cfg.model = process.env.ROFORGE_MODEL || cfg.model;
|
|
148
|
+
if (process.env.ROFORGE_PROVIDER) cfg.provider = process.env.ROFORGE_PROVIDER;
|
|
149
|
+
if (process.env.ROFORGE_MCP_URL) cfg.mcpUrl = process.env.ROFORGE_MCP_URL;
|
|
150
|
+
if (process.env.ROFORGE_BRIDGE_PORT) cfg.bridge.port = Number(process.env.ROFORGE_BRIDGE_PORT);
|
|
151
|
+
if (process.env.ROFORGE_STUDIO_MODE) cfg.studioMode = process.env.ROFORGE_STUDIO_MODE;
|
|
152
|
+
if (process.env.ROFORGE_MAX_ITERATIONS) cfg.maxIterations = Number(process.env.ROFORGE_MAX_ITERATIONS);
|
|
153
|
+
if (process.env.ROFORGE_FREE_FIRST === "0" || process.env.ROFORGE_FREE_FIRST === "false") cfg.freeFirst = false;
|
|
154
|
+
|
|
155
|
+
if (!cfg.bridge.token) {
|
|
156
|
+
cfg.bridge.token = randomToken(24);
|
|
157
|
+
// persist so the token survives restarts (the plugin stores it once)
|
|
158
|
+
try {
|
|
159
|
+
saveFileConfig({ bridge: { token: cfg.bridge.token } });
|
|
160
|
+
} catch {
|
|
161
|
+
/* non-fatal */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return cfg;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function providerMeta(name) {
|
|
168
|
+
return PROVIDERS[name] || null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function providerHasKey(cfg, name) {
|
|
172
|
+
const meta = PROVIDERS[name];
|
|
173
|
+
return Boolean(meta && cfg[meta.keyField]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The provider that will actually serve requests:
|
|
177
|
+
// an explicit (valid, key-held) cfg.provider wins; otherwise "auto" walks
|
|
178
|
+
// the configured providers free-tier-first (or paid-first when freeFirst=false).
|
|
179
|
+
export function effectiveProvider(cfg) {
|
|
180
|
+
if (cfg.provider && cfg.provider !== "auto") {
|
|
181
|
+
return PROVIDERS[cfg.provider] && providerHasKey(cfg, cfg.provider) ? cfg.provider : null;
|
|
182
|
+
}
|
|
183
|
+
const order = cfg.freeFirst === false ? ORDER_PAID_FIRST : ORDER_FREE_FIRST;
|
|
184
|
+
return order.find((p) => providerHasKey(cfg, p)) || null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// "gemini:gemini-2.5-flash" → {provider:"gemini", model:"gemini-2.5-flash"}.
|
|
188
|
+
// Models may contain ":" themselves (OpenRouter), so only the FIRST segment
|
|
189
|
+
// counts as a provider prefix.
|
|
190
|
+
export function parseModelRef(model) {
|
|
191
|
+
if (typeof model === "string" && model.includes(":")) {
|
|
192
|
+
const i = model.indexOf(":");
|
|
193
|
+
const p = model.slice(0, i);
|
|
194
|
+
const rest = model.slice(i + 1);
|
|
195
|
+
if (PROVIDERS[p] && rest) return { provider: p, model: rest };
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function modelFor(cfg) {
|
|
201
|
+
// an explicit model ref like "provider:model" pins both
|
|
202
|
+
const ref = parseModelRef(cfg.model);
|
|
203
|
+
if (ref && providerHasKey(cfg, ref.provider)) {
|
|
204
|
+
return ref.model;
|
|
205
|
+
}
|
|
206
|
+
const prov = ref ? null : effectiveProvider(cfg);
|
|
207
|
+
const meta = prov && PROVIDERS[prov];
|
|
208
|
+
if (!meta) return cfg.model || "claude-sonnet-4-5";
|
|
209
|
+
// auto mode + freeFirst → the provider's free model; explicit provider →
|
|
210
|
+
// its default model
|
|
211
|
+
const wantFree = cfg.provider === "auto" && cfg.freeFirst !== false;
|
|
212
|
+
return wantFree && meta.freeModel ? meta.freeModel : meta.defaultModel;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function apiKeyFor(cfg) {
|
|
216
|
+
const prov = effectiveProvider(cfg);
|
|
217
|
+
const meta = prov && PROVIDERS[prov];
|
|
218
|
+
return meta ? cfg[meta.keyField] || "" : "";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function estimateCost(cfg, usage) {
|
|
222
|
+
if (!usage) return null;
|
|
223
|
+
const p = cfg.pricing && cfg.pricing[modelFor(cfg)];
|
|
224
|
+
if (!p) return { tokens: usage, cost: null };
|
|
225
|
+
const cost = ((usage.input_tokens || 0) * p.input + (usage.output_tokens || 0) * p.output) / 1_000_000;
|
|
226
|
+
return { tokens: usage, cost };
|
|
227
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Minimal MCP (Model Context Protocol) client over the Streamable HTTP
|
|
2
|
+
// transport — used to talk to the MCP server built into Roblox Studio
|
|
3
|
+
// (File → Studio Settings → Beta Features → "MCP Server", default
|
|
4
|
+
// http://localhost:3004/mcp).
|
|
5
|
+
//
|
|
6
|
+
// Implements just enough of the protocol: initialize → tools/list → tools/call.
|
|
7
|
+
// Responses may arrive as a single JSON object or as an SSE stream; both handled.
|
|
8
|
+
import { parseSSEStream } from "./util.js";
|
|
9
|
+
|
|
10
|
+
export class McpError extends Error {}
|
|
11
|
+
|
|
12
|
+
export class McpClient {
|
|
13
|
+
constructor(url, { timeoutMs = 30000 } = {}) {
|
|
14
|
+
this.url = url;
|
|
15
|
+
this.timeoutMs = timeoutMs;
|
|
16
|
+
this.sessionId = null;
|
|
17
|
+
this.nextId = 1;
|
|
18
|
+
this.serverInfo = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async request(method, params) {
|
|
22
|
+
const id = this.nextId++;
|
|
23
|
+
const headers = {
|
|
24
|
+
"content-type": "application/json",
|
|
25
|
+
accept: "application/json, text/event-stream",
|
|
26
|
+
};
|
|
27
|
+
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
28
|
+
|
|
29
|
+
let res;
|
|
30
|
+
try {
|
|
31
|
+
res = await fetch(this.url, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers,
|
|
34
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
|
|
35
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
36
|
+
});
|
|
37
|
+
} catch (e) {
|
|
38
|
+
const reason = e.name === "TimeoutError" ? "timed out " : "";
|
|
39
|
+
throw new McpError(`Studio MCP unreachable at ${this.url} (${reason}${e.cause?.code || e.message})`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sid = res.headers.get("mcp-session-id");
|
|
43
|
+
if (sid) this.sessionId = sid;
|
|
44
|
+
const ctype = res.headers.get("content-type") || "";
|
|
45
|
+
const bodyText = await res.text();
|
|
46
|
+
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
let msg = bodyText.slice(0, 200);
|
|
49
|
+
try {
|
|
50
|
+
const j = JSON.parse(bodyText);
|
|
51
|
+
msg = (j.error && j.error.message) || msg;
|
|
52
|
+
} catch {
|
|
53
|
+
/* keep slice */
|
|
54
|
+
}
|
|
55
|
+
throw new McpError(`MCP HTTP ${res.status}: ${msg}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let payload = null;
|
|
59
|
+
if (ctype.includes("text/event-stream")) {
|
|
60
|
+
for (const ev of parseSSEStream(bodyText)) {
|
|
61
|
+
if (ev.event === "message" && ev.data && ev.data.id === id) {
|
|
62
|
+
payload = ev.data;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!payload) {
|
|
67
|
+
// some servers send the result in an unnamed message event
|
|
68
|
+
const first = parseSSEStream(bodyText).find((e) => e.data && typeof e.data === "object" && e.data.result);
|
|
69
|
+
if (first) payload = first.data;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
try {
|
|
73
|
+
payload = JSON.parse(bodyText);
|
|
74
|
+
} catch {
|
|
75
|
+
if (bodyText.trim()) throw new McpError(`MCP: unparseable response: ${bodyText.slice(0, 120)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!payload) throw new McpError(`MCP: no response for request ${id}`);
|
|
79
|
+
if (payload.error) throw new McpError(`MCP error ${payload.error.code}: ${payload.error.message}`);
|
|
80
|
+
return payload.result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async notify(method, params) {
|
|
84
|
+
const headers = {
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
accept: "application/json, text/event-stream",
|
|
87
|
+
};
|
|
88
|
+
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch(this.url, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers,
|
|
93
|
+
body: JSON.stringify({ jsonrpc: "2.0", method, params }),
|
|
94
|
+
signal: AbortSignal.timeout(10000),
|
|
95
|
+
});
|
|
96
|
+
await res.arrayBuffer(); // drain
|
|
97
|
+
} catch {
|
|
98
|
+
/* notifications are best-effort */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async connect() {
|
|
103
|
+
const result = await this.request("initialize", {
|
|
104
|
+
protocolVersion: "2025-03-26",
|
|
105
|
+
capabilities: {},
|
|
106
|
+
clientInfo: { name: "roforge-cli", version: "0.2.0" },
|
|
107
|
+
});
|
|
108
|
+
this.serverInfo = result.serverInfo || null;
|
|
109
|
+
await this.notify("notifications/initialized", {});
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async listTools() {
|
|
114
|
+
const result = await this.request("tools/list", {});
|
|
115
|
+
return (result && result.tools) || [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Returns { text, isError, image? } — image is {base64, mediaType} when the
|
|
119
|
+
// tool returned an MCP image content block (e.g. Studio's built-in
|
|
120
|
+
// screenshot/capture tools) or a resource blob.
|
|
121
|
+
async callTool(name, args) {
|
|
122
|
+
const result = await this.request("tools/call", { name, arguments: args || {} });
|
|
123
|
+
const content = (result && result.content) || [];
|
|
124
|
+
const texts = [];
|
|
125
|
+
let image = null;
|
|
126
|
+
for (const c of content) {
|
|
127
|
+
if (c.type === "text" && typeof c.text === "string") {
|
|
128
|
+
texts.push(c.text);
|
|
129
|
+
} else if (c.type === "image" && typeof c.data === "string" && c.data.length) {
|
|
130
|
+
image = image || { base64: c.data, mediaType: c.mimeType || "image/png" };
|
|
131
|
+
} else if (c.type === "resource" && c.resource) {
|
|
132
|
+
if (typeof c.resource.blob === "string" && c.resource.blob.length) {
|
|
133
|
+
image = image || { base64: c.resource.blob, mediaType: c.resource.mimeType || "image/png" };
|
|
134
|
+
}
|
|
135
|
+
if (typeof c.resource.text === "string") texts.push(c.resource.text);
|
|
136
|
+
} else {
|
|
137
|
+
texts.push(JSON.stringify(c));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { text: texts.join("\n") || "(no output)", isError: Boolean(result && result.isError), image: image || undefined };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Probe: is a Studio MCP server answering at url? Returns {ok, serverInfo, toolCount} or {ok:false, error}.
|
|
145
|
+
export async function probeMcp(url, { timeoutMs = 4000 } = {}) {
|
|
146
|
+
const client = new McpClient(url, { timeoutMs });
|
|
147
|
+
try {
|
|
148
|
+
const init = await client.connect();
|
|
149
|
+
let toolCount = -1;
|
|
150
|
+
try {
|
|
151
|
+
toolCount = (await client.listTools()).length;
|
|
152
|
+
} catch {
|
|
153
|
+
/* connected but tools/list failed — still ok */
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, serverInfo: init.serverInfo, toolCount };
|
|
156
|
+
} catch (e) {
|
|
157
|
+
return { ok: false, error: e.message };
|
|
158
|
+
}
|
|
159
|
+
}
|