@nodaro/prompts 1.11.0 → 1.13.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 +627 -177
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +726 -33
  4. package/dist/index.d.ts +726 -33
  5. package/dist/index.js +598 -179
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
  9. package/src/__tests__/animal-getters-parity.test.ts +82 -0
  10. package/src/__tests__/assemble-image-input-cap.test.ts +236 -0
  11. package/src/__tests__/assemble-image-input.test.ts +100 -19
  12. package/src/__tests__/assemble-video-input-cap.test.ts +442 -0
  13. package/src/__tests__/assemble-video-input.test.ts +167 -33
  14. package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
  15. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  16. package/src/__tests__/location-convergence-image.test.ts +29 -1
  17. package/src/__tests__/location-default-role-image.test.ts +166 -0
  18. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  19. package/src/__tests__/prompt-style-section.test.ts +345 -0
  20. package/src/__tests__/read-node-subject.test.ts +140 -0
  21. package/src/__tests__/style-section-boundary.test.ts +179 -0
  22. package/src/__tests__/subject-fold.test.ts +251 -0
  23. package/src/__tests__/subject-registry.test.ts +312 -0
  24. package/src/assemble-image-input.ts +169 -41
  25. package/src/assemble-video-input.ts +200 -25
  26. package/src/direction-registry.ts +116 -28
  27. package/src/hint-shedding.ts +87 -0
  28. package/src/index.ts +3 -0
  29. package/src/parameter-prompt-hint.ts +8 -7
  30. package/src/picker-catalogs.ts +14 -7
  31. package/src/prompt-builder.ts +628 -88
  32. package/src/prompt-hint-join.ts +9 -0
  33. package/src/prompt-style-section.ts +256 -0
  34. package/src/read-node-direction.ts +60 -1
  35. package/src/subject-registry.ts +464 -0
  36. package/src/video-reference-resolver.ts +5 -2
@@ -0,0 +1,312 @@
1
+ /**
2
+ * The subject registry is the platform-owned contract for the flat `subject`
3
+ * wire channel: WHICH keys ride it, in WHAT order the rows fold, and HOW each
4
+ * catalog renders its selection. Every assertion here is a pin on that
5
+ * contract — a failure means a reorder / retable was intentional and the
6
+ * changeset has to say so.
7
+ *
8
+ * Two pins are load-bearing beyond mere table hygiene:
9
+ * - the FLAT-BAG dedupe (`lipState` suppressing `makeup-bold-lips`), which is
10
+ * the reason the wire is flat and which fails on any future nesting;
11
+ * - the GRAMMAR pin (person/styling arrive as ONE comma-joined clause, never N
12
+ * `". "`-joined fragments), which is why those rows are `kind: "group"`.
13
+ */
14
+ import { describe, it, expect } from "vitest"
15
+ import {
16
+ MAX_SUBJECT_KEYS,
17
+ SUBJECT_ARRAY_CEILING,
18
+ SUBJECT_CUSTOM_AGE_KEY,
19
+ SUBJECT_FIELDS,
20
+ SUBJECT_FOLD_KEYS,
21
+ SUBJECT_ID_MAX_CHARS,
22
+ SUBJECT_IMAGE_HINT_MODE_DEFAULT,
23
+ SUBJECT_KEYS,
24
+ SUBJECT_VIDEO_HINT_MODE_DEFAULT,
25
+ normalizeSubjectFields,
26
+ renderSubjectHints,
27
+ subjectFieldsForSurface,
28
+ } from "../subject-registry.js"
29
+ import {
30
+ DIRECTION_ARRAY_CEILING,
31
+ DIRECTION_ID_MAX_CHARS,
32
+ DIRECTION_KEYS,
33
+ } from "../direction-registry.js"
34
+ import {
35
+ PERSON_DIMENSION_ORDER,
36
+ PERSON_FIELD_BY_DIMENSION,
37
+ buildPersonHints,
38
+ getPersonPromptHint,
39
+ getPersonTerm,
40
+ } from "../person.js"
41
+ import {
42
+ STYLING_DIMENSION_ORDER,
43
+ STYLING_FIELD_BY_DIMENSION,
44
+ getStylingPromptHint,
45
+ } from "../styling.js"
46
+ import { getHeldPropPromptHint } from "../held-prop.js"
47
+ import { getAnimalPromptHint, getAnimalTerm } from "@nodaro/shared"
48
+
49
+ const IMAGE = { surface: "image" } as const
50
+ const VIDEO = { surface: "video" } as const
51
+
52
+ // Real catalog ids — every getter returns "" on a miss, so a fake id would make
53
+ // most of these assertions vacuously pass.
54
+ const NO_SUCH_ID = "__no_such_id__"
55
+
56
+ describe("SUBJECT_FIELDS — table integrity", () => {
57
+ it("has unique row keys", () => {
58
+ const keys = SUBJECT_FIELDS.map((f) => f.key)
59
+ expect(new Set(keys).size).toBe(keys.length)
60
+ })
61
+
62
+ it("gives every row a render function, and every ids row a positive maxPicks", () => {
63
+ for (const spec of SUBJECT_FIELDS) {
64
+ expect(typeof spec.render, spec.key).toBe("function")
65
+ if (spec.kind === "ids") expect(spec.maxPicks, spec.key).toBeGreaterThanOrEqual(1)
66
+ }
67
+ })
68
+
69
+ it("exports SUBJECT_FOLD_KEYS in table order", () => {
70
+ expect(SUBJECT_FOLD_KEYS).toEqual(SUBJECT_FIELDS.map((f) => f.key))
71
+ })
72
+
73
+ it("pins the fold order (a reorder is a deliberate, changeset-worthy change)", () => {
74
+ expect(SUBJECT_FOLD_KEYS).toEqual(["person", "styling", "heldProp", "material", "animal"])
75
+ })
76
+
77
+ it("folds both group rows before any prop row", () => {
78
+ const lastGroup = SUBJECT_FIELDS.map((f) => f.kind).lastIndexOf("group")
79
+ const firstIds = SUBJECT_FIELDS.map((f) => f.kind).indexOf("ids")
80
+ expect(lastGroup).toBeLessThan(firstIds)
81
+ })
82
+
83
+ it("folds every row on both surfaces today", () => {
84
+ expect(subjectFieldsForSurface("image").map((f) => f.key)).toEqual([...SUBJECT_FOLD_KEYS])
85
+ expect(subjectFieldsForSurface("video").map((f) => f.key)).toEqual([...SUBJECT_FOLD_KEYS])
86
+ })
87
+
88
+ it("takes its bounds FROM the direction registry (one literal, both channels)", () => {
89
+ expect(SUBJECT_ID_MAX_CHARS).toBe(DIRECTION_ID_MAX_CHARS)
90
+ expect(SUBJECT_ARRAY_CEILING).toBe(DIRECTION_ARRAY_CEILING)
91
+ })
92
+
93
+ it("defaults image to full clauses and video to compact terms", () => {
94
+ expect(SUBJECT_IMAGE_HINT_MODE_DEFAULT).toBe("full")
95
+ expect(SUBJECT_VIDEO_HINT_MODE_DEFAULT).toBe("compact")
96
+ })
97
+ })
98
+
99
+ describe("SUBJECT_KEYS — the derived wire vocabulary", () => {
100
+ it("is unique and fits the record bound", () => {
101
+ expect(new Set(SUBJECT_KEYS).size).toBe(SUBJECT_KEYS.length)
102
+ expect(SUBJECT_KEYS.length).toBeLessThanOrEqual(MAX_SUBJECT_KEYS)
103
+ })
104
+
105
+ it("carries every person field, every styling field, customAge and the three props", () => {
106
+ for (const d of PERSON_DIMENSION_ORDER) {
107
+ expect(SUBJECT_KEYS, d).toContain(PERSON_FIELD_BY_DIMENSION[d])
108
+ }
109
+ for (const d of STYLING_DIMENSION_ORDER) {
110
+ expect(SUBJECT_KEYS, d).toContain(STYLING_FIELD_BY_DIMENSION[d])
111
+ }
112
+ expect(SUBJECT_KEYS).toContain(SUBJECT_CUSTOM_AGE_KEY)
113
+ expect(SUBJECT_KEYS).toContain("heldProp")
114
+ expect(SUBJECT_KEYS).toContain("material")
115
+ expect(SUBJECT_KEYS).toContain("animal")
116
+ expect(SUBJECT_KEYS.length).toBe(
117
+ PERSON_DIMENSION_ORDER.length + STYLING_DIMENSION_ORDER.length + 1 + 3,
118
+ )
119
+ })
120
+
121
+ it("excludes the free-text pre/post fields (the v1 carve-out)", () => {
122
+ expect(SUBJECT_KEYS).not.toContain("preText")
123
+ expect(SUBJECT_KEYS).not.toContain("postText")
124
+ })
125
+
126
+ it("is DISJOINT from DIRECTION_KEYS — nothing folds twice", () => {
127
+ const direction = new Set<string>(DIRECTION_KEYS)
128
+ expect(SUBJECT_KEYS.filter((k) => direction.has(k))).toEqual([])
129
+ })
130
+ })
131
+
132
+ describe("renderSubjectHints — inertness", () => {
133
+ it("returns [] for undefined, {} and a non-object", () => {
134
+ expect(renderSubjectHints(undefined, IMAGE)).toEqual([])
135
+ expect(renderSubjectHints({}, IMAGE)).toEqual([])
136
+ expect(renderSubjectHints([] as never, IMAGE)).toEqual([])
137
+ })
138
+
139
+ it("ignores unknown wire keys", () => {
140
+ expect(renderSubjectHints({ notAField: "man", hairColor: NO_SUCH_ID }, IMAGE)).toEqual([])
141
+ })
142
+
143
+ it("skips unknown ids instead of 400ing on them", () => {
144
+ expect(renderSubjectHints({ type: NO_SUCH_ID, animal: NO_SUCH_ID }, IMAGE)).toEqual([])
145
+ })
146
+
147
+ it("drops preText / postText — the carve-out that stops a double emission", () => {
148
+ expect(
149
+ renderSubjectHints(
150
+ { preText: "a lone wanderer", postText: "seen from behind" } as never,
151
+ IMAGE,
152
+ ),
153
+ ).toEqual([])
154
+ const withPerson = renderSubjectHints(
155
+ { type: "woman", preText: "a lone wanderer" } as never,
156
+ IMAGE,
157
+ )
158
+ expect(withPerson).toEqual([getPersonPromptHint("woman")])
159
+ })
160
+ })
161
+
162
+ describe("renderSubjectHints — grammar (the R4 pin)", () => {
163
+ it("emits person as ONE comma-joined clause, not N fragments", () => {
164
+ const bag = { type: "woman", ethnicity: "east-asian", hairBase: "base-short-straight" }
165
+ const out = renderSubjectHints(bag, IMAGE)
166
+ expect(out).toHaveLength(1)
167
+ expect(out[0]).toBe(buildPersonHints(bag, "full").join(", "))
168
+ expect(out[0]).toContain(", ")
169
+ })
170
+
171
+ it("emits styling as its own single clause, after person", () => {
172
+ const out = renderSubjectHints({ type: "woman", makeup: "makeup-smoky" }, IMAGE)
173
+ expect(out).toEqual([getPersonPromptHint("woman"), getStylingPromptHint("makeup-smoky")])
174
+ })
175
+
176
+ it("folds the prop rows after both group rows, in table order", () => {
177
+ const out = renderSubjectHints(
178
+ { type: "man", heldProp: "smartphone", material: "silk", animal: "dog-corgi" },
179
+ IMAGE,
180
+ )
181
+ expect(out[0]).toBe(getPersonPromptHint("man"))
182
+ expect(out).toContain(getHeldPropPromptHint("smartphone"))
183
+ expect(out[out.length - 1]).toBe(getAnimalPromptHint("dog-corgi"))
184
+ })
185
+
186
+ it("renders compact terms in compact mode", () => {
187
+ const bag = { type: "not-defined", animal: "dog-corgi" }
188
+ expect(renderSubjectHints(bag, { ...VIDEO, mode: "compact" })).toEqual([
189
+ getPersonTerm("not-defined"),
190
+ getAnimalTerm("dog-corgi"),
191
+ ])
192
+ })
193
+ })
194
+
195
+ describe("renderSubjectHints — the flat-bag behaviors", () => {
196
+ it("dedupes the lipstick clause across the person and styling catalogs (R3)", () => {
197
+ const out = renderSubjectHints(
198
+ { lipState: "lip-state-bold-red", makeup: "makeup-bold-lips" },
199
+ IMAGE,
200
+ )
201
+ expect(out).toEqual([getPersonPromptHint("lip-state-bold-red")])
202
+ expect(out.join(" ")).not.toContain(getStylingPromptHint("makeup-bold-lips"))
203
+ })
204
+
205
+ it("keeps a NON-twin makeup pick alongside the bold-red lip state", () => {
206
+ const out = renderSubjectHints(
207
+ { lipState: "lip-state-bold-red", makeup: "makeup-smoky" },
208
+ IMAGE,
209
+ )
210
+ expect(out).toEqual([
211
+ getPersonPromptHint("lip-state-bold-red"),
212
+ getStylingPromptHint("makeup-smoky"),
213
+ ])
214
+ })
215
+
216
+ it("folds midriff + navel into the single neutral safety clause", () => {
217
+ const out = renderSubjectHints(
218
+ { distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"] },
219
+ IMAGE,
220
+ )
221
+ expect(out).toEqual(["wearing a cropped style, midriff and navel visible"])
222
+ })
223
+
224
+ it("de-duplicates an exact repeated clause, first occurrence winning", () => {
225
+ const out = renderSubjectHints({ heldProp: ["smartphone", "smartphone"] }, IMAGE)
226
+ expect(out).toEqual([getHeldPropPromptHint("smartphone")])
227
+ })
228
+ })
229
+
230
+ describe("normalizeSubjectFields — the cap the builders do not apply", () => {
231
+ it("slices a multi-pick dimension to its registry limit (jewelry = 3)", () => {
232
+ const ids = ["jewelry-subtle", "jewelry-statement", "jewelry-gold", "jewelry-silver", "jewelry-layered"]
233
+ expect(normalizeSubjectFields({ jewelry: ids })).toEqual({ jewelry: ids.slice(0, 3) })
234
+ const out = renderSubjectHints({ jewelry: ids }, IMAGE)
235
+ expect(out).toHaveLength(1)
236
+ expect(out[0]).toBe(ids.slice(0, 3).map(getStylingPromptHint).join(", "))
237
+ expect(out[0]).not.toContain(getStylingPromptHint("jewelry-silver"))
238
+ })
239
+
240
+ it("slices a 2-pick dimension handed the full array ceiling", () => {
241
+ const eight = Array.from({ length: SUBJECT_ARRAY_CEILING }, (_, i) => `texture-${i}`)
242
+ const normalized = normalizeSubjectFields({ skinTexture: eight }) as Record<string, unknown>
243
+ expect(normalized.skinTexture).toEqual(eight.slice(0, 2))
244
+ })
245
+
246
+ it("slices the prop rows to their row maxPicks", () => {
247
+ const props = ["smartphone", "smartphone-raised", "polaroid-camera"]
248
+ const normalized = normalizeSubjectFields({ heldProp: props }) as Record<string, unknown>
249
+ expect(normalized.heldProp).toEqual(props.slice(0, 2))
250
+ })
251
+
252
+ it("collapses a single-pick dimension's array to a bare string — without this it folds to NOTHING", () => {
253
+ expect(normalizeSubjectFields({ hairBase: ["base-buzz"] })).toEqual({ hairBase: "base-buzz" })
254
+ expect(renderSubjectHints({ hairBase: ["base-buzz"] }, IMAGE)).toEqual([
255
+ getPersonPromptHint("base-buzz"),
256
+ ])
257
+ })
258
+
259
+ it("drops unknown keys, so the persisted input_data stays platform vocabulary", () => {
260
+ expect(normalizeSubjectFields({ type: "man", notAField: "x", preText: "y" })).toEqual({
261
+ type: "man",
262
+ })
263
+ })
264
+
265
+ it("drops empty strings and non-string entries", () => {
266
+ expect(normalizeSubjectFields({ type: "", jewelry: ["", 7 as never, "jewelry-gold"] })).toEqual({
267
+ jewelry: "jewelry-gold",
268
+ })
269
+ })
270
+
271
+ it("returns undefined for an empty or all-unknown bag (never {})", () => {
272
+ expect(normalizeSubjectFields(undefined)).toBeUndefined()
273
+ expect(normalizeSubjectFields({})).toBeUndefined()
274
+ expect(normalizeSubjectFields({ notAField: "x" })).toBeUndefined()
275
+ })
276
+
277
+ it("never mutates the caller's object", () => {
278
+ const bag = { jewelry: ["jewelry-subtle", "jewelry-statement", "jewelry-gold", "jewelry-silver"] }
279
+ const before = JSON.stringify(bag)
280
+ normalizeSubjectFields(bag)
281
+ expect(JSON.stringify(bag)).toBe(before)
282
+ })
283
+
284
+ it("is idempotent (a door may normalize before the renderer does)", () => {
285
+ const bag = { type: "man", jewelry: ["jewelry-gold", "jewelry-silver"], customAge: 34.6 }
286
+ const once = normalizeSubjectFields(bag)
287
+ expect(normalizeSubjectFields(once)).toEqual(once)
288
+ })
289
+ })
290
+
291
+ describe("customAge — the one number on the wire", () => {
292
+ it("renders the literal age when age === 'age-custom'", () => {
293
+ expect(renderSubjectHints({ age: "age-custom", customAge: 34 }, IMAGE)).toEqual([
294
+ "34 years old",
295
+ ])
296
+ })
297
+
298
+ it("emits nothing for age-custom with no customAge", () => {
299
+ expect(renderSubjectHints({ age: "age-custom" }, IMAGE)).toEqual([])
300
+ })
301
+
302
+ it("clamps and rounds to a whole 0..120", () => {
303
+ expect(normalizeSubjectFields({ customAge: 34.6 })).toEqual({ customAge: 35 })
304
+ expect(normalizeSubjectFields({ customAge: -5 })).toEqual({ customAge: 0 })
305
+ expect(normalizeSubjectFields({ customAge: 9999 })).toEqual({ customAge: 120 })
306
+ })
307
+
308
+ it("drops a non-finite or non-number customAge", () => {
309
+ expect(normalizeSubjectFields({ customAge: Number.NaN })).toBeUndefined()
310
+ expect(normalizeSubjectFields({ customAge: "34" })).toBeUndefined()
311
+ })
312
+ })
@@ -16,17 +16,17 @@
16
16
  * This wrapper collapses them into one.
17
17
  *
18
18
  * THE NO-OP CONTRACT (load-bearing — the platform-caller parity relies on it):
19
- * a node that carries NO stored `direction` / `structured` (every workflow
20
- * authored before the canvas honored them) still reaches here with both absent,
21
- * and `composePromptText` MUST return the caller's `userPrompt` byte-for-byte
22
- * unchanged, so the wrapper degenerates to exactly the `buildImagePrompt(...)`
23
- * call those sites made before. The platform callers (`execute-node` /
24
- * `payload-builder`) compose their prompt from the canvas GRAPH themselves and
25
- * ALSO forward a node's STORED `direction` / `structured` when it carries them
26
- * (`readDirectionFields` / `readStructuredFields`); those nodes get the id-hint
27
- * composition on top, ADDITIVE to the graph-wired cinematography hints the
28
- * caller already folded into `userPrompt`. Studio and the MCP route supply the
29
- * same two levers directly.
19
+ * a node that carries NO stored `subject` / `direction` / `structured` (every
20
+ * workflow authored before the canvas honored them) still reaches here with all
21
+ * three absent, and `composePromptText` MUST return the caller's `userPrompt`
22
+ * byte-for-byte unchanged, so the wrapper degenerates to exactly the
23
+ * `buildImagePrompt(...)` call those sites made before. The platform callers
24
+ * (`execute-node` / `payload-builder`) compose their prompt from the canvas GRAPH themselves and
25
+ * ALSO forward a node's STORED `subject` / `direction` / `structured` when it
26
+ * carries them (`readSubjectFields` / `readDirectionFields` /
27
+ * `readStructuredFields`); those nodes get the id-hint composition on top,
28
+ * ADDITIVE to the graph-wired cinematography hints the caller already folded
29
+ * into `userPrompt`. Studio and the MCP route supply the levers directly.
30
30
  *
31
31
  * THE EMPTY-CHECK FLAG (also load-bearing for parity): `execute-node` rejects a
32
32
  * truly-empty assembled prompt (its "type one, mention a character, or connect
@@ -36,7 +36,7 @@
36
36
  * guard (frontend, Studio, route) pass `throwOnEmpty: true`.
37
37
  */
38
38
  import {
39
- buildImagePrompt,
39
+ buildImagePromptWithOverflow,
40
40
  type BuildImagePromptResult,
41
41
  } from "./prompt-builder.js"
42
42
  import {
@@ -44,11 +44,22 @@ import {
44
44
  type StructuredPromptFields,
45
45
  } from "./prompt-builder-structured-fields.js"
46
46
  import {
47
- renderDirectionHints,
48
47
  IMAGE_HINT_MODE_DEFAULT,
49
48
  type DirectionFields,
50
49
  } from "./direction-registry.js"
51
- import { joinPromptHints } from "./prompt-hint-join.js"
50
+ import {
51
+ renderSubjectHints,
52
+ SUBJECT_IMAGE_HINT_MODE_DEFAULT,
53
+ type SubjectFields,
54
+ } from "./subject-registry.js"
55
+ import {
56
+ asBodyClauses,
57
+ composeSectionedPrompt,
58
+ partitionStyleClauses,
59
+ sectionedClauseCosts,
60
+ type SlottedPromptClause,
61
+ } from "./prompt-style-section.js"
62
+ import { keepableDirectionHints } from "./hint-shedding.js"
52
63
  import type { CharacterDef, ConnectedReference, IdentityMeta } from "@nodaro/shared"
53
64
 
54
65
  /**
@@ -62,6 +73,14 @@ import type { CharacterDef, ConnectedReference, IdentityMeta } from "@nodaro/sha
62
73
  */
63
74
  export type { DirectionFields }
64
75
 
76
+ /**
77
+ * Flat SUBJECT ids (Person / Styling / prop catalogs) — the companion channel
78
+ * to `direction`, describing WHO is in the shot rather than how it is shot. Its
79
+ * table, fold order and per-catalog rendering live in `subject-registry.ts`;
80
+ * this re-export keeps one import path for a consumer that takes both levers.
81
+ */
82
+ export type { SubjectFields }
83
+
65
84
  /**
66
85
  * Input to `assembleImageInput`. A faithful SUPERSET of what the two platform
67
86
  * callers pass to `buildImagePrompt` today (so they can route through this
@@ -86,6 +105,13 @@ export interface AssembleImageInput {
86
105
  * is a no-op for it and the result is byte-identical to today).
87
106
  */
88
107
  direction?: DirectionFields
108
+ /**
109
+ * Flat subject ids (Person / Styling / props) → folded into the prompt AHEAD
110
+ * of the direction clauses: the subject is the noun phrase the cinematography
111
+ * then modifies. Same provenance as `direction` — Studio / MCP-route, or the
112
+ * platform callers' narrow-read of a node's STORED `data.subject`.
113
+ */
114
+ subject?: SubjectFields
89
115
  /** Path-1 structured fields → composed fragment appended to the prompt. */
90
116
  structured?: StructuredPromptFields
91
117
  /**
@@ -143,52 +169,128 @@ export interface AssembleImageInput {
143
169
  }
144
170
 
145
171
  /**
146
- * Compose the cinematic-direction hints + structured-field fragment with the
147
- * user's prompt. `renderDirectionHints` folds the `direction` ids in the
148
- * registry's canonical table order (unknown keys and unknown ids contribute
149
- * nothing), and `renderStructuredFields` returns "" when nothing is populated —
150
- * so the structured fragment always lands LAST.
172
+ * The hint pieces a fold contributes, split by whether the assembler may SHED
173
+ * them under a provider prompt cap.
174
+ *
175
+ * `hintClauses` are the catalog-rendered clauses — the SUBJECT fold first, then
176
+ * the cinematic direction fold — decorative garnish next to a reference
177
+ * directive or the user's own prose, and the only thing this assembler drops
178
+ * when the prompt won't fit. They are ONE list because the shed walks it from
179
+ * the TAIL: direction leaves before subject, which is the right order (who is
180
+ * in the shot outranks how it is lit). `structuredFragment` is user CONTENT (a
181
+ * Path-1 structured field the caller populated), so it is sticky and always
182
+ * ends the BODY, exactly as before — the `[style]` section reads after it.
183
+ *
184
+ * Each clause carries the SLOT it reads in. The list order is the SURVIVAL
185
+ * order; it stopped being the string order when the look clauses lifted into
186
+ * the section (`prompt-style-section.ts`). On this surface every direction row
187
+ * is `look` — the registry has no image-surface motion row — so an image
188
+ * `[style]` section carries the whole direction fold.
189
+ */
190
+ interface ImageHintPieces {
191
+ /** Subject clauses then direction clauses, each in its registry's fold order. */
192
+ readonly hintClauses: readonly SlottedPromptClause[]
193
+ /** The structured-field fragment ("" when nothing is populated). */
194
+ readonly structuredFragment: string
195
+ }
196
+
197
+ /**
198
+ * Render the fold's hint pieces once, so the cap-aware retry can re-compose a
199
+ * SUBSET of them without re-rendering the catalogs. Each renderer folds its own
200
+ * channel in its registry's canonical table order (unknown keys and unknown ids
201
+ * contribute nothing), and `renderStructuredFields` returns "" when nothing is
202
+ * populated. Never mutates inputs.
203
+ *
204
+ * SUBJECT LEADS DIRECTION: the subject is the noun phrase ("a woman in her
205
+ * 30s, …") the cinematographic clauses then modify. With no `subject` the list
206
+ * IS the direction fold, so every existing caller sheds identically.
207
+ */
208
+ function renderImageHintPieces(
209
+ subject: SubjectFields | undefined,
210
+ direction: DirectionFields | undefined,
211
+ structured: StructuredPromptFields | undefined,
212
+ ): ImageHintPieces {
213
+ return {
214
+ hintClauses: [
215
+ ...asBodyClauses(
216
+ renderSubjectHints(subject, {
217
+ surface: "image",
218
+ mode: SUBJECT_IMAGE_HINT_MODE_DEFAULT,
219
+ }),
220
+ ),
221
+ ...partitionStyleClauses(direction, {
222
+ surface: "image",
223
+ mode: IMAGE_HINT_MODE_DEFAULT,
224
+ }),
225
+ ].filter((c) => c.text.length > 0),
226
+ structuredFragment: structured ? renderStructuredFields(structured) : "",
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Compose the subject + cinematic-direction hints and the structured-field
232
+ * fragment with the user's prompt, keeping the FIRST `keptHintClauses` clauses
233
+ * (the full count on the first pass; fewer only when the provider cap forced a
234
+ * shed). The structured fragment always ends the body; the look clauses that
235
+ * survived follow it in the `[style]` section.
151
236
  *
152
- * EXACT NO-OP CONTRACT: when there are no cinematic/structured hint pieces (the
153
- * platform-caller case for a node that carries no stored `direction`/
154
- * `structured` — every workflow authored before the canvas honored them), the
155
- * user's prompt is returned **verbatim, untrimmed** by `joinPromptHints`. This
156
- * is load-bearing for parity: the old platform path passed the prompt straight
157
- * to `buildImagePrompt`, which never trims, so trimming here would change the
237
+ * EXACT NO-OP CONTRACT: when there are no subject/cinematic/structured hint
238
+ * pieces (the platform-caller case for a node that carries no stored `subject`/
239
+ * `direction`/`structured` — every workflow authored before the canvas honored
240
+ * them), the
241
+ * user's prompt is returned **verbatim, untrimmed**. This is load-bearing for
242
+ * parity: the old platform path passed the prompt straight to
243
+ * `buildImagePrompt`, which never trims, so trimming here would change the
158
244
  * assembled prompt (and the recorded `jobs.input_data`) byte-for-byte. Never
159
245
  * mutates inputs.
160
246
  *
161
- * A node that DOES carry `direction`/`structured` takes the join branch and is
162
- * therefore trimmed + `". "`-joined — intended, and asserted at the caller
163
- * level by the payload-builder before/after test.
247
+ * A node that DOES carry `direction`/`structured` takes the fold branch and is
248
+ * therefore trimmed — a `[style]` section counts as folded even when the body
249
+ * gained nothing. Intended, and asserted at the caller level by the
250
+ * payload-builder before/after test.
164
251
  */
165
252
  function composePromptText(
166
253
  userPrompt: string,
167
- direction: DirectionFields | undefined,
168
- structured: StructuredPromptFields | undefined,
254
+ pieces: ImageHintPieces,
255
+ keptHintClauses: number,
169
256
  ): string {
170
- const hints = [
171
- ...renderDirectionHints(direction, { surface: "image", mode: IMAGE_HINT_MODE_DEFAULT }),
172
- structured ? renderStructuredFields(structured) : "",
173
- ].filter((p) => p.length > 0)
174
- return joinPromptHints(userPrompt, hints)
257
+ return composeSectionedPrompt(
258
+ userPrompt,
259
+ pieces.hintClauses.slice(0, keptHintClauses),
260
+ pieces.structuredFragment,
261
+ )
175
262
  }
176
263
 
177
264
  /**
178
265
  * Assemble a node's image-generation inputs into a `BuildImagePromptResult`
179
266
  * (`{ prompt, nativeNegativePrompt, referenceImageUrls }`).
180
267
  *
181
- * Order: (1) compose the prompt text (no-op when no direction/structured),
268
+ * Order: (1) compose the prompt text (no-op when no subject/direction/structured),
182
269
  * (2) `buildImagePrompt(...)` — exactly the call the three sites make today,
183
- * (3) optional post-assembly empty-prompt throw (gated by `throwOnEmpty`).
270
+ * (3) shed hint clauses and re-assemble while the provider cap overflows,
271
+ * (4) optional post-assembly empty-prompt throw (gated by `throwOnEmpty`).
272
+ *
273
+ * TRUNCATION ORDERING (step 3): `buildImagePrompt`'s cap clamp cuts the TAIL,
274
+ * which is ORDER-BLIND — on a low-cap provider (seedream = 3000) a maximal
275
+ * direction fold renders ~3.3K characters of clauses and the cut can sever a
276
+ * reference directive, mention-resolved text or the user's own prose while a
277
+ * decorative clause survives. So the ASSEMBLER decides instead: it knows which
278
+ * clauses are hints because it just built them, and drops them last-folded
279
+ * first until the prompt fits. Everything else — references, prose, the
280
+ * structured fragment, the Style/Avoid suffixes — outranks a hint. A body that
281
+ * still overflows with ZERO hints (long prose or many directives on its own)
282
+ * falls back to the builder's clamp, unchanged.
283
+ *
284
+ * UNDER-CAP PARITY: the first pass folds every hint, so a prompt that fits is
285
+ * byte-identical to before — the retry only ever runs on an over-cap assembly.
184
286
  */
185
287
  export function assembleImageInput(
186
288
  input: AssembleImageInput,
187
289
  ): BuildImagePromptResult {
188
- const prompt = composePromptText(input.userPrompt, input.direction, input.structured)
290
+ const pieces = renderImageHintPieces(input.subject, input.direction, input.structured)
189
291
 
190
- const result = buildImagePrompt({
191
- prompt,
292
+ const assembleWith = (keptHintClauses: number) => buildImagePromptWithOverflow({
293
+ prompt: composePromptText(input.userPrompt, pieces, keptHintClauses),
192
294
  provider: input.provider,
193
295
  ...(input.connectedReferences !== undefined
194
296
  ? { connectedReferences: input.connectedReferences }
@@ -222,8 +324,34 @@ export function assembleImageInput(
222
324
  : {}),
223
325
  })
224
326
 
327
+ // Fold everything first (the under-cap byte-parity pass), then shed hints
328
+ // from the tail of the COMBINED fold order (subject clauses first in the list,
329
+ // therefore last to leave) while the assembled prompt overflows the provider
330
+ // cap. `keepableDirectionHints` — the one shed arithmetic, shared with
331
+ // `composeVideoPromptText` — strictly decreases `kept` whenever there IS an
332
+ // overflow, so this terminates at `kept === 0` in the worst case, at which
333
+ // point the body overflows on its own and the builder's clamp stands.
334
+ let kept = pieces.hintClauses.length
335
+ let fitted = assembleWith(kept)
336
+ if (fitted.overflowChars > 0) {
337
+ // Priced only on the overflow path: the deltas cost a composition per
338
+ // clause and the fits-first-time case is the common one.
339
+ const costs = sectionedClauseCosts(
340
+ input.userPrompt,
341
+ pieces.hintClauses,
342
+ pieces.structuredFragment,
343
+ )
344
+ const texts = pieces.hintClauses.map((c) => c.text)
345
+ while (fitted.overflowChars > 0 && kept > 0) {
346
+ kept = keepableDirectionHints(texts, kept, fitted.overflowChars, costs)
347
+ fitted = assembleWith(kept)
348
+ }
349
+ }
350
+ // `overflowChars` is assembly bookkeeping, not part of the callers' contract.
351
+ const { overflowChars, ...result } = fitted
352
+
225
353
  // Post-assembly empty-prompt check (opt-in): a bound entity / `@`-mention /
226
- // direction chip could have filled the assembled prompt even if the user
354
+ // subject or direction chip could have filled the assembled prompt even if the user
227
355
  // typed nothing — so only reject when the FINAL prompt is truly empty.
228
356
  if (input.throwOnEmpty && !result.prompt.trim()) {
229
357
  throw new Error(