makaron-persona-look-cli 0.5.2 → 0.5.3

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## What an Agent gets
6
6
 
7
- `find` is the no-generation delivery route for a single library result: `--kind persona` returns one matched Persona image, and `--kind look` returns one matched Look image. 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 sends those private inputs to the caller's authenticated Makaron CLI and writes exactly one full-body front/right-side/back turnaround sheet. The Persona is the only identity reference; the Look image's face is excluded.
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 one matching library image; no Makaron request.
48
- personlib remote find --kind persona --brief '我要一个长相高冷的女生' --output-dir ./personlib-persona --json
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 one matching library image; no Makaron request.
51
- personlib remote find --kind look --brief '我要一套 Y2K 的妆造' --output-dir ./personlib-look --json
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 the fused three-view image.
54
- personlib remote render --brief '我要一个嘻哈风格的亚洲女生' --output-dir ./personlib-turnaround --json
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, a combined Persona + Look request authorizes exactly one Makaron submission, so the Agent generates and returns the turnaround without a second confirmation step. The result is `turnaround.png` (or `.jpg`/`.webp`), plus `turnaround-plan.json`, `prompt_used.md`, and `qc_report.md`. 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` only to inspect the exact prompt and matching IDs without downloading inputs or submitting anything, or `--no-wait` to keep the returned Makaron run ID for later retrieval.
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) throw new Error("no staged Persona records available for recommendation");
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);