@nodaro/prompts 1.9.0 → 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
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",
@@ -20,7 +20,7 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "@nodaro/shared": "^2.13.0"
23
+ "@nodaro/shared": "^2.14.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.5.0",
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The character-fx node's three timing parameters (position / duration /
3
+ * intensity) are enumerable catalogs, not free values, so a consumer that can
4
+ * only send ids — Studio, the SDK, MCP — can offer them without composing any
5
+ * prompt text of its own. This is the character-fx twin of
6
+ * `transition-timing-catalogs.test.ts`; the two nodes share ids and shape but
7
+ * NOT wording, and nothing here may couple them.
8
+ *
9
+ * Three properties matter, and each is easy to lose silently:
10
+ *
11
+ * 1. The clause the composer INJECTS and the hint the catalog ADVERTISES are
12
+ * the same string. They are derived from one array in `character-fx.ts`,
13
+ * and these tests fail the moment someone re-introduces a second copy.
14
+ * 2. The prompt text is exactly what shipped before it was enumerable. The
15
+ * parameter-hint golden covers only three of the twelve clauses, so the
16
+ * twelve literals are pinned here: a change is a deliberate diff, never a
17
+ * side effect.
18
+ * 3. The dimensions survive the wire projection — `projectAllCatalogs()` is
19
+ * what an id-only client actually reads, and a green in-memory catalog
20
+ * proves nothing about it.
21
+ */
22
+ import { describe, it, expect } from "vitest"
23
+ import {
24
+ CHARACTER_FX_POSITIONS,
25
+ CHARACTER_FX_DURATIONS,
26
+ CHARACTER_FX_INTENSITIES,
27
+ composeCharacterFxHintFromConnections,
28
+ } from "../character-fx.js"
29
+ import { TRANSITION_POSITIONS, TRANSITION_DURATIONS } from "../transitions.js"
30
+ import { PICKER_CATALOGS, projectPickerCatalog, projectAllCatalogs } from "../picker-catalogs.js"
31
+
32
+ const DIMENSIONS = [
33
+ ["position", CHARACTER_FX_POSITIONS],
34
+ ["duration", CHARACTER_FX_DURATIONS],
35
+ ["intensity", CHARACTER_FX_INTENSITIES],
36
+ ] as const
37
+
38
+ const characterFxCatalog = PICKER_CATALOGS.find((c) => c.nodeType === "character-fx")!
39
+
40
+ describe("character-fx timing catalogs", () => {
41
+ // The `CharacterFxPosition` / `Duration` / `Intensity` unions are DERIVED
42
+ // from these arrays, so nothing at the type level can pin the values
43
+ // themselves. These are the values the editor dropdowns, the node data, and
44
+ // every stored workflow already use: changing one is a data migration, not a
45
+ // rename.
46
+ it("spells exactly the ids every stored workflow already uses", () => {
47
+ expect(CHARACTER_FX_POSITIONS.map((o) => o.id)).toEqual([
48
+ "auto", "start", "middle", "end", "full",
49
+ ])
50
+ expect(CHARACTER_FX_DURATIONS.map((o) => o.id)).toEqual([
51
+ "auto", "instant", "short", "medium", "long",
52
+ ])
53
+ expect(CHARACTER_FX_INTENSITIES.map((o) => o.id)).toEqual([
54
+ "auto", "subtle", "natural", "dynamic", "crazy",
55
+ ])
56
+ })
57
+
58
+ it("injects exactly the clauses that shipped before the scales were enumerable", () => {
59
+ // Byte-for-byte the literals the hand-written clause tables carried. This
60
+ // task was exposure, not authoring: if any of these move, that is a prompt
61
+ // change for real users and must be its own deliberate diff.
62
+ expect(CHARACTER_FX_POSITIONS.map((o) => o.promptHint)).toEqual([
63
+ "",
64
+ "the effect occurs at the opening of the clip",
65
+ "the effect occurs in the middle of the clip",
66
+ "the effect occurs at the end of the clip",
67
+ "the effect persists for the entire clip",
68
+ ])
69
+ expect(CHARACTER_FX_DURATIONS.map((o) => o.promptHint)).toEqual([
70
+ "",
71
+ "manifesting instantaneously",
72
+ "manifesting over approximately 1 second",
73
+ "manifesting over approximately 2 seconds",
74
+ "manifesting over approximately 3 seconds",
75
+ ])
76
+ expect(CHARACTER_FX_INTENSITIES.map((o) => o.promptHint)).toEqual([
77
+ "",
78
+ "with subtle restrained energy and minimal flourish",
79
+ "with natural unhurried timing",
80
+ "with dynamic energy and assertive flourish",
81
+ "with extreme exaggerated energy, wild flourishes, and dramatic distortion",
82
+ ])
83
+ })
84
+
85
+ it("keeps its own wording — an effect manifests and persists, a transition occurs and spans", () => {
86
+ // The one constraint that matters most: these scales must never be unified
87
+ // with the transition ones. Position and duration are worded differently
88
+ // on purpose, so pointing character-fx at the transition rows fails here.
89
+ // Intensity is deliberately NOT compared: its three clauses happen to be
90
+ // identical today, and that is a coincidence, not a shared definition.
91
+ expect(CHARACTER_FX_POSITIONS.map((o) => o.promptHint)).not.toEqual(
92
+ TRANSITION_POSITIONS.map((o) => o.promptHint),
93
+ )
94
+ expect(CHARACTER_FX_DURATIONS.map((o) => o.promptHint)).not.toEqual(
95
+ TRANSITION_DURATIONS.map((o) => o.promptHint),
96
+ )
97
+ })
98
+
99
+ it("carries the timing precision users read off the dropdown", () => {
100
+ // The editor renders straight from here (its own catalogs, not the
101
+ // transition ones), so this is the one place the numbers live.
102
+ expect(CHARACTER_FX_DURATIONS.map((o) => o.label)).toEqual([
103
+ "Auto", "Instant", "Short (~1s)", "Medium (~2s)", "Long (~3s)",
104
+ ])
105
+ })
106
+
107
+ it("gives every injecting step a compact term, so compact mode never drops it", () => {
108
+ // `catalog-terms.test.ts` enforces this globally; pinned here too because
109
+ // the platform composes timing from `promptHint` in BOTH hint modes, so a
110
+ // broken `term` would only ever surface on an external compact client.
111
+ for (const [field, options] of DIMENSIONS) {
112
+ for (const o of options.slice(1)) {
113
+ expect(o.term, `${field}/${o.id} needs a compact term`).toBeTruthy()
114
+ }
115
+ expect(options[0]!.term, `${field}/auto injects nothing`).toBe("")
116
+ }
117
+ })
118
+
119
+ it("derives a clause for every non-auto step, so the composer can't index a hole", () => {
120
+ // The regression this guards: a step present in the catalog but missing
121
+ // from the clause table made `parts.push(undefined)` render as a dangling
122
+ // ", " on a prompt that shipped to the provider. Walks the ARRAYS, not a
123
+ // hardcoded id list, so a step added to a catalog is exercised here too.
124
+ for (const [field, options] of DIMENSIONS) {
125
+ for (const o of options.slice(1)) {
126
+ const composed = composeCharacterFxHintFromConnections("werewolf", [], {
127
+ [field]: o.id,
128
+ } as never)
129
+ expect(composed, `${field}/${o.id} composed a dangling separator`).not.toMatch(/,\s*$/)
130
+ expect(composed, `${field}/${o.id}`).not.toContain("undefined")
131
+ }
132
+ }
133
+ })
134
+
135
+ it("every scale leads with a no-op `auto` that injects nothing", () => {
136
+ for (const [field, options] of DIMENSIONS) {
137
+ expect(options[0]!.id, `${field} must lead with auto`).toBe("auto")
138
+ expect(options[0]!.promptHint, `${field}/auto must inject nothing`).toBe("")
139
+ // Every other step must actually carry a clause.
140
+ for (const o of options.slice(1)) {
141
+ expect(o.promptHint, `${field}/${o.id} needs a promptHint`).not.toBe("")
142
+ }
143
+ }
144
+ })
145
+
146
+ it("the composed clause is the catalog's own promptHint, verbatim", () => {
147
+ // One source of truth: if the composer ever grows a private copy of these
148
+ // strings, the composed sentence stops containing the advertised hint.
149
+ for (const o of CHARACTER_FX_POSITIONS.slice(1)) {
150
+ const composed = composeCharacterFxHintFromConnections("werewolf", [], { position: o.id as never })
151
+ expect(composed, `position/${o.id}`).toContain(o.promptHint)
152
+ }
153
+ for (const o of CHARACTER_FX_DURATIONS.slice(1)) {
154
+ const composed = composeCharacterFxHintFromConnections("werewolf", [], { duration: o.id as never })
155
+ expect(composed, `duration/${o.id}`).toContain(o.promptHint)
156
+ }
157
+ for (const o of CHARACTER_FX_INTENSITIES.slice(1)) {
158
+ const composed = composeCharacterFxHintFromConnections("werewolf", [], { intensity: o.id as never })
159
+ expect(composed, `intensity/${o.id}`).toContain(o.promptHint)
160
+ }
161
+ })
162
+
163
+ it("the clauses are emitted identically in compact hint mode", () => {
164
+ // Timing is composed from `promptHint` in BOTH modes — only the effect's
165
+ // base fragment swaps to its term. Pinned so a compact-mode refactor cannot
166
+ // quietly start reading `term` for the timing rows.
167
+ for (const [field, options] of DIMENSIONS) {
168
+ for (const o of options.slice(1)) {
169
+ const compact = composeCharacterFxHintFromConnections("werewolf", [], {
170
+ [field]: o.id,
171
+ } as never, "compact")
172
+ expect(compact, `${field}/${o.id} in compact mode`).toContain(o.promptHint)
173
+ }
174
+ }
175
+ })
176
+
177
+ it("`auto` adds nothing to the composed sentence", () => {
178
+ const bare = composeCharacterFxHintFromConnections("werewolf", [])
179
+ const allAuto = composeCharacterFxHintFromConnections("werewolf", [], { position: "auto", duration: "auto", intensity: "auto" })
180
+ expect(allAuto).toBe(bare)
181
+ })
182
+
183
+ it("exposes the three dimensions on the character-fx catalog", () => {
184
+ expect(characterFxCatalog.kind).toBe("single")
185
+ expect(characterFxCatalog.dimensions?.map((d) => d.field)).toEqual([
186
+ "position",
187
+ "duration",
188
+ "intensity",
189
+ ])
190
+ })
191
+
192
+ it("keeps the dimensions through the wire projection", () => {
193
+ const wire = projectPickerCatalog(characterFxCatalog, { detail: "full" })
194
+
195
+ // The 57-option effect list is untouched by the addition.
196
+ expect(wire.options?.length).toBe(characterFxCatalog.options?.length)
197
+
198
+ expect(wire.dimensions).toHaveLength(3)
199
+ for (const [field, options] of DIMENSIONS) {
200
+ const dim = wire.dimensions?.find((d) => d.field === field)
201
+ expect(dim, `${field} must reach the wire`).toBeDefined()
202
+ expect(dim!.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
203
+ expect(dim!.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
204
+ }
205
+ })
206
+
207
+ it("field filter narrows a single-dim catalog to one secondary dimension", () => {
208
+ const wire = projectPickerCatalog(characterFxCatalog, { field: "duration" })
209
+ expect(wire.options?.length).toBe(characterFxCatalog.options?.length)
210
+ expect(wire.dimensions?.map((d) => d.field)).toEqual(["duration"])
211
+ })
212
+
213
+ describe("projectAllCatalogs — what an id-only client actually reads", () => {
214
+ // `GET /v1/catalogs` is built from this call. Both detail levels must carry
215
+ // the three dimensions with ids and labels; `full` must also carry the
216
+ // clause, and `compact` must carry the term so a thin client can inject
217
+ // without a second fetch.
218
+ for (const detail of ["compact", "full"] as const) {
219
+ it(`detail=${detail}: three dimensions with ids, labels${detail === "full" ? " and hints" : " and terms"}`, () => {
220
+ const wire = projectAllCatalogs({ detail }).find((c) => c.nodeType === "character-fx")!
221
+ expect(wire.detail).toBe(detail)
222
+ expect(wire.kind).toBe("single")
223
+ expect(wire.dimensions?.map((d) => d.field)).toEqual(["position", "duration", "intensity"])
224
+
225
+ for (const [field, options] of DIMENSIONS) {
226
+ const dim = wire.dimensions!.find((d) => d.field === field)!
227
+ expect(dim.label, `${field} label`).toBeTruthy()
228
+ expect(dim.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
229
+ expect(dim.options.map((o) => o.label)).toEqual(options.map((o) => o.label))
230
+ expect(dim.options.map((o) => o.term)).toEqual(options.map((o) => o.term))
231
+ if (detail === "full") {
232
+ expect(dim.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
233
+ } else {
234
+ for (const o of dim.options) expect(o, `${field}/${o.id} compact`).not.toHaveProperty("promptHint")
235
+ }
236
+ }
237
+ })
238
+ }
239
+ })
240
+ })
@@ -143,9 +143,11 @@ describe("transition timing catalogs", () => {
143
143
  })
144
144
 
145
145
  it("leaves every other single-dim catalog without dimensions", () => {
146
+ // `character-fx` carries the same three timing fields with its own,
147
+ // deliberately different wording (see `character-fx-timing-catalogs.test.ts`).
146
148
  const singlesWithDims = PICKER_CATALOGS.filter(
147
149
  (c) => c.kind === "single" && c.dimensions,
148
150
  ).map((c) => c.nodeType)
149
- expect(singlesWithDims).toEqual(["transition"])
151
+ expect(singlesWithDims).toEqual(["transition", "character-fx"])
150
152
  })
151
153
  })
@@ -0,0 +1,301 @@
1
+ /**
2
+ * `{ref:<id>}` / `{ref:<id>:<label>}` — id-addressed reference tokens.
3
+ *
4
+ * A client that names a reference by the `connectedReferences[].id` it sent
5
+ * (Studio's bound `@`-chips) gets the `@image_N` slot substituted by the
6
+ * platform AFTER the platform has numbered the references. That removes the
7
+ * client-side mirror of the numbering walk — the one duplicated rule that could
8
+ * silently misbind pictures for a client built against an older package.
9
+ *
10
+ * Contract pinned here:
11
+ * - the slot comes from the SAME walk that numbers the directives (mention
12
+ * URLs → canonical fallback → extras, offset by the leading flat refs);
13
+ * - the token is resolved BEFORE the `referenceOrder` reorder, so the
14
+ * renumber pass carries the binding to the ref's final seat — the opposite
15
+ * of `{image:N}`, which is resolved AFTER it to keep the author's N;
16
+ * - an unresolvable token never ships raw: label → the ref's display name
17
+ * (when the id is known) → "";
18
+ * - a prompt with no `{ref:` token is byte-identical to before.
19
+ */
20
+ import { describe, it, expect } from "vitest"
21
+ import {
22
+ resolveVideoReferenceCore,
23
+ resolveRefIdTokens,
24
+ resolveReferenceTokens,
25
+ } from "../video-reference-resolver.js"
26
+ import type { ConnectedReference } from "@nodaro/shared"
27
+
28
+ const charRef = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
29
+ id: "char-kira", defaultName: "Kira", source: "wired-character", url: "https://r2/kira.png",
30
+ characterSlug: "kira", variantSlug: undefined, characterCanonicalDescription: null,
31
+ variantDescription: null, variantDisplayName: "canonical", ...over,
32
+ })
33
+
34
+ const A = "https://cdn/a.png"
35
+ const B = "https://cdn/b.png"
36
+
37
+ /** Every case: the token must be gone, whatever it resolved to. */
38
+ function expectNoRawToken(prompt: string | undefined) {
39
+ expect(prompt ?? "").not.toMatch(/\{ref:/i)
40
+ }
41
+
42
+ describe("resolveVideoReferenceCore — {ref:<id>} id-addressed tokens", () => {
43
+ it("bare {ref:<id>} binds an image extra to its @image_N slot", () => {
44
+ const out = resolveVideoReferenceCore({
45
+ prompt: "drive {ref:car-1} fast",
46
+ wiredCharRefs: [],
47
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
48
+ })
49
+ expect(out.additionalUrls).toEqual(["https://r2/car.png"])
50
+ expect(out.prompt).toContain("- @image_1 (reference): a red car.")
51
+ expect(out.prompt).toContain("drive @image_1 fast")
52
+ expectNoRawToken(out.prompt)
53
+ })
54
+
55
+ it("labeled {ref:<id>:<label>} binds through REF_BINDING.image (parity with {image:N:label})", () => {
56
+ const out = resolveVideoReferenceCore({
57
+ prompt: "drive {ref:car-1:car} fast",
58
+ wiredCharRefs: [],
59
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
60
+ })
61
+ expect(out.prompt).toContain("drive the car from @image_1 fast")
62
+ expectNoRawToken(out.prompt)
63
+ })
64
+
65
+ it("a canonical wired character binds to its canonical-fallback slot", () => {
66
+ const out = resolveVideoReferenceCore({
67
+ prompt: "{ref:char-kira} walks in",
68
+ wiredCharRefs: [charRef()],
69
+ })
70
+ expect(out.additionalUrls).toEqual(["https://r2/kira.png"])
71
+ expect(out.prompt).toContain("Use these characters:")
72
+ expect(out.prompt).toContain("@image_1 walks in")
73
+ expectNoRawToken(out.prompt)
74
+ })
75
+
76
+ it("a character VIEW (extra with characterSlug) binds to its pair-back slot after the canonical", () => {
77
+ const out = resolveVideoReferenceCore({
78
+ prompt: "{ref:view-1} turns to face {ref:char-kira}",
79
+ wiredCharRefs: [charRef()],
80
+ extraRefs: [{ id: "view-1", url: "https://r2/kira-side.png", characterSlug: "kira", description: "side profile" }],
81
+ })
82
+ expect(out.additionalUrls).toEqual(["https://r2/kira.png", "https://r2/kira-side.png"])
83
+ expect(out.prompt).toContain("- @image_2 is the same subject as @image_1, side profile.")
84
+ expect(out.prompt).toContain("@image_2 turns to face @image_1")
85
+ expectNoRawToken(out.prompt)
86
+ })
87
+
88
+ it("leading flat refs offset the slot (D5 image-refs-first)", () => {
89
+ const out = resolveVideoReferenceCore({
90
+ prompt: "the {ref:obj} on the table",
91
+ wiredCharRefs: [],
92
+ leadingRefUrls: [A],
93
+ extraRefs: [{ id: "obj", url: B, description: "object" }],
94
+ })
95
+ expect(out.additionalUrls).toEqual([A, B])
96
+ expect(out.prompt).toContain("the @image_2 on the table")
97
+ expectNoRawToken(out.prompt)
98
+ })
99
+
100
+ it("ids are opaque: `:` and `/` inside an id resolve, and a trailing label still parses", () => {
101
+ const out = resolveVideoReferenceCore({
102
+ prompt: "{ref:https://cdn/pic.png} beside {ref:kira:smile:smile}",
103
+ wiredCharRefs: [],
104
+ extraRefs: [
105
+ { id: "https://cdn/pic.png", url: "https://cdn/pic.png", description: "pic" },
106
+ { id: "kira:smile", url: "https://r2/kira-smile.png", description: "smile" },
107
+ ],
108
+ })
109
+ expect(out.prompt).toContain("@image_1 beside the smile from @image_2")
110
+ expectNoRawToken(out.prompt)
111
+ })
112
+
113
+ it("an unknown id degrades to its label, or to nothing — never the raw token", () => {
114
+ const out = resolveVideoReferenceCore({
115
+ prompt: "a {ref:nope:ghost} b {ref:nope2} c",
116
+ wiredCharRefs: [],
117
+ extraRefs: [{ id: "x", url: A, description: "d" }],
118
+ })
119
+ expect(out.prompt).toContain("a ghost b c")
120
+ expectNoRawToken(out.prompt)
121
+ })
122
+
123
+ it("a known ref the walk never seated degrades to its display name", () => {
124
+ // A wired character with no url is skipped by the canonical loop — the id
125
+ // is known (so the name is), but there is no slot to bind.
126
+ const out = resolveVideoReferenceCore({
127
+ prompt: "{ref:char-kira} waves at {ref:capped-1}",
128
+ wiredCharRefs: [charRef({ url: "" })],
129
+ extraRefs: [{ id: "x", url: A, description: "d" }],
130
+ // The caller's full id → name map (the route builds it from EVERY
131
+ // connectedReference, including the ones it capped out before the walk).
132
+ refNamesById: new Map([["capped-1", "Truck"]]),
133
+ })
134
+ expect(out.prompt).toContain("Kira waves at Truck")
135
+ expectNoRawToken(out.prompt)
136
+ })
137
+
138
+ it("a duplicate-URL extra never binds past the payload: its {ref:} degrades to its name", () => {
139
+ // The walk counts every extra with a url while `merged` dedups by URL, so
140
+ // the second extra's directive is numbered @image_2 although the payload
141
+ // carries ONE image (pre-existing walk-vs-merged drift). The token is
142
+ // range-gated against the image count, so it degrades instead of emitting
143
+ // a phantom binding.
144
+ const out = resolveVideoReferenceCore({
145
+ prompt: "{ref:x} then {ref:y}",
146
+ wiredCharRefs: [],
147
+ extraRefs: [
148
+ { id: "x", url: A, description: "first" },
149
+ { id: "y", url: A, description: "second" },
150
+ ],
151
+ refNamesById: new Map([["y", "Second"]]),
152
+ })
153
+ expect(out.additionalUrls).toEqual([A])
154
+ expect(out.prompt).toContain("@image_1 then Second")
155
+ expectNoRawToken(out.prompt)
156
+ })
157
+
158
+ it("resolves BEFORE the referenceOrder reorder, so the binding follows the ref to its final seat", () => {
159
+ const out = resolveVideoReferenceCore({
160
+ prompt: "{ref:y} leads, {ref:x} follows",
161
+ wiredCharRefs: [],
162
+ extraRefs: [
163
+ { id: "x", url: A, description: "ax" },
164
+ { id: "y", url: B, description: "by" },
165
+ ],
166
+ // Extras' tile ids are `wired:<url>` (the reorder contract, unchanged).
167
+ referenceOrder: [`wired:${B}`, `wired:${A}`],
168
+ })
169
+ expect(out.additionalUrls).toEqual([B, A])
170
+ expect(out.prompt).toContain("@image_1 leads, @image_2 follows")
171
+ expect(out.prompt).toContain("- @image_1 (reference): by.")
172
+ expect(out.prompt).toContain("- @image_2 (reference): ax.")
173
+ expectNoRawToken(out.prompt)
174
+ })
175
+
176
+ it("keeps {image:N} resolved AFTER the reorder (author's N kept) while {ref:} follows the ref", () => {
177
+ const out = resolveVideoReferenceCore({
178
+ prompt: "{ref:y} and {image:2:second}",
179
+ wiredCharRefs: [],
180
+ extraRefs: [
181
+ { id: "x", url: A, description: "ax" },
182
+ { id: "y", url: B, description: "by" },
183
+ ],
184
+ referenceOrder: [`wired:${B}`, `wired:${A}`],
185
+ })
186
+ // y moved to seat 1 → {ref:y} rides along; {image:2} keeps the literal 2.
187
+ expect(out.prompt).toContain("@image_1 and the second from @image_2")
188
+ expectNoRawToken(out.prompt)
189
+ })
190
+
191
+ it("an @-mentioned character's ref binds to the mention's slot", () => {
192
+ const out = resolveVideoReferenceCore({
193
+ prompt: "@kira:1 waves, then {ref:char-kira} sits",
194
+ wiredCharRefs: [charRef()],
195
+ })
196
+ expect(out.additionalUrls).toEqual(["https://r2/kira.png"])
197
+ expect(out.prompt).toContain("Kira waves, then @image_1 sits")
198
+ expectNoRawToken(out.prompt)
199
+ })
200
+
201
+ it("is independent of hybridRoles — same slot, no legend block", () => {
202
+ const out = resolveVideoReferenceCore({
203
+ prompt: "drive {ref:car-1} fast",
204
+ wiredCharRefs: [],
205
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
206
+ hybridRoles: true,
207
+ })
208
+ expect(out.prompt).not.toContain("Use these characters:")
209
+ expect(out.prompt).toContain("drive @image_1 fast")
210
+ expectNoRawToken(out.prompt)
211
+ })
212
+
213
+ it("a prompt with no {ref: token is untouched — `{ref}` and `ref:` are not tokens", () => {
214
+ const out = resolveVideoReferenceCore({
215
+ prompt: "circle {image:1:object} {ref} ref: x",
216
+ wiredCharRefs: [],
217
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
218
+ })
219
+ expect(out.prompt).toContain("circle the object from @image_1 {ref} ref: x")
220
+ })
221
+
222
+ it("an empty id drops to nothing and the keyword is case-insensitive", () => {
223
+ const out = resolveVideoReferenceCore({
224
+ prompt: "x {ref:} {REF:car-1} y",
225
+ wiredCharRefs: [],
226
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
227
+ })
228
+ expect(out.prompt).toContain("x @image_1 y")
229
+ expectNoRawToken(out.prompt)
230
+ })
231
+
232
+ it("imageRefCount: 0 (no image tokens may bind) degrades a seated ref to its name", () => {
233
+ const out = resolveVideoReferenceCore({
234
+ prompt: "{ref:char-kira} walks",
235
+ wiredCharRefs: [charRef()],
236
+ imageRefCount: 0,
237
+ })
238
+ expect(out.prompt).toContain("Kira walks")
239
+ expect(out.prompt).not.toContain("@image_1 walks")
240
+ expectNoRawToken(out.prompt)
241
+ })
242
+
243
+ it("early-return path (no wired chars, no extras): degrades to name / label / nothing", () => {
244
+ const out = resolveVideoReferenceCore({
245
+ prompt: "{ref:a} and {ref:b:the dog} and {ref:c}",
246
+ wiredCharRefs: [],
247
+ leadingRefUrls: [A],
248
+ refNamesById: new Map([["a", "Alpha"]]),
249
+ })
250
+ expect(out.additionalUrls).toEqual([A])
251
+ expect(out.prompt).toBe("Alpha and the dog and")
252
+ expectNoRawToken(out.prompt)
253
+ })
254
+ })
255
+
256
+ describe("resolveRefIdTokens — malformed and adversarial input", () => {
257
+ it("a malformed token (brace inside the id, no closing brace) never ships its `{ref:` prefix", () => {
258
+ const out = resolveRefIdTokens("x {ref:a{b} y {ref:unclosed z", {
259
+ slotById: new Map([["a", 1]]),
260
+ nameById: new Map(),
261
+ imageCount: 1,
262
+ })
263
+ expect(out).not.toMatch(/\{ref:/i)
264
+ // The net is bounded by whitespace/braces: the prose after each run survives.
265
+ expect(out).toContain(" y ")
266
+ expect(out).toContain(" z")
267
+ })
268
+
269
+ it("scans a prompt at the hard ceiling with an adversarial shape and still resolves (linear matcher)", () => {
270
+ // 30k chars of `{ref:` followed by label-class text with no closing brace —
271
+ // the shape that made a lazy-quantifier matcher quadratic.
272
+ const adversarial = "{ref:" + ":a".repeat(15000)
273
+ const out = resolveRefIdTokens(`${adversarial} end {ref:x}`, {
274
+ slotById: new Map([["x", 1]]),
275
+ nameById: new Map(),
276
+ imageCount: 1,
277
+ })
278
+ expect(out).not.toMatch(/\{ref:/i)
279
+ expect(out).toContain("end @image_1")
280
+ })
281
+ })
282
+
283
+ describe("resolveRefIdTokens (standalone — the route's no-image-ref early return)", () => {
284
+ it("binds in-range slots, and degrades label → name → nothing otherwise", () => {
285
+ const resolved = resolveRefIdTokens("{ref:x:car} {ref:x} {ref:y:dog} {ref:y} {ref:z}", {
286
+ slotById: new Map([["x", 2], ["y", 4]]),
287
+ nameById: new Map([["y", "Dog"]]),
288
+ imageCount: 3,
289
+ })
290
+ // y is seated at 4 but only 3 images ship → name; z is unknown → nothing.
291
+ expect(resolveReferenceTokens(resolved, { image: 3, video: 0, audio: 0 })).toBe(
292
+ "the car from @image_2 @image_2 dog Dog",
293
+ )
294
+ })
295
+
296
+ it("returns the prompt untouched when no {ref: token is present", () => {
297
+ const prompt = "plain {image:1} prose"
298
+ expect(resolveRefIdTokens(prompt, { slotById: new Map(), nameById: new Map(), imageCount: 0 })).toBe(prompt)
299
+ expect(resolveRefIdTokens(undefined, { slotById: new Map(), nameById: new Map(), imageCount: 0 })).toBeUndefined()
300
+ })
301
+ })