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 ADDED
@@ -0,0 +1,58 @@
1
+ # roforge-cli
2
+
3
+ **RoForge** — a Claude-Code-style local AI agent for Roblox Studio. Bring your
4
+ own API key, zero backend, zero npm dependencies.
5
+
6
+ ```
7
+ roforge interactive TUI (auto-connects to Studio)
8
+ roforge chat -m "prompt" one-shot mode (streams to stdout)
9
+ roforge providers list providers, keys, and free-routing order
10
+ roforge login --provider <p> store an API key
11
+ roforge analyze <file...> official Luau analyzer
12
+ ```
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ # GitHub Packages (published — any GitHub token with read:packages works)
18
+ npm config set @hacvilke:registry https://npm.pkg.github.com
19
+ echo "//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN" >> ~/.npmrc
20
+ npm i -g @hacvilke/roforge-cli
21
+
22
+ # …or clone the repo and run `node cli/bin/roforge.js`
23
+ roforge
24
+ ```
25
+
26
+ Requires Node ≥ 18.17. No other dependencies.
27
+
28
+ ## Free tiers (no card needed)
29
+
30
+ `roforge providers` shows what you have. With `provider: "auto"` (default) the
31
+ CLI routes to the best free model you have a key for:
32
+
33
+ - **Gemini** — `gemini-2.5-flash` (~1,500 req/day free) → aistudio.google.com
34
+ - **Groq** — `llama-3.3-70b-versatile` (~1,000 req/day free) → console.groq.com
35
+ - **OpenRouter** — `:free` models (e.g. `qwen/qwen3-coder:free`) → openrouter.ai
36
+ - Anthropic / OpenAI — paid, highest priority when free tiers are exhausted
37
+
38
+ ## Connecting to Studio
39
+
40
+ 1. **Built-in MCP (recommended)**: Studio → File → Studio Settings → Beta
41
+ Features → MCP Server, pick a port. `roforge` probes `http://127.0.0.1:8998`
42
+ (configurable via `ROFORGE_MCP_URL`).
43
+ 2. **RoForge Bridge plugin** (our fallback): install from the repo
44
+ (`studio-bridge/`) — see the full README in the project repo.
45
+
46
+ With Studio connected, the agent can read the DataModel, read/write scripts,
47
+ create/delete instances, run Luau, **see the viewport** (vision), set
48
+ checkpoints and **undo its own edits**, find/bulk-create instances, and diff
49
+ the hierarchy.
50
+
51
+ ## Where
52
+
53
+ Full docs live in the project repo — `README.md`, `docs/CLI.md`,
54
+ `docs/BRIDGE.md`, `PROGRESS.md`.
55
+
56
+ > **Publishing:** before `npm publish`, set a real `repository`/`homepage`/`bugs`
57
+ > URL in `cli/package.json` (left empty on purpose so no placeholder is
58
+ > shipped).
package/bin/roforge.js ADDED
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env node
2
+ // RoForge — local Claude-Code-style agent for Roblox Studio.
3
+ // Zero dependencies, zero backend. Your key goes only to the model provider.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+ import { resolveConfig, apiKeyFor, modelFor, CONFIG_FILE, saveFileConfig, PROVIDERS, providerHasKey, effectiveProvider } from "../src/config.js";
8
+ import { Session } from "../src/session.js";
9
+ import { BridgeServer } from "../src/bridge/server.js";
10
+ import { probeMcp } from "../src/mcp.js";
11
+ import { TUI } from "../src/tui/tui.js";
12
+ import { bold, dim, red, green, cyan, yellow, gray } from "../src/tui/ansi.js";
13
+
14
+ const argv = process.argv.slice(2);
15
+ const command = argv[0] || "tui";
16
+ const flags = parseFlags(argv.slice(1));
17
+
18
+ function parseFlags(args) {
19
+ const flags = {};
20
+ for (let i = 0; i < args.length; i++) {
21
+ const a = args[i];
22
+ if (a.startsWith("--")) {
23
+ const key = a.slice(2);
24
+ const next = args[i + 1];
25
+ if (next !== undefined && !next.startsWith("--")) {
26
+ flags[key] = next;
27
+ i++;
28
+ } else {
29
+ flags[key] = true;
30
+ }
31
+ } else if (a.startsWith("-") && a.length === 2) {
32
+ const next = args[i + 1];
33
+ if (next !== undefined && !next.startsWith("-")) {
34
+ flags[a.slice(1)] = next;
35
+ i++;
36
+ } else {
37
+ flags[a.slice(1)] = true;
38
+ }
39
+ } else {
40
+ flags._pos = (flags._pos || []).concat(a);
41
+ }
42
+ }
43
+ return flags;
44
+ }
45
+
46
+ function findLuauAnalyze() {
47
+ const candidates = [
48
+ process.env.ROFORGE_LUAU_ANALYZE,
49
+ "luau-analyze",
50
+ path.resolve(process.cwd(), "luau-analyze"),
51
+ ].filter(Boolean);
52
+ for (const c of candidates) {
53
+ try {
54
+ execFileSync(c, ["--help"], { stdio: "ignore" });
55
+ return c;
56
+ } catch {
57
+ /* keep looking */
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ async function main() {
64
+ const cfg = resolveConfig();
65
+ if (flags.provider && PROVIDERS[flags.provider]) cfg.provider = flags.provider;
66
+ cfg._apiKeyPresent = Boolean(apiKeyFor(cfg));
67
+
68
+ switch (command) {
69
+ case "version":
70
+ case "--version":
71
+ case "-v": {
72
+ const pkg = JSON.parse(fs.readFileSync(path.resolve(path.dirname(process.argv[1]), "../package.json"), "utf8"));
73
+ console.log(`roforge ${pkg.version}`);
74
+ return;
75
+ }
76
+
77
+ case "login": {
78
+ const provider =
79
+ flags.provider || (cfg.provider !== "auto" && PROVIDERS[cfg.provider] ? cfg.provider : null);
80
+ if (!provider) {
81
+ console.error(red("usage: roforge login --provider <gemini|groq|openrouter|anthropic|openai>"));
82
+ process.exit(1);
83
+ }
84
+ const promptKey = async (label) => {
85
+ process.stderr.write(`Paste ${label} (input hidden): `);
86
+ let val = "";
87
+ if (process.stdin.isTTY) {
88
+ process.stdin.setRawMode(true);
89
+ for await (const chunk of process.stdin) {
90
+ for (const ch of String(chunk)) {
91
+ if (ch === "\r" || ch === "\n" || ch === "\x04") {
92
+ process.stdin.setRawMode(false);
93
+ console.error("");
94
+ return val;
95
+ }
96
+ if (ch === "\x7f" || ch === "\b") val = val.slice(0, -1);
97
+ else if (ch >= " " || ch === "\t") val += ch;
98
+ }
99
+ }
100
+ } else {
101
+ for await (const chunk of process.stdin) val += String(chunk);
102
+ return val.trim();
103
+ }
104
+ };
105
+ const hint = {
106
+ gemini: "Gemini API key (free at aistudio.google.com)",
107
+ groq: "Groq API key (console.groq.com)",
108
+ openrouter: "OpenRouter API key (openrouter.ai)",
109
+ anthropic: "Anthropic API key",
110
+ openai: "OpenAI API key",
111
+ }[provider];
112
+ const val = await promptKey(hint);
113
+ if (!val) {
114
+ console.error(red("no key entered"));
115
+ process.exit(1);
116
+ }
117
+ saveFileConfig({ [PROVIDERS[provider].keyField]: val });
118
+ console.error(green(`saved ${provider} key to ${CONFIG_FILE}`) + dim(" (0600 perms recommended)"));
119
+ try {
120
+ fs.chmodSync(CONFIG_FILE, 0o600);
121
+ } catch {
122
+ /* windows */
123
+ }
124
+ return;
125
+ }
126
+
127
+ case "studio": {
128
+ const bridge = new BridgeServer({ port: cfg.bridge.port, host: cfg.bridge.host, token: cfg.bridge.token });
129
+ try {
130
+ await bridge.start();
131
+ } catch (e) {
132
+ console.error(red(`bridge could not start on port ${cfg.bridge.port}: ${e.message}`));
133
+ process.exit(1);
134
+ }
135
+ const mcp = await probeMcp(cfg.mcpUrl);
136
+ console.log(bold("RoForge — Studio connection") + "\n");
137
+ if (mcp.ok) {
138
+ console.log(`${green("●")} studio MCP (built into Studio): ${mcp.toolCount} tools @ ${cfg.mcpUrl}`);
139
+ } else {
140
+ console.log(`${red("○")} studio MCP: not reachable @ ${cfg.mcpUrl}`);
141
+ console.log(dim(" enable: Roblox Studio → File → Studio Settings → Beta Features → 'MCP Server'"));
142
+ }
143
+ console.log(`${yellow("○")} bridge plugin: waiting for Studio @ http://${bridge.host}:${bridge.port}`);
144
+ console.log(dim(` bridge token: ${cfg.bridge.token}`));
145
+ console.log(dim(" (paste the token into the RoForge Bridge plugin's settings in Studio)"));
146
+ console.log(dim("\nthis process stays running as the bridge — Ctrl+C to stop"));
147
+ const stop = () => {
148
+ bridge.stop();
149
+ process.exit(0);
150
+ };
151
+ process.on("SIGINT", stop);
152
+ process.on("SIGTERM", stop);
153
+ return;
154
+ }
155
+
156
+ case "tools": {
157
+ const bridge = new BridgeServer({ port: cfg.bridge.port, host: cfg.bridge.host, token: cfg.bridge.token });
158
+ await bridge.start();
159
+ const session = new Session({ cfg, cwd: process.cwd(), bridgeServer: bridge, luauAnalyzePath: null, ui: {} });
160
+ await session.init();
161
+ console.log(bold(`tools (${session.tools.length})`));
162
+ for (const t of session.tools) {
163
+ const tier = t.tier ? gray(` [${t.tier}]`) : "";
164
+ const appr = t.requiresApproval ? gray(" (approve)") : "";
165
+ console.log(` ${cyan(t.name)}${tier}${appr} — ${dim(t.description.split(".")[0])}`);
166
+ }
167
+ bridge.stop();
168
+ return;
169
+ }
170
+
171
+ case "chat": {
172
+ const prompt = flags.m || flags.message;
173
+ if (!prompt) {
174
+ console.error(red('usage: roforge chat -m "your prompt" (or just run `roforge` for the TUI)'));
175
+ process.exit(1);
176
+ }
177
+ await oneShot(cfg, prompt);
178
+ return;
179
+ }
180
+
181
+ case "analyze": {
182
+ const files = flags._pos || [];
183
+ if (!files.length) {
184
+ console.error(red("usage: roforge analyze <file.lua ...>"));
185
+ process.exit(1);
186
+ }
187
+ const luau = findLuauAnalyze();
188
+ if (!luau) {
189
+ console.error(red("luau-analyze not found. Install it (https://github.com/luau-lang/luau/releases) or set ROFORGE_LUAU_ANALYZE."));
190
+ process.exit(1);
191
+ }
192
+ for (const f of files) {
193
+ let stdout = "";
194
+ let failed = false;
195
+ try {
196
+ stdout = execFileSync(luau, [f], { stdio: "pipe" }).toString();
197
+ } catch (e) {
198
+ // luau-analyze reports on stderr
199
+ const buf = (e.stderr && e.stderr.length ? e.stderr : e.stdout) || Buffer.alloc(0);
200
+ stdout = buf.toString();
201
+ failed = true;
202
+ }
203
+ // The standalone analyzer doesn't know Roblox's built-in globals
204
+ // (game, script, Instance, task, …). Those are expected noise; only
205
+ // real problems (syntax, type, undefined locals) should fail.
206
+ const issues = stdout
207
+ .split("\n")
208
+ .map((l) => l.trim())
209
+ .filter(Boolean)
210
+ .filter((l) => !/Unknown global|consider assigning to it first/i.test(l));
211
+ if (failed && issues.length) {
212
+ console.log(`${red("FAIL")} ${f}\n${stdout}`);
213
+ process.exitCode = 1;
214
+ } else if (failed && !issues.length) {
215
+ console.log(`${green("ok ")} ${f}` + (stdout.trim() ? dim(" (only expected Roblox-global notes)") : ""));
216
+ } else {
217
+ console.log(`${green("ok ")} ${f}`);
218
+ }
219
+ }
220
+ return;
221
+ }
222
+
223
+ case "config": {
224
+ const sub = flags._pos && flags._pos[0];
225
+ if (sub === "set" && flags._pos[1] && flags._pos[2]) {
226
+ const key = flags._pos[1];
227
+ const value = flags._pos[2];
228
+ saveFileConfig({ [key]: value });
229
+ console.log(`${key} = ${value}`);
230
+ } else {
231
+ console.log(JSON.stringify(cfg, (k, v) => (/Key$/.test(k) ? (v ? "•••set•••" : "") : v), 2));
232
+ }
233
+ return;
234
+ }
235
+
236
+ case "providers": {
237
+ const eff = effectiveProvider(cfg);
238
+ console.log(bold("providers") + dim(" (provider → model for auto mode)\n"));
239
+ for (const [name, meta] of Object.entries(PROVIDERS)) {
240
+ const has = providerHasKey(cfg, name);
241
+ const marker = eff === name ? green("◀ active") : has ? green("key set") : dim("no key");
242
+ const free = meta.hasFreeTier ? dim(` · free tier: ${meta.freeModel}`) : "";
243
+ console.log(` ${has ? green("●") : dim("№")} ${name.padEnd(11)} ${marker.padEnd(12)} ${has ? meta.defaultModel : dim(meta.defaultModel)}${free}`);
244
+ }
245
+ console.log(dim("\nset a key: roforge login --provider <name> · pin a model: roforge chat -m hi --model gemini:gemini-2.5-flash"));
246
+ console.log(dim("auto mode picks the first key above (free tiers first). freeFirst=false reverses the order."));
247
+ return;
248
+ }
249
+
250
+ case "help":
251
+ case "--help":
252
+ case "-h": {
253
+ printHelp();
254
+ return;
255
+ }
256
+
257
+ case "tui": {
258
+ await runTUI(cfg);
259
+ return;
260
+ }
261
+
262
+ default:
263
+ console.error(red(`unknown command: ${command}`));
264
+ printHelp();
265
+ process.exit(1);
266
+ return;
267
+ }
268
+ }
269
+
270
+ async function runTUI(cfg) {
271
+ const bridge = new BridgeServer({ port: cfg.bridge.port, host: cfg.bridge.host, token: cfg.bridge.token });
272
+ try {
273
+ await bridge.start();
274
+ } catch (e) {
275
+ console.error(yellow(`bridge disabled (port ${cfg.bridge.port} busy): ${e.message}`));
276
+ }
277
+ const session = new Session({
278
+ cfg,
279
+ cwd: process.cwd(),
280
+ bridgeServer: bridge.server ? bridge : null,
281
+ luauAnalyzePath: findLuauAnalyze(),
282
+ ui: {},
283
+ });
284
+ try {
285
+ await session.init();
286
+ } catch (e) {
287
+ console.error(red(`init: ${e.message}`));
288
+ }
289
+ const tui = new TUI(session);
290
+ session.ui = tui; // wire the event sink
291
+ const ok = await tui.start();
292
+ if (!ok) {
293
+ bridge.stop();
294
+ return;
295
+ }
296
+ process.on("exit", () => bridge.stop());
297
+ // keep the bridge alive while the TUI runs
298
+ await new Promise(() => {
299
+ /* stays until process exit */
300
+ });
301
+ }
302
+
303
+ async function oneShot(cfg, prompt) {
304
+ const bridge = new BridgeServer({ port: cfg.bridge.port, host: cfg.bridge.host, token: cfg.bridge.token });
305
+ try {
306
+ await bridge.start();
307
+ } catch {
308
+ /* bridge optional in one-shot */
309
+ }
310
+ const session = new Session({
311
+ cfg,
312
+ cwd: process.cwd(),
313
+ bridgeServer: bridge.server ? bridge : null,
314
+ luauAnalyzePath: findLuauAnalyze(),
315
+ ui: {
316
+ onText: (d) => process.stdout.write(d),
317
+ onToolStart: (tool, args) => {
318
+ let a;
319
+ try {
320
+ a = JSON.stringify(args || {});
321
+ } catch {
322
+ a = "{}";
323
+ }
324
+ if (a.length > 80) a = a.slice(0, 77) + "…";
325
+ process.stderr.write(gray(`[tool] ${tool.name}(${a})\n`));
326
+ },
327
+ onToolEnd: (tool, args, result) => {
328
+ const first = String(result || "").split("\n")[0].slice(0, 120);
329
+ process.stderr.write(result.startsWith("ERROR") ? red(` ↳ ${first}\n`) : dim(` ↳ ${first}\n`));
330
+ },
331
+ onWarn: (m) => process.stderr.write(red(m + "\n")),
332
+ onInfo: (m) => process.stderr.write(gray(m + "\n")),
333
+ onStatus: (m) => {
334
+ if (String(m).includes("tok")) process.stderr.write(gray(m + "\n"));
335
+ },
336
+ },
337
+ });
338
+ await session.init();
339
+ const out = await session.send(prompt);
340
+ process.stdout.write("\n");
341
+ bridge.stop();
342
+ process.exit(out.ok ? 0 : 1);
343
+ }
344
+
345
+ function printHelp() {
346
+ console.log(`
347
+ ${bold("RoForge")} — local Claude-Code-style agent for Roblox Studio. BYOK, zero backend.
348
+
349
+ ${bold("Usage")}
350
+ roforge interactive TUI (starts the local bridge)
351
+ roforge chat -m "prompt" one-shot mode (streams to stdout)
352
+ roforge studio keep the Studio bridge running + show connection status
353
+ roforge tools list all tools
354
+ roforge login --provider <p> store a key (gemini|groq|openrouter|anthropic|openai)
355
+ roforge providers list providers, keys, and auto-routing order
356
+ roforge analyze <file...> run the official Luau analyzer on files
357
+ roforge config [set k v] show / set configuration
358
+ roforge version
359
+
360
+ ${bold("How it connects to Studio")}
361
+ 1. Built-in MCP (recommended): Studio → File → Studio Settings → Beta Features →
362
+ ${bold("MCP Server")} — roforge talks to it at http://localhost:3004/mcp automatically.
363
+ 2. RoForge Bridge plugin: install studio-bridge/dist/RoForgeBridge.rbxm into Studio,
364
+ paste the bridge token (shown by ${bold("roforge studio")}) into the plugin.
365
+
366
+ ${bold("Model providers (BYOK, zero backend)")}
367
+ auto (default): first configured key wins, free tiers first:
368
+ gemini → groq → openrouter → anthropic → openai
369
+ free tiers: Gemini 2.5 Flash (~1,500 req/day), Groq Llama 3.3 70B (~1,000 req/day),
370
+ OpenRouter ":free" models (e.g. qwen/qwen3-coder:free)
371
+ pin: --provider <p> or --model <provider>:<model> · ROFORGE_FREE_FIRST=0
372
+
373
+ ${bold("Config & keys")}
374
+ ~/.roforge/config.json · env: GEMINI_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY,
375
+ ANTHROPIC_API_KEY, OPENAI_API_KEY, ROFORGE_MODEL, ROFORGE_PROVIDER,
376
+ ROFORGE_MCP_URL, ROFORGE_BRIDGE_PORT, ROFORGE_STUDIO_MODE (auto|mcp|bridge)
377
+ --no-color · ROFORGE_NO_COLOR / NO_COLOR=1 disable ANSI colors
378
+ `);
379
+ }
380
+
381
+ main().catch((e) => {
382
+ console.error(red(`error: ${e.stack || e.message || e}`));
383
+ process.exit(1);
384
+ });
@@ -0,0 +1,53 @@
1
+ // End-to-end demo (offline): mock Studio MCP + mock Anthropic, real CLI loop.
2
+ // Shows: agent → MCP (Studio) tool → local project tool → streamed answer.
3
+ import fs from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ const CLI_ROOT = fileURLToPath(new URL("..", import.meta.url));
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { startMockAnthropic, startMockMcp } from "../test/mock-server.js";
9
+
10
+ const anthropic = await startMockAnthropic({ toolName: "project_tree" });
11
+ const mcp = await startMockMcp();
12
+
13
+ const cfgDir = fs.mkdtempSync(path.join(os.tmpdir(), "roforge-demo-"));
14
+ fs.writeFileSync(
15
+ path.join(cfgDir, "config.json"),
16
+ JSON.stringify({
17
+ provider: "anthropic",
18
+ anthropicKey: "demo-key",
19
+ anthropicBaseUrl: `http://127.0.0.1:${anthropic.address().port}`,
20
+ mcpUrl: `http://127.0.0.1:${mcp.address().port}/mcp`,
21
+ approve: "yolo",
22
+ })
23
+ );
24
+
25
+ // a tiny fake project to work on
26
+ const proj = fs.mkdtempSync(path.join(os.tmpdir(), "roforge-demo-proj-"));
27
+ fs.mkdirSync(path.join(proj, "src"), { recursive: true });
28
+ fs.writeFileSync(path.join(proj, "src", "Main.lua"), "print('demo project')\n");
29
+
30
+ const { execFile } = await import("node:child_process");
31
+ try {
32
+ await new Promise((resolve, reject) => {
33
+ execFile(
34
+ process.execPath,
35
+ ["bin/roforge.js", "chat", "-m", "Summarize the project and list what studio can do."],
36
+ {
37
+ cwd: CLI_ROOT,
38
+ env: { ...process.env, ROFORGE_CONFIG_DIR: cfgDir, NO_COLOR: "1" },
39
+ stdio: ["ignore", "inherit", "inherit"],
40
+ timeout: 60000,
41
+ },
42
+ (err) => (err ? reject(err) : resolve())
43
+ );
44
+ });
45
+ console.log("\n=== E2E DEMO: OK (agent loop ran against mock Studio MCP + mock model) ===");
46
+ } catch (e) {
47
+ console.error("\n=== E2E DEMO exited:", e.status ?? e.message, "===");
48
+ } finally {
49
+ anthropic.close();
50
+ mcp.close();
51
+ fs.rmSync(cfgDir, { recursive: true, force: true });
52
+ fs.rmSync(proj, { recursive: true, force: true });
53
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "roforge-cli",
3
+ "version": "0.3.0",
4
+ "description": "RoForge — Claude-Code-style local AI agent for Roblox Studio. BYOK, zero backend, zero dependencies.",
5
+ "type": "module",
6
+ "bin": {
7
+ "roforge": "bin/roforge.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/roforge.js",
11
+ "test": "node --test test/",
12
+ "demo": "node demo/e2e-demo.mjs",
13
+ "install-plugin": "node ../scripts/install-plugin.mjs bridge",
14
+ "install-plugin:client": "node ../scripts/install-plugin.mjs client"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.17"
18
+ },
19
+ "license": "MIT",
20
+ "keywords": [
21
+ "roblox",
22
+ "ai",
23
+ "agent",
24
+ "cli",
25
+ "tui",
26
+ "mcp",
27
+ "lua",
28
+ "rojo"
29
+ ],
30
+ "files": [
31
+ "bin",
32
+ "src",
33
+ "demo",
34
+ "README.md"
35
+ ],
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/hacvilke/roforge.git"
39
+ },
40
+ "homepage": "https://github.com/hacvilke/roforge#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/hacvilke/roforge/issues"
43
+ }
44
+ }
package/src/agent.js ADDED
@@ -0,0 +1,137 @@
1
+ // The agent loop: chat ↔ model provider ↔ tools, until a final answer.
2
+ // Runs locally — streaming, with an iteration safety cap.
3
+ import { ProviderError } from "./providers/anthropic.js";
4
+
5
+ const MAX_TOOL_RESULT_CHARS = 24000;
6
+ const MAX_HISTORY_ITEMS = 60;
7
+
8
+ function truncate(s, n = MAX_TOOL_RESULT_CHARS) {
9
+ s = String(s ?? "");
10
+ return s.length > n ? s.slice(0, n) + `\n... [truncated ${s.length - n} chars]` : s;
11
+ }
12
+
13
+ function trimHistory(history) {
14
+ while (history.length > MAX_HISTORY_ITEMS) history.shift();
15
+ if (history.length && history[0].role !== "user") {
16
+ history.unshift({ role: "user", text: "(Earlier conversation was trimmed to fit the context window.)" });
17
+ }
18
+ }
19
+
20
+ /**
21
+ * run(cfg, provider, history, system, tools, io)
22
+ * provider: { chatStream(cfg, {model, system, messages, tools, signal}, events) -> {text, toolCalls, usage} }
23
+ * io: {
24
+ * onText(delta), onAssistantDone(text), onToolStart(tool, args), onToolEnd(tool, args, result),
25
+ * onIter(n, total), onUsage(usage), onDone(), onAborted(), onError(err),
26
+ * shouldAbort() -> bool, // user pressed Ctrl+C
27
+ * approve(name, args) -> bool, // approval gate (session supplies)
28
+ * }
29
+ * returns { ok, text, usage: {input_tokens, output_tokens}, iterations }
30
+ */
31
+ export async function runAgent(cfg, provider, history, system, tools, io) {
32
+ const toolByName = new Map(tools.map((t) => [t.name, t]));
33
+ const maxIterations = cfg.maxIterations || 12;
34
+ let totalUsage = { input_tokens: 0, output_tokens: 0 };
35
+ let finalText = "";
36
+
37
+ for (let iter = 1; iter <= maxIterations; iter++) {
38
+ if (io.shouldAbort && io.shouldAbort()) {
39
+ io.onAborted && io.onAborted();
40
+ return { ok: false, aborted: true, text: finalText, usage: totalUsage, iterations: iter - 1 };
41
+ }
42
+ io.onIter && io.onIter(iter, maxIterations);
43
+
44
+ let result;
45
+ try {
46
+ result = await provider.chatStream(
47
+ cfg,
48
+ {
49
+ model: cfg._activeModel,
50
+ maxTokens: cfg.maxTokens,
51
+ system,
52
+ messages: history,
53
+ tools,
54
+ signal: io.abortSignal,
55
+ },
56
+ { onText: io.onText }
57
+ );
58
+ } catch (e) {
59
+ if (io.shouldAbort && io.shouldAbort()) {
60
+ io.onAborted && io.onAborted();
61
+ return { ok: false, aborted: true, text: finalText, usage: totalUsage, iterations: iter - 1 };
62
+ }
63
+ io.onError && io.onError(e instanceof ProviderError ? e.message : String(e.message || e));
64
+ return { ok: false, error: true, text: finalText, usage: totalUsage, iterations: iter - 1 };
65
+ }
66
+
67
+ if (result.usage) {
68
+ totalUsage.input_tokens += result.usage.input_tokens || 0;
69
+ totalUsage.output_tokens += result.usage.output_tokens || 0;
70
+ io.onUsage && io.onUsage(totalUsage);
71
+ }
72
+
73
+ const text = result.text || "";
74
+ const calls = result.toolCalls || [];
75
+
76
+ if (text) {
77
+ finalText = text;
78
+ }
79
+ if (!calls.length) {
80
+ history.push({ role: "assistant", text: text || "(no output)" });
81
+ trimHistory(history);
82
+ io.onAssistantDone && io.onAssistantDone(text);
83
+ io.onDone && io.onDone();
84
+ return { ok: true, text: finalText, usage: totalUsage, iterations: iter };
85
+ }
86
+
87
+ // record assistant turn with tool calls, then execute each
88
+ history.push({
89
+ role: "assistant",
90
+ text: text || null,
91
+ calls: calls.map((c) => ({ id: c.id, name: c.name, args: c.input })),
92
+ });
93
+
94
+ for (const call of calls) {
95
+ if (io.shouldAbort && io.shouldAbort()) {
96
+ io.onAborted && io.onAborted();
97
+ return { ok: false, aborted: true, text: finalText, usage: totalUsage, iterations: iter };
98
+ }
99
+ const tool = toolByName.get(call.name);
100
+ let resultStr;
101
+ let image; // {base64, mediaType} — set when a tool returns an image
102
+ if (!tool) {
103
+ resultStr = `ERROR: unknown tool '${call.name}'. Do not call it again.`;
104
+ } else {
105
+ io.onToolStart && io.onToolStart(tool, call.input || {});
106
+ if (tool.requiresApproval) {
107
+ const ok = (await (io.approve && io.approve(tool.name, call.input || {}))) || false;
108
+ if (!ok) {
109
+ resultStr = "ERROR: user declined to run this tool. Choose a different approach or ask the user what they want.";
110
+ io.onToolEnd && io.onToolEnd(tool, call.input || {}, resultStr);
111
+ history.push({ role: "tool", id: call.id, name: call.name, result: resultStr });
112
+ continue;
113
+ }
114
+ }
115
+ try {
116
+ const raw = await tool.execute(call.input || {}, { cfg, history, iter });
117
+ if (raw && typeof raw === "object") {
118
+ resultStr = String(raw.text ?? "");
119
+ if (raw.image && typeof raw.image.base64 === "string") image = raw.image;
120
+ } else {
121
+ resultStr = String(raw ?? "");
122
+ }
123
+ } catch (e) {
124
+ resultStr = `ERROR: ${e.message || e}`;
125
+ }
126
+ }
127
+ resultStr = truncate(resultStr);
128
+ io.onToolEnd && io.onToolEnd(tool, call.input || {}, resultStr);
129
+ history.push({ role: "tool", id: call.id, name: call.name, result: resultStr, ...(image ? { image } : {}) });
130
+ trimHistory(history);
131
+ }
132
+ }
133
+
134
+ const msg = `Stopped after ${maxIterations} iterations (safety limit). Say "continue" to pick up where I left off.`;
135
+ io.onError && io.onError(msg);
136
+ return { ok: false, limit: true, text: finalText, usage: totalUsage, iterations: maxIterations };
137
+ }