@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.
Files changed (302) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/app/bin/diorama.mjs +13 -0
  4. package/app/src/actions/budget.ts +160 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +267 -0
  7. package/app/src/actions/depth.ts +95 -0
  8. package/app/src/actions/extend.ts +36 -0
  9. package/app/src/actions/faces.ts +160 -0
  10. package/app/src/actions/feedback.ts +87 -0
  11. package/app/src/actions/fix.ts +382 -0
  12. package/app/src/actions/history.ts +93 -0
  13. package/app/src/actions/idea-preview.ts +65 -0
  14. package/app/src/actions/idea-review.ts +102 -0
  15. package/app/src/actions/idea-sketch.ts +172 -0
  16. package/app/src/actions/index.ts +32 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/language.ts +52 -0
  20. package/app/src/actions/models.ts +64 -0
  21. package/app/src/actions/motion.ts +119 -0
  22. package/app/src/actions/pictures.ts +49 -0
  23. package/app/src/actions/reactions.ts +36 -0
  24. package/app/src/actions/registry.ts +101 -0
  25. package/app/src/actions/share.ts +32 -0
  26. package/app/src/actions/sound.ts +73 -0
  27. package/app/src/actions/start.ts +154 -0
  28. package/app/src/actions/step-work.ts +150 -0
  29. package/app/src/actions/story.ts +101 -0
  30. package/app/src/actions/studio.ts +133 -0
  31. package/app/src/actions/style.ts +72 -0
  32. package/app/src/actions/video.ts +46 -0
  33. package/app/src/actions/view.ts +109 -0
  34. package/app/src/actions/weather.ts +38 -0
  35. package/app/src/actions/world.ts +290 -0
  36. package/app/src/commands/action.ts +39 -0
  37. package/app/src/commands/backdrop.ts +188 -0
  38. package/app/src/commands/browsers.ts +21 -0
  39. package/app/src/commands/build.ts +503 -0
  40. package/app/src/commands/cast.ts +161 -0
  41. package/app/src/commands/clip.ts +431 -0
  42. package/app/src/commands/derive.ts +167 -0
  43. package/app/src/commands/doctor.ts +74 -0
  44. package/app/src/commands/explore.ts +123 -0
  45. package/app/src/commands/export.ts +158 -0
  46. package/app/src/commands/extend.ts +75 -0
  47. package/app/src/commands/faces.ts +80 -0
  48. package/app/src/commands/icons.ts +69 -0
  49. package/app/src/commands/index.ts +37 -0
  50. package/app/src/commands/language.ts +87 -0
  51. package/app/src/commands/layout.ts +31 -0
  52. package/app/src/commands/masks.ts +117 -0
  53. package/app/src/commands/mcp.ts +52 -0
  54. package/app/src/commands/model.ts +284 -0
  55. package/app/src/commands/paint.ts +182 -0
  56. package/app/src/commands/plates.ts +97 -0
  57. package/app/src/commands/play.ts +195 -0
  58. package/app/src/commands/playthrough.ts +111 -0
  59. package/app/src/commands/publish.ts +78 -0
  60. package/app/src/commands/reactions.ts +40 -0
  61. package/app/src/commands/reel.ts +46 -0
  62. package/app/src/commands/setup.ts +85 -0
  63. package/app/src/commands/sound.ts +100 -0
  64. package/app/src/commands/sprites.ts +232 -0
  65. package/app/src/commands/story.ts +37 -0
  66. package/app/src/commands/studio.ts +65 -0
  67. package/app/src/commands/style.ts +87 -0
  68. package/app/src/commands/verify.ts +45 -0
  69. package/app/src/commands/voice-retry.ts +14 -0
  70. package/app/src/commands/voices.ts +145 -0
  71. package/app/src/commands/weather.ts +52 -0
  72. package/app/src/commands/world.ts +104 -0
  73. package/app/src/film/clip.ts +386 -0
  74. package/app/src/film/reel.ts +126 -0
  75. package/app/src/film/screencast.ts +123 -0
  76. package/app/src/image/mask.ts +29 -0
  77. package/app/src/image/overlay.ts +38 -0
  78. package/app/src/image/raster.ts +224 -0
  79. package/app/src/main.ts +28 -0
  80. package/app/src/meta/choice.ts +27 -0
  81. package/app/src/meta/http.ts +99 -0
  82. package/app/src/meta/memo.ts +105 -0
  83. package/app/src/meta/muse.ts +70 -0
  84. package/app/src/meta/openrouter.ts +93 -0
  85. package/app/src/meta/prices.ts +25 -0
  86. package/app/src/meta/sam.ts +83 -0
  87. package/app/src/meta/schema.ts +63 -0
  88. package/app/src/meta/spark.ts +220 -0
  89. package/app/src/meta/step.ts +14 -0
  90. package/app/src/meta/voice-file.ts +78 -0
  91. package/app/src/meta/voice.ts +103 -0
  92. package/app/src/model/animation.ts +38 -0
  93. package/app/src/model/blender/relax.py +284 -0
  94. package/app/src/model/blender.ts +115 -0
  95. package/app/src/model/build.ts +50 -0
  96. package/app/src/model/face.ts +21 -0
  97. package/app/src/model/fit.ts +181 -0
  98. package/app/src/model/glb.ts +241 -0
  99. package/app/src/model/plan.ts +151 -0
  100. package/app/src/model/quality.ts +48 -0
  101. package/app/src/model/space.ts +168 -0
  102. package/app/src/model/sprites.ts +293 -0
  103. package/app/src/model/views.ts +163 -0
  104. package/app/src/play/creature.ts +43 -0
  105. package/app/src/play/depth-occlusion.ts +150 -0
  106. package/app/src/play/exit-outline.ts +48 -0
  107. package/app/src/play/face3d.ts +34 -0
  108. package/app/src/play/focus.ts +78 -0
  109. package/app/src/play/geometry.ts +272 -0
  110. package/app/src/play/gestures.ts +23 -0
  111. package/app/src/play/glow.ts +187 -0
  112. package/app/src/play/lipsync.ts +69 -0
  113. package/app/src/play/living.ts +83 -0
  114. package/app/src/play/outline-path.ts +130 -0
  115. package/app/src/play/parallax.ts +200 -0
  116. package/app/src/play/path.ts +262 -0
  117. package/app/src/play/preview.ts +71 -0
  118. package/app/src/play/reactions.ts +92 -0
  119. package/app/src/play/rig.ts +172 -0
  120. package/app/src/play/shadow.ts +253 -0
  121. package/app/src/play/sound.ts +51 -0
  122. package/app/src/play/weather.ts +58 -0
  123. package/app/src/qa/explore.ts +136 -0
  124. package/app/src/qa/probe.ts +139 -0
  125. package/app/src/qa/route.ts +118 -0
  126. package/app/src/registry.ts +23 -0
  127. package/app/src/scene/animation-source.ts +36 -0
  128. package/app/src/scene/assets.ts +37 -0
  129. package/app/src/scene/autofix.ts +133 -0
  130. package/app/src/scene/backdrop.ts +326 -0
  131. package/app/src/scene/blockin.ts +189 -0
  132. package/app/src/scene/brief.ts +74 -0
  133. package/app/src/scene/carry.ts +177 -0
  134. package/app/src/scene/cast-fixes.ts +33 -0
  135. package/app/src/scene/cast.ts +199 -0
  136. package/app/src/scene/corrections.ts +164 -0
  137. package/app/src/scene/critic.ts +169 -0
  138. package/app/src/scene/depthmap.ts +149 -0
  139. package/app/src/scene/derive.ts +475 -0
  140. package/app/src/scene/diagnose.ts +132 -0
  141. package/app/src/scene/face-calibration.ts +65 -0
  142. package/app/src/scene/face-registration.ts +21 -0
  143. package/app/src/scene/face-work.ts +71 -0
  144. package/app/src/scene/faces-data.ts +48 -0
  145. package/app/src/scene/faces-settings.ts +20 -0
  146. package/app/src/scene/faces.ts +435 -0
  147. package/app/src/scene/glows.ts +153 -0
  148. package/app/src/scene/held.ts +109 -0
  149. package/app/src/scene/icons.ts +185 -0
  150. package/app/src/scene/layers.ts +822 -0
  151. package/app/src/scene/layout.ts +216 -0
  152. package/app/src/scene/locate.ts +118 -0
  153. package/app/src/scene/maskcheck.ts +43 -0
  154. package/app/src/scene/masks.ts +392 -0
  155. package/app/src/scene/occluders.ts +123 -0
  156. package/app/src/scene/occlusion-check.ts +138 -0
  157. package/app/src/scene/paint-layout.ts +135 -0
  158. package/app/src/scene/paint.ts +81 -0
  159. package/app/src/scene/photo.ts +123 -0
  160. package/app/src/scene/plates.ts +256 -0
  161. package/app/src/scene/playbook.ts +121 -0
  162. package/app/src/scene/preview.ts +23 -0
  163. package/app/src/scene/reactions.ts +118 -0
  164. package/app/src/scene/restyle.ts +53 -0
  165. package/app/src/scene/rig-check.ts +68 -0
  166. package/app/src/scene/rig.ts +379 -0
  167. package/app/src/scene/scale.ts +148 -0
  168. package/app/src/scene/shadows.ts +215 -0
  169. package/app/src/scene/stairs.ts +183 -0
  170. package/app/src/scene/status.ts +32 -0
  171. package/app/src/scene/verify.ts +156 -0
  172. package/app/src/scene/walk.ts +326 -0
  173. package/app/src/scene/warp.ts +419 -0
  174. package/app/src/scene/weather.ts +118 -0
  175. package/app/src/scene/words.ts +65 -0
  176. package/app/src/secrets.ts +219 -0
  177. package/app/src/setup/checks.ts +110 -0
  178. package/app/src/sound/cost.ts +74 -0
  179. package/app/src/sound/data.ts +89 -0
  180. package/app/src/sound/fixes.ts +23 -0
  181. package/app/src/sound/issues.ts +21 -0
  182. package/app/src/sound/language.ts +11 -0
  183. package/app/src/sound/lipsync-make.ts +98 -0
  184. package/app/src/sound/lipsync.ts +24 -0
  185. package/app/src/sound/make.ts +229 -0
  186. package/app/src/sound/musicgen.py +20 -0
  187. package/app/src/sound/pcm.ts +280 -0
  188. package/app/src/sound/plan.ts +154 -0
  189. package/app/src/sound/retry.ts +43 -0
  190. package/app/src/sound/settings.ts +28 -0
  191. package/app/src/sound/sources.ts +81 -0
  192. package/app/src/sound/spoken.ts +70 -0
  193. package/app/src/sound/voices.ts +579 -0
  194. package/app/src/speech/transcribe.ts +38 -0
  195. package/app/src/speech/types.ts +18 -0
  196. package/app/src/story/check.ts +230 -0
  197. package/app/src/story/design.ts +108 -0
  198. package/app/src/story/engine.ts +233 -0
  199. package/app/src/story/fixes.ts +54 -0
  200. package/app/src/story/npc-guidance.ts +7 -0
  201. package/app/src/story/phrases.ts +4 -0
  202. package/app/src/story/schema.ts +92 -0
  203. package/app/src/studio/agent.ts +555 -0
  204. package/app/src/studio/discovery.ts +28 -0
  205. package/app/src/studio/events.ts +48 -0
  206. package/app/src/studio/history.ts +124 -0
  207. package/app/src/studio/jobs.ts +291 -0
  208. package/app/src/studio/locks.ts +33 -0
  209. package/app/src/studio/mcp.ts +86 -0
  210. package/app/src/studio/operation.ts +18 -0
  211. package/app/src/studio/repair.ts +31 -0
  212. package/app/src/studio/server.ts +423 -0
  213. package/app/src/studio/trouble.ts +48 -0
  214. package/app/src/world/design.ts +72 -0
  215. package/app/src/world/drawings.ts +198 -0
  216. package/app/src/world/extend-stage.ts +109 -0
  217. package/app/src/world/extend.ts +123 -0
  218. package/app/src/world/extension-progress.ts +10 -0
  219. package/app/src/world/files.ts +62 -0
  220. package/app/src/world/idea-review.ts +32 -0
  221. package/app/src/world/language.ts +116 -0
  222. package/app/src/world/photo-limits.ts +2 -0
  223. package/app/src/world/pool.ts +41 -0
  224. package/app/src/world/schedule.ts +64 -0
  225. package/app/src/world/schema.ts +74 -0
  226. package/app/src/world/sketch.ts +81 -0
  227. package/app/src/world/style-plan.ts +90 -0
  228. package/app/src/world/style-reference.ts +16 -0
  229. package/app/src/world/styled.ts +51 -0
  230. package/app/src/world/told.ts +105 -0
  231. package/app/src/world/video-limits.ts +3 -0
  232. package/app/src/world/video.ts +98 -0
  233. package/app/studio/activity.tsx +124 -0
  234. package/app/studio/agent.tsx +384 -0
  235. package/app/studio/api.ts +111 -0
  236. package/app/studio/atelier.tsx +234 -0
  237. package/app/studio/audio-missing.tsx +25 -0
  238. package/app/studio/audio-setup.tsx +33 -0
  239. package/app/studio/blender-setup.tsx +30 -0
  240. package/app/studio/build.tsx +679 -0
  241. package/app/studio/camera.tsx +59 -0
  242. package/app/studio/cast-work.tsx +207 -0
  243. package/app/studio/cast.tsx +178 -0
  244. package/app/studio/concept-edit.tsx +47 -0
  245. package/app/studio/creator-idea.tsx +46 -0
  246. package/app/studio/custom-look.tsx +36 -0
  247. package/app/studio/faces-setup.tsx +28 -0
  248. package/app/studio/favicon.svg +11 -0
  249. package/app/studio/figure.tsx +314 -0
  250. package/app/studio/fonts/OFL.txt +93 -0
  251. package/app/studio/fonts/fonts.css +7 -0
  252. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  253. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  254. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  255. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  256. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  257. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  258. package/app/studio/icons.tsx +42 -0
  259. package/app/studio/idea-card.tsx +42 -0
  260. package/app/studio/idea-review.tsx +181 -0
  261. package/app/studio/idea-storyboard.tsx +134 -0
  262. package/app/studio/index.html +15 -0
  263. package/app/studio/job-words.ts +33 -0
  264. package/app/studio/lightbox.tsx +49 -0
  265. package/app/studio/main.tsx +1329 -0
  266. package/app/studio/nav.ts +14 -0
  267. package/app/studio/place.tsx +264 -0
  268. package/app/studio/review-sketch.tsx +47 -0
  269. package/app/studio/say-it.tsx +49 -0
  270. package/app/studio/step-work.tsx +134 -0
  271. package/app/studio/story.tsx +394 -0
  272. package/app/studio/style-menu.tsx +81 -0
  273. package/app/studio/style.css +1390 -0
  274. package/app/studio/sure.tsx +54 -0
  275. package/app/studio/tip.tsx +74 -0
  276. package/app/studio/tsconfig.json +5 -0
  277. package/app/studio/video.tsx +73 -0
  278. package/app/studio/world.tsx +188 -0
  279. package/app/tsconfig.json +13 -0
  280. package/app/web/audio.ts +315 -0
  281. package/app/web/face.ts +131 -0
  282. package/app/web/face3d.ts +35 -0
  283. package/app/web/glows.ts +387 -0
  284. package/app/web/hero3d.ts +728 -0
  285. package/app/web/index.html +118 -0
  286. package/app/web/living.ts +404 -0
  287. package/app/web/main.ts +3769 -0
  288. package/app/web/pad.ts +174 -0
  289. package/app/web/reactions.ts +79 -0
  290. package/app/web/sheet.ts +36 -0
  291. package/app/web/synth.ts +140 -0
  292. package/app/web/tsconfig.json +5 -0
  293. package/app/web/ui.ts +263 -0
  294. package/app/web/weather.ts +306 -0
  295. package/app/web/words.ts +12 -0
  296. package/docs/characters.md +58 -0
  297. package/docs/npc-motion.md +69 -0
  298. package/docs/scenes.md +75 -0
  299. package/docs/setup.md +60 -0
  300. package/docs/sound.md +23 -0
  301. package/docs/studio.md +62 -0
  302. package/package.json +70 -4
@@ -0,0 +1,48 @@
1
+ // The live feed: everything the studio, its jobs and the agent do becomes an event with a sequence number.
2
+ // The browser and `diorama mcp` read the same feed, so a change made by either shows up in the other at once.
3
+
4
+ export type StudioEvent = { seq: number; at: string; type: string; world?: string; [k: string]: unknown };
5
+ type Listener = (e: StudioEvent) => void;
6
+
7
+ /** Events a page opened mid-build needs to draw the build's progress and its model call counts: a long build's
8
+ * logs and files push them out of the recent window long before it ends, so they are kept apart (else a refreshed
9
+ * Studio showed done steps as empty and counted calls from zero). */
10
+ const LASTING = new Set(['job.queued', 'job.start', 'job.end', 'build.start', 'step.start', 'step.end', 'step.fail', 'call']);
11
+
12
+ export class Events {
13
+ private seq = 0;
14
+ private readonly recent: StudioEvent[] = [];
15
+ private readonly lasting: StudioEvent[] = [];
16
+ private readonly listeners = new Set<Listener>();
17
+
18
+ constructor(private readonly keep = 2000) {}
19
+
20
+ publish(type: string, data: Record<string, unknown> = {}): StudioEvent {
21
+ const event: StudioEvent = { ...data, seq: ++this.seq, at: new Date().toISOString(), type };
22
+ this.recent.push(event);
23
+ if (this.recent.length > this.keep) this.recent.splice(0, this.recent.length - this.keep);
24
+ if (LASTING.has(type)) {
25
+ this.lasting.push(event);
26
+ if (this.lasting.length > this.keep * 5) this.lasting.splice(0, this.lasting.length - this.keep * 5);
27
+ }
28
+ for (const listener of this.listeners) listener(event);
29
+ return event;
30
+ }
31
+
32
+ /** Events after `seq` (for a reconnecting client), optionally for one world plus the studio-wide ones. */
33
+ since(seq: number, world?: string): StudioEvent[] {
34
+ const oldest = this.recent[0]?.seq ?? Infinity;
35
+ const kept = this.lasting.filter((e) => e.seq < oldest);
36
+ return [...kept, ...this.recent].filter((e) => e.seq > seq && (!world || !e.world || e.world === world));
37
+ }
38
+
39
+ /** The sequence number of the latest event. */
40
+ get last(): number {
41
+ return this.seq;
42
+ }
43
+
44
+ subscribe(listener: Listener): () => void {
45
+ this.listeners.add(listener);
46
+ return () => this.listeners.delete(listener);
47
+ }
48
+ }
@@ -0,0 +1,124 @@
1
+ // Undo for everything the Studio runs: before a job or an edit touches a world, its files are snapshotted
2
+ // under worlds/<slug>/studio/history/. Files are stored once by content hash, so a snapshot after the
3
+ // first costs only what changed. The memo (model answers) is not part of it: it never changes meaning.
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import type { Events } from './events.ts';
8
+ import type { Job } from './jobs.ts';
9
+
10
+ export type Snapshot = { id: string; at: string; label: string; by: string; files: Record<string, string> };
11
+ type Index = Record<string, { size: number; mtimeMs: number; hash: string }>;
12
+
13
+ const SKIP = new Set(['memo', 'studio']);
14
+
15
+ function walk(dir: string, rel = ''): string[] {
16
+ if (!fs.existsSync(path.join(dir, rel))) return [];
17
+ return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap((d) => {
18
+ const r = rel ? `${rel}/${d.name}` : d.name;
19
+ if (!rel && SKIP.has(d.name)) return [];
20
+ if (d.isDirectory()) return walk(dir, r);
21
+ return d.isFile() && !d.name.endsWith('.tmp') ? [r] : [];
22
+ });
23
+ }
24
+
25
+ export class History {
26
+ readonly home: string;
27
+
28
+ constructor(readonly dir: string) {
29
+ this.home = path.join(dir, 'studio', 'history');
30
+ }
31
+
32
+ private blob(hash: string) {
33
+ return path.join(this.home, 'blobs', hash.slice(0, 2), hash);
34
+ }
35
+
36
+ /** The world's files right now, by content hash (rehashing only files whose size or mtime moved). */
37
+ current(): Record<string, string> {
38
+ const indexFile = path.join(this.home, 'index.json');
39
+ let index: Index = {};
40
+ try {
41
+ index = JSON.parse(fs.readFileSync(indexFile, 'utf8')) as Index;
42
+ } catch {}
43
+ const files: Record<string, string> = {};
44
+ const next: Index = {};
45
+ for (const rel of walk(this.dir)) {
46
+ const at = path.join(this.dir, rel);
47
+ const stat = fs.statSync(at);
48
+ const known = index[rel];
49
+ const hash = known && known.size === stat.size && known.mtimeMs === stat.mtimeMs
50
+ ? known.hash
51
+ : createHash('sha256').update(fs.readFileSync(at)).digest('hex');
52
+ if (!fs.existsSync(this.blob(hash))) {
53
+ fs.mkdirSync(path.dirname(this.blob(hash)), { recursive: true });
54
+ fs.copyFileSync(at, this.blob(hash), fs.constants.COPYFILE_FICLONE);
55
+ }
56
+ files[rel] = hash;
57
+ next[rel] = { size: stat.size, mtimeMs: stat.mtimeMs, hash };
58
+ }
59
+ fs.mkdirSync(this.home, { recursive: true });
60
+ fs.writeFileSync(indexFile, JSON.stringify(next));
61
+ return files;
62
+ }
63
+
64
+ list(): Snapshot[] {
65
+ if (!fs.existsSync(this.home)) return [];
66
+ return fs.readdirSync(this.home)
67
+ .filter((f) => /^\d{8}T\d{6}-[0-9a-f]{4}\.json$/.test(f))
68
+ .map((f) => JSON.parse(fs.readFileSync(path.join(this.home, f), 'utf8')) as Snapshot)
69
+ // Newest first by time to the millisecond: ids only say the second.
70
+ .sort((a, b) => b.at.localeCompare(a.at) || b.id.localeCompare(a.id));
71
+ }
72
+
73
+ /** Records the world as it is before `label`. Nothing new when nothing changed since the last snapshot. */
74
+ snapshot(label: string, by: string): Snapshot {
75
+ const files = this.current();
76
+ const last = this.list()[0];
77
+ const same = last && Object.keys(last.files).length === Object.keys(files).length && Object.entries(files).every(([f, h]) => last.files[f] === h);
78
+ // Strictly after the last one, so two snapshots in the same millisecond keep their order.
79
+ const at = new Date(Math.max(Date.now(), last ? Date.parse(last.at) + 1 : 0));
80
+ const snap: Snapshot = { id: `${at.toISOString().replace(/[-:]/g, '').slice(0, 15)}-${randomBytes(2).toString('hex')}`, at: at.toISOString(), label, by, files };
81
+ if (same) return last;
82
+ fs.writeFileSync(path.join(this.home, `${snap.id}.json`), JSON.stringify(snap));
83
+ return snap;
84
+ }
85
+
86
+ /** Puts the world back as it was in snapshot `id`; what is there now is snapshotted first, so undo can be undone. */
87
+ restore(id: string, by: string, kind: 'undo' | 'redo' = 'undo'): { restored: string[]; removed: string[]; before: string } {
88
+ const target = this.list().find((s) => s.id === id);
89
+ if (!target) throw new Error(`no snapshot ${id}`);
90
+ const before = this.snapshot(`before ${kind} to ${id}`, by);
91
+ const restored: string[] = [];
92
+ const removed: string[] = [];
93
+ for (const [rel, hash] of Object.entries(target.files)) {
94
+ if (before.files[rel] === hash) continue;
95
+ const at = path.join(this.dir, rel);
96
+ fs.mkdirSync(path.dirname(at), { recursive: true });
97
+ fs.copyFileSync(this.blob(hash), at);
98
+ restored.push(rel);
99
+ }
100
+ for (const rel of Object.keys(before.files)) {
101
+ if (target.files[rel]) continue;
102
+ fs.rmSync(path.join(this.dir, rel), { force: true });
103
+ removed.push(rel);
104
+ }
105
+ return { restored, removed, before: before.id };
106
+ }
107
+
108
+ /** What changed from one snapshot to the next (or to now). */
109
+ static diff(a: Record<string, string>, b: Record<string, string>): { added: string[]; changed: string[]; removed: string[] } {
110
+ return {
111
+ added: Object.keys(b).filter((f) => !(f in a)),
112
+ changed: Object.keys(b).filter((f) => f in a && a[f] !== b[f]),
113
+ removed: Object.keys(a).filter((f) => !(f in b)),
114
+ };
115
+ }
116
+ }
117
+
118
+ /** All action entry points snapshot at the same job boundary, including CLI and standalone MCP. */
119
+ export function snapshotBeforeJob(root: string, events: Events): (job: Job) => void {
120
+ return (job) => {
121
+ const dir = path.join(root, job.world);
122
+ if (fs.existsSync(dir)) events.publish('history.snapshot', { world: job.world, id: new History(dir).snapshot(job.label, job.by).id });
123
+ };
124
+ }
@@ -0,0 +1,291 @@
1
+ // A job is a diorama command run as a child process, exactly as you would type it. One job per world runs
2
+ // at a time, the rest wait their turn. Output lines and `@@diorama` events go to the live feed, keys redacted.
3
+ import { spawn, type ChildProcess } from 'node:child_process';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { redact } from '../secrets.ts';
7
+ import type { Events } from './events.ts';
8
+ import type { ExtensionProgress } from '../world/extension-progress.ts';
9
+
10
+ const BIN = fileURLToPath(new URL('../../bin/diorama.mjs', import.meta.url));
11
+
12
+ export type Job = {
13
+ id: string;
14
+ world: string;
15
+ label: string;
16
+ args: string[];
17
+ by: string;
18
+ state: 'queued' | 'running' | 'done' | 'failed' | 'cancelled';
19
+ queuedAt: string;
20
+ startedAt?: string;
21
+ endedAt?: string;
22
+ /** The steps running now, joined ("model + verify"): a build runs independent steps side by side. */
23
+ step?: string;
24
+ /** Each step running now, in the order it started. */
25
+ steps?: string[];
26
+ /** Last confirmed extension milestone, available when a client opens mid-job. */
27
+ extension?: ExtensionProgress;
28
+ error?: string;
29
+ /** Optional stages completed with missing assets; distinct from a failed job. */
30
+ warnings?: Record<string, string[]>;
31
+ /** A queued addition reserves its key before its source correction is applied. */
32
+ addedThing?: { place: string; key: string };
33
+ /** Places verify found not playable, with why: the reason a build that finished still fails. */
34
+ unplayable?: Array<{ place: string; errors: string[] }>;
35
+ /** The last lines it printed. */
36
+ tail: string[];
37
+ };
38
+
39
+ type Activity = { at: string; type: string; message?: string; stream?: string; kind?: string; label?: string; outcome?: string; ms?: number; error?: string };
40
+ type Operation = { id: string; kind: string; label: string; startedAt: string };
41
+ type Progress = { completed: number; total: number; failed: number; kept?: number };
42
+ type Diagnostics = { lastActivityAt: string; recent: Activity[]; failures: Activity[]; active: Map<string, Operation>; progress: Record<string, Progress>; operations: Record<string, { started: number; completed: number; failed: number }> };
43
+
44
+ export class Jobs {
45
+ private readonly all: Job[] = [];
46
+ private readonly children = new Map<string, ChildProcess>();
47
+ private readonly waiters = new Map<string, Array<(job: Job) => void>>();
48
+ private readonly prepare = new Map<string, () => void>();
49
+ private readonly diagnostics = new Map<string, Diagnostics>();
50
+
51
+ /** echo: also print the job's output (the CLI runs jobs in the foreground). */
52
+ /** beforeStart runs as a job leaves the queue (the Studio snapshots the world there, for undo). */
53
+ constructor(private readonly events: Events, private readonly opts: { cwd?: string; echo?: boolean; bin?: string; beforeStart?: (job: Job) => void } = {}) {}
54
+
55
+ /** Whether a job for this world is running or waiting. */
56
+ busy(world: string): Job | undefined {
57
+ return this.all.find((j) => j.world === world && (j.state === 'running' || j.state === 'queued'));
58
+ }
59
+
60
+ /**
61
+ * Polish that must never hold up the game: speaking faces. They run only when nothing else for the world is
62
+ * waiting, and any other job stops a running one (face work is saved as it goes; it resumes on the next run).
63
+ */
64
+ static polish(args: readonly string[]): boolean {
65
+ const at = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] : undefined);
66
+ return args[0] === 'faces' || (args[0] === 'build' && at('--from') === 'faces' && at('--to') === 'faces');
67
+ }
68
+
69
+ start(world: string, args: string[], label: string, by: string, prepare?: () => void, addedThing?: Job['addedThing']): Job {
70
+ // The same job already waiting or running (a button pressed again, the agent asking twice) is that job:
71
+ // a second run would only pay for the same model calls again. A pending edit is its own job.
72
+ const same = prepare ? undefined : this.all.find((j) => j.world === world && (j.state === 'queued' || j.state === 'running') && !this.prepare.has(j.id) && j.args.join('\0') === args.join('\0'));
73
+ if (same) return same;
74
+ const job: Job = { id: randomBytes(4).toString('hex'), world, label, args, by, state: 'queued', queuedAt: new Date().toISOString(), tail: [], ...(addedThing && { addedThing }) };
75
+ this.all.push(job);
76
+ this.diagnostics.set(job.id, { lastActivityAt: job.queuedAt, recent: [], failures: [], active: new Map(), progress: {}, operations: {} });
77
+ if (prepare) this.prepare.set(job.id, prepare);
78
+ this.events.publish('job.queued', { world, job: { ...job } });
79
+ if (!Jobs.polish(args)) for (const running of this.all.filter((j) => j.world === world && j.state === 'running' && Jobs.polish(j.args))) {
80
+ running.error = `stopped so "${label}" can run first (speaking faces resume later)`;
81
+ this.cancel(running.id);
82
+ this.start(world, running.args, running.label, running.by);
83
+ }
84
+ this.next(world);
85
+ return job;
86
+ }
87
+
88
+ list(world?: string): Job[] {
89
+ return this.all.filter((j) => !world || j.world === world).map((j) => ({ ...j }));
90
+ }
91
+
92
+ get(id: string): Job | undefined {
93
+ return this.all.find((j) => j.id === id);
94
+ }
95
+
96
+ /** A bounded, timestamped view of this job, including work in flight in its own process. */
97
+ inspect(id: string, limit = 40) {
98
+ const job = this.get(id);
99
+ const data = this.diagnostics.get(id);
100
+ if (!job || !data) throw new Error(`no job ${id}`);
101
+ const now = Date.now();
102
+ const end = job.endedAt ? Date.parse(job.endedAt) : now;
103
+ return {
104
+ id: job.id, world: job.world, label: job.label, state: job.state,
105
+ startedAt: job.startedAt ?? null, endedAt: job.endedAt ?? null,
106
+ elapsedSeconds: job.startedAt ? Math.max(0, Math.round((end - Date.parse(job.startedAt)) / 1000)) : 0,
107
+ lastActivityAt: data.lastActivityAt,
108
+ quietSeconds: Math.max(0, Math.round((end - Date.parse(data.lastActivityAt)) / 1000)),
109
+ steps: [...(job.steps ?? [])], extension: job.extension ?? null, error: job.error ?? null,
110
+ activeOperations: [...data.active.values()].map(op => ({ ...op, elapsedSeconds: Math.max(0, Math.round((end - Date.parse(op.startedAt)) / 1000)) })),
111
+ operations: { ...data.operations }, progress: { ...data.progress }, recentFailures: data.failures.slice(-20), recent: data.recent.slice(-limit),
112
+ note: 'Active operations are observed in this process, not proof of the remote service state. Operations may be nested; their counts are not billing. Silence alone does not prove a hang.',
113
+ };
114
+ }
115
+
116
+ private activity(job: Job, event: Omit<Activity, 'at'>, at = new Date().toISOString()) {
117
+ const data = this.diagnostics.get(job.id);
118
+ if (!data) return;
119
+ data.lastActivityAt = at;
120
+ const record = { at, ...event };
121
+ data.recent.push(record);
122
+ if (event.outcome === 'failed' || event.type === 'step.fail' || event.type === 'place.fail' || event.stream === 'err' || (event.type === 'log' && /^\s*(?:FAIL\b|warning:|.*\bFAILED\b)/i.test(event.message ?? ''))) {
123
+ data.failures.push(record);
124
+ if (data.failures.length > 20) data.failures.shift();
125
+ }
126
+ if (data.recent.length > 200) data.recent.splice(0, data.recent.length - 200);
127
+ }
128
+
129
+ /** A cancelled queued edit has not changed its source yet; retry keeps that pending edit. */
130
+ retry(id: string, args: string[], by: string): Job {
131
+ const job = this.get(id);
132
+ if (!job) throw new Error(`no job ${id}`);
133
+ const prepare = this.prepare.get(id);
134
+ this.prepare.delete(id);
135
+ return this.start(job.world, args, job.label, by, prepare, job.addedThing);
136
+ }
137
+
138
+ cancel(id: string): Job {
139
+ const job = this.get(id);
140
+ if (!job) throw new Error(`no job ${id}`);
141
+ if (job.state === 'queued') this.finish(job, 'cancelled');
142
+ else if (job.state === 'running') {
143
+ job.state = 'cancelled';
144
+ this.children.get(id)?.kill('SIGTERM');
145
+ }
146
+ return { ...job };
147
+ }
148
+
149
+ /** Resolves when the job has ended, however it ended. */
150
+ wait(id: string, timeoutMs?: number, signal?: AbortSignal): Promise<Job> {
151
+ const job = this.get(id);
152
+ if (!job) return Promise.reject(new Error(`no job ${id}`));
153
+ if (!['queued', 'running'].includes(job.state) || signal?.aborted) return Promise.resolve({ ...job });
154
+ return new Promise((resolve) => {
155
+ const done = (j: Job) => {
156
+ clearTimeout(timer);
157
+ signal?.removeEventListener('abort', early);
158
+ resolve(j);
159
+ };
160
+ // Past the timeout, or when the signal ends the wait, it answers with the job as it stands, still running.
161
+ const early = () => {
162
+ clearTimeout(timer);
163
+ signal?.removeEventListener('abort', early);
164
+ this.waiters.set(id, (this.waiters.get(id) ?? []).filter((w) => w !== done));
165
+ resolve({ ...job });
166
+ };
167
+ const timer = timeoutMs === undefined ? undefined : setTimeout(early, timeoutMs);
168
+ signal?.addEventListener('abort', early);
169
+ this.waiters.set(id, [...(this.waiters.get(id) ?? []), done]);
170
+ });
171
+ }
172
+
173
+ stopAll() {
174
+ for (const job of this.all) if (job.state === 'queued' || job.state === 'running') this.cancel(job.id);
175
+ }
176
+
177
+ private next(world: string) {
178
+ if (this.all.some((j) => j.world === world && j.state === 'running')) return;
179
+ const queued = this.all.filter((j) => j.world === world && j.state === 'queued');
180
+ const job = queued.find((j) => !Jobs.polish(j.args)) ?? queued[0];
181
+ if (!job) return;
182
+ try {
183
+ this.opts.beforeStart?.(job);
184
+ } catch (error) {
185
+ this.finish(job, 'failed', `snapshot failed: ${error instanceof Error ? error.message : error}`);
186
+ return;
187
+ }
188
+ try {
189
+ this.prepare.get(job.id)?.();
190
+ this.prepare.delete(job.id);
191
+ } catch (error) {
192
+ this.finish(job, 'failed', error instanceof Error ? error.message : String(error));
193
+ return;
194
+ }
195
+ job.state = 'running';
196
+ job.startedAt = new Date().toISOString();
197
+ this.activity(job, { type: 'job.start' }, job.startedAt);
198
+ this.events.publish('job.start', { world, job: { ...job } });
199
+ const child = spawn(process.execPath, [this.opts.bin ?? BIN, ...job.args], {
200
+ cwd: this.opts.cwd,
201
+ env: { ...process.env, DIORAMA_EVENTS: '1', FORCE_COLOR: '0' },
202
+ stdio: ['ignore', 'pipe', 'pipe'],
203
+ });
204
+ this.children.set(job.id, child);
205
+ const lines = (stream: 'out' | 'err') => {
206
+ let rest = '';
207
+ return (chunk: Buffer) => {
208
+ const parts = (rest + chunk.toString('utf8')).split('\n');
209
+ rest = parts.pop() ?? '';
210
+ for (const line of parts) this.line(job, stream, line);
211
+ };
212
+ };
213
+ child.stdout!.on('data', lines('out'));
214
+ child.stderr!.on('data', lines('err'));
215
+ child.on('error', (error) => (job.error = error.message));
216
+ child.on('close', (code) => {
217
+ this.children.delete(job.id);
218
+ if (job.state === 'cancelled') this.finish(job, 'cancelled');
219
+ else this.finish(job, code === 0 ? 'done' : 'failed', code === 0 ? undefined : job.error ?? (job.unplayable?.length ? `not playable: ${job.unplayable.map((u) => `${u.place} (${u.errors.join('; ') || 'see verify.json'})`).join(', ')}` : `exited with ${code}`));
220
+ });
221
+ }
222
+
223
+ private line(job: Job, stream: 'out' | 'err', raw: string) {
224
+ const line = redact(raw);
225
+ if (line.startsWith('@@diorama ')) {
226
+ try {
227
+ const { type, ...data } = JSON.parse(line.slice('@@diorama '.length)) as { type: string } & Record<string, unknown>;
228
+ const step = (data as { step?: string }).step;
229
+ const diagnostics = this.diagnostics.get(job.id);
230
+ const at = new Date().toISOString();
231
+ if (diagnostics && type === 'operation.start' && typeof data.id === 'string' && typeof data.kind === 'string' && typeof data.label === 'string') {
232
+ const operation = { id: data.id, kind: data.kind.slice(0, 80), label: data.label.slice(0, 160), startedAt: at };
233
+ diagnostics.active.set(data.id, operation);
234
+ (diagnostics.operations[operation.kind] ??= { started: 0, completed: 0, failed: 0 }).started++;
235
+ this.activity(job, { type, kind: operation.kind, label: operation.label }, at);
236
+ } else if (diagnostics && type === 'operation.end' && typeof data.id === 'string') {
237
+ const op = diagnostics.active.get(data.id);
238
+ diagnostics.active.delete(data.id);
239
+ if (op) {
240
+ const count = diagnostics.operations[op.kind];
241
+ if (data.outcome === 'failed') count.failed++;
242
+ else count.completed++;
243
+ }
244
+ this.activity(job, { type, ...(op && { kind: op.kind, label: op.label }), ...(typeof data.outcome === 'string' && { outcome: data.outcome }), ...(typeof data.ms === 'number' && { ms: data.ms }), ...(typeof data.error === 'string' && { error: data.error.slice(0, 500) }) }, at);
245
+ } else if (diagnostics && type === 'work.progress' && typeof data.kind === 'string' && typeof data.completed === 'number' && typeof data.total === 'number' && typeof data.failed === 'number') {
246
+ diagnostics.progress[data.kind] = { completed: data.completed, total: data.total, failed: data.failed, ...(typeof data.kept === 'number' && { kept: data.kept }) };
247
+ this.activity(job, { type, kind: data.kind, message: `${data.completed}/${data.total}, ${data.failed} failed` }, at);
248
+ } else if (['step.start', 'step.end', 'step.fail', 'extension.progress', 'place.fail', 'place.verdict', 'repair.start', 'repair.end'].includes(type)) {
249
+ const failure = (type === 'place.verdict' || type === 'repair.end') && data.ok === false;
250
+ const reason = typeof data.error === 'string' ? data.error : failure && Array.isArray(data.errors) ? data.errors.join('; ') : undefined;
251
+ this.activity(job, { type, message: String(step ?? data.phase ?? data.place ?? '').slice(0, 160), ...(failure && { outcome: 'failed' }), ...(reason && { error: reason.slice(0, 500) }) }, at);
252
+ }
253
+ if (type === 'extension.progress') {
254
+ const next = data as ExtensionProgress;
255
+ job.extension = { ...next, newPlaces: next.newPlaces ?? job.extension?.newPlaces };
256
+ }
257
+ if (step && type === 'step.end' && data.status === 'incomplete' && Array.isArray(data.warnings)) (job.warnings ??= {})[step] = data.warnings.filter((v): v is string => typeof v === 'string');
258
+ if (step && type === 'step.start') job.steps = [...(job.steps ?? []).filter((s) => s !== step), step];
259
+ if (step && (type === 'step.end' || type === 'step.fail')) job.steps = (job.steps ?? []).filter((s) => s !== step);
260
+ // A failed step stays shown, so the job says where it broke; a finished one leaves the steps still running.
261
+ if (step && type === 'step.fail') job.step = step;
262
+ else if (type === 'step.start' || type === 'step.end') job.step = job.steps?.length ? job.steps.join(' + ') : job.error ? job.step : undefined;
263
+ if (type === 'step.fail') job.error = String((data as { error?: unknown }).error ?? '');
264
+ if (type === 'place.verdict') {
265
+ const v = data as { place: string; ok: boolean; errors?: string[] };
266
+ if (!v.ok) (job.unplayable ??= []).push({ place: v.place, errors: v.errors ?? [] });
267
+ }
268
+ this.events.publish(type, { ...data, world: job.world, job: job.id });
269
+ return;
270
+ } catch {}
271
+ }
272
+ if (stream === 'err' && line.startsWith('diorama ')) job.error = line;
273
+ if (line.trim()) this.activity(job, { type: 'log', stream, message: line.slice(0, 1200) });
274
+ job.tail.push(line);
275
+ if (job.tail.length > 40) job.tail.shift();
276
+ if (this.opts.echo) (stream === 'out' ? process.stdout : process.stderr).write(`${line}\n`);
277
+ this.events.publish('log', { world: job.world, job: job.id, stream, line });
278
+ }
279
+
280
+ private finish(job: Job, state: Job['state'], error?: string) {
281
+ job.state = state;
282
+ job.endedAt = new Date().toISOString();
283
+ if (error) job.error = error;
284
+ this.diagnostics.get(job.id)?.active.clear();
285
+ this.activity(job, { type: 'job.end', outcome: state, ...(error && { error }) }, job.endedAt);
286
+ this.events.publish('job.end', { world: job.world, job: { ...job } });
287
+ for (const resolve of this.waiters.get(job.id) ?? []) resolve({ ...job });
288
+ this.waiters.delete(job.id);
289
+ this.next(job.world);
290
+ }
291
+ }
@@ -0,0 +1,33 @@
1
+ // Locks keep the agent's hands off what the person is working on: a locked world or place refuses the
2
+ // agent's jobs and undo. The person's own actions go through; a lock only the person can lift.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ export type Lock = { by: string; at: string };
7
+ export type Locks = { world?: Lock; places: Record<string, Lock> };
8
+
9
+ const file = (dir: string) => path.join(dir, 'studio', 'locks.json');
10
+
11
+ export function readLocks(dir: string): Locks {
12
+ try {
13
+ return { places: {}, ...(JSON.parse(fs.readFileSync(file(dir), 'utf8')) as Partial<Locks>) };
14
+ } catch {
15
+ return { places: {} };
16
+ }
17
+ }
18
+
19
+ export function writeLocks(dir: string, locks: Locks) {
20
+ fs.mkdirSync(path.dirname(file(dir)), { recursive: true });
21
+ fs.writeFileSync(file(dir), `${JSON.stringify(locks, null, 2)}\n`);
22
+ }
23
+
24
+ /** Why the agent may not change this world (or, given one, this place) now, or null. */
25
+ export function agentBlocked(dir: string, by: string, place?: string): string | null {
26
+ if (by !== 'agent') return null;
27
+ const locks = readLocks(dir);
28
+ if (locks.world) return `the person locked this world (${locks.world.at}); ask them to unlock it`;
29
+ if (place) return locks.places[place] ? `the person locked ${place}; ask them to unlock it` : null;
30
+ const places = Object.keys(locks.places);
31
+ if (places.length) return `the person locked ${places.join(', ')}; a build would touch every place, so ask them first`;
32
+ return null;
33
+ }
@@ -0,0 +1,86 @@
1
+ // The Studio's actions as MCP tools: one tool per action, same name, same input schema. The studio serves
2
+ // this over streamable HTTP at /mcp (Muse Code connects there) and `diorama mcp` over stdio.
3
+ import { actions, describeActions, runAction, type ActionContext } from '../actions/registry.ts';
4
+ import { redact } from '../secrets.ts';
5
+
6
+ type Message = { jsonrpc: '2.0'; id?: string | number | null; method?: string; params?: Record<string, unknown> };
7
+ export type Reply = { jsonrpc: '2.0'; id: string | number | null; result?: unknown; error?: { code: number; message: string } };
8
+
9
+ const PROTOCOL = '2025-06-18';
10
+
11
+ export const INSTRUCTIONS = 'Diorama builds painted point-and-click worlds. Every tool here is a Diorama Studio action; the person sees each call '
12
+ + 'in the Studio at once. Long work (world_build, run_step) starts a job: follow it with job_wait. Use ui_focus to show the person the '
13
+ + 'world or place you are talking about. Keys are never available to you: if setup_status says one is missing, ask the person to run diorama setup.';
14
+
15
+ export function mcpTools() {
16
+ return describeActions().map((a) => ({
17
+ name: a.name,
18
+ description: a.description,
19
+ inputSchema: a.input,
20
+ annotations: { readOnlyHint: a.reads, openWorldHint: false },
21
+ }));
22
+ }
23
+
24
+ /** Answers one JSON-RPC message; null for a notification. */
25
+ /** `world`: the world the calling agent session is about; a tool call that leaves out `slug` gets it. */
26
+ /** `seen`: told of each tool call (name, arguments) and, through the function it returns, of how it ended. */
27
+ export async function handleMcp(message: Message, ctx: ActionContext, world?: string, seen?: (name: string, args: Record<string, unknown>) => (ok: boolean, result: unknown) => void): Promise<Reply | null> {
28
+ if (message.id === undefined || message.id === null) return null;
29
+ const id = message.id;
30
+ const ok = (result: unknown): Reply => ({ jsonrpc: '2.0', id, result });
31
+ switch (message.method) {
32
+ case 'initialize':
33
+ return ok({
34
+ protocolVersion: typeof message.params?.protocolVersion === 'string' ? message.params.protocolVersion : PROTOCOL,
35
+ capabilities: { tools: { listChanged: false } },
36
+ serverInfo: { name: 'diorama', version: '0.1.0' },
37
+ instructions: INSTRUCTIONS,
38
+ });
39
+ case 'ping':
40
+ return ok({});
41
+ case 'tools/list':
42
+ return ok({ tools: mcpTools() });
43
+ case 'tools/call': {
44
+ const name = String(message.params?.name ?? '');
45
+ if (!actions.has(name)) return { jsonrpc: '2.0', id, error: { code: -32602, message: `no tool ${name}` } };
46
+ try {
47
+ const args = { ...((message.params?.arguments ?? {}) as Record<string, unknown>) };
48
+ if (world && args.slug === undefined && takesSlug(name)) args.slug = world;
49
+ const settle = seen?.(name, args);
50
+ let ran: unknown;
51
+ try {
52
+ ran = await runAction(name, args, ctx);
53
+ } catch (error) {
54
+ settle?.(false, error);
55
+ throw error;
56
+ }
57
+ settle?.(true, ran);
58
+ const { result, images } = withoutImages(ran);
59
+ const content = [{ type: 'text', text: redact(JSON.stringify(result, null, 1)) }, ...images.map((i) => ({ type: 'image', data: i.data, mimeType: i.mimeType }))];
60
+ return ok({ content, structuredContent: wrap(result), isError: false });
61
+ } catch (error) {
62
+ return ok({ content: [{ type: 'text', text: redact(error instanceof Error ? error.message : String(error)) }], isError: true });
63
+ }
64
+ }
65
+ default:
66
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${message.method}` } };
67
+ }
68
+ }
69
+
70
+ /** structuredContent must be an object. */
71
+ const wrap = (result: unknown) => (result && typeof result === 'object' && !Array.isArray(result) ? result : { result });
72
+
73
+ type Image = { mimeType: string; data: string };
74
+
75
+ /** An action that shows a picture returns it as `images`: the agent gets it as image content, not as text. */
76
+ function withoutImages(result: unknown): { result: unknown; images: Image[] } {
77
+ if (!result || typeof result !== 'object' || !Array.isArray((result as { images?: unknown }).images)) return { result, images: [] };
78
+ const { images, ...rest } = result as { images: Image[] };
79
+ return { result: rest, images };
80
+ }
81
+
82
+ /** Whether a tool's input has a top-level `slug`. */
83
+ function takesSlug(name: string): boolean {
84
+ const input = actions.get(name)?.input;
85
+ return !!input && 'shape' in input && typeof input.shape === 'object' && input.shape !== null && 'slug' in input.shape;
86
+ }
@@ -0,0 +1,18 @@
1
+ // A local operation's lifetime, not a claim about what a remote service is doing.
2
+ import { randomUUID } from 'node:crypto';
3
+ import { emit } from '../registry.ts';
4
+
5
+ export async function trackOperation<T>(kind: string, label: string, run: () => Promise<T>): Promise<T> {
6
+ if (process.env.DIORAMA_EVENTS !== '1') return run();
7
+ const id = randomUUID();
8
+ const started = Date.now();
9
+ emit('operation.start', { id, kind, label });
10
+ try {
11
+ const result = await run();
12
+ emit('operation.end', { id, kind, label, outcome: 'ok', ms: Date.now() - started });
13
+ return result;
14
+ } catch (error) {
15
+ emit('operation.end', { id, kind, label, outcome: 'failed', ms: Date.now() - started, error: (error instanceof Error ? error.message : String(error)).slice(0, 500) });
16
+ throw error;
17
+ }
18
+ }
@@ -0,0 +1,31 @@
1
+ // The second tier of repair: when a build fails, the Studio hands it to the agent at once, without anyone
2
+ // asking. Places that are not playable (and that the build could not fix for free) get the playbook; any other
3
+ // failure (a step that broke, a service that did not answer) gets the error and the job's last lines. The agent's
4
+ // brief says how; what it cannot fix it tells the person, which is the third tier.
5
+ import type { Job } from './jobs.ts';
6
+
7
+ /** Whether a job that just ended should wake the agent. */
8
+ export function wantsRepair(job: Job, opts: { enabled: boolean }): boolean {
9
+ if (!opts.enabled) return false;
10
+ // The agent's own fix jobs come back to it through job_wait; only builds someone else started wake it.
11
+ if (job.by === 'agent' || job.args[0] !== 'build') return false;
12
+ return job.state === 'failed' || job.state === 'done' && !!job.warnings?.faces?.length;
13
+ }
14
+
15
+ /** What the agent is told: which places or which step, why, and what is expected back. */
16
+ export function repairPrompt(job: Job): string {
17
+ const animation = job.warnings?.faces?.length ? `\nSpeaking animation also needs attention: ${job.warnings.faces.join('; ')}. Use faces_status and inspect the saved work. For a bad articulation use faces_inspect then face_frame_repair; for uncertain head lip bounds use faces_inspect then face_lips_set; for source/final-patch bounds use face_calibration_inspect then face_calibration_set. Preserve accepted artwork, inspect actual pixels before coordinates, and do not repeat an unchanged failed repair.` : '';
18
+ if (job.state === 'done' && animation) return `[Studio] The build "${job.label}" finished with incomplete speaking animation. This does not block other build steps and does not certify gameplay or audio quality.${animation}\nRepair the affected stage, wait for the job, check coverage and reload after success. If the image is too ambiguous to calibrate, explain what is still missing.`;
19
+ if (job.unplayable?.length) {
20
+ const places = job.unplayable.map((u) => `- ${u.place}: ${u.errors.join('; ') || 'see place_status'}`).join('\n');
21
+ return `[Studio] The build "${job.label}" ended with places that are not playable, and its free fixes did not help:\n${places}\n`
22
+ + 'Repair them as your brief says: place_status and place_view first, then the playbook\'s fix tools in order, at most three fixes a place, '
23
+ + 'and ask me before the last resort. Then tell me in two sentences what you fixed and what is left for me.' + animation;
24
+ }
25
+ const tail = job.tail.slice(-15).map((l) => ` ${l}`).join('\n');
26
+ return `[Studio] The build "${job.label}" (job ${job.id}) failed${job.step ? ` at ${job.step}` : ''}: ${job.error ?? 'no error said'}\n`
27
+ + (tail ? `Its last lines:\n${tail}\n` : '')
28
+ + 'Find the cause and fix it as your brief says for a failed build: job_inspect first; a service that did not answer (HTTP 5xx, a timeout) gets one job_retry; '
29
+ + 'a step that broke on the world gets that step\'s own tool (cast_status, place_status, …). Do not retry the same error twice. '
30
+ + 'Then tell me in two sentences what you fixed and what is left for me.' + animation;
31
+ }