@nodaro/prompts 1.11.0 → 1.13.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 (36) hide show
  1. package/dist/index.cjs +627 -177
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +726 -33
  4. package/dist/index.d.ts +726 -33
  5. package/dist/index.js +598 -179
  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 +236 -0
  11. package/src/__tests__/assemble-image-input.test.ts +100 -19
  12. package/src/__tests__/assemble-video-input-cap.test.ts +442 -0
  13. package/src/__tests__/assemble-video-input.test.ts +167 -33
  14. package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
  15. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  16. package/src/__tests__/location-convergence-image.test.ts +29 -1
  17. package/src/__tests__/location-default-role-image.test.ts +166 -0
  18. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  19. package/src/__tests__/prompt-style-section.test.ts +345 -0
  20. package/src/__tests__/read-node-subject.test.ts +140 -0
  21. package/src/__tests__/style-section-boundary.test.ts +179 -0
  22. package/src/__tests__/subject-fold.test.ts +251 -0
  23. package/src/__tests__/subject-registry.test.ts +312 -0
  24. package/src/assemble-image-input.ts +169 -41
  25. package/src/assemble-video-input.ts +200 -25
  26. package/src/direction-registry.ts +116 -28
  27. package/src/hint-shedding.ts +87 -0
  28. package/src/index.ts +3 -0
  29. package/src/parameter-prompt-hint.ts +8 -7
  30. package/src/picker-catalogs.ts +14 -7
  31. package/src/prompt-builder.ts +628 -88
  32. package/src/prompt-hint-join.ts +9 -0
  33. package/src/prompt-style-section.ts +256 -0
  34. package/src/read-node-direction.ts +60 -1
  35. package/src/subject-registry.ts +464 -0
  36. package/src/video-reference-resolver.ts +5 -2
@@ -10,6 +10,15 @@
10
10
  * ("prompt. hint", not "prompt . hint"), and drop a blank body so the result
11
11
  * never starts with ". ".
12
12
  *
13
+ * WHAT THIS JOIN COVERS NOW: the prompt BODY only. A LOOK clause no longer
14
+ * reaches this function from either composer — it lifts into the trailing
15
+ * `[style]` section (`prompt-style-section.ts`), which is `". "`-joined WITHIN a
16
+ * line but hung off the body by a blank line. So "every folded clause is one
17
+ * `". "` further along the same string" stopped being true for a look-carrying
18
+ * call, deliberately; `composeSectionedPrompt` is the whole-prompt shape and
19
+ * this is the piece of it that assembles the body. The zero-hint no-op branch is
20
+ * untouched and still the thing the routes' `composed !== prompt` guard reads.
21
+ *
13
22
  * Never mutates its inputs.
14
23
  */
15
24
 
@@ -0,0 +1,256 @@
1
+ /**
2
+ * THE `[style]` SECTION — the trailing block an assembled prompt carries when a
3
+ * run selects any LOOK dimension, shared by the image (`assembleImageInput`)
4
+ * and video (`composeVideoPromptText`) composers so the two surfaces render one
5
+ * shape.
6
+ *
7
+ * <body>
8
+ *
9
+ * [style]:
10
+ * <film line>
11
+ * <scene line>
12
+ *
13
+ * WHY THE LOOK CLAUSES MOVED: folded inline, a broad direction buried the shot
14
+ * — a dozen grade/lighting/era sentences between the user's prose and the
15
+ * structured fields, all in the same register, with nothing telling the model
16
+ * which sentences describe the ACTION and which describe the LOOK. The section
17
+ * says it structurally instead.
18
+ *
19
+ * WHAT STAYS IN THE BODY: the user's prose, the subject fold, the whole MOTION
20
+ * family, and the structured fragment last. Camera motion is part of the shot
21
+ * prose, not the look — so the body/section boundary IS the registry's `family`
22
+ * column, the same column the video verbosity policy splits on. Coupling them
23
+ * is deliberate: one row cannot be shot-prose for the verbosity policy and look
24
+ * for the section.
25
+ *
26
+ * THE SECTION HAS NO TERMINATOR, so "after the section" is not a shape a caller
27
+ * can reach by appending: every assembler downstream of the composer (both
28
+ * reference resolvers, the legacy character-description wrapper, `Style:` /
29
+ * `Avoid:`) joins its text with a single `\n`, which lands it UNDER the header.
30
+ * Two helpers below are how they stay out — `insertBeforeStyleSection` for body
31
+ * content, `endsInsideStyleSection` for the self-labeling control lines that
32
+ * must stay last. A terminator instead would dangle whenever nothing follows,
33
+ * and would break the byte parity below.
34
+ *
35
+ * THE ZERO-CLAUSE CONTRACT (load-bearing): with no look clause — none selected,
36
+ * all shed, or all deduped away — there is NO header and NO extra newline, and
37
+ * the output is byte-identical to the plain hint join. That keeps the
38
+ * verbatim-and-untrimmed no-op alive (zero hints AND zero section → the user's
39
+ * prompt back byte-for-byte, `undefined` included), which is what the routes'
40
+ * `composed !== prompt` guard reads to decide whether to pin
41
+ * `input_data.userPrompt`.
42
+ *
43
+ * NO INDENTATION ANYWHERE: the video reference resolver collapses 2+ horizontal
44
+ * spaces unanchored, so an indented section line would come back flattened. The
45
+ * section is written flush-left rather than relying on that collapse to be
46
+ * harmless.
47
+ */
48
+ import { joinPromptHints, PROMPT_HINT_SEPARATOR } from "./prompt-hint-join.js"
49
+ import {
50
+ renderDirectionHintClauses,
51
+ type DirectionFamily,
52
+ type DirectionFields,
53
+ type DirectionHintMode,
54
+ type DirectionStyleGroup,
55
+ } from "./direction-registry.js"
56
+
57
+ /**
58
+ * The section header, verbatim. Lowercase and bracketed so it reads as
59
+ * structure rather than as a sentence — and so it can be found again by
60
+ * `buildImagePrompt`'s hybrid line-capitalizer, which must stop here
61
+ * (`[Style]:` would be a different token, and capitalized clause lines would
62
+ * corrupt the lowercase catalog wording).
63
+ */
64
+ export const STYLE_SECTION_HEADER = "[style]:"
65
+
66
+ /** The blank line between the body and the section. Omitted for an empty body. */
67
+ export const STYLE_SECTION_GAP = "\n\n"
68
+
69
+ /** The section's opening bytes — the gap, the header and the newline before its
70
+ * first clause line (the section is never emitted without one). */
71
+ const STYLE_SECTION_OPENING = `${STYLE_SECTION_GAP}${STYLE_SECTION_HEADER}\n`
72
+
73
+ /**
74
+ * Split a composed prompt into the BODY and the `[style]` section it ends with
75
+ * (`section: ""` when it carries none, the body then being the whole string;
76
+ * the section comes back WITHOUT the gap).
77
+ *
78
+ * Matched from the RIGHT: the composer always emits the section last, and a
79
+ * user's own prose is free to contain the same characters. A blank body drops
80
+ * the gap with it, so the section-only form is matched on its own.
81
+ */
82
+ export function splitStyleSection(prompt: string): { body: string; section: string } {
83
+ const at = prompt.lastIndexOf(STYLE_SECTION_OPENING)
84
+ if (at >= 0) {
85
+ return { body: prompt.slice(0, at), section: prompt.slice(at + STYLE_SECTION_GAP.length) }
86
+ }
87
+ return prompt.startsWith(`${STYLE_SECTION_HEADER}\n`)
88
+ ? { body: "", section: prompt }
89
+ : { body: prompt, section: "" }
90
+ }
91
+
92
+ /**
93
+ * Extend a composed prompt's BODY with more lines, AHEAD of the `[style]`
94
+ * section — what every assembler downstream of the composer needs, because the
95
+ * section has no terminator: a plain append lands under the header and reads as
96
+ * one more look clause. Reference bindings, element directives and character
97
+ * descriptions are scene content that belongs with the prose, and leaving the
98
+ * look clauses last is where a look tail was measured to cost nothing.
99
+ *
100
+ * With no section this IS the plain `"\n"` join every caller emitted before —
101
+ * the byte-parity path, down to the leading newline an empty prompt produces.
102
+ */
103
+ export function insertBeforeStyleSection(prompt: string, lines: readonly string[]): string {
104
+ if (lines.length === 0) return prompt
105
+ const block = lines.join("\n")
106
+ const { body, section } = splitStyleSection(prompt)
107
+ if (section.length === 0) return `${prompt}\n${block}`
108
+ return `${body.length > 0 ? `${body}\n${block}` : block}${STYLE_SECTION_GAP}${section}`
109
+ }
110
+
111
+ /**
112
+ * True when `prompt` ends INSIDE the section — its last `\n\n`-delimited block
113
+ * opens with the header. What the self-labeling control lines (`Style:`,
114
+ * `Avoid:`) read: they stay at the END of the prompt by design, so a blank line
115
+ * of their own is the only thing that can close the header's scope ahead of
116
+ * them.
117
+ */
118
+ export function endsInsideStyleSection(prompt: string): boolean {
119
+ const at = prompt.lastIndexOf(STYLE_SECTION_GAP)
120
+ const lastBlock = at >= 0 ? prompt.slice(at + STYLE_SECTION_GAP.length) : prompt
121
+ return lastBlock.startsWith(`${STYLE_SECTION_HEADER}\n`)
122
+ }
123
+
124
+ /** Where a rendered clause reads in the assembled prompt. */
125
+ export type PromptClauseSlot = "body" | "film" | "scene"
126
+
127
+ /** A rendered clause plus the line it belongs on. */
128
+ export interface SlottedPromptClause {
129
+ readonly text: string
130
+ readonly slot: PromptClauseSlot
131
+ }
132
+
133
+ /**
134
+ * The slot a direction row's clause takes: motion stays in the body, the five
135
+ * `styleGroup: "film"` rows lead the section, every other look row follows on
136
+ * the scene line.
137
+ */
138
+ export function styleSlotFor(
139
+ row: { readonly family: DirectionFamily; readonly styleGroup?: DirectionStyleGroup },
140
+ ): PromptClauseSlot {
141
+ if (row.family === "motion") return "body"
142
+ return row.styleGroup === "film" ? "film" : "scene"
143
+ }
144
+
145
+ /**
146
+ * The SUBJECT channel's clauses: always body. Who is in the shot is the noun
147
+ * phrase the look modifies, not part of the look.
148
+ */
149
+ export function asBodyClauses(texts: readonly string[]): SlottedPromptClause[] {
150
+ return texts.map((text) => ({ text, slot: "body" as const }))
151
+ }
152
+
153
+ /**
154
+ * Fold a direction bag and tag each clause with its slot, in registry table
155
+ * order. The ORDER is the fold/survival order, not the string order — the
156
+ * composers slice this list from the tail when a cap forces a shed, and only
157
+ * then hand the surviving prefix to `composeSectionedPrompt`.
158
+ */
159
+ export function partitionStyleClauses(
160
+ direction: DirectionFields | undefined,
161
+ opts: { surface: "image" | "video"; mode?: DirectionHintMode },
162
+ ): SlottedPromptClause[] {
163
+ return renderDirectionHintClauses(direction, opts).map((clause) => ({
164
+ text: clause.text,
165
+ slot: styleSlotFor(clause),
166
+ }))
167
+ }
168
+
169
+ /**
170
+ * The section block for a set of clauses — `""` when none of them is a look
171
+ * clause. Each line is omitted entirely when its half of the split is empty, so
172
+ * a scene-only fold never emits a blank film line.
173
+ */
174
+ export function styleSectionFromClauses(clauses: readonly SlottedPromptClause[]): string {
175
+ const line = (slot: PromptClauseSlot) =>
176
+ clauses
177
+ .filter((c) => c.slot === slot)
178
+ .map((c) => c.text)
179
+ .join(PROMPT_HINT_SEPARATOR)
180
+ const lines = [line("film"), line("scene")].filter((l) => l.length > 0)
181
+ return lines.length === 0 ? "" : `${STYLE_SECTION_HEADER}\n${lines.join("\n")}`
182
+ }
183
+
184
+ /**
185
+ * The section for a raw direction bag — the entry point a client renders its
186
+ * preview through, so the preview and the server emit the same bytes.
187
+ */
188
+ export function renderStyleSection(
189
+ direction: DirectionFields | undefined,
190
+ opts: { surface: "image" | "video"; mode?: DirectionHintMode },
191
+ ): string {
192
+ return styleSectionFromClauses(partitionStyleClauses(direction, opts))
193
+ }
194
+
195
+ /**
196
+ * The assembled prompt for a set of clauses: body clauses `". "`-joined onto the
197
+ * user's prompt, the structured fragment last in the body, then the section.
198
+ *
199
+ * TRIMMING follows `joinPromptHints`: the prompt is trimmed whenever ANYTHING
200
+ * folded — a section counts, so a look-only fold trims too, or the blank line
201
+ * would inherit the prompt's trailing whitespace. With nothing folded at all the
202
+ * prompt is returned VERBATIM AND UNTRIMMED (`undefined` passes straight
203
+ * through), which is the byte-parity contract every existing caller rests on.
204
+ *
205
+ * A blank body drops the gap with it, so the result never opens on a newline —
206
+ * the same reason `joinPromptHints` filters a blank prompt out of its join.
207
+ */
208
+ export function composeSectionedPrompt<T extends string | undefined>(
209
+ userPrompt: T,
210
+ clauses: readonly SlottedPromptClause[],
211
+ structuredFragment: string,
212
+ ): string | T {
213
+ const bodyHints = [
214
+ ...clauses.filter((c) => c.slot === "body").map((c) => c.text),
215
+ structuredFragment,
216
+ ].filter((p) => p.length > 0)
217
+ const section = styleSectionFromClauses(clauses)
218
+ if (section.length === 0) {
219
+ return bodyHints.length === 0 ? userPrompt : joinPromptHints(userPrompt ?? "", bodyHints)
220
+ }
221
+ const body =
222
+ bodyHints.length > 0 ? joinPromptHints(userPrompt ?? "", bodyHints) : (userPrompt ?? "").trim()
223
+ return body.length > 0 ? `${body}${STYLE_SECTION_GAP}${section}` : section
224
+ }
225
+
226
+ /**
227
+ * What each clause costs the assembled prompt, as the EXACT composed-length
228
+ * delta of adding it to the prefix below it. Feeds `keepableDirectionHints`,
229
+ * which walks the list tail-first.
230
+ *
231
+ * Exact deltas rather than "clause + separator" because the section's own bytes
232
+ * are not evenly distributed: the FIRST look clause carries the whole
233
+ * `"\n\n[style]:\n"` header (11 characters that only come back when the section
234
+ * disappears), the second look clause of a line carries a `". "`, the first of
235
+ * the scene line carries a `"\n"`. Flat costs would under-price the header, so
236
+ * the walk would cover a deficit with more clauses than it needs and over-shed
237
+ * — visible as a fold that drops two clauses where one would have fit. The
238
+ * deltas do not change between shed iterations (the walk only ever shortens the
239
+ * prefix), so they are computed once, before the loop.
240
+ */
241
+ export function sectionedClauseCosts(
242
+ userPrompt: string | undefined,
243
+ clauses: readonly SlottedPromptClause[],
244
+ structuredFragment: string,
245
+ ): number[] {
246
+ const lengthAt = (kept: number) =>
247
+ composeSectionedPrompt(userPrompt, clauses.slice(0, kept), structuredFragment)?.length ?? 0
248
+ const costs: number[] = []
249
+ let below = lengthAt(0)
250
+ for (let i = 0; i < clauses.length; i++) {
251
+ const at = lengthAt(i + 1)
252
+ costs.push(at - below)
253
+ below = at
254
+ }
255
+ return costs
256
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Narrow readers turning UNTRUSTED persisted node data (`workflows.nodes`
3
3
  * JSONB — import, MCP write, node preset, a Studio-emitted graph) into the
4
- * typed `direction` / `structured` levers `assembleImageInput` accepts.
4
+ * typed `subject` / `direction` / `structured` levers `assembleImageInput`
5
+ * accepts.
5
6
  * `buildPayload` has no zod and workflow writes are
6
7
  * `z.record(z.string(), z.unknown())`, so this blob may have been written years
7
8
  * ago by any client.
@@ -46,6 +47,13 @@ import {
46
47
  DIRECTION_ID_MAX_CHARS,
47
48
  type DirectionFields,
48
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"
49
57
  import type { StructuredPromptFields } from "./prompt-builder-structured-fields.js"
50
58
 
51
59
  /**
@@ -87,6 +95,57 @@ export function readDirectionFields(value: unknown): DirectionFields | undefined
87
95
  return Object.keys(out).length > 0 ? (out as DirectionFields) : undefined
88
96
  }
89
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
+
90
149
  // ── structured ───────────────────────────────────────────────────────────────
91
150
 
92
151
  type FieldKind = "string" | "number" | "gender"