From 02e77e9f95d9aec0dbc05eb50d9640d5011ad40b Mon Sep 17 00:00:00 2001 From: avtara k Date: Sun, 18 Oct 2026 17:07:23 +0700 Subject: [PATCH 1/2] Add an MCP server for coding agents apps/mcp is a stdio MCP server that lets coding agents (Claude Code, Cursor, Codex, ...) author Bedrock particle effects with Oxygen's own engine, analysis and viewport: - reference: components with fields, defaults and evaluation phase; materials; Molang queries and maths; Oxygen's measured game rules and Molang guide - list_examples / create_effect: start from the starting effect or a bundled example - read_effect / check_effect: summary and the problems the Problems panel shows - edit_effect: surgical edits by JSON path that keep formatting, key order and comments, reported as a semantic diff - simulate_effect / preview_effect: run the effect headless in Chromium (Playwright) on a fixed 60 fps clock and seed, report counts, bounds, emitters and events, and render pictures or a contact sheet with the editor's viewport To drive the viewport headless, packages/ui exports ViewportScene, the gizmo defaults and the texture providers, makes EffectSession.refreshStats public and lets ViewportScene.frame step without drawing. CI builds the server and the tests include apps/mcp. Setup is in docs/agents.md. Signed-off-by: avtara k --- .github/workflows/ci.yml | 3 +- CHANGELOG.md | 4 + README.md | 4 + apps/mcp/build.mjs | 43 ++ apps/mcp/package.json | 40 ++ apps/mcp/preview/index.html | 19 + apps/mcp/preview/main.ts | 319 +++++++++++ apps/mcp/preview/tsconfig.json | 10 + apps/mcp/src/browser.ts | 123 +++++ apps/mcp/src/effects.ts | 200 +++++++ apps/mcp/src/pack.ts | 146 +++++ apps/mcp/src/protocol.ts | 99 ++++ apps/mcp/src/reference.ts | 94 ++++ apps/mcp/src/server.ts | 331 +++++++++++ apps/mcp/test/mcp.test.ts | 69 +++ apps/mcp/tsconfig.json | 5 + docs/agents.md | 71 +++ docs/building.md | 2 + packages/ui/src/index.ts | 3 + packages/ui/src/state/session.svelte.ts | 3 +- packages/ui/src/viewport/scene.ts | 9 +- pnpm-lock.yaml | 700 ++++++++++++++++++++---- vitest.config.ts | 1 + 23 files changed, 2189 insertions(+), 109 deletions(-) create mode 100644 apps/mcp/build.mjs create mode 100644 apps/mcp/package.json create mode 100644 apps/mcp/preview/index.html create mode 100644 apps/mcp/preview/main.ts create mode 100644 apps/mcp/preview/tsconfig.json create mode 100644 apps/mcp/src/browser.ts create mode 100644 apps/mcp/src/effects.ts create mode 100644 apps/mcp/src/pack.ts create mode 100644 apps/mcp/src/protocol.ts create mode 100644 apps/mcp/src/reference.ts create mode 100644 apps/mcp/src/server.ts create mode 100644 apps/mcp/test/mcp.test.ts create mode 100644 apps/mcp/tsconfig.json create mode 100644 docs/agents.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d782a2d..039301c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,9 @@ jobs: run: pnpm test - name: Type checks run: pnpm typecheck - - name: Build the web app, the VS Code extension and the desktop app + - name: Build the web app, the VS Code extension, the desktop app and the MCP server run: | pnpm --filter @oxygen/web build pnpm --filter oxygen-particle-editor build pnpm --filter @oxygen/desktop build + pnpm --filter @oxygen/mcp build diff --git a/CHANGELOG.md b/CHANGELOG.md index bd13704..1b059b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Added - The web app is online at [oxygen.hydrogen.exposed](https://oxygen.hydrogen.exposed). +- An MCP server for coding agents (`apps/mcp`): agents can look up the particle format and the game's measured + behaviour, create effects from the starting effect or an example, edit them by JSON path without disturbing + formatting, check them, simulate them and render previews with Oxygen's viewport. See + [Oxygen for coding agents](https://github.com/dev-hydrogen/oxygen/blob/main/docs/agents.md). ### Fixed diff --git a/README.md b/README.md index 1de5fd3..00e6865 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,9 @@ menu) opens one of the bundled examples as a new file you can save into your pac files or a whole resource pack folder. The desktop app can also deploy your pack to Minecraft and spawn the effect in your world: run `/connect localhost:19131` in the game once, then use the Game menu. +**Coding agents.** An MCP server lets agents such as Claude Code create, edit, check and preview effects with +Oxygen's engine. See [Oxygen for coding agents](docs/agents.md). + New to the editor? The [user guide](docs/guide.md) walks through the panels and the usual tasks. ## Layouts @@ -76,6 +79,7 @@ pnpm dev # the web app at http://localhost:5178 | Web app | `pnpm --filter @oxygen/web build` | `apps/web/dist/`, a static site | | VS Code extension | `pnpm package:vscode` | `apps/vscode/oxygen-particle-editor-.vsix` | | Desktop app | `pnpm package:desktop` | an installer in `apps/desktop/release/` | +| MCP server for coding agents | `pnpm --filter @oxygen/mcp build` | `apps/mcp/dist/server.cjs` | Tests, the repository layout and the in-game test pack are covered in [building and development](docs/building.md). What changed from Snowstorm is in the [changelog](CHANGELOG.md). diff --git a/apps/mcp/build.mjs b/apps/mcp/build.mjs new file mode 100644 index 0000000..5964bce --- /dev/null +++ b/apps/mcp/build.mjs @@ -0,0 +1,43 @@ +// Builds the MCP server (esbuild → dist/server.cjs) and the headless preview page (Vite → dist/preview), and copies +// what they read at run time: the examples, the vanilla sprites and Oxygen's guides. +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import * as esbuild from 'esbuild'; +import { chmodSync, cpSync, mkdirSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build as viteBuild } from 'vite'; + +const here = dirname(fileURLToPath(import.meta.url)); +rmSync(resolve(here, 'dist'), { recursive: true, force: true }); + +cpSync(resolve(here, '../../packages/ui/static/examples'), resolve(here, 'dist/examples'), { recursive: true }); +cpSync(resolve(here, '../../vendor/wintersky/assets'), resolve(here, 'dist/vanilla'), { recursive: true }); +mkdirSync(resolve(here, 'dist/docs'), { recursive: true }); +for (const doc of ['accuracy.md', 'molang.md']) cpSync(resolve(here, '../../docs', doc), resolve(here, 'dist/docs', doc)); + +await esbuild.build({ + entryPoints: [resolve(here, 'src/server.ts')], + outfile: resolve(here, 'dist/server.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + // Playwright finds its browsers relative to its own install + external: ['playwright'], + // prefer ESM builds: jsonc-parser's UMD build hides its requires from the bundler + mainFields: ['module', 'main'], + banner: { js: '#!/usr/bin/env node' }, + sourcemap: true, + logLevel: 'warning', +}); +chmodSync(resolve(here, 'dist/server.cjs'), 0o755); + +await viteBuild({ + root: resolve(here, 'preview'), + base: './', + configFile: false, + plugins: [svelte()], + logLevel: 'warn', + build: { outDir: resolve(here, 'dist/preview'), emptyOutDir: true, target: 'es2022', chunkSizeWarningLimit: 5000, modulePreload: false }, +}); +console.log('mcp built'); diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 0000000..5f6c809 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,40 @@ +{ + "name": "@oxygen/mcp", + "version": "0.1.1", + "private": true, + "type": "module", + "description": "Oxygen for coding agents: an MCP server that creates, edits, checks, simulates and renders Bedrock particle effects", + "license": "GPL-3.0-or-later", + "bin": { + "oxygen-mcp": "./dist/server.cjs" + }, + "scripts": { + "build": "node build.mjs", + "start": "node dist/server.cjs", + "typecheck": "tsc --noEmit -p . && svelte-check --tsconfig ./preview/tsconfig.json" + }, + "dependencies": { + "playwright": "1.64.1" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.32.1", + "@oxygen/effect": "workspace:*", + "@oxygen/format": "workspace:*", + "@oxygen/lang": "workspace:*", + "@oxygen/metadata": "workspace:*", + "@oxygen/ui": "workspace:*", + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@types/three": "^0.186.0", + "@types/node": "^24.0.0", + "esbuild": "^0.28.2", + "jsonc-parser": "^3.3.1", + "molangjs": "workspace:*", + "svelte": "^5.57.2", + "svelte-check": "^4.7.7", + "three": "^0.186.1", + "typescript": "6.0.3", + "vite": "^8.3.4", + "wintersky": "workspace:*", + "zod": "^4.0.0" + } +} diff --git a/apps/mcp/preview/index.html b/apps/mcp/preview/index.html new file mode 100644 index 0000000..8bbedea --- /dev/null +++ b/apps/mcp/preview/index.html @@ -0,0 +1,19 @@ + + + + + Oxygen headless preview + + + + + + diff --git a/apps/mcp/preview/main.ts b/apps/mcp/preview/main.ts new file mode 100644 index 0000000..b65c192 --- /dev/null +++ b/apps/mcp/preview/main.ts @@ -0,0 +1,319 @@ +// The headless preview the MCP server drives through Playwright: Oxygen's own session and viewport, stepped +// with a fixed clock instead of the animation loop, so a request always gives the same pictures and numbers. +import { DefaultLoadingManager } from 'three'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { readMeta } from '@oxygen/format'; +import { DEFAULT_GIZMOS, EffectSession, ViewportScene, Workspace, createMersProvider, createTextureProvider, type FileRef, type Host } from '@oxygen/ui'; +import type { CameraRequest, EmitterSummary, PreviewApi, RunProblem, RunRequest, RunResult, SampleStats, Vec3 } from '../src/protocol'; + +/** the simulation step: the editor's 60 fps */ +const DT = 1 / 60; + +/** default viewing directions (from the target toward the camera) */ +const ANGLES: Record['angle'], Vec3> = { + iso: [3.6, 2.8, 5.2], + front: [0, 0.35, 1], + side: [1, 0.35, 0], + top: [0, 1, 0.02], + bottom: [0, -1, 0.02], +}; + +// Texture loads in flight: the fetch through the server, then three's image decode. A picture is taken only +// once a frame draws without starting new loads. +let loading = 0; +let requests = 0; +const manager = DefaultLoadingManager; +const itemStart = manager.itemStart.bind(manager); +const itemEnd = manager.itemEnd.bind(manager); +manager.itemStart = (url) => { + loading++; + itemStart(url); +}; +manager.itemEnd = (url) => { + loading--; + itemEnd(url); +}; + +function sleep(ms: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, ms); + return promise; +} + +/** times and coordinates reported to the millisecond / millimetre */ +const round3 = (x: number) => Math.round(x * 1000) / 1000; + +function makeHost(missing: Set, blobs: string[]): Host { + /** Fetch a file of the effect's pack through the server; it counts as a load in flight until `use` is done */ + const fromPack = async (kind: 'texture' | 'pack-text', file: FileRef, path: string, use: (r: Response) => Promise): Promise => { + requests++; + loading++; + try { + return await use(await fetch(`./api/${kind}?file=${encodeURIComponent(file.id ?? '')}&path=${encodeURIComponent(path)}`)); + } finally { + loading--; + } + }; + return { + kind: 'web', + save: async () => null, + examples: async () => [], + resolveTexture: (texture, file) => + fromPack('texture', file, texture, async (r) => { + if (!r.ok) { + missing.add(texture); + return null; + } + const url = URL.createObjectURL(await r.blob()); + blobs.push(url); + return url; + }), + readPackText: (path, file) => fromPack('pack-text', file, path, async (r) => (r.ok ? r.text() : null)), + loadSettings: async () => ({}), + saveSettings: () => {}, + }; +} + +/** Draw paused frames until one draws without asking for anything new, and every load has finished */ +async function settle(scene: ViewportScene): Promise { + for (let round = 0; round < 40; round++) { + const before = requests; + scene.frame(0); + await sleep(0); + for (let i = 0; i < 2000 && loading > 0; i++) await sleep(5); + if (requests === before) return; + } +} + +function measure(session: EffectSession, time: number): SampleStats { + const p: Vec3 = [0, 0, 0]; + const min: Vec3 = [Infinity, Infinity, Infinity]; + const max: Vec3 = [-Infinity, -Infinity, -Infinity]; + let particles = 0; + let visible = 0; + let capHit = false; + for (const e of session.world.emitters) { + particles += e.pool.count; + if (e.pool.count >= e.cap) capHit = true; + if (e.culled) continue; + for (let i = 0; i < e.pool.count; i++) { + if (!e.pool.visible[i]) continue; + visible++; + e.particleWorldPos(i, p); + for (let k = 0; k < 3; k++) { + if (p[k] < min[k]) min[k] = p[k]; + if (p[k] > max[k]) max[k] = p[k]; + } + } + } + return { time: round3(time), particles, visible, emitters: session.world.emitters.length, bounds: visible ? { min: min.map(round3) as Vec3, max: max.map(round3) as Vec3 } : null, capHit }; +} + +/** Put the pictures side by side, each labelled with its time and particle count */ +async function contactSheet(urls: string[], labels: string[], w: number, h: number): Promise { + const cols = Math.ceil(Math.sqrt(urls.length)); + const rows = Math.ceil(urls.length / cols); + const c = document.createElement('canvas'); + c.width = cols * w; + c.height = rows * h; + const g = c.getContext('2d')!; + g.fillStyle = '#000'; + g.fillRect(0, 0, c.width, c.height); + for (let i = 0; i < urls.length; i++) { + const img = new Image(); + img.src = urls[i]; + await img.decode(); + const x = (i % cols) * w; + const y = Math.floor(i / cols) * h; + g.drawImage(img, x, y, w, h); + g.font = '600 14px system-ui, sans-serif'; + const tw = g.measureText(labels[i]).width; + g.fillStyle = 'rgba(0, 0, 0, 0.6)'; + g.fillRect(x + 6, y + 6, tw + 12, 22); + g.fillStyle = '#fff'; + g.fillText(labels[i], x + 12, y + 22); + g.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + g.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); + } + return c.toDataURL('image/png'); +} + +async function run(req: RunRequest): Promise { + const missing = new Set(); + const blobs: string[] = []; + const host = makeHost(missing, blobs); + const children = new Map(); + for (const [id, text] of Object.entries(req.effects)) children.set(id, parseJsonc(text, [], { allowTrailingComma: true })); + + const session = new EffectSession(req.text, req.file, { engine: req.engine, findChild: (id) => children.get(id) ?? null }); + // worker threads would make stepping asynchronous: keep the whole world on this thread + session.parallel.unavailable = true; + session.setParentInputs(req.parents); + if (req.timeOfDay !== null) session.timeOfDay = req.timeOfDay; + if (req.spawn && session.spawnMode !== req.spawn) session.setSpawnMode(req.spawn); + session.loopPreview = req.repeat; + await new Workspace(host).loadPackVisuals(session); + + // every event fired and every emitter seen, counted exactly (the Timeline panel keeps a capped window) + let events = new Map>(); + let lanes = new Map(); + let now = 0; + const recordEvent = session.world.onEvent; + session.world.onEvent = (e, name, particle) => { + recordEvent?.(e, name, particle); + let byName = events.get(e.def.identifier); + if (!byName) events.set(e.def.identifier, (byName = new Map())); + const ev = byName.get(name); + if (ev) ev.count++; + else byName.set(name, { count: 1, first: round3(now) }); + }; + const noteEmitters = () => { + const seen = new Set(); + for (const e of session.world.emitters) { + seen.add(e.id); + if (!lanes.has(e.id)) lanes.set(e.id, { effect: e.def.identifier, start: now, end: null }); + } + for (const [id, l] of lanes) if (l.end === null && !seen.has(id)) l.end = now; + }; + + const r = req.render; + const width = r?.width ?? 320; + const height = r?.height ?? 240; + const canvas = document.createElement('canvas'); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + document.body.append(canvas); + const scene = new ViewportScene(canvas, { textures: createTextureProvider(host, () => session), mers: createMersProvider(host, () => session) }); + scene.resize(width, height); + scene.setSession(session); + if (r) { + scene.renderMode = r.mode; + scene.gizmos = { ...DEFAULT_GIZMOS, ground: r.ground, block: r.ground, emitter: r.gizmos, children: r.gizmos, killPlane: r.gizmos }; + session.showShape = r.gizmos; + } + + let peak = { particles: 0, time: 0 }; + let finishedAt: number | null = null; + /** Start over and play to each sample time; `at` sees the paused world at each one */ + const play = async (at: (stats: SampleStats) => Promise | void) => { + events = new Map(); + lanes = new Map(); + peak = { particles: 0, time: 0 }; + finishedAt = null; + now = 0; + session.restart(); + session.playing = true; + noteEmitters(); + for (const t of req.times) { + while (now + DT / 2 < t && !session.finished) { + now += DT; + scene.frame(DT, false); + noteEmitters(); + const n = session.world.particleCount; + if (n > peak.particles) peak = { particles: n, time: round3(now) }; + } + if (session.finished && finishedAt === null) finishedAt = round3(now); + session.playing = false; + // once everything has ended the clock stops; the sample still stands for the time asked about + await at(measure(session, session.finished ? t : now)); + session.playing = !session.finished; + } + }; + + let samples: SampleStats[] = []; + const shots: string[] = []; + if (r) { + let view: { position: Vec3; target: Vec3; fov?: number } | null = null; + const cam = r.camera; + if (cam.kind === 'manual') view = cam; + else if (cam.kind === 'bookmark') { + const saved = readMeta(session.doc).cameras ?? []; + const b = saved.find((c) => c.name === cam.name); + if (!b) throw new Error(`No camera bookmark named "${cam.name}" in the file (saved: ${saved.map((c) => c.name).join(', ') || 'none'})`); + view = b; + } else { + // a first run finds where the particles go, so every picture shares one camera that shows them all + const lo: Vec3 = [Infinity, Infinity, Infinity]; + const hi: Vec3 = [-Infinity, -Infinity, -Infinity]; + const grow = (p: readonly number[]) => { + for (let k = 0; k < 3; k++) { + lo[k] = Math.min(lo[k], p[k]); + hi[k] = Math.max(hi[k], p[k]); + } + }; + await play((s) => { + if (s.bounds) { + grow(s.bounds.min); + grow(s.bounds.max); + } else for (const e of session.world.emitters) grow(e.position); + }); + if (lo[0] === Infinity) { + grow([-1, -0.5, -1]); + grow([1, 1.5, 1]); + } + const center: Vec3 = [0, 1, 2].map((k) => (lo[k] + hi[k]) / 2) as Vec3; + const radius = Math.max(0.25, Math.hypot(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) / 2); + const fov = 70; + const dist = (radius / Math.sin((fov * Math.PI) / 360)) * 1.1 / Math.max(0.05, cam.zoom); + const d = ANGLES[cam.angle]; + const len = Math.hypot(d[0], d[1], d[2]); + view = { position: [0, 1, 2].map((k) => center[k] + (d[k] / len) * dist) as Vec3, target: center, fov }; + } + scene.setView({ position: view.position, target: view.target, fov: view.fov ?? 70 }); + await play(async (s) => { + samples.push(s); + await settle(scene); + scene.frame(0); + // read the picture in the same task as the draw (the drawing buffer isn't kept) + shots.push(canvas.toDataURL('image/png')); + }); + } else { + await play((s) => { + samples.push(s); + }); + } + noteEmitters(); + + session.refreshStats(); + const problems: RunProblem[] = session.problems.map((p) => ({ path: p.path, severity: p.severity, message: p.message, code: p.code, source: p.source })); + + const emitters = new Map(); + for (const l of lanes.values()) { + const s = emitters.get(l.effect); + const end = l.end === null ? null : round3(l.end); + if (!s) emitters.set(l.effect, { effect: l.effect, count: 1, firstStart: round3(l.start), lastEnd: end, events: {} }); + else { + s.count++; + s.lastEnd = s.lastEnd === null || end === null ? null : Math.max(s.lastEnd, end); + } + } + for (const [effect, byName] of events) { + const s = emitters.get(effect) ?? { effect, count: 0, firstStart: 0, lastEnd: null, events: {} }; + for (const [name, ev] of byName) s.events[name] = ev; + emitters.set(effect, s); + } + + const images = r && shots.length > 1 && r.sheet ? [await contactSheet(shots, samples.map((s) => `${s.time.toFixed(2)} s · ${s.particles} particles`), width, height)] : shots; + const camera = r ? { position: scene.view().position, target: scene.view().target, fov: scene.view().fov } : null; + + scene.dispose(); + scene.renderer.forceContextLoss(); + canvas.remove(); + session.dispose(); + for (const url of blobs) URL.revokeObjectURL(url); + + return { + identifier: session.identifier, + samples, + peak, + finishedAt, + emitters: [...emitters.values()], + problems, + missingTextures: [...missing], + images, + camera, + }; +} + +const api: PreviewApi = { run }; +window.oxygenPreview = api; diff --git a/apps/mcp/preview/tsconfig.json b/apps/mcp/preview/tsconfig.json new file mode 100644 index 0000000..eaa5e66 --- /dev/null +++ b/apps/mcp/preview/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["vite/client", "svelte"], + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "allowImportingTsExtensions": true + }, + "include": [".", "../src/protocol.ts"] +} diff --git a/apps/mcp/src/browser.ts b/apps/mcp/src/browser.ts new file mode 100644 index 0000000..25cbc2c --- /dev/null +++ b/apps/mcp/src/browser.ts @@ -0,0 +1,123 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import { extname, join, resolve } from 'node:path'; +import { chromium, type Browser, type Page } from 'playwright'; +import { BUNDLED, packRoot, packText, textureFile } from './pack'; +import type { RunRequest, RunResult } from './protocol'; + +const TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript', + '.css': 'text/css', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.json': 'application/json', +}; + +/** a run that takes longer than this is stuck: the browser is restarted */ +const RUN_TIMEOUT_MS = 180_000; + +/** + * Oxygen's viewport in headless Chromium (software WebGL), fed by a local server that hands it the preview page + * and the files of the effect's pack. Started on first use and kept for later calls; runs one request at a time. + */ +export class PreviewBrowser { + private server: Server | null = null; + private browser: Browser | null = null; + private page: Promise | null = null; + private queue: Promise = Promise.resolve(); + /** errors the page logged during the current run */ + private pageErrors: string[] = []; + + run(request: RunRequest): Promise { + const next = this.queue.then(() => this.runNow(request)); + this.queue = next.catch(() => undefined); + return next; + } + + private async runNow(request: RunRequest): Promise { + const page = await this.open(); + this.pageErrors = []; + const { promise: timeout, reject } = Promise.withResolvers(); + const timer = setTimeout(() => reject(new Error(`The preview took longer than ${RUN_TIMEOUT_MS / 1000} s; try fewer or earlier sample times.`)), RUN_TIMEOUT_MS); + try { + const result = await Promise.race([page.evaluate((r) => window.oxygenPreview.run(r), request), timeout]); + return { ...result, pageErrors: [...this.pageErrors] }; + } catch (e) { + // a stuck, crashed or half-finished page is replaced on the next call + await this.close(); + // page.evaluate prefixes the page's error and appends the page's stack: keep the message + const message = e instanceof Error ? e.message : String(e); + throw new Error(message.replace(/^page\.evaluate: (Error: )?/, '').split('\n at ')[0]); + } finally { + clearTimeout(timer); + } + } + + private open(): Promise { + this.page ??= this.start().catch((e) => { + this.page = null; + throw e; + }); + return this.page; + } + + private async start(): Promise { + const index = join(BUNDLED.preview, 'index.html'); + if (!existsSync(index)) throw new Error(`The preview page isn't built (${index} is missing): run \`pnpm --filter @oxygen/mcp build\`.`); + const root = resolve(BUNDLED.preview); + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + const file = url.searchParams.get('file') ?? ''; + const path = url.searchParams.get('path') ?? ''; + if (url.pathname === '/api/texture') { + const png = file ? textureFile(packRoot(file), path).png : null; + if (png) res.writeHead(200, { 'content-type': 'image/png' }).end(readFileSync(png)); + else res.writeHead(404).end(); + return; + } + if (url.pathname === '/api/pack-text') { + const text = file ? packText(packRoot(file), path) : null; + if (text !== null) res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }).end(text); + else res.writeHead(404).end(); + return; + } + const target = resolve(root, `.${decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)}`); + if (target.startsWith(root) && existsSync(target) && statSync(target).isFile()) res.writeHead(200, { 'content-type': TYPES[extname(target)] ?? 'application/octet-stream' }).end(readFileSync(target)); + else res.writeHead(404).end(); + }); + const { promise: listening, resolve: ready } = Promise.withResolvers(); + server.listen(0, '127.0.0.1', ready); + await listening; + this.server = server; + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('The preview server has no port'); + try { + this.browser = await chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'] }); + } catch (e) { + await this.close(); + throw new Error(`Couldn't start headless Chromium for the preview. Install it with \`npx playwright install chromium\`.\n${e instanceof Error ? e.message : String(e)}`); + } + const page = await this.browser.newPage({ deviceScaleFactor: 1 }); + page.on('pageerror', (e) => this.pageErrors.push(e.message)); + page.on('console', (m) => { + // pack files that aren't there (lighting, texture sets) are expected 404s, reported in their own terms + if (m.type() === 'error' && !m.text().startsWith('Failed to load resource')) this.pageErrors.push(m.text()); + }); + page.on('crash', () => void this.close()); + await page.goto(`http://127.0.0.1:${address.port}/index.html`); + await page.waitForFunction(() => !!window.oxygenPreview); + return page; + } + + async close(): Promise { + this.page = null; + const browser = this.browser; + const server = this.server; + this.browser = null; + this.server = null; + await browser?.close().catch(() => undefined); + server?.close(); + } +} diff --git a/apps/mcp/src/effects.ts b/apps/mcp/src/effects.ts new file mode 100644 index 0000000..a6179b0 --- /dev/null +++ b/apps/mcp/src/effects.ts @@ -0,0 +1,200 @@ +import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; +import { analyzeEffect, type EffectAnalysis, type Problem } from '@oxygen/effect'; +import { COMPONENT_BY_ID, componentInsertionIndex, formatDiff, JsonDocument, newEffect, readMeta, semanticDiff, validateEffect } from '@oxygen/format'; +import { queryExists } from '@oxygen/metadata'; +import { ParticleWorld, type EffectDefinition, type EventNode, type SpawnContext } from 'wintersky'; +import { effectIndex, examples, packEngine, packRoot, parentInputs } from './pack'; + +export type SpawnMode = 'command' | 'script' | 'entity'; + +/** One open particle file with its compiled definition and the problems the editor would show */ +export interface LoadedEffect { + path: string; + root: string | null; + doc: JsonDocument; + def: EffectDefinition; + analysis: EffectAnalysis; + problems: Problem[]; + /** what other effects in the pack (or the examples) set for this one in their pre-effects */ + parents: Record; + engine: number[] | null; +} + +function canonicalVar(k: string): string { + const lower = k.toLowerCase(); + return lower.startsWith('v.') ? `variable.${lower.slice(2)}` : lower.startsWith('variable.') ? lower : `variable.${lower}`; +} + +/** + * Compile and analyze an effect the way the editor's session does: syntax, a definition that failed to load, + * the effect-wide analysis (with the active scenario's variables and the parents' pre-effects as inputs) and + * Mojang's schema as notes + */ +export function analyze(path: string, text: string, spawn: SpawnMode = 'command'): LoadedEffect { + const root = packRoot(path); + const engine = packEngine(root); + const doc = new JsonDocument(text); + const world = new ParticleWorld({ engine, queryExists }); + const def = world.loadEffect(doc.value, engine); + const parents = def.identifier ? parentInputs(effectIndex(root), def.identifier) : {}; + const external: Record = {}; + const meta = readMeta(doc); + const sc = (meta.activeScenario && meta.scenarios?.find((s) => s.name === meta.activeScenario)) || null; + for (const k of Object.keys(sc?.variables ?? {})) external[canonicalVar(k)] = `scenario “${sc!.name}”`; + if (sc?.preEffect) for (const m of sc.preEffect.matchAll(/\b(?:v|variable)\.([a-z_][a-z0-9_]*)\s*=(?!=)/gi)) external[`variable.${m[1].toLowerCase()}`] = `scenario “${sc.name}” pre-effect`; + const mode = sc?.spawn ?? spawn; + const analysis = analyzeEffect(def, { external, parents, cap: world.options.particleCap[mode as SpawnContext], spawnContext: mode }); + const problems: Problem[] = []; + for (const p of doc.problems) problems.push({ path: '', severity: 'error', message: `${p.message} at offset ${p.offset} (the game can't read this file)`, code: 'json', source: 'json', from: p.offset, to: p.offset + p.length }); + if (def.failed) problems.push({ path: def.failed.path, severity: 'error', message: `${def.failed.reason}. Nothing from this file runs.`, code: 'failed', source: 'effect' }); + problems.push(...analysis.problems); + // the game is more lenient than Mojang's schema, so these are notes, not warnings + for (const s of validateEffect(doc)) problems.push({ path: s.path, severity: 'info', message: `Schema: ${s.message}`, code: 'schema', source: 'schema' }); + return { path, root, doc, def, analysis, problems, parents, engine }; +} + +export function load(path: string, spawn?: SpawnMode): LoadedEffect { + return analyze(path, readFileSync(path, 'utf8'), spawn); +} + +const SEVERITY_RANK: Record = { error: 0, warning: 1, info: 2 }; + +/** Problems as lines an agent can act on: severity, JSON path, message, code; notes only when asked */ +export function formatProblems(all: { severity: Problem['severity']; path: string; message: string; code: string }[], withInfo: boolean): string { + // the same problem can arrive twice (a parse error reported per pass) + const problems = [...new Map(all.map((p) => [`${p.severity}|${p.path}|${p.message}`, p])).values()]; + const shown = problems.filter((p) => withInfo || p.severity !== 'info').sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]); + const hidden = problems.length - shown.length; + const counts = (['error', 'warning', 'info'] as const) + .map((s) => { + const n = problems.filter((p) => p.severity === s).length; + return `${n} ${s}${n === 1 || s === 'info' ? '' : 's'}`; + }) + .join(', '); + const lines = [`Problems: ${counts}${hidden ? ` (${hidden} info notes hidden; pass include_info to see them)` : ''}`]; + for (const p of shown) lines.push(`- [${p.severity}] ${p.path || '(file)'}: ${p.message} (${p.code})`); + return lines.join('\n'); +} + +/** What one event node does, in a line */ +function describeEvent(n: EventNode): string { + const parts: string[] = []; + if (n.sequence?.length) parts.push(`sequence of ${n.sequence.length}: ${n.sequence.map(describeEvent).join(' → ')}`); + if (n.randomize?.length) parts.push(`random choice of ${n.randomize.length}: ${n.randomize.map((r) => `${r.weight}× ${describeEvent(r.node)}`).join(' | ')}`); + if (n.particleEffect?.effect) parts.push(`spawns ${n.particleEffect.effect} (${n.particleEffect.type})`); + if (n.sound) parts.push(`plays sound ${n.sound.event}`); + if (n.expression) parts.push('runs an expression'); + return parts.join('; ') || 'does nothing'; +} + +/** A short, structured description of the effect: what it's made of and how its parts connect */ +export function summarize(e: LoadedEffect): string { + const { def, doc, analysis } = e; + const components = doc.get('particle_effect/components'); + const lines = [`${def.identifier || '(no identifier)'} — ${basename(e.path)}`, `format_version ${def.formatVersion || '?'}, material ${def.material || '?'}, texture ${def.texture || '?'}`]; + lines.push(e.root ? `Pack: ${e.root}${e.engine ? ` (min_engine_version ${e.engine.join('.')})` : ''}` : 'Pack: none found (no manifest.json above the file and no particles/ folder)'); + lines.push('', 'Components:'); + for (const id of components && typeof components === 'object' ? Object.keys(components) : []) { + const info = COMPONENT_BY_ID.get(id); + lines.push(`- ${id}${info ? ` (${info.label}, ${info.category})` : ' (unknown to the game: ignored)'}`); + } + if (def.curves.length) lines.push('', 'Curves:', ...def.curves.map((c) => `- ${c.name} (${c.type})`)); + if (def.events.size) { + lines.push('', 'Events:'); + for (const [name, node] of def.events) lines.push(`- ${name}: ${describeEvent(node)}`); + } + const vars = analysis.variables.filter((v) => !v.builtin); + if (vars.length) { + lines.push('', 'Variables:'); + for (const v of vars) { + const from = v.external ? `set by ${v.external}` : v.curve ? 'set by a curve' : v.writes.length ? `set in ${v.writes.map((w) => w.label).join(', ')}` : 'never set'; + lines.push(`- ${v.name.replace(/^variable\./, 'v.')}: ${from}; read in ${v.reads.map((r) => r.label).join(', ') || 'nothing'}`); + } + } + if (Object.keys(e.parents).length) lines.push('', `Set by parent effects' pre-effects: ${Object.entries(e.parents).map(([v, p]) => `${v} (${p})`).join(', ')}`); + const meta = readMeta(doc); + if (meta.scenarios?.length) lines.push('', `Scenarios: ${meta.scenarios.map((s) => s.name + (s.name === meta.activeScenario ? ' (active)' : '')).join(', ')}`); + if (meta.cameras?.length) lines.push(`Camera bookmarks: ${meta.cameras.map((c) => c.name).join(', ')}`); + return lines.join('\n'); +} + +export type EditOp = + | { op: 'set'; path: string; value: unknown } + | { op: 'molang'; path: string; expression: string } + | { op: 'remove'; path: string } + | { op: 'insert'; path: string; value: unknown; index?: number } + | { op: 'rename'; path: string; to: string }; + +/** `components/…`, `curves/…`, `events/…` and `description/…` are short for the same path under `particle_effect/` */ +export function fullPath(path: string): string { + const p = path.replace(/^\/+/, ''); + return /^(components|curves|events|description)(\/|$)/.test(p) ? `particle_effect/${p}` : p; +} + +/** + * Apply edits as one change, the way Oxygen edits files: surgical text edits that keep formatting, key order, + * comments and number spelling. Throws (and changes nothing) when an edit can't apply. + */ +export function applyEdits(text: string, ops: EditOp[]): { text: string; diff: string } { + const doc = new JsonDocument(text); + if (doc.problems.length) throw new Error(`The file isn't valid JSON (${doc.problems[0].message} at offset ${doc.problems[0].offset}); fix it as text first.`); + const before = doc.value; + doc.transact('Agent edit', () => { + ops.forEach((o, i) => { + const path = fullPath(o.path); + const where = `edit ${i + 1} (${o.op} ${path})`; + const parts = path.split('/'); + switch (o.op) { + case 'set': { + // a new component goes where the editor puts it, in the catalog's order + const isComponent = parts.length === 3 && parts[0] === 'particle_effect' && parts[1] === 'components'; + const order = isComponent && !doc.has(path) ? componentInsertionIndex(parts[2]) : undefined; + doc.set(path, o.value, 'Agent edit', order); + break; + } + case 'molang': + doc.setMolang(path, o.expression); + break; + case 'remove': + if (!doc.has(path)) throw new Error(`${where}: nothing at that path`); + doc.remove(path); + break; + case 'insert': { + const arr = doc.node(path); + if (arr && arr.type !== 'array') throw new Error(`${where}: the value there isn't an array`); + doc.insert(path, o.value, o.index); + break; + } + case 'rename': + if (!doc.has(path)) throw new Error(`${where}: nothing at that path`); + if (doc.node(path)?.parent?.type !== 'property') throw new Error(`${where}: only object keys can be renamed`); + doc.renameKey(path, o.to); + break; + } + if (doc.problems.length) throw new Error(`${where} broke the JSON: ${doc.problems[0].message}`); + }); + }); + return { text: doc.text, diff: formatDiff(semanticDiff(before, doc.value), 'before', 'after') }; +} + +/** + * Text for a new effect: Oxygen's starting effect, or a copy of a bundled example under a new identifier. + * `children` lists the effects the example spawns, which come from the examples unless the pack has them. + */ +export function newEffectText(identifier: string, template: string | null): { text: string; children: string[] } { + if (!template) return { text: `${JSON.stringify(newEffect(identifier), null, '\t')}\n`, children: [] }; + const file = examples().get(template); + if (!file) throw new Error(`No example named "${template}". Examples: ${[...examples().keys()].join(', ')}`); + const doc = new JsonDocument(readFileSync(file, 'utf8')); + doc.set('particle_effect/description/identifier', identifier); + const world = new ParticleWorld({ queryExists }); + const children = new Set(); + const walk = (n: EventNode) => { + if (n.particleEffect?.effect) children.add(n.particleEffect.effect); + n.sequence?.forEach(walk); + n.randomize?.forEach((r) => walk(r.node)); + }; + for (const n of world.loadEffect(doc.value).events.values()) walk(n); + return { text: doc.text, children: [...children] }; +} diff --git a/apps/mcp/src/pack.ts b/apps/mcp/src/pack.ts new file mode 100644 index 0000000..2ca50ae --- /dev/null +++ b/apps/mcp/src/pack.ts @@ -0,0 +1,146 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { parentInputsFor, spawnsOf, type EffectSpawns } from '@oxygen/effect'; + +/** What the build copies next to the server bundle (see build.mjs) */ +export const BUNDLED = { + examples: join(__dirname, 'examples'), + vanilla: join(__dirname, 'vanilla'), + preview: join(__dirname, 'preview'), + docs: join(__dirname, 'docs'), +}; + +/** vanilla texture paths → the sprites wintersky bundles */ +const VANILLA_TEXTURES: Record = { + 'textures/particle/particles': 'particles.png', + 'textures/particle/campfire_smoke': 'campfire_smoke.png', + 'textures/particle/soul': 'soul.png', + 'textures/flame_atlas': 'flame_atlas.png', +}; + +/** Resource pack root of a file: the nearest folder above with manifest.json, else the parent of `particles/` */ +export function packRoot(file: string): string | null { + let dir = dirname(resolve(file)); + let particlesParent: string | null = null; + for (let i = 0; i < 12; i++) { + if (basename(dir) === 'particles') particlesParent ??= dirname(dir); + if (existsSync(join(dir, 'manifest.json'))) return dir; + const up = dirname(dir); + if (up === dir) break; + dir = up; + } + return particlesParent; +} + +/** The pack's min_engine_version (selects Molang rules for plain-string expressions) */ +export function packEngine(root: string | null): number[] | null { + if (!root) return null; + try { + const m = parseJsonc(readFileSync(join(root, 'manifest.json'), 'utf8').replace(/^\uFEFF/, '')); + const v = m?.header?.min_engine_version; + return Array.isArray(v) ? v.map(Number) : null; + } catch { + return null; + } +} + +/** The identifier an effect file declares (not just any mention: events name other effects too) */ +export function identifierOf(text: string): string | null { + return /"identifier"\s*:\s*"([^"]+)"/.exec(text)?.[1] ?? null; +} + +/** Particle files under `dir` by identifier. Read fresh every time: an agent edits files between calls. */ +function scanEffects(dir: string): Map { + const out = new Map(); + const walk = (d: string, depth: number) => { + if (depth > 6 || !existsSync(d)) return; + for (const f of readdirSync(d)) { + const p = join(d, f); + if (statSync(p).isDirectory()) walk(p, depth + 1); + else if (f.endsWith('.json')) { + const id = identifierOf(readFileSync(p, 'utf8')); + if (id && !out.has(id)) out.set(id, p); + } + } + }; + walk(dir, 0); + return out; +} + +/** The bundled examples by name (`fire`) → file */ +export function examples(): Map { + const out = new Map(); + if (!existsSync(BUNDLED.examples)) return out; + for (const f of readdirSync(BUNDLED.examples).sort()) if (f.endsWith('.particle.json')) out.set(f.replace('.particle.json', ''), join(BUNDLED.examples, f)); + return out; +} + +/** + * Where effects are looked up by identifier: the pack's particles folder, then the bundled examples (what an + * example spawns is another example, as in the editor) + */ +export function effectIndex(root: string | null): Map { + const index = root ? scanEffects(join(root, 'particles')) : new Map(); + for (const [id, p] of scanEffects(BUNDLED.examples)) if (!index.has(id)) index.set(id, p); + return index; +} + +/** + * Every effect the events of `text` reach, directly or through other children, as text by identifier. The effect + * itself is left out: a child spawning it back plays the text being previewed, not the file on disk. + */ +export function childEffects(text: string, index: Map): { effects: Record; missing: string[] } { + const effects: Record = {}; + const missing: string[] = []; + const pending: string[] = []; + const queue = (json: unknown) => pending.push(...(spawnsOf(json)?.spawns ?? []).map((s) => s.effect)); + const json = parseJsonc(text, [], { allowTrailingComma: true }); + const root = spawnsOf(json)?.identifier; + queue(json); + while (pending.length) { + const id = pending.shift()!; + if (id === root || id in effects || missing.includes(id)) continue; + const p = index.get(id); + if (!p) { + missing.push(id); + continue; + } + effects[id] = readFileSync(p, 'utf8'); + queue(parseJsonc(effects[id], [], { allowTrailingComma: true })); + } + return { effects, missing }; +} + +/** What the indexed effects set, in their pre-effects, for an effect they spawn (variable → parents) */ +export function parentInputs(index: Map, identifier: string): Record { + const spawns: EffectSpawns[] = []; + for (const p of index.values()) { + const s = spawnsOf(parseJsonc(readFileSync(p, 'utf8'), [], { allowTrailingComma: true })); + if (s) spawns.push(s); + } + return parentInputsFor(spawns, identifier); +} + +/** + * Image for a texture path from a particle file (`textures/particle/foo`): the pack's PNG, else the vanilla + * sprite. `tga` is set when the pack only has a TGA, which the preview can't decode. + */ +export function textureFile(root: string | null, texture: string): { png: string | null; tga: boolean } { + const base = texture.replace(/\.(png|tga)$/i, ''); + if (root) { + const png = join(root, `${base}.png`); + if (existsSync(png)) return { png, tga: false }; + } + const bundled = VANILLA_TEXTURES[base]; + if (bundled && existsSync(join(BUNDLED.vanilla, bundled))) return { png: join(BUNDLED.vanilla, bundled), tga: false }; + return { png: null, tga: !!root && existsSync(join(root, `${base}.tga`)) }; +} + +/** A text file of the pack (lighting, texture sets, manifest); null when absent or outside the pack */ +export function packText(root: string | null, path: string): string | null { + if (!root) return null; + const p = resolve(root, path); + if (!p.startsWith(resolve(root)) || !existsSync(p)) return null; + return readFileSync(p, 'utf8'); +} diff --git a/apps/mcp/src/protocol.ts b/apps/mcp/src/protocol.ts new file mode 100644 index 0000000..54794f0 --- /dev/null +++ b/apps/mcp/src/protocol.ts @@ -0,0 +1,99 @@ +/** What the MCP server asks of the preview page (preview/main.ts) and what it answers. Plain JSON both ways. */ + +export type Vec3 = [number, number, number]; + +export type CameraRequest = + /** frame every particle seen across the sampled times, looking from `angle`; `zoom` > 1 moves closer */ + | { kind: 'auto'; angle: 'iso' | 'front' | 'side' | 'top' | 'bottom'; zoom: number } + /** a camera bookmark saved in the file's oxygen block */ + | { kind: 'bookmark'; name: string } + | { kind: 'manual'; position: Vec3; target: Vec3; fov?: number }; + +export interface RunRequest { + /** the effect file's text */ + text: string; + /** display name and absolute path; the page resolves textures and pack files through the server by path */ + file: { name: string; id: string }; + /** every child effect its events reach, as JSON text by identifier */ + effects: Record; + /** the pack's min_engine_version */ + engine: number[] | null; + /** seconds after the spawn to sample, ascending */ + times: number[]; + /** how the effect is started; null keeps what the file's active scenario says (default /particle) */ + spawn: 'command' | 'script' | 'entity' | null; + /** Oxygen's Repeat: respawn (or loop in place) when the effect ends; the game plays /particle once */ + repeat: boolean; + /** draw images (false: statistics only) */ + render: null | { + width: number; + height: number; + mode: 'classic' | 'vv'; + camera: CameraRequest; + ground: boolean; + gizmos: boolean; + /** one labelled contact sheet instead of an image per time */ + sheet: boolean; + }; + /** query.time_of_day (0 midnight, 0.25 sunrise, 0.5 noon); null keeps the effect's own scene setting */ + timeOfDay: number | null; + /** what other effects' pre-effects set for this one (variable → their identifiers) */ + parents: Record; +} + +export interface SampleStats { + /** simulation time of the sample */ + time: number; + particles: number; + /** particles the renderer draws (alive, positive size, not culled) */ + visible: number; + emitters: number; + /** world-space box around the visible particles */ + bounds: { min: Vec3; max: Vec3 } | null; + capHit: boolean; +} + +export interface RunProblem { + path: string; + severity: 'error' | 'warning' | 'info'; + message: string; + code: string; + source: string; +} + +export interface EmitterSummary { + effect: string; + /** how many emitters of this effect ran */ + count: number; + firstStart: number; + /** when the last of them ended (null: still running at the last sample) */ + lastEnd: number | null; + /** events fired by these emitters and their particles: count and first time */ + events: Record; +} + +export interface RunResult { + identifier: string; + samples: SampleStats[]; + /** highest live particle count over the run, and when */ + peak: { particles: number; time: number }; + /** set when the effect ended (nothing alive, Repeat off) before the last sample */ + finishedAt: number | null; + emitters: EmitterSummary[]; + problems: RunProblem[]; + /** texture paths that resolved to nothing: they draw as the magenta-black missing texture */ + missingTextures: string[]; + /** PNG data URLs: one per sample, or a single contact sheet */ + images: string[]; + camera: { position: Vec3; target: Vec3; fov: number } | null; +} + +export interface PreviewApi { + run(request: RunRequest): Promise; +} + +declare global { + interface Window { + oxygenPreview: PreviewApi; + } +} diff --git a/apps/mcp/src/reference.ts b/apps/mcp/src/reference.ts new file mode 100644 index 0000000..9a98b53 --- /dev/null +++ b/apps/mcp/src/reference.ts @@ -0,0 +1,94 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { CATEGORIES, COMPONENT_BY_ID, COMPONENTS, MATERIALS, PHASES, type FieldKind } from '@oxygen/format'; +import { getQuery, molangMetadata } from '@oxygen/metadata'; +import { BUNDLED } from './pack'; + +/** Oxygen's own guides, copied next to the server at build time */ +export const GUIDES: Record = { + 'game-rules': { file: 'accuracy.md', about: "How the game really runs particles (quirks the docs don't mention), measured in-game" }, + molang: { file: 'molang.md', about: 'Writing Molang in particle files' }, +}; + +function kindText(k: FieldKind): string { + const unit = 'unit' in k && k.unit ? ` ${k.unit}` : ''; + switch (k.type) { + case 'molang': + return `Molang (number or expression)${k.default !== undefined && k.default !== null ? `, default ${k.default}` : ''}${unit}`; + case 'vec': + return `[${k.labels.join(', ')}], each Molang${k.default ? `, default [${k.default.join(', ')}]` : ''}${unit}`; + case 'statements': + return 'Molang statements (assignments, `;`-separated)'; + case 'number': + return `number${k.default !== undefined ? `, default ${k.default}` : ''}${k.min !== undefined ? `, min ${k.min}` : ''}${k.max !== undefined ? `, max ${k.max}` : ''}${unit}`; + case 'bool': + return `boolean${k.default !== undefined ? `, default ${k.default}` : ''}`; + case 'enum': + return `one of ${k.options.map((o) => `"${o.value}"${o.help ? ` (${o.help})` : ''}`).join(', ')}${k.default ? `; default "${k.default}"` : ''}`; + case 'string': + return `string${k.default ? `, default "${k.default}"` : ''}`; + case 'events': + return 'event name or array of event names'; + case 'timeline': + return `object: ${k.key === 'time' ? 'seconds' : 'blocks travelled'} → event name(s)`; + case 'color': + return 'colour: [r, g, b, a] Molang, "#AARRGGBB", or a gradient { "interpolant": Molang, "gradient": { "": colour } }'; + case 'uv': + return 'UV: texture_width/height, uv, uv_size (Molang), or a "flipbook" object'; + default: + return k.type; + } +} + +const USAGE = [ + 'Topics:', + '- components: every component, by category', + '- , e.g. minecraft:particle_appearance_billboard: its fields, defaults and when the game evaluates them', + '- materials: render materials', + '- phases: the order the game evaluates things in', + '- queries (optionally with search): Molang queries a particle can use', + '- , e.g. q.distance_from_camera: one query', + '- math: Molang math functions', + ...Object.entries(GUIDES).map(([k, g]) => `- ${k}: ${g.about}`), +].join('\n'); + +export function reference(topic: string, search: string | null): string { + const t = topic.trim(); + const lower = t.toLowerCase(); + if (!t || lower === 'help') return USAGE; + if (lower === 'components') { + const lines = ['Components (one per group: rate, lifetime, shape). Write them under particle_effect/components.']; + for (const c of CATEGORIES) { + lines.push('', `${c.label}:`); + for (const info of COMPONENTS.filter((x) => x.category === c.id)) lines.push(`- ${info.id}${info.group ? ` [${info.group}]` : ''}: ${info.label}. ${info.help}`); + } + return lines.join('\n'); + } + const component = COMPONENT_BY_ID.get(t) ?? COMPONENT_BY_ID.get(`minecraft:${t}`); + if (component) { + const lines = [`${component.id}: ${component.label} (${component.category}${component.group ? `, one ${component.group} component per effect` : ''})`, component.help]; + if (component.valueShape && component.valueShape !== 'object') lines.push(`Value shape: ${component.valueShape} (not an object)`); + if (component.fields.length) lines.push('', 'Fields:'); + for (const f of component.fields) lines.push(`- ${f.key}${f.required ? ' (required)' : ''}: ${f.label}. ${kindText(f.kind)}. Evaluated in the ${f.phase} phase${f.measured ? ' (default measured in-game)' : ''}. ${f.help}`); + return lines.join('\n'); + } + if (lower === 'materials') return ['Materials (description/basic_render_parameters/material):', ...MATERIALS.map((m) => `- ${m.value}: ${m.label}. ${m.help}`)].join('\n'); + if (lower === 'phases') return ['Evaluation phases, in order:', ...PHASES.map((p) => `- ${p.id}: ${p.label}. ${p.detail}`)].join('\n'); + if (lower === 'math') return ['Math functions:', ...molangMetadata.math.map((m) => `- ${m.name}(${m.params.join(', ')}): ${m.description}`)].join('\n'); + if (lower === 'queries') { + const s = search?.toLowerCase() ?? ''; + const found = molangMetadata.queries.filter((q) => !q.lastVersion && (!s || q.name.includes(s) || q.description.toLowerCase().includes(s))); + // most queries describe entities; a particle emitter spawned by /particle or a script has none + const lines = [`${found.length} queries${s ? ` matching "${s}"` : ''}. "entity" ones need an emitter attached to an entity; with /particle they read 0.`]; + for (const q of found) lines.push(`- ${q.name}${q.context === 'entity' ? ' [entity]' : ''}: ${q.description.split(/(?<=\.)\s/)[0]}`); + return lines.join('\n'); + } + const q = getQuery(t); + if (q) return [`${q.name} → ${q.returnType}`, q.description, `Arguments: ${q.minArgs}${q.maxArgs !== q.minArgs ? `–${q.maxArgs ?? 'any'}` : ''}. Context: ${q.context === 'entity' ? 'needs an emitter attached to an entity' : 'any emitter'}. Since ${q.firstVersion}${q.lastVersion ? `, removed after ${q.lastVersion}` : ''}.`].join('\n'); + const guide = GUIDES[lower]; + if (guide) { + const file = join(BUNDLED.docs, guide.file); + if (existsSync(file)) return readFileSync(file, 'utf8'); + } + return `Nothing found for "${t}".\n\n${USAGE}`; +} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts new file mode 100644 index 0000000..1a868bd --- /dev/null +++ b/apps/mcp/src/server.ts @@ -0,0 +1,331 @@ +// Oxygen for coding agents: an MCP server (stdio) that creates, edits, checks, simulates and renders Minecraft +// Bedrock particle effects with Oxygen's own engine, analysis and viewport. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, extname, join, resolve } from 'node:path'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { PreviewBrowser } from './browser'; +import { analyze, applyEdits, formatProblems, load, newEffectText, summarize, type EditOp, type SpawnMode } from './effects'; +import { BUNDLED, childEffects, effectIndex, examples, packRoot, textureFile } from './pack'; +import type { CameraRequest, RunRequest, RunResult } from './protocol'; +import { reference } from './reference'; + +const VERSION = '0.1.1'; + +const browser = new PreviewBrowser(); + +const spawnSchema = z + .enum(['command', 'script', 'entity']) + .optional() + .describe("How the effect is started: 'command' (/particle, the default), 'script' (Script API spawnParticle) or 'entity' (attached to an entity, which loops looping emitters). The file's active scenario can set it too."); +const pathSchema = z.string().describe('Path to the .particle.json / particles/*.json file (absolute, or relative to the working directory)'); + +/** Run a tool body, turning a thrown error into a tool error the agent can read */ +async function tool(work: () => Promise | CallToolResult): Promise { + try { + return await work(); + } catch (e) { + return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] }; + } +} + +const text = (t: string): CallToolResult => ({ content: [{ type: 'text', text: t }] }); + +function existing(path: string): string { + const p = resolve(path); + if (!existsSync(p)) throw new Error(`No file at ${p}`); + return p; +} + +/** Sample times: given ones, or `samples` evenly spread over `duration` (the first a frame in) */ +function sampleTimes(times: number[] | undefined, duration: number, samples: number): number[] { + const raw = times?.length ? times : Array.from({ length: samples }, (_, i) => (duration * (i + 1)) / samples); + const out = [...new Set(raw.map((t) => Math.min(60, Math.max(1 / 60, t))))].sort((a, b) => a - b); + if (out.length > 24) throw new Error('At most 24 sample times'); + return out; +} + +function runRequest(path: string, o: { times: number[]; spawn?: SpawnMode; repeat: boolean; timeOfDay?: number; render: RunRequest['render'] }): { request: RunRequest; missingChildren: string[] } { + const e = load(path, o.spawn); + const { effects, missing } = childEffects(e.doc.text, effectIndex(e.root)); + return { + request: { text: e.doc.text, file: { name: basename(path), id: path }, effects, engine: e.engine, times: o.times, spawn: o.spawn ?? null, repeat: o.repeat, render: o.render, timeOfDay: o.timeOfDay ?? null, parents: e.parents }, + missingChildren: missing, + }; +} + +/** The numbers of a run, as a report */ +function report(r: RunResult & { pageErrors: string[] }, path: string, missingChildren: string[], withInfo: boolean): string { + const lines = [`${r.identifier}: ${r.samples.length} samples. Peak ${r.peak.particles} live particles at ${r.peak.time} s.${r.finishedAt !== null ? ` Everything ended at ${r.finishedAt} s.` : ''}`, '']; + lines.push('| time (s) | live | drawn | emitters | bounds min → max (blocks) |', '| --- | --- | --- | --- | --- |'); + for (const s of r.samples) { + const b = s.bounds ? `[${s.bounds.min.join(', ')}] → [${s.bounds.max.join(', ')}]` : '—'; + lines.push(`| ${s.time} | ${s.particles}${s.capHit ? ' (cap)' : ''} | ${s.visible} | ${s.emitters} | ${b} |`); + } + if (r.emitters.length) { + lines.push('', 'Emitters and events:'); + for (const em of r.emitters) { + const evs = Object.entries(em.events).map(([n, ev]) => `${n} ×${ev.count} (first ${ev.first} s)`); + lines.push(`- ${em.effect}: ${em.count} emitter${em.count === 1 ? '' : 's'}, from ${em.firstStart} s${em.lastEnd !== null ? ` to ${em.lastEnd} s` : ', still running'}${evs.length ? `; events ${evs.join(', ')}` : ''}`); + } + } + if (missingChildren.length) lines.push('', `Child effects not found in the pack or the examples (they don't spawn): ${missingChildren.join(', ')}`); + if (r.missingTextures.length) { + const root = packRoot(path); + const notes = r.missingTextures.map((t) => `${t}${textureFile(root, t).tga ? ' (only a TGA exists; the preview reads PNG)' : ''}`); + lines.push('', `Missing textures (drawn as the magenta-black missing texture): ${notes.join(', ')}`); + } + lines.push('', formatProblems(r.problems, withInfo)); + if (r.pageErrors.length) lines.push('', `Preview page errors: ${r.pageErrors.slice(0, 5).join(' | ')}`); + return lines.join('\n'); +} + +const server = new McpServer({ name: 'oxygen', version: VERSION }, { + instructions: [ + 'Oxygen is a Minecraft Bedrock particle editor whose engine was checked against the game. Use these tools to author particle effects (JSON files under a resource pack\'s particles/ folder):', + '1. reference (topic "components", a component id, "game-rules", "queries") to learn the format and the game\'s real behaviour.', + '2. create_effect to start from Oxygen\'s template or a bundled example (list_examples).', + '3. edit_effect for formatting-preserving edits by JSON path; read_effect / check_effect to see structure and problems.', + '4. simulate_effect for numbers over time (particle counts, bounds, events); preview_effect to see it rendered.', + 'Paths in edits look like components/minecraft:particle_appearance_billboard/size or events/pop/particle_effect/effect.', + ].join('\n'), +}); + +server.registerTool( + 'list_examples', + { + title: 'List bundled examples', + description: 'The example effects bundled with Oxygen (each checked in Minecraft), usable as templates for create_effect.', + annotations: { readOnlyHint: true }, + }, + () => + tool(() => { + const lines = ['Examples (name: identifier, components):']; + for (const [name, file] of examples()) { + const e = analyze(file, readFileSync(file, 'utf8')); + const components = e.doc.get('particle_effect/components'); + const comps = (components && typeof components === 'object' ? Object.keys(components) : []).map((c) => c.replace('minecraft:', '')); + lines.push(`- ${name}: ${e.def.identifier}; ${e.def.material}; ${comps.join(', ')}${e.def.events.size ? `; events ${[...e.def.events.keys()].join(', ')}` : ''}`); + } + return text(lines.join('\n')); + }), +); + +server.registerTool( + 'reference', + { + title: 'Particle format reference', + description: + 'Look up the particle format and the game\'s behaviour: topic "components" (all components), a component id such as "minecraft:emitter_shape_sphere" (fields, defaults, evaluation phase), "materials", "phases", "queries" (with search), a query such as "q.distance_from_camera", "math", "game-rules" (measured in-game quirks: read this before designing effects) or "molang".', + inputSchema: { topic: z.string().describe('What to look up; "help" lists topics'), search: z.string().optional().describe('Filter for the "queries" topic') }, + annotations: { readOnlyHint: true }, + }, + ({ topic, search }) => tool(() => text(reference(topic, search ?? null))), +); + +server.registerTool( + 'create_effect', + { + title: 'Create a particle effect', + description: "Write a new particle effect file: Oxygen's starting effect (a small steady fountain) or a copy of a bundled example under a new identifier. Returns its summary and problems.", + inputSchema: { + path: pathSchema, + identifier: z.string().regex(/^[a-z0-9_.-]+:[a-z0-9_./-]+$/, 'namespace:name, lowercase').describe('The effect identifier, e.g. "mypack:spark_burst"'), + template: z.string().optional().describe('A bundled example to start from (see list_examples); omit for the starting effect'), + overwrite: z.boolean().optional().describe('Replace an existing file (default false)'), + }, + }, + ({ path, identifier, template, overwrite }) => + tool(() => { + const p = resolve(path); + if (existsSync(p) && !overwrite) throw new Error(`${p} already exists; pass overwrite: true to replace it, or use edit_effect`); + if (extname(p) !== '.json') throw new Error('Particle files end in .json (Oxygen opens *.particle.json by default)'); + const { text: body, children } = newEffectText(identifier, template ?? null); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, body); + const e = load(p); + const lines = [`Created ${p}`, '', summarize(e), '', formatProblems(e.problems, false)]; + const index = effectIndex(e.root); + const fromExamples = children.filter((c) => index.get(c)?.startsWith(BUNDLED.examples) ?? true); + if (fromExamples.length) lines.push('', `This example spawns ${fromExamples.join(', ')}. The preview finds them among the bundled examples, but the game needs them in the pack: create them too (create_effect with their example as template, keeping their identifiers) or point the events at your own effects.`); + if (!e.root) lines.push('', 'No resource pack around this file (no manifest.json above it and no particles/ folder): pack textures and child effects can\'t be found.'); + return text(lines.join('\n')); + }), +); + +server.registerTool( + 'read_effect', + { + title: 'Read a particle effect', + description: 'Summarize an effect: identifier, material, texture, components, curves, events (what they spawn), variables (where set and read), scenarios, plus its problems. Optionally the file text.', + inputSchema: { + path: pathSchema, + include_text: z.boolean().optional().describe('Also return the file text (default false)'), + include_info: z.boolean().optional().describe('Also list info-level notes such as schema hints (default false)'), + }, + annotations: { readOnlyHint: true }, + }, + ({ path, include_text, include_info }) => + tool(() => { + const e = load(existing(path)); + const parts = [summarize(e), '', formatProblems(e.problems, !!include_info)]; + if (include_text) parts.push('', '```json', e.doc.text.trimEnd(), '```'); + return text(parts.join('\n')); + }), +); + +const editSchema = z.discriminatedUnion('op', [ + z.object({ op: z.literal('set'), path: z.string(), value: z.unknown().describe('Any JSON value; creates missing parents') }), + z.object({ op: z.literal('molang'), path: z.string(), expression: z.string().describe('Molang source; kept as a number when it is one and the field held a number') }), + z.object({ op: z.literal('remove'), path: z.string() }), + z.object({ op: z.literal('insert'), path: z.string().describe('Path of an array'), value: z.unknown(), index: z.number().int().optional().describe('Default: append') }), + z.object({ op: z.literal('rename'), path: z.string().describe('Path of an object key'), to: z.string() }), +]); + +server.registerTool( + 'edit_effect', + { + title: 'Edit a particle effect', + description: + 'Apply edits by JSON path as one change, keeping the file\'s formatting, key order and comments (Oxygen\'s surgical edits). Paths are slash-separated; "components/", "curves/", "events/" and "description/" are short for the same under "particle_effect/". Use "~1" for a "/" inside a key. A new component is placed in canonical order. All edits apply or none do. Returns what changed (in the game\'s terms) and the problems afterwards.', + inputSchema: { + path: pathSchema, + edits: z.array(editSchema).min(1), + dry_run: z.boolean().optional().describe("Report the change and its problems without writing the file"), + include_info: z.boolean().optional(), + }, + }, + ({ path, edits, dry_run, include_info }) => + tool(() => { + const p = existing(path); + const before = readFileSync(p, 'utf8'); + const { text: after, diff } = applyEdits(before, edits as EditOp[]); + if (!dry_run && after !== before) writeFileSync(p, after); + const e = analyze(p, after); + return text([dry_run ? 'Dry run: nothing written.' : after === before ? 'No change.' : `Saved ${p}.`, '', diff.trimEnd(), '', formatProblems(e.problems, !!include_info)].join('\n')); + }), +); + +server.registerTool( + 'check_effect', + { + title: 'Check a particle effect', + description: "List an effect's problems the way Oxygen reports them: JSON errors, fields the game drops, Molang errors, unset variable reads (which abort the expression in-game), structural mistakes and schema notes. Static only; simulate_effect adds runtime problems.", + inputSchema: { path: pathSchema, spawn: spawnSchema, include_info: z.boolean().optional() }, + annotations: { readOnlyHint: true }, + }, + ({ path, spawn, include_info }) => tool(() => text(formatProblems(load(existing(path), spawn).problems, !!include_info))), +); + +server.registerTool( + 'simulate_effect', + { + title: 'Simulate a particle effect', + description: + "Run the effect in Oxygen's game-faithful engine (fixed 60 fps clock, same seed every time) and report numbers at sample times: live and drawn particles, emitters, the bounding box of the particles, the cap; which emitters and events ran and when; and the problems, including runtime ones (unset variables read, division by zero, cap reached). Child effects come from the pack, then the bundled examples.", + inputSchema: { + path: pathSchema, + duration: z.number().positive().max(60).optional().describe('Seconds to simulate (default 3)'), + samples: z.number().int().min(1).max(24).optional().describe('How many evenly spaced samples (default 6)'), + times: z.array(z.number().min(0).max(60)).optional().describe('Exact sample times in seconds (overrides duration and samples)'), + spawn: spawnSchema, + repeat: z.boolean().optional().describe("Oxygen's Repeat: start the effect again when it ends (default false: /particle plays once, like the game)"), + include_info: z.boolean().optional(), + }, + annotations: { readOnlyHint: true }, + }, + ({ path, duration, samples, times, spawn, repeat, include_info }) => + tool(async () => { + const p = existing(path); + const { request, missingChildren } = runRequest(p, { times: sampleTimes(times, duration ?? 3, samples ?? 6), spawn, repeat: !!repeat, render: null }); + return text(report(await browser.run(request), p, missingChildren, !!include_info)); + }), +); + +const cameraSchema = z + .object({ + angle: z.enum(['iso', 'front', 'side', 'top', 'bottom']).optional().describe('Direction to look from when framing automatically (default iso)'), + zoom: z.number().positive().optional().describe('Automatic framing: >1 closer, <1 further (default 1)'), + position: z.array(z.number()).length(3).optional().describe('Manual camera position (with target)'), + target: z.array(z.number()).length(3).optional().describe('Manual point to look at (with position)'), + fov: z.number().min(10).max(120).optional().describe('Vertical field of view in degrees (default 70)'), + bookmark: z.string().optional().describe("A camera saved in the file's oxygen block"), + }) + .optional() + .describe('Default: frame every particle seen across the sample times, from the iso angle'); + +server.registerTool( + 'preview_effect', + { + title: 'Render a particle effect', + description: + "Render the effect with Oxygen's viewport (the same renderer as the editor, matched to the game) at sample times and return the pictures, by default as one labelled contact sheet, plus the simulate_effect numbers. The ground grid is at y=0 and the small outlined cube is one block. Optionally saves the PNGs.", + inputSchema: { + path: pathSchema, + times: z.array(z.number().min(0).max(60)).max(9).optional().describe('Seconds after the spawn to show (default [0.25, 0.75, 1.5, 3])'), + width: z.number().int().min(64).max(1920).optional().describe('Pixels per picture (default 480)'), + height: z.number().int().min(64).max(1920).optional().describe('Pixels per picture (default 360)'), + camera: cameraSchema, + mode: z.enum(['classic', 'vv']).optional().describe("'classic' (default) or 'vv' for the Vibrant Visuals approximation (uses the pack's lighting and texture sets)"), + time_of_day: z.number().min(0).max(1).optional().describe('0 midnight, 0.25 sunrise, 0.5 noon (lighting, query.time_of_day)'), + ground: z.boolean().optional().describe('Draw the ground grid and the one-block reference (default true)'), + gizmos: z.boolean().optional().describe('Draw emitter markers, spawn shapes and the kill plane (default false)'), + sheet: z.boolean().optional().describe('One contact sheet (default true) instead of a picture per time'), + save_to: z.string().optional().describe('Write the PNG(s) here: a .png file, or a folder for one file per picture'), + spawn: spawnSchema, + repeat: z.boolean().optional().describe("Oxygen's Repeat (default false: /particle plays once, like the game)"), + include_info: z.boolean().optional(), + }, + annotations: { readOnlyHint: false }, + }, + (a) => + tool(async () => { + const p = existing(a.path); + const c = a.camera ?? {}; + let camera: CameraRequest; + if (c.bookmark) camera = { kind: 'bookmark', name: c.bookmark }; + else if (c.position && c.target) camera = { kind: 'manual', position: [c.position[0], c.position[1], c.position[2]], target: [c.target[0], c.target[1], c.target[2]], fov: c.fov }; + else if (c.position || c.target) throw new Error('A manual camera needs both position and target'); + else camera = { kind: 'auto', angle: c.angle ?? 'iso', zoom: c.zoom ?? 1 }; + const times = sampleTimes(a.times ?? [0.25, 0.75, 1.5, 3], 0, 0); + const sheet = a.sheet ?? true; + const { request, missingChildren } = runRequest(p, { + times, + spawn: a.spawn, + repeat: !!a.repeat, + timeOfDay: a.time_of_day, + render: { width: a.width ?? 480, height: a.height ?? 360, mode: a.mode ?? 'classic', camera, ground: a.ground ?? true, gizmos: !!a.gizmos, sheet }, + }); + const r = await browser.run(request); + const pngs = r.images.map((url) => url.replace(/^data:image\/png;base64,/, '')); + const lines = [report(r, p, missingChildren, !!a.include_info)]; + if (r.camera) lines.push('', `Camera: position [${r.camera.position.map((x) => +x.toFixed(3)).join(', ')}], target [${r.camera.target.map((x) => +x.toFixed(3)).join(', ')}], fov ${r.camera.fov}`); + if (a.save_to) { + const out = resolve(a.save_to); + const name = (r.identifier.split(':').pop() ?? 'effect').replace(/[^a-z0-9_.-]/gi, '_'); + let files: string[]; + if (extname(out).toLowerCase() === '.png') { + if (pngs.length > 1) throw new Error('Several pictures need a folder for save_to (or sheet: true)'); + files = [out]; + } else files = pngs.map((_, i) => join(out, pngs.length === 1 ? `${name}.png` : `${name}_${r.samples[i].time}s.png`)); + mkdirSync(dirname(files[0]), { recursive: true }); + pngs.forEach((b64, i) => writeFileSync(files[i], Buffer.from(b64, 'base64'))); + lines.push('', `Saved ${files.join(', ')}`); + } + return { content: [{ type: 'text', text: lines.join('\n') }, ...pngs.map((data) => ({ type: 'image' as const, data, mimeType: 'image/png' }))] }; + }), +); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + void browser.close().finally(() => process.exit(0)); + }); +} +process.stdin.on('close', () => void browser.close()); + +server.connect(new StdioServerTransport()).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/mcp/test/mcp.test.ts b/apps/mcp/test/mcp.test.ts new file mode 100644 index 0000000..38a53e9 --- /dev/null +++ b/apps/mcp/test/mcp.test.ts @@ -0,0 +1,69 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { newEffect } from '@oxygen/format'; +import { applyEdits } from '../src/effects'; +import { childEffects, packRoot } from '../src/pack'; + +const starter = `${JSON.stringify(newEffect('test:a'), null, '\t')}\n`; + +describe('applyEdits', () => { + it('applies all edits or none: a failing edit throws, naming it', () => { + expect(() => + applyEdits(starter, [ + { op: 'set', path: 'components/minecraft:emitter_rate_steady/spawn_rate', value: 5 }, + { op: 'remove', path: 'components/minecraft:not_there' }, + ]), + ).toThrow(/edit 2 \(remove particle_effect\/components\/minecraft:not_there\)/); + }); + + it('places a new component in canonical order through the components/ shorthand', () => { + const { text } = applyEdits(starter, [ + { op: 'remove', path: 'components/minecraft:emitter_shape_point' }, + { op: 'set', path: 'components/minecraft:emitter_shape_sphere', value: { radius: 1 } }, + ]); + const keys = Object.keys(JSON.parse(text).particle_effect.components); + expect(keys.indexOf('minecraft:emitter_shape_sphere')).toBe(keys.indexOf('minecraft:emitter_lifetime_looping') + 1); + }); + + it('leaves untouched text alone (comments, number spelling) and reports the change in game terms', () => { + const src = '{\n // keep me\n "format_version": "1.10.0",\n "particle_effect": {\n "components": {\n "minecraft:particle_initial_speed": 1.0,\n "minecraft:emitter_rate_instant": { "num_particles": 3 }\n }\n }\n}\n'; + const { text, diff } = applyEdits(src, [{ op: 'molang', path: 'components/minecraft:emitter_rate_instant/num_particles', expression: '10' }]); + expect(text).toBe(src.replace('"num_particles": 3', '"num_particles": 10')); + expect(diff).toContain('was: 3'); + }); + + it('refuses to edit a file that is not valid JSON', () => { + expect(() => applyEdits('{ "particle_effect": {', [{ op: 'set', path: 'format_version', value: '1.10.0' }])).toThrow(/isn't valid JSON/); + }); +}); + +describe('pack lookup', () => { + const spawner = (id: string, children: string[]) => + JSON.stringify({ particle_effect: { description: { identifier: id }, events: Object.fromEntries(children.map((c, i) => [`e${i}`, { particle_effect: { effect: c, type: 'emitter' } }])) } }); + + it('collects every child an effect reaches, once, and names the missing ones', () => { + const dir = mkdtempSync(join(tmpdir(), 'oxygen-mcp-')); + const index = new Map(); + for (const [id, children] of [ + ['t:b', ['t:c', 't:a']], + ['t:c', ['t:b', 't:gone']], + ] as const) { + const file = join(dir, `${id.slice(2)}.json`); + writeFileSync(file, spawner(id, [...children])); + index.set(id, file); + } + const { effects, missing } = childEffects(spawner('t:a', ['t:b']), index); + expect(Object.keys(effects).sort()).toEqual(['t:b', 't:c']); + expect(missing).toEqual(['t:gone']); + }); + + it('finds the pack root by manifest, else by the particles folder', () => { + const dir = mkdtempSync(join(tmpdir(), 'oxygen-mcp-')); + mkdirSync(join(dir, 'rp/particles/fx'), { recursive: true }); + expect(packRoot(join(dir, 'rp/particles/fx/a.json'))).toBe(join(dir, 'rp')); + writeFileSync(join(dir, 'manifest.json'), '{}'); + expect(packRoot(join(dir, 'rp/particles/fx/a.json'))).toBe(dir); + }); +}); diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 0000000..b003361 --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "types": ["node"], "lib": ["ES2024", "DOM", "DOM.Iterable"] }, + "include": ["src", "test"] +} diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..a88a069 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,71 @@ +# Oxygen for coding agents (MCP) + +Oxygen includes an [MCP](https://modelcontextprotocol.io) server. With it, a coding agent such as Claude Code, +Cursor or Codex can create particle effects, edit them, check them for problems and look at them. It uses the +same engine, analysis and viewport as the editor, so what the agent sees is what you would see in Oxygen, and +what players see in the game. + +## Setting it up + +Build it once from a clone of this repository (see [building](building.md) for the setup): + +```sh +pnpm install +pnpm --filter @oxygen/mcp build +npx playwright install chromium # the preview renders in headless Chromium +``` + +Then add the server to your agent. It talks over stdio: + +```sh +claude mcp add oxygen -- node /path/to/oxygen/apps/mcp/dist/server.cjs +``` + +Other agents take the same command in their MCP settings: + +```json +{ + "mcpServers": { + "oxygen": { "command": "node", "args": ["/path/to/oxygen/apps/mcp/dist/server.cjs"] } + } +} +``` + +Relative file paths are relative to the folder the agent starts the server in, usually your project. + +## What the agent can do + +| Tool | What it does | +| --- | --- | +| `reference` | The particle format and how the game really behaves: every component with its fields, defaults and when the game evaluates them; materials; Molang queries and math; and the [game rules](accuracy.md) Oxygen measured in Minecraft | +| `list_examples` | The bundled examples, to start from | +| `create_effect` | A new effect file from Oxygen's starting effect or a copy of an example under a new identifier | +| `read_effect` | A summary of an effect: components, curves, events and what they spawn, variables and where they're set and read, scenarios, problems | +| `edit_effect` | Edits by JSON path (set, Molang, remove, insert, rename), applied together like an edit in Oxygen: formatting, key order and comments stay as they were. Reports what changed and the problems afterwards | +| `check_effect` | The problems the Problems panel would show | +| `simulate_effect` | Runs the effect and reports, at chosen times, how many particles are alive and drawn, where they are, which emitters and events ran, and runtime problems such as unset variables or the particle cap | +| `preview_effect` | Pictures of the effect at chosen times, by default on one labelled sheet, with the same numbers. Optionally saves the PNGs | + +Previews and simulations play the effect the way `/particle` does unless asked otherwise (`spawn`: `script` or +`entity`), and play it once, like the game. They use a fixed clock and the same random seed every time, so the +same file always gives the same pictures and numbers. The camera frames every particle across the chosen times, +from an angle the agent picks; it can also be placed by hand or taken from a view saved in the file. The ground +grid sits at y = 0 and the small outlined cube is one block. + +## Packs, textures and child effects + +The server finds the resource pack around a file the same way the desktop app does: the nearest folder above it +with a `manifest.json`, or else the folder holding `particles/`. From the pack it reads: + +- **textures**, as PNG. Vanilla particle textures the pack doesn't replace come with Oxygen. A texture that + can't be found draws as the game's magenta and black, and the tools say which one. TGA textures aren't shown. +- **child effects** that events spawn, by identifier, falling back to the bundled examples. Effects copied from + an example that spawns others come with a note: the game needs those children in the pack too. +- **Vibrant Visuals** lighting and texture sets, for previews with `mode: "vv"`. + +## Things to know + +- Pictures come from software rendering in headless Chromium. They show exactly what the editor's viewport + shows, but each one takes a moment; a sheet of four 480 × 360 pictures takes one to two seconds. +- `edit_effect` refuses a file that isn't valid JSON. Fix that by editing the text first. +- The server doesn't talk to Minecraft. To try an effect in the game, use the desktop app's Game menu. diff --git a/docs/building.md b/docs/building.md index 1698aad..cc630fe 100644 --- a/docs/building.md +++ b/docs/building.md @@ -26,6 +26,7 @@ The particle engine and the Molang compiler are Git submodules in `vendor/`. If | Desktop app, from source | `pnpm desktop` | the app in a window | | Desktop installer | `pnpm package:desktop` | `apps/desktop/release/` (Windows installer; dmg and AppImage are set up) | | VS Code extension | `pnpm package:vscode` | `apps/vscode/oxygen-particle-editor-.vsix` | +| MCP server for coding agents | `pnpm --filter @oxygen/mcp build` | `apps/mcp/dist/server.cjs` ([setup](agents.md)) | To try a `.vsix`, install it in VS Code with *Extensions: Install from VSIX…*, or run `code --install-extension apps/vscode/oxygen-particle-editor-.vsix`. @@ -50,6 +51,7 @@ The VS Code and desktop tests can also run against packaged builds: | Path | What's there | | --- | --- | | `apps/web`, `apps/vscode`, `apps/desktop` | The three apps | +| `apps/mcp` | The MCP server for coding agents, and the headless preview page it renders with | | `packages/ui` | The editor itself: panels, fields and the viewport (Svelte 5) | | `packages/format` | Reading and editing particle JSON without disturbing its formatting | | `packages/lang` | The Molang editor: completion, problems, fixes and refactors | diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index cdb73c7..19ad9ac 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -11,6 +11,9 @@ export type { FileRef, Host, OpenedFile } from './host'; export { Workspace } from './state/workspace.svelte'; export { EffectSession } from './state/session.svelte'; export { readShareHash, shareLink } from './lib/share'; +export { ViewportScene } from './viewport/scene'; +export { DEFAULT_GIZMOS, type Gizmos } from './viewport/gizmos'; +export { createMersProvider, createTextureProvider } from './lib/textures'; /** * Mount the Oxygen editor into an element. `mode: 'preview'` mounts only the viewport (for a file edited in diff --git a/packages/ui/src/state/session.svelte.ts b/packages/ui/src/state/session.svelte.ts index 516d9f2..8e434cf 100644 --- a/packages/ui/src/state/session.svelte.ts +++ b/packages/ui/src/state/session.svelte.ts @@ -861,7 +861,8 @@ export class EffectSession { return undefined; } - private refreshStats() { + /** Recount the preview stats and the runtime problems (playback does this a few times a second) */ + refreshStats(): void { const cap = this.capFor(this.spawnMode); let capHit = false; for (const e of this.world.emitters) if (e.pool.count >= e.cap) capHit = true; diff --git a/packages/ui/src/viewport/scene.ts b/packages/ui/src/viewport/scene.ts index 5fbf30e..090f5b5 100644 --- a/packages/ui/src/viewport/scene.ts +++ b/packages/ui/src/viewport/scene.ts @@ -269,7 +269,11 @@ export class ViewportScene { this.controls.lookAtTarget(); } - private frame(dt: number): void { + /** + * Advance the session by `dt` and draw. The animation loop calls this; headless renderers drive it directly, + * with `draw` off for frames nobody looks at. + */ + frame(dt: number, draw = true): void { this.fly(dt); this.controls.update(); this.controls.updateMarker(this.canvas.clientHeight); @@ -278,8 +282,9 @@ export class ViewportScene { if (s) { this.syncWorldCamera(s); s.tick(dt); - this.updateHelpers(s); } + if (!draw) return; + if (s) this.updateHelpers(s); this.grid.visible = this.showGrid; this.ground.mesh.visible = this.gizmos.ground; if (this.scaleBlock) this.scaleBlock.visible = this.gizmos.block; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9980090..9a07bcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,70 @@ importers: specifier: ^8.3.4 version: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15) + apps/mcp: + dependencies: + playwright: + specifier: 1.64.1 + version: 1.64.1 + devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.32.1 + version: 1.32.1(supports-color@7.2.0)(zod@4.6.5) + '@oxygen/effect': + specifier: workspace:* + version: link:../../packages/effect + '@oxygen/format': + specifier: workspace:* + version: link:../../packages/format + '@oxygen/lang': + specifier: workspace:* + version: link:../../packages/lang + '@oxygen/metadata': + specifier: workspace:* + version: link:../../packages/metadata + '@oxygen/ui': + specifier: workspace:* + version: link:../../packages/ui + '@sveltejs/vite-plugin-svelte': + specifier: ^7.3.1 + version: 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)) + '@types/node': + specifier: ^24.0.0 + version: 24.19.2 + '@types/three': + specifier: ^0.186.0 + version: 0.186.0 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 + molangjs: + specifier: workspace:* + version: link:../../vendor/molangjs + svelte: + specifier: ^5.57.2 + version: 5.57.2 + svelte-check: + specifier: ^4.7.7 + version: 4.7.7(picomatch@4.0.7)(svelte@5.57.2)(typescript@6.0.3) + three: + specifier: ^0.186.1 + version: 0.186.1 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15) + wintersky: + specifier: workspace:* + version: link:../../vendor/wintersky + zod: + specifier: ^4.0.0 + version: 4.6.5 + apps/vscode: devDependencies: '@oxygen/effect': @@ -234,16 +298,16 @@ importers: version: link:../../packages/ui '@sveltejs/vite-plugin-svelte': specifier: ^7.3.1 - version: 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) + version: 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)) '@types/vscode': specifier: ^1.95.0 version: 1.140.0 '@vscode/test-electron': specifier: ^3.1.0 - version: 3.1.0 + version: 3.1.0(supports-color@10.2.2) '@vscode/vsce': specifier: ^4.0.0 - version: 4.0.0 + version: 4.0.0(supports-color@10.2.2) esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -261,7 +325,7 @@ importers: version: 6.0.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15) + version: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15) wintersky: specifier: workspace:* version: link:../../vendor/wintersky @@ -470,13 +534,13 @@ importers: devDependencies: tsup: specifier: ^9.0.0 - version: 9.0.0(postcss@8.5.30)(tsx@4.23.15)(typescript@6.0.3) + version: 9.0.0(jiti@2.7.0)(postcss@8.5.30)(tsx@4.23.15)(typescript@6.0.3) typescript: specifier: 6.0.3 version: 6.0.3 vitest: specifier: ^5.0.5 - version: 5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) + version: 5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)) vendor/wintersky: dependencies: @@ -495,13 +559,13 @@ importers: version: 0.186.1 tsup: specifier: ^9.0.0 - version: 9.0.0(postcss@8.5.30)(tsx@4.23.15)(typescript@6.0.3) + version: 9.0.0(jiti@2.7.0)(postcss@8.5.30)(tsx@4.23.15)(typescript@6.0.3) typescript: specifier: 6.0.3 version: 6.0.3 vitest: specifier: ^5.0.5 - version: 5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) + version: 5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)) packages: @@ -851,6 +915,12 @@ packages: '@fontsource-variable/recursive@5.3.0': resolution: {integrity: sha512-4B4/lWCVeZgdbpJdIIU1sOVO/ECjjM673ohuxvyQ6uCK41TPlGzKRNokKY84noUgGYnpwc//MW4k2KrOMY19jQ==} + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1059,6 +1129,16 @@ packages: '@marijn/find-cluster-break@1.0.4': resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/keyring-darwin-arm64@1.3.0': resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} engines: {node: '>= 10'} @@ -1612,6 +1692,10 @@ packages: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn@8.19.0: resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} @@ -1621,6 +1705,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -1705,6 +1792,10 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -1749,6 +1840,10 @@ packages: peerDependencies: esbuild: '>=0.18' + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} engines: {node: '>=6.0.0'} @@ -1877,6 +1972,26 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1884,6 +1999,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + crelt@1.0.7: resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} @@ -1939,6 +2058,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1980,6 +2103,9 @@ packages: resolution: {integrity: sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==} engines: {ecmascript: '>= es5', node: '>=4'} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -2011,6 +2137,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2063,6 +2193,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2081,6 +2214,18 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -2088,6 +2233,16 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2109,6 +2264,10 @@ packages: filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -2116,6 +2275,14 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -2213,6 +2380,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -2220,6 +2391,10 @@ packages: http-cache-semantics@4.3.0: resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -2232,6 +2407,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -2242,6 +2421,14 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2264,6 +2451,9 @@ packages: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -2314,6 +2504,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2328,6 +2521,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -2514,6 +2710,14 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + meshoptimizer@1.1.1: resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} @@ -2521,10 +2725,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -2602,6 +2814,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + node-abi@4.38.0: resolution: {integrity: sha512-suSdznUPfabdaeLiNERA0LHod14RhcEn8wMPUZEQ6bKvajUCZ+aizY4pIvlLU+gImeLKp2bFDHhOc9yonISsXw==} engines: {node: '>=22.12.0'} @@ -2646,6 +2862,10 @@ packages: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2675,6 +2895,10 @@ packages: parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2683,6 +2907,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2704,6 +2931,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -2774,6 +3005,10 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -2792,6 +3027,14 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + read-binary-file-arch@1.0.6: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true @@ -2860,6 +3103,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -2874,6 +3121,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-filename@1.6.4: resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} @@ -2902,13 +3152,24 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.5: resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} @@ -2971,6 +3232,10 @@ packages: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} @@ -3079,6 +3344,10 @@ packages: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3124,6 +3393,10 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-rest-client@1.8.11: resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} @@ -3164,6 +3437,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} @@ -3179,6 +3456,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + version-range@4.15.0: resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} engines: {node: '>=4'} @@ -3392,6 +3673,11 @@ packages: zimmerframe@1.1.5: resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} @@ -3401,36 +3687,36 @@ snapshots: dependencies: tslib: 2.8.1 - '@azure/core-auth@1.11.0': + '@azure/core-auth@1.11.0(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-util': 1.14.0 + '@azure/core-util': 1.14.0(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/core-client@1.11.1': + '@azure/core-client@1.11.1(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-auth': 1.11.0(supports-color@10.2.2) + '@azure/core-rest-pipeline': 1.25.0(supports-color@10.2.2) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@10.2.2) + '@azure/logger': 1.4.0(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color '@azure/core-process@1.0.0': {} - '@azure/core-rest-pipeline@1.25.0': + '@azure/core-rest-pipeline@1.25.0(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 + '@azure/core-auth': 1.11.0(supports-color@10.2.2) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 - '@typespec/ts-http-runtime': 0.3.9 + '@azure/core-util': 1.14.0(supports-color@10.2.2) + '@azure/logger': 1.4.0(supports-color@10.2.2) + '@typespec/ts-http-runtime': 0.3.9(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3439,24 +3725,24 @@ snapshots: dependencies: tslib: 2.8.1 - '@azure/core-util@1.14.0': + '@azure/core-util@1.14.0(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@typespec/ts-http-runtime': 0.3.9 + '@typespec/ts-http-runtime': 0.3.9(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/identity@4.14.0': + '@azure/identity@4.14.0(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.1 + '@azure/core-auth': 1.11.0(supports-color@10.2.2) + '@azure/core-client': 1.11.1(supports-color@10.2.2) '@azure/core-process': 1.0.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@10.2.2) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@10.2.2) + '@azure/logger': 1.4.0(supports-color@10.2.2) '@azure/msal-browser': 5.25.1 '@azure/msal-node': 6.0.1 open: 11.0.4 @@ -3464,9 +3750,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/logger@1.4.0': + '@azure/logger@1.4.0(supports-color@10.2.2)': dependencies: - '@typespec/ts-http-runtime': 0.3.9 + '@typespec/ts-http-runtime': 0.3.9(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3777,6 +4063,10 @@ snapshots: '@fontsource-variable/recursive@5.3.0': {} + '@hono/node-server@2.1.5(hono@4.13.13)': + dependencies: + hono: 4.13.13 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.5': @@ -3942,6 +4232,28 @@ snapshots: '@marijn/find-cluster-break@1.0.4': {} + '@modelcontextprotocol/sdk@1.32.1(supports-color@7.2.0)(zod@4.6.5)': + dependencies: + '@hono/node-server': 2.1.5(hono@4.13.13) + ajv: 8.20.0 + ajv-formats: 3.0.1 + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0(supports-color@7.2.0) + express-rate-limit: 8.7.1(express@5.3.0(supports-color@7.2.0))(supports-color@7.2.0) + hono: 4.13.13 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + transitivePeerDependencies: + - supports-color + '@napi-rs/keyring-darwin-arm64@1.3.0': optional: true @@ -4162,11 +4474,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.64.7': optional: true - '@secretlint/core@10.2.2': + '@secretlint/core@10.2.2(supports-color@10.2.2)': dependencies: '@secretlint/profiler': 10.2.2 '@secretlint/types': 10.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) structured-source: 4.0.0 transitivePeerDependencies: - supports-color @@ -4207,15 +4519,6 @@ snapshots: vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15) vitefu: 1.1.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)) - '@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15))': - dependencies: - deepmerge: 4.3.1 - magic-string: 1.4.3 - obug: 2.2.1 - svelte: 5.57.2 - vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15) - vitefu: 1.1.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) - '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -4281,10 +4584,10 @@ snapshots: dependencies: '@types/node': 24.19.2 - '@typespec/ts-http-runtime@0.3.9': + '@typespec/ts-http-runtime@0.3.9(supports-color@10.2.2)': dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -4298,21 +4601,12 @@ snapshots: optionalDependencies: vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15) - '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15))': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.5 - estree-walker: 3.0.3 - magic-string: 1.4.3 - optionalDependencies: - vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15) - '@vitest/spy@5.0.5': {} - '@vscode/test-electron@3.1.0': + '@vscode/test-electron@3.1.0(supports-color@10.2.2)': dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) jszip: 3.10.2 ora: 8.2.0 semver: 7.8.5 @@ -4358,11 +4652,11 @@ snapshots: '@vscode/vsce-sign-win32-arm64': 2.0.6 '@vscode/vsce-sign-win32-x64': 2.0.6 - '@vscode/vsce@4.0.0': + '@vscode/vsce@4.0.0(supports-color@10.2.2)': dependencies: - '@azure/identity': 4.14.0 + '@azure/identity': 4.14.0(supports-color@10.2.2) '@napi-rs/keyring': 1.3.0 - '@secretlint/core': 10.2.2 + '@secretlint/core': 10.2.2(supports-color@10.2.2) '@secretlint/secretlint-rule-no-dotenv': 10.2.2 '@secretlint/secretlint-rule-preset-recommend': 10.2.2 '@secretlint/source-creator': 10.2.2 @@ -4393,10 +4687,19 @@ snapshots: abbrev@4.0.0: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + acorn@8.19.0: {} agent-base@7.1.4: {} + ajv-formats@3.0.1: + dependencies: + ajv: 8.20.0 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -4505,6 +4808,20 @@ snapshots: bluebird@3.7.2: {} + body-parser@2.3.0(supports-color@7.2.0): + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3(supports-color@7.2.0) + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.1 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolean@3.2.0: optional: true @@ -4564,6 +4881,8 @@ snapshots: esbuild: 0.28.2 load-tsconfig: 0.2.5 + bytes@3.1.2: {} + bytestreamjs@2.0.1: {} cac@7.1.0: {} @@ -4674,10 +4993,25 @@ snapshots: consola@3.4.2: {} + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + crelt@1.0.7: {} cross-dirname@0.1.0: @@ -4689,9 +5023,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 debug@4.4.3(supports-color@7.2.0): dependencies: @@ -4732,6 +5068,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + detect-libc@2.1.2: {} detect-node@2.1.0: @@ -4780,6 +5118,8 @@ snapshots: dependencies: version-range: 4.15.0 + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -4847,6 +5187,8 @@ snapshots: emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -4912,6 +5254,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: optional: true @@ -4925,10 +5269,59 @@ snapshots: dependencies: '@types/estree': 1.0.9 + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + expect-type@1.4.0: {} exponential-backoff@3.1.3: {} + express-rate-limit@8.7.1(express@5.3.0(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + express: 5.3.0(supports-color@7.2.0) + ip-address: 10.7.4 + transitivePeerDependencies: + - supports-color + + express@5.3.0(supports-color@7.2.0): + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0(supports-color@7.2.0) + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@7.2.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1(supports-color@7.2.0) + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.1 + range-parser: 1.3.0 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-uri@3.1.8: {} @@ -4943,6 +5336,17 @@ snapshots: dependencies: minimatch: 5.1.9 + finalhandler@2.1.1(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -4957,6 +5361,10 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -5087,16 +5495,26 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.13.13: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 http-cache-semantics@4.3.0: {} - http-proxy-agent@7.0.2: + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5112,10 +5530,10 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5126,6 +5544,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + immediate@3.0.6: {} inflight@1.0.6: @@ -5135,6 +5557,10 @@ snapshots: inherits@2.0.4: {} + ip-address@10.7.4: {} + + ipaddr.js@1.9.1: {} + is-docker@3.0.0: {} is-fullwidth-code-point@3.0.0: {} @@ -5147,6 +5573,8 @@ snapshots: is-interactive@2.0.0: {} + is-promise@4.0.0: {} + is-reference@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -5185,6 +5613,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.12: {} + joycon@3.1.1: {} js-yaml@4.3.2: @@ -5195,6 +5625,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stringify-safe@5.0.1: optional: true @@ -5356,14 +5788,24 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + meshoptimizer@1.1.1: {} mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@2.6.0: {} @@ -5436,6 +5878,10 @@ snapshots: nanoid@3.3.20: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + node-abi@4.38.0: dependencies: semver: 7.8.5 @@ -5476,6 +5922,10 @@ snapshots: obug@3.0.0: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5517,10 +5967,14 @@ snapshots: dependencies: entities: 8.1.0 + parseurl@1.3.3: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pe-library@0.4.1: {} @@ -5533,6 +5987,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -5560,10 +6016,11 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - postcss-load-config@6.0.1(postcss@8.5.30)(tsx@4.23.15): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.30)(tsx@4.23.15): dependencies: lilconfig: 3.1.3 optionalDependencies: + jiti: 2.7.0 postcss: 8.5.30 tsx: 4.23.15 @@ -5597,6 +6054,11 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -5615,6 +6077,15 @@ snapshots: quick-lru@5.1.1: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + read-binary-file-arch@1.0.6(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -5729,6 +6200,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.64.7 fsevents: 2.3.3 + router@2.2.0(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} sade@1.8.1: @@ -5739,6 +6220,8 @@ snapshots: safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} + sanitize-filename@1.6.4: dependencies: truncate-utf8-bytes: 1.0.2 @@ -5756,13 +6239,40 @@ snapshots: semver@7.8.5: {} + send@1.2.1(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 optional: true + serve-static@2.2.1(supports-color@7.2.0): + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} + sharp@0.35.5(@types/node@24.19.2): dependencies: '@img/colour': 1.1.0 @@ -5852,6 +6362,8 @@ snapshots: stat-mode@1.0.0: {} + statuses@2.0.2: {} + std-env@4.3.0: {} stdin-discarder@0.2.2: {} @@ -5992,6 +6504,8 @@ snapshots: tmp@0.2.7: {} + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} truncate-utf8-bytes@1.0.2: @@ -6002,7 +6516,7 @@ snapshots: tslib@2.8.1: {} - tsup@9.0.0(postcss@8.5.30)(tsx@4.23.15)(typescript@6.0.3): + tsup@9.0.0(jiti@2.7.0)(postcss@8.5.30)(tsx@4.23.15)(typescript@6.0.3): dependencies: bundle-require: 5.1.0(esbuild@0.28.2) cac: 7.1.0 @@ -6013,7 +6527,7 @@ snapshots: joycon: 3.1.1 obug: 3.0.0 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.30)(tsx@4.23.15) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.30)(tsx@4.23.15) resolve-from: 5.0.0 rollup: 4.64.7 source-map-js: 1.2.2 @@ -6040,6 +6554,12 @@ snapshots: type-fest@0.13.1: optional: true + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typed-rest-client@1.8.11: dependencies: qs: 6.16.1 @@ -6069,6 +6589,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + unzipper@0.12.5: dependencies: bluebird: 3.7.2 @@ -6085,6 +6607,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + version-range@4.15.0: {} vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15): @@ -6101,27 +6625,10 @@ snapshots: jiti: 2.7.0 tsx: 4.23.15 - vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15): - dependencies: - lightningcss: 1.33.0 - picomatch: 4.0.7 - postcss: 8.5.30 - rolldown: 1.2.14 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.19.2 - esbuild: 0.28.2 - fsevents: 2.3.3 - tsx: 4.23.15 - vitefu@1.1.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)): optionalDependencies: vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15) - vitefu@1.1.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)): - optionalDependencies: - vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15) - vitest@5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)): dependencies: '@types/chai': 5.2.3 @@ -6143,27 +6650,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)): - dependencies: - '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) - chai: 6.3.0 - es-module-lexer: 2.3.2 - expect-type: 1.4.0 - magic-string: 1.4.3 - obug: 3.0.0 - picomatch: 4.0.7 - std-env: 4.3.0 - tinybench: 6.2.2 - tinyexec: 1.3.1 - tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15) - why-is-node-running: 3.2.1 - optionalDependencies: - '@types/node': 24.19.2 - transitivePeerDependencies: - - msw - w3c-keyname@2.2.8: {} webcrypto-core@1.9.2: @@ -6265,4 +6751,8 @@ snapshots: zimmerframe@1.1.5: {} + zod-to-json-schema@3.25.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + zod@4.6.5: {} diff --git a/vitest.config.ts b/vitest.config.ts index 76ea42d..892552e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'vendor/molangjs/test/**/*.test.ts', 'vendor/wintersky/test/**/*.test.ts', 'packages/*/test/**/*.test.ts', + 'apps/mcp/test/**/*.test.ts', ], environment: 'node', }, From fab1eadab6eeeea2f36649cc990e3bdd1c2ae5b0 Mon Sep 17 00:00:00 2001 From: avtara k Date: Sun, 18 Oct 2026 17:08:04 +0700 Subject: [PATCH 2/2] MCP: write_effect, scene effects, open_in_editor; fix paused redraws Engine (vendor/wintersky, bumped): redrawing a paused frame no longer re-runs its render writes. Effects that lay particles out with a render-pass counter (spiral galaxy, aurora, lightning, magic circle, ...) drifted to wrong colours and sizes whenever the preview was paused, in the editor and in preview_effect, which draws paused frames until textures settle. MCP server: - write_effect replaces a file's whole text (or creates it); refuses invalid JSON, reports the semantic diff or the new file's summary, supports dry_run - simulate_effect / preview_effect take `others` (identifiers and positions) to play other effects in the same scene, through the session's existing scene extras; without it, the extras the file's scene keeps now reach the preview too (they weren't sent before) - preview_effect `return_images: false` (needs save_to) keeps big pictures out of the reply - open_in_editor opens the file in the desktop app: OXYGEN_DESKTOP, else the repository's desktop build, else an installed app; ELECTRON_RUN_AS_NODE is cleared for the child Desktop: one instance (requestSingleInstanceLock). Starting Oxygen again with files, or macOS open-file, hands them to the running window, which opens them or focuses their tabs; files arriving before the renderer asks for its start files are queued. tools/desktop-test.mjs checks the hand-over. Docs: agents.md (new tools and options), accuracy.md (per-particle values in the render pass), building.md (Electron cache, single instance), CHANGELOG. Signed-off-by: avtara k --- CHANGELOG.md | 9 +++- apps/desktop/renderer/main.ts | 11 +++-- apps/desktop/src/api.ts | 2 + apps/desktop/src/main.ts | 78 +++++++++++++++++++++++------- apps/desktop/src/preload.ts | 1 + apps/mcp/preview/main.ts | 2 + apps/mcp/src/desktop.ts | 59 +++++++++++++++++++++++ apps/mcp/src/effects.ts | 13 +++++ apps/mcp/src/pack.ts | 9 ++-- apps/mcp/src/protocol.ts | 2 + apps/mcp/src/server.ts | 91 +++++++++++++++++++++++++++++++---- docs/accuracy.md | 6 +++ docs/agents.md | 17 ++++++- docs/building.md | 5 ++ tools/desktop-test.mjs | 22 ++++++++- vendor/wintersky | 2 +- 16 files changed, 288 insertions(+), 41 deletions(-) create mode 100644 apps/mcp/src/desktop.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b059b9..b8aabca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,18 @@ - The web app is online at [oxygen.hydrogen.exposed](https://oxygen.hydrogen.exposed). - An MCP server for coding agents (`apps/mcp`): agents can look up the particle format and the game's measured behaviour, create effects from the starting effect or an example, edit them by JSON path without disturbing - formatting, check them, simulate them and render previews with Oxygen's viewport. See + formatting or rewrite them whole, check them, simulate them and render previews with Oxygen's viewport, with + other effects playing alongside, and open them in the desktop app for you. See [Oxygen for coding agents](https://github.com/dev-hydrogen/oxygen/blob/main/docs/agents.md). +- The desktop app keeps one window: opening a file while it runs (from the command line, a file manager or an + agent) opens it there, or brings its tab forward. ### Fixed +- A paused preview drew effects that count particles in their render expression (spiral galaxy, aurora, + lightning, magic circle and others) wrong: every redraw advanced the count, so colours and sizes drifted. + Redrawing a paused frame, or one scrubbed to, now shows it as it played. + ## Oxygen 0.1.0 The first release of Oxygen, a ground-up rewrite of [Snowstorm](https://github.com/JannisX11/snowstorm) 3.2.2. diff --git a/apps/desktop/renderer/main.ts b/apps/desktop/renderer/main.ts index 156a5f1..47c1083 100644 --- a/apps/desktop/renderer/main.ts +++ b/apps/desktop/renderer/main.ts @@ -1,5 +1,5 @@ import { mountOxygen, type FileRef, type Host } from '@oxygen/ui'; -import type { DesktopApi } from '../src/api'; +import type { DesktopApi, DesktopFile } from '../src/api'; /** Oxygen in Electron: native files, pack folders, outside-change sync and the game link */ const desk = (window as unknown as { oxygenDesktop: DesktopApi }).oxygenDesktop; @@ -97,10 +97,13 @@ desk.onMenu((cmd) => { else if (cmd === 'openPack') void w.openPack(); }); -// reopen what was open last time (or files passed on the command line); otherwise the start screen -void desk.openedAtStart().then((files) => { +// reopen what was open last time (or files passed on the command line); otherwise the start screen. Files opened +// from outside later arrive the same way; one already open gets its tab focused. +const openAll = (files: DesktopFile[]) => { for (const f of files) { desk.watch(f.path); w.open({ file: { name: f.name, id: f.path }, text: f.text }); } -}); +}; +desk.onOpenRequested(openAll); +void desk.openedAtStart().then(openAll); diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index fe6e2cd..7508deb 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -44,6 +44,8 @@ export interface DesktopApi { setOpenFiles(paths: string[]): void; watch(path: string): void; onExternalChange(cb: (path: string, text: string) => void): void; + /** files opened from outside while Oxygen runs (Oxygen started again with them, or handed over by the OS) */ + onOpenRequested(cb: (files: DesktopFile[]) => void): void; onMenu(cb: (command: string) => void): void; settings(): Promise>; saveSettings(s: Record): void; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 518cfb4..309ea0d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -47,6 +47,37 @@ function readDesktopFile(path: string): DesktopFile { return { path, name: basename(path), text: readFileSync(path, 'utf8') }; } +/** Particle files named on a command line, resolved against the folder it ran in */ +function filesFromArgs(argv: string[], cwd: string): string[] { + return argv + .slice(app.isPackaged ? 1 : 2) + .filter((a) => a.endsWith('.json')) + .map((a) => resolve(cwd, a)) + .filter((a) => existsSync(a)); +} + +// ---- files opened from outside while Oxygen runs (a file manager, the command line, a coding agent) +/** files that arrived before the window asked for its start files */ +let queued: string[] = []; +let rendererStarted = false; +function openRequested(paths: string[]) { + if (!win) { + queued.push(...paths); + if (app.isReady()) createWindow(); + return; + } + // a window still loading picks the files up with its start files, and shows itself when ready + if (!rendererStarted) { + queued.push(...paths); + return; + } + if (paths.length) win.webContents.send('openRequested', paths.map(readDesktopFile)); + if (win.isMinimized()) win.restore(); + win.show(); + if (process.platform === 'darwin') app.focus({ steal: true }); + else win.focus(); +} + // ---- file watching: outside changes flow into the open document; our own saves are ignored const watchers = new Map(); const lastText = new Map(); @@ -247,10 +278,12 @@ function registerIpc() { .map((f) => ({ name: f.replace('.particle.json', ''), text: readFileSync(join(dir, f), 'utf8') })); }); ipcMain.handle('openedAtStart', () => { - const args = process.argv.slice(app.isPackaged ? 1 : 2).filter((a) => a.endsWith('.json') && existsSync(a)); + rendererStarted = true; + const args = [...filesFromArgs(process.argv, process.cwd()), ...queued]; + queued = []; // what was open when Oxygen last closed (closed tabs stay closed) const last = ((settings.openFiles as string[]) ?? []).filter((p) => existsSync(p)); - return [...new Set([...args.map((a) => resolve(a)), ...last])].map(readDesktopFile); + return [...new Set([...args, ...last])].map(readDesktopFile); }); ipcMain.on('watch', (_e, path: string) => { watchFile(path); @@ -318,6 +351,7 @@ function buildMenu() { } function createWindow() { + rendererStarted = false; win = new BrowserWindow({ width: 1480, height: 940, @@ -339,20 +373,30 @@ function createWindow() { win.on('closed', () => (win = null)); } -app.whenReady().then(() => { - loadSettings(); - registerIpc(); - buildMenu(); - // a taken port moves on to the next free one; the Game menu shows which (or why none) - void connect.start().then(() => win?.webContents.send('gameStatus', gameStatus())); - setInterval(pollContentLog, 1500); - createWindow(); - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow(); +// one Oxygen at a time: started again (with files or not), it hands its command line to the running app and quits +if (!app.requestSingleInstanceLock()) app.quit(); +else { + app.on('second-instance', (_e, argv, cwd) => openRequested(filesFromArgs(argv, cwd))); + // macOS hands files over by event (Finder, `open -a Oxygen file`), at launch before the app is ready too + app.on('open-file', (e, path) => { + e.preventDefault(); + openRequested([path]); + }); + app.whenReady().then(() => { + loadSettings(); + registerIpc(); + buildMenu(); + // a taken port moves on to the next free one; the Game menu shows which (or why none) + void connect.start().then(() => win?.webContents.send('gameStatus', gameStatus())); + setInterval(pollContentLog, 1500); + if (!win) createWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); }); -}); -app.on('window-all-closed', () => { - connect.stop(); - if (process.platform !== 'darwin') app.quit(); -}); + app.on('window-all-closed', () => { + connect.stop(); + if (process.platform !== 'darwin') app.quit(); + }); +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index fab56e6..b48d7de 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -23,6 +23,7 @@ const api: DesktopApi = { setOpenFiles: (paths) => ipcRenderer.send('openSet', paths), watch: (path) => ipcRenderer.send('watch', path), onExternalChange: (cb) => on('externalChange', cb), + onOpenRequested: (cb) => on('openRequested', cb), onMenu: (cb) => on('menu', cb), settings: () => ipcRenderer.invoke('settings'), saveSettings: (s) => ipcRenderer.send('saveSettings', s), diff --git a/apps/mcp/preview/main.ts b/apps/mcp/preview/main.ts index b65c192..af83ee3 100644 --- a/apps/mcp/preview/main.ts +++ b/apps/mcp/preview/main.ts @@ -151,6 +151,8 @@ async function run(req: RunRequest): Promise { session.setParentInputs(req.parents); if (req.timeOfDay !== null) session.timeOfDay = req.timeOfDay; if (req.spawn && session.spawnMode !== req.spawn) session.setSpawnMode(req.spawn); + // every play starts with a restart, which spawns these too + if (req.extras) session.setScene({ extras: req.extras.length ? req.extras : undefined }, false); session.loopPreview = req.repeat; await new Workspace(host).loadPackVisuals(session); diff --git a/apps/mcp/src/desktop.ts b/apps/mcp/src/desktop.ts new file mode 100644 index 0000000..4564808 --- /dev/null +++ b/apps/mcp/src/desktop.ts @@ -0,0 +1,59 @@ +// Starting the Oxygen desktop app with a file. The app keeps one instance: started again, it hands the file to +// the window already open, which opens it in a tab (or focuses the tab it has). +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +interface Launch { + command: string; + args: string[]; + /** how Oxygen was found, for the reply */ + via: string; +} + +/** The desktop app next to this server when it runs from a clone of the repository (apps/mcp/dist → apps/desktop) */ +function repositoryBuild(file: string): Launch | null { + const dir = resolve(__dirname, '../../desktop'); + if (!existsSync(join(dir, 'dist', 'main.cjs'))) return null; + try { + // the electron package's main is the path of its binary; it throws when the binary was never downloaded + const electron = createRequire(join(dir, 'package.json'))('electron') as unknown; + if (typeof electron !== 'string' || !existsSync(electron)) return null; + return { command: electron, args: [dir, file], via: `the desktop build in ${dir}` }; + } catch { + return null; + } +} + +/** How to start Oxygen with `file`: OXYGEN_DESKTOP, the repository's own build, then an installed app */ +export function desktopLaunch(file: string): Launch | null { + const configured = process.env.OXYGEN_DESKTOP; + if (configured) return { command: configured, args: [file], via: `OXYGEN_DESKTOP (${configured})` }; + const dev = repositoryBuild(file); + if (dev) return dev; + if (process.platform === 'darwin' && ['/Applications', join(homedir(), 'Applications')].some((d) => existsSync(join(d, 'Oxygen.app')))) { + return { command: 'open', args: ['-a', 'Oxygen', file], via: 'the installed Oxygen.app' }; + } + if (process.platform === 'win32' && process.env.LOCALAPPDATA) { + // the installer's default per-user location + const exe = join(process.env.LOCALAPPDATA, 'Programs', 'Oxygen', 'Oxygen.exe'); + if (existsSync(exe)) return { command: exe, args: [file], via: exe }; + } + return null; +} + +/** Start the process detached (it outlives this server); rejects when it can't be started at all */ +export function launchDetached(l: Launch): Promise { + const { promise, resolve: done, reject } = Promise.withResolvers(); + // agents built on Electron (VS Code, Cursor) start MCP servers with ELECTRON_RUN_AS_NODE, which would make the + // app's Electron run as plain Node + const child = spawn(l.command, l.args, { detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined } }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + done(); + }); + return promise; +} diff --git a/apps/mcp/src/effects.ts b/apps/mcp/src/effects.ts index a6179b0..973aace 100644 --- a/apps/mcp/src/effects.ts +++ b/apps/mcp/src/effects.ts @@ -178,6 +178,19 @@ export function applyEdits(text: string, ops: EditOp[]): { text: string; diff: s return { text: doc.text, diff: formatDiff(semanticDiff(before, doc.value), 'before', 'after') }; } +/** + * Check a whole new text for a file the way applyEdits checks an edit, and describe the change in the game's terms + * (empty for a new file). Throws when the text isn't valid JSON, so nothing broken gets written. + */ +export function rewriteDiff(before: string | null, after: string): string { + const doc = new JsonDocument(after); + if (doc.problems.length) throw new Error(`The text isn't valid JSON (${doc.problems[0].message} at offset ${doc.problems[0].offset}); nothing was written.`); + if (before === null) return ''; + const old = new JsonDocument(before); + // a file that didn't parse before can't be compared field by field + return old.problems.length ? "The file before wasn't valid JSON: no field-by-field comparison." : formatDiff(semanticDiff(old.value, doc.value), 'before', 'after'); +} + /** * Text for a new effect: Oxygen's starting effect, or a copy of a bundled example under a new identifier. * `children` lists the effects the example spawns, which come from the examples unless the pack has them. diff --git a/apps/mcp/src/pack.ts b/apps/mcp/src/pack.ts index 2ca50ae..a155bed 100644 --- a/apps/mcp/src/pack.ts +++ b/apps/mcp/src/pack.ts @@ -87,13 +87,14 @@ export function effectIndex(root: string | null): Map { } /** - * Every effect the events of `text` reach, directly or through other children, as text by identifier. The effect - * itself is left out: a child spawning it back plays the text being previewed, not the file on disk. + * Every effect the events of `text` reach, directly or through other children, plus the effects in `also` (other + * effects playing in the scene) and what they reach, as text by identifier. The effect itself is left out: a child + * spawning it back plays the text being previewed, not the file on disk. */ -export function childEffects(text: string, index: Map): { effects: Record; missing: string[] } { +export function childEffects(text: string, index: Map, also: readonly string[] = []): { effects: Record; missing: string[] } { const effects: Record = {}; const missing: string[] = []; - const pending: string[] = []; + const pending: string[] = [...also]; const queue = (json: unknown) => pending.push(...(spawnsOf(json)?.spawns ?? []).map((s) => s.effect)); const json = parseJsonc(text, [], { allowTrailingComma: true }); const root = spawnsOf(json)?.identifier; diff --git a/apps/mcp/src/protocol.ts b/apps/mcp/src/protocol.ts index 54794f0..99cb1e7 100644 --- a/apps/mcp/src/protocol.ts +++ b/apps/mcp/src/protocol.ts @@ -39,6 +39,8 @@ export interface RunRequest { timeOfDay: number | null; /** what other effects' pre-effects set for this one (variable → their identifiers) */ parents: Record; + /** other effects playing in the scene, by identifier (their text is in `effects`); null keeps the file's own scene */ + extras: { effect: string; position: Vec3 }[] | null; } export interface SampleStats { diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 1a868bd..c4231c2 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -5,11 +5,13 @@ import { basename, dirname, extname, join, resolve } from 'node:path'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { readMeta } from '@oxygen/format'; import { z } from 'zod'; import { PreviewBrowser } from './browser'; -import { analyze, applyEdits, formatProblems, load, newEffectText, summarize, type EditOp, type SpawnMode } from './effects'; +import { desktopLaunch, launchDetached } from './desktop'; +import { analyze, applyEdits, formatProblems, load, newEffectText, rewriteDiff, summarize, type EditOp, type SpawnMode } from './effects'; import { BUNDLED, childEffects, effectIndex, examples, packRoot, textureFile } from './pack'; -import type { CameraRequest, RunRequest, RunResult } from './protocol'; +import type { CameraRequest, RunRequest, RunResult, Vec3 } from './protocol'; import { reference } from './reference'; const VERSION = '0.1.1'; @@ -21,6 +23,16 @@ const spawnSchema = z .optional() .describe("How the effect is started: 'command' (/particle, the default), 'script' (Script API spawnParticle) or 'entity' (attached to an entity, which loops looping emitters). The file's active scenario can set it too."); const pathSchema = z.string().describe('Path to the .particle.json / particles/*.json file (absolute, or relative to the working directory)'); +const othersSchema = z + .array( + z.object({ + effect: z.string().describe('Identifier, looked up in the pack, then the bundled examples'), + position: z.array(z.number()).length(3).optional().describe('Where it plays (default: 3 blocks apart along +X)'), + }), + ) + .max(8) + .optional() + .describe("Other effects playing in the same scene, like the Scene panel's \"Other effects\". Default: the ones the file's scene keeps; [] for none"); /** Run a tool body, turning a thrown error into a tool error the agent can read */ async function tool(work: () => Promise | CallToolResult): Promise { @@ -47,11 +59,15 @@ function sampleTimes(times: number[] | undefined, duration: number, samples: num return out; } -function runRequest(path: string, o: { times: number[]; spawn?: SpawnMode; repeat: boolean; timeOfDay?: number; render: RunRequest['render'] }): { request: RunRequest; missingChildren: string[] } { +function runRequest(path: string, o: { times: number[]; spawn?: SpawnMode; repeat: boolean; timeOfDay?: number; render: RunRequest['render']; others?: { effect: string; position?: number[] }[] }): { request: RunRequest; missingChildren: string[] } { const e = load(path, o.spawn); - const { effects, missing } = childEffects(e.doc.text, effectIndex(e.root)); + const extras = o.others?.map((x, i) => ({ effect: x.effect, position: (x.position ?? [3 * (i + 1), 0, 0]) as Vec3 })) ?? null; + // what plays besides the effect: the effects asked for, else the ones the file's scene keeps + const saved = readMeta(e.doc).scene?.extras; + const sceneIds = extras ? extras.map((x) => x.effect) : Array.isArray(saved) ? saved.flatMap((x: { effect?: unknown } | null) => (typeof x?.effect === 'string' ? [x.effect] : [])) : []; + const { effects, missing } = childEffects(e.doc.text, effectIndex(e.root), sceneIds); return { - request: { text: e.doc.text, file: { name: basename(path), id: path }, effects, engine: e.engine, times: o.times, spawn: o.spawn ?? null, repeat: o.repeat, render: o.render, timeOfDay: o.timeOfDay ?? null, parents: e.parents }, + request: { text: e.doc.text, file: { name: basename(path), id: path }, effects, engine: e.engine, times: o.times, spawn: o.spawn ?? null, repeat: o.repeat, render: o.render, timeOfDay: o.timeOfDay ?? null, parents: e.parents, extras }, missingChildren: missing, }; } @@ -71,7 +87,7 @@ function report(r: RunResult & { pageErrors: string[] }, path: string, missingCh lines.push(`- ${em.effect}: ${em.count} emitter${em.count === 1 ? '' : 's'}, from ${em.firstStart} s${em.lastEnd !== null ? ` to ${em.lastEnd} s` : ', still running'}${evs.length ? `; events ${evs.join(', ')}` : ''}`); } } - if (missingChildren.length) lines.push('', `Child effects not found in the pack or the examples (they don't spawn): ${missingChildren.join(', ')}`); + if (missingChildren.length) lines.push('', `Effects not found in the pack or the examples (they don't play): ${missingChildren.join(', ')}`); if (r.missingTextures.length) { const root = packRoot(path); const notes = r.missingTextures.map((t) => `${t}${textureFile(root, t).tga ? ' (only a TGA exists; the preview reads PNG)' : ''}`); @@ -87,8 +103,9 @@ const server = new McpServer({ name: 'oxygen', version: VERSION }, { 'Oxygen is a Minecraft Bedrock particle editor whose engine was checked against the game. Use these tools to author particle effects (JSON files under a resource pack\'s particles/ folder):', '1. reference (topic "components", a component id, "game-rules", "queries") to learn the format and the game\'s real behaviour.', '2. create_effect to start from Oxygen\'s template or a bundled example (list_examples).', - '3. edit_effect for formatting-preserving edits by JSON path; read_effect / check_effect to see structure and problems.', + '3. edit_effect for formatting-preserving edits by JSON path, write_effect to replace the whole text; read_effect / check_effect to see structure and problems.', '4. simulate_effect for numbers over time (particle counts, bounds, events); preview_effect to see it rendered.', + '5. open_in_editor to show the effect to the user in the Oxygen desktop app, which follows later edits live.', 'Paths in edits look like components/minecraft:particle_appearance_billboard/size or events/pop/particle_effect/effect.', ].join('\n'), }); @@ -208,6 +225,35 @@ server.registerTool( }), ); +server.registerTool( + 'write_effect', + { + title: 'Write a particle effect', + description: + "Write a particle file's whole text: a new file, or a rewrite where edit_effect would need a long list of edits. The text replaces the file as given (edit_effect keeps the file's formatting and comments; this doesn't). Text that isn't valid JSON is refused. Returns what changed (in the game's terms), or the summary of a new file, and the problems.", + inputSchema: { + path: pathSchema, + text: z.string().describe('The complete file text'), + dry_run: z.boolean().optional().describe('Report the change and its problems without writing the file'), + include_info: z.boolean().optional(), + }, + }, + ({ path, text: body, dry_run, include_info }) => + tool(() => { + const p = resolve(path); + if (extname(p) !== '.json') throw new Error('Particle files end in .json (Oxygen opens *.particle.json by default)'); + const before = existsSync(p) ? readFileSync(p, 'utf8') : null; + const diff = rewriteDiff(before, body); + if (!dry_run && body !== before) { + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, body); + } + const e = analyze(p, body); + const head = dry_run ? 'Dry run: nothing written.' : body === before ? 'No change.' : before === null ? `Created ${p}.` : `Saved ${p}.`; + return text([head, '', before === null ? summarize(e) : diff.trimEnd(), '', formatProblems(e.problems, !!include_info)].join('\n')); + }), +); + server.registerTool( 'check_effect', { @@ -232,14 +278,15 @@ server.registerTool( times: z.array(z.number().min(0).max(60)).optional().describe('Exact sample times in seconds (overrides duration and samples)'), spawn: spawnSchema, repeat: z.boolean().optional().describe("Oxygen's Repeat: start the effect again when it ends (default false: /particle plays once, like the game)"), + others: othersSchema, include_info: z.boolean().optional(), }, annotations: { readOnlyHint: true }, }, - ({ path, duration, samples, times, spawn, repeat, include_info }) => + ({ path, duration, samples, times, spawn, repeat, others, include_info }) => tool(async () => { const p = existing(path); - const { request, missingChildren } = runRequest(p, { times: sampleTimes(times, duration ?? 3, samples ?? 6), spawn, repeat: !!repeat, render: null }); + const { request, missingChildren } = runRequest(p, { times: sampleTimes(times, duration ?? 3, samples ?? 6), spawn, repeat: !!repeat, render: null, others }); return text(report(await browser.run(request), p, missingChildren, !!include_info)); }), ); @@ -274,8 +321,10 @@ server.registerTool( gizmos: z.boolean().optional().describe('Draw emitter markers, spawn shapes and the kill plane (default false)'), sheet: z.boolean().optional().describe('One contact sheet (default true) instead of a picture per time'), save_to: z.string().optional().describe('Write the PNG(s) here: a .png file, or a folder for one file per picture'), + return_images: z.boolean().optional().describe('Put the pictures in the reply (default true). false needs save_to: only the numbers and the saved paths come back'), spawn: spawnSchema, repeat: z.boolean().optional().describe("Oxygen's Repeat (default false: /particle plays once, like the game)"), + others: othersSchema, include_info: z.boolean().optional(), }, annotations: { readOnlyHint: false }, @@ -283,6 +332,7 @@ server.registerTool( (a) => tool(async () => { const p = existing(a.path); + if (a.return_images === false && !a.save_to) throw new Error('return_images: false needs save_to, or the pictures would be lost'); const c = a.camera ?? {}; let camera: CameraRequest; if (c.bookmark) camera = { kind: 'bookmark', name: c.bookmark }; @@ -297,6 +347,7 @@ server.registerTool( repeat: !!a.repeat, timeOfDay: a.time_of_day, render: { width: a.width ?? 480, height: a.height ?? 360, mode: a.mode ?? 'classic', camera, ground: a.ground ?? true, gizmos: !!a.gizmos, sheet }, + others: a.others, }); const r = await browser.run(request); const pngs = r.images.map((url) => url.replace(/^data:image\/png;base64,/, '')); @@ -314,7 +365,27 @@ server.registerTool( pngs.forEach((b64, i) => writeFileSync(files[i], Buffer.from(b64, 'base64'))); lines.push('', `Saved ${files.join(', ')}`); } - return { content: [{ type: 'text', text: lines.join('\n') }, ...pngs.map((data) => ({ type: 'image' as const, data, mimeType: 'image/png' }))] }; + const images = a.return_images === false ? [] : pngs.map((data) => ({ type: 'image' as const, data, mimeType: 'image/png' })); + return { content: [{ type: 'text', text: lines.join('\n') }, ...images] }; + }), +); + +server.registerTool( + 'open_in_editor', + { + title: 'Open in Oxygen', + description: + "Open the file in the Oxygen desktop app for the user: starts the app, or hands the file to the one running, which opens it in a tab or focuses its tab. The app follows the file on disk, so later changes from these tools show there live (a tab with unsaved changes keeps them). Oxygen is found through OXYGEN_DESKTOP (its executable), the desktop build of the repository this server was built in, or an installed app.", + inputSchema: { path: pathSchema }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + ({ path }) => + tool(async () => { + const p = existing(path); + const launch = desktopLaunch(p); + if (!launch) throw new Error('Oxygen desktop not found: install it, build it in this repository (pnpm --filter @oxygen/desktop build), or set OXYGEN_DESKTOP to its executable.'); + await launchDetached(launch); + return text(`Opening ${p} in Oxygen (via ${launch.via}).`); }), ); diff --git a/docs/accuracy.md b/docs/accuracy.md index d4a808b..9e2ab6a 100644 --- a/docs/accuracy.md +++ b/docs/accuracy.md @@ -47,6 +47,12 @@ with a counter: each particle takes the next number and places itself by it. Sno update before moving any of them, so every particle ends up with the last particle's number and they all stack on one spot. +Variables belong to the emitter, not to each particle. By the time render expressions run, a value set in the +update holds whatever the last particle left there (Oxygen flags reads like that). Size, colour or UV that depend +on something per particle need it again in the render pass: worked out from `v.particle_random_1`–`4` and +`v.particle_age`, which are each particle's own, or from a second counter in the render expression that the +emitter's update resets, as several examples do. + The Inspector's Phases view and the Expression panel's "whole effect" view show this order for your effect. ## Events diff --git a/docs/agents.md b/docs/agents.md index a88a069..06ece35 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -42,9 +42,11 @@ Relative file paths are relative to the folder the agent starts the server in, u | `create_effect` | A new effect file from Oxygen's starting effect or a copy of an example under a new identifier | | `read_effect` | A summary of an effect: components, curves, events and what they spawn, variables and where they're set and read, scenarios, problems | | `edit_effect` | Edits by JSON path (set, Molang, remove, insert, rename), applied together like an edit in Oxygen: formatting, key order and comments stay as they were. Reports what changed and the problems afterwards | +| `write_effect` | Replaces a file's whole text (or creates it), for rewrites too big for a list of edits. Refuses text that isn't valid JSON; reports what changed and the problems | | `check_effect` | The problems the Problems panel would show | | `simulate_effect` | Runs the effect and reports, at chosen times, how many particles are alive and drawn, where they are, which emitters and events ran, and runtime problems such as unset variables or the particle cap | -| `preview_effect` | Pictures of the effect at chosen times, by default on one labelled sheet, with the same numbers. Optionally saves the PNGs | +| `preview_effect` | Pictures of the effect at chosen times, by default on one labelled sheet, with the same numbers. Optionally saves the PNGs, and can leave the pictures out of the reply once saved (`return_images: false`) | +| `open_in_editor` | Opens the file in the Oxygen desktop app for you to look at and play with | Previews and simulations play the effect the way `/particle` does unless asked otherwise (`spawn`: `script` or `entity`), and play it once, like the game. They use a fixed clock and the same random seed every time, so the @@ -52,6 +54,17 @@ same file always gives the same pictures and numbers. The camera frames every pa from an angle the agent picks; it can also be placed by hand or taken from a view saved in the file. The ground grid sits at y = 0 and the small outlined cube is one block. +Other effects can play in the same scene (`others`: identifiers and positions), like the Scene panel's "Other +effects". Without `others`, the ones the file's scene keeps play, as in the editor. + +## Opening effects in Oxygen + +`open_in_editor` starts the desktop app with the file, or hands the file to the app already running, which opens it +in a tab (or brings its tab forward). The app follows the file on disk, so the agent's later changes show there as +they're made; a tab with unsaved changes keeps them and says so. The server finds Oxygen through `OXYGEN_DESKTOP` +(the app's executable) if set, then the desktop build of the repository the server was built in, then an installed +app. + ## Packs, textures and child effects The server finds the resource pack around a file the same way the desktop app does: the nearest folder above it @@ -67,5 +80,5 @@ with a `manifest.json`, or else the folder holding `particles/`. From the pack i - Pictures come from software rendering in headless Chromium. They show exactly what the editor's viewport shows, but each one takes a moment; a sheet of four 480 × 360 pictures takes one to two seconds. -- `edit_effect` refuses a file that isn't valid JSON. Fix that by editing the text first. +- `edit_effect` refuses a file that isn't valid JSON. Fix that with `write_effect`, which replaces the whole text. - The server doesn't talk to Minecraft. To try an effect in the game, use the desktop app's Game menu. diff --git a/docs/building.md b/docs/building.md index cc630fe..671d20c 100644 --- a/docs/building.md +++ b/docs/building.md @@ -31,6 +31,11 @@ The particle engine and the Molang compiler are Git submodules in `vendor/`. If To try a `.vsix`, install it in VS Code with *Extensions: Install from VSIX…*, or run `code --install-extension apps/vscode/oxygen-particle-editor-.vsix`. +`pnpm desktop` downloads Electron into its cache on the first run. Where that cache can't be written (a read-only +`~/Library/Caches/electron`, for instance), point it elsewhere: `electron_config_cache=/tmp/electron-cache pnpm desktop`. +Oxygen runs one window at a time: starting it again with a file, as `open_in_editor` does for agents, opens the +file in the window already running. + ## Tests | Command | What it checks | diff --git a/tools/desktop-test.mjs b/tools/desktop-test.mjs index cf18830..d155a9d 100644 --- a/tools/desktop-test.mjs +++ b/tools/desktop-test.mjs @@ -1,10 +1,14 @@ // Launches the built Electron app and checks: window opens, files passed on the command line open, -// outside edits to the file reach the open document, the game link menu is present. +// outside edits to the file reach the open document, starting it again hands files to the running window, +// the game link menu is present. // node tools/desktop-test.mjs [--packaged apps/desktop/release/win-unpacked/Oxygen.exe] import { _electron as electron } from '@playwright/test'; +import { execFile } from 'node:child_process'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; delete process.env.ELECTRON_RUN_AS_NODE; const dir = mkdtempSync(join(tmpdir(), 'oxygen-desktop-')); @@ -20,11 +24,12 @@ const check = (ok, what) => { const appDir = resolve('apps/desktop'); // the packaged app (electron-builder output) instead of the development build const packaged = process.argv.includes('--packaged') ? resolve(process.argv[process.argv.indexOf('--packaged') + 1]) : null; +const env = { ...process.env, ELECTRON_ENABLE_LOGGING: '0', OXYGEN_USER_DATA: join(dir, 'profile') }; const launch = (files) => electron.launch({ ...(packaged ? { executablePath: packaged, args: files } : { args: [appDir, ...files] }), cwd: appDir, - env: { ...process.env, ELECTRON_ENABLE_LOGGING: '0', OXYGEN_USER_DATA: join(dir, 'profile') }, + env, }); if (packaged) console.log(`info testing ${packaged}`); const app = await launch([file]); @@ -43,6 +48,19 @@ check(before === 10 && after === 77, `editing the file on disk updates the open const dirty = await page.evaluate(() => window.oxygen.active.dirty); check(dirty === false, 'a file changed on disk without local edits stays clean'); +// started again with another file while it runs, Oxygen hands the file to the open window and quits +const second = join(dir, 'second.particle.json'); +writeFileSync(second, readFileSync(file, 'utf8').replace(/"identifier":\s*"[^"]+"/, '"identifier": "test:second"')); +const binary = packaged ?? createRequire(join(appDir, 'package.json'))('electron'); +const handedOver = await promisify(execFile)(binary, packaged ? [second] : [appDir, second], { cwd: appDir, env, timeout: 20000 }).then( + () => true, + () => false, +); +await page.waitForFunction(() => window.oxygen?.active?.file.name === 'second.particle.json', null, { timeout: 10000 }).catch(() => {}); +const tabs = await page.evaluate(() => window.oxygen.sessions.map((s) => s.file.name)); +check(handedOver && tabs.length === 2 && tabs.includes('second.particle.json'), `starting Oxygen again with a file opens it in the running window (${tabs.join(', ')})`); +await page.evaluate(() => window.oxygen.close(window.oxygen.active.id)); + await page.waitForTimeout(800); await page.screenshot({ path: 'test-results/ui/desktop.png' }); check(errors.length === 0, `no page errors${errors.length ? ': ' + errors.join(' | ') : ''}`); diff --git a/vendor/wintersky b/vendor/wintersky index 09df56d..d94434e 160000 --- a/vendor/wintersky +++ b/vendor/wintersky @@ -1 +1 @@ -Subproject commit 09df56d495b2adfbfc4999891fff064de0b26288 +Subproject commit d94434ed84c3ae967fda628ab342c15aca060288