@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/styling.ts CHANGED
@@ -424,10 +424,10 @@ export const STYLINGS: ReadonlyArray<Styling> = [
424
424
 
425
425
  // -------------------- Wardrobe State (modifier — composes with any garment) --------------------
426
426
  { id: "state-oversized", label: "Oversized", dimension: "wardrobe-state", description: "Loose, oversized fit", promptHint: "the clothing loose and oversized, draping freely well past the body", term: "oversized fit" },
427
- { id: "state-fitted", label: "Fitted", dimension: "wardrobe-state", description: "Form-fitting silhouette", promptHint: "the clothing fitted and form-conscious, hugging the contours of the body", term: "form-fitting clothing" , adultOnly: true },
427
+ { id: "state-fitted", label: "Fitted", dimension: "wardrobe-state", description: "Form-fitting silhouette", promptHint: "tailored, close-fitting clothing", term: "close-fitting clothing" , adultOnly: true },
428
428
  { id: "state-cropped", label: "Cropped", dimension: "wardrobe-state", description: "Top cropped at midriff", promptHint: "the top cropped above the midriff with the stomach visible", term: "cropped top" , adultOnly: true },
429
429
  { id: "state-sheer", label: "Sheer", dimension: "wardrobe-state", description: "Translucent fabric", promptHint: "the fabric translucent and sheer, hinting at the silhouette underneath", term: "sheer clothing" , adultOnly: true },
430
- { id: "state-wet", label: "Wet", dimension: "wardrobe-state", description: "Soaked, water-clinging", promptHint: "the clothing soaked and wet, the fabric clinging to the body and dripping water", term: "wet clothing" , adultOnly: true },
430
+ { id: "state-wet", label: "Wet", dimension: "wardrobe-state", description: "Soaked, water-clinging", promptHint: "the clothing soaked through and dripping", term: "soaked clothing" , adultOnly: true },
431
431
  { id: "state-ripped", label: "Ripped", dimension: "wardrobe-state", description: "Torn and frayed", promptHint: "the fabric torn and ripped at the seams, with frayed edges", term: "ripped clothing" },
432
432
  { id: "state-distressed", label: "Distressed", dimension: "wardrobe-state", description: "Worn, faded, weathered", promptHint: "the clothing distressed and weathered, with faded color and worn-down edges", term: "distressed clothing" },
433
433
  { id: "state-vintage", label: "Vintage", dimension: "wardrobe-state", description: "Worn, retro character", promptHint: "the clothing carrying vintage character — softened color, broken-in fit, retro silhouette", term: "vintage clothing" },
@@ -651,6 +651,40 @@ function collectStylingFragments(
651
651
  const floored = isMinorAge(data as { age?: string; customAge?: number; type?: string })
652
652
  const emit = floored ? (id: string) => (getStyling(id)?.adultOnly === true ? "" : fragmentFor(id)) : fragmentFor
653
653
 
654
+ // W1-b (spec 2026-09-01 §3.3): three ids say "cropped" — the Person
655
+ // catalog's `feature-midriff-visible` and this catalog's `top-crop-top` and
656
+ // `state-cropped`. Stacked they read as three separate statements about a
657
+ // bare stomach, which is one of the compounds the safety classifiers flag.
658
+ // Same mechanism and same caveat as the bold-lips dedupe above: the subject
659
+ // fold hands BOTH builders one flat bag, so the person pick is readable
660
+ // here; a separate styling node carries no `distinctiveFeature` and this
661
+ // no-ops. Person leads the fold order, so the person clause wins; with no
662
+ // person clause the garment wins over the modifier.
663
+ const featureRaw = (data as Record<string, unknown>).distinctiveFeature
664
+ const midriffPicked = Array.isArray(featureRaw)
665
+ ? featureRaw.includes("feature-midriff-visible")
666
+ : featureRaw === "feature-midriff-visible"
667
+ // Gate on pick-minus-floor (`midriffPicked && !floored`), which equals EMISSION
668
+ // unless an overlay pack drops the entry — the same rule the midriff+navel fold
669
+ // uses (`fragmentFor(id) !== ""` inside `emitIndependentFragments`).
670
+ // `feature-midriff-visible` is `adultOnly`, so for a minor the person
671
+ // collector drops it; suppressing the styling twins as well would leave a
672
+ // minor who picked midriff AND crop-top with NO cropped clause at all.
673
+ // `floored` is the local computed just above from the same shared bag, so
674
+ // the two collectors cannot disagree.
675
+ const midriffAlready = midriffPicked && !floored
676
+ const cropTopPicked = (data as Record<string, unknown>).top === "top-crop-top"
677
+ // `top` is single-pick (always a bare string), but `wardrobeState` is
678
+ // declared `string | ReadonlyArray<string>` AND the subject-fold door
679
+ // (`normalizeSubjectFields`) unwraps a lone array pick down to a bare
680
+ // string — so a single "state-cropped" selection, the common case, arrives
681
+ // here as a plain string, not an array. The suppression must therefore be
682
+ // checked at BOTH emission sites below (the `typeof raw === "string"`
683
+ // branch and the `Array.isArray(raw)` branch), not just the array one.
684
+ const croppedSuppressed = (id: string): boolean =>
685
+ (id === "top-crop-top" && midriffAlready) ||
686
+ (id === "state-cropped" && (midriffAlready || cropTopPicked))
687
+
654
688
  for (const dimension of STYLING_DIMENSION_ORDER) {
655
689
  const field = STYLING_FIELD_BY_DIMENSION[dimension]
656
690
  const raw = data[field]
@@ -658,11 +692,13 @@ function collectStylingFragments(
658
692
  // jewelry / wardrobe-state / hair-state are multi-pick (string | string[]);
659
693
  // emit each id's fragment independently and let the comma-join compose.
660
694
  if (typeof raw === "string" && raw.length > 0) {
695
+ if (croppedSuppressed(raw)) continue
661
696
  const fragment = emit(raw)
662
697
  if (fragment) out.push(fragment)
663
698
  } else if (Array.isArray(raw)) {
664
699
  for (const item of raw) {
665
700
  if (typeof item !== "string" || item.length === 0) continue
701
+ if (croppedSuppressed(item)) continue
666
702
  const fragment = emit(item)
667
703
  if (fragment) out.push(fragment)
668
704
  }
@@ -32,7 +32,13 @@ import { findCharacterMentionTokens, type CharacterMentionTokenInfo } from "@nod
32
32
  import { resolveCharacterMentions, applyReferenceOrderToVideo } from "./prompt-builder.js"
33
33
  import { roleToPhrase, REFERENCE_ROLE_PRESETS, resolveDefaultRole } from "@nodaro/shared"
34
34
  import { buildIdentityLockLine, withForcedIdentityLock } from "./identity-lock.js"
35
- import type { ConnectedReference } from "@nodaro/shared"
35
+ import type { ConnectedReference, DescribedReference } from "@nodaro/shared"
36
+ import {
37
+ appendReferenceLines,
38
+ referenceDescriptionLine,
39
+ renderDescribedReferenceLines,
40
+ renderReferenceCaptionLines,
41
+ } from "./described-references.js"
36
42
  import { REF_BINDING } from "./ref-binding.js"
37
43
  import { resolveRefIdTokens } from "./ref-id-tokens.js"
38
44
  import { insertBeforeStyleSection } from "./prompt-style-section.js"
@@ -152,6 +158,13 @@ export interface VideoExtraRef {
152
158
  * orchestrator extras leave this unset (they resolve via `CharacterMeta`).
153
159
  */
154
160
  defaultRole?: string
161
+ /**
162
+ * The `ConnectedReference.descriptionOverride` analogue — the PER-USE identity
163
+ * description for this extra. Fills the row's own description slot (the
164
+ * pair-back tail, the name-mode bullet, the first-sight descriptor), winning
165
+ * over `description`, which keeps its label semantics. Absent → unchanged.
166
+ */
167
+ descriptionOverride?: string
155
168
  }
156
169
 
157
170
  /**
@@ -230,6 +243,24 @@ export interface ResolveVideoReferenceCoreArgs {
230
243
  * The wired character refs' `defaultName`s are known without this.
231
244
  */
232
245
  refNamesById?: ReadonlyMap<string, string>
246
+ /**
247
+ * References the caller can NAME and DESCRIBE but has no media for — an
248
+ * un-bound cast role, an analysis slot. They attach no URL and claim no
249
+ * `@image_N` seat: they are rendered as prose by `renderDescribedReferenceLines`
250
+ * and joined the way this lane joins its trailing directives. The
251
+ * described-ONLY request (no wired characters, no extras) is the normal case,
252
+ * so the early return below carries the join too.
253
+ */
254
+ describedReferences?: readonly DescribedReference[]
255
+ /**
256
+ * Captions for the node's video / audio rail references, INDEX-ALIGNED with
257
+ * the caller's `referenceVideoUrls` / `referenceAudioUrls`. Rendered as
258
+ * `@video_N: <caption>.` / `@audio_N: <caption>.` and bounded by
259
+ * `videoRefCount` / `audioRefCount`, so a caption for a url the provider cap
260
+ * dropped never binds a slot the payload does not ship.
261
+ */
262
+ videoCaptions?: readonly string[]
263
+ audioCaptions?: readonly string[]
233
264
  }
234
265
 
235
266
  /** Result of the HYBRID mention pass — inline role phrases + surfaced opt-in
@@ -368,6 +399,11 @@ function resolveVideoCharacterMentionsHybrid(
368
399
  const binding = bindingFor(m.url)
369
400
  const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
370
401
  if (lock) lockLines.push(lock)
402
+ // A per-use `descriptionOverride` has no slot in a hybrid role phrase, so it
403
+ // rides the trailing-directive channel as its own binding-subject line —
404
+ // exactly as on the image lane (`pushOverrideDirective`).
405
+ const overrideLine = referenceDescriptionLine(binding, ref.descriptionOverride)
406
+ if (overrideLine) elementDirectives.push(overrideLine)
371
407
  const inject = ref.elementInjection?.trim()
372
408
  if (inject) elementDirectives.push(inject)
373
409
  }
@@ -429,6 +465,18 @@ export function resolveVideoReferenceCore(
429
465
  video: args.videoRefCount ?? 0,
430
466
  audio: args.audioRefCount ?? 0,
431
467
  })
468
+ // Described references (name + description, no media) and the video/audio rail
469
+ // captions — rendered ONCE, ahead of every exit, because both the early return
470
+ // below and the main assembly have to carry them. They attach no URL and claim
471
+ // no `@image_N` seat, so they take no part in the numbering walk. Empty for
472
+ // every caller that sends neither → every existing output is untouched.
473
+ const describedAndCaptionLines = [
474
+ ...renderDescribedReferenceLines(args.describedReferences),
475
+ ...renderReferenceCaptionLines(args.videoCaptions, args.audioCaptions, {
476
+ video: args.videoRefCount ?? 0,
477
+ audio: args.audioRefCount ?? 0,
478
+ }),
479
+ ]
432
480
  let wiredCharRefs = [...args.wiredCharRefs]
433
481
  const suppressedSlugs = new Set(args.suppressedCanonicalCharacterIds ?? [])
434
482
  if (suppressedSlugs.size > 0) {
@@ -469,14 +517,20 @@ export function resolveVideoReferenceCore(
469
517
  // leading refs were passed); the leading URLs are returned for the payload.
470
518
  // Nothing was seated, so every `{ref:}` degrades (label → name → "").
471
519
  const counts = tokenCounts(leadingRefUrls.length)
520
+ // tokenCounts(leadingRefUrls.length) → image count == offset (no assets here):
521
+ // leadingRefUrls mode counts the leading refs; ordinalOffset mode counts the
522
+ // caller-owned leading refs the offset stands in for.
523
+ const resolved = resolveReferenceTokens(
524
+ resolveRefIdTokens(args.prompt, { slotById: slotByRefId, nameById: nameByRefId, imageCount: counts.image }),
525
+ counts,
526
+ )
472
527
  return {
473
- // tokenCounts(leadingRefUrls.length) → image count == offset (no assets here):
474
- // leadingRefUrls mode counts the leading refs; ordinalOffset mode counts the
475
- // caller-owned leading refs the offset stands in for.
476
- prompt: resolveReferenceTokens(
477
- resolveRefIdTokens(args.prompt, { slotById: slotByRefId, nameById: nameByRefId, imageCount: counts.image }),
478
- counts,
479
- ),
528
+ // Described references and rail captions are the ONE thing this branch can
529
+ // still contribute: they need no seat, so a request that carries only them
530
+ // (a story landing before any entity exists) reaches the model here.
531
+ prompt: describedAndCaptionLines.length > 0
532
+ ? appendReferenceLines(resolved ?? "", describedAndCaptionLines, args.hybridRoles === true ? "hybrid" : "legacy")
533
+ : resolved,
480
534
  additionalUrls: [...leadingRefUrls],
481
535
  }
482
536
  }
@@ -579,6 +633,8 @@ export function resolveVideoReferenceCore(
579
633
  canonicalPhrases.push(roleToPhrase(resolveDefaultRole(r.defaultRole, r.defaultUsageMode, r.source), binding))
580
634
  const lock = buildIdentityLockLine(r, binding)
581
635
  if (lock) canonicalLockLines.push(lock)
636
+ const overrideLine = referenceDescriptionLine(binding, r.descriptionOverride)
637
+ if (overrideLine) canonicalElementDirectives.push(overrideLine)
582
638
  const inject = r.elementInjection?.trim()
583
639
  if (inject) canonicalElementDirectives.push(inject)
584
640
  continue
@@ -601,7 +657,13 @@ export function resolveVideoReferenceCore(
601
657
  // mirrors the shared image-side `composeIdentityDescPart`. The subject here
602
658
  // is the bare display name (video numbering is applied separately above).
603
659
  const descBodyParts: string[] = []
604
- if (includeCanonicalDesc && r.characterCanonicalDescription?.trim()) {
660
+ // The per-use override IS the caller describing this subject for this run,
661
+ // so it fills the identity slot ahead of the stored canonical description —
662
+ // and rides every mode that emits a bullet at all ("none" returned above).
663
+ const canonicalOverride = r.descriptionOverride?.trim()
664
+ if (canonicalOverride) {
665
+ descBodyParts.push(canonicalOverride)
666
+ } else if (includeCanonicalDesc && r.characterCanonicalDescription?.trim()) {
605
667
  descBodyParts.push(r.characterCanonicalDescription.trim())
606
668
  }
607
669
  if (r.elementInjection?.trim()) descBodyParts.push(r.elementInjection.trim())
@@ -637,7 +699,9 @@ export function resolveVideoReferenceCore(
637
699
  if (!ex.url) continue
638
700
  position += 1
639
701
  if (ex.id && !slotByRefId.has(ex.id)) slotByRefId.set(ex.id, position)
640
- const desc = (ex.description ?? "").trim()
702
+ // Per-use override fills this row's description slot; `description` keeps
703
+ // its own label semantics (mirrors the image extras).
704
+ const desc = (ex.descriptionOverride ?? "").trim() || (ex.description ?? "").trim()
641
705
  if (ex.characterSlug) {
642
706
  // First sight of this character via an extra. Resolution chain
643
707
  // matches the image side: per-ref override → upstream character
@@ -820,6 +884,17 @@ export function resolveVideoReferenceCore(
820
884
  if (u && !seen.has(u)) { seen.add(u); merged.push(u) }
821
885
  }
822
886
 
887
+ // Described references + rail captions — joined the way this format joins its
888
+ // own trailing directives (hybrid: ahead of the `[style]` section; legacy:
889
+ // bullets consolidated into the "Use these characters:" block the branch above
890
+ // may just have created). Ahead of the reorder, like every other assembled
891
+ // line; they carry no `@image_N` binding for the renumber to move.
892
+ finalPrompt = appendReferenceLines(
893
+ finalPrompt,
894
+ describedAndCaptionLines,
895
+ hybrid ? "hybrid" : "legacy",
896
+ )
897
+
823
898
  // `{ref:<id>}` tokens resolve HERE — after the walk has seated every reference
824
899
  // (the slot map is complete) and BEFORE the user reorder below, so the
825
900
  // reorder's `@image_N` renumber pass carries the freshly emitted binding to