@nodaro/prompts 1.11.0 → 1.13.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 (36) hide show
  1. package/dist/index.cjs +627 -177
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +726 -33
  4. package/dist/index.d.ts +726 -33
  5. package/dist/index.js +598 -179
  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 +236 -0
  11. package/src/__tests__/assemble-image-input.test.ts +100 -19
  12. package/src/__tests__/assemble-video-input-cap.test.ts +442 -0
  13. package/src/__tests__/assemble-video-input.test.ts +167 -33
  14. package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
  15. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  16. package/src/__tests__/location-convergence-image.test.ts +29 -1
  17. package/src/__tests__/location-default-role-image.test.ts +166 -0
  18. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  19. package/src/__tests__/prompt-style-section.test.ts +345 -0
  20. package/src/__tests__/read-node-subject.test.ts +140 -0
  21. package/src/__tests__/style-section-boundary.test.ts +179 -0
  22. package/src/__tests__/subject-fold.test.ts +251 -0
  23. package/src/__tests__/subject-registry.test.ts +312 -0
  24. package/src/assemble-image-input.ts +169 -41
  25. package/src/assemble-video-input.ts +200 -25
  26. package/src/direction-registry.ts +116 -28
  27. package/src/hint-shedding.ts +87 -0
  28. package/src/index.ts +3 -0
  29. package/src/parameter-prompt-hint.ts +8 -7
  30. package/src/picker-catalogs.ts +14 -7
  31. package/src/prompt-builder.ts +628 -88
  32. package/src/prompt-hint-join.ts +9 -0
  33. package/src/prompt-style-section.ts +256 -0
  34. package/src/read-node-direction.ts +60 -1
  35. package/src/subject-registry.ts +464 -0
  36. package/src/video-reference-resolver.ts +5 -2
@@ -7,12 +7,20 @@
7
7
  import { resolveTemplate, applyTemplate } from "./prompt-templates.js"
8
8
  import { NATIVE_NEGATIVE_PROMPT_MODELS, MODELS_WITH_REFERENCE_IMAGE_SUPPORT, imageReferenceLimit, getMaxImagePromptChars, getMaxNegativePromptChars } from "@nodaro/shared"
9
9
  import { getStylePromptHint } from "./style.js"
10
+ import {
11
+ STYLE_SECTION_GAP,
12
+ STYLE_SECTION_HEADER,
13
+ endsInsideStyleSection,
14
+ insertBeforeStyleSection,
15
+ splitStyleSection,
16
+ } from "./prompt-style-section.js"
10
17
  import { findCharacterMentionTokens, type CharacterMentionTokenInfo } from "@nodaro/shared"
11
18
  import { usageModeDirective, DEFAULT_USAGE_MODE, type UsageMode } from "@nodaro/shared"
12
19
  import { roleToPhrase, defaultRoleForSource, REFERENCE_ROLE_PRESETS, normalizeRoleSlug, resolveDefaultRole } from "@nodaro/shared"
13
20
  import { buildIdentityLockLine, withForcedIdentityLock } from "./identity-lock.js"
14
21
  import { findLocationMentionTokens, DEFAULT_LOCATION_USAGE_MODE, type LocationMentionTokenInfo, type LocationUsageMode } from "@nodaro/shared"
15
22
  import { findImageMentionTokens, imageMentionSlugForRef, knownImageSlugsFromRefs, type ImageMentionTokenInfo } from "@nodaro/shared"
23
+ import { findEntityMentionTokens, entityMentionSlugForRef, knownEntitySlugsFromRefs, type EntityMentionTokenInfo } from "@nodaro/shared"
16
24
  import type { CharacterDef, ConnectedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
17
25
  import { locationReferencePhotoKindLabel, type LocationReferencePhotoKind } from "@nodaro/shared"
18
26
 
@@ -206,6 +214,56 @@ export function resolveCharacterMentions(
206
214
  return { prompt: resolvedPrompt, additionalUrls, mentionedCharacterSlugs }
207
215
  }
208
216
 
217
+ /**
218
+ * Splice one resolved mention phrase in for its token, collapsing the horizontal
219
+ * whitespace ON EITHER SIDE OF THE SEAM to a single space. The single splice
220
+ * primitive for every HYBRID image mention splice — the four `@`-mention
221
+ * resolvers (character, location, named image, wired entity) and the
222
+ * `{image:N:label}` positional-pill expansion (`expandImageRefTokensHybrid`).
223
+ *
224
+ * WHY: an editor serializes a mention chip as its token plus its own trailing
225
+ * space, and the prose that follows the chip carries the space the author typed
226
+ * — so a perfectly ordinary sentence arrives as `@panda:1 and @panda2:2 …` and
227
+ * assembles to "the person from reference image A and reference image C …",
228
+ * with the doubled space sitting exactly where the model is being told what the
229
+ * reference IS. The VIDEO core never shows this because `resolveReferenceTokens`
230
+ * runs a `[^\S\r\n]{2,}` collapse over its fully-assembled prompt on every
231
+ * return; the image path has no such tidy. This is that collapse, SCOPED to the
232
+ * seam: a doubled space the author put elsewhere in their prose is theirs to
233
+ * keep, and a prompt whose seams are already single-spaced is byte-identical
234
+ * (the run must be 2+ to match).
235
+ *
236
+ * The class is `[^\S\r\n]` (NOT `\s`) for the same reason the video tidy uses
237
+ * it: `\n` / `\n\n` separate the assembled blocks, and a `\s`-based collapse
238
+ * would silently merge paragraphs.
239
+ *
240
+ * INDENTATION is structure, not a seam. The leading collapse is anchored with a
241
+ * `(?<=\S)` lookbehind so it only fires on a run that FOLLOWS prose on the same
242
+ * line. A mention that opens an indented line (`"Scene:\n @panda:1 stands"` —
243
+ * a shot list, a numbered beat sheet) keeps its indent verbatim; without the
244
+ * anchor that run IS the left seam and a 4-space indent flattened to one space,
245
+ * which is the same class of damage as merging paragraphs and would have made
246
+ * the "already-single-spaced prompts are byte-identical" claim false for every
247
+ * multi-line prompt. The trailing collapse stays unconditional: a run AFTER a
248
+ * chip is the reported bug itself and can never be indentation.
249
+ *
250
+ * OFFSET SAFETY: callers splice right-to-left over offsets taken against the
251
+ * pre-splice string. The left-hand collapse only ever removes characters from
252
+ * the whitespace run immediately preceding THIS token — positions at or after
253
+ * the end of any earlier token (tokens are non-whitespace and never overlap) —
254
+ * so every not-yet-applied (smaller) offset stays valid.
255
+ */
256
+ function spliceMentionPhrase(
257
+ prompt: string,
258
+ offset: number,
259
+ tokenLength: number,
260
+ phrase: string,
261
+ ): string {
262
+ const before = prompt.slice(0, offset).replace(/(?<=\S)[^\S\r\n]{2,}$/, " ")
263
+ const after = prompt.slice(offset + tokenLength).replace(/^[^\S\r\n]{2,}/, " ")
264
+ return before + phrase + after
265
+ }
266
+
209
267
  interface ResolveCharacterMentionsHybridResult {
210
268
  /** Body with each `@`-mention replaced INLINE by its role phrase
211
269
  * ("the {role} from reference image {LETTER}"). No directive block. */
@@ -249,6 +307,7 @@ interface ResolveCharacterMentionsHybridResult {
249
307
  * parsed as a variant slug (e.g. `@kira:1:person`) still attaches the canonical
250
308
  * reference instead of being dropped (the legacy resolver skips variant misses).
251
309
  */
310
+
252
311
  function resolveCharacterMentionsHybrid(
253
312
  prompt: string,
254
313
  tokens: readonly CharacterMentionTokenInfo[],
@@ -327,12 +386,12 @@ function resolveCharacterMentionsHybrid(
327
386
  return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
328
387
  }
329
388
 
330
- // Replace mention tokens right-to-left so earlier offsets stay valid.
389
+ // Replace mention tokens right-to-left so earlier offsets stay valid;
390
+ // `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
331
391
  let resolvedPrompt = prompt
332
392
  for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
333
393
  const phrase = roleToPhrase(m.role, bindingFor(m.url))
334
- resolvedPrompt =
335
- resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
394
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
336
395
  }
337
396
 
338
397
  // One identity-lock + one element directive per UNIQUE attached URL (a
@@ -399,28 +458,111 @@ function locationModeDirective(mode: LocationUsageMode): string | null {
399
458
  * `roleToPhrase` renders into "the {role} from reference image {LETTER}". The
400
459
  * location analog of the character mention's role segment, for the 4-mode
401
460
  * 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"`).
461
+ * - identical → the source default (`"location"`) — lock the scene to this image
462
+ * - style → "style" (borrow look / mood / palette)
463
+ * - layout → "layout" (borrow compositional framing)
464
+ * - none / undefined / anything else → the source default (`"location"`).
465
+ *
466
+ * A PLACE, NOT A BACKDROP — `identical` used to map to `"background"`, which is
467
+ * the exact word `DEFAULT_LABEL_BY_SOURCE["wired-location"]` stopped emitting on
468
+ * 2026-08-05 after it was measured harmful: `roleToPhrase` renders it as "the
469
+ * background from reference image B", and image models read that as *paste this
470
+ * behind the subject* — on gpt-image-2 (character + location, 4 draws per arm,
471
+ * only the role word varying) every `background` draw came back a cut-out
472
+ * composite with no shared light and no ground contact, while `location` put the
473
+ * subject inside the scene. That fix changed the SOURCE default but missed this
474
+ * branch, so `identical` — the DEFAULT location usage mode
475
+ * (`DEFAULT_LOCATION_USAGE_MODE`) — kept handing every un-roled location mention
476
+ * the harmful word. Both defaults now read from `defaultRoleForSource`, so the
477
+ * source default is the single source of truth and the two cannot drift again.
478
+ * `"background"` stays a curated pick in `REFERENCE_ROLE_PRESETS` for the genuine
479
+ * backdrop case — an explicit `@lib:1:background` token is untouched.
406
480
  *
407
481
  * Accepts a loose `string` so a `ConnectedReference.defaultUsageMode` (typed as
408
482
  * the CHARACTER `UsageMode`, which can't express `"layout"`) flows in without a
409
483
  * cast — unknown / non-location modes fall through to the safe source default
410
- * instead of throwing. The three real roles are members of
484
+ * instead of throwing. The two mode-specific roles are members of
411
485
  * `REFERENCE_ROLE_PRESETS["wired-location"]`, so the phrasing stays curated.
412
486
  */
413
487
  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
- }
488
+ return roleBearingLocationMode(mode) ?? defaultRoleForSource("wired-location")
489
+ }
490
+
491
+ /**
492
+ * The ROLE a location usage mode expresses, or `null` when the mode expresses no
493
+ * role opinion at all. Splits the 4-mode enum the way the character chain splits
494
+ * `USAGE_MODES`: `style` / `layout` SAY what the reference is, while `identical`
495
+ * (which IS `DEFAULT_LOCATION_USAGE_MODE`) and `none` are directive-only — they
496
+ * say how the LEGACY bullet reads, not what the model should take from the
497
+ * image. Only a role-bearing mode may outrank a ref-level `defaultRole`; see
498
+ * `resolveLocationRole`.
499
+ *
500
+ * Data-driven rather than a hardcoded pair: a mode is role-bearing exactly when
501
+ * it is itself a member of `REFERENCE_ROLE_PRESETS["wired-location"]` — the same
502
+ * test the location pill runs (`LOCATION_ROLE_PRESETS.includes(attrs.usageMode)`)
503
+ * to decide whether a mode-slot value should surface as the pill's hybrid role,
504
+ * and the same shape as the character resolver's `presets.includes(segment)`. A
505
+ * mode added to both lists later is role-bearing on its own, with no list here
506
+ * to remember to update.
507
+ */
508
+ function roleBearingLocationMode(mode: string | null | undefined): string | null {
509
+ const m = mode?.trim()
510
+ if (!m) return null
511
+ return REFERENCE_ROLE_PRESETS["wired-location"].includes(m) ? m : null
512
+ }
513
+
514
+ /**
515
+ * The effective HYBRID role for a wired LOCATION reference — the location analog
516
+ * of `resolveDefaultRole`, and the single source of truth for the location role
517
+ * chain (read by the mention resolver AND the canonical-fallback renderer, so a
518
+ * location cannot phrase itself one way mentioned and another way unmentioned).
519
+ *
520
+ * Precedence:
521
+ * 1. the per-mention token ROLE (`@lib:1:atmosphere`, `@lib:1:x/y:lighting`) —
522
+ * verbatim, slug-normalized so the non-noun specials still hit
523
+ * (`empty-background` → `empty background`).
524
+ * 2. the per-mention token MODE, but ONLY when it is ROLE-BEARING
525
+ * (`@lib:1:style`, `@lib:1:layout` — `roleBearingLocationMode`).
526
+ * 3. the ref's own `defaultRole` — the node/caller's hybrid role pick, same
527
+ * slug normalization as (1).
528
+ * 4. the ref's legacy `defaultUsageMode` → `locationModeToRole`.
529
+ * 5. the source default (`locationModeToRole`'s fallback — `"location"`).
530
+ *
531
+ * Step 2 is GATED for the same reason the character chain gates its segment on
532
+ * `presets.includes(segment)`: `identical` and `none` express no role opinion —
533
+ * `identical` IS `DEFAULT_LOCATION_USAGE_MODE`, the un-roled state, and the
534
+ * location pill's `renderText` emits a mode segment whenever the attr is set, so
535
+ * an ungated step 2 would let a round-tripped `@lib:1:identical` suppress the
536
+ * very `defaultRole` this chain exists to honor — on the majority of real
537
+ * tokens. A directive-only mode therefore falls THROUGH to the ref's role, the
538
+ * same way a character's `identical` / `name` / `none` segment falls through to
539
+ * `resolveDefaultRole`. With no `defaultRole` the outcome is unchanged
540
+ * (`identical` → step 4 → the source default, `"location"`).
541
+ *
542
+ * Step 3 is what this exists for. `ConnectedReference.defaultRole` is on the
543
+ * wire schema for EVERY source, and the character and named-image mention paths
544
+ * have always consulted it via `resolveDefaultRole` — but the location paths
545
+ * read only the usage mode, so a ref-level default role was silently dropped.
546
+ * It is the ONLY channel a location has for a custom default role: a location
547
+ * mention's 3rd segment is a bucket/variant or a role, so a caller cannot pin a
548
+ * per-mention role AND keep the canonical image, and the character trick of a
549
+ * 4th segment needs a variant to sit in the third. An explicit token role or
550
+ * role-bearing mode still wins (steps 1–2); with no `defaultRole` the chain
551
+ * collapses to exactly the old `t.usageMode ?? defaultUsageMode ?? DEFAULT`
552
+ * derivation.
553
+ */
554
+ function resolveLocationRole(
555
+ tokenRole: string | null | undefined,
556
+ tokenUsageMode: string | null | undefined,
557
+ ref: Pick<ConnectedReference, "defaultRole" | "defaultUsageMode">,
558
+ ): string {
559
+ const explicitRole = tokenRole?.trim()
560
+ if (explicitRole) return normalizeRoleSlug(explicitRole)
561
+ const modeRole = roleBearingLocationMode(tokenUsageMode)
562
+ if (modeRole) return modeRole
563
+ const nodeRole = ref.defaultRole?.trim()
564
+ if (nodeRole) return normalizeRoleSlug(nodeRole)
565
+ return locationModeToRole(ref.defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE)
424
566
  }
425
567
 
426
568
  /**
@@ -595,9 +737,12 @@ interface ResolveLocationMentionsHybridResult {
595
737
  * fallback on a variant miss) so the set of attached URLs / matched tokens never
596
738
  * diverges between formats — only the rendered phrasing differs.
597
739
  *
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`.
740
+ * ROLE is `resolveLocationRole(t.role, t.usageMode, ref)` — per-mention role →
741
+ * per-mention ROLE-BEARING mode (`style` / `layout`; the directive-only
742
+ * `identical` / `none` fall through) → the ref's own `defaultRole` → its
743
+ * `defaultUsageMode` → the source default. So a node whose default mode is
744
+ * "style" renders "the style from …" for a bare `@old-library:1`, and one
745
+ * carrying `defaultRole: "atmosphere"` renders "the atmosphere from …".
601
746
  *
602
747
  * SLOT/LETTER: a URL's 1-based position in `dedup([...existingUrls,
603
748
  * ...mentionUrls])`. The caller passes `existingUrls = [base refs, resolved
@@ -645,15 +790,12 @@ function resolveLocationMentionsHybrid(
645
790
  mentionedLocationSlugs.add(t.locationSlug)
646
791
  refByUrl.set(match.url, match)
647
792
  if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
648
- const mode = t.usageMode ?? match.defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE
649
793
  // A bare-slug ROLE (Unified Reference Roles, Phase D — e.g. `background`,
650
794
  // `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)
795
+ // it's acting as a role, not selecting a bucket/variant. With no role
796
+ // segment, the chain falls through the token's ROLE-BEARING mode, then the
797
+ // REF's own `defaultRole`, then its usage mode — see `resolveLocationRole`.
798
+ const role = resolveLocationRole(t.role, t.usageMode, match)
657
799
  matched.push({ token: t.token, offset: t.offset, url: match.url, role })
658
800
  }
659
801
 
@@ -668,12 +810,12 @@ function resolveLocationMentionsHybrid(
668
810
  return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
669
811
  }
670
812
 
671
- // Replace mention tokens right-to-left so earlier offsets stay valid.
813
+ // Replace mention tokens right-to-left so earlier offsets stay valid;
814
+ // `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
672
815
  let resolvedPrompt = prompt
673
816
  for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
674
817
  const phrase = roleToPhrase(m.role, bindingFor(m.url))
675
- resolvedPrompt =
676
- resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
818
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
677
819
  }
678
820
 
679
821
  // One opt-in lock + one element directive per UNIQUE attached URL.
@@ -793,12 +935,12 @@ function resolveImageMentionsHybrid(
793
935
  return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
794
936
  }
795
937
 
796
- // Replace mention tokens right-to-left so earlier offsets stay valid.
938
+ // Replace mention tokens right-to-left so earlier offsets stay valid;
939
+ // `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
797
940
  let resolvedPrompt = prompt
798
941
  for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
799
942
  const phrase = roleToPhrase(m.role, bindingFor(m.url))
800
- resolvedPrompt =
801
- resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
943
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
802
944
  }
803
945
 
804
946
  // One lock + one element directive per UNIQUE attached URL.
@@ -820,6 +962,188 @@ function resolveImageMentionsHybrid(
820
962
  return { prompt: resolvedPrompt, additionalUrls, lockLines, elementDirectives }
821
963
  }
822
964
 
965
+ interface ResolveEntityMentionsHybridResult {
966
+ /** Body with each `@<entity-name>` mention replaced INLINE by its role phrase
967
+ * ("the creature from reference image D", "the material from reference image
968
+ * B", …). No directive block. */
969
+ prompt: string
970
+ /** Matched entity URLs in mention order (deduped by the caller). */
971
+ additionalUrls: string[]
972
+ /**
973
+ * The URLs a mention BOUND — fed into `renderObjectCreatureCanonicalHybrid`'s
974
+ * `coveredUrls` so the ref's trailing canonical phrase is SUPPRESSED. This is
975
+ * the whole point of the pass: without it the same reference would render
976
+ * twice, once inline and once as the dangling trailing line the mention exists
977
+ * to replace. Distinct from `additionalUrls` only in being a set — kept as its
978
+ * own field so the suppression contract is explicit at both ends.
979
+ */
980
+ mentionedUrls: Set<string>
981
+ /** Per-reference identity-lock lines (deduped per URL). Caller prepends them
982
+ * as ONE block, merged with the character/location/image lock lines. */
983
+ lockLines: string[]
984
+ /** Non-empty `elementInjection` fragments (deduped per URL). Caller appends
985
+ * them as trailing scene directives. */
986
+ elementDirectives: string[]
987
+ }
988
+
989
+ /**
990
+ * HYBRID wired-entity mention convergence — the `wired-creature` /
991
+ * `wired-object` analog of `resolveImageMentionsHybrid`, for entities addressed
992
+ * by the slug of their `defaultName`.
993
+ *
994
+ * THE BUG THIS KILLS. An unmentioned creature renders through
995
+ * `renderObjectCreatureCanonicalHybrid` as a TRAILING phrase — "the creature
996
+ * from reference image D" — appended after the scene and the style hints, while
997
+ * the creature's name sits in the prose as plain text the model has no reason to
998
+ * connect to a reference. A mention binds the two: the phrase renders INLINE at
999
+ * the typed position, and this pass's `mentionedUrls` suppresses the trailing
1000
+ * fallback for that ref.
1001
+ *
1002
+ * ASYMMETRY vs. images, and why suppression is needed here but not there: a
1003
+ * media ref carries NO canonical prose, so the image pass has nothing to
1004
+ * suppress. A creature/object DOES, so this pass must hand its bound URLs to the
1005
+ * canonical renderer's `coveredUrls` — exactly the mechanism that already stops
1006
+ * an `{image:N}`-token-referenced entity from double-rendering.
1007
+ *
1008
+ * NOT a `connectedReferences` FILTER, matching the image pass and unlike the
1009
+ * location pass: the ref stays in the list so `nonCharacterRefs[N-1]` positional
1010
+ * indexing for `{image:N}` tokens is unperturbed and the New-path URL merge keeps
1011
+ * the ref's earlier Phase-0 slot (the merge dedups by URL). Suppression is
1012
+ * coveredUrls-only.
1013
+ *
1014
+ * PRECEDENCE. The caller runs this AFTER the character, location and image
1015
+ * passes, each of which has already spliced its own tokens out — so a name shared
1016
+ * with an earlier kind never reaches this pass (character → location → image →
1017
+ * creature → object). The creature-before-object tail is settled HERE, in the
1018
+ * slug → ref map: creature refs are inserted first, so a name claimed by both a
1019
+ * creature and an object binds the CREATURE.
1020
+ *
1021
+ * DUPLICATE SLUGS within a kind: FIRST WINS, matching the image pass.
1022
+ *
1023
+ * ROLE precedence: the per-mention 3rd segment (VERBATIM — both entity preset
1024
+ * lists are single-word, so `normalizeRoleSlug` stays location-only and must NOT
1025
+ * be called here) → the ref's own `defaultRole`, then its `defaultUsageMode`, via
1026
+ * the SHARED `resolveDefaultRole` helper → the SOURCE default (`"creature"` /
1027
+ * `"object"`).
1028
+ *
1029
+ * ONE HELPER, NOT A COPY. `resolveDefaultRole` is the same chain the character
1030
+ * and named-image resolvers run, and `ConnectedReference.defaultRole` is a wire
1031
+ * field on EVERY source (see its doc in `types.ts`), so an entity ref's role pick
1032
+ * is honored here for free. The LOCATION paths need their own `resolveLocationRole`
1033
+ * only because a location token carries a MODE segment whose role-bearing members
1034
+ * (`style` / `layout`) sit between the token role and the ref's `defaultRole`, and
1035
+ * because location roles are slug-normalized; an entity token has no mode segment
1036
+ * (`EntityMentionTokenInfo` is role + lock only) and no multi-word roles, so there
1037
+ * is no gate to replicate and an entity analog of that helper would be two copies
1038
+ * of one chain.
1039
+ *
1040
+ * That LAST step is the one `renderObjectCreatureCanonicalHybrid` would also have
1041
+ * used for the trailing line — but only that one: the canonical renderer reads
1042
+ * `defaultRoleForSource(r.source)` and ignores `defaultRole` / `defaultUsageMode`
1043
+ * entirely. So a bare `@nessie:4` relocates the IDENTICAL phrase only when the ref
1044
+ * carries neither node field; with a node default the mention honors it and the
1045
+ * phrase CHANGES ("the anatomy from …" rather than "the creature from …"). That is
1046
+ * deliberate — it is what `resolveCharacterMentionsHybrid` and
1047
+ * `resolveImageMentionsHybrid` do, and it is pinned by
1048
+ * `entity-convergence-image.test.ts`. Suppression is
1049
+ * unaffected either way: the mention pass covers the same URL and emits the same
1050
+ * lock line (modulo a `~lock`/`~nolock` sentinel) and the same `elementInjection`.
1051
+ *
1052
+ * NO LEGACY COUNTERPART — the image-grammar precedent. The Phase-0 arm that
1053
+ * reaches this is hybrid-gated, so under the legacy reference format an
1054
+ * `@name:N` token stays literal text and the entity attaches with its numbered
1055
+ * directive exactly as it does today.
1056
+ *
1057
+ * CAPPED REFS: `imageReferenceLimit(provider)` truncates `connectedReferences`
1058
+ * BEFORE Phase 0, so a mention whose ref was capped out silently falls through as
1059
+ * literal text — matching how a capped character or image mention behaves today.
1060
+ */
1061
+ function resolveEntityMentionsHybrid(
1062
+ prompt: string,
1063
+ tokens: readonly EntityMentionTokenInfo[],
1064
+ refs: readonly ConnectedReference[],
1065
+ existingUrls: readonly string[],
1066
+ ): ResolveEntityMentionsHybridResult {
1067
+ const bySlug = new Map<string, ConnectedReference>()
1068
+ // Creature-first insertion IS the creature → object half of the precedence
1069
+ // chain (the other four steps are the caller's pass order). `entityMentionSlug
1070
+ // ForRef` is the SAME predicate `knownEntitySlugsFromRefs` applies to build the
1071
+ // finder's known-slug set — one gate, so this map and that set can never admit
1072
+ // different refs.
1073
+ for (const source of ["wired-creature", "wired-object"] as const) {
1074
+ for (const r of refs) {
1075
+ if (r.source !== source) continue
1076
+ const slug = entityMentionSlugForRef(r)
1077
+ if (!slug) continue
1078
+ if (!bySlug.has(slug)) bySlug.set(slug, r)
1079
+ }
1080
+ }
1081
+
1082
+ const additionalUrls: string[] = []
1083
+ const mentionedUrls = new Set<string>()
1084
+ const refByUrl = new Map<string, ConnectedReference>()
1085
+ // Per-mention `~lock` / `~nolock`: the tri-state lock OVERRIDE per attached
1086
+ // URL, fed to `withForcedIdentityLock` below. Only sentinel-bearing mentions
1087
+ // write here (last sentinel wins), mirroring the image/location resolvers.
1088
+ const lockOverrideByUrl = new Map<string, boolean>()
1089
+ const matched: Array<{ token: string; offset: number; url: string; role: string }> = []
1090
+
1091
+ for (const t of tokens) {
1092
+ const match = bySlug.get(t.entitySlug)
1093
+ if (!match || !match.url) continue
1094
+ additionalUrls.push(match.url)
1095
+ mentionedUrls.add(match.url)
1096
+ refByUrl.set(match.url, match)
1097
+ if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
1098
+ const role = (t.role ?? "").trim()
1099
+ || resolveDefaultRole(match.defaultRole, match.defaultUsageMode, match.source)
1100
+ matched.push({ token: t.token, offset: t.offset, url: match.url, role })
1101
+ }
1102
+
1103
+ // Slot letters from the deduped [existing, mention] URL list — the prefix of
1104
+ // the caller's `finalIndexByUrl`, so the letters agree.
1105
+ const slotByUrl = new Map<string, number>()
1106
+ for (const u of [...existingUrls, ...additionalUrls]) {
1107
+ if (!slotByUrl.has(u)) slotByUrl.set(u, slotByUrl.size + 1)
1108
+ }
1109
+ const bindingFor = (url: string): string => {
1110
+ const slot = slotByUrl.get(url)
1111
+ return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
1112
+ }
1113
+
1114
+ // Replace mention tokens right-to-left so earlier offsets stay valid;
1115
+ // `spliceMentionPhrase` also tidies the horizontal whitespace at each seam —
1116
+ // an entity chip serializes with its own trailing space exactly like every
1117
+ // other mention chip, so its seams get the same collapse as the character,
1118
+ // location and named-image ones.
1119
+ let resolvedPrompt = prompt
1120
+ for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
1121
+ const phrase = roleToPhrase(m.role, bindingFor(m.url))
1122
+ resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
1123
+ }
1124
+
1125
+ // One lock + one element directive per UNIQUE attached URL. These are the
1126
+ // SAME lines `renderObjectCreatureCanonicalHybrid` would have emitted for the
1127
+ // ref, moved here — which is why suppressing its canonical entry does not lose
1128
+ // an opt-in lock or a wired `elementInjection`.
1129
+ const lockLines: string[] = []
1130
+ const elementDirectives: string[] = []
1131
+ const seenUrls = new Set<string>()
1132
+ for (const m of matched) {
1133
+ if (seenUrls.has(m.url)) continue
1134
+ seenUrls.add(m.url)
1135
+ const ref = refByUrl.get(m.url)
1136
+ if (!ref) continue
1137
+ const binding = bindingFor(m.url)
1138
+ const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
1139
+ if (lock) lockLines.push(lock)
1140
+ const inject = ref.elementInjection?.trim()
1141
+ if (inject) elementDirectives.push(inject)
1142
+ }
1143
+
1144
+ return { prompt: resolvedPrompt, additionalUrls, mentionedUrls, lockLines, elementDirectives }
1145
+ }
1146
+
823
1147
  /**
824
1148
  * Build the canonical-fallback directive lines + URLs for wired characters
825
1149
  * that were NOT @-mentioned in the prompt. Matches the pre-mention behavior:
@@ -1180,8 +1504,9 @@ function renderExtraRefsHybrid(
1180
1504
  * Render the hybrid canonical convergence for UNMENTIONED wired locations (the
1181
1505
  * location analog of `renderCanonicalFallbackHybrid`). Each unmentioned
1182
1506
  * `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
1507
+ * `roleToPhrase(resolveLocationRole(null, null, ref), binding)` — the SAME role
1508
+ * chain the mention path runs, minus the token steps (the caller appends these as
1509
+ * trailing scene directives) + its opt-in identity-lock line
1185
1510
  * (null unless the ref enables one — locations have no built-in lock wording) +
1186
1511
  * its wired `elementInjection` as a trailing directive.
1187
1512
  *
@@ -1215,7 +1540,10 @@ function renderLocationCanonicalHybrid(
1215
1540
  if (!slot) continue
1216
1541
  seenUrls.add(r.url)
1217
1542
  const binding = `reference image ${slotToLetter(slot)}`
1218
- phrases.push(roleToPhrase(locationModeToRole(r.defaultUsageMode), binding))
1543
+ // Same role chain as the mention path (there is no token here, so it starts
1544
+ // at the ref's own `defaultRole`) — one helper, so a location cannot phrase
1545
+ // itself one way mentioned and another way unmentioned.
1546
+ phrases.push(roleToPhrase(resolveLocationRole(null, null, r), binding))
1219
1547
  const lock = buildIdentityLockLine(r, binding)
1220
1548
  if (lock) lockLines.push(lock)
1221
1549
  const inject = r.elementInjection?.trim()
@@ -1236,11 +1564,19 @@ function renderLocationCanonicalHybrid(
1236
1564
  * has wording but it is OFF unless `identityLock.enabled === true`, per Plan A's
1237
1565
  * default-off flip) + its wired `elementInjection` as a trailing directive.
1238
1566
  *
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).
1567
+ * `coveredUrls` is every URL that ALREADY rendered inline in this scene, and it
1568
+ * carries TWO contributions the caller unions together: the URLs expanded by an
1569
+ * `{image:N}` token, and — since the creature/object mention leg — the URLs bound
1570
+ * by a Phase-0 `@creature` / `@object` mention. Either way the ref renders ONCE,
1571
+ * inline, never also as a trailing canonical phrase.
1572
+ *
1573
+ * The mention half is why this loop is NOT reached via a `connectedReferences`
1574
+ * filter the way mentioned locations are: an entity ref stays in the list to keep
1575
+ * `nonCharacterRefs[N-1]` positional `{image:N}` indexing stable, so suppression
1576
+ * has to happen HERE, per URL. The mention pass emits the same lock line and
1577
+ * `elementInjection` this loop would have, so nothing is lost by skipping it.
1578
+ *
1579
+ * Deduped per URL (an object wired twice → one phrase).
1244
1580
  */
1245
1581
  function renderObjectCreatureCanonicalHybrid(
1246
1582
  nonCharacterRefs: readonly ConnectedReference[],
@@ -1671,12 +2007,18 @@ export interface BuildImagePromptSegmentsResult extends BuildImagePromptResult {
1671
2007
  * - `bodyBeforeSuffixes`: the prompt string captured immediately BEFORE the
1672
2008
  * style/avoid suffixes were appended (covers the common no-truncation case).
1673
2009
  * - `styleSuffix` / `avoidSuffix`: the `\nStyle: …` / `\nAvoid: …` strings.
2010
+ * - `overflowChars`: how many characters the provider cap forced OFF the tail
2011
+ * (0 when the assembled prompt fit). Accumulated across every clamp site in
2012
+ * both assembly paths. Read by {@link buildImagePromptWithOverflow} so a
2013
+ * cap-aware caller can shed its own lowest-value text and re-assemble
2014
+ * instead of letting the order-blind tail cut decide.
1674
2015
  */
1675
2016
  interface AssemblyMarks {
1676
2017
  directivesPrefix: string
1677
2018
  bodyBeforeSuffixes: string
1678
2019
  styleSuffix: string
1679
2020
  avoidSuffix: string
2021
+ overflowChars: number
1680
2022
  }
1681
2023
 
1682
2024
  /** Keep `bodySegments` when the assembled body still equals their join;
@@ -1690,6 +2032,32 @@ function reconcileBodySegments(body: string, bodySegments: readonly PromptSegmen
1690
2032
  return [{ text: body, origin: "user" }]
1691
2033
  }
1692
2034
 
2035
+ /**
2036
+ * The `Style:` / `Avoid:` control lines with the separator each one needs.
2037
+ *
2038
+ * They are self-labeling and stay at the very END of the prompt, which makes
2039
+ * them the only text that can still land under an open `[style]` header — the
2040
+ * section has no terminator, so a blank line is what closes its scope. `Avoid:`
2041
+ * reads the body WITH `Style:` already on it, so once the first control line has
2042
+ * closed the section the second rejoins on a single newline, exactly as before.
2043
+ *
2044
+ * Derived from the body they are appended to, because the cap's tail cut moves
2045
+ * that boundary. The cut can only NARROW the separator — the section is the last
2046
+ * block, so a cut either drops it entirely or lands inside it — which is what
2047
+ * lets the caller reserve on the pre-cut body and re-derive afterwards.
2048
+ */
2049
+ function controlSuffixes(
2050
+ body: string,
2051
+ styleLine: string,
2052
+ avoidLine: string,
2053
+ ): { styleSuffix: string; avoidSuffix: string } {
2054
+ const separator = (text: string): string =>
2055
+ endsInsideStyleSection(text) ? STYLE_SECTION_GAP : "\n"
2056
+ const styleSuffix = styleLine ? `${separator(body)}${styleLine}` : ""
2057
+ const avoidSuffix = avoidLine ? `${separator(body + styleSuffix)}${avoidLine}` : ""
2058
+ return { styleSuffix, avoidSuffix }
2059
+ }
2060
+
1693
2061
  /**
1694
2062
  * Build the final image generation prompt from config.
1695
2063
  * Handles character description wrapping, style appending, negative prompt routing,
@@ -1703,6 +2071,47 @@ export function buildImagePrompt(config: BuildImagePromptConfig): BuildImageProm
1703
2071
  return buildImagePromptInternal(config)
1704
2072
  }
1705
2073
 
2074
+ /** {@link buildImagePrompt} plus `overflowChars`. */
2075
+ export interface BuildImagePromptOverflowResult extends BuildImagePromptResult {
2076
+ /**
2077
+ * Characters the provider's prompt cap forced off the tail — `0` when the
2078
+ * assembled prompt fit. This is the amount of text that must LEAVE the body
2079
+ * for the prompt to fit without a tail cut, so a caller can shed exactly
2080
+ * enough of its own droppable text and re-assemble.
2081
+ */
2082
+ overflowChars: number
2083
+ }
2084
+
2085
+ /**
2086
+ * `buildImagePrompt` plus the size of the cap overflow it had to clamp away.
2087
+ *
2088
+ * The prompt (and every other field) is BYTE-IDENTICAL to `buildImagePrompt` —
2089
+ * the marks channel only observes. Exists because the tail clamp is ORDER-BLIND:
2090
+ * it cuts whatever happens to be last, which on a low-cap provider can sever a
2091
+ * reference directive or the user's own prose while a decorative hint clause
2092
+ * survives. `assembleImageInput` uses this to drop its lowest-value text
2093
+ * (direction-folded hint clauses) FIRST and re-assemble; the clamp then stays
2094
+ * the last-resort fallback for a body that overflows on prose alone.
2095
+ */
2096
+ export function buildImagePromptWithOverflow(
2097
+ config: BuildImagePromptConfig,
2098
+ ): BuildImagePromptOverflowResult {
2099
+ const marks = newAssemblyMarks()
2100
+ const result = buildImagePromptInternal(config, marks)
2101
+ return { ...result, overflowChars: marks.overflowChars }
2102
+ }
2103
+
2104
+ /** Fresh zeroed marks — one construction site so a new field can't be forgotten. */
2105
+ function newAssemblyMarks(): AssemblyMarks {
2106
+ return {
2107
+ directivesPrefix: "",
2108
+ bodyBeforeSuffixes: "",
2109
+ styleSuffix: "",
2110
+ avoidSuffix: "",
2111
+ overflowChars: 0,
2112
+ }
2113
+ }
2114
+
1706
2115
  /**
1707
2116
  * Shared assembly body for `buildImagePrompt` + `buildImagePromptSegments`.
1708
2117
  * When `marks` is provided, records the directive-prefix / body / suffix spans
@@ -1738,6 +2147,14 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1738
2147
  // line-initials (which would corrupt "the face from reference image A" → "The
1739
2148
  // face …" / "the style from reference image A" → "The style …").
1740
2149
  let hybridBodyConverged = false
2150
+ // URLs bound by a Phase-0 wired-entity (`@creature` / `@object`) mention.
2151
+ // Declared at function scope because it must BRIDGE the two blocks below:
2152
+ // Phase 0 populates it, and the New path unions it into
2153
+ // `renderObjectCreatureCanonicalHybrid`'s `coveredUrls` so a mentioned
2154
+ // creature/object does NOT also emit its trailing canonical phrase. Empty for
2155
+ // every prompt without an entity mention, which is what keeps those outputs
2156
+ // byte-identical.
2157
+ const mentionedEntityUrls = new Set<string>()
1741
2158
 
1742
2159
  // Character LoRA inference path: trigger word + LoRA model carry identity,
1743
2160
  // so we strip raw `@slug[:V[:variant]]` tokens from the prompt AND drop the
@@ -1861,7 +2278,22 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1861
2278
  const hasImageMentionTokens = isHybrid
1862
2279
  && knownImageSlugs.length > 0
1863
2280
  && findImageMentionTokens(config.prompt, knownImageSlugs).length > 0
1864
- if (knownCharacterSlugs.length > 0 || hasExtraRefs || knownLocationSlugs.length > 0 || hasImageMentionTokens) {
2281
+ // Wired-entity mentions (`wired-creature` / `wired-object`). Slugs derive
2282
+ // from each entity ref's `defaultName` — no `entitySlug` wire field, matching
2283
+ // the image grammar — and the derivation is shared with the backend
2284
+ // orchestrator's structured-branch gate via `knownEntitySlugsFromRefs`.
2285
+ const knownEntitySlugs = knownEntitySlugsFromRefs(connectedReferences)
2286
+ // EXISTENCE-ONLY precheck, gating the Phase-0 arm below — the image
2287
+ // precedent. An unmentioned creature/object already renders correctly
2288
+ // through `renderObjectCreatureCanonicalHybrid`, so a graph with no entity
2289
+ // mention has no reason to enter Phase 0: gating on TOKEN presence keeps
2290
+ // today's control flow and byte output for every such graph BY
2291
+ // CONSTRUCTION. HYBRID-only — under the legacy format an `@name:N` token
2292
+ // stays literal text.
2293
+ const hasEntityMentionTokens = isHybrid
2294
+ && knownEntitySlugs.length > 0
2295
+ && findEntityMentionTokens(config.prompt, knownEntitySlugs).length > 0
2296
+ if (knownCharacterSlugs.length > 0 || hasExtraRefs || knownLocationSlugs.length > 0 || hasImageMentionTokens || hasEntityMentionTokens) {
1865
2297
  const mentionTokens = knownCharacterSlugs.length > 0
1866
2298
  ? findCharacterMentionTokens(config.prompt, knownCharacterSlugs)
1867
2299
  : []
@@ -1991,6 +2423,47 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
1991
2423
  // is gated to wired-location/object/creature); and leaving the ref in
1992
2424
  // place keeps `nonCharacterRefs[N-1]` positional indexing stable for
1993
2425
  // `{image:N}` tokens.
2426
+ //
2427
+ // Pass 4: wired-entity (`@creature` / `@object`) mentions, resolved on the
2428
+ // POST-character, POST-location, POST-image prompt. The finder MUST re-run
2429
+ // here — every earlier pass spliced its own tokens out, so an offset
2430
+ // computed before them is stale. PRECEDENCE character → location → image →
2431
+ // creature → object falls out of this pass order plus the creature-first
2432
+ // map inside the resolver: a name shared with an earlier kind resolves as
2433
+ // that kind, and the entity token never fires.
2434
+ //
2435
+ // `existingUrls` is already location- AND image-inclusive because
2436
+ // `additionalUrls` absorbed both above, so the mention slot letters stay a
2437
+ // prefix of the final `finalIndexByUrl`.
2438
+ let hybridEntityLockLines: string[] = []
2439
+ let hybridEntityElementDirectives: string[] = []
2440
+ if (hasEntityMentionTokens) {
2441
+ const entityTokens = findEntityMentionTokens(resolved.prompt, knownEntitySlugs)
2442
+ if (entityTokens.length > 0) {
2443
+ const he = resolveEntityMentionsHybrid(
2444
+ resolved.prompt,
2445
+ entityTokens,
2446
+ connectedReferences,
2447
+ [...(referenceImageUrls || []), ...resolved.additionalUrls],
2448
+ )
2449
+ resolved.prompt = he.prompt
2450
+ resolved.additionalUrls = [...resolved.additionalUrls, ...he.additionalUrls]
2451
+ hybridEntityLockLines = he.lockLines
2452
+ hybridEntityElementDirectives = he.elementDirectives
2453
+ // The suppression handoff to the New path: every bound URL is skipped
2454
+ // by `renderObjectCreatureCanonicalHybrid`, so the phrase renders ONCE
2455
+ // — inline, where the user typed it — instead of also dangling as a
2456
+ // trailing "the creature from reference image D" line. Like the image
2457
+ // pass, the ref itself is NOT filtered out of `connectedReferences`
2458
+ // (positional `{image:N}` stability); coveredUrls is the whole
2459
+ // mechanism.
2460
+ for (const u of he.mentionedUrls) mentionedEntityUrls.add(u)
2461
+ // Inline role phrases now live in the body → skip line-initial
2462
+ // capitalization, which would otherwise corrupt a mid-sentence
2463
+ // "the creature from reference image D".
2464
+ if (he.additionalUrls.length > 0) hybridBodyConverged = true
2465
+ }
2466
+ }
1994
2467
  // Default-fallback canonical URLs + directives for any wired character
1995
2468
  // that has zero mentions in the prompt. Mirrors the legacy behavior the
1996
2469
  // mention feature replaced — wire a character with no typing required.
@@ -2130,6 +2603,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2130
2603
  ...hybridLockLines,
2131
2604
  ...hybridLocationLockLines,
2132
2605
  ...hybridImageLockLines,
2606
+ ...hybridEntityLockLines,
2133
2607
  ...canonical.lockLines,
2134
2608
  ...extrasRendered.lockLines,
2135
2609
  ])]
@@ -2137,14 +2611,16 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2137
2611
  ...hybridElementDirectives,
2138
2612
  ...hybridLocationElementDirectives,
2139
2613
  ...hybridImageElementDirectives,
2614
+ ...hybridEntityElementDirectives,
2140
2615
  ...canonical.phrases,
2141
2616
  ...canonical.elementDirectives,
2142
2617
  ...extrasRendered.bodyLines,
2143
2618
  ...extrasRendered.elementDirectives,
2144
2619
  ]
2145
2620
  const lockBlock = allLockLines.length > 0 ? `${allLockLines.join("\n")}\n\n` : ""
2146
- const trailingBlock = trailingLines.length > 0 ? `\n${trailingLines.join("\n")}` : ""
2147
- promptForNext = `${lockBlock}${promptForNext}${trailingBlock}`
2621
+ // Scene content, so it extends the BODY: appended flat it would land
2622
+ // under a `[style]` header the composer left open.
2623
+ promptForNext = `${lockBlock}${insertBeforeStyleSection(promptForNext, trailingLines)}`
2148
2624
  }
2149
2625
 
2150
2626
  // Mutate the config locals (NOT the original passed config).
@@ -2267,18 +2743,32 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2267
2743
  // (Phase C): each → the trailing role phrase "the {role} from reference
2268
2744
  // image {LETTER}" + opt-in lock + wired elementInjection, numbered against
2269
2745
  // `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).
2746
+ // and filtered out of `nonCharacterRefs`; mentioned objects/creatures were
2747
+ // converged inline too but stay in the list, so they are suppressed by URL
2748
+ // via the covered set below.
2272
2749
  const locCanon = renderLocationCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, tokenCoveredUrls)
2273
- const objCanon = renderObjectCreatureCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, tokenCoveredUrls)
2750
+ // Objects/creatures now have a mention path too, so their covered set is
2751
+ // the union of the `{image:N}`-token URLs and the URLs a Phase-0
2752
+ // `@creature` / `@object` mention bound. That union is the SUPPRESSION —
2753
+ // a mentioned entity already rendered its role phrase inline, and without
2754
+ // this it would ALSO emit the trailing "the creature from reference image
2755
+ // D" line, which is the exact double-render this leg removes. Locations
2756
+ // keep `tokenCoveredUrls` alone: their mentioned refs are FILTERED out of
2757
+ // `nonCharacterRefs` upstream, so they never reach the loop at all.
2758
+ const objCoveredUrls = mentionedEntityUrls.size > 0
2759
+ ? new Set([...tokenCoveredUrls, ...mentionedEntityUrls])
2760
+ : tokenCoveredUrls
2761
+ const objCanon = renderObjectCreatureCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, objCoveredUrls)
2274
2762
  const canonLockLines = [...locCanon.lockLines, ...objCanon.lockLines]
2275
2763
  const canonLockBlock = canonLockLines.length > 0 ? `${canonLockLines.join("\n")}\n\n` : ""
2276
2764
  const canonTrailingLines = [
2277
2765
  ...locCanon.phrases, ...objCanon.phrases,
2278
2766
  ...locCanon.elementDirectives, ...objCanon.elementDirectives,
2279
2767
  ]
2280
- const canonTrailingBlock = canonTrailingLines.length > 0 ? `\n${canonTrailingLines.join("\n")}` : ""
2281
- const composedScene = `${canonLockBlock}${scene}${canonTrailingBlock}`
2768
+ // Role phrases and element injections are scene content → they extend the
2769
+ // BODY, ahead of the `[style]` section (which has no terminator, so a flat
2770
+ // append would read as one more look clause).
2771
+ const composedScene = `${canonLockBlock}${insertBeforeStyleSection(scene, canonTrailingLines)}`
2282
2772
  prompt = config.referenceLockSnippet
2283
2773
  ? `${config.referenceLockSnippet}\n${composedScene}`
2284
2774
  : composedScene
@@ -2322,24 +2812,20 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2322
2812
  }
2323
2813
 
2324
2814
  const styleText = style?.trim()
2325
- const styleSuffix = styleText ? `\nStyle: ${getStylePromptHint(styleText) || styleText}` : ""
2815
+ const styleLine = styleText ? `Style: ${getStylePromptHint(styleText) || styleText}` : ""
2326
2816
 
2327
2817
  const negPrompt = negativePrompt?.trim()
2328
2818
  let nativeNegativePrompt: string | undefined
2329
- let avoidSuffix = ""
2819
+ let avoidLine = ""
2330
2820
  if (negPrompt) {
2331
2821
  if (NATIVE_NEGATIVE_PROMPT_MODELS.has(provider)) {
2332
2822
  // Clamp native negatives to the provider's verified cap (e.g. ideogram /
2333
2823
  // qwen = 500) so an over-long negative can't trigger a provider reject.
2334
2824
  nativeNegativePrompt = negPrompt.slice(0, getMaxNegativePromptChars(provider))
2335
2825
  } else {
2336
- avoidSuffix = `\nAvoid: ${negPrompt}`
2826
+ avoidLine = `Avoid: ${negPrompt}`
2337
2827
  }
2338
2828
  }
2339
- if (marks) {
2340
- marks.styleSuffix = styleSuffix
2341
- marks.avoidSuffix = avoidSuffix
2342
- }
2343
2829
 
2344
2830
  // Cap the assembled prompt at the PROVIDER's max (default IMAGE_PROMPT_MAX =
2345
2831
  // 5000, what the image routes already accept) — never the old hardcoded
@@ -2347,11 +2833,22 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2347
2833
  // the `Avoid:` negative whenever the body was long. The style/avoid suffixes
2348
2834
  // are RESERVED first so a long body can never drop them (the control text is
2349
2835
  // the most important to keep). Truncate the BODY, THEN append the suffixes.
2836
+ // The cut is ORDER-BLIND — record how much it removed so a cap-aware caller
2837
+ // (`assembleImageInput`) can shed its own lowest-value text and re-assemble.
2350
2838
  const maxLen = getMaxImagePromptChars(provider)
2351
- const reserved = styleSuffix.length + avoidSuffix.length
2839
+ // Reserved on the PRE-cut body; the cut can only NARROW the separator (see
2840
+ // `controlSuffixes`), so the re-derived suffixes always fit the reservation.
2841
+ const preCut = controlSuffixes(prompt, styleLine, avoidLine)
2842
+ const reserved = preCut.styleSuffix.length + preCut.avoidSuffix.length
2352
2843
  if (prompt.length + reserved > maxLen) {
2844
+ if (marks) marks.overflowChars += prompt.length + reserved - maxLen
2353
2845
  prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
2354
2846
  }
2847
+ const { styleSuffix, avoidSuffix } = controlSuffixes(prompt, styleLine, avoidLine)
2848
+ if (marks) {
2849
+ marks.styleSuffix = styleSuffix
2850
+ marks.avoidSuffix = avoidSuffix
2851
+ }
2355
2852
  // Body span = everything after the captured directive prefix, taken from the
2356
2853
  // possibly-truncated body so the segment join still reconstructs (empty in the
2357
2854
  // Phase-0 consolidation branch → collapses via the fallback, the documented
@@ -2360,6 +2857,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2360
2857
  prompt = prompt + styleSuffix + avoidSuffix
2361
2858
  // Safety clamp for a pathological native-less negative that alone overflows.
2362
2859
  if (prompt.length > maxLen) {
2860
+ if (marks) marks.overflowChars += prompt.length - maxLen
2363
2861
  prompt = prompt.slice(0, maxLen - 3) + "..."
2364
2862
  }
2365
2863
 
@@ -2420,53 +2918,63 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
2420
2918
  })
2421
2919
  })
2422
2920
 
2423
- // Assemble prompt
2921
+ // Assemble prompt. The wrapper template composes the BODY only — a `[style]`
2922
+ // section is lifted off first and re-attached after, so a description can
2923
+ // never land under its header (and a user-overridden template, which may put
2924
+ // the descriptions anywhere, still only ever rearranges the body).
2424
2925
  let prompt = config.prompt
2425
2926
  if (charDescs.length > 0) {
2426
2927
  const wrapperTemplate = resolveTemplate("generate-image-wrapper", userTemplates, flowTemplates)
2427
- prompt = applyTemplate(wrapperTemplate, {
2428
- userPrompt: prompt,
2928
+ const { body, section } = splitStyleSection(prompt)
2929
+ const wrapped = applyTemplate(wrapperTemplate, {
2930
+ userPrompt: body,
2429
2931
  assetDescriptions: charDescs.join(" "),
2430
2932
  })
2933
+ prompt = section.length > 0 ? `${wrapped}${STYLE_SECTION_GAP}${section}` : wrapped
2431
2934
  }
2432
2935
 
2433
2936
  // Append style — if the inline `style` is a known STYLES catalog id, inject
2434
2937
  // the richer promptHint; otherwise fall back to the raw text (covers custom
2435
2938
  // free-text styles that don't match a preset).
2436
2939
  const styleText = style?.trim()
2437
- const styleSuffix = styleText ? `\nStyle: ${getStylePromptHint(styleText) || styleText}` : ""
2940
+ const styleLine = styleText ? `Style: ${getStylePromptHint(styleText) || styleText}` : ""
2438
2941
 
2439
2942
  // Handle negative prompt: native support vs prompt-appended
2440
2943
  const negPrompt = negativePrompt?.trim()
2441
2944
  let nativeNegativePrompt: string | undefined
2442
- let avoidSuffix = ""
2945
+ let avoidLine = ""
2443
2946
  if (negPrompt) {
2444
2947
  if (NATIVE_NEGATIVE_PROMPT_MODELS.has(provider)) {
2445
2948
  nativeNegativePrompt = negPrompt.slice(0, getMaxNegativePromptChars(provider))
2446
2949
  } else {
2447
- avoidSuffix = `\nAvoid: ${negPrompt}`
2950
+ avoidLine = `Avoid: ${negPrompt}`
2448
2951
  }
2449
2952
  }
2450
- if (marks) {
2451
- marks.styleSuffix = styleSuffix
2452
- marks.avoidSuffix = avoidSuffix
2453
- }
2454
2953
 
2455
2954
  // Cap at the provider max (default IMAGE_PROMPT_MAX = 5000), reserving the
2456
2955
  // style/avoid suffixes so a long body never severs the appended control text
2457
2956
  // (was a hardcoded 2000 tail-cut that dropped the negative). Truncate the
2458
- // BODY, THEN append the suffixes.
2957
+ // BODY, THEN append the suffixes. Same order-blind cut as the
2958
+ // connectedReferences path above → same `overflowChars` bookkeeping.
2459
2959
  const maxLen = getMaxImagePromptChars(provider)
2460
- const reserved = styleSuffix.length + avoidSuffix.length
2960
+ const preCut = controlSuffixes(prompt, styleLine, avoidLine)
2961
+ const reserved = preCut.styleSuffix.length + preCut.avoidSuffix.length
2461
2962
  if (prompt.length + reserved > maxLen) {
2963
+ if (marks) marks.overflowChars += prompt.length + reserved - maxLen
2462
2964
  prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
2463
2965
  }
2966
+ const { styleSuffix, avoidSuffix } = controlSuffixes(prompt, styleLine, avoidLine)
2967
+ if (marks) {
2968
+ marks.styleSuffix = styleSuffix
2969
+ marks.avoidSuffix = avoidSuffix
2970
+ }
2464
2971
  // Legacy path has no directive prefix; the body is the char-desc-wrapped
2465
2972
  // (possibly-truncated) prompt right before the style/avoid suffixes.
2466
2973
  if (marks) marks.bodyBeforeSuffixes = prompt
2467
2974
  prompt = prompt + styleSuffix + avoidSuffix
2468
2975
  // Safety clamp for a pathological native-less negative that alone overflows.
2469
2976
  if (prompt.length > maxLen) {
2977
+ if (marks) marks.overflowChars += prompt.length - maxLen
2470
2978
  prompt = prompt.slice(0, maxLen - 3) + "..."
2471
2979
  }
2472
2980
 
@@ -2499,12 +3007,7 @@ export function buildImagePromptSegments(
2499
3007
  config: BuildImagePromptConfig,
2500
3008
  bodySegments?: readonly PromptSegment[],
2501
3009
  ): BuildImagePromptSegmentsResult {
2502
- const marks: AssemblyMarks = {
2503
- directivesPrefix: "",
2504
- bodyBeforeSuffixes: "",
2505
- styleSuffix: "",
2506
- avoidSuffix: "",
2507
- }
3010
+ const marks = newAssemblyMarks()
2508
3011
 
2509
3012
  const result = buildImagePromptInternal(config, marks)
2510
3013
 
@@ -2887,21 +3390,48 @@ function hybridRolePhrase(label: string, letter: string): string {
2887
3390
  return `the ${label} from reference image ${letter}`
2888
3391
  }
2889
3392
 
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. */
3393
+ /**
3394
+ * Like `expandImageRefTokensForRefs`, but emits the hybrid lettered phrase
3395
+ * ("the subject from reference image A") instead of the legacy "Image N
3396
+ * (label)". Out-of-range tokens / URL-less refs are left untouched — visible so
3397
+ * the author can fix them.
3398
+ *
3399
+ * A `{image:N:label}` pill is a mention chip like any other: `buildRefPillNodes`
3400
+ * appends its own trailing space to EVERY pill it builds, the positional
3401
+ * `imageRef` node included, so `a man wearing {image:1:hat} in the park` is the
3402
+ * ordinary shape and it expanded to "…the hat from reference image A in the
3403
+ * park". Splice through `spliceMentionPhrase` for the same seam tidy the three
3404
+ * `@`-mention resolvers use, right-to-left over offsets taken against the
3405
+ * pre-splice string (its OFFSET SAFETY note covers exactly this loop). The
3406
+ * legacy `expandImageRefTokensForRefs` keeps its raw `.replace` — legacy output
3407
+ * is pinned byte-identical.
3408
+ */
2893
3409
  function expandImageRefTokensHybrid(
2894
3410
  prompt: string,
2895
3411
  refs: readonly ConnectedReference[],
2896
3412
  finalIndexByUrl: ReadonlyMap<string, number>,
2897
3413
  ): string {
2898
- return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
2899
- const n = parseInt(num, 10)
3414
+ const splices: Array<{ offset: number; length: number; phrase: string }> = []
3415
+ for (const m of prompt.matchAll(IMAGE_TOKEN_PATTERN)) {
3416
+ const n = parseInt(m[1], 10)
2900
3417
  const ref = refs[n - 1]
2901
3418
  const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
2902
- if (!finalIdx) return match
2903
- return hybridRolePhrase((label ?? "").trim(), slotToLetter(finalIdx))
2904
- })
3419
+ if (!finalIdx) continue
3420
+ splices.push({
3421
+ offset: m.index ?? 0,
3422
+ length: m[0].length,
3423
+ phrase: hybridRolePhrase((m[2] ?? "").trim(), slotToLetter(finalIdx)),
3424
+ })
3425
+ }
3426
+
3427
+ // `matchAll` yields ascending offsets; splice back-to-front so the
3428
+ // not-yet-applied (smaller) offsets stay valid.
3429
+ let resolved = prompt
3430
+ for (let i = splices.length - 1; i >= 0; i--) {
3431
+ const s = splices[i]
3432
+ resolved = spliceMentionPhrase(resolved, s.offset, s.length, s.phrase)
3433
+ }
3434
+ return resolved
2905
3435
  }
2906
3436
 
2907
3437
  /** Capitalize the first alphabetic character of a string (line-initial). */
@@ -2911,15 +3441,25 @@ function capitalizeLineInitial(line: string): string {
2911
3441
 
2912
3442
  /** Render the user prompt as the hybrid scene: each `{image:N:label}` token
2913
3443
  * expanded to its uniform lettered phrase, each line's first letter
2914
- * capitalized. No per-role special-casing — the label drives the phrase. */
3444
+ * capitalized. No per-role special-casing — the label drives the phrase.
3445
+ *
3446
+ * The capitalizer STOPS at the `[style]` section and everything after it: the
3447
+ * header would become `[Style]:`, and every catalog clause under it would gain
3448
+ * a capital it was not written with. The header line is matched EXACTLY — the
3449
+ * composer never indents it, and the whitespace collapses on this path are
3450
+ * horizontal-only (`[^\S\r\n]`), so nothing can pad it before this runs. */
2915
3451
  function buildHybridScene(
2916
3452
  prompt: string,
2917
3453
  refs: readonly ConnectedReference[],
2918
3454
  finalIndexByUrl: ReadonlyMap<string, number>,
2919
3455
  ): string {
2920
- return prompt
2921
- .split("\n")
2922
- .map((line) => capitalizeLineInitial(expandImageRefTokensHybrid(line, refs, finalIndexByUrl)))
3456
+ const lines = prompt.split("\n")
3457
+ const sectionAt = lines.indexOf(STYLE_SECTION_HEADER)
3458
+ return lines
3459
+ .map((line, i) => {
3460
+ const expanded = expandImageRefTokensHybrid(line, refs, finalIndexByUrl)
3461
+ return sectionAt >= 0 && i >= sectionAt ? expanded : capitalizeLineInitial(expanded)
3462
+ })
2923
3463
  .join("\n")
2924
3464
  }
2925
3465