@nodaro/prompts 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.cjs +389 -194
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +244 -29
  4. package/dist/index.d.ts +244 -29
  5. package/dist/index.js +377 -196
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/assemble-image-input-cap.test.ts +37 -13
  9. package/src/__tests__/assemble-image-input.test.ts +100 -19
  10. package/src/__tests__/assemble-video-input-cap.test.ts +101 -15
  11. package/src/__tests__/assemble-video-input.test.ts +167 -33
  12. package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
  13. package/src/__tests__/multi-picker-spec.test.ts +21 -1
  14. package/src/__tests__/person-regional-aesthetic.test.ts +2 -1
  15. package/src/__tests__/prompt-style-section.test.ts +345 -0
  16. package/src/__tests__/provider-prompt-doctrine.test.ts +39 -0
  17. package/src/__tests__/style-section-boundary.test.ts +179 -0
  18. package/src/__tests__/subject-fold.test.ts +32 -13
  19. package/src/assemble-image-input.ts +51 -26
  20. package/src/assemble-video-input.ts +54 -34
  21. package/src/direction-registry.ts +108 -37
  22. package/src/gemini-omni-inputs.ts +11 -3
  23. package/src/held-prop.ts +1 -0
  24. package/src/hint-shedding.ts +23 -4
  25. package/src/index.ts +1 -0
  26. package/src/person.ts +4 -0
  27. package/src/picker-analyzer-registry.ts +37 -0
  28. package/src/prompt-builder.ts +84 -27
  29. package/src/prompt-hint-join.ts +9 -0
  30. package/src/prompt-style-section.ts +256 -0
  31. package/src/prompt-wizard-categories.ts +6 -0
  32. package/src/provider-prompt-doctrine.ts +51 -2
  33. package/src/setting.ts +1 -0
  34. package/src/style.ts +1 -0
  35. package/src/styling.ts +5 -1
  36. package/src/video-reference-resolver.ts +5 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -20,7 +20,7 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "@nodaro/shared": "^2.17.0"
23
+ "@nodaro/shared": "^2.19.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.5.0",
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"
2
2
  import { assembleImageInput } from "../assemble-image-input.js"
3
3
  import { buildImagePrompt, buildImagePromptWithOverflow } from "../prompt-builder.js"
4
4
  import { renderDirectionHints, IMAGE_HINT_MODE_DEFAULT } from "../direction-registry.js"
5
- import { joinPromptHints } from "../prompt-hint-join.js"
5
+ import { renderStyleSection } from "../prompt-style-section.js"
6
6
  import { getMaxImagePromptChars } from "@nodaro/shared"
7
7
  import type { ConnectedReference } from "@nodaro/shared"
8
8
 
@@ -14,11 +14,15 @@ import type { ConnectedReference } from "@nodaro/shared"
14
14
  * TRULY maximal image-surface `direction` renders ~3.3K characters of clauses —
15
15
  * the broad-but-not-maximal fold below renders ~1.2K, which is already enough
16
16
  * to push the assembled prompt past a low-cap provider (seedream = 3000).
17
- * `buildImagePrompt` then cuts the TAIL — and in the hybrid reference
18
- * format the trailing canonical role phrase ("the location from reference image
19
- * B") sits AFTER the folded hints, so the cut severs a REFERENCE BINDING while
20
- * decorative hint clauses survive. The assembler knows which clauses are hints
21
- * (it just rendered them), so it drops them last-folded-first and re-assembles.
17
+ * `buildImagePrompt` then cuts the TAIL, order-blind and mid-word.
18
+ *
19
+ * WHAT THE CUT REACHES FIRST is the `[style]` section: the hybrid role phrases
20
+ * splice into the BODY ahead of it (`insertBeforeStyleSection`), so the
21
+ * casualty is a look clause the user picked, severed halfway through, rather
22
+ * than a whole clause dropped cleanly. The assembler knows which clauses are
23
+ * hints (it just rendered them), so it drops them last-folded-first and
24
+ * re-assembles. A larger fold walks the same cut back into the bindings and the
25
+ * prose, which is what the shed keeps out of reach entirely.
22
26
  */
23
27
 
24
28
  // A broad but ordinary image direction — the kind a "set every picker" UI
@@ -45,6 +49,18 @@ const IMAGE_HINTS = renderDirectionHints(DIRECTION, {
45
49
  mode: IMAGE_HINT_MODE_DEFAULT,
46
50
  })
47
51
 
52
+ /**
53
+ * The UNSHED body for a prompt — the oracle for "what the assembler composes
54
+ * before any cap thinking". Every image-surface direction row is `look`, so the
55
+ * whole fold lands in the `[style]` section and the body is the prose alone,
56
+ * trimmed (something folded).
57
+ */
58
+ const unshedBody = (prompt: string): string =>
59
+ `${prompt.trim()}\n\n${renderStyleSection(DIRECTION, {
60
+ surface: "image",
61
+ mode: IMAGE_HINT_MODE_DEFAULT,
62
+ })}`
63
+
48
64
  /** The mentioned character — its directive is the FIRST binding in the prompt. */
49
65
  const KIRA: ConnectedReference = {
50
66
  id: "kira-id",
@@ -58,8 +74,8 @@ const KIRA: ConnectedReference = {
58
74
  variantDisplayName: "canonical",
59
75
  }
60
76
 
61
- /** An UNMENTIONED wired location — hybrid renders its role phrase at the very
62
- * END of the prompt, behind every folded hint. The order-blind casualty. */
77
+ /** An UNMENTIONED wired location — hybrid renders its role phrase as the last
78
+ * line of the BODY, just ahead of the `[style]` section. */
63
79
  const PIER: ConnectedReference = {
64
80
  id: "pier-id",
65
81
  defaultName: "Pier",
@@ -70,9 +86,15 @@ const PIER: ConnectedReference = {
70
86
 
71
87
  /** The mention-resolved character binding, in the middle of the prose. */
72
88
  const CHARACTER_BINDING = "the person from reference image A"
73
- /** The binding the tail cut destroys when nothing sheds the hints first. */
89
+ /** The binding the tail cut used to destroy when nothing shed the hints first. */
74
90
  const LOCATION_BINDING = "the location from reference image B"
75
91
 
92
+ /** The whole look section, unshed — what an order-blind cut severs first. */
93
+ const FULL_SECTION = renderStyleSection(DIRECTION, {
94
+ surface: "image",
95
+ mode: IMAGE_HINT_MODE_DEFAULT,
96
+ })
97
+
76
98
  /** Prose long enough that prose + directives + the full fold clears 3000. */
77
99
  const PROSE = "@kira:1 walks the seawall at dusk. " + "The waves are loud. ".repeat(90)
78
100
 
@@ -93,14 +115,16 @@ describe("assembleImageInput — cap-aware hint shedding", () => {
93
115
  // ever shrinks enough that this no longer truncates, the scenario below is
94
116
  // vacuous and this assertion says so loudly.
95
117
  const naive = buildImagePrompt({
96
- prompt: joinPromptHints(PROSE, IMAGE_HINTS),
118
+ prompt: unshedBody(PROSE),
97
119
  provider: "seedream",
98
120
  connectedReferences: [KIRA, PIER],
99
121
  referenceFormat: "hybrid",
100
122
  })
101
123
  expect(naive.prompt.endsWith("...")).toBe(true)
102
- // …and what it cut was the reference binding, not the decorative tail.
103
- expect(naive.prompt).not.toContain(LOCATION_BINDING)
124
+ // …and what it cut was the look section, mid-clause — the binding spliced
125
+ // into the body ahead of it and survives the cut it used to die to.
126
+ expect(naive.prompt).toContain(LOCATION_BINDING)
127
+ expect(naive.prompt).not.toContain(FULL_SECTION)
104
128
  })
105
129
 
106
130
  it("keeps every reference binding and the full prose, dropping trailing hints", () => {
@@ -154,7 +178,7 @@ describe("assembleImageInput — under-cap byte parity", () => {
154
178
  for (const { name, provider, prompt } of parityCases) {
155
179
  it(`is byte-identical to the unordered fold — ${name}`, () => {
156
180
  const expected = buildImagePrompt({
157
- prompt: joinPromptHints(prompt, IMAGE_HINTS),
181
+ prompt: unshedBody(prompt),
158
182
  provider,
159
183
  connectedReferences: [KIRA, PIER],
160
184
  referenceFormat: "hybrid",
@@ -49,7 +49,9 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
49
49
  connectedReferences: [ref],
50
50
  direction: { framingId: "medium-shot" },
51
51
  })
52
- expect(result.prompt).toBe(`a knight. ${getFramingPromptHint("medium-shot")}`)
52
+ expect(result.prompt).toBe(
53
+ `a knight\n\n[style]:\n${getFramingPromptHint("medium-shot")}`,
54
+ )
53
55
  expect(result.referenceImageUrls).toEqual(["https://r2.example/hero.png"])
54
56
  })
55
57
 
@@ -59,8 +61,10 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
59
61
  provider: REF_PROVIDER,
60
62
  direction: { framingId: "medium-shot", framingAngleId: "low-angle" },
61
63
  })
64
+ // Two scene-line clauses share one line, `. `-joined.
62
65
  expect(result.prompt).toBe(
63
- `a knight. ${getFramingPromptHint("medium-shot")}. ${getFramingPromptHint("low-angle")}`,
66
+ `a knight\n\n[style]:\n` +
67
+ `${getFramingPromptHint("medium-shot")}. ${getFramingPromptHint("low-angle")}`,
64
68
  )
65
69
  })
66
70
 
@@ -120,7 +124,7 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
120
124
  provider: REF_PROVIDER,
121
125
  direction: { style: "anime" },
122
126
  })
123
- expect(result.prompt).toBe(`a knight. ${getStylePromptHint("anime")}`)
127
+ expect(result.prompt).toBe(`a knight\n\n[style]:\n${getStylePromptHint("anime")}`)
124
128
  })
125
129
 
126
130
  it("blends a multi-pick dimension into ONE clause", () => {
@@ -131,21 +135,37 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
131
135
  provider: REF_PROVIDER,
132
136
  direction: { mood: ["happy", "joyful"] },
133
137
  })
134
- expect(result.prompt).toBe(`a knight. ${blended[0]}`)
138
+ expect(result.prompt).toBe(`a knight\n\n[style]:\n${blended[0]}`)
135
139
  })
136
140
 
137
- it("folds in TABLE order, not the caller's object-literal order", () => {
141
+ it("groups before it orders: the film line leads the scene line", () => {
142
+ // `shotSize` folds at row 2 and `style` at row 22, so table order alone
143
+ // would read the framing clause first; the section's two lines outrank it.
138
144
  const result = assembleImageInput({
139
145
  userPrompt: "a knight",
140
146
  provider: REF_PROVIDER,
141
147
  direction: { style: "anime", shotSize: "wide-shot" },
142
148
  })
143
149
  expect(result.prompt).toBe(
144
- `a knight. ${getFramingPromptHint("wide-shot")}. ${getStylePromptHint("anime")}`,
150
+ `a knight\n\n[style]:\n` +
151
+ `${getStylePromptHint("anime")}\n${getFramingPromptHint("wide-shot")}`,
145
152
  )
146
153
  })
147
154
 
148
- it("keeps the five pre-registry keys byte-identical to the old inlined fold", () => {
155
+ it("folds in TABLE order within a line, not the caller's object-literal order", () => {
156
+ // `shotSize` (row 2) precedes `timeOfDay` (row 14) on the scene line.
157
+ const result = assembleImageInput({
158
+ userPrompt: "a knight",
159
+ provider: REF_PROVIDER,
160
+ direction: { timeOfDay: "golden-hour", shotSize: "wide-shot" },
161
+ })
162
+ expect(result.prompt).toBe(
163
+ `a knight\n\n[style]:\n` +
164
+ `${getFramingPromptHint("wide-shot")}. ${getLightingPromptHint("golden-hour")}`,
165
+ )
166
+ })
167
+
168
+ it("splits the five pre-registry keys across the section's two lines", () => {
149
169
  const direction = {
150
170
  framingId: "wide-shot",
151
171
  framingAngleId: "low-angle",
@@ -158,21 +178,21 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
158
178
  provider: REF_PROVIDER,
159
179
  direction,
160
180
  })
161
- // The exact string the pre-registry `composePromptText` produced: the same
162
- // five clauses, in the same order, joined with the same ". ".
181
+ // `cameraFormatId` is the one film row in the legacy block; the other four
182
+ // fall to the scene line, in the same relative order they always folded in.
163
183
  expect(result.prompt).toBe(
164
- [
165
- "a knight",
166
- getFramingPromptHint("wide-shot"),
167
- getFramingPromptHint("low-angle"),
168
- getLightingPromptHint("golden-hour"),
169
- getLensPromptHint("wide-24mm"),
170
- getCameraFormatPromptHint("16mm-film"),
171
- ].join(". "),
184
+ "a knight\n\n[style]:\n" +
185
+ `${getCameraFormatPromptHint("16mm-film")}\n` +
186
+ [
187
+ getFramingPromptHint("wide-shot"),
188
+ getFramingPromptHint("low-angle"),
189
+ getLightingPromptHint("golden-hour"),
190
+ getLensPromptHint("wide-24mm"),
191
+ ].join(". "),
172
192
  )
173
193
  })
174
194
 
175
- it("keeps the structured fragment LAST, after every direction clause", () => {
195
+ it("keeps the structured fragment LAST IN THE BODY, ahead of the section", () => {
176
196
  const result = assembleImageInput({
177
197
  userPrompt: "a portrait",
178
198
  provider: REF_PROVIDER,
@@ -180,8 +200,69 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
180
200
  structured: { person: { age: 30, gender: "woman", expression: "calm" } },
181
201
  })
182
202
  expect(result.prompt).toBe(
183
- `a portrait. ${getStylePromptHint("anime")}. Subject: 30 years old, woman, calm expression.`,
203
+ "a portrait. Subject: 30 years old, woman, calm expression." +
204
+ `\n\n[style]:\n${getStylePromptHint("anime")}`,
205
+ )
206
+ })
207
+
208
+ it("emits no section on the image surface only when nothing look-family folds", () => {
209
+ // Every image-surface direction row is `look` (the registry has no
210
+ // image-surface motion row), so a direction that renders ANY clause always
211
+ // opens a section — and a structured-only fold never does.
212
+ expect(
213
+ assembleImageInput({
214
+ userPrompt: "a portrait",
215
+ provider: REF_PROVIDER,
216
+ structured: { person: { age: 30 } },
217
+ }).prompt,
218
+ ).not.toContain("[style]")
219
+ expect(
220
+ assembleImageInput({
221
+ userPrompt: "a portrait",
222
+ provider: REF_PROVIDER,
223
+ direction: { isoValue: "iso-100" },
224
+ }).prompt,
225
+ ).toContain("[style]:\n")
226
+ })
227
+ })
228
+
229
+ /**
230
+ * THE HYBRID LINE-CAPITALIZER. On the hybrid reference format with connected
231
+ * references and NO converged `@`-mention, `buildHybridScene` capitalizes the
232
+ * first alphabetic character of EVERY line of the body. Run over the section
233
+ * that would rewrite the header to `[Style]:` and give every catalog clause a
234
+ * capital it was not written with — so the capitalizer stops at the header.
235
+ */
236
+ describe("assembleImageInput — the hybrid capitalizer stops at the section", () => {
237
+ // A plain wired image: no mention to converge and no canonical role phrase to
238
+ // render, which is what leaves the body UNCONVERGED — the only path where the
239
+ // capitalizer runs at all. (A wired CHARACTER converges via its canonical
240
+ // phrase and skips the capitalizer entirely.)
241
+ const plate: ConnectedReference = {
242
+ id: "plate-id",
243
+ defaultName: "Plate",
244
+ source: "wired-image",
245
+ url: "https://r2.example/plate.png",
246
+ }
247
+
248
+ const hybridInput = {
249
+ userPrompt: "a knight on a hill",
250
+ provider: REF_PROVIDER,
251
+ connectedReferences: [plate],
252
+ referenceFormat: "hybrid" as const,
253
+ direction: { style: "anime", shotSize: "wide-shot" },
254
+ }
255
+
256
+ it("capitalizes the body line (non-vacuity: the capitalizer really runs here)", () => {
257
+ expect(assembleImageInput(hybridInput).prompt).toContain("A knight on a hill")
258
+ })
259
+
260
+ it("leaves the header and every clause line byte-intact", () => {
261
+ const result = assembleImageInput(hybridInput)
262
+ expect(result.prompt).toContain(
263
+ `[style]:\n${getStylePromptHint("anime")}\n${getFramingPromptHint("wide-shot")}`,
184
264
  )
265
+ expect(result.prompt).not.toContain("[Style]")
185
266
  })
186
267
  })
187
268
 
@@ -4,6 +4,11 @@ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
4
4
  import { renderDirectionHints, VIDEO_HINT_MODE_DEFAULT } from "../direction-registry.js"
5
5
  import { renderSubjectHints, SUBJECT_VIDEO_HINT_MODE_DEFAULT } from "../subject-registry.js"
6
6
  import { renderStructuredFields } from "../prompt-builder-structured-fields.js"
7
+ import {
8
+ composeSectionedPrompt,
9
+ partitionStyleClauses,
10
+ renderStyleSection,
11
+ } from "../prompt-style-section.js"
7
12
  import { joinPromptHints } from "../prompt-hint-join.js"
8
13
  import { getMaxVideoPromptChars } from "@nodaro/shared"
9
14
  import type { ConnectedReference } from "@nodaro/shared"
@@ -15,12 +20,13 @@ import type { ConnectedReference } from "@nodaro/shared"
15
20
  * `assemble-image-input-cap.test.ts`; this suite mirrors its shape.
16
21
  *
17
22
  * THE ORDERING PROBLEM THIS SIDE HAS AND THE IMAGE SIDE DID NOT: the fold runs
18
- * BEFORE `resolveVideoReferenceCore`, and the resolver then ADDS the binding
19
- * text — hybrid's role phrases are APPENDED, so they sit behind every folded
20
- * hint and are the first thing a tail cut destroys. So the shed is decided on
21
- * the FRAMED length (`opts.frame`), not on the folded body: the resolver's
22
- * additions are inside the budget, while the only thing the composer can drop
23
- * is a clause it rendered itself. The frame below is the real resolver.
23
+ * BEFORE `resolveVideoReferenceCore`, and the resolver then ADDS binding text —
24
+ * lock lines ahead of the body, role phrases spliced in at the end of it, just
25
+ * before the `[style]` section. None of it is sheddable and all of it counts
26
+ * against the ceiling, so the shed is decided on the FRAMED length
27
+ * (`opts.frame`), not on the folded body: the resolver's additions are inside
28
+ * the budget, while the only thing the composer can drop is a clause it
29
+ * rendered itself. The frame below is the real resolver.
24
30
  *
25
31
  * Video caps are far tighter than the image side's (kling = 1000 vs seedream =
26
32
  * 3000), so an ordinary direction overflows without any contrived prose.
@@ -47,6 +53,15 @@ const VIDEO_HINTS = renderDirectionHints(DIRECTION, {
47
53
  mode: VIDEO_HINT_MODE_DEFAULT,
48
54
  })
49
55
 
56
+ /** The same clauses, slotted — the shed keeps a PREFIX of exactly this list. */
57
+ const DIRECTION_CLAUSES = partitionStyleClauses(DIRECTION, {
58
+ surface: "video",
59
+ mode: VIDEO_HINT_MODE_DEFAULT,
60
+ })
61
+
62
+ /** Everything before the `[style]` section — the half the shed budget grows. */
63
+ const bodyOf = (composed: string): string => composed.split("\n\n[style]:\n")[0]!
64
+
50
65
  /** The mentioned character — hybrid replaces the mention INLINE, mid-prose. */
51
66
  const KIRA: ConnectedReference = {
52
67
  id: "kira-id",
@@ -93,9 +108,16 @@ const frame = (body: string | undefined): string | undefined =>
93
108
 
94
109
  /** The binding that lands inline, inside the prose. */
95
110
  const MENTION_BINDING = "@image_1"
96
- /** Ray is unmentioned → his canonical-fallback phrase is APPENDED, last. */
111
+ /** Ray is unmentioned → his canonical-fallback phrase ends the BODY, spliced in
112
+ * ahead of the `[style]` section. */
97
113
  const TRAILING_BINDING = "@image_2"
98
114
 
115
+ /** The whole look section, unshed — what an order-blind cut severs first. */
116
+ const FULL_SECTION = renderStyleSection(DIRECTION, {
117
+ surface: "video",
118
+ mode: VIDEO_HINT_MODE_DEFAULT,
119
+ })
120
+
99
121
  describe("composeVideoPromptText — cap-aware hint shedding", () => {
100
122
  it("the unshed fold really does overflow kling through the frame (non-vacuity guard)", () => {
101
123
  // The oracle for "what the composer produced before": fold every hint, hand
@@ -104,9 +126,11 @@ describe("composeVideoPromptText — cap-aware hint shedding", () => {
104
126
  // below is vacuous and this assertion says so loudly.
105
127
  const naive = frame(composeVideoPromptText(PROSE, DIRECTION))!
106
128
  expect(naive.length).toBeGreaterThan(KLING_CAP)
107
- // …and what a tail cut at the cap would destroy is the trailing BINDING,
108
- // not the decorative tail: the role phrase sits past the cap.
109
- expect(naive.slice(0, KLING_CAP)).not.toContain(TRAILING_BINDING)
129
+ // …and what a tail cut at the cap would destroy is the look section,
130
+ // mid-clause: the role phrase splices into the body ahead of it and clears
131
+ // the cut, so the shed's job is to drop whole clauses instead.
132
+ expect(naive.slice(0, KLING_CAP)).toContain(TRAILING_BINDING)
133
+ expect(naive.slice(0, KLING_CAP)).not.toContain(FULL_SECTION)
110
134
  })
111
135
 
112
136
  it("keeps every binding and the full prose, dropping trailing hints", () => {
@@ -148,6 +172,53 @@ describe("composeVideoPromptText — cap-aware hint shedding", () => {
148
172
  const starved = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 10, frame })
149
173
  expect(starved).toBe(PROSE)
150
174
  for (const hint of VIDEO_HINTS) expect(starved).not.toContain(hint)
175
+ // A FULL shed takes the header with it — byte-identical to the prompt, not
176
+ // an empty section hanging off it. This is what keeps the routes'
177
+ // `composed !== prompt` guard reading false when nothing survived.
178
+ expect(starved).not.toContain("[style]")
179
+ })
180
+
181
+ it("survives the reference resolver byte-intact", () => {
182
+ // The resolver is why the section is written flush-left: it collapses 2+
183
+ // HORIZONTAL spaces unanchored, and it rewrites mentions and appends role
184
+ // phrases around the body. None of that may touch the section's bytes.
185
+ const body = composeVideoPromptText(PROSE, DIRECTION)!
186
+ const section = body.slice(body.indexOf("\n\n[style]:\n"))
187
+ expect(section).toContain("[style]:\n")
188
+ // ENDS with it, not merely contains it: the resolver's role phrases splice
189
+ // into the body ahead of the section, so nothing of the resolver's may
190
+ // extend the clause block the header opens.
191
+ expect(frame(body)!.endsWith(section)).toBe(true)
192
+ })
193
+
194
+ it("reclaims the header only when the LAST look clause sheds", () => {
195
+ // Budgets derived from what the composer actually builds, so they track
196
+ // catalog wording instead of pinning it.
197
+ const capForKept = (n: number): number =>
198
+ frame(composeSectionedPrompt(PROSE, DIRECTION_CLAUSES.slice(0, n), ""))!.length
199
+ // The first clause is `cameraMotion` (motion → body), the second the first
200
+ // LOOK clause — so `kept = 2` is "body plus exactly one section clause".
201
+ expect(DIRECTION_CLAUSES[0]!.slot).toBe("body")
202
+ expect(DIRECTION_CLAUSES[1]!.slot).not.toBe("body")
203
+
204
+ const atTwo = composeVideoPromptText(PROSE, DIRECTION, undefined, {
205
+ cap: capForKept(2),
206
+ frame,
207
+ })!
208
+ expect(atTwo).toBe(composeSectionedPrompt(PROSE, DIRECTION_CLAUSES.slice(0, 2), ""))
209
+ expect(atTwo).toContain("[style]:")
210
+
211
+ // ONE byte tighter, and the section's last clause goes — taking the whole
212
+ // 11-byte `"\n\n[style]:\n"` with it, so the body drops all the way back to
213
+ // the prose. (The cost of under-pricing that header instead shows up as an
214
+ // over-shed in "sheds the whole direction fold before a single subject
215
+ // clause" below, which is where a flat per-clause charge fails.)
216
+ const justUnder = composeVideoPromptText(PROSE, DIRECTION, undefined, {
217
+ cap: capForKept(2) - 1,
218
+ frame,
219
+ })!
220
+ expect(justUnder).toBe(composeSectionedPrompt(PROSE, DIRECTION_CLAUSES.slice(0, 1), ""))
221
+ expect(justUnder).not.toContain("[style]")
151
222
  })
152
223
 
153
224
  it("reserves the caller's budget rather than re-deriving a provider cap", () => {
@@ -166,12 +237,25 @@ describe("composeVideoPromptText — cap-aware hint shedding", () => {
166
237
  })
167
238
 
168
239
  it("never sheds the structured fragment — it is user content, not a garnish", () => {
169
- const structured = { subject: "a lighthouse keeper", action: "hauls a rope hand over hand" }
240
+ const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
170
241
  const fragment = renderStructuredFields(structured)
242
+ expect(fragment.length, "an empty fragment makes every claim below vacuous").toBeGreaterThan(0)
171
243
  const body = composeVideoPromptText(PROSE, DIRECTION, structured, { cap: 700, frame })!
172
244
  expect(body).toContain(fragment)
173
- // …and it still lands LAST, behind the surviving hints.
174
- expect(body.endsWith(fragment)).toBe(true)
245
+ // …and it still ends the BODY, behind every surviving body hint.
246
+ expect(bodyOf(body).endsWith(fragment)).toBe(true)
247
+ })
248
+
249
+ it("ends the BODY with the fragment even when the section survives above it", () => {
250
+ // The capless fold, where every clause lives: the fragment is the last
251
+ // thing in the body and the section reads after it, so the composed prompt
252
+ // does NOT end with the fragment any more.
253
+ const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
254
+ const fragment = renderStructuredFields(structured)
255
+ const body = composeVideoPromptText(PROSE, DIRECTION, structured)!
256
+ expect(body).toContain("\n\n[style]:\n")
257
+ expect(bodyOf(body).endsWith(fragment)).toBe(true)
258
+ expect(body.endsWith(fragment)).toBe(false)
175
259
  })
176
260
  })
177
261
 
@@ -316,17 +400,19 @@ describe("composeVideoPromptText — the subject fold under the cap", () => {
316
400
  it("never sheds the structured fragment to save a subject clause", () => {
317
401
  // Ordering across ALL THREE pieces at once: user content outranks both
318
402
  // catalog channels and still lands last.
319
- const structured = { subject: "a lighthouse keeper", action: "hauls a rope" }
403
+ const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
320
404
  const fragment = renderStructuredFields(structured)
321
405
  const body = composeVideoPromptText(PROSE, DIRECTION, structured, {
322
406
  subject: SUBJECT,
323
407
  cap: framedWithSubjectClauses(0),
324
408
  frame,
325
409
  })!
326
- expect(body.endsWith(fragment)).toBe(true)
410
+ expect(bodyOf(body).endsWith(fragment)).toBe(true)
327
411
  for (const hint of [...SUBJECT_HINTS, ...VIDEO_HINTS]) {
328
412
  expect(body).not.toContain(hint)
329
413
  }
414
+ // Everything droppable went, so there is no section left to end with.
415
+ expect(body).not.toContain("[style]")
330
416
  })
331
417
 
332
418
  it("is byte-identical to the capless subject fold when it fits", () => {