@nodaro/shared 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "1.4.2",
3
+ "version": "1.6.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -39,14 +39,13 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "fast-json-patch": "^3.1.1",
42
- "zod": "^3.24.2"
42
+ "zod": "^4.4.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@vitest/coverage-v8": "^4.1.2",
46
46
  "tsup": "^8.5.0",
47
47
  "typescript": "^5.7.3",
48
- "vitest": "^4.1.2",
49
- "zod-to-json-schema": "^3.25.2"
48
+ "vitest": "^4.1.2"
50
49
  },
51
50
  "engines": {
52
51
  "node": ">=22"
@@ -136,12 +136,53 @@ describe("parseCharacterMentionToken", () => {
136
136
  })
137
137
  })
138
138
 
139
- it("4-part: @kira:1:smile:invalid invalid mode rejects the entire token", () => {
140
- expect(parseCharacterMentionToken("@kira:1:smile:invalid")).toBeNull()
139
+ // Variant + Role Separation: the 4th segment is no longer mode-only. A
140
+ // non-mode slug parses as a per-mention ROLE (curated or custom) that
141
+ // COEXISTS with the variant — the variant picks the image, the role picks
142
+ // the phrase. The `role` key is OMITTED (not null) for every other shape so
143
+ // pre-existing parses stay byte-identical.
144
+ it("4-part: @kira:1:walking:clothes — variant + non-mode ROLE coexist", () => {
145
+ expect(parseCharacterMentionToken("@kira:1:walking:clothes")).toEqual({
146
+ characterSlug: "kira",
147
+ imageIndex: 1,
148
+ variantSlug: "walking",
149
+ usageMode: null,
150
+ role: "clothes",
151
+ })
152
+ })
153
+
154
+ it("4-part: @kira:1:smile:invalid — a custom 4th-segment slug parses as a role (was rejected)", () => {
155
+ expect(parseCharacterMentionToken("@kira:1:smile:invalid")).toEqual({
156
+ characterSlug: "kira",
157
+ imageIndex: 1,
158
+ variantSlug: "smile",
159
+ usageMode: null,
160
+ role: "invalid",
161
+ })
162
+ })
163
+
164
+ it("4-part role + lock sentinel compose: @kira:1:walking:clothes~lock", () => {
165
+ expect(parseCharacterMentionToken("@kira:1:walking:clothes~lock")).toEqual({
166
+ characterSlug: "kira",
167
+ imageIndex: 1,
168
+ variantSlug: "walking",
169
+ usageMode: null,
170
+ role: "clothes",
171
+ lock: true,
172
+ })
173
+ })
174
+
175
+ it("4-part mode still routes to usageMode, NOT role (back-compat)", () => {
176
+ const parsed = parseCharacterMentionToken("@kira:1:smile:face")
177
+ expect(parsed?.usageMode).toBe("face")
178
+ expect(parsed && "role" in parsed).toBe(false)
141
179
  })
142
180
 
143
- it("4-part: @kira:1:smile:smile variant-shaped 4th segment still rejects (must be a mode)", () => {
144
- expect(parseCharacterMentionToken("@kira:1:smile:smile")).toBeNull()
181
+ it("findCharacterMentionTokens surfaces the 4-part role token", () => {
182
+ const tokens = findCharacterMentionTokens("a shot of @kira:1:walking:clothes at dawn", ["kira"])
183
+ expect(tokens).toHaveLength(1)
184
+ expect(tokens[0].variantSlug).toBe("walking")
185
+ expect(tokens[0].role).toBe("clothes")
145
186
  })
146
187
 
147
188
  it("rejects 5-part tokens", () => {
@@ -191,9 +232,13 @@ describe("findCharacterMentionTokens", () => {
191
232
  it("does not parse a dash-form as a variant token", () => {
192
233
  expect(findCharacterMentionTokens("@kira-smile waves", ["kira"])).toEqual([])
193
234
  })
194
- it("4-part with invalid mode is dropped (matches regex but parser rejects)", () => {
195
- // The regex matches structurally but `parseCharacterMentionToken` rejects
196
- // the 4th segment as an invalid mode, so the token is filtered out.
197
- expect(findCharacterMentionTokens("@kira:1:smile:bogus waves", ["kira"])).toEqual([])
235
+ it("4-part with a non-mode 4th segment parses as variant + ROLE (Variant + Role Separation)", () => {
236
+ // Previously this was rejected outright; now the non-mode segment is a
237
+ // per-mention role coexisting with the variant.
238
+ const tokens = findCharacterMentionTokens("@kira:1:smile:bogus waves", ["kira"])
239
+ expect(tokens).toHaveLength(1)
240
+ expect(tokens[0].variantSlug).toBe("smile")
241
+ expect(tokens[0].role).toBe("bogus")
242
+ expect(tokens[0].usageMode).toBeNull()
198
243
  })
199
244
  })
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ CHARACTER_PICKER_DISPLAY_ORDER,
4
+ characterBucketDisplayRank,
5
+ characterMentionableAssetArrays,
6
+ sortCharacterEntriesForDisplay,
7
+ } from "../character-variant-assets"
8
+ import { CHARACTER_ATTACH_COLUMNS } from "../entity-asset-types"
9
+
10
+ describe("CHARACTER_PICKER_DISPLAY_ORDER", () => {
11
+ it("covers exactly the keys of characterMentionableAssetArrays (drift guard)", () => {
12
+ // If a new bucket is added to the data record without a display rank (or
13
+ // vice versa), pickers would silently drop/misplace it. Fail loudly here.
14
+ const dataKeys = Object.keys(characterMentionableAssetArrays({})).sort()
15
+ const displayKeys = [...CHARACTER_PICKER_DISPLAY_ORDER].sort()
16
+ expect(displayKeys).toEqual(dataKeys)
17
+ })
18
+
19
+ it("pins boards first, sheets second", () => {
20
+ expect(CHARACTER_PICKER_DISPLAY_ORDER[0]).toBe("boards")
21
+ expect(CHARACTER_PICKER_DISPLAY_ORDER[1]).toBe("sheets")
22
+ })
23
+ })
24
+
25
+ describe("characterBucketDisplayRank", () => {
26
+ it("ranks canonical (no bucket) before boards, boards before variant buckets", () => {
27
+ expect(characterBucketDisplayRank(undefined)).toBeLessThan(characterBucketDisplayRank("boards"))
28
+ expect(characterBucketDisplayRank("boards")).toBeLessThan(characterBucketDisplayRank("expressions"))
29
+ })
30
+
31
+ it("puts unknown buckets last", () => {
32
+ expect(characterBucketDisplayRank("someFutureBucket")).toBeGreaterThan(
33
+ characterBucketDisplayRank("detailCloseups"),
34
+ )
35
+ })
36
+ })
37
+
38
+ describe("sortCharacterEntriesForDisplay", () => {
39
+ it("reorders within a character run (boards first) and keeps non-character items in place", () => {
40
+ const items = [
41
+ { characterSlug: undefined, url: "upload1" },
42
+ { characterSlug: "kira", bucket: undefined, url: "canonical" },
43
+ { characterSlug: "kira", bucket: "expressions", url: "smile" },
44
+ { characterSlug: "kira", bucket: "boards", url: "board1" },
45
+ { characterSlug: undefined, url: "upload2" },
46
+ { characterSlug: "bob", bucket: "poses", url: "bobpose" },
47
+ { characterSlug: "bob", bucket: "boards", url: "bobboard" },
48
+ ]
49
+ const sorted = sortCharacterEntriesForDisplay(items)
50
+ expect(sorted.map((i) => i.url)).toEqual([
51
+ "upload1", "canonical", "board1", "smile", "upload2", "bobboard", "bobpose",
52
+ ])
53
+ })
54
+
55
+ it("is stable within the same bucket", () => {
56
+ const items = [
57
+ { characterSlug: "kira", bucket: "boards", url: "b1" },
58
+ { characterSlug: "kira", bucket: "boards", url: "b2" },
59
+ ]
60
+ expect(sortCharacterEntriesForDisplay(items).map((i) => i.url)).toEqual(["b1", "b2"])
61
+ })
62
+ })
63
+
64
+ describe("CHARACTER_ATTACH_COLUMNS", () => {
65
+ it("includes boards (worker auto-attach whitelist, mirrors migration 250)", () => {
66
+ expect(CHARACTER_ATTACH_COLUMNS).toContain("boards")
67
+ })
68
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES } from "../entity-image-handle.js"
2
+ import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES, sourceRefKey } from "../entity-image-handle.js"
3
3
 
4
4
  const ENTITY_REF_HANDLE: Record<string, string> = {
5
5
  character: "characterRef",
@@ -41,3 +41,24 @@ describe("resolveEffectiveSourceType (entity image handle → upload-image)", ()
41
41
  )
42
42
  })
43
43
  })
44
+
45
+ describe("sourceRefKey (handle-scoped ref key — prevents node-id collision)", () => {
46
+ it("scopes an entity's image handle to `${id}::image`", () => {
47
+ for (const entity of ENTITY_IMAGE_HANDLE_TYPES) {
48
+ expect(sourceRefKey("n1", "image", entity)).toBe("n1::image")
49
+ }
50
+ })
51
+
52
+ it("keeps the bare node id for the identity handle so the two refs stay distinct", () => {
53
+ expect(sourceRefKey("n1", "characterRef", "character")).toBe("n1")
54
+ // Identity vs image handle of the SAME node → distinct keys (the fix).
55
+ expect(sourceRefKey("n1", "characterRef", "character"))
56
+ .not.toBe(sourceRefKey("n1", "image", "character"))
57
+ })
58
+
59
+ it("leaves non-entity image producers and the no-handle case on the bare node id", () => {
60
+ expect(sourceRefKey("n1", "image", "generate-image")).toBe("n1")
61
+ expect(sourceRefKey("n1", "image", "upload-image")).toBe("n1")
62
+ expect(sourceRefKey("n1", undefined, "character")).toBe("n1")
63
+ })
64
+ })
@@ -89,10 +89,39 @@ describe("parseLocationMentionToken", () => {
89
89
  expect(parseLocationMentionToken("@oldlibrary:1:weather:style")).toBeNull()
90
90
  })
91
91
 
92
- it("returns null for 4-part with unknown 4th-segment mode", () => {
92
+ it("4-part with a non-mode 4th segment parses as bucket/variant + ROLE (Variant + Role Separation)", () => {
93
+ // Previously rejected; now the non-mode segment is a per-mention role
94
+ // that coexists with the variant — the variant picks the image, the
95
+ // role picks the phrase.
93
96
  expect(
94
- parseLocationMentionToken("@oldlibrary:1:weather/rain:face"),
95
- ).toBeNull()
97
+ parseLocationMentionToken("@oldlibrary:1:weather/rain:lighting"),
98
+ ).toEqual({
99
+ locationSlug: "oldlibrary",
100
+ imageIndex: 1,
101
+ bucket: "weather",
102
+ variant: "rain",
103
+ usageMode: null,
104
+ role: "lighting",
105
+ })
106
+ })
107
+
108
+ it("4-part custom role slug coexists with the variant", () => {
109
+ expect(
110
+ parseLocationMentionToken("@oldlibrary:1:weather/rain:cozy-corner"),
111
+ ).toEqual({
112
+ locationSlug: "oldlibrary",
113
+ imageIndex: 1,
114
+ bucket: "weather",
115
+ variant: "rain",
116
+ usageMode: null,
117
+ role: "cozy-corner",
118
+ })
119
+ })
120
+
121
+ it("4-part MODE still routes to usageMode, not role (back-compat)", () => {
122
+ const parsed = parseLocationMentionToken("@oldlibrary:1:weather/rain:style")
123
+ expect(parsed?.usageMode).toBe("style")
124
+ expect(parsed && "role" in parsed).toBe(false)
96
125
  })
97
126
 
98
127
  it("3-part with a bare non-mode slug parses as a custom ROLE (parser gate removed, F2)", () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { default as zodToJsonSchema } from "zod-to-json-schema"
2
+ import { z } from "zod"
3
3
  import { ChatTurnResponseSchema, STAGE_PATCH_SCHEMA } from "../pipeline-chat.js"
4
4
  import { PIPELINE_STAGE_NAMES } from "../pipeline-events.js"
5
5
 
@@ -61,7 +61,7 @@ describe("ChatTurnResponseSchema", () => {
61
61
  })
62
62
 
63
63
  it("produces JSON Schema with root type:object (Anthropic-valid)", () => {
64
- const js = zodToJsonSchema(ChatTurnResponseSchema, { target: "jsonSchema7" })
64
+ const js = z.toJSONSchema(ChatTurnResponseSchema, { target: "draft-7", unrepresentable: "any", io: "input" })
65
65
  expect((js as { type?: string }).type).toBe("object")
66
66
  })
67
67
 
@@ -8,10 +8,19 @@ describe("reference-roles registry", () => {
8
8
  for (const source of Object.keys(DEFAULT_LABEL_BY_SOURCE) as Array<keyof typeof DEFAULT_LABEL_BY_SOURCE>) {
9
9
  const presets = REFERENCE_ROLE_PRESETS[source]
10
10
  expect(presets, source).toBeTruthy()
11
- expect(presets, source).toContain(defaultRoleForSource(source))
11
+ const def = defaultRoleForSource(source)
12
+ // Media sources (manual / wired-image) default to ref-only = the empty
13
+ // label (a bare {image:N} token). Empty is not a preset role.
14
+ if (def === "") continue
15
+ expect(presets, source).toContain(def)
12
16
  }
13
17
  })
14
18
 
19
+ it("media sources default to ref-only (empty label)", () => {
20
+ expect(DEFAULT_LABEL_BY_SOURCE["manual"]).toBe("")
21
+ expect(DEFAULT_LABEL_BY_SOURCE["wired-image"]).toBe("")
22
+ })
23
+
15
24
  it("defaultRoleForSource mirrors DEFAULT_LABEL_BY_SOURCE", () => {
16
25
  expect(defaultRoleForSource("wired-character")).toBe("person")
17
26
  expect(defaultRoleForSource("wired-location")).toBe("background")
@@ -34,3 +43,14 @@ describe("reference-roles registry", () => {
34
43
  expect(roleToPhrase("hoodie", "reference image B")).toBe("the hoodie from reference image B")
35
44
  })
36
45
  })
46
+
47
+ describe("ref-only role", () => {
48
+ it("renders as the bare binding (no descriptive phrase)", () => {
49
+ expect(roleToPhrase("ref-only", "reference image A")).toBe("reference image A")
50
+ expect(roleToPhrase("ref-only", "@image_3")).toBe("@image_3")
51
+ })
52
+ it("is a curated preset for character and location (honored by the preset-gated resolvers)", () => {
53
+ expect(REFERENCE_ROLE_PRESETS["wired-character"]).toContain("ref-only")
54
+ expect(REFERENCE_ROLE_PRESETS["wired-location"]).toContain("ref-only")
55
+ })
56
+ })
@@ -31,6 +31,16 @@ export interface CharacterMentionTokenInfo {
31
31
  * other 4-part token.
32
32
  */
33
33
  readonly usageMode: UsageMode | null
34
+ /**
35
+ * Per-mention ROLE from a NON-MODE 4th segment (Variant + Role Separation) —
36
+ * coexists with a real variant: the variant picks the IMAGE, the role picks
37
+ * the PHRASE (`@kira:1:walking:clothes` → walking image, "the clothes from
38
+ * …"). Curated or custom slug, stored verbatim. OMITTED (undefined, not
39
+ * null) for every other shape — 2/3-part tokens and mode-bearing 4-part
40
+ * tokens keep their pre-existing parse byte-identically. Mirrors
41
+ * `LocationMentionTokenInfo.role`.
42
+ */
43
+ readonly role?: string
34
44
  /**
35
45
  * Additive per-mention identity-lock sentinel (Unified Reference Roles,
36
46
  * Task 4 + F4). Tri-state: `true` (trailing `~lock`, force lock ON) |
@@ -55,9 +65,11 @@ export interface CharacterMentionTokenInfo {
55
65
  * - `@kira:1:smile` → variant smile, default mode
56
66
  * - `@kira:1:face` → canonical, mode "face" (mode is detected by
57
67
  * checking the 3rd segment against `USAGE_MODES`)
58
- * - `@kira:1:smile:face` → variant smile, mode "face"
59
- * - `@kira:1:smile:bogus` null (4-part rejected when last segment is not
60
- * a valid usage mode keeps the format strict)
68
+ * - `@kira:1:smile:face` → variant smile, mode "face"
69
+ * - `@kira:1:walking:clothes` variant walking, ROLE "clothes" (Variant +
70
+ * Role Separation: a non-mode 4th segment is a
71
+ * per-mention role — curated or custom — that
72
+ * coexists with the variant)
61
73
  *
62
74
  * Format change: as of the index-in-slug update, the index segment is required.
63
75
  * Bare `@kira` is not a mention; the autocomplete always inserts at least
@@ -71,7 +83,7 @@ export interface CharacterMentionTokenInfo {
71
83
  export function parseCharacterMentionToken(
72
84
  text: string,
73
85
  _knownCharacterSlugs?: readonly string[],
74
- ): { characterSlug: string; imageIndex: number; variantSlug: string | null; usageMode: UsageMode | null; lock?: boolean } | null {
86
+ ): { characterSlug: string; imageIndex: number; variantSlug: string | null; usageMode: UsageMode | null; role?: string; lock?: boolean } | null {
75
87
  if (!text.startsWith("@")) return null
76
88
  let rest = text.slice(1)
77
89
  if (rest.length === 0 || !/^[a-z]/.test(rest)) return null
@@ -123,13 +135,19 @@ export function parseCharacterMentionToken(
123
135
  return { characterSlug, imageIndex, variantSlug: third, usageMode: null, ...lockField }
124
136
  }
125
137
 
126
- // 4-part: kira:1:smile:mode — variant + mode override. Both segments must
127
- // satisfy their respective shape; an unknown mode kills the token entirely
128
- // (callers fall back to literal text, matching unknown-character behavior).
138
+ // 4-part: kira:1:variant:X — variant + a 4th segment that is EITHER a
139
+ // usage-mode override (today's shape, routed to `usageMode` byte-identically)
140
+ // OR Variant + Role Separation — any other slug, parsed as a per-mention
141
+ // ROLE that coexists with the variant (the variant picks the image, the role
142
+ // picks the phrase). The `role` key is emitted ONLY on the non-mode branch so
143
+ // mode-bearing tokens keep their exact pre-existing shape.
129
144
  if (parts.length === 4) {
130
145
  if (!/^[a-z][a-z0-9-]*$/.test(third)) return null
131
- if (!isUsageMode(fourth)) return null
132
- return { characterSlug, imageIndex, variantSlug: third, usageMode: fourth, ...lockField }
146
+ if (!/^[a-z][a-z0-9-]*$/.test(fourth)) return null
147
+ if (isUsageMode(fourth)) {
148
+ return { characterSlug, imageIndex, variantSlug: third, usageMode: fourth, ...lockField }
149
+ }
150
+ return { characterSlug, imageIndex, variantSlug: third, usageMode: null, role: fourth, ...lockField }
133
151
  }
134
152
 
135
153
  return null
@@ -119,3 +119,64 @@ export function characterMentionableAssetArrays(
119
119
  boards: characterBoardItems(data),
120
120
  }
121
121
  }
122
+
123
+ /**
124
+ * DISPLAY order for character asset groups in picker menus (swap-picker,
125
+ * `@` autocomplete) — boards first (the highest-value identity reference),
126
+ * then sheets, then the variant buckets in data order.
127
+ *
128
+ * DISPLAY ONLY. The data-record key order of
129
+ * {@link characterMentionableAssetArrays} is payload-numbering-bearing
130
+ * (`{image:N}` / `@name:N` are positional) and MUST NOT change; menus sort
131
+ * their rendered rows by this rank instead. The guard test asserts this
132
+ * list's key set stays identical to the data record's.
133
+ */
134
+ export const CHARACTER_PICKER_DISPLAY_ORDER: readonly string[] = [
135
+ "boards",
136
+ "sheets",
137
+ ...CHARACTER_VARIANT_ASSET_BUCKETS,
138
+ ]
139
+
140
+ /**
141
+ * Display rank for a character entry's asset bucket. Canonical entries
142
+ * (no bucket — the portrait row) lead, then buckets per
143
+ * {@link CHARACTER_PICKER_DISPLAY_ORDER}; unknown buckets sink to the end.
144
+ */
145
+ export function characterBucketDisplayRank(bucket: string | undefined): number {
146
+ if (bucket === undefined) return -1
147
+ const i = CHARACTER_PICKER_DISPLAY_ORDER.indexOf(bucket)
148
+ return i === -1 ? CHARACTER_PICKER_DISPLAY_ORDER.length : i
149
+ }
150
+
151
+ /**
152
+ * Sort picker entries for display: within each CONTIGUOUS run of entries
153
+ * sharing a `characterSlug`, order by {@link characterBucketDisplayRank}
154
+ * (stable within a rank); entries without a slug — and run boundaries —
155
+ * stay exactly where they are. Runs (not a global sort) because the flat
156
+ * reference list interleaves characters with uploads/wired images whose
157
+ * positions are meaningful.
158
+ */
159
+ export function sortCharacterEntriesForDisplay<
160
+ T extends { readonly characterSlug?: string; readonly bucket?: string },
161
+ >(items: readonly T[]): T[] {
162
+ const out: T[] = []
163
+ let i = 0
164
+ while (i < items.length) {
165
+ const slug = items[i].characterSlug
166
+ if (!slug) {
167
+ out.push(items[i])
168
+ i++
169
+ continue
170
+ }
171
+ let j = i
172
+ while (j < items.length && items[j].characterSlug === slug) j++
173
+ const run = items.slice(i, j)
174
+ const ranked = run
175
+ .map((it, k) => ({ it, k, r: characterBucketDisplayRank(it.bucket) }))
176
+ .sort((a, b) => a.r - b.r || a.k - b.k)
177
+ .map((x) => x.it)
178
+ out.push(...ranked)
179
+ i = j
180
+ }
181
+ return out
182
+ }
@@ -68,6 +68,9 @@ export const CHARACTER_ATTACH_COLUMNS = [
68
68
  "sheets",
69
69
  "detail_closeups",
70
70
  "outfit_variations",
71
+ // Named composite reference boards (migration 250) — identity-collage
72
+ // sheets auto-attached by the ffmpeg image-collage worker.
73
+ "boards",
71
74
  ] as const
72
75
  export type CharacterAttachColumn = (typeof CHARACTER_ATTACH_COLUMNS)[number]
73
76
 
@@ -34,3 +34,25 @@ export function resolveEffectiveSourceType(
34
34
  }
35
35
  return rawSourceType ?? ""
36
36
  }
37
+
38
+ /**
39
+ * The ref-assembly map key (and `ConnectedReference.id`) for a wired source.
40
+ *
41
+ * An entity's `image` handle emits a plain image that is DISTINCT from the
42
+ * entity's identity `*Ref` handle, yet both edges carry the SAME source node
43
+ * id. Every ref-assembly map that keyed by the bare node id therefore had the
44
+ * two edges collide — silently dropping one (identity → a literal `@abi:N`
45
+ * token + lost character, or the plain image → missing from the picker),
46
+ * non-deterministically by edge order. Scoping the entity-`image`-handle ref to
47
+ * `${nodeId}::image` keeps the two refs distinct so BOTH survive. Every other
48
+ * (type, handle) keeps the bare node id, so nothing else changes.
49
+ */
50
+ export function sourceRefKey(
51
+ nodeId: string,
52
+ sourceHandleId: string | undefined | null,
53
+ rawSourceType: string | undefined | null,
54
+ ): string {
55
+ return sourceHandleId === "image" && ENTITY_IMAGE_HANDLE_TYPES.has(rawSourceType ?? "")
56
+ ? `${nodeId}::image`
57
+ : nodeId
58
+ }
package/src/index.ts CHANGED
@@ -706,6 +706,9 @@ export {
706
706
  characterSheetRefItems,
707
707
  characterBoardItems,
708
708
  characterMentionableAssetArrays,
709
+ CHARACTER_PICKER_DISPLAY_ORDER,
710
+ characterBucketDisplayRank,
711
+ sortCharacterEntriesForDisplay,
709
712
  } from "./character-variant-assets.js"
710
713
  export type {
711
714
  CharacterVariantAssetBucket,
@@ -107,11 +107,13 @@ export interface LocationMentionTokenInfo {
107
107
  readonly usageMode: LocationUsageMode | null
108
108
  /** Bare-slug ROLE (Unified Reference Roles, Phase D + F2) — any slash-less,
109
109
  * non-mode 3rd segment (`background`, `empty-background`, `as-is`, … or a
110
- * CUSTOM slug), stored verbatim in slug form. Absent (undefined) for canonical /
111
- * bucket-variant / mode tokens. The hybrid resolver + role pill map it back to
112
- * the phrase key via `normalizeRoleSlug` (custom slugs pass through). Optional so
113
- * canonical/variant/mode tokens stay shape-identical to the pre-Phase-D parser
114
- * output. */
110
+ * CUSTOM slug), stored verbatim in slug form OR (Variant + Role
111
+ * Separation) a non-mode 4th segment coexisting with a bucket/variant
112
+ * (`@lib:1:weather/rain:lighting` rain image, lighting phrase). Absent
113
+ * (undefined) for canonical / bare-variant / mode tokens. The hybrid
114
+ * resolver + role pill map it back to the phrase key via `normalizeRoleSlug`
115
+ * (custom slugs pass through). Optional so those tokens stay
116
+ * shape-identical to the pre-Phase-D parser output. */
115
117
  readonly role?: string
116
118
  /**
117
119
  * Additive per-mention identity-lock sentinel (Unified Reference Roles,
@@ -235,7 +237,11 @@ export function parseLocationMentionToken(
235
237
  }
236
238
  }
237
239
 
238
- // 4-part: @oldlibrary:1:bucket/variant:mode.
240
+ // 4-part: @oldlibrary:1:bucket/variant:X — X is EITHER a mode override
241
+ // (today's shape, byte-identical) OR — Variant + Role Separation — any other
242
+ // slug, parsed as a per-mention ROLE coexisting with the bucket/variant (the
243
+ // variant picks the image, the role picks the phrase). The `role` key is
244
+ // emitted only on the non-mode branch, mirroring the character parser.
239
245
  if (parts.length === 4) {
240
246
  if (!third.includes("/")) return null
241
247
  const slashAt = third.indexOf("/")
@@ -243,8 +249,11 @@ export function parseLocationMentionToken(
243
249
  const variant = third.slice(slashAt + 1)
244
250
  if (!/^[a-z][a-z0-9-]*$/.test(bucket)) return null
245
251
  if (!/^[a-z][a-z0-9-]*$/.test(variant)) return null
246
- if (!isLocationUsageMode(fourth)) return null
247
- return { locationSlug, imageIndex, bucket, variant, usageMode: fourth, ...lockField }
252
+ if (!/^[a-z][a-z0-9-]*$/.test(fourth)) return null
253
+ if (isLocationUsageMode(fourth)) {
254
+ return { locationSlug, imageIndex, bucket, variant, usageMode: fourth, ...lockField }
255
+ }
256
+ return { locationSlug, imageIndex, bucket, variant, usageMode: null, role: fourth, ...lockField }
248
257
  }
249
258
 
250
259
  return null
@@ -148,7 +148,7 @@ export const StageAwaitingSubGateEventSchema = z.object({
148
148
  pipelineId: z.string().uuid(),
149
149
  stageName: z.enum(["animate_audio_edit", "scene_images"]),
150
150
  subGate: SubGateNameSchema,
151
- payload: z.record(z.unknown()).optional(),
151
+ payload: z.record(z.string(), z.unknown()).optional(),
152
152
  })
153
153
  export type StageAwaitingSubGateEvent = z.infer<typeof StageAwaitingSubGateEventSchema>
154
154
 
@@ -1,4 +1,4 @@
1
- import { z, type ZodType, type ZodTypeDef } from "zod"
1
+ import { z, type ZodType } from "zod"
2
2
  // Reuse the canonical `OutputType` from presentation-utils so a single union
3
3
  // is the source of truth across the package. Re-exporting it here would
4
4
  // collide with the explicit named re-export in `index.ts`.
@@ -13,7 +13,7 @@ export type ReduceStrategy<TConfig = unknown> = {
13
13
  readonly label: string
14
14
  readonly description: string
15
15
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
- readonly configSchema: ZodType<TConfig, ZodTypeDef, any>
16
+ readonly configSchema: ZodType<TConfig, any>
17
17
  readonly defaultConfig: TConfig
18
18
  readonly outputType: OutputType
19
19
  readonly creditCostKey: string
@@ -9,9 +9,9 @@ import { DEFAULT_LABEL_BY_SOURCE, type ReferenceSource } from "./types.js"
9
9
  * value (guarded by reference-roles.test.ts).
10
10
  */
11
11
  export const REFERENCE_ROLE_PRESETS: Record<ReferenceSource, readonly string[]> = {
12
- "wired-character": ["person", "face", "clothes", "hair", "pose", "expression", "style"],
12
+ "wired-character": ["ref-only", "person", "face", "clothes", "hair", "pose", "expression", "style"],
13
13
  "wired-face": ["face", "person", "expression", "style"],
14
- "wired-location": ["background", "atmosphere", "as-is", "empty background", "layout", "lighting", "style"],
14
+ "wired-location": ["ref-only", "background", "atmosphere", "as-is", "empty background", "layout", "lighting", "style"],
15
15
  "wired-object": ["object", "shape", "material", "color", "texture", "style"],
16
16
  "wired-creature": ["creature", "anatomy", "markings", "pose", "color", "style"],
17
17
  "wired-image": ["object", "person", "face", "clothes", "background", "style", "pose", "texture"],
@@ -35,6 +35,10 @@ export function roleToPhrase(role: string, binding: string): string {
35
35
  const r = role.trim()
36
36
  if (!r) return binding
37
37
  switch (r) {
38
+ case "ref-only":
39
+ // Bare reference pointer — no descriptive phrase. The label-less/default
40
+ // state for media refs; an explicit pick for character/location assets.
41
+ return binding
38
42
  case "as-is":
39
43
  return `${binding}, used as-is`
40
44
  case "empty background":
@@ -89,7 +89,7 @@ export const ShotSpecSchema = z.object({
89
89
  camera_path_directive: z
90
90
  .object({
91
91
  path_kind: z.enum(["orbit", "dolly", "crane", "arc", "reveal"]),
92
- parameters: z.record(z.unknown()).optional(),
92
+ parameters: z.record(z.string(), z.unknown()).optional(),
93
93
  })
94
94
  .optional(),
95
95
 
package/src/types.ts CHANGED
@@ -82,6 +82,13 @@ export interface ConnectedReference {
82
82
  readonly characterSlug?: string
83
83
  /** Variant slug (e.g. "smile"). undefined = canonical/default. */
84
84
  readonly variantSlug?: string
85
+ /**
86
+ * Which character asset bucket this entry came from ("boards", "sheets",
87
+ * "expressions", …); undefined = the canonical portrait entry. DISPLAY
88
+ * metadata only — menus use it to rank rows (boards first) via
89
+ * `sortCharacterEntriesForDisplay`; it never affects payload numbering.
90
+ */
91
+ readonly bucket?: string
85
92
  /** Character's canonical_description. Set on every entry from the same character (used for dedup). */
86
93
  readonly characterCanonicalDescription?: string | null
87
94
  /**
@@ -205,8 +212,11 @@ export interface ConnectedReference {
205
212
 
206
213
  /** Default label per source — used by `@` autocomplete and inventory fallback. */
207
214
  export const DEFAULT_LABEL_BY_SOURCE: Record<ReferenceSource, string> = {
208
- "manual": "object",
209
- "wired-image": "object",
215
+ // Media references default to "ref-only" — the empty label, i.e. a bare
216
+ // {image:N} token that injects only "reference image A" (image) / "@image_N"
217
+ // (video) with no descriptive role phrase. Pick a role on the pill to change.
218
+ "manual": "",
219
+ "wired-image": "",
210
220
  "wired-character": "person",
211
221
  "wired-face": "face",
212
222
  "wired-object": "object",