@nodaro/prompts 1.16.0 → 1.17.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,595 @@
1
+ /**
2
+ * Described references (`DescribedReference` — a name + a description, no
3
+ * media) and the per-use `descriptionOverride`, on the IMAGE lane, in BOTH
4
+ * reference formats.
5
+ *
6
+ * Contract under test:
7
+ * - a described reference reaches the model as `<Name> — <description>.` —
8
+ * bulleted into the legacy "Use these characters:" block, a trailing scene
9
+ * directive in hybrid — even when the request carries NO connected
10
+ * references at all (the story-landing case: a cast role nothing is bound
11
+ * to yet).
12
+ * - `descriptionOverride` is the reference's identity description for THIS
13
+ * use: it fills the description slot a legacy bullet (and the hybrid
14
+ * extras' `, <desc>` clause) already has, winning over the entity's stored
15
+ * canonical description AND over the ref's own `description`; where hybrid
16
+ * has no such slot it adds ONE `reference image A — <override>.` line.
17
+ * - neither field present → byte-identical output.
18
+ */
19
+
20
+ import { describe, it, expect } from "vitest"
21
+ import { getMaxImagePromptChars } from "@nodaro/shared"
22
+ import type { ConnectedReference, DescribedReference } from "@nodaro/shared"
23
+ import { buildImagePrompt } from "../prompt-builder.js"
24
+ import {
25
+ appendReferenceLines,
26
+ referenceDescriptionLine,
27
+ renderDescribedReferenceLines,
28
+ renderReferenceCaptionLines,
29
+ } from "../described-references.js"
30
+
31
+ const PROVIDER = "nano-banana-pro" // in MODELS_WITH_REFERENCE_IMAGE_SUPPORT
32
+
33
+ const described: DescribedReference[] = [
34
+ { name: "Natalie", description: "a tall woman in a red coat" },
35
+ ]
36
+
37
+ describe("renderDescribedReferenceLines", () => {
38
+ it("renders `<Name> — <description>.`", () => {
39
+ expect(renderDescribedReferenceLines(described)).toEqual([
40
+ "Natalie — a tall woman in a red coat.",
41
+ ])
42
+ })
43
+
44
+ it("drops an entry missing either half and dedups by name", () => {
45
+ expect(
46
+ renderDescribedReferenceLines([
47
+ { name: " ", description: "nameless" },
48
+ { name: "Jack", description: " " },
49
+ { name: "Natalie", description: "a tall woman" },
50
+ { name: "natalie", description: "a second take" },
51
+ ]),
52
+ ).toEqual(["Natalie — a tall woman."])
53
+ })
54
+
55
+ it("returns nothing for an absent or empty list", () => {
56
+ expect(renderDescribedReferenceLines(undefined)).toEqual([])
57
+ expect(renderDescribedReferenceLines([])).toEqual([])
58
+ })
59
+ })
60
+
61
+ describe("referenceDescriptionLine", () => {
62
+ it("takes the binding as the subject for a per-use override", () => {
63
+ expect(referenceDescriptionLine("reference image A", "in a red coat")).toBe(
64
+ "reference image A — in a red coat.",
65
+ )
66
+ })
67
+
68
+ it("is empty when either half is blank", () => {
69
+ expect(referenceDescriptionLine("reference image A", " ")).toBe("")
70
+ expect(referenceDescriptionLine("", "in a red coat")).toBe("")
71
+ expect(referenceDescriptionLine("reference image A", undefined)).toBe("")
72
+ })
73
+ })
74
+
75
+ describe("renderReferenceCaptionLines", () => {
76
+ it("renders index-aligned `@video_N:` / `@audio_N:` lines", () => {
77
+ expect(
78
+ renderReferenceCaptionLines(
79
+ ["the establishing drone shot", "the close-up"],
80
+ ["the score"],
81
+ { video: 2, audio: 1 },
82
+ ),
83
+ ).toEqual([
84
+ "@video_1: the establishing drone shot.",
85
+ "@video_2: the close-up.",
86
+ "@audio_1: the score.",
87
+ ])
88
+ })
89
+
90
+ it("keeps the alignment when a caption is blank and stops at the shipped count", () => {
91
+ expect(
92
+ renderReferenceCaptionLines(["", "the close-up", "dropped by the cap"], undefined, {
93
+ video: 2,
94
+ audio: 0,
95
+ }),
96
+ ).toEqual(["@video_2: the close-up."])
97
+ })
98
+ })
99
+
100
+ describe("appendReferenceLines", () => {
101
+ it("creates a legacy block ahead of the body", () => {
102
+ expect(appendReferenceLines("A woman walks.", ["Natalie — tall."], "legacy")).toBe(
103
+ "Use these characters:\n- Natalie — tall.\n\nA woman walks.",
104
+ )
105
+ })
106
+
107
+ it("consolidates into an existing legacy block", () => {
108
+ const prompt = "Use these characters:\n- Image 1 (Kira) — match exactly.\n\nA woman walks."
109
+ expect(appendReferenceLines(prompt, ["Natalie — tall."], "legacy")).toBe(
110
+ "Use these characters:\n- Image 1 (Kira) — match exactly.\n- Natalie — tall.\n\nA woman walks.",
111
+ )
112
+ })
113
+
114
+ it("appends hybrid lines to the body", () => {
115
+ expect(appendReferenceLines("A woman walks.", ["Natalie — tall."], "hybrid")).toBe(
116
+ "A woman walks.\nNatalie — tall.",
117
+ )
118
+ })
119
+
120
+ it("returns the prompt untouched with no lines", () => {
121
+ expect(appendReferenceLines("A woman walks.", [], "legacy")).toBe("A woman walks.")
122
+ expect(appendReferenceLines("A woman walks.", [], "hybrid")).toBe("A woman walks.")
123
+ })
124
+ })
125
+
126
+ describe("buildImagePrompt — described references with no connected references", () => {
127
+ it("bullets them into a legacy block", () => {
128
+ const { prompt, referenceImageUrls } = buildImagePrompt({
129
+ provider: PROVIDER,
130
+ prompt: "Natalie walks down the pier.",
131
+ describedReferences: described,
132
+ })
133
+ expect(prompt).toBe(
134
+ "Use these characters:\n- Natalie — a tall woman in a red coat.\n\nNatalie walks down the pier.",
135
+ )
136
+ // A described reference carries no url — nothing to attach.
137
+ expect(referenceImageUrls).toBeUndefined()
138
+ })
139
+
140
+ it("appends them as hybrid trailing directives", () => {
141
+ const { prompt } = buildImagePrompt({
142
+ provider: PROVIDER,
143
+ prompt: "Natalie walks down the pier.",
144
+ describedReferences: described,
145
+ referenceFormat: "hybrid",
146
+ })
147
+ expect(prompt).toBe("Natalie walks down the pier.\nNatalie — a tall woman in a red coat.")
148
+ })
149
+
150
+ it("keeps them inside the body, ahead of the [style] section", () => {
151
+ const { prompt } = buildImagePrompt({
152
+ provider: PROVIDER,
153
+ prompt: "Natalie walks down the pier.\n\n[style]:\nsoft dusk light",
154
+ describedReferences: described,
155
+ referenceFormat: "hybrid",
156
+ })
157
+ expect(prompt).toBe(
158
+ "Natalie walks down the pier.\nNatalie — a tall woman in a red coat.\n\n[style]:\nsoft dusk light",
159
+ )
160
+ })
161
+
162
+ it("is byte-identical to the same call without the field when the list is empty", () => {
163
+ const base = { provider: PROVIDER, prompt: "Natalie walks down the pier." }
164
+ expect(buildImagePrompt({ ...base, describedReferences: [] }).prompt).toBe(
165
+ buildImagePrompt(base).prompt,
166
+ )
167
+ })
168
+
169
+ it("rides the provider prompt cap like any other body text", () => {
170
+ const cap = getMaxImagePromptChars(PROVIDER)
171
+ const { prompt } = buildImagePrompt({
172
+ provider: PROVIDER,
173
+ prompt: "A woman walks.",
174
+ describedReferences: [{ name: "Natalie", description: "x".repeat(cap + 1000) }],
175
+ })
176
+ expect(prompt.length).toBeLessThanOrEqual(cap)
177
+ expect(prompt.endsWith("...")).toBe(true)
178
+ })
179
+ })
180
+
181
+ describe("buildImagePrompt — described references alongside connected references", () => {
182
+ const kira: ConnectedReference = {
183
+ id: "c1",
184
+ defaultName: "Kira",
185
+ source: "wired-character",
186
+ characterSlug: "kira",
187
+ url: "https://r2/kira.png",
188
+ characterCanonicalDescription: "a woman with short black hair",
189
+ }
190
+
191
+ it("consolidates into the one legacy character block", () => {
192
+ const { prompt } = buildImagePrompt({
193
+ provider: PROVIDER,
194
+ prompt: "Kira meets Natalie.",
195
+ connectedReferences: [kira],
196
+ describedReferences: described,
197
+ })
198
+ expect(prompt).toBe(
199
+ "Use these characters:\n"
200
+ + "- Image 1 (Kira) — a woman with short black hair. The subject must remain exactly the same person"
201
+ + " — preserve 100% facial identity, bone structure, skin tone, proportions, and all unique features."
202
+ + " Do not alter eyes, nose, mouth, or facial shape. Maintain natural skin texture, including pores"
203
+ + " and imperfections.\n"
204
+ + "- Natalie — a tall woman in a red coat.\n\n"
205
+ + "Kira meets Natalie.",
206
+ )
207
+ })
208
+
209
+ it("makes ONE block when the references produce the 'Use these references' wrap", () => {
210
+ // A non-character ref with no wired character takes the legacy PREPEND
211
+ // branch ("Use these references for the output image:… Compose them
212
+ // naturally…"). The described block must be the block that wrap folds
213
+ // into — never a second header stacked above it.
214
+ const { prompt } = buildImagePrompt({
215
+ provider: PROVIDER,
216
+ prompt: "A still life with {image:1:vase}.",
217
+ connectedReferences: [
218
+ { id: "o1", defaultName: "Vase", source: "wired-object", url: "https://r2/vase.png", description: "a blue vase" },
219
+ ],
220
+ describedReferences: described,
221
+ })
222
+ expect(prompt.match(/Use these characters:/g)).toHaveLength(1)
223
+ expect(prompt).not.toContain("Use these references for the output image:")
224
+ expect(prompt).toBe(
225
+ "Use these characters:\n"
226
+ + "- Natalie — a tall woman in a red coat.\n"
227
+ + "- Image 1 (vase — a blue vase) — match exactly. Maintain perfect likeness.\n\n"
228
+ + "A still life with Image 1 (vase).",
229
+ )
230
+ })
231
+
232
+ it("lands after the hybrid role phrases", () => {
233
+ const { prompt } = buildImagePrompt({
234
+ provider: PROVIDER,
235
+ prompt: "Kira meets Natalie.",
236
+ connectedReferences: [kira],
237
+ describedReferences: described,
238
+ referenceFormat: "hybrid",
239
+ })
240
+ expect(prompt).toBe(
241
+ "Kira meets Natalie.\n"
242
+ + "the person from reference image A\n"
243
+ + "Natalie — a tall woman in a red coat.",
244
+ )
245
+ })
246
+ })
247
+
248
+ describe("descriptionOverride — legacy", () => {
249
+ const withOverride: ConnectedReference = {
250
+ id: "c1",
251
+ defaultName: "Kira",
252
+ source: "wired-character",
253
+ characterSlug: "kira",
254
+ url: "https://r2/kira.png",
255
+ characterCanonicalDescription: "a woman with short black hair",
256
+ descriptionOverride: "a woman with a shaved head and a scar",
257
+ }
258
+
259
+ it("replaces the canonical description on the canonical fallback", () => {
260
+ const { prompt } = buildImagePrompt({
261
+ provider: PROVIDER,
262
+ prompt: "Kira walks.",
263
+ connectedReferences: [withOverride],
264
+ })
265
+ expect(prompt).toContain("- Image 1 (Kira) — a woman with a shaved head and a scar.")
266
+ expect(prompt).not.toContain("short black hair")
267
+ })
268
+
269
+ it("replaces the canonical description on an @-mention", () => {
270
+ const { prompt } = buildImagePrompt({
271
+ provider: PROVIDER,
272
+ prompt: "@kira:1 walks.",
273
+ connectedReferences: [withOverride],
274
+ })
275
+ expect(prompt).toContain("- Image 1 (Kira) — a woman with a shaved head and a scar.")
276
+ expect(prompt).not.toContain("short black hair")
277
+ })
278
+
279
+ it("is honored in a mode that suppresses the canonical description", () => {
280
+ // "emotion" gates the canonical description off — an explicit per-use
281
+ // override is the caller speaking, so it rides every mode that emits a
282
+ // bullet at all.
283
+ const { prompt } = buildImagePrompt({
284
+ provider: PROVIDER,
285
+ prompt: "Kira walks.",
286
+ connectedReferences: [{ ...withOverride, defaultUsageMode: "emotion" }],
287
+ })
288
+ expect(prompt).toContain("- Image 1 (Kira) — a woman with a shaved head and a scar.")
289
+ })
290
+
291
+ it("emits nothing extra in a mode that emits no bullet", () => {
292
+ const { prompt } = buildImagePrompt({
293
+ provider: PROVIDER,
294
+ prompt: "Kira walks.",
295
+ connectedReferences: [{ ...withOverride, defaultUsageMode: "none" }],
296
+ })
297
+ expect(prompt).toBe("Kira walks.")
298
+ })
299
+
300
+ it("wins over an extra-ref's own description", () => {
301
+ const { prompt } = buildImagePrompt({
302
+ provider: PROVIDER,
303
+ prompt: "A still life.",
304
+ connectedReferences: [
305
+ {
306
+ id: "m1",
307
+ defaultName: "Vase",
308
+ source: "manual",
309
+ url: "https://r2/vase.png",
310
+ isExtraRef: true,
311
+ description: "a blue vase",
312
+ descriptionOverride: "a cracked terracotta urn",
313
+ },
314
+ ],
315
+ })
316
+ expect(prompt).toContain("- Image 1 (reference): a cracked terracotta urn.")
317
+ expect(prompt).not.toContain("blue vase")
318
+ })
319
+
320
+ it("wins over a MENTIONED location's canonical description", () => {
321
+ const { prompt } = buildImagePrompt({
322
+ provider: PROVIDER,
323
+ prompt: "A wide shot of @old-library:1.",
324
+ connectedReferences: [
325
+ {
326
+ id: "l1",
327
+ defaultName: "Old Library",
328
+ source: "wired-location",
329
+ locationSlug: "old-library",
330
+ url: "https://r2/library.png",
331
+ locationCanonicalDescription: "a dusty reading room",
332
+ descriptionOverride: "a flooded reading room at night",
333
+ },
334
+ ],
335
+ })
336
+ expect(prompt).toContain("- Image 1 (Old Library) — a flooded reading room at night.")
337
+ expect(prompt).not.toContain("dusty reading room")
338
+ })
339
+
340
+ it("fills the descriptor of a `{image:N:label}` identity directive", () => {
341
+ // The token path reads its description through `collectIdentities`, which is
342
+ // what feeds the `{image:N:label}` directive — so the override lands in the
343
+ // subject's parenthetical, ahead of the ref's own `description`.
344
+ const { prompt } = buildImagePrompt({
345
+ provider: PROVIDER,
346
+ prompt: "A still life with {image:1:vase}.",
347
+ connectedReferences: [
348
+ {
349
+ id: "o1",
350
+ defaultName: "Vase",
351
+ source: "wired-object",
352
+ url: "https://r2/vase.png",
353
+ description: "a blue vase",
354
+ descriptionOverride: "a cracked urn",
355
+ },
356
+ ],
357
+ })
358
+ expect(prompt).toContain("- Image 1 (vase — a cracked urn) — match exactly.")
359
+ expect(prompt).not.toContain("blue vase")
360
+ })
361
+
362
+ it("wins over an UNMENTIONED wired location's canonical description", () => {
363
+ const { prompt } = buildImagePrompt({
364
+ provider: PROVIDER,
365
+ prompt: "A wide shot.",
366
+ connectedReferences: [
367
+ {
368
+ id: "l1",
369
+ defaultName: "Old Library",
370
+ source: "wired-location",
371
+ locationSlug: "old-library",
372
+ url: "https://r2/library.png",
373
+ locationCanonicalDescription: "a dusty reading room",
374
+ descriptionOverride: "a flooded reading room at night",
375
+ },
376
+ ],
377
+ })
378
+ expect(prompt).toContain("a flooded reading room at night")
379
+ expect(prompt).not.toContain("dusty reading room")
380
+ })
381
+ })
382
+
383
+ describe("descriptionOverride — hybrid", () => {
384
+ const kira: ConnectedReference = {
385
+ id: "c1",
386
+ defaultName: "Kira",
387
+ source: "wired-character",
388
+ characterSlug: "kira",
389
+ url: "https://r2/kira.png",
390
+ characterCanonicalDescription: "a woman with short black hair",
391
+ descriptionOverride: "a woman with a shaved head and a scar",
392
+ }
393
+
394
+ it("adds one binding-subject line for a canonical fallback", () => {
395
+ const { prompt } = buildImagePrompt({
396
+ provider: PROVIDER,
397
+ prompt: "Kira walks.",
398
+ connectedReferences: [kira],
399
+ referenceFormat: "hybrid",
400
+ })
401
+ expect(prompt).toBe(
402
+ "Kira walks.\n"
403
+ + "the person from reference image A\n"
404
+ + "reference image A — a woman with a shaved head and a scar.",
405
+ )
406
+ })
407
+
408
+ it("adds one binding-subject line for an @-mention", () => {
409
+ const { prompt } = buildImagePrompt({
410
+ provider: PROVIDER,
411
+ prompt: "@kira:1 walks.",
412
+ connectedReferences: [kira],
413
+ referenceFormat: "hybrid",
414
+ })
415
+ expect(prompt).toBe(
416
+ "the person from reference image A walks.\n"
417
+ + "reference image A — a woman with a shaved head and a scar.",
418
+ )
419
+ })
420
+
421
+ it("adds one binding-subject line for a wired location", () => {
422
+ const { prompt } = buildImagePrompt({
423
+ provider: PROVIDER,
424
+ prompt: "A wide shot.",
425
+ connectedReferences: [
426
+ {
427
+ id: "l1",
428
+ defaultName: "Old Library",
429
+ source: "wired-location",
430
+ locationSlug: "old-library",
431
+ url: "https://r2/library.png",
432
+ locationCanonicalDescription: "a dusty reading room",
433
+ descriptionOverride: "a flooded reading room at night",
434
+ },
435
+ ],
436
+ referenceFormat: "hybrid",
437
+ })
438
+ expect(prompt).toContain("reference image A — a flooded reading room at night.")
439
+ })
440
+
441
+ it("adds one binding-subject line for a wired object", () => {
442
+ const { prompt } = buildImagePrompt({
443
+ provider: PROVIDER,
444
+ prompt: "A still life.",
445
+ connectedReferences: [
446
+ {
447
+ id: "o1",
448
+ defaultName: "Vase",
449
+ source: "wired-object",
450
+ url: "https://r2/vase.png",
451
+ descriptionOverride: "a cracked terracotta urn",
452
+ },
453
+ ],
454
+ referenceFormat: "hybrid",
455
+ })
456
+ expect(prompt).toContain("reference image A — a cracked terracotta urn.")
457
+ })
458
+
459
+ it("fills the extras' own description clause instead of adding a line", () => {
460
+ const { prompt } = buildImagePrompt({
461
+ provider: PROVIDER,
462
+ prompt: "A still life.",
463
+ connectedReferences: [
464
+ {
465
+ id: "m1",
466
+ defaultName: "Vase",
467
+ source: "manual",
468
+ url: "https://r2/vase.png",
469
+ isExtraRef: true,
470
+ description: "a blue vase",
471
+ descriptionOverride: "a cracked terracotta urn",
472
+ },
473
+ ],
474
+ referenceFormat: "hybrid",
475
+ })
476
+ expect(prompt).toBe("A still life.\na cracked terracotta urn (reference image A).")
477
+ expect(prompt).not.toContain("blue vase")
478
+ })
479
+
480
+ // References the hybrid format renders NO role phrase for: a `{image:N:label}`
481
+ // token expands the ref inline (and suppresses both canonical renders), and an
482
+ // unmentioned `wired-image` / `manual` ref has no canonical render at all. The
483
+ // per-use override still has to reach the model — same one-line grammar, bound
484
+ // to the seat the reference actually ships in.
485
+ const boat: ConnectedReference = {
486
+ id: "i1",
487
+ defaultName: "Boat",
488
+ source: "wired-image",
489
+ url: "https://r2/boat.png",
490
+ descriptionOverride: "a wrecked boat at dawn",
491
+ }
492
+ const vase: ConnectedReference = {
493
+ id: "o1",
494
+ defaultName: "Vase",
495
+ source: "wired-object",
496
+ url: "https://r2/vase.png",
497
+ descriptionOverride: "a cracked urn",
498
+ }
499
+
500
+ it("adds one binding-subject line for an object a {image:N} token expanded", () => {
501
+ const { prompt } = buildImagePrompt({
502
+ provider: PROVIDER,
503
+ prompt: "A still life with {image:1:vase}.",
504
+ connectedReferences: [vase],
505
+ referenceFormat: "hybrid",
506
+ })
507
+ expect(prompt).toBe(
508
+ "A still life with the vase from reference image A.\n"
509
+ + "reference image A — a cracked urn.",
510
+ )
511
+ })
512
+
513
+ it("adds one binding-subject line for a wired image a {image:N} token expanded", () => {
514
+ const { prompt } = buildImagePrompt({
515
+ provider: PROVIDER,
516
+ prompt: "{image:1:boat} drifts.",
517
+ connectedReferences: [boat],
518
+ referenceFormat: "hybrid",
519
+ })
520
+ expect(prompt).toBe(
521
+ "The boat from reference image A drifts.\n"
522
+ + "reference image A — a wrecked boat at dawn.",
523
+ )
524
+ })
525
+
526
+ it("adds one binding-subject line for an unmentioned wired image", () => {
527
+ const { prompt } = buildImagePrompt({
528
+ provider: PROVIDER,
529
+ prompt: "A boat drifts.",
530
+ connectedReferences: [boat],
531
+ referenceFormat: "hybrid",
532
+ })
533
+ expect(prompt).toBe(
534
+ "A boat drifts.\nreference image A — a wrecked boat at dawn.",
535
+ )
536
+ })
537
+
538
+ it("adds one binding-subject line for a manual reference that is not an extra", () => {
539
+ const { prompt } = buildImagePrompt({
540
+ provider: PROVIDER,
541
+ prompt: "A still life.",
542
+ connectedReferences: [
543
+ {
544
+ id: "m1",
545
+ defaultName: "Vase",
546
+ source: "manual",
547
+ url: "https://r2/vase.png",
548
+ description: "a blue vase",
549
+ descriptionOverride: "a cracked urn",
550
+ },
551
+ ],
552
+ referenceFormat: "hybrid",
553
+ })
554
+ expect(prompt).toBe("A still life.\nreference image A — a cracked urn.")
555
+ })
556
+
557
+ it("leaves those same requests byte-identical without an override", () => {
558
+ const withoutOverride = (ref: ConnectedReference, text: string) =>
559
+ buildImagePrompt({
560
+ provider: PROVIDER,
561
+ prompt: text,
562
+ connectedReferences: [{ ...ref, descriptionOverride: undefined }],
563
+ referenceFormat: "hybrid",
564
+ }).prompt
565
+ expect(withoutOverride(vase, "A still life with {image:1:vase}.")).toBe(
566
+ "A still life with the vase from reference image A.",
567
+ )
568
+ expect(withoutOverride(boat, "{image:1:boat} drifts.")).toBe(
569
+ "The boat from reference image A drifts.",
570
+ )
571
+ expect(withoutOverride(boat, "A boat drifts.")).toBe("A boat drifts.")
572
+ })
573
+
574
+ it("tells a reference that is BOTH @-mentioned and token-covered once", () => {
575
+ const line = "reference image A — a wrecked boat at dawn."
576
+ const { prompt } = buildImagePrompt({
577
+ provider: PROVIDER,
578
+ prompt: "@boat:1 drifts past {image:1:boat}.",
579
+ connectedReferences: [boat],
580
+ referenceFormat: "hybrid",
581
+ })
582
+ expect(prompt.split(line)).toHaveLength(2)
583
+ })
584
+
585
+ it("tells a token-covered entity the @-mention already bound once", () => {
586
+ const line = "reference image A — a cracked urn."
587
+ const { prompt } = buildImagePrompt({
588
+ provider: PROVIDER,
589
+ prompt: "@vase:1 sits beside {image:1:vase}.",
590
+ connectedReferences: [vase],
591
+ referenceFormat: "hybrid",
592
+ })
593
+ expect(prompt.split(line)).toHaveLength(2)
594
+ })
595
+ })
@@ -60,7 +60,7 @@ import {
60
60
  type SlottedPromptClause,
61
61
  } from "./prompt-style-section.js"
62
62
  import { keepableDirectionHints } from "./hint-shedding.js"
63
- import type { CharacterDef, ConnectedReference, IdentityMeta } from "@nodaro/shared"
63
+ import type { CharacterDef, ConnectedReference, DescribedReference, IdentityMeta } from "@nodaro/shared"
64
64
 
65
65
  /**
66
66
  * Flat cinematic-direction ids the Studio framing UI, the MCP route and the
@@ -98,6 +98,13 @@ export interface AssembleImageInput {
98
98
  * (gated per provider there). Omit when the caller wires only raw URLs.
99
99
  */
100
100
  connectedReferences?: ConnectedReference[]
101
+ /**
102
+ * References the caller can NAME and DESCRIBE but has no media for — an
103
+ * un-bound cast role, an analysis slot. They attach no URL and claim no
104
+ * `Image N` slot; `buildImagePrompt` renders them as prose. Present with no
105
+ * `connectedReferences` is the normal case, so they are forwarded on their own.
106
+ */
107
+ describedReferences?: readonly DescribedReference[]
101
108
  /**
102
109
  * Flat cinematic-direction ids → folded into the prompt as hints. Studio /
103
110
  * MCP-route use, and the platform callers' narrow-read of a node's STORED
@@ -295,6 +302,9 @@ export function assembleImageInput(
295
302
  ...(input.connectedReferences !== undefined
296
303
  ? { connectedReferences: input.connectedReferences }
297
304
  : {}),
305
+ ...(input.describedReferences !== undefined
306
+ ? { describedReferences: input.describedReferences }
307
+ : {}),
298
308
  // Manual uploads / direct refs ride the builder's reference-URL channel so
299
309
  // the per-provider reference gate filters them alongside bound entities.
300
310
  // Omit the field entirely when absent so the builder's default ([]) kicks