@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/dist/index.cjs +243 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +149 -3
- package/dist/index.d.ts +149 -3
- package/dist/index.js +239 -70
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-image-input.test.ts +30 -0
- package/src/__tests__/described-references-video.test.ts +250 -0
- package/src/__tests__/described-references.test.ts +595 -0
- package/src/__tests__/doctrine-roster-completeness.test.ts +1 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +313 -35
- package/src/__tests__/minor-age-floor.test.ts +4 -4
- package/src/__tests__/person-body-axes.test.ts +2 -2
- package/src/__tests__/person-exposure-hints.test.ts +59 -4
- package/src/__tests__/retired-hint-strings.test.ts +68 -0
- package/src/__tests__/subject-registry.test.ts +1 -1
- package/src/__tests__/w1b-rephrase.test.ts +119 -0
- package/src/age-floor.ts +65 -2
- package/src/assemble-image-input.ts +11 -1
- package/src/described-references.ts +138 -0
- package/src/index.ts +1 -0
- package/src/person.ts +18 -17
- package/src/pose.ts +1 -1
- package/src/prompt-builder.ts +196 -19
- package/src/styling.ts +38 -2
- package/src/video-reference-resolver.ts +85 -10
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: "
|
|
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
|
|
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
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
prompt:
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|