@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.
@@ -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
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Named-image `@-mention` parser — `@<name-slug>:<index>[:<role>]`.
3
+ *
4
+ * The media analog of `character-mention-slug.ts` / `location-mention-slug.ts`,
5
+ * for a wired image (`wired-image` / `manual` reference) addressed by the slug of
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 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.
12
+ *
13
+ * @town:3 — bare mention; renders the reference's binding
14
+ * ("reference image C") at the typed position
15
+ * @town:3:background — role phrase ("the background from reference image C")
16
+ * @town:3:my-custom-role — custom roles pass through verbatim
17
+ * @town:3~lock — additive identity-lock sentinel (also `~nolock`)
18
+ * @town:1:a:b — NULL. A 4-part token is never an image mention.
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
+ *
27
+ * NO WIRE FIELD. Unlike `characterSlug` / `locationSlug`, there is no `imageSlug`
28
+ * on `ConnectedReference`: the slug is DERIVED from `defaultName` at resolve time
29
+ * (`knownImageSlugsFromRefs`), so a client cannot drift from the grammar and the
30
+ * reference schema is untouched.
31
+ *
32
+ * NO LEGACY RESOLVER. Only the hybrid reference format resolves these tokens;
33
+ * under the legacy format an `@name:N` token stays literal text and the
34
+ * reference auto-attaches exactly as it does today.
35
+ */
36
+
37
+ import type { ConnectedReference } from "./types.js"
38
+ import {
39
+ MENTION_SLUG_PATTERN,
40
+ findMentionTokens,
41
+ mentionNameSlug,
42
+ parseMentionToken,
43
+ } from "./mention-token-grammar.js"
44
+
45
+ /**
46
+ * Slugify an image reference's display name for `@`-mention tokens. Byte-
47
+ * identical algorithm to `characterMentionSlug` / `locationMentionSlug`; kept as
48
+ * a separate export to make the call site's intent explicit.
49
+ */
50
+ export function imageMentionSlug(name: string): string {
51
+ return mentionNameSlug(name)
52
+ }
53
+
54
+ export interface ImageMentionTokenInfo {
55
+ /** The matched token text, verbatim — spliced out of the prompt at resolve time. */
56
+ readonly token: string
57
+ readonly imageSlug: string
58
+ /**
59
+ * 1-based correlation index assigned at insertion by the autocomplete
60
+ * (`nextMentionIndex` = max(existing N) + 1, unified across characters,
61
+ * locations and images). The hybrid resolver binds by its own numbering walk,
62
+ * so the index is CORRELATION ONLY — it is never echoed into the prompt.
63
+ */
64
+ readonly imageIndex: number
65
+ /**
66
+ * Per-mention ROLE from the 3rd segment (`@town:3:background`) — curated
67
+ * (`REFERENCE_ROLE_PRESETS["wired-image"]`) or custom, stored VERBATIM. Media
68
+ * role presets are all single-word, so this never needs `normalizeRoleSlug`
69
+ * (the location-only remapping for multi-word presets). OMITTED (undefined,
70
+ * never null) for 2-part tokens, so those stay shape-identical to a parser
71
+ * with no role support.
72
+ */
73
+ readonly role?: string
74
+ /**
75
+ * Additive `~lock` / `~nolock` sentinel. Tri-state: `true` (force ON) |
76
+ * `false` (force OFF, suppressing a ref-level `identityLock.enabled`) |
77
+ * ABSENT/undefined (inherit the ref default). Honored only by the hybrid
78
+ * resolver — there is no legacy image resolver to make it inert on.
79
+ */
80
+ readonly lock?: boolean
81
+ /** Byte offset into the source prompt — used to splice the token out. */
82
+ readonly offset: number
83
+ }
84
+
85
+ /**
86
+ * Parse a single `@<name-slug>:<index>[:<role>]` token. Returns null when the
87
+ * token doesn't match a supported shape (the caller falls back to literal text).
88
+ *
89
+ * Segment count is 2 or 3 — NOT 2–4 like the character/location parsers. A media
90
+ * reference has no variant/bucket slot, so there is nothing for a 4th segment to
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.
97
+ */
98
+ export function parseImageMentionToken(text: string): {
99
+ imageSlug: string
100
+ imageIndex: number
101
+ /** Present ONLY for a 3-part token; omitted otherwise (shape rule). */
102
+ role?: string
103
+ /** Present ONLY when a sentinel was found; omitted otherwise (shape rule). */
104
+ lock?: boolean
105
+ } | null {
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 } : {}),
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Find every image `@-mention` in a prompt whose slug is a known image slug.
118
+ *
119
+ * `knownImageSlugs` (from `knownImageSlugsFromRefs`) is what keeps this parser
120
+ * off the other grammars' tokens — every finder matches the same `@slug:N…`
121
+ * surface and only the known-slug set separates them.
122
+ */
123
+ export function findImageMentionTokens(
124
+ prompt: string,
125
+ knownImageSlugs: readonly string[],
126
+ ): ImageMentionTokenInfo[] {
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
+ }))
135
+ }
136
+
137
+ /**
138
+ * The mention slug a single reference contributes, or `null` when the ref
139
+ * cannot carry a mention at all — the SINGLE gate, so every view of "which
140
+ * refs are mentionable" is the same view.
141
+ *
142
+ * Shared by `knownImageSlugsFromRefs` (the finder's known-slug set) and the
143
+ * prompt-builder's hybrid resolver (its slug → ref lookup map). Those two must
144
+ * admit exactly the same refs: a slug the finder accepts but the resolver drops
145
+ * would splice a token with nothing to bind, and a ref the resolver keys under
146
+ * a slug no token can match is dead weight. Emptiness is NOT the gate (see
147
+ * `MENTION_SLUG_PATTERN`).
148
+ */
149
+ export function imageMentionSlugForRef(r: ConnectedReference): string | null {
150
+ if (r.source !== "wired-image" && r.source !== "manual") return null
151
+ if (r.isExtraRef === true) return null
152
+ if (!r.url || !r.defaultName) return null
153
+ const slug = imageMentionSlug(r.defaultName)
154
+ return MENTION_SLUG_PATTERN.test(slug) ? slug : null
155
+ }
156
+
157
+ /**
158
+ * The known-image-slug set for a reference list — the SINGLE source of truth for
159
+ * the derivation, shared by `buildImagePrompt`'s Phase 0 and the backend
160
+ * orchestrator's structured-branch gate so the two can never disagree about
161
+ * whether a prompt carries a resolvable image mention.
162
+ *
163
+ * Only MEDIA refs (`wired-image` / `manual`) with a URL participate — the other
164
+ * sources have their own mention grammars: characters, locations, and — since the
165
+ * creature/object leg — wired entities via `knownEntitySlugsFromRefs`.
166
+ *
167
+ * `isExtraRef` refs are EXCLUDED: an extra renders through the extras path with
168
+ * its own body line, so letting a mention also bind one would double-emit prose.
169
+ *
170
+ * Grammar-invalid slugs are DROPPED (see `MENTION_SLUG_PATTERN`) — a ref named
171
+ * "3D Render" slugs to the non-empty but unparseable `"3d-render"`, and admitting
172
+ * it would put a slug in the set that no token can ever match.
173
+ *
174
+ * All four of those gates live in `imageMentionSlugForRef`, which the hybrid
175
+ * resolver's own lookup map uses too — one predicate, so the two views cannot
176
+ * drift apart.
177
+ */
178
+ export function knownImageSlugsFromRefs(
179
+ refs: readonly ConnectedReference[],
180
+ ): string[] {
181
+ const out = new Set<string>()
182
+ for (const r of refs) {
183
+ const slug = imageMentionSlugForRef(r)
184
+ if (slug) out.add(slug)
185
+ }
186
+ return [...out]
187
+ }
package/src/index.ts CHANGED
@@ -637,6 +637,8 @@ export {
637
637
  ANIMAL_SUBCATEGORY_ORDER,
638
638
  getAnimal,
639
639
  getAnimalLabel,
640
+ getAnimalPromptHint,
641
+ getAnimalTerm,
640
642
  } from "./animals.js"
641
643
  export type { Animal, AnimalSubcategory } from "./animals.js"
642
644
 
@@ -768,6 +770,8 @@ export type {
768
770
  WorkflowMediaRef,
769
771
  WorkflowPortability,
770
772
  WorkflowImportReport,
773
+ WorkflowImportSkippedAsset,
774
+ WorkflowAssetKind,
771
775
  } from "./workflow-export.js"
772
776
  export { stripExportContent } from "./workflow-export.js"
773
777
 
@@ -809,6 +813,29 @@ export type {
809
813
  LocationMentionTokenInfo,
810
814
  } from "./location-mention-slug.js"
811
815
 
816
+ export {
817
+ imageMentionSlug,
818
+ parseImageMentionToken,
819
+ findImageMentionTokens,
820
+ knownImageSlugsFromRefs,
821
+ imageMentionSlugForRef,
822
+ } from "./image-mention-slug.js"
823
+ export type { ImageMentionTokenInfo } from "./image-mention-slug.js"
824
+
825
+ // Wired-creature / wired-object mentions — the SAME 2-or-3-segment grammar as
826
+ // the named-image mention (both are views of `mention-token-grammar.ts`), with
827
+ // their own ref gate. The grammar core itself stays internal to the package: it
828
+ // has no standalone consumer, and a third public `@slug:N` surface would invite
829
+ // call sites that bypass a kind's gate.
830
+ export {
831
+ entityMentionSlug,
832
+ parseEntityMentionToken,
833
+ findEntityMentionTokens,
834
+ knownEntitySlugsFromRefs,
835
+ entityMentionSlugForRef,
836
+ } from "./entity-mention-slug.js"
837
+ export type { EntityMentionTokenInfo } from "./entity-mention-slug.js"
838
+
812
839
  export {
813
840
  toConnectedReference,
814
841
  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
+ }
@@ -58,6 +58,10 @@ export type ModelMode =
58
58
  | "isolation"
59
59
  | "dubbing"
60
60
  | "forced-alignment"
61
+ // dialogue — deliberately its own mode, never "tts": the dialogue model
62
+ // takes a multi-speaker script shape (inputs[]), not the single-text
63
+ // generate_speech contract, so it must never appear in a TTS model list.
64
+ | "dialogue"
61
65
  // video analysis
62
66
  | "video-analysis"
63
67
  // video audit — deliberately its own mode, never "video-analysis": the
@@ -2143,18 +2147,17 @@ const AUDIO_MODELS: Record<string, ModelCatalogEntry> = {
2143
2147
  "elevenlabs-dialogue": {
2144
2148
  id: "elevenlabs-dialogue",
2145
2149
  kind: "audio",
2146
- modes: ["tts"] as const,
2150
+ // Its own mode, never "tts": the script shape (inputs[]) doesn't fit the
2151
+ // single-text generate_speech contract, so list_models must never offer
2152
+ // it there. The dialogue-capable MCP verb is `generate_dialogue`.
2153
+ modes: ["dialogue"] as const,
2147
2154
  family: "ElevenLabs",
2148
2155
  label: "ElevenLabs Dialogue v3",
2149
2156
  series: "ElevenLabs",
2150
- description: "Multi-speaker dialogue TTS — give it a script, it voices each role.",
2157
+ description: "Multi-speaker dialogue via the direct ElevenLabs API — give it a script, it voices each role (any voice: premade, library, or cloned).",
2151
2158
  useCases: ["tts", "dialogue", "multi-speaker"],
2159
+ features: ["audio-tags", "voice-cloning"],
2152
2160
  pricing: [{ identifier: "elevenlabs-dialogue", credits: 25, note: "per 1K chars" }],
2153
- // Driven only via the dialogue/character-voice path (multi-speaker script
2154
- // shape), NOT the single-text generate_speech verb. Hide from MCP
2155
- // list_models so generate_speech (TTS_PROVIDERS) can't advertise it and
2156
- // then 400. Re-expose if a dialogue-capable MCP verb is added.
2157
- mcpHidden: true,
2158
2161
  },
2159
2162
 
2160
2163
  // ── ElevenLabs voice utilities ──
@@ -2220,9 +2223,9 @@ const AUDIO_MODELS: Record<string, ModelCatalogEntry> = {
2220
2223
  family: "ElevenLabs",
2221
2224
  label: "ElevenLabs Dubbing",
2222
2225
  series: "ElevenLabs",
2223
- 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.",
2224
2227
  useCases: ["dubbing", "multilingual"],
2225
- pricing: [{ identifier: "elevenlabs-dubbing", credits: 80 }],
2228
+ pricing: [{ identifier: "elevenlabs-dubbing", credits: 40, note: "per minute of the dubbed span (min 1)" }],
2226
2229
  },
2227
2230
  "elevenlabs-forced-alignment": {
2228
2231
  id: "elevenlabs-forced-alignment",