@nodaro/prompts 1.11.0 → 1.12.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.
@@ -17,14 +17,26 @@
17
17
  * bug this channel exists to fix. The image side is structurally identical
18
18
  * (`assembleImageInput` = `composePromptText` → `buildImagePrompt`).
19
19
  *
20
+ * That ordering is also why cap-aware shedding here takes a `frame` callback
21
+ * rather than a provider id: the shed must run at the FOLD site (before the
22
+ * resolver) but be decided on the RESOLVED length (after it), so the binding
23
+ * text the resolver adds is inside the budget and can never be the thing that
24
+ * gets dropped. See {@link VideoPromptCapOptions}. Both catalog channels —
25
+ * SUBJECT and direction — fold into the one sheddable list that budget walks.
26
+ *
20
27
  * THE VERBOSITY POLICY LIVES HERE, NOT IN THE CLIENT: motion dimensions render
21
28
  * their compact professional term, look dimensions their full clause
22
- * (`VIDEO_HINT_MODE_DEFAULT`, resolved per row's `family` by the registry).
29
+ * (`VIDEO_HINT_MODE_DEFAULT`, resolved per row's `family` by the registry). The
30
+ * SUBJECT fold has its own policy — compact on video
31
+ * (`SUBJECT_VIDEO_HINT_MODE_DEFAULT`), because a fully specified person at full
32
+ * verbosity is ~30 paragraph clauses and the start frame already carries the
33
+ * subject's identity into the clip.
23
34
  * It is a threaded PARAMETER with a pure default — never deployment state:
24
35
  * `__tests__/content-free-contract.test.ts` hard-fails any environment read
25
36
  * under `packages/prompts/src`, and this module has nothing to read anyway.
26
37
  *
27
- * EXACT NO-OP CONTRACT: with no direction and no structured fields the caller's
38
+ * EXACT NO-OP CONTRACT: with no subject, no direction and no structured fields
39
+ * the caller's
28
40
  * `userPrompt` comes back VERBATIM AND UNTRIMMED — `undefined` included, since
29
41
  * a video prompt is optional on the route. That is what keeps every existing
30
42
  * caller byte-identical (the "backward-compatible: no connectedReferences →
@@ -33,8 +45,9 @@
33
45
  * `__tests__/assemble-video-input.test.ts`).
34
46
  *
35
47
  * WHAT IS DELIBERATELY NOT HERE: the dimension table, the fold order, the
36
- * dedupe and the surface filter all live in `direction-registry.ts` — ONE
37
- * renderer serves both surfaces, so the image and video folds cannot drift.
48
+ * dedupe and the surface filter all live in `direction-registry.ts` (and
49
+ * `subject-registry.ts` for the subject channel) — ONE renderer per channel
50
+ * serves both surfaces, so the image and video folds cannot drift.
38
51
  * Clients render their "will inject into prompt" preview by importing
39
52
  * `renderDirectionHints` + `joinPromptHints` directly.
40
53
  */
@@ -44,19 +57,112 @@ import {
44
57
  type DirectionFields,
45
58
  type DirectionHintMode,
46
59
  } from "./direction-registry.js"
60
+ import {
61
+ renderSubjectHints,
62
+ SUBJECT_VIDEO_HINT_MODE_DEFAULT,
63
+ type SubjectFields,
64
+ type SubjectHintMode,
65
+ } from "./subject-registry.js"
47
66
  import { joinPromptHints } from "./prompt-hint-join.js"
67
+ import { keepableDirectionHints } from "./hint-shedding.js"
48
68
  import {
49
69
  renderStructuredFields,
50
70
  type StructuredPromptFields,
51
71
  } from "./prompt-builder-structured-fields.js"
52
72
 
53
73
  /**
54
- * Fold a video run's cinematic-direction ids (and optional structured fields)
55
- * into its prompt body.
74
+ * Cap-aware shedding, opt-in. Absent → the composer is exactly what it always
75
+ * was (every existing caller stays byte-identical, and the no-op path below is
76
+ * never even reached differently).
77
+ *
78
+ * WHY A NUMBER AND A CALLBACK, NOT A PROVIDER ID — the two halves of the video
79
+ * surface's problem, which the image half did not have:
80
+ *
81
+ * - `cap` is the caller's EFFECTIVE ceiling, not `getMaxVideoPromptChars` read
82
+ * here. The routes compute it with `effectiveVideoPromptCeiling`, which
83
+ * mirrors `applyVideoNegativePrompt`'s reservation of the `"\nAvoid: …"`
84
+ * suffix for a provider with no native negative param. Re-deriving the cap
85
+ * inside this package would put a second copy of that reservation one
86
+ * refactor away from drifting from the clamp it is supposed to predict.
87
+ *
88
+ * - `frame` is the REFERENCE RESOLVER, and it is what makes the shed correct
89
+ * end-to-end. The fold runs BEFORE `resolveVideoReferenceCore` (see the
90
+ * module header — folding afterwards strands the scene description past the
91
+ * identity directives). The resolver then ADDS binding text: legacy's
92
+ * "Use these characters:" block, hybrid's lock lines and the canonical role
93
+ * phrases it APPENDS. That added text is exactly what an order-blind tail cut
94
+ * destroys first, so it must be inside the budget — but it must never be
95
+ * shed. Measuring THROUGH the caller's framing gives both properties at once:
96
+ * the shed decision sees the final length, while the only thing it can drop
97
+ * is a hint clause it rendered itself.
98
+ *
99
+ * Re-framing a SUBSET of the hints is sound because a hint can never change how
100
+ * the resolver reads the rest of the body: no registered catalog hint, term or
101
+ * label contains a `{image:N}` / `{ref:` / `@slug:N` shape
102
+ * (`__tests__/direction-hint-token-safety.test.ts` pins that for every catalog),
103
+ * so dropping one cannot renumber or unbind a reference.
104
+ */
105
+ export interface VideoPromptCapOptions {
106
+ /**
107
+ * The maximum length the FRAMED prompt may reach. Sheds only while the framed
108
+ * body exceeds it; `undefined` (the default) disables shedding entirely.
109
+ */
110
+ readonly cap?: number
111
+ /**
112
+ * The downstream framing the cap is measured through — the caller's reference
113
+ * assembly. Identity when omitted (a caller with a cap but no references).
114
+ * Must be PURE: it is called once per shed iteration, and the caller re-runs
115
+ * its own real assembly on the returned body afterwards.
116
+ */
117
+ readonly frame?: (body: string | undefined) => string | undefined
118
+ }
119
+
120
+ /**
121
+ * Fold a video run's subject and cinematic-direction ids (and optional
122
+ * structured fields) into its prompt body.
56
123
  *
57
- * The direction hints land first, in the registry's canonical table order
58
- * (camera motion leads), and the structured fragment lands LAST — the same
59
- * ordering `composePromptText` uses for stills.
124
+ * The SUBJECT hints land first (who is in the shot — the noun phrase the
125
+ * cinematography modifies), then the direction hints in the registry's
126
+ * canonical table order (camera motion leads), and the structured fragment
127
+ * lands LAST — the same ordering `composePromptText` uses for stills.
128
+ *
129
+ * `subject` rides `opts` rather than a fourth positional parameter on purpose:
130
+ * every existing caller passes `(prompt, direction)` or
131
+ * `(prompt, direction, structured)` positionally, and a new positional would
132
+ * have made the two levers' order a memorization test.
133
+ *
134
+ * TRUNCATION ORDERING (opt-in via `opts.cap`): the provider clamp
135
+ * (`applyVideoNegativePrompt`) slices the prompt TAIL, which is ORDER-BLIND —
136
+ * on a low-cap provider (kling = 1000) a broad direction renders more than the
137
+ * whole ceiling and the cut severs reference bindings and the end of the user's
138
+ * prose while decorative clauses survive. With a cap the composer decides
139
+ * instead: it knows which clauses are hints because it just rendered them, and
140
+ * drops them LAST-FOLDED FIRST until the framed prompt fits. Everything else —
141
+ * the user's prose, the structured fragment (user CONTENT, never a garnish) and
142
+ * every byte the resolver's framing adds — outranks a hint.
143
+ *
144
+ * SUBJECT CLAUSES ARE SHED CANDIDATES TOO, and they shed AFTER the direction
145
+ * clauses. Both folds are catalog decoration of the same class — ids the
146
+ * platform rendered into wording — so exempting one would just move the
147
+ * overflow into the order-blind clamp, which is the bug this machinery exists
148
+ * to prevent. They ride the SAME `hintClauses` list the shed already walks
149
+ * (subject first, direction second, tail-first shedding), so there is exactly
150
+ * one shed arithmetic (`hint-shedding.ts`) across both channels and both
151
+ * surfaces. Neither channel ever sheds before the prose, the references or the
152
+ * structured fragment.
153
+ *
154
+ * WHAT THE BUDGET DELIBERATELY EXCLUDES: the route's later opt-in identity
155
+ * injection (an async DB read that appends a canonical description) and any
156
+ * registered `applyPromptPolicies` transform both run AFTER the reference
157
+ * assembly and are not modelled here. Pricing them in would mean folding an
158
+ * await into this pure composer; instead the provider clamp stays their last
159
+ * resort, exactly as today. Same for a body that still overflows with ZERO
160
+ * hints left — long prose, or many bound references on their own.
161
+ *
162
+ * UNDER-CAP PARITY: the first pass folds every hint, so a prompt that fits is
163
+ * byte-identical to a capless call, and a caller with no
164
+ * `subject`/`direction`/`structured` takes the same exact no-op path it always
165
+ * did.
60
166
  *
61
167
  * @param userPrompt The user's prompt. Optional: an image-to-video run may
62
168
  * legitimately have none, and it is returned as-is when nothing folds.
@@ -65,25 +171,74 @@ import {
65
171
  * nothing — never a throw.
66
172
  * @param structured Path-1 structured fields. Not a `/v1/generate-video` wire
67
173
  * field today; the canvas orchestrator passes it directly.
68
- * @param opts.hintMode Override the verbosity policy (a whole-fold
174
+ * @param opts.hintMode Override the direction verbosity policy (a whole-fold
69
175
  * `PickerHintMode`, or a `{ look, motion }` split).
176
+ * @param opts.subject Flat subject ids (Person / Styling / props), same
177
+ * inertness contract as `direction`.
178
+ * @param opts.subjectHintMode Override the subject verbosity policy.
179
+ * @param opts.cap / `opts.frame` See {@link VideoPromptCapOptions}.
70
180
  */
71
181
  export function composeVideoPromptText(
72
182
  userPrompt: string | undefined,
73
183
  direction: DirectionFields | undefined,
74
184
  structured?: StructuredPromptFields,
75
- opts?: { readonly hintMode?: DirectionHintMode },
185
+ opts?: {
186
+ readonly hintMode?: DirectionHintMode
187
+ readonly subject?: SubjectFields
188
+ readonly subjectHintMode?: SubjectHintMode
189
+ } & VideoPromptCapOptions,
76
190
  ): string | undefined {
77
- const hints = [
191
+ // ONE sheddable list, subject FIRST then direction — because the shed walks it
192
+ // from the TAIL, so this order IS the survival order: a direction clause
193
+ // leaves before a subject clause. Deliberate, and the same order the image
194
+ // side uses (`renderImageHintPieces`): the subject is the noun phrase the
195
+ // cinematography modifies, so losing "who is in the shot" to keep a
196
+ // decorative grade would be the wrong trade. With no `subject` the list IS
197
+ // the direction fold, so every pre-subject caller is byte-identical.
198
+ const hintClauses = [
199
+ ...renderSubjectHints(opts?.subject, {
200
+ surface: "video",
201
+ mode: opts?.subjectHintMode ?? SUBJECT_VIDEO_HINT_MODE_DEFAULT,
202
+ }),
78
203
  ...renderDirectionHints(direction, {
79
204
  surface: "video",
80
205
  mode: opts?.hintMode ?? VIDEO_HINT_MODE_DEFAULT,
81
206
  }),
82
- structured ? renderStructuredFields(structured) : "",
83
207
  ].filter((p) => p.length > 0)
84
- // Nothing to fold → the caller's value straight back, `undefined` included.
85
- // Do NOT collapse this into `joinPromptHints(userPrompt ?? "", hints)`: that
86
- // would turn an absent prompt into `""` and break the no-op contract above.
87
- if (hints.length === 0) return userPrompt
88
- return joinPromptHints(userPrompt ?? "", hints)
208
+ // User CONTENT, not a garnish: never sheddable, always last.
209
+ const structuredFragment = structured ? renderStructuredFields(structured) : ""
210
+
211
+ const composeWith = (kept: number): string | undefined => {
212
+ const hints = [...hintClauses.slice(0, kept), structuredFragment].filter(
213
+ (p) => p.length > 0,
214
+ )
215
+ // Nothing to fold → the caller's value straight back, `undefined` included.
216
+ // Do NOT collapse this into `joinPromptHints(userPrompt ?? "", hints)`: that
217
+ // would turn an absent prompt into `""` and break the no-op contract above.
218
+ // A FULL shed lands here too, which is what keeps the no-op contract intact
219
+ // at `kept === 0` — the route's `composed !== prompt` guard then correctly
220
+ // leaves `input_data.userPrompt` unpinned.
221
+ if (hints.length === 0) return userPrompt
222
+ return joinPromptHints(userPrompt ?? "", hints)
223
+ }
224
+
225
+ const cap = opts?.cap
226
+ if (cap === undefined) return composeWith(hintClauses.length)
227
+
228
+ // Fold everything first (the under-cap byte-parity pass), then shed from the
229
+ // tail of the fold order while the FRAMED prompt overflows the ceiling.
230
+ // `keepableDirectionHints` — the ONE shed arithmetic, shared with the image
231
+ // assembler — strictly decreases `kept` whenever there is a deficit, so this
232
+ // terminates at `kept === 0` in the worst case, at which point nothing
233
+ // droppable is left and the provider clamp stands.
234
+ const frame = opts?.frame ?? ((body: string | undefined) => body)
235
+ let kept = hintClauses.length
236
+ let body = composeWith(kept)
237
+ let framedLength = frame(body)?.length ?? 0
238
+ while (framedLength > cap && kept > 0) {
239
+ kept = keepableDirectionHints(hintClauses, kept, framedLength - cap)
240
+ body = composeWith(kept)
241
+ framedLength = frame(body)?.length ?? 0
242
+ }
243
+ return body
89
244
  }
@@ -32,7 +32,8 @@
32
32
  * token, not a bare id. A single-id channel cannot carry it.
33
33
  * - Subject / Styling / prop dimensions (`animal`, `heldProp`, `material`,
34
34
  * Person, Styling) — a separate `subject` channel, deliberately out of scope
35
- * here.
35
+ * here. It now exists: `subject-registry.ts`, same table-driven shape, its
36
+ * key set DISJOINT from this one (pinned by a test) so nothing folds twice.
36
37
  *
37
38
  * PACK BLINDNESS (parity, not a regression): `get*PromptHint` reads the frozen
38
39
  * base arrays, so ids added by a deployment-registered catalog pack resolve to
@@ -158,6 +159,22 @@ const temporal = perId(getTemporalPromptHint, getTemporalTerm)
158
159
  * so they are NOT aliases of `shotSize` / `lightingStyle`, and an alias table
159
160
  * would wrongly suppress a legal second selection. Overlap is handled instead
160
161
  * by the exact-string dedupe in `renderDirectionHints`.
162
+ *
163
+ * SECOND MEANING OF POSITION: BOTH cap-aware assemblers — `assembleImageInput`
164
+ * (stills) and `composeVideoPromptText` (video) — shed hint clauses from the
165
+ * TAIL of this order when a provider's prompt cap overflows, through the one
166
+ * shared arithmetic in `hint-shedding.ts`. So a row's position is also its
167
+ * survival order under the cap on EVERY surface: reordering rows for one
168
+ * surface silently changes what the other drops first, and the row a
169
+ * video-surface reorder would most likely touch (`cameraMotion`) leads the
170
+ * fold. That is a consequence of reusing the fold order, not a ranking — this
171
+ * table stays a compatibility order; anything that needs a real importance
172
+ * ranking should add an explicit priority column rather than reorder these rows.
173
+ *
174
+ * WHERE THIS TABLE SITS IN THE COMBINED ORDER: both assemblers fold the SUBJECT
175
+ * channel (`subject-registry.ts`) BEFORE this one and shed the combined list
176
+ * tail-first, so every direction row here is dropped before any subject clause.
177
+ * Deliberate — see `hint-shedding.ts` for the argument.
161
178
  */
162
179
  export const DIRECTION_FIELDS = [
163
180
  { key: "cameraMotion", surface: "video", family: "motion", maxPicks: 1, render: perId(getCameraMotionPromptHint, getCameraMotionTerm) },
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The shed arithmetic shared by the image (`assembleImageInput`) and video
3
+ * (`composeVideoPromptText`) cap-aware assemblers, so the two surfaces cannot
4
+ * drift in WHICH clause goes first when a provider's prompt cap overflows.
5
+ *
6
+ * Only the arithmetic lives here. Each surface keeps its own loop, because what
7
+ * they MEASURE differs: the image side reads `buildImagePrompt`'s
8
+ * `overflowChars` (the cap clamp reports how much it cut), while the video side
9
+ * measures the resolver-FRAMED body against the route's effective ceiling. Both
10
+ * hand this function the same question — "how many of the first `kept` clauses
11
+ * may stay if `deficit` characters have to leave the body?" — and both re-assemble
12
+ * and re-check afterwards.
13
+ *
14
+ * WHAT COUNTS AS A SHEDDABLE CLAUSE (both surfaces, one answer): every clause
15
+ * the platform RENDERED from catalog ids — the SUBJECT fold and the cinematic
16
+ * DIRECTION fold alike. They are decoration of the same class, so exempting
17
+ * either would not save it: the overflow would simply land in the provider's
18
+ * order-blind tail clamp, severing reference bindings or the end of the user's
19
+ * prose instead — precisely the bug this machinery exists to prevent. Never
20
+ * sheddable: the user's prose, the bound references and the framing text the
21
+ * reference resolver adds, and the structured fragment (user CONTENT).
22
+ */
23
+ import { PROMPT_HINT_SEPARATOR } from "./prompt-hint-join.js"
24
+
25
+ /**
26
+ * How many of the first `kept` hint clauses may STAY if `deficit`
27
+ * characters have to leave the body. Walks the fold order from the TAIL,
28
+ * subtracting each clause plus the separator it brought, and stops as soon as
29
+ * enough has been reclaimed.
30
+ *
31
+ * The name is historical (direction was the first and for a while the only
32
+ * channel); the list both callers pass is now the COMBINED fold —
33
+ * `[...subject, ...direction]` on both surfaces — so the shed order is that
34
+ * combined order REVERSED: the direction block empties first, then the subject
35
+ * block. Deliberate, and the reason the two folds share one list: a fully
36
+ * specified person renders ~30 clauses, so a subject fold left unsheddable
37
+ * would be the single biggest way to push an overflow into the order-blind
38
+ * clamp, while a decorative grade or ISO value survives.
39
+ *
40
+ * Within the direction block the order is `DIRECTION_FIELDS` order REVERSED
41
+ * (and within the subject block, `SUBJECT_FIELDS` reversed). Note what that
42
+ * is and is not: each table's order is a COMPATIBILITY order (grouped by family,
43
+ * with the legacy `DirectionFields` block pinned last so every pre-registry
44
+ * caller's fold stays byte-identical) — it is NOT a ranking of how load-bearing
45
+ * a dimension is, and this function does not claim one. Tail-first is chosen
46
+ * because it is deterministic, matches the fold order the API documents, and
47
+ * needs no second ordering to drift out of sync with the table. A caller mixing
48
+ * legacy keys with the newer ones can therefore lose e.g. `lightingId` before a
49
+ * decorative `isoValue` clause; if that ever matters, the fix is an explicit
50
+ * priority column on `DIRECTION_FIELDS`, not a second hand-kept list here.
51
+ *
52
+ * Deliberately approximate (assembly is not perfectly additive); the caller
53
+ * re-assembles and re-checks, and this function strictly decreases `kept`
54
+ * whenever `deficit > 0`, so that loop terminates.
55
+ */
56
+ export function keepableDirectionHints(
57
+ hintClauses: readonly string[],
58
+ kept: number,
59
+ deficit: number,
60
+ ): number {
61
+ let remaining = deficit
62
+ let next = kept
63
+ while (next > 0 && remaining > 0) {
64
+ next -= 1
65
+ remaining -= hintClauses[next]!.length + PROMPT_HINT_SEPARATOR.length
66
+ }
67
+ return next
68
+ }
package/src/index.ts CHANGED
@@ -19,7 +19,9 @@ export * from "./brand-tokens.js"
19
19
  export * from "./prompt-builder.js"
20
20
  export * from "./prompt-builder-structured-fields.js"
21
21
  export * from "./direction-registry.js"
22
+ export * from "./subject-registry.js"
22
23
  export * from "./prompt-hint-join.js"
24
+ export * from "./hint-shedding.js"
23
25
  export * from "./video-reference-resolver.js"
24
26
  export * from "./sound-aggregator.js"
25
27
  export * from "./assemble-suno-input.js"
@@ -38,7 +38,7 @@ import { composeCameraMotionHintFromConnections } from "./camera-motions.js"
38
38
  import { composeTransitionHintFromConnections, type TransitionDuration, type TransitionIntensity, type TransitionPosition, type TransitionTiming } from "./transitions.js"
39
39
  import { composeCharacterFxHintFromConnections, type CharacterFxDuration, type CharacterFxIntensity, type CharacterFxPosition, type CharacterFxTiming } from "./character-fx.js"
40
40
  import { buildMaterialHints } from "./materials.js"
41
- import { getAnimal } from "@nodaro/shared"
41
+ import { getAnimalPromptHint, getAnimalTerm } from "@nodaro/shared"
42
42
  import { getVehicle } from "@nodaro/shared"
43
43
  import { getWeapon } from "@nodaro/shared"
44
44
  import { getFurniture } from "@nodaro/shared"
@@ -322,15 +322,16 @@ function resolveBaseHint(
322
322
  return withCustomText(data, byMode(mode, getLoopSubjectPromptHint, getLoopSubjectTerm)(asStr(data.loopSubject)))
323
323
  case "material":
324
324
  return withCustomText(data, buildMaterialHints(data.material, mode))
325
- case "animal": {
326
- const animal = getAnimal(asStr(data.animal))
325
+ // Animal is the one Object-entity catalog whose phrasing has a single
326
+ // owner: `@nodaro/shared`'s `getAnimalPromptHint` / `getAnimalTerm`, which
327
+ // the picker-catalog funnel calls too. Both getters already return "" on a
328
+ // miss, so the entry lookup and the `animal ? … : ""` guard are the
329
+ // getters' job now, not this switch's.
330
+ case "animal":
327
331
  return withCustomText(
328
332
  data,
329
- animal
330
- ? byMode(mode, `featuring a ${animal.label.toLowerCase()}, ${animal.description}`, objectEntityTerm(animal))
331
- : "",
333
+ byMode(mode, getAnimalPromptHint, getAnimalTerm)(asStr(data.animal)),
332
334
  )
333
- }
334
335
  case "vehicle": {
335
336
  const vehicle = getVehicle(asStr(data.vehicle))
336
337
  return withCustomText(
@@ -69,7 +69,7 @@ import {
69
69
  } from "./character-fx.js"
70
70
  import { POSES, POSE_CATEGORY_LABELS, POSE_CATEGORY_ORDER } from "./pose.js"
71
71
  import { MATERIALS, MATERIAL_CATEGORY_LABELS, MATERIAL_CATEGORY_ORDER } from "./materials.js"
72
- import { ANIMALS, ANIMAL_SUBCATEGORY_LABELS, ANIMAL_SUBCATEGORY_ORDER } from "@nodaro/shared"
72
+ import { ANIMALS, ANIMAL_SUBCATEGORY_LABELS, ANIMAL_SUBCATEGORY_ORDER, getAnimalPromptHint } from "@nodaro/shared"
73
73
  import { VEHICLES, VEHICLE_SUBCATEGORY_LABELS, VEHICLE_SUBCATEGORY_ORDER } from "@nodaro/shared"
74
74
  import { WEAPONS, WEAPON_SUBCATEGORY_LABELS, WEAPON_SUBCATEGORY_ORDER } from "@nodaro/shared"
75
75
  import { FURNITURE, FURNITURE_SUBCATEGORY_LABELS, FURNITURE_SUBCATEGORY_ORDER } from "@nodaro/shared"
@@ -193,6 +193,13 @@ function toOptions<T extends BaseCatalogEntry>(
193
193
  * label + description. Reproduce the exact phrasing from
194
194
  * `getParameterPromptHint` so the registry stays faithful + every option's
195
195
  * `promptHint` is non-empty.
196
+ *
197
+ * ANIMALS is the exception, and the direction of travel for the other three:
198
+ * its phrasing now has ONE owner, `@nodaro/shared`'s `getAnimalPromptHint`,
199
+ * which this funnel and `getParameterPromptHint` both call instead of
200
+ * re-authoring the sentence. `phrase` therefore takes the ENTRY (not
201
+ * label+description), so a catalog whose phrasing has moved to a getter can be
202
+ * pointed at it.
196
203
  */
197
204
  interface ObjectCatalogEntry {
198
205
  readonly id: string
@@ -204,14 +211,14 @@ interface ObjectCatalogEntry {
204
211
  }
205
212
  function objectOptions(
206
213
  arr: ReadonlyArray<ObjectCatalogEntry>,
207
- phrase: (label: string, description: string) => string,
214
+ phrase: (entry: ObjectCatalogEntry) => string,
208
215
  ): ReadonlyArray<PickerOption> {
209
216
  return arr.map((e) => ({
210
217
  id: e.id,
211
218
  label: e.label,
212
219
  description: e.description,
213
220
  category: e.subcategory,
214
- promptHint: phrase(e.label.toLowerCase(), e.description),
221
+ promptHint: phrase(e),
215
222
  // Object entities have no `promptHint` field of their own (it is
216
223
  // synthesized above), so the term cannot come from `resolveTerm`'s
217
224
  // empty-hint rule: an authored `term` wins, and otherwise the label IS the
@@ -608,7 +615,7 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
608
615
  defaultValue: "dog-golden-retriever",
609
616
  categoryOrder: ANIMAL_SUBCATEGORY_ORDER,
610
617
  categoryLabels: ANIMAL_SUBCATEGORY_LABELS,
611
- options: objectOptions(ANIMALS, (label, description) => `featuring a ${label}, ${description}`),
618
+ options: objectOptions(ANIMALS, (e) => getAnimalPromptHint(e.id)),
612
619
  },
613
620
  {
614
621
  nodeType: "vehicle",
@@ -619,7 +626,7 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
619
626
  defaultValue: "sedan",
620
627
  categoryOrder: VEHICLE_SUBCATEGORY_ORDER,
621
628
  categoryLabels: VEHICLE_SUBCATEGORY_LABELS,
622
- options: objectOptions(VEHICLES, (label, description) => `featuring a ${label}, ${description}`),
629
+ options: objectOptions(VEHICLES, (e) => `featuring a ${e.label.toLowerCase()}, ${e.description}`),
623
630
  },
624
631
  {
625
632
  nodeType: "weapon",
@@ -630,7 +637,7 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
630
637
  defaultValue: "katana",
631
638
  categoryOrder: WEAPON_SUBCATEGORY_ORDER,
632
639
  categoryLabels: WEAPON_SUBCATEGORY_LABELS,
633
- options: objectOptions(WEAPONS, (label, description) => `with a ${label}, ${description}`),
640
+ options: objectOptions(WEAPONS, (e) => `with a ${e.label.toLowerCase()}, ${e.description}`),
634
641
  },
635
642
  {
636
643
  nodeType: "furniture",
@@ -641,7 +648,7 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
641
648
  defaultValue: "sofa",
642
649
  categoryOrder: FURNITURE_SUBCATEGORY_ORDER,
643
650
  categoryLabels: FURNITURE_SUBCATEGORY_LABELS,
644
- options: objectOptions(FURNITURE, (label, description) => `including a ${label}, ${description}`),
651
+ options: objectOptions(FURNITURE, (e) => `including a ${e.label.toLowerCase()}, ${e.description}`),
645
652
  },
646
653
  {
647
654
  nodeType: "held-prop",