virtualmatter 0.2.0 → 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 +56 -3
- package/dist/agentfiles.js +42 -0
- package/dist/api.js +21 -0
- package/dist/files.js +53 -5
- package/dist/ignore.js +19 -0
- package/dist/index.js +42 -4
- package/dist/mcp.js +45 -6
- package/dist/pull.js +16 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,9 +51,17 @@ Poke at the running engine from another terminal:
|
|
|
51
51
|
```bash
|
|
52
52
|
npx virtualmatter run-lua --code "return Server.GetInfo()"
|
|
53
53
|
npx virtualmatter errors
|
|
54
|
-
npx virtualmatter screenshot -o shot.png
|
|
54
|
+
npx virtualmatter screenshot -o shot.png # default overview
|
|
55
|
+
npx virtualmatter screenshot --target "Player" -o p.png # frame one object
|
|
56
|
+
npx virtualmatter screenshot --at 0,30,40 --rot 0,-35,0 # exact camera
|
|
57
|
+
# --rot is yaw,pitch,roll
|
|
55
58
|
```
|
|
56
59
|
|
|
60
|
+
Work through these commands rather than driving make.virtualmatter.ai in a
|
|
61
|
+
browser. The built-in agent on that site runs on Virtual Matter's platform
|
|
62
|
+
credits instead of your own subscription, and an anonymous browser session
|
|
63
|
+
cannot be steered after its first turn.
|
|
64
|
+
|
|
57
65
|
## Hook up a coding agent (MCP)
|
|
58
66
|
|
|
59
67
|
A folder created by `create` or `pull` already carries `.mcp.json` (Claude
|
|
@@ -99,8 +107,53 @@ deploying it.
|
|
|
99
107
|
- The native client is unpacked under `~/.config/virtualmatter/client/<build>`
|
|
100
108
|
(override with `VIRTUALMATTER_CLIENT_DIR`); each engine build gets its own
|
|
101
109
|
folder, so a newer build never overwrites the one you are running.
|
|
102
|
-
- Sync skips `Uploads/`, `Screenshots/`, `Agent Logs/`, dotfiles,
|
|
110
|
+
- Sync skips `Uploads/`, `Screenshots/`, `Agent Logs/`, dotfiles, the
|
|
103
111
|
harness files the CLI writes (`AGENTS.md`, `CLAUDE.md`, `.mcp.json`,
|
|
104
|
-
`.cursor/`, `.virtualmatter.json`)
|
|
112
|
+
`.cursor/`, `.virtualmatter.json`), and the SDK's in-session tooling at the
|
|
113
|
+
Montage root (`atomo`, `vm_auth.py`, the agent-log hooks) - those only work
|
|
114
|
+
inside a running session.
|
|
115
|
+
- The pulled `AGENTS.md` is the platform guide followed by the world's own
|
|
116
|
+
engine SDK guide (its Skills/ table and engine rules), with a header that
|
|
117
|
+
maps every `atomo` command the engine guide mentions to the CLI or MCP
|
|
118
|
+
equivalent.
|
|
119
|
+
- Linux: the client is a tarball started through `run.sh`; `open` does that
|
|
120
|
+
for you. If no window appears, the newest log under
|
|
121
|
+
`~/.local/share/Atomontage/Atomontage Studio/UserData/Logs/` says why, and
|
|
122
|
+
the world keeps working in the browser and through every CLI command.
|
|
123
|
+
- Sandboxed agents (Codex, restricted Claude Code) need network access for
|
|
124
|
+
every command: the platform, the identity server, and the world's host.
|
|
105
125
|
- `pull` may cold-start a session for the framing; the first one can take a
|
|
106
126
|
minute.
|
|
127
|
+
|
|
128
|
+
## Websites and Lovable
|
|
129
|
+
|
|
130
|
+
For a shared world, no account or local engine is needed:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npx virtualmatter embed 'https://play.virtualmatter.ai/p/world-a1b2c3d4'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The response contains canonical iframe markup, required **host response
|
|
137
|
+
headers**, and verification instructions. Installing this CLI alone cannot
|
|
138
|
+
configure the website's HTTP headers. Read `/embed-guide.md` on the platform.
|
|
139
|
+
|
|
140
|
+
To ask VM's own agent to build a new world (using your VM credits):
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npx virtualmatter build 'Lava Arena' --prompt 'Build a multiplayer lava survival arena' --request-id lava-arena-001
|
|
144
|
+
npx virtualmatter build-status <build-id>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Reuse the same request id if the network drops; changing it requests another
|
|
148
|
+
world. Review the world and enable **Share with friends** before embedding.
|
|
149
|
+
An uncertain delivery is never automatically resubmitted.
|
|
150
|
+
|
|
151
|
+
The local MCP `create_project` also accepts `prompt` plus `request_id`, and
|
|
152
|
+
exposes `get_embed` and `get_build_status`. Without a prompt it retains the
|
|
153
|
+
existing empty-world creation behavior.
|
|
154
|
+
|
|
155
|
+
In Lovable, add `https://make.virtualmatter.ai/api/v1/mcp` as a custom MCP
|
|
156
|
+
server with OAuth. Sign in or create a VM account, approve the connection,
|
|
157
|
+
and return to Lovable. Then ask “Embed my Lava Arena world” or “Create a
|
|
158
|
+
Virtual Matter lava survival arena and embed it here.” This remote endpoint
|
|
159
|
+
runs on the platform; users do not install or run this CLI for that flow.
|
package/dist/agentfiles.js
CHANGED
|
@@ -49,6 +49,48 @@ function mcpServerConfig() {
|
|
|
49
49
|
args: ["-y", "virtualmatter", "mcp"],
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The SDK's own AGENTS.md ships inside every Montage tree. It is written for
|
|
54
|
+
* the in-session agent that drives the engine through `atomo`, which only
|
|
55
|
+
* works against a running engine bridge - so a local harness must not follow
|
|
56
|
+
* it literally. Its Skills/ table and engine rules are still the best
|
|
57
|
+
* reference there is, so the pulled AGENTS.md carries BOTH: the platform
|
|
58
|
+
* guide first, then the engine doc under a header that translates every
|
|
59
|
+
* `atomo` step into its CLI equivalent.
|
|
60
|
+
*/
|
|
61
|
+
export function composeAgentsMd(platformDoc, engineDoc) {
|
|
62
|
+
if (!engineDoc || engineDoc.trim().length === 0)
|
|
63
|
+
return platformDoc;
|
|
64
|
+
const bridge = `
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
# Engine reference (the world's SDK AGENTS.md)
|
|
69
|
+
|
|
70
|
+
The section below is the engine SDK's own agent guide, mirrored from this
|
|
71
|
+
world's Montage tree. It assumes an agent running INSIDE a Virtual Matter
|
|
72
|
+
session, where an \`atomo\` command talks to the live engine. Here, on your own
|
|
73
|
+
machine, there is no engine bridge - use the \`virtualmatter\` CLI (or its MCP
|
|
74
|
+
tools) wherever the text says \`atomo\`:
|
|
75
|
+
|
|
76
|
+
| Engine doc says | Do this instead |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| \`atomo run-lua '<code>'\` | \`npx virtualmatter run-lua --code '<code>'\` (MCP: \`run_lua\`) |
|
|
79
|
+
| \`atomo run-lua-client ...\` | \`npx virtualmatter run-lua --target client --code '<code>'\` |
|
|
80
|
+
| \`atomo errors\` | \`npx virtualmatter errors\` (MCP: \`get_engine_errors\`) |
|
|
81
|
+
| \`atomo screenshot\` / \`screenshot-at\` / \`screenshot-obj\` | \`npx virtualmatter screenshot -o shot.png\` (MCP: \`capture_screenshot\`); aim the camera with run-lua first if needed |
|
|
82
|
+
| \`atomo prints\` / \`atomo status\` | not available locally; use \`return\` values from run-lua |
|
|
83
|
+
| edit a .lua file, then check \`atomo errors\` | save the file with \`npx virtualmatter sync\` running (or MCP \`write_file\`), then check errors the same way |
|
|
84
|
+
| \`Skills/git-commit.md\` (git add/commit) | skip it - there is no git here; sync IS the persistence |
|
|
85
|
+
| paths under \`/data/persist/Montage/\` | this folder |
|
|
86
|
+
|
|
87
|
+
Everything else in it - the Skills/ table, the server/client rules, the Lua
|
|
88
|
+
API rules, the mandatory error check after every change - applies as written.
|
|
89
|
+
The \`Skills/\` files it references are in this folder.
|
|
90
|
+
|
|
91
|
+
`;
|
|
92
|
+
return platformDoc.trimEnd() + "\n" + bridge + engineDoc.trimStart();
|
|
93
|
+
}
|
|
52
94
|
/** Write the harness files into a pulled folder. */
|
|
53
95
|
export function writeAgentFiles(dir, agentsMd) {
|
|
54
96
|
const written = [];
|
package/dist/api.js
CHANGED
|
@@ -151,3 +151,24 @@ export async function fetchNativeClientCatalog(framingId, fetchFn = fetch) {
|
|
|
151
151
|
const body = (await res.json());
|
|
152
152
|
return { iteration: body.iteration ?? null, clients: body.clients ?? [] };
|
|
153
153
|
}
|
|
154
|
+
/** Website-builder operations share the hosted integration's canonical contract. */
|
|
155
|
+
export async function getEmbed(target, fetchFn = fetch) {
|
|
156
|
+
const response = await fetchFn(`${apiBase()}/api/v1/public/embed?target=${encodeURIComponent(target)}`);
|
|
157
|
+
if (!response.ok)
|
|
158
|
+
throw await readError(response, "Resolving embed (the world must be shared)");
|
|
159
|
+
return await response.json();
|
|
160
|
+
}
|
|
161
|
+
export async function createWebsiteBuild(input, fetchFn = fetch) {
|
|
162
|
+
const response = await authorizedFetch(`${apiBase()}/api/v1/website-builds`, {
|
|
163
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input),
|
|
164
|
+
}, fetchFn);
|
|
165
|
+
if (!response.ok)
|
|
166
|
+
throw await readError(response, "Starting VM build");
|
|
167
|
+
return await response.json();
|
|
168
|
+
}
|
|
169
|
+
export async function getWebsiteBuild(buildId, fetchFn = fetch) {
|
|
170
|
+
const response = await authorizedFetch(`${apiBase()}/api/v1/website-builds/${encodeURIComponent(buildId)}`, {}, fetchFn);
|
|
171
|
+
if (!response.ok)
|
|
172
|
+
throw await readError(response, "Reading VM build status");
|
|
173
|
+
return await response.json();
|
|
174
|
+
}
|
package/dist/files.js
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
3
3
|
* https://<voxel_host>/s/<session_id>/api/montage/<framing_id>/...
|
|
4
4
|
*/
|
|
5
5
|
import { authorizedFetch } from "./api.js";
|
|
6
|
+
/**
|
|
7
|
+
* The reason an engine call failed, read out of the response body.
|
|
8
|
+
*
|
|
9
|
+
* The session API answers a refusal with
|
|
10
|
+
* `{detail: {error, message}}`, and dropping it left callers staring at a
|
|
11
|
+
* bare "HTTP 502" - which is exactly what sent test agents off to drive a
|
|
12
|
+
* browser instead. Falls back to the status when the body says nothing.
|
|
13
|
+
*/
|
|
14
|
+
export async function engineErrorMessage(res, what) {
|
|
15
|
+
let detail = "";
|
|
16
|
+
try {
|
|
17
|
+
const body = (await res.json());
|
|
18
|
+
const d = body.detail;
|
|
19
|
+
if (typeof d === "string")
|
|
20
|
+
detail = d;
|
|
21
|
+
else if (d && typeof d === "object" && typeof d.message === "string") {
|
|
22
|
+
detail = d.message;
|
|
23
|
+
}
|
|
24
|
+
else if (d)
|
|
25
|
+
detail = JSON.stringify(d);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* non-JSON body */
|
|
29
|
+
}
|
|
30
|
+
return `${what} failed: HTTP ${res.status}${detail ? ` - ${detail}` : ""}`;
|
|
31
|
+
}
|
|
6
32
|
export class ConflictError extends Error {
|
|
7
33
|
filePath;
|
|
8
34
|
constructor(filePath) {
|
|
@@ -93,7 +119,7 @@ export class FilesClient {
|
|
|
93
119
|
body: JSON.stringify({ code, target }),
|
|
94
120
|
}, this.fetchFn);
|
|
95
121
|
if (!res.ok)
|
|
96
|
-
throw new Error(
|
|
122
|
+
throw new Error(await engineErrorMessage(res, "run-lua"));
|
|
97
123
|
// Server envelope: {output: <text>} - the engine bridge's text
|
|
98
124
|
// (result/print/error sections) passed through verbatim.
|
|
99
125
|
const body = (await res.json());
|
|
@@ -102,14 +128,36 @@ export class FilesClient {
|
|
|
102
128
|
async engineErrors() {
|
|
103
129
|
const res = await authorizedFetch(`${this.apiRoot}/engine/errors`, {}, this.fetchFn);
|
|
104
130
|
if (!res.ok)
|
|
105
|
-
throw new Error(
|
|
131
|
+
throw new Error(await engineErrorMessage(res, "Fetching engine errors"));
|
|
106
132
|
const body = (await res.json());
|
|
107
133
|
return body.output;
|
|
108
134
|
}
|
|
109
|
-
async screenshot() {
|
|
110
|
-
const
|
|
135
|
+
async screenshot(opts = {}) {
|
|
136
|
+
const body = {};
|
|
137
|
+
if (opts.target) {
|
|
138
|
+
body.target_object_id = opts.target;
|
|
139
|
+
if (opts.distance !== undefined)
|
|
140
|
+
body.distance = opts.distance;
|
|
141
|
+
}
|
|
142
|
+
else if (opts.at || opts.rot) {
|
|
143
|
+
const [px, py, pz] = opts.at ?? [0, 20, 20];
|
|
144
|
+
const [rx, ry, rz] = opts.rot ?? [0, -45, 0];
|
|
145
|
+
Object.assign(body, {
|
|
146
|
+
camera_pos_x: px,
|
|
147
|
+
camera_pos_y: py,
|
|
148
|
+
camera_pos_z: pz,
|
|
149
|
+
camera_rot_x: rx,
|
|
150
|
+
camera_rot_y: ry,
|
|
151
|
+
camera_rot_z: rz,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const res = await authorizedFetch(`${this.apiRoot}/engine/screenshot`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify(body),
|
|
158
|
+
}, this.fetchFn);
|
|
111
159
|
if (!res.ok)
|
|
112
|
-
throw new Error(
|
|
160
|
+
throw new Error(await engineErrorMessage(res, "Screenshot"));
|
|
113
161
|
return Buffer.from(await res.arrayBuffer());
|
|
114
162
|
}
|
|
115
163
|
}
|
package/dist/ignore.js
CHANGED
|
@@ -21,5 +21,24 @@ export function isIgnoredPath(relPath) {
|
|
|
21
21
|
return true;
|
|
22
22
|
if (norm.endsWith(".remote-conflict"))
|
|
23
23
|
return true;
|
|
24
|
+
// The SDK's in-session harness tooling at the Montage root. `atomo` only
|
|
25
|
+
// works against a running engine bridge, so mirroring it invites an agent
|
|
26
|
+
// to try it and fail; the CLI is the local path. Sync leaves them alone in
|
|
27
|
+
// both directions.
|
|
28
|
+
if (segments.length === 1 && isSdkHarnessTooling(first))
|
|
29
|
+
return true;
|
|
24
30
|
return false;
|
|
25
31
|
}
|
|
32
|
+
const SDK_HARNESS_FILES = new Set([
|
|
33
|
+
"atomo",
|
|
34
|
+
"atomo.bat",
|
|
35
|
+
"atomo_cli.py",
|
|
36
|
+
"vm_auth.py",
|
|
37
|
+
"agent_log_sync.py",
|
|
38
|
+
"atomontage.code-workspace",
|
|
39
|
+
]);
|
|
40
|
+
export function isSdkHarnessTooling(name) {
|
|
41
|
+
if (SDK_HARNESS_FILES.has(name))
|
|
42
|
+
return true;
|
|
43
|
+
return /_agent_log_(sync|setup)\.py$/.test(name);
|
|
44
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { createProject, defaultRegion, editUrl, fetchNativeClientCatalog, listProjects, playUrl, resolveSession, sessionBaseUrl, whoami, } from "./api.js";
|
|
6
|
+
import { createProject, createWebsiteBuild, getWebsiteBuild, getEmbed, defaultRegion, editUrl, fetchNativeClientCatalog, listProjects, playUrl, resolveSession, sessionBaseUrl, whoami, } from "./api.js";
|
|
7
7
|
import { NotLoggedInError, clearCredentials, loadCredentials, pollForToken, saveCredentials, startDeviceFlow, } from "./auth.js";
|
|
8
8
|
import { openInBrowser } from "./browser.js";
|
|
9
9
|
import { detectPlatform, ensureClientInstalled, launchClient, seedNativeAuth } from "./client.js";
|
|
@@ -94,7 +94,10 @@ function reportPull(result, dest) {
|
|
|
94
94
|
console.log(`Wrote ${files.written.join(", ")} so Claude Code, Codex, and Cursor find their way around.`);
|
|
95
95
|
}
|
|
96
96
|
const rel = path.relative(process.cwd(), dest) || ".";
|
|
97
|
-
|
|
97
|
+
// A relative path is friendlier only while it stays short; a folder far
|
|
98
|
+
// from the cwd reads better as the absolute path it is.
|
|
99
|
+
const shown = rel.startsWith("..") ? dest : rel;
|
|
100
|
+
console.log(`Next: cd ${JSON.stringify(shown).slice(1, -1).includes(" ") ? `"${shown}"` : shown} && npx virtualmatter sync`);
|
|
98
101
|
console.log(` (or open the folder in your agent - the MCP server is registered in .mcp.json)`);
|
|
99
102
|
}
|
|
100
103
|
program
|
|
@@ -117,6 +120,21 @@ program
|
|
|
117
120
|
const me = await whoami();
|
|
118
121
|
console.log(JSON.stringify(me, null, 2));
|
|
119
122
|
}));
|
|
123
|
+
program
|
|
124
|
+
.command("embed <target>")
|
|
125
|
+
.description("Get iframe markup and required hosting setup for a shared world; no sign-in")
|
|
126
|
+
.action((target) => run(async () => console.log(JSON.stringify(await getEmbed(target), null, 2))));
|
|
127
|
+
program
|
|
128
|
+
.command("build <name>")
|
|
129
|
+
.description("Create a private world and send a build prompt to VM's agent (uses VM credits)")
|
|
130
|
+
.requiredOption("--prompt <text>", "What VM should build")
|
|
131
|
+
.requiredOption("--request-id <id>", "Stable identifier for this request; reuse on retries")
|
|
132
|
+
.option("--region <region>", "NA, EU, or AS", defaultRegion())
|
|
133
|
+
.action((name, opts) => run(async () => console.log(JSON.stringify(await createWebsiteBuild({ name, prompt: opts.prompt, request_id: opts.requestId, region: opts.region }), null, 2))));
|
|
134
|
+
program
|
|
135
|
+
.command("build-status <build-id>")
|
|
136
|
+
.description("Read a previously requested VM build's progress")
|
|
137
|
+
.action((id) => run(async () => console.log(JSON.stringify(await getWebsiteBuild(id), null, 2))));
|
|
120
138
|
program
|
|
121
139
|
.command("list")
|
|
122
140
|
.alias("ls")
|
|
@@ -234,14 +252,31 @@ program
|
|
|
234
252
|
const { client } = await clientForDir(path.resolve(dir));
|
|
235
253
|
console.log(JSON.stringify(await client.engineErrors(), null, 2));
|
|
236
254
|
}));
|
|
255
|
+
/** Parse an "x,y,z" flag into a numeric triple. */
|
|
256
|
+
function triple(value, flag) {
|
|
257
|
+
const parts = value.split(",").map((p) => Number(p.trim()));
|
|
258
|
+
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) {
|
|
259
|
+
fail(`${flag} takes three numbers, e.g. ${flag} 0,20,20`);
|
|
260
|
+
}
|
|
261
|
+
return [parts[0], parts[1], parts[2]];
|
|
262
|
+
}
|
|
237
263
|
program
|
|
238
264
|
.command("screenshot")
|
|
239
|
-
.description("Capture a PNG of the
|
|
265
|
+
.description("Capture a PNG of the world - a default overview, or aimed with --at/--rot or --target")
|
|
240
266
|
.argument("[dir]", "a synced folder", ".")
|
|
241
267
|
.option("-o, --out <file>", "output file", "screenshot.png")
|
|
268
|
+
.option("--at <x,y,z>", "camera position (default: 0,20,20)")
|
|
269
|
+
.option("--rot <yaw,pitch,roll>", "camera rotation in degrees, yaw first (default: 0,-45,0)")
|
|
270
|
+
.option("--target <id-or-name>", "frame this object instead of using a camera pose")
|
|
271
|
+
.option("--distance <meters>", "framing distance for --target (default: from the object's bounds)")
|
|
242
272
|
.action((dir, opts) => run(async () => {
|
|
243
273
|
const { client } = await clientForDir(path.resolve(dir));
|
|
244
|
-
const png = await client.screenshot(
|
|
274
|
+
const png = await client.screenshot({
|
|
275
|
+
at: opts.at ? triple(opts.at, "--at") : undefined,
|
|
276
|
+
rot: opts.rot ? triple(opts.rot, "--rot") : undefined,
|
|
277
|
+
target: opts.target,
|
|
278
|
+
distance: opts.distance === undefined ? undefined : Number(opts.distance),
|
|
279
|
+
});
|
|
245
280
|
fs.writeFileSync(opts.out, png);
|
|
246
281
|
console.log(`Wrote ${opts.out} (${png.length} bytes).`);
|
|
247
282
|
}));
|
|
@@ -253,6 +288,9 @@ async function openNative(framingId, url, force = false) {
|
|
|
253
288
|
console.log("Signed the native client in with your account.");
|
|
254
289
|
launchClient(installed, url);
|
|
255
290
|
console.log(`Opening ${url} in the Virtual Matter client (${installed.dir}).`);
|
|
291
|
+
if (installed.platform === "linux") {
|
|
292
|
+
console.log("Linux: the client starts through run.sh with its bundled libraries. If no window appears, read the newest file in ~/.local/share/Atomontage/Atomontage Studio/UserData/Logs/ - \"Failed to initialize graphics adapter\" means this build has no renderer for your setup yet; the world still runs in the browser at the URL above, and every CLI command works without the client.");
|
|
293
|
+
}
|
|
256
294
|
}
|
|
257
295
|
program
|
|
258
296
|
.command("open")
|
package/dist/mcp.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
14
|
import { z } from "zod";
|
|
15
|
-
import { createProject, defaultRegion, editUrl, listProjects, playUrl, resolveSession, sessionBaseUrl, } from "./api.js";
|
|
15
|
+
import { createProject, createWebsiteBuild, getWebsiteBuild, getEmbed, defaultRegion, editUrl, listProjects, playUrl, resolveSession, sessionBaseUrl, } from "./api.js";
|
|
16
16
|
import { ensureClientInstalled, launchClient, seedNativeAuth } from "./client.js";
|
|
17
17
|
import { packageVersion } from "./config.js";
|
|
18
18
|
import { ConflictError, FilesClient } from "./files.js";
|
|
@@ -97,6 +97,14 @@ export async function writeWithRetry(client, filePath, body) {
|
|
|
97
97
|
export async function runMcpServer(dir, framingFlag) {
|
|
98
98
|
const ctx = makeContext(resolveFramingId(dir, framingFlag));
|
|
99
99
|
const server = new McpServer({ name: "virtualmatter", version: packageVersion() });
|
|
100
|
+
server.registerTool("get_embed", {
|
|
101
|
+
description: "Get canonical iframe markup and hosting instructions for a shared Virtual Matter world URL. No sign-in needed. Follow the instructions and verify the published page.",
|
|
102
|
+
inputSchema: { target: z.string() },
|
|
103
|
+
}, async ({ target }) => textResult(JSON.stringify(await getEmbed(target))));
|
|
104
|
+
server.registerTool("get_build_status", {
|
|
105
|
+
description: "Check a VM build by build_id. Do not create another project to check progress. Review the world and enable sharing before embedding.",
|
|
106
|
+
inputSchema: { build_id: z.string() },
|
|
107
|
+
}, async ({ build_id }) => textResult(JSON.stringify(await getWebsiteBuild(build_id))));
|
|
100
108
|
server.registerTool("list_projects", {
|
|
101
109
|
description: "List the signed-in maker's Virtual Matter projects (worlds): name, id, region, the editing framing id, and edit/play URLs. Call this to find a world to work in, then select_project.",
|
|
102
110
|
inputSchema: {},
|
|
@@ -105,6 +113,8 @@ export async function runMcpServer(dir, framingFlag) {
|
|
|
105
113
|
description: "Create a new Virtual Matter world (project) for the signed-in maker and select it for this session. The world spins up live on Virtual Matter servers; its content is SDK Lua plus assets that hot-reload as you write them. Region defaults to the one nearest this machine.",
|
|
106
114
|
inputSchema: {
|
|
107
115
|
name: z.string().min(1).max(100).describe("Project name, e.g. \"Lava Arena\""),
|
|
116
|
+
prompt: z.string().min(1).max(16000).optional().describe("Optional build prompt to send to VM's agent, using VM credits. Requires request_id. Starts privately."),
|
|
117
|
+
request_id: z.string().min(8).max(100).optional().describe("Stable id for a prompted build; reuse unchanged on retries."),
|
|
108
118
|
description: z.string().max(1000).optional().describe("One-line description (optional)"),
|
|
109
119
|
region: z.enum(["NA", "EU", "AS"]).optional().describe("Hosting region (default: nearest)"),
|
|
110
120
|
pull_to: z
|
|
@@ -112,7 +122,17 @@ export async function runMcpServer(dir, framingFlag) {
|
|
|
112
122
|
.optional()
|
|
113
123
|
.describe("Local folder to mirror the new world's files into (optional; sync works from there)"),
|
|
114
124
|
},
|
|
115
|
-
}, async ({ name, description, region, pull_to }) => {
|
|
125
|
+
}, async ({ name, description, region, pull_to, prompt, request_id }) => {
|
|
126
|
+
if (prompt) {
|
|
127
|
+
if (!request_id)
|
|
128
|
+
throw new Error("A stable request_id is required when sending a build prompt.");
|
|
129
|
+
if (pull_to || description)
|
|
130
|
+
throw new Error("Prompted builds run in VM. Omit pull_to/description and use select_project afterwards if local files are needed.");
|
|
131
|
+
const build = await createWebsiteBuild({ name, prompt, request_id, region: region ?? defaultRegion() });
|
|
132
|
+
if (build.framing_id)
|
|
133
|
+
ctx.select(build.framing_id);
|
|
134
|
+
return textResult(JSON.stringify(build));
|
|
135
|
+
}
|
|
116
136
|
const project = await createProject({ name, description, region: region ?? defaultRegion() });
|
|
117
137
|
const framingId = makeFramingOf(project);
|
|
118
138
|
ctx.select(framingId, project);
|
|
@@ -192,11 +212,30 @@ export async function runMcpServer(dir, framingFlag) {
|
|
|
192
212
|
return textResult(JSON.stringify(await client.engineErrors(), null, 2));
|
|
193
213
|
});
|
|
194
214
|
server.registerTool("capture_screenshot", {
|
|
195
|
-
description: "Capture a PNG screenshot of the
|
|
196
|
-
inputSchema: {
|
|
197
|
-
|
|
215
|
+
description: "Capture a PNG screenshot of the world and return it as an image - how you SEE what you built. With no arguments it shoots a default overview of the world origin. Pass target to frame one object by name or id (easiest and usually what you want), or a full at + rot camera pose. Use it to visually verify a change instead of assuming it worked.",
|
|
216
|
+
inputSchema: {
|
|
217
|
+
target: z
|
|
218
|
+
.string()
|
|
219
|
+
.optional()
|
|
220
|
+
.describe("Object name or id to frame; the engine picks the distance"),
|
|
221
|
+
at: z
|
|
222
|
+
.array(z.number())
|
|
223
|
+
.length(3)
|
|
224
|
+
.optional()
|
|
225
|
+
.describe("Camera position [x, y, z]; Y is up and -Z is forward"),
|
|
226
|
+
rot: z
|
|
227
|
+
.array(z.number())
|
|
228
|
+
.length(3)
|
|
229
|
+
.optional()
|
|
230
|
+
.describe("Camera rotation in degrees as [yaw, pitch, roll] - that order. Yaw 0 faces -Z; pitch -90 looks straight down, 0 is the horizon. Default [0, -45, 0] looks down at the origin."),
|
|
231
|
+
},
|
|
232
|
+
}, async ({ target, at, rot }) => {
|
|
198
233
|
const client = await ctx.getClient();
|
|
199
|
-
const png = await client.screenshot(
|
|
234
|
+
const png = await client.screenshot({
|
|
235
|
+
target,
|
|
236
|
+
at: at,
|
|
237
|
+
rot: rot,
|
|
238
|
+
});
|
|
200
239
|
return {
|
|
201
240
|
content: [
|
|
202
241
|
{ type: "image", data: png.toString("base64"), mimeType: "image/png" },
|
package/dist/pull.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { resolveSession, sessionBaseUrl } from "./api.js";
|
|
5
|
-
import { fetchAgentsMd, writeAgentFiles } from "./agentfiles.js";
|
|
5
|
+
import { composeAgentsMd, fetchAgentsMd, writeAgentFiles } from "./agentfiles.js";
|
|
6
6
|
import { FilesClient } from "./files.js";
|
|
7
7
|
import { isIgnoredPath } from "./ignore.js";
|
|
8
8
|
import { saveState } from "./state.js";
|
|
@@ -33,6 +33,19 @@ function makeProgress(total) {
|
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
/** The engine SDK's AGENTS.md from the Montage tree, or null when the world has none. */
|
|
37
|
+
export async function fetchEngineAgentsMd(client) {
|
|
38
|
+
try {
|
|
39
|
+
const listing = await client.list();
|
|
40
|
+
if (!listing.some((f) => f.path === "AGENTS.md"))
|
|
41
|
+
return null;
|
|
42
|
+
const { bytes } = await client.get("AGENTS.md");
|
|
43
|
+
return bytes.toString("utf8");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
36
49
|
export async function pullTree(client, dir, framingId) {
|
|
37
50
|
fs.mkdirSync(dir, { recursive: true });
|
|
38
51
|
const remote = (await client.list()).filter((f) => !isIgnoredPath(f.path));
|
|
@@ -64,6 +77,7 @@ export async function pullCommand(framingId, dir) {
|
|
|
64
77
|
const client = new FilesClient(sessionBaseUrl(session), framingId);
|
|
65
78
|
console.log(`Session ready at ${sessionBaseUrl(session)}. Downloading files ...`);
|
|
66
79
|
const result = await pullTree(client, dir, framingId);
|
|
67
|
-
|
|
80
|
+
const [platformDoc, engineDoc] = await Promise.all([fetchAgentsMd(), fetchEngineAgentsMd(client)]);
|
|
81
|
+
result.agentFiles = writeAgentFiles(dir, composeAgentsMd(platformDoc, engineDoc));
|
|
68
82
|
return result;
|
|
69
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "virtualmatter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "CLI + MCP server for building with Virtual Matter - list and create worlds, sync their files, run Lua, capture screenshots, open the native client, and wire coding agents into a live session.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|