makaron-persona-look-cli 0.5.3 → 0.5.5

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
 
@@ -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,7 +58,7 @@ 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
 
@@ -74,6 +77,7 @@ node bin/personlib.mjs list --library ./private-library --json
74
77
  node bin/personlib.mjs show --persona P-001 --library ./private-library --json
75
78
  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
79
  node bin/personlib.mjs show --look L-001 --library ./private-library --json
80
+ 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
81
  node bin/personlib.mjs recommend --brief '请使用人物资产库,为一个 15 秒高端美妆广告找一位 25 岁左右、东亚、干净冷感的女性人物。' --library ./private-library --json
78
82
  node bin/personlib.mjs fetch --brief '请为一个 15 秒成年女性地铁皮夹克通勤广告找一套都市造型。' --output-dir ./selection --library ./private-library --json
79
83
  node bin/personlib.mjs compose --persona P-003 --look L-011 --library ./private-library --json
@@ -94,7 +98,7 @@ node bin/personlib.mjs intake \
94
98
 
95
99
  `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
100
 
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.
101
+ `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
102
 
99
103
  ## Test
100
104
 
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 = [
@@ -542,6 +577,7 @@ try {
542
577
  else if (command === "remote") await runRemote(argv.slice(1), { output, readManifest });
543
578
  else if (command === "intake") intake();
544
579
  else if (command === "review-persona") reviewPersonaAppearance();
580
+ else if (command === "review-look") reviewLook();
545
581
  else if (command === "recommend") recommend();
546
582
  else if (command === "fetch") fetchReferencePack();
547
583
  else if (command === "compose") compose();
@@ -569,7 +605,7 @@ try {
569
605
  output({ ok: errors.length === 0, errors });
570
606
  if (errors.length) process.exitCode = 1;
571
607
  } else {
572
- output("Usage: personlib <setup|intake|review-persona|list|show|recommend|fetch|compose|validate|remote> [--library PATH] [--json]");
608
+ output("Usage: personlib <setup|intake|review-persona|review-look|list|show|recommend|fetch|compose|validate|remote> [--library PATH] [--json]");
573
609
  process.exitCode = command ? 1 : 0;
574
610
  }
575
611
  } 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);
@@ -162,6 +170,13 @@ function distinctSourceCandidates(candidates) {
162
170
  });
163
171
  }
164
172
 
173
+ function excludeRecordCandidates(candidates, excludeIds) {
174
+ if (!excludeIds.length) return candidates;
175
+ const excluded = new Set(excludeIds);
176
+ const excludedSources = new Set(candidates.filter(({ record }) => excluded.has(record.id)).map(({ record }) => record.source_asset_id).filter(Boolean));
177
+ return candidates.filter(({ record }) => !excluded.has(record.id) && !excludedSources.has(record.source_asset_id));
178
+ }
179
+
165
180
  function buildRenderBrief(persona, look) {
166
181
  const identity = persona.identity || {};
167
182
  const direction = look.look || look;
@@ -223,17 +238,20 @@ async function selectSingle(env, { kind, brief }) {
223
238
  return selected.records[0];
224
239
  }
225
240
 
226
- async function selectSingles(env, { kind, brief, count = 1 }) {
241
+ async function selectSingles(env, { kind, brief, count = 1, excludeIds = [] }) {
227
242
  if (!['persona', 'look'].includes(kind)) throw new Error("kind must be persona or look");
228
243
  const requested = requestedCount(count);
229
244
  if (kind === "persona") {
230
- const selected = await selectPersonas(env, { brief, count: requested });
245
+ const selected = await selectPersonas(env, { brief, count: requested, excludeIds });
231
246
  return { records: selected.personas, available_count: selected.available_count };
232
247
  }
233
- const candidates = distinctSourceCandidates((await records(env, "look_records"))
248
+ const scored = (await records(env, "look_records"))
234
249
  .map((record) => ({ record, score: scoreLook(record, brief) }))
235
250
  .filter(({ score }) => score >= 6)
236
- .sort((a, b) => b.score - a.score || a.record.id.localeCompare(b.record.id)));
251
+ .sort((a, b) => b.score - a.score || a.record.id.localeCompare(b.record.id));
252
+ const unexcludedCandidates = distinctSourceCandidates(scored);
253
+ const candidates = distinctSourceCandidates(excludeRecordCandidates(scored, excludeIds));
254
+ if (excludeIds.length && unexcludedCandidates.length && candidates.length < requested) throw insufficientCandidatesBlocked("Look", requested, candidates.length);
237
255
  if (candidates.length < requested) throw insufficientCandidatesBlocked("Look", requested, candidates.length);
238
256
  return { records: candidates.slice(0, requested).map(({ record }) => record), available_count: candidates.length };
239
257
  }
@@ -247,7 +265,7 @@ async function selectPersona(env, { brief, personaId }) {
247
265
  };
248
266
  }
249
267
 
250
- async function selectPersonas(env, { brief, personaId, count = 1 }) {
268
+ async function selectPersonas(env, { brief, personaId, count = 1, excludeIds = [] }) {
251
269
  const requested = requestedCount(count);
252
270
  const personas = await records(env, "persona_records");
253
271
  const requiredAppearanceTags = requestedAppearanceTags(brief || "");
@@ -256,10 +274,18 @@ async function selectPersonas(env, { brief, personaId, count = 1 }) {
256
274
  if (!available.length) throw new Error(personaId ? `persona not found: ${personaId}` : "no Persona records available");
257
275
  const presentationCandidates = available.filter((record) => presentationMatches(record, requiredPresentation));
258
276
  if (!presentationCandidates.length && requiredPresentation) throw presentationBlocked(requiredPresentation, { requestedCount: requested, availableCount: 0 });
259
- const candidates = distinctSourceCandidates(presentationCandidates
277
+ const scored = presentationCandidates
260
278
  .filter((record) => appearanceMatches(record, requiredAppearanceTags))
261
279
  .map((record) => ({ record, score: scorePersona(record, brief || "") }))
262
- .sort((a, b) => b.score - a.score || a.record.id.localeCompare(b.record.id)));
280
+ .sort((a, b) => b.score - a.score || a.record.id.localeCompare(b.record.id));
281
+ const unexcludedCandidates = distinctSourceCandidates(scored);
282
+ const candidates = distinctSourceCandidates(excludeRecordCandidates(scored, excludeIds));
283
+ if (excludeIds.length && unexcludedCandidates.length && candidates.length < requested) {
284
+ throw insufficientCandidatesBlocked("Persona", requested, candidates.length, {
285
+ required_appearance_tags: requiredAppearanceTags,
286
+ required_presentation: requiredPresentation
287
+ });
288
+ }
263
289
  if (!candidates.length && requiredAppearanceTags.length) throw appearanceBlocked(requiredAppearanceTags, requiredPresentation, { requestedCount: requested, availableCount: 0 });
264
290
  if (!candidates.length && requiredPresentation) throw presentationBlocked(requiredPresentation, { requestedCount: requested, availableCount: 0 });
265
291
  if (!candidates.length) throw new Error(personaId ? `persona not found: ${personaId}` : "no Persona records available");
@@ -403,6 +429,7 @@ function blocked(cause, status = 409) {
403
429
  kind: cause?.kind || null,
404
430
  requested_count: cause?.requested_count || null,
405
431
  available_count: cause?.available_count ?? null,
432
+ insufficient_matches: cause?.code === "insufficient-candidates",
406
433
  error: cause?.message || "request blocked"
407
434
  }, status);
408
435
  }
@@ -533,12 +560,14 @@ async function route(request, env) {
533
560
  try {
534
561
  const kind = String(body.kind || "");
535
562
  const count = requestedCount(body?.count);
536
- const selected = await selectSingles(env, { kind, brief: String(body.brief), count });
563
+ const excludeIds = requestedExcludedIds(body?.exclude_ids);
564
+ const selected = await selectSingles(env, { kind, brief: String(body.brief), count, excludeIds });
537
565
  return json({
538
566
  kind: `${kind}-reference`,
539
567
  image_status: "library-reference-not-generated",
540
568
  requested_count: count,
541
569
  available_count: selected.available_count,
570
+ returned_count: selected.records.length,
542
571
  assets: selected.records.map((record, index) => ({
543
572
  role: kind,
544
573
  ...(count > 1 ? { index: index + 1 } : {}),
@@ -85,7 +85,8 @@ 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" },
@@ -180,6 +181,21 @@ test("owner sync, self-registration, recommendation, compose, and private previe
180
181
  assert.equal(personaBatch.available_count, 3);
181
182
  assert.deepEqual(personaBatch.assets.map((asset) => asset.index), [1, 2, 3]);
182
183
  assert.deepEqual(new Set(personaBatch.assets.map((asset) => asset.asset_id)), new Set(["P-001:source", "P-002:source", "P-003:source"]));
184
+ response = await worker.fetch(request("/v1/find", { method: "POST", headers, body: JSON.stringify({ kind: "persona", brief: "长相高冷的亚洲女生", count: 2, exclude_ids: ["P-001"] }) }), env);
185
+ const excludedPersonaBatch = await response.json();
186
+ assert.equal(response.status, 200);
187
+ assert.equal(excludedPersonaBatch.requested_count, 2);
188
+ assert.equal(excludedPersonaBatch.available_count, 2);
189
+ assert.equal(excludedPersonaBatch.returned_count, 2);
190
+ assert.deepEqual(excludedPersonaBatch.assets.map((asset) => asset.asset_id), ["P-002:source", "P-003:source"]);
191
+ response = await worker.fetch(request("/v1/find", { method: "POST", headers, body: JSON.stringify({ kind: "persona", brief: "长相高冷的亚洲女生", count: 3, exclude_ids: ["P-001"] }) }), env);
192
+ const insufficientPersonaBatch = await response.json();
193
+ assert.equal(response.status, 409);
194
+ assert.equal(insufficientPersonaBatch.state, "BLOCKED");
195
+ assert.equal(insufficientPersonaBatch.insufficient_matches, true);
196
+ assert.equal(insufficientPersonaBatch.requested_count, 3);
197
+ assert.equal(insufficientPersonaBatch.available_count, 2);
198
+ assert.equal(Object.hasOwn(insufficientPersonaBatch, "assets"), false);
183
199
  response = await worker.fetch(request("/v1/find", { method: "POST", headers, body: JSON.stringify({ kind: "look", brief: "3套 Y2K 的妆造", count: 3 }) }), env);
184
200
  const lookBatch = await response.json();
185
201
  assert.equal(response.status, 200);
@@ -187,6 +203,10 @@ test("owner sync, self-registration, recommendation, compose, and private previe
187
203
  assert.equal(lookBatch.available_count, 3);
188
204
  assert.deepEqual(lookBatch.assets.map((asset) => asset.index), [1, 2, 3]);
189
205
  assert.deepEqual(new Set(lookBatch.assets.map((asset) => asset.asset_id)), new Set(["L-115:source", "L-116:source", "L-117:source"]));
206
+ 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);
207
+ const excludedLookBatch = await response.json();
208
+ assert.equal(response.status, 200);
209
+ assert.deepEqual(excludedLookBatch.assets.map((asset) => asset.asset_id), ["L-116:source", "L-117:source"]);
190
210
  response = await worker.fetch(request("/v1/recommend", { method: "POST", headers, body: JSON.stringify({ brief: "3个都市风格的亚洲女生", count: 3 }) }), env);
191
211
  const urbanBatch = await response.json();
192
212
  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) {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "makaron-persona-look-cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Portable Persona and Look library for Makaron agents",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "bin": {
8
- "personlib": "bin/personlib.mjs",
9
- "makaron-persona-look-cli": "bin/personlib.mjs"
8
+ "makaron-persona-look-cli": "bin/personlib.mjs",
9
+ "personlib": "bin/personlib.mjs"
10
10
  },
11
11
  "files": [
12
12
  "bin",
@@ -12,10 +12,10 @@ 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.
@@ -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.