@slatesvideo/shared 0.6.11 → 0.7.1

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 (83) hide show
  1. package/dist/auth.js +2 -2
  2. package/dist/clients/cloud.js +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/manual/content.d.ts +1 -1
  6. package/dist/manual/content.js +1 -1
  7. package/dist/operations/index.d.ts +817 -16
  8. package/dist/operations/index.js +1413 -360
  9. package/dist/operations/surface.d.ts +4 -1
  10. package/dist/operations/surface.js +41 -10
  11. package/dist/prompts/ad-presets.d.ts +77 -0
  12. package/dist/prompts/ad-presets.js +43 -0
  13. package/dist/prompts/agent-doctrine.js +27 -5
  14. package/dist/prompts/banned-tokens.d.ts +4 -29
  15. package/dist/prompts/banned-tokens.js +29 -204
  16. package/dist/prompts/craft-cards.js +2 -2
  17. package/dist/prompts/generation-policy.d.ts +41 -0
  18. package/dist/prompts/generation-policy.js +53 -0
  19. package/dist/prompts/guide-retrieval.d.ts +9 -0
  20. package/dist/prompts/guide-retrieval.js +53 -0
  21. package/dist/prompts/index.d.ts +1 -0
  22. package/dist/prompts/index.js +1 -0
  23. package/dist/prompts/model-capabilities.d.ts +18 -1
  24. package/dist/prompts/model-capabilities.js +72 -19
  25. package/dist/prompts/model-facts.d.ts +34 -2
  26. package/dist/prompts/model-facts.js +66 -5
  27. package/dist/prompts/partials.generated.js +8 -2
  28. package/dist/prompts/prompting-tips.d.ts +1 -1
  29. package/dist/prompts/prompting-tips.js +61 -16
  30. package/dist/prompts/reference-composer.d.ts +2 -0
  31. package/dist/prompts/reference-composer.js +51 -50
  32. package/dist/prompts/script-document.d.ts +165 -0
  33. package/dist/prompts/script-document.js +11 -0
  34. package/dist/prompts/shot-grammar.d.ts +4 -4
  35. package/dist/prompts/shot-grammar.js +3 -3
  36. package/dist/prompts/shot-spec.d.ts +13 -0
  37. package/dist/prompts/shot-spec.js +23 -5
  38. package/dist/skills/content.js +27 -24
  39. package/exports/slates-chatgpt-images/generated/SKILL.md +107 -0
  40. package/exports/slates-chatgpt-images/generated/slates-chatgpt-images.skill +0 -0
  41. package/exports/slates-prompt-builder/generated/SKILL.md +1 -1
  42. package/exports/slates-prompt-builder/generated/reference-character.md +9 -1
  43. package/exports/slates-prompt-builder/generated/reference-kling.md +3 -3
  44. package/exports/slates-prompt-builder/generated/reference-nano-banana.md +22 -10
  45. package/exports/slates-prompt-builder/generated/reference-seedance.md +4 -4
  46. package/exports/slates-prompt-builder/generated/slates-prompt-builder-manifest.json +17 -17
  47. package/exports/slates-prompt-builder/generated/slates-prompt-builder.skill +0 -0
  48. package/package.json +9 -3
  49. package/skills/_partials/cinematic-card.md +8 -0
  50. package/skills/_partials/cinematic-routes-short.md +2 -0
  51. package/skills/_partials/cinematic-tips-short.md +2 -0
  52. package/skills/_partials/decision-log.md +1 -13
  53. package/skills/_partials/image-defaults.md +11 -0
  54. package/skills/_partials/lens-video-split.md +1 -0
  55. package/skills/_partials/reference-rules-core.md +1 -1
  56. package/skills/_partials/sheet-tool-defaults.md +6 -0
  57. package/skills/slates-character-identity.md +9 -1
  58. package/skills/slates-chatgpt-images.md +107 -0
  59. package/skills/slates-cinematic-look.md +237 -0
  60. package/skills/slates-cost-discipline.md +18 -12
  61. package/skills/slates-direct-response-ad.md +13 -53
  62. package/skills/slates-edit-and-iterate.md +1 -1
  63. package/skills/slates-model-selection.md +20 -14
  64. package/skills/slates-one-prompt-film.md +19 -77
  65. package/skills/slates-project-organization.md +7 -3
  66. package/skills/slates-prompting-flux-2-max.md +15 -4
  67. package/skills/slates-prompting-gpt-image-2-5.md +41 -28
  68. package/skills/slates-prompting-inworld-tts.md +174 -174
  69. package/skills/slates-prompting-kling-v3.md +3 -3
  70. package/skills/slates-prompting-lip-sync.md +1 -1
  71. package/skills/slates-prompting-minimax-h3.md +30 -17
  72. package/skills/slates-prompting-motion-transfer.md +1 -1
  73. package/skills/slates-prompting-nano-banana-2.md +24 -11
  74. package/skills/slates-prompting-seedance-2-5.md +7 -6
  75. package/skills/slates-prompting-seedance.md +5 -5
  76. package/skills/slates-prompting-seedream-5-lite.md +14 -3
  77. package/skills/slates-prompting-veo-3.md +1 -1
  78. package/skills/slates-script-craft.md +45 -0
  79. package/skills/slates-shot-variety.md +11 -40
  80. package/skills/slates-storyboard-from-script.md +14 -66
  81. package/skills/slates-style-prompting.md +4 -4
  82. package/skills/slates-ugc-influencer-ad.md +32 -309
  83. package/skills/slates-vision-feedback-loop.md +2 -1
@@ -1,4 +1,6 @@
1
- import { MINIMAX_MAX_REFERENCE, minimaxMaxReferenceTokens, MODEL_CAPABILITIES, GPT_QUALITY_TIERS, GPT_BACKGROUNDS } from '../prompts/model-capabilities.js';
1
+ import { MAX_IMAGE_VARIATIONS } from '../prompts/generation-policy.js';
2
+ import { retrieveGuide, guideSections } from '../prompts/guide-retrieval.js';
3
+ import { MINIMAX_MAX_REFERENCE, minimaxMaxReferenceTokens, MODEL_CAPABILITIES, GPT_QUALITY_TIERS, DEFAULT_GPT_QUALITY, GPT_BACKGROUNDS } from '../prompts/model-capabilities.js';
2
4
  // Operations layer — the ONE place every Slates agent tool is defined.
3
5
  // Both the MCP server and the CLI register these as their tool / command
4
6
  // surface. Every operation:
@@ -23,7 +25,11 @@ import { multimodalRefSummary, multimodalRefModels, seedanceTaskIntentWords,
23
25
  // slates-mcp/CLAUDE.md forbids restating it in an op description, and this
24
26
  // file did it anyway for 1,282 characters that repeated MODEL_FACTS phrase
25
27
  // for phrase. Edit model-facts.ts; both surfaces follow.
26
- describeRouting, } from '../prompts/model-facts.js';
28
+ describeRouting,
29
+ // The default seat per kind, READ from `tier` — never a literal model id.
30
+ defaultModelFor,
31
+ // The seat a built-in tool renders on, READ from TOOL_SEAT (generation-policy.ts).
32
+ toolModelFor, CHATGPT_FRAMING_RATIOS, } from '../prompts/model-facts.js';
27
33
  // 🚨 MODEL CAPABILITY SSOT. Aspect ratios, video resolutions and durations are
28
34
  // VALIDATED against and DESCRIBED from `MODEL_CAPABILITIES` — this file must
29
35
  // never re-state one of those constraints as a literal enum or a sentence
@@ -33,16 +39,12 @@ describeRouting, } from '../prompts/model-facts.js';
33
39
  // `videoResolution` line that never mentioned Kling, and 4s quoted for Veo at
34
40
  // 1080p. A customer burned a round trip on `4:5` + Seedance: accepted here,
35
41
  // queued, credits reserved, rejected by the provider asynchronously.
36
- import { AGENT_ROUTE_PROVIDER, aspectRatioUnion, videoResolutionUnion, durationBounds, aspectRatiosFor, getModelCapability, defaultVideoResolutionFor, checkAspectRatio, checkVideoResolution, checkDuration, describeAspectRatios, describeVideoResolutions, describeDurations, describeReferenceImageCaps, } from '../prompts/model-capabilities.js';
37
- // 🚨 "LOAD THE GUIDE" MADE STRUCTURAL. The never-use token lists are EXTRACTED
38
- // from the skill files (between `@banned` markers) and inlined into the two
39
- // generate ops' descriptions, which are always in context on both surfaces —
40
- // no call to skip, no discretion. `bannedTokenWarning` then reports what the
41
- // submitted prompt actually contained, in the result, without blocking it.
42
- // Never hand-type one of these tokens here; edit the skill.
42
+ import { AGENT_ROUTE_PROVIDER, defaultImageResolutionFor, aspectRatioUnion, videoResolutionUnion, durationBounds, aspectRatiosFor, getModelCapability, defaultVideoResolutionFor, checkAspectRatio, checkVideoResolution, checkDuration, describeAspectRatios, describeVideoResolutions, describeDurations, describeReferenceImageCaps, } from '../prompts/model-capabilities.js';
43
+ // Per-model warnings are extracted from skill @banned blocks. They ride the
44
+ // selected model's estimate and submitted-prompt result, never another model's schema.
43
45
  import { describeBannedTokens, bannedTokenWarning, describeBannedTokensForSkill, } from '../prompts/banned-tokens.js';
44
46
  // 🚨 THE OTHER HALF OF THE SAME LESSON. The banned list is the NEGATIVE half and
45
- // it rides an op description. The CRAFT CARD is the POSITIVE half — the levers
47
+ // it rides the selected estimate. The CRAFT CARD is the POSITIVE half — the levers
46
48
  // that make a shot good rather than merely un-bad — and it rides the estimate
47
49
  // RESULT, which the doctrine already makes the agent call before generating.
48
50
  // Zero prefix bytes, present at the moment the model has just been named.
@@ -161,13 +163,14 @@ export const ELEVEN_SFX_DEFAULT_SECONDS = 4;
161
163
  // bucket of CHARACTERS instead, and the bucket exists because of the minimum
162
164
  // billable floor rather than for tidiness.
163
165
  //
164
- // At $20.8/M characters and a 1.5× markup, a 200-character line is $0.006 of
165
- // basis — under `MIN_AUDIO_BILLABLE_DOLLARS` ($0.01), so it bills the floor.
166
- // The floor stops biting at 321 characters. A bucket SMALLER than that would be
167
- // entirely floor-bound (every bucket the same price, so the displayed rate stops
168
- // tracking cost and becomes a lie); a much LARGER one over-bills the short lines
169
- // this feature is mostly for. 250 splits that difference and divides 2,000
170
- // exactly, giving eight buckets and no ragged last one.
166
+ // At $25/M characters (Inworld On-Demand, read 2026-09-28) and a 1.5× markup,
167
+ // a 200-character line is $0.0075 of basis — under `MIN_AUDIO_BILLABLE_DOLLARS`
168
+ // ($0.01), so it bills the floor. The floor stops biting at 267 characters, so
169
+ // the 250 bucket is floor-bound. A narrower bucket would put several buckets on
170
+ // the one floor price, and the displayed rate would stop tracking cost; a much
171
+ // WIDER one over-bills the short lines this feature is mostly for. 250 splits
172
+ // that difference and divides 2,000 exactly, giving eight buckets and no ragged
173
+ // last one.
171
174
  //
172
175
  // 🚨 THE CAP IS READ FROM THE CAPABILITY SSOT, NEVER TYPED. 2,000 is the
173
176
  // vendor's MEASURED limit (the API rejects 2,001 by name), and this module
@@ -254,7 +257,7 @@ function backgroundSubmitted(kind, ids, extra, note) {
254
257
  // ── Workspace + identity ────────────────────────────────────────
255
258
  export const getWorkspaceState = {
256
259
  id: 'slates_get_workspace_state',
257
- description: 'Snapshot of the user\'s Slates workspace: the project list (most recent first) plus the active project in full when you name one. Call once at the start of a workflow to seed your understanding.',
260
+ description: 'Snapshot of the user\'s Slates workspace: the project list (most recent first) plus the active project in full when you name one.',
258
261
  input: z.object({
259
262
  projectId: z.string().optional(),
260
263
  limit: z.number().int().min(1).max(200).optional().describe('How many projects to list, newest first. Default 40.'),
@@ -287,9 +290,195 @@ export const getWorkspaceState = {
287
290
  ? { truncated: `${rows.length - compact.length} more — raise limit or call slates_list_projects.` }
288
291
  : {}),
289
292
  activeProject,
293
+ generationDefaults: {
294
+ imageModel: defaultModelFor('image'),
295
+ imageResolution: defaultImageResolutionFor(defaultModelFor('image')),
296
+ gptQuality: DEFAULT_GPT_QUALITY,
297
+ videoModel: defaultModelFor('video'),
298
+ },
290
299
  });
291
300
  },
292
301
  };
302
+ /**
303
+ * What the user has selected in the app RIGHT NOW. The selection band in every
304
+ * grid (images, clips, audio, storyboard shots) reports its ids to the desktop,
305
+ * and the image viewer reports what is open; the desktop looks codes and labels
306
+ * up at request time. Exists so "make these into videos" needs no codes typed.
307
+ */
308
+ export const getSelection = {
309
+ id: 'slates_get_selection',
310
+ description:
311
+ // Lean on purpose: WHEN to call it is the doctrine's ASSET CODES line, and
312
+ // this description is paid on every desktop turn (lockstep § 7).
313
+ 'The user\'s live selection in the Slates app: ticked cards (with codes) and the image open in the viewer.',
314
+ input: z.object({}).strict(),
315
+ async run(_input, ctx) {
316
+ await ctx.desktop().requireCapability('selection', 'reading the live selection');
317
+ const r = await ctx.desktop().get('/agent/selection');
318
+ const describe = (row) => row.code ? (row.label ? `${row.code} — ${row.label}` : String(row.code)) : String(row.id);
319
+ const selected = r.selection
320
+ ? r.selection.items.map((i) => {
321
+ const row = i;
322
+ return row.type === 'shot'
323
+ ? { id: row.id, code: row.code ?? null, label: row.label ?? null, type: 'shot' }
324
+ : compactAsset(row);
325
+ })
326
+ : [];
327
+ const viewed = r.viewing ? compactAsset(r.viewing.item) : null;
328
+ const lines = [];
329
+ if (r.selection) {
330
+ const where = r.selection.projectName ? ` in project "${r.selection.projectName}"` : '';
331
+ lines.push(`${selected.length} selected on the ${r.selection.surface} tab${where} (projectId ${r.selection.projectId}): ` +
332
+ selected.map(describe).join(', ') +
333
+ '.');
334
+ }
335
+ if (viewed)
336
+ lines.push(`Open in the viewer: ${describe(viewed)}.`);
337
+ if (lines.length === 0) {
338
+ lines.push('Nothing is selected and no image is open in the viewer. Ask the user to select the cards in Slates (click or drag), or to name the codes.');
339
+ }
340
+ return {
341
+ text: lines.join(' '),
342
+ data: {
343
+ selection: r.selection
344
+ ? {
345
+ surface: r.selection.surface,
346
+ project_id: r.selection.projectId,
347
+ project_name: r.selection.projectName,
348
+ items: selected,
349
+ updated_at: r.selection.updatedAt,
350
+ }
351
+ : null,
352
+ viewing: viewed
353
+ ? { project_id: r.viewing.projectId, item: viewed, updated_at: r.viewing.updatedAt }
354
+ : null,
355
+ },
356
+ };
357
+ },
358
+ };
359
+ // The view's three lists, mirrored from the desktop's `@shared/types/view`
360
+ // (`LENSES`, `CUT_SIDES`, `DOCK_SECTIONS`), which this package cannot import.
361
+ // Lockstep check 12 fails when they differ.
362
+ const VIEW_LENSES = ['board', 'media', 'script'];
363
+ const VIEW_CUT_SIDES = ['bottom', 'left', 'right'];
364
+ const VIEW_DOCK_SECTIONS = ['storyboards', 'library', 'folders', 'pinned'];
365
+ /** What each dock section is called on screen. */
366
+ const DOCK_SECTION_NAMES = { storyboards: 'Boards', library: 'Library', folders: 'Folders', pinned: 'Pinned' };
367
+ const describeView = (v) => {
368
+ const where = v.cut.full
369
+ ? 'filling the workspace'
370
+ : v.cut.open
371
+ ? v.cut.side === 'bottom'
372
+ ? `a ${v.cut.height}px band under the ${v.lens} tab`
373
+ : `a ${v.cut.width}px column on the ${v.cut.side} of the ${v.lens} tab`
374
+ : 'closed, resting as one line at the bottom';
375
+ const agent = v.studioAgent.enabled
376
+ ? v.studioAgent.open
377
+ ? `the Studio Agent panel is open (${v.studioAgent.width}px)`
378
+ : 'the Studio Agent panel is closed'
379
+ : 'the Studio Agent is switched off in Settings';
380
+ return (`The ${v.lens} tab is showing. The timeline is ${where}. ` +
381
+ `The project navigator is ${v.leftDock.open ? `open (${v.leftDock.width}px)` : 'closed'}, and ${agent}.` +
382
+ (v.leftDock.folded?.length ? ` Folded in the navigator: ${v.leftDock.folded.map((f) => DOCK_SECTION_NAMES[f] ?? f).join(', ')}.` : '') +
383
+ (v.script ? ` The Script page shows ${v.script.details ? 'Words + shots (each shot\'s picture beside its words)' : 'Words (the words alone)'}.` : ''));
384
+ };
385
+ /**
386
+ * How the Slates window is ARRANGED right now — which lens is showing, where
387
+ * the timeline sits, which side panels are open. Read it before rearranging
388
+ * anything, so you change one thing and leave the rest as the user had it.
389
+ */
390
+ export const getView = {
391
+ id: 'slates_get_view',
392
+ description: "How the Slates window is arranged: the tab showing (Media, Script or Board), where the timeline sits, which side panels are open, and whether the Script page shows Words or Words + shots.",
393
+ input: z.object({}).strict(),
394
+ async run(_input, ctx) {
395
+ await ctx.desktop().requireCapability('view', 'the window layout');
396
+ const r = await ctx.desktop().get('/agent/view');
397
+ if (!r.view) {
398
+ return {
399
+ text: r.reason
400
+ ? `Slates has not reported a window layout yet (${r.reason}).`
401
+ : 'Slates has not reported a window layout yet.',
402
+ data: { view: null },
403
+ };
404
+ }
405
+ return { text: describeView(r.view), data: { view: r.view } };
406
+ },
407
+ };
408
+ /**
409
+ * Rearrange the window. Every field is optional and only what you name moves,
410
+ * so parking the timeline does not also change the lens.
411
+ *
412
+ * 🚨 THE APP ANSWERS, NOT THE REQUEST. Sizes are clamped by the app's own
413
+ * rules and the reply reports what it settled on — ask for a 10px timeline and
414
+ * you are told 420, because that is what is on the user's screen. A window too
415
+ * small to show the timeline beside the board will not split at all; it shows
416
+ * the timeline full-screen instead, and says so.
417
+ */
418
+ export const setView = {
419
+ id: 'slates_set_view',
420
+ description: "Rearrange the Slates window: switch tab (Media, Script or Board), open/close the timeline or park it along the bottom or as a left/right column, open/close or resize the side panels, fold the navigator's sections, and switch the Script page between Words and Words + shots. Only the fields you name change; sizes are clamped by the app and the reply says what it settled on.",
421
+ input: z
422
+ .object({
423
+ lens: z.enum(VIEW_LENSES).optional().describe('Which tab the centre shows.'),
424
+ cut: z
425
+ .object({
426
+ open: z.boolean().optional().describe('Show or hide the timeline.'),
427
+ full: z.boolean().optional().describe('Give the timeline the whole workspace.'),
428
+ side: z
429
+ .enum(VIEW_CUT_SIDES)
430
+ .optional()
431
+ .describe('Where the timeline is parked. A column suits a wide monitor; picking a side opens the timeline.'),
432
+ height: z.number().optional().describe('The bottom band\'s height in px.'),
433
+ width: z.number().optional().describe('The side column\'s width in px.'),
434
+ })
435
+ .strict()
436
+ .optional(),
437
+ leftDock: z
438
+ .object({
439
+ open: z.boolean().optional(),
440
+ width: z.number().optional().describe('Width in px.'),
441
+ folded: z
442
+ .array(z.enum(VIEW_DOCK_SECTIONS))
443
+ .optional()
444
+ .describe('Every section to fold to its title (storyboards is Boards); a section not named unfolds. Applies to the open project.'),
445
+ })
446
+ .strict()
447
+ .optional()
448
+ .describe('The project navigator on the left.'),
449
+ studioAgent: z
450
+ .object({
451
+ open: z.boolean().optional(),
452
+ width: z.number().optional().describe('Width in px.'),
453
+ })
454
+ .strict()
455
+ .optional()
456
+ .describe('The Studio Agent panel on the right. It cannot be opened while the agent is off in Settings.'),
457
+ script: z
458
+ .object({
459
+ details: z.boolean().optional().describe('Words + shots (true): each shot\'s picture beside its words on the Script page. Words (false): the words alone.'),
460
+ })
461
+ .strict()
462
+ .optional()
463
+ .describe('The Script page.'),
464
+ })
465
+ .strict(),
466
+ async run(input, ctx) {
467
+ await ctx.desktop().requireCapability('view', 'the window layout');
468
+ if (Object.keys(input).length === 0) {
469
+ throw new Error('Name at least one of lens, cut, leftDock, studioAgent or script — there is nothing to change otherwise.');
470
+ }
471
+ const r = await ctx.desktop().post('/agent/view', input);
472
+ if (!r.view) {
473
+ return { text: r.reason ? `Nothing was rearranged (${r.reason}).` : 'Nothing was rearranged.', data: { view: null } };
474
+ }
475
+ // A desktop whose view predates folding takes the patch and ignores `folded`.
476
+ const noFolds = input.leftDock?.folded !== undefined && r.view.leftDock.folded === undefined
477
+ ? " This Slates cannot fold the navigator's sections; update Slates."
478
+ : '';
479
+ return { text: describeView(r.view) + noFolds, data: { view: r.view } };
480
+ },
481
+ };
293
482
  export const getMe = {
294
483
  id: 'slates_get_me',
295
484
  description: 'Identity, license tier, and credit balance for the connected Slates account.',
@@ -360,18 +549,21 @@ export const VIDEO_MODELS = [
360
549
  // Flash ones.
361
550
  'seedance-2.5',
362
551
  'omni-flash',
363
- // MiniMax H3, two seats in one family (2026-08-27). Base H3 is the AUTHORED-
364
- // AUDIO seat — three directable sound layers in one pass, declared reference
365
- // relationships, 480p to 4K, and the cheapest 768-class second we sell.
366
- // H3 Max is fal's self-hosted post-train: faster, capped at 768p, takes NO
367
- // references, and costs MORE than base H3 at the tier they share — a
368
- // premium-speed seat, never a cheap H3.
552
+ // MiniMax H3, three seats in one family. Base H3 (2026-08-27) is the
553
+ // AUTHORED-AUDIO seat — three directable sound layers in one pass, declared
554
+ // reference relationships, 480p to 4K. H3 Max (2026-08-27) is fal's
555
+ // self-hosted post-train: faster, 480p to a 1080p refinement, the same
556
+ // omni-reference set, and dearer than base H3 at the tier they share. H3 Max
557
+ // Turbo (2026-09-29) is a second fal post-train at half Max's rate, with NO
558
+ // reference endpoint.
369
559
  //
370
- // NEVER PREFIX-MATCH: 'minimax-h3-max' starts with 'minimax-h3'. Every
371
- // branch keyed on these ids matches EXACTLY; a prefix test silently bills
372
- // the Max row at base rates and offers it 2K/4K it cannot render.
560
+ // NEVER PREFIX-MATCH: 'minimax-h3-max-turbo' starts with 'minimax-h3-max',
561
+ // which starts with 'minimax-h3'. Every branch keyed on these ids matches
562
+ // EXACTLY; a prefix test bills one row at another's rates and offers it a
563
+ // ladder it cannot render.
373
564
  'minimax-h3',
374
565
  'minimax-h3-max',
566
+ 'minimax-h3-max-turbo',
375
567
  // LTX-2.5, two seats in one family (2026-08-29). Base is the VOLUME seat —
376
568
  // cheapest native 1080p second we sell, free native audio at every tier, the
377
569
  // only row reaching 1440p, and the longest clips in the catalogue (20s).
@@ -425,7 +617,7 @@ const VIDEO_RESOLUTION_VOCAB = VIDEO_RESOLUTIONS;
425
617
  // $0.080 x 1.5 x 100 = 12 cents, and 12 is divisible by CENTS_PER_CREDIT (3),
426
618
  // so every extra image is 4 credits at every resolution and every duration with
427
619
  // zero drift. A rate that is not a multiple of 2 cents breaks that property.
428
- const MINIMAX_MODELS = new Set(['minimax-h3', 'minimax-h3-max']);
620
+ const MINIMAX_MODELS = new Set(['minimax-h3', 'minimax-h3-max', 'minimax-h3-max-turbo']);
429
621
  /** Reference images fal does not charge for. */
430
622
  const MINIMAX_FREE_REF_IMAGES = 5;
431
623
  /** Per-row free allowance. The Max row's is FOUR — fal prices its references by
@@ -512,8 +704,8 @@ export const estimateGenerationCost = {
512
704
  duration: z.number().int().min(1).max(360).optional().describe(`Seconds; cost scales linearly. Required with a video or PER-SECOND audio base id. Per-model windows: see slates_generate_video's duration. Audio: seed-audio ${SEED_AUDIO_MIN_SECONDS}-${SEED_AUDIO_MAX_SECONDS} (⚠️ the requested duration IS the bill), eleven-sfx ${ELEVEN_SFX_MIN_SECONDS}-${ELEVEN_SFX_MAX_SECONDS}. ⛔ NOT for ${TTS_MODEL} — pass \`characters\`.`),
513
705
  characters: z.number().int().min(1).max(TTS_MAX_CHARACTERS).optional().describe(`${TTS_MODEL} only — the LENGTH OF THE TEXT to speak (${TTS_BUCKET_CHARS}-char buckets).`),
514
706
  videoResolution: zEnum(VIDEO_RESOLUTIONS).optional().describe('Video only. Omitted, each model quotes at its own default. Per-model ladders: see slates_generate_video\'s videoResolution.'),
515
- resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('Image only (default 2k; 3k: GPT Image/seedream-5-lite).'),
516
- quality: z.enum(GPT_QUALITY_TIERS).optional().describe('GPT Image tier; default high.'),
707
+ resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('Image only. Omit for the model default; pass the same value to generation.'),
708
+ quality: z.enum(GPT_QUALITY_TIERS).optional().describe(`GPT Image tier; default ${DEFAULT_GPT_QUALITY}.`),
517
709
  aspectRatio: z.string().optional().describe('Image only. 1:1/4:3/3:4 cost more than 16:9.'),
518
710
  sound: z.boolean().optional().describe('Veo only — audio flag changes the cost key.'),
519
711
  seedanceFace: z.boolean().optional().describe('Seedance AI-face route (pricier key).'),
@@ -541,7 +733,7 @@ export const estimateGenerationCost = {
541
733
  // generation quoted 1 cr for a 2 cr job. Four separate copies of one
542
734
  // default is what made that possible; there are now none.
543
735
  if (img)
544
- key = imageCostKey(img, input.resolution ?? (img === 'nano-banana-2-lite' ? '1k' : '2k'), input.quality, input.aspectRatio);
736
+ key = imageCostKey(img, input.resolution ?? defaultImageResolutionFor(img), input.quality, input.aspectRatio);
545
737
  }
546
738
  // 2a) audio base id → seconds. Both surfaces bill per second, so a
547
739
  // duration is always required. Runs BEFORE the video resolver: it is
@@ -652,7 +844,7 @@ export const estimateGenerationCost = {
652
844
  defaultVideoResolutionFor(resolved.model),
653
845
  sound: input.sound ?? resolved.sound,
654
846
  seedanceFace: input.seedanceFace ?? resolved.seedanceFace,
655
- seedanceRealFace: input.seedanceRealFace,
847
+ seedanceRealFace: input.seedanceRealFace ?? resolved.seedanceRealFace,
656
848
  referenceImages: input.referenceImages ?? resolved.referenceImages,
657
849
  videoRefSeconds: input.videoRefSeconds, audioRefSeconds: input.audioRefSeconds,
658
850
  });
@@ -739,6 +931,9 @@ function compactAsset(a) {
739
931
  prompt: typeof r.prompt === 'string' ? r.prompt : null,
740
932
  }),
741
933
  type: r.type,
934
+ // Only when it IS one. A compact row pays for every key on every asset,
935
+ // and "not a favorite" is the default the app writes — absent says it.
936
+ ...(r.isFavorite === true ? { isFavorite: true } : {}),
742
937
  created_at: r.createdAt ?? r.created_at ?? undefined,
743
938
  };
744
939
  }
@@ -807,7 +1002,7 @@ function describeResolvedRefs(refInputs, resolved) {
807
1002
  }
808
1003
  export const listAssets = {
809
1004
  id: 'slates_list_assets',
810
- description: 'List assets in a Slates project as COMPACT rows (id, code, label, type) — newest first, default limit 50. Each asset carries its short code (IMG-A12 / VID-V3 / AUD-S1 — the badge the user sees on the gallery card) and label. When the user names an asset by code ("use IMG-A36 as the reference"), pass it as `search` to resolve the assetId. Always speak about assets by code + label, never by UUID. NOTE: generate_* results already return the new asset ids — do NOT call this to find an asset you just created.',
1005
+ description: 'List assets in a Slates project as COMPACT rows (id, code, label, type) — newest first, default limit 50. Each asset carries its short code (IMG-A12 / VID-V3 / AUD-S1 — the badge the user sees on the gallery card) and label. When the user names an asset by code ("use IMG-A36 as the reference"), pass it as `search` to resolve the assetId. Always speak about assets by code + label, never by UUID. A favorited asset carries `isFavorite: true` (absent means not favorited). NOTE: generate_* results already return the new asset ids — do NOT call this to find an asset you just created.',
811
1006
  input: z.object({
812
1007
  projectId: z.string().uuid(),
813
1008
  type: z.enum(['image', 'video', 'audio']).optional().describe('Only this asset type'),
@@ -868,7 +1063,7 @@ export const getAssetImage = {
868
1063
  };
869
1064
  export const getAssetsBatch = {
870
1065
  id: 'slates_get_assets_batch',
871
- description: 'Fetch up to 8 image-asset thumbnails inline in a single call. Use this when picking the right reference from a project gallery — one round trip beats N. Each returned image carries its short code (IMG-A12) and label so you can speak about candidates in the user\'s shared vocabulary ("between IMG-A12 and IMG-A14, the second has the right composition"). Video assets are not supported here — call slates_get_asset_video_frames for those.',
1066
+ description: 'Fetch up to 8 image-asset thumbnails inline in a single call. Use this when picking the right reference from a project gallery — one round trip beats N. Each returned image carries its short code (IMG-A12) and label. Video assets are not supported here — call slates_get_asset_video_frames for those.',
872
1067
  input: z.object({
873
1068
  ids: z.array(z.string().uuid()).min(1).max(8).describe('1-8 image-asset ids. Order is preserved in the response.'),
874
1069
  }),
@@ -931,6 +1126,71 @@ export const getAssetVideoFrames = {
931
1126
  };
932
1127
  },
933
1128
  };
1129
+ export const getChatGptStatus = {
1130
+ id: 'slates_get_chatgpt_status',
1131
+ description: 'Check whether the local Codex host is installed, signed in with ChatGPT, and supports built-in image generation. Does not generate or charge Slates credits.',
1132
+ input: z.object({}),
1133
+ async run(_input, ctx) {
1134
+ await ctx.desktop().requireCapability('chatgpt-image-generation', 'ChatGPT image generation');
1135
+ return ok(await ctx.desktop().get('/agent/chatgpt/status'));
1136
+ },
1137
+ };
1138
+ export const connectChatGpt = {
1139
+ id: 'slates_connect_chatgpt',
1140
+ description: 'Start Codex-managed ChatGPT sign-in and return a browser sign-in URL for the user. Call only when the user requests connecting their account. Never ask for credentials or tokens.',
1141
+ input: z.object({}),
1142
+ async run(_input, ctx) {
1143
+ await ctx.desktop().requireCapability('chatgpt-image-generation', 'ChatGPT image generation');
1144
+ return ok(await ctx.desktop().post('/agent/chatgpt/connect', {}));
1145
+ },
1146
+ };
1147
+ export const generateChatGptImage = {
1148
+ id: 'slates_generate_chatgpt_image',
1149
+ description: 'Generate an image through the local Codex host using the connected ChatGPT account, then save it in Slates with its exact submitted prompt, reference lineage and measured dimensions. Uses ChatGPT account limits, never Slates credits or a paid API fallback. Check slates_get_chatgpt_status first. Supply a new UUID requestId once per intended generation; reuse it for retries to avoid duplicate generation/import. No explicit image model, quality or size controls are exposed. Prompt may request visual properties without guaranteeing them. Background returns a generationId for slates_get_generation_status.',
1150
+ input: z.object({ projectId: z.string().uuid(), requestId: z.string().uuid(), prompt: z.string().min(1),
1151
+ aspectRatio: z.enum(CHATGPT_FRAMING_RATIOS).optional().describe('Optional framing request appended verbally to the prompt, not an exact output-size guarantee.'),
1152
+ referenceAssetIds: z.array(z.string().min(1)).optional(), background: z.boolean().optional() }),
1153
+ async run(input, ctx) {
1154
+ const desktop = ctx.desktop();
1155
+ await desktop.requireCapability('chatgpt-image-generation', 'ChatGPT image generation');
1156
+ const refs = await resolveAssetRefs(ctx, input.projectId, input.referenceAssetIds ?? []);
1157
+ const result = await desktop.post('/agent/generation/chatgpt-image', {
1158
+ ...input, referenceAssetIds: (input.referenceAssetIds ?? []).map(id => refs.get(id).id),
1159
+ });
1160
+ return ok(result, JSON.stringify(result) + '\n' + describeResolvedRefs((input.referenceAssetIds ?? []).map(ref => ({ ref, role: 'reference image' })), refs));
1161
+ },
1162
+ };
1163
+ export const saveExternalImage = {
1164
+ id: 'slates_save_external_image',
1165
+ description: 'Save an image generated by an external host with its exact prompt, generator, reference images and measured dimensions. This does not generate or spend Slates credits. Pass filePath or dataUrl for a new result, or assetId to annotate an existing upload in place. Ordinary pasted or dragged files belong to slates_upload_reference_image. Only name a model if the host reported it; requestedSettings records requests, not verified output settings.',
1166
+ input: z.object({
1167
+ projectId: z.string().uuid(),
1168
+ filePath: z.string().min(1).optional(),
1169
+ dataUrl: z.string().min(1).optional(),
1170
+ assetId: z.string().min(1).optional(),
1171
+ prompt: z.string().min(1),
1172
+ generator: z.string().min(1),
1173
+ model: z.string().min(1).optional(),
1174
+ negativePrompt: z.string().min(1).optional(),
1175
+ referenceAssetIds: z.array(z.string().min(1)).optional(),
1176
+ requestedSettings: z.record(z.union([z.string(), z.number().finite(), z.boolean()])).optional(),
1177
+ }).refine(d => [d.filePath, d.dataUrl, d.assetId].filter(Boolean).length === 1, {
1178
+ message: 'Pass exactly one of filePath, dataUrl or assetId',
1179
+ }),
1180
+ async run(input, ctx) {
1181
+ const desktop = ctx.desktop();
1182
+ await desktop.requireCapability('external-image-metadata', 'external image generation metadata');
1183
+ const refs = await resolveAssetRefs(ctx, input.projectId, [
1184
+ ...(input.referenceAssetIds ?? []), ...(input.assetId ? [input.assetId] : []),
1185
+ ]);
1186
+ const result = await desktop.post('/agent/assets/external-image', {
1187
+ ...input,
1188
+ assetId: input.assetId ? refs.get(input.assetId).id : undefined,
1189
+ referenceAssetIds: (input.referenceAssetIds ?? []).map(id => refs.get(id).id),
1190
+ });
1191
+ return ok(result, JSON.stringify(result) + '\n' + describeResolvedRefs((input.referenceAssetIds ?? []).map(ref => ({ ref, role: 'reference image' })), refs));
1192
+ },
1193
+ };
934
1194
  export const uploadReferenceImage = {
935
1195
  id: 'slates_upload_reference_image',
936
1196
  description: 'Add a reference image, video clip, or audio file to a Slates project from disk. Pass either filePath (absolute path to a local file) or dataUrl (base64 data: URL) — exactly one. Set type:"video" to bring in a clip (the user\'s own footage to edit/relocate/trim) or type:"audio" for music/VO/SFX they already have; both are probed on ingest, so duration (and for video, dimensions) are available immediately, and audio gets its waveform. Default type is "image". dataUrl is image-only.',
@@ -1000,6 +1260,62 @@ export const moveAssetsToFolder = {
1000
1260
  return ok(await ctx.desktop().post('/agent/folders/move-assets', input));
1001
1261
  },
1002
1262
  };
1263
+ export const setAssetFavorite = {
1264
+ id: 'slates_set_asset_favorite',
1265
+ description: 'Mark or unmark one asset as a favorite. The heart persists on the card in the app, and slates_list_assets reports it as isFavorite.',
1266
+ input: z.object({
1267
+ assetId: z.string().uuid(),
1268
+ favorite: z.boolean(),
1269
+ }),
1270
+ async run(input, ctx) {
1271
+ await ctx.desktop().requireCapability('asset-favorite', 'favorites');
1272
+ return ok(await ctx.desktop().post('/agent/assets/favorite', input));
1273
+ },
1274
+ };
1275
+ export const exportAssets = {
1276
+ id: 'slates_export_assets',
1277
+ description: 'Copy the ORIGINAL files of one or more assets (images, clips or audio) into a directory on this machine, named by their codes (IMG-A12.png). Never overwrites: a clash gets -2, -3. The project is untouched. Works for an image-only project with no shot, video or timeline. Returns each exported path and any asset whose file is missing.',
1278
+ input: z.object({
1279
+ assetIds: z.array(z.string().uuid()).min(1),
1280
+ directory: z.string().min(1).describe('Absolute path; created if it does not exist.'),
1281
+ }),
1282
+ async run(input, ctx) {
1283
+ await ctx.desktop().requireCapability('asset-export', 'exporting media');
1284
+ return ok(await ctx.desktop().post('/agent/assets/export', input));
1285
+ },
1286
+ };
1287
+ export const listPins = {
1288
+ id: 'slates_list_pins',
1289
+ description: "List a project's pinned references: the images kept in the dock's Pinned section, one click from the prompt. A pin attaches nothing to a generation by itself.",
1290
+ input: z.object({ projectId: z.string().uuid() }),
1291
+ async run(input, ctx) {
1292
+ await ctx.desktop().requireCapability('pins', 'pinned references');
1293
+ return ok(await ctx.desktop().get('/agent/pins', { projectId: input.projectId }));
1294
+ },
1295
+ };
1296
+ export const pinReferences = {
1297
+ id: 'slates_pin_references',
1298
+ description: 'Pin one or more images to the dock\'s Pinned section (UUIDs or badge codes like IMG-A8). Already-pinned ones are reported, not doubled. Pinning keeps a picture at hand; pass it as a reference to a generation to actually use it.',
1299
+ input: z.object({
1300
+ projectId: z.string().uuid(),
1301
+ assetIds: z.array(z.string().min(1)).min(1).describe('Image assets, UUIDs or badge codes.'),
1302
+ }),
1303
+ async run(input, ctx) {
1304
+ await ctx.desktop().requireCapability('pins', 'pinned references');
1305
+ const resolved = await resolveAssetRefs(ctx, input.projectId, input.assetIds);
1306
+ return ok(await ctx.desktop().post('/agent/pins', { projectId: input.projectId, assetIds: input.assetIds.map((r) => resolved.get(r).id) }));
1307
+ },
1308
+ };
1309
+ export const unpinReference = {
1310
+ id: 'slates_unpin_reference',
1311
+ description: 'Take one image off the dock\'s Pinned section. The image stays in the project.',
1312
+ input: z.object({ projectId: z.string().uuid(), assetId: z.string().min(1).describe('UUID or badge code.') }),
1313
+ async run(input, ctx) {
1314
+ await ctx.desktop().requireCapability('pins', 'pinned references');
1315
+ const resolved = await resolveAssetRefs(ctx, input.projectId, [input.assetId]);
1316
+ return ok(await ctx.desktop().post('/agent/pins/remove', { projectId: input.projectId, assetId: resolved.get(input.assetId).id }));
1317
+ },
1318
+ };
1003
1319
  export const moveAssetsToProject = {
1004
1320
  id: 'slates_move_assets_to_project',
1005
1321
  description: 'Move assets (images, videos, audio) out of one project into another. The media files move on disk into the destination project folder — this is a real re-home, not a copy. Moved assets leave whatever gallery folder they were in and are issued fresh badge codes in the destination.',
@@ -1041,7 +1357,7 @@ export const moveAssetsToProject = {
1041
1357
  };
1042
1358
  export const copyAssetsToProject = {
1043
1359
  id: 'slates_copy_assets_to_project',
1044
- description: 'Copy assets (images, videos, audio) from one project into another. The originals stay exactly where they are — new files, new rows, new badge codes in the destination. Use this instead of slates_move_assets_to_project when the asset is already in use where it lives: an image that is a character/environment/style identity or sits in a storyboard frame CANNOT be moved out (that would leave the other project pointing at a file it no longer owns), but it can always be copied. Lineage is not copied; the copy starts clean.',
1360
+ description: 'Copy assets (images, videos, audio) from one project into another. The originals stay exactly where they are — new files, new rows, new badge codes in the destination. Use this instead of slates_move_assets_to_project when the asset is already in use where it lives: an image that is a character, location or look picture, or sits in a board frame, CANNOT be moved out (that would leave the other project pointing at a file it no longer owns), but it can always be copied. Lineage is not copied; the copy starts clean.',
1045
1361
  input: z.object({
1046
1362
  sourceProjectId: z.string().uuid(),
1047
1363
  // Badge codes ("IMG-A8") resolve against sourceProjectId at call time.
@@ -1066,13 +1382,20 @@ export const copyAssetsToProject = {
1066
1382
  };
1067
1383
  export const moveEntityToProject = {
1068
1384
  id: 'slates_move_entity_to_project',
1069
- description: "Move a character, environment, or style into another project, taking every image it references with it. This is the fix when slates_move_assets_to_project refuses an asset because an entity still uses it: the identity image can't leave on its own, but the whole entity can. Refuses (rather than cascading) if one of its images is ALSO used by something else — copy the images instead in that case. Storyboard frames are not movable this way; moving a frame's image out would empty the shot.",
1385
+ description: "Move a Library item (a character, location, product, look…) into another project, taking every image it references with it; it lands in the target's category of the same name. This is the fix when slates_move_assets_to_project refuses an asset because a Library item still uses it: the image can't leave on its own, but the whole item can. Refuses (rather than cascading) if one of its images is ALSO used by something else — copy the images instead in that case. mentioningShots lists this project's Shots that mention it: they keep the words and fire without it until it comes back. Board frames are not movable this way; moving a frame's image out would empty the shot.",
1070
1386
  input: z.object({
1071
- kind: z.enum(['character', 'environment', 'style']),
1387
+ kind: z
1388
+ .enum(['library', 'character', 'environment', 'style'])
1389
+ .optional()
1390
+ .describe('Optional and ignored: every value means a Library item. Kept for older callers.'),
1072
1391
  entityId: z.string().uuid(),
1073
1392
  targetProjectId: z.string().uuid(),
1074
1393
  }),
1075
1394
  async run(input, ctx) {
1395
+ // 1.5.8 refuses an omitted or 'library' kind; only a legacy kind reaches it.
1396
+ if (input.kind === undefined || input.kind === 'library') {
1397
+ await ctx.desktop().requireCapability('library', 'the Library');
1398
+ }
1076
1399
  return ok(await ctx.desktop().post('/agent/entities/move-to-project', input));
1077
1400
  },
1078
1401
  };
@@ -1115,7 +1438,7 @@ export const setCharacterIdentity = {
1115
1438
  // ── Environments ────────────────────────────────────────────────
1116
1439
  export const listEnvironments = {
1117
1440
  id: 'slates_list_environments',
1118
- description: 'List environments in a Slates project.',
1441
+ description: 'List the locations (environments) in a Slates project.',
1119
1442
  input: z.object({ projectId: z.string().uuid() }),
1120
1443
  async run(input, ctx) {
1121
1444
  return ok(await ctx.desktop().get('/agent/environments', { projectId: input.projectId }));
@@ -1123,7 +1446,7 @@ export const listEnvironments = {
1123
1446
  };
1124
1447
  export const createEnvironment = {
1125
1448
  id: 'slates_create_environment',
1126
- description: 'Create a new environment in a Slates project.',
1449
+ description: 'Create a new location (environment) in a Slates project.',
1127
1450
  input: z.object({
1128
1451
  projectId: z.string().uuid(),
1129
1452
  name: z.string().min(1).max(120),
@@ -1139,14 +1462,14 @@ export const generateCharacterIdentity = {
1139
1462
  billable: true,
1140
1463
  description: "Generate one character identity sheet from a base portrait asset and bind it as the character's canonical reference. Call after slates_create_character. Read slates-character-identity before calling and quote the cost from slates_estimate_generation_cost.",
1141
1464
  input: z.object({
1142
- characterId: z.string().uuid(),
1465
+ characterId: z.string().uuid().describe('A character, or the id of any Library thing: both sheet tools run on any thing, and the result binds as its image.'),
1143
1466
  projectId: z.string().uuid(),
1144
1467
  baseAssetId: z.string().uuid().describe('The base portrait asset the identity is generated from.'),
1145
1468
  userNotes: z.string().optional().describe('Extra instruction, e.g. "use the woman on the left".'),
1146
1469
  model: z
1147
1470
  .enum(['nano-banana-2', 'nano-banana-2-lite', 'nano-banana-pro', 'gpt-image-2-5-flare', 'gpt-image-2-5-sunburst'])
1148
1471
  .optional()
1149
- .describe('Image model for the sheet. Omit for the default (nano-banana-2). Exists so the layout-vs-face tradeoff can be tested with comparison gens — do not switch without a receipt.'),
1472
+ .describe(`Image model for the sheet. Omit for the sheet tool's seat (${toolModelFor('character-sheet')}), which is what the app's own button uses. Pass another only to compare seats side by side.`),
1150
1473
  }),
1151
1474
  async run(input, ctx) {
1152
1475
  // 🚨 THE ROSTER GATE, WHICH THIS OP NEVER HAD. Its `model` enum offers
@@ -1161,6 +1484,11 @@ export const generateCharacterIdentity = {
1161
1484
  else if (input.model === 'nano-banana-pro' || input.model === 'nano-banana-2-lite') {
1162
1485
  await ctx.desktop().requireCapability('image-models-v2', `${input.model} character identity`);
1163
1486
  }
1487
+ // 1.5.8 renders an unnamed model as Nano Banana 2 and every model at 2k, so
1488
+ // only an explicit Nano Banana model runs there as quoted.
1489
+ if (!input.model || isGptImageModel(input.model)) {
1490
+ await ctx.desktop().requireCapability('sheet-tool-seats', 'sheet tools on the default image seat');
1491
+ }
1164
1492
  return ok(await ctx.desktop().post('/agent/characters/generate-identity', {
1165
1493
  characterId: input.characterId,
1166
1494
  projectId: input.projectId,
@@ -1173,9 +1501,9 @@ export const generateCharacterIdentity = {
1173
1501
  export const generateEnvironmentPlate = {
1174
1502
  id: 'slates_generate_environment_plate',
1175
1503
  billable: true,
1176
- description: "Generate one clean establishing image from an optional base image and bind it as the environment's canonical reference. Call after slates_create_environment and quote the cost from slates_estimate_generation_cost.",
1504
+ description: "Generate one clean establishing image from an optional base image and bind it as the location's canonical reference. Call after slates_create_environment and quote the cost from slates_estimate_generation_cost.",
1177
1505
  input: z.object({
1178
- environmentId: z.string().uuid(),
1506
+ environmentId: z.string().uuid().describe('A location, or the id of any Library thing: both sheet tools run on any thing, and the result binds as its image.'),
1179
1507
  projectId: z.string().uuid(),
1180
1508
  baseAssetId: z
1181
1509
  .string()
@@ -1185,6 +1513,8 @@ export const generateEnvironmentPlate = {
1185
1513
  userNotes: z.string().optional(),
1186
1514
  }),
1187
1515
  async run(input, ctx) {
1516
+ // 1.5.8 always renders the plate on Nano Banana 2 at 2k, never the seat quoted.
1517
+ await ctx.desktop().requireCapability('sheet-tool-seats', 'sheet tools on the default image seat');
1188
1518
  return ok(await ctx.desktop().post('/agent/environments/generate-plate', {
1189
1519
  environmentId: input.environmentId,
1190
1520
  projectId: input.projectId,
@@ -1196,7 +1526,7 @@ export const generateEnvironmentPlate = {
1196
1526
  // ── Storyboards ─────────────────────────────────────────────────
1197
1527
  export const listStoryboards = {
1198
1528
  id: 'slates_list_storyboards',
1199
- description: 'List storyboards in a Slates project.',
1529
+ description: 'List the boards (storyboards) in a Slates project.',
1200
1530
  input: z.object({ projectId: z.string().uuid() }),
1201
1531
  async run(input, ctx) {
1202
1532
  return ok(await ctx.desktop().get('/agent/storyboards', { projectId: input.projectId }));
@@ -1204,7 +1534,7 @@ export const listStoryboards = {
1204
1534
  };
1205
1535
  export const createStoryboard = {
1206
1536
  id: 'slates_create_storyboard',
1207
- description: 'Create a new storyboard with a default first scene. Returns the storyboard record.',
1537
+ description: 'Create a new board (storyboard) with a default first scene. Returns the board record.',
1208
1538
  input: z.object({
1209
1539
  projectId: z.string().uuid(),
1210
1540
  name: z.string().min(1).max(120),
@@ -1220,7 +1550,7 @@ export const getStoryboardWithFrames = {
1220
1550
  // in the same order, that the user is looking at. Before this the agent got
1221
1551
  // scenes and frames with no Shots, so the user read an arranged board while
1222
1552
  // the agent read an unordered pile.
1223
- description: "Deep-fetch a storyboard: every scene, every slot, and the SHOT in each slot — its script line, references, model, params and takes — plus the piece's variety distribution. This is the same board, in the same order, that the user is reading.",
1553
+ description: "Deep-fetch a board: every scene, every slot, and the SHOT in each slot — its script line, references, model, params and takes — plus the piece's variety distribution. This is the same board, in the same order, that the user is reading.",
1224
1554
  input: z.object({ storyboardId: z.string().uuid() }),
1225
1555
  async run(input, ctx) {
1226
1556
  const desktop = ctx.desktop();
@@ -1233,7 +1563,7 @@ export const getStoryboardWithFrames = {
1233
1563
  };
1234
1564
  export const addScene = {
1235
1565
  id: 'slates_add_scene',
1236
- description: 'Add a new scene to a storyboard.',
1566
+ description: 'Add a new scene to a board.',
1237
1567
  input: z.object({
1238
1568
  storyboardId: z.string().uuid(),
1239
1569
  name: z.string().min(1).max(120),
@@ -1368,7 +1698,7 @@ export function isGptImageModel(model) {
1368
1698
  * ten and the other five would be accepted here. The image param surface has
1369
1699
  * not been audited (aspect ratios, resolution classes, per-model reference
1370
1700
  * caps) — that audit is the named follow-up in
1371
- * `slate/docs/plan-docs/2026-08-16-MODEL-CAPABILITY-SSOT.md` §5. The machinery
1701
+ * `second-brain/plans/2026-08-16-slates-model-capability-ssot.md` §5. The machinery
1372
1702
  * to close it already exists: call `checkAspectRatio(model, ratio)` the way
1373
1703
  * `assertVideoCapabilities` does below.
1374
1704
  */
@@ -1404,7 +1734,7 @@ function gptKeyAspect(aspectRatio) {
1404
1734
  // imports it and asserts, key by key, that it equals the desktop's
1405
1735
  // `imageCreditKey`. Before that check existed the byte-match was a comment and
1406
1736
  // a hope — the two are in different repos and nothing compared them.
1407
- export function imageCostKey(model, resolution, quality = 'high', aspectRatio) {
1737
+ export function imageCostKey(model, resolution, quality = DEFAULT_GPT_QUALITY, aspectRatio) {
1408
1738
  if (model === 'flux-2-max')
1409
1739
  return resolution === '1k' ? 'flux-2-max' : `flux-2-max-${resolution}`;
1410
1740
  if (model === 'seedream-5-lite')
@@ -1417,6 +1747,17 @@ export function imageCostKey(model, resolution, quality = 'high', aspectRatio) {
1417
1747
  return `${model}-${gptKeyTier(quality)}-${resolution}${gptKeyAspect(aspectRatio)}`;
1418
1748
  return `nano-banana-2-${resolution}`;
1419
1749
  }
1750
+ // The default image seat with a project to route through, READ from MODEL_FACTS
1751
+ // `tier`. Asserted at load so a default moved to a seat this op cannot send
1752
+ // fails the build instead of every omitted-model call.
1753
+ const DEFAULT_IMAGE_MODEL = defaultModelFor('image');
1754
+ if (!IMAGE_MODELS.includes(DEFAULT_IMAGE_MODEL)) {
1755
+ throw new Error(`slates_generate_image: default image seat ${DEFAULT_IMAGE_MODEL} is not in IMAGE_MODELS`);
1756
+ }
1757
+ /** The headless path's one fal batch: `maxBatchImages` owns it (nano-banana-2 is the only batching model). */
1758
+ const HEADLESS_BATCH_CAP = MODEL_CAPABILITIES['nano-banana-2']?.maxBatchImages ?? 1;
1759
+ /** A 1.5.8 desktop's own clamp on a project batch, frozen with that build. */
1760
+ const LEGACY_DESKTOP_IMAGE_BATCH = 4;
1420
1761
  export const generateImage = {
1421
1762
  id: 'slates_generate_image',
1422
1763
  billable: true,
@@ -1435,13 +1776,13 @@ export const generateImage = {
1435
1776
  describeBannedTokens('image'),
1436
1777
  input: z.object({
1437
1778
  prompt: z.string().min(1).max(4000),
1438
- model: zEnum(IMAGE_MODELS).optional().describe('Image model. Default nano-banana-2. Routing doctrine: slates-model-selection skill. All except nano-banana-2 require projectId.'),
1779
+ model: zEnum(IMAGE_MODELS).optional().describe(`Image model. Omitted: ${DEFAULT_IMAGE_MODEL} with projectId, nano-banana-2 (the only headless seat) without. Routing: slates-model-selection skill.`),
1439
1780
  projectId: z.string().uuid().optional().describe('Save into this Slates project. Renderer refreshes live. Required for every model except nano-banana-2.'),
1440
- resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('1k drafts, 2k hero, 4k final. nano-banana-2-lite: 1k only. GPT Image classes 1024²/1080p/1440p/2160p. Never default this.'),
1441
- quality: z.enum(GPT_QUALITY_TIERS).optional().describe('GPT Image only. UNEVEN ladder: max=4× high, xhigh~1.8×. medium drafts; default high.'),
1781
+ resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('Omit for the selected model default. Override for a specific delivery size; use the same setting when estimating.'),
1782
+ quality: z.enum(GPT_QUALITY_TIERS).optional().describe(`GPT Image only. Default ${DEFAULT_GPT_QUALITY}; estimate the chosen tier before generation.`),
1442
1783
  backgroundMode: z.enum(GPT_BACKGROUNDS).optional().describe('GPT Image only. transparent = alpha channel. Free.'),
1443
1784
  aspectRatio: zEnum(IMAGE_ASPECT_RATIOS).optional().describe(`Pick from the use case: cinematic 16:9 · TikTok/Reels 9:16 · IG square 1:1 · ultra-wide 21:9. 1:1 costs most on GPT Image. Per model: ${describeAspectRatios(IMAGE_MODELS)}`),
1444
- count: z.number().int().min(1).max(10).optional().describe('Up to 10 with projectId; headless caps at 4.'),
1785
+ count: z.number().int().min(1).max(MAX_IMAGE_VARIATIONS).optional().describe(`Up to ${MAX_IMAGE_VARIATIONS} with projectId; headless caps at ${HEADLESS_BATCH_CAP}.`),
1445
1786
  referenceImageUrls: z.array(z.string().url()).max(14).optional().describe('Headless (no projectId) nano-banana-2 only. With a projectId, upload via slates_upload_reference_image. Label every image role in the prompt.'),
1446
1787
  referenceAssetIds: z.array(z.string()).max(16).optional().describe("Project assets as references — UUIDs or badge codes (\"IMG-A8\"), resolved at call time. Requires projectId. Caps: GPT Image 16, nano-banana-2 14, FLUX/Seedream lower. Label every reference role in the prompt."),
1447
1788
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
@@ -1455,7 +1796,13 @@ export const generateImage = {
1455
1796
  // Non-blocking prompt hygiene. Computed once, reported on every exit path
1456
1797
  // that echoes a prompt -- the clarification and confirm gates are PRE-spend,
1457
1798
  // which is where a rewrite is still free.
1458
- const promptWarning = bannedTokenWarning(input.prompt, 'image');
1799
+ // Omitted model: the default seat when there is a project to route through,
1800
+ // nano-banana-2 without one, because it is the only headless seat. The
1801
+ // desktop's pre-flight guard (`estimateOpCostCents`, src/main/studio-agent/
1802
+ // ops.ts) picks the same seat: change both together.
1803
+ const imageModel = input.model ?? (input.projectId ? DEFAULT_IMAGE_MODEL : 'nano-banana-2');
1804
+ const promptWarning = bannedTokenWarning(input.prompt, 'image', promptingSkillFor(imageModel));
1805
+ input = { ...input, resolution: input.resolution ?? defaultImageResolutionFor(imageModel) };
1459
1806
  if (!input.aspectRatio || !input.resolution) {
1460
1807
  const missing = [];
1461
1808
  if (!input.aspectRatio)
@@ -1468,14 +1815,13 @@ export const generateImage = {
1468
1815
  ...(promptWarning ? { prompt_warning: promptWarning } : {}),
1469
1816
  message: (promptWarning ? `${promptWarning}\n\n` : '') +
1470
1817
  `Missing required field(s): ${missing.join(', ')}. ` +
1471
- `Read the slates-prompting-nano-banana-2 + slates-cost-discipline skills, ` +
1472
- // Generated from MODEL_CAPABILITIES — never retype a ratio list.
1818
+ `Read the ${promptingSkillFor(imageModel)} + slates-cost-discipline skills, ` +
1819
+ // Generated from MODEL_CAPABILITIES — never retype a ratio or resolution list.
1473
1820
  `or ask the user. Aspect ratio by model: ${describeAspectRatios(IMAGE_MODELS)}. ` +
1474
- `Resolution options: 1k 2k 4k (same price band — pick by need, not cost).`,
1821
+ `Resolution options for ${imageModel}: ${(MODEL_CAPABILITIES[imageModel]?.imageResolutions ?? []).join(' ')}.`,
1475
1822
  });
1476
1823
  }
1477
1824
  const resolution = input.resolution;
1478
- const imageModel = input.model ?? 'nano-banana-2';
1479
1825
  const resolutions = MODEL_CAPABILITIES[imageModel]?.imageResolutions ?? [];
1480
1826
  if (resolution && !resolutions.includes(resolution)) {
1481
1827
  return ok({ requires_clarification: true, missing: ['resolution'],
@@ -1514,11 +1860,11 @@ export const generateImage = {
1514
1860
  // `num_images` maximum is 4 (fal schema, 2026-09-09). Refused rather than
1515
1861
  // clamped: a silent clamp would make four images against a request for ten
1516
1862
  // and read to the caller as a partial failure it should retry.
1517
- if (!input.projectId && (input.count ?? 1) > 4) {
1863
+ if (!input.projectId && (input.count ?? 1) > HEADLESS_BATCH_CAP) {
1518
1864
  return ok({
1519
1865
  requires_clarification: true,
1520
1866
  missing: ['projectId'],
1521
- message: 'count above 4 needs a projectId. The headless path asks fal for one batch and nano-banana-2 caps a batch at 4; with a projectId the desktop fires them as separate generations and the limit is 10.',
1867
+ message: `count above ${HEADLESS_BATCH_CAP} needs a projectId. The headless path asks fal for one batch and nano-banana-2 caps a batch at ${HEADLESS_BATCH_CAP}; with a projectId the desktop fires them as separate generations and the limit is ${MAX_IMAGE_VARIATIONS}.`,
1522
1868
  });
1523
1869
  }
1524
1870
  let refEcho = '';
@@ -1548,6 +1894,10 @@ export const generateImage = {
1548
1894
  (imageModel === 'nano-banana-pro' || imageModel === 'nano-banana-2-lite')) {
1549
1895
  await ctx.desktop().requireCapability('image-models-v2', `${imageModel} generation`);
1550
1896
  }
1897
+ // 1.5.8 clamps a project batch to its own 4 and bills 4 against this op's quote for all of them.
1898
+ if (input.projectId && (input.count ?? 1) > LEGACY_DESKTOP_IMAGE_BATCH) {
1899
+ await ctx.desktop().requireCapability('image-variations', 'more than 4 images per call');
1900
+ }
1551
1901
  const costKey = imageCostKey(imageModel, resolution, input.quality, input.aspectRatio ?? '1:1');
1552
1902
  const cloud = ctx.cloud();
1553
1903
  const registry = await cloud.get('/api/agent/models');
@@ -1618,7 +1968,7 @@ export const generateImage = {
1618
1968
  resolution,
1619
1969
  aspectRatio: input.aspectRatio ?? '1:1',
1620
1970
  count: input.count ?? 1,
1621
- ...(isGptImageModel(imageModel) ? { gptQuality: input.quality, gptBackground: input.backgroundMode } : {}),
1971
+ ...(isGptImageModel(imageModel) ? { gptQuality: input.quality ?? DEFAULT_GPT_QUALITY, gptBackground: input.backgroundMode } : {}),
1622
1972
  ...(referenceAssetIds.length > 0 ? { referenceAssetIds } : {}),
1623
1973
  background: input.background,
1624
1974
  });
@@ -1799,18 +2149,22 @@ async function pollProxyJob(cloud, jobId, options = {}) {
1799
2149
  throw new Error(`Generation timed out after ${Math.round(timeoutMs / 1000)}s.`);
1800
2150
  }
1801
2151
  // ── Edit image ──────────────────────────────────────────────────
2152
+ /** References an edit carries beside the source, which is image 1 of the same request. */
2153
+ const editReferenceCap = (model) => Math.max(0, (MODEL_CAPABILITIES[model]?.maxRefImages ?? 1) - 1);
2154
+ /** The models a 1.5.8 desktop sends edit references on, frozen with that build: on every other model it drops them and bills the edit in full. */
2155
+ const LEGACY_EDIT_REFERENCE_MODELS = ['nano-banana-2', 'nano-banana-2-lite', 'nano-banana-pro'];
1802
2156
  export const editImage = {
1803
2157
  id: 'slates_edit_image',
1804
2158
  billable: true,
1805
- description: 'Surgically edit an image asset with a text instruction (e.g. \'make the jacket red\') instead of regenerating from scratch — use when ~90% of the image is already right. The result is a NEW asset (prompt prefixed \'[Edit]\'); the source is untouched. Default model nano-banana-2 (only model that also accepts referenceAssetIds); flux-2-max / seedream-5-lite use their own edit endpoints and ignore references. Before first use call slates_get_prompting_guide with topic \'slates-edit-and-iterate\'.',
2159
+ description: 'Surgically edit an image asset with a text instruction (e.g. \'make the jacket red\') instead of regenerating from scratch — use when ~90% of the image is already right. The result is a NEW asset (prompt prefixed \'[Edit]\'); the source is untouched. Default model ' + toolModelFor('image-edit') + ' (the image-edit tool seat, the model the app\'s Edit box opens on). Every model also takes referenceAssetIds, up to its reference cap less one: the source is image 1 of the request. Before first use call slates_get_prompting_guide with topic \'slates-edit-and-iterate\'.',
1806
2160
  input: z.object({
1807
2161
  projectId: z.string().uuid(),
1808
2162
  sourceAssetId: z.string().uuid().describe('Image asset to edit. Must exist in the project.'),
1809
2163
  prompt: z.string().min(1).max(4000).describe('The change, not the whole image.'),
1810
- editModel: z.enum(['nano-banana-2', 'nano-banana-2-lite', 'nano-banana-pro', 'gpt-image-2-5-flare', 'gpt-image-2-5-sunburst', 'flux-2-max', 'seedream-5-lite']).optional(),
1811
- referenceAssetIds: z.array(z.string().uuid()).max(13).optional().describe('Nano-Banana only (NB Pro 13, NB2 Lite 3).'),
2164
+ editModel: zEnum(IMAGE_MODELS).optional(),
2165
+ referenceAssetIds: z.array(z.string().uuid()).max(Math.max(...IMAGE_MODELS.map(editReferenceCap))).optional().describe(`Images beside the source, which is image 1. Each model's own cap: ${IMAGE_MODELS.map((m) => `${m} ${editReferenceCap(m)}`).join(', ')}.`),
1812
2166
  resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('3k = GPT Image/seedream-5-lite; nano-banana-2-lite is 1k only.'),
1813
- quality: z.enum(GPT_QUALITY_TIERS).optional().describe('GPT Image tier; default high.'),
2167
+ quality: z.enum(GPT_QUALITY_TIERS).optional().describe(`GPT Image tier; default ${DEFAULT_GPT_QUALITY}.`),
1814
2168
  backgroundMode: z.enum(GPT_BACKGROUNDS).optional().describe('GPT Image only. transparent = alpha channel. Free.'),
1815
2169
  aspectRatio: z.string().optional(),
1816
2170
  confirm: z.boolean().optional().describe('Set true to bypass the confirm gate.'),
@@ -1822,8 +2176,9 @@ export const editImage = {
1822
2176
  if (input.background) {
1823
2177
  await desktop.requireCapability('background-generation', 'background generation');
1824
2178
  }
1825
- const editModel = input.editModel ?? 'nano-banana-2';
1826
- const resolution = input.resolution ?? (editModel === 'nano-banana-2-lite' ? '1k' : '2k');
2179
+ // The image-edit tool's seat (TOOL_SEAT), the model the app's own Edit uses.
2180
+ const editModel = input.editModel ?? toolModelFor('image-edit');
2181
+ const resolution = input.resolution ?? defaultImageResolutionFor(editModel);
1827
2182
  if (isGptImageModel(editModel)) {
1828
2183
  // 🚨 v3, LIKE generateImage — this site was missed once already.
1829
2184
  // A pre-2.5 desktop advertises v2, so gating the 2.5 seats on v2 lets it
@@ -1835,8 +2190,15 @@ export const editImage = {
1835
2190
  else if (editModel === 'nano-banana-pro' || editModel === 'nano-banana-2-lite') {
1836
2191
  await desktop.requireCapability('image-models-v2', `${editModel} editing`);
1837
2192
  }
2193
+ if (input.referenceAssetIds?.length && !LEGACY_EDIT_REFERENCE_MODELS.includes(editModel)) {
2194
+ await desktop.requireCapability('edit-references-all-models', `references on a ${editModel} edit`);
2195
+ }
2196
+ // Past the model's cap the desktop sends the first ones and says so in its reply (`note`).
2197
+ const referencesOver = (input.referenceAssetIds?.length ?? 0) - editReferenceCap(editModel);
1838
2198
  // Nano-Banana family + GPT Image 2.5 edits charge the same key as gen;
1839
2199
  // FLUX / Seedream route to dedicated edit endpoints priced under '-edit' keys.
2200
+ // Mirrors `editCreditKey` in the desktop's src/shared/imageEdit.ts, which
2201
+ // this package cannot import: change both together.
1840
2202
  const costKey = editModel === 'flux-2-max' || editModel === 'seedream-5-lite'
1841
2203
  ? `${imageCostKey(editModel, resolution)}-edit`
1842
2204
  : imageCostKey(editModel, resolution, input.quality, input.aspectRatio);
@@ -1856,6 +2218,7 @@ export const editImage = {
1856
2218
  estimated_credits: totalCents,
1857
2219
  source_ref: sourceRef,
1858
2220
  message: `Cost: ${fmtCredits(totalCents)} to edit ${sourceRef} with ${editModel} (${costKey}). ` +
2221
+ (referencesOver > 0 ? `${editModel} takes ${editReferenceCap(editModel)} references beside the source; ${referencesOver} would not be sent. ` : '') +
1859
2222
  `Re-call with confirm=true after the user explicitly OKs the spend. ` +
1860
2223
  `When discussing with the user, refer to the source by its code (matches the gallery badge).`,
1861
2224
  });
@@ -1867,7 +2230,7 @@ export const editImage = {
1867
2230
  editModel,
1868
2231
  referenceAssetIds: input.referenceAssetIds,
1869
2232
  resolution,
1870
- ...(isGptImageModel(editModel) ? { gptQuality: input.quality, gptBackground: input.backgroundMode } : {}),
2233
+ ...(isGptImageModel(editModel) ? { gptQuality: input.quality ?? DEFAULT_GPT_QUALITY, gptBackground: input.backgroundMode } : {}),
1871
2234
  aspectRatio: input.aspectRatio,
1872
2235
  background: input.background,
1873
2236
  });
@@ -1882,7 +2245,8 @@ export const editImage = {
1882
2245
  sourceAssetId: input.sourceAssetId,
1883
2246
  cost_cents: totalCents,
1884
2247
  cost_credits: totalCents,
1885
- });
2248
+ ...(result.note ? { note: result.note } : {}),
2249
+ }, result.note ? `${result.note}.` : undefined);
1886
2250
  }
1887
2251
  // Inline the edited result so the LLM sees whether the surgery landed —
1888
2252
  // same best-effort pattern as slates_generate_image.
@@ -1900,7 +2264,8 @@ export const editImage = {
1900
2264
  return {
1901
2265
  text: `Edited image saved as a new asset in project ${input.projectId} ` +
1902
2266
  `for ${fmtCredits(totalCents)} via ${editModel}. ` +
1903
- `Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`,
2267
+ `Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
2268
+ (result.note ? ` ${result.note}.` : ''),
1904
2269
  images,
1905
2270
  data: {
1906
2271
  editModel,
@@ -1912,6 +2277,7 @@ export const editImage = {
1912
2277
  cost_credits: totalCents,
1913
2278
  asset: result.asset,
1914
2279
  generationId: result.generationId,
2280
+ ...(result.note ? { note: result.note } : {}),
1915
2281
  },
1916
2282
  };
1917
2283
  },
@@ -2010,7 +2376,7 @@ export function videoCostKey(input) {
2010
2376
  // × vref × res × duration). AI-face route bills the `-face-` key (~45% over
2011
2377
  // faceless); consented real-person route bills the premium `-realface-` key
2012
2378
  // (fal partner endpoint). A reference video flips to `-vref-{res}-{T}s`,
2013
- // T = in + out.
2379
+ // T = in + out, or max(in, out) + out on the AI-face route.
2014
2380
  //
2015
2381
  // ⚠️ EVERY BOUND HERE IS VERSION-SCOPED. 2.5 runs 480p/720p/1080p (no 4K),
2016
2382
  // reaches 30s, and takes references to 30s combined — so its vref total
@@ -2024,7 +2390,11 @@ export function videoCostKey(input) {
2024
2390
  if (vrefSecs > 0) {
2025
2391
  // ceil(x - 0.05) matches the server's probe rounding — quote = bill.
2026
2392
  const maxTotal = v25 ? SEEDANCE_25_VREF_MAX_TOTAL : SEEDANCE_20_VREF_MAX_TOTAL;
2027
- const total = Math.min(maxTotal, Math.max(6, Math.ceil(vrefSecs - 0.05) + input.duration));
2393
+ // EvoLink (the AI-face rail) bills a reference on max(input, output) +
2394
+ // output on 2.0 and 2.5. Mirrors seedanceBilledRefSeconds in slate pricing.ts.
2395
+ const inSecs = Math.ceil(vrefSecs - 0.05);
2396
+ const billedIn = face === '-face' ? Math.max(inSecs, input.duration) : inSecs;
2397
+ const total = Math.min(maxTotal, Math.max(6, billedIn + input.duration));
2028
2398
  return `${input.model}${face}-vref-${res}-${total}s`;
2029
2399
  }
2030
2400
  return `${input.model}${face}-${res}-${input.duration}s`;
@@ -2195,9 +2565,15 @@ function resolveVideoModel(raw) {
2195
2565
  out.sound = true;
2196
2566
  s = s.replace(/-audio\b/, '');
2197
2567
  }
2198
- if (/-(realface|face)\b/.test(s)) {
2199
- out.seedanceFace = true;
2200
- s = s.replace(/-(realface|face)\b/, '');
2568
+ // Two routes, two keys: `-realface` is the real-person route (still refused
2569
+ // without realFaceConsent), `-face` the AI-face one.
2570
+ const face = /-(realface|face)\b/.exec(s);
2571
+ if (face) {
2572
+ if (face[1] === 'realface')
2573
+ out.seedanceRealFace = true;
2574
+ else
2575
+ out.seedanceFace = true;
2576
+ s = s.replace(face[0], '');
2201
2577
  }
2202
2578
  const direct = VIDEO_MODELS.find((m) => m === s);
2203
2579
  if (direct) {
@@ -2211,8 +2587,8 @@ function resolveVideoModel(raw) {
2211
2587
  'kling-v3': 'kling-v3.0-std',
2212
2588
  'kling-v3-pro': 'kling-v3.0-pro',
2213
2589
  'kling-v3-omni': 'kling-v3.0-omni',
2214
- 'kling-v3-omni-pro': 'kling-v3.0-omni',
2215
- 'kling-v3.0-omni-pro': 'kling-v3.0-omni',
2590
+ // No Omni Pro spelling here: Omni Pro is not a seat on this surface, and
2591
+ // aliasing it to Omni ran a cheaper model after a Pro quote.
2216
2592
  'seedance-2.0': 'seedance-2',
2217
2593
  'seedance-2-0': 'seedance-2',
2218
2594
  // ⚠️ The 2.5 spellings must resolve to 2.5, and the BARE `seedance` keeps
@@ -2229,9 +2605,14 @@ function resolveVideoModel(raw) {
2229
2605
  'gemini-omni-flash': 'omni-flash',
2230
2606
  'gemini-omni-flash-preview': 'omni-flash',
2231
2607
  'omni-flash-preview': 'omni-flash',
2232
- // The MAX spellings must come out as MAX. Bare `minimax`, `h3` and
2233
- // `hailuo-3` all mean the BASE row — it holds the full ladder and the
2234
- // references, and it is cheaper at the tier they share.
2608
+ // The MAX and TURBO spellings must come out as themselves. Bare `minimax`,
2609
+ // `h3` and `hailuo-3` all mean the BASE row — it holds the full ladder and
2610
+ // the references, and it is cheaper than Max at the tier they share.
2611
+ 'minimax-h3-max-turbo': 'minimax-h3-max-turbo',
2612
+ 'minimax-h3-turbo': 'minimax-h3-max-turbo',
2613
+ 'h3-max-turbo': 'minimax-h3-max-turbo',
2614
+ 'h3-turbo': 'minimax-h3-max-turbo',
2615
+ 'hailuo-3-max-turbo': 'minimax-h3-max-turbo',
2235
2616
  'minimax-h3-max': 'minimax-h3-max',
2236
2617
  'minimax-h3max': 'minimax-h3-max',
2237
2618
  'h3-max': 'minimax-h3-max',
@@ -2348,7 +2729,7 @@ export const generateVideo = {
2348
2729
  // kling-v3.0-omni-pro entirely and read as if 7 were an Omni Flash-only rule.
2349
2730
  `Visual reference / ingredient assets. Cap per model, combined across the ingredient/character/environment/style params: ${describeReferenceImageCaps(VIDEO_MODELS)}. 2-4 strong references beat both extremes.`),
2350
2731
  characterAssetIds: z.array(z.string()).optional().describe('Character sheet assets — keeps a character consistent.'),
2351
- environmentAssetIds: z.array(z.string()).optional().describe('Environment references — keeps a location consistent.'),
2732
+ environmentAssetIds: z.array(z.string()).optional().describe('Location references — keeps a location consistent.'),
2352
2733
  styleAssetIds: z.array(z.string()).optional().describe('Style references — locks the look.'),
2353
2734
  videoReferenceAssetId: z.string().optional().describe('DEPRECATED — use videoReferenceAssetIds. Kept working: shipped CLI/MCP builds send this shape.'),
2354
2735
  videoReferenceSeconds: z.number().optional().describe('DEPRECATED — the singular partner of videoReferenceSecondsEach.'),
@@ -2361,11 +2742,11 @@ export const generateVideo = {
2361
2742
  videoReferenceSecondsEach: z.array(z.number()).optional().describe('REQUIRED with videoReferenceAssetIds, same order and length: each clip\'s duration in seconds. Feeds the vref cost key; the server re-probes and corrects an understated value upward.'),
2362
2743
  audioReferenceAssetIds: z.array(z.string()).optional().describe(`Reference AUDIO, cited as "audio 1", "audio 2"… in the order given. ${multimodalRefModels().join(' / ')} only. No billing surcharge. ${multimodalRefModels().map(multimodalRefSummary).join(' ')}`),
2363
2744
  audioReferenceSpokenText: z.array(z.string()).optional().describe('The exact words in each reference clip — same order and length as audioReferenceAssetIds, "" for a clip with no speech. The model RE-TRANSCRIBES a take rather than using it verbatim, so audio decides voice/accent/timing and only this decides the WORDS. Omit it and the words are a guess.'),
2364
- sound: z.boolean().optional().describe('Kling Omni / Veo / Seedance: enable audio generation. Default true.'),
2745
+ sound: z.boolean().optional().describe('Kling Omni / Veo / Seedance: Sound on (true) or Silent (false). Default true.'),
2365
2746
  audioLanguage: z.enum(['EN', 'ZH', 'JA', 'KO', 'ES']).optional().describe('Kling Omni only — language for dialogue.'),
2366
2747
  generateMusic: z.boolean().optional().describe('Kling Omni only — auto-generate background music.'),
2367
2748
  seedanceFace: z.boolean().optional().describe('Seedance ONLY: a reference shows an AI CHARACTER\'s face. Faces are blocked on the default route, so this reroutes to a face-capable provider at ~45% more. A REAL person fails here with [REAL_FACE_DETECTED] — see seedanceRealFace.'),
2368
- seedanceRealFace: z.boolean().optional().describe('Seedance ONLY: a reference shows a REAL person. Premium route, roughly 2x the AI-face price — quote it first. REQUIRES realFaceConsent=true.'),
2749
+ seedanceRealFace: z.boolean().optional().describe('Seedance ONLY: a reference shows a REAL person. Real-person route, roughly 2x the AI-face price — quote it first. REQUIRES realFaceConsent=true.'),
2369
2750
  realFaceConsent: z.boolean().optional().describe('MANDATORY with seedanceRealFace: true ONLY after the user has explicitly confirmed they hold rights/consent to this likeness and it does not impersonate or misrepresent them. Refused without it; public figures fail on every route.'),
2370
2751
  negativePrompt: z.string().optional(),
2371
2752
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
@@ -2391,13 +2772,15 @@ export const generateVideo = {
2391
2772
  input.sound = resolved.sound;
2392
2773
  if (input.seedanceFace == null && resolved.seedanceFace != null)
2393
2774
  input.seedanceFace = resolved.seedanceFace;
2775
+ if (input.seedanceRealFace == null && resolved.seedanceRealFace != null)
2776
+ input.seedanceRealFace = resolved.seedanceRealFace;
2394
2777
  // projectId is required for video — without it there's no UI feedback,
2395
2778
  // no asset to reference later, and a failed gen leaves the user with
2396
2779
  // nothing. The MCP-only headless path that exists for image gen is
2397
2780
  // not reasonable for video given the cost.
2398
2781
  // Non-blocking prompt hygiene, computed once. Reported on the gates that
2399
2782
  // fire BEFORE any spend, where a rewrite is still free.
2400
- const promptWarning = bannedTokenWarning(input.prompt, 'video');
2783
+ const promptWarning = bannedTokenWarning(input.prompt, 'video', promptingSkillFor(resolved.model));
2401
2784
  if (!input.projectId) {
2402
2785
  return ok({
2403
2786
  requires_clarification: true,
@@ -2497,7 +2880,9 @@ export const generateVideo = {
2497
2880
  // not a number the registry models: fal publishes text-to-video,
2498
2881
  // image-to-video and reference-to-video for `minimax/h3`, and only the
2499
2882
  // all three for `minimax/h3-max` as well (corrected 2026-09-09 — its
2500
- // reference-to-video was wrongly believed to 404). The
2883
+ // reference-to-video was wrongly believed to 404). `minimax/h3-max-turbo`
2884
+ // has text-to-video and image-to-video only; its caps declare no references,
2885
+ // so the first branch below refuses them. The
2501
2886
  // reference endpoint has no frame parameters at all, so frames and
2502
2887
  // references are mutually exclusive — a shape mismatch, not a preference.
2503
2888
  if (MINIMAX_MODELS.has(input.model)) {
@@ -2669,7 +3054,7 @@ export const generateVideo = {
2669
3054
  return ok({
2670
3055
  requires_clarification: true,
2671
3056
  missing: ['videoReferenceSeconds'],
2672
- message: 'A Seedance video reference bills on combined input+output seconds. Pass videoReferenceSeconds (the reference clip\'s duration, shown in slates_list_assets) so the pre-flight quote matches the bill.',
3057
+ message: 'A Seedance video reference bills on combined input+output seconds (on 2.5 AI-face, max(input, output) + output). Pass videoReferenceSeconds (the reference clip\'s duration, shown in slates_list_assets) so the pre-flight quote matches the bill.',
2673
3058
  });
2674
3059
  }
2675
3060
  const pluralRefCount = input.videoReferenceAssetIds?.length ?? 0;
@@ -2678,7 +3063,7 @@ export const generateVideo = {
2678
3063
  return ok({
2679
3064
  requires_clarification: true,
2680
3065
  missing: ['videoReferenceSecondsEach'],
2681
- message: `A Seedance video reference bills on combined input+output seconds. Pass videoReferenceSecondsEach with exactly ${pluralRefCount} duration${pluralRefCount === 1 ? '' : 's'}, in the same order as videoReferenceAssetIds (durations are shown in slates_list_assets), so the pre-flight quote matches the bill.`,
3066
+ message: `A Seedance video reference bills on combined input+output seconds (on 2.5 AI-face, max(input, output) + output). Pass videoReferenceSecondsEach with exactly ${pluralRefCount} duration${pluralRefCount === 1 ? '' : 's'}, in the same order as videoReferenceAssetIds (durations are shown in slates_list_assets), so the pre-flight quote matches the bill.`,
2682
3067
  });
2683
3068
  }
2684
3069
  const cloud = ctx.cloud();
@@ -3139,11 +3524,42 @@ export const generateAudio = {
3139
3524
  };
3140
3525
  },
3141
3526
  };
3527
+ // ── The Kling tools' quote (lip-sync, motion transfer) ─────────
3528
+ /** The server bills the tools in blocks of this many seconds (slates-api `TOOL_BLOCK_SECONDS`). */
3529
+ const TOOL_BLOCK_SECONDS = 5;
3530
+ /** A project asset's recorded length in seconds, or null when its row has none. */
3531
+ async function recordedSeconds(desktop, projectId, assetId) {
3532
+ const { assets } = await desktop.get('/agent/assets', { projectId });
3533
+ const row = (assets ?? []).find((a) => String(a.id).toLowerCase() === assetId.toLowerCase());
3534
+ const seconds = Number(row?.duration);
3535
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
3536
+ }
3537
+ /**
3538
+ * Whole blocks of the media the output follows, priced as the server bills
3539
+ * them: `${stem}-${blocks × 5}s` measured from that media (slates-api
3540
+ * `tool-keys.ts`), plus one flat block for a voice step. An unknown length
3541
+ * quotes one block, the floor. A flat one-block quote approved a 30s Motion
3542
+ * Control Pro at 42 credits that billed 252.
3543
+ */
3544
+ async function quoteToolBlocks(ctx, stem, seconds, voiceStep) {
3545
+ const blocks = Math.max(1, Math.ceil((seconds ?? 0) / TOOL_BLOCK_SECONDS));
3546
+ const costKey = `${stem}-${blocks * TOOL_BLOCK_SECONDS}s`;
3547
+ const byKey = new Map();
3548
+ const price = async (key) => {
3549
+ await loadDynamicPrice(ctx, byKey, key);
3550
+ const credits = byKey.get(key);
3551
+ if (credits == null)
3552
+ throw new Error(`Model variant not in registry: ${key}`);
3553
+ return credits;
3554
+ };
3555
+ const totalCents = (await price(costKey)) + (voiceStep ? await price(`${stem}-${TOOL_BLOCK_SECONDS}s`) : 0);
3556
+ return { costKey, totalCents, blocks };
3557
+ }
3142
3558
  // ── Generate lip-sync ───────────────────────────────────────────
3143
3559
  export const generateLipSync = {
3144
3560
  id: 'slates_generate_lip_sync',
3145
3561
  billable: true,
3146
- description: 'Lip-sync a still image (avatar) or a video clip to audio. KLING-ONLY — this tool wraps Kling\'s dedicated lip-sync endpoints and nothing else: sourceType=video re-syncs a clip, sourceType=image animates a still avatar (avatar-standard, or avatar-pro for the premium seat). Quote each with slates_estimate_generation_cost rather than from memory. Audio from TTS (ttsText + ttsVoice) or an uploaded file. Always 5 seconds. For a Seedance version, do NOT look for an engine switch here — run a normal slates_generate_video on seedance-2 with the clip attached as a video reference and the dialogue written into the prompt; that is the same call, with the prompt visible and editable. REQUIRED before calling: slates-cost-discipline + slates-prompting-lip-sync skills. projectId is REQUIRED.',
3562
+ description: 'Lip-sync a still image (avatar) or a video clip to audio. KLING-ONLY — this tool wraps Kling\'s dedicated lip-sync endpoints and nothing else: sourceType=video re-syncs a clip, sourceType=image animates a still avatar (avatar-standard, or avatar-pro for the premium seat). Quote each with slates_estimate_generation_cost rather than from memory. Audio from TTS (ttsText + ttsVoice) or an uploaded file. Billed per 5s of the output (the clip, or on a still the voice track). For a Seedance version, do NOT look for an engine switch here — run a normal slates_generate_video on seedance-2 with the clip attached as a video reference and the dialogue written into the prompt; that is the same call, with the prompt visible and editable. REQUIRED before calling: slates-cost-discipline + slates-prompting-lip-sync skills. projectId is REQUIRED.',
3147
3563
  input: z.object({
3148
3564
  projectId: z.string().uuid().describe('Slates project the source asset lives in. The new lip-synced video lands here.'),
3149
3565
  sourceAssetId: z.string().uuid().describe('Asset id of the still image (avatar flow) or video clip (lip-sync flow). Must already exist in the project — use slates_upload_reference_image or slates_generate_image / slates_generate_video first if needed.'),
@@ -3174,27 +3590,28 @@ export const generateLipSync = {
3174
3590
  message: 'audioMethod=upload requires audioFilePath. Pass an absolute path to the audio file on the user\'s machine.',
3175
3591
  });
3176
3592
  }
3177
- let costKey;
3178
- if (input.sourceType === 'video') {
3179
- costKey = 'kling-lip-sync-video-5s';
3180
- }
3181
- else {
3182
- costKey = input.avatarModel === 'avatar-pro'
3183
- ? 'kling-lip-sync-avatar-pro-5s'
3184
- : 'kling-lip-sync-avatar-5s';
3185
- }
3186
- const cloud = ctx.cloud();
3187
- const registry = await cloud.get('/api/agent/models');
3188
- const entry = registry.models.find((m) => m.model === costKey) ??
3189
- (await cloud.get(`/api/agent/models?costKey=${encodeURIComponent(costKey)}`)).models[0];
3190
- if (!entry)
3191
- throw new Error(`Model variant not in registry: ${costKey}`);
3192
- const totalCents = creditCost(entry);
3593
+ const stem = input.sourceType === 'video'
3594
+ ? 'kling-lip-sync-video'
3595
+ : input.avatarModel === 'avatar-pro' ? 'kling-lip-sync-avatar-pro' : 'kling-lip-sync-avatar';
3596
+ // Billed per 5s block of the output: the clip's length on a video source;
3597
+ // on a still, the voice track's — typed words at the desktop's reading pace
3598
+ // (`lipSyncEstimate`, 13 characters a second, so this quote is the Generate
3599
+ // button's), an uploaded file unknown until the desktop measures it. Typed
3600
+ // words on a still are two billed calls: the voice step adds one flat block.
3601
+ const seconds = input.sourceType === 'video'
3602
+ ? await recordedSeconds(ctx.desktop(), input.projectId, input.sourceAssetId)
3603
+ : input.audioMethod === 'tts'
3604
+ ? (input.ttsText ?? '').trim().length / (13 * (input.ttsSpeed ?? 1))
3605
+ : null;
3606
+ const voiceStep = input.sourceType === 'image' && input.audioMethod === 'tts';
3607
+ const { costKey, totalCents, blocks } = await quoteToolBlocks(ctx, stem, seconds, voiceStep);
3608
+ const length = `${blocks * TOOL_BLOCK_SECONDS}s${voiceStep ? ' + voice' : ''}`;
3193
3609
  // Cost confirm gate. Lip-sync is mechanical — the model re-syncs the
3194
3610
  // user-chosen source to the user-chosen audio. The agent doesn't
3195
3611
  // write a prompt that depends on what the source looks like, so we
3196
3612
  // skip the inline preview and just announce the source code in text.
3197
- if (totalCents > CONFIRM_CREDITS && !input.confirm) {
3613
+ // An unknown length quotes one block, a floor, so it always asks.
3614
+ if ((totalCents > CONFIRM_CREDITS || seconds == null) && !input.confirm) {
3198
3615
  const sourceRef = await lookupAssetRef(ctx.desktop(), input.sourceAssetId);
3199
3616
  const audioPreview = input.audioMethod === 'tts'
3200
3617
  ? `Audio: TTS — "${(input.ttsText ?? '').slice(0, 120)}"`
@@ -3205,7 +3622,7 @@ export const generateLipSync = {
3205
3622
  estimated_cents: totalCents,
3206
3623
  estimated_credits: totalCents,
3207
3624
  source_ref: sourceRef,
3208
- message: `Cost: ${fmtCredits(totalCents)} for 5s lip-sync (${costKey}). ` +
3625
+ message: `Cost: ${fmtCredits(totalCents)}${seconds == null ? ' or more' : ''} for ${length} lip-sync (${costKey}). ` +
3209
3626
  `Source: ${sourceRef}. ${audioPreview}. ` +
3210
3627
  `Re-call with confirm=true after the user explicitly OKs the spend. ` +
3211
3628
  `When discussing with the user, refer to the source by its code (matches the gallery badge).`,
@@ -3226,14 +3643,15 @@ export const generateLipSync = {
3226
3643
  audioFilePath: input.audioFilePath,
3227
3644
  avatarModel: input.avatarModel,
3228
3645
  klingProvider: input.klingProvider,
3229
- estimatedCost: totalCents,
3646
+ // No estimatedCost: the desktop prices the media itself, in dollars.
3647
+ // This op's figure is CREDITS, which a 1.5.8 desktop recorded as dollars.
3230
3648
  background: input.background,
3231
3649
  });
3232
3650
  if (!result.success)
3233
3651
  throw new Error(result.error ?? 'Lip-sync generation failed');
3234
3652
  if (result.background) {
3235
3653
  const ids = result.generationIds ?? (result.generationId ? [result.generationId] : []);
3236
- return backgroundSubmitted(`5s lip-sync (${costKey})`, ids, {
3654
+ return backgroundSubmitted(`${length} lip-sync (${costKey})`, ids, {
3237
3655
  variant: costKey,
3238
3656
  projectId: input.projectId,
3239
3657
  sourceAssetId: input.sourceAssetId,
@@ -3242,7 +3660,7 @@ export const generateLipSync = {
3242
3660
  });
3243
3661
  }
3244
3662
  return {
3245
- text: `Generated 5s lip-sync (${costKey}) into project ${input.projectId} ` +
3663
+ text: `Generated ${length} lip-sync (${costKey}) into project ${input.projectId} ` +
3246
3664
  `for ${fmtCredits(totalCents)}. ` +
3247
3665
  (input.audioMethod === 'tts'
3248
3666
  ? `Spoken: "${(input.ttsText ?? '').slice(0, 60)}${(input.ttsText ?? '').length > 60 ? '...' : ''}"`
@@ -3264,7 +3682,7 @@ export const generateLipSync = {
3264
3682
  export const generateMotionTransfer = {
3265
3683
  id: 'slates_generate_motion_transfer',
3266
3684
  billable: true,
3267
- description: 'Transfer the motion from a reference video onto a target image character. KLING-ONLY — this tool wraps Kling Motion Control and nothing else: kling-mc-std or kling-mc-pro, structured skeleton/depth retargeting, always 5s. For a Seedance version, do NOT look for an engine switch here — run a normal slates_generate_video on seedance-2 with the driving clip attached as a video reference and the motion described in the prompt ("the character from image 1 performs the exact motion from video 1"); that is the same call, with the prompt visible and editable. REQUIRED before calling: slates-cost-discipline + slates-prompting-motion-transfer skills. projectId is REQUIRED — both assets must exist in the project. ' +
3685
+ description: 'Transfer the motion from a reference video onto a target image character. KLING-ONLY — this tool wraps Kling Motion Control and nothing else: kling-mc-std or kling-mc-pro, structured skeleton/depth retargeting, billed per 5s of the driving clip. For a Seedance version, do NOT look for an engine switch here — run a normal slates_generate_video on seedance-2 with the driving clip attached as a video reference and the motion described in the prompt ("the character from image 1 performs the exact motion from video 1"); that is the same call, with the prompt visible and editable. REQUIRED before calling: slates-cost-discipline + slates-prompting-motion-transfer skills. projectId is REQUIRED — both assets must exist in the project. ' +
3268
3686
  CONFIRM_GATE_SENTENCE,
3269
3687
  input: z.object({
3270
3688
  projectId: z.string().uuid().describe('Slates project. Both source and target assets must live here.'),
@@ -3279,19 +3697,17 @@ export const generateMotionTransfer = {
3279
3697
  }),
3280
3698
  async run(input, ctx) {
3281
3699
  const motionModel = input.motionModel ?? 'kling-mc-pro';
3282
- const costKey = motionModel === 'kling-mc-std' ? 'kling-mc-std-5s' : 'kling-mc-pro-5s';
3283
- const cloud = ctx.cloud();
3284
- const registry = await cloud.get('/api/agent/models');
3285
- const entry = registry.models.find((m) => m.model === costKey) ??
3286
- (await cloud.get(`/api/agent/models?costKey=${encodeURIComponent(costKey)}`)).models[0];
3287
- if (!entry)
3288
- throw new Error(`Model variant not in registry: ${costKey}`);
3289
- const totalCents = creditCost(entry);
3700
+ // Billed per 5s block of the driving clip, which the output follows, up to
3701
+ // the orientation's maximum (the desktop's `motionTransferEstimate`).
3702
+ const clipSeconds = await recordedSeconds(ctx.desktop(), input.projectId, input.sourceVideoAssetId);
3703
+ const seconds = clipSeconds == null ? null : Math.min(clipSeconds, (input.characterOrientation ?? 'video') === 'video' ? 30 : 10);
3704
+ const { costKey, totalCents, blocks } = await quoteToolBlocks(ctx, motionModel, seconds, false);
3290
3705
  // Cost confirm gate. Motion transfer is mechanical — the model
3291
3706
  // applies source motion to target image deterministically. We don't
3292
3707
  // burn tokens previewing assets the user already chose; codes in the
3293
- // text are enough to keep the chat unambiguous.
3294
- if (totalCents > CONFIRM_CREDITS && !input.confirm) {
3708
+ // text are enough to keep the chat unambiguous. A clip with no recorded
3709
+ // length quotes one block, a floor, so it always asks.
3710
+ if ((totalCents > CONFIRM_CREDITS || seconds == null) && !input.confirm) {
3295
3711
  const desktop = ctx.desktop();
3296
3712
  const [source, target] = await Promise.all([
3297
3713
  lookupAssetRef(desktop, input.sourceVideoAssetId),
@@ -3304,14 +3720,14 @@ export const generateMotionTransfer = {
3304
3720
  estimated_credits: totalCents,
3305
3721
  source_ref: source,
3306
3722
  target_ref: target,
3307
- message: `Cost: ${fmtCredits(totalCents)} for 5s ${motionModel} (${costKey}). ` +
3723
+ message: `Cost: ${fmtCredits(totalCents)}${seconds == null ? ' or more' : ''} for ${blocks * TOOL_BLOCK_SECONDS}s ${motionModel} (${costKey}). ` +
3308
3724
  `Transferring motion from ${source} onto ${target}. ` +
3309
3725
  // The saving is READ from the registry, never guessed: "~10 credits"
3310
3726
  // was hand-typed and is a rate change away from being a lie.
3311
3727
  `Re-call with confirm=true after the user explicitly OKs the spend${motionModel === 'kling-mc-pro'
3312
- ? (() => {
3313
- const std = registry.models.find((m) => m.model === 'kling-mc-std-5s');
3314
- const saving = std ? totalCents - creditCost(std) : 0;
3728
+ ? await (async () => {
3729
+ const std = await quoteToolBlocks(ctx, 'kling-mc-std', seconds, false);
3730
+ const saving = totalCents - std.totalCents;
3315
3731
  return saving > 0 ? `, or pick kling-mc-std to save ${fmtCredits(saving)}` : '';
3316
3732
  })()
3317
3733
  : ''}. ` +
@@ -3330,14 +3746,15 @@ export const generateMotionTransfer = {
3330
3746
  characterOrientation: input.characterOrientation ?? 'video',
3331
3747
  prompt: input.prompt,
3332
3748
  klingProvider: input.klingProvider,
3333
- estimatedCost: totalCents,
3749
+ // No estimatedCost: the desktop prices the media itself, in dollars.
3750
+ // This op's figure is CREDITS, which a 1.5.8 desktop recorded as dollars.
3334
3751
  background: input.background,
3335
3752
  });
3336
3753
  if (!result.success)
3337
3754
  throw new Error(result.error ?? 'Motion transfer generation failed');
3338
3755
  if (result.background) {
3339
3756
  const ids = result.generationIds ?? (result.generationId ? [result.generationId] : []);
3340
- return backgroundSubmitted(`5s motion transfer (${motionModel})`, ids, {
3757
+ return backgroundSubmitted(`motion transfer (${motionModel})`, ids, {
3341
3758
  variant: costKey,
3342
3759
  motionModel,
3343
3760
  projectId: input.projectId,
@@ -3348,7 +3765,7 @@ export const generateMotionTransfer = {
3348
3765
  });
3349
3766
  }
3350
3767
  return {
3351
- text: `Generated 5s motion transfer (${motionModel}) into project ${input.projectId} ` +
3768
+ text: `Generated motion transfer (${motionModel}) into project ${input.projectId} ` +
3352
3769
  `for ${fmtCredits(totalCents)}.` +
3353
3770
  (input.prompt ? ` Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` : ''),
3354
3771
  data: {
@@ -3633,15 +4050,53 @@ export const listGenerations = {
3633
4050
  }));
3634
4051
  },
3635
4052
  };
4053
+ const exportCutsInput = z.object({ projectId: z.string().uuid(), directory: z.string(), manifestId: z.string().min(1),
4054
+ action: z.enum(['start', 'status', 'cancel']).optional().describe('start (default) begins, or re-attaches to a running export; status reads it; cancel stops after the output being rendered.'),
4055
+ items: z.array(z.object({ timelineId: z.string().uuid(), format: z.enum(['mp4', 'xml']) })).min(1).optional().describe('Required to start.') });
4056
+ export const exportCuts = {
4057
+ id: 'slates_export_cuts', description: 'Export explicit named cuts to distinct local files with a frozen manifest of timeline settings, media, script revisions and generation provenance. Returns at once while rendering continues; poll with action status. Starting again with the same manifestId and selection retries unfinished outputs under the same filenames and skips finished ones. Existing outputs are never overwritten, and export never generates media. Uses current video/XML fidelity limits.',
4058
+ input: exportCutsInput,
4059
+ async run(input, ctx) {
4060
+ await ctx.desktop().requireCapability('named-cuts', 'named cuts');
4061
+ return ok(await ctx.desktop().post('/agent/timeline/export-cuts', input));
4062
+ },
4063
+ };
4064
+ export const listTimelines = {
4065
+ id: 'slates_list_timelines', description: 'List the independent named cuts in a project. Use the selected timelineId for edits, builds and exports.',
4066
+ input: z.object({ projectId: z.string().uuid() }),
4067
+ async run(input, ctx) {
4068
+ await ctx.desktop().requireCapability('named-cuts', 'named cuts');
4069
+ return ok(await ctx.desktop().get('/agent/timelines', input));
4070
+ },
4071
+ };
4072
+ export const saveTimeline = {
4073
+ id: 'slates_save_timeline', description: 'Create a named cut, or rename the explicit timelineId. Existing cuts and the legacy default stay intact.',
4074
+ input: z.object({ projectId: z.string().uuid(), timelineId: z.string().uuid().optional(), name: z.string().min(1) }),
4075
+ async run(input, ctx) {
4076
+ await ctx.desktop().requireCapability('named-cuts', 'named cuts');
4077
+ return ok(await ctx.desktop().post('/agent/timelines', input));
4078
+ },
4079
+ };
4080
+ /**
4081
+ * A `timelineId` names a cut only on a desktop with named cuts. Its read, clip,
4082
+ * track and settings routes predate them and ignore the field, so a 1.5.8
4083
+ * desktop would act on the default timeline instead of the one named. The
4084
+ * exports resolved a timeline id before named cuts and need no gate.
4085
+ */
4086
+ async function requireNamedCut(desktop, timelineId) {
4087
+ if (timelineId)
4088
+ await desktop.requireCapability('named-cuts', 'named cuts');
4089
+ }
3636
4090
  export const getTimeline = {
3637
4091
  id: 'slates_get_timeline',
3638
- description: 'Get (or lazily create) the single editing timeline for a Slates project, with all tracks, clips, markers, and a flat clipIndex mapping every clip back to its source asset (assetId + code + label). Frames are the unit of time; durationSec is provided. Call this before adding, reordering, or removing clips, and before exporting — it tells you the timeline id, frame rate, resolution, and current end frame.',
3639
- input: z.object({ projectId: z.string().uuid() }),
4092
+ description: 'Get (or lazily create) a named editing timeline, or the stable legacy default when timelineId is omitted, with all tracks, clips, markers, and a flat clipIndex mapping every clip back to its source asset (assetId + code + label). Frames are the unit of time; durationSec is provided. Call this before adding, reordering, or removing clips, and before exporting — it tells you the timeline id, frame rate, resolution, and current end frame.',
4093
+ input: z.object({ projectId: z.string().uuid(), timelineId: z.string().uuid().optional() }),
3640
4094
  async run(input, ctx) {
3641
4095
  const desktop = ctx.desktop();
3642
4096
  await desktop.requireCapability('timeline', 'timeline editing');
4097
+ await requireNamedCut(desktop, input.timelineId);
3643
4098
  const r = await desktop.get('/agent/timeline', {
3644
- projectId: input.projectId,
4099
+ projectId: input.projectId, timelineId: input.timelineId,
3645
4100
  });
3646
4101
  const t = r.timeline ?? {};
3647
4102
  const clipCount = r.clipIndex?.length ??
@@ -3657,7 +4112,7 @@ export const addClipToTimeline = {
3657
4112
  id: 'slates_add_clip_to_timeline',
3658
4113
  description: "Append a video or audio asset from the project to the project's timeline (or place it at an explicit startFrame). Defaults match the desktop UI: video clips go to the end of the first video track; audio clips (music, voiceover, AI audio) go after the last clip on the first AUDIO track and are mixed under the video on export. An empty timeline auto-adopts the first video clip's resolution and frame rate; later higher-resolution clips raise the canvas. Overlapping video clips resolve top-track-wins. Optionally trim with sourceInFrame/sourceOutFrame (frames at the SOURCE fps). Use slates_get_timeline first to see current clips and pick positions.",
3659
4114
  input: z.object({
3660
- projectId: z.string().uuid(),
4115
+ projectId: z.string().uuid(), timelineId: z.string().uuid().optional(),
3661
4116
  assetId: z.string().uuid().describe('Video or audio asset already in the project.'),
3662
4117
  trackId: z.string().uuid().optional().describe('Target track (type must match the asset: video asset → video track, audio asset → audio track). Default: the first track of the matching type.'),
3663
4118
  startFrame: z.number().int().min(0).optional().describe('Timeline frame to place the clip at. Default: append after the last clip.'),
@@ -3667,8 +4122,9 @@ export const addClipToTimeline = {
3667
4122
  async run(input, ctx) {
3668
4123
  const desktop = ctx.desktop();
3669
4124
  await desktop.requireCapability('timeline', 'timeline editing');
4125
+ await requireNamedCut(desktop, input.timelineId);
3670
4126
  const r = await desktop.post('/agent/timeline/add-clip', {
3671
- projectId: input.projectId,
4127
+ projectId: input.projectId, timelineId: input.timelineId,
3672
4128
  assetId: input.assetId,
3673
4129
  trackId: input.trackId,
3674
4130
  startFrame: input.startFrame,
@@ -3710,17 +4166,40 @@ export const removeClip = {
3710
4166
  return ok(await desktop.post('/agent/timeline/remove-clip', input));
3711
4167
  },
3712
4168
  };
4169
+ export const manageTimelineMarker = {
4170
+ id: 'slates_manage_timeline_marker',
4171
+ description: "Add, change or delete a marker on the timeline, as the Cut's ruler and marker menus do (slates_get_timeline lists them under timeline.markers). create: frame, optional name and color. update: markerId plus any of frame, name, color. delete: markerId. The color is one of the app's marker colours by name (e.g. Red); any other is refused with the list. The open Cut shows the change.",
4172
+ input: z.object({
4173
+ action: z.enum(['create', 'update', 'delete']),
4174
+ projectId: z.string().uuid(),
4175
+ timelineId: z.string().uuid().optional().describe("A named cut; omit for the project's timeline."),
4176
+ markerId: z.string().uuid().optional(),
4177
+ frame: z.number().int().min(0).optional().describe('Where the marker sits, in timeline frames.'),
4178
+ name: z.string().max(120).optional(),
4179
+ color: z.string().optional(),
4180
+ }),
4181
+ async run(input, ctx) {
4182
+ const desktop = ctx.desktop();
4183
+ await desktop.requireCapability('timeline-markers', 'timeline markers');
4184
+ if (input.action === 'create' && input.frame === undefined)
4185
+ throw new Error('create needs a frame.');
4186
+ if (input.action !== 'create' && !input.markerId)
4187
+ throw new Error(`${input.action} needs a markerId (slates_get_timeline lists them).`);
4188
+ return ok(await desktop.post('/agent/timeline/markers', input));
4189
+ },
4190
+ };
3713
4191
  export const addTimelineTrack = {
3714
4192
  id: 'slates_add_timeline_track',
3715
4193
  description: "Add a track to the project's timeline (default: an audio track, for layering voiceover + music + AI audio). The new track is appended below existing tracks. Returns the new track and the full timeline.",
3716
4194
  input: z.object({
3717
- projectId: z.string().uuid(),
4195
+ projectId: z.string().uuid(), timelineId: z.string().uuid().optional(),
3718
4196
  type: z.enum(['video', 'audio']).optional().describe('Default: audio.'),
3719
4197
  name: z.string().optional().describe("Default: 'Audio N' / 'Video N'."),
3720
4198
  }),
3721
4199
  async run(input, ctx) {
3722
4200
  const desktop = ctx.desktop();
3723
4201
  await desktop.requireCapability('timeline-tracks', 'timeline tracks + audio mixing');
4202
+ await requireNamedCut(desktop, input.timelineId);
3724
4203
  return ok(await desktop.post('/agent/timeline/add-track', input));
3725
4204
  },
3726
4205
  };
@@ -3758,7 +4237,7 @@ export const updateTimelineSettings = {
3758
4237
  id: 'slates_update_timeline_settings',
3759
4238
  description: "Update the project timeline's output settings: resolution, frame rate (24/30/60 — all clips are conformed to it on export), and masterVolume, the output fader (linear gain, -∞ to +12 dB) applied to the final mix in both preview and MP4 export (use it to prevent clipping when stacking loud tracks). Note these are normally auto-managed: the first video clip sets fps + resolution, and higher-res clips raise the canvas. Changing frameRate after clips are placed retimes them — avoid unless the timeline is empty.",
3760
4239
  input: z.object({
3761
- projectId: z.string().uuid(),
4240
+ projectId: z.string().uuid(), timelineId: z.string().uuid().optional(),
3762
4241
  width: z.number().int().min(16).optional(),
3763
4242
  height: z.number().int().min(16).optional(),
3764
4243
  frameRate: z.union([z.literal(24), z.literal(30), z.literal(60)]).optional(),
@@ -3767,6 +4246,7 @@ export const updateTimelineSettings = {
3767
4246
  async run(input, ctx) {
3768
4247
  const desktop = ctx.desktop();
3769
4248
  await desktop.requireCapability('timeline-tracks', 'timeline tracks + audio mixing');
4249
+ await requireNamedCut(desktop, input.timelineId);
3770
4250
  return ok(await desktop.post('/agent/timeline/update-settings', input));
3771
4251
  },
3772
4252
  };
@@ -3804,7 +4284,7 @@ export const exportVideo = {
3804
4284
  };
3805
4285
  export const exportTimelineXml = {
3806
4286
  id: 'slates_export_timeline_xml',
3807
- description: "Export the project's timeline as FCP7/XMEML XML — the file DaVinci Resolve imports directly (File → Import → Timeline) to recreate the edit with references to the original clip media on disk. This is the 'open in DaVinci' handoff path. Pass an absolute outputPath ending in .xml; fails if the file exists unless overwrite=true.",
4287
+ description: "Export the project's timeline as FCP7/XMEML XML — the file DaVinci Resolve imports directly (File → Import → Timeline) to recreate the edit with references to the original clip media on disk. This is the 'Export for DaVinci, Premiere or Final Cut' path. Pass an absolute outputPath ending in .xml; fails if the file exists unless overwrite=true.",
3808
4288
  input: z
3809
4289
  .object({
3810
4290
  projectId: z.string().uuid().optional(),
@@ -3859,7 +4339,7 @@ export const updateProject = {
3859
4339
  };
3860
4340
  export const deleteProject = {
3861
4341
  id: 'slates_delete_project',
3862
- description: 'Delete a Slates project. DESTRUCTIVE — permanently removes the project with all its assets, storyboards, and media files. Requires confirm=true after explicit user OK.',
4342
+ description: 'Delete a Slates project. DESTRUCTIVE — permanently removes the project with all its assets, boards, and media files. Requires confirm=true after explicit user OK.',
3863
4343
  input: z.object({
3864
4344
  id: z.string().uuid(),
3865
4345
  confirm: z.boolean().optional().describe('Set true only after the user explicitly OKs the deletion.'),
@@ -3868,7 +4348,7 @@ export const deleteProject = {
3868
4348
  if (!input.confirm) {
3869
4349
  return ok({
3870
4350
  requires_confirm: true,
3871
- message: 'Deleting a project permanently removes all its assets, storyboards, and media files. Re-call with confirm=true after explicit user OK.',
4351
+ message: 'Deleting a project permanently removes all its assets, boards, and media files. Re-call with confirm=true after explicit user OK.',
3872
4352
  });
3873
4353
  }
3874
4354
  return ok(await ctx.desktop().post('/agent/projects/delete', { id: input.id }));
@@ -3882,6 +4362,36 @@ export const getProjectDirectory = {
3882
4362
  return ok(await ctx.desktop().get('/agent/projects/location', { id: input.id }));
3883
4363
  },
3884
4364
  };
4365
+ /** What both relocation routes answer: the folder the project now uses, and
4366
+ * the one its files could not all be removed from, when that happened. */
4367
+ function relocated(r, done) {
4368
+ const text = r.leftBehind
4369
+ ? `${done} to ${r.directory}. Some files could not be removed from ${r.leftBehind}, so both folders exist and the project uses the new one; tell the user, and delete the old folder once nothing holds it open. There is no undo for this move.`
4370
+ : `${done} to ${r.directory}.`;
4371
+ return ok(r, text);
4372
+ }
4373
+ export const relocateProject = {
4374
+ id: 'slates_relocate_project',
4375
+ description: "Move a project stored in an older folder into the current projects folder (Settings › Storage's move): its files are copied, every path is pointed at the copy, then the old folder is removed. slates_get_project_directory says where it is now. slates_undo_relocate_project puts it back while Slates stays open. Refused while the project is generating.",
4376
+ input: z.object({ id: z.string().uuid() }),
4377
+ async run(input, ctx) {
4378
+ const desktop = ctx.desktop();
4379
+ await desktop.requireCapability('project-relocate', 'moving a project into the current projects folder');
4380
+ const r = await desktop.post('/agent/projects/relocate', { id: input.id });
4381
+ const moved = relocated(r, 'Moved the project');
4382
+ return r.leftBehind ? moved : { ...moved, text: `${moved.text} slates_undo_relocate_project puts it back until Slates quits.` };
4383
+ },
4384
+ };
4385
+ export const undoRelocateProject = {
4386
+ id: 'slates_undo_relocate_project',
4387
+ description: "Put a project moved by slates_relocate_project (or Settings › Storage) back in the folder it came from. Works only for the last move, in the same run of Slates, and only while nothing has moved it since.",
4388
+ input: z.object({ id: z.string().uuid() }),
4389
+ async run(input, ctx) {
4390
+ const desktop = ctx.desktop();
4391
+ await desktop.requireCapability('project-relocate', 'moving a project back');
4392
+ return relocated(await desktop.post('/agent/projects/relocate-undo', { id: input.id }), 'Moved the project back');
4393
+ },
4394
+ };
3885
4395
  export const deleteAsset = {
3886
4396
  id: 'slates_delete_asset',
3887
4397
  description: 'Delete an asset from its project. Permanent — also deletes the media file from disk.',
@@ -3962,7 +4472,7 @@ export const deleteCharacter = {
3962
4472
  };
3963
4473
  export const updateEnvironment = {
3964
4474
  id: 'slates_update_environment',
3965
- description: 'Update an environment\'s name, description, style, or bound reference image (referenceAssetId=null clears it).',
4475
+ description: 'Update a location\'s name, description, style, or bound reference image (referenceAssetId=null clears it).',
3966
4476
  input: z.object({
3967
4477
  environmentId: z.string().uuid(),
3968
4478
  name: z.string().min(1).max(120).optional(),
@@ -3984,7 +4494,7 @@ export const updateEnvironment = {
3984
4494
  };
3985
4495
  export const deleteEnvironment = {
3986
4496
  id: 'slates_delete_environment',
3987
- description: 'Delete an environment from its project.',
4497
+ description: 'Delete a location from its project.',
3988
4498
  input: z.object({ environmentId: z.string().uuid() }),
3989
4499
  async run(input, ctx) {
3990
4500
  return ok(await ctx.desktop().post('/agent/environments/delete', { id: input.environmentId }));
@@ -3992,7 +4502,7 @@ export const deleteEnvironment = {
3992
4502
  };
3993
4503
  export const listStyles = {
3994
4504
  id: 'slates_list_styles',
3995
- description: 'List visual styles in a Slates project.',
4505
+ description: 'List the looks (styles) in a Slates project.',
3996
4506
  input: z.object({ projectId: z.string().uuid() }),
3997
4507
  async run(input, ctx) {
3998
4508
  return ok(await ctx.desktop().get('/agent/styles', { projectId: input.projectId }));
@@ -4000,7 +4510,7 @@ export const listStyles = {
4000
4510
  };
4001
4511
  export const createStyle = {
4002
4512
  id: 'slates_create_style',
4003
- description: 'Create a new visual style in a Slates project.',
4513
+ description: 'Create a new look (style) in a Slates project.',
4004
4514
  input: z.object({
4005
4515
  projectId: z.string().uuid(),
4006
4516
  name: z.string().min(1).max(120),
@@ -4012,7 +4522,7 @@ export const createStyle = {
4012
4522
  };
4013
4523
  export const updateStyle = {
4014
4524
  id: 'slates_update_style',
4015
- description: 'Update a style\'s name, description, or bound reference image (imageAssetId=null clears it).',
4525
+ description: 'Update a look\'s name, description, or bound reference image (imageAssetId=null clears it).',
4016
4526
  input: z.object({
4017
4527
  styleId: z.string().uuid(),
4018
4528
  name: z.string().min(1).max(120).optional(),
@@ -4028,15 +4538,199 @@ export const updateStyle = {
4028
4538
  };
4029
4539
  export const deleteStyle = {
4030
4540
  id: 'slates_delete_style',
4031
- description: 'Delete a visual style from its project.',
4541
+ description: 'Delete a look from its project.',
4032
4542
  input: z.object({ styleId: z.string().uuid() }),
4033
4543
  async run(input, ctx) {
4034
4544
  return ok(await ctx.desktop().post('/agent/styles/delete', { id: input.styleId }));
4035
4545
  },
4036
4546
  };
4547
+ // ── The Library ─────────────────────────────────────────────────
4548
+ // One saved reference in a category the USER names (characters, locations,
4549
+ // products, looks…). The character / environment / style ops above read and
4550
+ // write the same rows and stay for published clients; new work uses these.
4551
+ export const listLibrary = {
4552
+ id: 'slates_list_library',
4553
+ description: "List a project's Library: its categories (user-named; each is kind 'thing' = used as @name in prompts, or 'look' = used as #name) and every saved reference in them. Each item carries `mention` — exactly what to type in a prompt to attach it (a bare name is prose and attaches nothing) — plus its image, source and voice asset ids.",
4554
+ input: z.object({ projectId: z.string().uuid() }),
4555
+ async run(input, ctx) {
4556
+ await ctx.desktop().requireCapability('library', 'the Library');
4557
+ return ok(await ctx.desktop().get('/agent/library', { projectId: input.projectId }));
4558
+ },
4559
+ };
4560
+ export const createLibraryItem = {
4561
+ id: 'slates_create_library_item',
4562
+ description: 'Save a reference to the Library in one of the project\'s categories (ids from slates_list_library) — a person, a location, a product, a prop, a look. Names share ONE namespace per sigil: a name whose handle is already taken comes back suffixed ("Candle 2"), so read `mention` from the result instead of assuming it. Pass imageAssetId (UUID or badge code) to bind its picture in the same call.',
4563
+ input: z.object({
4564
+ projectId: z.string().uuid(),
4565
+ categoryId: z.string().uuid(),
4566
+ name: z.string().min(1).max(120),
4567
+ description: z.string().optional(),
4568
+ style: z.string().max(200).optional().describe('Optional free-text style instruction the sheet tools read.'),
4569
+ imageAssetId: z.string().min(1).optional().describe('The one image a mention attaches. UUID or badge code ("IMG-A8").'),
4570
+ }),
4571
+ async run(input, ctx) {
4572
+ await ctx.desktop().requireCapability('library', 'the Library');
4573
+ const { imageAssetId, ...body } = input;
4574
+ // Resolved BEFORE the create, so an unknown code fails without leaving an
4575
+ // item behind.
4576
+ const resolved = imageAssetId ? await resolveAssetRefs(ctx, input.projectId, [imageAssetId]) : null;
4577
+ const created = await ctx.desktop().post('/agent/library/items', body);
4578
+ if (!imageAssetId || !resolved)
4579
+ return ok(created);
4580
+ return ok(await ctx.desktop().post('/agent/library/items/update', {
4581
+ id: created.item.id,
4582
+ data: { imageAssetId: resolved.get(imageAssetId).id },
4583
+ }));
4584
+ },
4585
+ };
4586
+ export const updateLibraryItem = {
4587
+ id: 'slates_update_library_item',
4588
+ description: "Update a Library item: rename it, move it to another category (categoryId), bind its picture (imageAssetId) or its voice (voiceAssetId, an audio asset; a thing in a location category composes as an environment and carries none). null clears an asset; the asset itself stays. Moving between a 'thing' and a 'look' category flips the sigil (@ ↔ #), and moving a thing into or out of a 'location'-template category changes what it composes as. A rename changes its handle. In every case each saved Shot that mentions the item is rewritten to match (the mention in its prompt, and its mention id), and the result's `move` reports `shotsMentioning`, `shotsRetagged` and the old and new `mention`: tell the user what changed. Pass retagShots:false to leave Shots alone; they then show the item as missing until it is mentioned again. Moving it back, or renaming it back, undoes the rewrite.",
4589
+ input: z.object({
4590
+ projectId: z.string().uuid().describe('The project the item is in — asset codes resolve against it.'),
4591
+ itemId: z.string().uuid(),
4592
+ name: z.string().min(1).max(120).optional(),
4593
+ description: z.string().optional(),
4594
+ style: z.string().max(200).optional(),
4595
+ categoryId: z.string().uuid().optional(),
4596
+ retagShots: z.boolean().optional().describe('Only with categoryId or name. Default true: Shots that mention the item follow a move or a rename that changes how it is mentioned.'),
4597
+ imageAssetId: z.string().min(1).nullable().optional().describe('UUID or badge code; null clears.'),
4598
+ voiceAssetId: z.string().min(1).nullable().optional().describe('An AUDIO asset, UUID or badge code; null detaches.'),
4599
+ }),
4600
+ async run(input, ctx) {
4601
+ await ctx.desktop().requireCapability('library', 'the Library');
4602
+ const refs = [input.imageAssetId, input.voiceAssetId].filter((r) => typeof r === 'string');
4603
+ const resolved = await resolveAssetRefs(ctx, input.projectId, refs);
4604
+ const asset = (ref) => typeof ref === 'string' ? resolved.get(ref).id : ref;
4605
+ return ok(await ctx.desktop().post('/agent/library/items/update', {
4606
+ id: input.itemId,
4607
+ retagShots: input.retagShots,
4608
+ data: {
4609
+ name: input.name,
4610
+ description: input.description,
4611
+ style: input.style,
4612
+ categoryId: input.categoryId,
4613
+ // Sent only when given: the route treats presence as intent, and an
4614
+ // explicit null is the clear.
4615
+ ...(input.imageAssetId !== undefined ? { imageAssetId: asset(input.imageAssetId) } : {}),
4616
+ ...(input.voiceAssetId !== undefined ? { voiceAssetId: asset(input.voiceAssetId) } : {}),
4617
+ },
4618
+ }));
4619
+ },
4620
+ };
4621
+ export const deleteLibraryItem = {
4622
+ id: 'slates_delete_library_item',
4623
+ description: 'Delete a Library item. Its images and voice clip stay in the project as ordinary assets.',
4624
+ input: z.object({ itemId: z.string().uuid() }),
4625
+ async run(input, ctx) {
4626
+ await ctx.desktop().requireCapability('library', 'the Library');
4627
+ return ok(await ctx.desktop().post('/agent/library/items/delete', { id: input.itemId }));
4628
+ },
4629
+ };
4630
+ export const manageLibraryCategory = {
4631
+ id: 'slates_manage_library_category',
4632
+ description: "Create, rename, change what it is (set-behaviour), reorder or delete a Library category. Categories are the USER's labels (\"Products\", \"Mascots\"); never invent ones they did not ask for. create: projectId + name + kind ('thing' = used as @name in prompts, 'look' = used as #name), optional template ('person' | 'location'; the app asks \"Is this a subject, a place, or a look?\" — a 'location' category's things compose as environments and carry no voice; every other thing composes as a character). set-behaviour: categoryId + kind + template (null for no template); retagShots defaults to true and rewrites affected shot mentions to match. The result reports affected items and rewritten shots. rename: categoryId + name. reorder: projectId + orderedIds (every category id, in the new order). delete: categoryId — refused while the category holds items.",
4633
+ input: z.object({
4634
+ action: z.enum(['create', 'rename', 'set-behaviour', 'reorder', 'delete']),
4635
+ projectId: z.string().uuid().optional(),
4636
+ categoryId: z.string().uuid().optional(),
4637
+ name: z.string().min(1).max(80).optional(),
4638
+ kind: z.enum(['thing', 'look']).optional(),
4639
+ template: z.enum(['person', 'location']).nullable().optional(),
4640
+ retagShots: z.boolean().optional(),
4641
+ orderedIds: z.array(z.string().uuid()).optional(),
4642
+ }),
4643
+ async run(input, ctx) {
4644
+ await ctx.desktop().requireCapability('library', 'the Library');
4645
+ switch (input.action) {
4646
+ case 'create':
4647
+ return ok(await ctx.desktop().post('/agent/library/categories', { projectId: input.projectId, name: input.name, kind: input.kind, template: input.template }));
4648
+ case 'set-behaviour':
4649
+ if (!input.categoryId || !input.kind || input.template === undefined)
4650
+ throw new Error('set-behaviour requires categoryId, kind and template (null for none)');
4651
+ return ok(await ctx.desktop().post('/agent/library/categories/update', { id: input.categoryId, kind: input.kind, template: input.template, retagShots: input.retagShots }));
4652
+ case 'reorder':
4653
+ return ok(await ctx.desktop().post('/agent/library/categories/update', { projectId: input.projectId, orderedIds: input.orderedIds }));
4654
+ case 'delete':
4655
+ return ok(await ctx.desktop().post('/agent/library/categories/delete', { id: input.categoryId }));
4656
+ default:
4657
+ return ok(await ctx.desktop().post('/agent/library/categories/update', { id: input.categoryId, name: input.name }));
4658
+ }
4659
+ },
4660
+ };
4661
+ export const copyLibraryItemToProject = {
4662
+ id: 'slates_copy_library_item_to_project',
4663
+ description: "Copy a Library item into another project: the item AND duplicates of its picture, source and voice, filed in the target's category of the same name (made when missing). The original is untouched. Use this to reuse a character or product across projects; use slates_move_entity_to_project to take it out of this one instead.",
4664
+ input: z.object({ itemId: z.string().uuid(), targetProjectId: z.string().uuid() }),
4665
+ async run(input, ctx) {
4666
+ await ctx.desktop().requireCapability('library', 'the Library');
4667
+ return ok(await ctx.desktop().post('/agent/library/items/copy-to-project', { id: input.itemId, targetProjectId: input.targetProjectId }));
4668
+ },
4669
+ };
4670
+ // ── Templates ───────────────────────────────────────────────────────────
4671
+ // A `.slatestemplate` file is a portable storyboard, scene or single Shot: the
4672
+ // recipes, their script words, their references and the Library items they
4673
+ // mention. It holds no takes, and importing one generates and spends nothing.
4674
+ export const getTemplate = {
4675
+ id: 'slates_get_template',
4676
+ description: "Read a Slates template file without changing anything. With `path`: what it holds (scenes, shots, library items, the models its shots are set to) and its SWAP SLOTS — every Library item (`item:i1`, labelled with its mention, e.g. @candle) and every directly attached reference (`asset:a3`, with its roles) that slates_import_template can point at one of the project's own assets instead. Without `path`: the saved templates on this machine (the app's starter set and the user's own folder).",
4677
+ input: z.object({ path: z.string().min(1).optional().describe('Absolute path of a .slatestemplate file. Omit to list saved templates.') }),
4678
+ async run(input, ctx) {
4679
+ await ctx.desktop().requireCapability('templates', 'templates');
4680
+ if (!input.path)
4681
+ return ok(await ctx.desktop().get('/agent/templates'));
4682
+ return ok(await ctx.desktop().get('/agent/templates/inspect', { path: input.path }));
4683
+ },
4684
+ };
4685
+ export const exportTemplate = {
4686
+ id: 'slates_export_template',
4687
+ description: "Save a board, a scene or one Shot as a template file someone else can start from. Give exactly one of storyboardId, sceneId or shotId. The file carries each Shot's full recipe (prompt, model, settings, reference roles, script words) plus the reference files and the Library items the Shots mention; it carries no takes. The project is untouched. Returns the path, the counts, and a warning for any reference whose file is missing.",
4688
+ input: z.object({
4689
+ projectId: z.string().uuid(),
4690
+ path: z.string().min(1).describe('Absolute path to write, ending in .slatestemplate. Its folder is created if needed.'),
4691
+ storyboardId: z.string().uuid().optional(),
4692
+ sceneId: z.string().uuid().optional(),
4693
+ shotId: z.string().min(1).optional().describe('A Shot id or code (SHOT-A3).'),
4694
+ name: z.string().optional().describe("The template's name. Defaults to the board's, scene's or Shot's."),
4695
+ description: z.string().optional(),
4696
+ }),
4697
+ async run(input, ctx) {
4698
+ await ctx.desktop().requireCapability('templates', 'templates');
4699
+ const named = [input.storyboardId, input.sceneId, input.shotId].filter(Boolean).length;
4700
+ if (named !== 1)
4701
+ throw new Error('Give exactly one of storyboardId, sceneId or shotId.');
4702
+ return ok(await ctx.desktop().post('/agent/templates/export', input));
4703
+ },
4704
+ };
4705
+ export const importTemplate = {
4706
+ id: 'slates_import_template',
4707
+ description: "Add a template to a project. A board template becomes a new board; a scene template becomes a new scene of `storyboardId` (default: the most recently edited board); a single-Shot template is filed like any new Shot (into `sceneId` when given). Its reference files become new assets of this project and its Library items are created here (a taken name is suffixed and the imported prompts are rewritten to match; see `renamed`). `swaps` maps a slot key from slates_get_template to an asset of THIS project (id or code) of the slot's type: the item takes that picture, or every Shot that attached the template's reference attaches it instead, and the template's own file for that slot is not imported. Generates nothing and spends nothing: price and fire the returned shotIds with slates_generate_from_shots, after the user approves the prices.",
4708
+ input: z.object({
4709
+ projectId: z.string().uuid(),
4710
+ path: z.string().min(1).describe('Absolute path of the .slatestemplate file.'),
4711
+ sceneIndices: z.array(z.number().int().min(0)).min(1).optional().describe('Import only these scene indices from the inspected parts, preserving their order. Omit for the whole template.'),
4712
+ storyboardId: z.string().uuid().optional(),
4713
+ sceneId: z.string().uuid().optional(),
4714
+ swaps: z.record(z.string().min(1)).optional().describe('{ "item:i1": "IMG-A12", "asset:a3": "<asset id>" }'),
4715
+ useTemplateCategories: z
4716
+ .boolean()
4717
+ .optional()
4718
+ .describe("Remove the project's untouched default Library categories the template does not use. Only acts on a project whose Library is empty; defaults to true on a project with no board."),
4719
+ }),
4720
+ async run(input, ctx) {
4721
+ await ctx.desktop().requireCapability('templates', 'templates');
4722
+ const swaps = {};
4723
+ if (input.swaps) {
4724
+ const resolved = await resolveAssetRefs(ctx, input.projectId, Object.values(input.swaps));
4725
+ for (const [slot, ref] of Object.entries(input.swaps))
4726
+ swaps[slot] = resolved.get(ref)?.id ?? ref;
4727
+ }
4728
+ return ok(await ctx.desktop().post('/agent/templates/import', { ...input, swaps }));
4729
+ },
4730
+ };
4037
4731
  export const updateStoryboard = {
4038
4732
  id: 'slates_update_storyboard',
4039
- description: 'Update a storyboard\'s name and/or description.',
4733
+ description: 'Update a board\'s name and/or description.',
4040
4734
  input: z.object({
4041
4735
  storyboardId: z.string().uuid(),
4042
4736
  name: z.string().min(1).max(120).optional(),
@@ -4051,7 +4745,7 @@ export const updateStoryboard = {
4051
4745
  };
4052
4746
  export const deleteStoryboard = {
4053
4747
  id: 'slates_delete_storyboard',
4054
- description: 'Delete a storyboard with all its scenes and frames (the referenced assets are untouched).',
4748
+ description: 'Delete a board with all its scenes and frames (the referenced assets are untouched).',
4055
4749
  input: z.object({ storyboardId: z.string().uuid() }),
4056
4750
  async run(input, ctx) {
4057
4751
  return ok(await ctx.desktop().post('/agent/storyboards/delete', { id: input.storyboardId }));
@@ -4059,7 +4753,7 @@ export const deleteStoryboard = {
4059
4753
  };
4060
4754
  export const updateScene = {
4061
4755
  id: 'slates_update_scene',
4062
- description: 'Update a scene\'s name and/or position within its storyboard.',
4756
+ description: 'Update a scene\'s name and/or position within its board.',
4063
4757
  input: z.object({
4064
4758
  sceneId: z.string().uuid(),
4065
4759
  name: z.string().min(1).max(120).optional(),
@@ -4074,7 +4768,7 @@ export const updateScene = {
4074
4768
  };
4075
4769
  export const deleteScene = {
4076
4770
  id: 'slates_delete_scene',
4077
- description: 'Delete a scene (and its frames) from a storyboard.',
4771
+ description: 'Delete a scene (and its frames) from a board.',
4078
4772
  input: z.object({ sceneId: z.string().uuid() }),
4079
4773
  async run(input, ctx) {
4080
4774
  return ok(await ctx.desktop().post('/agent/scenes/delete', { id: input.sceneId }));
@@ -4082,7 +4776,7 @@ export const deleteScene = {
4082
4776
  };
4083
4777
  export const reorderScenes = {
4084
4778
  id: 'slates_reorder_scenes',
4085
- description: 'Reorder the scenes of a storyboard. Pass the COMPLETE list of the storyboard\'s scene ids in the desired order.',
4779
+ description: 'Reorder the scenes of a board. Pass the COMPLETE list of the board\'s scene ids in the desired order.',
4086
4780
  input: z.object({
4087
4781
  storyboardId: z.string().uuid(),
4088
4782
  sceneIds: z.array(z.string().uuid()).min(1),
@@ -4098,13 +4792,13 @@ export const updateFrame = {
4098
4792
  // of what the Shot in this slot already encodes — the image's role and the
4099
4793
  // beat's words — and they were backfilled into Shots on 2026-08-31. Use
4100
4794
  // slates_update_shot for either.
4101
- 'Update a slot: its shot label, notes, bound asset (assetId=null unbinds), scene or position. The BEAT — its line, references, model, prompt and framing — lives on the Shot in this slot; use slates_update_shot for that.',
4795
+ 'Update a slot: its shot label, notes, bound asset (assetId=null unbinds), scene or position. A scene or position change carries the words in this slot with it on the Script page. The BEAT — its line, references, model, prompt and framing — lives on the Shot in this slot; use slates_update_shot for that.',
4102
4796
  input: z.object({
4103
4797
  frameId: z.string().uuid(),
4104
4798
  shotLabel: z.string().optional(),
4105
4799
  notes: z.string().optional(),
4106
4800
  assetId: z.string().uuid().nullable().optional(),
4107
- sceneId: z.string().uuid().nullable().optional(),
4801
+ sceneId: z.string().uuid().optional(),
4108
4802
  position: z.number().int().min(0).optional(),
4109
4803
  }),
4110
4804
  async run(input, ctx) {
@@ -4135,7 +4829,7 @@ export const updateFrame = {
4135
4829
  */
4136
4830
  export const batchUpdateFrames = {
4137
4831
  id: 'slates_batch_update_frames',
4138
- description: 'Update MANY slots in one call — shot labels, notes, asset binding, scene/position. Prefer this over repeated slates_update_frame when re-arranging a scene: it is one round-trip and one UI refresh, and every id is validated before anything is written, so the batch never lands half-applied. To write the BEATS themselves, use slates_create_shot / slates_update_shot.',
4832
+ description: 'Update MANY slots in one call — shot labels, notes, asset binding, scene/position. Prefer this over repeated slates_update_frame when re-arranging a scene: it is one round-trip and one UI refresh, and every id is validated before anything is written, so the batch never lands half-applied. A scene or position change carries the words in each slot with it on the Script page. To write the BEATS themselves, use slates_create_shot / slates_update_shot.',
4139
4833
  input: z.object({
4140
4834
  updates: z
4141
4835
  .array(z.object({
@@ -4143,7 +4837,7 @@ export const batchUpdateFrames = {
4143
4837
  shotLabel: z.string().optional(),
4144
4838
  notes: z.string().optional(),
4145
4839
  assetId: z.string().uuid().nullable().optional(),
4146
- sceneId: z.string().uuid().nullable().optional(),
4840
+ sceneId: z.string().uuid().optional(),
4147
4841
  position: z.number().int().min(0).optional(),
4148
4842
  }))
4149
4843
  .min(1),
@@ -4231,6 +4925,10 @@ const SHOT_ASPECT_RATIOS = [...new Set([...VIDEO_ASPECT_RATIOS, ...IMAGE_ASPECT_
4231
4925
  // The vocabulary is still ENFORCED (a Zod enum built from MODEL_CAPABILITIES),
4232
4926
  // and the per-model narrowing is enforced by `assertShotCapabilities` at save
4233
4927
  // time — which is stronger than prose, not weaker.
4928
+ //
4929
+ // 🚨 EVERY `ShotParams` FIELD, OR A COMPILE ERROR. A plain `z.object` strips
4930
+ // what it does not declare, so a field missing here was dropped on save while
4931
+ // the op reported success; `satisfies` makes the next new param fail to build.
4234
4932
  function shotParamsShape(described) {
4235
4933
  const d = (node, text) => (described ? node.describe(text) : node);
4236
4934
  return {
@@ -4251,6 +4949,22 @@ function shotParamsShape(described) {
4251
4949
  voiceId: d(z.string().optional(), `${TTS_MODEL}: preset voiceId (slates_list_voices).`),
4252
4950
  voiceReferenceAssetId: d(z.string().optional(), `${TTS_MODEL}: audio asset to clone.`),
4253
4951
  voiceDescription: d(z.string().optional(), `${TTS_MODEL}: the voice in words.`),
4952
+ quality: z.string().optional(),
4953
+ gridMode: d(z.enum(['off', '2x2', '3x3']).optional(), 'Image models: a grid of takes in one picture.'),
4954
+ audioLanguage: d(z.enum(['en', 'zh', 'ja', 'ko', 'es']).optional(), 'Kling with sound: dialogue language.'),
4955
+ audioAccent: d(z.enum(['american', 'british', 'indian']).optional(), 'Kling with sound: English accent.'),
4956
+ generateMusic: d(z.boolean().optional(), 'Kling Omni: background music.'),
4957
+ multiShot: d(z.boolean().optional(), 'Several cuts in one take (multiShotSegments).'),
4958
+ multiShotSegments: z
4959
+ .array(z.object({ prompt: z.string(), duration: z.number(), camera: z.string(), shotSize: z.string() }))
4960
+ .nullable()
4961
+ .optional(),
4962
+ cameraControls: z
4963
+ .object({ horizontal: z.number(), vertical: z.number(), pan: z.number(), tilt: z.number(), roll: z.number(), zoom: z.number() })
4964
+ .optional(),
4965
+ audioLoop: d(z.boolean().optional(), 'eleven-sfx: seamless loop.'),
4966
+ audioPromptInfluence: d(z.number().min(0).max(1).optional(), 'eleven-sfx: how literally the prompt is followed.'),
4967
+ audioMultilingual: d(z.boolean().optional(), 'seed-audio: mixed-language casting.'),
4254
4968
  };
4255
4969
  }
4256
4970
  const shotParamsSchema = z.object(shotParamsShape(true)).optional();
@@ -4278,6 +4992,8 @@ function shotScriptShape(described) {
4278
4992
  ]));
4279
4993
  return {
4280
4994
  ...text,
4995
+ recipeMode: z.enum(['script', 'custom']).optional().describe('Script mode compiles active words with production fields. Custom keeps the authored prompt exactly.'),
4996
+ promptScriptLine: z.string().nullable().optional().describe('The exact script line reviewed when authoring this custom prompt.'),
4281
4997
  continues: described
4282
4998
  ? z.boolean().optional().describe(SCRIPT_FIELD_DESCRIPTION.continues)
4283
4999
  : z.boolean().optional(),
@@ -4299,6 +5015,10 @@ const FRAMING_NOTE = `shotSize and camera are FREE TEXT and are never rejected o
4299
5015
  function shotScriptPatch(input) {
4300
5016
  const out = {};
4301
5017
  const raw = input;
5018
+ if (input.recipeMode === 'script' || input.recipeMode === 'custom')
5019
+ out.recipeMode = input.recipeMode;
5020
+ if (typeof input.promptScriptLine === 'string' || input.promptScriptLine === null)
5021
+ out.promptScriptLine = input.promptScriptLine;
4302
5022
  for (const field of SCRIPT_TEXT_FIELDS) {
4303
5023
  if (raw[field] !== undefined)
4304
5024
  out[field] = raw[field];
@@ -4444,73 +5164,7 @@ function assertShotCapabilities(model, params) {
4444
5164
  });
4445
5165
  return err ? ok(err) : null;
4446
5166
  }
4447
- /**
4448
- * The registry cost key for a saved Shot, through the SAME builders every quote
4449
- * in this file uses (`videoCostKey` / `imageCostKey` / `audioCostKey`).
4450
- *
4451
- * Returns null when the Shot cannot be priced — no model, or a model this
4452
- * surface does not carry. The caller REPORTS that rather than quoting zero: a
4453
- * missing price displayed as free is the failure mode the whole pricing
4454
- * contract exists to prevent.
4455
- */
4456
- function shotCostKey(detail) {
4457
- const model = detail.model;
4458
- if (!model)
4459
- return null;
4460
- const p = detail.params;
4461
- // Prefer the CLAMPED values the desktop will actually fire with; a listing row
4462
- // has none, so it falls back to the raw ones and is announced as a floor.
4463
- const fires = detail.firesWith;
4464
- if (AUDIO_MODELS.includes(model)) {
4465
- if (model === TTS_MODEL) {
4466
- const text = detail.composedPrompt ?? (detail.rawPrompt.trim() || detail.line?.trim() || '');
4467
- if (!text || text.length > TTS_MAX_CHARACTERS)
4468
- return null;
4469
- return audioCostKey({ model, characters: text.length });
4470
- }
4471
- const seconds = fires?.audioDurationSeconds ?? p.audioDurationSeconds;
4472
- if (!seconds)
4473
- return null;
4474
- return audioCostKey({ model: model, durationSeconds: seconds });
4475
- }
4476
- if (VIDEO_MODELS.includes(model)) {
4477
- const duration = fires?.duration ?? p.duration;
4478
- if (!duration)
4479
- return null;
4480
- const billed = (d) => model === 'minimax-h3-max' ? Math.max(0, d) : (d > 0 ? Math.ceil(d - 0.05) : 0);
4481
- return videoCostKey({
4482
- model: model,
4483
- duration,
4484
- videoResolution: fires?.videoResolution ??
4485
- p.videoResolution ??
4486
- defaultVideoResolutionFor(model),
4487
- sound: p.sound,
4488
- seedanceFace: p.seedanceFace,
4489
- // `references` is absent on a LISTING row (it does not compose), so both
4490
- // of these read 0 there. That is why a listing quote is announced as a
4491
- // floor and `slates_get_shot` is the exact one.
4492
- referenceImages: (detail.references ?? []).filter((r) => r.kind === 'image').length,
4493
- audioRefSeconds: (detail.references ?? []).filter((r) => r.kind === 'audio').reduce((n, r) => n + (r.durationSeconds ?? 0), 0),
4494
- videoRefSeconds: (detail.references ?? [])
4495
- .filter((r) => r.kind === 'video')
4496
- .reduce((n, r) => n + billed(r.durationSeconds ?? 0), 0),
4497
- });
4498
- }
4499
- if (IMAGE_MODELS.includes(model)) {
4500
- return imageCostKey(model, (fires?.imageResolution ?? p.imageResolution) ??
4501
- (model === 'nano-banana-2-lite' ? '1k' : '2k'),
4502
- // No inline default — see the note at the estimate op. Firing a Shot with
4503
- // no stored tier lands on `high` via the desktop's `normalizeGptQuality`,
4504
- // and `imageCostKey`'s parameter default is pinned to match it.
4505
- p.gptQuality,
4506
- // The aspect the Shot will fire at — on GPT Image it moves the key, so
4507
- // quoting without it under-prices every square Shot. `fires` carries
4508
- // only the clamped resolution/duration axes, never the aspect.
4509
- p.aspectRatio);
4510
- }
4511
- return null;
4512
- }
4513
- /** Dynamic reference keys are priced by the same server calculator that debits them. */
5167
+ /** Fetch a dynamic cloud cost key for the standalone generation estimate. */
4514
5168
  async function loadDynamicPrice(ctx, byKey, key) {
4515
5169
  if (!key || byKey.has(key))
4516
5170
  return;
@@ -4518,22 +5172,9 @@ async function loadDynamicPrice(ctx, byKey, key) {
4518
5172
  for (const row of response.models)
4519
5173
  byKey.set(row.model, creditCost(row));
4520
5174
  }
4521
- /** Credits for one Shot, and how many generations it fires.
4522
- *
4523
- * `imageQuantity` multiplies IMAGE models only — the same condition the
4524
- * desktop's `estimateCostFor` applies and the only lane `/agent/shots/*` sends
4525
- * a `count` for. Multiplying it blindly would quote a video Shot 3× for a
4526
- * param its request never carries, and the card beside it would say ×1. */
4527
- function shotQuote(detail, byKey) {
4528
- const key = shotCostKey(detail);
4529
- const isImage = !!detail.model && IMAGE_MODELS.includes(detail.model);
4530
- const quantity = isImage ? (detail.params.imageQuantity ?? 1) || 1 : 1;
4531
- const per = key != null ? byKey.get(key) : undefined;
4532
- return { key, credits: (per ?? 0) * quantity, quantity };
4533
- }
4534
5175
  export const createShot = {
4535
5176
  id: 'slates_create_shot',
4536
- description: 'Write one beat of the piece — a Shot: its script line, its references with their roles, its model and params, and the prompt that fires. It needs NO image to exist, so a whole film can be written, arranged and priced before anything is generated. It lands in the storyboard automatically (the open scene, else the most recent storyboard) — never unfiled. ' +
5177
+ description: 'Write one beat of the piece — a Shot: its script line, its references with their roles, its model and params, and the prompt that fires. It needs NO image to exist, so a whole film can be written, arranged and priced before anything is generated. It lands in the board automatically (the open scene, else the most recent board) — never unfiled. ' +
4537
5178
  FRAMING_NOTE,
4538
5179
  input: z.object({
4539
5180
  projectId: z.string().uuid(),
@@ -4548,8 +5189,10 @@ export const createShot = {
4548
5189
  characterIds: z.array(z.string().uuid()).optional().describe('Characters the prompt @mentions — stored as ENTITY ids, so updating the character updates every Shot that names it.'),
4549
5190
  environmentIds: z.array(z.string().uuid()).optional(),
4550
5191
  styleIds: z.array(z.string().uuid()).optional(),
4551
- frameId: z.string().uuid().optional().describe('Put it in this exact frame. Optional — omit it and the Shot files itself into a scene, creating a storyboard named after the project if there is none.'),
5192
+ frameId: z.string().uuid().optional().describe('Put it in this exact frame. Optional — omit it and the Shot files itself into a scene, creating a board if there is none.'),
4552
5193
  sceneId: z.string().uuid().optional().describe('File it into this scene. Optional; ignored when frameId is given.'),
5194
+ storyboardId: z.string().uuid().optional().describe('File it into this board (its last scene, or a new one). Optional; ignored when frameId or sceneId is given.'),
5195
+ position: z.number().int().min(0).optional().describe('Slot in the scene it files into, 0 = first. Omit to file it last.'),
4553
5196
  ...shotScriptSchema,
4554
5197
  }),
4555
5198
  async run(input, ctx) {
@@ -4561,6 +5204,9 @@ export const createShot = {
4561
5204
  return alignErr;
4562
5205
  const desktop = ctx.desktop();
4563
5206
  await desktop.requireCapability('shots', 'saved Shots');
5207
+ // 1.5.8 ignores `position` and files the Shot last.
5208
+ if (input.position !== undefined)
5209
+ await desktop.requireCapability('shot-position', 'placing a new Shot at a slot');
4564
5210
  const { spec, refEcho } = await buildShotSpecInput(ctx, input.projectId, input);
4565
5211
  const r = await desktop.post('/agent/shots', {
4566
5212
  projectId: input.projectId,
@@ -4568,6 +5214,8 @@ export const createShot = {
4568
5214
  spec: { ...spec, ...shotScriptPatch(input) },
4569
5215
  frameId: input.frameId ?? null,
4570
5216
  sceneId: input.sceneId ?? null,
5217
+ storyboardId: input.storyboardId ?? null,
5218
+ position: input.position,
4571
5219
  });
4572
5220
  // The CODE is the address the user sees on the row — say it back so the
4573
5221
  // next call, and the next sentence to the user, can point at it.
@@ -4576,7 +5224,7 @@ export const createShot = {
4576
5224
  };
4577
5225
  export const updateShot = {
4578
5226
  id: 'slates_update_shot',
4579
- description: 'Change part of a Shot, or attach/detach it from a storyboard frame — anything you omit is left exactly as it was. ' +
5227
+ description: 'Change part of a Shot, or attach/detach it from a board frame — anything you omit is left exactly as it was. ' +
4580
5228
  FRAMING_NOTE,
4581
5229
  input: z.object({
4582
5230
  shotId: z.string().describe('The Shot id, or its SHOT-A code as shown on the row.'),
@@ -4592,8 +5240,8 @@ export const updateShot = {
4592
5240
  characterIds: z.array(z.string().uuid()).optional(),
4593
5241
  environmentIds: z.array(z.string().uuid()).optional(),
4594
5242
  styleIds: z.array(z.string().uuid()).optional(),
4595
- attachFrameId: z.string().uuid().optional().describe('Attach this Shot to a storyboard frame.'),
4596
- detachFrameId: z.string().uuid().optional().describe('Detach it from a frame. The Shot itself survives.'),
5243
+ attachFrameId: z.string().uuid().optional().describe('Attach this Shot to a board frame.'),
5244
+ detachFrameId: z.string().uuid().optional().describe('Detach it from a frame. Not its last one: pass attachFrameId too, or delete the Shot.'),
4597
5245
  posterAssetId: z.string().nullable().optional().describe('Which reference represents this Shot as a thumbnail. Defaulted automatically (first frame, else the first image reference, else the newest take) — only set it to OVERRIDE, and pass null to go back to the default.'),
4598
5246
  ...shotScriptSchemaTerse,
4599
5247
  }),
@@ -4759,35 +5407,41 @@ function describeVarietyReport(v) {
4759
5407
  }
4760
5408
  export const listShots = {
4761
5409
  id: 'slates_list_shots',
4762
- description: "Read the shot list — every Shot as a compact row IN BOARD ORDER (scene, then position), with the piece's cut count, runtime, credit floor and its variety distribution. Read this before firing a set: if one shot size is the plurality or three cuts in a row share a camera move, the batch is wrong before a credit is spent.",
5410
+ description: "Read the shot list — every Shot as a compact row IN BOARD ORDER (scene, then position), with the piece's cut count, runtime, saved-recipe price and its variety distribution. Read this before firing a set: if one shot size is the plurality or three cuts in a row share a camera move, the batch is wrong before a credit is spent.",
4763
5411
  input: z.object({
4764
5412
  projectId: z.string().uuid(),
4765
- storyboardId: z.string().uuid().optional().describe('Only Shots attached to a frame in this storyboard.'),
5413
+ storyboardId: z.string().uuid().optional().describe('Only Shots attached to a frame in this board.'),
4766
5414
  frameId: z.string().uuid().optional().describe('Only Shots attached to this frame.'),
4767
5415
  }),
4768
5416
  async run(input, ctx) {
4769
5417
  const desktop = ctx.desktop();
4770
5418
  await desktop.requireCapability('shots', 'saved Shots');
5419
+ // Prices come only from the desktop's quote; a 1.5.8 desktop has none.
5420
+ await desktop.requireCapability('board-quote', 'Shot prices');
4771
5421
  const r = await desktop.get('/agent/shots', {
4772
5422
  projectId: input.projectId,
4773
5423
  storyboardId: input.storyboardId,
4774
5424
  frameId: input.frameId,
4775
5425
  });
4776
5426
  const rows = r.shots ?? [];
4777
- // Deliberately does NOT compose each Shot — that is what slates_get_shot is
4778
- // for. A listing that composed every row would make browsing cost as much as
4779
- // auditing.
4780
- const registry = await ctx.cloud().get('/api/agent/models');
4781
- const byKey = new Map(registry.models.map((m) => [m.model, creditCost(m)]));
4782
- for (const row of rows)
4783
- await loadDynamicPrice(ctx, byKey, shotCostKey(row));
5427
+ const projectId = input.projectId;
5428
+ // The quote is a GET with every id in its query string, and Node refuses a
5429
+ // request head over 16 KiB (about 345 ids), so a long board is quoted in
5430
+ // chunks. Each Shot's price is its own; only this listing reads them.
5431
+ const QUOTE_CHUNK = 150;
5432
+ const chunks = [];
5433
+ for (let i = 0; i < rows.length; i += QUOTE_CHUNK)
5434
+ chunks.push(rows.slice(i, i + QUOTE_CHUNK).map(r => r.id));
5435
+ const quote = projectId && rows.length
5436
+ ? { items: (await Promise.all(chunks.map((shotIds) => desktop.get('/agent/shots/quote', { input: JSON.stringify({ projectId, shotIds }) })))).flatMap((q) => q.items) }
5437
+ : null;
4784
5438
  let total = 0;
4785
5439
  let unpriced = 0;
4786
5440
  const shots = rows.map((s) => {
4787
- const q = shotQuote(s, byKey);
4788
- if (q.key == null || !byKey.has(q.key))
5441
+ const q = quote?.items.find(i => i.shotId === s.id);
5442
+ if (q?.credits == null)
4789
5443
  unpriced += 1;
4790
- total += q.credits;
5444
+ total += q?.credits ?? 0;
4791
5445
  return {
4792
5446
  id: s.id,
4793
5447
  code: s.code,
@@ -4806,15 +5460,11 @@ export const listShots = {
4806
5460
  shot_size: s.shotSize,
4807
5461
  camera: s.camera,
4808
5462
  continues: s.continues,
4809
- credits: q.credits,
5463
+ credits: q?.credits ?? null,
4810
5464
  };
4811
5465
  });
4812
5466
  return ok({ shots, total_credits: total, unpriced, variety: r.variety }, `${shots.length} shot(s), at least ${fmtCredits(total)} to fire them all` +
4813
5467
  (unpriced > 0 ? ` (${unpriced} could not be priced — no model or no duration set).` : '.') +
4814
- ' 🚨 That is a FLOOR, not the bill: a listing does not compose, so the two dimensions that' +
4815
- ' depend on the reference set — Seedance reference-clip seconds and MiniMax reference images' +
4816
- ' past the free five — are missing from it. slates_get_shot prices one exactly, and' +
4817
- ' slates_generate_from_shots quotes the set exactly before it fires anything.' +
4818
5468
  (describeVarietyReport(r.variety) ? `
4819
5469
 
4820
5470
  ${describeVarietyReport(r.variety)}` : ''));
@@ -4829,14 +5479,14 @@ export const getShot = {
4829
5479
  async run(input, ctx) {
4830
5480
  const desktop = ctx.desktop();
4831
5481
  await desktop.requireCapability('shots', 'saved Shots');
5482
+ // Prices come only from the desktop's quote; a 1.5.8 desktop has none.
5483
+ await desktop.requireCapability('board-quote', 'Shot prices');
4832
5484
  const r = await desktop.get('/agent/shots/get', { id: input.shotId });
4833
- const registry = await ctx.cloud().get('/api/agent/models');
4834
- const byKey = new Map(registry.models.map((m) => [m.model, creditCost(m)]));
4835
- await loadDynamicPrice(ctx, byKey, shotCostKey(r.shot));
4836
- const q = shotQuote(r.shot, byKey);
4837
- return ok({ ...r.shot, cost_key: q.key, credits: q.credits }, `"${r.shot.name || 'Untitled'}" — ${r.shot.model ?? 'no model set'}, ` +
4838
- (q.key && !r.shot.blocked
4839
- ? `${fmtCredits(q.credits)} (${q.key}).`
5485
+ const quote = await desktop.get('/agent/shots/quote', { input: JSON.stringify({ projectId: r.shot.projectId, shotIds: [r.shot.id] }) });
5486
+ const q = quote.items[0];
5487
+ return ok({ ...r.shot, credits: q.credits, quoteFingerprint: quote.fingerprint }, `"${r.shot.name || 'Untitled'}" — ${r.shot.model ?? 'no model set'}, ` +
5488
+ (q.credits != null && !r.shot.blocked
5489
+ ? `${fmtCredits(q.credits ?? 0)}.`
4840
5490
  : `CANNOT FIRE YET: ${r.shot.blocked ?? 'not priceable — set a model and a duration.'}`) +
4841
5491
  (r.shot.blocked ? '' : ` Fires with ${JSON.stringify(r.shot.firesWith)}.`) +
4842
5492
  `\nCOMPOSED PROMPT (what the model is told): ${r.shot.composedPrompt}`);
@@ -4878,6 +5528,43 @@ export const splitShot = {
4878
5528
  `set it on each if the chop changed how long they run.`);
4879
5529
  },
4880
5530
  };
5531
+ export const splitTake = {
5532
+ id: 'slates_split_take',
5533
+ description: "A take founds its own Shot. The result (a take under `shotId`) moves to a NEW Shot whose recipe is the take's RECORDED one — the prompt, model, params and references it was actually made from, which may differ from what the source Shot says now. The new Shot lands directly after the source by default; the source keeps its other takes and its recipe untouched, and is never deleted when emptied. Script fields (line, speaker) start empty on the new row.",
5534
+ input: z.object({
5535
+ shotId: z.string().describe('The source Shot id, or its SHOT-A code.'),
5536
+ assetId: z.string().describe('The take to move — an asset id or its IMG-A / VID-A code. Must be a take of `shotId`.'),
5537
+ sceneId: z.string().uuid().optional().describe("Scene for the new Shot. Omitted = the source's scene."),
5538
+ position: z.number().int().min(0).optional().describe('Frame position in that scene. Omitted = directly after the source.'),
5539
+ name: z.string().optional().describe("The new Shot's name. Omitted = the take's label or the first 60 characters of its prompt."),
5540
+ }),
5541
+ async run(input, ctx) {
5542
+ const desktop = ctx.desktop();
5543
+ await desktop.requireCapability('takes', 'moving takes between Shots');
5544
+ const r = await desktop.post('/agent/shots/split-take', input);
5545
+ const unsaved = r.unsavedPaths?.length
5546
+ ? ` ${r.unsavedPaths.length} recorded attachment(s) are not project assets and were not carried.`
5547
+ : '';
5548
+ return ok(r, `${r.shot?.code || 'A new Shot'} founded from that take, after ${r.source?.code || 'the source'}, with the take's recorded recipe.${unsaved}`);
5549
+ },
5550
+ };
5551
+ export const refileTake = {
5552
+ id: 'slates_refile_take',
5553
+ description: 'Move a take (a result) from one Shot to another in the same project. Only the attribution moves: neither Shot\'s recipe changes, the asset stays where it is in Media, and a clip that a frame prefers keeps that role. `fromShotId` is a guard — the take moves only if it still hangs under that Shot (omit it to claim an unattributed result). An emptied Shot is kept, never deleted.',
5554
+ input: z.object({
5555
+ assetId: z.string().describe('The take — an asset id or its IMG-A / VID-A code.'),
5556
+ fromShotId: z.string().nullable().optional().describe('The Shot it hangs under now (id or SHOT-A code). Omitted or null = an unattributed result.'),
5557
+ toShotId: z.string().describe('The Shot to move it to (id or SHOT-A code).'),
5558
+ }),
5559
+ async run(input, ctx) {
5560
+ const desktop = ctx.desktop();
5561
+ await desktop.requireCapability('takes', 'moving takes between Shots');
5562
+ const r = await desktop.post('/agent/shots/refile-take', input);
5563
+ return ok(r, r.moved > 0
5564
+ ? `Moved to ${r.shot?.code || 'that Shot'}.`
5565
+ : `Nothing moved — that result is not under the Shot you named (or is already under ${r.shot?.code || 'the target'}).`);
5566
+ },
5567
+ };
4881
5568
  export const mergeShots = {
4882
5569
  id: 'slates_merge_shots',
4883
5570
  description: "Merge two adjacent Shots into one. Texts join, references union, the FIRST Shot's model and params win, and the durations SUM — which may exceed the model's window, in which case it is shown and never blocked. Lossy in one direction: the second Shot's model and params are discarded, and there is no undo (the takes are the history).",
@@ -4896,81 +5583,366 @@ export const mergeShots = {
4896
5583
  `${r.shot?.cuts ?? 1} cut(s), ${r.shot?.runtimeSeconds ?? 'no'} second(s).`);
4897
5584
  },
4898
5585
  };
5586
+ // ── The script (overhaul §4.9 "The script IS the shots", P2.4a) ───────
5587
+ // A scene owns ONE continuous text and a Shot with words is a RANGE of it;
5588
+ // the Shot's `line` mirrors the range. The agent edits the same document
5589
+ // through the same operations the Script page uses (rule 8). Offsets are
5590
+ // character positions into the scene's script as `slates_get_script` returns
5591
+ // it; read before you write, because every edit moves what follows it.
5592
+ export const getScript = {
5593
+ id: 'slates_get_script',
5594
+ description: "A scene's script — the ONE text its Shots' lines are ranges of — with every ranged Shot's [start, end) offsets and code. Read this before slates_edit_script or slates_make_shot_from_script: offsets are character positions into exactly this text. Text no Shot holds is unshot; a Shot listed by slates_list_shots but absent here has no words on the page yet. `rev` names this exact text and `revision` the whole script document: pass either to slates_edit_script so an edit measured on it is refused, not misplaced, if the words changed in between (1.6.0 desktops; older ones return neither).",
5595
+ input: z.object({
5596
+ sceneId: z.string().uuid().describe('The scene (slates_get_storyboard_with_frames lists them, each with its script).'),
5597
+ }),
5598
+ async run(input, ctx) {
5599
+ const desktop = ctx.desktop();
5600
+ await desktop.requireCapability('script', 'the Script page');
5601
+ const r = await desktop.get('/agent/script', { sceneId: input.sceneId });
5602
+ return ok(r, `${r.script.length} characters, ${r.shots.length} ranged Shot(s).`);
5603
+ },
5604
+ };
5605
+ const variationSchema = z.object({
5606
+ storyboardId: z.string().uuid(), expectedRevision: z.number().int().nonnegative(), name: z.string().min(1),
5607
+ choices: z.array(z.object({ sectionId: z.string().uuid(), alternativeId: z.string().uuid() })),
5608
+ arrangement: z.array(z.object({ sectionId: z.string().uuid(), alternativeId: z.string().uuid() })).optional(),
5609
+ itemOverrides: z.array(z.object({ from: z.string().uuid(), to: z.string().uuid(), voice: z.enum(['keep', 'replace']) })).optional(),
5610
+ assetOverrides: z.array(z.object({ from: z.string().min(1), to: z.string().min(1) })).optional(),
5611
+ idempotencyKey: z.string().min(1).optional(),
5612
+ });
5613
+ export const previewScriptVariation = {
5614
+ id: 'slates_preview_script_variation', description: 'Preview explicit version choices (one saved version per section) and independent ID-based reference replacements without writes or generation. An optional ordered arrangement supports repetition and omission. Returns composed requests, custom-prompt/reference warnings, a quote, and a plan marking each shot reuse (a finished take with matching inputs exists) or new. Inspect only the combinations you need.',
5615
+ input: variationSchema,
5616
+ async run(input, ctx) {
5617
+ await ctx.desktop().requireCapability('script-documents', 'script documents and variations');
5618
+ return ok(await ctx.desktop().post('/agent/script/variation-preview', await resolveVariationRefs(input, ctx)));
5619
+ },
5620
+ };
5621
+ export const createScriptVariation = {
5622
+ id: 'slates_create_script_variation', description: 'Materialize one reviewed variation as an independent Board with local shot IDs and lineage. Requires idempotencyKey; retry returns the same Board. Returns a quote through the normal request service. No media generation or implicit take reuse.',
5623
+ input: variationSchema,
5624
+ async run(input, ctx) {
5625
+ await ctx.desktop().requireCapability('script-documents', 'script documents and variations');
5626
+ return ok(await ctx.desktop().post('/agent/script/variation-create', await resolveVariationRefs(input, ctx)));
5627
+ },
5628
+ };
5629
+ async function resolveVariationRefs(input, ctx) {
5630
+ if (!input.assetOverrides?.length)
5631
+ return input;
5632
+ const { storyboard } = await ctx.desktop().get('/agent/storyboards/get', { id: input.storyboardId });
5633
+ const assets = await resolveAssetRefs(ctx, storyboard.projectId, input.assetOverrides.flatMap(o => [o.from, o.to]));
5634
+ return { ...input, assetOverrides: input.assetOverrides.map(o => ({ from: assets.get(o.from).id, to: assets.get(o.to).id })) };
5635
+ }
5636
+ export const getScriptUses = {
5637
+ id: 'slates_get_script_uses',
5638
+ description: 'Review each reused passage before updating it: current and proposed words, destination revision, and local-edit conflicts. Pass only explicitly selected non-conflicting uses to slates_update_script_section.',
5639
+ input: z.object({ sectionId: z.string().uuid() }),
5640
+ async run(input, ctx) {
5641
+ await ctx.desktop().requireCapability('script-documents', 'script documents');
5642
+ return ok(await ctx.desktop().get('/agent/script/uses', input));
5643
+ },
5644
+ };
5645
+ export const getShotInputs = {
5646
+ id: 'slates_get_shot_inputs',
5647
+ description: 'Read take input history and compatible reusable media. Earlier and unknown inputs remain playable; they are never treated as a fresh match. No generation.',
5648
+ input: z.object({ projectId: z.string().uuid(), shotId: z.string() }),
5649
+ async run(input, ctx) {
5650
+ await ctx.desktop().requireCapability('script-documents', 'script documents');
5651
+ return ok(await ctx.desktop().get('/agent/shots/inputs', input));
5652
+ },
5653
+ };
5654
+ export const reuseShotTake = {
5655
+ id: 'slates_reuse_shot_take',
5656
+ description: 'Explicitly link a take with matching saved request and recipe inputs to this shot. Keeps the original generation and independent shot recipe. Input changes are rechecked before reuse; costs no credits.',
5657
+ input: z.object({ projectId: z.string().uuid(), shotId: z.string(), assetId: z.string().describe('Asset UUID or badge code from compatibleTakes.') }),
5658
+ async run(input, ctx) {
5659
+ await ctx.desktop().requireCapability('script-documents', 'script documents');
5660
+ const assets = await resolveAssetRefs(ctx, input.projectId, [input.assetId]);
5661
+ return ok(await ctx.desktop().post('/agent/shots/reuse-take', { ...input, assetId: assets.get(input.assetId).id }));
5662
+ },
5663
+ };
5664
+ export const getScriptSections = {
5665
+ id: 'slates_get_script_sections', description: 'Read free-named script sections, anchored fragments, saved versions and local-change state.',
5666
+ input: z.object({ storyboardId: z.string().uuid() }),
5667
+ async run(input, ctx) {
5668
+ await ctx.desktop().requireCapability('script-documents', 'script documents');
5669
+ const data = await ctx.desktop().get('/agent/script/sections', input);
5670
+ return ok(data, 'Script sections read.');
5671
+ },
5672
+ };
5673
+ const sectionInput = z.object({
5674
+ storyboardId: z.string().uuid(), expectedRevision: z.number().int().min(0), action: z.enum(['create', 'alternative', 'choose', 'save', 'reuse', 'updateUses', 'rename', 'archive', 'tags']),
5675
+ sectionId: z.string().uuid().optional(), alternativeId: z.string().uuid().optional(), parentId: z.string().uuid().optional(), label: z.string().optional(),
5676
+ tags: z.array(z.string()).max(20).optional().describe('Free tags for action tags; replaces the list.'),
5677
+ target: z.object({ sceneId: z.string().uuid(), at: z.number().int().min(0), expectedRevision: z.number().int().min(0) }).optional(),
5678
+ uses: z.array(z.object({ sectionId: z.string().uuid(), expectedRevision: z.number().int().min(0) })).optional(),
5679
+ fragments: z.array(z.object({ sceneId: z.string().uuid(), start: z.number().int().min(0), end: z.number().int().min(0) })).optional(),
5680
+ });
5681
+ export const changeScriptSection = {
5682
+ id: 'slates_update_script_section', description: 'Create a free-named section from a selected passage, save the words now on the page as a new version (action alternative), save changes to the shown version (save) or choose another (choose), insert an editable copy (reuse), update unedited copies (updateUses), rename or archive a section or one version (alternativeId), or set its free tags. Choosing, archiving a section, reuse and updateUses first save the page\'s words and shot bindings into the version that was showing (the page is never unsaved; save remains for an explicit save), and only alternative starts a new version; archiving a section keeps its words, shots and media on the page. Uses the current document revision and never generates media. Partial crossing sections are refused.',
5683
+ input: sectionInput,
5684
+ async run(input, ctx) {
5685
+ await ctx.desktop().requireCapability('script-documents', 'script documents');
5686
+ const data = await ctx.desktop().post('/agent/script/sections', input);
5687
+ return ok(data, 'Script section updated.');
5688
+ },
5689
+ };
5690
+ export const getScriptSuggestions = {
5691
+ id: 'slates_get_script_suggestions', description: 'Read suggested replacements on a script: pending ones first (stale when their words changed since), then recent accepted or dismissed ones.',
5692
+ input: z.object({ storyboardId: z.string().uuid() }),
5693
+ async run(input, ctx) {
5694
+ await ctx.desktop().requireCapability('script-documents', 'script documents');
5695
+ return ok(await ctx.desktop().get('/agent/script/suggestions', input), 'Script suggestions read.');
5696
+ },
5697
+ };
5698
+ const suggestionInput = z.object({
5699
+ storyboardId: z.string().uuid(), expectedRevision: z.number().int().min(0), action: z.enum(['create', 'accept', 'dismiss']),
5700
+ suggestions: z.array(z.object({ sceneId: z.string().uuid(), start: z.number().int().min(0), end: z.number().int().min(0),
5701
+ original: z.string().describe('The exact words now at start to end.'), replacement: z.string(), note: z.string().max(500).optional().describe('One line on why.') })).min(1).max(20).optional(),
5702
+ suggestionId: z.string().uuid().optional(),
5703
+ });
5704
+ export const changeScriptSuggestions = {
5705
+ id: 'slates_update_script_suggestions', description: 'Propose replacements for exact passages without editing the script (create), or accept or dismiss one. Use create when asked to suggest or improve a passage; the creator reviews and accepts it in the document. Each quotes the words it replaces; one whose words change later is stale and will not apply. Accepting is one undoable write. Never generates media.',
5706
+ input: suggestionInput,
5707
+ async run(input, ctx) {
5708
+ await ctx.desktop().requireCapability('script-documents', 'script documents');
5709
+ return ok(await ctx.desktop().post('/agent/script/suggestions', input), 'Script suggestions updated.');
5710
+ },
5711
+ };
5712
+ const documentBlocks = z.array(z.object({
5713
+ id: z.string().min(1), kind: z.enum(['paragraph', 'heading', 'direction']),
5714
+ start: z.number().int().min(0), end: z.number().int().min(0), label: z.string().optional(), level: z.union([z.literal(2), z.literal(3)]).optional(),
5715
+ marks: z.array(z.object({ start: z.number().int().min(0), end: z.number().int().min(0), type: z.enum(['strong', 'em']) })),
5716
+ }));
5717
+ export const getScriptDocument = {
5718
+ id: 'slates_get_script_document',
5719
+ description: 'Read the whole script document: scene strings, UTF-16 block/shot anchors, non-spoken headings/directions and a revision for safe writes. Formatting metadata contains no second copy of paragraph text.',
5720
+ input: z.object({ storyboardId: z.string().uuid() }),
5721
+ async run(input, ctx) {
5722
+ await ctx.desktop().requireCapability('script-documents', 'script documents');
5723
+ const data = await ctx.desktop().get('/agent/script/document', input);
5724
+ return ok(data, 'Script document read.');
5725
+ },
5726
+ };
5727
+ const writeScriptDocumentInput = z.object({
5728
+ storyboardId: z.string().uuid(), expectedRevision: z.number().int().min(0),
5729
+ edits: z.array(z.object({ sceneId: z.string().uuid(), at: z.number().int().min(0), removed: z.number().int().min(0), text: z.string() })),
5730
+ structures: z.array(z.object({ sceneId: z.string().uuid(), blocks: documentBlocks })).optional(),
5731
+ move: z.object({ sceneId: z.string().uuid(), blockId: z.string(), delta: z.union([z.literal(-1), z.literal(1)]) }).optional(),
5732
+ makeShots: z.array(z.object({ sceneId: z.string().uuid(), start: z.number().int().min(0), end: z.number().int().min(0), waitingShotId: z.string().uuid().optional() })).optional(),
5733
+ restoreRevision: z.number().int().min(0).optional(), ensureScene: z.boolean().optional(),
5734
+ sceneAction: z.discriminatedUnion('kind', [z.object({ kind: z.literal('split'), sceneId: z.string().uuid(), at: z.number().int().min(0) }), z.object({ kind: z.literal('merge'), sceneId: z.string().uuid() })]).optional(),
5735
+ });
5736
+ export const writeScriptDocument = {
5737
+ id: 'slates_update_script_document',
5738
+ description: 'Apply ordered script edits and optional formatting in one revision-checked transaction. Read the document first. Stale writes are refused without replacing either author’s work. A move carries contained shot anchors; restoreRevision restores an existing text/structure revision. No generation side effect. ensureScene creates an empty scene only if needed.',
5739
+ input: writeScriptDocumentInput,
5740
+ async run(input, ctx) {
5741
+ await ctx.desktop().requireCapability('script-documents', 'script documents');
5742
+ const data = await ctx.desktop().post('/agent/script/document', input);
5743
+ return ok(data, 'Script document updated.');
5744
+ },
5745
+ };
5746
+ export const editScript = {
5747
+ id: 'slates_edit_script',
5748
+ description: "Edit a scene's script: ONE contiguous replacement — `removed` characters at `at` become `text` (insert: removed 0; delete: text ''). Every Shot's range follows the way comment anchors follow a document: text before a Shot moves it, text after leaves it, an edit inside grows or shrinks it, typing at its end extends it unless the text starts a new line, and deleting all of a Shot's words leaves it with no words and no place on the page. Every affected Shot's `line` changes at once — this IS editing the Shots. Read slates_get_script first; offsets are into that text. Pass its `rev` (or the document `revision` as `expectedRevision`): when the Script page or anything else changed the words since, the edit is refused ('the script changed since you read it', or SCRIPT_CONFLICT for a stale revision), and you re-read and measure again. The result carries the new `rev` and `revision` for a following edit.",
5749
+ input: z.object({
5750
+ sceneId: z.string().uuid(),
5751
+ expectedRevision: z.number().int().nonnegative().optional().describe('The document `revision` slates_get_script or slates_get_script_document returned. The edit is refused if the document changed since.'),
5752
+ at: z.number().int().min(0).describe('Character offset the replacement starts at.'),
5753
+ removed: z.number().int().min(0).describe('How many characters to remove there (0 to insert).'),
5754
+ text: z.string().describe("What goes in their place ('' to delete). A blank line (\\n\\n) separates paragraphs."),
5755
+ rev: z.string().optional().describe('The `rev` slates_get_script (or the previous edit) returned. The edit is refused if the text changed since.'),
5756
+ }),
5757
+ async run(input, ctx) {
5758
+ const desktop = ctx.desktop();
5759
+ await desktop.requireCapability('script', 'the Script page');
5760
+ const r = await desktop.post('/agent/script/edit', input);
5761
+ return ok(r, `Script is now ${r.script.length} characters; every Shot's line follows.`);
5762
+ },
5763
+ };
5764
+ export const makeShotFromScript = {
5765
+ id: 'slates_make_shot_from_script',
5766
+ description: "Do the ONE thing a selection of the script can mean, exactly as the Script page's popover does: Make shot when the selection touches no Shot; Split when it lies inside one Shot (the Shot keeps its first piece and its takes, every other piece becomes a new Shot after it); Extend when it crosses one Shot's edge; Merge when it spans several (the first takes the span — unshot text between them included, nothing deleted — the others give up their words: one holding a take (finished or generating), a prompt, a reference or a picture stays and waits for text; only a completely blank one is removed). Pass `waitingShotId` to give a Shot that has no words yet (made on the Board, split off a take) the selected text instead. `perParagraph: true` over several unshot paragraphs makes one Shot per paragraph. A new Shot is filed after every ranged Shot whose words start before it.",
5767
+ input: z.object({
5768
+ sceneId: z.string().uuid(),
5769
+ start: z.number().int().min(0),
5770
+ end: z.number().int().min(0),
5771
+ waitingShotId: z.string().nullable().optional().describe('A Shot with no words (id or SHOT-A code) that should take the selection.'),
5772
+ perParagraph: z.boolean().optional().describe('Over unshot text spanning several paragraphs: one Shot per paragraph.'),
5773
+ }),
5774
+ async run(input, ctx) {
5775
+ const desktop = ctx.desktop();
5776
+ await desktop.requireCapability('script', 'the Script page');
5777
+ const r = await desktop.post('/agent/script/make-shot', input);
5778
+ const kept = r.kept?.length ? ` ${r.kept.join(', ')} kept ${r.kept.length === 1 ? 'its takes and waits' : 'their takes and wait'} for text.` : '';
5779
+ const removed = r.removed?.length ? ` ${r.removed.length} empty Shot(s) removed.` : '';
5780
+ const made = r.shotIds ? `${r.shotIds.length} Shot(s) made, one per paragraph.` : `${r.action.label}: ${r.shot?.code || r.shotId || 'done'}.`;
5781
+ return ok(r, `${made}${kept}${removed}`);
5782
+ },
5783
+ };
5784
+ export const breakScriptIntoShots = {
5785
+ id: 'slates_break_script_into_shots',
5786
+ description: '"Break the script into shots": a Shot for every piece of text no Shot holds — one per paragraph — across a whole board, or one scene. Shots that already exist keep their words. This is the cut after the script is right; slates_paste_script is the step before it.',
5787
+ input: z.object({
5788
+ storyboardId: z.string().uuid().optional().describe('Every scene of this board.'),
5789
+ sceneId: z.string().uuid().optional().describe('Only this scene.'),
5790
+ }),
5791
+ async run(input, ctx) {
5792
+ const desktop = ctx.desktop();
5793
+ await desktop.requireCapability('script', 'the Script page');
5794
+ const r = await desktop.post('/agent/script/break', input);
5795
+ return ok(r, `${r.shotIds.length} Shot(s) made from the unshot text.`);
5796
+ },
5797
+ };
5798
+ export const splitScene = {
5799
+ id: 'slates_split_scene',
5800
+ description: "Open a scene at a heading line, the Notion way: the text from `lineEnd` on moves into a NEW scene directly after this one, with its Shots and their slots; the heading line [lineStart, lineEnd) itself is consumed and `name` becomes the new scene's name. Use slates_paste_script to do this for every SCENE / INT. / EXT. / # line of a pasted script at once.",
5801
+ input: z.object({
5802
+ sceneId: z.string().uuid(),
5803
+ lineStart: z.number().int().min(0).describe('Offset where the heading line starts.'),
5804
+ lineEnd: z.number().int().min(0).describe('Offset just past the heading line (the newline after it is consumed too).'),
5805
+ name: z.string().nullable().optional().describe("The new scene's name. Omitted = 'Scene N'."),
5806
+ }),
5807
+ async run(input, ctx) {
5808
+ const desktop = ctx.desktop();
5809
+ await desktop.requireCapability('script', 'the Script page');
5810
+ const r = await desktop.post('/agent/script/split-scene', input);
5811
+ return ok(r, `Opened "${r.scene?.name ?? 'a scene'}" there; the text below moved into it.`);
5812
+ },
5813
+ };
5814
+ export const mergeScene = {
5815
+ id: 'slates_merge_scene',
5816
+ description: "Join a scene to the one before it (Backspace in an emptied heading): the texts join with a blank line, every Shot keeps its words and its slot appends after the previous scene's. Only the scene row goes — no Shot does. Returns the offset in the joined script where this scene's text now starts.",
5817
+ input: z.object({ sceneId: z.string().uuid() }),
5818
+ async run(input, ctx) {
5819
+ const desktop = ctx.desktop();
5820
+ await desktop.requireCapability('script', 'the Script page');
5821
+ const r = await desktop.post('/agent/script/merge-scene', input);
5822
+ return ok(r, `Joined into "${r.scene?.name ?? 'the previous scene'}" at offset ${r.at}.`);
5823
+ },
5824
+ };
5825
+ export const pasteScript = {
5826
+ id: 'slates_paste_script',
5827
+ description: "Dump a script in as TEXT, not Shots: the text lands in the scene's script (at its end by default), and lines reading `SCENE …`, `INT.` / `EXT.` / `I/E.` or `# Name` open scenes. No Shot is made — cutting is the writer's act or yours: slates_break_script_into_shots for one per paragraph, or slates_make_shot_from_script per selection. With no sceneId, a new scene at the end of the board takes the text (dropped again if the script opens with its own heading). Prefer this over slates_create_shot per paragraph: see the whole script, get it right, then cut it.",
5828
+ input: z.object({
5829
+ storyboardId: z.string().uuid().optional().describe('The board, when no scene is named.'),
5830
+ sceneId: z.string().uuid().nullable().optional().describe('The scene to paste into. Omitted = a new scene at the end.'),
5831
+ text: z.string().describe('The script. Blank lines separate paragraphs.'),
5832
+ at: z.number().int().min(0).optional().describe("Offset in the scene's script to paste at. Omitted = the end."),
5833
+ }),
5834
+ async run(input, ctx) {
5835
+ const desktop = ctx.desktop();
5836
+ await desktop.requireCapability('script', 'the Script page');
5837
+ const r = await desktop.post('/agent/script/paste', input);
5838
+ return ok(r, `Pasted as text; the board now has ${r.scenes.length} scene(s). No Shot was made — break it into shots when it reads right.`);
5839
+ },
5840
+ };
5841
+ /** The draft mode both quote ops accept: one picture per Shot in scope that has none. */
5842
+ const DRAFT_INPUT = z
5843
+ .object({ model: z.enum(IMAGE_MODELS).optional().describe('The image model to draft on. Omit it for the default image model.') })
5844
+ .optional();
4899
5845
  export const generateFromShots = {
4900
5846
  id: 'slates_generate_from_shots',
4901
5847
  billable: true,
4902
- description: 'Generate from saved Shots, ONE AFTER ANOTHER, with a single quote and a single approval for the whole set. It blocks until the last one lands, so a set of video Shots can outlast the HTTP timeout while the run keeps going — if that happens, poll slates_get_shot for each Shot\'s generationIds instead of re-firing, which double-spends.',
5848
+ description: 'Preview the itemized prices the Generate panel shows for saved Shots. After explicit approval, pass confirm and the returned fingerprint. Changed recipes require a fresh price. Runs sequentially; after a timeout inspect generations instead of re-firing. Pass draft to generate previews instead: one picture for each Shot that has none, never saved to the Shot.',
4903
5849
  input: z.object({
4904
- shotIds: z.array(z.string()).min(1).max(20).describe('The Shots to fire, in order — ids or SHOT-A codes.'),
4905
- confirm: z.boolean().optional().describe('Set true after explicit user OK on the TOTAL below.'),
5850
+ shotIds: z.array(z.string()).min(1).max(20),
5851
+ confirm: z.boolean().optional(),
5852
+ fingerprint: z.string().optional(),
5853
+ draft: DRAFT_INPUT.describe('Generate previews: pass {} to price ONE picture for each Shot in scope that has no picture, instead of each Shot recipe, at the chosen image model’s default settings. Works on a Shot with no model. No Shot row changes; the picture becomes a take of that Shot. Pass the same value when confirming.'),
4906
5854
  }),
4907
5855
  async run(input, ctx) {
4908
5856
  const desktop = ctx.desktop();
4909
5857
  await desktop.requireCapability('shots', 'saved Shots');
4910
- // Resolve and price every Shot BEFORE anything fires. A dead id found
4911
- // halfway through a batch means a partially-fired, partially-BILLED run.
4912
- const details = [];
4913
- for (const id of input.shotIds) {
4914
- const r = await desktop.get('/agent/shots/get', { id });
4915
- details.push(r.shot);
4916
- }
4917
- const registry = await ctx.cloud().get('/api/agent/models');
4918
- const byKey = new Map(registry.models.map((m) => [m.model, creditCost(m)]));
4919
- await Promise.all(details.map((d) => loadDynamicPrice(ctx, byKey, shotCostKey(d))));
4920
- const quotes = details.map((d) => ({ detail: d, ...shotQuote(d, byKey) }));
4921
- const total = quotes.reduce((n, q) => n + q.credits, 0);
4922
- const largest = quotes.reduce((m, q) => (q.credits > m ? q.credits : m), 0);
4923
- const unpriced = quotes.filter((q) => q.key == null || !byKey.has(q.key));
4924
- const blockedShots = details.filter((d) => d.blocked);
4925
- if (!input.confirm) {
4926
- // ONE approval for the set, itemised. N approvals would re-introduce the
4927
- // friction the batch exists to remove; the safety is the STATED TOTAL,
4928
- // prominent — count, total, and the largest single Shot.
4929
- const lines = quotes.map((q) => ` - ${q.detail.name || 'Untitled'} · ${q.detail.model ?? 'no model'} · ` +
4930
- (q.key ? fmtCredits(q.credits) : 'NOT PRICEABLE'));
4931
- const blockedLines = blockedShots.map((d) => ` ✖ ${d.name || 'Untitled'} WILL NOT FIRE: ${d.blocked}`);
4932
- const warnings = details
4933
- .filter((d) => d.missing || d.unresolvedTokens?.length)
4934
- .map((d) => ` ! ${d.name || 'Untitled'}: ${d.missing ? 'references something that no longer exists' : ''}` +
4935
- `${d.unresolvedTokens?.length ? ` ${d.unresolvedTokens.join(', ')} match nothing saved (sent as written, no reference attached)` : ''}`);
4936
- return ok({
4937
- requires_confirm: true,
4938
- count: quotes.length,
4939
- total_credits: total,
4940
- largest_single_credits: largest,
4941
- blocked_count: blockedShots.length,
4942
- shots: quotes.map((q) => ({
4943
- id: q.detail.id,
4944
- name: q.detail.name,
4945
- model: q.detail.model,
4946
- cost_key: q.key,
4947
- credits: q.credits,
4948
- blocked: q.detail.blocked,
4949
- })),
4950
- }, `Firing ${quotes.length} Shot(s) SEQUENTIALLY.\n` +
4951
- `TOTAL ${fmtCredits(total)} · largest single ${fmtCredits(largest)}\n` +
4952
- lines.join('\n') +
4953
- (blockedLines.length > 0 ? `\n${blockedLines.join('\n')}` : '') +
4954
- (unpriced.length > 0
4955
- ? `\n ! ${unpriced.length} shot(s) could not be priced — they will still be attempted and may fail.`
4956
- : '') +
4957
- (warnings.length > 0 ? `\n${warnings.join('\n')}` : '') +
4958
- `\n\nRe-call with confirm: true after explicit user OK on that total.`);
5858
+ // Prices come only from the desktop's quote; a 1.5.8 desktop has none.
5859
+ await desktop.requireCapability('board-quote', 'Shot prices');
5860
+ // Resolved first so a SHOT-A code fails here, not halfway through a billed run.
5861
+ const details = await Promise.all(input.shotIds.map((id) => desktop.get('/agent/shots/get', { id })));
5862
+ const quote = await desktop.get('/agent/shots/quote', {
5863
+ input: JSON.stringify({ projectId: details[0].shot.projectId, shotIds: details.map((d) => d.shot.id), draft: input.draft }),
5864
+ });
5865
+ const total = quote.items.reduce((n, i) => n + (i.credits ?? 0), 0);
5866
+ const largest = Math.max(0, ...quote.items.map((i) => i.credits ?? 0));
5867
+ // ONE approval for the set, on the STATED total; a stale fingerprint is a
5868
+ // changed recipe and never reaches generation.
5869
+ if (!input.confirm || input.fingerprint !== quote.fingerprint) {
5870
+ return ok({ ...quote, requires_confirm: true, total_credits: total, largest_single_credits: largest }, `TOTAL ${fmtCredits(total)} · largest ${fmtCredits(largest)}.\n` +
5871
+ quote.items
5872
+ .map((i) => `${i.code ?? i.name}: ${i.credits == null ? 'unpriced' : fmtCredits(i.credits)}${i.blocked ? ` — ${i.blocked}` : ''}`)
5873
+ .join('\n') +
5874
+ '\nGet approval for these prices, then pass their fingerprint with confirm: true.');
4959
5875
  }
4960
- const r = await desktop.post('/agent/shots/batch-generate', { shotIds: input.shotIds });
4961
- const failedLines = (r.results ?? [])
4962
- .filter((x) => x.status === 'failed')
4963
- .map((x) => ` ✗ ${x.name || 'Untitled'}: ${x.error ?? 'failed'}`);
4964
- return ok(r, `${r.succeeded} of ${r.total} generated for about ${fmtCredits(total)}.` +
4965
- (failedLines.length > 0
4966
- ? // Reported, never retried: an agent that treats a failed render as
4967
- // something to try again spends credits before anyone notices.
4968
- `\n${failedLines.join('\n')}\nThese were NOT retried. Read each error, fix the Shot, and re-fire only what you meant to.`
4969
- : '') +
4970
- ` ${BACKGROUND_REVIEW_POINTER}`);
4971
- },
4972
- };
4973
- function resolveGuideTopic(topic) {
5876
+ return ok(await desktop.post('/agent/shots/batch-generate', {
5877
+ shotIds: quote.items.map((i) => i.shotId),
5878
+ fingerprint: quote.fingerprint,
5879
+ draft: input.draft,
5880
+ }));
5881
+ },
5882
+ };
5883
+ export const quoteBoard = {
5884
+ id: 'slates_get_board_quote',
5885
+ description: 'Read the itemized prices the Generate panel shows for a board, scene or shot selection, optionally only missing results. No generation. Uses the desktop composer pricing source and returns a fingerprint for slates_generate_from_shots. Pass draft to price previews (one picture per Shot that has none).',
5886
+ input: z.object({
5887
+ projectId: z.string().uuid(),
5888
+ storyboardId: z.string().uuid().optional(),
5889
+ sceneId: z.string().uuid().optional(),
5890
+ shotIds: z.array(z.string()).optional(),
5891
+ missingOnly: z.boolean().optional(),
5892
+ draft: DRAFT_INPUT.describe('Generate previews: pass {} to price one picture for each Shot in scope with no picture, instead of each Shot recipe.'),
5893
+ }),
5894
+ async run(input, ctx) {
5895
+ await ctx.desktop().requireCapability('board-quote', 'the board prices');
5896
+ return ok(await ctx.desktop().get('/agent/shots/quote', { input: JSON.stringify(input) }));
5897
+ },
5898
+ };
5899
+ export const getBoardProgress = {
5900
+ id: 'slates_get_board_progress',
5901
+ description: 'Read per-shot spend from generation history, surviving take counts, running/failed counts and recorded-round progress. Deleted takes do not reduce spend.',
5902
+ input: z.object({ projectId: z.string().uuid() }),
5903
+ async run(input, ctx) {
5904
+ await ctx.desktop().requireCapability('board-progress', 'board progress');
5905
+ return ok(await ctx.desktop().get('/agent/shots/progress', input));
5906
+ },
5907
+ };
5908
+ export const editCut = {
5909
+ id: 'slates_edit_cut',
5910
+ description: 'Read pending preferred-take changes, explicitly replace one clip, sync a board’s preferred clips, or build a cut in board order. Read changes first for the count. Swaps keep timeline starts and source timing when possible; shorter takes are visibly marked. Never changes a shot recipe or poster. Undo swaps with restore and the returned before snapshots; undo a build with undo-build and its clipIds/markerIds.',
5911
+ input: z.object({
5912
+ projectId: z.string().uuid(), timelineId: z.string().uuid().optional(),
5913
+ action: z.enum(['changes', 'replace', 'sync', 'build', 'restore', 'undo-build']),
5914
+ storyboardId: z.string().uuid().optional(),
5915
+ clipId: z.string().uuid().optional(),
5916
+ assetId: z.string().optional(),
5917
+ before: z
5918
+ .array(z
5919
+ .object({
5920
+ id: z.string().uuid(),
5921
+ assetPath: z.string(),
5922
+ assetId: z.string().nullable().optional(),
5923
+ shotId: z.string().nullable().optional(),
5924
+ thumbnailPath: z.string().optional(),
5925
+ sourceDuration: z.number().positive(),
5926
+ sourceFps: z.number().positive(),
5927
+ sourceInFrame: z.number().int().min(0),
5928
+ sourceOutFrame: z.number().int().positive(),
5929
+ endFrame: z.number().int().positive(),
5930
+ shortened: z.boolean().optional(),
5931
+ })
5932
+ .passthrough())
5933
+ .optional()
5934
+ .describe('Exact before snapshots returned by replace/sync, for restore.'),
5935
+ clipIds: z.array(z.string().uuid()).optional(),
5936
+ markerIds: z.array(z.string().uuid()).optional(),
5937
+ skipPresent: z.boolean().optional().describe('build: add only Shots this cut does not already hold, so a repeated build appends nothing twice. Default appends the whole board.'),
5938
+ }),
5939
+ async run(input, ctx) {
5940
+ await ctx.desktop().requireCapability('cut-edit', 'editing a cut');
5941
+ const refs = input.assetId ? await resolveAssetRefs(ctx, input.projectId, [input.assetId]) : new Map();
5942
+ return ok(await ctx.desktop().post('/agent/timeline/cut', { ...input, assetId: input.assetId ? refs.get(input.assetId)?.id : undefined }));
5943
+ },
5944
+ };
5945
+ export function resolveGuideTopic(topic) {
4974
5946
  const t = topic.trim().toLowerCase();
4975
5947
  if (SKILLS[t])
4976
5948
  return t;
@@ -4995,6 +5967,32 @@ function resolveGuideTopic(topic) {
4995
5967
  if (t === 'camera' || t === 'camera-moves' || t === 'camera moves' || t === 'shot-list' || t === 'shot list') {
4996
5968
  return 'slates-camera-language';
4997
5969
  }
5970
+ // The cinematic-look catalogue: light, exposure, grade, what a lens does to the
5971
+ // picture, imperfection. Above the model prefixes like the blocks before it;
5972
+ // `camera` stays with camera-language, `lens` and `lighting` are look words.
5973
+ if (t === 'cinematic' ||
5974
+ t === 'cinematic-look' ||
5975
+ t === 'cinematic look' ||
5976
+ t === 'film-look' ||
5977
+ t === 'film look' ||
5978
+ t === 'look' ||
5979
+ t === 'lighting' ||
5980
+ t === 'light' ||
5981
+ t === 'exposure' ||
5982
+ t === 'grade' ||
5983
+ t === 'grading' ||
5984
+ t === 'color-grade' ||
5985
+ t === 'colour-grade' ||
5986
+ t === 'silhouette' ||
5987
+ t === 'lens' ||
5988
+ t === 'lenses' ||
5989
+ t === 'realism' ||
5990
+ t === 'natural-light' ||
5991
+ t === 'natural light' ||
5992
+ t === 'too-perfect' ||
5993
+ t === 'imperfection') {
5994
+ return 'slates-cinematic-look';
5995
+ }
4998
5996
  if (t === 'blocking-to-prompt' || t === 'previs-prompt' || t === 'reference-video' || t === 'video-to-video' || t === 'v2v') {
4999
5997
  return 'slates-blocking-to-prompt';
5000
5998
  }
@@ -5014,7 +6012,7 @@ function resolveGuideTopic(topic) {
5014
6012
  }
5015
6013
  if (t.startsWith('nano-banana'))
5016
6014
  return 'slates-prompting-nano-banana-2';
5017
- if (t.startsWith('gpt-image') || t.startsWith('gpt image'))
6015
+ if (t === 'sunburst' || t === 'flare' || t.startsWith('gpt-image') || t.startsWith('gpt image'))
5018
6016
  return 'slates-prompting-gpt-image-2-5';
5019
6017
  if (t.startsWith('flux'))
5020
6018
  return 'slates-prompting-flux-2-max';
@@ -5135,61 +6133,64 @@ export const getPromptingGuide = {
5135
6133
  // not this op is ever called.
5136
6134
  "Return a bundled Slates prompting/workflow guide. MCP-only clients (Claude Desktop, Smithery) don't get the CLI-installed skill files — call this instead. Accepts a guide name or a model id ('veo-3.1-fast', 'kling-v3.0-pro', 'seedance-2', 'nano-banana-2'), which maps to the right guide. Reach for it when a card is not enough: the failure modes, the worked examples and the sources are only in the full text.",
5137
6135
  input: z.object({
5138
- query: z.string().max(200).optional().describe('For app-manual: keywords to retrieve relevant UI sections. Omit for the entire manual.'),
6136
+ query: z.string().max(200).optional().describe('Keywords, section heading, or exact cinematic technique ID. Returns only the matching section or technique.'),
5139
6137
  topic: z
5140
6138
  .string()
5141
6139
  .min(1)
5142
6140
  .describe(`Guide name, model id, or style name. ${describeGuideTopics()}`),
5143
- depth: z.enum(['card', 'full']).optional().describe('"card" returns just the levers block (a few hundred words — the same card slates_estimate_generation_cost already attached, so usually redundant). "full" (default) returns the whole guide, up to several thousand words.'),
6141
+ depth: z.enum(['card', 'index', 'section', 'full']).optional().describe('Default "card" is a short overview. "index" lists sections; query selects one section or technique; "full" explicitly returns the complete guide.'),
5144
6142
  }),
5145
6143
  async run(input) {
5146
6144
  if (input.topic.trim().toLowerCase() === 'app-manual') {
5147
- const content = appManualSections(input.query);
5148
- return { text: content, data: { topic: 'app-manual', bytes: Buffer.byteLength(content, 'utf8') } };
6145
+ const content = input.query || input.depth === 'full'
6146
+ ? appManualSections(input.query)
6147
+ : 'App manual sections. Pass query to read a section, or depth "full" for the complete manual.\n\n' +
6148
+ guideSections(appManualSections()).map((s) => `- ${s.title}`).join('\n');
6149
+ return { text: content, data: { topic: 'app-manual', bytes: Buffer.byteLength(content, 'utf8'), guide: content } };
5149
6150
  }
5150
6151
  const resolved = resolveGuideTopic(input.topic);
5151
6152
  const content = resolved ? SKILLS[resolved] : undefined;
5152
6153
  if (!resolved || content === undefined) {
5153
6154
  throw new Error(`Unknown guide topic: ${input.topic}. Valid topics: ${Object.keys(SKILLS).sort().join(', ')}`);
5154
6155
  }
5155
- if (input.depth === 'card') {
5156
- const card = describeCraftCard(resolved);
5157
- if (card) {
5158
- return { text: card, data: { topic: resolved, depth: 'card', bytes: Buffer.byteLength(card, 'utf8') } };
5159
- }
5160
- // No card on this guide — returning nothing would read as "no guidance",
5161
- // which is worse than a fall-through the result names.
5162
- }
5163
- return {
5164
- text: content,
5165
- data: { topic: resolved, depth: 'full', bytes: Buffer.byteLength(content, 'utf8') },
5166
- };
6156
+ const depth = input.depth ?? 'card';
6157
+ const guide = retrieveGuide(resolved, content, depth, input.query);
6158
+ // Both fields carry the body: some native clients expose structured data only.
6159
+ return { text: guide, data: { topic: resolved, depth, bytes: Buffer.byteLength(guide, 'utf8'), guide } };
5167
6160
  },
5168
6161
  };
5169
6162
  /**
5170
6163
  * The one op that changes what OTHER ops are visible.
5171
6164
  *
5172
6165
  * 🚨 IT EXISTS BECAUSE THE SURFACE IS 112 KB AND EVERY TURN PAYS FOR ALL OF IT.
5173
- * The desktop Studio Agent sends `core` plus this; a group arrives when the
5174
- * work needs it and stays for the rest of the run. On the MCP surface every op
5175
- * is registered up front (a stdio server has no run to append to), so this
5176
- * returns the same definitions as a plain listing — useful either way, since
5177
- * it is also how an agent asks "what else can you do".
6166
+ * Both surfaces start with the shared core set. Search returns compact metadata;
6167
+ * names/group returns exact schemas and replaces the optional selection.
5178
6168
  */
5179
6169
  export const loadTools = {
5180
6170
  id: 'slates_load_tools',
5181
- description: 'Load a deferred group of tools for the rest of this session. The core surface is always present; these four groups are held back so every turn does not pay for the whole registry. ' +
5182
- Object.entries(GROUP_SUMMARY)
5183
- .map(([g, s]) => `"${g}": ${s}`)
5184
- .join('. ') +
5185
- '. Call it the moment the work needs one of those — the tools arrive in the same turn\'s result and stay loaded. On MCP clients every tool is already registered and this just lists the group.',
6171
+ // Worded to be true on both surfaces: the desktop agent must load an
6172
+ // extended tool before calling it, while the MCP server lists every tool, so
6173
+ // "missing from your list" never happens there by default.
6174
+ description: 'Find Slates tools by task. query searches tool names and descriptions and returns a compact list. names returns up to five exact tools with their schemas; group returns a whole task group. Call the tools by their own names. If a tool you need is missing from your tool list, a names or group load adds it; a load replaces the previous optional selection, and query alone does not change it. Groups: ' + Object.entries(GROUP_SUMMARY).map(([g, s]) => `${g}: ${s}`).join('; '),
5186
6175
  input: z.object({
5187
- group: z.enum(['library', 'timeline', 'admin', 'blender']).describe('Which group to load.'),
5188
- }),
6176
+ group: z.enum(['script', 'library', 'timeline', 'admin', 'blender']).optional(),
6177
+ query: z.string().min(1).max(160).optional().describe('Search for a task such as generate image, create shot, or edit video.'),
6178
+ names: z.array(z.string()).min(1).max(5).optional().describe('Exact operation names to load after discovery. Their permission annotations remain separate.'),
6179
+ }).refine((v) => [v.group, v.query, v.names].filter(Boolean).length === 1, 'Pass exactly one of query, names, or group.'),
5189
6180
  async run(input) {
5190
- const defs = toolDefinitions(ALL_OPERATIONS.filter((op) => groupFor(op.id) === input.group), { surface: 'mcp' });
5191
- return ok({ group: input.group, tools: defs }, `Loaded the "${input.group}" group — ${defs.length} tool(s) now available:\n` +
5192
- defs.map((d) => `${d.name}: ${d.description.split(/(?<=\.)\s/)[0]}`).join('\n'));
6181
+ if (input.query) {
6182
+ const words = input.query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
6183
+ const ranked = ALL_OPERATIONS.map((op) => ({ op, score: words.reduce((n, w) => n + (op.id.includes(w) ? 4 : op.description.toLowerCase().includes(w) ? 1 : 0), 0) }))
6184
+ .filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 10);
6185
+ const matches = ranked.map(({ op }) => ({ name: op.id, description: op.description.split(/(?<=\.)\s/)[0], billable: !!op.billable, annotations: op.annotations }));
6186
+ return ok({ matches }, matches.map((o) => `${o.name}: ${o.description}`).join('\n') || 'No matching tools. Try another task description.');
6187
+ }
6188
+ const names = input.names ?? OPERATION_GROUPS[input.group];
6189
+ const unknown = names.filter((id) => !ALL_OPERATIONS.some((op) => op.id === id));
6190
+ if (unknown.length)
6191
+ throw new Error(`Unknown operation(s): ${unknown.join(', ')}`);
6192
+ const defs = toolDefinitions(ALL_OPERATIONS.filter((op) => names.includes(op.id)), { surface: 'mcp' });
6193
+ return ok({ group: input.group, tools: defs }, `Loaded tools (call by name):\n${JSON.stringify(defs)}`);
5193
6194
  },
5194
6195
  };
5195
6196
  // ── Blender previs ──────────────────────────────────────────────
@@ -5348,6 +6349,9 @@ export const blenderRenderBlocking = {
5348
6349
  // ── Aggregation ─────────────────────────────────────────────────
5349
6350
  export const ALL_OPERATIONS = [
5350
6351
  getWorkspaceState,
6352
+ getSelection,
6353
+ getView,
6354
+ setView,
5351
6355
  getMe,
5352
6356
  getCreditBalance,
5353
6357
  listAvailableModels,
@@ -5360,9 +6364,18 @@ export const ALL_OPERATIONS = [
5360
6364
  getAssetsBatch,
5361
6365
  getAssetVideoFrames,
5362
6366
  uploadReferenceImage,
6367
+ saveExternalImage,
6368
+ getChatGptStatus,
6369
+ connectChatGpt,
6370
+ generateChatGptImage,
5363
6371
  listFolders,
5364
6372
  createFolder,
5365
6373
  moveAssetsToFolder,
6374
+ setAssetFavorite,
6375
+ exportAssets,
6376
+ listPins,
6377
+ pinReferences,
6378
+ unpinReference,
5366
6379
  moveAssetsToProject,
5367
6380
  copyAssetsToProject,
5368
6381
  moveEntityToProject,
@@ -5389,10 +6402,14 @@ export const ALL_OPERATIONS = [
5389
6402
  editImage,
5390
6403
  getGenerationStatus,
5391
6404
  listGenerations,
6405
+ listTimelines,
6406
+ saveTimeline,
6407
+ exportCuts,
5392
6408
  getTimeline,
5393
6409
  addClipToTimeline,
5394
6410
  reorderClips,
5395
6411
  removeClip,
6412
+ manageTimelineMarker,
5396
6413
  addTimelineTrack,
5397
6414
  updateTimelineTrack,
5398
6415
  removeTimelineTrack,
@@ -5403,6 +6420,8 @@ export const ALL_OPERATIONS = [
5403
6420
  updateProject,
5404
6421
  deleteProject,
5405
6422
  getProjectDirectory,
6423
+ relocateProject,
6424
+ undoRelocateProject,
5406
6425
  deleteAsset,
5407
6426
  renameFolder,
5408
6427
  deleteFolder,
@@ -5415,6 +6434,15 @@ export const ALL_OPERATIONS = [
5415
6434
  createStyle,
5416
6435
  updateStyle,
5417
6436
  deleteStyle,
6437
+ listLibrary,
6438
+ createLibraryItem,
6439
+ updateLibraryItem,
6440
+ deleteLibraryItem,
6441
+ manageLibraryCategory,
6442
+ copyLibraryItemToProject,
6443
+ getTemplate,
6444
+ exportTemplate,
6445
+ importTemplate,
5418
6446
  updateStoryboard,
5419
6447
  deleteStoryboard,
5420
6448
  updateScene,
@@ -5433,9 +6461,34 @@ export const ALL_OPERATIONS = [
5433
6461
  // actually decided. They sit beside the writers, not with the spender.
5434
6462
  splitShot,
5435
6463
  mergeShots,
6464
+ // A take founding its own Shot, or moving between Shots (P2.3).
6465
+ splitTake,
6466
+ refileTake,
6467
+ // The script IS the shots (P2.4a): one text per scene, Shots as ranges of it.
6468
+ getScript,
6469
+ getScriptUses,
6470
+ getShotInputs,
6471
+ reuseShotTake,
6472
+ previewScriptVariation,
6473
+ createScriptVariation,
6474
+ getScriptSections,
6475
+ changeScriptSection,
6476
+ getScriptSuggestions,
6477
+ changeScriptSuggestions,
6478
+ getScriptDocument,
6479
+ writeScriptDocument,
6480
+ editScript,
6481
+ makeShotFromScript,
6482
+ breakScriptIntoShots,
6483
+ splitScene,
6484
+ mergeScene,
6485
+ pasteScript,
5436
6486
  listShots,
5437
6487
  getShot,
5438
6488
  generateFromShots,
6489
+ quoteBoard,
6490
+ getBoardProgress,
6491
+ editCut,
5439
6492
  getPromptingGuide,
5440
6493
  loadTools,
5441
6494
  // ── Blender previs, LAST and deliberately ────────────────────────────
@@ -5482,5 +6535,5 @@ for (const op of ALL_OPERATIONS) {
5482
6535
  }
5483
6536
  }
5484
6537
  }
5485
- export { toolDefinitions, toolDefinition, groupFor, tierFor, OPERATION_GROUPS, GROUP_SUMMARY } from './surface.js';
6538
+ export { toolDefinitions, toolDefinition, groupFor, tierFor, OPERATION_GROUPS, GROUP_SUMMARY, STARTUP_TOOL_IDS } from './surface.js';
5486
6539
  //# sourceMappingURL=index.js.map