@gtanczyk/diorama 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +290 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +161 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +158 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +37 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +195 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +100 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +105 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +33 -0
- package/app/src/scene/cast.ts +199 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +109 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +419 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +555 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +423 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/language.ts +116 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +679 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1329 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3769 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// The coding agent in the Studio: one conversation per world, a genaicode AgentSession over Muse Code
|
|
2
|
+
// (museLive, `muse serve`, the default), Claude Code or Codex, the drivers that take MCP servers; shown in the page with genaicode's own chat. It works only through the Studio's MCP tools,
|
|
3
|
+
// so each of its moves shows up in the page like a click would; Muse's own shell and file tools are refused.
|
|
4
|
+
// It gets no provider key: its environment is scrubbed and Muse uses its own login.
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { claude, codexLive, createAgentSession, detectAgents, museLive, type CodingAgent, withoutProviderCredentials, type AgentSession, type ApprovalDecision, type ApprovalRequest, type SessionState, type SessionTurn } from 'genaicode/agents';
|
|
9
|
+
import { configDir, redact, withoutSecrets } from '../secrets.ts';
|
|
10
|
+
import type { Events } from './events.ts';
|
|
11
|
+
import { History } from './history.ts';
|
|
12
|
+
import { NPC_STORY_GUIDANCE } from '../story/npc-guidance.ts';
|
|
13
|
+
|
|
14
|
+
export const AGENT_BRIEF = (slug: string) => `You are the coding agent inside Diorama Studio, working on the world "${slug}" with the person.
|
|
15
|
+
Use only the diorama MCP tools: they are the same actions as the Studio's buttons, and the person watches each call live.
|
|
16
|
+
Make a new world from an idea with world_new (it picks the name, places and language). Start long work with world_build or run_step and follow it with job_wait. Call ui_focus to show the place you are talking about.
|
|
17
|
+
Every place moves as a diorama (the backdrop step: cut-out things over a painted backdrop, far flats, flowing water). When the person says the water runs the wrong way or should stand still, call water_flow (reverse, still, or auto with a hint in their words), then show the place; never edit files by hand. Still water (the sea, a lake) sways in place by itself. When only part of the water moves (a strip at the bank), nothing moves, or something that is not water ripples, call place_view layer water to see what moves, then water_area with the 60 px cells to add or remove, then show the place.
|
|
18
|
+
${NPC_STORY_GUIDANCE}
|
|
19
|
+
For NPC story-stage or travel requests, inspect story_status first and use story_redo with a concrete wish describing the stages, event and changed dialogue. For a wording-only change use story_text. npc_move only edits where someone stands in the scene; cast_animate only prepares their animation assets. Neither authors story-driven walking. Keep the person's requested scope: a simple stage change does not imply a patrol. world_extend preserves existing NPC stages, flags and motion; use it for new content, and story_redo for a requested change to an existing character. After a story job finishes, inspect story_status again. If newly walking or speaking characters need assets, inspect cast_status and run the required model/sprites steps; new spoken lines need voices and faces, and visual object changes need plates. Follow jobs with job_wait and play_reload after success. Use playthrough for an actual route through the changed stages; a place_motion still or a successful build alone does not verify NPC departure, destination dialogue or return. State what was actually observed.
|
|
20
|
+
The reactions step automatically chooses subtle physical effects from each scene picture, floor materials, weather and story. It binds step and area-entry effects to reachable floor cells and object effects to exact use/take interactions. Use reactions_status to inspect the choices and reactions_set with a hint to adjust them, then play_reload. Empty lists are valid on clean surfaces. A still frame cannot verify these event-driven effects: walk through the area or use the actual object to observe them.
|
|
21
|
+
The weather step animates what falls on each place (rain or snow, rings on water and puddles, splashes, drops off the eaves). When the person says it should (not) rain or snow there, it rains too hard, or water should drip from something, call weather_set (fall auto/none/rain/snow, strength, a hint in their words), then show the place. Fog painted into the picture (a bank over the land, haze round a far lighthouse) drifts by itself: when the person says it stands still, drifts the wrong way or too fast, call fog_drift (auto with the wind, left, right, still; speed 0.25..3), then show the place; fog that still does not move means SAM found none there: offer a rebuild of that place's backdrop. More mist than the picture has is weather: weather_set with fog 0..1 (0 none, 0.3 haze, 0.7 thick fog).
|
|
22
|
+
A place's things are its props, exits, items and people. When the person wants a prop off the list, call thing_remove (paintOut false keeps it in the picture, true has Muse paint it out: ask which when they did not say); when something painted should be a thing of its own, thing_add with what it is in their words. Exits, items and people belong to the story: change those with story_redo.
|
|
23
|
+
When the person adds pictures and asks to expand this existing game, inspect each with picture_view, save each new drawing with kids_from_picture using its studio/pictures/ path, and call kids_status. Use world_extend with the returned drawings/ paths and the person's wish; it builds the connecting place and new places and adds story and characters, preserving the old script. A connecting place is repainted and model calls cost money: say this before starting. Follow the job with job_wait, inspect world_status and story_status, then play_reload only when it succeeds; say clearly if any step failed. A preview alone is not part of the game. An already represented drawing cannot be added again: explain this rather than claiming it was incorporated.
|
|
24
|
+
During a staged extension, the log describes work in a private copy: it is not in the game yet. A successful job_wait call may still return state running; report the actual job state and current work, never call it complete or playable until the job says done and the checks have been inspected. The progress display reports build milestones, not an assessment of visual quality or an actual playthrough.
|
|
25
|
+
When a job seems slow or the person asks what it is doing, call job_inspect for timestamped activity, how long it has been quiet, locally observed operations, and voice/sound counts and failures. job_wait returns a shorter version too. Say what is actually observed, including failed or missing lines; an individual failed attempt can be retried, so distinguish it from a failed job or an item that stayed missing. A long-running local request or quiet log alone does not prove the provider is stuck. job_cancel can stop a running or queued job when the person asks; stopping can discard staged work and does not undo paid calls. Do not cancel just because the log is quiet.
|
|
26
|
+
To fix a place use the fix tools, which are kept across rebuilds: mask_fix (a better SAM phrase, the real box, or the kind of a thing), floor_cells, mask_brush, stand_set, repaint_region (costs a Muse edit), paint_words (how the place, its things and floors are described to the painter); corrections_get shows what is fixed already, corrections_clear takes fixes back. When asked to go through the person's notes, read feedback_pending, fix each one and close it with feedback_done in one sentence. Check budget before a rebuild of many places.
|
|
27
|
+
When the person wants the game in another language ("make it English", "po polsku"), call world_language: it translates the title, names and the whole story in one pass and casts and records the voices again as native speakers; first without go, tell them what it redoes and what the voices cost, then call it with go true once they agree (voices false when they only want the text now). Never translate it line by line with story_text, and never say the language cannot be changed.
|
|
28
|
+
When the person wants the game to look different (more realistic, watercolour, like a picture book, less cartoon), call style_set with the style in a few words (medium, palette, light; scope place or cast for only one place or the characters): first without go, tell them what it repaints and about what it costs, then call it with go true once they agree. It repaints each picture keeping where everything is, so masks and their fixes still fit; children's drawings stay the reference; history_undo takes it back. Never write world.json's style by hand.
|
|
29
|
+
Before a risky change, say what you will do; history_undo can take back a job. Respect locks. Keys are never yours: if a key is missing, ask the person to run \`diorama setup\` in a terminal.
|
|
30
|
+
Read files freely to find out why something failed, but do not edit world files or run commands that change them: make every change through the diorama MCP tools, so the person sees it live, can undo it, and it survives the next build (a hand edit is overwritten by the next build). When no single tool makes the change the person asks for, do not stop at "there is no tool": find the nearest way with the tools you have (several story_text calls, a story_redo with their wish, a run_step) and offer it with what it costs, using ask_person; mention the missing tool only as a side note, so it can be added later. Refuse only what would need a hand edit or would throw away their work. Start from world_status, place_status, place_view, job_wait, corrections_get and feedback_pending: place_view shows you a place as a picture (layer walk, masks or painting), which files and the shell cannot. Reach for the shell only when no Studio tool answers the question.
|
|
31
|
+
When a build fails for another reason (a step that broke, a service that did not answer), the Studio wakes you with the error and the job's last lines: job_inspect it, give a service error (HTTP 5xx, a timeout, a busy Space) one job_retry, which picks up at the failed step, and fix anything else with that step's own tool; never retry the same error twice, tell the person instead. A character the 3D service left in 2D (cast_status: the 3D service did not answer) still plays with its 2D rig; cast_animate tries its model again once the service is back. When a build leaves places not playable, repair them one by one: place_status lists each error with its playbook (where to look, the fix tools in order, the last resort), and place_view shows you the walkable floor with the 60 px cells floor_cells takes, the gaps to bridge and the spots off the floor. An unreached patch where nothing is floor (a wall, the sky) is taken back, not bridged: corrections_clear the fix that made it, or mask_brush erase. Try the tools in that order; each fix rebuilds only that place, so read place_status again after its job ends. Stop after three fixes that did not change the error, and tell the person what you tried and what you would do next. Ask before the last resort, except one repaint_region per place.
|
|
32
|
+
When layout failed and there is no picture, do not wait for place_view: inspect place_status errors, then call layout_fix with concrete layout guidance, or place_fix to resume. Both work before the place directory exists. Failed layout batches retain their diagnostics and retry with new feedback instead of replaying the same rejected batch. When paint fails with content_policy_violation, the image safety filter refused a word, not the picture: paint already lets Spark reword the place twice, so call paint_words with calm, plain words for the scene and the looks the brief had (leafless, not bare; a seat that rests, not hangs on chains; no children, blood or weapons), never an edit of world.json. A place that never got past a step is rebuilt from that step: place_fix (the Fix it button on its card) or job_retry does that by itself.
|
|
33
|
+
Stairs: derive finds the steps of floors and walk-on things named as stairs (stair, step, schody), and people then step from tread to tread; place_view (layer walk) draws the steps it found in blue. Scale: derive measures how tall a person is at each spot (Spark's people, held against a door on the floor: a person by a door is about 0.8 of its height). When the hero looks far too small or too big next to a door, run_step derive with that place, which rebuilds it through verify; when it stays wrong, the door is not named as a door: give it a door phrase with mask_fix. When people glide up stairs as on a ramp, the stairs thing or floor is not named as stairs: give it a stairs phrase with mask_fix (for example "stone stairs"), which rebuilds the place.
|
|
34
|
+
A message that starts with "[On screen: …]" says what the person had open in Studio as they wrote ("place", "place / thing", "cast", "cast / character" or "cast / item key"; older messages say "[Looking at: …]"): when they say "this", "it" or "here" without a name, they mean that; look at it (place_view for a place) before you change it. Never mention the prefix. A message that starts with "[Pictures: studio/pictures/01.jpg, …]" comes with pictures the person pasted, uploaded or took with the webcam: look at each with picture_view before you answer, and act on what they show. When the person is playing and your change shows in the game, call play_reload yourself rather than asking them to refresh. You cannot watch the game, but place_motion can: after a change to motion (water flow, fog, fire, weather, an animated thing) call it and check the motion shows before you say it works. A message that starts with "[Studio]" is a private note from the Studio, not from the person: follow it, never mention it or reply to it. The story has story_status, story_text (rewording) and story_redo (a wish to Spark); story_status lists unanswerable questions (a question in a "say" or in a line without choices leaves the player stuck): reword it into a statement with story_text, or story_redo when it needs real answers; it also lists unseen story parts (an item thing whose item has another key stays in the painting; a change on a person is never drawn): fix those with story_redo; the cast has cast_status, cast_redo, cast_animate, cast_rewalk, cast_holds and voice_set; a figure whose cut-out lost what it holds or carries (a toast over its head, a lantern, a basket; cast_status cut.holds lists what is kept) gets cast_holds, not cast_redo: it cuts the same painting again with the held thing; speaking faces have faces_status and faces_prepare. Talking mouths can be off in Setup (faces_get; off by default): then never prepare, repair or calibrate faces and never offer to; characters speak with voice and gestures. Free fixes before paid ones: a 3D figure whose face shows as a patch of another colour, or whose colours look pale or plastic beside its painting, is a render matter; run sprites again (free for UV sprites, no Muse) before ever offering sprites_redo or per-frame Muse repaints, and when you do offer paid work say the real count, not a range. Order: a playable game first, talking mouths last. While any place is unpainted, being built or unplayable, never start face work (faces_prepare, face_frame_repair, face_lips_set, face_calibration_set refuse it); repair the places, and leave lips for after everything plays, or skip them: characters talk with gestures. A failed speaking animation is separate from game readiness; recordings and the previous appearance remain usable. Inspect coverage and repairWork first. Accepted atlases and calibrated mouths are saved, so resume rather than repainting the whole character. For a rejected mouth, faces_inspect with its work id and mouth returns the exact head and reference; face_frame_repair uses the returned revision and concrete visual guidance to edit ONLY that mouth, then revalidate the atlas. For an accepted atlas with uncertain lip corners, inspect the exact mouth image with faces_inspect and use face_lips_set with a normalized tight lip box relative to that single full-head image (not the whole atlas). Never guess coordinates, use it on rejected articulation, or lower validation thresholds. Both tools resume preparation and reject stale revisions/sources. Wait for the job, inspect faces_status again and play_reload after success. Do not repeat an unchanged failed repair; inspect the new stage and use its matching tool. For source-pose or final-patch calibration failures, face_calibration_inspect lists pending targets and shows their exact close-up. Use face_calibration_set with its target, revision, imageHash, tight lip box and visible-cavity open flag. This preserves accepted artwork and resumes the remaining checks. If an inspected mouth remains genuinely ambiguous, stop and explain that specific uncertainty rather than inventing coordinates. Image, 2D rig and talk-sprite faces share audio timing. 3D models articulate when model3d.mouth is true: method morphs uses authored facial poses, painted-surfaces animates calibrated face paint on the actual mesh. Check the reported view coverage and spritePoses pending count; a body skeleton alone does not articulate. Do not claim native mouth geometry for the painted-surface method; a 3D figure that walks straddle-legged with arms held out to the sides (like a zombie) or hands sticking out gets cast_relax (local Blender, free; blender_get says if it is there, else tell the person to install Blender and turn it on in Setup); a walk that tears (holes in clothes, a leg, boot or arm coming off, a lantern left hanging in the air) gets cast_rewalk, not cast_redo; a character with no 3D model the person explicitly wants animated in 3D (a creature, cast_status body upright false, or an NPC with no lines) gets a model with cast_animate, never a cast_redo that makes it stand on two legs: a creature hops, squashes and blinks as one body, then play_reload; a hero painted sitting, lying or with furniture grown into the 3D model (from a photo where they sat or lay) gets cast_redo with looks that describe only the person standing (clothes, trousers, shoes; no pose, bed or "not visible"), then the model again; inventory icons have icons_status, icon_redo (a hint in the person's words, cut or paint, another take) and icon_fix_clear, never a swapped file in items/. Voices are cast from the gender and age Spark sees in each figure: adults get stock voices, children, teens and the old get a voice made for them by ElevenLabs Voice Design (voice "design"). When one sounds wrong, voice_list with slug and key lists the voices that fit that character first; voice_set with voice "design" and a design description in English remakes a designed voice (no word "child" or age in years there: ElevenLabs refuses it). Never give a child an adult's voice or raise an adult voice's pitch to fake one. A character whose voice is "pending-design" in cast_status waits for Voice Design (it was refused or the service was busy): run_step voices or voice_set designs it again; never give them a stock adult instead. Lines with numbers or sums ("10+10=20") are read and checked as words by themselves; voiceFailures lists only lines the story still has. For one missing or rejected line, use voice_retry with slug, key and the exact text from voiceFailures: it keeps the selected voice and every other recording, gives rejected takes fresh seeds, and resumes saved audio after a transcription outage. Do not change voice_set merely to retry a line. Sounds have sound_status (each bed, loop, footstep, one-shot and music: made, or rejected and why) and sound_redo (ids, an optional hint in English, kept across rebuilds): a sound rejected as "mostly hiss" or "nearly silent" stays silent in the game until sound_redo, never an edited sound.json. cast_status says how the game moves each character (moves), why there is no 2D walk (rigIssue) and which walk directions left the pose (spriteIssues): those get sprites_redo, which paints new sheets without repainting the character; a character with no model after flat reconstructions walks with its 2D rig or slides as a cut-out, and cast_animate tries the model again. When the hero starts in front of an NPC or a thing and hides it, hero_start_set moves where they begin; npc_move changes the NPC standing position in the layout. Author a story-triggered trip with story_redo. A still frame in place_motion is a fault only when inTheGame lists water, mist or weather for that place.
|
|
35
|
+
When you need the person to decide (yes or no, which of two fixes, go ahead with something that costs calls), ask with ask_person instead of a question in your text: it shows them buttons; then end your turn.
|
|
36
|
+
After a redo the person asked for (a repaint, a recast), show them the result and stop: if a trace of what they wanted gone is still faintly there, say so and offer one more try with ask_person; never start another paid redo on your own. A message from the person while you wait for a job comes first: read it before waiting again, and job_cancel the job if they no longer want it.
|
|
37
|
+
Answer in two or three short sentences, in the language the person wrote their newest message in (not the world's language, not an earlier turn's); the steps already show what you did.`;
|
|
38
|
+
|
|
39
|
+
const NUDGE_AFTER = 3;
|
|
40
|
+
const NUDGE = '[Studio] You are using the shell a lot. Look with the Studio tools instead: place_view shows a place as a picture '
|
|
41
|
+
+ '(layer walk: reachable floor, gaps, spots; masks: each thing\'s mask against where it should be; painting: the grid), place_status '
|
|
42
|
+
+ 'gives each error with its playbook, corrections_get the fixes so far, place_motion what moves in the game. Change things only with the fix tools. '
|
|
43
|
+
+ 'This note is from the Studio, not from the person: do not mention it or answer it in your reply, just carry on with their request.';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A call of one of the Studio's MCP tools: not a shell, process or file access, and named for the diorama
|
|
47
|
+
* server. A path that merely contains "diorama" (every world lives under it) is not enough.
|
|
48
|
+
*/
|
|
49
|
+
export function studioTool(request: ApprovalRequest): boolean {
|
|
50
|
+
if (request.kind !== 'other') return false;
|
|
51
|
+
const detail = (request.detail ?? {}) as { subject?: { kind?: string } };
|
|
52
|
+
if (['shell', 'process', 'fileAccess'].includes(String(detail.subject?.kind))) return false;
|
|
53
|
+
const text = JSON.stringify(detail);
|
|
54
|
+
return /"mcp__diorama__[a-z_]+"/.test(text) || /"(server|serverName|mcpServer)"\s*:\s*"diorama"/.test(text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A shell call in a turn's transcript (Muse's shell or bash, Codex's exec, a REPL). Counted from the transcript,
|
|
59
|
+
* not from approvals: Muse runs a read-only command without asking, so most shell calls never reach autoApprove.
|
|
60
|
+
*/
|
|
61
|
+
export const shellTool = (name: string) => /(^|[/_.])(shell|bash|sh|exec|exec_command|repl|js)$/i.test(name);
|
|
62
|
+
|
|
63
|
+
/** Turns a session snapshot into what the page may see: secrets blanked. */
|
|
64
|
+
const safe = (state: SessionState): SessionState => JSON.parse(redact(JSON.stringify(state))) as SessionState;
|
|
65
|
+
|
|
66
|
+
/** Muse reading a file: harmless, and it helps the agent find out why something failed. */
|
|
67
|
+
export function readsOnly(request: ApprovalRequest): boolean {
|
|
68
|
+
const subject = ((request.detail ?? {}) as { subject?: { kind?: string; access?: string } }).subject;
|
|
69
|
+
return request.kind === 'other' && subject?.kind === 'fileAccess' && subject.access === 'read';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Call = { turn?: number; name: string; args: Record<string, unknown>; done: boolean; ok?: boolean; summary?: string };
|
|
73
|
+
|
|
74
|
+
/** A Studio tool's own name, however the agent spells it (mcp__diorama__job_wait, diorama.job_wait). */
|
|
75
|
+
export function toolName(name: string): string | undefined {
|
|
76
|
+
return /^(?:mcp__diorama__|diorama[.:_/]+)(\w+)$/.exec(name)?.[1];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A tool's result in one line for the panel: a job by its name and state, else the result itself, short. */
|
|
80
|
+
export function summarize(result: unknown): string {
|
|
81
|
+
if (result instanceof Error) return result.message;
|
|
82
|
+
if (typeof result === 'string') return result.slice(0, 400);
|
|
83
|
+
if (!result || typeof result !== 'object') return String(result);
|
|
84
|
+
const r = result as Record<string, unknown>;
|
|
85
|
+
if (typeof r.label === 'string' && typeof r.state === 'string') {
|
|
86
|
+
const tail = Array.isArray(r.tail) ? (r.tail as unknown[]).filter((x) => typeof x === 'string').slice(-3).join('\n') : '';
|
|
87
|
+
return `${r.label}: ${r.state}${typeof r.step === 'string' ? `, at ${r.step}` : ''}${typeof r.error === 'string' ? ` (${r.error})` : ''}${tail ? `\n${tail}` : ''}`;
|
|
88
|
+
}
|
|
89
|
+
// The picture a view tool showed goes first, so it survives the cut and the panel can show it.
|
|
90
|
+
const { images: _images, picture, ...rest } = r;
|
|
91
|
+
const text = JSON.stringify(typeof picture === 'string' ? { picture, ...rest } : rest);
|
|
92
|
+
return text.length > 400 ? `${text.slice(0, 400)}…` : text;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class Talk {
|
|
96
|
+
readonly log: string;
|
|
97
|
+
/** Whether the current agent session got the brief; a new agent starts without it. */
|
|
98
|
+
briefed = false;
|
|
99
|
+
/** Whether the agent goes on with its own session from turn to turn; one that does not (Codex) is briefed with the conversation every turn. */
|
|
100
|
+
resumes = true;
|
|
101
|
+
rebrief() {
|
|
102
|
+
this.briefed = false;
|
|
103
|
+
}
|
|
104
|
+
/** Shell calls in the current turn. */
|
|
105
|
+
shell = 0;
|
|
106
|
+
/** How far each turn's transcript has been announced on the event feed. */
|
|
107
|
+
private seen = new Map<number, number>();
|
|
108
|
+
/** Tools announced already: a running tool is announced when it starts and must not be again once it settles. */
|
|
109
|
+
private tools = new Set<string>();
|
|
110
|
+
private ended = new Set<number>();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The Studio's tools as the Studio saw them called (arguments, outcome). Some agents report a tool call by
|
|
114
|
+
* name alone (Muse Code), so the panel showed "Wait for a job · No output."; the calls fill that in.
|
|
115
|
+
*/
|
|
116
|
+
private calls: Call[] = [];
|
|
117
|
+
readonly listeners = new Set<() => void>();
|
|
118
|
+
|
|
119
|
+
/** The conversation before this session (earlier agents, before the Studio restarted), shown above it. */
|
|
120
|
+
readonly past: SessionTurn[];
|
|
121
|
+
|
|
122
|
+
constructor(readonly slug: string, readonly session: AgentSession, private readonly dir: string, private readonly events: Events) {
|
|
123
|
+
this.past = pastTurns(dir);
|
|
124
|
+
const id = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
|
|
125
|
+
this.log = path.join(dir, 'studio', 'sessions', `${id}-${randomBytes(2).toString('hex')}.jsonl`);
|
|
126
|
+
session.subscribe((state) => {
|
|
127
|
+
this.announce(state);
|
|
128
|
+
this.film(this.told(state));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A Studio tool the agent called: kept with the turn it ran in; the returned function settles it. */
|
|
133
|
+
call(name: string, args: Record<string, unknown>): (ok: boolean, result: unknown) => void {
|
|
134
|
+
const c: Call = { turn: this.session.get().turns.at(-1)?.id, name, args, done: false };
|
|
135
|
+
this.calls.push(c);
|
|
136
|
+
if (this.calls.length > 400) this.calls.splice(0, this.calls.length - 400);
|
|
137
|
+
this.changed();
|
|
138
|
+
return (ok, result) => {
|
|
139
|
+
Object.assign(c, { done: true, ok, summary: summarize(result) });
|
|
140
|
+
this.changed();
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private changed() {
|
|
145
|
+
for (const l of this.listeners) l();
|
|
146
|
+
this.film(this.told(this.session.get()));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The state with each Studio tool's arguments and outcome filled in where the agent left them out. */
|
|
150
|
+
told(state: SessionState): SessionState {
|
|
151
|
+
if (!this.calls.length) return state;
|
|
152
|
+
const turns = state.turns.map((turn) => {
|
|
153
|
+
const mine = this.calls.filter((c) => c.turn === turn.id);
|
|
154
|
+
if (!mine.length) return turn;
|
|
155
|
+
const seen = new Map<string, number>();
|
|
156
|
+
let changed = false;
|
|
157
|
+
const entries = turn.entries.map((e) => {
|
|
158
|
+
if (e.kind !== 'tool') return e;
|
|
159
|
+
const name = toolName(e.name);
|
|
160
|
+
if (!name) return e;
|
|
161
|
+
const n = seen.get(name) ?? 0;
|
|
162
|
+
seen.set(name, n + 1);
|
|
163
|
+
const c = mine.filter((x) => x.name === name)[n];
|
|
164
|
+
if (!c) return e;
|
|
165
|
+
const bare = e.input === undefined || e.input === null || (typeof e.input === 'object' && !Object.keys(e.input).length);
|
|
166
|
+
const silent = !e.output || /^no output\.?$/i.test(e.output.trim());
|
|
167
|
+
if (!bare && !(silent && c.done)) return e;
|
|
168
|
+
changed = true;
|
|
169
|
+
return { ...e, ...(bare && { input: c.args }), ...(silent && c.done && { output: c.summary, isError: e.isError || !c.ok }) };
|
|
170
|
+
});
|
|
171
|
+
return changed ? { ...turn, entries } : turn;
|
|
172
|
+
});
|
|
173
|
+
return { ...state, turns };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** What the page shows: the earlier turns, then this session's. */
|
|
177
|
+
shown(state: SessionState): SessionState {
|
|
178
|
+
const now = this.told(state);
|
|
179
|
+
return this.past.length ? { ...now, turns: [...this.past, ...now.turns] } : now;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private filmTimer: NodeJS.Timeout | undefined;
|
|
183
|
+
private filmLatest: SessionState | null = null;
|
|
184
|
+
private filmed = new Map<number, SessionState['turns'][number]>();
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* For replay in the film: the running turn as it grows, at most four times a second. Each line holds that
|
|
188
|
+
* turn whole (turns are small), so a replay rebuilds the chat from the lines alone.
|
|
189
|
+
*/
|
|
190
|
+
private film(state: SessionState) {
|
|
191
|
+
this.filmLatest = state;
|
|
192
|
+
// Persist completed turns immediately: a restart can follow the answer before the throttle fires.
|
|
193
|
+
if (state.turns.at(-1)?.result) this.flushFilm();
|
|
194
|
+
else this.filmTimer ??= setTimeout(() => this.flushFilm(), 250);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private flushFilm() {
|
|
198
|
+
clearTimeout(this.filmTimer);
|
|
199
|
+
this.filmTimer = undefined;
|
|
200
|
+
const s = this.filmLatest;
|
|
201
|
+
if (!s) return;
|
|
202
|
+
for (const turn of s.turns) {
|
|
203
|
+
if (this.filmed.get(turn.id) === turn) continue;
|
|
204
|
+
this.filmed.set(turn.id, turn);
|
|
205
|
+
this.record({ type: 'state', agent: turn.agent ?? s.agent, status: turn === s.turns.at(-1) ? s.status : 'idle', turn });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
close() {
|
|
210
|
+
this.session.close();
|
|
211
|
+
this.flushFilm();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The feed (Activity, the terminal) gets the gist: what was said, each tool, refusals, how a turn ended. */
|
|
215
|
+
private announce(state: SessionState) {
|
|
216
|
+
for (const turn of state.turns) {
|
|
217
|
+
if (!this.seen.has(turn.id)) {
|
|
218
|
+
this.seen.set(turn.id, 0);
|
|
219
|
+
this.shell = 0;
|
|
220
|
+
this.publish('turn.start', { turn: turn.id });
|
|
221
|
+
}
|
|
222
|
+
const from = this.seen.get(turn.id)!;
|
|
223
|
+
for (const [i, entry] of turn.entries.entries()) {
|
|
224
|
+
if (i < from) continue;
|
|
225
|
+
// A turn's entries only grow, so a turn and a place in it name an entry.
|
|
226
|
+
if (entry.kind === 'tool' && !this.tools.has(`${turn.id}:${i}`)) {
|
|
227
|
+
this.tools.add(`${turn.id}:${i}`);
|
|
228
|
+
this.publish('tool', { name: entry.name.replace(/^mcp__diorama__/, '') });
|
|
229
|
+
// The shell sees files, not the pictures the Studio's tools show: after a few shell calls in a turn,
|
|
230
|
+
// the agent is reminded once which tools look for it. It is still let through.
|
|
231
|
+
if (shellTool(entry.name) && ++this.shell === NUDGE_AFTER) {
|
|
232
|
+
this.publish('nudge', { text: NUDGE });
|
|
233
|
+
setTimeout(() => this.session.send(NUDGE), 0);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (entry.kind === 'approval' && entry.decision === 'deny') this.publish('blocked', { kind: entry.request.kind, summary: entry.request.summary ?? entry.request.kind });
|
|
237
|
+
if (entry.kind === 'error') this.publish('error', { text: entry.message });
|
|
238
|
+
}
|
|
239
|
+
// Entries still streaming are announced once they settle.
|
|
240
|
+
const settled = turn.entries.findIndex((e, i) => i >= from && ((e.kind === 'text' && e.streaming) || (e.kind === 'tool' && !e.done) || (e.kind === 'approval' && !e.decision)));
|
|
241
|
+
this.seen.set(turn.id, settled < 0 ? turn.entries.length : settled);
|
|
242
|
+
if (turn.result && !this.ended.has(turn.id)) {
|
|
243
|
+
this.ended.add(turn.id);
|
|
244
|
+
const said = turn.entries.filter((e) => e.kind === 'text').map((e) => (e as { text: string }).text).at(-1);
|
|
245
|
+
if (said) this.publish('message', { text: said });
|
|
246
|
+
this.remember(state.agent, turn, said);
|
|
247
|
+
this.publish('turn.end', { status: turn.result.status, ...(turn.result.error && { error: String(turn.result.error) }) });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The world's conversation in words, across Studio restarts and agents: studio/conversation.md. A fresh agent
|
|
254
|
+
* session is told to read it, so switching agents or restarting the Studio does not lose what was agreed.
|
|
255
|
+
*/
|
|
256
|
+
private remember(agent: string, turn: SessionState['turns'][number], said: string | undefined) {
|
|
257
|
+
const tools = [...new Set(turn.entries.flatMap((e) => (e.kind === 'tool' ? [toolName(e.name) ?? e.name] : [])))];
|
|
258
|
+
const who = turn.prompt.startsWith('[Studio]') ? 'Studio' : 'Person';
|
|
259
|
+
const lines = [`## ${new Date().toISOString().slice(0, 16).replace('T', ' ')} · ${agent}`, '', `**${who}:** ${turn.prompt.trim()}`, ''];
|
|
260
|
+
if (tools.length) lines.push(`**Tools:** ${tools.join(', ')}`, '');
|
|
261
|
+
lines.push(`**Agent:** ${said?.trim() || (turn.result?.status === 'completed' ? '(no words)' : `(turn ${turn.result?.status ?? 'ended'})`)}`, '', '');
|
|
262
|
+
try {
|
|
263
|
+
fs.mkdirSync(path.dirname(conversationFile(this.dir)), { recursive: true });
|
|
264
|
+
fs.appendFileSync(conversationFile(this.dir), redact(lines.join('\n')));
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A handover in the conversation file, so the next agent reads who it took over from. */
|
|
269
|
+
handedOver(from: string, to: string) {
|
|
270
|
+
try {
|
|
271
|
+
fs.mkdirSync(path.dirname(conversationFile(this.dir)), { recursive: true });
|
|
272
|
+
fs.appendFileSync(conversationFile(this.dir), `## ${new Date().toISOString().slice(0, 16).replace('T', ' ')} · handed over from ${from} to ${to}\n\n`);
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
publish(type: string, data: Record<string, unknown>) {
|
|
277
|
+
const clean = JSON.parse(redact(JSON.stringify(data))) as Record<string, unknown>;
|
|
278
|
+
this.record({ type, ...clean });
|
|
279
|
+
this.events.publish(`agent.${type}`, { world: this.slug, ...clean });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The session log (studio/sessions/*.jsonl), for replay in the film. */
|
|
283
|
+
record(data: Record<string, unknown>) {
|
|
284
|
+
try {
|
|
285
|
+
fs.mkdirSync(path.dirname(this.log), { recursive: true });
|
|
286
|
+
fs.appendFileSync(this.log, `${redact(JSON.stringify({ at: new Date().toISOString(), ...data }))}\n`);
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Codex asks before each MCP tool call through a request the app server's approval routing does not carry,
|
|
293
|
+
* so the call was refused before the panel could show it. The Studio's own tools are approved anyway (as for
|
|
294
|
+
* Muse and Claude): tell Codex so for the diorama server. Shell and file approvals still come to the panel.
|
|
295
|
+
*/
|
|
296
|
+
export function preapproved(agent: CodingAgent): CodingAgent {
|
|
297
|
+
return { ...agent, run: (task) => agent.run({ ...task, extraArgs: [...(task.extraArgs ?? []), '-c', 'mcp_servers.diorama.default_tools_approval_mode="approve"'] }) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The agents the Studio offers: those genaicode can hand the Studio's MCP server to. Muse Code first, the default. */
|
|
301
|
+
export const AGENTS = {
|
|
302
|
+
muse: { label: 'Muse Code', make: (command?: string) => museLive({ command: command ?? process.env.DIORAMA_MUSE_COMMAND ?? 'muse' }) },
|
|
303
|
+
claude: { label: 'Claude Code', make: () => claude() },
|
|
304
|
+
codex: { label: 'Codex', make: () => preapproved(codexLive()) },
|
|
305
|
+
} satisfies Record<string, { label: string; make(command?: string): CodingAgent }>;
|
|
306
|
+
export type AgentName = keyof typeof AGENTS;
|
|
307
|
+
export const isAgentName = (name: string): name is AgentName => Object.hasOwn(AGENTS, name);
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The world's earlier turns from its session logs (studio/sessions/*.jsonl, one per Studio session), oldest first,
|
|
311
|
+
* the last `limit` of them. Each keeps its last recorded state; one cut off by a restart shows as ended. Their ids
|
|
312
|
+
* are negative, so they never meet a live turn's.
|
|
313
|
+
*/
|
|
314
|
+
export function pastTurns(dir: string, limit = 60): SessionTurn[] {
|
|
315
|
+
const logs = path.join(dir, 'studio', 'sessions');
|
|
316
|
+
let files: string[] = [];
|
|
317
|
+
try {
|
|
318
|
+
files = fs.readdirSync(logs).filter((f) => f.endsWith('.jsonl')).sort();
|
|
319
|
+
} catch {
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
const turns: SessionTurn[] = [];
|
|
323
|
+
for (const file of files) {
|
|
324
|
+
const last = new Map<number, SessionTurn>();
|
|
325
|
+
for (const line of fs.readFileSync(path.join(logs, file), 'utf8').split('\n')) {
|
|
326
|
+
if (!/"type":\s*"state"/.test(line)) continue;
|
|
327
|
+
try {
|
|
328
|
+
const turn = (JSON.parse(line) as { turn?: SessionTurn }).turn;
|
|
329
|
+
if (turn && typeof turn.id === 'number' && Array.isArray(turn.entries)) last.set(turn.id, turn);
|
|
330
|
+
} catch {}
|
|
331
|
+
}
|
|
332
|
+
turns.push(...[...last.values()].sort((a, b) => a.id - b.id));
|
|
333
|
+
}
|
|
334
|
+
const kept = turns.sort((a, b) => a.startedAt - b.startedAt).slice(-limit);
|
|
335
|
+
return kept.map((turn, i) => ({
|
|
336
|
+
...turn,
|
|
337
|
+
id: i - kept.length,
|
|
338
|
+
endedAt: turn.endedAt ?? turn.startedAt,
|
|
339
|
+
// A turn the restart cut off ended there: without a result the chat would show it still working.
|
|
340
|
+
result: turn.result ?? { status: 'aborted' as const, ok: false, exitCode: null, signal: null },
|
|
341
|
+
entries: turn.entries.map((e) => (e.kind === 'text' ? { ...e, streaming: false } : e.kind === 'tool' ? { ...e, done: true } : e.kind === 'approval' && !e.decision ? { ...e, decision: 'deny' as const } : e)),
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const conversationFile = (dir: string) => path.join(dir, 'studio', 'conversation.md');
|
|
346
|
+
|
|
347
|
+
/** How much of the conversation a fresh session is handed in its brief: the last turns, about this many bytes. */
|
|
348
|
+
const HANDED = 8000;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* For a fresh agent session: the conversation so far, when there is one. Its last turns are in the brief itself,
|
|
352
|
+
* since an agent that keeps to the Studio's tools (Codex) does not open files; the whole of it stays in the file.
|
|
353
|
+
*/
|
|
354
|
+
export function earlier(dir: string): string {
|
|
355
|
+
let text = '';
|
|
356
|
+
try {
|
|
357
|
+
text = fs.readFileSync(conversationFile(dir), 'utf8');
|
|
358
|
+
} catch {
|
|
359
|
+
return '';
|
|
360
|
+
}
|
|
361
|
+
if (!text.trim()) return '';
|
|
362
|
+
// Whole turns only: cut at a turn's heading.
|
|
363
|
+
let tail = text.slice(-HANDED);
|
|
364
|
+
if (tail.length < text.length) tail = tail.slice(Math.max(0, tail.indexOf('\n## ') + 1));
|
|
365
|
+
return `\nYou are joining a conversation about this world that started before this session (another agent, or before the Studio restarted). `
|
|
366
|
+
+ 'The person sees the same conversation on screen, so carry on from it: what they asked, what was changed and what is still open. '
|
|
367
|
+
+ `${tail.length < text.length ? `Its last turns (all of it, ${Math.round(text.length / 1024)} KB, is in studio/conversation.md):` : 'Here it is (also in studio/conversation.md):'}\n\n<conversation>\n${tail.trim()}\n</conversation>\n`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export class AgentHub {
|
|
371
|
+
private readonly talks = new Map<string, Talk>();
|
|
372
|
+
|
|
373
|
+
constructor(
|
|
374
|
+
private readonly events: Events,
|
|
375
|
+
private readonly opts: { root: string; mcpUrl: string; agentToken: string; command?: string },
|
|
376
|
+
) {}
|
|
377
|
+
|
|
378
|
+
/** The agent last picked in the Studio, for new conversations (~/.config/diorama/studio.json). */
|
|
379
|
+
private static prefs = () => path.join(configDir(), 'studio.json');
|
|
380
|
+
private saved(): string | undefined {
|
|
381
|
+
try {
|
|
382
|
+
return (JSON.parse(fs.readFileSync(AgentHub.prefs(), 'utf8')) as { agent?: string }).agent;
|
|
383
|
+
} catch {
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
private save(name: AgentName) {
|
|
388
|
+
try {
|
|
389
|
+
fs.mkdirSync(path.dirname(AgentHub.prefs()), { recursive: true });
|
|
390
|
+
const old = fs.existsSync(AgentHub.prefs()) ? JSON.parse(fs.readFileSync(AgentHub.prefs(), 'utf8')) : {};
|
|
391
|
+
fs.writeFileSync(AgentHub.prefs(), `${JSON.stringify({ ...old, agent: name }, null, 2)}\n`);
|
|
392
|
+
} catch {}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private talk(slug: string): Talk {
|
|
396
|
+
let t = this.talks.get(slug);
|
|
397
|
+
if (t) return t;
|
|
398
|
+
const dir = path.join(this.opts.root, slug);
|
|
399
|
+
const cwd = fs.existsSync(dir) ? dir : this.opts.root;
|
|
400
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
401
|
+
const ask = process.env.DIORAMA_AGENT_TOOLS === 'ask';
|
|
402
|
+
const agent = this.make(this.defaultAgent(slug));
|
|
403
|
+
const session = createAgentSession({
|
|
404
|
+
agent,
|
|
405
|
+
cwd,
|
|
406
|
+
task: {
|
|
407
|
+
env: withoutProviderCredentials(withoutSecrets(process.env)) as Record<string, string>,
|
|
408
|
+
// genaicode hands the server to Muse as -c overrides, the token through an environment variable, never argv.
|
|
409
|
+
// The URL names the world this session is about, so a call that leaves out `slug` means this world.
|
|
410
|
+
mcpServers: [{ name: 'diorama', url: `${this.opts.mcpUrl}?world=${slug}`, headers: { Authorization: `Bearer ${this.opts.agentToken}` } }],
|
|
411
|
+
permissions: { approval: 'ask' },
|
|
412
|
+
},
|
|
413
|
+
// The agent may use any tool; the brief steers it to the Studio's. Each turn starts from a snapshot, so
|
|
414
|
+
// Undo takes back a shell command or a file edit too, and the file watcher shows such changes live.
|
|
415
|
+
// DIORAMA_AGENT_TOOLS=ask puts everything but the Studio's tools and reading to the person instead.
|
|
416
|
+
autoApprove: (request) => {
|
|
417
|
+
if (studioTool(request) || readsOnly(request)) return 'approve';
|
|
418
|
+
t?.record({ type: 'outside', kind: request.kind, detail: request.detail });
|
|
419
|
+
return ask ? undefined : 'approve';
|
|
420
|
+
},
|
|
421
|
+
// The first prompt carries the brief; later ones resume Muse's own session and go as typed.
|
|
422
|
+
transformPrompt: (prompt) => {
|
|
423
|
+
if (t?.briefed && t.resumes && session.get().sessionId) return prompt;
|
|
424
|
+
if (t) t.briefed = true;
|
|
425
|
+
return `${AGENT_BRIEF(slug)}${earlier(dir)}\n\nPerson: ${prompt}`;
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
t = new Talk(slug, session, dir, this.events);
|
|
429
|
+
t.resumes = !!agent.capabilities.resume;
|
|
430
|
+
this.talks.set(slug, t);
|
|
431
|
+
return t;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private make(name: AgentName): CodingAgent {
|
|
435
|
+
return name === 'muse' ? AGENTS.muse.make(this.opts.command) : AGENTS[name].make();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* DIORAMA_AGENT when set; else the agent this world's conversation was last with, so a restart carries on with
|
|
440
|
+
* it; else the one last picked in the Studio; else Muse Code, or the first agent this machine has.
|
|
441
|
+
*/
|
|
442
|
+
private defaultAgent(slug?: string): AgentName {
|
|
443
|
+
const wanted = process.env.DIORAMA_AGENT;
|
|
444
|
+
if (wanted && isAgentName(wanted)) return wanted;
|
|
445
|
+
const names = Object.keys(AGENTS) as AgentName[];
|
|
446
|
+
const found = detectAgents(names.map((n) => this.make(n)));
|
|
447
|
+
const installed = (n: string | undefined) => names.find((name, i) => found[i]?.path && (name === n || found[i]?.agent.name === n));
|
|
448
|
+
const last = slug ? pastTurns(path.join(this.opts.root, slug), 1)[0]?.agent : undefined;
|
|
449
|
+
return installed(last) ?? installed(this.saved()) ?? names.find((_, i) => found[i]?.path) ?? 'muse';
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The agents on offer, which are installed, and which one this world's conversation uses. */
|
|
453
|
+
agents(slug: string) {
|
|
454
|
+
const found = detectAgents((Object.keys(AGENTS) as AgentName[]).map((n) => this.make(n)));
|
|
455
|
+
const using = this.talks.get(slug)?.session.get().agent ?? this.make(this.defaultAgent(slug)).name;
|
|
456
|
+
return (Object.keys(AGENTS) as AgentName[]).map((name, i) => ({ name, label: AGENTS[name].label, agent: found[i]?.agent.name ?? name, installed: !!found[i]?.path, using: found[i]?.agent.name === using }));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** For Setup: the agents, which are installed, and the one new worlds start with. */
|
|
460
|
+
defaults() {
|
|
461
|
+
const names = Object.keys(AGENTS) as AgentName[];
|
|
462
|
+
const found = detectAgents(names.map((n) => this.make(n)));
|
|
463
|
+
const pick = this.defaultAgent();
|
|
464
|
+
return { env: process.env.DIORAMA_AGENT ?? null, agents: names.map((name, i) => ({ name, label: AGENTS[name].label, installed: !!found[i]?.path, default: name === pick })) };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Setup's choice of the agent new worlds start with. */
|
|
468
|
+
setDefault(name: AgentName) {
|
|
469
|
+
this.save(name);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Switches this world's conversation to another agent; it starts a fresh session, the transcript stays. */
|
|
473
|
+
use(slug: string, name: AgentName) {
|
|
474
|
+
const t = this.talk(slug);
|
|
475
|
+
if (t.session.get().status === 'running') throw new Error('the agent is working: stop it or wait before switching');
|
|
476
|
+
const from = this.agents(slug).find((a) => a.using)?.label;
|
|
477
|
+
const agent = this.make(name);
|
|
478
|
+
t.session.setAgent(agent);
|
|
479
|
+
t.resumes = !!agent.capabilities.resume;
|
|
480
|
+
t.rebrief();
|
|
481
|
+
this.save(name);
|
|
482
|
+
if (from && from !== AGENTS[name].label && (t.past.length || t.session.get().turns.length)) t.handedOver(from, AGENTS[name].label);
|
|
483
|
+
t.publish('agent', { name, label: AGENTS[name].label });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** What the page shows: genaicode's session state, secrets blanked; null before the first message. */
|
|
487
|
+
state(slug: string): SessionState | null {
|
|
488
|
+
const t = this.talks.get(slug) ?? (pastTurns(path.join(this.opts.root, slug), 1).length ? this.talk(slug) : undefined);
|
|
489
|
+
return t ? safe(t.shown(t.session.get())) : null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Every change of a world's conversation, for the page's live view. */
|
|
493
|
+
watch(slug: string, listener: (state: SessionState) => void): () => void {
|
|
494
|
+
const t = this.talk(slug);
|
|
495
|
+
const send = () => listener(safe(t.shown(t.session.get())));
|
|
496
|
+
const off = t.session.subscribe(send);
|
|
497
|
+
t.listeners.add(send);
|
|
498
|
+
return () => {
|
|
499
|
+
off();
|
|
500
|
+
t.listeners.delete(send);
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** For the MCP endpoint: a Studio tool the agent of this world called, settled by the returned function. */
|
|
505
|
+
called(slug: string, name: string, args: Record<string, unknown>): (ok: boolean, result: unknown) => void {
|
|
506
|
+
return this.talks.get(slug)?.call(name, args) ?? (() => {});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** A message from the person: steers the running turn, or starts a new one that resumes the conversation. */
|
|
510
|
+
async say(slug: string, text: string, from: 'person' | 'studio' = 'person'): Promise<void> {
|
|
511
|
+
const t = this.talk(slug);
|
|
512
|
+
// The Studio's own wake-ups (a repair after a build) show as such, not as the person speaking.
|
|
513
|
+
t.publish(from === 'studio' ? 'wake' : 'user', { text });
|
|
514
|
+
// A new turn starts from a snapshot of the world, whatever tools the agent then uses.
|
|
515
|
+
const dir = path.join(this.opts.root, slug);
|
|
516
|
+
if (t.session.get().status === 'idle' && fs.existsSync(dir)) {
|
|
517
|
+
try {
|
|
518
|
+
new History(dir).snapshot(`agent: ${text.slice(0, 60)}`, 'agent');
|
|
519
|
+
} catch {}
|
|
520
|
+
}
|
|
521
|
+
// The Studio's own messages go as host notes: never steered into a running turn, they run after it.
|
|
522
|
+
const steering = from === 'person' && t.session.get().status === 'running' && t.session.get().canSteer;
|
|
523
|
+
t.session.send(text, from === 'studio' ? { note: true } : undefined);
|
|
524
|
+
// A steer reaches the agent between tool calls: a job_wait under way ends now, so it is not held for minutes.
|
|
525
|
+
if (steering) for (const listener of [...(this.hearing.get(slug) ?? [])]) listener(text);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Who waits on this world's agent being steered by the person (a job_wait the agent is in). */
|
|
529
|
+
private readonly hearing = new Map<string, Set<(text: string) => void>>();
|
|
530
|
+
|
|
531
|
+
/** Calls `listener` with each message the person steers this world's agent with, until the returned function is called. */
|
|
532
|
+
onPersonSays(slug: string, listener: (text: string) => void): () => void {
|
|
533
|
+
const set = this.hearing.get(slug) ?? new Set();
|
|
534
|
+
set.add(listener);
|
|
535
|
+
this.hearing.set(slug, set);
|
|
536
|
+
return () => {
|
|
537
|
+
set.delete(listener);
|
|
538
|
+
if (!set.size) this.hearing.delete(slug);
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
stop(slug: string) {
|
|
543
|
+
this.talks.get(slug)?.session.stop();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
answer(slug: string, id: string, decision: ApprovalDecision) {
|
|
547
|
+
if (!this.talks.get(slug)?.session.approve(id, decision)) throw new Error('that question is no longer open');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
stopAll() {
|
|
551
|
+
for (const t of this.talks.values()) t.close();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export type { ApprovalRequest };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// While `diorama studio` runs, ~/.config/diorama/studio/<hash of the folder>.json (0600) tells `diorama mcp`
|
|
2
|
+
// started in the same folder where it is and the agent's token. It is removed when the Studio stops.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { configDir } from '../secrets.ts';
|
|
7
|
+
|
|
8
|
+
type StudioFile = { url: string; agentToken: string; pid: number; cwd: string };
|
|
9
|
+
|
|
10
|
+
const fileFor = (cwd: string) => path.join(configDir(), 'studio', `${createHash('sha256').update(path.resolve(cwd)).digest('hex').slice(0, 16)}.json`);
|
|
11
|
+
|
|
12
|
+
export function writeStudioFile(cwd: string, info: Omit<StudioFile, 'pid' | 'cwd'>): () => void {
|
|
13
|
+
const file = fileFor(cwd);
|
|
14
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
15
|
+
fs.writeFileSync(file, JSON.stringify({ ...info, pid: process.pid, cwd: path.resolve(cwd) }), { mode: 0o600 });
|
|
16
|
+
return () => fs.rmSync(file, { force: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The running Studio for this folder, or null (no file, or its process is gone). */
|
|
20
|
+
export function readStudioFile(cwd: string): StudioFile | null {
|
|
21
|
+
try {
|
|
22
|
+
const info = JSON.parse(fs.readFileSync(fileFor(cwd), 'utf8')) as StudioFile;
|
|
23
|
+
process.kill(info.pid, 0);
|
|
24
|
+
return info;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|