virtualmatter 0.1.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/dist/pull.js CHANGED
@@ -2,53 +2,74 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { resolveSession, sessionBaseUrl } from "./api.js";
5
- import { apiBase } from "./config.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";
9
- const AGENTS_MD_STUB = `# Working with Virtual Matter
10
-
11
- This folder is a live mirror of a Virtual Matter framing's Montage files.
12
-
13
- - \`virtualmatter sync\` keeps it in sync with the running session.
14
- - \`virtualmatter run-lua --code "..."\` executes Lua in the engine.
15
- - \`virtualmatter errors\` shows recent engine errors.
16
- - \`virtualmatter screenshot -o shot.png\` captures the current view.
17
- - Lua scripts under this tree hot-reload in the engine when saved.
18
-
19
- Do not edit \`.virtualmatter.json\` - it is sync bookkeeping.
20
- `;
21
- export async function fetchAgentsMd(fetchFn = fetch) {
9
+ export { AGENTS_MD_STUB, fetchAgentsMd } from "./agentfiles.js";
10
+ /** Download workers per pull. The tree is ~1000 small files; sequential
11
+ * GETs left the command silent for minutes. Eight in flight keeps a
12
+ * single origin comfortable while cutting wall clock roughly 6-8x. */
13
+ const PULL_CONCURRENCY = 8;
14
+ /** One mutable status line on a TTY (`\r`-overwritten); on a plain
15
+ * pipe, a heartbeat line every 100 files instead of 1000 spam lines. */
16
+ function makeProgress(total) {
17
+ const tty = process.stderr.isTTY === true;
18
+ let count = 0;
19
+ return {
20
+ tick(current) {
21
+ count++;
22
+ if (tty) {
23
+ const label = current.length > 60 ? `...${current.slice(-57)}` : current;
24
+ process.stderr.write(`\r\x1b[2K[${count}/${total}] ${label}`);
25
+ }
26
+ else if (count % 100 === 0 || count === total) {
27
+ process.stderr.write(`[${count}/${total}] files pulled\n`);
28
+ }
29
+ },
30
+ done() {
31
+ if (tty)
32
+ process.stderr.write("\r\x1b[2K");
33
+ },
34
+ };
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) {
22
38
  try {
23
- const res = await fetchFn(`${apiBase()}/AGENTS.md`);
24
- if (res.ok) {
25
- const text = await res.text();
26
- if (text.trim().length > 0 && !text.trimStart().startsWith("<"))
27
- return text;
28
- }
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");
29
44
  }
30
45
  catch {
31
- /* fall through to the stub */
46
+ return null;
32
47
  }
33
- return AGENTS_MD_STUB;
34
48
  }
35
49
  export async function pullTree(client, dir, framingId) {
36
50
  fs.mkdirSync(dir, { recursive: true });
37
- const remote = await client.list();
51
+ const remote = (await client.list()).filter((f) => !isIgnoredPath(f.path));
38
52
  const state = { framing_id: framingId, etags: {} };
39
- let count = 0;
40
- for (const file of remote) {
41
- if (isIgnoredPath(file.path))
42
- continue;
43
- const { bytes, etag } = await client.get(file.path);
44
- const absPath = path.join(dir, file.path);
45
- fs.mkdirSync(path.dirname(absPath), { recursive: true });
46
- fs.writeFileSync(absPath, bytes);
47
- state.etags[file.path] = etag ?? file.etag;
48
- count++;
49
- }
53
+ const progress = makeProgress(remote.length);
54
+ let next = 0;
55
+ const worker = async () => {
56
+ for (;;) {
57
+ const index = next++;
58
+ if (index >= remote.length)
59
+ return;
60
+ const file = remote[index];
61
+ const { bytes, etag } = await client.get(file.path);
62
+ const absPath = path.join(dir, file.path);
63
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
64
+ fs.writeFileSync(absPath, bytes);
65
+ state.etags[file.path] = etag ?? file.etag;
66
+ progress.tick(file.path);
67
+ }
68
+ };
69
+ await Promise.all(Array.from({ length: Math.min(PULL_CONCURRENCY, remote.length) }, () => worker()));
70
+ progress.done();
50
71
  saveState(dir, state);
51
- return { dir, framingId, fileCount: count };
72
+ return { dir, framingId, fileCount: remote.length };
52
73
  }
53
74
  export async function pullCommand(framingId, dir) {
54
75
  console.log(`Resolving session for framing ${framingId} (this can cold-start one) ...`);
@@ -56,7 +77,7 @@ export async function pullCommand(framingId, dir) {
56
77
  const client = new FilesClient(sessionBaseUrl(session), framingId);
57
78
  console.log(`Session ready at ${sessionBaseUrl(session)}. Downloading files ...`);
58
79
  const result = await pullTree(client, dir, framingId);
59
- const agentsMd = await fetchAgentsMd();
60
- fs.writeFileSync(path.join(dir, "AGENTS.md"), agentsMd);
80
+ const [platformDoc, engineDoc] = await Promise.all([fetchAgentsMd(), fetchEngineAgentsMd(client)]);
81
+ result.agentFiles = writeAgentFiles(dir, composeAgentsMd(platformDoc, engineDoc));
61
82
  return result;
62
83
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Turn whatever the user handed us (a URL of any shape, a bare id, or
3
+ * nothing) into the one framing id every other command needs.
4
+ */
5
+ import { getProject, getProjectByPublicId, listProjects } from "./api.js";
6
+ import { loadState } from "./state.js";
7
+ import { parseTarget } from "./urls.js";
8
+ /** The make framing of a project row, or a clear error when it has none. */
9
+ export function makeFramingOf(project) {
10
+ if (!project.make_framing_id) {
11
+ throw new Error(`Project "${project.name}" has no editing framing yet - open it in the browser once.`);
12
+ }
13
+ return project.make_framing_id;
14
+ }
15
+ export async function resolveParsedTarget(parsed, fetchFn = fetch) {
16
+ if (parsed.kind === "invalid")
17
+ throw new Error(parsed.reason);
18
+ if (parsed.kind === "framing")
19
+ return { framingId: parsed.framingId };
20
+ const project = parsed.publicId
21
+ ? await getProjectByPublicId(parsed.publicId, fetchFn)
22
+ : await getProject(parsed.montageId, fetchFn);
23
+ return { framingId: makeFramingOf(project), project };
24
+ }
25
+ /** Resolve a pasted target string. */
26
+ export async function resolveTarget(target, fetchFn = fetch) {
27
+ return resolveParsedTarget(parseTarget(target), fetchFn);
28
+ }
29
+ /**
30
+ * No target given: the folder's state file wins; otherwise, when the
31
+ * account has exactly one project, that one - nothing to choose between.
32
+ * Anything else throws with the list, so the caller can print it.
33
+ */
34
+ export async function resolveImplicitTarget(dir, fetchFn = fetch) {
35
+ const state = loadState(dir);
36
+ if (state)
37
+ return { framingId: state.framing_id };
38
+ const projects = (await listProjects(fetchFn)).filter((p) => p.make_framing_id);
39
+ if (projects.length === 1)
40
+ return { framingId: projects[0].make_framing_id, project: projects[0] };
41
+ if (projects.length === 0) {
42
+ throw new Error('You have no projects yet. Create one with `npx virtualmatter create "My world"`.');
43
+ }
44
+ throw new NeedsChoiceError(projects);
45
+ }
46
+ export class NeedsChoiceError extends Error {
47
+ projects;
48
+ constructor(projects) {
49
+ super("Which project? Pass its URL or id. Yours:\n" + formatProjectList(projects));
50
+ this.projects = projects;
51
+ this.name = "NeedsChoiceError";
52
+ }
53
+ }
54
+ export function formatProjectList(projects) {
55
+ if (projects.length === 0)
56
+ return "(no projects yet)";
57
+ const width = Math.min(40, Math.max(...projects.map((p) => p.name.length)));
58
+ return projects
59
+ .map((p) => {
60
+ const name = p.name.length > width ? p.name.slice(0, width - 1) + "…" : p.name.padEnd(width);
61
+ const fid = p.make_framing_id ?? "(no editing framing)";
62
+ return ` ${name} ${fid} ${p.region}`;
63
+ })
64
+ .join("\n");
65
+ }
package/dist/urls.js CHANGED
@@ -1,14 +1,34 @@
1
1
  /**
2
- * Parse the URL shapes a user may paste and extract a framing id.
2
+ * Parse anything a user (or an agent) may paste and say what it names.
3
3
  *
4
- * Framing-shaped (id resolves locally):
5
- * /edit/<framingId> /play/<framingId> /m/<framingId>
6
- * Project-shaped (v1 does not resolve these):
7
- * /g/<montageId> /play/p/<montageId>
8
- * Accepted on any virtualmatter.ai / virtualmatter.dev host, and a bare
9
- * framing id (UUID) is accepted as-is.
4
+ * Framing-shaped - the id is right there:
5
+ * /edit/<framingId> /edit/<slug>-<framingId>
6
+ * /play/<framingId> /m/<framingId>
7
+ * a bare framing id (21-char nanoid, or a legacy UUID)
8
+ * Project-shaped - resolved through the API to the project's make framing:
9
+ * /g/<montageUuid> /play/p/<montageUuid>
10
+ * /projects/<slug>-<publicId> /projects/<publicId> /projects/<montageUuid>
11
+ * /p/<slug>-<publicId> /p/<publicId>
12
+ * Accepted on any virtualmatter.ai / virtualmatter.dev host (plus localhost
13
+ * for dev), on the make.* and play.* subdomains alike.
14
+ *
15
+ * Readable segments are `<slug>-<identity>` with the identity FIXED-WIDTH and
16
+ * ANCHORED AT THE RIGHT END, mirroring `frontend/src/lib/readable-routes.ts`:
17
+ * framing ids are nanoids that routinely contain `-` (and can start with
18
+ * one), so splitting on any hyphen is wrong on real ids. Only `/edit/` is
19
+ * slug-aware; `/play/` and `/m/` are exact, so their segment is the whole id.
10
20
  */
11
21
  const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
22
+ /** Length of a minted `Framing.id` - mirrors `services/ids.py::FRAMING_ID_SIZE`. */
23
+ export const FRAMING_ID_LENGTH = 21;
24
+ /** Length of `Montage.public_id` - lowercase base36. */
25
+ export const PUBLIC_ID_LENGTH = 8;
26
+ const FRAMING_ID_CHARS_RE = /^[A-Za-z0-9_-]+$/;
27
+ const PUBLIC_ID_RE = /^[0-9a-z]{8}$/;
28
+ // Framing ids are nanoids (URL-safe alphabet, 21 chars as minted),
29
+ // case-SENSITIVE - never lowercase them. 15-32 keeps obvious typos and
30
+ // full URLs out while accepting every id shape the platform mints.
31
+ const NANOID_RE = /^[A-Za-z0-9_-]{15,32}$/;
12
32
  function isVirtualmatterHost(host) {
13
33
  const h = host.toLowerCase();
14
34
  return (h === "virtualmatter.ai" ||
@@ -18,10 +38,34 @@ function isVirtualmatterHost(host) {
18
38
  h === "localhost" ||
19
39
  h === "127.0.0.1");
20
40
  }
21
- // Framing ids are nanoids (URL-safe alphabet, typically 21 chars),
22
- // case-SENSITIVE - never lowercase them. 15-32 keeps obvious typos and
23
- // full URLs out while accepting every id shape the platform mints.
24
- const NANOID_RE = /^[A-Za-z0-9_-]{15,32}$/;
41
+ /** `<slug>-<identity>` with a fixed-width identity anchored at the right end. */
42
+ function splitFixedWidth(value, length) {
43
+ if (value.length < length + 2)
44
+ return null;
45
+ if (value[value.length - length - 1] !== "-")
46
+ return null;
47
+ return { slug: value.slice(0, value.length - length - 1), identity: value.slice(value.length - length) };
48
+ }
49
+ /** The framing id inside an `/edit/` segment: bare, or `<slug>-<id>`. */
50
+ export function framingIdFromEditSegment(segment) {
51
+ if (segment.length === FRAMING_ID_LENGTH)
52
+ return segment;
53
+ const split = splitFixedWidth(segment, FRAMING_ID_LENGTH);
54
+ if (split && FRAMING_ID_CHARS_RE.test(split.identity))
55
+ return split.identity;
56
+ return segment;
57
+ }
58
+ /** A `/p/` or `/projects/` segment as a project reference, or null. */
59
+ function projectFromReadableSegment(segment) {
60
+ if (UUID_RE.test(segment))
61
+ return { kind: "project", montageId: segment.toLowerCase() };
62
+ if (PUBLIC_ID_RE.test(segment))
63
+ return { kind: "project", publicId: segment };
64
+ const split = splitFixedWidth(segment, PUBLIC_ID_LENGTH);
65
+ if (split && PUBLIC_ID_RE.test(split.identity))
66
+ return { kind: "project", publicId: split.identity };
67
+ return null;
68
+ }
25
69
  export function parseTarget(input) {
26
70
  const trimmed = input.trim();
27
71
  if (UUID_RE.test(trimmed)) {
@@ -41,22 +85,33 @@ export function parseTarget(input) {
41
85
  return { kind: "invalid", reason: `Not a Virtual Matter host: ${url.hostname}` };
42
86
  }
43
87
  const parts = url.pathname.split("/").filter((p) => p.length > 0);
44
- // Project-level shapes first: /g/<montageId> and /play/p/<montageId>
45
- if (parts.length >= 2 && parts[0] === "g" && parts[1]) {
46
- return { kind: "project", montageId: parts[1] };
88
+ const [first, second, third] = parts;
89
+ // Project-level shapes: /g/<uuid>, /play/p/<uuid>, /projects/<seg>, /p/<seg>
90
+ if (first === "g" && second) {
91
+ return projectFromReadableSegment(second) ?? { kind: "project", montageId: second };
92
+ }
93
+ if (first === "play" && second === "p" && third) {
94
+ return projectFromReadableSegment(third) ?? { kind: "project", montageId: third };
95
+ }
96
+ if ((first === "projects" || first === "p") && second) {
97
+ const project = projectFromReadableSegment(second);
98
+ if (project)
99
+ return project;
100
+ return { kind: "invalid", reason: `Not a readable project link: ${url.pathname}` };
101
+ }
102
+ // Framing shapes: /edit/<id or slug-id>, /play/<id>, /m/<id>
103
+ if (first === "edit" && second) {
104
+ const id = framingIdFromEditSegment(second);
105
+ return { kind: "framing", framingId: UUID_RE.test(id) ? id.toLowerCase() : id };
47
106
  }
48
- if (parts.length >= 3 && parts[0] === "play" && parts[1] === "p" && parts[2]) {
49
- return { kind: "project", montageId: parts[2] };
107
+ if ((first === "play" || first === "m") && second) {
108
+ return { kind: "framing", framingId: UUID_RE.test(second) ? second.toLowerCase() : second };
50
109
  }
51
- // Framing shapes: /edit/<id>, /play/<id>, /m/<id>
52
- if (parts.length >= 2 && parts[1] && ["edit", "play", "m"].includes(parts[0])) {
53
- const id = parts[1];
54
- if (UUID_RE.test(id)) {
55
- return { kind: "framing", framingId: id.toLowerCase() };
56
- }
57
- return { kind: "framing", framingId: id };
110
+ if (first === "new" || parts.length === 0) {
111
+ return {
112
+ kind: "invalid",
113
+ reason: "That link is the home page, not a world. Run `npx virtualmatter create \"My world\"` to make one, or `npx virtualmatter list` to see yours.",
114
+ };
58
115
  }
59
116
  return { kind: "invalid", reason: `Unrecognized Virtual Matter URL path: ${url.pathname}` };
60
117
  }
61
- /** Human guidance for project-shaped URLs (v1 keeps it simple). */
62
- export const PROJECT_URL_HELP = "That link points at a whole project, not a single framing. Open the project in your browser and copy the /edit/<id> link for the framing you want, then try again.";
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "virtualmatter",
3
- "version": "0.1.0",
4
- "description": "CLI + MCP server for building with Virtual Matter - sync montage files, run Lua, capture screenshots, and wire coding agents into a live session.",
3
+ "version": "0.3.0",
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",
7
7
  "bin": {
8
- "virtualmatter": "./dist/index.js",
9
- "vm": "./dist/index.js"
8
+ "virtualmatter": "dist/index.js",
9
+ "vm": "dist/index.js"
10
10
  },
11
11
  "files": [
12
12
  "dist",