@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,374 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildImagePrompt } from "../prompt-builder.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ /**
6
+ * Wired-entity mentions — `@<name-slug>:<index>[:<role>]` for `wired-creature`
7
+ * and `wired-object`.
8
+ *
9
+ * The entity analog of `image-convergence-image.test.ts`, plus the two things
10
+ * only this leg has: FALLBACK SUPPRESSION (a mentioned entity must not ALSO emit
11
+ * its trailing canonical phrase — the live bug) and CROSS-KIND PRECEDENCE
12
+ * (character → location → image → creature → object).
13
+ *
14
+ * `referenceFormat` is passed EXPLICITLY on every hybrid case: `packages/prompts`
15
+ * is env-free (`content-free-contract.test.ts`), so `NODE_ENV=test` only steers
16
+ * the *callers*, never this package.
17
+ */
18
+
19
+ const nessie: ConnectedReference = {
20
+ id: "cr1", defaultName: "Nessie", source: "wired-creature", url: "https://cdn/nessie.png",
21
+ }
22
+ const chair: ConnectedReference = {
23
+ id: "ob1", defaultName: "Chair", source: "wired-object", url: "https://cdn/chair.png",
24
+ }
25
+ const kira: ConnectedReference = {
26
+ id: "c1", defaultName: "Kira", source: "wired-character",
27
+ url: "https://cdn/kira.png", characterSlug: "kira",
28
+ }
29
+ const library: ConnectedReference = {
30
+ id: "l1", defaultName: "Old Library", source: "wired-location",
31
+ url: "https://cdn/library.png", locationSlug: "old-library",
32
+ }
33
+ const town: ConnectedReference = {
34
+ id: "n1", defaultName: "Town", source: "wired-image", url: "https://cdn/town.png",
35
+ }
36
+
37
+ /** Count non-overlapping occurrences — the double-render detector. */
38
+ const occurrences = (haystack: string, needle: string): number =>
39
+ haystack.split(needle).length - 1
40
+
41
+ describe("wired-entity mentions resolve on the image hybrid path", () => {
42
+ it("bare @nessie:4 → the SOURCE-default creature phrase, inline, token consumed", () => {
43
+ const out = buildImagePrompt({
44
+ prompt: "a wide shot of @nessie:4 rising from the lake",
45
+ connectedReferences: [nessie],
46
+ provider: "nano-banana-pro",
47
+ referenceFormat: "hybrid",
48
+ })
49
+ expect(out.prompt).toContain("the creature from reference image A")
50
+ expect(out.prompt).not.toContain("@nessie")
51
+ expect(out.referenceImageUrls).toEqual(["https://cdn/nessie.png"])
52
+ })
53
+
54
+ it("the phrase renders where the user TYPED it, not appended after the scene", () => {
55
+ const out = buildImagePrompt({
56
+ prompt: "a wide shot of @nessie:4 rising from the lake",
57
+ connectedReferences: [nessie],
58
+ provider: "nano-banana-pro",
59
+ referenceFormat: "hybrid",
60
+ })
61
+ // The whole point: bound INSIDE the sentence, so "rising from the lake"
62
+ // still attaches to the reference rather than to a bare name.
63
+ expect(out.prompt).toContain("the creature from reference image A rising from the lake")
64
+ })
65
+
66
+ it("SUPPRESSES the trailing canonical fallback for the mentioned ref (the live bug)", () => {
67
+ const out = buildImagePrompt({
68
+ prompt: "a wide shot of @nessie:4 rising from the lake",
69
+ connectedReferences: [nessie],
70
+ provider: "nano-banana-pro",
71
+ referenceFormat: "hybrid",
72
+ })
73
+ // Before this leg the same ref rendered TWICE: the name as plain prose in
74
+ // the body, plus a dangling trailing phrase after the style hints.
75
+ expect(occurrences(out.prompt, "the creature from reference image A")).toBe(1)
76
+ })
77
+
78
+ it("@nessie:4:markings → the curated creature role", () => {
79
+ const out = buildImagePrompt({
80
+ prompt: "a close shot of @nessie:4:markings",
81
+ connectedReferences: [nessie],
82
+ provider: "nano-banana-pro",
83
+ referenceFormat: "hybrid",
84
+ })
85
+ expect(out.prompt).toContain("the markings from reference image A")
86
+ expect(occurrences(out.prompt, "reference image A")).toBe(1)
87
+ })
88
+
89
+ it("@chair:2:material → the curated OBJECT role, fallback suppressed", () => {
90
+ const out = buildImagePrompt({
91
+ prompt: "a still life of @chair:2:material",
92
+ connectedReferences: [chair],
93
+ provider: "nano-banana-pro",
94
+ referenceFormat: "hybrid",
95
+ })
96
+ expect(out.prompt).toContain("the material from reference image A")
97
+ expect(out.prompt).not.toContain("the object from reference image A")
98
+ })
99
+
100
+ it("a CUSTOM role passes through verbatim", () => {
101
+ const out = buildImagePrompt({
102
+ prompt: "a close shot of @nessie:4:dorsal-fin",
103
+ connectedReferences: [nessie],
104
+ provider: "nano-banana-pro",
105
+ referenceFormat: "hybrid",
106
+ })
107
+ expect(out.prompt).toContain("the dorsal-fin from reference image A")
108
+ })
109
+
110
+ it("the node's own defaultRole is the fallback when the token carries none", () => {
111
+ const out = buildImagePrompt({
112
+ prompt: "a close shot of @nessie:4",
113
+ connectedReferences: [{ ...nessie, defaultRole: "anatomy" } as ConnectedReference],
114
+ provider: "nano-banana-pro",
115
+ referenceFormat: "hybrid",
116
+ })
117
+ expect(out.prompt).toContain("the anatomy from reference image A")
118
+ expect(out.prompt).not.toContain("the creature from reference image A")
119
+ })
120
+
121
+ it("a token role BEATS the node's defaultRole", () => {
122
+ const out = buildImagePrompt({
123
+ prompt: "a close shot of @nessie:4:pose",
124
+ connectedReferences: [{ ...nessie, defaultRole: "anatomy" } as ConnectedReference],
125
+ provider: "nano-banana-pro",
126
+ referenceFormat: "hybrid",
127
+ })
128
+ expect(out.prompt).toContain("the pose from reference image A")
129
+ expect(out.prompt).not.toContain("the anatomy from")
130
+ })
131
+
132
+ it("mentions ONE of two wired entities — the other keeps its trailing canonical", () => {
133
+ const out = buildImagePrompt({
134
+ prompt: "@nessie:1 looms over the room",
135
+ connectedReferences: [nessie, chair],
136
+ provider: "nano-banana-pro",
137
+ referenceFormat: "hybrid",
138
+ })
139
+ expect(out.prompt).toContain("the creature from reference image A looms over the room")
140
+ // The unmentioned chair still auto-attaches with its trailing phrase — the
141
+ // pre-mention behavior, untouched.
142
+ expect(out.prompt).toContain("the object from reference image B")
143
+ expect(occurrences(out.prompt, "the creature from reference image A")).toBe(1)
144
+ })
145
+
146
+ it("two mentions of the SAME entity render twice inline but attach ONE URL", () => {
147
+ const out = buildImagePrompt({
148
+ prompt: "@nessie:1 circles, then @nessie:1 dives",
149
+ connectedReferences: [nessie],
150
+ provider: "nano-banana-pro",
151
+ referenceFormat: "hybrid",
152
+ })
153
+ expect(occurrences(out.prompt, "the creature from reference image A")).toBe(2)
154
+ expect(out.referenceImageUrls).toEqual(["https://cdn/nessie.png"])
155
+ })
156
+
157
+ it("a mention whose slug matches NO wired entity stays literal text", () => {
158
+ const out = buildImagePrompt({
159
+ prompt: "a shot of @dragon:1 over the lake",
160
+ connectedReferences: [nessie],
161
+ provider: "nano-banana-pro",
162
+ referenceFormat: "hybrid",
163
+ })
164
+ expect(out.prompt).toContain("@dragon:1")
165
+ // Nessie was never mentioned, so it keeps its canonical trailing phrase.
166
+ expect(out.prompt).toContain("the creature from reference image A")
167
+ })
168
+ })
169
+
170
+ describe("wired-entity mention locks and element injections", () => {
171
+ it("~lock forces the creature's identity lock ON, emitted ONCE", () => {
172
+ const locked = buildImagePrompt({
173
+ prompt: "@nessie:1 rises",
174
+ connectedReferences: [nessie],
175
+ provider: "nano-banana-pro",
176
+ referenceFormat: "hybrid",
177
+ })
178
+ const forced = buildImagePrompt({
179
+ prompt: "@nessie:1~lock rises",
180
+ connectedReferences: [nessie],
181
+ provider: "nano-banana-pro",
182
+ referenceFormat: "hybrid",
183
+ })
184
+ // `wired-creature` lock wording is OFF by default, so the sentinel is the
185
+ // only difference between these two prompts.
186
+ expect(forced.prompt.length).toBeGreaterThan(locked.prompt.length)
187
+ expect(forced.prompt).not.toContain("~lock")
188
+ expect(forced.prompt).toContain("reference image A")
189
+ // The ONCE half: the mention pass emits the lock line INSTEAD of the
190
+ // suppressed canonical one, never in addition to it. Matched on the stable
191
+ // prefix of `DEFAULT_LOCK_TEXT["wired-creature"]`, not the em-dashed tail.
192
+ expect(occurrences(forced.prompt, "Lock the exact identity of the creature")).toBe(1)
193
+ expect(occurrences(locked.prompt, "Lock the exact identity of the creature")).toBe(0)
194
+ })
195
+
196
+ it("a lock-less mention INHERITS the ref's own lock — emitted ONCE", () => {
197
+ const out = buildImagePrompt({
198
+ prompt: "@nessie:1 rises",
199
+ connectedReferences: [{ ...nessie, identityLock: { enabled: true } } as ConnectedReference],
200
+ provider: "nano-banana-pro",
201
+ referenceFormat: "hybrid",
202
+ })
203
+ expect(occurrences(out.prompt, "Lock the exact identity of the creature")).toBe(1)
204
+ })
205
+
206
+ it("~nolock forces the lock OFF even when the ref itself enables it", () => {
207
+ const out = buildImagePrompt({
208
+ prompt: "@nessie:1~nolock rises",
209
+ connectedReferences: [{ ...nessie, identityLock: { enabled: true } } as ConnectedReference],
210
+ provider: "nano-banana-pro",
211
+ referenceFormat: "hybrid",
212
+ })
213
+ expect(out.prompt).not.toContain("Lock the exact identity of the creature")
214
+ expect(out.prompt).not.toContain("~nolock")
215
+ expect(out.prompt).toContain("the creature from reference image A")
216
+ })
217
+
218
+ it("carries the ref's elementInjection ONCE — not once inline and once canonical", () => {
219
+ const inject = "keep the bioluminescent spines visible"
220
+ const out = buildImagePrompt({
221
+ prompt: "@nessie:1 rises",
222
+ connectedReferences: [{ ...nessie, elementInjection: inject } as ConnectedReference],
223
+ provider: "nano-banana-pro",
224
+ referenceFormat: "hybrid",
225
+ })
226
+ expect(occurrences(out.prompt, inject)).toBe(1)
227
+ })
228
+ })
229
+
230
+ describe("cross-kind precedence: character → location → image → creature → object", () => {
231
+ it("a slug known to a CHARACTER and a CREATURE resolves as the CHARACTER", () => {
232
+ const twin: ConnectedReference = {
233
+ id: "cr2", defaultName: "Kira", source: "wired-creature", url: "https://cdn/kira-creature.png",
234
+ }
235
+ const out = buildImagePrompt({
236
+ prompt: "@kira:1 steps forward",
237
+ connectedReferences: [kira, twin],
238
+ provider: "nano-banana-pro",
239
+ referenceFormat: "hybrid",
240
+ })
241
+ // The character pass spliced the token, so the creature pass saw nothing.
242
+ expect(out.prompt).toContain("the person from reference image A")
243
+ expect(out.referenceImageUrls?.[0]).toBe("https://cdn/kira.png")
244
+ // The creature was never mentioned → it keeps its trailing canonical phrase.
245
+ expect(out.prompt).toContain("the creature from reference image B")
246
+ })
247
+
248
+ it("a slug known to a LOCATION and a CREATURE resolves as the LOCATION", () => {
249
+ const twin: ConnectedReference = {
250
+ id: "cr3", defaultName: "Old Library", source: "wired-creature", url: "https://cdn/lib-creature.png",
251
+ }
252
+ const out = buildImagePrompt({
253
+ prompt: "a shot inside @old-library:1",
254
+ connectedReferences: [library, twin],
255
+ provider: "nano-banana-pro",
256
+ referenceFormat: "hybrid",
257
+ })
258
+ expect(out.referenceImageUrls?.[0]).toBe("https://cdn/library.png")
259
+ expect(out.prompt).not.toContain("@old-library")
260
+ })
261
+
262
+ it("a slug known to an IMAGE and a CREATURE resolves as the IMAGE", () => {
263
+ const twin: ConnectedReference = {
264
+ id: "cr4", defaultName: "Town", source: "wired-creature", url: "https://cdn/town-creature.png",
265
+ }
266
+ const out = buildImagePrompt({
267
+ prompt: "a wide shot of @town:1 at dusk",
268
+ connectedReferences: [town, twin],
269
+ provider: "nano-banana-pro",
270
+ referenceFormat: "hybrid",
271
+ })
272
+ // `wired-image`'s default role is "" → the BARE binding, and the image URL
273
+ // is the one that got the mention's slot.
274
+ expect(out.referenceImageUrls?.[0]).toBe("https://cdn/town.png")
275
+ expect(out.prompt).not.toContain("@town")
276
+ // The creature was never mentioned → trailing canonical, at its own letter.
277
+ expect(out.prompt).toContain("the creature from reference image B")
278
+ })
279
+
280
+ it("a slug known to a CREATURE and an OBJECT resolves as the CREATURE", () => {
281
+ // The tail of the chain, settled by the creature-first slug → ref map.
282
+ const twinCreature: ConnectedReference = {
283
+ id: "cr5", defaultName: "Totem", source: "wired-creature", url: "https://cdn/totem-creature.png",
284
+ }
285
+ const twinObject: ConnectedReference = {
286
+ id: "ob5", defaultName: "Totem", source: "wired-object", url: "https://cdn/totem-object.png",
287
+ }
288
+ // Object listed FIRST, so ref order cannot be what decides it.
289
+ const out = buildImagePrompt({
290
+ prompt: "@totem:1 stands in the clearing",
291
+ connectedReferences: [twinObject, twinCreature],
292
+ provider: "nano-banana-pro",
293
+ referenceFormat: "hybrid",
294
+ })
295
+ expect(out.prompt).toContain("the creature from")
296
+ expect(out.prompt).toContain("stands in the clearing")
297
+ })
298
+
299
+ it("all five kinds in one prompt each bind their own reference", () => {
300
+ const out = buildImagePrompt({
301
+ prompt: "@kira:1 walks past @old-library:2 toward @town:3 with @nessie:4 and @chair:5",
302
+ connectedReferences: [kira, library, town, nessie, chair],
303
+ provider: "nano-banana-pro",
304
+ referenceFormat: "hybrid",
305
+ })
306
+ for (const t of ["@kira", "@old-library", "@town", "@nessie", "@chair"]) {
307
+ expect(out.prompt).not.toContain(t)
308
+ }
309
+ expect(out.referenceImageUrls).toHaveLength(5)
310
+ // Both entities were mentioned → NO trailing canonical phrases for them.
311
+ expect(occurrences(out.prompt, "the creature from")).toBe(1)
312
+ expect(occurrences(out.prompt, "the object from")).toBe(1)
313
+ })
314
+ })
315
+
316
+ describe("byte-identity guarantees", () => {
317
+ const cases: Array<[string, Parameters<typeof buildImagePrompt>[0]]> = [
318
+ ["mention-free prompt with a wired creature", {
319
+ prompt: "a wide shot of a lake monster at dusk",
320
+ connectedReferences: [nessie],
321
+ provider: "nano-banana-pro",
322
+ referenceFormat: "hybrid",
323
+ }],
324
+ ["mention-free prompt with a wired creature AND object", {
325
+ prompt: "a still life in a dim room",
326
+ connectedReferences: [nessie, chair],
327
+ provider: "nano-banana-pro",
328
+ referenceFormat: "hybrid",
329
+ }],
330
+ ["prompt with an @-token that matches no entity", {
331
+ prompt: "a shot of @dragon:1 over the lake",
332
+ connectedReferences: [nessie],
333
+ provider: "nano-banana-pro",
334
+ referenceFormat: "hybrid",
335
+ }],
336
+ ["no connectedReferences at all", {
337
+ prompt: "a wide shot of a lake at dusk",
338
+ provider: "nano-banana-pro",
339
+ referenceFormat: "hybrid",
340
+ }],
341
+ ]
342
+
343
+ // PINNED OUTPUTS. The entity pass is gated on TOKEN presence, so an
344
+ // unmentioned graph never enters it. These snapshots were VERIFIED against the
345
+ // pre-leg resolver (the entity wiring reverted, the snapshots kept) and matched
346
+ // byte-for-byte, so they are a real regression pin and not a recording of
347
+ // whatever the new code happens to emit. A diff here means the gate leaked.
348
+ it.each(cases)("%s is unchanged", (_name, config) => {
349
+ expect(buildImagePrompt(config).prompt).toMatchSnapshot()
350
+ })
351
+
352
+ it("an unmentioned creature still renders its trailing canonical phrase", () => {
353
+ const out = buildImagePrompt({
354
+ prompt: "a wide shot of a lake monster at dusk",
355
+ connectedReferences: [nessie],
356
+ provider: "nano-banana-pro",
357
+ referenceFormat: "hybrid",
358
+ })
359
+ expect(out.prompt).toContain("the creature from reference image A")
360
+ expect(out.referenceImageUrls).toEqual(["https://cdn/nessie.png"])
361
+ })
362
+
363
+ it("LEGACY leaves the token literal and attaches the entity exactly as today", () => {
364
+ const withMention = buildImagePrompt({
365
+ prompt: "a wide shot of @nessie:4 rising",
366
+ connectedReferences: [nessie],
367
+ provider: "nano-banana-pro",
368
+ referenceFormat: "legacy",
369
+ })
370
+ // No hybrid resolver runs under legacy — the token survives as typed.
371
+ expect(withMention.prompt).toContain("@nessie:4")
372
+ expect(withMention.referenceImageUrls).toEqual(["https://cdn/nessie.png"])
373
+ })
374
+ })