@nodaro/shared 2.16.0 → 2.18.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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Wired-entity `@-mention` parser — `@<name-slug>:<index>[:<role>][~lock|~nolock]`
3
+ * for `wired-creature` and `wired-object` references.
4
+ *
5
+ * THE BUG THIS KILLS. Before this leg, creatures and objects were the only wired
6
+ * sources with NO mention grammar. A user writing "Nessie rises from the lake"
7
+ * with a creature node wired in got the creature's NAME as plain prose while its
8
+ * binding dangled as a trailing "the creature from reference image D" line after
9
+ * the style hints — two disconnected halves of one intent, which is exactly the
10
+ * failure mode mentions exist to remove. With a mention, the binding renders
11
+ * INLINE at the typed position and the trailing canonical fallback for that ref
12
+ * is suppressed.
13
+ *
14
+ * @nessie:4 — bare mention; the source-default role phrase
15
+ * ("the creature from reference image D")
16
+ * @nessie:4:markings — role phrase ("the markings from reference image D")
17
+ * @chair:2:material — objects use the `wired-object` presets
18
+ * @nessie:4:my-custom-role — custom roles pass through verbatim
19
+ * @nessie:4~lock — additive identity-lock sentinel (also `~nolock`)
20
+ * @nessie:4:a:b — NULL. A 4-part token is never an entity mention.
21
+ *
22
+ * GRAMMAR CORE. Identical grammar to the named-image mention, so the slug shape,
23
+ * the parser and the finder (with BOTH collision guards — the 4-part trailing
24
+ * reject and the location slash guard) are the SHARED `mention-token-grammar.ts`,
25
+ * not a second hand-copied edition. This module is the ENTITY view of that core:
26
+ * the field names, and the part that genuinely differs — WHICH refs contribute a
27
+ * slug.
28
+ *
29
+ * PRECEDENCE, across all five kinds:
30
+ *
31
+ * character → location → image → creature → object
32
+ *
33
+ * Enforced by RESOLUTION ORDER in `buildImagePrompt`'s Phase 0, not by anything
34
+ * in this file: each pass splices its matched tokens out of the prompt before the
35
+ * next pass runs its finder, so a slug claimed by an earlier kind never reaches a
36
+ * later pass. A name shared by a character and a creature resolves as the
37
+ * CHARACTER, and the creature token never fires. The creature-before-object half
38
+ * of the tail is enforced inside the single entity pass, whose slug → ref map is
39
+ * built creature-first (see `resolveEntityMentionsHybrid`).
40
+ *
41
+ * NO WIRE FIELD, matching the image grammar and unlike `characterSlug` /
42
+ * `locationSlug`: the slug is DERIVED from `defaultName` at resolve time
43
+ * (`knownEntitySlugsFromRefs`), so a client cannot drift from the grammar and the
44
+ * reference schema is untouched.
45
+ *
46
+ * NO LEGACY RESOLVER — the image-grammar precedent. Only the hybrid reference
47
+ * format resolves these tokens; under the legacy format an `@name:N` token stays
48
+ * literal text and the entity auto-attaches with its trailing canonical phrase
49
+ * exactly as it does today. Legacy assembly has no inline role-phrase machinery
50
+ * at all (its object/creature rendering is the numbered-directive block), so
51
+ * there is no clean seam to add one and no consumer asking for it.
52
+ */
53
+
54
+ import type { ConnectedReference } from "./types.js"
55
+ import {
56
+ MENTION_SLUG_PATTERN,
57
+ findMentionTokens,
58
+ mentionNameSlug,
59
+ parseMentionToken,
60
+ } from "./mention-token-grammar.js"
61
+
62
+ /**
63
+ * Slugify a wired entity's display name for `@`-mention tokens. Byte-identical
64
+ * algorithm to `characterMentionSlug` / `imageMentionSlug`; kept as a separate
65
+ * export to make the call site's intent explicit.
66
+ */
67
+ export function entityMentionSlug(name: string): string {
68
+ return mentionNameSlug(name)
69
+ }
70
+
71
+ export interface EntityMentionTokenInfo {
72
+ /** The matched token text, verbatim — spliced out of the prompt at resolve time. */
73
+ readonly token: string
74
+ readonly entitySlug: string
75
+ /**
76
+ * 1-based correlation index assigned at insertion by the autocomplete
77
+ * (`nextMentionIndex` = max(existing N) + 1, unified across every mention
78
+ * kind). The hybrid resolver binds by its own numbering walk, so the index is
79
+ * CORRELATION ONLY — it is never echoed into the prompt.
80
+ */
81
+ readonly entityIndex: number
82
+ /**
83
+ * Per-mention ROLE from the 3rd segment (`@nessie:4:markings`) — curated
84
+ * (`REFERENCE_ROLE_PRESETS["wired-creature"]` / `["wired-object"]`) or custom,
85
+ * stored VERBATIM. Both preset lists are entirely single-word, so this never
86
+ * needs `normalizeRoleSlug` (the location-only remapping for multi-word
87
+ * presets). OMITTED (undefined, never null) for 2-part tokens.
88
+ */
89
+ readonly role?: string
90
+ /**
91
+ * Additive `~lock` / `~nolock` sentinel. Tri-state: `true` (force ON) |
92
+ * `false` (force OFF, suppressing a ref-level `identityLock.enabled`) |
93
+ * ABSENT/undefined (inherit the ref default). Honored only by the hybrid
94
+ * resolver — there is no legacy entity resolver to make it inert on.
95
+ */
96
+ readonly lock?: boolean
97
+ /** Byte offset into the source prompt — used to splice the token out. */
98
+ readonly offset: number
99
+ }
100
+
101
+ /**
102
+ * Parse a single `@<name-slug>:<index>[:<role>]` token. Returns null when the
103
+ * token doesn't match a supported shape (the caller falls back to literal text).
104
+ *
105
+ * Segment count is 2 or 3 — NOT 2–4 like the character/location parsers. A wired
106
+ * creature or object has no variant/bucket slot, so there is nothing for a 4th
107
+ * segment to mean, and claiming one would let this parser swallow a character
108
+ * token.
109
+ */
110
+ export function parseEntityMentionToken(text: string): {
111
+ entitySlug: string
112
+ entityIndex: number
113
+ /** Present ONLY for a 3-part token; omitted otherwise (shape rule). */
114
+ role?: string
115
+ /** Present ONLY when a sentinel was found; omitted otherwise (shape rule). */
116
+ lock?: boolean
117
+ } | null {
118
+ const parsed = parseMentionToken(text)
119
+ if (!parsed) return null
120
+ return {
121
+ entitySlug: parsed.slug,
122
+ entityIndex: parsed.index,
123
+ ...(parsed.role !== undefined ? { role: parsed.role } : {}),
124
+ ...(parsed.lock !== undefined ? { lock: parsed.lock } : {}),
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Find every entity `@-mention` in a prompt whose slug is a known entity slug.
130
+ *
131
+ * `knownEntitySlugs` (from `knownEntitySlugsFromRefs`) is what keeps this parser
132
+ * off the other grammars' tokens — every finder matches the same `@slug:N…`
133
+ * surface and only the known-slug set separates them. Cross-kind precedence is
134
+ * additionally enforced by pass ORDER at the resolver (see the module header).
135
+ */
136
+ export function findEntityMentionTokens(
137
+ prompt: string,
138
+ knownEntitySlugs: readonly string[],
139
+ ): EntityMentionTokenInfo[] {
140
+ return findMentionTokens(prompt, knownEntitySlugs).map((t) => ({
141
+ token: t.token,
142
+ entitySlug: t.slug,
143
+ entityIndex: t.index,
144
+ ...(t.role !== undefined ? { role: t.role } : {}),
145
+ ...(t.lock !== undefined ? { lock: t.lock } : {}),
146
+ offset: t.offset,
147
+ }))
148
+ }
149
+
150
+ /**
151
+ * The mention slug a single reference contributes, or `null` when the ref cannot
152
+ * carry an entity mention at all — the SINGLE gate, so every view of "which refs
153
+ * are entity-mentionable" is the same view.
154
+ *
155
+ * Shared by `knownEntitySlugsFromRefs` (the finder's known-slug set) and the
156
+ * prompt-builder's hybrid resolver (its slug → ref lookup map). Those two must
157
+ * admit exactly the same refs: a slug the finder accepts but the resolver drops
158
+ * would splice a token with nothing to bind, and a ref the resolver keys under a
159
+ * slug no token can match is dead weight. Emptiness is NOT the gate (see
160
+ * `MENTION_SLUG_PATTERN`) — a creature named "3-Eyed Raven" slugs to the truthy
161
+ * but unparseable `"3-eyed-raven"`.
162
+ *
163
+ * `isExtraRef` refs are EXCLUDED, mirroring `imageMentionSlugForRef`: an extra
164
+ * renders through the extras path with its own body line, so letting a mention
165
+ * also bind one would double-emit prose.
166
+ */
167
+ export function entityMentionSlugForRef(r: ConnectedReference): string | null {
168
+ if (r.source !== "wired-creature" && r.source !== "wired-object") return null
169
+ if (r.isExtraRef === true) return null
170
+ if (!r.url || !r.defaultName) return null
171
+ const slug = entityMentionSlug(r.defaultName)
172
+ return MENTION_SLUG_PATTERN.test(slug) ? slug : null
173
+ }
174
+
175
+ /**
176
+ * The known-entity-slug set for a reference list — the SINGLE source of truth for
177
+ * the derivation, shared by `buildImagePrompt`'s Phase 0 and the backend
178
+ * orchestrator's structured-branch gate so the two can never disagree about
179
+ * whether a prompt carries a resolvable entity mention.
180
+ *
181
+ * UNFILTERED, exactly like `knownImageSlugsFromRefs` and the character/location
182
+ * slug sets: every mentionable creature/object contributes its slug regardless of
183
+ * what any OTHER kind may also claim. Cross-kind precedence is a property of the
184
+ * resolver's pass order (character → location → image → creature → object), NOT
185
+ * of this set — subtracting the earlier kinds' slugs here would put the
186
+ * precedence rule in two places and let them drift.
187
+ *
188
+ * Creature and object share ONE set (and one resolver pass): the grammar, the
189
+ * gate and the rendering are identical, and `defaultRoleForSource(r.source)`
190
+ * already tells the two apart at phrase time. Their relative precedence is
191
+ * settled where a tie can actually occur — the resolver's slug → ref map, built
192
+ * creature-first.
193
+ */
194
+ export function knownEntitySlugsFromRefs(
195
+ refs: readonly ConnectedReference[],
196
+ ): string[] {
197
+ const out = new Set<string>()
198
+ for (const r of refs) {
199
+ const slug = entityMentionSlugForRef(r)
200
+ if (slug) out.add(slug)
201
+ }
202
+ return [...out]
203
+ }
@@ -4,10 +4,11 @@
4
4
  * The media analog of `character-mention-slug.ts` / `location-mention-slug.ts`,
5
5
  * for a wired image (`wired-image` / `manual` reference) addressed by the slug of
6
6
  * its NAME: an upload node's label on the canvas, or the name a thin client puts
7
- * on the reference. The grammar is the SIMPLEST of the three — 2 or 3 segments,
8
- * no buckets, no variants, no usage-mode enum — because a media reference has no
9
- * variant array to select from and no identity mode to override. Every valid 3rd
10
- * segment is a ROLE.
7
+ * on the reference. The grammar is the SHORT one — 2 or 3 segments, no buckets, no
8
+ * variants, no usage-mode enum — because a media reference has no variant array to
9
+ * select from and no identity mode to override. Every valid 3rd segment is a ROLE.
10
+ * It is not unique to media: `entity-mention-slug.ts` (creatures/objects) speaks
11
+ * the identical grammar off the shared core noted below.
11
12
  *
12
13
  * @town:3 — bare mention; renders the reference's binding
13
14
  * ("reference image C") at the typed position
@@ -16,6 +17,13 @@
16
17
  * @town:3~lock — additive identity-lock sentinel (also `~nolock`)
17
18
  * @town:1:a:b — NULL. A 4-part token is never an image mention.
18
19
  *
20
+ * GRAMMAR CORE. The slug shape, the parser and the finder (including BOTH
21
+ * collision guards) live in `mention-token-grammar.ts` and are shared verbatim
22
+ * with `entity-mention-slug.ts` (creatures/objects), which speaks the identical
23
+ * 2-or-3-segment grammar. This module is the MEDIA view of that core: the
24
+ * per-kind field names, and the part that genuinely differs — WHICH refs
25
+ * contribute a slug.
26
+ *
19
27
  * NO WIRE FIELD. Unlike `characterSlug` / `locationSlug`, there is no `imageSlug`
20
28
  * on `ConnectedReference`: the slug is DERIVED from `defaultName` at resolve time
21
29
  * (`knownImageSlugsFromRefs`), so a client cannot drift from the grammar and the
@@ -27,30 +35,20 @@
27
35
  */
28
36
 
29
37
  import type { ConnectedReference } from "./types.js"
30
-
31
- /**
32
- * Grammar-valid slug shape — the exact shape `findImageMentionTokens`' regex can
33
- * produce, and therefore the gate on BOTH sides of the match.
34
- *
35
- * Emptiness is NOT the gate: `imageMentionSlug("3D Render")` → `"3d-render"` is
36
- * non-empty yet UNPARSEABLE (a leading digit), so a ref named "3D Render" must be
37
- * dropped from the known-slug set even though its slug is truthy. This pattern is
38
- * what drops it.
39
- */
40
- const IMAGE_SLUG_PATTERN = /^[a-z][a-z0-9-]*$/
38
+ import {
39
+ MENTION_SLUG_PATTERN,
40
+ findMentionTokens,
41
+ mentionNameSlug,
42
+ parseMentionToken,
43
+ } from "./mention-token-grammar.js"
41
44
 
42
45
  /**
43
46
  * Slugify an image reference's display name for `@`-mention tokens. Byte-
44
47
  * identical algorithm to `characterMentionSlug` / `locationMentionSlug`; kept as
45
- * a separate export to make the call site's intent explicit and to allow future
46
- * divergence.
48
+ * a separate export to make the call site's intent explicit.
47
49
  */
48
50
  export function imageMentionSlug(name: string): string {
49
- return name
50
- .toLowerCase()
51
- .replace(/[^a-z0-9]+/g, "-")
52
- .replace(/-+/g, "-")
53
- .replace(/^-|-$/g, "")
51
+ return mentionNameSlug(name)
54
52
  }
55
53
 
56
54
  export interface ImageMentionTokenInfo {
@@ -91,6 +89,11 @@ export interface ImageMentionTokenInfo {
91
89
  * Segment count is 2 or 3 — NOT 2–4 like the character/location parsers. A media
92
90
  * reference has no variant/bucket slot, so there is nothing for a 4th segment to
93
91
  * mean, and claiming one would let this parser swallow a character token.
92
+ *
93
+ * Delegates to the shared `parseMentionToken` and renames its kind-neutral
94
+ * `slug` / `index` to this module's `imageSlug` / `imageIndex`. The optional
95
+ * `role` / `lock` keys are re-emitted CONDITIONALLY so the documented shape rule
96
+ * survives the rename: a 2-part token has no `role` key at all.
94
97
  */
95
98
  export function parseImageMentionToken(text: string): {
96
99
  imageSlug: string
@@ -100,87 +103,35 @@ export function parseImageMentionToken(text: string): {
100
103
  /** Present ONLY when a sentinel was found; omitted otherwise (shape rule). */
101
104
  lock?: boolean
102
105
  } | null {
103
- if (!text.startsWith("@")) return null
104
- let rest = text.slice(1)
105
- if (rest.length === 0 || !/^[a-z]/.test(rest)) return null
106
-
107
- // Strip a trailing `~nolock` (force OFF) or `~lock` (force ON) BEFORE splitting
108
- // so the segment grammar is untouched (a `~` never appears inside a segment).
109
- // Check `~nolock` FIRST — `~lock` is its suffix. A token with NEITHER sentinel
110
- // gains NO `lock` key.
111
- let lockField: { lock?: boolean } = {}
112
- if (rest.endsWith("~nolock")) {
113
- rest = rest.slice(0, -"~nolock".length)
114
- lockField = { lock: false }
115
- } else if (rest.endsWith("~lock")) {
116
- rest = rest.slice(0, -"~lock".length)
117
- lockField = { lock: true }
106
+ const parsed = parseMentionToken(text)
107
+ if (!parsed) return null
108
+ return {
109
+ imageSlug: parsed.slug,
110
+ imageIndex: parsed.index,
111
+ ...(parsed.role !== undefined ? { role: parsed.role } : {}),
112
+ ...(parsed.lock !== undefined ? { lock: parsed.lock } : {}),
118
113
  }
119
-
120
- const parts = rest.split(":")
121
- if (parts.length < 2 || parts.length > 3) return null
122
-
123
- const [imageSlug, indexStr, third] = parts
124
- if (!IMAGE_SLUG_PATTERN.test(imageSlug)) return null
125
- if (!/^\d+$/.test(indexStr)) return null
126
- const imageIndex = parseInt(indexStr, 10)
127
- if (!Number.isInteger(imageIndex) || imageIndex < 1) return null
128
-
129
- if (parts.length === 2) return { imageSlug, imageIndex, ...lockField }
130
- if (!IMAGE_SLUG_PATTERN.test(third)) return null
131
- return { imageSlug, imageIndex, role: third, ...lockField }
132
114
  }
133
115
 
134
116
  /**
135
117
  * Find every image `@-mention` in a prompt whose slug is a known image slug.
136
118
  *
137
119
  * `knownImageSlugs` (from `knownImageSlugsFromRefs`) is what keeps this parser
138
- * off the other two grammars' tokens — all three finders match the same
139
- * `@slug:N…` surface and only the known-slug set separates them.
120
+ * off the other grammars' tokens — every finder matches the same `@slug:N…`
121
+ * surface and only the known-slug set separates them.
140
122
  */
141
123
  export function findImageMentionTokens(
142
124
  prompt: string,
143
125
  knownImageSlugs: readonly string[],
144
126
  ): ImageMentionTokenInfo[] {
145
- const tokens: ImageMentionTokenInfo[] = []
146
- // ONE optional segment (the role) — images have no variant/bucket slot.
147
- //
148
- // The trailing `(?![:a-z0-9-])` is the DELIBERATE divergence from the character
149
- // and location finders. Without it, a 4-part CHARACTER token that the character
150
- // pass failed to resolve (`@kira:1:smile:face`) would be captured here as the
151
- // 3-part `@kira:1:smile`, leaving `:face` dangling in the prompt. The lookahead
152
- // makes the regex backtrack and match nothing, so a 4-part token is NEVER an
153
- // image mention. `~lock` still matches (`~` is outside the class), and its own
154
- // `(?![a-z0-9-])` keeps `~locked` / `~nolockx` literal.
155
- //
156
- // Linear-scan shape (a fixed prefix then bounded optional groups, no nested
157
- // quantifiers) — matching the sibling finders, and ReDoS-free.
158
- const regex =
159
- /(?:^|[^a-zA-Z0-9])(@[a-z][a-z0-9-]*:\d+(?::[a-z][a-z0-9-]*)?(?:~(?:no)?lock(?![a-z0-9-]))?)(?![:a-z0-9-])/g
160
- const knownSet = new Set(knownImageSlugs)
161
- for (const match of prompt.matchAll(regex)) {
162
- const token = match[1]
163
- const offset = (match.index ?? 0) + (match[0].length - token.length)
164
- // SLASH GUARD — the second half of the collision guard, and the reason it
165
- // is a post-match check instead of another lookahead in the regex. `/` is
166
- // the LOCATION grammar's bucket/variant separator (`@lib:1:weather/rain`),
167
- // so a token immediately followed by `/<segment>` is a sibling-grammar
168
- // token, never an image mention. A lookahead cannot express this: the
169
- // engine would just BACKTRACK to a shorter prefix (`@lib:1:weather` →
170
- // `@lib:1`, or `@town:1~lock` → `@town:1`) and splice THAT, which is the
171
- // very corruption being prevented. Rejecting the whole match here leaves
172
- // the token literal, exactly as the character/location finders do.
173
- //
174
- // `/` alone is NOT the signal — `@town:1/@barn:2` (two mentions separated
175
- // by a slash) must keep matching, and a location segment always starts
176
- // `[a-z]`. So the guard is `/` + a segment start.
177
- if (/^\/[a-z]/.test(prompt.slice(offset + token.length))) continue
178
- const parsed = parseImageMentionToken(token)
179
- if (parsed && knownSet.has(parsed.imageSlug)) {
180
- tokens.push({ token, ...parsed, offset })
181
- }
182
- }
183
- return tokens
127
+ return findMentionTokens(prompt, knownImageSlugs).map((t) => ({
128
+ token: t.token,
129
+ imageSlug: t.slug,
130
+ imageIndex: t.index,
131
+ ...(t.role !== undefined ? { role: t.role } : {}),
132
+ ...(t.lock !== undefined ? { lock: t.lock } : {}),
133
+ offset: t.offset,
134
+ }))
184
135
  }
185
136
 
186
137
  /**
@@ -193,14 +144,14 @@ export function findImageMentionTokens(
193
144
  * admit exactly the same refs: a slug the finder accepts but the resolver drops
194
145
  * would splice a token with nothing to bind, and a ref the resolver keys under
195
146
  * a slug no token can match is dead weight. Emptiness is NOT the gate (see
196
- * `IMAGE_SLUG_PATTERN`).
147
+ * `MENTION_SLUG_PATTERN`).
197
148
  */
198
149
  export function imageMentionSlugForRef(r: ConnectedReference): string | null {
199
150
  if (r.source !== "wired-image" && r.source !== "manual") return null
200
151
  if (r.isExtraRef === true) return null
201
152
  if (!r.url || !r.defaultName) return null
202
153
  const slug = imageMentionSlug(r.defaultName)
203
- return IMAGE_SLUG_PATTERN.test(slug) ? slug : null
154
+ return MENTION_SLUG_PATTERN.test(slug) ? slug : null
204
155
  }
205
156
 
206
157
  /**
@@ -210,13 +161,13 @@ export function imageMentionSlugForRef(r: ConnectedReference): string | null {
210
161
  * whether a prompt carries a resolvable image mention.
211
162
  *
212
163
  * Only MEDIA refs (`wired-image` / `manual`) with a URL participate — the other
213
- * sources have their own mention grammars (characters, locations) or no mention
214
- * path at all (objects, creatures).
164
+ * sources have their own mention grammars: characters, locations, and — since the
165
+ * creature/object leg — wired entities via `knownEntitySlugsFromRefs`.
215
166
  *
216
167
  * `isExtraRef` refs are EXCLUDED: an extra renders through the extras path with
217
168
  * its own body line, so letting a mention also bind one would double-emit prose.
218
169
  *
219
- * Grammar-invalid slugs are DROPPED (see `IMAGE_SLUG_PATTERN`) — a ref named
170
+ * Grammar-invalid slugs are DROPPED (see `MENTION_SLUG_PATTERN`) — a ref named
220
171
  * "3D Render" slugs to the non-empty but unparseable `"3d-render"`, and admitting
221
172
  * it would put a slug in the set that no token can ever match.
222
173
  *
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  NATIVE_NEGATIVE_PROMPT_MODELS,
39
39
  NATIVE_NEGATIVE_VIDEO_PROVIDERS,
40
40
  applyVideoNegativePrompt,
41
+ videoNegativeSuffix,
41
42
  MODELS_WITH_REFERENCE_IMAGE_SUPPORT,
42
43
  T2I_TO_I2I_VARIANT,
43
44
  REF_IMAGE_MAX_LIMITS,
@@ -637,6 +638,8 @@ export {
637
638
  ANIMAL_SUBCATEGORY_ORDER,
638
639
  getAnimal,
639
640
  getAnimalLabel,
641
+ getAnimalPromptHint,
642
+ getAnimalTerm,
640
643
  } from "./animals.js"
641
644
  export type { Animal, AnimalSubcategory } from "./animals.js"
642
645
 
@@ -768,6 +771,8 @@ export type {
768
771
  WorkflowMediaRef,
769
772
  WorkflowPortability,
770
773
  WorkflowImportReport,
774
+ WorkflowImportSkippedAsset,
775
+ WorkflowAssetKind,
771
776
  } from "./workflow-export.js"
772
777
  export { stripExportContent } from "./workflow-export.js"
773
778
 
@@ -818,6 +823,20 @@ export {
818
823
  } from "./image-mention-slug.js"
819
824
  export type { ImageMentionTokenInfo } from "./image-mention-slug.js"
820
825
 
826
+ // Wired-creature / wired-object mentions — the SAME 2-or-3-segment grammar as
827
+ // the named-image mention (both are views of `mention-token-grammar.ts`), with
828
+ // their own ref gate. The grammar core itself stays internal to the package: it
829
+ // has no standalone consumer, and a third public `@slug:N` surface would invite
830
+ // call sites that bypass a kind's gate.
831
+ export {
832
+ entityMentionSlug,
833
+ parseEntityMentionToken,
834
+ findEntityMentionTokens,
835
+ knownEntitySlugsFromRefs,
836
+ entityMentionSlugForRef,
837
+ } from "./entity-mention-slug.js"
838
+ export type { EntityMentionTokenInfo } from "./entity-mention-slug.js"
839
+
821
840
  export {
822
841
  toConnectedReference,
823
842
  toConnectedReferences,
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The SHORT `@-mention` grammar core — `@<name-slug>:<index>[:<role>][~lock|~nolock]`.
3
+ *
4
+ * ONE parser, ONE finder, ONE pair of collision guards, shared by every mention
5
+ * kind whose token has NO variant/bucket slot:
6
+ *
7
+ * - `image-mention-slug.ts` — wired media (`wired-image` / `manual`)
8
+ * - `entity-mention-slug.ts` — wired entities (`wired-creature` / `wired-object`)
9
+ *
10
+ * WHY EXTRACTED (and why the 5-line slugify precedent does NOT apply here). The
11
+ * character/location/image modules each keep their own copy of the trivial
12
+ * `characterMentionSlug` algorithm — duplication that is cheap because the
13
+ * function is five obvious lines. What is shared HERE is the opposite: the
14
+ * two-part collision guard (`(?![:a-z0-9-])`, which stops a 4-part CHARACTER
15
+ * token being claimed as a 3-part one, and the post-match slash guard, which
16
+ * stops a LOCATION bucket token being spliced as its own truncated prefix).
17
+ * Those guards exist precisely to prevent prompt corruption, and a second
18
+ * hand-copied edition of them is a drift surface with a corruption payload. So
19
+ * the media and entity grammars converge on this module and the per-kind files
20
+ * keep only what genuinely differs: WHICH refs contribute a slug.
21
+ *
22
+ * The character and location grammars do NOT use this core — their tokens carry
23
+ * 2–4 segments with a variant/bucket/usage-mode slot, a materially different
24
+ * shape, and their finders deliberately have NO trailing-reject lookahead.
25
+ */
26
+
27
+ /**
28
+ * Grammar-valid slug shape — the exact shape `findMentionTokens`' regex can
29
+ * produce, and therefore the gate on BOTH sides of the match.
30
+ *
31
+ * Emptiness is NOT the gate: `mentionNameSlug("3D Render")` → `"3d-render"` is
32
+ * non-empty yet UNPARSEABLE (a leading digit), so a ref named "3D Render" must
33
+ * be dropped from a known-slug set even though its slug is truthy. This pattern
34
+ * is what drops it.
35
+ */
36
+ export const MENTION_SLUG_PATTERN = /^[a-z][a-z0-9-]*$/
37
+
38
+ /**
39
+ * Slugify a reference's display name for `@`-mention tokens. Byte-identical
40
+ * algorithm to `characterMentionSlug` / `locationMentionSlug`; the per-kind
41
+ * modules re-export it under their own name so each call site's intent stays
42
+ * explicit.
43
+ */
44
+ export function mentionNameSlug(name: string): string {
45
+ return name
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, "-")
48
+ .replace(/-+/g, "-")
49
+ .replace(/^-|-$/g, "")
50
+ }
51
+
52
+ /** Kind-neutral parse result. The per-kind modules rename `slug` / `index`. */
53
+ export interface ParsedMentionToken {
54
+ readonly slug: string
55
+ readonly index: number
56
+ /** Present ONLY for a 3-part token; omitted otherwise (shape rule). */
57
+ readonly role?: string
58
+ /** Present ONLY when a sentinel was found; omitted otherwise (shape rule). */
59
+ readonly lock?: boolean
60
+ }
61
+
62
+ /** Kind-neutral finder result — a `ParsedMentionToken` plus its splice site. */
63
+ export interface FoundMentionToken extends ParsedMentionToken {
64
+ /** The matched token text, verbatim — spliced out of the prompt at resolve time. */
65
+ readonly token: string
66
+ /** Byte offset into the source prompt — used to splice the token out. */
67
+ readonly offset: number
68
+ }
69
+
70
+ /**
71
+ * Parse a single `@<name-slug>:<index>[:<role>]` token. Returns null when the
72
+ * token doesn't match a supported shape (the caller falls back to literal text).
73
+ *
74
+ * Segment count is 2 or 3 — NOT 2–4 like the character/location parsers. Neither
75
+ * a media reference nor a wired entity has a variant/bucket slot, so there is
76
+ * nothing for a 4th segment to mean, and claiming one would let this parser
77
+ * swallow a character token.
78
+ */
79
+ export function parseMentionToken(text: string): ParsedMentionToken | null {
80
+ if (!text.startsWith("@")) return null
81
+ let rest = text.slice(1)
82
+ if (rest.length === 0 || !/^[a-z]/.test(rest)) return null
83
+
84
+ // Strip a trailing `~nolock` (force OFF) or `~lock` (force ON) BEFORE splitting
85
+ // so the segment grammar is untouched (a `~` never appears inside a segment).
86
+ // Check `~nolock` FIRST — `~lock` is its suffix. A token with NEITHER sentinel
87
+ // gains NO `lock` key.
88
+ let lockField: { lock?: boolean } = {}
89
+ if (rest.endsWith("~nolock")) {
90
+ rest = rest.slice(0, -"~nolock".length)
91
+ lockField = { lock: false }
92
+ } else if (rest.endsWith("~lock")) {
93
+ rest = rest.slice(0, -"~lock".length)
94
+ lockField = { lock: true }
95
+ }
96
+
97
+ const parts = rest.split(":")
98
+ if (parts.length < 2 || parts.length > 3) return null
99
+
100
+ const [slug, indexStr, third] = parts
101
+ if (!MENTION_SLUG_PATTERN.test(slug)) return null
102
+ if (!/^\d+$/.test(indexStr)) return null
103
+ const index = parseInt(indexStr, 10)
104
+ if (!Number.isInteger(index) || index < 1) return null
105
+
106
+ if (parts.length === 2) return { slug, index, ...lockField }
107
+ if (!MENTION_SLUG_PATTERN.test(third)) return null
108
+ return { slug, index, role: third, ...lockField }
109
+ }
110
+
111
+ // ONE optional segment (the role) — media refs and wired entities have no
112
+ // variant/bucket slot.
113
+ //
114
+ // The trailing `(?![:a-z0-9-])` is the DELIBERATE divergence from the character
115
+ // and location finders. Without it, a 4-part CHARACTER token that the character
116
+ // pass failed to resolve (`@kira:1:smile:face`) would be captured here as the
117
+ // 3-part `@kira:1:smile`, leaving `:face` dangling in the prompt. The lookahead
118
+ // makes the regex backtrack and match nothing, so a 4-part token is NEVER a
119
+ // short-grammar mention. `~lock` still matches (`~` is outside the class), and
120
+ // its own `(?![a-z0-9-])` keeps `~locked` / `~nolockx` literal.
121
+ //
122
+ // Linear-scan shape (a fixed prefix then bounded optional groups, no nested
123
+ // quantifiers) — matching the sibling finders, and ReDoS-free.
124
+ const MENTION_TOKEN_REGEX =
125
+ /(?:^|[^a-zA-Z0-9])(@[a-z][a-z0-9-]*:\d+(?::[a-z][a-z0-9-]*)?(?:~(?:no)?lock(?![a-z0-9-]))?)(?![:a-z0-9-])/g
126
+
127
+ /**
128
+ * Find every short-grammar `@-mention` in a prompt whose slug is in
129
+ * `knownSlugs`.
130
+ *
131
+ * `knownSlugs` is what keeps one kind's parser off another kind's tokens — every
132
+ * finder matches the same `@slug:N…` surface and only the known-slug set
133
+ * separates them.
134
+ */
135
+ export function findMentionTokens(
136
+ prompt: string,
137
+ knownSlugs: readonly string[],
138
+ ): FoundMentionToken[] {
139
+ const tokens: FoundMentionToken[] = []
140
+ // A module-level `g` regex carries `lastIndex` state; `matchAll` requires the
141
+ // `g` flag but resets nothing, so re-create the scanner per call.
142
+ const regex = new RegExp(MENTION_TOKEN_REGEX.source, "g")
143
+ const knownSet = new Set(knownSlugs)
144
+ for (const match of prompt.matchAll(regex)) {
145
+ const token = match[1]
146
+ const offset = (match.index ?? 0) + (match[0].length - token.length)
147
+ // SLASH GUARD — the second half of the collision guard, and the reason it
148
+ // is a post-match check instead of another lookahead in the regex. `/` is
149
+ // the LOCATION grammar's bucket/variant separator (`@lib:1:weather/rain`),
150
+ // so a token immediately followed by `/<segment>` is a sibling-grammar
151
+ // token, never a short-grammar mention. A lookahead cannot express this:
152
+ // the engine would just BACKTRACK to a shorter prefix (`@lib:1:weather` →
153
+ // `@lib:1`, or `@town:1~lock` → `@town:1`) and splice THAT, which is the
154
+ // very corruption being prevented. Rejecting the whole match here leaves
155
+ // the token literal, exactly as the character/location finders do.
156
+ //
157
+ // `/` alone is NOT the signal — `@town:1/@barn:2` (two mentions separated
158
+ // by a slash) must keep matching, and a location segment always starts
159
+ // `[a-z]`. So the guard is `/` + a segment start.
160
+ if (/^\/[a-z]/.test(prompt.slice(offset + token.length))) continue
161
+ const parsed = parseMentionToken(token)
162
+ if (parsed && knownSet.has(parsed.slug)) {
163
+ tokens.push({ token, ...parsed, offset })
164
+ }
165
+ }
166
+ return tokens
167
+ }
@@ -2223,9 +2223,9 @@ const AUDIO_MODELS: Record<string, ModelCatalogEntry> = {
2223
2223
  family: "ElevenLabs",
2224
2224
  label: "ElevenLabs Dubbing",
2225
2225
  series: "ElevenLabs",
2226
- description: "Translate + dub a video into a new language. Async.",
2226
+ description: "Translate + dub audio or a whole video into a new language — video in, dubbed video out. Async.",
2227
2227
  useCases: ["dubbing", "multilingual"],
2228
- pricing: [{ identifier: "elevenlabs-dubbing", credits: 80 }],
2228
+ pricing: [{ identifier: "elevenlabs-dubbing", credits: 40, note: "per minute of the dubbed span (min 1)" }],
2229
2229
  },
2230
2230
  "elevenlabs-forced-alignment": {
2231
2231
  id: "elevenlabs-forced-alignment",