@nodaro/prompts 1.10.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.
Files changed (35) hide show
  1. package/dist/index.cjs +693 -53
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1084 -27
  4. package/dist/index.d.ts +1084 -27
  5. package/dist/index.js +664 -55
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
  9. package/src/__tests__/animal-getters-parity.test.ts +82 -0
  10. package/src/__tests__/assemble-image-input-cap.test.ts +212 -0
  11. package/src/__tests__/assemble-image-input.test.ts +93 -3
  12. package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
  13. package/src/__tests__/assemble-video-input.test.ts +301 -0
  14. package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
  15. package/src/__tests__/direction-registry.test.ts +393 -0
  16. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  17. package/src/__tests__/image-convergence-image.test.ts +370 -0
  18. package/src/__tests__/location-convergence-image.test.ts +29 -1
  19. package/src/__tests__/location-default-role-image.test.ts +166 -0
  20. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  21. package/src/__tests__/read-node-direction.test.ts +154 -0
  22. package/src/__tests__/read-node-subject.test.ts +140 -0
  23. package/src/__tests__/subject-fold.test.ts +232 -0
  24. package/src/__tests__/subject-registry.test.ts +312 -0
  25. package/src/assemble-image-input.ts +160 -58
  26. package/src/assemble-video-input.ts +244 -0
  27. package/src/direction-registry.ts +371 -0
  28. package/src/hint-shedding.ts +68 -0
  29. package/src/index.ts +10 -2
  30. package/src/parameter-prompt-hint.ts +8 -7
  31. package/src/picker-catalogs.ts +14 -7
  32. package/src/prompt-builder.ts +728 -58
  33. package/src/prompt-hint-join.ts +30 -0
  34. package/src/read-node-direction.ts +233 -0
  35. package/src/subject-registry.ts +464 -0
@@ -12,6 +12,8 @@ import { usageModeDirective, DEFAULT_USAGE_MODE, type UsageMode } from "@nodaro/
12
12
  import { roleToPhrase, defaultRoleForSource, REFERENCE_ROLE_PRESETS, normalizeRoleSlug, resolveDefaultRole } from "@nodaro/shared"
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
+ import { findImageMentionTokens, imageMentionSlugForRef, knownImageSlugsFromRefs, type ImageMentionTokenInfo } from "@nodaro/shared"
16
+ import { findEntityMentionTokens, entityMentionSlugForRef, knownEntitySlugsFromRefs, type EntityMentionTokenInfo } from "@nodaro/shared"
15
17
  import type { CharacterDef, ConnectedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
16
18
  import { locationReferencePhotoKindLabel, type LocationReferencePhotoKind } from "@nodaro/shared"
17
19
 
@@ -205,6 +207,56 @@ export function resolveCharacterMentions(
205
207
  return { prompt: resolvedPrompt, additionalUrls, mentionedCharacterSlugs }
206
208
  }
207
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
+
208
260
  interface ResolveCharacterMentionsHybridResult {
209
261
  /** Body with each `@`-mention replaced INLINE by its role phrase
210
262
  * ("the {role} from reference image {LETTER}"). No directive block. */
@@ -248,6 +300,7 @@ interface ResolveCharacterMentionsHybridResult {
248
300
  * parsed as a variant slug (e.g. `@kira:1:person`) still attaches the canonical
249
301
  * reference instead of being dropped (the legacy resolver skips variant misses).
250
302
  */
303
+
251
304
  function resolveCharacterMentionsHybrid(
252
305
  prompt: string,
253
306
  tokens: readonly CharacterMentionTokenInfo[],
@@ -326,12 +379,12 @@ function resolveCharacterMentionsHybrid(
326
379
  return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
327
380
  }
328
381
 
329
- // 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.
330
384
  let resolvedPrompt = prompt
331
385
  for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
332
386
  const phrase = roleToPhrase(m.role, bindingFor(m.url))
333
- resolvedPrompt =
334
- resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
387
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
335
388
  }
336
389
 
337
390
  // One identity-lock + one element directive per UNIQUE attached URL (a
@@ -398,28 +451,111 @@ function locationModeDirective(mode: LocationUsageMode): string | null {
398
451
  * `roleToPhrase` renders into "the {role} from reference image {LETTER}". The
399
452
  * location analog of the character mention's role segment, for the 4-mode
400
453
  * location enum:
401
- * - identical → "background" (lock the scene to this image)
402
- * - style → "style" (borrow look / mood / palette)
403
- * - layout → "layout" (borrow compositional framing)
404
- * - 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.
405
473
  *
406
474
  * Accepts a loose `string` so a `ConnectedReference.defaultUsageMode` (typed as
407
475
  * the CHARACTER `UsageMode`, which can't express `"layout"`) flows in without a
408
476
  * cast — unknown / non-location modes fall through to the safe source default
409
- * instead of throwing. The three real roles are members of
477
+ * instead of throwing. The two mode-specific roles are members of
410
478
  * `REFERENCE_ROLE_PRESETS["wired-location"]`, so the phrasing stays curated.
411
479
  */
412
480
  function locationModeToRole(mode: string | null | undefined): string {
413
- switch (mode) {
414
- case "identical":
415
- return "background"
416
- case "style":
417
- return "style"
418
- case "layout":
419
- return "layout"
420
- default:
421
- return defaultRoleForSource("wired-location")
422
- }
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)
423
559
  }
424
560
 
425
561
  /**
@@ -594,9 +730,12 @@ interface ResolveLocationMentionsHybridResult {
594
730
  * fallback on a variant miss) so the set of attached URLs / matched tokens never
595
731
  * diverges between formats — only the rendered phrasing differs.
596
732
  *
597
- * ROLE is `locationModeToRole(mode)` with `mode = perMentionOverride ?? the
598
- * location node's defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE` — so a node
599
- * 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 …".
600
739
  *
601
740
  * SLOT/LETTER: a URL's 1-based position in `dedup([...existingUrls,
602
741
  * ...mentionUrls])`. The caller passes `existingUrls = [base refs, resolved
@@ -644,15 +783,12 @@ function resolveLocationMentionsHybrid(
644
783
  mentionedLocationSlugs.add(t.locationSlug)
645
784
  refByUrl.set(match.url, match)
646
785
  if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
647
- const mode = t.usageMode ?? match.defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE
648
786
  // A bare-slug ROLE (Unified Reference Roles, Phase D — e.g. `background`,
649
787
  // `empty-background`, `as-is`, or a curated custom role) is used VERBATIM:
650
- // it's acting as a role, not selecting a bucket/variant. `t.role` is the
651
- // token slug; map it back to the phrase key so `roleToPhrase` hits the
652
- // non-noun specials (`empty-background` → `empty background`). With no role
653
- // segment, derive the role from the usage mode (mode-aware default) —
654
- // byte-identical to the prior behavior for every non-role mention.
655
- 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)
656
792
  matched.push({ token: t.token, offset: t.offset, url: match.url, role })
657
793
  }
658
794
 
@@ -667,12 +803,12 @@ function resolveLocationMentionsHybrid(
667
803
  return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
668
804
  }
669
805
 
670
- // 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.
671
808
  let resolvedPrompt = prompt
672
809
  for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
673
810
  const phrase = roleToPhrase(m.role, bindingFor(m.url))
674
- resolvedPrompt =
675
- resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
811
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
676
812
  }
677
813
 
678
814
  // One opt-in lock + one element directive per UNIQUE attached URL.
@@ -694,6 +830,313 @@ function resolveLocationMentionsHybrid(
694
830
  return { prompt: resolvedPrompt, additionalUrls, mentionedLocationSlugs, lockLines, elementDirectives }
695
831
  }
696
832
 
833
+ interface ResolveImageMentionsHybridResult {
834
+ /** Body with each `@<image-name>` mention replaced INLINE by its role phrase
835
+ * ("the {role} from reference image {LETTER}", or the bare binding when the
836
+ * role resolves to the media default `""`). No directive block. */
837
+ prompt: string
838
+ /** Matched image URLs in mention order (deduped by the caller). */
839
+ additionalUrls: string[]
840
+ /** NO `mentionedImageSlugs` — the location result's analog exists to FILTER
841
+ * mentioned refs out of `connectedReferences`, and this pass deliberately
842
+ * does no such filtering (see the caller's NOTE). Carrying the set anyway
843
+ * would advertise a filter that does not exist. */
844
+ /** Per-reference identity-lock lines (deduped per URL). Caller prepends them
845
+ * as ONE block, merged with the character/location lock lines. */
846
+ lockLines: string[]
847
+ /** Non-empty `elementInjection` fragments (deduped per URL). Caller appends
848
+ * them as trailing scene directives. */
849
+ elementDirectives: string[]
850
+ }
851
+
852
+ /**
853
+ * HYBRID named-image mention convergence (P3). The media analog of
854
+ * `resolveLocationMentionsHybrid`, for `wired-image` / `manual` references
855
+ * addressed by the slug of their `defaultName` (an upload node's label on the
856
+ * canvas, or the name a thin client puts on the reference).
857
+ *
858
+ * ASYMMETRY vs. characters and locations: a media ref AUTO-ATTACHES its URL
859
+ * through the New path whether or not it is mentioned, and carries NO canonical
860
+ * prose. So a mention here is BINDING + RE-SEATING, never attach-gating — which
861
+ * is why the caller does NOT filter mentioned refs out of `connectedReferences`
862
+ * the way the location pass does.
863
+ *
864
+ * NO LEGACY COUNTERPART. The Phase-0 arm that reaches this is hybrid-gated, so
865
+ * an `@name:N` token under the legacy reference format stays literal text and
866
+ * the ref attaches exactly as it does today (the `resolveLocationMentions`
867
+ * role-token precedent, where `if (t.role) continue` leaves the token alone).
868
+ *
869
+ * DUPLICATE SLUGS: FIRST WINS (`if (!bySlug.has(...))`), matching
870
+ * `buildTileIdForUrl`. Every unrenamed upload node shares its default label, so
871
+ * ties are the common case, not an edge.
872
+ *
873
+ * ROLE precedence: the per-mention 3rd segment (VERBATIM — media role presets
874
+ * are all single-word, so `normalizeRoleSlug` is location-only and must NOT be
875
+ * called here) → the node default via `resolveDefaultRole` → `""`, which
876
+ * `roleToPhrase` renders as the BARE binding ("reference image C"), i.e. today's
877
+ * ref-only default with a name attached to it.
878
+ *
879
+ * CAPPED REFS: `imageReferenceLimit(provider)` truncates `connectedReferences`
880
+ * BEFORE Phase 0, so a mention whose ref was capped out silently falls through
881
+ * as literal text — matching how a capped character mention behaves today.
882
+ */
883
+ function resolveImageMentionsHybrid(
884
+ prompt: string,
885
+ tokens: readonly ImageMentionTokenInfo[],
886
+ refs: readonly ConnectedReference[],
887
+ existingUrls: readonly string[],
888
+ ): ResolveImageMentionsHybridResult {
889
+ const bySlug = new Map<string, ConnectedReference>()
890
+ for (const r of refs) {
891
+ // `imageMentionSlugForRef` is the SAME predicate `knownImageSlugsFromRefs`
892
+ // applies to build the finder's known-slug set — one gate, so this map and
893
+ // that set can never admit different refs. (It also drops extras, which
894
+ // render through `renderExtraRefsHybrid` with their own body lines, and
895
+ // grammar-invalid slugs, where emptiness is NOT the gate.)
896
+ const slug = imageMentionSlugForRef(r)
897
+ if (!slug) continue
898
+ if (!bySlug.has(slug)) bySlug.set(slug, r)
899
+ }
900
+
901
+ const additionalUrls: string[] = []
902
+ const refByUrl = new Map<string, ConnectedReference>()
903
+ // Per-mention `~lock` / `~nolock`: the tri-state lock OVERRIDE per attached
904
+ // URL, fed to `withForcedIdentityLock` below. Only sentinel-bearing mentions
905
+ // write here (last sentinel wins), mirroring the location resolver.
906
+ const lockOverrideByUrl = new Map<string, boolean>()
907
+ const matched: Array<{ token: string; offset: number; url: string; role: string }> = []
908
+
909
+ for (const t of tokens) {
910
+ const match = bySlug.get(t.imageSlug)
911
+ if (!match || !match.url) continue
912
+ additionalUrls.push(match.url)
913
+ refByUrl.set(match.url, match)
914
+ if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
915
+ const role = (t.role ?? "").trim()
916
+ || resolveDefaultRole(match.defaultRole, match.defaultUsageMode, match.source)
917
+ matched.push({ token: t.token, offset: t.offset, url: match.url, role })
918
+ }
919
+
920
+ // Slot letters from the deduped [existing, mention] URL list — the prefix of
921
+ // the caller's `finalIndexByUrl`, so the letters agree.
922
+ const slotByUrl = new Map<string, number>()
923
+ for (const u of [...existingUrls, ...additionalUrls]) {
924
+ if (!slotByUrl.has(u)) slotByUrl.set(u, slotByUrl.size + 1)
925
+ }
926
+ const bindingFor = (url: string): string => {
927
+ const slot = slotByUrl.get(url)
928
+ return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
929
+ }
930
+
931
+ // Replace mention tokens right-to-left so earlier offsets stay valid;
932
+ // `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
933
+ let resolvedPrompt = prompt
934
+ for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
935
+ const phrase = roleToPhrase(m.role, bindingFor(m.url))
936
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
937
+ }
938
+
939
+ // One lock + one element directive per UNIQUE attached URL.
940
+ const lockLines: string[] = []
941
+ const elementDirectives: string[] = []
942
+ const seenUrls = new Set<string>()
943
+ for (const m of matched) {
944
+ if (seenUrls.has(m.url)) continue
945
+ seenUrls.add(m.url)
946
+ const ref = refByUrl.get(m.url)
947
+ if (!ref) continue
948
+ const binding = bindingFor(m.url)
949
+ const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
950
+ if (lock) lockLines.push(lock)
951
+ const inject = ref.elementInjection?.trim()
952
+ if (inject) elementDirectives.push(inject)
953
+ }
954
+
955
+ return { prompt: resolvedPrompt, additionalUrls, lockLines, elementDirectives }
956
+ }
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
+
697
1140
  /**
698
1141
  * Build the canonical-fallback directive lines + URLs for wired characters
699
1142
  * that were NOT @-mentioned in the prompt. Matches the pre-mention behavior:
@@ -1054,8 +1497,9 @@ function renderExtraRefsHybrid(
1054
1497
  * Render the hybrid canonical convergence for UNMENTIONED wired locations (the
1055
1498
  * location analog of `renderCanonicalFallbackHybrid`). Each unmentioned
1056
1499
  * `wired-location` ref → the inline role phrase
1057
- * `roleToPhrase(locationModeToRole(defaultUsageMode), binding)` (the caller
1058
- * 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
1059
1503
  * (null unless the ref enables one — locations have no built-in lock wording) +
1060
1504
  * its wired `elementInjection` as a trailing directive.
1061
1505
  *
@@ -1089,7 +1533,10 @@ function renderLocationCanonicalHybrid(
1089
1533
  if (!slot) continue
1090
1534
  seenUrls.add(r.url)
1091
1535
  const binding = `reference image ${slotToLetter(slot)}`
1092
- 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))
1093
1540
  const lock = buildIdentityLockLine(r, binding)
1094
1541
  if (lock) lockLines.push(lock)
1095
1542
  const inject = r.elementInjection?.trim()
@@ -1110,11 +1557,19 @@ function renderLocationCanonicalHybrid(
1110
1557
  * has wording but it is OFF unless `identityLock.enabled === true`, per Plan A's
1111
1558
  * default-off flip) + its wired `elementInjection` as a trailing directive.
1112
1559
  *
1113
- * Objects/creatures have NO `@-mention` path, so the ONLY way one renders inline
1114
- * is an `{image:N}` token. `coveredUrls` (the URLs already expanded by such a
1115
- * token in this scene) is threaded in so a wired object/creature that is BOTH
1116
- * unmentioned AND `{image:N}`-referenced renders ONCE (inline), never also as a
1117
- * 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).
1118
1573
  */
1119
1574
  function renderObjectCreatureCanonicalHybrid(
1120
1575
  nonCharacterRefs: readonly ConnectedReference[],
@@ -1495,6 +1950,12 @@ export interface BuildImagePromptConfig {
1495
1950
  * (`flux-lora-character`) — the trigger word + LoRA carries identity, so
1496
1951
  * the directive bullets are redundant and the wired-character refs
1497
1952
  * shouldn't be injected as `Image N`.
1953
+ *
1954
+ * The strip regex is grammar-agnostic — it eats LOCATION and named-IMAGE
1955
+ * mentions (`@town:3:background`) as well as character ones. That is
1956
+ * INTENDED, not an oversight: this path drops `connectedReferences` outright,
1957
+ * so there is no reference left for any mention to bind to and a surviving
1958
+ * token would reach the model as literal noise. Do not narrow it.
1498
1959
  */
1499
1960
  skipCharacterMentions?: boolean
1500
1961
  }
@@ -1539,12 +2000,18 @@ export interface BuildImagePromptSegmentsResult extends BuildImagePromptResult {
1539
2000
  * - `bodyBeforeSuffixes`: the prompt string captured immediately BEFORE the
1540
2001
  * style/avoid suffixes were appended (covers the common no-truncation case).
1541
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.
1542
2008
  */
1543
2009
  interface AssemblyMarks {
1544
2010
  directivesPrefix: string
1545
2011
  bodyBeforeSuffixes: string
1546
2012
  styleSuffix: string
1547
2013
  avoidSuffix: string
2014
+ overflowChars: number
1548
2015
  }
1549
2016
 
1550
2017
  /** Keep `bodySegments` when the assembled body still equals their join;
@@ -1571,6 +2038,47 @@ export function buildImagePrompt(config: BuildImagePromptConfig): BuildImageProm
1571
2038
  return buildImagePromptInternal(config)
1572
2039
  }
1573
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
+
1574
2082
  /**
1575
2083
  * Shared assembly body for `buildImagePrompt` + `buildImagePromptSegments`.
1576
2084
  * When `marks` is provided, records the directive-prefix / body / suffix spans
@@ -1606,6 +2114,14 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1606
2114
  // line-initials (which would corrupt "the face from reference image A" → "The
1607
2115
  // face …" / "the style from reference image A" → "The style …").
1608
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>()
1609
2125
 
1610
2126
  // Character LoRA inference path: trigger word + LoRA model carry identity,
1611
2127
  // so we strip raw `@slug[:V[:variant]]` tokens from the prompt AND drop the
@@ -1714,7 +2230,37 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1714
2230
  .filter((s): s is string => typeof s === "string" && s.length > 0)
1715
2231
  )
1716
2232
  )
1717
- if (knownCharacterSlugs.length > 0 || hasExtraRefs || knownLocationSlugs.length > 0) {
2233
+ // Named-image mentions (P3). Slugs derive from each media ref's
2234
+ // `defaultName` — there is no `imageSlug` wire field; the derivation is
2235
+ // shared with the backend orchestrator's structured-branch gate via
2236
+ // `knownImageSlugsFromRefs`, so the two can never disagree about whether a
2237
+ // prompt carries a resolvable image mention.
2238
+ const knownImageSlugs = knownImageSlugsFromRefs(connectedReferences)
2239
+ // EXISTENCE-ONLY precheck, gating the Phase-0 arm below. Unlike characters
2240
+ // (whose canonical fallback must run even with ZERO mentions), images have
2241
+ // NO canonical fallback, so an image-only graph with no mention has no
2242
+ // reason to enter Phase 0 — gating on TOKEN presence keeps today's control
2243
+ // flow and byte output for every unmentioned graph BY CONSTRUCTION.
2244
+ // HYBRID-only: under the legacy format an `@name:N` token stays literal text.
2245
+ const hasImageMentionTokens = isHybrid
2246
+ && knownImageSlugs.length > 0
2247
+ && findImageMentionTokens(config.prompt, knownImageSlugs).length > 0
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) {
1718
2264
  const mentionTokens = knownCharacterSlugs.length > 0
1719
2265
  ? findCharacterMentionTokens(config.prompt, knownCharacterSlugs)
1720
2266
  : []
@@ -1806,6 +2352,85 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1806
2352
  if (r.locationVariantBucket) return false
1807
2353
  return !mentionedLocationSlugs.has(r.locationSlug)
1808
2354
  })
2355
+ // Pass 3 (P3): named-image mentions, resolved on the POST-character,
2356
+ // POST-location prompt. The finder MUST re-run here — the earlier passes
2357
+ // spliced their own tokens out, so any offset computed before them is
2358
+ // stale. PRECEDENCE character → location → image falls out of this pass
2359
+ // order: a name shared by a character and an image resolves as the
2360
+ // character, and the image token never fires.
2361
+ //
2362
+ // `existingUrls` is already location-inclusive because `additionalUrls`
2363
+ // absorbed the location URLs just above, so the mention slot letters stay
2364
+ // a prefix of the final `finalIndexByUrl`.
2365
+ let hybridImageLockLines: string[] = []
2366
+ let hybridImageElementDirectives: string[] = []
2367
+ if (hasImageMentionTokens) {
2368
+ const imageTokens = findImageMentionTokens(resolved.prompt, knownImageSlugs)
2369
+ if (imageTokens.length > 0) {
2370
+ const hi = resolveImageMentionsHybrid(
2371
+ resolved.prompt,
2372
+ imageTokens,
2373
+ connectedReferences,
2374
+ [...(referenceImageUrls || []), ...resolved.additionalUrls],
2375
+ )
2376
+ resolved.prompt = hi.prompt
2377
+ resolved.additionalUrls = [...resolved.additionalUrls, ...hi.additionalUrls]
2378
+ hybridImageLockLines = hi.lockLines
2379
+ hybridImageElementDirectives = hi.elementDirectives
2380
+ // Inline role phrases now live in the body → skip line-initial
2381
+ // capitalization, which would otherwise corrupt a mid-sentence
2382
+ // "the background from reference image C".
2383
+ if (hi.additionalUrls.length > 0) hybridBodyConverged = true
2384
+ }
2385
+ }
2386
+ // NOTE — deliberately NO `connectedReferences` filter for mentioned image
2387
+ // refs (the location pass filters, just above). The New-path URL merge
2388
+ // dedups by URL so the ref keeps its earlier Phase-0 slot; wired-image /
2389
+ // manual refs have no canonical-fallback prose to double-emit (that loop
2390
+ // is gated to wired-location/object/creature); and leaving the ref in
2391
+ // place keeps `nonCharacterRefs[N-1]` positional indexing stable for
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
+ }
1809
2434
  // Default-fallback canonical URLs + directives for any wired character
1810
2435
  // that has zero mentions in the prompt. Mirrors the legacy behavior the
1811
2436
  // mention feature replaced — wire a character with no typing required.
@@ -1944,12 +2569,16 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1944
2569
  const allLockLines = [...new Set([
1945
2570
  ...hybridLockLines,
1946
2571
  ...hybridLocationLockLines,
2572
+ ...hybridImageLockLines,
2573
+ ...hybridEntityLockLines,
1947
2574
  ...canonical.lockLines,
1948
2575
  ...extrasRendered.lockLines,
1949
2576
  ])]
1950
2577
  const trailingLines = [
1951
2578
  ...hybridElementDirectives,
1952
2579
  ...hybridLocationElementDirectives,
2580
+ ...hybridImageElementDirectives,
2581
+ ...hybridEntityElementDirectives,
1953
2582
  ...canonical.phrases,
1954
2583
  ...canonical.elementDirectives,
1955
2584
  ...extrasRendered.bodyLines,
@@ -2080,10 +2709,22 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2080
2709
  // (Phase C): each → the trailing role phrase "the {role} from reference
2081
2710
  // image {LETTER}" + opt-in lock + wired elementInjection, numbered against
2082
2711
  // `finalIndexByUrl`. Mentioned locations were converged inline in Phase 0
2083
- // and filtered out of `nonCharacterRefs`; objects/creatures have no mention
2084
- // 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.
2085
2715
  const locCanon = renderLocationCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, tokenCoveredUrls)
2086
- 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)
2087
2728
  const canonLockLines = [...locCanon.lockLines, ...objCanon.lockLines]
2088
2729
  const canonLockBlock = canonLockLines.length > 0 ? `${canonLockLines.join("\n")}\n\n` : ""
2089
2730
  const canonTrailingLines = [
@@ -2160,9 +2801,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2160
2801
  // the `Avoid:` negative whenever the body was long. The style/avoid suffixes
2161
2802
  // are RESERVED first so a long body can never drop them (the control text is
2162
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.
2163
2806
  const maxLen = getMaxImagePromptChars(provider)
2164
2807
  const reserved = styleSuffix.length + avoidSuffix.length
2165
2808
  if (prompt.length + reserved > maxLen) {
2809
+ if (marks) marks.overflowChars += prompt.length + reserved - maxLen
2166
2810
  prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
2167
2811
  }
2168
2812
  // Body span = everything after the captured directive prefix, taken from the
@@ -2173,6 +2817,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2173
2817
  prompt = prompt + styleSuffix + avoidSuffix
2174
2818
  // Safety clamp for a pathological native-less negative that alone overflows.
2175
2819
  if (prompt.length > maxLen) {
2820
+ if (marks) marks.overflowChars += prompt.length - maxLen
2176
2821
  prompt = prompt.slice(0, maxLen - 3) + "..."
2177
2822
  }
2178
2823
 
@@ -2268,10 +2913,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2268
2913
  // Cap at the provider max (default IMAGE_PROMPT_MAX = 5000), reserving the
2269
2914
  // style/avoid suffixes so a long body never severs the appended control text
2270
2915
  // (was a hardcoded 2000 tail-cut that dropped the negative). Truncate the
2271
- // BODY, THEN append the suffixes.
2916
+ // BODY, THEN append the suffixes. Same order-blind cut as the
2917
+ // connectedReferences path above → same `overflowChars` bookkeeping.
2272
2918
  const maxLen = getMaxImagePromptChars(provider)
2273
2919
  const reserved = styleSuffix.length + avoidSuffix.length
2274
2920
  if (prompt.length + reserved > maxLen) {
2921
+ if (marks) marks.overflowChars += prompt.length + reserved - maxLen
2275
2922
  prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
2276
2923
  }
2277
2924
  // Legacy path has no directive prefix; the body is the char-desc-wrapped
@@ -2280,6 +2927,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2280
2927
  prompt = prompt + styleSuffix + avoidSuffix
2281
2928
  // Safety clamp for a pathological native-less negative that alone overflows.
2282
2929
  if (prompt.length > maxLen) {
2930
+ if (marks) marks.overflowChars += prompt.length - maxLen
2283
2931
  prompt = prompt.slice(0, maxLen - 3) + "..."
2284
2932
  }
2285
2933
 
@@ -2312,12 +2960,7 @@ export function buildImagePromptSegments(
2312
2960
  config: BuildImagePromptConfig,
2313
2961
  bodySegments?: readonly PromptSegment[],
2314
2962
  ): BuildImagePromptSegmentsResult {
2315
- const marks: AssemblyMarks = {
2316
- directivesPrefix: "",
2317
- bodyBeforeSuffixes: "",
2318
- styleSuffix: "",
2319
- avoidSuffix: "",
2320
- }
2963
+ const marks = newAssemblyMarks()
2321
2964
 
2322
2965
  const result = buildImagePromptInternal(config, marks)
2323
2966
 
@@ -2700,21 +3343,48 @@ function hybridRolePhrase(label: string, letter: string): string {
2700
3343
  return `the ${label} from reference image ${letter}`
2701
3344
  }
2702
3345
 
2703
- /** Like `expandImageRefTokensForRefs`, but emits the hybrid lettered phrase
2704
- * ("the subject from reference image A") instead of the legacy "Image N
2705
- * (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
+ */
2706
3362
  function expandImageRefTokensHybrid(
2707
3363
  prompt: string,
2708
3364
  refs: readonly ConnectedReference[],
2709
3365
  finalIndexByUrl: ReadonlyMap<string, number>,
2710
3366
  ): string {
2711
- return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
2712
- 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)
2713
3370
  const ref = refs[n - 1]
2714
3371
  const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
2715
- if (!finalIdx) return match
2716
- return hybridRolePhrase((label ?? "").trim(), slotToLetter(finalIdx))
2717
- })
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
2718
3388
  }
2719
3389
 
2720
3390
  /** Capitalize the first alphabetic character of a string (line-initial). */