makaron-persona-look-cli 0.5.4 → 0.5.6

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: `--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.
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` (default `1`) returns exactly N different references or `BLOCKED`; `--exclude ID[,ID...]` omits prior internal selections, including records that share their source image. It does not expose IDs or selection metadata to the end user. `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
 
@@ -37,7 +37,7 @@ personlib remote sync --library ./private-library --dry-run --json
37
37
  personlib remote sync --library ./private-library --json
38
38
  ```
39
39
 
40
- `remote sync` remains owner-only. Open enrollment authorizes Agents to receive their own credentials, but does not expose an anonymous catalog or direct R2 object URLs.
40
+ `remote sync` remains owner-only. It uploads private asset bytes in controlled batches after its manifest write, so a partial network failure can be safely rerun without exposing sources. Open enrollment authorizes Agents to receive their own credentials, but does not expose an anonymous catalog or direct R2 object URLs.
41
41
 
42
42
  ## Agent natural-language route
43
43
 
@@ -47,6 +47,9 @@ Once setup and `remote doctor` succeed, use the route that matches the request:
47
47
  # Persona-only: return matching library image(s); no Makaron request.
48
48
  personlib remote find --kind persona --brief '3个长相高冷的亚洲女生' --count 3 --output-dir ./personlib-personas --json
49
49
 
50
+ # Follow-up internal search: exclude the previous records without exposing their IDs to an end user.
51
+ personlib remote find --kind persona --brief '长相高冷的亚洲女生' --count 2 --exclude P-001,P-002 --output-dir ./personlib-personas-next --json
52
+
50
53
  # Look-only: return matching library image(s); no Makaron request.
51
54
  personlib remote find --kind look --brief '3套 Y2K 的妆造' --count 3 --output-dir ./personlib-looks --json
52
55
 
@@ -55,12 +58,14 @@ personlib remote render --brief '3个都市风格的亚洲女生' --count 3 --ou
55
58
  personlib remote render --brief '3个都市风格的亚洲女生' --count 3 --output-dir ./personlib-turnarounds --json
56
59
  ```
57
60
 
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.
61
+ 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 a `find` request has fewer than `--count` distinct eligible records after exclusions, it returns `BLOCKED`, `insufficient_matches: true`, and the available count without downloading partial or duplicate images. 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
62
 
60
63
  `亚洲女生` 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
64
 
62
65
  `都市`, `urban`, `city`, `city chic`, and `downtown` all retrieve the urban/commute/editorial Look family; users do not need to rewrite `都市风格` as `都市通勤`.
63
66
 
67
+ `性感`, `sexy`, `sensual`, `夜生活`, `夜店`, `nightlife`, and `约会夜` retrieve owner-reviewed adult evening Looks only; they never use a Look source face as the rendered identity.
68
+
64
69
  For an explicit free Persona + Look source-reference pack, retain `preview`:
65
70
 
66
71
  ```bash
@@ -74,6 +79,7 @@ node bin/personlib.mjs list --library ./private-library --json
74
79
  node bin/personlib.mjs show --persona P-001 --library ./private-library --json
75
80
  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
76
81
  node bin/personlib.mjs show --look L-001 --library ./private-library --json
82
+ node bin/personlib.mjs review-look --look L-001 --family 'adult hip-hop streetwear' --silhouette 'relaxed low-rise layered proportion' --garments 'unbranded color-block jacket, cropped top, baggy denim and retro sneakers' --palette 'red, blue, ivory and indigo' --materials 'nylon, cotton jersey and washed denim' --accessories 'minimal metal chain' --scene 'neutral adult music-fashion studio' --reviewed-by owner-name --owner-confirmed --library ./private-library --json
77
83
  node bin/personlib.mjs recommend --brief '请使用人物资产库,为一个 15 秒高端美妆广告找一位 25 岁左右、东亚、干净冷感的女性人物。' --library ./private-library --json
78
84
  node bin/personlib.mjs fetch --brief '请为一个 15 秒成年女性地铁皮夹克通勤广告找一套都市造型。' --output-dir ./selection --library ./private-library --json
79
85
  node bin/personlib.mjs compose --persona P-003 --look L-011 --library ./private-library --json
@@ -94,7 +100,7 @@ node bin/personlib.mjs intake \
94
100
 
95
101
  `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.
96
102
 
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.
103
+ `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. `review-look` is the equivalent owner-only gate for a new Look: all seven structured styling fields, source-face exclusion, source-brand replacement, and reviewer/time provenance are required before it can become `private-staged`. Follow either review with owner `remote sync` so the Worker receives only approved metadata and private source bytes.
98
104
 
99
105
  ## Test
100
106
 
package/bin/personlib.mjs CHANGED
@@ -132,6 +132,11 @@ function validate(manifest, root) {
132
132
  } else if (lookRecord.look?.review_state !== "needs-structured-review") {
133
133
  errors.push(`${lookRecord.id}: intake-review records must declare look.review_state`);
134
134
  }
135
+ if (lookRecord.look_review !== undefined) {
136
+ if (lookRecord.status !== "private-staged" || lookRecord.look_review?.state !== "owner-reviewed" || !lookRecord.look_review?.reviewed_by || !lookRecord.look_review?.reviewed_at) {
137
+ errors.push(`${lookRecord.id}: look_review requires private-staged owner-reviewed provenance`);
138
+ }
139
+ }
135
140
  }
136
141
  return errors;
137
142
  }
@@ -264,6 +269,36 @@ function reviewPersonaAppearance() {
264
269
  output({ ok: true, persona: { id: persona.id, appearance_tags: tags, appearance_tags_review: persona.appearance_tags_review } });
265
270
  }
266
271
 
272
+ function reviewLook() {
273
+ const root = rootPath();
274
+ const lookId = option("--look");
275
+ const reviewedBy = option("--reviewed-by");
276
+ const fields = ["family", "silhouette", "garments", "palette", "materials", "accessories", "scene"];
277
+ const look = Object.fromEntries(fields.map((field) => [field, option(`--${field}`)?.trim()]));
278
+ if (!lookId || !reviewedBy || !has("--owner-confirmed") || fields.some((field) => !look[field])) {
279
+ throw new Error("usage: personlib review-look --look L-001 --family TEXT --silhouette TEXT --garments TEXT --palette TEXT --materials TEXT --accessories TEXT --scene TEXT --reviewed-by NAME --owner-confirmed --library PATH");
280
+ }
281
+ const manifest = readManifest(root);
282
+ const record = (manifest.looks || []).find((entry) => entry.id === lookId);
283
+ if (!record) throw new Error(`look not found: ${lookId}`);
284
+ if (!["intake-review", "private-staged"].includes(record.status)) throw new Error(`look is not ready for owner review: ${lookId}`);
285
+ record.status = "private-staged";
286
+ record.look = look;
287
+ record.identity_policy = "do-not-use-source-face";
288
+ record.brand_policy = "replace-source-logos-and-wordmarks";
289
+ record.look_review = {
290
+ state: "owner-reviewed",
291
+ reviewed_by: reviewedBy,
292
+ reviewed_at: new Date().toISOString(),
293
+ method: "manual-owner-review-source-face-excluded"
294
+ };
295
+ record.cloud_sync = "not-started";
296
+ const errors = validate(manifest, root);
297
+ if (errors.length) throw new Error(`library validation failed: ${errors.join("; ")}`);
298
+ saveManifest(root, manifest);
299
+ output({ ok: true, look: { id: record.id, status: record.status, look: record.look, brand_policy: record.brand_policy, look_review: record.look_review } });
300
+ }
301
+
267
302
  function buildRenderBrief(persona, look) {
268
303
  const identity = persona.identity;
269
304
  const identityLock = [
@@ -332,6 +367,7 @@ function scoreLookForBrief(lookRecord, brief) {
332
367
  addSignals(/晚宴|gala|black-tie|formal/, [["gala", 4], ["evening", 3], ["formal", 3], ["gown", 2], ["soiree", 2]]);
333
368
  addSignals(/红毯|red carpet/, [["red carpet", 6], ["gala", 3], ["carpet", 2], ["train", 2], ["gown", 2]]);
334
369
  addSignals(/高级派对|party|cocktail|after-party/, [["party", 4], ["cocktail", 3], ["soiree", 3], ["crystal", 1], ["sequin", 1], ["gown", 1]]);
370
+ addSignals(/性感|sexy|sensual|夜生活|夜店|nightlife|club|约会夜|date night/, [["sensual", 6], ["nightlife", 5], ["club", 4], ["bodycon", 4], ["cutout", 3], ["halter", 3], ["ruched", 2], ["sheer", 2], ["satin", 2], ["fitted", 1]]);
335
371
  addSignals(/运动休闲|athleisure|运动|sport|sportswear|健身|球类|篮球|网球|跑步|track|训练/, [["athleisure", 5], ["sport", 4], ["track", 3], ["running", 2], ["tennis", 2], ["basketball", 2], ["technical", 1], ["fleece", 1], ["hoodie", 1]]);
336
372
  addSignals(/度假|海岛|resort|beach|holiday|pool|yacht|sailing|夏日/, [["resort", 5], ["beach", 5], ["island", 4], ["pool", 4], ["sailing", 4], ["summer", 3], ["linen", 2], ["swim", 2], ["nautical", 2]]);
337
373
  return score;
@@ -542,6 +578,7 @@ try {
542
578
  else if (command === "remote") await runRemote(argv.slice(1), { output, readManifest });
543
579
  else if (command === "intake") intake();
544
580
  else if (command === "review-persona") reviewPersonaAppearance();
581
+ else if (command === "review-look") reviewLook();
545
582
  else if (command === "recommend") recommend();
546
583
  else if (command === "fetch") fetchReferencePack();
547
584
  else if (command === "compose") compose();
@@ -569,7 +606,7 @@ try {
569
606
  output({ ok: errors.length === 0, errors });
570
607
  if (errors.length) process.exitCode = 1;
571
608
  } else {
572
- output("Usage: personlib <setup|intake|review-persona|list|show|recommend|fetch|compose|validate|remote> [--library PATH] [--json]");
609
+ output("Usage: personlib <setup|intake|review-persona|review-look|list|show|recommend|fetch|compose|validate|remote> [--library PATH] [--json]");
573
610
  process.exitCode = command ? 1 : 0;
574
611
  }
575
612
  } catch (error) {
@@ -18,6 +18,14 @@ function requestedCount(value) {
18
18
  return count;
19
19
  }
20
20
 
21
+ function requestedExcludedIds(value) {
22
+ if (value === undefined) return [];
23
+ if (!Array.isArray(value)) throw new Error("exclude_ids must be an array of record IDs");
24
+ const ids = [...new Set(value.map((id) => String(id || "").trim()).filter(Boolean))];
25
+ if (!ids.length) throw new Error("exclude_ids must contain at least one record ID when supplied");
26
+ return ids;
27
+ }
28
+
21
29
  async function sha256(value) {
22
30
  const input = typeof value === "string" ? encoder.encode(value) : value;
23
31
  const hash = await crypto.subtle.digest("SHA-256", input);
@@ -148,6 +156,7 @@ function scoreLook(record, brief) {
148
156
  signals(/晚宴|gala|black-tie|formal/, [["gala", 4], ["evening", 3], ["formal", 3], ["gown", 2], ["soiree", 2]]);
149
157
  signals(/红毯|red carpet/, [["red carpet", 6], ["gala", 3], ["carpet", 2], ["train", 2], ["gown", 2]]);
150
158
  signals(/高级派对|party|cocktail|after-party/, [["party", 4], ["cocktail", 3], ["soiree", 3], ["crystal", 1], ["sequin", 1], ["gown", 1]]);
159
+ signals(/性感|sexy|sensual|夜生活|夜店|nightlife|club|约会夜|date night/, [["sensual", 6], ["nightlife", 5], ["club", 4], ["bodycon", 4], ["cutout", 3], ["halter", 3], ["ruched", 2], ["sheer", 2], ["satin", 2], ["fitted", 1]]);
151
160
  signals(/运动休闲|athleisure|运动|sport|sportswear|健身|球类|篮球|网球|跑步|track|训练/, [["athleisure", 5], ["sport", 4], ["track", 3], ["running", 2], ["tennis", 2], ["basketball", 2], ["technical", 1], ["fleece", 1], ["hoodie", 1]]);
152
161
  return score;
153
162
  }
@@ -162,6 +171,13 @@ function distinctSourceCandidates(candidates) {
162
171
  });
163
172
  }
164
173
 
174
+ function excludeRecordCandidates(candidates, excludeIds) {
175
+ if (!excludeIds.length) return candidates;
176
+ const excluded = new Set(excludeIds);
177
+ const excludedSources = new Set(candidates.filter(({ record }) => excluded.has(record.id)).map(({ record }) => record.source_asset_id).filter(Boolean));
178
+ return candidates.filter(({ record }) => !excluded.has(record.id) && !excludedSources.has(record.source_asset_id));
179
+ }
180
+
165
181
  function buildRenderBrief(persona, look) {
166
182
  const identity = persona.identity || {};
167
183
  const direction = look.look || look;
@@ -223,17 +239,20 @@ async function selectSingle(env, { kind, brief }) {
223
239
  return selected.records[0];
224
240
  }
225
241
 
226
- async function selectSingles(env, { kind, brief, count = 1 }) {
242
+ async function selectSingles(env, { kind, brief, count = 1, excludeIds = [] }) {
227
243
  if (!['persona', 'look'].includes(kind)) throw new Error("kind must be persona or look");
228
244
  const requested = requestedCount(count);
229
245
  if (kind === "persona") {
230
- const selected = await selectPersonas(env, { brief, count: requested });
246
+ const selected = await selectPersonas(env, { brief, count: requested, excludeIds });
231
247
  return { records: selected.personas, available_count: selected.available_count };
232
248
  }
233
- const candidates = distinctSourceCandidates((await records(env, "look_records"))
249
+ const scored = (await records(env, "look_records"))
234
250
  .map((record) => ({ record, score: scoreLook(record, brief) }))
235
251
  .filter(({ score }) => score >= 6)
236
- .sort((a, b) => b.score - a.score || a.record.id.localeCompare(b.record.id)));
252
+ .sort((a, b) => b.score - a.score || a.record.id.localeCompare(b.record.id));
253
+ const unexcludedCandidates = distinctSourceCandidates(scored);
254
+ const candidates = distinctSourceCandidates(excludeRecordCandidates(scored, excludeIds));
255
+ if (excludeIds.length && unexcludedCandidates.length && candidates.length < requested) throw insufficientCandidatesBlocked("Look", requested, candidates.length);
237
256
  if (candidates.length < requested) throw insufficientCandidatesBlocked("Look", requested, candidates.length);
238
257
  return { records: candidates.slice(0, requested).map(({ record }) => record), available_count: candidates.length };
239
258
  }
@@ -247,7 +266,7 @@ async function selectPersona(env, { brief, personaId }) {
247
266
  };
248
267
  }
249
268
 
250
- async function selectPersonas(env, { brief, personaId, count = 1 }) {
269
+ async function selectPersonas(env, { brief, personaId, count = 1, excludeIds = [] }) {
251
270
  const requested = requestedCount(count);
252
271
  const personas = await records(env, "persona_records");
253
272
  const requiredAppearanceTags = requestedAppearanceTags(brief || "");
@@ -256,10 +275,18 @@ async function selectPersonas(env, { brief, personaId, count = 1 }) {
256
275
  if (!available.length) throw new Error(personaId ? `persona not found: ${personaId}` : "no Persona records available");
257
276
  const presentationCandidates = available.filter((record) => presentationMatches(record, requiredPresentation));
258
277
  if (!presentationCandidates.length && requiredPresentation) throw presentationBlocked(requiredPresentation, { requestedCount: requested, availableCount: 0 });
259
- const candidates = distinctSourceCandidates(presentationCandidates
278
+ const scored = presentationCandidates
260
279
  .filter((record) => appearanceMatches(record, requiredAppearanceTags))
261
280
  .map((record) => ({ record, score: scorePersona(record, brief || "") }))
262
- .sort((a, b) => b.score - a.score || a.record.id.localeCompare(b.record.id)));
281
+ .sort((a, b) => b.score - a.score || a.record.id.localeCompare(b.record.id));
282
+ const unexcludedCandidates = distinctSourceCandidates(scored);
283
+ const candidates = distinctSourceCandidates(excludeRecordCandidates(scored, excludeIds));
284
+ if (excludeIds.length && unexcludedCandidates.length && candidates.length < requested) {
285
+ throw insufficientCandidatesBlocked("Persona", requested, candidates.length, {
286
+ required_appearance_tags: requiredAppearanceTags,
287
+ required_presentation: requiredPresentation
288
+ });
289
+ }
263
290
  if (!candidates.length && requiredAppearanceTags.length) throw appearanceBlocked(requiredAppearanceTags, requiredPresentation, { requestedCount: requested, availableCount: 0 });
264
291
  if (!candidates.length && requiredPresentation) throw presentationBlocked(requiredPresentation, { requestedCount: requested, availableCount: 0 });
265
292
  if (!candidates.length) throw new Error(personaId ? `persona not found: ${personaId}` : "no Persona records available");
@@ -403,6 +430,7 @@ function blocked(cause, status = 409) {
403
430
  kind: cause?.kind || null,
404
431
  requested_count: cause?.requested_count || null,
405
432
  available_count: cause?.available_count ?? null,
433
+ insufficient_matches: cause?.code === "insufficient-candidates",
406
434
  error: cause?.message || "request blocked"
407
435
  }, status);
408
436
  }
@@ -533,12 +561,14 @@ async function route(request, env) {
533
561
  try {
534
562
  const kind = String(body.kind || "");
535
563
  const count = requestedCount(body?.count);
536
- const selected = await selectSingles(env, { kind, brief: String(body.brief), count });
564
+ const excludeIds = requestedExcludedIds(body?.exclude_ids);
565
+ const selected = await selectSingles(env, { kind, brief: String(body.brief), count, excludeIds });
537
566
  return json({
538
567
  kind: `${kind}-reference`,
539
568
  image_status: "library-reference-not-generated",
540
569
  requested_count: count,
541
570
  available_count: selected.available_count,
571
+ returned_count: selected.records.length,
542
572
  assets: selected.records.map((record, index) => ({
543
573
  role: kind,
544
574
  ...(count > 1 ? { index: index + 1 } : {}),
@@ -85,12 +85,14 @@ test("owner sync, self-registration, recommendation, compose, and private previe
85
85
  { id: "P-000", display_name: "Adult Man", status: "private-staged", identity: { presentation: "masculine adult", face_shape: "square", brows: "straight", eyes: "round", nose: "straight", mouth: "thin", skin: "natural", stable_marks: "none" }, appearance_tags: ["east_asian"], appearance_tags_review: { state: "owner-reviewed", reviewed_by: "test-owner", reviewed_at: "2026-09-04T00:00:00.000Z" }, source_asset_id: "P-000:source", identity_references: [] },
86
86
  { id: "P-001", display_name: "Adult One", status: "private-staged", identity: { presentation: "feminine adult", face_shape: "oval", brows: "straight", eyes: "almond", nose: "straight", mouth: "soft", skin: "natural", stable_marks: "none" }, appearance_tags: ["east_asian"], appearance_tags_review: { state: "owner-reviewed", reviewed_by: "test-owner", reviewed_at: "2026-09-04T00:00:00.000Z" }, source_asset_id: "P-001:source", identity_references: [] },
87
87
  { id: "P-002", display_name: "Adult Two", status: "private-staged", identity: { presentation: "feminine adult", face_shape: "long oval", brows: "arched", eyes: "hooded", nose: "compact", mouth: "full", skin: "neutral", stable_marks: "freckle" }, appearance_tags: ["east_asian"], appearance_tags_review: { state: "owner-reviewed", reviewed_by: "test-owner", reviewed_at: "2026-09-04T00:00:00.000Z" }, source_asset_id: "P-002:source", identity_references: [] },
88
- { id: "P-003", display_name: "Adult Three", status: "private-staged", identity: { presentation: "feminine adult", face_shape: "heart", brows: "soft", eyes: "narrow", nose: "straight", mouth: "centered", skin: "even", stable_marks: "beauty mark" }, appearance_tags: ["east_asian"], appearance_tags_review: { state: "owner-reviewed", reviewed_by: "test-owner", reviewed_at: "2026-09-04T00:00:00.000Z" }, source_asset_id: "P-003:source", identity_references: [] }
88
+ { id: "P-003", display_name: "Adult Three", status: "private-staged", identity: { presentation: "feminine adult", face_shape: "heart", brows: "soft", eyes: "narrow", nose: "straight", mouth: "centered", skin: "even", stable_marks: "beauty mark" }, appearance_tags: ["east_asian"], appearance_tags_review: { state: "owner-reviewed", reviewed_by: "test-owner", reviewed_at: "2026-09-04T00:00:00.000Z" }, source_asset_id: "P-003:source", identity_references: [] },
89
+ { id: "P-004", display_name: "Adult One Duplicate Record", status: "private-staged", identity: { presentation: "feminine adult", face_shape: "oval", brows: "straight", eyes: "almond", nose: "straight", mouth: "soft", skin: "natural", stable_marks: "none" }, appearance_tags: ["east_asian"], appearance_tags_review: { state: "owner-reviewed", reviewed_by: "test-owner", reviewed_at: "2026-09-04T00:00:00.000Z" }, source_asset_id: "P-001:source", identity_references: [] }
89
90
  ],
90
91
  looks: [
91
92
  { id: "L-115", display_name: "Hip-Hop Rap", status: "private-staged", look: { family: "hip-hop rap y2k", silhouette: "baggy", garments: "unbranded jersey and cargo trousers", palette: "red black", materials: "denim", accessories: "chain", scene: "neutral studio" }, source_asset_id: "L-115:source" },
92
93
  { id: "L-116", display_name: "Y2K Indigo", status: "private-staged", look: { family: "y2k urban", silhouette: "low rise", garments: "unbranded denim set", palette: "blue silver", materials: "denim", accessories: "chain", scene: "city studio" }, source_asset_id: "L-116:source" },
93
94
  { id: "L-117", display_name: "Y2K Chrome", status: "private-staged", look: { family: "y2k city chic", silhouette: "wide leg", garments: "unbranded chrome jacket", palette: "black silver", materials: "nylon", accessories: "belt", scene: "downtown studio" }, source_asset_id: "L-117:source" },
95
+ { id: "L-118", display_name: "Adult Sensual Night", status: "private-staged", look: { family: "adult sensual nightlife", silhouette: "fitted bodycon mini", garments: "unbranded black cutout dress", palette: "black", materials: "satin jersey", accessories: "minimal chain", scene: "adult nightlife studio" }, source_asset_id: "L-118:source" },
94
96
  { id: "L-132", display_name: "Metro Leather Transit", status: "private-staged", look: { family: "urban transit", silhouette: "moto", garments: "leather jacket", palette: "black grey", materials: "leather", accessories: "minimal", scene: "metro commuter" }, source_asset_id: "L-132:source" }
95
97
  ]
96
98
  };
@@ -102,6 +104,7 @@ test("owner sync, self-registration, recommendation, compose, and private previe
102
104
  { id: "L-115:source", object_key: "looks/L-115/source.png", sha256: lookHash, mime_type: "image/png" },
103
105
  { id: "L-116:source", object_key: "looks/L-116/source.png", sha256: lookHash, mime_type: "image/png" },
104
106
  { id: "L-117:source", object_key: "looks/L-117/source.png", sha256: lookHash, mime_type: "image/png" },
107
+ { id: "L-118:source", object_key: "looks/L-118/source.png", sha256: lookHash, mime_type: "image/png" },
105
108
  { id: "L-132:source", object_key: "looks/L-132/source.png", sha256: lookHash, mime_type: "image/png" }
106
109
  ];
107
110
  let response = await worker.fetch(request("/v1/owner/sync/manifest", { method: "POST", headers: { authorization: "Bearer owner-token", "content-type": "application/json" }, body: JSON.stringify({ manifest, assets }) }), env);
@@ -118,7 +121,7 @@ test("owner sync, self-registration, recommendation, compose, and private previe
118
121
  assert.equal(agent.enrollment, "open-self-registration");
119
122
  const headers = { authorization: `Bearer ${agent.agent_token}`, "content-type": "application/json" };
120
123
  response = await worker.fetch(request("/v1/doctor", { headers }), env);
121
- assert.deepEqual(await response.json(), { ok: true, service: "personlib-worker", version: "1", agent_id: agent.agent_id, d1: "ok", r2_binding: true, look_records: 4 });
124
+ assert.deepEqual(await response.json(), { ok: true, service: "personlib-worker", version: "1", agent_id: agent.agent_id, d1: "ok", r2_binding: true, look_records: 5 });
122
125
  response = await worker.fetch(request("/v1/recommend", { method: "POST", headers, body: JSON.stringify({ brief: "15 秒成年女性地铁皮夹克通勤广告" }) }), env);
123
126
  const recommendation = await response.json();
124
127
  assert.equal(response.status, 200);
@@ -173,6 +176,11 @@ test("owner sync, self-registration, recommendation, compose, and private previe
173
176
  assert.equal(lookFind.requested_count, 1);
174
177
  assert.equal(lookFind.available_count, 1);
175
178
  assert.deepEqual(lookFind.assets, [{ role: "look", asset_id: "L-132:source", url: "/v1/assets/L-132%3Asource" }]);
179
+ response = await worker.fetch(request("/v1/find", { method: "POST", headers, body: JSON.stringify({ kind: "look", brief: "性感夜生活成人妆造" }) }), env);
180
+ const sensualLookFind = await response.json();
181
+ assert.equal(response.status, 200);
182
+ assert.equal(sensualLookFind.returned_count, 1);
183
+ assert.deepEqual(sensualLookFind.assets, [{ role: "look", asset_id: "L-118:source", url: "/v1/assets/L-118%3Asource" }]);
176
184
  response = await worker.fetch(request("/v1/find", { method: "POST", headers, body: JSON.stringify({ kind: "persona", brief: "3个长相高冷的亚洲女生", count: 3 }) }), env);
177
185
  const personaBatch = await response.json();
178
186
  assert.equal(response.status, 200);
@@ -180,6 +188,21 @@ test("owner sync, self-registration, recommendation, compose, and private previe
180
188
  assert.equal(personaBatch.available_count, 3);
181
189
  assert.deepEqual(personaBatch.assets.map((asset) => asset.index), [1, 2, 3]);
182
190
  assert.deepEqual(new Set(personaBatch.assets.map((asset) => asset.asset_id)), new Set(["P-001:source", "P-002:source", "P-003:source"]));
191
+ response = await worker.fetch(request("/v1/find", { method: "POST", headers, body: JSON.stringify({ kind: "persona", brief: "长相高冷的亚洲女生", count: 2, exclude_ids: ["P-001"] }) }), env);
192
+ const excludedPersonaBatch = await response.json();
193
+ assert.equal(response.status, 200);
194
+ assert.equal(excludedPersonaBatch.requested_count, 2);
195
+ assert.equal(excludedPersonaBatch.available_count, 2);
196
+ assert.equal(excludedPersonaBatch.returned_count, 2);
197
+ assert.deepEqual(excludedPersonaBatch.assets.map((asset) => asset.asset_id), ["P-002:source", "P-003:source"]);
198
+ response = await worker.fetch(request("/v1/find", { method: "POST", headers, body: JSON.stringify({ kind: "persona", brief: "长相高冷的亚洲女生", count: 3, exclude_ids: ["P-001"] }) }), env);
199
+ const insufficientPersonaBatch = await response.json();
200
+ assert.equal(response.status, 409);
201
+ assert.equal(insufficientPersonaBatch.state, "BLOCKED");
202
+ assert.equal(insufficientPersonaBatch.insufficient_matches, true);
203
+ assert.equal(insufficientPersonaBatch.requested_count, 3);
204
+ assert.equal(insufficientPersonaBatch.available_count, 2);
205
+ assert.equal(Object.hasOwn(insufficientPersonaBatch, "assets"), false);
183
206
  response = await worker.fetch(request("/v1/find", { method: "POST", headers, body: JSON.stringify({ kind: "look", brief: "3套 Y2K 的妆造", count: 3 }) }), env);
184
207
  const lookBatch = await response.json();
185
208
  assert.equal(response.status, 200);
@@ -187,6 +210,10 @@ test("owner sync, self-registration, recommendation, compose, and private previe
187
210
  assert.equal(lookBatch.available_count, 3);
188
211
  assert.deepEqual(lookBatch.assets.map((asset) => asset.index), [1, 2, 3]);
189
212
  assert.deepEqual(new Set(lookBatch.assets.map((asset) => asset.asset_id)), new Set(["L-115:source", "L-116:source", "L-117:source"]));
213
+ response = await worker.fetch(request("/v1/find", { method: "POST", headers, body: JSON.stringify({ kind: "look", brief: "3套 Y2K 的妆造", count: 2, exclude_ids: ["L-115"] }) }), env);
214
+ const excludedLookBatch = await response.json();
215
+ assert.equal(response.status, 200);
216
+ assert.deepEqual(excludedLookBatch.assets.map((asset) => asset.asset_id), ["L-116:source", "L-117:source"]);
190
217
  response = await worker.fetch(request("/v1/recommend", { method: "POST", headers, body: JSON.stringify({ brief: "3个都市风格的亚洲女生", count: 3 }) }), env);
191
218
  const urbanBatch = await response.json();
192
219
  assert.equal(response.status, 200);
@@ -28,6 +28,14 @@ function countOption(args) {
28
28
  return count;
29
29
  }
30
30
 
31
+ function excludeIdsOption(args) {
32
+ const value = getOption(args, "--exclude");
33
+ if (value === undefined) return [];
34
+ const ids = [...new Set(value.split(",").map((id) => id.trim()).filter(Boolean))];
35
+ if (!ids.length) throw new Error("--exclude must be a comma-separated list of record IDs");
36
+ return ids;
37
+ }
38
+
31
39
  function jsonOption(args, name) {
32
40
  const value = getOption(args, name);
33
41
  if (!value) return undefined;
@@ -252,14 +260,15 @@ async function downloadReferencePack(remote, payload, destination, args, { roles
252
260
 
253
261
  async function find(args, output) {
254
262
  const kind = getOption(args, "--kind");
255
- if (!['persona', 'look'].includes(kind)) throw new Error("usage: personlib remote find --kind <persona|look> --brief TEXT --output-dir PATH");
263
+ if (!['persona', 'look'].includes(kind)) throw new Error("usage: personlib remote find --kind <persona|look> --brief TEXT [--count N] [--exclude ID[,ID...]] --output-dir PATH");
256
264
  const brief = getOption(args, "--brief");
257
265
  if (!brief) throw new Error("personlib remote find requires --brief TEXT");
258
266
  const destination = getOption(args, "--output-dir");
259
267
  if (!destination) throw new Error("personlib remote find requires --output-dir PATH so it can return an image");
260
268
  const count = countOption(args);
269
+ const excludeIds = excludeIdsOption(args);
261
270
  const remote = agentConnection(args);
262
- const { response, payload } = await request(remote.endpoint, "/v1/find", { token: remote.token, method: "POST", json: { kind, brief, count }, allowError: true });
271
+ const { response, payload } = await request(remote.endpoint, "/v1/find", { token: remote.token, method: "POST", json: { kind, brief, count, ...(excludeIds.length ? { exclude_ids: excludeIds } : {}) }, allowError: true });
263
272
  if (!response.ok) {
264
273
  if (payload?.state === "BLOCKED") return jsonOutput(payload, output, args);
265
274
  throw new Error(`remote request failed (${response.status}): ${payload?.error || "find failed"}`);
@@ -267,7 +276,7 @@ async function find(args, output) {
267
276
  const absoluteDestination = resolve(destination);
268
277
  const files = await downloadReferencePack(remote, payload, absoluteDestination, args, { roles: [kind], writeSelection: false });
269
278
  if (files.length !== count) throw new Error(`library search expected ${count} ${kind} images, received ${files.length}`);
270
- jsonOutput({ ok: true, retrieval: { kind: `${kind}-reference`, image_status: "library-reference-not-generated", requested_count: count, available_count: payload.available_count, output_dir: absoluteDestination, files: files.map(({ path }) => path) } }, output, args);
279
+ jsonOutput({ ok: true, retrieval: { kind: `${kind}-reference`, image_status: "library-reference-not-generated", requested_count: count, available_count: payload.available_count, returned_count: files.length, output_dir: absoluteDestination, files: files.map(({ path }) => path) } }, output, args);
271
280
  }
272
281
 
273
282
  async function preview(args, output) {
@@ -628,13 +637,14 @@ async function sync(args, output, { readManifest }) {
628
637
  const ownerToken = process.env.PERSONLIB_OWNER_SYNC_TOKEN;
629
638
  if (!ownerToken) throw new Error("PERSONLIB_OWNER_SYNC_TOKEN is required for owner sync");
630
639
  await request(endpoint, "/v1/owner/sync/manifest", { token: ownerToken, method: "POST", json: { manifest: { schema_version: payload.schema_version, personas: payload.personas, looks: payload.looks }, assets: payload.assets.map(({ local_path, ...asset }) => asset) } });
631
- for (const asset of payload.assets) {
632
- await request(endpoint, `/v1/owner/assets/${encodeURIComponent(asset.id)}`, {
640
+ const uploadBatchSize = 8;
641
+ for (let offset = 0; offset < payload.assets.length; offset += uploadBatchSize) {
642
+ await Promise.all(payload.assets.slice(offset, offset + uploadBatchSize).map((asset) => request(endpoint, `/v1/owner/assets/${encodeURIComponent(asset.id)}`, {
633
643
  token: ownerToken,
634
644
  method: "PUT",
635
645
  body: readFileSync(asset.local_path),
636
646
  headers: { "content-type": asset.mime_type, "x-personlib-sha256": asset.sha256 }
637
- });
647
+ })));
638
648
  }
639
649
  jsonOutput({ ok: true, synced: { endpoint, personas: payload.personas.length, looks: payload.looks.length, assets: payload.assets.length } }, output, args);
640
650
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makaron-persona-look-cli",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Portable Persona and Look library for Makaron agents",
5
5
  "private": false,
6
6
  "type": "module",
@@ -12,14 +12,14 @@ Persona is the stable identity reference: face structure, natural skin traits, s
12
12
  ## Natural-language routing
13
13
 
14
14
  - Search-only rule: requests that ask only for a Persona or only for a Look do not authorize image generation. Return the matched reference image only—never expose IDs, match scores, or internal selection metadata—and do not call Makaron or `remote render`.
15
- - “我要一个长相高冷的女生” is Persona-only search: use `personlib remote find --kind persona --brief TEXT --output-dir PATH --json`, then return only the Persona reference image. Do not generate a three-view image. If the request says `3个` (or another quantity), pass `--count N`; return exactly N different references or report `BLOCKED`, never fewer.
16
- - “我要一套 Y2K 的妆造” is Look-only search: use `personlib remote find --kind look --brief TEXT --output-dir PATH --json`, then return only the Look reference image. Do not generate a three-view image. If the request says `3套` (or another quantity), pass `--count N`; return exactly N different references or report `BLOCKED`, never fewer.
15
+ - “我要一个长相高冷的女生” is Persona-only search: use `personlib remote find --kind persona --brief TEXT --output-dir PATH --json`, then return only the Persona reference image. Do not generate a three-view image. If the request says `3个` (or another quantity), pass `--count N`; return exactly N different references or report `BLOCKED`, never fewer. For a follow-up search, the Agent may pass internal prior record IDs as `--exclude ID[,ID...]`; never expose those IDs to the end user.
16
+ - “我要一套 Y2K 的妆造” is Look-only search: use `personlib remote find --kind look --brief TEXT --output-dir PATH --json`, then return only the Look reference image. Do not generate a three-view image. If the request says `3套` (or another quantity), pass `--count N`; return exactly N different references or report `BLOCKED`, never fewer. For a follow-up search, the Agent may pass internal prior record IDs as `--exclude ID[,ID...]`; never expose those IDs to the end user.
17
17
  - “这张图进 Persona 库” is an owner-only intake request. Require clear owner authorization, then use `personlib intake --into persona --rights authorized`.
18
- - “这张图进 Look 库” is an owner-only intake request. Use `personlib intake --into look --rights authorized`; extract only clothing, hair, makeup, accessory, palette, material, and scene information. Never use the source face as a new Persona.
18
+ - “这张图进 Look 库” is an owner-only intake request. Use `personlib intake --into look --rights authorized`; extract only clothing, hair, makeup, accessory, palette, material, and scene information. Never use the source face as a new Persona. Before an intake Look can be searched remotely, the owner must use `personlib review-look` with all structured Look fields and `--owner-confirmed`; it preserves source-face exclusion and removes only source brand identifiers in future output.
19
19
  - “找一个人物” and “为广告找一个人物” are Persona-only searches: use `personlib remote find --kind persona --brief TEXT --output-dir PATH --json`, then return only the Persona reference image. Do not expose the selected ID or create a temporary Look.
20
20
  - A request that combines a person and a style—such as “我要一个嘻哈风格的亚洲女生”—is an image request. Run `personlib remote render --brief TEXT --output-dir PATH --json`; it creates and resolves a locked internal selection before submitting, so it never reranks after selection. Never expose the internal selection ID to the user. For `3个都市风格的亚洲女生`, pass `--count 3`: dry-run must return three independent plans, and a real run submits exactly three Makaron jobs—one front/right-side/back sheet per distinct Persona. If the library has fewer than the requested number, return `BLOCKED`; do not silently deliver fewer images or submit any job. A pre-confirmed single selection may use `remote render --selection`. Explicit wording such as “生成三视图”, “出图”, “生成这个人物”, or “做视频出图” follows the same route. The default engine is Makaron and each output is exactly one full-body front/right-side/back turnaround sheet of one adult Persona in one unchanged Look. In the owner-approved direct-delivery mode, that request authorizes exactly one paid submission per selected result: do not pause to display Persona/Look or request a second confirmation. Return the finished turnaround and QC result directly. Use `--dry-run` only when the user asks to preview the locked selection(s) without generating.
21
21
  - “只看看匹配的参考” should use `personlib remote preview --brief TEXT --output-dir PATH --json`. This is free retrieval of the private Persona + Look reference pack, not a synthesized image.
22
- - “都市 / urban / city / city chic / downtown / 都市通勤 / 职场 / 杂志感”, “晚宴 / 红毯 / 高级派对”, and “运动休闲时尚” are Look-only searches: use `personlib remote find --kind look --brief TEXT --output-dir PATH --json`, then return only the Look reference image. Do not generate a person.
22
+ - “都市 / urban / city / city chic / downtown / 都市通勤 / 职场 / 杂志感”, “晚宴 / 红毯 / 高级派对”, “性感 / sexy / sensual / 夜生活 / 约会夜”, and “运动休闲时尚” are Look-only searches: use `personlib remote find --kind look --brief TEXT --output-dir PATH --json`, then return only the Look reference image. Do not generate a person.
23
23
  - “给这个人物换妆造” and “用这个人物生成” are combined Persona + Look requests: create a locked internal selection with `remote recommend` or `remote compose`, then use `remote render --selection`. Return only the finished turnaround to the user; never expose internal IDs.
24
24
 
25
25
  ## Hard Persona conditions
@@ -30,10 +30,10 @@ Persona is the stable identity reference: face structure, natural skin traits, s
30
30
 
31
31
  ## Access boundary
32
32
 
33
- Any enrolled OpenClaw Agent may use credentialed `remote doctor`, `remote find`, `remote recommend`, `remote compose`, `remote preview`, and `remote render`. It may not call `remote sync`, upload source images, manually apply appearance tags, delete assets, or publish the bundle. `remote render` additionally needs the Agent's own already-authenticated Makaron CLI. In owner-approved direct-delivery mode, a combined Persona + Look request is approval for exactly one paid submission per locked selection; do not ask a second confirmation. Open enrollment is an owner-selected access rule; never request or print API keys, Agent tokens, owner tokens, or Makaron credentials. `find --count N` returns N library images without IDs; `preview` returns source references only; `render --count N` returns N turnarounds plus one plan, prompt, run ID, and QC record per result.
33
+ Any enrolled OpenClaw Agent may use credentialed `remote doctor`, `remote find`, `remote recommend`, `remote compose`, `remote preview`, and `remote render`. It may not call `remote sync`, upload source images, manually apply appearance tags, delete assets, or publish the bundle. `remote render` additionally needs the Agent's own already-authenticated Makaron CLI. In owner-approved direct-delivery mode, a combined Persona + Look request is approval for exactly one paid submission per locked selection; do not ask a second confirmation. Open enrollment is an owner-selected access rule; never request or print API keys, Agent tokens, owner tokens, or Makaron credentials. `find --count N` returns N library images without IDs; its internal-only `--exclude ID[,ID...]` prevents a previously selected record or its matching source image from returning again. On shortfall it returns `BLOCKED` with `insufficient_matches: true` and a count, never a duplicate. `preview` returns source references only; `render --count N` returns N turnarounds plus one plan, prompt, run ID, and QC record per result.
34
34
 
35
35
  ## Current local-admin milestone
36
36
 
37
- The current package supports local Persona and Look intake, owner-reviewed appearance tags, list, show, integrity validation, `recommend --brief TEXT`, `fetch --brief TEXT --output-dir PATH`, `compose --persona P-xxx --look L-xxx`, and no-generation `remote find --kind persona|look --brief TEXT --count N --output-dir PATH`; it also ships the remote Worker/client implementation. The `find` route returns exactly the requested number of distinct reference images, with no IDs or selection metadata, or `BLOCKED` with the available count. The remote commands need a deployed private Worker and a successful top-level `setup` before use; do not claim that the current host is live until `remote doctor` succeeds against its Worker URL. `remote render --brief TEXT --count N` creates and resolves N locked selections just in time, calls Makaron exactly N times after a passing dry-run, and asks for one neutral-studio front/right-side/back turnaround per selection. It retains the Look's silhouette, palette, materials, energy, ordinary stripes, numbers, abstract graphics, and shoe construction. It removes only brand identifiers: logos, wordmarks, brand names, protected monograms, recognizable trademark symbols, and brand/team crests. It keeps internal inputs in `_internal_references/` for traceability but only the finished turnaround is customer-ready. Reject an output that is not one three-view adult turnaround, mixes identities or Looks, uses the Look source face, fails an owner-reviewed requested appearance tag or presentation, or includes a brand identifier. Record `PASS`, `REROLL`, or `BLOCKED`; do not submit a hidden retry.
37
+ The current package supports local Persona and Look intake, owner-reviewed Persona appearance tags, owner-reviewed structured Looks (`review-look`), list, show, integrity validation, `recommend --brief TEXT`, `fetch --brief TEXT --output-dir PATH`, `compose --persona P-xxx --look L-xxx`, and no-generation `remote find --kind persona|look --brief TEXT --count N --output-dir PATH`; it also ships the remote Worker/client implementation. The `find` route returns exactly the requested number of distinct reference images, with no IDs or selection metadata, or `BLOCKED` with the available count. The remote commands need a deployed private Worker and a successful top-level `setup` before use; do not claim that the current host is live until `remote doctor` succeeds against its Worker URL. `remote render --brief TEXT --count N` creates and resolves N locked selections just in time, calls Makaron exactly N times after a passing dry-run, and asks for one neutral-studio front/right-side/back turnaround per selection. It retains the Look's silhouette, palette, materials, energy, ordinary stripes, numbers, abstract graphics, and shoe construction. It removes only brand identifiers: logos, wordmarks, brand names, protected monograms, recognizable trademark symbols, and brand/team crests. It keeps internal inputs in `_internal_references/` for traceability but only the finished turnaround is customer-ready. Reject an output that is not one three-view adult turnaround, mixes identities or Looks, uses the Look source face, fails an owner-reviewed requested appearance tag or presentation, or includes a brand identifier. Record `PASS`, `REROLL`, or `BLOCKED`; do not submit a hidden retry.
38
38
 
39
39
  When a source has preppy, uniform-adjacent, or youth-coded styling, store only an adult office-core/editorial interpretation. Do not use a student identity, school context, or sexualized youth styling.