@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,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
|
+
}
|
package/src/model-catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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",
|