@slatesvideo/shared 0.6.1 → 0.6.3

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 (32) hide show
  1. package/dist/clients/blender.d.ts +50 -0
  2. package/dist/clients/blender.js +195 -0
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +26 -0
  5. package/dist/operations/index.d.ts +25 -1
  6. package/dist/operations/index.js +454 -24
  7. package/dist/prompts/agent-doctrine.d.ts +36 -0
  8. package/dist/prompts/agent-doctrine.js +194 -0
  9. package/dist/prompts/banned-tokens.d.ts +28 -0
  10. package/dist/prompts/banned-tokens.js +152 -0
  11. package/dist/prompts/model-capabilities.d.ts +13 -1
  12. package/dist/prompts/model-capabilities.js +97 -2
  13. package/dist/prompts/model-facts.d.ts +20 -0
  14. package/dist/prompts/model-facts.js +87 -23
  15. package/dist/prompts/prompting-tips.d.ts +1 -1
  16. package/dist/prompts/prompting-tips.js +65 -0
  17. package/dist/prompts/reference-composer.d.ts +57 -0
  18. package/dist/prompts/reference-composer.js +70 -1
  19. package/dist/skills/content.js +8 -2
  20. package/exports/slates-prompt-builder/generated/SKILL.md +3 -3
  21. package/exports/slates-prompt-builder/generated/reference-seedance.md +2 -1
  22. package/exports/slates-prompt-builder/generated/slates-prompt-builder-manifest.json +8 -8
  23. package/exports/slates-prompt-builder/generated/slates-prompt-builder.skill +0 -0
  24. package/package.json +1 -1
  25. package/skills/slates-blocking-to-prompt.md +250 -0
  26. package/skills/slates-camera-language.md +196 -0
  27. package/skills/slates-dialogue-blocking.md +134 -0
  28. package/skills/slates-previs-blocking.md +153 -0
  29. package/skills/slates-prompting-ltx-2-5.md +180 -0
  30. package/skills/slates-prompting-nano-banana-2.md +10 -0
  31. package/skills/slates-prompting-seedance.md +10 -1
  32. package/skills/slates-restyle-from-blocking.md +121 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Which agent surface is being briefed.
3
+ *
4
+ * `desktop` — the in-app Studio Agent. Its loop enforces plan approval in
5
+ * code, auto-polls generation status, and displays orchestration cost itself.
6
+ * `mcp` — Claude Code / Claude Desktop / Cursor / Codex over stdio. No
7
+ * `present_plan` tool, no auto-poll, no app chrome; the consent gate is the
8
+ * op-level `requires_confirm` threshold plus the host client's own per-call
9
+ * tool approval. The asymmetry is DELIBERATE — see slates-mcp/CLAUDE.md.
10
+ */
11
+ export type AgentSurface = 'desktop' | 'mcp';
12
+ interface SkillIndexEntry {
13
+ name: string;
14
+ description: string;
15
+ }
16
+ /** Parse `name:`/`description:` out of each embedded skill's frontmatter. */
17
+ export declare function buildSkillIndex(): SkillIndexEntry[];
18
+ export declare const WORKING_METHOD: ReadonlyArray<Record<AgentSurface, string>>;
19
+ export declare const HARD_RULES: ReadonlyArray<Record<AgentSurface, string>>;
20
+ /**
21
+ * THE doctrine string for a surface. The desktop's whole system prompt and the
22
+ * MCP server's `instructions` are both exactly this — neither consumer adds
23
+ * doctrine prose of its own.
24
+ *
25
+ * SENTINEL: the literal below must appear in this workspace in exactly ONE
26
+ * file — this one. scripts/agent-surface-lockstep-check.mjs assembles the same
27
+ * string from its parts rather than writing it out, precisely so that grepping
28
+ * for it stays a meaningful question. It is how "no consumer grew a private
29
+ * copy of the doctrine" is proved rather than assumed.
30
+ * SLATES-AGENT-DOCTRINE-SSOT
31
+ */
32
+ export declare function buildAgentDoctrine({ surface }: {
33
+ surface: AgentSurface;
34
+ }): string;
35
+ export {};
36
+ //# sourceMappingURL=agent-doctrine.d.ts.map
@@ -0,0 +1,194 @@
1
+ // ============================================================
2
+ // AGENT GUIDANCE SSOT — one doctrine, both surfaces.
3
+ //
4
+ // `ALL_OPERATIONS` has been the SSOT for what the agent CAN DO since the ops
5
+ // registry was built, and it held. The guidance layer — the working method,
6
+ // the hard rules, the guide index — never was, and it drifted the moment a
7
+ // second surface existed: a 37K-character system prompt reached ONLY the
8
+ // desktop Studio Agent, while the MCP server shipped no `instructions` at all.
9
+ // A Claude Code user got tool descriptions and nothing else: no working
10
+ // method, no REAL NUMBERS rule, no guide index.
11
+ //
12
+ // This module is that missing SSOT. Both surfaces compose from it:
13
+ // slate/src/main/studio-agent/context.ts buildSystemPrompt()
14
+ // slates-mcp/packages/mcp/src/server.ts `instructions` on the Server
15
+ //
16
+ // 🚨 RULES
17
+ //
18
+ // 1. ONE DOCTRINE STRING, ONE FILE. Any working-method or hard-rule prose
19
+ // living in a consumer after this is a bug. `context.ts` composes; it does
20
+ // not author.
21
+ // 2. SURFACE-AWARE, NOT SURFACE-FORKED. A step or rule forks ONLY where the
22
+ // mechanism genuinely differs (the desktop's `present_plan` gate does not
23
+ // exist on MCP; the desktop's loop auto-polls generation status and MCP's
24
+ // does not). Everything else is one string used by both. Two hand-maintained
25
+ // copies is the drift this file exists to kill — so `both()` is the default
26
+ // and `fork()` needs a reason you could defend in review.
27
+ // 3. PROSE IS EXPLANATION, NOT ENFORCEMENT. The two rules the agent actually
28
+ // breaks — "load the guide" and "quality-check with vision" — are enforced
29
+ // STRUCTURALLY in the op layer (generated banned-token lists inlined into
30
+ // the generate ops' descriptions, non-blocking warnings on the submitted
31
+ // prompt, and a review pointer on every generation result). A rule with no
32
+ // check is a suggestion, and an LLM is the least reliable enforcer you
33
+ // could pick. Do not "fix" a skipped rule by adding a sentence here.
34
+ // 4. NEVER BLOCK. PRODUCT_PHILOSOPHY.md → the Sandbox Doctrine: make state
35
+ // visible, never block. Enforcement means the guidance is already present
36
+ // and violations are reported back — never a refused call or a wizard step.
37
+ // 5. CACHE DISCIPLINE. This output is the desktop's cached prompt prefix and
38
+ // its byte-stability IS the cache mechanism (measured 97.4% steady-state
39
+ // cache hit). No timestamps, no balances, no project names, no per-session
40
+ // anything. Dynamic state reaches the brain through ops, never through here.
41
+ //
42
+ // SSOT DISCIPLINE: this module no longer renders model routing AT ALL. Routing
43
+ // rides `describeRouting()` on the four ops that choose a model, because that is
44
+ // where the choice is made; all the doctrine says is that the three KINDS are
45
+ // disjoint, which is the one part no single op can say. The guide index is
46
+ // DERIVED from SKILLS. Never hand-type either.
47
+ //
48
+ // Not exported from ./prompts on purpose: that subpath is bundled by the
49
+ // desktop RENDERER and must stay small and Node-free, and this module pulls in
50
+ // the whole embedded SKILLS record. Root barrel only.
51
+ // ============================================================
52
+ import { SKILLS } from '../skills/content.js';
53
+ import { VIDEO_MODELS, AUDIO_MODELS } from '../operations/index.js';
54
+ /** A line that is identical on both surfaces. The default. */
55
+ function both(text) {
56
+ return { desktop: text, mcp: text };
57
+ }
58
+ /** A line whose MECHANISM differs between surfaces. Needs a reason in-comment. */
59
+ function fork(desktop, mcp) {
60
+ return { desktop, mcp };
61
+ }
62
+ /** Parse `name:`/`description:` out of each embedded skill's frontmatter. */
63
+ export function buildSkillIndex() {
64
+ const entries = [];
65
+ for (const key of Object.keys(SKILLS).sort()) {
66
+ const content = SKILLS[key];
67
+ const fm = /^---\n([\s\S]*?)\n---/.exec(content);
68
+ let description = '';
69
+ if (fm) {
70
+ const m = /^description:\s*(.+)$/m.exec(fm[1]);
71
+ if (m)
72
+ description = m[1].trim().replace(/^['"]|['"]$/g, '');
73
+ }
74
+ entries.push({ name: key, description });
75
+ }
76
+ return entries;
77
+ }
78
+ // ── Preamble ───────────────────────────────────────────────────────
79
+ const PREAMBLE = fork(`You are the Slates Studio Agent — a production assistant living inside Slates, the AI video creation studio. You plan and execute video/image production runs by chaining the Slates tools: script → characters → images → videos → quality-check → regenerate, ending with assets in the user's project (and on the timeline when asked).`, `You are connected to Slates, the AI video creation studio, through its MCP tool surface. These tools plan and execute real video/image production runs that spend the user's Slates credits: script → characters → images → videos → quality-check → regenerate, ending with assets in the user's project. Follow the working method and hard rules below on every Slates task — this is the same doctrine the in-app Studio Agent runs on.`);
80
+ // ── The working method ─────────────────────────────────────────────
81
+ export const WORKING_METHOD = [
82
+ both(`1. UNDERSTAND the outcome the user wants. If intent is clear, act with sane defaults — don't interrogate. If genuinely ambiguous, batch every question into ONE message.`),
83
+ both(`2. ORIENT: call slates_get_workspace_state once at the start of a workflow. Work in the user's CURRENT project — this chat lives inside it. NEVER create a new project unless explicitly asked; if there's no current project, ask which to use.`),
84
+ both(`3. LOAD KNOWLEDGE ON DEMAND: before prompting any model or running a multi-step workflow, load the matching guide with slates_get_prompting_guide (index below). Only the guides the task needs, when it needs them.`),
85
+ // FORKED: `present_plan` is a loop-level DESKTOP tool, deliberately not in
86
+ // ALL_OPERATIONS, so MCP never sees it and has no plan gate at all. Its
87
+ // substitute is the per-op `requires_confirm` threshold plus the host
88
+ // client's own per-call approval UI. Describing the desktop gate to an MCP
89
+ // client would name a tool that does not exist.
90
+ fork(`4. PLAN + GET APPROVAL: before ANY generation, call present_plan with itemized credit costs (slates_estimate_generation_cost per step). One approval covers the plan's listed steps ONLY. Generation tools are rejected without an approved plan — and any user revision, question, or new instruction after an approval means you MUST re-present the plan BEFORE the next generation call (calling a generation op first just gets BLOCKED and wastes a turn).`, `4. PLAN + GET APPROVAL: before ANY generation, price every step with slates_estimate_generation_cost and put the itemized total in front of the user in ONE message, then wait for their answer. There is no present_plan tool on this surface — consent is per call: a generation over the confirm threshold returns requires_confirm, and confirm: true is only ever a relay of an explicit user OK for that exact spend. Never pass confirm: true to clear a gate the user has not seen.`),
91
+ fork(`5. EXECUTE: after approval, pass confirm: true (the approval IS the consent — never re-ask per step). Use background: true + status polling for video.`, `5. EXECUTE: run the approved steps, passing confirm: true only for the spend the user actually OK'd. Use background: true + status polling for video.`),
92
+ both(`6. QUALITY-CHECK: you have vision. After key generations, fetch the result (slates_get_asset_image / slates_get_asset_video_frames) and review it against the brief (slates-vision-feedback-loop). Fix real problems; don't churn credits polishing what works. Change ONE variable per regeneration.`),
93
+ both(`7. REPORT: when done, summarize what was made and where it landed. Concise and concrete.`),
94
+ ];
95
+ // ── Hard rules ─────────────────────────────────────────────────────
96
+ //
97
+ // Each entry is a COMPLETE line including its leading "- ". MODEL ROUTING is
98
+ // the exception: it is multi-line and generated, and supplies its own bullet.
99
+ export const HARD_RULES = [
100
+ // FORKED: "outside an approved plan" names the desktop's code-level gate.
101
+ fork(`- COST DISCIPLINE (slates-cost-discipline): never fire a billable generation outside an approved plan. Estimate before you promise. Batch related generations into one plan.`, `- COST DISCIPLINE (slates-cost-discipline): never fire a billable generation the user has not agreed to. Estimate before you promise. Batch related generations into one quote so the user approves a total, not a drip.`),
102
+ both(`- CONTENT POLICY: before writing prompts involving real people/celebrities, minors, brands/logos, weapons, or gore, load slates-content-policy and build the scene safe from the first word. If a provider rejects (e.g. real-face detection), explain in plain language — refunds for provider rejections are automatic.`),
103
+ both(`- PROMPT IS LAW (reference doctrine): references are cited inline by name and image number ("Marcus (images 1 and 2)"); the prompt text leads. Never write role-essays about what each reference is "for".`),
104
+ both(`- RESOLUTION DEFAULT IS UNIFORM: 1080p on the best available video model. Do not crank resolution the user didn't ask for.`),
105
+ // ⛔ THE MODEL ROUTING BLOCK IS GONE FROM HERE, DELIBERATELY (2026-08-30).
106
+ //
107
+ // It was 17,700 characters — 47% of the whole doctrine — and every lane of it
108
+ // now rides the op that actually makes the decision: describeRouting('image')
109
+ // in slates_generate_image, ('video','generate') in slates_generate_video's
110
+ // model param, ('video','edit') in slates_edit_video, ('audio') in
111
+ // slates_generate_audio. An op description is always in context on both
112
+ // surfaces, so nothing was lost in reach — it moved next to the choice.
113
+ //
114
+ // The measured argument for doing this: the never-use token list went from 0%
115
+ // to 94% compliance when it moved from prose into an op description, while
116
+ // the same guidance in prose moved nothing. Placement beats presence.
117
+ // The old `buildModelRouting()` renderer was DELETED rather than left
118
+ // exported "in case": an export nothing calls is an export nothing keeps
119
+ // honest. `describeRouting()` in model-facts.ts is the renderer now.
120
+ both(`- MODEL KINDS: image, video and audio models are disjoint — no image model makes a video, no video model makes a standalone image, no image or video model makes audio. Which SEAT to pick inside a kind is on each generate op's own \`model\` description, and the full table is the slates-model-selection skill.`),
121
+ both(`- ASSET CODES + STALENESS: every asset carries a badge code (IMG-A12 / VID-V3 / AUD-S1, top-left of its gallery card); speak about assets by code + label. Asset lists go STALE — the user creates assets in the Slates UI mid-conversation. When the user names a code you have NOT seen in a tool result this session, resolve it with slates_list_assets (use the search filter) BEFORE using it. NEVER guess an asset id or reuse a nearby UUID — pass the exact id a tool returned for that exact code. A wrong start frame burns real credits.`),
122
+ both(`- CREDITS ONLY: every generation you drive bills Slates credits (your tool calls enforce this). Never suggest BYOK keys for agent work.`),
123
+ both(`- Do not invent tools, asset ids, or credit prices. If a tool errors, read the error and fix that exact issue; don't repeat the same call unchanged and never switch models to route around a parameter mistake.`),
124
+ // FORKED: only the desktop loop auto-polls generation status
125
+ // (loop.ts → autoPollUntilTerminal). Telling an MCP client "the app keeps
126
+ // polling for you" would strand a job nobody is watching.
127
+ fork(`- TOKEN DISCIPLINE: every tool result you read costs the user money. generate_* results already return the new asset ids/codes — NEVER call slates_list_assets to find something you just created. When you do list, pass search/type/limit filters. Poll generation status ONCE with waitSeconds: 45 — the app keeps auto-polling for you and returns the terminal status; do not narrate between polls or call status in a loop. Use slates_estimate_generation_cost for known models instead of dumping the registry; if you need the registry, pass a filter.`, `- TOKEN DISCIPLINE: every tool result you read costs the user money. generate_* results already return the new asset ids/codes — NEVER call slates_list_assets to find something you just created. When you do list, pass search/type/limit filters. Poll generation status with waitSeconds: 45 — one long poll per check, no tight loops and no narration between polls. Use slates_estimate_generation_cost for known models instead of dumping the registry; if you need the registry, pass a filter.`),
128
+ // FORKED: the desktop app renders orchestration cost itself; there is no such
129
+ // display on MCP, so that clause would point at nothing. The MCP variant
130
+ // spends the saved words on the claim an MCP client is most likely to
131
+ // fabricate: what the generation LOOKS like.
132
+ fork(`- REAL NUMBERS ONLY — NEVER approximate, estimate, or recall any figure. Every cost, balance, count, or duration you state must be copied verbatim from a tool result IN THIS SESSION: generation costs come from the cost_credits field in generate/status results (whole credits); the balance comes from a fresh slates_get_credit_balance call at summary time (never arithmetic you did yourself); orchestration cost is displayed by the app automatically — NEVER state or estimate it. No "approx", no "~", no rounded guesses. A number you cannot point to in a tool result is a number you do not say.`, `- REAL NUMBERS ONLY — NEVER approximate, estimate, or recall any figure. Every cost, balance, count, or duration you state must be copied verbatim from a tool result IN THIS SESSION: generation costs come from the cost_credits field in generate/status results (whole credits); the balance comes from a fresh slates_get_credit_balance call at summary time (never arithmetic you did yourself). No "approx", no "~", no rounded guesses. A number you cannot point to in a tool result is a number you do not say. The same rule covers what you SAW: never describe how a generation looks unless you fetched it this session with slates_get_asset_image / slates_get_asset_video_frames.`),
133
+ both(`- MODEL IDS ARE FIXED: slates_generate_video takes exactly ${VIDEO_MODELS.join(' | ')}, with duration + videoResolution as separate params. slates_generate_audio takes exactly ${AUDIO_MODELS.join(' | ')}. Registry entries like "kling-v3-standard-8s" or "seed-audio-15s" are billing keys, not model ids.`),
134
+ both(`- AUDIO LENGTH IS PROMPT-DRIVEN ON SEED AUDIO: it has no duration parameter, so the durationSeconds you pass is written into the prompt AND is what the user is charged, whatever comes back. Choose it deliberately and load slates-prompting-seed-audio before the first call.`),
135
+ ];
136
+ // ── Surface-only footers ───────────────────────────────────────────
137
+ //
138
+ // MCP clients have no Slates chrome and may have no skill FILES installed
139
+ // (`slates install-skills` covers Claude Code; Claude Desktop, Cursor and
140
+ // Codex have no equivalent mechanism). The desktop always has the embedded
141
+ // record, so this paragraph would be dead weight in its cached prefix.
142
+ const MCP_FOOTER = `## Working without skill files
143
+
144
+ If slates-* skill files are not installed in this client, every one of them is still reachable as data: call slates_get_prompting_guide with the skill name (or a model id — it resolves aliases). The guide index above is the complete list. "No skill installed" is never a reason to prompt a model blind.
145
+ `;
146
+ /**
147
+ * THE doctrine string for a surface. The desktop's whole system prompt and the
148
+ * MCP server's `instructions` are both exactly this — neither consumer adds
149
+ * doctrine prose of its own.
150
+ *
151
+ * SENTINEL: the literal below must appear in this workspace in exactly ONE
152
+ * file — this one. scripts/agent-surface-lockstep-check.mjs assembles the same
153
+ * string from its parts rather than writing it out, precisely so that grepping
154
+ * for it stays a meaningful question. It is how "no consumer grew a private
155
+ * copy of the doctrine" is proved rather than assumed.
156
+ * SLATES-AGENT-DOCTRINE-SSOT
157
+ */
158
+ export function buildAgentDoctrine({ surface }) {
159
+ // COMPRESSED on purpose (2026-08-30). The full frontmatter descriptions were
160
+ // 14,556 characters — 38% of the doctrine — and they are DISCOVERY blurbs,
161
+ // written in Claude Code's "Use when X, or when Y" style for a fuzzy matcher
162
+ // choosing among skills. Here the mapping is close to deterministic:
163
+ // `resolveGuideTopic()` turns a model id into the right guide in code.
164
+ //
165
+ // So the per-model guides are listed as bare names (the name IS the
166
+ // description) and everything else keeps its FIRST SENTENCE, which is the
167
+ // part that says when to reach for it. The complete list with full
168
+ // descriptions is one `slates_get_prompting_guide` call away.
169
+ const entries = buildSkillIndex();
170
+ const firstSentence = (d) => {
171
+ const m = /^(.*?[.!?])(\s|$)/.exec(d.trim());
172
+ return (m ? m[1] : d.trim()).slice(0, 180);
173
+ };
174
+ const perModel = entries.filter((s) => s.name.startsWith('slates-prompting-'));
175
+ const rest = entries.filter((s) => !s.name.startsWith('slates-prompting-'));
176
+ const skillIndex = rest.map((s) => `- ${s.name}: ${firstSentence(s.description)}`).join('\n') +
177
+ `\n- PER-MODEL PROMPTING GUIDES — pass the model id, or the name: ` +
178
+ perModel.map((s) => s.name).join(', ');
179
+ return `${PREAMBLE[surface]}
180
+
181
+ ## How you work
182
+
183
+ ${WORKING_METHOD.map((s) => s[surface]).join('\n')}
184
+
185
+ ## Hard rules
186
+
187
+ ${HARD_RULES.map((r) => r[surface]).join('\n')}
188
+
189
+ ## Guide index (load via slates_get_prompting_guide)
190
+
191
+ ${skillIndex}
192
+ ${surface === 'mcp' ? `\n${MCP_FOOTER}` : ''}`;
193
+ }
194
+ //# sourceMappingURL=agent-doctrine.js.map
@@ -0,0 +1,28 @@
1
+ /** Which generate op a list applies to. */
2
+ export type BannedTokenScope = 'image' | 'video';
3
+ export interface BannedToken {
4
+ /** The literal phrase, verbatim from the skill. */
5
+ token: string;
6
+ /** Skill file it was read from — cited in every warning. */
7
+ skill: string;
8
+ scope: BannedTokenScope;
9
+ }
10
+ /** Every banned token, in skill-document order. Integrity asserted at load. */
11
+ export declare const BANNED_PROMPT_TOKENS: readonly BannedToken[];
12
+ export declare function bannedTokensFor(scope: BannedTokenScope): readonly BannedToken[];
13
+ /** The tokens a submitted prompt actually contains. */
14
+ export declare function findBannedTokens(prompt: string, scope: BannedTokenScope): BannedToken[];
15
+ /**
16
+ * The list as it appears INSIDE an op description — always in context, on both
17
+ * surfaces, with no call required to see it. Generated, never hand-typed.
18
+ */
19
+ export declare function describeBannedTokens(scope: BannedTokenScope): string;
20
+ /**
21
+ * Non-blocking warning for a submitted prompt. Empty string when clean.
22
+ *
23
+ * Returned in the op RESULT — the one place the agent cannot avoid reading —
24
+ * rather than raised as an error. The generation proceeds either way: the
25
+ * sandbox doctrine says make state visible, never block.
26
+ */
27
+ export declare function bannedTokenWarning(prompt: string, scope: BannedTokenScope): string;
28
+ //# sourceMappingURL=banned-tokens.d.ts.map
@@ -0,0 +1,152 @@
1
+ // ============================================================
2
+ // BANNED PROMPT TOKENS — the "load the guide" rule, made structural.
3
+ //
4
+ // THE PROBLEM: "before prompting any model, load the matching guide" is a
5
+ // sentence in a system prompt with nothing checking it. Measured 2026-08-30 in
6
+ // a real Studio Agent session: slates_get_prompting_guide was called ZERO
7
+ // times, and the prompt that shipped tripped the skill's own never-use list
8
+ // twice (`photorealistic`, `cinematic`). A rule with no check is a suggestion,
9
+ // and an LLM is the least reliable enforcer you could pick.
10
+ //
11
+ // THE FIX, in two halves, neither of which the model can skip:
12
+ // (a) the never-use list is INLINED into the generate ops' descriptions.
13
+ // Op descriptions are always in context on BOTH surfaces — there is no
14
+ // call to omit and no discretion to exercise.
15
+ // (b) the submitted prompt is MATCHED against the list and a warning comes
16
+ // back in the op result. Non-blocking: the generation proceeds
17
+ // (PRODUCT_PHILOSOPHY.md → make state visible, never block).
18
+ //
19
+ // 🚨 THE LIST IS NEVER HAND-TYPED HERE. It is extracted from the skill files
20
+ // themselves, between `<!-- @banned:start -->` / `<!-- @banned:end -->`
21
+ // markers. That is this workspace's LLM-docs doctrine — never hand-type a fact
22
+ // an LLM will read — and it is the only way the op description and the skill
23
+ // cannot drift apart. Change the skill; the description follows on the next
24
+ // build. scripts/agent-surface-lockstep-check.mjs fails the build if a token
25
+ // in a description no longer appears in its source skill.
26
+ //
27
+ // Deterministic by construction (document order, no sorting, no dedupe
28
+ // reshuffle) because these strings land in the desktop's prompt-cached tool
29
+ // prefix, whose byte-stability IS the cache mechanism.
30
+ // ============================================================
31
+ import { SKILLS } from '../skills/content.js';
32
+ /**
33
+ * Where each scope's list lives.
34
+ *
35
+ * One skill per scope on purpose: these are the two lists that are GENERIC to
36
+ * their modality (Stable-Diffusion-era tag soup for images, quality
37
+ * incantations for video), not model-specific quirks. A per-model list would
38
+ * mean the op description changed with the `model` argument, which it cannot —
39
+ * a description is one static string for every call.
40
+ */
41
+ const BANNED_TOKEN_SOURCES = [
42
+ { skill: 'slates-prompting-nano-banana-2', scope: 'image' },
43
+ { skill: 'slates-prompting-seedance', scope: 'video' },
44
+ ];
45
+ const FENCE_RE = /<!--\s*@banned:start\s*-->([\s\S]*?)<!--\s*@banned:end\s*-->/g;
46
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
47
+ const BACKTICKED_RE = /`([^`\n]+)`/g;
48
+ /**
49
+ * Pull every backticked phrase out of a skill's fenced block(s).
50
+ *
51
+ * HTML comments are stripped FIRST: the fence carries a "MACHINE-READ" note to
52
+ * whoever edits the skill next, and that note itself contains backticks.
53
+ */
54
+ function extractFromSkill(skill) {
55
+ const content = SKILLS[skill];
56
+ if (content === undefined) {
57
+ throw new Error(`[banned-tokens] no such skill: ${skill}. BANNED_TOKEN_SOURCES must name files in packages/shared/skills/.`);
58
+ }
59
+ const out = [];
60
+ let fence;
61
+ FENCE_RE.lastIndex = 0;
62
+ let fences = 0;
63
+ while ((fence = FENCE_RE.exec(content)) !== null) {
64
+ fences += 1;
65
+ const body = fence[1].replace(HTML_COMMENT_RE, '');
66
+ let m;
67
+ BACKTICKED_RE.lastIndex = 0;
68
+ while ((m = BACKTICKED_RE.exec(body)) !== null) {
69
+ const token = m[1].trim();
70
+ if (token && !out.includes(token))
71
+ out.push(token);
72
+ }
73
+ }
74
+ if (fences === 0) {
75
+ throw new Error(`[banned-tokens] ${skill}.md has no <!-- @banned:start --> / <!-- @banned:end --> block. ` +
76
+ `The op description and the prompt warnings are GENERATED from it — restore the markers, ` +
77
+ `or drop the skill from BANNED_TOKEN_SOURCES.`);
78
+ }
79
+ if (out.length === 0) {
80
+ throw new Error(`[banned-tokens] ${skill}.md has a @banned block with no backticked tokens in it. ` +
81
+ `An empty list would silently disable the check instead of failing it.`);
82
+ }
83
+ return out;
84
+ }
85
+ /** Every banned token, in skill-document order. Integrity asserted at load. */
86
+ export const BANNED_PROMPT_TOKENS = BANNED_TOKEN_SOURCES.flatMap(({ skill, scope }) => extractFromSkill(skill).map((token) => ({ token, skill, scope })));
87
+ const byScope = new Map();
88
+ for (const entry of BANNED_PROMPT_TOKENS) {
89
+ const list = byScope.get(entry.scope) ?? [];
90
+ list.push(entry);
91
+ byScope.set(entry.scope, list);
92
+ }
93
+ export function bannedTokensFor(scope) {
94
+ return byScope.get(scope) ?? [];
95
+ }
96
+ /** Regex cache — one word-boundary matcher per token, built once. */
97
+ const matchers = new Map();
98
+ function matcherFor(token) {
99
+ let re = matchers.get(token);
100
+ if (!re) {
101
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
102
+ // \b at both ends so `4k` does not fire inside "84king" and `flawless`
103
+ // does not fire inside "flawlessly". Every token starts and ends with a
104
+ // word character today; if one ever starts with punctuation, \b would
105
+ // anchor wrong — the load-time check below is what would catch it.
106
+ re = new RegExp(`\\b${escaped}\\b`, 'i');
107
+ matchers.set(token, re);
108
+ }
109
+ return re;
110
+ }
111
+ for (const { token, skill } of BANNED_PROMPT_TOKENS) {
112
+ if (!/^\w/.test(token) || !/\w$/.test(token)) {
113
+ throw new Error(`[banned-tokens] ${skill}: token ${JSON.stringify(token)} does not start and end with a word ` +
114
+ `character, so the \\b word-boundary match would never fire. Rewrite the entry or teach ` +
115
+ `matcherFor() the new shape.`);
116
+ }
117
+ }
118
+ /** The tokens a submitted prompt actually contains. */
119
+ export function findBannedTokens(prompt, scope) {
120
+ return bannedTokensFor(scope).filter((b) => matcherFor(b.token).test(prompt));
121
+ }
122
+ /**
123
+ * The list as it appears INSIDE an op description — always in context, on both
124
+ * surfaces, with no call required to see it. Generated, never hand-typed.
125
+ */
126
+ export function describeBannedTokens(scope) {
127
+ const list = bannedTokensFor(scope);
128
+ if (list.length === 0)
129
+ return '';
130
+ const skills = [...new Set(list.map((b) => b.skill))].join(' / ');
131
+ return (`NEVER put these in a prompt (they measurably degrade output — full rationale in ${skills}): ` +
132
+ list.map((b) => `"${b.token}"`).join(', ') +
133
+ `. Describe specifically instead.`);
134
+ }
135
+ /**
136
+ * Non-blocking warning for a submitted prompt. Empty string when clean.
137
+ *
138
+ * Returned in the op RESULT — the one place the agent cannot avoid reading —
139
+ * rather than raised as an error. The generation proceeds either way: the
140
+ * sandbox doctrine says make state visible, never block.
141
+ */
142
+ export function bannedTokenWarning(prompt, scope) {
143
+ const hits = findBannedTokens(prompt, scope);
144
+ if (hits.length === 0)
145
+ return '';
146
+ const skills = [...new Set(hits.map((b) => b.skill))].join(', ');
147
+ return (`⚠️ PROMPT WARNING: your prompt contains ${hits.map((b) => `"${b.token}"`).join(', ')} — ` +
148
+ `on the never-use list in ${skills}. Not blocked, and this generation ran as submitted. ` +
149
+ `Load that guide with slates_get_prompting_guide and rewrite with specific description ` +
150
+ `(named lens, light direction, stock, composition) before the next generation.`);
151
+ }
152
+ //# sourceMappingURL=banned-tokens.js.map
@@ -6,8 +6,20 @@ export type AspectRatio = '1:1' | '2:3' | '3:2' | '3:4' | '4:3' | '4:5' | '5:4'
6
6
  * and prices between 480p and 2K ($0.060/s vs 720p Seedance's $0.15/s — a
7
7
  * different tier of a different model, not a rename); 2K is H3's upscaled tier.
8
8
  * Aliasing either onto 720p/1080p would build a cost key that does not exist.
9
+ *
10
+ * 🚨 `1440p` entered with LTX-2.5 (2026-08-29) and is likewise NOT an alias —
11
+ * specifically it is NOT `2k`, despite both being ~1440 lines tall. `2k` is
12
+ * H3's UPSCALED tier ($0.130/s, an H3-Regenerate-2K pass over a 768p base);
13
+ * `1440p` is LTX's NATIVELY GENERATED tier ($0.190/s). Different models,
14
+ * different mechanisms, different prices, and `ltx-2-5-2k-6s` is a key that
15
+ * exists nowhere. Co-height is not sameness.
16
+ *
17
+ * ⚠️ Our `4k` is LTX's `2160p` ON THE WIRE. fal's enum literal for that tier is
18
+ * `2160p` while its own pricing copy calls it "4K". `4k` stays the token here
19
+ * because it is what every cost key, `is4kVideoKey` and the Pro gate already
20
+ * speak; the handler translates at the request boundary.
9
21
  */
10
- export type VideoResolution = '480p' | '720p' | '768p' | '1080p' | '2k' | '4k';
22
+ export type VideoResolution = '480p' | '720p' | '768p' | '1080p' | '1440p' | '2k' | '4k';
11
23
  /**
12
24
  * The full ten, in display order. `9:21` was in the MCP op's enum and in NO
13
25
  * model — it was invented downstream. Do not add a ratio here that no model
@@ -88,6 +88,18 @@ const SEEDANCE_ASPECT_RATIOS = ['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'];
88
88
  * is not an AspectRatio in this vocabulary.
89
89
  */
90
90
  const MINIMAX_H3_ASPECT_RATIOS = ['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'];
91
+ /**
92
+ * LTX-2.5, all four integrated endpoints: TWO. Read off fal's live OpenAPI
93
+ * 2026-08-29 — `text-to-video/{fast,pro}` declare exactly `['16:9','9:16']`,
94
+ * the narrowest video set in the roster alongside Veo-on-fal.
95
+ *
96
+ * `image-to-video/{fast,pro}` additionally offer `auto` (follow the start
97
+ * frame). We never send it and it is not an `AspectRatio` in this vocabulary —
98
+ * identical treatment to H3's `adaptive`, and for the identical reason: the
99
+ * composer always holds an explicit ratio, so `auto` would only ever be a way
100
+ * to lose track of what was actually generated.
101
+ */
102
+ const LTX_2_5_ASPECT_RATIOS = ['16:9', '9:16'];
91
103
  /**
92
104
  * The provider every AGENT generation actually lands on for Kling and Veo.
93
105
  *
@@ -371,6 +383,87 @@ export const MODEL_CAPABILITIES = {
371
383
  // above what the handler sends is a SILENT DROP — the exact failure
372
384
  // `seedance-2.5-edit` shipped with. Absent means the composer refuses.
373
385
  },
386
+ // ── LTX-2.5 (both seats on fal — added 2026-08-29) ─────────────────────────
387
+ //
388
+ // Every value below is READ OFF fal's live OpenAPI, fetched 2026-08-29:
389
+ // lightricks/ltx-2.5/{text-to-video,image-to-video}/{fast,pro}
390
+ // Note the owner namespace — no `fal-ai/` prefix, exactly like `minimax/h3/`.
391
+ //
392
+ // 🚨 SAME PREFIX COLLISION AS THE MINIMAX PAIR, AND IT IS WORSE HERE.
393
+ // `ltx-2-5-pro` starts with `ltx-2-5`, so ANY `startsWith('ltx-2-5')` swallows
394
+ // the Pro row into the Fast row's branch — a shorter ladder, a shorter
395
+ // duration list AND a 31-33% higher price at both tiers they share. Every
396
+ // lookup downstream is an exact-id map, never a prefix test.
397
+ //
398
+ // 🚨 THE CHEAP ROW IS THE ONE WITH THE LONGER REACH. Counter to how every
399
+ // other Fast/Pro pair in this file behaves, `ltx-2-5` (the distilled 8-step
400
+ // build) reaches 1440p, 4K and 20s while `ltx-2-5-pro` (full diffusion) stops
401
+ // at 1080p and 10s. Pro buys fidelity on a NARROWER ladder. Do not "fix" this
402
+ // by assuming Pro is a superset — it is not, on either axis.
403
+ //
404
+ // ⛔ `audio-to-video/{fast,pro}` EXIST AND ARE DELIBERATELY ABSENT from this
405
+ // file. They carry no `resolution` and no `duration` parameter at all: output
406
+ // length is dictated by the uploaded audio, so they bill per second of INPUT
407
+ // while every credit key we own bills OUTPUT. That is a billing-SHAPE change,
408
+ // not a missing row. See slates-api/PRICING.md.
409
+ 'ltx-2-5': {
410
+ aspectRatios: LTX_2_5_ASPECT_RATIOS,
411
+ // Full ladder, all four tiers NATIVELY generated (no upscale pass anywhere
412
+ // — the contrast with H3's 2K/4K is the whole reason `1440p` is its own
413
+ // token). DEFAULT 1080p, which is also fal's own schema default: unusually
414
+ // for this file the default tier is not the floor. It is the cheapest
415
+ // native 1080p second in the roster at $0.130/s.
416
+ videoResolution: { options: ['720p', '1080p', '1440p', '4k'], default: '1080p' },
417
+ // 🚨 DISCRETE AND EVEN-ONLY. There is NO 5s LTX clip and no odd duration of
418
+ // any length — fal's enum is literally [6,8,10,12,14,16,18,20]. A
419
+ // `{ min: 6, max: 20, mode: 'continuous' }` here would offer 7s in the
420
+ // composer, quote a `ltx-2-5-1080p-7s` key that exists on no server, and
421
+ // fail at the proxy after the user had already chosen it.
422
+ //
423
+ // The evenness is also what makes all 28 cost keys round exactly (every
424
+ // basis is a multiple of CENTS_PER_CREDIT=3) — see PRICING.md. If fal ever
425
+ // admits odd seconds, the rounding proof must be re-verified.
426
+ duration: {
427
+ min: 6,
428
+ max: 20,
429
+ mode: 'discrete',
430
+ values: [6, 8, 10, 12, 14, 16, 18, 20],
431
+ // fal: "At 720p and 1080p, 24 or 25 FPS supports up to 20 seconds […] At
432
+ // 1440p and 2160p, all frame rates support up to 10 seconds."
433
+ //
434
+ // ⚠️ THE REAL CEILING IS A FUNCTION OF RESOLUTION *AND* FPS, and this
435
+ // field can only express the resolution half. It is correct ONLY because
436
+ // we pin fps to fal's default of 25 and never expose the control. If fps
437
+ // is ever exposed, 48/50 drops the 720p/1080p ceiling to 10s too, and
438
+ // that needs a second override axis — not a wider window here.
439
+ resolutionOverrides: {
440
+ '1440p': { min: 6, max: 10, mode: 'discrete', values: [6, 8, 10] },
441
+ '4k': { min: 6, max: 10, mode: 'discrete', values: [6, 8, 10] },
442
+ },
443
+ },
444
+ // NO reference caps of any kind, deliberately. fal publishes text-to-video
445
+ // and image-to-video for LTX and NOTHING else — there is no
446
+ // `reference-to-video` endpoint, so there is no transport for an ingredient
447
+ // or a multimodal reference, and a cap declared above what the handler
448
+ // sends is a SILENT DROP (the failure `seedance-2.5-edit` shipped with).
449
+ // The start/end frames i2v does carry are FRAME SLOTS, not references, and
450
+ // live in MODEL_REGISTRY.features.lastFrame — not here.
451
+ },
452
+ 'ltx-2-5-pro': {
453
+ aspectRatios: LTX_2_5_ASPECT_RATIOS,
454
+ // 720p/1080p ONLY. Declaring the shorter ladder here IS the whole Pro-seat
455
+ // mechanism: `assertVideoCapabilities` refuses 1440p/4K on this id, the
456
+ // desktop picker renders only what this entry declares, and the agent's Zod
457
+ // enum stays the union while the per-model guard narrows. Anything shaped
458
+ // like "hide the top tiers when Pro is selected" re-implements a guard that
459
+ // already exists.
460
+ videoResolution: { options: ['720p', '1080p'], default: '1080p' },
461
+ // Three values, full stop — fal's enum is [6,8,10] on both Pro endpoints,
462
+ // with no resolution override needed because the ceiling is already 10s at
463
+ // both tiers this row reaches.
464
+ duration: { min: 6, max: 10, mode: 'discrete', values: [6, 8, 10] },
465
+ // No reference caps — same reasoning as the Fast row above.
466
+ },
374
467
  // ── Audio ──────────────────────────────────────────────────────────────────
375
468
  //
376
469
  // `aspectRatios: []` is deliberate, not an oversight: audio has no frame, and
@@ -461,8 +554,10 @@ export function aspectRatioUnion(models, provider) {
461
554
  /** Union of every resolution the given models accept. */
462
555
  export function videoResolutionUnion(models) {
463
556
  // Ascending by output height, so an enum reads as a ladder. 768p sits between
464
- // 720p and 1080p; 2k (≈2560×1440) between 1080p and 4k.
465
- const order = ['480p', '720p', '768p', '1080p', '2k', '4k'];
557
+ // 720p and 1080p; 1440p and 2k (≈2560×1440) are CO-HEIGHT and sit together
558
+ // between 1080p and 4k — their relative order here is cosmetic, because no
559
+ // model declares both (1440p is LTX-only, 2k is H3-only).
560
+ const order = ['480p', '720p', '768p', '1080p', '1440p', '2k', '4k'];
466
561
  const seen = new Set();
467
562
  for (const m of models)
468
563
  for (const r of videoResolutionsFor(m))
@@ -2,6 +2,14 @@ export interface ModelFact {
2
2
  id: string;
3
3
  label: string;
4
4
  kind: 'image' | 'video' | 'audio';
5
+ /**
6
+ * Which op this seat is reachable through. `edit` rows live on
7
+ * slates_edit_video and must never appear in slates_generate_video's routing
8
+ * list — they were mixed into one VIDEO block with nothing marking them.
9
+ * Explicit rather than an id-suffix test: a structural test on "-edit" holds
10
+ * only while that substring is unique, which is how isOmniFlashModel broke.
11
+ */
12
+ route: 'generate' | 'edit';
5
13
  /** Max reference images (image models) — null if not applicable. */
6
14
  maxRefImages: number | null;
7
15
  /** Max ingredient images (video models) — null if not applicable. */
@@ -59,6 +67,18 @@ export declare function seedanceTaskIntentWords(prompt: string): string[];
59
67
  /** Every model that reads reference video and/or audio, for op descriptions. */
60
68
  export declare function multimodalRefModels(): string[];
61
69
  export declare const MODEL_FACTS: ModelFact[];
70
+ /**
71
+ * Routing prose for one lane, generated from the SSOT.
72
+ *
73
+ * THE ONE RENDERER. The Studio Agent's system prompt, the MCP server's
74
+ * instructions and the generate/edit ops' `model` descriptions all call this —
75
+ * so "never restate model routing in an op description" (slates-mcp/CLAUDE.md)
76
+ * is now enforced by there being nothing to restate. Before this, the video op
77
+ * carried 1,282 characters of hand-written routing that repeated MODEL_FACTS
78
+ * phrase for phrase ("SECOND SEAT", "AUTHORED-AUDIO", "never the default"),
79
+ * in the same file that forbids exactly that.
80
+ */
81
+ export declare function describeRouting(kind: ModelFact['kind'], route?: ModelFact['route']): string;
62
82
  export declare function getModelFact(id: string): ModelFact | undefined;
63
83
  /** The official NB2 / general image prompt formula (subject-first). */
64
84
  export declare const IMAGE_PROMPT_FORMULA = "[Subject] + [Action] + [Location/context] + [Composition] + [Style]";