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 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, and the
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.
@@ -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(`run-lua failed: HTTP ${res.status}`);
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(`Fetching engine errors failed: HTTP ${res.status}`);
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 res = await authorizedFetch(`${this.apiRoot}/engine/screenshot`, { method: "POST" }, this.fetchFn);
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(`Screenshot failed: HTTP ${res.status}`);
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
- console.log(`Next: cd ${rel} && npx virtualmatter sync`);
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 engine's current view")
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 engine's current view and return it as an image. Use it to visually verify what a change actually looks like in the world.",
196
- inputSchema: {},
197
- }, async () => {
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
- result.agentFiles = writeAgentFiles(dir, await fetchAgentsMd());
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.2.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",