@nodaro/prompts 1.11.0 → 1.12.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.
@@ -13,6 +13,7 @@ import { roleToPhrase, defaultRoleForSource, REFERENCE_ROLE_PRESETS, normalizeRo
13
13
  import { buildIdentityLockLine, withForcedIdentityLock } from "./identity-lock.js"
14
14
  import { findLocationMentionTokens, DEFAULT_LOCATION_USAGE_MODE, type LocationMentionTokenInfo, type LocationUsageMode } from "@nodaro/shared"
15
15
  import { findImageMentionTokens, imageMentionSlugForRef, knownImageSlugsFromRefs, type ImageMentionTokenInfo } from "@nodaro/shared"
16
+ import { findEntityMentionTokens, entityMentionSlugForRef, knownEntitySlugsFromRefs, type EntityMentionTokenInfo } from "@nodaro/shared"
16
17
  import type { CharacterDef, ConnectedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
17
18
  import { locationReferencePhotoKindLabel, type LocationReferencePhotoKind } from "@nodaro/shared"
18
19
 
@@ -206,6 +207,56 @@ export function resolveCharacterMentions(
206
207
  return { prompt: resolvedPrompt, additionalUrls, mentionedCharacterSlugs }
207
208
  }
208
209
 
210
+ /**
211
+ * Splice one resolved mention phrase in for its token, collapsing the horizontal
212
+ * whitespace ON EITHER SIDE OF THE SEAM to a single space. The single splice
213
+ * primitive for every HYBRID image mention splice — the four `@`-mention
214
+ * resolvers (character, location, named image, wired entity) and the
215
+ * `{image:N:label}` positional-pill expansion (`expandImageRefTokensHybrid`).
216
+ *
217
+ * WHY: an editor serializes a mention chip as its token plus its own trailing
218
+ * space, and the prose that follows the chip carries the space the author typed
219
+ * — so a perfectly ordinary sentence arrives as `@panda:1 and @panda2:2 …` and
220
+ * assembles to "the person from reference image A and reference image C …",
221
+ * with the doubled space sitting exactly where the model is being told what the
222
+ * reference IS. The VIDEO core never shows this because `resolveReferenceTokens`
223
+ * runs a `[^\S\r\n]{2,}` collapse over its fully-assembled prompt on every
224
+ * return; the image path has no such tidy. This is that collapse, SCOPED to the
225
+ * seam: a doubled space the author put elsewhere in their prose is theirs to
226
+ * keep, and a prompt whose seams are already single-spaced is byte-identical
227
+ * (the run must be 2+ to match).
228
+ *
229
+ * The class is `[^\S\r\n]` (NOT `\s`) for the same reason the video tidy uses
230
+ * it: `\n` / `\n\n` separate the assembled blocks, and a `\s`-based collapse
231
+ * would silently merge paragraphs.
232
+ *
233
+ * INDENTATION is structure, not a seam. The leading collapse is anchored with a
234
+ * `(?<=\S)` lookbehind so it only fires on a run that FOLLOWS prose on the same
235
+ * line. A mention that opens an indented line (`"Scene:\n @panda:1 stands"` —
236
+ * a shot list, a numbered beat sheet) keeps its indent verbatim; without the
237
+ * anchor that run IS the left seam and a 4-space indent flattened to one space,
238
+ * which is the same class of damage as merging paragraphs and would have made
239
+ * the "already-single-spaced prompts are byte-identical" claim false for every
240
+ * multi-line prompt. The trailing collapse stays unconditional: a run AFTER a
241
+ * chip is the reported bug itself and can never be indentation.
242
+ *
243
+ * OFFSET SAFETY: callers splice right-to-left over offsets taken against the
244
+ * pre-splice string. The left-hand collapse only ever removes characters from
245
+ * the whitespace run immediately preceding THIS token — positions at or after
246
+ * the end of any earlier token (tokens are non-whitespace and never overlap) —
247
+ * so every not-yet-applied (smaller) offset stays valid.
248
+ */
249
+ function spliceMentionPhrase(
250
+ prompt: string,
251
+ offset: number,
252
+ tokenLength: number,
253
+ phrase: string,
254
+ ): string {
255
+ const before = prompt.slice(0, offset).replace(/(?<=\S)[^\S\r\n]{2,}$/, " ")
256
+ const after = prompt.slice(offset + tokenLength).replace(/^[^\S\r\n]{2,}/, " ")
257
+ return before + phrase + after
258
+ }
259
+
209
260
  interface ResolveCharacterMentionsHybridResult {
210
261
  /** Body with each `@`-mention replaced INLINE by its role phrase
211
262
  * ("the {role} from reference image {LETTER}"). No directive block. */
@@ -249,6 +300,7 @@ interface ResolveCharacterMentionsHybridResult {
249
300
  * parsed as a variant slug (e.g. `@kira:1:person`) still attaches the canonical
250
301
  * reference instead of being dropped (the legacy resolver skips variant misses).
251
302
  */
303
+
252
304
  function resolveCharacterMentionsHybrid(
253
305
  prompt: string,
254
306
  tokens: readonly CharacterMentionTokenInfo[],
@@ -327,12 +379,12 @@ function resolveCharacterMentionsHybrid(
327
379
  return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
328
380
  }
329
381
 
330
- // Replace mention tokens right-to-left so earlier offsets stay valid.
382
+ // Replace mention tokens right-to-left so earlier offsets stay valid;
383
+ // `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
331
384
  let resolvedPrompt = prompt
332
385
  for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
333
386
  const phrase = roleToPhrase(m.role, bindingFor(m.url))
334
- resolvedPrompt =
335
- resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
387
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
336
388
  }
337
389
 
338
390
  // One identity-lock + one element directive per UNIQUE attached URL (a
@@ -399,28 +451,111 @@ function locationModeDirective(mode: LocationUsageMode): string | null {
399
451
  * `roleToPhrase` renders into "the {role} from reference image {LETTER}". The
400
452
  * location analog of the character mention's role segment, for the 4-mode
401
453
  * location enum:
402
- * - identical → "background" (lock the scene to this image)
403
- * - style → "style" (borrow look / mood / palette)
404
- * - layout → "layout" (borrow compositional framing)
405
- * - none / undefined / anything else → the source default (`"background"`).
454
+ * - identical → the source default (`"location"`) — lock the scene to this image
455
+ * - style → "style" (borrow look / mood / palette)
456
+ * - layout → "layout" (borrow compositional framing)
457
+ * - none / undefined / anything else → the source default (`"location"`).
458
+ *
459
+ * A PLACE, NOT A BACKDROP — `identical` used to map to `"background"`, which is
460
+ * the exact word `DEFAULT_LABEL_BY_SOURCE["wired-location"]` stopped emitting on
461
+ * 2026-08-05 after it was measured harmful: `roleToPhrase` renders it as "the
462
+ * background from reference image B", and image models read that as *paste this
463
+ * behind the subject* — on gpt-image-2 (character + location, 4 draws per arm,
464
+ * only the role word varying) every `background` draw came back a cut-out
465
+ * composite with no shared light and no ground contact, while `location` put the
466
+ * subject inside the scene. That fix changed the SOURCE default but missed this
467
+ * branch, so `identical` — the DEFAULT location usage mode
468
+ * (`DEFAULT_LOCATION_USAGE_MODE`) — kept handing every un-roled location mention
469
+ * the harmful word. Both defaults now read from `defaultRoleForSource`, so the
470
+ * source default is the single source of truth and the two cannot drift again.
471
+ * `"background"` stays a curated pick in `REFERENCE_ROLE_PRESETS` for the genuine
472
+ * backdrop case — an explicit `@lib:1:background` token is untouched.
406
473
  *
407
474
  * Accepts a loose `string` so a `ConnectedReference.defaultUsageMode` (typed as
408
475
  * the CHARACTER `UsageMode`, which can't express `"layout"`) flows in without a
409
476
  * cast — unknown / non-location modes fall through to the safe source default
410
- * instead of throwing. The three real roles are members of
477
+ * instead of throwing. The two mode-specific roles are members of
411
478
  * `REFERENCE_ROLE_PRESETS["wired-location"]`, so the phrasing stays curated.
412
479
  */
413
480
  function locationModeToRole(mode: string | null | undefined): string {
414
- switch (mode) {
415
- case "identical":
416
- return "background"
417
- case "style":
418
- return "style"
419
- case "layout":
420
- return "layout"
421
- default:
422
- return defaultRoleForSource("wired-location")
423
- }
481
+ return roleBearingLocationMode(mode) ?? defaultRoleForSource("wired-location")
482
+ }
483
+
484
+ /**
485
+ * The ROLE a location usage mode expresses, or `null` when the mode expresses no
486
+ * role opinion at all. Splits the 4-mode enum the way the character chain splits
487
+ * `USAGE_MODES`: `style` / `layout` SAY what the reference is, while `identical`
488
+ * (which IS `DEFAULT_LOCATION_USAGE_MODE`) and `none` are directive-only — they
489
+ * say how the LEGACY bullet reads, not what the model should take from the
490
+ * image. Only a role-bearing mode may outrank a ref-level `defaultRole`; see
491
+ * `resolveLocationRole`.
492
+ *
493
+ * Data-driven rather than a hardcoded pair: a mode is role-bearing exactly when
494
+ * it is itself a member of `REFERENCE_ROLE_PRESETS["wired-location"]` — the same
495
+ * test the location pill runs (`LOCATION_ROLE_PRESETS.includes(attrs.usageMode)`)
496
+ * to decide whether a mode-slot value should surface as the pill's hybrid role,
497
+ * and the same shape as the character resolver's `presets.includes(segment)`. A
498
+ * mode added to both lists later is role-bearing on its own, with no list here
499
+ * to remember to update.
500
+ */
501
+ function roleBearingLocationMode(mode: string | null | undefined): string | null {
502
+ const m = mode?.trim()
503
+ if (!m) return null
504
+ return REFERENCE_ROLE_PRESETS["wired-location"].includes(m) ? m : null
505
+ }
506
+
507
+ /**
508
+ * The effective HYBRID role for a wired LOCATION reference — the location analog
509
+ * of `resolveDefaultRole`, and the single source of truth for the location role
510
+ * chain (read by the mention resolver AND the canonical-fallback renderer, so a
511
+ * location cannot phrase itself one way mentioned and another way unmentioned).
512
+ *
513
+ * Precedence:
514
+ * 1. the per-mention token ROLE (`@lib:1:atmosphere`, `@lib:1:x/y:lighting`) —
515
+ * verbatim, slug-normalized so the non-noun specials still hit
516
+ * (`empty-background` → `empty background`).
517
+ * 2. the per-mention token MODE, but ONLY when it is ROLE-BEARING
518
+ * (`@lib:1:style`, `@lib:1:layout` — `roleBearingLocationMode`).
519
+ * 3. the ref's own `defaultRole` — the node/caller's hybrid role pick, same
520
+ * slug normalization as (1).
521
+ * 4. the ref's legacy `defaultUsageMode` → `locationModeToRole`.
522
+ * 5. the source default (`locationModeToRole`'s fallback — `"location"`).
523
+ *
524
+ * Step 2 is GATED for the same reason the character chain gates its segment on
525
+ * `presets.includes(segment)`: `identical` and `none` express no role opinion —
526
+ * `identical` IS `DEFAULT_LOCATION_USAGE_MODE`, the un-roled state, and the
527
+ * location pill's `renderText` emits a mode segment whenever the attr is set, so
528
+ * an ungated step 2 would let a round-tripped `@lib:1:identical` suppress the
529
+ * very `defaultRole` this chain exists to honor — on the majority of real
530
+ * tokens. A directive-only mode therefore falls THROUGH to the ref's role, the
531
+ * same way a character's `identical` / `name` / `none` segment falls through to
532
+ * `resolveDefaultRole`. With no `defaultRole` the outcome is unchanged
533
+ * (`identical` → step 4 → the source default, `"location"`).
534
+ *
535
+ * Step 3 is what this exists for. `ConnectedReference.defaultRole` is on the
536
+ * wire schema for EVERY source, and the character and named-image mention paths
537
+ * have always consulted it via `resolveDefaultRole` — but the location paths
538
+ * read only the usage mode, so a ref-level default role was silently dropped.
539
+ * It is the ONLY channel a location has for a custom default role: a location
540
+ * mention's 3rd segment is a bucket/variant or a role, so a caller cannot pin a
541
+ * per-mention role AND keep the canonical image, and the character trick of a
542
+ * 4th segment needs a variant to sit in the third. An explicit token role or
543
+ * role-bearing mode still wins (steps 1–2); with no `defaultRole` the chain
544
+ * collapses to exactly the old `t.usageMode ?? defaultUsageMode ?? DEFAULT`
545
+ * derivation.
546
+ */
547
+ function resolveLocationRole(
548
+ tokenRole: string | null | undefined,
549
+ tokenUsageMode: string | null | undefined,
550
+ ref: Pick<ConnectedReference, "defaultRole" | "defaultUsageMode">,
551
+ ): string {
552
+ const explicitRole = tokenRole?.trim()
553
+ if (explicitRole) return normalizeRoleSlug(explicitRole)
554
+ const modeRole = roleBearingLocationMode(tokenUsageMode)
555
+ if (modeRole) return modeRole
556
+ const nodeRole = ref.defaultRole?.trim()
557
+ if (nodeRole) return normalizeRoleSlug(nodeRole)
558
+ return locationModeToRole(ref.defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE)
424
559
  }
425
560
 
426
561
  /**
@@ -595,9 +730,12 @@ interface ResolveLocationMentionsHybridResult {
595
730
  * fallback on a variant miss) so the set of attached URLs / matched tokens never
596
731
  * diverges between formats — only the rendered phrasing differs.
597
732
  *
598
- * ROLE is `locationModeToRole(mode)` with `mode = perMentionOverride ?? the
599
- * location node's defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE` — so a node
600
- * whose default is "style" renders "the style from …" for a bare `@old-library:1`.
733
+ * ROLE is `resolveLocationRole(t.role, t.usageMode, ref)` — per-mention role →
734
+ * per-mention ROLE-BEARING mode (`style` / `layout`; the directive-only
735
+ * `identical` / `none` fall through) → the ref's own `defaultRole` → its
736
+ * `defaultUsageMode` → the source default. So a node whose default mode is
737
+ * "style" renders "the style from …" for a bare `@old-library:1`, and one
738
+ * carrying `defaultRole: "atmosphere"` renders "the atmosphere from …".
601
739
  *
602
740
  * SLOT/LETTER: a URL's 1-based position in `dedup([...existingUrls,
603
741
  * ...mentionUrls])`. The caller passes `existingUrls = [base refs, resolved
@@ -645,15 +783,12 @@ function resolveLocationMentionsHybrid(
645
783
  mentionedLocationSlugs.add(t.locationSlug)
646
784
  refByUrl.set(match.url, match)
647
785
  if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
648
- const mode = t.usageMode ?? match.defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE
649
786
  // A bare-slug ROLE (Unified Reference Roles, Phase D — e.g. `background`,
650
787
  // `empty-background`, `as-is`, or a curated custom role) is used VERBATIM:
651
- // it's acting as a role, not selecting a bucket/variant. `t.role` is the
652
- // token slug; map it back to the phrase key so `roleToPhrase` hits the
653
- // non-noun specials (`empty-background` → `empty background`). With no role
654
- // segment, derive the role from the usage mode (mode-aware default) —
655
- // byte-identical to the prior behavior for every non-role mention.
656
- const role = t.role ? normalizeRoleSlug(t.role) : locationModeToRole(mode)
788
+ // it's acting as a role, not selecting a bucket/variant. With no role
789
+ // segment, the chain falls through the token's ROLE-BEARING mode, then the
790
+ // REF's own `defaultRole`, then its usage mode — see `resolveLocationRole`.
791
+ const role = resolveLocationRole(t.role, t.usageMode, match)
657
792
  matched.push({ token: t.token, offset: t.offset, url: match.url, role })
658
793
  }
659
794
 
@@ -668,12 +803,12 @@ function resolveLocationMentionsHybrid(
668
803
  return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
669
804
  }
670
805
 
671
- // Replace mention tokens right-to-left so earlier offsets stay valid.
806
+ // Replace mention tokens right-to-left so earlier offsets stay valid;
807
+ // `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
672
808
  let resolvedPrompt = prompt
673
809
  for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
674
810
  const phrase = roleToPhrase(m.role, bindingFor(m.url))
675
- resolvedPrompt =
676
- resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
811
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
677
812
  }
678
813
 
679
814
  // One opt-in lock + one element directive per UNIQUE attached URL.
@@ -793,12 +928,12 @@ function resolveImageMentionsHybrid(
793
928
  return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
794
929
  }
795
930
 
796
- // Replace mention tokens right-to-left so earlier offsets stay valid.
931
+ // Replace mention tokens right-to-left so earlier offsets stay valid;
932
+ // `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
797
933
  let resolvedPrompt = prompt
798
934
  for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
799
935
  const phrase = roleToPhrase(m.role, bindingFor(m.url))
800
- resolvedPrompt =
801
- resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
936
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
802
937
  }
803
938
 
804
939
  // One lock + one element directive per UNIQUE attached URL.
@@ -820,6 +955,188 @@ function resolveImageMentionsHybrid(
820
955
  return { prompt: resolvedPrompt, additionalUrls, lockLines, elementDirectives }
821
956
  }
822
957
 
958
+ interface ResolveEntityMentionsHybridResult {
959
+ /** Body with each `@<entity-name>` mention replaced INLINE by its role phrase
960
+ * ("the creature from reference image D", "the material from reference image
961
+ * B", …). No directive block. */
962
+ prompt: string
963
+ /** Matched entity URLs in mention order (deduped by the caller). */
964
+ additionalUrls: string[]
965
+ /**
966
+ * The URLs a mention BOUND — fed into `renderObjectCreatureCanonicalHybrid`'s
967
+ * `coveredUrls` so the ref's trailing canonical phrase is SUPPRESSED. This is
968
+ * the whole point of the pass: without it the same reference would render
969
+ * twice, once inline and once as the dangling trailing line the mention exists
970
+ * to replace. Distinct from `additionalUrls` only in being a set — kept as its
971
+ * own field so the suppression contract is explicit at both ends.
972
+ */
973
+ mentionedUrls: Set<string>
974
+ /** Per-reference identity-lock lines (deduped per URL). Caller prepends them
975
+ * as ONE block, merged with the character/location/image lock lines. */
976
+ lockLines: string[]
977
+ /** Non-empty `elementInjection` fragments (deduped per URL). Caller appends
978
+ * them as trailing scene directives. */
979
+ elementDirectives: string[]
980
+ }
981
+
982
+ /**
983
+ * HYBRID wired-entity mention convergence — the `wired-creature` /
984
+ * `wired-object` analog of `resolveImageMentionsHybrid`, for entities addressed
985
+ * by the slug of their `defaultName`.
986
+ *
987
+ * THE BUG THIS KILLS. An unmentioned creature renders through
988
+ * `renderObjectCreatureCanonicalHybrid` as a TRAILING phrase — "the creature
989
+ * from reference image D" — appended after the scene and the style hints, while
990
+ * the creature's name sits in the prose as plain text the model has no reason to
991
+ * connect to a reference. A mention binds the two: the phrase renders INLINE at
992
+ * the typed position, and this pass's `mentionedUrls` suppresses the trailing
993
+ * fallback for that ref.
994
+ *
995
+ * ASYMMETRY vs. images, and why suppression is needed here but not there: a
996
+ * media ref carries NO canonical prose, so the image pass has nothing to
997
+ * suppress. A creature/object DOES, so this pass must hand its bound URLs to the
998
+ * canonical renderer's `coveredUrls` — exactly the mechanism that already stops
999
+ * an `{image:N}`-token-referenced entity from double-rendering.
1000
+ *
1001
+ * NOT a `connectedReferences` FILTER, matching the image pass and unlike the
1002
+ * location pass: the ref stays in the list so `nonCharacterRefs[N-1]` positional
1003
+ * indexing for `{image:N}` tokens is unperturbed and the New-path URL merge keeps
1004
+ * the ref's earlier Phase-0 slot (the merge dedups by URL). Suppression is
1005
+ * coveredUrls-only.
1006
+ *
1007
+ * PRECEDENCE. The caller runs this AFTER the character, location and image
1008
+ * passes, each of which has already spliced its own tokens out — so a name shared
1009
+ * with an earlier kind never reaches this pass (character → location → image →
1010
+ * creature → object). The creature-before-object tail is settled HERE, in the
1011
+ * slug → ref map: creature refs are inserted first, so a name claimed by both a
1012
+ * creature and an object binds the CREATURE.
1013
+ *
1014
+ * DUPLICATE SLUGS within a kind: FIRST WINS, matching the image pass.
1015
+ *
1016
+ * ROLE precedence: the per-mention 3rd segment (VERBATIM — both entity preset
1017
+ * lists are single-word, so `normalizeRoleSlug` stays location-only and must NOT
1018
+ * be called here) → the ref's own `defaultRole`, then its `defaultUsageMode`, via
1019
+ * the SHARED `resolveDefaultRole` helper → the SOURCE default (`"creature"` /
1020
+ * `"object"`).
1021
+ *
1022
+ * ONE HELPER, NOT A COPY. `resolveDefaultRole` is the same chain the character
1023
+ * and named-image resolvers run, and `ConnectedReference.defaultRole` is a wire
1024
+ * field on EVERY source (see its doc in `types.ts`), so an entity ref's role pick
1025
+ * is honored here for free. The LOCATION paths need their own `resolveLocationRole`
1026
+ * only because a location token carries a MODE segment whose role-bearing members
1027
+ * (`style` / `layout`) sit between the token role and the ref's `defaultRole`, and
1028
+ * because location roles are slug-normalized; an entity token has no mode segment
1029
+ * (`EntityMentionTokenInfo` is role + lock only) and no multi-word roles, so there
1030
+ * is no gate to replicate and an entity analog of that helper would be two copies
1031
+ * of one chain.
1032
+ *
1033
+ * That LAST step is the one `renderObjectCreatureCanonicalHybrid` would also have
1034
+ * used for the trailing line — but only that one: the canonical renderer reads
1035
+ * `defaultRoleForSource(r.source)` and ignores `defaultRole` / `defaultUsageMode`
1036
+ * entirely. So a bare `@nessie:4` relocates the IDENTICAL phrase only when the ref
1037
+ * carries neither node field; with a node default the mention honors it and the
1038
+ * phrase CHANGES ("the anatomy from …" rather than "the creature from …"). That is
1039
+ * deliberate — it is what `resolveCharacterMentionsHybrid` and
1040
+ * `resolveImageMentionsHybrid` do, and it is pinned by
1041
+ * `entity-convergence-image.test.ts`. Suppression is
1042
+ * unaffected either way: the mention pass covers the same URL and emits the same
1043
+ * lock line (modulo a `~lock`/`~nolock` sentinel) and the same `elementInjection`.
1044
+ *
1045
+ * NO LEGACY COUNTERPART — the image-grammar precedent. The Phase-0 arm that
1046
+ * reaches this is hybrid-gated, so under the legacy reference format an
1047
+ * `@name:N` token stays literal text and the entity attaches with its numbered
1048
+ * directive exactly as it does today.
1049
+ *
1050
+ * CAPPED REFS: `imageReferenceLimit(provider)` truncates `connectedReferences`
1051
+ * BEFORE Phase 0, so a mention whose ref was capped out silently falls through as
1052
+ * literal text — matching how a capped character or image mention behaves today.
1053
+ */
1054
+ function resolveEntityMentionsHybrid(
1055
+ prompt: string,
1056
+ tokens: readonly EntityMentionTokenInfo[],
1057
+ refs: readonly ConnectedReference[],
1058
+ existingUrls: readonly string[],
1059
+ ): ResolveEntityMentionsHybridResult {
1060
+ const bySlug = new Map<string, ConnectedReference>()
1061
+ // Creature-first insertion IS the creature → object half of the precedence
1062
+ // chain (the other four steps are the caller's pass order). `entityMentionSlug
1063
+ // ForRef` is the SAME predicate `knownEntitySlugsFromRefs` applies to build the
1064
+ // finder's known-slug set — one gate, so this map and that set can never admit
1065
+ // different refs.
1066
+ for (const source of ["wired-creature", "wired-object"] as const) {
1067
+ for (const r of refs) {
1068
+ if (r.source !== source) continue
1069
+ const slug = entityMentionSlugForRef(r)
1070
+ if (!slug) continue
1071
+ if (!bySlug.has(slug)) bySlug.set(slug, r)
1072
+ }
1073
+ }
1074
+
1075
+ const additionalUrls: string[] = []
1076
+ const mentionedUrls = new Set<string>()
1077
+ const refByUrl = new Map<string, ConnectedReference>()
1078
+ // Per-mention `~lock` / `~nolock`: the tri-state lock OVERRIDE per attached
1079
+ // URL, fed to `withForcedIdentityLock` below. Only sentinel-bearing mentions
1080
+ // write here (last sentinel wins), mirroring the image/location resolvers.
1081
+ const lockOverrideByUrl = new Map<string, boolean>()
1082
+ const matched: Array<{ token: string; offset: number; url: string; role: string }> = []
1083
+
1084
+ for (const t of tokens) {
1085
+ const match = bySlug.get(t.entitySlug)
1086
+ if (!match || !match.url) continue
1087
+ additionalUrls.push(match.url)
1088
+ mentionedUrls.add(match.url)
1089
+ refByUrl.set(match.url, match)
1090
+ if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
1091
+ const role = (t.role ?? "").trim()
1092
+ || resolveDefaultRole(match.defaultRole, match.defaultUsageMode, match.source)
1093
+ matched.push({ token: t.token, offset: t.offset, url: match.url, role })
1094
+ }
1095
+
1096
+ // Slot letters from the deduped [existing, mention] URL list — the prefix of
1097
+ // the caller's `finalIndexByUrl`, so the letters agree.
1098
+ const slotByUrl = new Map<string, number>()
1099
+ for (const u of [...existingUrls, ...additionalUrls]) {
1100
+ if (!slotByUrl.has(u)) slotByUrl.set(u, slotByUrl.size + 1)
1101
+ }
1102
+ const bindingFor = (url: string): string => {
1103
+ const slot = slotByUrl.get(url)
1104
+ return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
1105
+ }
1106
+
1107
+ // Replace mention tokens right-to-left so earlier offsets stay valid;
1108
+ // `spliceMentionPhrase` also tidies the horizontal whitespace at each seam —
1109
+ // an entity chip serializes with its own trailing space exactly like every
1110
+ // other mention chip, so its seams get the same collapse as the character,
1111
+ // location and named-image ones.
1112
+ let resolvedPrompt = prompt
1113
+ for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
1114
+ const phrase = roleToPhrase(m.role, bindingFor(m.url))
1115
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
1116
+ }
1117
+
1118
+ // One lock + one element directive per UNIQUE attached URL. These are the
1119
+ // SAME lines `renderObjectCreatureCanonicalHybrid` would have emitted for the
1120
+ // ref, moved here — which is why suppressing its canonical entry does not lose
1121
+ // an opt-in lock or a wired `elementInjection`.
1122
+ const lockLines: string[] = []
1123
+ const elementDirectives: string[] = []
1124
+ const seenUrls = new Set<string>()
1125
+ for (const m of matched) {
1126
+ if (seenUrls.has(m.url)) continue
1127
+ seenUrls.add(m.url)
1128
+ const ref = refByUrl.get(m.url)
1129
+ if (!ref) continue
1130
+ const binding = bindingFor(m.url)
1131
+ const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
1132
+ if (lock) lockLines.push(lock)
1133
+ const inject = ref.elementInjection?.trim()
1134
+ if (inject) elementDirectives.push(inject)
1135
+ }
1136
+
1137
+ return { prompt: resolvedPrompt, additionalUrls, mentionedUrls, lockLines, elementDirectives }
1138
+ }
1139
+
823
1140
  /**
824
1141
  * Build the canonical-fallback directive lines + URLs for wired characters
825
1142
  * that were NOT @-mentioned in the prompt. Matches the pre-mention behavior:
@@ -1180,8 +1497,9 @@ function renderExtraRefsHybrid(
1180
1497
  * Render the hybrid canonical convergence for UNMENTIONED wired locations (the
1181
1498
  * location analog of `renderCanonicalFallbackHybrid`). Each unmentioned
1182
1499
  * `wired-location` ref → the inline role phrase
1183
- * `roleToPhrase(locationModeToRole(defaultUsageMode), binding)` (the caller
1184
- * appends these as trailing scene directives) + its opt-in identity-lock line
1500
+ * `roleToPhrase(resolveLocationRole(null, null, ref), binding)` — the SAME role
1501
+ * chain the mention path runs, minus the token steps (the caller appends these as
1502
+ * trailing scene directives) + its opt-in identity-lock line
1185
1503
  * (null unless the ref enables one — locations have no built-in lock wording) +
1186
1504
  * its wired `elementInjection` as a trailing directive.
1187
1505
  *
@@ -1215,7 +1533,10 @@ function renderLocationCanonicalHybrid(
1215
1533
  if (!slot) continue
1216
1534
  seenUrls.add(r.url)
1217
1535
  const binding = `reference image ${slotToLetter(slot)}`
1218
- phrases.push(roleToPhrase(locationModeToRole(r.defaultUsageMode), binding))
1536
+ // Same role chain as the mention path (there is no token here, so it starts
1537
+ // at the ref's own `defaultRole`) — one helper, so a location cannot phrase
1538
+ // itself one way mentioned and another way unmentioned.
1539
+ phrases.push(roleToPhrase(resolveLocationRole(null, null, r), binding))
1219
1540
  const lock = buildIdentityLockLine(r, binding)
1220
1541
  if (lock) lockLines.push(lock)
1221
1542
  const inject = r.elementInjection?.trim()
@@ -1236,11 +1557,19 @@ function renderLocationCanonicalHybrid(
1236
1557
  * has wording but it is OFF unless `identityLock.enabled === true`, per Plan A's
1237
1558
  * default-off flip) + its wired `elementInjection` as a trailing directive.
1238
1559
  *
1239
- * Objects/creatures have NO `@-mention` path, so the ONLY way one renders inline
1240
- * is an `{image:N}` token. `coveredUrls` (the URLs already expanded by such a
1241
- * token in this scene) is threaded in so a wired object/creature that is BOTH
1242
- * unmentioned AND `{image:N}`-referenced renders ONCE (inline), never also as a
1243
- * trailing canonical phrase. Deduped per URL (an object wired twice → one phrase).
1560
+ * `coveredUrls` is every URL that ALREADY rendered inline in this scene, and it
1561
+ * carries TWO contributions the caller unions together: the URLs expanded by an
1562
+ * `{image:N}` token, and — since the creature/object mention leg — the URLs bound
1563
+ * by a Phase-0 `@creature` / `@object` mention. Either way the ref renders ONCE,
1564
+ * inline, never also as a trailing canonical phrase.
1565
+ *
1566
+ * The mention half is why this loop is NOT reached via a `connectedReferences`
1567
+ * filter the way mentioned locations are: an entity ref stays in the list to keep
1568
+ * `nonCharacterRefs[N-1]` positional `{image:N}` indexing stable, so suppression
1569
+ * has to happen HERE, per URL. The mention pass emits the same lock line and
1570
+ * `elementInjection` this loop would have, so nothing is lost by skipping it.
1571
+ *
1572
+ * Deduped per URL (an object wired twice → one phrase).
1244
1573
  */
1245
1574
  function renderObjectCreatureCanonicalHybrid(
1246
1575
  nonCharacterRefs: readonly ConnectedReference[],
@@ -1671,12 +2000,18 @@ export interface BuildImagePromptSegmentsResult extends BuildImagePromptResult {
1671
2000
  * - `bodyBeforeSuffixes`: the prompt string captured immediately BEFORE the
1672
2001
  * style/avoid suffixes were appended (covers the common no-truncation case).
1673
2002
  * - `styleSuffix` / `avoidSuffix`: the `\nStyle: …` / `\nAvoid: …` strings.
2003
+ * - `overflowChars`: how many characters the provider cap forced OFF the tail
2004
+ * (0 when the assembled prompt fit). Accumulated across every clamp site in
2005
+ * both assembly paths. Read by {@link buildImagePromptWithOverflow} so a
2006
+ * cap-aware caller can shed its own lowest-value text and re-assemble
2007
+ * instead of letting the order-blind tail cut decide.
1674
2008
  */
1675
2009
  interface AssemblyMarks {
1676
2010
  directivesPrefix: string
1677
2011
  bodyBeforeSuffixes: string
1678
2012
  styleSuffix: string
1679
2013
  avoidSuffix: string
2014
+ overflowChars: number
1680
2015
  }
1681
2016
 
1682
2017
  /** Keep `bodySegments` when the assembled body still equals their join;
@@ -1703,6 +2038,47 @@ export function buildImagePrompt(config: BuildImagePromptConfig): BuildImageProm
1703
2038
  return buildImagePromptInternal(config)
1704
2039
  }
1705
2040
 
2041
+ /** {@link buildImagePrompt} plus `overflowChars`. */
2042
+ export interface BuildImagePromptOverflowResult extends BuildImagePromptResult {
2043
+ /**
2044
+ * Characters the provider's prompt cap forced off the tail — `0` when the
2045
+ * assembled prompt fit. This is the amount of text that must LEAVE the body
2046
+ * for the prompt to fit without a tail cut, so a caller can shed exactly
2047
+ * enough of its own droppable text and re-assemble.
2048
+ */
2049
+ overflowChars: number
2050
+ }
2051
+
2052
+ /**
2053
+ * `buildImagePrompt` plus the size of the cap overflow it had to clamp away.
2054
+ *
2055
+ * The prompt (and every other field) is BYTE-IDENTICAL to `buildImagePrompt` —
2056
+ * the marks channel only observes. Exists because the tail clamp is ORDER-BLIND:
2057
+ * it cuts whatever happens to be last, which on a low-cap provider can sever a
2058
+ * reference directive or the user's own prose while a decorative hint clause
2059
+ * survives. `assembleImageInput` uses this to drop its lowest-value text
2060
+ * (direction-folded hint clauses) FIRST and re-assemble; the clamp then stays
2061
+ * the last-resort fallback for a body that overflows on prose alone.
2062
+ */
2063
+ export function buildImagePromptWithOverflow(
2064
+ config: BuildImagePromptConfig,
2065
+ ): BuildImagePromptOverflowResult {
2066
+ const marks = newAssemblyMarks()
2067
+ const result = buildImagePromptInternal(config, marks)
2068
+ return { ...result, overflowChars: marks.overflowChars }
2069
+ }
2070
+
2071
+ /** Fresh zeroed marks — one construction site so a new field can't be forgotten. */
2072
+ function newAssemblyMarks(): AssemblyMarks {
2073
+ return {
2074
+ directivesPrefix: "",
2075
+ bodyBeforeSuffixes: "",
2076
+ styleSuffix: "",
2077
+ avoidSuffix: "",
2078
+ overflowChars: 0,
2079
+ }
2080
+ }
2081
+
1706
2082
  /**
1707
2083
  * Shared assembly body for `buildImagePrompt` + `buildImagePromptSegments`.
1708
2084
  * When `marks` is provided, records the directive-prefix / body / suffix spans
@@ -1738,6 +2114,14 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1738
2114
  // line-initials (which would corrupt "the face from reference image A" → "The
1739
2115
  // face …" / "the style from reference image A" → "The style …").
1740
2116
  let hybridBodyConverged = false
2117
+ // URLs bound by a Phase-0 wired-entity (`@creature` / `@object`) mention.
2118
+ // Declared at function scope because it must BRIDGE the two blocks below:
2119
+ // Phase 0 populates it, and the New path unions it into
2120
+ // `renderObjectCreatureCanonicalHybrid`'s `coveredUrls` so a mentioned
2121
+ // creature/object does NOT also emit its trailing canonical phrase. Empty for
2122
+ // every prompt without an entity mention, which is what keeps those outputs
2123
+ // byte-identical.
2124
+ const mentionedEntityUrls = new Set<string>()
1741
2125
 
1742
2126
  // Character LoRA inference path: trigger word + LoRA model carry identity,
1743
2127
  // so we strip raw `@slug[:V[:variant]]` tokens from the prompt AND drop the
@@ -1861,7 +2245,22 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1861
2245
  const hasImageMentionTokens = isHybrid
1862
2246
  && knownImageSlugs.length > 0
1863
2247
  && findImageMentionTokens(config.prompt, knownImageSlugs).length > 0
1864
- if (knownCharacterSlugs.length > 0 || hasExtraRefs || knownLocationSlugs.length > 0 || hasImageMentionTokens) {
2248
+ // Wired-entity mentions (`wired-creature` / `wired-object`). Slugs derive
2249
+ // from each entity ref's `defaultName` — no `entitySlug` wire field, matching
2250
+ // the image grammar — and the derivation is shared with the backend
2251
+ // orchestrator's structured-branch gate via `knownEntitySlugsFromRefs`.
2252
+ const knownEntitySlugs = knownEntitySlugsFromRefs(connectedReferences)
2253
+ // EXISTENCE-ONLY precheck, gating the Phase-0 arm below — the image
2254
+ // precedent. An unmentioned creature/object already renders correctly
2255
+ // through `renderObjectCreatureCanonicalHybrid`, so a graph with no entity
2256
+ // mention has no reason to enter Phase 0: gating on TOKEN presence keeps
2257
+ // today's control flow and byte output for every such graph BY
2258
+ // CONSTRUCTION. HYBRID-only — under the legacy format an `@name:N` token
2259
+ // stays literal text.
2260
+ const hasEntityMentionTokens = isHybrid
2261
+ && knownEntitySlugs.length > 0
2262
+ && findEntityMentionTokens(config.prompt, knownEntitySlugs).length > 0
2263
+ if (knownCharacterSlugs.length > 0 || hasExtraRefs || knownLocationSlugs.length > 0 || hasImageMentionTokens || hasEntityMentionTokens) {
1865
2264
  const mentionTokens = knownCharacterSlugs.length > 0
1866
2265
  ? findCharacterMentionTokens(config.prompt, knownCharacterSlugs)
1867
2266
  : []
@@ -1991,6 +2390,47 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1991
2390
  // is gated to wired-location/object/creature); and leaving the ref in
1992
2391
  // place keeps `nonCharacterRefs[N-1]` positional indexing stable for
1993
2392
  // `{image:N}` tokens.
2393
+ //
2394
+ // Pass 4: wired-entity (`@creature` / `@object`) mentions, resolved on the
2395
+ // POST-character, POST-location, POST-image prompt. The finder MUST re-run
2396
+ // here — every earlier pass spliced its own tokens out, so an offset
2397
+ // computed before them is stale. PRECEDENCE character → location → image →
2398
+ // creature → object falls out of this pass order plus the creature-first
2399
+ // map inside the resolver: a name shared with an earlier kind resolves as
2400
+ // that kind, and the entity token never fires.
2401
+ //
2402
+ // `existingUrls` is already location- AND image-inclusive because
2403
+ // `additionalUrls` absorbed both above, so the mention slot letters stay a
2404
+ // prefix of the final `finalIndexByUrl`.
2405
+ let hybridEntityLockLines: string[] = []
2406
+ let hybridEntityElementDirectives: string[] = []
2407
+ if (hasEntityMentionTokens) {
2408
+ const entityTokens = findEntityMentionTokens(resolved.prompt, knownEntitySlugs)
2409
+ if (entityTokens.length > 0) {
2410
+ const he = resolveEntityMentionsHybrid(
2411
+ resolved.prompt,
2412
+ entityTokens,
2413
+ connectedReferences,
2414
+ [...(referenceImageUrls || []), ...resolved.additionalUrls],
2415
+ )
2416
+ resolved.prompt = he.prompt
2417
+ resolved.additionalUrls = [...resolved.additionalUrls, ...he.additionalUrls]
2418
+ hybridEntityLockLines = he.lockLines
2419
+ hybridEntityElementDirectives = he.elementDirectives
2420
+ // The suppression handoff to the New path: every bound URL is skipped
2421
+ // by `renderObjectCreatureCanonicalHybrid`, so the phrase renders ONCE
2422
+ // — inline, where the user typed it — instead of also dangling as a
2423
+ // trailing "the creature from reference image D" line. Like the image
2424
+ // pass, the ref itself is NOT filtered out of `connectedReferences`
2425
+ // (positional `{image:N}` stability); coveredUrls is the whole
2426
+ // mechanism.
2427
+ for (const u of he.mentionedUrls) mentionedEntityUrls.add(u)
2428
+ // Inline role phrases now live in the body → skip line-initial
2429
+ // capitalization, which would otherwise corrupt a mid-sentence
2430
+ // "the creature from reference image D".
2431
+ if (he.additionalUrls.length > 0) hybridBodyConverged = true
2432
+ }
2433
+ }
1994
2434
  // Default-fallback canonical URLs + directives for any wired character
1995
2435
  // that has zero mentions in the prompt. Mirrors the legacy behavior the
1996
2436
  // mention feature replaced — wire a character with no typing required.
@@ -2130,6 +2570,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2130
2570
  ...hybridLockLines,
2131
2571
  ...hybridLocationLockLines,
2132
2572
  ...hybridImageLockLines,
2573
+ ...hybridEntityLockLines,
2133
2574
  ...canonical.lockLines,
2134
2575
  ...extrasRendered.lockLines,
2135
2576
  ])]
@@ -2137,6 +2578,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2137
2578
  ...hybridElementDirectives,
2138
2579
  ...hybridLocationElementDirectives,
2139
2580
  ...hybridImageElementDirectives,
2581
+ ...hybridEntityElementDirectives,
2140
2582
  ...canonical.phrases,
2141
2583
  ...canonical.elementDirectives,
2142
2584
  ...extrasRendered.bodyLines,
@@ -2267,10 +2709,22 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2267
2709
  // (Phase C): each → the trailing role phrase "the {role} from reference
2268
2710
  // image {LETTER}" + opt-in lock + wired elementInjection, numbered against
2269
2711
  // `finalIndexByUrl`. Mentioned locations were converged inline in Phase 0
2270
- // and filtered out of `nonCharacterRefs`; objects/creatures have no mention
2271
- // path, so their only inline route is an `{image:N}` token (guarded above).
2712
+ // and filtered out of `nonCharacterRefs`; mentioned objects/creatures were
2713
+ // converged inline too but stay in the list, so they are suppressed by URL
2714
+ // via the covered set below.
2272
2715
  const locCanon = renderLocationCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, tokenCoveredUrls)
2273
- const objCanon = renderObjectCreatureCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, tokenCoveredUrls)
2716
+ // Objects/creatures now have a mention path too, so their covered set is
2717
+ // the union of the `{image:N}`-token URLs and the URLs a Phase-0
2718
+ // `@creature` / `@object` mention bound. That union is the SUPPRESSION —
2719
+ // a mentioned entity already rendered its role phrase inline, and without
2720
+ // this it would ALSO emit the trailing "the creature from reference image
2721
+ // D" line, which is the exact double-render this leg removes. Locations
2722
+ // keep `tokenCoveredUrls` alone: their mentioned refs are FILTERED out of
2723
+ // `nonCharacterRefs` upstream, so they never reach the loop at all.
2724
+ const objCoveredUrls = mentionedEntityUrls.size > 0
2725
+ ? new Set([...tokenCoveredUrls, ...mentionedEntityUrls])
2726
+ : tokenCoveredUrls
2727
+ const objCanon = renderObjectCreatureCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, objCoveredUrls)
2274
2728
  const canonLockLines = [...locCanon.lockLines, ...objCanon.lockLines]
2275
2729
  const canonLockBlock = canonLockLines.length > 0 ? `${canonLockLines.join("\n")}\n\n` : ""
2276
2730
  const canonTrailingLines = [
@@ -2347,9 +2801,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2347
2801
  // the `Avoid:` negative whenever the body was long. The style/avoid suffixes
2348
2802
  // are RESERVED first so a long body can never drop them (the control text is
2349
2803
  // the most important to keep). Truncate the BODY, THEN append the suffixes.
2804
+ // The cut is ORDER-BLIND — record how much it removed so a cap-aware caller
2805
+ // (`assembleImageInput`) can shed its own lowest-value text and re-assemble.
2350
2806
  const maxLen = getMaxImagePromptChars(provider)
2351
2807
  const reserved = styleSuffix.length + avoidSuffix.length
2352
2808
  if (prompt.length + reserved > maxLen) {
2809
+ if (marks) marks.overflowChars += prompt.length + reserved - maxLen
2353
2810
  prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
2354
2811
  }
2355
2812
  // Body span = everything after the captured directive prefix, taken from the
@@ -2360,6 +2817,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2360
2817
  prompt = prompt + styleSuffix + avoidSuffix
2361
2818
  // Safety clamp for a pathological native-less negative that alone overflows.
2362
2819
  if (prompt.length > maxLen) {
2820
+ if (marks) marks.overflowChars += prompt.length - maxLen
2363
2821
  prompt = prompt.slice(0, maxLen - 3) + "..."
2364
2822
  }
2365
2823
 
@@ -2455,10 +2913,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2455
2913
  // Cap at the provider max (default IMAGE_PROMPT_MAX = 5000), reserving the
2456
2914
  // style/avoid suffixes so a long body never severs the appended control text
2457
2915
  // (was a hardcoded 2000 tail-cut that dropped the negative). Truncate the
2458
- // BODY, THEN append the suffixes.
2916
+ // BODY, THEN append the suffixes. Same order-blind cut as the
2917
+ // connectedReferences path above → same `overflowChars` bookkeeping.
2459
2918
  const maxLen = getMaxImagePromptChars(provider)
2460
2919
  const reserved = styleSuffix.length + avoidSuffix.length
2461
2920
  if (prompt.length + reserved > maxLen) {
2921
+ if (marks) marks.overflowChars += prompt.length + reserved - maxLen
2462
2922
  prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
2463
2923
  }
2464
2924
  // Legacy path has no directive prefix; the body is the char-desc-wrapped
@@ -2467,6 +2927,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2467
2927
  prompt = prompt + styleSuffix + avoidSuffix
2468
2928
  // Safety clamp for a pathological native-less negative that alone overflows.
2469
2929
  if (prompt.length > maxLen) {
2930
+ if (marks) marks.overflowChars += prompt.length - maxLen
2470
2931
  prompt = prompt.slice(0, maxLen - 3) + "..."
2471
2932
  }
2472
2933
 
@@ -2499,12 +2960,7 @@ export function buildImagePromptSegments(
2499
2960
  config: BuildImagePromptConfig,
2500
2961
  bodySegments?: readonly PromptSegment[],
2501
2962
  ): BuildImagePromptSegmentsResult {
2502
- const marks: AssemblyMarks = {
2503
- directivesPrefix: "",
2504
- bodyBeforeSuffixes: "",
2505
- styleSuffix: "",
2506
- avoidSuffix: "",
2507
- }
2963
+ const marks = newAssemblyMarks()
2508
2964
 
2509
2965
  const result = buildImagePromptInternal(config, marks)
2510
2966
 
@@ -2887,21 +3343,48 @@ function hybridRolePhrase(label: string, letter: string): string {
2887
3343
  return `the ${label} from reference image ${letter}`
2888
3344
  }
2889
3345
 
2890
- /** Like `expandImageRefTokensForRefs`, but emits the hybrid lettered phrase
2891
- * ("the subject from reference image A") instead of the legacy "Image N
2892
- * (label)". Out-of-range tokens / URL-less refs are left untouched. */
3346
+ /**
3347
+ * Like `expandImageRefTokensForRefs`, but emits the hybrid lettered phrase
3348
+ * ("the subject from reference image A") instead of the legacy "Image N
3349
+ * (label)". Out-of-range tokens / URL-less refs are left untouched — visible so
3350
+ * the author can fix them.
3351
+ *
3352
+ * A `{image:N:label}` pill is a mention chip like any other: `buildRefPillNodes`
3353
+ * appends its own trailing space to EVERY pill it builds, the positional
3354
+ * `imageRef` node included, so `a man wearing {image:1:hat} in the park` is the
3355
+ * ordinary shape and it expanded to "…the hat from reference image A in the
3356
+ * park". Splice through `spliceMentionPhrase` for the same seam tidy the three
3357
+ * `@`-mention resolvers use, right-to-left over offsets taken against the
3358
+ * pre-splice string (its OFFSET SAFETY note covers exactly this loop). The
3359
+ * legacy `expandImageRefTokensForRefs` keeps its raw `.replace` — legacy output
3360
+ * is pinned byte-identical.
3361
+ */
2893
3362
  function expandImageRefTokensHybrid(
2894
3363
  prompt: string,
2895
3364
  refs: readonly ConnectedReference[],
2896
3365
  finalIndexByUrl: ReadonlyMap<string, number>,
2897
3366
  ): string {
2898
- return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
2899
- const n = parseInt(num, 10)
3367
+ const splices: Array<{ offset: number; length: number; phrase: string }> = []
3368
+ for (const m of prompt.matchAll(IMAGE_TOKEN_PATTERN)) {
3369
+ const n = parseInt(m[1], 10)
2900
3370
  const ref = refs[n - 1]
2901
3371
  const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
2902
- if (!finalIdx) return match
2903
- return hybridRolePhrase((label ?? "").trim(), slotToLetter(finalIdx))
2904
- })
3372
+ if (!finalIdx) continue
3373
+ splices.push({
3374
+ offset: m.index ?? 0,
3375
+ length: m[0].length,
3376
+ phrase: hybridRolePhrase((m[2] ?? "").trim(), slotToLetter(finalIdx)),
3377
+ })
3378
+ }
3379
+
3380
+ // `matchAll` yields ascending offsets; splice back-to-front so the
3381
+ // not-yet-applied (smaller) offsets stay valid.
3382
+ let resolved = prompt
3383
+ for (let i = splices.length - 1; i >= 0; i--) {
3384
+ const s = splices[i]
3385
+ resolved = spliceMentionPhrase(resolved, s.offset, s.length, s.phrase)
3386
+ }
3387
+ return resolved
2905
3388
  }
2906
3389
 
2907
3390
  /** Capitalize the first alphabetic character of a string (line-initial). */