@nodaro/prompts 1.11.0 → 1.12.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.
@@ -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 {
@@ -48,7 +48,13 @@ import {
48
48
  IMAGE_HINT_MODE_DEFAULT,
49
49
  type DirectionFields,
50
50
  } from "./direction-registry.js"
51
+ import {
52
+ renderSubjectHints,
53
+ SUBJECT_IMAGE_HINT_MODE_DEFAULT,
54
+ type SubjectFields,
55
+ } from "./subject-registry.js"
51
56
  import { joinPromptHints } from "./prompt-hint-join.js"
57
+ import { keepableDirectionHints } from "./hint-shedding.js"
52
58
  import type { CharacterDef, ConnectedReference, IdentityMeta } from "@nodaro/shared"
53
59
 
54
60
  /**
@@ -62,6 +68,14 @@ import type { CharacterDef, ConnectedReference, IdentityMeta } from "@nodaro/sha
62
68
  */
63
69
  export type { DirectionFields }
64
70
 
71
+ /**
72
+ * Flat SUBJECT ids (Person / Styling / prop catalogs) — the companion channel
73
+ * to `direction`, describing WHO is in the shot rather than how it is shot. Its
74
+ * table, fold order and per-catalog rendering live in `subject-registry.ts`;
75
+ * this re-export keeps one import path for a consumer that takes both levers.
76
+ */
77
+ export type { SubjectFields }
78
+
65
79
  /**
66
80
  * Input to `assembleImageInput`. A faithful SUPERSET of what the two platform
67
81
  * callers pass to `buildImagePrompt` today (so they can route through this
@@ -86,6 +100,13 @@ export interface AssembleImageInput {
86
100
  * is a no-op for it and the result is byte-identical to today).
87
101
  */
88
102
  direction?: DirectionFields
103
+ /**
104
+ * Flat subject ids (Person / Styling / props) → folded into the prompt AHEAD
105
+ * of the direction clauses: the subject is the noun phrase the cinematography
106
+ * then modifies. Same provenance as `direction` — Studio / MCP-route, or the
107
+ * platform callers' narrow-read of a node's STORED `data.subject`.
108
+ */
109
+ subject?: SubjectFields
89
110
  /** Path-1 structured fields → composed fragment appended to the prompt. */
90
111
  structured?: StructuredPromptFields
91
112
  /**
@@ -143,15 +164,66 @@ export interface AssembleImageInput {
143
164
  }
144
165
 
145
166
  /**
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.
167
+ * The hint pieces a fold contributes, split by whether the assembler may SHED
168
+ * them under a provider prompt cap.
151
169
  *
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
170
+ * `hintClauses` are the catalog-rendered clauses — the SUBJECT fold first, then
171
+ * the cinematic direction fold — decorative garnish next to a reference
172
+ * directive or the user's own prose, and the only thing this assembler drops
173
+ * when the prompt won't fit. They are ONE list because the shed walks it from
174
+ * the TAIL: direction leaves before subject, which is the right order (who is
175
+ * in the shot outranks how it is lit). `structuredFragment` is user CONTENT (a
176
+ * Path-1 structured field the caller populated), so it is sticky and always
177
+ * lands LAST, exactly as before.
178
+ */
179
+ interface ImageHintPieces {
180
+ /** Subject clauses then direction clauses, each in its registry's fold order. */
181
+ readonly hintClauses: readonly string[]
182
+ /** The structured-field fragment ("" when nothing is populated). */
183
+ readonly structuredFragment: string
184
+ }
185
+
186
+ /**
187
+ * Render the fold's hint pieces once, so the cap-aware retry can re-join a
188
+ * SUBSET of them without re-rendering the catalogs. Each renderer folds its own
189
+ * channel in its registry's canonical table order (unknown keys and unknown ids
190
+ * contribute nothing), and `renderStructuredFields` returns "" when nothing is
191
+ * populated. Never mutates inputs.
192
+ *
193
+ * SUBJECT LEADS DIRECTION: the subject is the noun phrase ("a woman in her
194
+ * 30s, …") the cinematographic clauses then modify. With no `subject` the list
195
+ * IS the direction fold, so every existing caller's prompt is byte-identical.
196
+ */
197
+ function renderImageHintPieces(
198
+ subject: SubjectFields | undefined,
199
+ direction: DirectionFields | undefined,
200
+ structured: StructuredPromptFields | undefined,
201
+ ): ImageHintPieces {
202
+ return {
203
+ hintClauses: [
204
+ ...renderSubjectHints(subject, {
205
+ surface: "image",
206
+ mode: SUBJECT_IMAGE_HINT_MODE_DEFAULT,
207
+ }),
208
+ ...renderDirectionHints(direction, {
209
+ surface: "image",
210
+ mode: IMAGE_HINT_MODE_DEFAULT,
211
+ }),
212
+ ].filter((p) => p.length > 0),
213
+ structuredFragment: structured ? renderStructuredFields(structured) : "",
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Compose the subject + cinematic-direction hints and the structured-field
219
+ * fragment with the user's prompt, keeping the FIRST `keptHintClauses` clauses
220
+ * (the full count on the first pass; fewer only when the provider cap forced a
221
+ * shed). The structured fragment always lands LAST.
222
+ *
223
+ * EXACT NO-OP CONTRACT: when there are no subject/cinematic/structured hint
224
+ * pieces (the platform-caller case for a node that carries no stored `subject`/
225
+ * `direction`/`structured` — every workflow authored before the canvas honored
226
+ * them), the
155
227
  * user's prompt is returned **verbatim, untrimmed** by `joinPromptHints`. This
156
228
  * is load-bearing for parity: the old platform path passed the prompt straight
157
229
  * to `buildImagePrompt`, which never trims, so trimming here would change the
@@ -164,12 +236,12 @@ export interface AssembleImageInput {
164
236
  */
165
237
  function composePromptText(
166
238
  userPrompt: string,
167
- direction: DirectionFields | undefined,
168
- structured: StructuredPromptFields | undefined,
239
+ pieces: ImageHintPieces,
240
+ keptHintClauses: number,
169
241
  ): string {
170
242
  const hints = [
171
- ...renderDirectionHints(direction, { surface: "image", mode: IMAGE_HINT_MODE_DEFAULT }),
172
- structured ? renderStructuredFields(structured) : "",
243
+ ...pieces.hintClauses.slice(0, keptHintClauses),
244
+ pieces.structuredFragment,
173
245
  ].filter((p) => p.length > 0)
174
246
  return joinPromptHints(userPrompt, hints)
175
247
  }
@@ -178,17 +250,32 @@ function composePromptText(
178
250
  * Assemble a node's image-generation inputs into a `BuildImagePromptResult`
179
251
  * (`{ prompt, nativeNegativePrompt, referenceImageUrls }`).
180
252
  *
181
- * Order: (1) compose the prompt text (no-op when no direction/structured),
253
+ * Order: (1) compose the prompt text (no-op when no subject/direction/structured),
182
254
  * (2) `buildImagePrompt(...)` — exactly the call the three sites make today,
183
- * (3) optional post-assembly empty-prompt throw (gated by `throwOnEmpty`).
255
+ * (3) shed hint clauses and re-assemble while the provider cap overflows,
256
+ * (4) optional post-assembly empty-prompt throw (gated by `throwOnEmpty`).
257
+ *
258
+ * TRUNCATION ORDERING (step 3): `buildImagePrompt`'s cap clamp cuts the TAIL,
259
+ * which is ORDER-BLIND — on a low-cap provider (seedream = 3000) a maximal
260
+ * direction fold renders ~3.3K characters of clauses and the cut can sever a
261
+ * reference directive, mention-resolved text or the user's own prose while a
262
+ * decorative clause survives. So the ASSEMBLER decides instead: it knows which
263
+ * clauses are hints because it just built them, and drops them last-folded
264
+ * first until the prompt fits. Everything else — references, prose, the
265
+ * structured fragment, the Style/Avoid suffixes — outranks a hint. A body that
266
+ * still overflows with ZERO hints (long prose or many directives on its own)
267
+ * falls back to the builder's clamp, unchanged.
268
+ *
269
+ * UNDER-CAP PARITY: the first pass folds every hint, so a prompt that fits is
270
+ * byte-identical to before — the retry only ever runs on an over-cap assembly.
184
271
  */
185
272
  export function assembleImageInput(
186
273
  input: AssembleImageInput,
187
274
  ): BuildImagePromptResult {
188
- const prompt = composePromptText(input.userPrompt, input.direction, input.structured)
275
+ const pieces = renderImageHintPieces(input.subject, input.direction, input.structured)
189
276
 
190
- const result = buildImagePrompt({
191
- prompt,
277
+ const assembleWith = (keptHintClauses: number) => buildImagePromptWithOverflow({
278
+ prompt: composePromptText(input.userPrompt, pieces, keptHintClauses),
192
279
  provider: input.provider,
193
280
  ...(input.connectedReferences !== undefined
194
281
  ? { connectedReferences: input.connectedReferences }
@@ -222,8 +309,24 @@ export function assembleImageInput(
222
309
  : {}),
223
310
  })
224
311
 
312
+ // Fold everything first (the under-cap byte-parity pass), then shed hints
313
+ // from the tail of the COMBINED fold order (subject clauses first in the list,
314
+ // therefore last to leave) while the assembled prompt overflows the provider
315
+ // cap. `keepableDirectionHints` — the one shed arithmetic, shared with
316
+ // `composeVideoPromptText` — strictly decreases `kept` whenever there IS an
317
+ // overflow, so this terminates at `kept === 0` in the worst case, at which
318
+ // point the body overflows on its own and the builder's clamp stands.
319
+ let kept = pieces.hintClauses.length
320
+ let fitted = assembleWith(kept)
321
+ while (fitted.overflowChars > 0 && kept > 0) {
322
+ kept = keepableDirectionHints(pieces.hintClauses, kept, fitted.overflowChars)
323
+ fitted = assembleWith(kept)
324
+ }
325
+ // `overflowChars` is assembly bookkeeping, not part of the callers' contract.
326
+ const { overflowChars, ...result } = fitted
327
+
225
328
  // Post-assembly empty-prompt check (opt-in): a bound entity / `@`-mention /
226
- // direction chip could have filled the assembled prompt even if the user
329
+ // subject or direction chip could have filled the assembled prompt even if the user
227
330
  // typed nothing — so only reject when the FINAL prompt is truly empty.
228
331
  if (input.throwOnEmpty && !result.prompt.trim()) {
229
332
  throw new Error(