virtualmatter 0.6.0 → 0.6.2
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 +12 -6
- package/dist/agents-md.generated.js +1 -1
- package/dist/client.js +176 -14
- package/dist/files.js +7 -2
- package/dist/index.js +15 -1
- package/dist/mcp.js +17 -4
- package/dist/pull.js +4 -2
- package/dist/state.js +56 -2
- package/dist/sync.js +237 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,9 +38,11 @@ npx virtualmatter pull https://make.virtualmatter.ai/edit/my-world-<framing-id>
|
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
While `sync` runs, saving a Lua script in your editor deploys it - scripts
|
|
41
|
-
hot-reload in the engine.
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
hot-reload in the engine. Sync never overwrites work you have not synced: if
|
|
42
|
+
a file changed both here and in the live session (while sync ran, or while it
|
|
43
|
+
was stopped), your copy stays put and the remote version lands next to it as
|
|
44
|
+
`<name>.remote-conflict`. Merge, save, and it pushes. Edits made while sync
|
|
45
|
+
was stopped go out when it starts again.
|
|
44
46
|
|
|
45
47
|
`make`, `pull` and `sync` print the world's editor link ending in `?chat=closed`.
|
|
46
48
|
Open it in any browser to watch changes land live - no install needed.
|
|
@@ -56,10 +58,12 @@ Poke at the running engine from another terminal:
|
|
|
56
58
|
|
|
57
59
|
```bash
|
|
58
60
|
npx virtualmatter run-lua --code "return Server.GetInfo()"
|
|
61
|
+
npx virtualmatter run-lua --target client --code "return Client:GetID()" # UI, input, camera
|
|
59
62
|
npx virtualmatter errors
|
|
60
63
|
npx virtualmatter screenshot -o shot.png # default overview
|
|
61
64
|
npx virtualmatter screenshot --target "Player" -o p.png # frame one object
|
|
62
65
|
npx virtualmatter screenshot --at 0,30,40 --rot 0,-35,0 # exact camera
|
|
66
|
+
npx virtualmatter screenshot --client 4 -o ui.png # what client 4 sees, UI included
|
|
63
67
|
# --rot is yaw,pitch,roll
|
|
64
68
|
```
|
|
65
69
|
|
|
@@ -117,9 +121,9 @@ deploying it.
|
|
|
117
121
|
| `pull [target] [dir]` | Mirror a world's file tree. `target` is any link to the world (or your website) or a framing id, and always resolves to the world's editor; omitted, it uses this folder's world or your only one. |
|
|
118
122
|
| `sync [dir]` | Watch + two-way sync with the live session. Ctrl-C to stop. |
|
|
119
123
|
| `open [target]` (`client`) | Optional: open a world in the native desktop client, downloading and signing it in on first use. `--install-only`, `--print` (just the download URL), `--force`. |
|
|
120
|
-
| `run-lua [dir] --code "<lua>" [--target server\|client]` | Execute Lua in the running engine. |
|
|
124
|
+
| `run-lua [dir] --code "<lua>" [--target server\|client] [--client <id>]` | Execute Lua in the running engine. A client run uses the only connected client unless `--client` names one (list them with `--code "return Server:GetClients()"`). |
|
|
121
125
|
| `errors [dir]` | Recent engine errors. |
|
|
122
|
-
| `screenshot [dir] [-o out.png]` | Capture the engine's
|
|
126
|
+
| `screenshot [dir] [-o out.png] [--client <id>]` | Capture the engine's view; `--client` captures through a connected client, UI included. |
|
|
123
127
|
| `mcp [dir] [--framing <id>]` | Run the stdio MCP server. |
|
|
124
128
|
| `login` / `logout` / `whoami` | Device-code sign-in; every other command signs you in automatically when needed. Tokens live in `~/.config/virtualmatter/credentials.json` (mode 0600) and refresh automatically. |
|
|
125
129
|
|
|
@@ -131,7 +135,9 @@ deploying it.
|
|
|
131
135
|
an error, for scripts that must never block on a browser.
|
|
132
136
|
- The native client is unpacked under `~/.config/virtualmatter/client/<build>`
|
|
133
137
|
(override with `VIRTUALMATTER_CLIENT_DIR`); each engine build gets its own
|
|
134
|
-
folder, so a newer build never overwrites the one you are running.
|
|
138
|
+
folder, so a newer build never overwrites the one you are running. Once a
|
|
139
|
+
newer build is installed, older ones are removed unless a client is still
|
|
140
|
+
running from them.
|
|
135
141
|
- Sync skips `Uploads/`, `Screenshots/`, `Agent Logs/`, dotfiles, the
|
|
136
142
|
harness files the CLI writes (`AGENTS.md`, `CLAUDE.md`, `.mcp.json`,
|
|
137
143
|
`.cursor/`, `.codex/`, `.virtualmatter.json`), and the SDK's in-session tooling at the
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
// frontend/content/agent-briefing.md and AGENTS.source.md. The CLI writes
|
|
4
4
|
// this into a world folder only when it cannot fetch the live copy. After
|
|
5
5
|
// changing either source, run: node frontend/scripts/sync-cli-agents-md.mjs
|
|
6
|
-
export const AGENTS_MD_FALLBACK = "# AGENTS.md - working with a Virtual Matter project\n\nThis file tells a coding agent how to think about a Virtual Matter project. The\n`virtualmatter` CLI drops a copy into every local project mirror it creates\n(`virtualmatter pull`); the canonical copy lives at\nhttps://make.virtualmatter.ai/AGENTS.md.\n\n## The rule that comes before everything else\n\n**Build through the CLI. Never through the website's agent.** If you were asked to\nbuild something in Virtual Matter, handed the domain virtualmatter.ai, or told to run\n`npx virtualmatter make`, the CLI is the path - always, from the first step. Do not\nopen virtualmatter.ai or make.virtualmatter.ai in a browser or under computer use in\norder to reach the built-in agent, and do not type prompts into the agent chat in the\n/edit view. Driving that agent puts two models in a relay where one would do: you spend\ntokens describing the work, it spends its own doing it, and what comes back is chat\nrather than files you can read. It is slower, it costs roughly twice the inference, it\nbills that inference to Virtual Matter's platform credits rather than the subscription\nyou already run on, an anonymous browser session cannot be steered after its first\nturn, and a rendered page gives you no engine errors. Everything that agent can do, you\ndo yourself with the commands below.\n\n**The rule holds whoever is signed in.** Being logged in, having credits to spare, or\nworking in an admin account with no limit at all is not an exemption, and neither is\nbeing told the credits do not matter. What is wasteful is the relay itself - two models\ndoing one model's job - not the bill.\n\n**Playtesting is the exception, and it is encouraged.** Once something is built, open\nthe world and try it: the editor link ending in `?chat=closed`, which `make`, `pull` and\n`sync` print, shows your latest changes live in any browser with no install, with\ncomputer use if that is how you drive a window. The native client\n(`npx virtualmatter open`) is optional, for more memory and frame rate. Looking at your\nwork, walking around in it, and playing it is the point. The rule is about who writes\nthe world, not about who is allowed to see it.\n\n**Every link resolves to the editor, and the editor is the only place you build.** Hand\nthe CLI or MCP server any link to a world - its editor, a play or share link, the\nproject page, an embed's iframe src, or the maker's own website - and it works on that\nworld's editor (its make framing). Play servers show the last published version and are\noverwritten on every publish, so work never goes there, and their /play and /g links\nshow that published copy rather than your latest edits.\n\n## The mental model\n\n- The world itself runs on Virtual Matter servers, not on the maker's machine. There is\n one authoritative server simulation per world session, and any number of connected\n clients (browser WASM or the native client).\n- The project's content is SDK Lua scripts plus assets (voxel data, prefabs, images,\n sounds) under a `Montage/` tree. Virtual Matter can publish that tree to a GitHub repo\n in the maker's account for versioning and remixing; pushing to that repo does not feed\n back into the running world today - live edits happen in the session.\n- Changes hot-reload. Saving a `.lua` file in the live session's Montage tree re-runs\n the module and its `Start()` on a fresh instance inside the running world - no restart,\n no build step.\n- Prefabs are reusable scene-graph snippets: JSON files with a `.prefab` extension in the\n Montage folder, instantiated at runtime via `Server:InsertPrefab(asset)`.\n- There is no local runtime - the world cannot run on the maker's machine. The\n `virtualmatter` CLI (npm) is how you reach it from a local harness, and it needs no\n setup: the first command that needs an account signs you in with a device code.\n `npx virtualmatter make \"My world\"` makes a world and mirrors its Montage tree\n into `./my-world`; `npx virtualmatter pull <any Virtual Matter link>` mirrors an\n existing one (/edit, /play, /g, /p, /projects links all work, with or without a\n readable slug); `npx virtualmatter list` shows your worlds; `npx virtualmatter sync`\n live-pushes saves into the running session (which hot-reloads them);\n `npx virtualmatter run-lua` / `errors` / `screenshot` drive the engine;\n `npx virtualmatter open` optionally opens the world in the native desktop client,\n downloaded and signed in on first use; and `npx virtualmatter mcp` serves\n all of it over MCP. A mirrored folder carries `.mcp.json` and `.cursor/mcp.json`\n (so Claude Code and Cursor register the MCP server on their own), this file, and a\n `CLAUDE.md` that imports it. Folders from virtualmatter 0.5.0 on also carry\n `.codex/config.toml`, which Codex loads once you trust the folder; with an older\n CLI, or if Codex does not list the server, register it once with\n `codex mcp add virtualmatter -- npx -y virtualmatter mcp`. In Claude Code outside a\n mirrored folder, use `claude mcp add virtualmatter -- npx -y virtualmatter mcp`. The\n server works before a world\n is selected (`list_projects`, `create_project`, `select_project`), then exposes\n `list_files`, `read_file`, `write_file`, `run_lua`, `get_engine_errors`,\n `capture_screenshot`, `open_native_client`, and `world_info`.\n- Work through the CLI, not the website - see the rule above. Prompting the built-in\n agent to do your building is the one thing to avoid; opening the world to play it is\n not.\n- Seeing your work: `virtualmatter screenshot` shoots a default overview of the world\n origin, `--target <object>` frames one object by name or id, and `--at x,y,z --rot\n yaw,pitch,roll` places the camera exactly. Y is up and -Z is forward, and the\n rotation really is yaw first: yaw 0 faces -Z, pitch -90 looks straight down, pitch 0\n is the horizon. The MCP `capture_screenshot`\n tool takes the same arguments. Verify a change with a screenshot plus `errors` rather\n than assuming a write worked.\n- The mirrored folder's AGENTS.md is this guide followed by the engine SDK's own agent guide\n (the AGENTS.md that lives in every Montage tree and inside the desktop client's\n Data/Sdk/Montage/). The engine guide assumes an in-session agent driving the engine\n through `atomo`; the merged file maps each `atomo` step to the CLI or MCP equivalent, and\n the `Skills/*.md` references it points at are in the folder. The SDK's own tooling\n (`atomo`, `vm_auth.py`, the agent-log hooks) is not mirrored: it only works inside a\n session. Sandboxed agents need network access for every CLI command.\n\n## Reference\n\nThe same facts as terse lists, for quick lookup.\n\n### Project model\n- A project (\"montage\") is one voxel world hosted with Virtual Matter.\n- A project has framings: \"make\" framings (editing sessions) and \"play\"\n framings (public play sessions). A framing is a running session slot\n on a voxel host.\n- Project content = SDK Lua scripts + assets under a Montage/ tree.\n Edits made in the live session hot-reload into the running world.\n The tree can be published to a maker-owned GitHub repo.\n\n### URL shapes (make.virtualmatter.ai)\n- /new builder for people: create a world from a prompt\n (also at https://virtualmatter.ai/); coding agents\n use npx virtualmatter make instead\n- /edit/<framing-id> maker session: live world + agent chat. The chat is\n for people; a coding agent builds with the CLI and\n never prompts it\n (also /edit/<slug>-<framing-id>; the CLI accepts both)\n- /play/<framing-id> play session for a specific framing\n- /g/<id> share URL resolver: 302s to a live session\n\n### APIs (unauthenticated)\n- GET https://make.virtualmatter.ai/api/v1/public/native-clients\n JSON: { iteration, clients: [{ platform, kind, url, filename,\n file_size, branch, commit, match }] } - per-platform native client\n installers (kind \"download\") or store links (kind \"store\", iOS).\n\n### CLI + MCP (npm package \"virtualmatter\", Node >= 20)\n- npx virtualmatter make \"<name>\" make a world, mirror it into ./<slug>\n- npx virtualmatter pull <link> mirror an existing world (any link shape)\n- npx virtualmatter list your worlds with framing ids + URLs\n- npx virtualmatter sync live-push saves into the running world\n- npx virtualmatter open optional: open it in the native client\n- npx virtualmatter mcp stdio MCP server (list_projects,\n create_project, select_project, list_files, read_file, write_file,\n run_lua, get_engine_errors, capture_screenshot, open_native_client,\n world_info)\n- Sign-in happens on first use (device code); no separate login step.\n- These commands are the whole build path. Never prompt the built-in\n agent on the site to do the work; a browser or the native client is\n for playtesting what you built.\n- Any link to a world (editor, play or share link, project page, embed,\n or the maker's own website) resolves to its editor, the only place\n the CLI and MCP server work. Play servers show the last published\n version and are overwritten on publish.\n- Watch changes land live at the editor link ending in ?chat=closed,\n in any browser, no install. The native client is optional.\n- A mirrored folder carries .mcp.json, .cursor/mcp.json, AGENTS.md and\n CLAUDE.md, so Claude Code and Cursor register the server on their own.\n- Codex: folders from 0.5.0 on carry .codex/config.toml, loaded once the\n folder is trusted; otherwise register the server once:\n codex mcp add virtualmatter -- npx -y virtualmatter mcp\n\n## SDK Lua cheat sheet\n\nThe scripting language is Lua 5.4, sandboxed: `os`, `io`, `require`, `package`,\n`dofile`, `loadfile`, `loadstring` are nil. `math`, `string`, `table`, `coroutine`\nremain. No `os.time` - use `Time.time` (sim time), `Time.frame`, or\n`AE:GetDebugTime()` (wall clock).\n\n### Script shape\n\nEvery persistent behavior is a `.lua` file in the Montage tree returning a `self` table:\n\n```lua\nlocal self = {}\nfunction self:Start() end\nfunction self:Update(deltaTime) end\nreturn self\n```\n\nAttach to an object with `obj:AddScript(\"My Folder/Example.lua\", sync)` - the path is\nrelative to the Montage root; `sync = true` replicates the script to clients.\n`obj:FindScript(\"Example\")` returns the live instance.\n\n### Server / client split\n\nThe same script runs on the server and (when synced) on every client. Branch with\n`self.onServer` / `self.onClient`:\n\n```lua\nfunction self:Update(dt)\n if self.onClient then return end -- voxel edits are server-only\n -- authoritative logic here\nend\n```\n\nNothing replicates automatically: set `syncToClients = true` on the script or\nVoxelData component, and use `util:makeNetworkedTable(self, { hp = 100 })` for\nproperties that should sync (server writes, clients read, deltas only).\n`obj.pos` / `obj.rot` do not auto-sync - replicate them yourself.\n\n### RPC\n\n```lua\nself:RPC(\"serverDoSomething\", pos, dir) -- on a client: goes to the server\nfunction self:serverDoSomething(pos, dir, clientID) -- clientID auto-appended\n assert(self.onServer)\nend\n```\n\nOn the server, `self:RPC(...)` fans out to all clients. Reliable, FIFO per direction.\nRequires `self.component.syncToClients = true`.\n\n### Scene and objects (server)\n\n```lua\nlocal ob = Scene:CreateObject(\"Name\")\nob.save = true\nob:AddScript(\"Path/Script.lua\")\nScene:GetObjectByName(\"Name\")\nScene:CloneObject(ob) -- prefer over rebuilding\nob.active = false -- prefer over destroy\nob:AddTag(\"Enemy\"); FindObjectsWithTag(\"Enemy\") -- tags are runtime-only state\n```\n\n### Voxel editing (server only, async)\n\n```lua\nVox:Add(Sphere(pos, 2)):Color(1, 0, 0):Run()\nVox:Add(Box(Vec3(0, -1, 0), Vec3(20, 2, 20))):ForceStatic():Run()\n```\n\nShapes: `Box(center, fullSize)`, `Sphere(center, r)`, `Capsule(p1, p2, r)`,\n`Cylinder(p1, p2, r1[, r2])`. `Run()` is async - chain `:OnFinished(fn)` before\n`:Run()` to read post-commit state. Big edits stall the frame for every client;\nkeep in-game edits small and infrequent.\n\n### Input (event-driven, client input reaches the server)\n\n```lua\nself.component.syncToClients = true\nevents.keyDown.addListener(self, function(key, from) self.keys[key] = true end)\nevents.keyUp.addListener(self, function(key, from) self.keys[key] = false end)\n```\n\nPlayer-driven movement must be client-predicted - never gate the player's own\nfeedback on the server round-trip.\n\n### Physics\n\n```lua\nlocal rb = obj:AddComponent(\"RigidBody\")\nrb:AddImpulse(Vec3(0, 50, 0), obj.pos)\nrb.velocity; rb.mass; rb.gravityScale\n```\n\n### Time and diagnostics\n\n```lua\nTime.dt; Time.time; Time.frame; Time.timeScale\nAE:GetLogValue(\"Raycasts\") -- engine counters, e.g. raycasts this frame\nAE:GetAssets() -- list all assets (APIs take Assets, not paths)\n```\n\n### UI (client-side HUD)\n\nBuild screen UI with the MUI builder: `UI:AddPanel|AddButton|AddLabel|AddSlider|...`\nchained with `:Set{...}`; flexbox-like row-wrap layout.\n\n## Conventions\n\n- Coordinates: Y is up, -Z is forward, X is right.\n- Script files are named in capital case with spaces (`Character Controller.lua`).\n- Check whether a script is already attached before adding it - duplicates run twice.\n- The server is for authority (world edits, spawns, state); clients are for feel\n (prediction, FX, UI).\n\n## Embed or build a world for an existing website\n\nFor a shared project link, fetch `/api/v1/public/embed?target=<encoded-link>`\nand follow `/embed-guide.md`. Use the returned canonical player URL, not the\nmaker's external website URL. Keep the existing website and its design.\n\nFor account discovery or prompt-based creation, connect the hosted MCP at\n`https://make.virtualmatter.ai/api/v1/mcp` using OAuth. Users can sign up in\nthe connection flow. Tools: `list_projects`, `get_embed`, `create_project`\n(with a prompt and stable request_id), `get_build_status`. VM's agent builds\nusing VM credits. Review and share the private world before embedding.\nThe local CLI exposes `embed`, `build --prompt --request-id`, and `build-status`.\n\n## Local conversation history\n\nAfter pulling a project, run `virtualmatter agent-logs setup` once to enable\nfuture chat history capture for local harnesses. It adds Codex, Claude Code,\nCursor, and (when installed) Hermes hooks without replacing existing hooks.\nKeep `virtualmatter sync` or `virtualmatter agent-logs watch` running for retry\nand delayed transcript capture. Follow each harness's normal hook review and\nrestart flow. Use `agent-logs status` to inspect pending uploads and\n`agent-logs disable` to stop capture.\n\nOther harnesses can send portable public event JSONL with\n`virtualmatter agent-logs upload <file> --harness <name> --session <id>`, or use\nMCP `upload_agent_logs`. Supply stable event IDs for retries; omit private\nreasoning, system/developer prompts, credentials, and binary media.\n";
|
|
6
|
+
export const AGENTS_MD_FALLBACK = "# AGENTS.md - working with a Virtual Matter project\n\nThis file tells a coding agent how to think about a Virtual Matter project. The\n`virtualmatter` CLI drops a copy into every local project mirror it creates\n(`virtualmatter pull`); the canonical copy lives at\nhttps://make.virtualmatter.ai/AGENTS.md.\n\n## The rule that comes before everything else\n\n**Build through the CLI. Never through the website's agent.** If you were asked to\nbuild something in Virtual Matter, handed the domain virtualmatter.ai, or told to run\n`npx virtualmatter make`, the CLI is the path - always, from the first step. Do not\nopen virtualmatter.ai or make.virtualmatter.ai in a browser or under computer use in\norder to reach the built-in agent, and do not type prompts into the agent chat in the\n/edit view. Driving that agent puts two models in a relay where one would do: you spend\ntokens describing the work, it spends its own doing it, and what comes back is chat\nrather than files you can read. It is slower, it costs roughly twice the inference, it\nbills that inference to Virtual Matter's platform credits rather than the subscription\nyou already run on, an anonymous browser session cannot be steered after its first\nturn, and a rendered page gives you no engine errors. Everything that agent can do, you\ndo yourself with the commands below.\n\n**The rule holds whoever is signed in.** Being logged in, having credits to spare, or\nworking in an admin account with no limit at all is not an exemption, and neither is\nbeing told the credits do not matter. What is wasteful is the relay itself - two models\ndoing one model's job - not the bill.\n\n**Playtesting is the exception, and it is encouraged.** Once something is built, open\nthe world and try it: the editor link ending in `?chat=closed`, which `make`, `pull` and\n`sync` print, shows your latest changes live in any browser with no install, with\ncomputer use if that is how you drive a window. The native client\n(`npx virtualmatter open`) is optional, for more memory and frame rate. Looking at your\nwork, walking around in it, and playing it is the point. The rule is about who writes\nthe world, not about who is allowed to see it.\n\n**Every link resolves to the editor, and the editor is the only place you build.** Hand\nthe CLI or MCP server any link to a world - its editor, a play or share link, the\nproject page, an embed's iframe src, or the maker's own website - and it works on that\nworld's editor (its make framing). Play servers show the last published version and are\noverwritten on every publish, so work never goes there, and their /play and /g links\nshow that published copy rather than your latest edits.\n\n## The mental model\n\n- The world itself runs on Virtual Matter servers, not on the maker's machine. There is\n one authoritative server simulation per world session, and any number of connected\n clients (browser WASM or the native client).\n- The project's content is SDK Lua scripts plus assets (voxel data, prefabs, images,\n sounds) under a `Montage/` tree. Virtual Matter can publish that tree to a GitHub repo\n in the maker's account for versioning and remixing; pushing to that repo does not feed\n back into the running world today - live edits happen in the session.\n- Changes hot-reload. Saving a `.lua` file in the live session's Montage tree re-runs\n the module and its `Start()` on a fresh instance inside the running world - no restart,\n no build step.\n- Prefabs are reusable scene-graph snippets: JSON files with a `.prefab` extension in the\n Montage folder, instantiated at runtime via `Server:InsertPrefab(asset)`.\n- There is no local runtime - the world cannot run on the maker's machine. The\n `virtualmatter` CLI (npm) is how you reach it from a local harness, and it needs no\n setup: the first command that needs an account signs you in with a device code.\n `npx virtualmatter make \"My world\"` makes a world and mirrors its Montage tree\n into `./my-world`; `npx virtualmatter pull <any Virtual Matter link>` mirrors an\n existing one (/edit, /play, /g, /p, /projects links all work, with or without a\n readable slug); `npx virtualmatter list` shows your worlds; `npx virtualmatter sync`\n live-pushes saves into the running session (which hot-reloads them);\n `npx virtualmatter run-lua` / `errors` / `screenshot` drive the engine;\n `npx virtualmatter open` optionally opens the world in the native desktop client,\n downloaded and signed in on first use; and `npx virtualmatter mcp` serves\n all of it over MCP. A mirrored folder carries `.mcp.json` and `.cursor/mcp.json`\n (so Claude Code and Cursor register the MCP server on their own), this file, and a\n `CLAUDE.md` that imports it. Folders from virtualmatter 0.5.0 on also carry\n `.codex/config.toml`, which Codex loads once you trust the folder; with an older\n CLI, or if Codex does not list the server, register it once with\n `codex mcp add virtualmatter -- npx -y virtualmatter mcp`. In Claude Code outside a\n mirrored folder, use `claude mcp add virtualmatter -- npx -y virtualmatter mcp`. The\n server works before a world\n is selected (`list_projects`, `create_project`, `select_project`), then exposes\n `list_files`, `read_file`, `write_file`, `run_lua`, `get_engine_errors`,\n `capture_screenshot`, `open_native_client`, and `world_info`.\n- Work through the CLI, not the website - see the rule above. Prompting the built-in\n agent to do your building is the one thing to avoid; opening the world to play it is\n not.\n- Seeing your work: `virtualmatter screenshot` shoots a default overview of the world\n origin, `--target <object>` frames one object by name or id, and `--at x,y,z --rot\n yaw,pitch,roll` places the camera exactly. Y is up and -Z is forward, and the\n rotation really is yaw first: yaw 0 faces -Z, pitch -90 looks straight down, pitch 0\n is the horizon. The MCP `capture_screenshot`\n tool takes the same arguments. Verify a change with a screenshot plus `errors` rather\n than assuming a write worked.\n- The mirrored folder's AGENTS.md is this guide followed by the engine SDK's own agent guide\n (the AGENTS.md that lives in every Montage tree and inside the desktop client's\n Data/Sdk/Montage/). The engine guide assumes an in-session agent driving the engine\n through `atomo`; the merged file maps each `atomo` step to the CLI or MCP equivalent, and\n the `Skills/*.md` references it points at are in the folder. The SDK's own tooling\n (`atomo`, `vm_auth.py`, the agent-log hooks) is not mirrored: it only works inside a\n session. Sandboxed agents need network access for every CLI command.\n\n## Reference\n\nThe same facts as terse lists, for quick lookup.\n\n### Project model\n- A project (\"montage\") is one voxel world hosted with Virtual Matter.\n- A project has framings: \"make\" framings (editing sessions) and \"play\"\n framings (public play sessions). A framing is a running session slot\n on a voxel host.\n- Project content = SDK Lua scripts + assets under a Montage/ tree.\n Edits made in the live session hot-reload into the running world.\n The tree can be published to a maker-owned GitHub repo.\n\n### URL shapes (make.virtualmatter.ai)\n- /new builder for people: create a world from a prompt\n (also at https://virtualmatter.ai/); coding agents\n use npx virtualmatter make instead\n- /edit/<framing-id> maker session: live world + agent chat. The chat is\n for people; a coding agent builds with the CLI and\n never prompts it\n (also /edit/<slug>-<framing-id>; the CLI accepts both)\n- /play/<framing-id> play session for a specific framing\n- /g/<id> share URL resolver: 302s to a live session\n\n### APIs (unauthenticated)\n- GET https://make.virtualmatter.ai/api/v1/public/native-clients\n JSON: { iteration, clients: [{ platform, kind, variant, url, filename,\n file_size, branch, commit, match }] } - per-platform native client\n installers (kind \"download\") or store links (kind \"store\", iOS).\n Windows and Linux list two downloads each: variant \"installer\" /\n \"flatpak\" for people, and \"portable\" / \"tarball\", the file tree the\n CLI installs.\n\n### CLI + MCP (npm package \"virtualmatter\", Node >= 20)\n- npx virtualmatter make \"<name>\" make a world, mirror it into ./<slug>\n- npx virtualmatter pull <link> mirror an existing world (any link shape)\n- npx virtualmatter list your worlds with framing ids + URLs\n- npx virtualmatter sync live-push saves into the running world\n- npx virtualmatter open optional: open it in the native client\n- npx virtualmatter mcp stdio MCP server (list_projects,\n create_project, select_project, list_files, read_file, write_file,\n run_lua, get_engine_errors, capture_screenshot, open_native_client,\n world_info)\n- Sign-in happens on first use (device code); no separate login step.\n- These commands are the whole build path. Never prompt the built-in\n agent on the site to do the work; a browser or the native client is\n for playtesting what you built.\n- Any link to a world (editor, play or share link, project page, embed,\n or the maker's own website) resolves to its editor, the only place\n the CLI and MCP server work. Play servers show the last published\n version and are overwritten on publish.\n- Watch changes land live at the editor link ending in ?chat=closed,\n in any browser, no install. The native client is optional.\n- A mirrored folder carries .mcp.json, .cursor/mcp.json, AGENTS.md and\n CLAUDE.md, so Claude Code and Cursor register the server on their own.\n- Codex: folders from 0.5.0 on carry .codex/config.toml, loaded once the\n folder is trusted; otherwise register the server once:\n codex mcp add virtualmatter -- npx -y virtualmatter mcp\n\n## SDK Lua cheat sheet\n\nThe scripting language is Lua 5.4, sandboxed: `os`, `io`, `require`, `package`,\n`dofile`, `loadfile`, `loadstring` are nil. `math`, `string`, `table`, `coroutine`\nremain. No `os.time` - use `Time.time` (sim time), `Time.frame`, or\n`AE:GetDebugTime()` (wall clock).\n\n### Script shape\n\nEvery persistent behavior is a `.lua` file in the Montage tree returning a `self` table:\n\n```lua\nlocal self = {}\nfunction self:Start() end\nfunction self:Update(deltaTime) end\nreturn self\n```\n\nAttach to an object with `obj:AddScript(\"My Folder/Example.lua\", sync)` - the path is\nrelative to the Montage root; `sync = true` replicates the script to clients.\n`obj:FindScript(\"Example\")` returns the live instance.\n\n### Server / client split\n\nThe same script runs on the server and (when synced) on every client. Branch with\n`self.onServer` / `self.onClient`:\n\n```lua\nfunction self:Update(dt)\n if self.onClient then return end -- voxel edits are server-only\n -- authoritative logic here\nend\n```\n\nNothing replicates automatically: set `syncToClients = true` on the script or\nVoxelData component, and use `util:makeNetworkedTable(self, { hp = 100 })` for\nproperties that should sync (server writes, clients read, deltas only).\n`obj.pos` / `obj.rot` do not auto-sync - replicate them yourself.\n\n### RPC\n\n```lua\nself:RPC(\"serverDoSomething\", pos, dir) -- on a client: goes to the server\nfunction self:serverDoSomething(pos, dir, clientID) -- clientID auto-appended\n assert(self.onServer)\nend\n```\n\nOn the server, `self:RPC(...)` fans out to all clients. Reliable, FIFO per direction.\nRequires `self.component.syncToClients = true`.\n\n### Scene and objects (server)\n\n```lua\nlocal ob = Scene:CreateObject(\"Name\")\nob.save = true\nob:AddScript(\"Path/Script.lua\")\nScene:GetObjectByName(\"Name\")\nScene:CloneObject(ob) -- prefer over rebuilding\nob.active = false -- prefer over destroy\nob:AddTag(\"Enemy\"); FindObjectsWithTag(\"Enemy\") -- tags are runtime-only state\n```\n\n### Voxel editing (server only, async)\n\n```lua\nVox:Add(Sphere(pos, 2)):Color(1, 0, 0):Run()\nVox:Add(Box(Vec3(0, -1, 0), Vec3(20, 2, 20))):ForceStatic():Run()\n```\n\nShapes: `Box(center, fullSize)`, `Sphere(center, r)`, `Capsule(p1, p2, r)`,\n`Cylinder(p1, p2, r1[, r2])`. `Run()` is async - chain `:OnFinished(fn)` before\n`:Run()` to read post-commit state. Big edits stall the frame for every client;\nkeep in-game edits small and infrequent.\n\n### Input (event-driven, client input reaches the server)\n\n```lua\nself.component.syncToClients = true\nevents.keyDown.addListener(self, function(key, from) self.keys[key] = true end)\nevents.keyUp.addListener(self, function(key, from) self.keys[key] = false end)\n```\n\nPlayer-driven movement must be client-predicted - never gate the player's own\nfeedback on the server round-trip.\n\n### Physics\n\n```lua\nlocal rb = obj:AddComponent(\"RigidBody\")\nrb:AddImpulse(Vec3(0, 50, 0), obj.pos)\nrb.velocity; rb.mass; rb.gravityScale\n```\n\n### Time and diagnostics\n\n```lua\nTime.dt; Time.time; Time.frame; Time.timeScale\nAE:GetLogValue(\"Raycasts\") -- engine counters, e.g. raycasts this frame\nAE:GetAssets() -- list all assets (APIs take Assets, not paths)\n```\n\n### UI (client-side HUD)\n\nBuild screen UI with the MUI builder: `UI:AddPanel|AddButton|AddLabel|AddSlider|...`\nchained with `:Set{...}`; flexbox-like row-wrap layout.\n\n## Conventions\n\n- Coordinates: Y is up, -Z is forward, X is right.\n- Script files are named in capital case with spaces (`Character Controller.lua`).\n- Check whether a script is already attached before adding it - duplicates run twice.\n- The server is for authority (world edits, spawns, state); clients are for feel\n (prediction, FX, UI).\n\n## Embed or build a world for an existing website\n\nFor a shared project link, fetch `/api/v1/public/embed?target=<encoded-link>`\nand follow `/embed-guide.md`. Use the returned canonical player URL, not the\nmaker's external website URL. Keep the existing website and its design.\n\nFor account discovery or prompt-based creation, connect the hosted MCP at\n`https://make.virtualmatter.ai/api/v1/mcp` using OAuth. Users can sign up in\nthe connection flow. Tools: `list_projects`, `get_embed`, `create_project`\n(with a prompt and stable request_id), `get_build_status`. VM's agent builds\nusing VM credits. Review and share the private world before embedding.\nThe local CLI exposes `embed`, `build --prompt --request-id`, and `build-status`.\n\n## Local conversation history\n\nCapturing this harness's conversation into the world's Agent Logs is the maker's\ndecision, not yours. Do not run `virtualmatter agent-logs setup`, and do not upload\nlogs with `agent-logs upload` or MCP `upload_agent_logs`, unless the maker asks for it\nin their own words. `virtualmatter agent-logs status` shows whether capture is on;\n`virtualmatter agent-logs disable` turns it off.\n";
|
package/dist/client.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Builds are unpacked under <configDir>/client/<dir_uid>/ so re-running
|
|
7
7
|
* `open` is instant, and a newer build lands next to the old one instead
|
|
8
|
-
* of over it. Archives are removed after extraction
|
|
8
|
+
* of over it. Archives are removed after extraction, and once a new build
|
|
9
|
+
* is in place the older ones are removed unless a client is running from them.
|
|
9
10
|
*/
|
|
10
11
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
12
|
import fs from "node:fs";
|
|
@@ -21,9 +22,23 @@ export function detectPlatform(p = process.platform) {
|
|
|
21
22
|
return "windows";
|
|
22
23
|
return "linux";
|
|
23
24
|
}
|
|
24
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The build the CLI can install for a platform, or null when there is none.
|
|
27
|
+
*
|
|
28
|
+
* Linux and Windows list two downloads each: the one for people (the Flatpak,
|
|
29
|
+
* the installer) and a plain file tree (the tarball, the portable zip). The
|
|
30
|
+
* CLI unpacks and runs a file tree, which a Flatpak bundle or a Setup.exe is
|
|
31
|
+
* not, so it always takes the file tree - by `variant`, and by file name for a
|
|
32
|
+
* platform that predates the field.
|
|
33
|
+
*/
|
|
25
34
|
export function pickBuild(clients, platform) {
|
|
26
|
-
return clients.find((c) => c.platform.toLowerCase() === platform && c.kind === "download") ?? null;
|
|
35
|
+
return (clients.find((c) => c.platform.toLowerCase() === platform && c.kind === "download" && !isHumanOnlyDownload(c)) ?? null);
|
|
36
|
+
}
|
|
37
|
+
function isHumanOnlyDownload(client) {
|
|
38
|
+
if (client.variant === "flatpak" || client.variant === "installer")
|
|
39
|
+
return true;
|
|
40
|
+
const name = (client.filename ?? client.url).toLowerCase();
|
|
41
|
+
return name.endsWith(".flatpak") || name.endsWith(".exe") || name.endsWith(".msi");
|
|
27
42
|
}
|
|
28
43
|
/** A stable per-build install directory name derived from the download path. */
|
|
29
44
|
export function buildKey(client) {
|
|
@@ -116,10 +131,86 @@ async function download(url, dest, onProgress, fetchFn = fetch) {
|
|
|
116
131
|
}
|
|
117
132
|
fs.renameSync(tmp, dest);
|
|
118
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The path of `lsregister`, the tool that edits macOS's database of known apps.
|
|
136
|
+
* Not on any PATH - it lives inside the LaunchServices framework.
|
|
137
|
+
*/
|
|
138
|
+
const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
139
|
+
/**
|
|
140
|
+
* Take this cached client OUT of macOS's app database.
|
|
141
|
+
*
|
|
142
|
+
* The cache holds a real .app bundle, and macOS registers every bundle it
|
|
143
|
+
* sees. Every copy carries the same bundle id, and the "which app opens
|
|
144
|
+
* virtualmatter:// links" preference is stored per bundle ID, not per path -
|
|
145
|
+
* so macOS picks among the copies by its own rules, and a cached build from
|
|
146
|
+
* last week can win over the one in /Applications. That is not hypothetical:
|
|
147
|
+
* a world link opened a nine-day-old cached client, which then refused to
|
|
148
|
+
* connect to its own world because the client predated a compatibility fix on
|
|
149
|
+
* that same release branch.
|
|
150
|
+
*
|
|
151
|
+
* The CLI launches this copy by path, so it never needs to be registered.
|
|
152
|
+
* Unregistering it leaves the user's installed app as the only candidate.
|
|
153
|
+
* Best-effort by design: this is tidiness, not correctness, and any failure
|
|
154
|
+
* (another OS, a future layout, a locked database) must not break a launch.
|
|
155
|
+
*/
|
|
156
|
+
export function dropFromLaunchServices(appPath, platform = detectPlatform(), runner = spawnSync) {
|
|
157
|
+
if (platform !== "macos" || !appPath.endsWith(".app"))
|
|
158
|
+
return;
|
|
159
|
+
try {
|
|
160
|
+
runner(LSREGISTER, ["-u", appPath], { stdio: "ignore" });
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* tidiness only */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function run(cmd, args) {
|
|
167
|
+
const res = spawnSync(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
168
|
+
if (res.error)
|
|
169
|
+
throw new Error(`Could not run ${cmd}: ${res.error.message}`);
|
|
170
|
+
if (res.status !== 0) {
|
|
171
|
+
throw new Error(`${cmd} failed (${res.status}): ${res.stderr?.toString().trim()}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A macOS disk image: mount it read-only out of sight, copy every top-level
|
|
176
|
+
* .app out with ditto (which keeps the bundle's symlinks and signature), and
|
|
177
|
+
* always detach. The image's other entries - the /Applications shortcut, the
|
|
178
|
+
* window background - are for people dragging the app across by hand.
|
|
179
|
+
*/
|
|
180
|
+
function extractDmg(archive, dest) {
|
|
181
|
+
const mount = fs.mkdtempSync(path.join(path.dirname(dest), ".mount-"));
|
|
182
|
+
try {
|
|
183
|
+
run("hdiutil", ["attach", archive, "-readonly", "-nobrowse", "-noautoopen", "-noverify", "-mountpoint", mount]);
|
|
184
|
+
try {
|
|
185
|
+
const apps = fs.readdirSync(mount).filter((n) => n.endsWith(".app"));
|
|
186
|
+
if (apps.length === 0)
|
|
187
|
+
throw new Error(`${path.basename(archive)} holds no .app bundle.`);
|
|
188
|
+
for (const app of apps)
|
|
189
|
+
run("ditto", [path.join(mount, app), path.join(dest, app)]);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
const res = spawnSync("hdiutil", ["detach", mount, "-quiet"], { stdio: "ignore" });
|
|
193
|
+
if (res.status !== 0)
|
|
194
|
+
spawnSync("hdiutil", ["detach", mount, "-force", "-quiet"], { stdio: "ignore" });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
try {
|
|
199
|
+
fs.rmdirSync(mount);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
/* still attached: removing it would reach into the image */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
119
206
|
/** Unpack with the OS's own tools: ditto keeps bundle symlinks + bits on macOS; bsdtar handles zip + tgz elsewhere. */
|
|
120
207
|
export function extractArchive(archive, dest, platform) {
|
|
121
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
122
208
|
const lower = archive.toLowerCase();
|
|
209
|
+
if (platform === "macos" && lower.endsWith(".dmg")) {
|
|
210
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
211
|
+
extractDmg(archive, dest);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
123
214
|
let cmd;
|
|
124
215
|
let args;
|
|
125
216
|
if (platform === "macos" && lower.endsWith(".zip")) {
|
|
@@ -137,12 +228,65 @@ export function extractArchive(archive, dest, platform) {
|
|
|
137
228
|
else {
|
|
138
229
|
throw new Error(`Don't know how to unpack ${path.basename(archive)}.`);
|
|
139
230
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
231
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
232
|
+
run(cmd, args);
|
|
233
|
+
}
|
|
234
|
+
const ARCHIVE_RE = /\.(zip|tgz|tar\.gz|tar\.xz|tar\.bz2|tar|dmg)(\.part)?$/i;
|
|
235
|
+
/** A partial download younger than this may belong to another `open` still running. */
|
|
236
|
+
const PART_GRACE_MS = 60 * 60 * 1000;
|
|
237
|
+
/** Whether a running process was started from inside `dir`. */
|
|
238
|
+
function runningFrom(dir) {
|
|
239
|
+
if (process.platform === "win32")
|
|
240
|
+
return false; // Windows refuses to delete a running build, which is the same answer
|
|
241
|
+
const res = spawnSync("ps", ["-axo", "command="], { encoding: "utf8" });
|
|
242
|
+
if (res.status !== 0)
|
|
243
|
+
return true; // unknown: keep it
|
|
244
|
+
return res.stdout.split("\n").some((line) => line.includes(dir + path.sep));
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Remove what earlier installs left in the install root: other builds, empty
|
|
248
|
+
* folders and archives from a download or unpack that never finished. Only
|
|
249
|
+
* touches what this module creates - a folder carrying the install marker,
|
|
250
|
+
* an empty folder, an archive - so a shared VIRTUALMATTER_CLIENT_DIR keeps
|
|
251
|
+
* anything else. A build a client is still running from is kept. Returns the
|
|
252
|
+
* paths removed.
|
|
253
|
+
*/
|
|
254
|
+
export function pruneOtherBuilds(keepDir, root = path.dirname(keepDir)) {
|
|
255
|
+
let entries;
|
|
256
|
+
try {
|
|
257
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
145
258
|
}
|
|
259
|
+
catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
const removed = [];
|
|
263
|
+
for (const e of entries) {
|
|
264
|
+
const full = path.join(root, e.name);
|
|
265
|
+
if (path.resolve(full) === path.resolve(keepDir))
|
|
266
|
+
continue;
|
|
267
|
+
try {
|
|
268
|
+
if (e.isDirectory()) {
|
|
269
|
+
const ours = fs.existsSync(path.join(full, MARKER)) || fs.readdirSync(full).length === 0;
|
|
270
|
+
if (!ours || runningFrom(full))
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
else if (e.isFile()) {
|
|
274
|
+
if (!ARCHIVE_RE.test(e.name))
|
|
275
|
+
continue;
|
|
276
|
+
if (e.name.endsWith(".part") && Date.now() - fs.statSync(full).mtimeMs < PART_GRACE_MS)
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
283
|
+
removed.push(full);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
/* in use or not ours to delete: leave it */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return removed;
|
|
146
290
|
}
|
|
147
291
|
function formatMb(bytes) {
|
|
148
292
|
return `${(bytes / 1048576).toFixed(0)} MB`;
|
|
@@ -177,11 +321,21 @@ export async function ensureClientInstalled(opts = {}) {
|
|
|
177
321
|
}, fetchFn);
|
|
178
322
|
log(`Unpacking into ${dir} ...`);
|
|
179
323
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
324
|
+
let entrypoint;
|
|
325
|
+
try {
|
|
326
|
+
extractArchive(archive, dir, platform);
|
|
327
|
+
entrypoint = findEntrypoint(dir, platform);
|
|
328
|
+
if (!entrypoint)
|
|
329
|
+
throw new Error(`Unpacked ${build.filename ?? "the build"} but found nothing to launch in ${dir}.`);
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
// Leave nothing half-installed behind: the next run downloads afresh either way.
|
|
333
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
fs.rmSync(archive, { force: true });
|
|
338
|
+
}
|
|
185
339
|
if (platform !== "windows") {
|
|
186
340
|
// Archives produced on Windows CI can lose the executable bit.
|
|
187
341
|
for (const p of [entrypoint, path.join(dir, "Client"), path.join(dir, "crashpad_handler")]) {
|
|
@@ -195,7 +349,12 @@ export async function ensureClientInstalled(opts = {}) {
|
|
|
195
349
|
}
|
|
196
350
|
}
|
|
197
351
|
const installed = { platform, dir, entrypoint, build };
|
|
352
|
+
// Keep the cache out of the OS's app database (see dropFromLaunchServices).
|
|
353
|
+
dropFromLaunchServices(entrypoint, platform);
|
|
198
354
|
fs.writeFileSync(path.join(dir, MARKER), JSON.stringify(installed, null, 2) + "\n");
|
|
355
|
+
const pruned = pruneOtherBuilds(dir);
|
|
356
|
+
if (pruned.length)
|
|
357
|
+
log(`Removed ${pruned.length === 1 ? "an older build" : `${pruned.length} older builds and leftovers`}: ${pruned.map((p) => path.basename(p)).join(", ")}`);
|
|
199
358
|
return installed;
|
|
200
359
|
}
|
|
201
360
|
function apiBaseFor(_build) {
|
|
@@ -225,6 +384,9 @@ export function launchClient(installed, montageUrl) {
|
|
|
225
384
|
console.error(`Could not launch the client: ${err.message}`);
|
|
226
385
|
});
|
|
227
386
|
child.unref();
|
|
387
|
+
// Launching re-registers the bundle, so drop it again: the installed app in
|
|
388
|
+
// /Applications should stay the one that opens world links.
|
|
389
|
+
dropFromLaunchServices(installed.entrypoint, installed.platform);
|
|
228
390
|
}
|
|
229
391
|
// ---------------------------------------------------------------- sign-in hand-over
|
|
230
392
|
/**
|
package/dist/files.js
CHANGED
|
@@ -112,11 +112,14 @@ export class FilesClient {
|
|
|
112
112
|
throw new Error(`Deleting ${filePath} failed: HTTP ${res.status}`);
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
-
async runLua(code, target) {
|
|
115
|
+
async runLua(code, target, clientId) {
|
|
116
|
+
const payload = { code, target };
|
|
117
|
+
if (target === "client" && clientId !== undefined)
|
|
118
|
+
payload.client_id = clientId;
|
|
116
119
|
const res = await authorizedFetch(`${this.apiRoot}/engine/run-lua`, {
|
|
117
120
|
method: "POST",
|
|
118
121
|
headers: { "Content-Type": "application/json" },
|
|
119
|
-
body: JSON.stringify(
|
|
122
|
+
body: JSON.stringify(payload),
|
|
120
123
|
}, this.fetchFn);
|
|
121
124
|
if (!res.ok)
|
|
122
125
|
throw new Error(await engineErrorMessage(res, "run-lua"));
|
|
@@ -151,6 +154,8 @@ export class FilesClient {
|
|
|
151
154
|
camera_rot_z: rz,
|
|
152
155
|
});
|
|
153
156
|
}
|
|
157
|
+
if (opts.clientId !== undefined)
|
|
158
|
+
body.client_id = opts.clientId;
|
|
154
159
|
const res = await authorizedFetch(`${this.apiRoot}/engine/screenshot`, {
|
|
155
160
|
method: "POST",
|
|
156
161
|
headers: { "Content-Type": "application/json" },
|
package/dist/index.js
CHANGED
|
@@ -300,12 +300,17 @@ program
|
|
|
300
300
|
.argument("[dir]", "a synced folder", ".")
|
|
301
301
|
.requiredOption("--code <lua>", "Lua source to execute")
|
|
302
302
|
.option("--target <target>", "server or client", "server")
|
|
303
|
+
.option("--client <id>", "client id for --target client (default: the only connected client; list them with --code 'return Server:GetClients()')")
|
|
303
304
|
.action((dir, opts) => run(async () => {
|
|
304
305
|
if (opts.target !== "server" && opts.target !== "client") {
|
|
305
306
|
fail("--target must be server or client");
|
|
306
307
|
}
|
|
308
|
+
const clientId = opts.client === undefined ? undefined : clientIdFlag(opts.client);
|
|
309
|
+
if (clientId !== undefined && opts.target !== "client") {
|
|
310
|
+
fail("--client needs --target client");
|
|
311
|
+
}
|
|
307
312
|
const { client } = await clientForDir(path.resolve(dir));
|
|
308
|
-
const result = await client.runLua(opts.code, opts.target);
|
|
313
|
+
const result = await client.runLua(opts.code, opts.target, clientId);
|
|
309
314
|
console.log(JSON.stringify({ result }, null, 2));
|
|
310
315
|
}));
|
|
311
316
|
program
|
|
@@ -316,6 +321,13 @@ program
|
|
|
316
321
|
const { client } = await clientForDir(path.resolve(dir));
|
|
317
322
|
console.log(JSON.stringify(await client.engineErrors(), null, 2));
|
|
318
323
|
}));
|
|
324
|
+
/** Parse a --client flag: a positive integer client id. */
|
|
325
|
+
function clientIdFlag(value) {
|
|
326
|
+
const n = Number(value);
|
|
327
|
+
if (!Number.isInteger(n) || n < 1)
|
|
328
|
+
fail(`--client takes a client id (a positive integer), got "${value}"`);
|
|
329
|
+
return n;
|
|
330
|
+
}
|
|
319
331
|
/** Parse an "x,y,z" flag into a numeric triple. */
|
|
320
332
|
function triple(value, flag) {
|
|
321
333
|
const parts = value.split(",").map((p) => Number(p.trim()));
|
|
@@ -333,6 +345,7 @@ program
|
|
|
333
345
|
.option("--rot <yaw,pitch,roll>", "camera rotation in degrees, yaw first (default: 0,-45,0)")
|
|
334
346
|
.option("--target <id-or-name>", "frame this object instead of using a camera pose")
|
|
335
347
|
.option("--distance <meters>", "framing distance for --target (default: from the object's bounds)")
|
|
348
|
+
.option("--client <id>", "capture through this connected client, UI included; with no --at/--rot/--target it is exactly what that client sees")
|
|
336
349
|
.action((dir, opts) => run(async () => {
|
|
337
350
|
const { client } = await clientForDir(path.resolve(dir));
|
|
338
351
|
const png = await client.screenshot({
|
|
@@ -340,6 +353,7 @@ program
|
|
|
340
353
|
rot: opts.rot ? triple(opts.rot, "--rot") : undefined,
|
|
341
354
|
target: opts.target,
|
|
342
355
|
distance: opts.distance === undefined ? undefined : Number(opts.distance),
|
|
356
|
+
clientId: opts.client === undefined ? undefined : clientIdFlag(opts.client),
|
|
343
357
|
});
|
|
344
358
|
fs.writeFileSync(opts.out, png);
|
|
345
359
|
console.log(`Wrote ${opts.out} (${png.length} bytes).`);
|
package/dist/mcp.js
CHANGED
|
@@ -249,17 +249,23 @@ export async function runMcpServer(dir, framingFlag) {
|
|
|
249
249
|
return textResult(JSON.stringify({ ok: true, path: filePath, etag }));
|
|
250
250
|
});
|
|
251
251
|
server.registerTool("run_lua", {
|
|
252
|
-
description: "Execute a Lua snippet in the running Virtual Matter engine and return its result. target \"server\" (default) runs in the server-side Lua state where game logic lives; \"client\" runs in
|
|
252
|
+
description: "Execute a Lua snippet in the running Virtual Matter engine and return its result. target \"server\" (default) runs in the server-side Lua state where game logic lives; \"client\" runs in a connected client (someone must have the world open) - that is where UI, input and the camera live. With several clients connected, pass client_id; list them with target server and code 'return Server:GetClients()'. Use this to inspect live state, call engine APIs, or nudge the scene without editing files.",
|
|
253
253
|
inputSchema: {
|
|
254
254
|
code: z.string().describe("Lua source to execute"),
|
|
255
255
|
target: z
|
|
256
256
|
.enum(["server", "client"])
|
|
257
257
|
.optional()
|
|
258
258
|
.describe("Which Lua state to run in (default: server)"),
|
|
259
|
+
client_id: z
|
|
260
|
+
.number()
|
|
261
|
+
.int()
|
|
262
|
+
.positive()
|
|
263
|
+
.optional()
|
|
264
|
+
.describe("For target client: which connected client (default: the only one connected)"),
|
|
259
265
|
},
|
|
260
|
-
}, async ({ code, target }) => {
|
|
266
|
+
}, async ({ code, target, client_id }) => {
|
|
261
267
|
const client = await ctx.getClient();
|
|
262
|
-
const result = await client.runLua(code, target ?? "server");
|
|
268
|
+
const result = await client.runLua(code, target ?? "server", client_id);
|
|
263
269
|
return textResult(JSON.stringify({ result }, null, 2));
|
|
264
270
|
});
|
|
265
271
|
server.registerTool("get_engine_errors", {
|
|
@@ -286,13 +292,20 @@ export async function runMcpServer(dir, framingFlag) {
|
|
|
286
292
|
.length(3)
|
|
287
293
|
.optional()
|
|
288
294
|
.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."),
|
|
295
|
+
client_id: z
|
|
296
|
+
.number()
|
|
297
|
+
.int()
|
|
298
|
+
.positive()
|
|
299
|
+
.optional()
|
|
300
|
+
.describe("Capture through this connected client, UI (HUD, menus) included - a server capture never shows UI. With no target/at/rot it is exactly what that client sees."),
|
|
289
301
|
},
|
|
290
|
-
}, async ({ target, at, rot }) => {
|
|
302
|
+
}, async ({ target, at, rot, client_id }) => {
|
|
291
303
|
const client = await ctx.getClient();
|
|
292
304
|
const png = await client.screenshot({
|
|
293
305
|
target,
|
|
294
306
|
at: at,
|
|
295
307
|
rot: rot,
|
|
308
|
+
clientId: client_id,
|
|
296
309
|
});
|
|
297
310
|
return {
|
|
298
311
|
content: [
|
package/dist/pull.js
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveEditSession, sessionBaseUrl } from "./api.js";
|
|
|
5
5
|
import { composeAgentsMd, fetchAgentsMd, writeAgentFiles } from "./agentfiles.js";
|
|
6
6
|
import { FilesClient } from "./files.js";
|
|
7
7
|
import { isIgnoredPath } from "./ignore.js";
|
|
8
|
-
import { saveState } from "./state.js";
|
|
8
|
+
import { recordLocal, saveState } from "./state.js";
|
|
9
9
|
export { fetchAgentsMd } from "./agentfiles.js";
|
|
10
10
|
export { AGENTS_MD_FALLBACK } from "./agents-md.generated.js";
|
|
11
11
|
/** Download workers per pull. The tree is ~1000 small files; sequential
|
|
@@ -50,7 +50,7 @@ export async function fetchEngineAgentsMd(client) {
|
|
|
50
50
|
export async function pullTree(client, dir, framingId) {
|
|
51
51
|
fs.mkdirSync(dir, { recursive: true });
|
|
52
52
|
const remote = (await client.list()).filter((f) => !isIgnoredPath(f.path));
|
|
53
|
-
const state = { framing_id: framingId, etags: {} };
|
|
53
|
+
const state = { framing_id: framingId, etags: {}, local: {} };
|
|
54
54
|
const progress = makeProgress(remote.length);
|
|
55
55
|
let next = 0;
|
|
56
56
|
const worker = async () => {
|
|
@@ -64,6 +64,8 @@ export async function pullTree(client, dir, framingId) {
|
|
|
64
64
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
65
65
|
fs.writeFileSync(absPath, bytes);
|
|
66
66
|
state.etags[file.path] = etag ?? file.etag;
|
|
67
|
+
// what the disk holds now, so the first sync can tell local edits apart
|
|
68
|
+
recordLocal(state, dir, file.path, bytes);
|
|
67
69
|
progress.tick(file.path);
|
|
68
70
|
}
|
|
69
71
|
};
|
package/dist/state.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** The .virtualmatter.json state file: framing id + per-file
|
|
1
|
+
/** The .virtualmatter.json state file: framing id + per-file sync bookkeeping. */
|
|
2
|
+
import crypto from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { STATE_FILE } from "./config.js";
|
|
@@ -11,7 +12,7 @@ export function loadState(dir) {
|
|
|
11
12
|
const parsed = JSON.parse(raw);
|
|
12
13
|
if (typeof parsed.framing_id !== "string")
|
|
13
14
|
return null;
|
|
14
|
-
return { framing_id: parsed.framing_id, etags: parsed.etags ?? {} };
|
|
15
|
+
return { framing_id: parsed.framing_id, etags: parsed.etags ?? {}, local: parsed.local ?? {} };
|
|
15
16
|
}
|
|
16
17
|
catch {
|
|
17
18
|
return null;
|
|
@@ -30,3 +31,56 @@ export function requireState(dir) {
|
|
|
30
31
|
}
|
|
31
32
|
return state;
|
|
32
33
|
}
|
|
34
|
+
export function sha256Hex(bytes) {
|
|
35
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Record a file whose content hash is already known to match its last sync,
|
|
39
|
+
* from a stat alone. How a state file from before `local` records existed
|
|
40
|
+
* gets upgraded as files are first checked, so the next check is stat-only.
|
|
41
|
+
*/
|
|
42
|
+
export function adoptLocal(state, dir, relPath, hash) {
|
|
43
|
+
const st = fs.statSync(path.join(dir, relPath));
|
|
44
|
+
state.local ??= {};
|
|
45
|
+
state.local[relPath] = { hash, size: st.size, mtime_ms: st.mtimeMs };
|
|
46
|
+
}
|
|
47
|
+
/** Record `bytes` (just written to or read from `relPath`) as the synced local copy. */
|
|
48
|
+
export function recordLocal(state, dir, relPath, bytes) {
|
|
49
|
+
const st = fs.statSync(path.join(dir, relPath));
|
|
50
|
+
state.local ??= {};
|
|
51
|
+
state.local[relPath] = { hash: sha256Hex(bytes), size: st.size, mtime_ms: st.mtimeMs };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The current content hash of a local file, or null if it does not exist.
|
|
55
|
+
* Reuses the recorded hash when size and mtime are unchanged, so a start
|
|
56
|
+
* over a big world does not re-read every voxel file.
|
|
57
|
+
*/
|
|
58
|
+
export function localHash(state, dir, relPath) {
|
|
59
|
+
const abs = path.join(dir, relPath);
|
|
60
|
+
let st;
|
|
61
|
+
try {
|
|
62
|
+
st = fs.statSync(abs);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!st.isFile())
|
|
68
|
+
return null;
|
|
69
|
+
const rec = state.local?.[relPath];
|
|
70
|
+
if (rec && rec.size === st.size && rec.mtime_ms === st.mtimeMs)
|
|
71
|
+
return rec.hash;
|
|
72
|
+
return sha256Hex(fs.readFileSync(abs));
|
|
73
|
+
}
|
|
74
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
75
|
+
/**
|
|
76
|
+
* The content hash of `relPath` as of its last sync, or undefined when
|
|
77
|
+
* unknown. Falls back to the etag for state files written before `local`
|
|
78
|
+
* existed - the session API's etags ARE the sha256 of the content.
|
|
79
|
+
*/
|
|
80
|
+
export function syncedHash(state, relPath) {
|
|
81
|
+
const rec = state.local?.[relPath];
|
|
82
|
+
if (rec)
|
|
83
|
+
return rec.hash;
|
|
84
|
+
const etag = state.etags[relPath];
|
|
85
|
+
return etag !== undefined && SHA256_HEX.test(etag) ? etag : undefined;
|
|
86
|
+
}
|
package/dist/sync.js
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
import { startLogSync } from "./agent-logs.js";
|
|
2
2
|
/**
|
|
3
|
-
* The sync hot loop:
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* The sync hot loop: reconcile both sides at start, watch the local tree and
|
|
4
|
+
* push edits with stored etags, poll the remote listing and pull files whose
|
|
5
|
+
* etag changed remotely.
|
|
6
|
+
*
|
|
7
|
+
* NEVER OVERWRITE UNSYNCED WORK. Every decision compares each side against
|
|
8
|
+
* what it looked like at the last sync - the server's etag, and the local
|
|
9
|
+
* record (content hash) in the state file - so a side that moved while the
|
|
10
|
+
* other did not is copied across, and a file BOTH sides changed is a
|
|
11
|
+
* conflict: the local file stays exactly as it is, the remote version lands
|
|
12
|
+
* next to it as <name>.remote-conflict, and nothing is pushed until you save.
|
|
13
|
+
* Conflicts never kill the loop.
|
|
7
14
|
*/
|
|
8
15
|
import fs from "node:fs";
|
|
9
16
|
import path from "node:path";
|
|
10
17
|
import { ConflictError } from "./files.js";
|
|
11
18
|
import { isIgnoredPath } from "./ignore.js";
|
|
12
|
-
import { loadState, saveState } from "./state.js";
|
|
19
|
+
import { loadState, adoptLocal, localHash, recordLocal, saveState, sha256Hex, syncedHash, } from "./state.js";
|
|
13
20
|
const consoleLogger = {
|
|
14
21
|
info: (m) => console.log(m),
|
|
15
22
|
warn: (m) => console.warn(m),
|
|
16
23
|
};
|
|
17
24
|
/** How long after our own local write of a pulled file we ignore watcher events for it. */
|
|
18
25
|
const SELF_WRITE_GRACE_MS = 2000;
|
|
26
|
+
export const CONFLICT_SUFFIX = ".remote-conflict";
|
|
19
27
|
export class SyncEngine {
|
|
20
28
|
client;
|
|
21
29
|
dir;
|
|
@@ -24,12 +32,15 @@ export class SyncEngine {
|
|
|
24
32
|
state;
|
|
25
33
|
/** relPath -> timestamp of a write WE made (pull), so the watcher skips it. */
|
|
26
34
|
selfWrites = new Map();
|
|
35
|
+
/** State changed without being written yet (see flush). */
|
|
36
|
+
dirty = false;
|
|
27
37
|
constructor(client, dir, state, opts = {}) {
|
|
28
38
|
this.client = client;
|
|
29
39
|
this.dir = dir;
|
|
30
40
|
this.log = opts.logger ?? consoleLogger;
|
|
31
41
|
this.now = opts.now ?? Date.now;
|
|
32
42
|
this.state = state;
|
|
43
|
+
this.state.local ??= {};
|
|
33
44
|
}
|
|
34
45
|
get framingId() {
|
|
35
46
|
return this.state.framing_id;
|
|
@@ -53,6 +64,43 @@ export class SyncEngine {
|
|
|
53
64
|
}
|
|
54
65
|
return true;
|
|
55
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Has the local copy changed since its last sync? `true` also when the
|
|
69
|
+
* base is unknown but the file exists - "maybe" must be treated as "yes",
|
|
70
|
+
* or an unknown base is exactly how local work gets pulled over.
|
|
71
|
+
*/
|
|
72
|
+
localChanged(relPath) {
|
|
73
|
+
const now = localHash(this.state, this.dir, relPath);
|
|
74
|
+
if (now === null)
|
|
75
|
+
return false;
|
|
76
|
+
const base = syncedHash(this.state, relPath);
|
|
77
|
+
if (base !== undefined && now === base) {
|
|
78
|
+
const rec = this.state.local?.[relPath];
|
|
79
|
+
if (!rec || rec.hash !== now) {
|
|
80
|
+
// Unchanged, but only known so by reading it (no record yet, or
|
|
81
|
+
// the mtime moved with the content the same). Record it so the
|
|
82
|
+
// next check is a stat - the backstop scan runs every few seconds.
|
|
83
|
+
adoptLocal(this.state, this.dir, relPath, now);
|
|
84
|
+
this.dirty = true;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const st = fs.statSync(this.abs(relPath));
|
|
88
|
+
if (st.mtimeMs !== rec.mtime_ms || st.size !== rec.size) {
|
|
89
|
+
adoptLocal(this.state, this.dir, relPath, now);
|
|
90
|
+
this.dirty = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/** Record changes made while checking files (adopted records) - batched, one write per pass. */
|
|
98
|
+
flush() {
|
|
99
|
+
if (!this.dirty)
|
|
100
|
+
return;
|
|
101
|
+
this.dirty = false;
|
|
102
|
+
this.persist();
|
|
103
|
+
}
|
|
56
104
|
async pullFile(relPath, etag) {
|
|
57
105
|
const { bytes, etag: gotEtag } = await this.client.get(relPath);
|
|
58
106
|
const absPath = this.abs(relPath);
|
|
@@ -60,39 +108,129 @@ export class SyncEngine {
|
|
|
60
108
|
this.recordSelfWrite(relPath);
|
|
61
109
|
fs.writeFileSync(absPath, bytes);
|
|
62
110
|
this.state.etags[relPath] = gotEtag ?? etag;
|
|
111
|
+
recordLocal(this.state, this.dir, relPath, bytes);
|
|
63
112
|
this.persist();
|
|
64
113
|
}
|
|
65
114
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
115
|
+
* Both sides changed: keep the local file untouched, put the remote copy
|
|
116
|
+
* beside it, and point the stored etag at the remote version so that the
|
|
117
|
+
* next deliberate save of the local file is what wins. The local record is
|
|
118
|
+
* left alone, so the file still reads as locally changed.
|
|
119
|
+
*/
|
|
120
|
+
async writeConflict(relPath, remoteEtag) {
|
|
121
|
+
try {
|
|
122
|
+
const { bytes, etag } = await this.client.get(relPath);
|
|
123
|
+
const conflictPath = this.abs(relPath) + CONFLICT_SUFFIX;
|
|
124
|
+
fs.mkdirSync(path.dirname(conflictPath), { recursive: true });
|
|
125
|
+
fs.writeFileSync(conflictPath, bytes);
|
|
126
|
+
const e = etag ?? remoteEtag;
|
|
127
|
+
if (e)
|
|
128
|
+
this.state.etags[relPath] = e;
|
|
129
|
+
this.persist();
|
|
130
|
+
this.log.warn(`conflict on ${relPath}: it changed both here and in the world. Your file is untouched; ` +
|
|
131
|
+
`the world's version is saved as ${relPath}${CONFLICT_SUFFIX} - merge, save, and it will push.`);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
this.log.warn(`conflict on ${relPath}, and fetching the remote version failed: ${String(err)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A conflict the user has not resolved yet: the sidecar is still there and
|
|
139
|
+
* the local file has not been saved since it was written. Such a file is
|
|
140
|
+
* never pushed automatically - only a save does that.
|
|
141
|
+
*/
|
|
142
|
+
unresolvedConflict(relPath) {
|
|
143
|
+
try {
|
|
144
|
+
const side = fs.statSync(this.abs(relPath) + CONFLICT_SUFFIX);
|
|
145
|
+
const mine = fs.statSync(this.abs(relPath));
|
|
146
|
+
return side.mtimeMs >= mine.mtimeMs;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Initial reconcile, per file, against the last sync:
|
|
154
|
+
*
|
|
155
|
+
* local unchanged, remote changed -> pull
|
|
156
|
+
* local changed, remote unchanged -> push
|
|
157
|
+
* both changed (or both new, and different) -> conflict (local kept)
|
|
158
|
+
* local only, never synced -> push (create)
|
|
159
|
+
* remote only -> pull
|
|
160
|
+
*
|
|
161
|
+
* A file deleted locally while sync was off is pulled back rather than
|
|
162
|
+
* deleted remotely: a start never destroys anything on either side.
|
|
69
163
|
*/
|
|
70
164
|
async initialSync() {
|
|
71
165
|
const remote = await this.client.list();
|
|
72
166
|
let pulled = 0;
|
|
73
167
|
let pushed = 0;
|
|
74
|
-
|
|
168
|
+
let conflicts = 0;
|
|
169
|
+
const remoteByPath = new Map();
|
|
75
170
|
for (const file of remote) {
|
|
76
171
|
if (isIgnoredPath(file.path))
|
|
77
172
|
continue;
|
|
78
|
-
|
|
173
|
+
remoteByPath.set(file.path, file);
|
|
79
174
|
const known = this.state.etags[file.path];
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
175
|
+
const current = localHash(this.state, this.dir, file.path);
|
|
176
|
+
if (current === null) {
|
|
177
|
+
await this.pullFile(file.path, file.etag);
|
|
178
|
+
pulled++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (current === file.etag) {
|
|
182
|
+
// same content on both sides (etags are content hashes): just
|
|
183
|
+
// bring the bookkeeping up to date
|
|
184
|
+
const rec = this.state.local?.[file.path];
|
|
185
|
+
if (known !== file.etag || !rec || rec.hash !== current) {
|
|
186
|
+
this.state.etags[file.path] = file.etag;
|
|
187
|
+
adoptLocal(this.state, this.dir, file.path, current);
|
|
188
|
+
this.dirty = true;
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const remoteChanged = known !== file.etag;
|
|
193
|
+
const localChanged = this.localChanged(file.path);
|
|
194
|
+
if (!localChanged && remoteChanged) {
|
|
82
195
|
await this.pullFile(file.path, file.etag);
|
|
196
|
+
this.log.info(`pulled ${file.path}`);
|
|
83
197
|
pulled++;
|
|
84
198
|
}
|
|
199
|
+
else if (localChanged && !remoteChanged) {
|
|
200
|
+
if (this.unresolvedConflict(file.path)) {
|
|
201
|
+
this.log.warn(`${file.path} still has an unresolved ${file.path}${CONFLICT_SUFFIX} - not pushing it. ` +
|
|
202
|
+
`Merge, save the file (or delete the ${CONFLICT_SUFFIX} file), and it will push.`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (await this.pushFile(file.path))
|
|
206
|
+
pushed++;
|
|
207
|
+
}
|
|
208
|
+
else if (localChanged && remoteChanged) {
|
|
209
|
+
await this.writeConflict(file.path, file.etag);
|
|
210
|
+
conflicts++;
|
|
211
|
+
}
|
|
85
212
|
}
|
|
86
|
-
// Local files the remote has never seen get created remotely.
|
|
87
213
|
for (const relPath of this.walkLocal()) {
|
|
88
|
-
if (
|
|
214
|
+
if (remoteByPath.has(relPath))
|
|
89
215
|
continue;
|
|
90
|
-
if (this.state.etags[relPath]
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
216
|
+
if (this.state.etags[relPath] === undefined) {
|
|
217
|
+
// never synced: new here, create it remotely
|
|
218
|
+
if (await this.pushFile(relPath, { createOnly: true }))
|
|
219
|
+
pushed++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// Synced once, gone remotely. Untouched here: leave it (a remote
|
|
223
|
+
// delete is not ours to undo). Edited here since: that is work, so it
|
|
224
|
+
// is re-created rather than stranded.
|
|
225
|
+
if (this.localChanged(relPath)) {
|
|
226
|
+
this.log.warn(`${relPath} was deleted in the world but edited here - re-creating it.`);
|
|
227
|
+
delete this.state.etags[relPath];
|
|
228
|
+
if (await this.pushFile(relPath, { createOnly: true }))
|
|
229
|
+
pushed++;
|
|
230
|
+
}
|
|
94
231
|
}
|
|
95
|
-
|
|
232
|
+
this.flush();
|
|
233
|
+
return { pulled, pushed, conflicts };
|
|
96
234
|
}
|
|
97
235
|
*walkLocal(rel = "") {
|
|
98
236
|
const absDir = path.join(this.dir, rel);
|
|
@@ -113,42 +251,31 @@ export class SyncEngine {
|
|
|
113
251
|
yield relPath;
|
|
114
252
|
}
|
|
115
253
|
}
|
|
254
|
+
/** Push the local file. Returns false when there was nothing to send or it became a conflict. */
|
|
116
255
|
async pushFile(relPath, opts) {
|
|
117
256
|
const body = fs.readFileSync(this.abs(relPath));
|
|
118
257
|
const known = this.state.etags[relPath];
|
|
258
|
+
// unchanged since the last sync: nothing to send (a touch, or the
|
|
259
|
+
// watcher and the local scan both reporting the same save)
|
|
260
|
+
if (known !== undefined && syncedHash(this.state, relPath) === sha256Hex(body))
|
|
261
|
+
return false;
|
|
119
262
|
const createOnly = opts?.createOnly ?? known === undefined;
|
|
120
263
|
try {
|
|
121
264
|
const etag = await this.client.put(relPath, body, createOnly ? { createOnly: true } : { etag: known });
|
|
122
265
|
this.state.etags[relPath] = etag;
|
|
266
|
+
recordLocal(this.state, this.dir, relPath, body);
|
|
123
267
|
this.persist();
|
|
124
268
|
this.log.info(`pushed ${relPath}`);
|
|
269
|
+
return true;
|
|
125
270
|
}
|
|
126
271
|
catch (err) {
|
|
127
272
|
if (err instanceof ConflictError) {
|
|
128
|
-
await this.
|
|
129
|
-
return;
|
|
273
|
+
await this.writeConflict(relPath);
|
|
274
|
+
return false;
|
|
130
275
|
}
|
|
131
276
|
throw err;
|
|
132
277
|
}
|
|
133
278
|
}
|
|
134
|
-
/** 412: fetch the remote version to <name>.remote-conflict, keep the loop alive. */
|
|
135
|
-
async handleConflict(relPath) {
|
|
136
|
-
try {
|
|
137
|
-
const { bytes, etag } = await this.client.get(relPath);
|
|
138
|
-
const conflictPath = this.abs(relPath) + ".remote-conflict";
|
|
139
|
-
fs.mkdirSync(path.dirname(conflictPath), { recursive: true });
|
|
140
|
-
fs.writeFileSync(conflictPath, bytes);
|
|
141
|
-
if (etag)
|
|
142
|
-
this.state.etags[relPath] = etag;
|
|
143
|
-
// Deliberately NOT overwriting the local file - the user resolves.
|
|
144
|
-
this.persist();
|
|
145
|
-
this.log.warn(`conflict on ${relPath}: the remote copy changed while you edited it. ` +
|
|
146
|
-
`Remote version saved as ${relPath}.remote-conflict - merge, save, and it will push.`);
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
this.log.warn(`conflict on ${relPath}, and fetching the remote version failed: ${String(err)}`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
279
|
/** Watcher callback for a local add/change. */
|
|
153
280
|
async handleLocalChange(relPath) {
|
|
154
281
|
const norm = relPath.replace(/\\/g, "/");
|
|
@@ -178,12 +305,13 @@ export class SyncEngine {
|
|
|
178
305
|
try {
|
|
179
306
|
await this.client.delete(norm, known);
|
|
180
307
|
delete this.state.etags[norm];
|
|
308
|
+
delete this.state.local?.[norm];
|
|
181
309
|
this.persist();
|
|
182
310
|
this.log.info(`deleted ${norm} remotely`);
|
|
183
311
|
}
|
|
184
312
|
catch (err) {
|
|
185
313
|
if (err instanceof ConflictError) {
|
|
186
|
-
await this.
|
|
314
|
+
await this.writeConflict(norm);
|
|
187
315
|
return;
|
|
188
316
|
}
|
|
189
317
|
this.log.warn(`remote delete of ${norm} failed: ${String(err)}`);
|
|
@@ -192,7 +320,8 @@ export class SyncEngine {
|
|
|
192
320
|
/**
|
|
193
321
|
* One remote poll tick: pull files whose remote etag differs from our
|
|
194
322
|
* bookkeeping. Files we just pushed match by etag, so they are skipped
|
|
195
|
-
* naturally.
|
|
323
|
+
* naturally. A file with local changes that have not gone out yet is a
|
|
324
|
+
* conflict, never a pull. Returns the pulled paths.
|
|
196
325
|
*/
|
|
197
326
|
async pollOnce() {
|
|
198
327
|
const remote = await this.client.list();
|
|
@@ -200,23 +329,81 @@ export class SyncEngine {
|
|
|
200
329
|
for (const file of remote) {
|
|
201
330
|
if (isIgnoredPath(file.path))
|
|
202
331
|
continue;
|
|
203
|
-
|
|
332
|
+
const exists = fs.existsSync(this.abs(file.path));
|
|
333
|
+
if (this.state.etags[file.path] === file.etag && exists)
|
|
334
|
+
continue;
|
|
335
|
+
if (!exists && this.state.etags[file.path] === file.etag) {
|
|
336
|
+
// Deleted here, unchanged in the world since the last sync: a delete
|
|
337
|
+
// the watcher has not delivered yet, not a file to restore. Pulling it
|
|
338
|
+
// back also marked it as our own write, so the late unlink event was
|
|
339
|
+
// then ignored and the file came back for good.
|
|
340
|
+
await this.handleLocalDelete(file.path);
|
|
204
341
|
continue;
|
|
342
|
+
}
|
|
343
|
+
if (exists) {
|
|
344
|
+
const current = localHash(this.state, this.dir, file.path);
|
|
345
|
+
if (current === file.etag) {
|
|
346
|
+
this.state.etags[file.path] = file.etag;
|
|
347
|
+
adoptLocal(this.state, this.dir, file.path, current);
|
|
348
|
+
this.persist();
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (this.localChanged(file.path)) {
|
|
352
|
+
// writeConflict points the etag at this remote version, so the
|
|
353
|
+
// check at the top of the loop keeps it from firing again
|
|
354
|
+
await this.writeConflict(file.path, file.etag);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
205
358
|
await this.pullFile(file.path, file.etag);
|
|
206
359
|
pulled.push(file.path);
|
|
207
360
|
this.log.info(`pulled ${file.path}`);
|
|
208
361
|
}
|
|
209
362
|
return pulled;
|
|
210
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Backstop for the watcher: push every local file that changed since its
|
|
366
|
+
* last sync, and create new ones. File-system events get lost (editors
|
|
367
|
+
* that write in place, network drives, a burst while the queue is busy);
|
|
368
|
+
* a stat walk every few seconds cannot miss a save. Unchanged files cost a
|
|
369
|
+
* stat and no read. Returns the pushed paths.
|
|
370
|
+
*/
|
|
371
|
+
async scanLocal() {
|
|
372
|
+
const pushed = [];
|
|
373
|
+
for (const relPath of this.walkLocal()) {
|
|
374
|
+
if (this.isSelfWrite(relPath))
|
|
375
|
+
continue;
|
|
376
|
+
const known = this.state.etags[relPath];
|
|
377
|
+
if (known !== undefined && !this.localChanged(relPath))
|
|
378
|
+
continue;
|
|
379
|
+
if (known !== undefined && this.unresolvedConflict(relPath))
|
|
380
|
+
continue;
|
|
381
|
+
try {
|
|
382
|
+
if (await this.pushFile(relPath))
|
|
383
|
+
pushed.push(relPath);
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
this.log.warn(`push of ${relPath} failed: ${String(err)}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
this.flush();
|
|
390
|
+
return pushed;
|
|
391
|
+
}
|
|
211
392
|
}
|
|
212
393
|
/** Run the full watch loop until SIGINT. Not unit-tested directly; the decisions above are. */
|
|
213
394
|
export async function runSyncLoop(engine, dir) {
|
|
214
395
|
const { default: chokidar } = await import("chokidar");
|
|
215
|
-
const { pulled, pushed } = await engine.initialSync();
|
|
216
|
-
console.log(`initial sync: ${pulled} pulled, ${pushed} pushed
|
|
396
|
+
const { pulled, pushed, conflicts } = await engine.initialSync();
|
|
397
|
+
console.log(`initial sync: ${pulled} pulled, ${pushed} pushed` +
|
|
398
|
+
(conflicts ? `, ${conflicts} conflict${conflicts === 1 ? "" : "s"} (see above)` : "") +
|
|
399
|
+
`. Watching ${dir} ...`);
|
|
217
400
|
const stopLogs = startLogSync(dir);
|
|
218
401
|
const watcher = chokidar.watch(dir, {
|
|
219
402
|
ignoreInitial: true,
|
|
403
|
+
// Wait for a write to settle before reading it. Without this an editor
|
|
404
|
+
// that rewrites a file in place fired "change" mid-write and the sync
|
|
405
|
+
// pushed a truncated file.
|
|
406
|
+
awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50 },
|
|
220
407
|
ignored: (p) => {
|
|
221
408
|
const rel = path.relative(dir, p).replace(/\\/g, "/");
|
|
222
409
|
return rel.length > 0 && isIgnoredPath(rel);
|
|
@@ -231,6 +418,12 @@ export async function runSyncLoop(engine, dir) {
|
|
|
231
418
|
watcher.on("unlink", (p) => enqueue(() => engine.handleLocalDelete(path.relative(dir, p))));
|
|
232
419
|
const interval = setInterval(() => {
|
|
233
420
|
enqueue(async () => {
|
|
421
|
+
try {
|
|
422
|
+
await engine.scanLocal();
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
console.warn(`local scan failed: ${String(err)}`);
|
|
426
|
+
}
|
|
234
427
|
try {
|
|
235
428
|
await engine.pollOnce();
|
|
236
429
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "virtualmatter",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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",
|