@nodaro/prompts 1.15.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.
package/src/person.ts CHANGED
@@ -437,10 +437,10 @@ export const PEOPLE: ReadonlyArray<Person> = [
437
437
  { id: "bust-small", label: "Small", dimension: "bust", description: "Small bust", promptHint: "small bust", term: "small bust" , adultOnly: true },
438
438
  { id: "bust-average", label: "Average", dimension: "bust", description: "Average bust", promptHint: "" },
439
439
  { id: "bust-full", label: "Full", dimension: "bust", description: "Full bust", promptHint: "full bust", term: "full bust" , adultOnly: true },
440
- { id: "bust-very-full", label: "Very Full", dimension: "bust", description: "Very full bust", promptHint: "very full bust", term: "very full bust" , adultOnly: true },
440
+ { id: "bust-very-full", label: "Very Full", dimension: "bust", description: "Very full bust", promptHint: "a fuller bust", term: "fuller bust" , adultOnly: true },
441
441
 
442
442
  // ----- Waist -----
443
- { id: "waist-defined", label: "Defined", dimension: "waist", description: "Defined, cinched waist", promptHint: "defined waist", term: "defined waist" , adultOnly: true },
443
+ { id: "waist-defined", label: "Defined", dimension: "waist", description: "Defined, cinched waist", promptHint: "a defined waistline", term: "defined waistline" , adultOnly: true },
444
444
  { id: "waist-average", label: "Average", dimension: "waist", description: "Average waist", promptHint: "" },
445
445
  { id: "waist-straight", label: "Straight", dimension: "waist", description: "Straight, undefined waist", promptHint: "straight waistline", term: "straight waistline" , adultOnly: true },
446
446
 
@@ -450,7 +450,7 @@ export const PEOPLE: ReadonlyArray<Person> = [
450
450
  { id: "hips-wide", label: "Wide", dimension: "hips", description: "Wide hips", promptHint: "wide hips", term: "wide hips" , adultOnly: true },
451
451
 
452
452
  // ----- Silhouette (overall body shape; optional, no neutral) -----
453
- { id: "silhouette-hourglass", label: "Hourglass", dimension: "silhouette", description: "Balanced bust + hips, defined waist", promptHint: "hourglass silhouette", term: "hourglass silhouette" , adultOnly: true },
453
+ { id: "silhouette-hourglass", label: "Hourglass", dimension: "silhouette", description: "Balanced bust + hips, defined waist", promptHint: "an hourglass figure", term: "hourglass figure" , adultOnly: true },
454
454
  { id: "silhouette-rectangular", label: "Rectangular", dimension: "silhouette", description: "Straight up-and-down silhouette", promptHint: "rectangular silhouette", term: "rectangular silhouette" },
455
455
  { id: "silhouette-pear", label: "Pear", dimension: "silhouette", description: "Hips wider than shoulders", promptHint: "pear-shaped silhouette", term: "pear-shaped silhouette" , adultOnly: true },
456
456
  { id: "silhouette-inverted", label: "Inverted Triangle", dimension: "silhouette", description: "Broad shoulders, narrow hips", promptHint: "inverted-triangle silhouette", term: "inverted-triangle silhouette" },
@@ -553,11 +553,11 @@ export const PEOPLE: ReadonlyArray<Person> = [
553
553
 
554
554
  // -------------------- Lip State (what the lips are doing / wearing) --------------------
555
555
  { id: "lip-state-chapped", label: "Chapped", dimension: "lip-state", description: "Cracked, dry, weather-worn lips", promptHint: "with chapped, cracked, weather-worn dry lips", term: "chapped dry lips" },
556
- { id: "lip-state-glossy", label: "Glossy", dimension: "lip-state", description: "High-shine, wet-look lips", promptHint: "with high-shine glossy wet-look lips", term: "glossy wet-look lips" , adultOnly: true },
556
+ { id: "lip-state-glossy", label: "Glossy", dimension: "lip-state", description: "High-shine, wet-look lips", promptHint: "with a glossy lip finish", term: "glossy lips" , adultOnly: true },
557
557
  { id: "lip-state-bare", label: "Bare", dimension: "lip-state", description: "Natural, untreated, no makeup", promptHint: "with bare, natural, untreated lips", term: "bare natural lips" },
558
558
  { id: "lip-state-bold-red", label: "Bold Red", dimension: "lip-state", description: "Saturated red lipstick statement", promptHint: "with a bold, saturated red lipstick statement", term: "bold red lipstick" },
559
- { id: "lip-state-bitten", label: "Bitten", dimension: "lip-state", description: "Slight playful lip-bite, lower lip caught", promptHint: "playfully biting the lower lip", term: "biting the lower lip" , adultOnly: true },
560
- { id: "lip-state-parted", label: "Parted", dimension: "lip-state", description: "Lips slightly parted, breath of air", promptHint: "with lips slightly parted, taking a soft breath", term: "lips slightly parted" , adultOnly: true },
559
+ { id: "lip-state-bitten", label: "Bitten", dimension: "lip-state", description: "Slight playful lip-bite, lower lip caught", promptHint: "lightly biting the lower lip", term: "biting lower lip" , adultOnly: true },
560
+ { id: "lip-state-parted", label: "Parted", dimension: "lip-state", description: "Lips slightly parted, breath of air", promptHint: "with lips relaxed and slightly open, as if mid-sentence", term: "lips slightly open" , adultOnly: true },
561
561
  { id: "lip-state-pursed", label: "Pursed", dimension: "lip-state", description: "Lips pressed and pushed forward", promptHint: "with lips pursed, pressed and pushed forward", term: "pursed lips" },
562
562
  { id: "lip-state-bold-black", label: "Bold Black", dimension: "lip-state", description: "Saturated black lipstick statement (goth / avant-garde)", promptHint: "with a bold, saturated black lipstick statement, goth / avant-garde", term: "bold black lipstick" },
563
563
  { id: "lip-state-burgundy", label: "Burgundy", dimension: "lip-state", description: "Deep wine-red lipstick", promptHint: "with deep wine-red burgundy lipstick", term: "burgundy lipstick" },
@@ -676,9 +676,9 @@ export const PEOPLE: ReadonlyArray<Person> = [
676
676
 
677
677
  // -------------------- Eye State (what the eyes are doing / where they look) --------------------
678
678
  { id: "eye-state-closed", label: "Closed", dimension: "eye-state", description: "Eyes fully closed, peaceful", promptHint: "with eyes fully closed in a peaceful expression", term: "eyes fully closed" },
679
- { id: "eye-state-half-lidded", label: "Half-lidded", dimension: "eye-state", description: "Heavy-lidded sleepy gaze", promptHint: "with heavy half-lidded sleepy eyes", term: "half-lidded sleepy eyes" , adultOnly: true },
679
+ { id: "eye-state-half-lidded", label: "Half-lidded", dimension: "eye-state", description: "Heavy-lidded sleepy gaze", promptHint: "with drowsy, partly closed eyes, the lids sitting low over the iris", term: "drowsy, partly closed eyes" , adultOnly: true },
680
680
  { id: "eye-state-wide-eyed", label: "Wide-eyed", dimension: "eye-state", description: "Eyes wide open, alert / surprised", promptHint: "with eyes wide open, alert and surprised" },
681
- { id: "eye-state-staring-camera", label: "Staring at Camera", dimension: "eye-state", description: "Direct unbroken eye contact with the lens", promptHint: "staring directly at the camera with unbroken eye contact" },
681
+ { id: "eye-state-staring-camera", label: "Staring at Camera", dimension: "eye-state", description: "Direct unbroken eye contact with the lens", promptHint: "looking directly into the camera", term: "direct gaze to camera" },
682
682
  { id: "eye-state-gazing-away", label: "Gazing Away", dimension: "eye-state", description: "Looking off-camera, contemplative", promptHint: "gazing off-camera with a contemplative expression", term: "gazing off-camera" },
683
683
  { id: "eye-state-gazing-up", label: "Gazing Up", dimension: "eye-state", description: "Eyes raised toward something above", promptHint: "with eyes gazing upward toward something above" },
684
684
  { id: "eye-state-gazing-down", label: "Gazing Down", dimension: "eye-state", description: "Eyes downcast", promptHint: "with eyes downcast, gazing softly downward" },
@@ -697,8 +697,8 @@ export const PEOPLE: ReadonlyArray<Person> = [
697
697
  { id: "texture-smooth", label: "Smooth", dimension: "skin-texture", description: "Flawless, silky smooth skin", promptHint: "with flawless, silky smooth skin", term: "flawless smooth skin" },
698
698
  { id: "texture-wrinkled", label: "Wrinkled", dimension: "skin-texture", description: "Aged, deeply lined skin", promptHint: "with deep wrinkles and aged skin texture", term: "deeply wrinkled aged skin" },
699
699
  { id: "texture-goosebumps", label: "Goosebumps", dimension: "skin-texture", description: "Raised goosebumps on skin", promptHint: "with goosebumps raised on the skin" },
700
- { id: "texture-dewy", label: "Dewy", dimension: "skin-texture", description: "Glowing, dewy fresh skin", promptHint: "with dewy, glowing skin and a fresh sheen", term: "dewy glowing skin" },
701
- { id: "texture-glistening", label: "Glistening", dimension: "skin-texture", description: "Sweat or oil sheen", promptHint: "with glistening skin, sweat or oil catching the light", term: "glistening skin" , adultOnly: true },
700
+ { id: "texture-dewy", label: "Dewy", dimension: "skin-texture", description: "Glowing, dewy fresh skin", promptHint: "with dewy, luminous skin", term: "dewy skin" },
701
+ { id: "texture-glistening", label: "Glistening", dimension: "skin-texture", description: "Sweat or oil sheen", promptHint: "with a light glistening sheen on the skin", term: "glistening sheen" , adultOnly: true },
702
702
  { id: "texture-weathered", label: "Weathered", dimension: "skin-texture", description: "Sun-aged rough skin", promptHint: "with weathered, sun-worn rough skin", term: "weathered sun-worn skin" },
703
703
  { id: "texture-porcelain", label: "Porcelain", dimension: "skin-texture", description: "Flawless near-translucent porcelain", promptHint: "with flawless, near-translucent porcelain skin", term: "porcelain skin" },
704
704
  { id: "texture-sun-kissed", label: "Sun-kissed", dimension: "skin-texture", description: "Warm tan with healthy glow", promptHint: "with sun-kissed skin, warmly tanned with a healthy glow", term: "sun-kissed skin" },
@@ -710,8 +710,8 @@ export const PEOPLE: ReadonlyArray<Person> = [
710
710
  { id: "texture-oily", label: "Oily / Shiny", dimension: "skin-texture", description: "Slight oil sheen on T-zone", promptHint: "with a slight oily sheen across the T-zone", term: "oily shiny skin" , adultOnly: true },
711
711
  { id: "texture-matte", label: "Matte", dimension: "skin-texture", description: "Poreless matte finish", promptHint: "with a poreless, matte skin finish", term: "matte skin finish" },
712
712
  { id: "texture-blemished", label: "Blemished", dimension: "skin-texture", description: "Visible blemishes, real-skin imperfections", promptHint: "with visible blemishes and natural real-skin imperfections", term: "blemished skin" },
713
- { id: "texture-baby-soft", label: "Baby-soft", dimension: "skin-texture", description: "Smooth, fine-pored youthful skin", promptHint: "with baby-soft, fine-pored, youthful smooth skin", term: "baby-soft skin" },
714
- { id: "texture-shower-fresh-wet", label: "Shower-Fresh Wet", dimension: "skin-texture", description: "Just-out-of-shower wet skin with water beads", promptHint: "with just-out-of-the-shower wet skin, water beading on the surface and rolling in slow droplets down the curves of the body", term: "shower-fresh wet skin" , adultOnly: true },
713
+ { id: "texture-baby-soft", label: "Baby-soft", dimension: "skin-texture", description: "Smooth, fine-pored youthful skin", promptHint: "with soft, fine-pored skin", term: "soft fine-pored skin" },
714
+ { id: "texture-shower-fresh-wet", label: "Shower-Fresh Wet", dimension: "skin-texture", description: "Just-out-of-shower wet skin with water beads", promptHint: "with fresh, water-dappled skin as if just out of the shower", term: "water-dappled skin" , adultOnly: true },
715
715
  { id: "texture-acne-scarred", label: "Acne-scarred", dimension: "skin-texture", description: "Visible acne scarring (distinct from blemished — healed scar pattern)", promptHint: "with visible acne scarring, healed pitted-skin texture and uneven surface from past breakouts", term: "acne-scarred skin" },
716
716
 
717
717
  // -------------------- Distinctive Features --------------------
@@ -741,9 +741,9 @@ export const PEOPLE: ReadonlyArray<Person> = [
741
741
  { id: "feature-ear-piercings", label: "Ear Piercings", dimension: "distinctive-features", description: "Multiple stacked ear piercings (cartilage, helix, conch)", promptHint: "with multiple stacked ear piercings — cartilage, helix, and conch", term: "multiple stacked ear piercings" },
742
742
  { id: "feature-lip-piercing", label: "Lip Piercing", dimension: "distinctive-features", description: "Lip ring / labret stud", promptHint: "with a lip piercing, a small ring or labret stud at the lip" },
743
743
  { id: "feature-nostril-piercing", label: "Nostril Piercing", dimension: "distinctive-features", description: "Single nostril stud or ring", promptHint: "with a single nostril piercing, a small stud or ring at the nostril" },
744
- { id: "feature-bare-shoulders", label: "Bare Shoulders", dimension: "distinctive-features", description: "Bare shoulders exposed", promptHint: "with bare shoulders exposed, the line of the collarbone and shoulder muscles uncovered" , adultOnly: true },
745
- { id: "feature-collarbone-visible", label: "Collarbone Visible", dimension: "distinctive-features", description: "Prominent collarbone catching light", promptHint: "with a prominent collarbone clearly defined and catching the light", term: "visible collarbone" , adultOnly: true },
746
- { id: "feature-midriff-visible", label: "Midriff Visible", dimension: "distinctive-features", description: "Exposed midriff between top and bottom", promptHint: "wearing a cropped style with the midriff visible", term: "visible midriff" , adultOnly: true },
744
+ { id: "feature-bare-shoulders", label: "Bare Shoulders", dimension: "distinctive-features", description: "Bare shoulders exposed", promptHint: "with the shoulders uncovered", term: "shoulders uncovered" , adultOnly: true },
745
+ { id: "feature-collarbone-visible", label: "Collarbone Visible", dimension: "distinctive-features", description: "Prominent collarbone catching light", promptHint: "with an open neckline that leaves the collarbones uncovered", term: "open neckline, collarbones uncovered" , adultOnly: true },
746
+ { id: "feature-midriff-visible", label: "Midriff Visible", dimension: "distinctive-features", description: "Exposed midriff between top and bottom", promptHint: "with a cropped hemline", term: "cropped hemline" , adultOnly: true },
747
747
  { id: "feature-navel-visible", label: "Navel Visible", dimension: "distinctive-features", description: "Visible navel on bare stomach", promptHint: "with the navel visible", term: "visible navel" , adultOnly: true },
748
748
  { id: "feature-elongated-neck", label: "Elongated Neck", dimension: "distinctive-features", description: "Long swan-like neck", promptHint: "with an elongated, swan-like neck, long and gracefully extended", term: "elongated swan-like neck" },
749
749
  { id: "feature-under-eye-circles", label: "Under-Eye Circles", dimension: "distinctive-features", description: "Subtle dark circles under the eyes (distinct from puffy eye-bags)", promptHint: "with subtle dark circles under the eyes", term: "dark under-eye circles" },
@@ -1333,7 +1333,8 @@ function emitIndependentFragments(value: unknown, fragmentFor: PersonFragmentFor
1333
1333
  // "midriff visible" + "navel visible" as separate clauses reads twice as
1334
1334
  // exposed as it is, and stacked with glamour hints it tips borderline draws
1335
1335
  // into content blocks (observed on kie-standard, 2026-07-18 app_reports).
1336
- // When both are picked, fold them into ONE neutral clause.
1336
+ // When both are picked, fold them into ONE neutral clause
1337
+ // ("with a cropped hemline and the navel showing").
1337
1338
  const midriff = ids.includes("feature-midriff-visible")
1338
1339
  const navel = ids.includes("feature-navel-visible")
1339
1340
  for (const id of ids) {
@@ -1342,7 +1343,7 @@ function emitIndependentFragments(value: unknown, fragmentFor: PersonFragmentFor
1342
1343
  // floored `fragmentFor` (see `emit` in collectPersonFragments) returns
1343
1344
  // "" for a flagged id, so gate the fold on that instead of hardcoding
1344
1345
  // the minor check here.
1345
- if (id === "feature-midriff-visible" && fragmentFor(id) !== "") out.push("wearing a cropped style, midriff and navel visible")
1346
+ if (id === "feature-midriff-visible" && fragmentFor(id) !== "") out.push("with a cropped hemline and the navel showing")
1346
1347
  continue
1347
1348
  }
1348
1349
  const fragment = fragmentFor(id)
package/src/pose.ts CHANGED
@@ -105,7 +105,7 @@ export const POSES: ReadonlyArray<Pose> = [
105
105
  { id: "throwing", label: "Throwing", category: "action", description: "Mid-throw motion", promptHint: "caught mid-throw, body coiled and releasing", term: "caught mid-throw" },
106
106
  { id: "leaping", label: "Leaping", category: "action", description: "Leaping forward dynamically", promptHint: "leaping forward dynamically with body extended" },
107
107
  { id: "dramatic-action", label: "Dramatic Action", category: "action", description: "Exaggerated action pose", promptHint: "in a dramatic, exaggerated action pose full of motion", term: "dramatic exaggerated action pose" },
108
- { id: "biting-lip", label: "Biting Lip", category: "action", description: "Slight playful lip-bite", promptHint: "biting the lower lip with a subtle playful expression", term: "biting the lower lip" , adultOnly: true },
108
+ { id: "biting-lip", label: "Biting Lip", category: "action", description: "Slight playful lip-bite", promptHint: "lightly biting the lower lip, a subtle playful expression", term: "biting lower lip" , adultOnly: true },
109
109
  { id: "mid-laugh", label: "Mid-Laugh", category: "action", description: "Caught mid-laugh, head back", promptHint: "caught mid-laugh with head tipped back, eyes crinkled" },
110
110
  { id: "pointing-at-camera", label: "Pointing at Camera", category: "action", description: "Pointing directly at camera", promptHint: "pointing one finger directly at the camera, arm extended", term: "pointing directly at the camera" },
111
111
  { id: "tongue-out", label: "Sticking Tongue Out", category: "action", description: "Playful tongue-out expression", promptHint: "sticking the tongue out playfully" },
@@ -21,7 +21,8 @@ import { buildIdentityLockLine, withForcedIdentityLock } from "./identity-lock.j
21
21
  import { findLocationMentionTokens, DEFAULT_LOCATION_USAGE_MODE, type LocationMentionTokenInfo, type LocationUsageMode } from "@nodaro/shared"
22
22
  import { findImageMentionTokens, imageMentionSlugForRef, knownImageSlugsFromRefs, type ImageMentionTokenInfo } from "@nodaro/shared"
23
23
  import { findEntityMentionTokens, entityMentionSlugForRef, knownEntitySlugsFromRefs, type EntityMentionTokenInfo } from "@nodaro/shared"
24
- import type { CharacterDef, ConnectedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
24
+ import type { CharacterDef, ConnectedReference, DescribedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
25
+ import { appendReferenceLines, referenceDescriptionLine, renderDescribedReferenceLines } from "./described-references.js"
25
26
  import { locationReferencePhotoKindLabel, type LocationReferencePhotoKind } from "@nodaro/shared"
26
27
 
27
28
  export interface ResolveCharacterMentionsResult {
@@ -62,6 +63,30 @@ function composeIdentityDescPart(
62
63
  return parts.length > 0 ? `${subject} — ${parts.join(". ")}` : subject
63
64
  }
64
65
 
66
+ /**
67
+ * The trailing line a per-use `descriptionOverride` contributes in HYBRID
68
+ * format. The hybrid role phrase ("the person from reference image A") carries
69
+ * no description slot, so the override is surfaced as its own binding-subject
70
+ * line via the ONE phrasing helper in `described-references.ts`. It is pushed
71
+ * onto the reference's `elementDirectives` — the array every hybrid renderer
72
+ * already uses for "the trailing scene directives this reference contributes" —
73
+ * so it lands with that reference's other lines instead of threading a ninth
74
+ * channel through eight call sites.
75
+ *
76
+ * The hybrid EXTRAS path does not call this: it has a real `, <desc>` clause,
77
+ * which the override fills instead (so the model is never told twice). Nothing
78
+ * is pushed without an override, which is what keeps every existing hybrid
79
+ * output byte-identical.
80
+ */
81
+ function pushOverrideDirective(
82
+ out: string[],
83
+ ref: Pick<ConnectedReference, "descriptionOverride">,
84
+ binding: string,
85
+ ): void {
86
+ const line = referenceDescriptionLine(binding, ref.descriptionOverride)
87
+ if (line) out.push(line)
88
+ }
89
+
65
90
  /**
66
91
  * Resolve @-mention tokens in a prompt against connected references.
67
92
  * Returns: augmented prompt (with directives prepended + tokens replaced
@@ -176,9 +201,15 @@ export function resolveCharacterMentions(
176
201
  // (held-prop / styling / text) ride the bullet in every mode that emits
177
202
  // one. Byte-identical to the old `${subject} — ${canonical}` form when no
178
203
  // injection is present.
204
+ // A per-use `descriptionOverride` IS the caller describing this subject
205
+ // for this run, so it fills the identity slot ahead of the entity's stored
206
+ // canonical description — and rides every mode that emits a bullet at all
207
+ // (the mode gate exists to suppress STORED identity noise, not the
208
+ // caller's own words). "none" still emits nothing: it returned above.
179
209
  const descPart = composeIdentityDescPart(
180
210
  subject,
181
- includeCanonicalDesc ? match.characterCanonicalDescription : undefined,
211
+ match.descriptionOverride?.trim()
212
+ || (includeCanonicalDesc ? match.characterCanonicalDescription : undefined),
182
213
  match.elementInjection,
183
214
  )
184
215
  // `directive` is non-null here because usageModeDirective only returns
@@ -407,6 +438,7 @@ function resolveCharacterMentionsHybrid(
407
438
  const binding = bindingFor(m.url)
408
439
  const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
409
440
  if (lock) lockLines.push(lock)
441
+ pushOverrideDirective(elementDirectives, ref, binding)
410
442
  const inject = ref.elementInjection?.trim()
411
443
  if (inject) elementDirectives.push(inject)
412
444
  }
@@ -670,9 +702,13 @@ export function resolveLocationMentions(
670
702
  // description noise.
671
703
  const includeCanonicalDesc = effectiveMode === "identical"
672
704
  const canonicalDesc = match.locationCanonicalDescription?.trim()
673
- const descPart = includeCanonicalDesc && canonicalDesc
674
- ? `${subject} — ${canonicalDesc}`
675
- : subject
705
+ // A per-use `descriptionOverride` IS the caller describing this place for
706
+ // this run, so it fills the identity slot ahead of the location's stored
707
+ // canonical description — and rides every mode that emits a bullet at all,
708
+ // the same rule the character resolvers apply.
709
+ const shownDesc = match.descriptionOverride?.trim()
710
+ || (includeCanonicalDesc ? canonicalDesc : undefined)
711
+ const descPart = shownDesc ? `${subject} — ${shownDesc}` : subject
676
712
  directiveLines.push(`- ${descPart}.${directive ? ` ${directive}` : ""}`)
677
713
 
678
714
  // Variant display-name sub-line: only when the user pinned a specific
@@ -830,6 +866,7 @@ function resolveLocationMentionsHybrid(
830
866
  const binding = bindingFor(m.url)
831
867
  const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
832
868
  if (lock) lockLines.push(lock)
869
+ pushOverrideDirective(elementDirectives, ref, binding)
833
870
  const inject = ref.elementInjection?.trim()
834
871
  if (inject) elementDirectives.push(inject)
835
872
  }
@@ -848,6 +885,14 @@ interface ResolveImageMentionsHybridResult {
848
885
  * mentioned refs out of `connectedReferences`, and this pass deliberately
849
886
  * does no such filtering (see the caller's NOTE). Carrying the set anyway
850
887
  * would advertise a filter that does not exist. */
888
+ /**
889
+ * The URLs a mention BOUND (deduped). This pass already surfaced each bound
890
+ * ref's per-use `descriptionOverride` as its own trailing line, so the
891
+ * caller's untold-override sweep skips them — the same by-URL suppression
892
+ * contract `resolveEntityMentionsHybrid` carries, for the same reason: told
893
+ * once, never twice.
894
+ */
895
+ mentionedUrls: Set<string>
851
896
  /** Per-reference identity-lock lines (deduped per URL). Caller prepends them
852
897
  * as ONE block, merged with the character/location lock lines. */
853
898
  lockLines: string[]
@@ -955,11 +1000,12 @@ function resolveImageMentionsHybrid(
955
1000
  const binding = bindingFor(m.url)
956
1001
  const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
957
1002
  if (lock) lockLines.push(lock)
1003
+ pushOverrideDirective(elementDirectives, ref, binding)
958
1004
  const inject = ref.elementInjection?.trim()
959
1005
  if (inject) elementDirectives.push(inject)
960
1006
  }
961
1007
 
962
- return { prompt: resolvedPrompt, additionalUrls, lockLines, elementDirectives }
1008
+ return { prompt: resolvedPrompt, additionalUrls, mentionedUrls: seenUrls, lockLines, elementDirectives }
963
1009
  }
964
1010
 
965
1011
  interface ResolveEntityMentionsHybridResult {
@@ -1137,6 +1183,7 @@ function resolveEntityMentionsHybrid(
1137
1183
  const binding = bindingFor(m.url)
1138
1184
  const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
1139
1185
  if (lock) lockLines.push(lock)
1186
+ pushOverrideDirective(elementDirectives, ref, binding)
1140
1187
  const inject = ref.elementInjection?.trim()
1141
1188
  if (inject) elementDirectives.push(inject)
1142
1189
  }
@@ -1221,9 +1268,12 @@ function buildCanonicalFallback(
1221
1268
  // the (mode-gated) canonical description. This is the reported path — a
1222
1269
  // character wired with no @-mention — so a composed character surfaces its
1223
1270
  // elements wherever it's used downstream.
1271
+ // Per-use override ahead of the stored canonical description, mode-gate
1272
+ // included — same rule as the mention path above.
1224
1273
  const descPart = composeIdentityDescPart(
1225
1274
  subject,
1226
- includeCanonicalDesc ? r.characterCanonicalDescription : undefined,
1275
+ r.descriptionOverride?.trim()
1276
+ || (includeCanonicalDesc ? r.characterCanonicalDescription : undefined),
1227
1277
  r.elementInjection,
1228
1278
  )
1229
1279
  // `directive` is non-null here ("none"/"name" already short-circuited).
@@ -1283,7 +1333,12 @@ function buildExtraRefDirectives(
1283
1333
  for (const r of refs) {
1284
1334
  if (!r.isExtraRef) continue
1285
1335
  if (!r.url) continue
1286
- const description = (r.description ?? r.variantDescription ?? "").trim()
1336
+ // A per-use `descriptionOverride` fills THIS extra's description slot —
1337
+ // every branch below reads one `description`, so the override lands in the
1338
+ // pair-back tail, the name-mode bullet and the first-sight descriptor alike
1339
+ // (and, being the caller's own words, outranks the stored canonical too).
1340
+ const description = (r.descriptionOverride ?? "").trim()
1341
+ || (r.description ?? r.variantDescription ?? "").trim()
1287
1342
  // Character extra
1288
1343
  if (r.source === "wired-character" && r.characterSlug) {
1289
1344
  const effectiveMode: UsageMode = r.defaultUsageMode ?? DEFAULT_USAGE_MODE
@@ -1421,6 +1476,7 @@ function renderCanonicalFallbackHybrid(
1421
1476
  phrases.push(roleToPhrase(resolveDefaultRole(r.defaultRole, r.defaultUsageMode, r.source), binding))
1422
1477
  const lock = buildIdentityLockLine(r, binding)
1423
1478
  if (lock) lockLines.push(lock)
1479
+ pushOverrideDirective(elementDirectives, r, binding)
1424
1480
  const inject = r.elementInjection?.trim()
1425
1481
  if (inject) elementDirectives.push(inject)
1426
1482
  }
@@ -1464,7 +1520,10 @@ function renderExtraRefsHybrid(
1464
1520
  if (!r.url) continue
1465
1521
  const letter = letterForUrl(r.url)
1466
1522
  const binding = `reference image ${letter}`
1467
- const description = (r.description ?? r.variantDescription ?? "").trim()
1523
+ // Per-use override fills the extras' own `, <desc>` clause — the one
1524
+ // description slot the hybrid format has (see `described-references.ts`).
1525
+ const description = (r.descriptionOverride ?? "").trim()
1526
+ || (r.description ?? r.variantDescription ?? "").trim()
1468
1527
  if (r.source === "wired-character" && r.characterSlug) {
1469
1528
  const earlier = firstLetterByChar.get(r.characterSlug)
1470
1529
  if (earlier !== undefined && earlier !== letter) {
@@ -1523,12 +1582,16 @@ function renderExtraRefsHybrid(
1523
1582
  * the call site). A location that is BOTH unmentioned AND `{image:N}`-token-
1524
1583
  * referenced is rendered ONCE (inline, via the scene); we `continue` here so it
1525
1584
  * is not ALSO emitted as a trailing canonical phrase (the C1 review Minor).
1585
+ *
1586
+ * `renderedUrls` reports the URLs that DID render here — and therefore already
1587
+ * carry their per-use `descriptionOverride` line — so the caller's untold-
1588
+ * override sweep skips them instead of re-deriving this loop's predicate.
1526
1589
  */
1527
1590
  function renderLocationCanonicalHybrid(
1528
1591
  nonCharacterRefs: readonly ConnectedReference[],
1529
1592
  finalIndexByUrl: ReadonlyMap<string, number>,
1530
1593
  coveredUrls: ReadonlySet<string>,
1531
- ): { phrases: string[]; lockLines: string[]; elementDirectives: string[] } {
1594
+ ): { phrases: string[]; lockLines: string[]; elementDirectives: string[]; renderedUrls: Set<string> } {
1532
1595
  const phrases: string[] = []
1533
1596
  const lockLines: string[] = []
1534
1597
  const elementDirectives: string[] = []
@@ -1546,10 +1609,11 @@ function renderLocationCanonicalHybrid(
1546
1609
  phrases.push(roleToPhrase(resolveLocationRole(null, null, r), binding))
1547
1610
  const lock = buildIdentityLockLine(r, binding)
1548
1611
  if (lock) lockLines.push(lock)
1612
+ pushOverrideDirective(elementDirectives, r, binding)
1549
1613
  const inject = r.elementInjection?.trim()
1550
1614
  if (inject) elementDirectives.push(inject)
1551
1615
  }
1552
- return { phrases, lockLines, elementDirectives }
1616
+ return { phrases, lockLines, elementDirectives, renderedUrls: seenUrls }
1553
1617
  }
1554
1618
 
1555
1619
  /**
@@ -1576,13 +1640,15 @@ function renderLocationCanonicalHybrid(
1576
1640
  * has to happen HERE, per URL. The mention pass emits the same lock line and
1577
1641
  * `elementInjection` this loop would have, so nothing is lost by skipping it.
1578
1642
  *
1579
- * Deduped per URL (an object wired twice → one phrase).
1643
+ * Deduped per URL (an object wired twice → one phrase). `renderedUrls` reports
1644
+ * the URLs that DID render — already carrying their per-use
1645
+ * `descriptionOverride` line — for the caller's untold-override sweep.
1580
1646
  */
1581
1647
  function renderObjectCreatureCanonicalHybrid(
1582
1648
  nonCharacterRefs: readonly ConnectedReference[],
1583
1649
  finalIndexByUrl: ReadonlyMap<string, number>,
1584
1650
  coveredUrls: ReadonlySet<string>,
1585
- ): { phrases: string[]; lockLines: string[]; elementDirectives: string[] } {
1651
+ ): { phrases: string[]; lockLines: string[]; elementDirectives: string[]; renderedUrls: Set<string> } {
1586
1652
  const phrases: string[] = []
1587
1653
  const lockLines: string[] = []
1588
1654
  const elementDirectives: string[] = []
@@ -1597,10 +1663,49 @@ function renderObjectCreatureCanonicalHybrid(
1597
1663
  phrases.push(roleToPhrase(defaultRoleForSource(r.source), binding))
1598
1664
  const lock = buildIdentityLockLine(r, binding)
1599
1665
  if (lock) lockLines.push(lock)
1666
+ pushOverrideDirective(elementDirectives, r, binding)
1600
1667
  const inject = r.elementInjection?.trim()
1601
1668
  if (inject) elementDirectives.push(inject)
1602
1669
  }
1603
- return { phrases, lockLines, elementDirectives }
1670
+ return { phrases, lockLines, elementDirectives, renderedUrls: seenUrls }
1671
+ }
1672
+
1673
+ /**
1674
+ * The per-use `descriptionOverride` of every non-character reference the hybrid
1675
+ * format renders NO role phrase for — the last leg of "the override is honoured
1676
+ * wherever the reference ships".
1677
+ *
1678
+ * Three kinds land here: a ref a `{image:N:label}` token expanded INLINE (the
1679
+ * expansion carries no description slot, and the covered set suppresses both
1680
+ * canonical renders), an unmentioned `wired-image` / `manual` / `wired-face` ref
1681
+ * (no canonical render exists for those sources at all), and any other seated
1682
+ * ref the passes above left unspoken. Each gets ONE
1683
+ * `reference image <LETTER> — <override>.` line, from the same helper every
1684
+ * other site uses, numbered against `finalIndexByUrl`.
1685
+ *
1686
+ * `toldUrls` is every URL a pass above ALREADY gave an override line — the two
1687
+ * canonical renders' `renderedUrls` plus the image / entity mention passes'
1688
+ * bound URLs — so a reference that is both @-mentioned and token-covered is told
1689
+ * once, never twice. A URL is marked swept only when it actually emitted a line,
1690
+ * so two refs sharing one URL where only the later carries an override still
1691
+ * speak.
1692
+ */
1693
+ function renderUntoldOverridesHybrid(
1694
+ nonCharacterRefs: readonly ConnectedReference[],
1695
+ finalIndexByUrl: ReadonlyMap<string, number>,
1696
+ toldUrls: ReadonlySet<string>,
1697
+ ): string[] {
1698
+ const lines: string[] = []
1699
+ const sweptUrls = new Set<string>()
1700
+ for (const r of nonCharacterRefs) {
1701
+ if (!r.url || toldUrls.has(r.url) || sweptUrls.has(r.url)) continue
1702
+ const slot = finalIndexByUrl.get(r.url)
1703
+ if (!slot) continue
1704
+ const before = lines.length
1705
+ pushOverrideDirective(lines, r, `reference image ${slotToLetter(slot)}`)
1706
+ if (lines.length > before) sweptUrls.add(r.url)
1707
+ }
1708
+ return lines
1604
1709
  }
1605
1710
 
1606
1711
  /**
@@ -1874,6 +1979,16 @@ export interface BuildImagePromptConfig {
1874
1979
  * in order, and tokens expand to "the {label} from image {N}".
1875
1980
  */
1876
1981
  connectedReferences?: ConnectedReference[]
1982
+ /**
1983
+ * References the caller can NAME and DESCRIBE but has no media for (an
1984
+ * un-bound cast role, an analysis slot). They attach no URL and claim no
1985
+ * `Image N` / lettered slot — they reach the model as prose, rendered once by
1986
+ * `renderDescribedReferenceLines` and joined the way the active format joins
1987
+ * its trailing directives. Present with NO `connectedReferences` is the normal
1988
+ * case (a story landing before any entity exists), so they enter the
1989
+ * connected-reference path on their own.
1990
+ */
1991
+ describedReferences?: readonly DescribedReference[]
1877
1992
  /** Per-identity (imageIndex+label) user overrides for fidelity / custom text. */
1878
1993
  identityMeta?: readonly IdentityMeta[]
1879
1994
  /**
@@ -2141,6 +2256,13 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2141
2256
  // reassignment, so computing this once up front is stable. Gates the hybrid
2142
2257
  // character convergence (Phase 0) and the non-capitalizing scene render below.
2143
2258
  const isHybrid = config.referenceFormat === "hybrid"
2259
+ // Described references (name + description, no media) — rendered ONCE, up
2260
+ // front, so the reassignments below can't lose them. They attach no URL, so
2261
+ // they take no part in numbering; they are joined onto the assembled prompt at
2262
+ // the single site in the connected-reference path below, ahead of the provider
2263
+ // cap so a shed sees them like any other body text. Empty for every caller
2264
+ // that doesn't send them → nothing downstream changes.
2265
+ const describedLines = renderDescribedReferenceLines(config.describedReferences)
2144
2266
  // Set when Phase 0 converged character OR location @-mentions into inline
2145
2267
  // hybrid role phrases (+ identity-lock + element directives). Tells the hybrid
2146
2268
  // scene render to expand any remaining {image:N} tokens WITHOUT capitalizing
@@ -2155,6 +2277,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2155
2277
  // every prompt without an entity mention, which is what keeps those outputs
2156
2278
  // byte-identical.
2157
2279
  const mentionedEntityUrls = new Set<string>()
2280
+ // URLs bound by a Phase-0 named-image (`@<image-name>`) mention. Bridges the
2281
+ // same two blocks for the OTHER suppression the New path owes: that pass
2282
+ // already emitted each bound ref's per-use `descriptionOverride` line, so the
2283
+ // untold-override sweep must not say it a second time. Empty for every prompt
2284
+ // without an image mention.
2285
+ const mentionedImageUrls = new Set<string>()
2158
2286
 
2159
2287
  // Character LoRA inference path: trigger word + LoRA model carry identity,
2160
2288
  // so we strip raw `@slug[:V[:variant]]` tokens from the prompt AND drop the
@@ -2410,6 +2538,10 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2410
2538
  resolved.additionalUrls = [...resolved.additionalUrls, ...hi.additionalUrls]
2411
2539
  hybridImageLockLines = hi.lockLines
2412
2540
  hybridImageElementDirectives = hi.elementDirectives
2541
+ // The override handoff to the New path: a bound URL was already told
2542
+ // its per-use description here, inline with the mention, so the
2543
+ // untold-override sweep below skips it.
2544
+ for (const u of hi.mentionedUrls) mentionedImageUrls.add(u)
2413
2545
  // Inline role phrases now live in the body → skip line-initial
2414
2546
  // capitalization, which would otherwise corrupt a mid-sentence
2415
2547
  // "the background from reference image C".
@@ -2646,9 +2778,25 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2646
2778
  // wired-image, wired-face, wired-object, wired-location) still auto-
2647
2779
  // attach so unchanged behavior for them.
2648
2780
  // -------------------------------------------------------------------------
2649
- if (connectedReferences) {
2781
+ // Entered by `connectedReferences` OR by described references alone: a story
2782
+ // landing whose cast has no entities yet sends only names + descriptions, and
2783
+ // the legacy path below has no directive seam to join them onto.
2784
+ if (connectedReferences || describedLines.length > 0) {
2785
+ const structuredRefs = connectedReferences ?? []
2650
2786
  let prompt = config.prompt
2651
2787
 
2788
+ // Described references, LEGACY half — joined BEFORE the directive assembly
2789
+ // below, because the block it creates is the block that assembly consolidates
2790
+ // into. Joined AFTER instead, a request whose references produce the
2791
+ // "Use these references for the output image:" wrap (any non-character ref,
2792
+ // no wired character) would get a SECOND "Use these characters:" header
2793
+ // prepended above it. Prepending here also keeps `marks.directivesPrefix`
2794
+ // honest: the prepend branch that records it is only reachable when the
2795
+ // prompt does NOT already open with the character block.
2796
+ if (!isHybrid && describedLines.length > 0) {
2797
+ prompt = appendReferenceLines(prompt, describedLines, "legacy")
2798
+ }
2799
+
2652
2800
  // Non-character refs are still emitted as per-identity directives + URLs.
2653
2801
  // Character refs are filtered out here — Phase 0 has already added their
2654
2802
  // URLs to `referenceImageUrls` (via mention resolution) and prepended the
@@ -2658,7 +2806,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2658
2806
  // subject as Image M, …") and merged their URLs into `referenceImageUrls`.
2659
2807
  // Letting them through here would double-emit the URLs (and append a
2660
2808
  // second positional directive via the {image:N:label} path).
2661
- const nonCharacterRefs = connectedReferences.filter(
2809
+ const nonCharacterRefs = structuredRefs.filter(
2662
2810
  (r) => r.source !== "wired-character" && r.isExtraRef !== true,
2663
2811
  )
2664
2812
 
@@ -2761,9 +2909,27 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2761
2909
  const objCanon = renderObjectCreatureCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, objCoveredUrls)
2762
2910
  const canonLockLines = [...locCanon.lockLines, ...objCanon.lockLines]
2763
2911
  const canonLockBlock = canonLockLines.length > 0 ? `${canonLockLines.join("\n")}\n\n` : ""
2912
+ // Per-use descriptions nothing above spoke for: a `{image:N}`-covered ref
2913
+ // (expanded inline, suppressed from both canonical renders) and a seated
2914
+ // `wired-image` / `manual` ref (which has no canonical render at all)
2915
+ // would otherwise drop their `descriptionOverride` silently — the one
2916
+ // place the hybrid format renders neither a description slot nor a role
2917
+ // phrase. Deduped against every URL a pass above already told.
2918
+ const toldOverrideUrls = new Set<string>([
2919
+ ...locCanon.renderedUrls,
2920
+ ...objCanon.renderedUrls,
2921
+ ...mentionedEntityUrls,
2922
+ ...mentionedImageUrls,
2923
+ ])
2924
+ const untoldOverrides = renderUntoldOverridesHybrid(
2925
+ nonCharacterRefs,
2926
+ finalIndexByUrl,
2927
+ toldOverrideUrls,
2928
+ )
2764
2929
  const canonTrailingLines = [
2765
2930
  ...locCanon.phrases, ...objCanon.phrases,
2766
2931
  ...locCanon.elementDirectives, ...objCanon.elementDirectives,
2932
+ ...untoldOverrides,
2767
2933
  ]
2768
2934
  // Role phrases and element injections are scene content → they extend the
2769
2935
  // BODY, ahead of the `[style]` section (which has no terminator, so a flat
@@ -2811,6 +2977,14 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2811
2977
  }
2812
2978
  }
2813
2979
 
2980
+ // Described references, HYBRID half: trailing scene directives, so they land
2981
+ // behind the reference role phrases the branch above appended (the legacy
2982
+ // half ran before it — see the comment at that call). Still ahead of the
2983
+ // provider cap below, so the lines are body text like any other.
2984
+ if (isHybrid && describedLines.length > 0) {
2985
+ prompt = appendReferenceLines(prompt, describedLines, "hybrid")
2986
+ }
2987
+
2814
2988
  const styleText = style?.trim()
2815
2989
  const styleLine = styleText && !isDeniedStyleId(styleText) ? `Style: ${getStylePromptHint(styleText) || styleText}` : ""
2816
2990
 
@@ -2883,7 +3057,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2883
3057
  const reordered = applyReferenceOrder(
2884
3058
  assembledUrls,
2885
3059
  prompt,
2886
- connectedReferences,
3060
+ structuredRefs,
2887
3061
  referenceOrder,
2888
3062
  sourceNodeIdById,
2889
3063
  )
@@ -3110,7 +3284,10 @@ function collectIdentities(
3110
3284
  label,
3111
3285
  fidelity: m?.fidelity ?? defaultFidelityForSource(ref?.source),
3112
3286
  customText: m?.customText?.trim() || undefined,
3113
- description: ref?.description,
3287
+ // Per-use override ahead of the ref's own description; the directive
3288
+ // builder's location-canonical fallback only fires when BOTH are absent,
3289
+ // so an override outranks the stored location wording too.
3290
+ description: ref?.descriptionOverride?.trim() || ref?.description,
3114
3291
  source: ref?.source,
3115
3292
  locationCanonicalDescription: ref?.locationCanonicalDescription,
3116
3293
  locationSlug: ref?.locationSlug,
@@ -3303,7 +3480,7 @@ function buildNonCharacterDirectives(
3303
3480
  ? "creature"
3304
3481
  : "object",
3305
3482
  fidelity: defaultFidelityForSource(ref.source),
3306
- description: ref.description?.trim() || undefined,
3483
+ description: ref.descriptionOverride?.trim() || ref.description?.trim() || undefined,
3307
3484
  source: ref.source,
3308
3485
  locationCanonicalDescription: ref.locationCanonicalDescription,
3309
3486
  locationSlug: ref.locationSlug,