@nodaro/shared 2.15.0 → 2.17.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/dist/index.cjs +178 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +378 -16
- package/dist/index.d.ts +378 -16
- package/dist/index.js +167 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/entity-mention-slug.test.ts +380 -0
- package/src/__tests__/image-mention-slug.test.ts +303 -0
- package/src/__tests__/producer-types.test.ts +22 -0
- package/src/__tests__/prompt-length-limits.test.ts +2 -2
- package/src/__tests__/to-connected-references.test.ts +45 -0
- package/src/animals.ts +57 -0
- package/src/character-voice.ts +4 -3
- package/src/entity-mention-slug.ts +203 -0
- package/src/image-mention-slug.ts +187 -0
- package/src/index.ts +27 -0
- package/src/mention-token-grammar.ts +167 -0
- package/src/model-catalog.ts +12 -9
- package/src/model-constants.ts +8 -6
- package/src/model-tree.ts +1 -0
- package/src/producer-types.ts +6 -0
- package/src/to-connected-references.ts +16 -2
- package/src/types.ts +11 -5
- package/src/workflow-export.ts +34 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
imageMentionSlug,
|
|
4
|
+
parseImageMentionToken,
|
|
5
|
+
findImageMentionTokens,
|
|
6
|
+
knownImageSlugsFromRefs,
|
|
7
|
+
imageMentionSlugForRef,
|
|
8
|
+
} from "../image-mention-slug.js"
|
|
9
|
+
import type { ConnectedReference } from "../types.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Named-image mentions — `@<name-slug>:<index>[:<role>]`.
|
|
13
|
+
*
|
|
14
|
+
* Mirrors `location-mention-slug.test.ts`, minus everything locations have and
|
|
15
|
+
* media references don't (buckets, variants, usage modes). The load-bearing
|
|
16
|
+
* assertions here are the two that keep this parser from colliding with the
|
|
17
|
+
* other two grammars: a 4-part token is NEVER claimed, and the known-slug set
|
|
18
|
+
* drops grammar-invalid slugs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const media = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
|
|
22
|
+
id: "img-1",
|
|
23
|
+
defaultName: "Town",
|
|
24
|
+
source: "wired-image",
|
|
25
|
+
url: "https://cdn/town.png",
|
|
26
|
+
...over,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("imageMentionSlug", () => {
|
|
30
|
+
it("lowercases and dash-joins", () => {
|
|
31
|
+
expect(imageMentionSlug("Town")).toBe("town")
|
|
32
|
+
expect(imageMentionSlug("Old Town Square")).toBe("old-town-square")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("collapses punctuation runs and strips leading/trailing dashes", () => {
|
|
36
|
+
expect(imageMentionSlug(" Town -- Square!! ")).toBe("town-square")
|
|
37
|
+
expect(imageMentionSlug("--town--")).toBe("town")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("keeps digits, including a leading one (emptiness is NOT the grammar gate)", () => {
|
|
41
|
+
// Non-empty yet UNPARSEABLE — the leading digit fails IMAGE_SLUG_PATTERN.
|
|
42
|
+
// `knownImageSlugsFromRefs` is what must drop it, not this function.
|
|
43
|
+
expect(imageMentionSlug("3D Render")).toBe("3d-render")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("returns empty for a name with no latin alphanumerics", () => {
|
|
47
|
+
expect(imageMentionSlug("עיר")).toBe("")
|
|
48
|
+
expect(imageMentionSlug("🎬🎬")).toBe("")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("is byte-identical to the character/location slug algorithm", () => {
|
|
52
|
+
for (const name of ["Kira", "Old Library", "A B", "Ünï-cödé 12"]) {
|
|
53
|
+
expect(imageMentionSlug(name)).toBe(
|
|
54
|
+
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""),
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("parseImageMentionToken — rejects", () => {
|
|
61
|
+
it("rejects text that is not a token at all", () => {
|
|
62
|
+
expect(parseImageMentionToken("town:1")).toBeNull() // no @
|
|
63
|
+
expect(parseImageMentionToken("@")).toBeNull()
|
|
64
|
+
expect(parseImageMentionToken("@town")).toBeNull() // no index segment
|
|
65
|
+
expect(parseImageMentionToken("@town:")).toBeNull() // empty index
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("rejects a non-positive or non-numeric index", () => {
|
|
69
|
+
expect(parseImageMentionToken("@town:0")).toBeNull()
|
|
70
|
+
expect(parseImageMentionToken("@town:x")).toBeNull()
|
|
71
|
+
expect(parseImageMentionToken("@town:1x")).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("rejects out-of-grammar slugs", () => {
|
|
75
|
+
expect(parseImageMentionToken("@Town:1")).toBeNull() // uppercase
|
|
76
|
+
expect(parseImageMentionToken("@3d:1")).toBeNull() // leading digit
|
|
77
|
+
expect(parseImageMentionToken("@-town:1")).toBeNull() // leading dash
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("rejects a FOUR-part token — the collision guard against character mentions", () => {
|
|
81
|
+
expect(parseImageMentionToken("@town:1:a:b")).toBeNull()
|
|
82
|
+
expect(parseImageMentionToken("@kira:1:smile:face")).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("rejects an empty or out-of-grammar role segment", () => {
|
|
86
|
+
expect(parseImageMentionToken("@town:1:")).toBeNull()
|
|
87
|
+
expect(parseImageMentionToken("@town:1:Bad")).toBeNull()
|
|
88
|
+
expect(parseImageMentionToken("@town:1:1st")).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("parseImageMentionToken — accepts", () => {
|
|
93
|
+
it("parses a bare 2-part token and emits NO role key", () => {
|
|
94
|
+
const parsed = parseImageMentionToken("@town:3")
|
|
95
|
+
expect(parsed).toEqual({ imageSlug: "town", imageIndex: 3 })
|
|
96
|
+
// Shape rule: a 2-part token must stay shape-identical to a role-less parser.
|
|
97
|
+
expect(parsed && "role" in parsed).toBe(false)
|
|
98
|
+
expect(parsed && "lock" in parsed).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("parses a curated role in the 3rd segment", () => {
|
|
102
|
+
expect(parseImageMentionToken("@town:3:background")).toEqual({
|
|
103
|
+
imageSlug: "town",
|
|
104
|
+
imageIndex: 3,
|
|
105
|
+
role: "background",
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("passes a CUSTOM role through verbatim", () => {
|
|
110
|
+
expect(parseImageMentionToken("@town:3:my-custom-role")).toEqual({
|
|
111
|
+
imageSlug: "town",
|
|
112
|
+
imageIndex: 3,
|
|
113
|
+
role: "my-custom-role",
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("parses the ~lock / ~nolock sentinels as a tri-state", () => {
|
|
118
|
+
expect(parseImageMentionToken("@town:3~lock")).toEqual({
|
|
119
|
+
imageSlug: "town", imageIndex: 3, lock: true,
|
|
120
|
+
})
|
|
121
|
+
expect(parseImageMentionToken("@town:3~nolock")).toEqual({
|
|
122
|
+
imageSlug: "town", imageIndex: 3, lock: false,
|
|
123
|
+
})
|
|
124
|
+
expect(parseImageMentionToken("@town:3:background~nolock")).toEqual({
|
|
125
|
+
imageSlug: "town", imageIndex: 3, role: "background", lock: false,
|
|
126
|
+
})
|
|
127
|
+
// The role + force-ON pairing — the other half of the role×sentinel matrix.
|
|
128
|
+
expect(parseImageMentionToken("@town:3:background~lock")).toEqual({
|
|
129
|
+
imageSlug: "town", imageIndex: 3, role: "background", lock: true,
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("accepts a multi-segment slug and a large index", () => {
|
|
134
|
+
expect(parseImageMentionToken("@old-town-square:12")).toEqual({
|
|
135
|
+
imageSlug: "old-town-square",
|
|
136
|
+
imageIndex: 12,
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe("findImageMentionTokens", () => {
|
|
142
|
+
it("finds a known slug and reports its exact offset", () => {
|
|
143
|
+
const prompt = "a shot of @town:3 at dusk"
|
|
144
|
+
const [t] = findImageMentionTokens(prompt, ["town"])
|
|
145
|
+
expect(t.token).toBe("@town:3")
|
|
146
|
+
expect(t.imageSlug).toBe("town")
|
|
147
|
+
expect(t.imageIndex).toBe(3)
|
|
148
|
+
expect(prompt.slice(t.offset, t.offset + t.token.length)).toBe("@town:3")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("matches a token at the very start of the prompt", () => {
|
|
152
|
+
const [t] = findImageMentionTokens("@town:1 at dusk", ["town"])
|
|
153
|
+
expect(t.offset).toBe(0)
|
|
154
|
+
expect(t.token).toBe("@town:1")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("filters out slugs that are not known", () => {
|
|
158
|
+
expect(findImageMentionTokens("a shot of @town:3", [])).toEqual([])
|
|
159
|
+
expect(findImageMentionTokens("a shot of @town:3", ["village"])).toEqual([])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("does not match an email-like `a@town:1` (preceding alphanumeric)", () => {
|
|
163
|
+
expect(findImageMentionTokens("mail a@town:1 now", ["town"])).toEqual([])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("yields NO token for a 4-part CHARACTER token, even when the slug is a known image", () => {
|
|
167
|
+
// The `(?![:a-z0-9-])` lookahead: without it this would be captured as the
|
|
168
|
+
// 3-part `@kira:1:smile`, leaving `:face` dangling in the prompt.
|
|
169
|
+
expect(findImageMentionTokens("@kira:1:smile:face poses", ["kira"])).toEqual([])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("does not swallow `~locked` as a sentinel (byte-identical to the location finder)", () => {
|
|
173
|
+
// The sentinel's own `(?![a-z0-9-])` rejects `~locked`, so the regex falls
|
|
174
|
+
// back to the bare `@town:1` and the `~locked` text stays literal — exactly
|
|
175
|
+
// what `findLocationMentionTokens` does for the same input.
|
|
176
|
+
const [t] = findImageMentionTokens("@town:1~locked", ["town"])
|
|
177
|
+
expect(t.token).toBe("@town:1")
|
|
178
|
+
expect("lock" in t).toBe(false)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("does claim the sentinel when it is well-formed", () => {
|
|
182
|
+
const [t] = findImageMentionTokens("@town:1~lock", ["town"])
|
|
183
|
+
expect(t.token).toBe("@town:1~lock")
|
|
184
|
+
expect(t.lock).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("claims a `~nolock` sentinel through the FINDER too (force-OFF, tri-state)", () => {
|
|
188
|
+
const [t] = findImageMentionTokens("a shot of @town:1~nolock at dusk", ["town"])
|
|
189
|
+
expect(t.token).toBe("@town:1~nolock")
|
|
190
|
+
expect(t.lock).toBe(false)
|
|
191
|
+
expect(findImageMentionTokens("@town:1~nolockx", ["town"])[0].token).toBe("@town:1")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("yields NO token for a LOCATION bucket/variant token, even on a known slug", () => {
|
|
195
|
+
// The slash guard. Without it the finder claims the truncated 3-part prefix
|
|
196
|
+
// `@old-library:1:weather` and SPLICES it, leaving `/rain` dangling in the
|
|
197
|
+
// model-facing prompt — a corruption the character/location finders never
|
|
198
|
+
// commit (they leave an unresolvable token literal).
|
|
199
|
+
expect(findImageMentionTokens("a shot of @old-library:1:weather/rain", ["old-library"]))
|
|
200
|
+
.toEqual([])
|
|
201
|
+
// 4-part location token (variant + mode) — same rejection.
|
|
202
|
+
expect(findImageMentionTokens("@old-library:1:weather/rain:style", ["old-library"]))
|
|
203
|
+
.toEqual([])
|
|
204
|
+
// Sentinel + slash: the whole token stays literal rather than backtracking
|
|
205
|
+
// to `@town:1` and splicing that.
|
|
206
|
+
expect(findImageMentionTokens("@town:1~lock/rain", ["town"])).toEqual([])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("still matches two mentions separated by a slash (`/` alone is not the signal)", () => {
|
|
210
|
+
const tokens = findImageMentionTokens("@town:1/@barn:2", ["town", "barn"])
|
|
211
|
+
expect(tokens.map((t) => t.token)).toEqual(["@town:1", "@barn:2"])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("finds several mentions in prompt order", () => {
|
|
215
|
+
const tokens = findImageMentionTokens("@town:1 then @barn:2:background", ["town", "barn"])
|
|
216
|
+
expect(tokens.map((t) => t.token)).toEqual(["@town:1", "@barn:2:background"])
|
|
217
|
+
expect(tokens[1].role).toBe("background")
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe("knownImageSlugsFromRefs", () => {
|
|
222
|
+
it("includes wired-image and manual media refs", () => {
|
|
223
|
+
expect(
|
|
224
|
+
knownImageSlugsFromRefs([
|
|
225
|
+
media(),
|
|
226
|
+
media({ id: "m", defaultName: "My Upload", source: "manual", url: "https://cdn/u.png" }),
|
|
227
|
+
]),
|
|
228
|
+
).toEqual(["town", "my-upload"])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("excludes every non-media source", () => {
|
|
232
|
+
expect(
|
|
233
|
+
knownImageSlugsFromRefs([
|
|
234
|
+
media({ source: "wired-character", defaultName: "Kira" }),
|
|
235
|
+
media({ source: "wired-location", defaultName: "Old Library" }),
|
|
236
|
+
media({ source: "wired-object", defaultName: "Chair" }),
|
|
237
|
+
media({ source: "wired-creature", defaultName: "Ember" }),
|
|
238
|
+
]),
|
|
239
|
+
).toEqual([])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("excludes extra refs (they render through the extras path)", () => {
|
|
243
|
+
expect(knownImageSlugsFromRefs([media({ isExtraRef: true })])).toEqual([])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("excludes url-less and name-less refs", () => {
|
|
247
|
+
expect(knownImageSlugsFromRefs([media({ url: "" })])).toEqual([])
|
|
248
|
+
expect(knownImageSlugsFromRefs([media({ defaultName: "" })])).toEqual([])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("drops a grammar-invalid slug even though it is non-empty", () => {
|
|
252
|
+
// "3D Render" → "3d-render": truthy, but no token can ever match it.
|
|
253
|
+
expect(knownImageSlugsFromRefs([media({ defaultName: "3D Render" })])).toEqual([])
|
|
254
|
+
expect(knownImageSlugsFromRefs([media({ defaultName: "🎬" })])).toEqual([])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it("dedupes refs that slug to the same name", () => {
|
|
258
|
+
expect(
|
|
259
|
+
knownImageSlugsFromRefs([
|
|
260
|
+
media({ id: "a", defaultName: "Upload Image", url: "https://cdn/a.png" }),
|
|
261
|
+
media({ id: "b", defaultName: "Upload Image", url: "https://cdn/b.png" }),
|
|
262
|
+
]),
|
|
263
|
+
).toEqual(["upload-image"])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it("is the exact gate the finder uses (no slug in the set is unmatchable)", () => {
|
|
267
|
+
const refs = [media(), media({ id: "b", defaultName: "3D Render", url: "https://cdn/b.png" })]
|
|
268
|
+
const slugs = knownImageSlugsFromRefs(refs)
|
|
269
|
+
for (const slug of slugs) {
|
|
270
|
+
expect(findImageMentionTokens(`@${slug}:1`, slugs)).toHaveLength(1)
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe("imageMentionSlugForRef — the single mentionability gate", () => {
|
|
276
|
+
it("returns the slug for a mentionable media ref", () => {
|
|
277
|
+
expect(imageMentionSlugForRef(media())).toBe("town")
|
|
278
|
+
expect(imageMentionSlugForRef(media({ source: "manual", defaultName: "My Upload" })))
|
|
279
|
+
.toBe("my-upload")
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it("returns null for every non-mentionable ref", () => {
|
|
283
|
+
expect(imageMentionSlugForRef(media({ source: "wired-character" }))).toBeNull()
|
|
284
|
+
expect(imageMentionSlugForRef(media({ isExtraRef: true }))).toBeNull()
|
|
285
|
+
expect(imageMentionSlugForRef(media({ url: "" }))).toBeNull()
|
|
286
|
+
expect(imageMentionSlugForRef(media({ defaultName: "" }))).toBeNull()
|
|
287
|
+
// Non-empty but grammar-invalid — emptiness is NOT the gate.
|
|
288
|
+
expect(imageMentionSlugForRef(media({ defaultName: "3D Render" }))).toBeNull()
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it("is the SAME gate `knownImageSlugsFromRefs` applies (the two views cannot drift)", () => {
|
|
292
|
+
const refs = [
|
|
293
|
+
media(),
|
|
294
|
+
media({ id: "b", defaultName: "3D Render", url: "https://cdn/b.png" }),
|
|
295
|
+
media({ id: "c", defaultName: "Extra", isExtraRef: true, url: "https://cdn/c.png" }),
|
|
296
|
+
media({ id: "d", source: "wired-location", defaultName: "Old Library", url: "https://cdn/d.png" }),
|
|
297
|
+
media({ id: "e", source: "manual", defaultName: "My Upload", url: "https://cdn/e.png" }),
|
|
298
|
+
]
|
|
299
|
+
expect(knownImageSlugsFromRefs(refs)).toEqual(
|
|
300
|
+
[...new Set(refs.map(imageMentionSlugForRef).filter((s): s is string => s !== null))],
|
|
301
|
+
)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
@@ -42,6 +42,28 @@ describe("producer-types", () => {
|
|
|
42
42
|
expect(DYNAMIC_PRODUCER_TYPES.has("voice-changer-pro")).toBe(true)
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
+
// Dubbing joined the dual-mode family with the full-surface upgrade
|
|
46
|
+
// (audio in → dubbed audio; video in / video sourceUrl → dubbed video +
|
|
47
|
+
// audio sidecar). EXPLICIT assertions — the suite does not fail on
|
|
48
|
+
// omission, so without these a refactor could drop dubbing from a set
|
|
49
|
+
// and resurrect the "cannot connect the outputs" bug for it alone.
|
|
50
|
+
it("registers dubbing as an audio producer (its default output)", () => {
|
|
51
|
+
expect(AUDIO_PRODUCER_TYPES.has("dubbing")).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("registers dubbing as a dynamic producer (dual-mode → accepted on BOTH audio and video input handles)", () => {
|
|
55
|
+
expect(DYNAMIC_PRODUCER_TYPES.has("dubbing")).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("keeps dubbing's producer membership identical to voice-changer (same dual-mode contract)", () => {
|
|
59
|
+
for (const [name, set] of Object.entries(ALL_SETS)) {
|
|
60
|
+
expect(
|
|
61
|
+
set.has("dubbing"),
|
|
62
|
+
`dubbing must match voice-changer in ${name}`,
|
|
63
|
+
).toBe(set.has("voice-changer"))
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
45
67
|
// voice-changer-pro is a behavioral twin of voice-changer (identical
|
|
46
68
|
// dual-mode output: audio in → audio out; video in → video out). Their
|
|
47
69
|
// producer-set membership must never drift apart — if voice-changer is
|
|
@@ -92,8 +92,8 @@ describe("per-model prompt length limits", () => {
|
|
|
92
92
|
it("returns verified per-model TTS caps", () => {
|
|
93
93
|
expect(getMaxTtsChars("elevenlabs-turbo")).toBe(40000)
|
|
94
94
|
expect(getMaxTtsChars("elevenlabs-multilingual")).toBe(10000)
|
|
95
|
-
expect(getMaxTtsChars("elevenlabs-v3")).toBe(
|
|
96
|
-
expect(getMaxTtsChars("elevenlabs-dialogue")).toBe(
|
|
95
|
+
expect(getMaxTtsChars("elevenlabs-v3")).toBe(5000) // official cap (probed 2026-08-30)
|
|
96
|
+
expect(getMaxTtsChars("elevenlabs-dialogue")).toBe(5000) // total across lines
|
|
97
97
|
})
|
|
98
98
|
it("defaults to TTS_TEXT_MAX for the legacy/unknown provider", () => {
|
|
99
99
|
expect(getMaxTtsChars("elevenlabs")).toBe(TTS_TEXT_MAX)
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"
|
|
|
2
2
|
|
|
3
3
|
import { characterMentionSlug } from "../character-mention-slug.js"
|
|
4
4
|
import { locationMentionSlug } from "../location-mention-slug.js"
|
|
5
|
+
import { imageMentionSlug, knownImageSlugsFromRefs } from "../image-mention-slug.js"
|
|
5
6
|
import {
|
|
6
7
|
toConnectedReference,
|
|
7
8
|
toConnectedReferences,
|
|
@@ -109,6 +110,50 @@ describe("toConnectedReference", () => {
|
|
|
109
110
|
).toBe(false)
|
|
110
111
|
})
|
|
111
112
|
|
|
113
|
+
it("maps an image binding to a wired-image reference (no slug field — derived at prompt time)", () => {
|
|
114
|
+
expect(
|
|
115
|
+
toConnectedReference({
|
|
116
|
+
id: "img-1",
|
|
117
|
+
kind: "image",
|
|
118
|
+
name: "Town",
|
|
119
|
+
url: "https://r2.example/town.png",
|
|
120
|
+
}),
|
|
121
|
+
).toEqual({
|
|
122
|
+
id: "img-1",
|
|
123
|
+
defaultName: "Town",
|
|
124
|
+
source: "wired-image",
|
|
125
|
+
url: "https://r2.example/town.png",
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("keeps the image name addressable by the derived mention slug", () => {
|
|
130
|
+
const ref = toConnectedReference({
|
|
131
|
+
id: "img-1",
|
|
132
|
+
kind: "image",
|
|
133
|
+
name: "Old Town Square",
|
|
134
|
+
url: "https://r2.example/town.png",
|
|
135
|
+
})
|
|
136
|
+
// There is no `imageSlug` on the wire — `knownImageSlugsFromRefs` derives it
|
|
137
|
+
// from `defaultName`, so this ref answers to `@old-town-square:N`.
|
|
138
|
+
expect(knownImageSlugsFromRefs([ref])).toEqual([imageMentionSlug("Old Town Square")])
|
|
139
|
+
expect(knownImageSlugsFromRefs([ref])).toEqual(["old-town-square"])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("folds an image description into the reference; absent adds no key", () => {
|
|
143
|
+
expect(
|
|
144
|
+
toConnectedReference({
|
|
145
|
+
id: "img-1",
|
|
146
|
+
kind: "image",
|
|
147
|
+
name: "Town",
|
|
148
|
+
url: "https://r2.example/town.png",
|
|
149
|
+
description: "a quiet town square at dusk",
|
|
150
|
+
}).description,
|
|
151
|
+
).toBe("a quiet town square at dusk")
|
|
152
|
+
expect(
|
|
153
|
+
"description" in toConnectedReference({ id: "img-2", kind: "image", name: "Barn" }),
|
|
154
|
+
).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
|
|
112
157
|
it("falls back to a placeholder-safe empty url when the entity has no thumbnail", () => {
|
|
113
158
|
expect(
|
|
114
159
|
toConnectedReference({
|
package/src/animals.ts
CHANGED
|
@@ -208,6 +208,63 @@ export function getAnimalLabel(id: string | undefined | null, fallback?: string)
|
|
|
208
208
|
return (id ?? "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The ONE authored phrasing for an animal selection — "featuring a golden
|
|
213
|
+
* retriever, Medium-large dog with …".
|
|
214
|
+
*
|
|
215
|
+
* WHY IT LIVES HERE: the phrase had two independent copies (the picker-catalog
|
|
216
|
+
* funnel's synthesized `promptHint` and `getParameterPromptHint`'s `animal`
|
|
217
|
+
* case), and the `subject` prompt channel needs a third caller. A third copy
|
|
218
|
+
* would be new drift, and the channel's registry cannot read `ANIMALS`
|
|
219
|
+
* directly — `catalog-funnel-ratchet.test.ts` derives its watch set from
|
|
220
|
+
* `picker-catalogs.ts`'s own uppercase value imports and can only SHRINK, so a
|
|
221
|
+
* new raw-array reader under `packages/prompts/src` fails CI. A FUNCTION in
|
|
222
|
+
* `@nodaro/shared` (where the catalog itself lives) is invisible to that scan
|
|
223
|
+
* and is the same "functions only" discipline `direction-registry.ts` follows.
|
|
224
|
+
*
|
|
225
|
+
* Empty string on a miss, matching every `get*PromptHint` in `@nodaro/prompts`:
|
|
226
|
+
* a retired or unknown id contributes no clause rather than throwing.
|
|
227
|
+
*/
|
|
228
|
+
export function getAnimalPromptHint(id: string | undefined | null): string {
|
|
229
|
+
const a = getAnimal(id)
|
|
230
|
+
return a ? `featuring a ${a.label.toLowerCase()}, ${a.description}` : ""
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Parenthetical segments — "Rifle (bolt-action)", "ISO 1600 (visible grain)". */
|
|
234
|
+
const PARENTHETICAL = /\([^)]*\)/g
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Mechanical label → term derivation, a deliberate LOCAL COPY of
|
|
238
|
+
* `@nodaro/prompts`' `deriveTerm`: `@nodaro/prompts` depends on this package,
|
|
239
|
+
* never the other way round, so the function cannot be imported here. The copy
|
|
240
|
+
* is pinned byte-for-byte against the original by
|
|
241
|
+
* `packages/prompts/src/__tests__/animal-getters-parity.test.ts` (which can
|
|
242
|
+
* import both sides) — the repo's "an invariant + guard test" idiom rather than
|
|
243
|
+
* "remember to update the other one".
|
|
244
|
+
*/
|
|
245
|
+
function deriveAnimalTerm(label: string): string {
|
|
246
|
+
return label
|
|
247
|
+
.replace(PARENTHETICAL, " ")
|
|
248
|
+
.toLowerCase()
|
|
249
|
+
.replace(/\s+/g, " ")
|
|
250
|
+
.trim()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The COMPACT counterpart of `getAnimalPromptHint`: the same lookup, the same
|
|
255
|
+
* empty-string-on-miss, returning the short professional term a prompt would
|
|
256
|
+
* actually carry ("golden retriever", "t-rex") instead of the full clause.
|
|
257
|
+
*
|
|
258
|
+
* An authored `term` wins; otherwise the label IS the professional term for a
|
|
259
|
+
* concrete object, resolved through the mechanical derivation — NOT a bare
|
|
260
|
+
* `toLowerCase()` — so a parenthetical label is stripped exactly the way every
|
|
261
|
+
* other catalog strips it.
|
|
262
|
+
*/
|
|
263
|
+
export function getAnimalTerm(id: string | undefined | null): string {
|
|
264
|
+
const a = getAnimal(id)
|
|
265
|
+
return a ? (a.term ?? deriveAnimalTerm(a.label)) : ""
|
|
266
|
+
}
|
|
267
|
+
|
|
211
268
|
export const ANIMAL_IDS: ReadonlyArray<string> = ANIMALS.map((a) => a.id)
|
|
212
269
|
|
|
213
270
|
export const ANIMAL_SUBCATEGORY_LABELS: Readonly<Record<AnimalSubcategory, string>> = {
|
package/src/character-voice.ts
CHANGED
|
@@ -40,9 +40,10 @@ export interface DialogueLine {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* A dialogue line resolved to a concrete voice,
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* A dialogue line resolved to a concrete voice: `{ text, voice }` per line, in
|
|
44
|
+
* order. `voice` is OUR identifier (premade name or library/custom UUID) — the
|
|
45
|
+
* direct ElevenLabs Dialogue call resolves it per line to the `voice_id` the
|
|
46
|
+
* wire shape (`inputs[]`) wants. `voiceType` rides along so the TTS layer
|
|
46
47
|
* resolves premade-by-name vs library/custom-by-id correctly.
|
|
47
48
|
*/
|
|
48
49
|
export interface ResolvedDialogueVoiceLine {
|