@nodaro/prompts 1.0.1 → 1.1.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -0,0 +1,63 @@
1
+ // End-to-end coverage for the "ref-only" reference role: a mention or node
2
+ // default of `ref-only` injects ONLY the bare reference pointer — `reference
3
+ // image A` on image nodes, `@image_1` on video nodes — with no "the {role}
4
+ // from …" wrapper. Character is preset-gated (ref-only must be a curated
5
+ // preset); location honors the token role verbatim. Both route through the one
6
+ // `roleToPhrase` chokepoint.
7
+ import { describe, it, expect } from "vitest"
8
+ import { buildImagePrompt } from "../prompt-builder.js"
9
+ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
10
+ import type { ConnectedReference } from "@nodaro/shared"
11
+
12
+ const kira = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
13
+ id: "k", defaultName: "Kira", source: "wired-character", url: "u-kira", characterSlug: "kira", ...over,
14
+ })
15
+ const library: ConnectedReference = {
16
+ id: "l", defaultName: "Old Library", source: "wired-location",
17
+ url: "https://cdn/library.png", locationSlug: "old-library",
18
+ }
19
+
20
+ describe("ref-only character (image)", () => {
21
+ it("mention '@kira:1:ref-only' → bare 'reference image A'", () => {
22
+ const out = buildImagePrompt({
23
+ provider: "nano-banana-pro", prompt: "@kira:1:ref-only in the rain",
24
+ connectedReferences: [kira()], referenceFormat: "hybrid",
25
+ })
26
+ expect(out.prompt).toContain("reference image A in the rain")
27
+ expect(out.prompt).not.toContain("the person from reference image A")
28
+ })
29
+ it("node defaultRole 'ref-only' (unmentioned canonical) → no 'the person from …'", () => {
30
+ const out = buildImagePrompt({
31
+ provider: "nano-banana-pro", prompt: "a portrait",
32
+ connectedReferences: [kira({ defaultRole: "ref-only" })], referenceFormat: "hybrid",
33
+ })
34
+ expect(out.prompt).not.toContain("the person from reference image A")
35
+ })
36
+ })
37
+
38
+ describe("ref-only character (video)", () => {
39
+ it("mention '@kira:1:ref-only' → bare '@image_1'", () => {
40
+ const out = resolveVideoReferenceCore({
41
+ prompt: "@kira:1:ref-only walks in", wiredCharRefs: [kira()], hybridRoles: true,
42
+ })
43
+ expect(out.prompt).toContain("@image_1 walks in")
44
+ expect(out.prompt).not.toContain("the person from @image_1")
45
+ })
46
+ it("node defaultRole 'ref-only' (unmentioned canonical) → no 'the person from @image_1'", () => {
47
+ const out = resolveVideoReferenceCore({
48
+ prompt: "a slow dolly", wiredCharRefs: [kira({ defaultRole: "ref-only" })], hybridRoles: true,
49
+ })
50
+ expect(out.prompt).not.toContain("the person from @image_1")
51
+ })
52
+ })
53
+
54
+ describe("ref-only location (image)", () => {
55
+ it("mention '@old-library:1:ref-only' → bare 'reference image A'", () => {
56
+ const out = buildImagePrompt({
57
+ provider: "nano-banana-pro", prompt: "@old-library:1:ref-only at night",
58
+ connectedReferences: [library], referenceFormat: "hybrid",
59
+ })
60
+ expect(out.prompt).toContain("reference image A at night")
61
+ expect(out.prompt).not.toContain("the background from reference image A")
62
+ })
63
+ })
@@ -0,0 +1,92 @@
1
+ // Variant + Role Separation: a mention's VARIANT picks the image, its ROLE
2
+ // picks the phrase — independently (`@kira:1:walking:clothes` → the walking
3
+ // image attached, "the clothes from …" injected). Covers the character
4
+ // resolvers on BOTH bindings (image letters, video @image_N), the location
5
+ // image resolver, ref-only composition, and the mode-in-seg4 back-compat.
6
+ import { describe, it, expect } from "vitest"
7
+ import { buildImagePrompt } from "../prompt-builder.js"
8
+ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
9
+ import type { ConnectedReference } from "@nodaro/shared"
10
+
11
+ const kira = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
12
+ id: "k", defaultName: "Kira", source: "wired-character", url: "u-kira", characterSlug: "kira", ...over,
13
+ })
14
+ const kiraWalking = kira({ id: "kw", variantSlug: "walking", url: "u-walk" })
15
+
16
+ const library: ConnectedReference = {
17
+ id: "l", defaultName: "Old Library", source: "wired-location",
18
+ url: "u-lib", locationSlug: "oldlibrary",
19
+ }
20
+ const libraryRain: ConnectedReference = {
21
+ ...library, id: "lr", url: "u-rain",
22
+ locationVariantBucket: "weather", locationVariantSlug: "rain",
23
+ }
24
+
25
+ describe("character variant + role (image)", () => {
26
+ it("@kira:1:walking:clothes → walking image attached, 'the clothes from reference image A'", () => {
27
+ const out = buildImagePrompt({
28
+ provider: "nano-banana-pro", prompt: "@kira:1:walking:clothes in the rain",
29
+ connectedReferences: [kira(), kiraWalking], referenceFormat: "hybrid",
30
+ })
31
+ expect(out.prompt).toContain("the clothes from reference image A in the rain")
32
+ expect(out.referenceImageUrls).toContain("u-walk")
33
+ })
34
+
35
+ it("@kira:1:walking:ref-only → walking image attached, bare 'reference image A'", () => {
36
+ const out = buildImagePrompt({
37
+ provider: "nano-banana-pro", prompt: "@kira:1:walking:ref-only in the rain",
38
+ connectedReferences: [kira(), kiraWalking], referenceFormat: "hybrid",
39
+ })
40
+ expect(out.prompt).toContain("reference image A in the rain")
41
+ expect(out.prompt).not.toContain("from reference image A")
42
+ expect(out.referenceImageUrls).toContain("u-walk")
43
+ })
44
+
45
+ it("a CUSTOM 4th-segment role survives verbatim", () => {
46
+ const out = buildImagePrompt({
47
+ provider: "nano-banana-pro", prompt: "@kira:1:walking:earrings at dawn",
48
+ connectedReferences: [kira(), kiraWalking], referenceFormat: "hybrid",
49
+ })
50
+ expect(out.prompt).toContain("the earrings from reference image A at dawn")
51
+ })
52
+
53
+ it("back-compat: @kira:1:walking:pose (mode in seg4) is unchanged", () => {
54
+ const out = buildImagePrompt({
55
+ provider: "nano-banana-pro", prompt: "@kira:1:walking:pose at dawn",
56
+ connectedReferences: [kira(), kiraWalking], referenceFormat: "hybrid",
57
+ })
58
+ expect(out.prompt).toContain("the pose from reference image A at dawn")
59
+ expect(out.referenceImageUrls).toContain("u-walk")
60
+ })
61
+ })
62
+
63
+ describe("character variant + role (video)", () => {
64
+ it("@kira:1:walking:clothes → 'the clothes from @image_1', walking image attached", () => {
65
+ const out = resolveVideoReferenceCore({
66
+ prompt: "@kira:1:walking:clothes walks in",
67
+ wiredCharRefs: [kira(), kiraWalking], hybridRoles: true,
68
+ })
69
+ expect(out.prompt).toContain("the clothes from @image_1 walks in")
70
+ expect(out.additionalUrls).toContain("u-walk")
71
+ })
72
+
73
+ it("@kira:1:walking:ref-only → bare '@image_1'", () => {
74
+ const out = resolveVideoReferenceCore({
75
+ prompt: "@kira:1:walking:ref-only walks in",
76
+ wiredCharRefs: [kira(), kiraWalking], hybridRoles: true,
77
+ })
78
+ expect(out.prompt).toContain("@image_1 walks in")
79
+ expect(out.prompt).not.toContain("from @image_1")
80
+ })
81
+ })
82
+
83
+ describe("location variant + role (image)", () => {
84
+ it("@oldlibrary:1:weather/rain:lighting → rain image attached, 'the lighting from reference image A'", () => {
85
+ const out = buildImagePrompt({
86
+ provider: "nano-banana-pro", prompt: "@oldlibrary:1:weather/rain:lighting a chase",
87
+ connectedReferences: [library, libraryRain], referenceFormat: "hybrid",
88
+ })
89
+ expect(out.prompt).toContain("the lighting from reference image A a chase")
90
+ expect(out.referenceImageUrls).toContain("u-rain")
91
+ })
92
+ })
@@ -156,8 +156,7 @@ describe("REF_BINDING", () => {
156
156
  // into `@image_N`-style subject bindings (Task 2.3). Positional, 1-based against
157
157
  // the corresponding count. Out-of-range / missing count → drop to the bare label
158
158
  // (legacy `stripVideoImageTokens` strip behavior). Label-less in-range token →
159
- // "the subject in @kind_N" so the binding still lands. Purely additive in 2.3 —
160
- // defined + exported + tested here, NOT yet called by the core (that is Task 2.4).
159
+ // the bare "@kind_N" (the ref-only default) so the binding lands with no wrapper.
161
160
  describe("resolveReferenceTokens", () => {
162
161
  const counts = { image: 4, video: 0, audio: 0 }
163
162
  it("resolves repeated labels distinctly by slot", () => {
@@ -168,8 +167,10 @@ describe("resolveReferenceTokens", () => {
168
167
  it("drops out-of-range tokens to bare label", () => {
169
168
  expect(resolveReferenceTokens("a {image:9:ghost} here", counts)).toBe("a ghost here")
170
169
  })
171
- it("handles a label-less token", () => {
172
- expect(resolveReferenceTokens("{image:1}", counts)).toBe("the subject in @image_1")
170
+ it("resolves a label-less token to the bare kind ordinal (ref-only)", () => {
171
+ expect(resolveReferenceTokens("{image:1}", counts)).toBe("@image_1")
172
+ expect(resolveReferenceTokens("{video:1}", { image: 0, video: 1, audio: 0 })).toBe("@video_1")
173
+ expect(resolveReferenceTokens("{audio:1}", { image: 0, video: 0, audio: 1 })).toBe("@audio_1")
173
174
  })
174
175
  it("resolves video + audio tokens against their own counts", () => {
175
176
  expect(resolveReferenceTokens("dance like {video:1:clip} to {audio:1:song}", { image: 0, video: 1, audio: 1 }))
@@ -188,7 +188,7 @@ export function buildPickerAnalyzerSpec(pickerType: PickerType): PickerAnalyzerS
188
188
  * (multi). `.strict()` blocks unknown keys. Mirrors the field cardinality. */
189
189
  export function buildPickerZodSchema(
190
190
  spec: PickerAnalyzerSpec,
191
- ): z.ZodType<Record<string, string | string[]>, z.ZodTypeDef, unknown> {
191
+ ): z.ZodType<Record<string, string | string[]>, unknown> {
192
192
  const shape: Record<string, z.ZodTypeAny> = {}
193
193
  for (const d of spec.dimensions) {
194
194
  const ids = d.entryIds as unknown as [string, ...string[]]
@@ -196,16 +196,12 @@ export function buildPickerZodSchema(
196
196
  shape[d.dimension] = d.limit > 1 ? z.array(enumZ).max(d.limit).optional() : enumZ.optional()
197
197
  }
198
198
  // dynamic shape → Zod can't infer the narrowed output type; runtime-validated by picker-analyzer-registry.test.ts
199
- return z.object(shape).strict() as unknown as z.ZodType<
200
- Record<string, string | string[]>,
201
- z.ZodTypeDef,
202
- unknown
203
- >
199
+ return z.object(shape).strict() as unknown as z.ZodType<Record<string, string | string[]>, unknown>
204
200
  }
205
201
 
206
202
  export interface PickerAnalyzer {
207
203
  readonly spec: PickerAnalyzerSpec
208
- readonly schema: z.ZodType<Record<string, string | string[]>, z.ZodTypeDef, unknown>
204
+ readonly schema: z.ZodType<Record<string, string | string[]>, unknown>
209
205
  readonly legend: string
210
206
  }
211
207
 
@@ -265,7 +261,7 @@ export interface PickerGaps {
265
261
  }
266
262
 
267
263
  export interface MultiPickerAnalyzerSpec {
268
- readonly schema: z.ZodType<Record<string, unknown>, z.ZodTypeDef, unknown>
264
+ readonly schema: z.ZodType<Record<string, unknown>, unknown>
269
265
  readonly toolName: string
270
266
  readonly legend: string
271
267
  }
@@ -289,6 +289,12 @@ function resolveCharacterMentionsHybrid(
289
289
  mentionedCharacterSlugs.add(t.characterSlug)
290
290
  refByUrl.set(match.url, match)
291
291
  if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
292
+ // Variant + Role Separation: an explicit 4th-segment ROLE
293
+ // (`@kira:1:walking:clothes`) wins outright and coexists with the variant —
294
+ // the variant picked the image (variantMatch above), the role picks the
295
+ // phrase. Used VERBATIM (curated or custom), mirroring the seg3 custom-role
296
+ // relaxation below.
297
+ const explicitRole = (t.role ?? "").trim()
292
298
  const segment = (t.usageMode ?? t.variantSlug ?? "").trim()
293
299
  // Custom roles survive VERBATIM (Unified Reference Roles, Phase D). A
294
300
  // non-empty segment is the role when it is a curated preset (face/pose/style
@@ -299,10 +305,11 @@ function resolveCharacterMentionsHybrid(
299
305
  // face-pose/emotion/name/none) fall back to the NODE default: the character
300
306
  // node's `defaultRole` (hybrid dropdown pick, verbatim) → its
301
307
  // `defaultUsageMode`-derived role → the source default ("person") — via
302
- // `resolveDefaultRole` (Character Node Role+Lock). Precedence: per-mention
303
- // token role → node defaultRole → defaultUsageMode-derived → source default.
304
- const role =
305
- segment && (presets.includes(segment) || (t.usageMode == null && !variantMatch))
308
+ // `resolveDefaultRole` (Character Node Role+Lock). Precedence: seg4 role →
309
+ // seg3 token role → node defaultRole → defaultUsageMode-derived → source default.
310
+ const role = explicitRole
311
+ ? explicitRole
312
+ : segment && (presets.includes(segment) || (t.usageMode == null && !variantMatch))
306
313
  ? segment
307
314
  : resolveDefaultRole(match.defaultRole, match.defaultUsageMode, "wired-character")
308
315
  matched.push({ token: t.token, offset: t.offset, url: match.url, role })
@@ -245,7 +245,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
245
245
  "bytedance-pro": "Higher quality ByteDance",
246
246
  "runway-kie": "Runway via KIE, strong cinematic quality",
247
247
  "wan-2.7-t2v": "Wan 2.7 T2V — 2–15s, 720p/1080p",
248
- "happyhorse": "HappyHorse T2V — 3–15s, 720p/1080p",
248
+ "happyhorse": "HappyHorse 1.1 T2V — 3–15s, 720p/1080p, 9 aspect ratios incl. 21:9/9:21",
249
249
  "ltx-2.3-pro": "Lightricks LTX 2.3 Pro — text/image/audio→video, 6–10s, up to 4K",
250
250
  "ltx-2.3-fast": "Lightricks LTX 2.3 Fast — text/image→video, 6–20s, up to 4K",
251
251
  "gemini-omni-video": "Google Gemini Omni — multimodal video with native audio, 4–10s, up to 4K.",
@@ -275,8 +275,8 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
275
275
  "grok-i2v": "General purpose animation",
276
276
  "runway-kie": "Cinematic image animation",
277
277
  "wan-2.7-i2v": "Wan 2.7 I2V — 2–15s, 720p/1080p, start+end frame",
278
- "happyhorse-i2v": "HappyHorse I2V — 3–15s, 720p/1080p",
279
- "happyhorse-ref2v": "HappyHorse Ref2V — multi-ref image to video, 3–15s",
278
+ "happyhorse-i2v": "HappyHorse 1.1 I2V — 3–15s, 720p/1080p",
279
+ "happyhorse-ref2v": "HappyHorse 1.1 Ref2V — multi-ref image to video (1–9 refs), 3–15s",
280
280
  "ltx-2.3-pro": "Lightricks LTX 2.3 Pro — start/end frame i2v + audio→video, 6–10s, up to 4K",
281
281
  "ltx-2.3-fast": "Lightricks LTX 2.3 Fast — start/end frame i2v, 6–20s, up to 4K",
282
282
  "gemini-omni-video": "Google Gemini Omni — multimodal video with native audio, 4–10s, up to 4K.",
@@ -88,8 +88,8 @@ const REFERENCE_TOKEN_RE = /\{(image|video|audio):(\d+)(?::([a-zA-Z0-9_ -]+))?\}
88
88
  * `stripVideoImageTokens` strip behavior: drop the token, keep the label text.
89
89
  * - in range, label present → `REF_BINDING[kind](label, N)`
90
90
  * (e.g. `the person from @image_2`).
91
- * - in range, no label → `the subject in @${kind}_${N}` so the binding still
92
- * lands even when the author didn't name the subject.
91
+ * - in range, no label → the bare `@${kind}_${N}` (the ref-only default): the
92
+ * binding lands with no descriptive wrapper.
93
93
  *
94
94
  * Runs of 2+ HORIZONTAL whitespace (left behind by a dropped label-less token)
95
95
  * collapse to one space, the result is trimmed, and an empty result becomes
@@ -115,7 +115,7 @@ export function resolveReferenceTokens(
115
115
  const n = parseInt(nStr, 10)
116
116
  if (n < 1 || n > counts[kind]) return label ?? ""
117
117
  if (label) return REF_BINDING[kind](label, n)
118
- return `the subject in @${kind}_${n}`
118
+ return `@${kind}_${n}`
119
119
  })
120
120
  // Horizontal whitespace only — preserve `\n` / `\n\n` block separators.
121
121
  .replace(/[^\S\r\n]{2,}/g, " ")
@@ -309,6 +309,10 @@ function resolveVideoCharacterMentionsHybrid(
309
309
  mentionedCharacterSlugs.add(t.characterSlug)
310
310
  refByUrl.set(match.url, match)
311
311
  if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
312
+ // Variant + Role Separation: an explicit 4th-segment ROLE
313
+ // (`@kira:1:walking:clothes`) wins outright and coexists with the variant —
314
+ // used VERBATIM, in lock-step with the image-side resolver.
315
+ const explicitRole = (t.role ?? "").trim()
312
316
  const segment = (t.usageMode ?? t.variantSlug ?? "").trim()
313
317
  // Custom roles survive VERBATIM — the SAME relaxation as the image-side
314
318
  // `resolveCharacterMentionsHybrid` (Unified Reference Roles, Phase D), kept
@@ -317,9 +321,11 @@ function resolveVideoCharacterMentionsHybrid(
317
321
  // a real variant URL → role; a real variant slug or a directive-only usage
318
322
  // mode → the NODE default (`defaultRole` verbatim → `defaultUsageMode`-derived
319
323
  // → "person", via `resolveDefaultRole` — Character Node Role+Lock), mirroring
320
- // the image-side mention fallback.
321
- const role =
322
- segment && (presets.includes(segment) || (t.usageMode == null && !variantMatch))
324
+ // the image-side mention fallback. Precedence: seg4 role → seg3 token role →
325
+ // node default chain.
326
+ const role = explicitRole
327
+ ? explicitRole
328
+ : segment && (presets.includes(segment) || (t.usageMode == null && !variantMatch))
323
329
  ? segment
324
330
  : resolveDefaultRole(match.defaultRole, match.defaultUsageMode, "wired-character")
325
331
  matched.push({ token: t.token, offset: t.offset, url: match.url, role })