makaron-persona-look-cli 0.5.2 → 0.5.4
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/README.md +16 -8
- package/bin/personlib.mjs +87 -5
- package/cloudflare-worker/README.md +11 -0
- package/cloudflare-worker/schema.sql +11 -0
- package/cloudflare-worker/src/worker.mjs +375 -25
- package/cloudflare-worker/test/worker.test.mjs +122 -7
- package/lib/remote-client.mjs +294 -32
- package/package.json +3 -3
- package/skills/makaron-persona-look/SKILL.md +13 -7
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## What an Agent gets
|
|
6
6
|
|
|
7
|
-
`find` is the no-generation delivery route
|
|
7
|
+
`find` is the no-generation delivery route: `--kind persona` returns matched Persona library images, and `--kind look` returns matched Look library images. `--count N` returns exactly N different references or `BLOCKED`; it does not expose IDs or selection metadata. `preview` downloads the selected Persona source/approved auxiliary views and the Look source, plus a safe `selection.json` render brief. It is intentionally a **source-reference pack**, not a synthesized image. `render` is the combined Persona + Look delivery route: it creates a locked selection for each requested result, sends those private inputs to the caller's authenticated Makaron CLI, and writes one full-body front/right-side/back turnaround sheet per selection. The Persona is the only identity reference; the Look image's face is excluded.
|
|
8
8
|
|
|
9
9
|
The raw `private-library/` is ignored by Git and is never included in the npm package. D1 holds catalog metadata, private R2 holds approved source bytes, and a Worker issues an individual credential to each enrolling Agent. The individual credential is stored only in the Agent's local mode-600 config file and is never printed by the CLI.
|
|
10
10
|
|
|
@@ -44,17 +44,22 @@ personlib remote sync --library ./private-library --json
|
|
|
44
44
|
Once setup and `remote doctor` succeed, use the route that matches the request:
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
-
# Persona-only: return
|
|
48
|
-
personlib remote find --kind persona --brief '
|
|
47
|
+
# Persona-only: return matching library image(s); no Makaron request.
|
|
48
|
+
personlib remote find --kind persona --brief '3个长相高冷的亚洲女生' --count 3 --output-dir ./personlib-personas --json
|
|
49
49
|
|
|
50
|
-
# Look-only: return
|
|
51
|
-
personlib remote find --kind look --brief '
|
|
50
|
+
# Look-only: return matching library image(s); no Makaron request.
|
|
51
|
+
personlib remote find --kind look --brief '3套 Y2K 的妆造' --count 3 --output-dir ./personlib-looks --json
|
|
52
52
|
|
|
53
|
-
# Persona + Look: render
|
|
54
|
-
personlib remote render --brief '
|
|
53
|
+
# Persona + Look: create locked selections internally, then render each exact selection.
|
|
54
|
+
personlib remote render --brief '3个都市风格的亚洲女生' --count 3 --output-dir ./personlib-turnarounds --dry-run --json
|
|
55
|
+
personlib remote render --brief '3个都市风格的亚洲女生' --count 3 --output-dir ./personlib-turnarounds --json
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
The third route is a paid external generation action. In the owner-approved direct-delivery mode,
|
|
58
|
+
The third route is a paid external generation action. `render --brief` first creates Agent-bound, 24-hour selection tokens containing the exact Persona plus catalog Look or a complete temporary Look specification, then resolves those exact tokens; it never reranks or silently swaps a result after selection. A pre-confirmed single selection can also be rendered with `--selection`. In the owner-approved direct-delivery mode, an Agent keeps tokens internal and makes exactly one Makaron submission per selection without a second user confirmation. A batch writes `turnaround-01/`, `turnaround-02/`, and so on; each directory contains one turnaround image (or a pending run ID with `--no-wait`), `turnaround-plan.json`, `prompt_used.md`, and `qc_report.md`. If fewer than `--count` distinct eligible Personas or Looks exist, the CLI returns `BLOCKED` with the available count and submits nothing. The CLI removes only brand identifiers: visible logos, wordmarks, brand names, protected monograms, recognizable trademark symbols, and brand/team crests. Ordinary stripes, numbers, abstract graphics, and shoe construction remain part of the Look. The CLI never automatically retries a paid job. Use `--dry-run` to inspect every locked request and complete prompt without downloading inputs or submitting anything, or `--no-wait` to keep returned Makaron run IDs for later retrieval.
|
|
59
|
+
|
|
60
|
+
`亚洲女生` is a hard retrieval and render condition, not a visual guess. The Worker requires both an owner-reviewed `east_asian` appearance tag and the Persona metadata `presentation: "feminine adult"`; no matching record returns `BLOCKED` before Makaron is called. `韩国女生` additionally requires `korean_style_compatible`. Appearance tags are never inferred by the running model. The current library needs owner review before any existing Persona is made eligible for these requests.
|
|
61
|
+
|
|
62
|
+
`都市`, `urban`, `city`, `city chic`, and `downtown` all retrieve the urban/commute/editorial Look family; users do not need to rewrite `都市风格` as `都市通勤`.
|
|
58
63
|
|
|
59
64
|
For an explicit free Persona + Look source-reference pack, retain `preview`:
|
|
60
65
|
|
|
@@ -67,6 +72,7 @@ personlib remote preview --brief '20 秒嘻哈说唱女 rapper 舞台视频,
|
|
|
67
72
|
```bash
|
|
68
73
|
node bin/personlib.mjs list --library ./private-library --json
|
|
69
74
|
node bin/personlib.mjs show --persona P-001 --library ./private-library --json
|
|
75
|
+
node bin/personlib.mjs review-persona --persona P-001 --appearance-tags east_asian,korean_style_compatible --reviewed-by owner-name --owner-confirmed --library ./private-library --json
|
|
70
76
|
node bin/personlib.mjs show --look L-001 --library ./private-library --json
|
|
71
77
|
node bin/personlib.mjs recommend --brief '请使用人物资产库,为一个 15 秒高端美妆广告找一位 25 岁左右、东亚、干净冷感的女性人物。' --library ./private-library --json
|
|
72
78
|
node bin/personlib.mjs fetch --brief '请为一个 15 秒成年女性地铁皮夹克通勤广告找一套都市造型。' --output-dir ./selection --library ./private-library --json
|
|
@@ -88,6 +94,8 @@ node bin/personlib.mjs intake \
|
|
|
88
94
|
|
|
89
95
|
`intake` records neither facial nor styling assertions automatically. A human/vision-review step adds Persona or Look fields. A Look always carries a source-face exclusion and brand-sanitization rule.
|
|
90
96
|
|
|
97
|
+
`review-persona` is an owner-only manual-audit command. It writes the reviewed tags plus reviewer/time provenance; do not run it from an image model, an automatic classifier, or an Agent guess. Follow it with owner `remote sync` so the Worker receives the audited metadata.
|
|
98
|
+
|
|
91
99
|
## Test
|
|
92
100
|
|
|
93
101
|
```bash
|
package/bin/personlib.mjs
CHANGED
|
@@ -100,6 +100,18 @@ function validate(manifest, root) {
|
|
|
100
100
|
for (const forbidden of ["hair", "makeup", "wardrobe", "accessories"]) {
|
|
101
101
|
if (Object.hasOwn(identity, forbidden)) errors.push(`${persona.id}: identity may not contain ${forbidden}`);
|
|
102
102
|
}
|
|
103
|
+
const appearanceTags = persona.appearance_tags;
|
|
104
|
+
const appearanceReview = persona.appearance_tags_review;
|
|
105
|
+
if (appearanceTags !== undefined) {
|
|
106
|
+
if (!Array.isArray(appearanceTags) || !appearanceTags.length || appearanceTags.some((tag) => typeof tag !== "string" || !tag.trim())) {
|
|
107
|
+
errors.push(`${persona.id}: appearance_tags must be a non-empty string array when present`);
|
|
108
|
+
}
|
|
109
|
+
if (appearanceReview?.state !== "owner-reviewed" || !appearanceReview?.reviewed_by || !appearanceReview?.reviewed_at) {
|
|
110
|
+
errors.push(`${persona.id}: appearance_tags require owner-reviewed provenance`);
|
|
111
|
+
}
|
|
112
|
+
} else if (appearanceReview !== undefined) {
|
|
113
|
+
errors.push(`${persona.id}: appearance_tags_review requires appearance_tags`);
|
|
114
|
+
}
|
|
103
115
|
}
|
|
104
116
|
const lookIds = new Set();
|
|
105
117
|
for (const lookRecord of manifest.looks || []) {
|
|
@@ -124,6 +136,38 @@ function validate(manifest, root) {
|
|
|
124
136
|
return errors;
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
function requestedAppearanceTags(brief = "") {
|
|
140
|
+
const query = String(brief).toLowerCase();
|
|
141
|
+
const tags = new Set();
|
|
142
|
+
if (/亚洲|亚裔|asian|东亚|east\s*asian/.test(query)) tags.add("east_asian");
|
|
143
|
+
if (/韩系|韩国|korean/.test(query)) {
|
|
144
|
+
tags.add("east_asian");
|
|
145
|
+
tags.add("korean_style_compatible");
|
|
146
|
+
}
|
|
147
|
+
return [...tags].sort();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function requestedPresentation(brief = "") {
|
|
151
|
+
const query = String(brief).toLowerCase();
|
|
152
|
+
if (/女生|女性|女人|女模特|女rapper|女说唱|female|woman|women/.test(query)) return "feminine adult";
|
|
153
|
+
if (/男生|男性|男人|男模特|男rapper|男说唱|male|man|men/.test(query)) return "masculine adult";
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function reviewedAppearanceTags(persona) {
|
|
158
|
+
if (persona?.appearance_tags_review?.state !== "owner-reviewed" || !Array.isArray(persona?.appearance_tags)) return [];
|
|
159
|
+
return [...new Set(persona.appearance_tags.map((tag) => String(tag).trim()).filter(Boolean))].sort();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function appearanceMatches(persona, requiredTags) {
|
|
163
|
+
const tags = new Set(reviewedAppearanceTags(persona));
|
|
164
|
+
return requiredTags.every((tag) => tags.has(tag));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function presentationMatches(persona, required) {
|
|
168
|
+
return !required || persona?.identity?.presentation === required;
|
|
169
|
+
}
|
|
170
|
+
|
|
127
171
|
function intake() {
|
|
128
172
|
const root = rootPath();
|
|
129
173
|
const image = option("--image");
|
|
@@ -193,6 +237,33 @@ function intake() {
|
|
|
193
237
|
output({ ok: true, [into]: record });
|
|
194
238
|
}
|
|
195
239
|
|
|
240
|
+
function reviewPersonaAppearance() {
|
|
241
|
+
const root = rootPath();
|
|
242
|
+
const personaId = option("--persona");
|
|
243
|
+
const rawTags = option("--appearance-tags");
|
|
244
|
+
const reviewedBy = option("--reviewed-by");
|
|
245
|
+
if (!personaId || !rawTags || !reviewedBy || !has("--owner-confirmed")) {
|
|
246
|
+
throw new Error("usage: personlib review-persona --persona P-001 --appearance-tags east_asian,korean_style_compatible --reviewed-by NAME --owner-confirmed --library PATH");
|
|
247
|
+
}
|
|
248
|
+
const tags = [...new Set(rawTags.split(",").map((tag) => tag.trim()).filter(Boolean))].sort();
|
|
249
|
+
if (!tags.length || tags.some((tag) => !/^[a-z][a-z0-9_]*$/.test(tag))) throw new Error("appearance tags must be lowercase underscore identifiers");
|
|
250
|
+
const manifest = readManifest(root);
|
|
251
|
+
const persona = (manifest.personas || []).find((entry) => entry.id === personaId);
|
|
252
|
+
if (!persona) throw new Error(`persona not found: ${personaId}`);
|
|
253
|
+
if (persona.status !== "private-staged") throw new Error(`persona is not ready for owner appearance review: ${personaId}`);
|
|
254
|
+
persona.appearance_tags = tags;
|
|
255
|
+
persona.appearance_tags_review = {
|
|
256
|
+
state: "owner-reviewed",
|
|
257
|
+
reviewed_by: reviewedBy,
|
|
258
|
+
reviewed_at: new Date().toISOString(),
|
|
259
|
+
method: "manual-owner-review-not-runtime-inference"
|
|
260
|
+
};
|
|
261
|
+
const errors = validate(manifest, root);
|
|
262
|
+
if (errors.length) throw new Error(`library validation failed: ${errors.join("; ")}`);
|
|
263
|
+
saveManifest(root, manifest);
|
|
264
|
+
output({ ok: true, persona: { id: persona.id, appearance_tags: tags, appearance_tags_review: persona.appearance_tags_review } });
|
|
265
|
+
}
|
|
266
|
+
|
|
196
267
|
function buildRenderBrief(persona, look) {
|
|
197
268
|
const identity = persona.identity;
|
|
198
269
|
const identityLock = [
|
|
@@ -314,29 +385,39 @@ function temporaryLookFromBrief(brief) {
|
|
|
314
385
|
}
|
|
315
386
|
|
|
316
387
|
function recommendationForBrief(manifest, brief) {
|
|
388
|
+
const requiredAppearanceTags = requestedAppearanceTags(brief);
|
|
389
|
+
const requiredPresentation = requestedPresentation(brief);
|
|
317
390
|
const candidates = (manifest.personas || [])
|
|
318
391
|
.filter((persona) => persona.status === "private-staged")
|
|
392
|
+
.filter((persona) => appearanceMatches(persona, requiredAppearanceTags))
|
|
393
|
+
.filter((persona) => presentationMatches(persona, requiredPresentation))
|
|
319
394
|
.map((persona) => ({ persona, score: scorePersonaForBrief(persona, brief) }))
|
|
320
395
|
.sort((a, b) => b.score - a.score || a.persona.id.localeCompare(b.persona.id));
|
|
321
|
-
if (!candidates.length)
|
|
396
|
+
if (!candidates.length) {
|
|
397
|
+
if (requiredAppearanceTags.length) throw new Error(`BLOCKED: no Persona matched owner-reviewed appearance tags: ${requiredAppearanceTags.join(", ")}`);
|
|
398
|
+
if (requiredPresentation) throw new Error(`BLOCKED: no Persona matched required presentation: ${requiredPresentation}`);
|
|
399
|
+
throw new Error("no staged Persona records available for recommendation");
|
|
400
|
+
}
|
|
322
401
|
const selected = candidates[0].persona;
|
|
323
402
|
const catalogMatch = catalogLookForBrief(manifest, brief);
|
|
324
403
|
const selectedLook = catalogMatch?.look || temporaryLookFromBrief(brief);
|
|
325
404
|
const ageRequested = /(?:25\s*岁|25\s*year|twenty[- ]?five)/i.test(brief);
|
|
326
|
-
const eastAsiaRequested = /东亚|east\s*asian/i.test(brief);
|
|
327
405
|
return {
|
|
328
406
|
request: brief,
|
|
329
407
|
persona: {
|
|
330
408
|
id: selected.id,
|
|
331
409
|
display_name: selected.display_name,
|
|
410
|
+
appearance_tags: reviewedAppearanceTags(selected),
|
|
411
|
+
presentation: selected.identity?.presentation || null,
|
|
332
412
|
match_reason: "Selected from explicit adult Persona metadata and structure-level clean/cool signals; style is not used as identity evidence."
|
|
333
413
|
},
|
|
334
414
|
alternatives: candidates.slice(1, 3).map(({ persona, score }) => ({ id: persona.id, display_name: persona.display_name, score })),
|
|
335
415
|
look: selectedLook,
|
|
336
416
|
look_match: catalogMatch ? { source: catalogMatch.source, score: catalogMatch.score } : { source: "temporary-text-derived", score: 0 },
|
|
417
|
+
requested_appearance_tags: requiredAppearanceTags,
|
|
418
|
+
requested_presentation: requiredPresentation,
|
|
337
419
|
constraints_not_verified_from_portrait: [
|
|
338
|
-
...(ageRequested ? ["exact age is not stored; the selected Persona is only recorded as an adult"] : [])
|
|
339
|
-
...(eastAsiaRequested ? ["East Asian identity is not inferred from the portrait; it requires explicit owner-provided metadata before it can be a hard filter"] : [])
|
|
420
|
+
...(ageRequested ? ["exact age is not stored; the selected Persona is only recorded as an adult"] : [])
|
|
340
421
|
],
|
|
341
422
|
render_brief: buildRenderBrief(selected, selectedLook),
|
|
342
423
|
preview: {
|
|
@@ -460,6 +541,7 @@ try {
|
|
|
460
541
|
if (command === "setup") await runBootstrapSetup(argv.slice(1), { output });
|
|
461
542
|
else if (command === "remote") await runRemote(argv.slice(1), { output, readManifest });
|
|
462
543
|
else if (command === "intake") intake();
|
|
544
|
+
else if (command === "review-persona") reviewPersonaAppearance();
|
|
463
545
|
else if (command === "recommend") recommend();
|
|
464
546
|
else if (command === "fetch") fetchReferencePack();
|
|
465
547
|
else if (command === "compose") compose();
|
|
@@ -487,7 +569,7 @@ try {
|
|
|
487
569
|
output({ ok: errors.length === 0, errors });
|
|
488
570
|
if (errors.length) process.exitCode = 1;
|
|
489
571
|
} else {
|
|
490
|
-
output("Usage: personlib <setup|intake|list|show|recommend|fetch|compose|validate|remote> [--library PATH] [--json]");
|
|
572
|
+
output("Usage: personlib <setup|intake|review-persona|list|show|recommend|fetch|compose|validate|remote> [--library PATH] [--json]");
|
|
491
573
|
process.exitCode = command ? 1 : 0;
|
|
492
574
|
}
|
|
493
575
|
} catch (error) {
|
|
@@ -20,6 +20,8 @@ npx wrangler secret put OWNER_SYNC_TOKEN
|
|
|
20
20
|
npx wrangler deploy
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
`schema.sql` is additive and includes the `render_selections` table used to bind a recommendation to one Agent for 24 hours. Run the schema command again before deploying this release so existing D1 databases receive that table.
|
|
24
|
+
|
|
23
25
|
The owner then uses an ephemeral `PERSONLIB_REMOTE_ENDPOINT` plus `PERSONLIB_OWNER_SYNC_TOKEN` to upload the reviewed source library from the project root:
|
|
24
26
|
|
|
25
27
|
```bash
|
|
@@ -27,6 +29,15 @@ personlib remote sync --library ./private-library --dry-run --json
|
|
|
27
29
|
personlib remote sync --library ./private-library --json
|
|
28
30
|
```
|
|
29
31
|
|
|
32
|
+
Before syncing a Persona as eligible for a region- or presentation-specific request, the owner must manually review it locally and record tags with provenance. For example, after human review only:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
personlib review-persona --persona P-001 --appearance-tags east_asian --reviewed-by owner-name --owner-confirmed --library ./private-library --json
|
|
36
|
+
personlib remote sync --library ./private-library --json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The Worker never infers these tags. A request such as `亚洲女生` blocks unless the stored Persona has owner-reviewed `east_asian` and `presentation: feminine adult`; it never falls back to a different gender or region.
|
|
40
|
+
|
|
30
41
|
After deployment, put the resulting **HTTPS Worker URL** in `package.json` under `personlib.default_api_url`; `npm publish` is intentionally blocked until this value is present. A released CLI then supports the expected Agent bootstrap:
|
|
31
42
|
|
|
32
43
|
```bash
|
|
@@ -24,3 +24,14 @@ CREATE TABLE IF NOT EXISTS assets (
|
|
|
24
24
|
sha256 TEXT NOT NULL,
|
|
25
25
|
mime_type TEXT NOT NULL
|
|
26
26
|
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS render_selections (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
agent_id TEXT NOT NULL,
|
|
31
|
+
selection_json TEXT NOT NULL,
|
|
32
|
+
created_at TEXT NOT NULL,
|
|
33
|
+
expires_at TEXT NOT NULL
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE INDEX IF NOT EXISTS render_selections_agent_expiry
|
|
37
|
+
ON render_selections(agent_id, expires_at);
|