@nodaro/prompts 1.10.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.
Files changed (35) hide show
  1. package/dist/index.cjs +693 -53
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1084 -27
  4. package/dist/index.d.ts +1084 -27
  5. package/dist/index.js +664 -55
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
  9. package/src/__tests__/animal-getters-parity.test.ts +82 -0
  10. package/src/__tests__/assemble-image-input-cap.test.ts +212 -0
  11. package/src/__tests__/assemble-image-input.test.ts +93 -3
  12. package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
  13. package/src/__tests__/assemble-video-input.test.ts +301 -0
  14. package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
  15. package/src/__tests__/direction-registry.test.ts +393 -0
  16. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  17. package/src/__tests__/image-convergence-image.test.ts +370 -0
  18. package/src/__tests__/location-convergence-image.test.ts +29 -1
  19. package/src/__tests__/location-default-role-image.test.ts +166 -0
  20. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  21. package/src/__tests__/read-node-direction.test.ts +154 -0
  22. package/src/__tests__/read-node-subject.test.ts +140 -0
  23. package/src/__tests__/subject-fold.test.ts +232 -0
  24. package/src/__tests__/subject-registry.test.ts +312 -0
  25. package/src/assemble-image-input.ts +160 -58
  26. package/src/assemble-video-input.ts +244 -0
  27. package/src/direction-registry.ts +371 -0
  28. package/src/hint-shedding.ts +68 -0
  29. package/src/index.ts +10 -2
  30. package/src/parameter-prompt-hint.ts +8 -7
  31. package/src/picker-catalogs.ts +14 -7
  32. package/src/prompt-builder.ts +728 -58
  33. package/src/prompt-hint-join.ts +30 -0
  34. package/src/read-node-direction.ts +233 -0
  35. package/src/subject-registry.ts +464 -0
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The measured hint join, shared by the image (`composePromptText`) and video
3
+ * (`composeVideoPromptText`) composers so the two can never drift.
4
+ *
5
+ * EXACT NO-OP: zero hints → the user's prompt back VERBATIM AND UNTRIMMED (the
6
+ * platform-caller byte-parity contract — the old platform path passed the
7
+ * prompt straight to `buildImagePrompt`, which never trims, so trimming here
8
+ * would change the assembled prompt and the recorded `jobs.input_data`
9
+ * byte-for-byte). With hints → trim the body so the join reads cleanly
10
+ * ("prompt. hint", not "prompt . hint"), and drop a blank body so the result
11
+ * never starts with ". ".
12
+ *
13
+ * Never mutates its inputs.
14
+ */
15
+
16
+ /** The measured separator between the user's prompt and each folded hint. */
17
+ export const PROMPT_HINT_SEPARATOR = ". "
18
+
19
+ /**
20
+ * Join a user prompt with its folded hint clauses.
21
+ *
22
+ * The trailing `.filter((p) => p.length > 0)` on the join array is
23
+ * parity-critical — do NOT remove it as "redundant": `hints` arrives
24
+ * pre-filtered but `userPrompt` does not, so a blank user prompt would
25
+ * otherwise make the result start with ". ".
26
+ */
27
+ export function joinPromptHints(userPrompt: string, hints: readonly string[]): string {
28
+ if (hints.length === 0) return userPrompt
29
+ return [userPrompt.trim(), ...hints].filter((p) => p.length > 0).join(PROMPT_HINT_SEPARATOR)
30
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Narrow readers turning UNTRUSTED persisted node data (`workflows.nodes`
3
+ * JSONB — import, MCP write, node preset, a Studio-emitted graph) into the
4
+ * typed `subject` / `direction` / `structured` levers `assembleImageInput`
5
+ * accepts.
6
+ * `buildPayload` has no zod and workflow writes are
7
+ * `z.record(z.string(), z.unknown())`, so this blob may have been written years
8
+ * ago by any client.
9
+ *
10
+ * Used by ALL THREE image-assembly sites so what the canvas accepts cannot
11
+ * drift between them: the frontend single-node executor (`execute-node.ts`),
12
+ * the orchestrator (`payload-builder.ts`), and the config-panel final-prompt
13
+ * preview (`build-image-assemble-input.ts`).
14
+ *
15
+ * `readDirectionFields` is DERIVED FROM `DIRECTION_FIELDS`, never a hand list:
16
+ * a dimension added to the registry is honored here by construction, so the
17
+ * reader can never silently drop a key the wire schema and the renderer both
18
+ * know about. Anything unrecognised is DROPPED, never thrown on — a malformed
19
+ * blob must not fail a canvas run, and an unknown catalog ID already degrades
20
+ * to `""` inside each `get*PromptHint`, so this validates SHAPE only.
21
+ *
22
+ * BOUNDS MATCH THE WIRE SCHEMA where both exist, by SHARED CONSTANT:
23
+ * `DIRECTION_ID_MAX_CHARS` and `DIRECTION_ARRAY_CEILING` are the same two
24
+ * literals the `generate-image` route's `directionSchema` enforces, imported
25
+ * from the registry rather than re-typed here — a body the route accepts and a
26
+ * node the canvas re-runs must not disagree about which strings are ids.
27
+ *
28
+ * DELIBERATELY STRICTER THAN THE WIRE SCHEMA on `structured` values: the
29
+ * route's `structuredPromptFieldsSchema` declares every field as a bare
30
+ * `z.string().optional()` (unbounded), while `MAX_STRUCTURED_VALUE_CHARS` below
31
+ * bounds them at 200. Do not "fix" that asymmetry by loosening HERE — this
32
+ * reads a blob any client may have written years ago, and the values land
33
+ * verbatim in `jobs.input_data.prompt`. Tightening the ROUTE instead would be a
34
+ * new 400 on currently-accepted input, so a >200-char structured value stored
35
+ * on a node is dropped on a canvas run while `POST /v1/generate-image` still
36
+ * renders it. That is the one known divergence; it is bounded to a field the
37
+ * canvas UI never writes that long.
38
+ *
39
+ * RETURNS `undefined`, NEVER `{}`: an empty object would still be a *defined*
40
+ * `direction`, and the call sites' `...(x !== undefined ? { x } : {})` spread
41
+ * would then hand `assembleImageInput` a defined-but-empty lever. Returning
42
+ * `undefined` keeps that spread honest and the exact no-op branch taken.
43
+ */
44
+ import {
45
+ DIRECTION_ARRAY_CEILING,
46
+ DIRECTION_FIELDS,
47
+ DIRECTION_ID_MAX_CHARS,
48
+ type DirectionFields,
49
+ } from "./direction-registry.js"
50
+ import {
51
+ SUBJECT_ARRAY_CEILING,
52
+ SUBJECT_CUSTOM_AGE_KEY,
53
+ SUBJECT_ID_MAX_CHARS,
54
+ getRegisteredSubjectKeys,
55
+ type SubjectFields,
56
+ } from "./subject-registry.js"
57
+ import type { StructuredPromptFields } from "./prompt-builder-structured-fields.js"
58
+
59
+ /**
60
+ * Read a node's stored cinematic-direction ids. Accepts a single id OR an array
61
+ * on every key (multi-pick dimensions carry arrays; a single-pick key may
62
+ * legitimately carry one).
63
+ *
64
+ * The SEMANTIC per-dimension cap stays the renderer's slice (`maxPicks`) — this
65
+ * reader does not know a row's pick budget and must not guess it. But it DOES
66
+ * bound cardinality at `DIRECTION_ARRAY_CEILING`, the same ceiling the wire
67
+ * schema enforces: the value is untrusted persisted JSONB (a node blob is
68
+ * validated only as `z.record(z.string(), z.unknown())` on write), and handing
69
+ * an unbounded array to `renderDirectionHints` would put an `includes`-dedupe
70
+ * scan in front of that slice. Keeping the FIRST `DIRECTION_ARRAY_CEILING`
71
+ * survivors is what the wire door already does to the same input.
72
+ *
73
+ * Junk is filtered BEFORE the cap, so valid ids sitting behind malformed
74
+ * entries survive rather than being crowded out by them.
75
+ */
76
+ export function readDirectionFields(value: unknown): DirectionFields | undefined {
77
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
78
+ const src = value as Record<string, unknown>
79
+ const out: Record<string, string | string[]> = {}
80
+ for (const spec of DIRECTION_FIELDS) {
81
+ const v = src[spec.key]
82
+ if (typeof v === "string") {
83
+ if (v.length > 0 && v.length <= DIRECTION_ID_MAX_CHARS) out[spec.key] = v
84
+ } else if (Array.isArray(v)) {
85
+ const kept: string[] = []
86
+ for (const x of v) {
87
+ if (kept.length >= DIRECTION_ARRAY_CEILING) break
88
+ if (typeof x === "string" && x.length > 0 && x.length <= DIRECTION_ID_MAX_CHARS) {
89
+ kept.push(x)
90
+ }
91
+ }
92
+ if (kept.length > 0) out[spec.key] = kept
93
+ }
94
+ }
95
+ return Object.keys(out).length > 0 ? (out as DirectionFields) : undefined
96
+ }
97
+
98
+ // ── subject ──────────────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Read a node's stored SUBJECT ids — the flat Person / Styling / prop bag the
102
+ * `subject` channel carries. Same three contracts as `readDirectionFields`:
103
+ * drop-never-throw, `undefined` never `{}`, and bounds shared with the wire
104
+ * door by CONSTANT (`SUBJECT_ID_MAX_CHARS` / `SUBJECT_ARRAY_CEILING`, which are
105
+ * defined AS the direction constants) so a body the route accepts and the same
106
+ * node re-run from the canvas cannot disagree about which strings are ids.
107
+ *
108
+ * DERIVED from `getRegisteredSubjectKeys()`, never a hand-authored field list —
109
+ * that is the whole lesson of `readStructuredFields` below, whose hand table is
110
+ * only viable because `StructuredPromptFields` is a small hand-authored type.
111
+ * The subject vocabulary is ~54 catalog-derived fields PLUS deployment-registered
112
+ * pack dimensions unknown at compile time, so it is read from the registry at
113
+ * call time. Iterating the KEY SET (never `Object.keys(value)`) is also what
114
+ * keeps an adversarial blob's key count off the hot path.
115
+ *
116
+ * The SEMANTIC per-dimension cap stays the renderer's job
117
+ * (`normalizeSubjectFields`), exactly as `maxPicks` does for direction: this
118
+ * reader bounds cardinality only. `customAge` is the one number on the wire and
119
+ * is passed through finite-only — the renderer clamps it.
120
+ */
121
+ export function readSubjectFields(value: unknown): SubjectFields | undefined {
122
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
123
+ const src = value as Record<string, unknown>
124
+ const out: Record<string, string | string[] | number> = {}
125
+ for (const key of getRegisteredSubjectKeys()) {
126
+ const v = src[key]
127
+ if (key === SUBJECT_CUSTOM_AGE_KEY) {
128
+ if (typeof v === "number" && Number.isFinite(v)) out[key] = v
129
+ continue
130
+ }
131
+ if (typeof v === "string") {
132
+ if (v.length > 0 && v.length <= SUBJECT_ID_MAX_CHARS) out[key] = v
133
+ } else if (Array.isArray(v)) {
134
+ // Junk is filtered BEFORE the cap, so valid ids sitting behind malformed
135
+ // entries survive rather than being crowded out by them.
136
+ const kept: string[] = []
137
+ for (const x of v) {
138
+ if (kept.length >= SUBJECT_ARRAY_CEILING) break
139
+ if (typeof x === "string" && x.length > 0 && x.length <= SUBJECT_ID_MAX_CHARS) {
140
+ kept.push(x)
141
+ }
142
+ }
143
+ if (kept.length > 0) out[key] = kept
144
+ }
145
+ }
146
+ return Object.keys(out).length > 0 ? (out as SubjectFields) : undefined
147
+ }
148
+
149
+ // ── structured ───────────────────────────────────────────────────────────────
150
+
151
+ type FieldKind = "string" | "number" | "gender"
152
+ const GENDERS = ["man", "woman", "child", "non-binary"] as const
153
+ const MAX_STRUCTURED_VALUE_CHARS = 200
154
+
155
+ type Group = Exclude<keyof StructuredPromptFields, "mood">
156
+ type GroupFields<K extends Group> = Record<keyof NonNullable<StructuredPromptFields[K]>, FieldKind>
157
+
158
+ /**
159
+ * A FIELD-BY-FIELD table (not a registry walk) on purpose:
160
+ * `StructuredPromptFields` is a small HAND-AUTHORED type, not catalog-derived,
161
+ * and the table is what blocks `person: { age: "drop table" }` from rendering
162
+ * verbatim into `jobs.input_data.prompt` — `renderStructuredFields` never
163
+ * throws on junk, but it does render it. Totality is enforced by the
164
+ * `GroupFields<K>` / `{ [K in Group]: … }` mapped types: a new field or group
165
+ * on the published type fails to typecheck here.
166
+ */
167
+ const PERSON_FIELDS: GroupFields<"person"> = {
168
+ age: "number",
169
+ gender: "gender",
170
+ hair: "string",
171
+ eyes: "string",
172
+ expression: "string",
173
+ profession: "string",
174
+ warriorType: "string",
175
+ }
176
+ const STYLING_FIELDS: GroupFields<"styling"> = {
177
+ mood: "string",
178
+ lighting: "string",
179
+ aesthetic: "string",
180
+ colorLook: "string",
181
+ }
182
+ const SETTING_FIELDS: GroupFields<"setting"> = {
183
+ era: "string",
184
+ atmosphere: "string",
185
+ backdrop: "string",
186
+ }
187
+ const CAMERA_FIELDS: GroupFields<"camera"> = {
188
+ framing: "string",
189
+ motion: "string",
190
+ format: "string",
191
+ }
192
+ const LENS_FIELDS: GroupFields<"lens"> = { focalLength: "string", aperture: "string" }
193
+
194
+ const STRUCTURED_GROUPS: { [K in Group]: GroupFields<K> } = {
195
+ person: PERSON_FIELDS,
196
+ styling: STYLING_FIELDS,
197
+ setting: SETTING_FIELDS,
198
+ camera: CAMERA_FIELDS,
199
+ lens: LENS_FIELDS,
200
+ }
201
+
202
+ function readField(v: unknown, kind: FieldKind): string | number | undefined {
203
+ if (kind === "number") return typeof v === "number" && Number.isFinite(v) ? v : undefined
204
+ if (typeof v !== "string" || v.length === 0 || v.length > MAX_STRUCTURED_VALUE_CHARS) {
205
+ return undefined
206
+ }
207
+ if (kind === "gender") return (GENDERS as readonly string[]).includes(v) ? v : undefined
208
+ return v
209
+ }
210
+
211
+ /** Read a node's stored Path-1 structured prompt fields. Same drop-never-throw
212
+ * and `undefined`-never-`{}` contract as `readDirectionFields`. */
213
+ export function readStructuredFields(value: unknown): StructuredPromptFields | undefined {
214
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
215
+ const src = value as Record<string, unknown>
216
+ const out: Record<string, unknown> = {}
217
+ for (const group of Object.keys(STRUCTURED_GROUPS) as Group[]) {
218
+ const raw = src[group]
219
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue
220
+ const rawRec = raw as Record<string, unknown>
221
+ const kept: Record<string, unknown> = {}
222
+ for (const [field, kind] of Object.entries(
223
+ STRUCTURED_GROUPS[group] as Record<string, FieldKind>,
224
+ )) {
225
+ const v = readField(rawRec[field], kind)
226
+ if (v !== undefined) kept[field] = v
227
+ }
228
+ if (Object.keys(kept).length > 0) out[group] = kept
229
+ }
230
+ const mood = readField(src.mood, "string")
231
+ if (mood !== undefined) out.mood = mood
232
+ return Object.keys(out).length > 0 ? (out as StructuredPromptFields) : undefined
233
+ }