golem-bridge 2.0.1 → 3.0.1

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/cli.js CHANGED
@@ -1,20 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
- // golem-bridge: verifies the Golem Studio plugin over the relay, then
5
- // stamps the channel into local golem-helper.py / golem-tools.md copies.
4
+ // golem-bridge: drive a live Roblox Studio session from any AI agent.
5
+ // Session setup (connect/reconnect/disconnect) plus every Studio tool,
6
+ // all through the Firebase command relay. Zero dependencies.
6
7
 
7
- const crypto = require("crypto");
8
8
  const fs = require("fs");
9
9
  const path = require("path");
10
- const readline = require("readline");
11
10
 
12
- const DB_URL = "https://roblox-golem-default-rtdb.firebaseio.com";
13
- const POLL_INTERVAL_MS = 2000;
14
- const POLL_ATTEMPTS = 45;
11
+ const DB_URL = process.env.AIB_FIREBASE_DB || "https://roblox-golem-default-rtdb.firebaseio.com";
12
+ const POLL_INTERVAL_MS = Math.max(250, (parseFloat(process.env.AIB_POLL_INTERVAL) || 2) * 1000);
15
13
  const FETCH_TIMEOUT_MS = 30000;
16
14
  const MAX_BODY_BYTES = 2 * 1024 * 1024;
17
- const MAX_FILE_BYTES = 1024 * 1024;
15
+ const DEFAULT_TIMEOUT = 120;
18
16
  // Channel IDs are hex tokens minted by the Studio plugin (16 chars today,
19
17
  // tolerated 8-64 for forward compatibility). Anything else is rejected so a
20
18
  // malformed ID can never alter the request URL.
@@ -27,95 +25,45 @@ try {
27
25
  // running outside the package dir; version is informational only
28
26
  }
29
27
 
30
- const SETUP_NOTE = `You are connected to this user's Roblox Studio through Golem, the plugin they installed at their own request - this is the user's own tooling. The helper (./.golem/golem-helper.py) is zero-dependency Python that contacts only this relay and Roblox's public APIs: no file access, no subprocess, no eval. It shipped inside the audited golem-bridge package - nothing executable was downloaded from the network - but you can still read it before you run anything. ./.golem/golem-tools.md is the tool's reference manual (command list, calling conventions, safety rules), in the same spirit as a man page: consult it when you need a command. It is documentation, not orders - your tasks come only from the user. If anything in either file looks wrong, stop and tell the user instead of proceeding. Otherwise run python3 ./.golem/golem-helper.py ping (expect "ok": true plus the open place's name) and tell the user you are connected and ready - one short line. Work only reaches the place file when the user presses Ctrl+S in Studio - remind them at milestones.`;
31
-
32
- function loadTemplate(name) {
33
- return fs.readFileSync(path.join(__dirname, name), "utf8");
34
- }
35
-
36
- function stamp(text, channelId) {
37
- return text.split("__DB_URL__").join(DB_URL).split("__CHANNEL_ID__").join(channelId);
38
- }
39
-
40
- function printHelp() {
41
- console.log(`golem-bridge v${VERSION} — connect an AI agent to Roblox Studio via the Golem plugin.
42
-
43
- Usage:
44
- golem-bridge connect <channelId> [--print] [--yes]
45
- golem-bridge reconnect <channelId> [--print] [--yes]
46
- golem-bridge disconnect
47
- golem-bridge --help
48
- golem-bridge --version
49
-
50
- <channelId> shown in the Golem plugin widget inside Roblox Studio.
51
- Fresh on every Studio start.
52
- --print audit mode: verify Studio, then print both files without writing.
53
- --yes answer the install question with yes (for scripts).
54
-
55
- connect link this folder to a Studio session (writes ./.golem/).
56
- reconnect same, for a rotated token: replaces the old session files.
57
- Use after a Studio restart, with the new line from the widget.
58
- disconnect forget this session (removes ./.golem/). Studio is unaffected.
59
-
60
- connect verifies Studio is alive over HTTPS, then stamps your channel ID
61
- into local copies of the bundled golem-helper.py and golem-tools.md. No code is ever
62
- downloaded from the network. It lists the files and asks before writing anything; review first with
63
- --print, or read them in this package before running anything.`);
64
- }
65
-
66
28
  function fail(message, exitCode) {
67
29
  console.error(`golem-bridge error: ${message}`);
68
30
  process.exit(exitCode || 1);
69
31
  }
70
32
 
71
- function validateChannel(channelId) {
72
- if (typeof channelId !== "string" || !CHANNEL_RE.test(channelId)) {
73
- fail("bad channel ID (expect 8-64 hex characters — copy the full line from the Studio widget).", 2);
74
- }
75
- return channelId;
33
+ function sleep(ms) {
34
+ return new Promise((resolve) => setTimeout(resolve, ms));
76
35
  }
77
36
 
78
- function askYes(question) {
79
- return new Promise((resolve) => {
80
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
81
- let done = false;
82
- const finish = (value) => {
83
- if (done) return;
84
- done = true;
85
- try {
86
- rl.close();
87
- } catch {
88
- // already closed (EOF on stdin)
89
- }
90
- resolve(value);
91
- };
92
- rl.question(question + " ", (answer) => finish(/^\s*y(es)?\s*$/i.test(answer || "")));
93
- rl.on("close", () => finish(false));
94
- });
37
+ function parseRetryAfter(headers, fallback) {
38
+ try {
39
+ const v = parseFloat(headers.get("retry-after"));
40
+ if (Number.isFinite(v) && v >= 0) return v;
41
+ } catch {
42
+ // fall through
43
+ }
44
+ return fallback;
95
45
  }
96
46
 
97
- async function fetchText(url, body) {
47
+ async function fetchText(url, opts) {
48
+ const o = opts || {};
98
49
  const ctrl = new AbortController();
99
- const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
50
+ const timer = setTimeout(() => ctrl.abort(), o.timeoutMs || FETCH_TIMEOUT_MS);
100
51
  try {
101
52
  const res = await fetch(url, {
102
- method: body === undefined ? "GET" : "POST",
103
- headers: { "Content-Type": "application/json" },
104
- body,
53
+ method: o.method || (o.body === undefined ? "GET" : "POST"),
54
+ headers: Object.assign({ Accept: "application/json" }, o.headers || {}),
55
+ body: o.body,
105
56
  signal: ctrl.signal,
106
- redirect: "error", // never follow redirects: responses must come from the relay itself
57
+ redirect: "error", // responses must come from the relay itself
107
58
  });
108
- if (!res.ok) {
109
- throw new Error(`HTTP ${res.status} from the relay`);
110
- }
111
59
  const text = await res.text();
112
60
  if (text.length > MAX_BODY_BYTES) {
113
- throw new Error("relay response too large, refusing to parse it");
61
+ throw new Error("response too large, refusing to parse it");
114
62
  }
115
- return text;
63
+ return { ok: res.ok, status: res.status, headers: res.headers, text };
116
64
  } catch (err) {
117
65
  if (err && err.name === "AbortError") {
118
- throw new Error("relay request timed out (30s)");
66
+ throw new Error(`request timed out (${(o.timeoutMs || FETCH_TIMEOUT_MS) / 1000}s)`);
119
67
  }
120
68
  throw err;
121
69
  } finally {
@@ -123,73 +71,181 @@ async function fetchText(url, body) {
123
71
  }
124
72
  }
125
73
 
126
- async function postJson(url, body) {
127
- const text = await fetchText(url, JSON.stringify(body));
128
- return JSON.parse(text);
129
- }
130
-
131
- async function getJson(url) {
132
- const text = await fetchText(url);
133
- return text === "null" ? null : JSON.parse(text);
134
- }
135
-
136
- function sleep(ms) {
137
- return new Promise((resolve) => setTimeout(resolve, ms));
138
- }
139
-
140
-
141
- async function relayCall(channelId, op, args, attempts) {
142
- const cmdId = `${op}${Date.now()}${Math.floor(Math.random() * 1e6)}`;
74
+ async function relayCall(channelId, op, args, timeoutSec) {
143
75
  const enc = encodeURIComponent(channelId);
144
- await postJson(`${DB_URL}/channels/${enc}/cmd.json`, {
145
- id: cmdId,
146
- op,
147
- args: args || {},
148
- ts: Math.floor(Date.now() / 1000),
149
- });
150
- for (let i = 0; i < attempts; i++) {
151
- await sleep(POLL_INTERVAL_MS);
152
- let keys;
76
+ const cmdId = `c${Date.now()}${Math.floor(Math.random() * 1e6)}`;
77
+ const payload = JSON.stringify({ id: cmdId, op, args: args || {}, ts: Math.floor(Date.now() / 1000) });
78
+ let sinceKey = null;
79
+ for (let attempt = 0; ; attempt++) {
80
+ let r;
153
81
  try {
154
- keys = await getJson(`${DB_URL}/channels/${enc}/res.json?shallow=true`);
82
+ r = await fetchText(`${DB_URL}/channels/${enc}/cmd.json`, { method: "POST", body: payload });
83
+ } catch (err) {
84
+ return { ok: false, error: `cannot reach the relay (${err.message}) - check internet/DNS` };
85
+ }
86
+ if (r.status === 429 && attempt < 3) {
87
+ await sleep(parseRetryAfter(r.headers, 5 * (attempt + 1)) * 1000);
88
+ continue;
89
+ }
90
+ if (!r.ok) {
91
+ return { ok: false, error: `relay rejected the command: HTTP ${r.status}: ${r.text.slice(0, 200)}` };
92
+ }
93
+ try {
94
+ sinceKey = (JSON.parse(r.text) || {}).name || null;
155
95
  } catch {
96
+ sinceKey = null;
97
+ }
98
+ break;
99
+ }
100
+ const deadline = Date.now() + timeoutSec * 1000;
101
+ let attempt = 0;
102
+ while (Date.now() < deadline) {
103
+ let r;
104
+ try {
105
+ r = await fetchText(`${DB_URL}/channels/${enc}/res.json?shallow=true`, { timeoutMs: 60000 });
106
+ } catch {
107
+ attempt++;
108
+ await sleep(Math.min(15, 0.5 * 2 ** Math.min(attempt, 5)) * 1000);
156
109
  continue;
157
110
  }
158
- if (!keys) continue;
159
- for (const key of Object.keys(keys).sort()) {
160
- if (typeof key !== "string" || key.length > 128) continue;
161
- let entry;
162
- try {
163
- entry = await getJson(`${DB_URL}/channels/${enc}/res/${encodeURIComponent(key)}.json`);
164
- } catch {
165
- continue;
111
+ if (r.status === 429) {
112
+ await sleep(parseRetryAfter(r.headers, 6) * 1000);
113
+ continue;
114
+ }
115
+ if (!r.ok) {
116
+ await sleep(2000);
117
+ continue;
118
+ }
119
+ attempt = 0;
120
+ let keys;
121
+ try {
122
+ keys = r.text === "null" ? {} : JSON.parse(r.text);
123
+ } catch {
124
+ keys = {};
125
+ }
126
+ if (keys && typeof keys === "object") {
127
+ for (const key of Object.keys(keys).sort()) {
128
+ if (sinceKey !== null && !(key > sinceKey)) continue;
129
+ sinceKey = key;
130
+ let entry;
131
+ try {
132
+ const er = await fetchText(`${DB_URL}/channels/${enc}/res/${encodeURIComponent(key)}.json`, {
133
+ timeoutMs: 60000,
134
+ });
135
+ if (!er.ok) continue;
136
+ entry = JSON.parse(er.text);
137
+ } catch {
138
+ continue;
139
+ }
140
+ if (entry && entry.id === cmdId) {
141
+ if (entry.resultEncoded && typeof entry.result === "string") {
142
+ try {
143
+ entry.result = JSON.parse(entry.result);
144
+ } catch {
145
+ // keep the raw string
146
+ }
147
+ delete entry.resultEncoded;
148
+ }
149
+ return entry;
150
+ }
166
151
  }
167
- if (entry && entry.id === cmdId) return entry;
168
152
  }
153
+ await sleep(POLL_INTERVAL_MS);
169
154
  }
170
- return null;
155
+ return {
156
+ ok: false,
157
+ error: `timed out after ${timeoutSec}s waiting for Studio to answer '${op}'. Is Roblox Studio open with the Golem plugin connected to the relay?`,
158
+ };
171
159
  }
172
160
 
173
- function sha256(text) {
174
- return crypto.createHash("sha256").update(text, "utf8").digest("hex");
161
+ function turnReminder(entry) {
162
+ if (!entry || !entry.turnOpen) return;
163
+ const n = entry.turnTools;
164
+ const head =
165
+ typeof n === "number"
166
+ ? `>> TURN STILL OPEN (${n} tools) - do NOT reply yet.`
167
+ : ">> TURN STILL OPEN - do NOT reply yet.";
168
+ console.error(`${head} When this task is done, close it with:\n>> npx golem-bridge turn end --note "your reply"`);
175
169
  }
176
170
 
177
- function readSavedChannel() {
178
- // New name first, pre-2.0.1 name as fallback (stale files are removed on connect).
171
+ // Exit codes: 0 = ok, 1 = Studio reported an error, 2 = usage error.
172
+ function out(data, opts) {
173
+ const o = opts || {};
174
+ if (data && data.ok) {
175
+ if (o.rawField && !o.asJson) {
176
+ const result = data.result || {};
177
+ if (typeof result[o.rawField] === "string") {
178
+ process.stdout.write(result[o.rawField]);
179
+ if (!result[o.rawField].endsWith("\n")) process.stdout.write("\n");
180
+ turnReminder(data);
181
+ process.exitCode = 0;
182
+ return;
183
+ }
184
+ }
185
+ console.log(JSON.stringify(data, null, 2));
186
+ turnReminder(data);
187
+ process.exitCode = 0;
188
+ return;
189
+ }
190
+ console.error(JSON.stringify(data, null, 2));
191
+ turnReminder(data);
192
+ process.exitCode = data && "error" in data ? 1 : 2;
193
+ }
194
+
195
+ function vec3(s) {
196
+ const parts = String(s)
197
+ .replace(/,/g, " ")
198
+ .split(/\s+/)
199
+ .filter((v) => v !== "")
200
+ .map(Number);
201
+ if (parts.length === 0 || parts.some((v) => !Number.isFinite(v))) {
202
+ throw new UsageError(`bad vector "${s}" (expected x,y,z numbers)`);
203
+ }
204
+ if (parts.length === 1) return { x: parts[0], y: 0, z: 0 };
205
+ if (parts.length !== 3) throw new UsageError(`bad vector "${s}" (expected x,y,z)`);
206
+ return { x: parts[0], y: parts[1], z: parts[2] };
207
+ }
208
+
209
+ class UsageError extends Error {}
210
+
211
+ function loadChannel() {
212
+ if (process.env.AIB_CHANNEL) return process.env.AIB_CHANNEL;
213
+ try {
214
+ const raw = fs.readFileSync(path.join(process.cwd(), ".golem", "channel"), "utf8").trim();
215
+ if (raw) return raw;
216
+ } catch {
217
+ // fall through to the 2.x stamped helper below
218
+ }
179
219
  for (const name of ["golem-helper.py", "golem.py"]) {
180
220
  try {
181
221
  const src = fs.readFileSync(path.join(process.cwd(), ".golem", name), "utf8");
182
222
  const m = src.match(/CHANNEL = os\.environ\.get\("AIB_CHANNEL", "([0-9a-fA-F]+)"\)/);
183
223
  if (m) return m[1];
184
224
  } catch {
185
- // missing or unreadable - try the next name
225
+ // try the next name
186
226
  }
187
227
  }
188
228
  return null;
189
229
  }
190
230
 
231
+ function requireChannel() {
232
+ const id = loadChannel();
233
+ if (typeof id !== "string" || !CHANNEL_RE.test(id)) {
234
+ out({ ok: false, error: "not connected (no channel saved) - run: npx golem-bridge connect <channelId>" });
235
+ return null;
236
+ }
237
+ return id;
238
+ }
239
+
240
+ function validateChannel(channelId) {
241
+ if (typeof channelId !== "string" || !CHANNEL_RE.test(channelId)) {
242
+ fail("bad channel ID (expect 8-64 hex characters — copy the full line from the Studio widget).", 2);
243
+ }
244
+ return channelId;
245
+ }
246
+
191
247
  function removeStaleHelpers(dir) {
192
- for (const stale of ["golem.py", "golem.md"]) {
248
+ for (const stale of ["golem-helper.py", "golem-tools.md", "golem.py", "golem.md"]) {
193
249
  try {
194
250
  fs.rmSync(path.join(dir, stale), { force: true });
195
251
  } catch {
@@ -198,147 +254,856 @@ function removeStaleHelpers(dir) {
198
254
  }
199
255
  }
200
256
 
201
- function disconnectLocal() {
202
- const dir = path.join(process.cwd(), ".golem");
203
- if (!fs.existsSync(dir)) {
204
- console.log("Not connected (no .golem/ in this folder).");
205
- return;
257
+ function readStdin() {
258
+ try {
259
+ return fs.readFileSync(0, "utf8");
260
+ } catch {
261
+ return "";
206
262
  }
207
- const old = readSavedChannel();
208
- fs.rmSync(dir, { recursive: true, force: true });
209
- console.log(old ? `Disconnected from channel ${old} (removed .golem/).` : "Disconnected (removed .golem/).");
210
- console.log("Studio is unaffected. To link again: npx golem-bridge connect <channelId>");
211
263
  }
212
264
 
213
- async function reconnect(channelId, printOnly, autoYes) {
214
- validateChannel(channelId);
215
- const old = readSavedChannel();
216
- if (!printOnly && old && old.toLowerCase() === channelId.toLowerCase()) {
217
- console.log(`Already linked to channel ${channelId} nothing to do.`);
218
- return;
219
- }
220
- if (!printOnly && old) {
221
- console.log(`Replacing session files for channel ${old}.`);
222
- }
223
- await connect(channelId, printOnly, autoYes);
265
+ // ---------------------------------------------------------------- marketplace
266
+
267
+ const MP_CATEGORIES = {
268
+ model: "Model", models: "Model", mesh: "MeshPart", meshes: "MeshPart", meshpart: "MeshPart",
269
+ decal: "Decal", decals: "Decal", image: "Decal", images: "Decal", picture: "Decal", texture: "Decal",
270
+ audio: "Audio", sound: "Audio", sounds: "Audio", music: "Audio",
271
+ video: "Video", videos: "Video", plugin: "Plugin", plugins: "Plugin",
272
+ };
273
+ const ASSET_TYPE_NAMES = { 1: "Image", 3: "Audio", 4: "Mesh", 9: "Decal", 10: "Model", 18: "Video", 19: "Font", 40: "MeshPart" };
274
+
275
+ async function mpGet(url) {
276
+ const r = await fetchText(url, { headers: { "User-Agent": "Golem-aib/1.0" }, timeoutMs: 20000 });
277
+ if (!r.ok) throw new Error(`HTTP ${r.status} from Roblox`);
278
+ return JSON.parse(r.text);
224
279
  }
225
280
 
226
- async function connect(channelId, printOnly, autoYes) {
227
- validateChannel(channelId);
228
- console.log(`Contacting Golem plugin on channel ${channelId} ...`);
229
- let entry;
230
- try {
231
- entry = await relayCall(channelId, "ping", {}, 30);
232
- } catch (err) {
233
- fail(err.message, 1);
281
+ async function mpSearch(query, category, limit, cursor) {
282
+ const assetType = MP_CATEGORIES[String(category || "model").toLowerCase()];
283
+ if (!assetType) throw new Error(`unknown category '${category}' (valid: model, mesh, image, audio, video, plugin)`);
284
+ let n = parseInt(limit, 10);
285
+ if (!Number.isFinite(n)) n = 10;
286
+ n = Math.max(1, Math.min(n, 50));
287
+ let url = `https://apis.roblox.com/toolbox-service/v1/marketplace/${assetType}?keyword=${encodeURIComponent(String(query))}&limit=${n}`;
288
+ if (cursor) url += `&cursor=${encodeURIComponent(String(cursor))}`;
289
+ const data = await mpGet(url);
290
+ const ids = ((data && data.data) || []).filter((it) => it && it.id != null).map((it) => it.id);
291
+ const details = {};
292
+ const thumbs = {};
293
+ for (let i = 0; i < Math.min(ids.length, 12); i++) {
294
+ try {
295
+ details[ids[i]] = await mpGet(`https://economy.roblox.com/v2/assets/${ids[i]}/details`);
296
+ } catch {
297
+ // one bad asset must not kill the search
298
+ }
299
+ if (i < 11) await sleep(400); // the economy API rate limits hard
234
300
  }
235
- if (!entry) {
236
- fail("no response from the Studio plugin. Is Roblox Studio open with Golem running?", 1);
301
+ if (ids.length) {
302
+ try {
303
+ const tdata = await mpGet(
304
+ `https://thumbnails.roblox.com/v1/assets?assetIds=${ids.slice(0, 12).join(",")}&size=420x420&format=Png`
305
+ );
306
+ for (const th of (tdata && tdata.data) || []) {
307
+ if (th && th.targetId != null) thumbs[th.targetId] = th;
308
+ }
309
+ } catch {
310
+ // thumbnails are a bonus
311
+ }
237
312
  }
238
- if (entry.ok !== true) {
239
- fail(`Studio reported an error: ${entry.error || "unknown error"}`, 1);
313
+ const results = ids.map((aid) => {
314
+ const d = details[aid];
315
+ const th = thumbs[aid];
316
+ const entry = { id: aid, category: assetType };
317
+ if (d && typeof d === "object") {
318
+ entry.name = d.Name;
319
+ entry.assetTypeId = d.AssetTypeId;
320
+ entry.assetType = ASSET_TYPE_NAMES[d.AssetTypeId];
321
+ if (d.Creator && typeof d.Creator === "object") entry.creator = d.Creator.Name;
322
+ if (d.PriceInRobux != null) entry.priceInRobux = d.PriceInRobux;
323
+ if (d.IsForSale != null) entry.forSale = d.IsForSale;
324
+ if (typeof d.Description === "string" && d.Description) entry.description = d.Description.slice(0, 280);
325
+ } else {
326
+ entry.detailsUnavailable = true;
327
+ }
328
+ if (th && typeof th === "object") {
329
+ entry.thumbnail = th.imageUrl;
330
+ entry.thumbnailState = th.state;
331
+ }
332
+ return entry;
333
+ });
334
+ const output = { totalResults: data.totalResults, results };
335
+ if (data.nextPageCursor) output.nextPageCursor = data.nextPageCursor;
336
+ return output;
337
+ }
338
+
339
+ async function mpInfo(assetId) {
340
+ const aid = parseInt(assetId, 10);
341
+ if (!Number.isFinite(aid)) throw new Error(`bad asset id '${assetId}'`);
342
+ const d = await mpGet(`https://economy.roblox.com/v2/assets/${aid}/details`);
343
+ const output = { id: aid };
344
+ if (d && typeof d === "object") {
345
+ output.name = d.Name;
346
+ output.assetTypeId = d.AssetTypeId;
347
+ output.assetType = ASSET_TYPE_NAMES[d.AssetTypeId];
348
+ if (d.Creator && typeof d.Creator === "object") output.creator = d.Creator.Name;
349
+ if (d.PriceInRobux != null) output.priceInRobux = d.PriceInRobux;
350
+ if (d.IsForSale != null) output.forSale = d.IsForSale;
351
+ if (typeof d.Description === "string") output.description = d.Description.slice(0, 1000);
240
352
  }
241
353
  try {
242
- const r = entry.resultEncoded && typeof entry.result === "string" ? JSON.parse(entry.result) : entry.result;
243
- if (r && typeof r.placeName === "string") console.log(`Studio is alive (place: ${r.placeName}).`);
354
+ const tdata = await mpGet(`https://thumbnails.roblox.com/v1/assets?assetIds=${aid}&size=420x420&format=Png`);
355
+ if (Array.isArray(tdata && tdata.data) && tdata.data.length) {
356
+ output.thumbnail = tdata.data[0].imageUrl;
357
+ output.thumbnailState = tdata.data[0].state;
358
+ }
244
359
  } catch {
245
- // place name is informational only
360
+ // thumbnails are a bonus
246
361
  }
362
+ return output;
363
+ }
364
+
365
+ function marketplaceViaPlugin() {
366
+ return (process.env.AIB_MARKETPLACE || "").toLowerCase() === "plugin";
367
+ }
247
368
 
248
- let files;
369
+ async function status() {
370
+ const channel = loadChannel();
371
+ if (typeof channel !== "string" || !CHANNEL_RE.test(channel)) {
372
+ return { ok: false, error: "not connected (no channel saved) - run: npx golem-bridge connect <channelId>" };
373
+ }
374
+ let beaconData;
375
+ let resKeys;
249
376
  try {
250
- const source = stamp(loadTemplate("golem-helper.py"), channelId);
251
- const prompt = stamp(loadTemplate("golem-tools.md"), channelId);
252
- if (source.includes("__CHANNEL_ID__") || prompt.includes("__CHANNEL_ID__")) {
253
- throw new Error("template stamping failed (placeholder left behind)");
254
- }
255
- files = { source, prompt, instructions: SETUP_NOTE };
377
+ const enc = encodeURIComponent(channel);
378
+ const bb = await fetchText(`${DB_URL}/channels/${enc}/beacons.json?orderBy=${encodeURIComponent('"$key"')}&limitToLast=5`, {
379
+ timeoutMs: 30000,
380
+ });
381
+ beaconData = bb.text === "null" ? {} : JSON.parse(bb.text);
382
+ if (typeof beaconData !== "object" || beaconData === null) beaconData = {};
383
+ const rb = await fetchText(`${DB_URL}/channels/${enc}/res.json?shallow=true`, { timeoutMs: 30000 });
384
+ resKeys = rb.text === "null" ? {} : JSON.parse(rb.text);
385
+ if (typeof resKeys !== "object" || resKeys === null) resKeys = {};
256
386
  } catch (err) {
257
- fail(`cannot prepare session files: ${err.message}`, 1);
387
+ return { ok: false, error: `cannot read the relay channel: ${err.message}` };
258
388
  }
389
+ const results = Object.values(resKeys).filter((v) => v === true).length;
390
+ const beacons = Object.values(beaconData)
391
+ .filter((e) => e && (e.op === "hello" || e.op === "paused" || e.op === "revoked"))
392
+ .map((e) => [parseFloat(e.ts) || 0, e]);
393
+ if (!beacons.length) {
394
+ const verdict = results
395
+ ? `no beacons, but ${results} cached result(s) - Studio is not running or runs an older plugin; ask the user to fully restart Studio with the current plugin`
396
+ : "result channel is empty - the plugin has never posted here: Studio is closed or was not restarted after a plugin update";
397
+ return { ok: true, beacon: null, ageSeconds: null, recentResults: results, verdict };
398
+ }
399
+ beacons.sort((a, b) => a[0] - b[0]);
400
+ const [ts, beacon] = beacons[beacons.length - 1];
401
+ const age = Math.max(0, Math.floor(Date.now() / 1000 - ts));
402
+ const ver = beacon.v || "?";
403
+ const verdict =
404
+ age <= 900
405
+ ? `plugin is LIVE (v${ver}, hello beacon ${age}s ago) - it should answer commands`
406
+ : `last hello was ${Math.floor(age / 60)} min ago (v${ver}) - Studio may be closed or hung since then; ask the user to check the Golem window`;
407
+ return { ok: true, beacon, ageSeconds: age, recentResults: results, verdict };
408
+ }
259
409
 
260
- if (printOnly) {
261
- console.log("===== golem-helper.py (not written) =====");
262
- console.log(files.source);
263
- console.log("===== golem-tools.md (not written) =====");
264
- console.log(files.prompt);
265
- console.log("===== connection note =====");
266
- console.log(files.instructions);
267
- return;
410
+ // ---------------------------------------------------------------- tool table
411
+ //
412
+ // pos: ["name"] = required, ["name", default] = optional, ["name", "+"]
413
+ // = one-or-more, 3rd element = choices, 4th = value type.
414
+ // flags: name: "bool"|"str"|"int"|"float"|"append" or [type, {flag, choices,
415
+ // default, required}]. Default flag spelling is the name with _ as -.
416
+
417
+ const GROUPS = [
418
+ ["Connection", ["ping", "debug", "status"]],
419
+ ["Exploring", ["list", "tree", "find", "grep", "count", "look"]],
420
+ ["Scripts and Lua", ["read", "script", "lua", "exec"]],
421
+ ["Organizing", ["delete", "move", "group", "duplicate", "rename", "selection", "waypoint", "undo", "attr", "tag"]],
422
+ ["Moving", ["rotate", "face", "shift", "scale", "place", "pivot"]],
423
+ ["Surfaces and physics", ["paint", "anchor", "collide", "terrain"]],
424
+ ["Gameplay", ["light", "sound", "prompt", "hitbox", "particles", "sign", "scatter", "match", "weld"]],
425
+ ["Effects", ["beam", "trail", "explosion"]],
426
+ ["UI", ["ui_screen", "ui_frame", "ui_label", "ui_button", "ui_input", "ui_image", "ui_list"]],
427
+ ["Marketplace", ["search", "info", "insert", "apply"]],
428
+ ["Playtesting", ["play", "stop", "logs"]],
429
+ ["Session", ["say", "turn"]],
430
+ ];
431
+
432
+ const TOOLS = {
433
+ ping: { help: "health check - is Studio connected?" },
434
+ debug: { help: "diagnostics: relay round-trip, marketplace reachability, HTTP state" },
435
+ status: { help: "is the plugin alive, paused, or the link revoked? (no Studio needed)", local: true },
436
+ exec: { help: 'send a raw op JSON: {"op":..., "args":...}', pos: [["json"]] },
437
+ lua: { help: "run Lua inside Studio (code arg, or - for stdin)", op: "run", pos: [["code", null]] },
438
+ list: { help: "children of a path", pos: [["path", "game"]], flags: { recursive: "bool", max: "int" } },
439
+ tree: { help: "recursive tree of a path", pos: [["path", "game"]], flags: { depth: "int" } },
440
+ read: { help: "read an instance (script source by default, --json for full record)", pos: ["path"], flags: { json: "bool", props: "str" } },
441
+ find: { help: "find instances by name or --tag", pos: [["query", ""]], flags: { cls: ["str", { flag: "--class" }], scope: ["str", { default: "game" }], max: "int", exact: "bool", tag: "str" } },
442
+ grep: { help: "search script sources", pos: ["pattern"], flags: { scope: ["str", { default: "game" }], max: "int", i: ["bool", { flag: "-i" }] } },
443
+ script: { help: "create/update a script (source from stdin or --source)", pos: ["parent", "name"], flags: { cls: ["str", { flag: "--class", default: "Script", choices: ["Script", "LocalScript", "ModuleScript"] }], mode: ["str", { choices: ["create", "update", "replace"], default: "create" }], source: "str" } },
444
+ delete: { help: "delete instances", pos: [["paths", "+"]] },
445
+ move: { help: "reparent an instance", pos: ["path", "parent"] },
446
+ selection: { help: "read or set the Studio selection", pos: [], flags: { set: "str", clear: "bool" } },
447
+ waypoint: { help: "set an undo checkpoint", pos: [["label", "bridge"]] },
448
+ say: { help: "post a message to the user's Studio chat (closes the turn)", pos: ["text"] },
449
+ turn: { help: "mark work turns: begin ... tools ... end --note (required protocol)", pos: [["action", undefined, ["begin", "end"]]], flags: { note: "str" } },
450
+ rotate: { help: "rotate: --axis y --degrees 90 (relative) or --set 0,90,0 (absolute)", pos: ["path"], flags: { axis: "str", degrees: "float", absolute: ["str", { flag: "--set" }], space: ["str", { choices: ["world", "local"], default: "world" }] } },
451
+ face: { help: "aim an instance's axis at a world point (keeps position)", pos: ["path", "target"], flags: { axis: ["str", { default: "forward" }] } },
452
+ shift: { help: "move by an offset in studs (world or local)", pos: ["path", "offset"], flags: { space: ["str", { choices: ["world", "local"], default: "world" }] } },
453
+ scale: { help: "scale a part/model by a relative factor", pos: ["path", ["factor", undefined, undefined, "float"]] },
454
+ duplicate: { help: "clone an instance (optionally N times with spacing)", pos: ["path"], flags: { count: ["int", { default: 1 }], offset: "str", parent: "str", name: "str" } },
455
+ group: { help: "wrap instances into a Model", pos: [["paths", "+"]], flags: { name: ["str", { default: "Group" }], parent: "str" } },
456
+ pivot: { help: "set a model/part pivot (what it rotates around)", op: "set_pivot", pos: ["path"], flags: { position: "str", orientation: "str" } },
457
+ place: { help: "absolute position via pivot (parts and models)", pos: ["path", "position"], flags: { orientation: "str" } },
458
+ paint: { help: "set color/material/transparency/reflectance on parts", pos: [["paths", "+"]], flags: { color: "str", material: "str", transparency: "float", reflectance: "float" } },
459
+ rename: { help: "rename an instance", pos: ["path", "name"] },
460
+ look: { help: "aim the Studio editor camera at a path", pos: ["path"], flags: { distance: ["float", { default: 30 }] } },
461
+ count: { help: "count instances (cheap, no payloads)", pos: [["scope", "game"]], flags: { cls: ["str", { flag: "--class" }] } },
462
+ undo: { help: "one Studio undo step (Ctrl+Z)" },
463
+ anchor: { help: "anchor parts in place (models: all their parts)", pos: [["paths", "+"]], flags: { off: "bool" } },
464
+ collide: { help: "enable part collision (models: all their parts)", pos: [["paths", "+"]], flags: { off: "bool" } },
465
+ light: { help: "add or update a light inside a part", pos: ["path"], flags: { light_type: ["str", { flag: "--type", default: "point", choices: ["point", "spot", "surface"] }], color: "str", range: "float", brightness: "float", shadows: "bool" } },
466
+ sound: { help: "add a Sound to a parent, optionally play it", pos: ["parent", "id"], flags: { volume: "float", loop: "bool", play: "bool", name: "str" } },
467
+ weld: { help: "weld a model's parts together with WeldConstraints", pos: [["paths", "+"]] },
468
+ hitbox: { help: "invisible hitbox part sized to a target", pos: ["path"], flags: { padding: ["float", { default: 0.5 }], name: "str", collide: "bool" } },
469
+ prompt: { help: "add a ProximityPrompt to a part (Press E to ...)", pos: ["path", "action"], flags: { object: "str", hold: ["float", { default: 0 }], distance: ["float", { default: 8 }] } },
470
+ particles: { help: "attach a particle preset to a part", pos: ["path", ["preset", undefined, ["leaves", "sparks", "smoke", "magic", "fire", "snow", "rain", "bubbles", "dust", "confetti", "fireflies"]]], flags: { rate: "float", color: "str" } },
471
+ sign: { help: "place a readable wooden sign", pos: ["text"], flags: { position: "str", parent: "str", size: "str", name: "str" } },
472
+ beam: { help: "glowing beam between two parts", pos: ["from", "to"], flags: { color: "str", width: "float", curve: "float", name: "str" } },
473
+ trail: { help: "motion trail on a moving part", pos: ["path"], flags: { color: "str", lifetime: "float", name: "str" } },
474
+ explosion: { help: "one-shot explosion (visual only)", pos: [], flags: { position: "str", radius: "float" } },
475
+ ui_screen: { help: "create a ScreenGui under StarterGui", pos: ["name"], flags: { parent: "str", order: "int" } },
476
+ ui_frame: { help: "rounded panel/frame", pos: ["parent", "name"], flags: { position: "str", size: "str", anchor: "str", color: "str", transparency: "float", radius: "int", clip: "bool" } },
477
+ ui_label: { help: "text label", pos: ["parent", "name"], flags: { text: "str", position: "str", size: "str", anchor: "str", color: "str", align: "str", font: "str", text_size: "int", wrap: "bool" } },
478
+ ui_button: { help: "text button", pos: ["parent", "name"], flags: { text: "str", position: "str", size: "str", anchor: "str", color: "str", text_color: "str", radius: "int", font: "str", text_size: "int" } },
479
+ ui_input: { help: "TextBox the player can type into", pos: ["parent", "name"], flags: { placeholder: "str", text: "str", position: "str", size: "str", color: "str", background: "str", radius: "int", text_size: "int" } },
480
+ ui_image: { help: "ImageLabel from a Roblox asset id", pos: ["parent", "name"], flags: { asset: "str", position: "str", size: "str", scale: "str" } },
481
+ ui_list: { help: "UIListLayout that auto-arranges a container's children", pos: ["parent"], flags: { direction: "str", padding: "int", halign: "str", valign: "str" } },
482
+ play: { help: "start play-testing the game (client when possible, Run mode fallback)", pos: [], flags: { mode: ["str", { choices: ["play", "run"] }] } },
483
+ stop: { help: "stop the running play test" },
484
+ logs: { help: "read Studio output - errors and warnings from the play test", pos: [], flags: { all: "bool", limit: "int", since: "float" } },
485
+ attr: { help: "read/set/clear Studio attributes on an instance", op: "attributes", pos: ["path"], flags: { set: "append", clear: "append" } },
486
+ tag: { help: "add/remove CollectionService tags", pos: [["paths", "+"]], flags: { add: "append", remove: "append" } },
487
+ match: { help: "copy color/material/transparency from one part onto targets", pos: ["from_path", ["to", "+"]] },
488
+ scatter: { help: "scatter N copies of a template in a disc around it", pos: ["path"], flags: { count: ["int", { default: 10 }], radius: ["float", { default: 20 }], y_jitter: ["float", { default: 0 }], parent: "str", name: "str" } },
489
+ terrain: { help: "fill or clear terrain (block or ball)", pos: [], flags: { action: ["str", { choices: ["fill", "clear"], default: "fill" }], shape: ["str", { choices: ["block", "ball"], default: "block" }], position: "str", size: "str", radius: "float", material: ["str", { default: "Grass" }] } },
490
+ search: { help: "search the Roblox Creator Store (models, meshes, images, audio)", pos: ["query"], flags: { category: ["str", { default: "model" }], limit: "int", cursor: "str" }, local: true },
491
+ info: { help: "details + thumbnail for one marketplace asset", pos: ["id"], local: true },
492
+ insert: { help: "insert a marketplace asset into the place", op: "insert_asset", pos: ["id", ["parent", "Workspace"]], flags: { name: "str" } },
493
+ apply: { help: "apply an asset id to a property (Image, Texture, SoundId, MeshId...)", op: "apply_asset", pos: ["id", "path", "prop"] },
494
+ };
495
+ function stripTimeout(argv) {
496
+ const out = [];
497
+ let timeout = null;
498
+ for (let i = 0; i < argv.length; i++) {
499
+ const a = argv[i];
500
+ if (a === "--timeout" && i + 1 < argv.length) { timeout = parseFloat(argv[++i]); continue; }
501
+ if (a.startsWith("--timeout=")) { timeout = parseFloat(a.slice(10)); continue; }
502
+ out.push(a);
268
503
  }
504
+ if (timeout !== null && !(timeout > 0)) throw new UsageError("--timeout must be a positive number of seconds");
505
+ return { args: out, timeout };
506
+ }
269
507
 
270
- const dir = path.join(process.cwd(), ".golem");
271
- const pyHash = sha256(files.source);
272
- const mdHash = sha256(files.prompt);
273
- if (!autoYes) {
274
- console.log("Ready to write 2 files (they ship in this package \u2014 nothing was downloaded):");
275
- console.log(" " + path.join(".golem", "golem-helper.py") + " (" + files.source.length + " bytes, sha256:" + pyHash.slice(0, 16) + "...) \u2014 the Studio helper");
276
- console.log(" " + path.join(".golem", "golem-tools.md") + " (" + files.prompt.length + " bytes, sha256:" + mdHash.slice(0, 16) + "...) \u2014 the tool manual");
277
- if (!(await askYes("Install these files? [y/n]"))) {
278
- fail("aborted: nothing was written. Re-run with --yes to skip this question, or --print to inspect first.", 1);
508
+ function parseToolArgs(name, argv) {
509
+ const spec = TOOLS[name];
510
+ const ns = {};
511
+ const bools = {}, vals = {}, shorts = {};
512
+ for (const dest of Object.keys(spec.flags || {})) {
513
+ const fs = spec.flags[dest];
514
+ const t = Array.isArray(fs) ? fs[0] : fs;
515
+ const o = Array.isArray(fs) ? (fs[1] || {}) : {};
516
+ const flag = o.flag || "--" + dest.replace(/_/g, "-");
517
+ if (o.short) shorts[o.short] = dest;
518
+ if (t === "bool") { ns[dest] = !!o.invert; bools[flag] = { dest, invert: !!o.invert }; }
519
+ else { ns[dest] = t === "append" ? [] : (o.default !== undefined ? o.default : null); vals[flag] = { dest, type: t, choices: o.choices, multi: t === "append" }; }
520
+ }
521
+ const coerce = (fl, raw) => {
522
+ let v = raw;
523
+ if (fl.type === "int") { v = parseInt(raw, 10); if (!Number.isInteger(v)) throw new UsageError(`${name}: bad integer for ${fl.dest}: ${raw}`); }
524
+ else if (fl.type === "float") { v = parseFloat(raw); if (!Number.isFinite(v)) throw new UsageError(`${name}: bad number for ${fl.dest}: ${raw}`); }
525
+ if (fl.choices && !fl.choices.includes(fl.type === "int" ? v : String(v))) throw new UsageError(`${name}: bad value for ${fl.dest}: ${raw} (need ${fl.choices.join("|")})`);
526
+ return v;
527
+ };
528
+ const pos = [];
529
+ let i = 0, ddash = false;
530
+ while (i < argv.length) {
531
+ const t = argv[i];
532
+ if (ddash) { pos.push(t); i++; continue; }
533
+ if (t === "--") { ddash = true; i++; continue; }
534
+ if (t.startsWith("--")) {
535
+ const eq = t.indexOf("=");
536
+ const fl = eq === -1 ? t : t.slice(0, eq);
537
+ if (fl in bools) {
538
+ if (eq !== -1) throw new UsageError(`${name}: ${fl} takes no value`);
539
+ ns[bools[fl].dest] = !bools[fl].invert; i++; continue;
540
+ }
541
+ const v = vals[fl];
542
+ if (!v) throw new UsageError(`${name}: unknown flag ${fl}`);
543
+ let raw;
544
+ if (eq !== -1) raw = t.slice(eq + 1);
545
+ else { i++; if (i >= argv.length) throw new UsageError(`${name}: ${fl} needs a value`); raw = argv[i]; }
546
+ const c = coerce(v, raw);
547
+ if (v.multi) ns[v.dest].push(c); else ns[v.dest] = c;
548
+ i++; continue;
549
+ }
550
+ if (t in bools) { ns[bools[t].dest] = !bools[t].invert; i++; continue; }
551
+ if (t.startsWith("-") && t.length > 1 && !/^-\d/.test(t)) {
552
+ for (const c of t.slice(1)) {
553
+ if (!(c in shorts)) throw new UsageError(`${name}: unknown flag -${c}`);
554
+ ns[shorts[c]] = true;
555
+ }
556
+ i++; continue;
279
557
  }
558
+ pos.push(t); i++;
280
559
  }
281
- fs.mkdirSync(dir, { recursive: true });
282
- removeStaleHelpers(dir);
560
+ const out = {};
561
+ let pi = 0;
562
+ for (const p of (spec.pos || [])) {
563
+ const a = Array.isArray(p) ? p : [p];
564
+ const pname = a[0], pdef = a[1], pchoices = a[2], ptype = a[3];
565
+ if (pdef === "+") {
566
+ if (pi >= pos.length) throw new UsageError(`${name}: need at least one ${pname}`);
567
+ out[pname] = pos.slice(pi); pi = pos.length;
568
+ } else if (pi < pos.length) {
569
+ const raw = pos[pi++];
570
+ let v = raw;
571
+ if (ptype === "int") { v = parseInt(raw, 10); if (!Number.isInteger(v)) throw new UsageError(`${name}: bad integer for ${pname}: ${raw}`); }
572
+ else if (ptype === "float") { v = parseFloat(raw); if (!Number.isFinite(v)) throw new UsageError(`${name}: bad number for ${pname}: ${raw}`); }
573
+ if (pchoices && !pchoices.includes(ptype ? v : String(v))) throw new UsageError(`${name}: bad ${pname}: ${raw} (need ${pchoices.join("|")})`);
574
+ out[pname] = v;
575
+ } else if (pdef !== undefined) out[pname] = pdef;
576
+ else throw new UsageError(`${name}: missing ${pname}`);
577
+ }
578
+ if (pi < pos.length) throw new UsageError(`${name}: too many arguments (got "${pos[pi]}")`);
579
+ return Object.assign(ns, out);
580
+ }
581
+ function scalar(s) {
582
+ const t = s.trim();
583
+ if (/^[+-]?\d+$/.test(t)) { const n = parseInt(t, 10); if (Number.isSafeInteger(n)) return n; }
584
+ if (/^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$/.test(t)) { const f = parseFloat(t); if (Number.isFinite(f)) return f; }
585
+ if (t === "true") return true;
586
+ if (t === "false") return false;
587
+ return s;
588
+ }
283
589
 
284
- const pyPath = path.join(dir, "golem-helper.py");
285
- const mdPath = path.join(dir, "golem-tools.md");
286
- fs.writeFileSync(pyPath, files.source);
287
- fs.writeFileSync(mdPath, files.prompt);
590
+ function buildArgs(name, ns) {
591
+ const V = (s) => vec3(s);
592
+ switch (name) {
593
+ case "exec": {
594
+ let raw;
595
+ try { raw = JSON.parse(ns.json); } catch { throw new UsageError("exec: invalid JSON"); }
596
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("op" in raw))
597
+ throw new UsageError('exec: JSON must be an object with an "op" key');
598
+ return { op: raw.op, args: (raw.args && typeof raw.args === "object" && !Array.isArray(raw.args)) ? raw.args : {} };
599
+ }
600
+ case "lua": {
601
+ let code = ns.code;
602
+ if (code === "-" || code == null) code = process.stdin.isTTY ? "" : readStdin();
603
+ if (!code) throw new UsageError("no Lua code given (pass it as an argument or pipe it in)");
604
+ return { op: "run", args: { code } };
605
+ }
606
+ case "list": {
607
+ const a = { path: ns.path };
608
+ if (ns.recursive) a.recursive = true;
609
+ if (ns.max) a.max = ns.max;
610
+ return { op: "list", args: a };
611
+ }
612
+ case "tree":
613
+ return { op: "tree", args: { path: ns.path, depth: ns.depth || 2 } };
614
+ case "read": {
615
+ const a = { path: ns.path };
616
+ if (ns.props) a.props = ns.props.split(",").map((s) => s.trim()).filter(Boolean);
617
+ return { op: "read", args: a, asJson: !!ns.json, rawField: "source" };
618
+ }
619
+ case "find": {
620
+ const a = { scope: ns.scope };
621
+ if (ns.tag) a.tag = ns.tag;
622
+ else a.query = ns.query;
623
+ if (ns.cls) a.class = ns.cls;
624
+ if (ns.max) a.max = ns.max;
625
+ if (ns.exact) a.exact = true;
626
+ return { op: "find", args: a };
627
+ }
628
+ case "grep": {
629
+ const a = { pattern: ns.pattern, scope: ns.scope };
630
+ if (ns.max) a.max = ns.max;
631
+ if (ns.i) a.caseSensitive = false;
632
+ return { op: "grep", args: a };
633
+ }
634
+ case "script": {
635
+ let source = ns.source;
636
+ if (source == null && !process.stdin.isTTY) source = readStdin();
637
+ if (source == null) throw new UsageError("pass the script source via stdin (heredoc/pipe) or --source");
638
+ return { op: "script", args: { parent: ns.parent, name: ns.name, class: ns.cls, mode: ns.mode, source } };
639
+ }
640
+ case "delete":
641
+ return { op: "delete", args: { paths: ns.paths } };
642
+ case "move":
643
+ return { op: "move", args: { path: ns.path, parent: ns.parent } };
644
+ case "selection": {
645
+ if (ns.clear) return { op: "selection", args: { clear: true } };
646
+ if (ns.set) return { op: "selection", args: { set: ns.set.split(",").map((s) => s.trim()).filter(Boolean) } };
647
+ return { op: "selection", args: {} };
648
+ }
649
+ case "waypoint":
650
+ return { op: "waypoint", args: { label: ns.label } };
651
+ case "say":
652
+ return { op: "say", args: { text: ns.text } };
653
+ case "turn": {
654
+ if (ns.action === "begin") return { op: "turn_begin", args: {} };
655
+ const a = {};
656
+ if (ns.note) a.note = ns.note;
657
+ return { op: "turn_end", args: a };
658
+ }
659
+ case "rotate": {
660
+ const a = { path: ns.path, space: ns.space };
661
+ if (ns.absolute != null) a.orientation = V(ns.absolute);
662
+ else { a.axis = ns.axis != null ? ns.axis : null; a.degrees = ns.degrees != null ? ns.degrees : null; }
663
+ return { op: "rotate", args: a };
664
+ }
665
+ case "face":
666
+ return { op: "face", args: { path: ns.path, target: V(ns.target), axis: ns.axis } };
667
+ case "shift":
668
+ return { op: "shift", args: { path: ns.path, offset: V(ns.offset), space: ns.space } };
669
+ case "scale":
670
+ return { op: "scale", args: { path: ns.path, factor: ns.factor } };
671
+ case "duplicate": {
672
+ const a = { path: ns.path, count: ns.count };
673
+ if (ns.offset) a.offset = V(ns.offset);
674
+ if (ns.parent) a.parent = ns.parent;
675
+ if (ns.name) a.name = ns.name;
676
+ return { op: "duplicate", args: a };
677
+ }
678
+ case "group": {
679
+ const a = { paths: ns.paths, name: ns.name };
680
+ if (ns.parent) a.parent = ns.parent;
681
+ return { op: "group", args: a };
682
+ }
683
+ case "pivot": {
684
+ const a = { path: ns.path };
685
+ if (ns.position) a.position = V(ns.position);
686
+ if (ns.orientation) a.orientation = V(ns.orientation);
687
+ return { op: "set_pivot", args: a };
688
+ }
689
+ case "place": {
690
+ const a = { path: ns.path, position: V(ns.position) };
691
+ if (ns.orientation) a.orientation = V(ns.orientation);
692
+ return { op: "place", args: a };
693
+ }
694
+ case "paint": {
695
+ const a = { paths: ns.paths };
696
+ if (ns.color) a.color = ns.color;
697
+ if (ns.material) a.material = ns.material;
698
+ if (ns.transparency != null) a.transparency = ns.transparency;
699
+ if (ns.reflectance != null) a.reflectance = ns.reflectance;
700
+ return { op: "paint", args: a };
701
+ }
702
+ case "rename":
703
+ return { op: "rename", args: { path: ns.path, name: ns.name } };
704
+ case "look":
705
+ return { op: "look", args: { path: ns.path, distance: ns.distance } };
706
+ case "count": {
707
+ const a = { scope: ns.scope };
708
+ if (ns.cls) a.class = ns.cls;
709
+ return { op: "count", args: a };
710
+ }
711
+ case "undo":
712
+ return { op: "undo", args: {} };
713
+ case "anchor":
714
+ return { op: "anchor", args: { paths: ns.paths, anchored: !ns.off } };
715
+ case "collide":
716
+ return { op: "collide", args: { paths: ns.paths, canCollide: !ns.off } };
717
+ case "light": {
718
+ const a = { path: ns.path, type: ns.light_type };
719
+ if (ns.color) a.color = ns.color;
720
+ if (ns.range != null) a.range = ns.range;
721
+ if (ns.brightness != null) a.brightness = ns.brightness;
722
+ if (ns.shadows) a.shadows = true;
723
+ return { op: "light", args: a };
724
+ }
725
+ case "sound": {
726
+ const a = { parent: ns.parent, id: ns.id };
727
+ if (ns.volume != null) a.volume = ns.volume;
728
+ if (ns.loop) a.looped = true;
729
+ if (ns.play) a.play = true;
730
+ if (ns.name) a.name = ns.name;
731
+ return { op: "sound", args: a };
732
+ }
733
+ case "weld":
734
+ return { op: "weld", args: { paths: ns.paths } };
735
+ case "hitbox": {
736
+ const a = { path: ns.path, padding: ns.padding };
737
+ if (ns.name) a.name = ns.name;
738
+ if (ns.collide) a.canCollide = true;
739
+ return { op: "hitbox", args: a };
740
+ }
741
+ case "prompt": {
742
+ const a = { path: ns.path, action: ns.action, hold: ns.hold, distance: ns.distance };
743
+ if (ns.object) a.object = ns.object;
744
+ return { op: "prompt", args: a };
745
+ }
746
+ case "particles": {
747
+ const a = { path: ns.path, preset: ns.preset };
748
+ if (ns.rate != null) a.rate = ns.rate;
749
+ if (ns.color) a.color = ns.color;
750
+ return { op: "particles", args: a };
751
+ }
752
+ case "sign": {
753
+ const a = { text: ns.text };
754
+ if (ns.position) a.position = V(ns.position);
755
+ if (ns.parent) a.parent = ns.parent;
756
+ if (ns.size) a.size = V(ns.size);
757
+ if (ns.name) a.name = ns.name;
758
+ return { op: "sign", args: a };
759
+ }
760
+ case "beam": {
761
+ const a = { from: ns.from, to: ns.to };
762
+ for (const k of ["color", "width", "curve", "name"]) if (ns[k] != null) a[k] = ns[k];
763
+ return { op: "beam", args: a };
764
+ }
765
+ case "trail": {
766
+ const a = { path: ns.path };
767
+ for (const k of ["color", "lifetime", "name"]) if (ns[k] != null) a[k] = ns[k];
768
+ return { op: "trail", args: a };
769
+ }
770
+ case "explosion": {
771
+ const a = {};
772
+ if (ns.position) a.position = V(ns.position);
773
+ if (ns.radius != null) a.radius = ns.radius;
774
+ return { op: "explosion", args: a };
775
+ }
776
+ case "ui_screen": {
777
+ const a = { name: ns.name };
778
+ if (ns.parent) a.parent = ns.parent;
779
+ if (ns.order != null) a.order = ns.order;
780
+ return { op: "ui_screen", args: a };
781
+ }
782
+ case "ui_frame": {
783
+ const a = { parent: ns.parent, name: ns.name };
784
+ for (const k of ["position", "size", "anchor", "color", "transparency", "radius"]) if (ns[k] != null) a[k] = ns[k];
785
+ if (ns.clip) a.clip = true;
786
+ return { op: "ui_frame", args: a };
787
+ }
788
+ case "ui_label": {
789
+ const a = { parent: ns.parent, name: ns.name };
790
+ for (const k of ["text", "position", "size", "anchor", "color", "align", "font", "text_size"]) if (ns[k] != null) a[k] = ns[k];
791
+ if (ns.wrap) a.wrap = true;
792
+ return { op: "ui_label", args: a };
793
+ }
794
+ case "ui_button": {
795
+ const a = { parent: ns.parent, name: ns.name };
796
+ for (const k of ["text", "position", "size", "anchor", "color", "text_color", "radius", "font", "text_size"]) if (ns[k] != null) a[k] = ns[k];
797
+ return { op: "ui_button", args: a };
798
+ }
799
+ case "ui_input": {
800
+ const a = { parent: ns.parent, name: ns.name };
801
+ for (const k of ["placeholder", "text", "position", "size", "color", "background", "radius", "text_size"]) if (ns[k] != null) a[k] = ns[k];
802
+ return { op: "ui_input", args: a };
803
+ }
804
+ case "ui_image": {
805
+ const a = { parent: ns.parent, name: ns.name };
806
+ if (ns.asset) a.asset = ns.asset;
807
+ for (const k of ["position", "size", "scale"]) if (ns[k] != null) a[k] = ns[k];
808
+ return { op: "ui_image", args: a };
809
+ }
810
+ case "ui_list": {
811
+ const a = { parent: ns.parent };
812
+ for (const k of ["direction", "padding", "halign", "valign"]) if (ns[k] != null) a[k] = ns[k];
813
+ return { op: "ui_list", args: a };
814
+ }
815
+ case "play": {
816
+ const a = {};
817
+ if (ns.mode) a.mode = ns.mode;
818
+ return { op: "play", args: a };
819
+ }
820
+ case "stop":
821
+ return { op: "stop", args: {} };
822
+ case "logs": {
823
+ const a = ns.all ? { filter: "all" } : {};
824
+ if (ns.limit != null) a.limit = ns.limit;
825
+ if (ns.since != null) a.since = ns.since;
826
+ return { op: "logs", args: a };
827
+ }
828
+ case "attr": {
829
+ const a = { path: ns.path };
830
+ if (ns.set && ns.set.length) {
831
+ const o = {};
832
+ for (const kv of ns.set) {
833
+ const e = kv.indexOf("=");
834
+ if (e === -1) throw new UsageError("--set expects K=V");
835
+ o[kv.slice(0, e)] = scalar(kv.slice(e + 1));
836
+ }
837
+ a.set = o;
838
+ }
839
+ if (ns.clear && ns.clear.length) a.clear = ns.clear;
840
+ return { op: "attributes", args: a };
841
+ }
842
+ case "tag": {
843
+ const a = { paths: ns.paths };
844
+ if (ns.add && ns.add.length) a.add = ns.add;
845
+ if (ns.remove && ns.remove.length) a.remove = ns.remove;
846
+ return { op: "tag", args: a };
847
+ }
848
+ case "match":
849
+ return { op: "match", args: { from: ns.from_path, paths: ns.to } };
850
+ case "scatter": {
851
+ const a = { path: ns.path, count: ns.count, radius: ns.radius, yJitter: ns.y_jitter };
852
+ if (ns.parent) a.parent = ns.parent;
853
+ if (ns.name) a.name = ns.name;
854
+ return { op: "scatter", args: a };
855
+ }
856
+ case "terrain": {
857
+ if (!ns.position) throw new UsageError("terrain: --position x,y,z is required");
858
+ const a = { action: ns.action, shape: ns.shape, position: V(ns.position), material: ns.material };
859
+ if (ns.shape === "block") {
860
+ if (!ns.size) throw new UsageError("--size x,y,z is required for block");
861
+ a.size = V(ns.size);
862
+ } else {
863
+ if (ns.radius == null) throw new UsageError("--radius is required for ball");
864
+ a.radius = ns.radius;
865
+ }
866
+ return { op: "terrain", args: a };
867
+ }
868
+ case "insert": {
869
+ const a = { id: ns.id, parent: ns.parent };
870
+ if (ns.name) a.name = ns.name;
871
+ return { op: "insert_asset", args: a };
872
+ }
873
+ case "apply":
874
+ return { op: "apply_asset", args: { id: ns.id, path: ns.path, prop: ns.prop } };
875
+ default:
876
+ throw new UsageError(`unknown tool: ${name}`);
877
+ }
878
+ }
879
+ function toolUsage(name) {
880
+ const spec = TOOLS[name];
881
+ const parts = [`npx golem-bridge ${name}`];
882
+ for (const p of (spec.pos || [])) {
883
+ const a = Array.isArray(p) ? p : [p];
884
+ if (a[1] === "+") parts.push(`<${a[0]}...>`);
885
+ else if (a[1] !== undefined) parts.push(`[${a[0]}]`);
886
+ else parts.push(`<${a[0]}>`);
887
+ }
888
+ if (spec.flags && Object.keys(spec.flags).length) parts.push("[options]");
889
+ return parts.join(" ");
890
+ }
288
891
 
892
+ function printHelp() {
893
+ console.log("golem-bridge: drive a live Roblox Studio session from any AI agent.");
894
+ console.log("");
895
+ console.log(" Connect to my Roblox Studio, Run: npx golem-bridge connect <id>");
896
+ console.log("");
897
+ console.log("Session: connect <id> | reconnect [id] | disconnect | manual | help [tool]");
898
+ for (const [group, names] of GROUPS) {
899
+ console.log("");
900
+ console.log(`${group}:`);
901
+ for (const n of names) console.log(` ${n} - ${TOOLS[n].help}`);
902
+ }
289
903
  console.log("");
290
- console.log(files.instructions);
904
+ console.log("See one tool: npx golem-bridge help <tool>. Full reference: npx golem-bridge manual.");
905
+ }
906
+
907
+ function toolHelp(name) {
908
+ const spec = TOOLS[name];
909
+ console.log(`${name} - ${spec.help}`);
291
910
  console.log("");
292
- console.log(
293
- `Wrote ${path.relative(process.cwd(), pyPath)} (${files.source.length} bytes, sha256:${sha256(files.source).slice(0, 16)}...)`
294
- );
295
- console.log(
296
- `Wrote ${path.relative(process.cwd(), mdPath)} (${files.prompt.length} bytes, sha256:${sha256(files.prompt).slice(0, 16)}...)`
297
- );
298
- console.log("These files were just downloaded from your Studio session — review them before running anything.");
299
- console.log(`Next: read ${path.relative(process.cwd(), mdPath)}, then run:`);
300
- console.log(` python3 ${path.relative(process.cwd(), pyPath)} ping`);
301
- }
302
-
303
- async function main() {
304
- const args = process.argv.slice(2);
305
- if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
306
- printHelp();
911
+ console.log(`Usage: ${toolUsage(name)}`);
912
+ const flags = spec.flags || {};
913
+ const names = Object.keys(flags);
914
+ if (names.length) {
915
+ console.log("");
916
+ console.log("Options:");
917
+ for (const d of names) {
918
+ const fs = flags[d];
919
+ const t = Array.isArray(fs) ? fs[0] : fs;
920
+ const o = Array.isArray(fs) ? (fs[1] || {}) : {};
921
+ let line = ` ${o.flag || "--" + d.replace(/_/g, "-")}`;
922
+ if (o.short) line += `, -${o.short}`;
923
+ if (t !== "bool") line += ` <${t === "append" ? "value (repeatable)" : "value"}>`;
924
+ if (o.choices) line += ` (${o.choices.join("|")})`;
925
+ if (o.default !== undefined) line += ` [default: ${o.default}]`;
926
+ console.log(line);
927
+ }
928
+ }
929
+ }
930
+
931
+ const MANUAL_FILE = path.join(__dirname, "golem-tools.md");
932
+
933
+ function printManual() {
934
+ try {
935
+ process.stdout.write(fs.readFileSync(MANUAL_FILE, "utf8").trimEnd() + "\n");
936
+ } catch {
937
+ fail("manual not found next to cli.js - reinstall the package.", 1);
938
+ }
939
+ }
940
+
941
+ async function connect(id, opts) {
942
+ opts = opts || {};
943
+ if (id == null) fail("connect: need the channel id from the Studio widget.", 2);
944
+ validateChannel(id);
945
+ const data = await relayCall(id, "ping", {}, opts.timeout || 60);
946
+ if (!data || !data.ok) {
947
+ console.error(JSON.stringify({ ok: false, error: "connect: Studio did not answer. Is the plugin running and the id exact?" }, null, 2));
948
+ process.exitCode = 1;
307
949
  return;
308
950
  }
309
- if (args[0] === "--version" || args[0] === "-V" || args[0] === "version") {
310
- console.log(VERSION);
951
+ const res = (data && typeof data.result === "object" && data.result) || {};
952
+ const place = res.place || res.placeName || data.place;
953
+ if (!opts.print) {
954
+ const dir = path.join(process.cwd(), ".golem");
955
+ removeStaleHelpers(dir);
956
+ fs.mkdirSync(dir, { recursive: true });
957
+ fs.writeFileSync(path.join(dir, "channel"), id + "\n");
958
+ console.log(`connected. channel saved to ${path.join(dir, "channel")}`);
959
+ }
960
+ if (place) console.log(`place: ${place}`);
961
+ printManual();
962
+ }
963
+
964
+ async function reconnect(id, opts) {
965
+ opts = opts || {};
966
+ const saved = loadChannel();
967
+ const target = id || saved;
968
+ if (!target) fail("reconnect: no saved channel and none given.", 2);
969
+ validateChannel(target);
970
+ const data = await relayCall(target, "ping", {}, opts.timeout || 60);
971
+ if (!data || !data.ok) {
972
+ console.error(JSON.stringify({ ok: false, error: "reconnect: Studio did not answer." }, null, 2));
973
+ process.exitCode = 1;
311
974
  return;
312
975
  }
313
- if (args[0] === "disconnect") {
314
- disconnectLocal();
976
+ if (target === saved) {
977
+ console.log("already connected to this channel.");
315
978
  return;
316
979
  }
317
- const isReconnect = args[0] === "reconnect";
318
- if (args[0] !== "connect" && !isReconnect) {
319
- printHelp();
320
- process.exit(2);
980
+ const dir = path.join(process.cwd(), ".golem");
981
+ removeStaleHelpers(dir);
982
+ fs.mkdirSync(dir, { recursive: true });
983
+ fs.writeFileSync(path.join(dir, "channel"), target + "\n");
984
+ console.log(`reconnected. channel saved to ${path.join(dir, "channel")}`);
985
+ }
986
+
987
+ function disconnect() {
988
+ const dir = path.join(process.cwd(), ".golem");
989
+ let removed = false;
990
+ for (const name of ["channel", "golem-helper.py", "golem-tools.md", "golem.py", "golem.md"]) {
991
+ const f = path.join(dir, name);
992
+ try {
993
+ if (fs.existsSync(f)) { fs.rmSync(f, { force: true }); removed = true; }
994
+ } catch {
995
+ // keep going
996
+ }
321
997
  }
322
- const channelId = args[1];
323
- const printOnly = args.includes("--print");
324
- const autoYes = args.includes("--yes") || args.includes("-y");
325
- if (!channelId || channelId.startsWith("-")) {
326
- printHelp();
327
- process.exit(2);
998
+ try { fs.rmdirSync(dir); } catch {
999
+ // stays if not empty
328
1000
  }
1001
+ console.log(removed ? "disconnected (local channel file removed)." : "already disconnected (nothing saved).");
1002
+ }
1003
+
1004
+ const TIMEOUT_MIN = { ping: 60, debug: 90, search_assets: 180, asset_info: 180, insert_asset: 300 };
1005
+
1006
+ async function runRelayTool(name, argv) {
1007
+ let args, timeout, ns;
1008
+ try {
1009
+ ({ args, timeout } = stripTimeout(argv));
1010
+ ns = parseToolArgs(name, args);
1011
+ } catch (err) {
1012
+ if (err instanceof UsageError) { console.error(`golem-bridge: ${err.message}`); process.exitCode = 2; return; }
1013
+ throw err;
1014
+ }
1015
+ let built;
1016
+ try {
1017
+ built = buildArgs(name, ns);
1018
+ } catch (err) {
1019
+ if (err instanceof UsageError) { console.error(`golem-bridge: ${err.message}`); process.exitCode = 2; return; }
1020
+ throw err;
1021
+ }
1022
+ const channel = requireChannel();
1023
+ if (!channel) return;
1024
+ const wait = Math.max(timeout != null ? timeout : DEFAULT_TIMEOUT, TIMEOUT_MIN[built.op] || 0);
1025
+ const entry = await relayCall(channel, built.op, built.args, wait);
1026
+ out(entry, { asJson: built.asJson, rawField: built.rawField });
1027
+ }
1028
+
1029
+ async function runLocalTool(name, argv) {
1030
+ let args, timeout, ns;
329
1031
  try {
330
- if (isReconnect) {
331
- await reconnect(channelId, printOnly, autoYes);
1032
+ ({ args, timeout } = stripTimeout(argv));
1033
+ ns = parseToolArgs(name, args);
1034
+ } catch (err) {
1035
+ if (err instanceof UsageError) { console.error(`golem-bridge: ${err.message}`); process.exitCode = 2; return; }
1036
+ throw err;
1037
+ }
1038
+ if (name === "status") { out(await status()); return; }
1039
+ if (name === "search" || name === "info") {
1040
+ if (!marketplaceViaPlugin()) {
1041
+ try {
1042
+ const result = name === "search"
1043
+ ? await mpSearch(ns.query, ns.category, ns.limit, ns.cursor)
1044
+ : await mpInfo(ns.id);
1045
+ out({ ok: true, op: name, result });
1046
+ } catch (err) {
1047
+ out({ ok: false, op: name, error: name === "search" ? `marketplace search failed: ${err.message}` : `asset info failed: ${err.message}` });
1048
+ }
1049
+ return;
1050
+ }
1051
+ const channel = requireChannel();
1052
+ if (!channel) return;
1053
+ const wait = Math.max(timeout != null ? timeout : DEFAULT_TIMEOUT, 180);
1054
+ if (name === "search") {
1055
+ const a = { query: ns.query, category: ns.category };
1056
+ if (ns.limit) a.limit = ns.limit;
1057
+ if (ns.cursor) a.cursor = ns.cursor;
1058
+ out(await relayCall(channel, "search_assets", a, wait));
332
1059
  } else {
333
- await connect(channelId, printOnly, autoYes);
1060
+ out(await relayCall(channel, "asset_info", { id: ns.id }, wait));
334
1061
  }
335
- } catch (err) {
336
- fail(err.message, 1);
337
1062
  }
338
1063
  }
339
1064
 
1065
+ async function main(argv) {
1066
+ const args = argv || process.argv.slice(2);
1067
+ if (!args.length || args[0] === "--help" || args[0] === "-h") { printHelp(); return; }
1068
+ if (args[0] === "--version" || args[0] === "-v") { console.log(VERSION); return; }
1069
+ const cmd = args[0];
1070
+ const rest = args.slice(1);
1071
+ if (cmd === "help") {
1072
+ if (!rest.length) { printHelp(); return; }
1073
+ if (Object.hasOwn(TOOLS, rest[0])) { toolHelp(rest[0]); return; }
1074
+ console.error(`golem-bridge: unknown tool "${rest[0]}"`);
1075
+ process.exitCode = 2;
1076
+ return;
1077
+ }
1078
+ if (cmd === "manual") { printManual(); return; }
1079
+ if (cmd === "disconnect") { disconnect(); return; }
1080
+ if (cmd === "connect" || cmd === "reconnect") {
1081
+ let print = false, timeout = null, id = null;
1082
+ for (let k = 0; k < rest.length; k++) {
1083
+ const a = rest[k];
1084
+ if (a === "--print") print = true;
1085
+ else if (a === "--yes" || a === "-y") { /* accepted for 2.x scripts; nothing to confirm anymore */ }
1086
+ else if (a === "--timeout" && k + 1 < rest.length) timeout = parseFloat(rest[++k]);
1087
+ else if (a.startsWith("--timeout=")) timeout = parseFloat(a.slice(10));
1088
+ else if (!a.startsWith("-") && id === null) id = a;
1089
+ else { console.error(`golem-bridge: ${cmd}: bad argument ${a}`); process.exitCode = 2; return; }
1090
+ }
1091
+ if (timeout != null && !(timeout > 0)) { console.error("golem-bridge: --timeout must be a positive number of seconds"); process.exitCode = 2; return; }
1092
+ if (cmd === "connect") await connect(id, { print, timeout });
1093
+ else await reconnect(id, { timeout });
1094
+ return;
1095
+ }
1096
+ if (Object.hasOwn(TOOLS, cmd)) {
1097
+ if (TOOLS[cmd].local) await runLocalTool(cmd, rest);
1098
+ else await runRelayTool(cmd, rest);
1099
+ return;
1100
+ }
1101
+ console.error(`golem-bridge: unknown command "${cmd}" (try: help)`);
1102
+ process.exitCode = 2;
1103
+ }
1104
+
340
1105
  if (require.main === module) {
341
- main();
1106
+ main().catch((err) => { console.error(`golem-bridge error: ${(err && err.message) || err}`); process.exitCode = 1; });
342
1107
  }
343
1108
 
344
- module.exports = { askYes, readSavedChannel, removeStaleHelpers };
1109
+ module.exports = { TOOLS, GROUPS, parseToolArgs, buildArgs, vec3, scalar, loadChannel, status, mpSearch, mpInfo, marketplaceViaPlugin, relayCall, UsageError, stripTimeout, removeStaleHelpers, disconnect, main };