@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.
@@ -14,15 +14,15 @@ describe("exposure hint collapsing", () => {
14
14
  const hints = buildPersonHints({
15
15
  distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"],
16
16
  } as never)
17
- const exposure = hints.filter((h) => /midriff|navel|stomach/i.test(h))
18
- expect(exposure).toEqual(["wearing a cropped style, midriff and navel visible"])
17
+ const exposure = hints.filter((h) => /cropped hemline|navel|stomach/i.test(h))
18
+ expect(exposure).toEqual(["with a cropped hemline and the navel showing"])
19
19
  })
20
20
 
21
21
  it("each alone keeps its own (softened, garment-language) hint", () => {
22
22
  const midriffOnly = buildPersonHints({
23
23
  distinctiveFeature: ["feature-midriff-visible"],
24
24
  } as never)
25
- expect(midriffOnly).toContain("wearing a cropped style with the midriff visible")
25
+ expect(midriffOnly).toContain("with a cropped hemline")
26
26
  expect(midriffOnly.join(" ")).not.toMatch(/bare stomach/i)
27
27
 
28
28
  const navelOnly = buildPersonHints({
@@ -40,7 +40,7 @@ describe("exposure hint collapsing", () => {
40
40
  "feature-freckles",
41
41
  ],
42
42
  } as never)
43
- expect(hints.filter((h) => /midriff/i.test(h))).toHaveLength(1)
43
+ expect(hints.filter((h) => /cropped hemline/i.test(h))).toHaveLength(1)
44
44
  expect(hints.length).toBeGreaterThan(1)
45
45
  })
46
46
  })
@@ -59,3 +59,58 @@ describe("bold-lips cross-catalog dedupe", () => {
59
59
  expect(hints.join(" ")).toMatch(/bold lips/i)
60
60
  })
61
61
  })
62
+
63
+ describe("cropped-clause de-stack across the person↔styling boundary (W1-b)", () => {
64
+ it("the midriff person clause suppresses BOTH styling cropped twins on the shared bag", () => {
65
+ const hints = buildStylingHints({
66
+ distinctiveFeature: ["feature-midriff-visible"],
67
+ top: "top-crop-top",
68
+ wardrobeState: ["state-cropped"],
69
+ } as never)
70
+ const joined = hints.join(", ")
71
+ expect(joined).not.toMatch(/cropped top|cropped above the midriff/i)
72
+ })
73
+
74
+ it("without the person clause, the garment wins and the modifier yields", () => {
75
+ const hints = buildStylingHints({
76
+ top: "top-crop-top",
77
+ wardrobeState: ["state-cropped"],
78
+ } as never)
79
+ const joined = hints.join(", ")
80
+ expect(joined).toMatch(/wearing a cropped top that ends above the midriff/i)
81
+ expect(joined).not.toMatch(/the top cropped above the midriff with the stomach visible/i)
82
+ })
83
+
84
+ it("a MINOR who picks midriff + crop-top still gets exactly one cropped clause", () => {
85
+ // The person clause is dropped by the floor (feature-midriff-visible is
86
+ // adultOnly), so the styling twin must NOT be suppressed — otherwise the
87
+ // floor silently deletes a garment detail instead of a body detail.
88
+ const hints = buildStylingHints({
89
+ age: "age-child",
90
+ distinctiveFeature: ["feature-midriff-visible"],
91
+ top: "top-crop-top",
92
+ } as never)
93
+ expect(hints.join(", ")).toMatch(/wearing a cropped top that ends above the midriff/i)
94
+ })
95
+
96
+ it("either styling id alone is untouched (no person clause, no twin)", () => {
97
+ expect(buildStylingHints({ wardrobeState: ["state-cropped"] } as never).join(", "))
98
+ .toMatch(/the top cropped above the midriff/i)
99
+ expect(buildStylingHints({ top: "top-crop-top" } as never).join(", "))
100
+ .toMatch(/wearing a cropped top/i)
101
+ })
102
+
103
+ it("de-stacks a BARE-STRING wardrobeState too, not just the array form", () => {
104
+ // `wardrobeState` is a single-pick-or-multi field (string | string[]), and
105
+ // `normalizeSubjectFields` unwraps a lone array pick to a bare string
106
+ // before the styling collector ever sees it — the single-pick case is the
107
+ // common one, not an edge case, so the suppression must not be array-only.
108
+ const hints = buildStylingHints({
109
+ distinctiveFeature: "feature-midriff-visible",
110
+ top: "top-crop-top",
111
+ wardrobeState: "state-cropped",
112
+ } as never)
113
+ const joined = hints.join(", ")
114
+ expect(joined).not.toMatch(/cropped top|cropped above the midriff/i)
115
+ })
116
+ })
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { getAdultOnlyHintStrings, RETIRED_ADULT_ONLY_HINT_STRINGS } from "../age-floor.js"
3
+
4
+ /**
5
+ * W1-b (spec 2026-09-01 §3.3) rewords 17 catalog hints that W1-a (PR 2) had
6
+ * flagged `adultOnly`. The backend `minor-age-floor` policy strips sentences
7
+ * by STRING, so a rewording silently narrows the floor for every consumer
8
+ * still on the pre-rephrase `@nodaro/prompts` — including person.nodaro.ai,
9
+ * whose client-assembled `seedPrompt` is precisely how the 2026-07-30 P0
10
+ * prompts carried `state-fitted`'s clause to the provider.
11
+ *
12
+ * The retired strings therefore stay in the strip set forever. A retired
13
+ * string can only over-strip, and only for a subject already judged a minor.
14
+ */
15
+ describe("retired adult-only wording stays inside the floor", () => {
16
+ it("every retired string is still returned by getAdultOnlyHintStrings()", () => {
17
+ const live = new Set(getAdultOnlyHintStrings())
18
+ const missing = RETIRED_ADULT_ONLY_HINT_STRINGS.filter((s) => !live.has(s.toLowerCase()))
19
+ expect(missing, "retired strings dropped out of the floor").toEqual([])
20
+ })
21
+
22
+ it("the 2026-07-30 incident clause is in the set by name", () => {
23
+ expect(getAdultOnlyHintStrings()).toContain(
24
+ "the clothing fitted and form-conscious, hugging the contours of the body",
25
+ )
26
+ expect(getAdultOnlyHintStrings()).toContain("wearing a cropped style, midriff and navel visible")
27
+ expect(getAdultOnlyHintStrings()).toContain("with lips slightly parted, taking a soft breath")
28
+ })
29
+
30
+ it("the NEW wording is a live needle too — the floor tracks the rephrase forward", () => {
31
+ // The derivation makes this true by construction, but nothing else states
32
+ // it: a future edit that drops a hint under the `length >= 8` filter, or
33
+ // an entry quietly losing `adultOnly`, would narrow the floor silently.
34
+ // (Green only after Task 5; see that task's Step 7 run set.)
35
+ const strings = getAdultOnlyHintStrings()
36
+ expect(strings).toContain("tailored, close-fitting clothing")
37
+ expect(strings).toContain("with a cropped hemline")
38
+ })
39
+
40
+ it("no bare derived TERM leaked into the needle set (the promptHint-only contract)", () => {
41
+ const strings = getAdultOnlyHintStrings()
42
+ for (const t of ["lounging", "cropped top", "school uniform", "lying down", "wet clothing", "bare shoulders"]) {
43
+ expect(strings, `term "${t}" must never be a needle`).not.toContain(t)
44
+ }
45
+ })
46
+
47
+ it("every retired string is lower-case and long enough to survive the length filter", () => {
48
+ for (const s of RETIRED_ADULT_ONLY_HINT_STRINGS) {
49
+ expect(s, s).toBe(s.toLowerCase())
50
+ expect(s.length, s).toBeGreaterThanOrEqual(8)
51
+ }
52
+ })
53
+
54
+ it("the set covers all 15 flagged rephrased entries plus the fold literal", () => {
55
+ // 15 flagged promptHints (the 14 flagged person/styling entries plus the
56
+ // pose twin) + the hard-coded fold literal = 16. `promptHint`s only —
57
+ // never terms. A change to the count must be a deliberate edit here, not
58
+ // a silent drift.
59
+ expect(RETIRED_ADULT_ONLY_HINT_STRINGS.length).toBe(16)
60
+ })
61
+
62
+ it("the list is longest-first, matching getAdultOnlyHintStrings' contract", () => {
63
+ const strings = getAdultOnlyHintStrings()
64
+ for (let i = 1; i < strings.length; i++) {
65
+ expect(strings[i - 1].length).toBeGreaterThanOrEqual(strings[i].length)
66
+ }
67
+ })
68
+ })
@@ -218,7 +218,7 @@ describe("renderSubjectHints — the flat-bag behaviors", () => {
218
218
  { distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"] },
219
219
  IMAGE,
220
220
  )
221
- expect(out).toEqual(["wearing a cropped style, midriff and navel visible"])
221
+ expect(out).toEqual(["with a cropped hemline and the navel showing"])
222
222
  })
223
223
 
224
224
  it("de-duplicates an exact repeated clause, first occurrence winning", () => {
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { PEOPLE } from "../person.js"
3
+ import { STYLINGS } from "../styling.js"
4
+ import { POSES } from "../pose.js"
5
+ import { PICKER_CATALOGS } from "../picker-catalogs.js"
6
+ import { RETIRED_ADULT_ONLY_HINT_STRINGS } from "../age-floor.js"
7
+
8
+ /**
9
+ * W1-b — spec 2026-09-01-app-reports-triage-design.md §3.3, wording approved
10
+ * by §10 decisions 1 and 6.
11
+ *
12
+ * 12 adult `generate-character` prompts were refused by the provider's safety
13
+ * filter because stacked picker fragments read as sexualized. The principle:
14
+ * describe the garment or the look, not exposed skin; drop
15
+ * "wet / sheen / exposed / bare / hugging / soft breath"; keep the attribute
16
+ * renderable.
17
+ *
18
+ * SCOPE RULING, LATER RESOLVED (replay-harness verdict, internal validation
19
+ * results, 2026-09-02): the go/no-go criterion initially failed for TWO
20
+ * entries — `eye-state-half-lidded` and `feature-collarbone-visible`
21
+ * rendered their attribute 0/10 and 0/22 times under either wording, so
22
+ * their approved rewording could not be validated at the time and both
23
+ * shipped byte-identical in the original W1-b PR. A follow-up replay on
24
+ * staging found wordings that DO render, approved 2026-09-03. Both are now
25
+ * included in PERSON_EXPECTED below with BOTH `promptHint` and `term`
26
+ * reworded, like every other entry — the compact-mode term is not left
27
+ * carrying the old wording.
28
+ *
29
+ * This file pins BOTH fields of every reworded entry — `promptHint` (verbose
30
+ * mode) and `term` (compact mode) — because a rephrase that touched only one
31
+ * of them would leave the old wording reachable through the other. The last
32
+ * test is the ratchet: no retired string may reappear anywhere in the
33
+ * catalog corpus.
34
+ */
35
+
36
+ interface Expected {
37
+ readonly id: string
38
+ readonly promptHint: string
39
+ readonly term: string
40
+ }
41
+
42
+ const PERSON_EXPECTED: ReadonlyArray<Expected> = [
43
+ { id: "bust-very-full", promptHint: "a fuller bust", term: "fuller bust" },
44
+ { id: "silhouette-hourglass", promptHint: "an hourglass figure", term: "hourglass figure" },
45
+ { id: "waist-defined", promptHint: "a defined waistline", term: "defined waistline" },
46
+ { id: "lip-state-glossy", promptHint: "with a glossy lip finish", term: "glossy lips" },
47
+ { id: "lip-state-parted", promptHint: "with lips relaxed and slightly open, as if mid-sentence", term: "lips slightly open" },
48
+ { id: "lip-state-bitten", promptHint: "lightly biting the lower lip", term: "biting lower lip" },
49
+ { id: "eye-state-staring-camera", promptHint: "looking directly into the camera", term: "direct gaze to camera" },
50
+ { id: "eye-state-half-lidded", promptHint: "with drowsy, partly closed eyes, the lids sitting low over the iris", term: "drowsy, partly closed eyes" },
51
+ { id: "texture-dewy", promptHint: "with dewy, luminous skin", term: "dewy skin" },
52
+ { id: "texture-glistening", promptHint: "with a light glistening sheen on the skin", term: "glistening sheen" },
53
+ { id: "texture-baby-soft", promptHint: "with soft, fine-pored skin", term: "soft fine-pored skin" },
54
+ { id: "texture-shower-fresh-wet", promptHint: "with fresh, water-dappled skin as if just out of the shower", term: "water-dappled skin" },
55
+ { id: "feature-bare-shoulders", promptHint: "with the shoulders uncovered", term: "shoulders uncovered" },
56
+ { id: "feature-collarbone-visible", promptHint: "with an open neckline that leaves the collarbones uncovered", term: "open neckline, collarbones uncovered" },
57
+ { id: "feature-midriff-visible", promptHint: "with a cropped hemline", term: "cropped hemline" },
58
+ ]
59
+
60
+ const STYLING_EXPECTED: ReadonlyArray<Expected> = [
61
+ { id: "state-fitted", promptHint: "tailored, close-fitting clothing", term: "close-fitting clothing" },
62
+ { id: "state-wet", promptHint: "the clothing soaked through and dripping", term: "soaked clothing" },
63
+ ]
64
+
65
+ const POSE_EXPECTED: ReadonlyArray<Expected> = [
66
+ { id: "biting-lip", promptHint: "lightly biting the lower lip, a subtle playful expression", term: "biting lower lip" },
67
+ ]
68
+
69
+ function check(catalog: ReadonlyArray<{ id: string; promptHint: string; term?: string }>, expected: ReadonlyArray<Expected>) {
70
+ for (const want of expected) {
71
+ const entry = catalog.find((e) => e.id === want.id)
72
+ expect(entry, `missing entry ${want.id}`).toBeDefined()
73
+ expect(entry!.promptHint, `${want.id}.promptHint`).toBe(want.promptHint)
74
+ expect(entry!.term, `${want.id}.term (authored, not label-derived)`).toBe(want.term)
75
+ }
76
+ }
77
+
78
+ describe("W1-b rephrase — the 17 reworded entries plus the pose twin", () => {
79
+ it("person entries carry the approved promptHint and term", () => {
80
+ check(PEOPLE, PERSON_EXPECTED)
81
+ })
82
+
83
+ it("eye-state-half-lidded and feature-collarbone-visible keep adultOnly through the rework", () => {
84
+ const halfLidded = PEOPLE.find((e) => e.id === "eye-state-half-lidded")
85
+ expect(halfLidded?.adultOnly).toBe(true)
86
+
87
+ const collarbone = PEOPLE.find((e) => e.id === "feature-collarbone-visible")
88
+ expect(collarbone?.adultOnly).toBe(true)
89
+ })
90
+
91
+ it("styling entries carry the approved promptHint and term", () => {
92
+ check(STYLINGS, STYLING_EXPECTED)
93
+ })
94
+
95
+ it("the pose twin of lip-state-bitten is reworded to match", () => {
96
+ check(POSES, POSE_EXPECTED)
97
+ })
98
+
99
+ it("all 18 ids are covered — the count is deliberate, not incidental", () => {
100
+ expect(PERSON_EXPECTED.length + STYLING_EXPECTED.length + POSE_EXPECTED.length).toBe(18)
101
+ })
102
+
103
+ it("RATCHET: no retired string survives anywhere in the catalog corpus", () => {
104
+ const offenders: string[] = []
105
+ for (const cat of PICKER_CATALOGS) {
106
+ const options = [...(cat.options ?? []), ...(cat.dimensions ?? []).flatMap((d) => d.options)]
107
+ for (const o of options) {
108
+ for (const retired of RETIRED_ADULT_ONLY_HINT_STRINGS) {
109
+ // The fold literal is not an entry field; it is pinned separately in
110
+ // person-exposure-hints.test.ts.
111
+ if (o.promptHint === retired || o.term === retired) {
112
+ offenders.push(`${cat.catalogId} • ${o.id} • "${retired}"`)
113
+ }
114
+ }
115
+ }
116
+ }
117
+ expect(offenders, `retired wording is still live:\n${offenders.join("\n")}`).toEqual([])
118
+ })
119
+ })
package/src/age-floor.ts CHANGED
@@ -65,10 +65,73 @@ export function getAdultOnlyIds(): ReadonlySet<string> {
65
65
  return new Set(getAdultOnlyEntries().map((e) => e.id))
66
66
  }
67
67
 
68
+ /**
69
+ * The pre-rework `promptHint` of every `adultOnly` entry the spec
70
+ * (2026-09-01-app-reports-triage-design §3.3) listed for rewording — including
71
+ * `eye-state-half-lidded` and `feature-collarbone-visible`, which the W1-b
72
+ * harness initially left byte-identical (0/10 and 0/22 render rate under
73
+ * either wording) and a 2026-09-03 replay then found renderable wordings for
74
+ * — plus the hard-coded midriff+navel fold clause. These strings are permanently part of the strip
75
+ * set: a consumer that has not bumped `@nodaro/prompts` still emits them, and
76
+ * the client-assembled `seedPrompt` path is exactly how the 2026-07-30
77
+ * minor-age prompts reached a provider. Retiring can only ever cause an EXTRA
78
+ * strip, and only for a subject `isMinorAge` has already judged a minor — so
79
+ * the list only grows, never shrinks.
80
+ *
81
+ * `promptHint` ONLY, for the same reason `getAdultOnlyHintStrings` gives:
82
+ * `term` is short display vocabulary and the composed-catalog projection
83
+ * back-fills a derived `term` for every option, so terms collide with benign
84
+ * everyday text. Assembled prompts and client seedPrompts are built from
85
+ * hints, never from terms. The three W1-b entries that are NOT flagged —
86
+ * `texture-dewy`, `texture-baby-soft`, `eye-state-staring-camera` (spec
87
+ * §3.3's "deliberately not flagged" list) — are absent: their old wording
88
+ * never belonged to the floor.
89
+ *
90
+ * 15 flagged hints + the fold literal = 16.
91
+ */
92
+ export const RETIRED_ADULT_ONLY_HINT_STRINGS: ReadonlyArray<string> = [
93
+ // bust-very-full
94
+ "very full bust",
95
+ // silhouette-hourglass
96
+ "hourglass silhouette",
97
+ // waist-defined
98
+ "defined waist",
99
+ // lip-state-glossy
100
+ "with high-shine glossy wet-look lips",
101
+ // lip-state-parted
102
+ "with lips slightly parted, taking a soft breath",
103
+ // lip-state-bitten
104
+ "playfully biting the lower lip",
105
+ // eye-state-half-lidded
106
+ "with heavy half-lidded sleepy eyes",
107
+ // texture-glistening
108
+ "with glistening skin, sweat or oil catching the light",
109
+ // texture-shower-fresh-wet
110
+ "with just-out-of-the-shower wet skin, water beading on the surface and rolling in slow droplets down the curves of the body",
111
+ // feature-bare-shoulders
112
+ "with bare shoulders exposed, the line of the collarbone and shoulder muscles uncovered",
113
+ // feature-collarbone-visible
114
+ "with a prominent collarbone clearly defined and catching the light",
115
+ // feature-midriff-visible
116
+ "wearing a cropped style with the midriff visible",
117
+ // state-fitted — the 2026-07-30 incident clause
118
+ "the clothing fitted and form-conscious, hugging the contours of the body",
119
+ // state-wet
120
+ "the clothing soaked and wet, the fabric clinging to the body and dripping water",
121
+ // pose `biting-lip` (the lip-state-bitten twin)
122
+ "biting the lower lip with a subtle playful expression",
123
+ // the hard-coded midriff+navel fold in emitIndependentFragments — not any
124
+ // entry's hint, so retiring it ADDS a needle for a clause that really is
125
+ // emitted verbatim.
126
+ "wearing a cropped style, midriff and navel visible",
127
+ ]
128
+
68
129
  /** Every full prompt-hint string a flagged entry can inject, lower-cased,
69
130
  * longest first — the backend policy strips text that contains any of them
70
131
  * (Layer 2), which is how flagged wording arriving inside free text (a
71
- * client-assembled seedPrompt) is caught.
132
+ * client-assembled seedPrompt) is caught. Seeded with
133
+ * `RETIRED_ADULT_ONLY_HINT_STRINGS` so a catalog rewording can never narrow
134
+ * the strip set for a consumer still shipping the pre-rephrase wording.
72
135
  *
73
136
  * `promptHint` ONLY, deliberately — `term` is analyzer/UI vocabulary
74
137
  * (compact-mode display), and `picker-catalogs.ts`'s composed-catalog
@@ -81,7 +144,7 @@ export function getAdultOnlyIds(): ReadonlySet<string> {
81
144
  * can actually arrive verbatim in free text — terms don't need to be (and
82
145
  * must not be) swept here. */
83
146
  export function getAdultOnlyHintStrings(): ReadonlyArray<string> {
84
- const out = new Set<string>()
147
+ const out = new Set<string>(RETIRED_ADULT_ONLY_HINT_STRINGS)
85
148
  for (const e of getAdultOnlyEntries()) {
86
149
  if (e.promptHint) out.add(e.promptHint.toLowerCase())
87
150
  }
@@ -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
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Described references + per-reference description overrides + rail captions —
3
+ * the ONE place either lane phrases "what this reference IS".
4
+ *
5
+ * Three shapes, one grammar (`<subject> — <description>.`) plus the caption's
6
+ * `<binding>: <caption>.`:
7
+ *
8
+ * - a DESCRIBED reference (`DescribedReference`, no media) → `Natalie — a
9
+ * tall woman in a red coat.` The subject is the NAME, because a described
10
+ * reference has no seat to bind to: correlation with the prose is by name,
11
+ * which is why the caller leaves the name in the prompt rather than an
12
+ * indexed `@slug:N` mention (that grammar is url-gated).
13
+ * - a per-use `descriptionOverride` on a reference the HYBRID format gives no
14
+ * description slot (a mention / canonical-fallback / location / object role
15
+ * phrase carries none) → `reference image A — a tall woman in a red coat.`
16
+ * Same grammar, the BINDING as the subject. Where a description slot DOES
17
+ * exist (every legacy bullet, and the hybrid extras' `, <desc>` clause) the
18
+ * override fills THAT slot instead, so the model is never told twice.
19
+ * - a rail CAPTION for a video / audio reference → `@video_1: the establishing
20
+ * drone shot.` Index-aligned with the caller's url array.
21
+ *
22
+ * Rendering is separate from JOINING on purpose: the lines are format-agnostic
23
+ * text, and each lane already owns where a trailing directive goes (the image
24
+ * hybrid's `trailingLines`, the video core's `trailingLines` /
25
+ * `allFallbackLines`). `appendReferenceLines` is the joiner for the call sites
26
+ * that have no such array to push into — it bullets the lines into the legacy
27
+ * "Use these characters:" block or lands them ahead of the `[style]` section in
28
+ * hybrid, matching what each lane's own assembly does.
29
+ */
30
+
31
+ import type { DescribedReference } from "@nodaro/shared"
32
+ import { insertBeforeStyleSection } from "./prompt-style-section.js"
33
+
34
+ /** Which reference-prompt format the lines are being rendered for. */
35
+ export type ReferenceLineFormat = "legacy" | "hybrid"
36
+
37
+ /** The legacy directive block every lane consolidates its bullets into. */
38
+ const CHARACTER_BLOCK_HEADER = "Use these characters:"
39
+
40
+ /**
41
+ * The ONE phrasing for "this subject is described as …":
42
+ * `<subject> — <description>.`
43
+ *
44
+ * `subject` is the name for a described reference and the reference's binding
45
+ * (`reference image A` / `@image_2`) for a per-use description override. Both
46
+ * halves are trimmed; an empty half yields `""` (the caller drops the line).
47
+ */
48
+ export function referenceDescriptionLine(
49
+ subject: string,
50
+ description: string | null | undefined,
51
+ ): string {
52
+ const s = subject.trim()
53
+ const d = description?.trim()
54
+ if (!s || !d) return ""
55
+ return `${s} — ${d}.`
56
+ }
57
+
58
+ /**
59
+ * Render the described references (name + description, no media) as trailing
60
+ * lines. Entries missing either half contribute nothing; entries repeating a
61
+ * name (case-insensitively) render once — a description a caller sent twice
62
+ * says nothing new to the model, and every sibling renderer in both lanes
63
+ * dedups the same way.
64
+ */
65
+ export function renderDescribedReferenceLines(
66
+ refs: readonly DescribedReference[] | undefined,
67
+ ): string[] {
68
+ if (!refs || refs.length === 0) return []
69
+ const lines: string[] = []
70
+ const seen = new Set<string>()
71
+ for (const r of refs) {
72
+ const key = r.name?.trim().toLowerCase()
73
+ if (!key || seen.has(key)) continue
74
+ const line = referenceDescriptionLine(r.name, r.description)
75
+ if (!line) continue
76
+ seen.add(key)
77
+ lines.push(line)
78
+ }
79
+ return lines
80
+ }
81
+
82
+ /**
83
+ * Render the video / audio rail captions as `@video_N: <caption>.` /
84
+ * `@audio_N: <caption>.` lines, index-aligned with the caller's url arrays.
85
+ *
86
+ * Each list is bounded by the count of references of that kind that actually
87
+ * ship, so a caption for a url the provider cap dropped never binds a phantom
88
+ * `@video_N`. Blank captions are holes in an aligned array, not lines.
89
+ */
90
+ export function renderReferenceCaptionLines(
91
+ videoCaptions: readonly string[] | undefined,
92
+ audioCaptions: readonly string[] | undefined,
93
+ counts: { readonly video: number; readonly audio: number },
94
+ ): string[] {
95
+ const lines: string[] = []
96
+ const render = (captions: readonly string[] | undefined, kind: "video" | "audio", cap: number) => {
97
+ if (!captions) return
98
+ for (let i = 0; i < captions.length && i < cap; i++) {
99
+ const text = captions[i]?.trim()
100
+ if (!text) continue
101
+ lines.push(`@${kind}_${i + 1}: ${text}.`)
102
+ }
103
+ }
104
+ render(videoCaptions, "video", counts.video)
105
+ render(audioCaptions, "audio", counts.audio)
106
+ return lines
107
+ }
108
+
109
+ /**
110
+ * Append reference lines to an assembled prompt the way the surrounding lane
111
+ * would have.
112
+ *
113
+ * - hybrid → trailing scene directives ahead of the `[style]` section (the
114
+ * section has no terminator, so a flat append would read as one more look
115
+ * clause).
116
+ * - legacy → `- ` bullets consolidated into the existing
117
+ * "Use these characters:" block, or a new block prepended ahead of the
118
+ * body — the same splice-or-create both lanes already do for their
119
+ * canonical-fallback and extra-ref bullets.
120
+ *
121
+ * No lines → the prompt is returned unchanged, byte-for-byte.
122
+ */
123
+ export function appendReferenceLines(
124
+ prompt: string,
125
+ lines: readonly string[],
126
+ format: ReferenceLineFormat,
127
+ ): string {
128
+ if (lines.length === 0) return prompt
129
+ if (format === "hybrid") return insertBeforeStyleSection(prompt, lines)
130
+ const bullets = lines.map((l) => `- ${l}`).join("\n")
131
+ if (prompt.startsWith(`${CHARACTER_BLOCK_HEADER}\n`)) {
132
+ const splitIdx = prompt.indexOf("\n\n")
133
+ if (splitIdx === -1) return `${prompt}\n${bullets}`
134
+ return `${prompt.slice(0, splitIdx)}\n${bullets}${prompt.slice(splitIdx)}`
135
+ }
136
+ const block = `${CHARACTER_BLOCK_HEADER}\n${bullets}`
137
+ return prompt ? `${block}\n\n${prompt}` : block
138
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export * from "./term.js"
16
16
  export * from "./parameter-prompt-hint.js"
17
17
  export * from "./entity-prompts.js"
18
18
  export * from "./brand-tokens.js"
19
+ export * from "./described-references.js"
19
20
  export * from "./prompt-builder.js"
20
21
  export * from "./prompt-builder-structured-fields.js"
21
22
  export * from "./direction-registry.js"