@nodaro/prompts 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +243 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +149 -3
- package/dist/index.d.ts +149 -3
- package/dist/index.js +239 -70
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-image-input.test.ts +30 -0
- package/src/__tests__/described-references-video.test.ts +250 -0
- package/src/__tests__/described-references.test.ts +595 -0
- package/src/__tests__/doctrine-roster-completeness.test.ts +1 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +313 -35
- package/src/__tests__/minor-age-floor.test.ts +4 -4
- package/src/__tests__/person-body-axes.test.ts +2 -2
- package/src/__tests__/person-exposure-hints.test.ts +59 -4
- package/src/__tests__/retired-hint-strings.test.ts +68 -0
- package/src/__tests__/subject-registry.test.ts +1 -1
- package/src/__tests__/w1b-rephrase.test.ts +119 -0
- package/src/age-floor.ts +65 -2
- package/src/assemble-image-input.ts +11 -1
- package/src/described-references.ts +138 -0
- package/src/index.ts +1 -0
- package/src/person.ts +18 -17
- package/src/pose.ts +1 -1
- package/src/prompt-builder.ts +196 -19
- package/src/styling.ts +38 -2
- package/src/video-reference-resolver.ts +85 -10
|
@@ -14,15 +14,15 @@ describe("exposure hint collapsing", () => {
|
|
|
14
14
|
const hints = buildPersonHints({
|
|
15
15
|
distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"],
|
|
16
16
|
} as never)
|
|
17
|
-
const exposure = hints.filter((h) => /
|
|
18
|
-
expect(exposure).toEqual(["
|
|
17
|
+
const exposure = hints.filter((h) => /cropped hemline|navel|stomach/i.test(h))
|
|
18
|
+
expect(exposure).toEqual(["with a cropped hemline and the navel showing"])
|
|
19
19
|
})
|
|
20
20
|
|
|
21
21
|
it("each alone keeps its own (softened, garment-language) hint", () => {
|
|
22
22
|
const midriffOnly = buildPersonHints({
|
|
23
23
|
distinctiveFeature: ["feature-midriff-visible"],
|
|
24
24
|
} as never)
|
|
25
|
-
expect(midriffOnly).toContain("
|
|
25
|
+
expect(midriffOnly).toContain("with a cropped hemline")
|
|
26
26
|
expect(midriffOnly.join(" ")).not.toMatch(/bare stomach/i)
|
|
27
27
|
|
|
28
28
|
const navelOnly = buildPersonHints({
|
|
@@ -40,7 +40,7 @@ describe("exposure hint collapsing", () => {
|
|
|
40
40
|
"feature-freckles",
|
|
41
41
|
],
|
|
42
42
|
} as never)
|
|
43
|
-
expect(hints.filter((h) => /
|
|
43
|
+
expect(hints.filter((h) => /cropped hemline/i.test(h))).toHaveLength(1)
|
|
44
44
|
expect(hints.length).toBeGreaterThan(1)
|
|
45
45
|
})
|
|
46
46
|
})
|
|
@@ -59,3 +59,58 @@ describe("bold-lips cross-catalog dedupe", () => {
|
|
|
59
59
|
expect(hints.join(" ")).toMatch(/bold lips/i)
|
|
60
60
|
})
|
|
61
61
|
})
|
|
62
|
+
|
|
63
|
+
describe("cropped-clause de-stack across the person↔styling boundary (W1-b)", () => {
|
|
64
|
+
it("the midriff person clause suppresses BOTH styling cropped twins on the shared bag", () => {
|
|
65
|
+
const hints = buildStylingHints({
|
|
66
|
+
distinctiveFeature: ["feature-midriff-visible"],
|
|
67
|
+
top: "top-crop-top",
|
|
68
|
+
wardrobeState: ["state-cropped"],
|
|
69
|
+
} as never)
|
|
70
|
+
const joined = hints.join(", ")
|
|
71
|
+
expect(joined).not.toMatch(/cropped top|cropped above the midriff/i)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("without the person clause, the garment wins and the modifier yields", () => {
|
|
75
|
+
const hints = buildStylingHints({
|
|
76
|
+
top: "top-crop-top",
|
|
77
|
+
wardrobeState: ["state-cropped"],
|
|
78
|
+
} as never)
|
|
79
|
+
const joined = hints.join(", ")
|
|
80
|
+
expect(joined).toMatch(/wearing a cropped top that ends above the midriff/i)
|
|
81
|
+
expect(joined).not.toMatch(/the top cropped above the midriff with the stomach visible/i)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("a MINOR who picks midriff + crop-top still gets exactly one cropped clause", () => {
|
|
85
|
+
// The person clause is dropped by the floor (feature-midriff-visible is
|
|
86
|
+
// adultOnly), so the styling twin must NOT be suppressed — otherwise the
|
|
87
|
+
// floor silently deletes a garment detail instead of a body detail.
|
|
88
|
+
const hints = buildStylingHints({
|
|
89
|
+
age: "age-child",
|
|
90
|
+
distinctiveFeature: ["feature-midriff-visible"],
|
|
91
|
+
top: "top-crop-top",
|
|
92
|
+
} as never)
|
|
93
|
+
expect(hints.join(", ")).toMatch(/wearing a cropped top that ends above the midriff/i)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("either styling id alone is untouched (no person clause, no twin)", () => {
|
|
97
|
+
expect(buildStylingHints({ wardrobeState: ["state-cropped"] } as never).join(", "))
|
|
98
|
+
.toMatch(/the top cropped above the midriff/i)
|
|
99
|
+
expect(buildStylingHints({ top: "top-crop-top" } as never).join(", "))
|
|
100
|
+
.toMatch(/wearing a cropped top/i)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("de-stacks a BARE-STRING wardrobeState too, not just the array form", () => {
|
|
104
|
+
// `wardrobeState` is a single-pick-or-multi field (string | string[]), and
|
|
105
|
+
// `normalizeSubjectFields` unwraps a lone array pick to a bare string
|
|
106
|
+
// before the styling collector ever sees it — the single-pick case is the
|
|
107
|
+
// common one, not an edge case, so the suppression must not be array-only.
|
|
108
|
+
const hints = buildStylingHints({
|
|
109
|
+
distinctiveFeature: "feature-midriff-visible",
|
|
110
|
+
top: "top-crop-top",
|
|
111
|
+
wardrobeState: "state-cropped",
|
|
112
|
+
} as never)
|
|
113
|
+
const joined = hints.join(", ")
|
|
114
|
+
expect(joined).not.toMatch(/cropped top|cropped above the midriff/i)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { getAdultOnlyHintStrings, RETIRED_ADULT_ONLY_HINT_STRINGS } from "../age-floor.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* W1-b (spec 2026-09-01 §3.3) rewords 17 catalog hints that W1-a (PR 2) had
|
|
6
|
+
* flagged `adultOnly`. The backend `minor-age-floor` policy strips sentences
|
|
7
|
+
* by STRING, so a rewording silently narrows the floor for every consumer
|
|
8
|
+
* still on the pre-rephrase `@nodaro/prompts` — including person.nodaro.ai,
|
|
9
|
+
* whose client-assembled `seedPrompt` is precisely how the 2026-07-30 P0
|
|
10
|
+
* prompts carried `state-fitted`'s clause to the provider.
|
|
11
|
+
*
|
|
12
|
+
* The retired strings therefore stay in the strip set forever. A retired
|
|
13
|
+
* string can only over-strip, and only for a subject already judged a minor.
|
|
14
|
+
*/
|
|
15
|
+
describe("retired adult-only wording stays inside the floor", () => {
|
|
16
|
+
it("every retired string is still returned by getAdultOnlyHintStrings()", () => {
|
|
17
|
+
const live = new Set(getAdultOnlyHintStrings())
|
|
18
|
+
const missing = RETIRED_ADULT_ONLY_HINT_STRINGS.filter((s) => !live.has(s.toLowerCase()))
|
|
19
|
+
expect(missing, "retired strings dropped out of the floor").toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("the 2026-07-30 incident clause is in the set by name", () => {
|
|
23
|
+
expect(getAdultOnlyHintStrings()).toContain(
|
|
24
|
+
"the clothing fitted and form-conscious, hugging the contours of the body",
|
|
25
|
+
)
|
|
26
|
+
expect(getAdultOnlyHintStrings()).toContain("wearing a cropped style, midriff and navel visible")
|
|
27
|
+
expect(getAdultOnlyHintStrings()).toContain("with lips slightly parted, taking a soft breath")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("the NEW wording is a live needle too — the floor tracks the rephrase forward", () => {
|
|
31
|
+
// The derivation makes this true by construction, but nothing else states
|
|
32
|
+
// it: a future edit that drops a hint under the `length >= 8` filter, or
|
|
33
|
+
// an entry quietly losing `adultOnly`, would narrow the floor silently.
|
|
34
|
+
// (Green only after Task 5; see that task's Step 7 run set.)
|
|
35
|
+
const strings = getAdultOnlyHintStrings()
|
|
36
|
+
expect(strings).toContain("tailored, close-fitting clothing")
|
|
37
|
+
expect(strings).toContain("with a cropped hemline")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("no bare derived TERM leaked into the needle set (the promptHint-only contract)", () => {
|
|
41
|
+
const strings = getAdultOnlyHintStrings()
|
|
42
|
+
for (const t of ["lounging", "cropped top", "school uniform", "lying down", "wet clothing", "bare shoulders"]) {
|
|
43
|
+
expect(strings, `term "${t}" must never be a needle`).not.toContain(t)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("every retired string is lower-case and long enough to survive the length filter", () => {
|
|
48
|
+
for (const s of RETIRED_ADULT_ONLY_HINT_STRINGS) {
|
|
49
|
+
expect(s, s).toBe(s.toLowerCase())
|
|
50
|
+
expect(s.length, s).toBeGreaterThanOrEqual(8)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("the set covers all 15 flagged rephrased entries plus the fold literal", () => {
|
|
55
|
+
// 15 flagged promptHints (the 14 flagged person/styling entries plus the
|
|
56
|
+
// pose twin) + the hard-coded fold literal = 16. `promptHint`s only —
|
|
57
|
+
// never terms. A change to the count must be a deliberate edit here, not
|
|
58
|
+
// a silent drift.
|
|
59
|
+
expect(RETIRED_ADULT_ONLY_HINT_STRINGS.length).toBe(16)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("the list is longest-first, matching getAdultOnlyHintStrings' contract", () => {
|
|
63
|
+
const strings = getAdultOnlyHintStrings()
|
|
64
|
+
for (let i = 1; i < strings.length; i++) {
|
|
65
|
+
expect(strings[i - 1].length).toBeGreaterThanOrEqual(strings[i].length)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -218,7 +218,7 @@ describe("renderSubjectHints — the flat-bag behaviors", () => {
|
|
|
218
218
|
{ distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"] },
|
|
219
219
|
IMAGE,
|
|
220
220
|
)
|
|
221
|
-
expect(out).toEqual(["
|
|
221
|
+
expect(out).toEqual(["with a cropped hemline and the navel showing"])
|
|
222
222
|
})
|
|
223
223
|
|
|
224
224
|
it("de-duplicates an exact repeated clause, first occurrence winning", () => {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { PEOPLE } from "../person.js"
|
|
3
|
+
import { STYLINGS } from "../styling.js"
|
|
4
|
+
import { POSES } from "../pose.js"
|
|
5
|
+
import { PICKER_CATALOGS } from "../picker-catalogs.js"
|
|
6
|
+
import { RETIRED_ADULT_ONLY_HINT_STRINGS } from "../age-floor.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* W1-b — spec 2026-09-01-app-reports-triage-design.md §3.3, wording approved
|
|
10
|
+
* by §10 decisions 1 and 6.
|
|
11
|
+
*
|
|
12
|
+
* 12 adult `generate-character` prompts were refused by the provider's safety
|
|
13
|
+
* filter because stacked picker fragments read as sexualized. The principle:
|
|
14
|
+
* describe the garment or the look, not exposed skin; drop
|
|
15
|
+
* "wet / sheen / exposed / bare / hugging / soft breath"; keep the attribute
|
|
16
|
+
* renderable.
|
|
17
|
+
*
|
|
18
|
+
* SCOPE RULING, LATER RESOLVED (replay-harness verdict, internal validation
|
|
19
|
+
* results, 2026-09-02): the go/no-go criterion initially failed for TWO
|
|
20
|
+
* entries — `eye-state-half-lidded` and `feature-collarbone-visible`
|
|
21
|
+
* rendered their attribute 0/10 and 0/22 times under either wording, so
|
|
22
|
+
* their approved rewording could not be validated at the time and both
|
|
23
|
+
* shipped byte-identical in the original W1-b PR. A follow-up replay on
|
|
24
|
+
* staging found wordings that DO render, approved 2026-09-03. Both are now
|
|
25
|
+
* included in PERSON_EXPECTED below with BOTH `promptHint` and `term`
|
|
26
|
+
* reworded, like every other entry — the compact-mode term is not left
|
|
27
|
+
* carrying the old wording.
|
|
28
|
+
*
|
|
29
|
+
* This file pins BOTH fields of every reworded entry — `promptHint` (verbose
|
|
30
|
+
* mode) and `term` (compact mode) — because a rephrase that touched only one
|
|
31
|
+
* of them would leave the old wording reachable through the other. The last
|
|
32
|
+
* test is the ratchet: no retired string may reappear anywhere in the
|
|
33
|
+
* catalog corpus.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
interface Expected {
|
|
37
|
+
readonly id: string
|
|
38
|
+
readonly promptHint: string
|
|
39
|
+
readonly term: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PERSON_EXPECTED: ReadonlyArray<Expected> = [
|
|
43
|
+
{ id: "bust-very-full", promptHint: "a fuller bust", term: "fuller bust" },
|
|
44
|
+
{ id: "silhouette-hourglass", promptHint: "an hourglass figure", term: "hourglass figure" },
|
|
45
|
+
{ id: "waist-defined", promptHint: "a defined waistline", term: "defined waistline" },
|
|
46
|
+
{ id: "lip-state-glossy", promptHint: "with a glossy lip finish", term: "glossy lips" },
|
|
47
|
+
{ id: "lip-state-parted", promptHint: "with lips relaxed and slightly open, as if mid-sentence", term: "lips slightly open" },
|
|
48
|
+
{ id: "lip-state-bitten", promptHint: "lightly biting the lower lip", term: "biting lower lip" },
|
|
49
|
+
{ id: "eye-state-staring-camera", promptHint: "looking directly into the camera", term: "direct gaze to camera" },
|
|
50
|
+
{ id: "eye-state-half-lidded", promptHint: "with drowsy, partly closed eyes, the lids sitting low over the iris", term: "drowsy, partly closed eyes" },
|
|
51
|
+
{ id: "texture-dewy", promptHint: "with dewy, luminous skin", term: "dewy skin" },
|
|
52
|
+
{ id: "texture-glistening", promptHint: "with a light glistening sheen on the skin", term: "glistening sheen" },
|
|
53
|
+
{ id: "texture-baby-soft", promptHint: "with soft, fine-pored skin", term: "soft fine-pored skin" },
|
|
54
|
+
{ id: "texture-shower-fresh-wet", promptHint: "with fresh, water-dappled skin as if just out of the shower", term: "water-dappled skin" },
|
|
55
|
+
{ id: "feature-bare-shoulders", promptHint: "with the shoulders uncovered", term: "shoulders uncovered" },
|
|
56
|
+
{ id: "feature-collarbone-visible", promptHint: "with an open neckline that leaves the collarbones uncovered", term: "open neckline, collarbones uncovered" },
|
|
57
|
+
{ id: "feature-midriff-visible", promptHint: "with a cropped hemline", term: "cropped hemline" },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const STYLING_EXPECTED: ReadonlyArray<Expected> = [
|
|
61
|
+
{ id: "state-fitted", promptHint: "tailored, close-fitting clothing", term: "close-fitting clothing" },
|
|
62
|
+
{ id: "state-wet", promptHint: "the clothing soaked through and dripping", term: "soaked clothing" },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
const POSE_EXPECTED: ReadonlyArray<Expected> = [
|
|
66
|
+
{ id: "biting-lip", promptHint: "lightly biting the lower lip, a subtle playful expression", term: "biting lower lip" },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
function check(catalog: ReadonlyArray<{ id: string; promptHint: string; term?: string }>, expected: ReadonlyArray<Expected>) {
|
|
70
|
+
for (const want of expected) {
|
|
71
|
+
const entry = catalog.find((e) => e.id === want.id)
|
|
72
|
+
expect(entry, `missing entry ${want.id}`).toBeDefined()
|
|
73
|
+
expect(entry!.promptHint, `${want.id}.promptHint`).toBe(want.promptHint)
|
|
74
|
+
expect(entry!.term, `${want.id}.term (authored, not label-derived)`).toBe(want.term)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("W1-b rephrase — the 17 reworded entries plus the pose twin", () => {
|
|
79
|
+
it("person entries carry the approved promptHint and term", () => {
|
|
80
|
+
check(PEOPLE, PERSON_EXPECTED)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("eye-state-half-lidded and feature-collarbone-visible keep adultOnly through the rework", () => {
|
|
84
|
+
const halfLidded = PEOPLE.find((e) => e.id === "eye-state-half-lidded")
|
|
85
|
+
expect(halfLidded?.adultOnly).toBe(true)
|
|
86
|
+
|
|
87
|
+
const collarbone = PEOPLE.find((e) => e.id === "feature-collarbone-visible")
|
|
88
|
+
expect(collarbone?.adultOnly).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("styling entries carry the approved promptHint and term", () => {
|
|
92
|
+
check(STYLINGS, STYLING_EXPECTED)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("the pose twin of lip-state-bitten is reworded to match", () => {
|
|
96
|
+
check(POSES, POSE_EXPECTED)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("all 18 ids are covered — the count is deliberate, not incidental", () => {
|
|
100
|
+
expect(PERSON_EXPECTED.length + STYLING_EXPECTED.length + POSE_EXPECTED.length).toBe(18)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("RATCHET: no retired string survives anywhere in the catalog corpus", () => {
|
|
104
|
+
const offenders: string[] = []
|
|
105
|
+
for (const cat of PICKER_CATALOGS) {
|
|
106
|
+
const options = [...(cat.options ?? []), ...(cat.dimensions ?? []).flatMap((d) => d.options)]
|
|
107
|
+
for (const o of options) {
|
|
108
|
+
for (const retired of RETIRED_ADULT_ONLY_HINT_STRINGS) {
|
|
109
|
+
// The fold literal is not an entry field; it is pinned separately in
|
|
110
|
+
// person-exposure-hints.test.ts.
|
|
111
|
+
if (o.promptHint === retired || o.term === retired) {
|
|
112
|
+
offenders.push(`${cat.catalogId} • ${o.id} • "${retired}"`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
expect(offenders, `retired wording is still live:\n${offenders.join("\n")}`).toEqual([])
|
|
118
|
+
})
|
|
119
|
+
})
|
package/src/age-floor.ts
CHANGED
|
@@ -65,10 +65,73 @@ export function getAdultOnlyIds(): ReadonlySet<string> {
|
|
|
65
65
|
return new Set(getAdultOnlyEntries().map((e) => e.id))
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The pre-rework `promptHint` of every `adultOnly` entry the spec
|
|
70
|
+
* (2026-09-01-app-reports-triage-design §3.3) listed for rewording — including
|
|
71
|
+
* `eye-state-half-lidded` and `feature-collarbone-visible`, which the W1-b
|
|
72
|
+
* harness initially left byte-identical (0/10 and 0/22 render rate under
|
|
73
|
+
* either wording) and a 2026-09-03 replay then found renderable wordings for
|
|
74
|
+
* — plus the hard-coded midriff+navel fold clause. These strings are permanently part of the strip
|
|
75
|
+
* set: a consumer that has not bumped `@nodaro/prompts` still emits them, and
|
|
76
|
+
* the client-assembled `seedPrompt` path is exactly how the 2026-07-30
|
|
77
|
+
* minor-age prompts reached a provider. Retiring can only ever cause an EXTRA
|
|
78
|
+
* strip, and only for a subject `isMinorAge` has already judged a minor — so
|
|
79
|
+
* the list only grows, never shrinks.
|
|
80
|
+
*
|
|
81
|
+
* `promptHint` ONLY, for the same reason `getAdultOnlyHintStrings` gives:
|
|
82
|
+
* `term` is short display vocabulary and the composed-catalog projection
|
|
83
|
+
* back-fills a derived `term` for every option, so terms collide with benign
|
|
84
|
+
* everyday text. Assembled prompts and client seedPrompts are built from
|
|
85
|
+
* hints, never from terms. The three W1-b entries that are NOT flagged —
|
|
86
|
+
* `texture-dewy`, `texture-baby-soft`, `eye-state-staring-camera` (spec
|
|
87
|
+
* §3.3's "deliberately not flagged" list) — are absent: their old wording
|
|
88
|
+
* never belonged to the floor.
|
|
89
|
+
*
|
|
90
|
+
* 15 flagged hints + the fold literal = 16.
|
|
91
|
+
*/
|
|
92
|
+
export const RETIRED_ADULT_ONLY_HINT_STRINGS: ReadonlyArray<string> = [
|
|
93
|
+
// bust-very-full
|
|
94
|
+
"very full bust",
|
|
95
|
+
// silhouette-hourglass
|
|
96
|
+
"hourglass silhouette",
|
|
97
|
+
// waist-defined
|
|
98
|
+
"defined waist",
|
|
99
|
+
// lip-state-glossy
|
|
100
|
+
"with high-shine glossy wet-look lips",
|
|
101
|
+
// lip-state-parted
|
|
102
|
+
"with lips slightly parted, taking a soft breath",
|
|
103
|
+
// lip-state-bitten
|
|
104
|
+
"playfully biting the lower lip",
|
|
105
|
+
// eye-state-half-lidded
|
|
106
|
+
"with heavy half-lidded sleepy eyes",
|
|
107
|
+
// texture-glistening
|
|
108
|
+
"with glistening skin, sweat or oil catching the light",
|
|
109
|
+
// texture-shower-fresh-wet
|
|
110
|
+
"with just-out-of-the-shower wet skin, water beading on the surface and rolling in slow droplets down the curves of the body",
|
|
111
|
+
// feature-bare-shoulders
|
|
112
|
+
"with bare shoulders exposed, the line of the collarbone and shoulder muscles uncovered",
|
|
113
|
+
// feature-collarbone-visible
|
|
114
|
+
"with a prominent collarbone clearly defined and catching the light",
|
|
115
|
+
// feature-midriff-visible
|
|
116
|
+
"wearing a cropped style with the midriff visible",
|
|
117
|
+
// state-fitted — the 2026-07-30 incident clause
|
|
118
|
+
"the clothing fitted and form-conscious, hugging the contours of the body",
|
|
119
|
+
// state-wet
|
|
120
|
+
"the clothing soaked and wet, the fabric clinging to the body and dripping water",
|
|
121
|
+
// pose `biting-lip` (the lip-state-bitten twin)
|
|
122
|
+
"biting the lower lip with a subtle playful expression",
|
|
123
|
+
// the hard-coded midriff+navel fold in emitIndependentFragments — not any
|
|
124
|
+
// entry's hint, so retiring it ADDS a needle for a clause that really is
|
|
125
|
+
// emitted verbatim.
|
|
126
|
+
"wearing a cropped style, midriff and navel visible",
|
|
127
|
+
]
|
|
128
|
+
|
|
68
129
|
/** Every full prompt-hint string a flagged entry can inject, lower-cased,
|
|
69
130
|
* longest first — the backend policy strips text that contains any of them
|
|
70
131
|
* (Layer 2), which is how flagged wording arriving inside free text (a
|
|
71
|
-
* client-assembled seedPrompt) is caught.
|
|
132
|
+
* client-assembled seedPrompt) is caught. Seeded with
|
|
133
|
+
* `RETIRED_ADULT_ONLY_HINT_STRINGS` so a catalog rewording can never narrow
|
|
134
|
+
* the strip set for a consumer still shipping the pre-rephrase wording.
|
|
72
135
|
*
|
|
73
136
|
* `promptHint` ONLY, deliberately — `term` is analyzer/UI vocabulary
|
|
74
137
|
* (compact-mode display), and `picker-catalogs.ts`'s composed-catalog
|
|
@@ -81,7 +144,7 @@ export function getAdultOnlyIds(): ReadonlySet<string> {
|
|
|
81
144
|
* can actually arrive verbatim in free text — terms don't need to be (and
|
|
82
145
|
* must not be) swept here. */
|
|
83
146
|
export function getAdultOnlyHintStrings(): ReadonlyArray<string> {
|
|
84
|
-
const out = new Set<string>()
|
|
147
|
+
const out = new Set<string>(RETIRED_ADULT_ONLY_HINT_STRINGS)
|
|
85
148
|
for (const e of getAdultOnlyEntries()) {
|
|
86
149
|
if (e.promptHint) out.add(e.promptHint.toLowerCase())
|
|
87
150
|
}
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
type SlottedPromptClause,
|
|
61
61
|
} from "./prompt-style-section.js"
|
|
62
62
|
import { keepableDirectionHints } from "./hint-shedding.js"
|
|
63
|
-
import type { CharacterDef, ConnectedReference, IdentityMeta } from "@nodaro/shared"
|
|
63
|
+
import type { CharacterDef, ConnectedReference, DescribedReference, IdentityMeta } from "@nodaro/shared"
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* Flat cinematic-direction ids the Studio framing UI, the MCP route and the
|
|
@@ -98,6 +98,13 @@ export interface AssembleImageInput {
|
|
|
98
98
|
* (gated per provider there). Omit when the caller wires only raw URLs.
|
|
99
99
|
*/
|
|
100
100
|
connectedReferences?: ConnectedReference[]
|
|
101
|
+
/**
|
|
102
|
+
* References the caller can NAME and DESCRIBE but has no media for — an
|
|
103
|
+
* un-bound cast role, an analysis slot. They attach no URL and claim no
|
|
104
|
+
* `Image N` slot; `buildImagePrompt` renders them as prose. Present with no
|
|
105
|
+
* `connectedReferences` is the normal case, so they are forwarded on their own.
|
|
106
|
+
*/
|
|
107
|
+
describedReferences?: readonly DescribedReference[]
|
|
101
108
|
/**
|
|
102
109
|
* Flat cinematic-direction ids → folded into the prompt as hints. Studio /
|
|
103
110
|
* MCP-route use, and the platform callers' narrow-read of a node's STORED
|
|
@@ -295,6 +302,9 @@ export function assembleImageInput(
|
|
|
295
302
|
...(input.connectedReferences !== undefined
|
|
296
303
|
? { connectedReferences: input.connectedReferences }
|
|
297
304
|
: {}),
|
|
305
|
+
...(input.describedReferences !== undefined
|
|
306
|
+
? { describedReferences: input.describedReferences }
|
|
307
|
+
: {}),
|
|
298
308
|
// Manual uploads / direct refs ride the builder's reference-URL channel so
|
|
299
309
|
// the per-provider reference gate filters them alongside bound entities.
|
|
300
310
|
// Omit the field entirely when absent so the builder's default ([]) kicks
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Described references + per-reference description overrides + rail captions —
|
|
3
|
+
* the ONE place either lane phrases "what this reference IS".
|
|
4
|
+
*
|
|
5
|
+
* Three shapes, one grammar (`<subject> — <description>.`) plus the caption's
|
|
6
|
+
* `<binding>: <caption>.`:
|
|
7
|
+
*
|
|
8
|
+
* - a DESCRIBED reference (`DescribedReference`, no media) → `Natalie — a
|
|
9
|
+
* tall woman in a red coat.` The subject is the NAME, because a described
|
|
10
|
+
* reference has no seat to bind to: correlation with the prose is by name,
|
|
11
|
+
* which is why the caller leaves the name in the prompt rather than an
|
|
12
|
+
* indexed `@slug:N` mention (that grammar is url-gated).
|
|
13
|
+
* - a per-use `descriptionOverride` on a reference the HYBRID format gives no
|
|
14
|
+
* description slot (a mention / canonical-fallback / location / object role
|
|
15
|
+
* phrase carries none) → `reference image A — a tall woman in a red coat.`
|
|
16
|
+
* Same grammar, the BINDING as the subject. Where a description slot DOES
|
|
17
|
+
* exist (every legacy bullet, and the hybrid extras' `, <desc>` clause) the
|
|
18
|
+
* override fills THAT slot instead, so the model is never told twice.
|
|
19
|
+
* - a rail CAPTION for a video / audio reference → `@video_1: the establishing
|
|
20
|
+
* drone shot.` Index-aligned with the caller's url array.
|
|
21
|
+
*
|
|
22
|
+
* Rendering is separate from JOINING on purpose: the lines are format-agnostic
|
|
23
|
+
* text, and each lane already owns where a trailing directive goes (the image
|
|
24
|
+
* hybrid's `trailingLines`, the video core's `trailingLines` /
|
|
25
|
+
* `allFallbackLines`). `appendReferenceLines` is the joiner for the call sites
|
|
26
|
+
* that have no such array to push into — it bullets the lines into the legacy
|
|
27
|
+
* "Use these characters:" block or lands them ahead of the `[style]` section in
|
|
28
|
+
* hybrid, matching what each lane's own assembly does.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { DescribedReference } from "@nodaro/shared"
|
|
32
|
+
import { insertBeforeStyleSection } from "./prompt-style-section.js"
|
|
33
|
+
|
|
34
|
+
/** Which reference-prompt format the lines are being rendered for. */
|
|
35
|
+
export type ReferenceLineFormat = "legacy" | "hybrid"
|
|
36
|
+
|
|
37
|
+
/** The legacy directive block every lane consolidates its bullets into. */
|
|
38
|
+
const CHARACTER_BLOCK_HEADER = "Use these characters:"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The ONE phrasing for "this subject is described as …":
|
|
42
|
+
* `<subject> — <description>.`
|
|
43
|
+
*
|
|
44
|
+
* `subject` is the name for a described reference and the reference's binding
|
|
45
|
+
* (`reference image A` / `@image_2`) for a per-use description override. Both
|
|
46
|
+
* halves are trimmed; an empty half yields `""` (the caller drops the line).
|
|
47
|
+
*/
|
|
48
|
+
export function referenceDescriptionLine(
|
|
49
|
+
subject: string,
|
|
50
|
+
description: string | null | undefined,
|
|
51
|
+
): string {
|
|
52
|
+
const s = subject.trim()
|
|
53
|
+
const d = description?.trim()
|
|
54
|
+
if (!s || !d) return ""
|
|
55
|
+
return `${s} — ${d}.`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render the described references (name + description, no media) as trailing
|
|
60
|
+
* lines. Entries missing either half contribute nothing; entries repeating a
|
|
61
|
+
* name (case-insensitively) render once — a description a caller sent twice
|
|
62
|
+
* says nothing new to the model, and every sibling renderer in both lanes
|
|
63
|
+
* dedups the same way.
|
|
64
|
+
*/
|
|
65
|
+
export function renderDescribedReferenceLines(
|
|
66
|
+
refs: readonly DescribedReference[] | undefined,
|
|
67
|
+
): string[] {
|
|
68
|
+
if (!refs || refs.length === 0) return []
|
|
69
|
+
const lines: string[] = []
|
|
70
|
+
const seen = new Set<string>()
|
|
71
|
+
for (const r of refs) {
|
|
72
|
+
const key = r.name?.trim().toLowerCase()
|
|
73
|
+
if (!key || seen.has(key)) continue
|
|
74
|
+
const line = referenceDescriptionLine(r.name, r.description)
|
|
75
|
+
if (!line) continue
|
|
76
|
+
seen.add(key)
|
|
77
|
+
lines.push(line)
|
|
78
|
+
}
|
|
79
|
+
return lines
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render the video / audio rail captions as `@video_N: <caption>.` /
|
|
84
|
+
* `@audio_N: <caption>.` lines, index-aligned with the caller's url arrays.
|
|
85
|
+
*
|
|
86
|
+
* Each list is bounded by the count of references of that kind that actually
|
|
87
|
+
* ship, so a caption for a url the provider cap dropped never binds a phantom
|
|
88
|
+
* `@video_N`. Blank captions are holes in an aligned array, not lines.
|
|
89
|
+
*/
|
|
90
|
+
export function renderReferenceCaptionLines(
|
|
91
|
+
videoCaptions: readonly string[] | undefined,
|
|
92
|
+
audioCaptions: readonly string[] | undefined,
|
|
93
|
+
counts: { readonly video: number; readonly audio: number },
|
|
94
|
+
): string[] {
|
|
95
|
+
const lines: string[] = []
|
|
96
|
+
const render = (captions: readonly string[] | undefined, kind: "video" | "audio", cap: number) => {
|
|
97
|
+
if (!captions) return
|
|
98
|
+
for (let i = 0; i < captions.length && i < cap; i++) {
|
|
99
|
+
const text = captions[i]?.trim()
|
|
100
|
+
if (!text) continue
|
|
101
|
+
lines.push(`@${kind}_${i + 1}: ${text}.`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
render(videoCaptions, "video", counts.video)
|
|
105
|
+
render(audioCaptions, "audio", counts.audio)
|
|
106
|
+
return lines
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Append reference lines to an assembled prompt the way the surrounding lane
|
|
111
|
+
* would have.
|
|
112
|
+
*
|
|
113
|
+
* - hybrid → trailing scene directives ahead of the `[style]` section (the
|
|
114
|
+
* section has no terminator, so a flat append would read as one more look
|
|
115
|
+
* clause).
|
|
116
|
+
* - legacy → `- ` bullets consolidated into the existing
|
|
117
|
+
* "Use these characters:" block, or a new block prepended ahead of the
|
|
118
|
+
* body — the same splice-or-create both lanes already do for their
|
|
119
|
+
* canonical-fallback and extra-ref bullets.
|
|
120
|
+
*
|
|
121
|
+
* No lines → the prompt is returned unchanged, byte-for-byte.
|
|
122
|
+
*/
|
|
123
|
+
export function appendReferenceLines(
|
|
124
|
+
prompt: string,
|
|
125
|
+
lines: readonly string[],
|
|
126
|
+
format: ReferenceLineFormat,
|
|
127
|
+
): string {
|
|
128
|
+
if (lines.length === 0) return prompt
|
|
129
|
+
if (format === "hybrid") return insertBeforeStyleSection(prompt, lines)
|
|
130
|
+
const bullets = lines.map((l) => `- ${l}`).join("\n")
|
|
131
|
+
if (prompt.startsWith(`${CHARACTER_BLOCK_HEADER}\n`)) {
|
|
132
|
+
const splitIdx = prompt.indexOf("\n\n")
|
|
133
|
+
if (splitIdx === -1) return `${prompt}\n${bullets}`
|
|
134
|
+
return `${prompt.slice(0, splitIdx)}\n${bullets}${prompt.slice(splitIdx)}`
|
|
135
|
+
}
|
|
136
|
+
const block = `${CHARACTER_BLOCK_HEADER}\n${bullets}`
|
|
137
|
+
return prompt ? `${block}\n\n${prompt}` : block
|
|
138
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export * from "./term.js"
|
|
|
16
16
|
export * from "./parameter-prompt-hint.js"
|
|
17
17
|
export * from "./entity-prompts.js"
|
|
18
18
|
export * from "./brand-tokens.js"
|
|
19
|
+
export * from "./described-references.js"
|
|
19
20
|
export * from "./prompt-builder.js"
|
|
20
21
|
export * from "./prompt-builder-structured-fields.js"
|
|
21
22
|
export * from "./direction-registry.js"
|