@nodaro/prompts 1.13.0 → 1.15.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.
Files changed (69) hide show
  1. package/dist/index.cjs +719 -255
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +390 -2
  4. package/dist/index.d.ts +390 -2
  5. package/dist/index.js +689 -257
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/adult-only-ratchet.test.ts +110 -0
  9. package/src/__tests__/age-floor.test.ts +347 -0
  10. package/src/__tests__/catalog-id-guard.test.ts +146 -0
  11. package/src/__tests__/catalog-overlay.test.ts +163 -0
  12. package/src/__tests__/dod-replace-pack-acceptance.test.ts +17 -0
  13. package/src/__tests__/fixtures/parameter-hint-golden.json +2 -2
  14. package/src/__tests__/minor-age-floor.test.ts +136 -0
  15. package/src/__tests__/multi-picker-spec.test.ts +21 -1
  16. package/src/__tests__/person-pack-adult-only.test.ts +45 -0
  17. package/src/__tests__/person-regional-aesthetic.test.ts +2 -1
  18. package/src/__tests__/picker-analyzer-registry.test.ts +14 -0
  19. package/src/__tests__/provider-prompt-doctrine.test.ts +39 -0
  20. package/src/action-fx.ts +2 -1
  21. package/src/aesthetic.ts +2 -1
  22. package/src/age-floor.ts +296 -0
  23. package/src/age-signal.ts +36 -0
  24. package/src/atmosphere.ts +2 -1
  25. package/src/backdrop.ts +2 -1
  26. package/src/camera-format.ts +2 -1
  27. package/src/camera-motions.ts +2 -1
  28. package/src/catalog-id-guard.ts +189 -0
  29. package/src/catalog-overlay.ts +148 -0
  30. package/src/catalog-packs.ts +27 -2
  31. package/src/character-fx.ts +2 -1
  32. package/src/color-look.ts +2 -1
  33. package/src/composition-effects.ts +2 -1
  34. package/src/era.ts +2 -1
  35. package/src/exposure-settings.ts +2 -1
  36. package/src/framing.ts +2 -1
  37. package/src/gemini-omni-inputs.ts +11 -3
  38. package/src/held-prop.ts +3 -1
  39. package/src/index.ts +4 -0
  40. package/src/instrumentation.ts +5 -4
  41. package/src/lens.ts +2 -1
  42. package/src/lighting.ts +2 -1
  43. package/src/loop-subject.ts +3 -1
  44. package/src/materials.ts +2 -1
  45. package/src/mood.ts +16 -5
  46. package/src/music-genre.ts +5 -4
  47. package/src/music-mood.ts +4 -3
  48. package/src/parameter-prompt-hint.ts +13 -57
  49. package/src/person-packs.ts +14 -2
  50. package/src/person.ts +75 -38
  51. package/src/photo-genre.ts +14 -3
  52. package/src/photographer.ts +2 -1
  53. package/src/picker-analyzer-registry.ts +46 -0
  54. package/src/picker-catalogs.ts +11 -0
  55. package/src/pose.ts +17 -6
  56. package/src/post-process-effects.ts +2 -1
  57. package/src/prompt-builder.ts +3 -3
  58. package/src/prompt-wizard-categories.ts +6 -0
  59. package/src/provider-prompt-doctrine.ts +51 -2
  60. package/src/render-quality.ts +2 -1
  61. package/src/setting.ts +3 -1
  62. package/src/shared-catalog-overlay.ts +83 -0
  63. package/src/style.ts +17 -1
  64. package/src/styling.ts +62 -30
  65. package/src/subject-registry.ts +2 -2
  66. package/src/temporal.ts +2 -1
  67. package/src/transitions.ts +2 -1
  68. package/src/voice-character.ts +6 -5
  69. package/src/voice-delivery.ts +4 -3
@@ -0,0 +1,296 @@
1
+ /**
2
+ * The minor-age floor — Layer 1 of spec 2026-09-01-app-reports-triage-design.md
3
+ * §3.3 (W1-a). Content-free: this module decides WHICH catalog entries a minor
4
+ * subject may not carry; the clause a deployment appends lives in its
5
+ * registered PromptPolicy (backend/src/lib/prompt-policies/), never here.
6
+ *
7
+ * `ADULT_ONLY_FLAG`, `ADULT_AGE_IDS`, `MINOR_IMPLYING_TYPE_IDS` and
8
+ * `isMinorAge` live in the import-free leaf `age-signal.ts` and are
9
+ * re-exported here unchanged — this module's own catalog-funnel imports
10
+ * (`person-packs.js`, `picker-catalogs.js`) are exactly what `person.ts` /
11
+ * `styling.ts` must NOT reach through, on pain of reopening the
12
+ * person → age-floor → picker-catalogs load-time cycle (see `age-signal.ts`'s
13
+ * header). `person.ts` / `styling.ts` import `isMinorAge` from
14
+ * `age-signal.ts` directly instead of from here.
15
+ */
16
+
17
+ import { getRegisteredPeople } from "./person-packs.js"
18
+ import { getRegisteredPickerCatalogs, type PickerCatalog, type PickerOption } from "./picker-catalogs.js"
19
+ import { isMinorAge, ADULT_AGE_IDS, MINOR_IMPLYING_TYPE_IDS } from "./age-signal.js"
20
+
21
+ export { ADULT_ONLY_FLAG, ADULT_AGE_IDS, MINOR_IMPLYING_TYPE_IDS, isMinorAge } from "./age-signal.js"
22
+
23
+ /** Shape shared by every catalog entry the floor reads. */
24
+ export interface AdultOnlyEntry {
25
+ readonly id: string
26
+ readonly promptHint: string
27
+ readonly term?: string
28
+ readonly adultOnly?: true
29
+ }
30
+
31
+ /** The picker catalogs (by `catalogId`) this floor sweeps beside person.
32
+ * Exported because it is the SINGLE source of truth for two things that used
33
+ * to be maintained by hand and drifted: which catalogs `getAdultOnlyEntries`
34
+ * sweeps for the flag, and which analyzer keys `FLOORED_PICKER_KEYS` strips
35
+ * flagged ids out of (a `photo-genre` swept for the flag but absent from the
36
+ * strip list let `glamour-portrait` survive on a minor). */
37
+ export const ADULT_SWEPT_CATALOG_IDS = ["styling", "mood", "pose", "photo-genre"] as const
38
+
39
+ /** Membership form of the list above (the sweep does one lookup per catalog). */
40
+ const ADULT_SWEPT_CATALOG_ID_SET: ReadonlySet<string> = new Set(ADULT_SWEPT_CATALOG_IDS)
41
+
42
+ /** Flatten a catalog's options regardless of shape: single-dim catalogs carry
43
+ * `options` directly, multi-dim ones carry `dimensions[].options` (mirrors
44
+ * `applyDeny` in catalog-packs.ts, the other consumer that has to handle
45
+ * both shapes). */
46
+ function optionsOf(catalog: PickerCatalog): ReadonlyArray<PickerOption> {
47
+ if (catalog.kind === "single") return catalog.options ?? []
48
+ return (catalog.dimensions ?? []).flatMap((d) => d.options)
49
+ }
50
+
51
+ /** Every entry, across the swept catalogs, that carries the flag. Reads every
52
+ * catalog through the pack-composed funnel — `getRegisteredPeople()` for
53
+ * person, `getRegisteredPickerCatalogs()` for styling/mood/pose/photo-genre —
54
+ * so a pack-added or pack-replaced entry is included. */
55
+ export function getAdultOnlyEntries(): ReadonlyArray<AdultOnlyEntry> {
56
+ const catalogEntries = getRegisteredPickerCatalogs()
57
+ .filter((c) => ADULT_SWEPT_CATALOG_ID_SET.has(c.catalogId))
58
+ .flatMap(optionsOf)
59
+ const all: ReadonlyArray<AdultOnlyEntry> = [...getRegisteredPeople(), ...catalogEntries]
60
+ return all.filter((e) => e.adultOnly === true)
61
+ }
62
+
63
+ /** The set of flagged ids (fast membership for the collectors). */
64
+ export function getAdultOnlyIds(): ReadonlySet<string> {
65
+ return new Set(getAdultOnlyEntries().map((e) => e.id))
66
+ }
67
+
68
+ /** Every full prompt-hint string a flagged entry can inject, lower-cased,
69
+ * longest first — the backend policy strips text that contains any of them
70
+ * (Layer 2), which is how flagged wording arriving inside free text (a
71
+ * client-assembled seedPrompt) is caught.
72
+ *
73
+ * `promptHint` ONLY, deliberately — `term` is analyzer/UI vocabulary
74
+ * (compact-mode display), and `picker-catalogs.ts`'s composed-catalog
75
+ * projection back-fills a `term` for EVERY option via `deriveTerm(label)`
76
+ * when no explicit term was authored (e.g. "lounging", "cropped top",
77
+ * "school uniform", "lying down"). Those short, generic derived terms are
78
+ * exactly the kind of everyday word that collides with unrelated, benign
79
+ * text and over-strips it. Assembled prompts and client seedPrompts are
80
+ * built from HINTS, never from terms, so hints are the only strings that
81
+ * can actually arrive verbatim in free text — terms don't need to be (and
82
+ * must not be) swept here. */
83
+ export function getAdultOnlyHintStrings(): ReadonlyArray<string> {
84
+ const out = new Set<string>()
85
+ for (const e of getAdultOnlyEntries()) {
86
+ if (e.promptHint) out.add(e.promptHint.toLowerCase())
87
+ }
88
+ return [...out].filter((s) => s.length >= 8).sort((a, b) => b.length - a.length)
89
+ }
90
+
91
+ /**
92
+ * Every full prompt-hint string that DESCRIBES A MINOR — the mirror of
93
+ * `getAdultOnlyHintStrings`, and the needle list for the TEXT signal below.
94
+ *
95
+ * Why a text signal exists at all: `isMinorAge` reads the structured picker
96
+ * value, and the P0 arrival path does not have one. A thin client can create a
97
+ * character row carrying only `{nodeId, name, projectId}` and send the picker
98
+ * selection as an already-assembled `seedPrompt`, so `row.person === null`
99
+ * while the prompt itself says "a young child around 5 years old". That client
100
+ * assembles the text FROM THESE HINTS, which is exactly why the catalog is the
101
+ * drift-proof needle list: a new minor age entry is swept the day it is added,
102
+ * with no second list to remember.
103
+ *
104
+ * Selection is DERIVED, never hand-listed: an `age` entry is a minor unless its
105
+ * id is in the `ADULT_AGE_IDS` allow-list (so a new age id is inside the floor
106
+ * by default — same ratchet as `isMinorAge`), plus every `type` entry named by
107
+ * `MINOR_IMPLYING_TYPE_IDS`. Read through the pack-composed funnel
108
+ * (`getRegisteredPeople()`), so a deployment pack's entries are swept too.
109
+ *
110
+ * `promptHint` ONLY — never `term` and never `label`, for the same reason
111
+ * `getAdultOnlyHintStrings` gives: `term` is short display vocabulary
112
+ * ("in their teens" as a compact chip) and the composed-catalog projection
113
+ * back-fills a derived `term` for every option, so terms collide with benign
114
+ * text. The `>= 8` filter drops `age-custom`'s empty hint (`""`), which would
115
+ * otherwise match every string ever written.
116
+ */
117
+ let cachedMinorHints: ReadonlyArray<string> | null = null
118
+ let cachedMinorHintSourceCount = -1
119
+ export function getMinorAgeHintStrings(): ReadonlyArray<string> {
120
+ const people = getRegisteredPeople()
121
+ if (cachedMinorHints && cachedMinorHintSourceCount === people.length) return cachedMinorHints
122
+ const out = new Set<string>()
123
+ for (const e of people) {
124
+ const minorAge = e.dimension === "age" && !ADULT_AGE_IDS.has(e.id)
125
+ const minorType = e.dimension === "type" && MINOR_IMPLYING_TYPE_IDS.has(e.id)
126
+ if (!minorAge && !minorType) continue
127
+ if (e.promptHint) out.add(e.promptHint.toLowerCase())
128
+ }
129
+ cachedMinorHintSourceCount = people.length
130
+ cachedMinorHints = [...out].filter((s) => s.length >= 8).sort((a, b) => b.length - a.length)
131
+ return cachedMinorHints
132
+ }
133
+
134
+ function escapeReSource(s: string): string {
135
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
136
+ }
137
+
138
+ /** One needle's regex source: the phrase's tokens — split on whitespace AND
139
+ * hyphens — escaped and rejoined by `[\s-]+`, so it matches however the text
140
+ * that carries it happened to be spaced or hyphenated. */
141
+ function needleTokenSource(needle: string): string {
142
+ return needle
143
+ .trim()
144
+ .split(/[\s-]+/)
145
+ .filter((t) => t.length > 0)
146
+ .map(escapeReSource)
147
+ .join("[\\s-]+")
148
+ }
149
+
150
+ /**
151
+ * The word-bounded alternation SOURCE for a needle list — the one place both
152
+ * layers of the floor build their matcher, so they can never disagree about
153
+ * what "the same phrase" means.
154
+ *
155
+ * Two properties, both load-bearing:
156
+ * - **Separator tolerance.** A catalog hint is authored with single spaces
157
+ * ("very full bust"), but the free text that carries it is written by a
158
+ * human or an LLM and arrives as "very-full bust" or "very full bust".
159
+ * A literal alternation misses those, and a miss is a flagged phrase
160
+ * reaching the provider on a minor's prompt. Each needle's tokens are
161
+ * joined by `[\s-]+`, which requires AT LEAST one separator — so
162
+ * "very fullbust" (no separator at all) is still not a match, and the
163
+ * phrase can't silently widen into a substring rule.
164
+ * - **Word boundaries.** `(?<![\w-])` / `(?![\w-])` keep a needle from
165
+ * gluing onto an adjacent word's characters ("mesh tops" must survive a
166
+ * "mesh top" needle).
167
+ *
168
+ * Ordering is the CALLER's: pass needles longest-first so the alternation
169
+ * consumes the longest match at a given position instead of leaving debris.
170
+ * Returns `null` when there is nothing to match (an empty list would otherwise
171
+ * compile to `(?:)`, which matches everywhere). Flags are the caller's too —
172
+ * `"i"` for a `.test()` instance, `"gi"` for one used with `String.replace`.
173
+ */
174
+ export function buildNeedleAlternationSource(needles: ReadonlyArray<string>): string | null {
175
+ const alts = needles.map(needleTokenSource).filter((s) => s.length > 0)
176
+ if (alts.length === 0) return null
177
+ return `(?<![\\w-])(?:${alts.join("|")})(?![\\w-])`
178
+ }
179
+
180
+ /** Needle alternation over `getMinorAgeHintStrings()`, word-bounded on both
181
+ * sides so a hint can only match a whole phrase. Deliberately NOT global:
182
+ * this instance is only ever `.test()`ed, and a `/g` regex would carry
183
+ * `lastIndex` across calls. Cached by needle count (packs are registered
184
+ * before the first prompt is assembled). */
185
+ let cachedMinorNeedleRe: RegExp | null = null
186
+ let cachedMinorNeedleCount = -1
187
+ function minorNeedleRegex(): RegExp | null {
188
+ const needles = getMinorAgeHintStrings()
189
+ if (cachedMinorNeedleRe && cachedMinorNeedleCount === needles.length) return cachedMinorNeedleRe
190
+ cachedMinorNeedleCount = needles.length
191
+ const src = buildNeedleAlternationSource(needles)
192
+ cachedMinorNeedleRe = src === null ? null : new RegExp(src, "i")
193
+ return cachedMinorNeedleRe
194
+ }
195
+
196
+ /**
197
+ * NUMBER-FIRST age shapes: the ones `buildAgeFragment` (person.ts) emits for a
198
+ * CUSTOM age ("N years old", "N year old", "N-year-old") and the catalog's own
199
+ * "2-3 years old" range (the FIRST number decides), plus the colloquial tails a
200
+ * human or an LLM writes instead — "12yo", "12 yo", "12 y.o.", "12 y/o",
201
+ * "12 yr old", "12-yr-old", "12yrs old", "12 years of age". The cut is `< 20`,
202
+ * the same boundary `isMinorAge` uses for `customAge` and the same one
203
+ * `buildAgeFragment` switches to "in their teens" at.
204
+ *
205
+ * Both boundaries are load-bearing. The LEADING one stops a match inside a
206
+ * larger token; the TRAILING one is what makes "a 5 year older sibling" a
207
+ * non-hit ("old" may not glue onto "older") and what stops "yo" from firing
208
+ * inside "12 young" or "12 yoga". Used via `matchAll`, which clones the regex
209
+ * rather than advancing this instance's `lastIndex`.
210
+ */
211
+ const NUMERIC_AGE_RE =
212
+ /(?<![\w-])(\d{1,3})(?:\s*-\s*\d{1,3})?\s*-?\s*(?:years?\s*-?\s*old|yrs?\s*-?\s*old|y(?:\.\s*o\.?|\/o|o)|years?\s+of\s+age)(?![\w-])/gi
213
+
214
+ /**
215
+ * AGE-FIRST shapes, where the number trails the word instead of leading it:
216
+ * "age 12", "aged 12", "at the age of 12". The leading boundary is what keeps
217
+ * "image 12" / "page 12" out (the `age` there is glued to a preceding word
218
+ * character), and the trailing one keeps "aged 12th" out.
219
+ */
220
+ const PREFIXED_AGE_RE = /(?<![\w-])aged?\s+(?:of\s+)?(\d{1,3})(?![\w-])/gi
221
+
222
+ /** Every rule that reads a NUMBER out of the text, each capturing the age in
223
+ * group 1. Iterated together below so a new shape is one array entry. */
224
+ const AGE_NUMBER_RULES: ReadonlyArray<RegExp> = [NUMERIC_AGE_RE, PREFIXED_AGE_RE]
225
+
226
+ /** The one NON-numeric shape `buildAgeFragment` emits below 20 (`${n} years
227
+ * old, in their teens`). Kept as its own rule rather than as a catalog term.
228
+ * Separator-tolerant on the same terms as the needle alternation, so
229
+ * "in their teens" and "in-their-teens" are the same phrase. */
230
+ const IN_THEIR_TEENS_RE = /(?<![\w-])in[\s-]+their[\s-]+teens(?![\w-])/i
231
+
232
+ /**
233
+ * True when free text describes a MINOR subject. Three rules, in order:
234
+ * 1. any minor-age / minor-implying-type prompt hint, word-bounded;
235
+ * 2. a numeric age below 20 in any shape `buildAgeFragment` emits;
236
+ * 3. the literal "in their teens".
237
+ *
238
+ * Deliberately NOT a bare-word check: "child", "teen", "kid" alone must never
239
+ * fire, or an adult prompt that merely MENTIONS a child ("a mother holding her
240
+ * child") would be floored — the spec's "adults are byte-identical" is a hard
241
+ * requirement, so every rule here is a full phrase or a bounded number.
242
+ *
243
+ * Pairs with `isMinorAge`, never replaces it: the structured picker value is
244
+ * the primary signal; this catches the same subject when only the assembled
245
+ * text survives.
246
+ */
247
+ export function containsMinorAgeHint(text: string | null | undefined): boolean {
248
+ if (typeof text !== "string" || text.trim().length === 0) return false
249
+ const re = minorNeedleRegex()
250
+ if (re && re.test(text)) return true
251
+ for (const rule of AGE_NUMBER_RULES) {
252
+ for (const m of text.matchAll(rule)) {
253
+ const n = Number(m[1])
254
+ if (Number.isFinite(n) && n < 20) return true
255
+ }
256
+ }
257
+ return IN_THEIR_TEENS_RE.test(text)
258
+ }
259
+
260
+ /** The analyzer/import value keys the floor strips flagged ids out of:
261
+ * `person` plus EVERY catalog the flag sweep reads. DERIVED, never
262
+ * hand-listed — the hand-written version omitted `photo-genre` while the
263
+ * sweep included it, so a minor kept `glamour-portrait`. The analyzer JSON is
264
+ * keyed by picker type id (`describe-to-picker.ts`), which is the same string
265
+ * as the catalog id, so this list IS the set of keys to visit. */
266
+ export const FLOORED_PICKER_KEYS = ["person", ...ADULT_SWEPT_CATALOG_IDS] as const
267
+
268
+ function stripIds(obj: Record<string, unknown>, drop: ReadonlySet<string>): Record<string, unknown> {
269
+ const out: Record<string, unknown> = {}
270
+ for (const [k, v] of Object.entries(obj)) {
271
+ if (typeof v === "string") {
272
+ if (!drop.has(v)) out[k] = v
273
+ } else if (Array.isArray(v)) {
274
+ const kept = v.filter((x) => !(typeof x === "string" && drop.has(x)))
275
+ if (kept.length > 0) out[k] = kept
276
+ } else {
277
+ out[k] = v
278
+ }
279
+ }
280
+ return out
281
+ }
282
+
283
+ /** Analyzer / import post-filter: when the person value describes a minor,
284
+ * remove every flagged id from the person, styling, pose and mood values.
285
+ * Identity (same reference) for an adult. */
286
+ export function applyMinorAgeFloorToPickerValues<T extends Record<string, unknown>>(values: T): T {
287
+ const person = values.person
288
+ if (!person || typeof person !== "object" || !isMinorAge(person as { age?: string; customAge?: number; type?: string })) return values
289
+ const drop = getAdultOnlyIds()
290
+ const out: Record<string, unknown> = { ...values }
291
+ for (const key of FLOORED_PICKER_KEYS) {
292
+ const v = out[key]
293
+ if (v && typeof v === "object" && !Array.isArray(v)) out[key] = stripIds(v as Record<string, unknown>, drop)
294
+ }
295
+ return out as T
296
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * LEAF MODULE — no runtime imports. person.ts and styling.ts import this;
3
+ * any catalog import here reopens the person → age-floor → picker-catalogs
4
+ * load-time cycle.
5
+ *
6
+ * `isMinorAge` is an ADULT ALLOW-LIST on purpose: a new age id added to the
7
+ * catalog is inside the floor until someone lists it here as adult. Custom
8
+ * numeric ages use the same boundary as `buildAgeFragment` in person.ts
9
+ * ("in their teens" below 20).
10
+ */
11
+
12
+ export const ADULT_ONLY_FLAG = "adultOnly" as const
13
+
14
+ /** Catalog age ids that describe an ADULT. Everything else is floored. */
15
+ export const ADULT_AGE_IDS: ReadonlySet<string> = new Set([
16
+ "age-early-20s", "age-late-20s", "age-20s", "age-30s", "age-40s", "age-50s", "age-60s", "age-elderly",
17
+ ])
18
+
19
+ /** Type entries whose hint reads as a child even with no age selected. */
20
+ export const MINOR_IMPLYING_TYPE_IDS: ReadonlySet<string> = new Set([
21
+ "alice-wonderland", "dorothy-oz", "peter-pan", "magical-girl", "prince", "princess",
22
+ ])
23
+
24
+ /** True when the picker value describes someone under 20, or a child-typed
25
+ * subject with no age. An explicit adult age always wins. */
26
+ export function isMinorAge(
27
+ value: { readonly age?: string; readonly customAge?: number; readonly type?: string } | null | undefined,
28
+ ): boolean {
29
+ if (!value) return false
30
+ const { age, customAge, type } = value
31
+ if (typeof age === "string" && age.length > 0) {
32
+ if (age === "age-custom") return !(typeof customAge === "number" && Number.isFinite(customAge) && customAge >= 20)
33
+ return !ADULT_AGE_IDS.has(age)
34
+ }
35
+ return typeof type === "string" && MINOR_IMPLYING_TYPE_IDS.has(type)
36
+ }
package/src/atmosphere.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { resolveTerm, type PickerHintMode } from "./term.js"
15
+ import { overlayEntry } from "./catalog-overlay.js"
15
16
 
16
17
  export interface Atmosphere {
17
18
  readonly id: string
@@ -73,7 +74,7 @@ const atmosphereById = new Map<string, Atmosphere>(ATMOSPHERES.map((a) => [a.id,
73
74
 
74
75
  export function getAtmosphere(id: string | undefined | null): Atmosphere | undefined {
75
76
  if (!id) return undefined
76
- return atmosphereById.get(id)
77
+ return overlayEntry("atmosphere", id, atmosphereById.get(id))
77
78
  }
78
79
 
79
80
  export function getAtmosphereLabel(id: string | undefined | null, fallback?: string): string {
package/src/backdrop.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { resolveTerm } from "./term.js"
26
+ import { overlayEntry } from "./catalog-overlay.js"
26
27
 
27
28
  export type BackdropCategory =
28
29
  | "solid"
@@ -105,7 +106,7 @@ const backdropById = new Map<string, Backdrop>(BACKDROPS.map((b) => [b.id, b]))
105
106
 
106
107
  export function getBackdrop(id: string | undefined | null): Backdrop | undefined {
107
108
  if (!id) return undefined
108
- return backdropById.get(id)
109
+ return overlayEntry("backdrop", id, backdropById.get(id))
109
110
  }
110
111
 
111
112
  export function getBackdropLabel(id: string | undefined | null, fallback?: string): string {
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { resolveTerm } from "./term.js"
13
+ import { overlayEntry } from "./catalog-overlay.js"
13
14
 
14
15
  export interface CameraFormat {
15
16
  readonly id: string
@@ -76,7 +77,7 @@ const formatById = new Map<string, CameraFormat>(CAMERA_FORMATS.map((f) => [f.id
76
77
 
77
78
  export function getCameraFormat(id: string | undefined | null): CameraFormat | undefined {
78
79
  if (!id) return undefined
79
- return formatById.get(id)
80
+ return overlayEntry("camera-format", id, formatById.get(id))
80
81
  }
81
82
 
82
83
  export function getCameraFormatLabel(id: string | undefined | null, fallback?: string): string {
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { resolveTerm, type PickerHintMode } from "./term.js"
11
+ import { overlayEntry } from "./catalog-overlay.js"
11
12
 
12
13
  export type CameraMotionCategory =
13
14
  | "default"
@@ -605,7 +606,7 @@ const motionById = new Map<string, CameraMotion>(
605
606
 
606
607
  export function getCameraMotion(id: string | undefined | null): CameraMotion | undefined {
607
608
  if (!id) return undefined
608
- return motionById.get(id)
609
+ return overlayEntry("camera-motions", id, motionById.get(id))
609
610
  }
610
611
 
611
612
  /** Human-readable label for the given motion id. Falls back to the id if unknown. */
@@ -0,0 +1,189 @@
1
+ import { PICKER_CATALOGS, getPickerCatalog } from "./picker-catalogs.js"
2
+ import { getRegisteredCatalogPacks, catalogPacksVersion } from "./catalog-packs.js"
3
+
4
+ /**
5
+ * THE WALL: refuse work that names a catalog id this deployment does not
6
+ * offer.
7
+ *
8
+ * A curated deployment composes its picker catalogs from packs — entries
9
+ * removed, some rewritten. The browser is supposed to show only the composed
10
+ * catalogs and the resolvers are supposed to honor them, but neither of those
11
+ * is a SAFETY property: a stale tab, an imported workflow, a hand-made request
12
+ * or a bug in either layer can still hand the run path an id the deployment
13
+ * never offered. This module is what makes curation hold regardless — it is
14
+ * consulted at the one place every run passes (the orchestrator, after input
15
+ * overrides are merged), inside nested sub-workflows, and on the single-node
16
+ * routes that carry ids on the wire.
17
+ *
18
+ * INERT WITHOUT PACKS. A deployment that registers no catalog packs offers
19
+ * every base id, so there is nothing to refuse; the guard returns [] without
20
+ * walking. Mainline behavior is unchanged by construction.
21
+ *
22
+ * WHAT COUNTS AS AN ID FIELD. Every field a picker catalog declares — a
23
+ * single-dim catalog's `valueField`, a multi-dim catalog's `dimensions[].field`
24
+ * — plus the few resolver side-fields the catalogs do not declare (the pose
25
+ * sub-picks, the legacy direction keys). A field NOT in that table is never
26
+ * validated: `preText`, `customText`, `customAge`, free-text notes and the
27
+ * like are not ids and must not be refused. The table is derived from the
28
+ * BASE catalogs, so a field a `replace` pack dropped is still recognised as an
29
+ * id field and any value in it fails membership — which is the point.
30
+ */
31
+
32
+ export interface ForeignCatalogId {
33
+ /** The node carrying the id (undefined for a request body). */
34
+ readonly nodeId?: string
35
+ readonly nodeType: string
36
+ readonly field: string
37
+ readonly id: string
38
+ readonly catalogId: string
39
+ }
40
+
41
+ /** Node-data fields that resolve against a catalog but are not declared by
42
+ * it. Kept beside the guard so a new one is a one-line, reviewed addition. */
43
+ const SIDE_FIELDS: ReadonlyArray<readonly [nodeType: string, field: string, catalogId: string]> = [
44
+ // pose.ts resolves all four sub-picks through getPosePromptHint
45
+ ["pose", "handPosition", "pose"],
46
+ ["pose", "bodyLean", "pose"],
47
+ ["pose", "headTilt", "pose"],
48
+ ["pose", "activity", "pose"],
49
+ ]
50
+
51
+ /** Legacy `direction` wire keys still accepted by direction-registry.ts. */
52
+ const DIRECTION_ALIASES: Readonly<Record<string, string>> = {
53
+ framingId: "framing",
54
+ framingAngleId: "framing",
55
+ lightingId: "lighting",
56
+ lensId: "lens",
57
+ cameraFormatId: "camera-format",
58
+ }
59
+
60
+ interface FieldTable {
61
+ /** nodeType → (field → catalogId) */
62
+ readonly byNodeType: ReadonlyMap<string, ReadonlyMap<string, string>>
63
+ /** field → catalogId, across every catalog (for direction/subject bodies) */
64
+ readonly byField: ReadonlyMap<string, string>
65
+ }
66
+
67
+ let tableMemo: FieldTable | null = null
68
+ /** Derived from the BASE catalogs once — the field contract is code, not packs. */
69
+ function fieldTable(): FieldTable {
70
+ if (tableMemo) return tableMemo
71
+ const byNodeType = new Map<string, Map<string, string>>()
72
+ const byField = new Map<string, string>()
73
+ const put = (nodeType: string, field: string, catalogId: string) => {
74
+ let m = byNodeType.get(nodeType)
75
+ if (!m) byNodeType.set(nodeType, (m = new Map()))
76
+ m.set(field, catalogId)
77
+ if (!byField.has(field)) byField.set(field, catalogId)
78
+ }
79
+ for (const c of PICKER_CATALOGS) {
80
+ if (c.valueField) put(c.nodeType, c.valueField, c.catalogId)
81
+ for (const d of c.dimensions ?? []) put(c.nodeType, d.field, c.catalogId)
82
+ for (const f of c.fields ?? []) put(c.nodeType, f, c.catalogId)
83
+ }
84
+ for (const [nodeType, field, catalogId] of SIDE_FIELDS) put(nodeType, field, catalogId)
85
+ for (const [alias, catalogId] of Object.entries(DIRECTION_ALIASES)) if (!byField.has(alias)) byField.set(alias, catalogId)
86
+ tableMemo = { byNodeType, byField }
87
+ return tableMemo
88
+ }
89
+
90
+ let idsMemo: { v: number; byCatalog: Map<string, ReadonlySet<string>> } | null = null
91
+ /** Every id the COMPOSED catalog offers, across options and all dimensions. */
92
+ function composedIds(catalogId: string): ReadonlySet<string> {
93
+ const v = catalogPacksVersion()
94
+ if (!idsMemo || idsMemo.v !== v) idsMemo = { v, byCatalog: new Map() }
95
+ const hit = idsMemo.byCatalog.get(catalogId)
96
+ if (hit) return hit
97
+ const cat = getPickerCatalog(catalogId)
98
+ const ids = new Set<string>()
99
+ for (const o of cat?.options ?? []) ids.add(o.id)
100
+ for (const d of cat?.dimensions ?? []) for (const o of d.options) ids.add(o.id)
101
+ idsMemo.byCatalog.set(catalogId, ids)
102
+ return ids
103
+ }
104
+
105
+ function values(v: unknown): string[] {
106
+ if (typeof v === "string") return v ? [v] : []
107
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string" && x.length > 0)
108
+ return []
109
+ }
110
+
111
+ /** The guard is only meaningful once something curates. */
112
+ export function catalogGuardActive(): boolean {
113
+ return getRegisteredCatalogPacks().length > 0
114
+ }
115
+
116
+ function checkRecord(
117
+ nodeType: string,
118
+ nodeId: string | undefined,
119
+ data: Record<string, unknown>,
120
+ fields: ReadonlyMap<string, string>,
121
+ out: ForeignCatalogId[],
122
+ ): void {
123
+ for (const [field, catalogId] of fields) {
124
+ const raw = data[field]
125
+ if (raw === undefined || raw === null) continue
126
+ const offered = composedIds(catalogId)
127
+ for (const id of values(raw)) {
128
+ if (!offered.has(id)) out.push({ nodeId, nodeType, field, id, catalogId })
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Walk a graph. Parameter nodes are checked against their own catalog's
135
+ * fields; consumer nodes (generate-image, image-to-video, …) carry folded
136
+ * `direction` / `subject` records whose keys are the same field names, so
137
+ * those are checked against the global field table.
138
+ */
139
+ export function findForeignCatalogIds(
140
+ nodes: ReadonlyArray<{ id?: unknown; type?: unknown; data?: unknown }> | undefined,
141
+ ): ForeignCatalogId[] {
142
+ if (!catalogGuardActive() || !nodes) return []
143
+ const table = fieldTable()
144
+ const out: ForeignCatalogId[] = []
145
+ for (const node of nodes) {
146
+ const type = typeof node.type === "string" ? node.type : ""
147
+ const nodeId = typeof node.id === "string" ? node.id : undefined
148
+ const data = node.data && typeof node.data === "object" ? (node.data as Record<string, unknown>) : null
149
+ if (!type || !data) continue
150
+ const own = table.byNodeType.get(type)
151
+ if (own) checkRecord(type, nodeId, data, own, out)
152
+ for (const key of ["direction", "subject"] as const) {
153
+ const rec = data[key]
154
+ if (rec && typeof rec === "object" && !Array.isArray(rec)) {
155
+ checkRecord(type, nodeId, rec as Record<string, unknown>, table.byField, out)
156
+ }
157
+ }
158
+ }
159
+ return out
160
+ }
161
+
162
+ /** A request body's `direction` / `subject` records (the single-node routes). */
163
+ export function findForeignCatalogIdsInBody(
164
+ nodeType: string,
165
+ body: { direction?: unknown; subject?: unknown } | undefined,
166
+ ): ForeignCatalogId[] {
167
+ if (!catalogGuardActive() || !body) return []
168
+ const table = fieldTable()
169
+ const out: ForeignCatalogId[] = []
170
+ for (const key of ["direction", "subject"] as const) {
171
+ const rec = body[key]
172
+ if (rec && typeof rec === "object" && !Array.isArray(rec)) {
173
+ checkRecord(nodeType, undefined, rec as Record<string, unknown>, table.byField, out)
174
+ }
175
+ }
176
+ return out
177
+ }
178
+
179
+ /** One line, naming every offender — the audit trail and the user message. */
180
+ export function foreignCatalogIdMessage(found: readonly ForeignCatalogId[]): string {
181
+ const parts = [...new Set(found.map((f) => `${f.field}="${f.id}"`))]
182
+ return `This deployment does not offer the following picker value${parts.length === 1 ? "" : "s"}: ${parts.join(", ")}. Choose from the options shown in the picker.`
183
+ }
184
+
185
+ /** Test hook. */
186
+ export function __resetCatalogIdGuardForTests(): void {
187
+ tableMemo = null
188
+ idsMemo = null
189
+ }