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,161 @@
|
|
|
1
|
+
// Anthropic Messages API — streaming (SSE) with tool use.
|
|
2
|
+
// The API key goes directly to api.anthropic.com (or cfg.anthropicBaseUrl).
|
|
3
|
+
import { createSSE } from "../util.js";
|
|
4
|
+
|
|
5
|
+
export class ProviderError extends Error {}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* cfg: resolved config
|
|
9
|
+
* params: { system, messages (internal history), tools, signal }
|
|
10
|
+
* events: { onText(delta), onToolStart(name, id) }
|
|
11
|
+
* returns: { text, toolCalls: [{id, name, input}], stopReason, usage }
|
|
12
|
+
*/
|
|
13
|
+
export async function chatStream(cfg, params, events = {}) {
|
|
14
|
+
const key = cfg.anthropicKey;
|
|
15
|
+
if (!key) throw new ProviderError("No Anthropic API key. Run `roforge login` or set ANTHROPIC_API_KEY.");
|
|
16
|
+
const body = {
|
|
17
|
+
model: params.model,
|
|
18
|
+
max_tokens: params.maxTokens || cfg.maxTokens || 8000,
|
|
19
|
+
stream: true,
|
|
20
|
+
messages: renderHistory(params.messages),
|
|
21
|
+
};
|
|
22
|
+
if (params.system) body.system = params.system;
|
|
23
|
+
if (params.tools && params.tools.length) body.tools = renderTools(params.tools);
|
|
24
|
+
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(`${cfg.anthropicBaseUrl}/v1/messages`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
"x-api-key": key,
|
|
31
|
+
"anthropic-version": "2023-06-01",
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
accept: "text/event-stream",
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify(body),
|
|
36
|
+
signal: params.signal,
|
|
37
|
+
});
|
|
38
|
+
} catch (e) {
|
|
39
|
+
throw new ProviderError(`network error calling Anthropic: ${e.cause?.code || e.message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const text = await res.text().catch(() => "");
|
|
44
|
+
throw new ProviderError(`Anthropic HTTP ${res.status}: ${text.slice(0, 400)}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let text = "";
|
|
48
|
+
let stopReason = null;
|
|
49
|
+
let usage = { input_tokens: 0, output_tokens: 0 };
|
|
50
|
+
const toolCalls = [];
|
|
51
|
+
let currentTool = null;
|
|
52
|
+
|
|
53
|
+
const sse = createSSE((ev) => {
|
|
54
|
+
const d = ev.data;
|
|
55
|
+
if (typeof d !== "object" || !d) return;
|
|
56
|
+
switch (ev.event) {
|
|
57
|
+
case "message_start":
|
|
58
|
+
if (d.message && d.message.usage) usage = { ...usage, ...d.message.usage };
|
|
59
|
+
break;
|
|
60
|
+
case "content_block_start":
|
|
61
|
+
if (d.content_block && d.content_block.type === "tool_use") {
|
|
62
|
+
currentTool = { id: d.content_block.id, name: d.content_block.name, input: {}, _json: "" };
|
|
63
|
+
toolCalls.push(currentTool);
|
|
64
|
+
events.onToolStart && events.onToolStart(currentTool.name, currentTool.id);
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
case "content_block_delta": {
|
|
68
|
+
const delta = d.delta;
|
|
69
|
+
if (!delta) break;
|
|
70
|
+
if (delta.type === "text_delta") {
|
|
71
|
+
text += delta.text || "";
|
|
72
|
+
events.onText && events.onText(delta.text || "");
|
|
73
|
+
} else if (delta.type === "input_json_delta" && currentTool) {
|
|
74
|
+
currentTool._json += delta.partial_json || "";
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "content_block_stop":
|
|
79
|
+
if (currentTool) {
|
|
80
|
+
try {
|
|
81
|
+
currentTool.input = currentTool._json ? JSON.parse(currentTool._json) : {};
|
|
82
|
+
} catch {
|
|
83
|
+
currentTool.input = {};
|
|
84
|
+
}
|
|
85
|
+
delete currentTool._json;
|
|
86
|
+
currentTool = null;
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
case "message_delta":
|
|
90
|
+
if (d.delta && d.delta.stop_reason) stopReason = d.delta.stop_reason;
|
|
91
|
+
if (d.usage) usage = { ...usage, ...d.usage };
|
|
92
|
+
break;
|
|
93
|
+
default:
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const reader = res.body.getReader();
|
|
99
|
+
const decoder = new TextDecoder();
|
|
100
|
+
while (true) {
|
|
101
|
+
const { done, value } = await reader.read();
|
|
102
|
+
if (done) break;
|
|
103
|
+
sse.push(decoder.decode(value, { stream: true }));
|
|
104
|
+
}
|
|
105
|
+
sse.end();
|
|
106
|
+
|
|
107
|
+
return { text, toolCalls, stopReason, usage };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Internal history → Anthropic messages.
|
|
111
|
+
// items: {role:"user",text} | {role:"assistant",text?,calls?} | {role:"tool",id,name,result}
|
|
112
|
+
export function renderHistory(history) {
|
|
113
|
+
const msgs = [];
|
|
114
|
+
for (const item of history) {
|
|
115
|
+
if (item.role === "user") {
|
|
116
|
+
msgs.push({ role: "user", content: item.text });
|
|
117
|
+
} else if (item.role === "assistant") {
|
|
118
|
+
const blocks = [];
|
|
119
|
+
if (item.text) blocks.push({ type: "text", text: item.text });
|
|
120
|
+
if (item.calls) {
|
|
121
|
+
for (const c of item.calls) {
|
|
122
|
+
blocks.push({ type: "tool_use", id: c.id, name: c.name, input: c.args || c.input || {} });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (blocks.length) msgs.push({ role: "assistant", content: blocks });
|
|
126
|
+
} else if (item.role === "tool") {
|
|
127
|
+
// content: string, or [image block, text block] when the tool returned
|
|
128
|
+
// a vision capture (e.g. forge_viewport).
|
|
129
|
+
let content = item.result;
|
|
130
|
+
if (item.image && item.image.base64) {
|
|
131
|
+
content = [
|
|
132
|
+
{
|
|
133
|
+
type: "image",
|
|
134
|
+
source: {
|
|
135
|
+
type: "base64",
|
|
136
|
+
media_type: item.image.mediaType || "image/png",
|
|
137
|
+
data: item.image.base64,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{ type: "text", text: item.result },
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
const block = { type: "tool_result", tool_use_id: item.id, content };
|
|
144
|
+
const last = msgs[msgs.length - 1];
|
|
145
|
+
if (last && last.role === "user" && Array.isArray(last.content)) {
|
|
146
|
+
last.content.push(block);
|
|
147
|
+
} else {
|
|
148
|
+
msgs.push({ role: "user", content: [block] });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return msgs;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function renderTools(tools) {
|
|
156
|
+
return tools.map((t) => ({
|
|
157
|
+
name: t.name,
|
|
158
|
+
description: t.description,
|
|
159
|
+
input_schema: t.inputSchema || t.input_schema,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Google Gemini (Generative Language API) — streaming (SSE) with function
|
|
2
|
+
// calling and inline images. The API key goes directly to Google
|
|
3
|
+
// (generativelanguage.googleapis.com, or cfg.geminiBaseUrl for mocks).
|
|
4
|
+
//
|
|
5
|
+
// Free tier: gemini-2.5-flash & Flash-Lite at ~1,500 req/day, no card needed.
|
|
6
|
+
import { createSSE } from "../util.js";
|
|
7
|
+
import { ProviderError } from "./anthropic.js";
|
|
8
|
+
|
|
9
|
+
let callCounter = 0;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* cfg: resolved config
|
|
13
|
+
* params: { model, maxTokens, system, messages (internal history), tools, signal }
|
|
14
|
+
* events: { onText(delta), onToolStart(name, id) }
|
|
15
|
+
* returns: { text, toolCalls: [{id, name, input}], stopReason, usage }
|
|
16
|
+
*/
|
|
17
|
+
export async function chatStream(cfg, params, events = {}) {
|
|
18
|
+
const key = cfg.geminiKey;
|
|
19
|
+
if (!key) throw new ProviderError("No Gemini API key. Run `roforge login --provider gemini` or set GEMINI_API_KEY (free at aistudio.google.com).");
|
|
20
|
+
const base = (cfg.geminiBaseUrl || "https://generativelanguage.googleapis.com").replace(/\/$/, "");
|
|
21
|
+
const body = {
|
|
22
|
+
contents: renderHistory(params.messages),
|
|
23
|
+
generationConfig: { maxOutputTokens: params.maxTokens || cfg.maxTokens || 8000 },
|
|
24
|
+
};
|
|
25
|
+
if (params.system) body.systemInstruction = { parts: [{ text: params.system }] };
|
|
26
|
+
if (params.tools && params.tools.length) body.tools = [{ functionDeclarations: renderTools(params.tools) }];
|
|
27
|
+
|
|
28
|
+
let res;
|
|
29
|
+
try {
|
|
30
|
+
res = await fetch(`${base}/v1beta/models/${params.model}:streamGenerateContent?alt=sse`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
"x-goog-api-key": key,
|
|
34
|
+
"content-type": "application/json",
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
signal: params.signal,
|
|
38
|
+
});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
throw new ProviderError(`network error calling Gemini: ${e.cause?.code || e.message}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const text = await res.text().catch(() => "");
|
|
45
|
+
throw new ProviderError(`Gemini HTTP ${res.status}: ${text.slice(0, 400)}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let text = "";
|
|
49
|
+
let stopReason = null;
|
|
50
|
+
let usage = { input_tokens: 0, output_tokens: 0 };
|
|
51
|
+
const toolCalls = [];
|
|
52
|
+
const seenTools = new Set();
|
|
53
|
+
|
|
54
|
+
const sse = createSSE((ev) => {
|
|
55
|
+
const d = ev.data;
|
|
56
|
+
if (typeof d !== "object" || !d) return;
|
|
57
|
+
if (d.usageMetadata) {
|
|
58
|
+
usage.input_tokens = d.usageMetadata.promptTokenCount || usage.input_tokens;
|
|
59
|
+
usage.output_tokens = d.usageMetadata.candidatesTokenCount || usage.output_tokens;
|
|
60
|
+
}
|
|
61
|
+
const candidate = (d.candidates || [])[0];
|
|
62
|
+
if (!candidate) return;
|
|
63
|
+
if (candidate.finishReason) {
|
|
64
|
+
stopReason =
|
|
65
|
+
candidate.finishReason === "STOP" ? "end_turn" : candidate.finishReason === "MAX_TOKENS" ? "max_tokens" : candidate.finishReason.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
const parts = (candidate.content && candidate.content.parts) || [];
|
|
68
|
+
for (const part of parts) {
|
|
69
|
+
if (typeof part.text === "string" && part.text) {
|
|
70
|
+
text += part.text;
|
|
71
|
+
events.onText && events.onText(part.text);
|
|
72
|
+
} else if (part.functionCall && part.functionCall.name) {
|
|
73
|
+
const id = `call_gem_${++callCounter}_${toolCalls.length}`;
|
|
74
|
+
const rec = { id, name: part.functionCall.name, input: part.functionCall.args || {} };
|
|
75
|
+
if (!seenTools.has(id)) {
|
|
76
|
+
seenTools.add(id);
|
|
77
|
+
toolCalls.push(rec);
|
|
78
|
+
events.onToolStart && events.onToolStart(rec.name, rec.id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const reader = res.body.getReader();
|
|
85
|
+
const decoder = new TextDecoder();
|
|
86
|
+
while (true) {
|
|
87
|
+
const { done, value } = await reader.read();
|
|
88
|
+
if (done) break;
|
|
89
|
+
sse.push(decoder.decode(value, { stream: true }));
|
|
90
|
+
}
|
|
91
|
+
sse.end();
|
|
92
|
+
|
|
93
|
+
return { text, toolCalls, stopReason, usage };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Internal history → Gemini `contents` (role "user"/"model", parts arrays).
|
|
97
|
+
// Consecutive same-role turns are merged (Gemini requires alternation; our
|
|
98
|
+
// history has one assistant turn followed by N tool results).
|
|
99
|
+
export function renderHistory(history) {
|
|
100
|
+
const contents = [];
|
|
101
|
+
const push = (role, parts) => {
|
|
102
|
+
if (!parts.length) return;
|
|
103
|
+
const last = contents[contents.length - 1];
|
|
104
|
+
if (last && last.role === role) {
|
|
105
|
+
last.parts.push(...parts);
|
|
106
|
+
} else {
|
|
107
|
+
contents.push({ role, parts });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
for (const item of history) {
|
|
111
|
+
if (item.role === "user") {
|
|
112
|
+
push("user", [{ text: item.text }]);
|
|
113
|
+
} else if (item.role === "assistant") {
|
|
114
|
+
const parts = [];
|
|
115
|
+
if (item.text) parts.push({ text: item.text });
|
|
116
|
+
if (item.calls) {
|
|
117
|
+
for (const c of item.calls) {
|
|
118
|
+
parts.push({ functionCall: { name: c.name, args: c.args || c.input || {} } });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
push("model", parts);
|
|
122
|
+
} else if (item.role === "tool") {
|
|
123
|
+
const parts = [
|
|
124
|
+
{ functionResponse: { name: item.name, response: { result: item.result } } },
|
|
125
|
+
];
|
|
126
|
+
if (item.image && item.image.base64) {
|
|
127
|
+
parts.push({ inlineData: { mimeType: item.image.mediaType || "image/png", data: item.image.base64 } });
|
|
128
|
+
}
|
|
129
|
+
push("user", parts);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return contents;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function renderTools(tools) {
|
|
136
|
+
return tools.map((t) => ({
|
|
137
|
+
name: t.name,
|
|
138
|
+
description: t.description,
|
|
139
|
+
parameters: t.inputSchema || t.input_schema,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Groq — fast inference for open-weight models (Llama, Gemma, DeepSeek
|
|
2
|
+
// distills). OpenAI-compatible, so this reuses the OpenAI streaming client
|
|
3
|
+
// with provider-specific key/base. Free tier: ~1,000 req/day per model.
|
|
4
|
+
// The key goes directly to api.groq.com (or cfg.groqBaseUrl for mocks).
|
|
5
|
+
import { chatStream as openaiChatStream } from "./openai.js";
|
|
6
|
+
|
|
7
|
+
export function chatStream(cfg, params, events = {}) {
|
|
8
|
+
const mapped = {
|
|
9
|
+
...cfg,
|
|
10
|
+
openaiKey: cfg.groqKey,
|
|
11
|
+
openaiBaseUrl: cfg.groqBaseUrl || "https://api.groq.com/openai",
|
|
12
|
+
};
|
|
13
|
+
return openaiChatStream(mapped, { ...params, model: params.model }, events);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { renderHistory, renderTools } from "./openai.js";
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// OpenAI Chat Completions — streaming (SSE) with function calling.
|
|
2
|
+
// The API key goes directly to api.openai.com (or cfg.openaiBaseUrl).
|
|
3
|
+
import { createSSE } from "../util.js";
|
|
4
|
+
import { ProviderError } from "./anthropic.js";
|
|
5
|
+
|
|
6
|
+
export async function chatStream(cfg, params, events = {}) {
|
|
7
|
+
const key = cfg.openaiKey;
|
|
8
|
+
if (!key) throw new ProviderError("No OpenAI API key. Run `roforge login` or set OPENAI_API_KEY.");
|
|
9
|
+
const msgs = renderHistory(params.messages);
|
|
10
|
+
if (params.system) msgs.unshift({ role: "system", content: params.system });
|
|
11
|
+
const body = {
|
|
12
|
+
model: params.model,
|
|
13
|
+
stream: true,
|
|
14
|
+
messages: msgs,
|
|
15
|
+
};
|
|
16
|
+
if (params.tools && params.tools.length) body.tools = renderTools(params.tools);
|
|
17
|
+
|
|
18
|
+
let res;
|
|
19
|
+
try {
|
|
20
|
+
res = await fetch(`${cfg.openaiBaseUrl}/v1/chat/completions`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${key}`,
|
|
24
|
+
"content-type": "application/json",
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
signal: params.signal,
|
|
28
|
+
});
|
|
29
|
+
} catch (e) {
|
|
30
|
+
throw new ProviderError(`network error calling OpenAI: ${e.cause?.code || e.message}`);
|
|
31
|
+
}
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const text = await res.text().catch(() => "");
|
|
34
|
+
throw new ProviderError(`OpenAI HTTP ${res.status}: ${text.slice(0, 400)}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let text = "";
|
|
38
|
+
let stopReason = null;
|
|
39
|
+
let usage = { input_tokens: 0, output_tokens: 0 };
|
|
40
|
+
const toolCalls = [];
|
|
41
|
+
|
|
42
|
+
const sse = createSSE((ev) => {
|
|
43
|
+
const d = ev.data;
|
|
44
|
+
if (d === "[DONE]") return;
|
|
45
|
+
if (typeof d !== "object" || !d) return;
|
|
46
|
+
if (d.usage) usage = { ...usage, ...d.usage };
|
|
47
|
+
const choice = (d.choices || [])[0];
|
|
48
|
+
if (!choice) return;
|
|
49
|
+
const delta = choice.delta || {};
|
|
50
|
+
if (choice.finish_reason) stopReason = choice.finish_reason;
|
|
51
|
+
if (typeof delta.content === "string" && delta.content) {
|
|
52
|
+
text += delta.content;
|
|
53
|
+
events.onText && events.onText(delta.content);
|
|
54
|
+
}
|
|
55
|
+
if (delta.tool_calls) {
|
|
56
|
+
for (const tc of delta.tool_calls) {
|
|
57
|
+
const idx = tc.index ?? 0;
|
|
58
|
+
if (!toolCalls[idx]) toolCalls[idx] = { id: "", name: "", input: {}, _json: "" };
|
|
59
|
+
const rec = toolCalls[idx];
|
|
60
|
+
if (tc.id) {
|
|
61
|
+
if (!rec.id) events.onToolStart && events.onToolStart(tc.function?.name || "tool", tc.id);
|
|
62
|
+
rec.id = tc.id;
|
|
63
|
+
}
|
|
64
|
+
if (tc.function && tc.function.name) rec.name = tc.function.name;
|
|
65
|
+
if (tc.function && tc.function.arguments) rec._json += tc.function.arguments;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const reader = res.body.getReader();
|
|
71
|
+
const decoder = new TextDecoder();
|
|
72
|
+
while (true) {
|
|
73
|
+
const { done, value } = await reader.read();
|
|
74
|
+
if (done) break;
|
|
75
|
+
sse.push(decoder.decode(value, { stream: true }));
|
|
76
|
+
}
|
|
77
|
+
sse.end();
|
|
78
|
+
|
|
79
|
+
const finalCalls = toolCalls
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.map((rec) => {
|
|
82
|
+
try {
|
|
83
|
+
rec.input = rec._json ? JSON.parse(rec._json) : {};
|
|
84
|
+
} catch {
|
|
85
|
+
rec.input = {};
|
|
86
|
+
}
|
|
87
|
+
delete rec._json;
|
|
88
|
+
return { id: rec.id, name: rec.name, input: rec.input };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const normReason = stopReason === "tool_calls" ? "tool_use" : stopReason === "stop" ? "end_turn" : stopReason;
|
|
92
|
+
return { text, toolCalls: finalCalls, stopReason: normReason, usage };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function renderHistory(history) {
|
|
96
|
+
const msgs = [];
|
|
97
|
+
for (const item of history) {
|
|
98
|
+
if (item.role === "user") {
|
|
99
|
+
msgs.push({ role: "user", content: item.text });
|
|
100
|
+
} else if (item.role === "assistant") {
|
|
101
|
+
const msg = { role: "assistant", content: item.text || "" };
|
|
102
|
+
if (item.calls && item.calls.length) {
|
|
103
|
+
msg.tool_calls = item.calls.map((c) => ({
|
|
104
|
+
id: c.id,
|
|
105
|
+
type: "function",
|
|
106
|
+
function: { name: c.name, arguments: JSON.stringify(c.args || c.input || {}) },
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
msgs.push(msg);
|
|
110
|
+
} else if (item.role === "tool") {
|
|
111
|
+
msgs.push({ role: "tool", tool_call_id: item.id, content: item.result });
|
|
112
|
+
// OpenAI has no image in tool messages — attach a follow-up user
|
|
113
|
+
// message with an image_url data URI when the tool returned an image.
|
|
114
|
+
if (item.image && item.image.base64) {
|
|
115
|
+
const uri = `data:${item.image.mediaType || "image/png"};base64,${item.image.base64}`;
|
|
116
|
+
msgs.push({
|
|
117
|
+
role: "user",
|
|
118
|
+
content: [
|
|
119
|
+
{ type: "text", text: `Image captured by tool ${item.name} (rendered from the Studio viewport):` },
|
|
120
|
+
{ type: "image_url", image_url: { url: uri } },
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return msgs;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function renderTools(tools) {
|
|
130
|
+
return tools.map((t) => ({
|
|
131
|
+
type: "function",
|
|
132
|
+
function: {
|
|
133
|
+
name: t.name,
|
|
134
|
+
description: t.description,
|
|
135
|
+
parameters: t.inputSchema || t.input_schema,
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// OpenRouter — one key, hundreds of models. OpenAI-compatible chat
|
|
2
|
+
// completions, so this reuses the OpenAI streaming client with
|
|
3
|
+
// provider-specific key/base. Free models use the ":free" suffix
|
|
4
|
+
// (request-per-day limits, no billing). The key goes directly to
|
|
5
|
+
// openrouter.ai (or cfg.openrouterBaseUrl for mocks).
|
|
6
|
+
import { chatStream as openaiChatStream } from "./openai.js";
|
|
7
|
+
|
|
8
|
+
export function chatStream(cfg, params, events = {}) {
|
|
9
|
+
const mapped = {
|
|
10
|
+
...cfg,
|
|
11
|
+
openaiKey: cfg.openrouterKey,
|
|
12
|
+
openaiBaseUrl: cfg.openrouterBaseUrl || "https://openrouter.ai/api",
|
|
13
|
+
};
|
|
14
|
+
return openaiChatStream(mapped, { ...params, model: params.model }, events);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { renderHistory, renderTools } from "./openai.js";
|
package/src/session.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Session: wires config + provider + tools + UI events into a working agent.
|
|
2
|
+
// Used by both the interactive TUI and the one-shot `roforge chat` mode.
|
|
3
|
+
import { buildTools } from "./tools/index.js";
|
|
4
|
+
import { runAgent } from "./agent.js";
|
|
5
|
+
import * as Anthropic from "./providers/anthropic.js";
|
|
6
|
+
import * as OpenAI from "./providers/openai.js";
|
|
7
|
+
import * as Gemini from "./providers/gemini.js";
|
|
8
|
+
import * as Groq from "./providers/groq.js";
|
|
9
|
+
import * as OpenRouter from "./providers/openrouter.js";
|
|
10
|
+
import { modelFor, estimateCost, effectiveProvider, PROVIDERS } from "./config.js";
|
|
11
|
+
|
|
12
|
+
const PROVIDER_MODULES = { anthropic: Anthropic, openai: OpenAI, gemini: Gemini, groq: Groq, openrouter: OpenRouter };
|
|
13
|
+
|
|
14
|
+
export class Session {
|
|
15
|
+
constructor({ cfg, cwd, bridgeServer, luauAnalyzePath, ui }) {
|
|
16
|
+
this.cfg = cfg;
|
|
17
|
+
this.cwd = cwd;
|
|
18
|
+
this.bridgeServer = bridgeServer;
|
|
19
|
+
this.luauAnalyzePath = luauAnalyzePath;
|
|
20
|
+
this.ui = ui; // { onText, onToolStart, onToolEnd, onInfo, onWarn, onStatus, promptApproval }
|
|
21
|
+
this.history = [];
|
|
22
|
+
this.tools = [];
|
|
23
|
+
this.studioInfo = { mcp: false, bridge: false, mcpToolCount: 0 };
|
|
24
|
+
this.aborted = false;
|
|
25
|
+
this._controller = null;
|
|
26
|
+
this.turns = 0;
|
|
27
|
+
this.totalUsage = { input_tokens: 0, output_tokens: 0 };
|
|
28
|
+
this._alwaysApprove = new Set();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get providerName() {
|
|
32
|
+
return effectiveProvider(this.cfg) || this.cfg.provider || "auto";
|
|
33
|
+
}
|
|
34
|
+
get provider() {
|
|
35
|
+
return PROVIDER_MODULES[this.providerName] || Anthropic;
|
|
36
|
+
}
|
|
37
|
+
get model() {
|
|
38
|
+
return this.cfg._activeModel || modelFor(this.cfg);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async init() {
|
|
42
|
+
const info = await buildTools({
|
|
43
|
+
cfg: this.cfg,
|
|
44
|
+
cwd: this.cwd,
|
|
45
|
+
bridgeServer: this.bridgeServer,
|
|
46
|
+
luauAnalyzePath: this.luauAnalyzePath,
|
|
47
|
+
});
|
|
48
|
+
this.tools = info.tools;
|
|
49
|
+
this.studioInfo = { mcp: info.mcp, bridge: info.bridge, mcpToolCount: info.mcpToolCount, mcpCapture: info.mcpCapture || [] };
|
|
50
|
+
this.cfg._activeModel = this.model;
|
|
51
|
+
return this.studioInfo;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
systemPrompt() {
|
|
55
|
+
const studio = [];
|
|
56
|
+
if (this.studioInfo.mcp) studio.push(`Studio's built-in MCP server is connected (${this.studioInfo.mcpToolCount} tools, prefix "studio_").`);
|
|
57
|
+
if (this.studioInfo.bridge) studio.push("The RoForge Bridge plugin is available (forge_* tools) — it connects when Studio is open and the bridge plugin is active.");
|
|
58
|
+
const caps = (this.studioInfo.mcpCapture || []).map((n) => `studio_${n}`).join(", ");
|
|
59
|
+
if (caps) studio.push(`Studio's MCP exposes vision tools (${caps}) — they return an image you can SEE; prefer them for visual checks.`);
|
|
60
|
+
if (!studio.length) studio.push("No Studio connection yet — forge_* tools will error until Studio is open with the RoForge Bridge plugin (or enable Studio's built-in MCP beta).");
|
|
61
|
+
|
|
62
|
+
return `You are RoForge, a local AI agent for Roblox development, running on the user's machine (Claude-Code-style). You work on two surfaces:
|
|
63
|
+
|
|
64
|
+
1. PROJECT FILES (on disk): the user's Rojo project in ${this.cwd}. Edit .lua/.json/.md files with project_read / project_edit / project_write, discover with project_tree / project_search, and run builds/checks with project_run (e.g. "rojo build -o dist/RoForge.rbxm"). This is the primary workflow for real development.
|
|
65
|
+
2. LIVE STUDIO (when connected): inspect and modify the open place with forge_* tools (tree, read/write scripts, run Luau, screenshot).
|
|
66
|
+
|
|
67
|
+
Rules:
|
|
68
|
+
- Prefer inspecting before changing: project_tree / project_read / forge_read first.
|
|
69
|
+
- When editing code, read the current content, then write the COMPLETE new file (project_write) or use project_edit for precise, unique replacements.
|
|
70
|
+
- After project_write / project_edit, verify: re-read the file or run the analyzer (luau_analyze) / build (project_run) when that makes sense.
|
|
71
|
+
- If a tool returns ERROR, read the message and adapt. Never repeat the exact same failing call.
|
|
72
|
+
- Roblox specifics: current Luau (task.*, string methods, continue), current Roblox APIs, ServerScriptService vs ReplicatedStorage scoping, Rojo conventions.
|
|
73
|
+
- Be concise. Show code only when the user asks or right after you wrote it.
|
|
74
|
+
- You are local: no telemetry, no backend. Only the model provider sees your prompts.
|
|
75
|
+
|
|
76
|
+
Current state:
|
|
77
|
+
- Model: ${this.model} (${this.providerName}${PROVIDERS[this.providerName] && PROVIDERS[this.providerName].hasFreeTier && this.cfg.freeFirst !== false ? ", free tier" : ""})
|
|
78
|
+
- ${studio.join(" ")}
|
|
79
|
+
- Tools: ${this.tools.map((t) => t.name).join(", ")}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async send(userText) {
|
|
83
|
+
this.turns++;
|
|
84
|
+
this.aborted = false;
|
|
85
|
+
this._controller = new AbortController();
|
|
86
|
+
this.history.push({ role: "user", text: userText });
|
|
87
|
+
|
|
88
|
+
const usageBefore = { ...this.totalUsage };
|
|
89
|
+
const out = await runAgent(
|
|
90
|
+
this.cfg,
|
|
91
|
+
this.provider,
|
|
92
|
+
this.history,
|
|
93
|
+
this.systemPrompt(),
|
|
94
|
+
this.tools,
|
|
95
|
+
{
|
|
96
|
+
onText: (d) => this.ui.onText && this.ui.onText(d),
|
|
97
|
+
onAssistantDone: (text) => this.ui.onAssistantDone && this.ui.onAssistantDone(text),
|
|
98
|
+
onToolStart: (tool, args) => this.ui.onToolStart && this.ui.onToolStart(tool, args),
|
|
99
|
+
onToolEnd: (tool, args, result) => this.ui.onToolEnd && this.ui.onToolEnd(tool, args, result),
|
|
100
|
+
onIter: (n, total) => this.ui.onStatus && this.ui.onStatus(`thinking… (step ${n}/${total})`),
|
|
101
|
+
onUsage: (u) => {
|
|
102
|
+
this.totalUsage = u;
|
|
103
|
+
},
|
|
104
|
+
onDone: () => this.ui.onStatus && this.ui.onStatus("done"),
|
|
105
|
+
onAborted: () => this.ui.onInfo && this.ui.onInfo("aborted"),
|
|
106
|
+
onError: (msg) => this.ui.onWarn && this.ui.onWarn(msg),
|
|
107
|
+
shouldAbort: () => this.aborted,
|
|
108
|
+
abortSignal: this._controller.signal,
|
|
109
|
+
approve: (name, args) => {
|
|
110
|
+
if (this.cfg.approve === "yolo" || this._alwaysApprove.has(name)) return Promise.resolve(true);
|
|
111
|
+
if (this.ui.promptApproval) {
|
|
112
|
+
return this.ui.promptApproval(name, args).then((ok) => {
|
|
113
|
+
if (ok === "always") this.alwaysApprove(name);
|
|
114
|
+
return ok === true || ok === "always";
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return Promise.resolve(true); // non-interactive fallback
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (out.ok) {
|
|
123
|
+
const est = estimateCost(this.cfg, this.totalUsage);
|
|
124
|
+
if (this.ui.onStatus) {
|
|
125
|
+
const delta = `↑${(this.totalUsage.input_tokens - usageBefore.input_tokens).toLocaleString()} ↓${(this.totalUsage.output_tokens - usageBefore.output_tokens).toLocaleString()} tok`;
|
|
126
|
+
this.ui.onStatus(`${delta}${est && est.cost != null ? ` ≈ $${est.cost.toFixed(4)}` : ""}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this._controller = null;
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
abort() {
|
|
134
|
+
this.aborted = true;
|
|
135
|
+
if (this._controller) this._controller.abort();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
clear() {
|
|
139
|
+
this.history = [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Render the conversation as a Markdown transcript (for /save).
|
|
143
|
+
transcript() {
|
|
144
|
+
const lines = [
|
|
145
|
+
"# RoForge session transcript",
|
|
146
|
+
"",
|
|
147
|
+
`- Model: ${this.model} (${this.providerName})`,
|
|
148
|
+
`- Project: ${this.cwd}`,
|
|
149
|
+
`- Date: ${new Date().toISOString()}`,
|
|
150
|
+
`- Turns: ${this.turns}`,
|
|
151
|
+
`- Tokens: ↑${this.totalUsage.input_tokens} ↓${this.totalUsage.output_tokens}`,
|
|
152
|
+
"",
|
|
153
|
+
"---",
|
|
154
|
+
"",
|
|
155
|
+
];
|
|
156
|
+
for (const item of this.history) {
|
|
157
|
+
if (item.role === "user") {
|
|
158
|
+
lines.push("## You", "", item.text, "");
|
|
159
|
+
} else if (item.role === "assistant") {
|
|
160
|
+
if (item.text) lines.push("## RoForge", "", item.text, "");
|
|
161
|
+
if (item.calls && item.calls.length) {
|
|
162
|
+
lines.push("### Tool calls");
|
|
163
|
+
for (const c of item.calls) {
|
|
164
|
+
let args = "{}";
|
|
165
|
+
try {
|
|
166
|
+
args = JSON.stringify(c.args || c.input || {});
|
|
167
|
+
} catch {
|
|
168
|
+
/* keep "{}" */
|
|
169
|
+
}
|
|
170
|
+
lines.push(`- ${c.name} \`${args}\``);
|
|
171
|
+
}
|
|
172
|
+
lines.push("");
|
|
173
|
+
}
|
|
174
|
+
} else if (item.role === "tool") {
|
|
175
|
+
let result = String(item.result ?? "");
|
|
176
|
+
if (result.length > 4000) {
|
|
177
|
+
result = result.slice(0, 4000) + `\n… [truncated ${result.length - 4000} chars]`;
|
|
178
|
+
}
|
|
179
|
+
lines.push(`### Tool result — ${item.name}`);
|
|
180
|
+
if (item.image) {
|
|
181
|
+
lines.push(`[image: ${item.image.mediaType || "image/png"}, ${item.image.base64.length} base64 chars — not inlined]`);
|
|
182
|
+
}
|
|
183
|
+
lines.push("```", result, "```", "");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
alwaysApprove(name) {
|
|
190
|
+
this._alwaysApprove.add(name);
|
|
191
|
+
}
|
|
192
|
+
}
|