makaron-persona-look-cli 0.4.1
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/LICENSE +1 -0
- package/README.md +84 -0
- package/bin/personlib.mjs +495 -0
- package/cloudflare-worker/README.md +37 -0
- package/cloudflare-worker/schema.sql +26 -0
- package/cloudflare-worker/src/worker.mjs +206 -0
- package/cloudflare-worker/test/worker.test.mjs +111 -0
- package/cloudflare-worker/wrangler.toml +17 -0
- package/lib/remote-client.mjs +254 -0
- package/package.json +31 -0
- package/skills/makaron-persona-look/SKILL.md +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
All rights reserved.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Makaron Persona + Look CLI
|
|
2
|
+
|
|
3
|
+
`makaron-persona-look-cli` keeps stable character identity (`Persona`) separate from styling (`Look`). It provides owner-local administration plus a Worker-backed library that an Agent can search and download as a selected image reference pack. It never generates an image or submits a paid render.
|
|
4
|
+
|
|
5
|
+
## What an Agent gets
|
|
6
|
+
|
|
7
|
+
`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: the Persona is the only identity reference and the Look image's face must be excluded from any later render.
|
|
8
|
+
|
|
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
|
+
|
|
11
|
+
## One-command Agent setup
|
|
12
|
+
|
|
13
|
+
After the Worker has been deployed, the reviewed library has been synced, and the released package contains that HTTPS Worker URL in `personlib.default_api_url`, any Agent can run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx -y makaron-persona-look-cli setup
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That command installs the CLI and the `makaron-persona-look` Skill globally, creates a per-Agent Worker credential through open self-registration, and saves it locally. No registration token needs to be handed to the Agent.
|
|
20
|
+
|
|
21
|
+
For a pre-release or a Worker URL that is not yet embedded in the package, use the same command with an explicit endpoint:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx -y makaron-persona-look-cli setup --api-url https://your-worker.example
|
|
25
|
+
personlib remote doctor --json
|
|
26
|
+
personlib remote preview --brief '为一个20秒 K-pop 女团舞台视频找冷感、银黑未来街头造型人物参考图' --output-dir ./personlib-selection --json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The Worker is deployed at `https://personlib-agent.bzz0309.workers.dev` and its HTTPS URL is embedded in this release's `package.json`. `prepublishOnly` blocks an accidental npm release if that endpoint is removed. npm publication is a separate final step.
|
|
30
|
+
|
|
31
|
+
## Owner deployment
|
|
32
|
+
|
|
33
|
+
See [the Worker deployment guide](cloudflare-worker/README.md). The owner must first create the private R2 bucket and D1 database, deploy the Worker with `OPEN_ENROLLMENT="true"`, supply a private `OWNER_SYNC_TOKEN`, and synchronize the reviewed library:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
personlib remote sync --library ./private-library --dry-run --json
|
|
37
|
+
personlib remote sync --library ./private-library --json
|
|
38
|
+
```
|
|
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.
|
|
41
|
+
|
|
42
|
+
## Agent natural-language route
|
|
43
|
+
|
|
44
|
+
Once setup and `remote doctor` succeed, an Agent can fulfill a request such as “按这段文案获取对应图” with:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
personlib remote preview --brief '20 秒嘻哈说唱女 rapper 舞台视频,红黑街头风、宽松工装、强节奏' --output-dir ./personlib-selection --json
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The result contains the selected original reference images. If the intended result is a brand-new generated image, connect a separate Makaron rendering adapter after an explicit credit/submit authorization; this CLI deliberately stops before that paid/external action.
|
|
51
|
+
|
|
52
|
+
## Local owner-admin commands
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node bin/personlib.mjs list --library ./private-library --json
|
|
56
|
+
node bin/personlib.mjs show --persona P-001 --library ./private-library --json
|
|
57
|
+
node bin/personlib.mjs show --look L-001 --library ./private-library --json
|
|
58
|
+
node bin/personlib.mjs recommend --brief '请使用人物资产库,为一个 15 秒高端美妆广告找一位 25 岁左右、东亚、干净冷感的女性人物。' --library ./private-library --json
|
|
59
|
+
node bin/personlib.mjs fetch --brief '请为一个 15 秒成年女性地铁皮夹克通勤广告找一套都市造型。' --output-dir ./selection --library ./private-library --json
|
|
60
|
+
node bin/personlib.mjs compose --persona P-003 --look L-011 --library ./private-library --json
|
|
61
|
+
node bin/personlib.mjs validate --library ./private-library --json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
To intake a new owner-authorized reference:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
node bin/personlib.mjs intake \
|
|
68
|
+
--image /absolute/path/to/reference.png \
|
|
69
|
+
--reference /absolute/path/to/second-face-view.png \
|
|
70
|
+
--reference /absolute/path/to/third-face-view.png \
|
|
71
|
+
--into persona \
|
|
72
|
+
--rights authorized \
|
|
73
|
+
--library ./private-library --json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`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.
|
|
77
|
+
|
|
78
|
+
## Test
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm test
|
|
82
|
+
npm run test:remote
|
|
83
|
+
npm pack --dry-run --json
|
|
84
|
+
```
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { runBootstrapSetup, runRemote } from "../lib/remote-client.mjs";
|
|
8
|
+
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
10
|
+
const command = argv[0];
|
|
11
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
12
|
+
const bundledLibraryRoot = join(packageRoot, "private-library");
|
|
13
|
+
|
|
14
|
+
function fail(message) {
|
|
15
|
+
process.stderr.write(`${message}\n`);
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function option(name) {
|
|
20
|
+
const index = argv.indexOf(name);
|
|
21
|
+
return index === -1 ? undefined : argv[index + 1];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function options(name) {
|
|
25
|
+
const values = [];
|
|
26
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
27
|
+
if (argv[index] === name && argv[index + 1]) values.push(argv[index + 1]);
|
|
28
|
+
}
|
|
29
|
+
return values;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function has(name) {
|
|
33
|
+
return argv.includes(name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rootPath() {
|
|
37
|
+
return resolve(option("--library") || process.env.PERSONLIB_LIBRARY || (existsSync(bundledLibraryRoot) ? bundledLibraryRoot : ".personlib"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function manifestPath(root) {
|
|
41
|
+
return join(root, "manifest.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readManifest(root) {
|
|
45
|
+
const path = manifestPath(root);
|
|
46
|
+
if (!existsSync(path)) throw new Error(`library manifest not found: ${path}`);
|
|
47
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveManifest(root, manifest) {
|
|
51
|
+
mkdirSync(root, { recursive: true });
|
|
52
|
+
writeFileSync(manifestPath(root), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sha256(path) {
|
|
56
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function output(payload, forceJson = has("--json")) {
|
|
60
|
+
if (forceJson) console.log(JSON.stringify(payload, null, 2));
|
|
61
|
+
else console.log(typeof payload === "string" ? payload : JSON.stringify(payload, null, 2));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nextId(manifest, collection) {
|
|
65
|
+
const prefix = collection === "looks" ? "L-" : "P-";
|
|
66
|
+
const largest = (manifest[collection] || []).reduce((max, record) => {
|
|
67
|
+
const number = Number.parseInt(record.id.replace(new RegExp(`^${prefix}`), ""), 10);
|
|
68
|
+
return Number.isFinite(number) ? Math.max(max, number) : max;
|
|
69
|
+
}, 0);
|
|
70
|
+
return `${prefix}${String(largest + 1).padStart(3, "0")}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validate(manifest, root) {
|
|
74
|
+
const errors = [];
|
|
75
|
+
if (manifest.schema_version !== "0.1") errors.push("unsupported schema_version");
|
|
76
|
+
const personaIds = new Set();
|
|
77
|
+
for (const persona of manifest.personas || []) {
|
|
78
|
+
if (!/^P-\d{3,}$/.test(persona.id || "")) errors.push(`invalid persona id: ${persona.id}`);
|
|
79
|
+
if (personaIds.has(persona.id)) errors.push(`duplicate persona id: ${persona.id}`);
|
|
80
|
+
personaIds.add(persona.id);
|
|
81
|
+
if (!["private-staged", "intake-review"].includes(persona.status)) errors.push(`${persona.id}: unexpected status`);
|
|
82
|
+
if (persona.provenance?.rights !== "owner-confirmed") errors.push(`${persona.id}: missing owner-confirmed rights`);
|
|
83
|
+
const sourcePath = join(root, persona.source?.relative_path || "");
|
|
84
|
+
if (!existsSync(sourcePath)) errors.push(`${persona.id}: source missing`);
|
|
85
|
+
else if (sha256(sourcePath) !== persona.source.sha256) errors.push(`${persona.id}: source checksum mismatch`);
|
|
86
|
+
for (const reference of persona.identity_references || []) {
|
|
87
|
+
const referencePath = join(root, reference.relative_path || "");
|
|
88
|
+
if (!reference.relative_path || !reference.sha256) errors.push(`${persona.id}: malformed identity reference`);
|
|
89
|
+
else if (!existsSync(referencePath)) errors.push(`${persona.id}: identity reference missing`);
|
|
90
|
+
else if (sha256(referencePath) !== reference.sha256) errors.push(`${persona.id}: identity reference checksum mismatch`);
|
|
91
|
+
}
|
|
92
|
+
const identity = persona.identity || {};
|
|
93
|
+
if (persona.status === "private-staged") {
|
|
94
|
+
for (const field of ["face_shape", "brows", "eyes", "nose", "mouth", "skin", "stable_marks"]) {
|
|
95
|
+
if (!identity[field]) errors.push(`${persona.id}: missing identity.${field}`);
|
|
96
|
+
}
|
|
97
|
+
} else if (identity.review_state !== "needs-structured-review") {
|
|
98
|
+
errors.push(`${persona.id}: intake-review records must declare identity.review_state`);
|
|
99
|
+
}
|
|
100
|
+
for (const forbidden of ["hair", "makeup", "wardrobe", "accessories"]) {
|
|
101
|
+
if (Object.hasOwn(identity, forbidden)) errors.push(`${persona.id}: identity may not contain ${forbidden}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const lookIds = new Set();
|
|
105
|
+
for (const lookRecord of manifest.looks || []) {
|
|
106
|
+
if (!/^L-\d{3,}$/.test(lookRecord.id || "")) errors.push(`invalid look id: ${lookRecord.id}`);
|
|
107
|
+
if (lookIds.has(lookRecord.id)) errors.push(`duplicate look id: ${lookRecord.id}`);
|
|
108
|
+
lookIds.add(lookRecord.id);
|
|
109
|
+
if (!["private-staged", "intake-review"].includes(lookRecord.status)) errors.push(`${lookRecord.id}: unexpected status`);
|
|
110
|
+
if (lookRecord.provenance?.rights !== "owner-confirmed") errors.push(`${lookRecord.id}: missing owner-confirmed rights`);
|
|
111
|
+
const sourcePath = join(root, lookRecord.source?.relative_path || "");
|
|
112
|
+
if (!existsSync(sourcePath)) errors.push(`${lookRecord.id}: source missing`);
|
|
113
|
+
else if (sha256(sourcePath) !== lookRecord.source.sha256) errors.push(`${lookRecord.id}: source checksum mismatch`);
|
|
114
|
+
if (lookRecord.identity_policy !== "do-not-use-source-face") errors.push(`${lookRecord.id}: missing source-face restriction`);
|
|
115
|
+
if (lookRecord.status === "private-staged") {
|
|
116
|
+
for (const field of ["family", "silhouette", "garments", "palette", "materials", "accessories", "scene"]) {
|
|
117
|
+
if (!lookRecord.look?.[field]) errors.push(`${lookRecord.id}: missing look.${field}`);
|
|
118
|
+
}
|
|
119
|
+
if (lookRecord.brand_policy !== "replace-source-logos-and-wordmarks") errors.push(`${lookRecord.id}: missing brand-sanitization rule`);
|
|
120
|
+
} else if (lookRecord.look?.review_state !== "needs-structured-review") {
|
|
121
|
+
errors.push(`${lookRecord.id}: intake-review records must declare look.review_state`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return errors;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function intake() {
|
|
128
|
+
const root = rootPath();
|
|
129
|
+
const image = option("--image");
|
|
130
|
+
const referenceImages = options("--reference");
|
|
131
|
+
const into = option("--into");
|
|
132
|
+
const rights = option("--rights");
|
|
133
|
+
if (!image || !["persona", "look"].includes(into) || rights !== "authorized") {
|
|
134
|
+
throw new Error("usage: personlib intake --image PATH [--reference PATH ...] --into <persona|look> --rights authorized [--id ID] [--name NAME]");
|
|
135
|
+
}
|
|
136
|
+
const approvedExtensions = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
|
137
|
+
for (const inputImage of [image, ...referenceImages]) {
|
|
138
|
+
if (!existsSync(inputImage)) throw new Error(`image not found: ${inputImage}`);
|
|
139
|
+
if (!approvedExtensions.has(extname(inputImage).toLowerCase())) throw new Error(`unsupported image type: ${extname(inputImage).toLowerCase()}`);
|
|
140
|
+
}
|
|
141
|
+
const manifest = existsSync(manifestPath(root)) ? readManifest(root) : {
|
|
142
|
+
schema_version: "0.1",
|
|
143
|
+
library_id: "private-persona-library",
|
|
144
|
+
visibility: "private",
|
|
145
|
+
personas: [],
|
|
146
|
+
looks: []
|
|
147
|
+
};
|
|
148
|
+
manifest.personas ||= [];
|
|
149
|
+
manifest.looks ||= [];
|
|
150
|
+
const collection = into === "persona" ? "personas" : "looks";
|
|
151
|
+
const id = option("--id") || nextId(manifest, collection);
|
|
152
|
+
if (manifest[collection].some((record) => record.id === id)) throw new Error(`${into} already exists: ${id}`);
|
|
153
|
+
const targetDir = join(root, collection, id);
|
|
154
|
+
mkdirSync(targetDir, { recursive: true });
|
|
155
|
+
const copySource = (sourceImage, filename) => {
|
|
156
|
+
const target = join(targetDir, filename);
|
|
157
|
+
copyFileSync(sourceImage, target);
|
|
158
|
+
return {
|
|
159
|
+
original_filename: basename(sourceImage),
|
|
160
|
+
relative_path: `${collection}/${id}/${filename}`,
|
|
161
|
+
sha256: sha256(target)
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
const source = copySource(image, `source${extname(image).toLowerCase()}`);
|
|
165
|
+
const identityReferences = into === "persona" ? referenceImages.map((referenceImage, index) => (
|
|
166
|
+
copySource(referenceImage, `reference-${index + 1}${extname(referenceImage).toLowerCase()}`)
|
|
167
|
+
)) : [];
|
|
168
|
+
const common = {
|
|
169
|
+
id,
|
|
170
|
+
display_name: option("--name") || `${into === "persona" ? "Persona" : "Look"} ${id.slice(2)}`,
|
|
171
|
+
status: "intake-review",
|
|
172
|
+
provenance: {
|
|
173
|
+
type: "user-provided",
|
|
174
|
+
rights: "owner-confirmed",
|
|
175
|
+
imported_at: new Date().toISOString()
|
|
176
|
+
},
|
|
177
|
+
source,
|
|
178
|
+
cloud_sync: "not-started"
|
|
179
|
+
};
|
|
180
|
+
const record = into === "persona" ? {
|
|
181
|
+
...common,
|
|
182
|
+
identity: { review_state: "needs-structured-review" },
|
|
183
|
+
identity_references: identityReferences,
|
|
184
|
+
look_observations: []
|
|
185
|
+
} : {
|
|
186
|
+
...common,
|
|
187
|
+
look: { review_state: "needs-structured-review" },
|
|
188
|
+
identity_policy: "do-not-use-source-face",
|
|
189
|
+
brand_policy: "needs-brand-sanitization"
|
|
190
|
+
};
|
|
191
|
+
manifest[collection].push(record);
|
|
192
|
+
saveManifest(root, manifest);
|
|
193
|
+
output({ ok: true, [into]: record });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildRenderBrief(persona, look) {
|
|
197
|
+
const identity = persona.identity;
|
|
198
|
+
const identityLock = [
|
|
199
|
+
identity.face_shape,
|
|
200
|
+
identity.brows,
|
|
201
|
+
identity.eyes,
|
|
202
|
+
identity.nose,
|
|
203
|
+
identity.mouth,
|
|
204
|
+
identity.skin,
|
|
205
|
+
identity.stable_marks,
|
|
206
|
+
identity.body_evidence
|
|
207
|
+
].filter(Boolean).join("; ");
|
|
208
|
+
return [
|
|
209
|
+
`Original ${identity.presentation || "adult"} character.`,
|
|
210
|
+
`Identity lock: ${identityLock}.`,
|
|
211
|
+
`Keep this Persona's facial structure, apparent adult age, natural skin traits, and stable marks; do not replace them with any Look-reference identity.`,
|
|
212
|
+
`Look direction: ${look.family}. ${look.silhouette}. Garments: ${look.garments}.`,
|
|
213
|
+
`Palette: ${look.palette}. Materials: ${look.materials}. Accessories: ${look.accessories}.`,
|
|
214
|
+
`Scene: ${look.scene}.`,
|
|
215
|
+
`Use original, generic design language: replace source logos, watermarks, wordmarks, celebrity likenesses, and recognizable brand elements.`
|
|
216
|
+
].join(" ");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function scorePersonaForBrief(persona, brief) {
|
|
220
|
+
const query = brief.toLowerCase();
|
|
221
|
+
const identity = Object.values(persona.identity || {}).join(" ").toLowerCase();
|
|
222
|
+
let score = persona.identity?.presentation === "feminine adult" ? 2 : 0;
|
|
223
|
+
if (/干净|clean|beauty|美妆/.test(query)) {
|
|
224
|
+
for (const signal of ["fine", "soft", "natural", "even", "neutral", "low-contrast"]) {
|
|
225
|
+
if (identity.includes(signal)) score += 2;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (/冷感|cool|冷调|高端|luxury/.test(query)) {
|
|
229
|
+
for (const signal of ["narrow", "long", "straight", "low-contrast", "calm", "slim"]) {
|
|
230
|
+
if (identity.includes(signal)) score += 2;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return score;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function scoreLookForBrief(lookRecord, brief) {
|
|
237
|
+
const query = brief.toLowerCase();
|
|
238
|
+
const lookText = Object.values(lookRecord.look || {}).join(" ").toLowerCase();
|
|
239
|
+
let score = 0;
|
|
240
|
+
const addSignals = (requestPattern, signals) => {
|
|
241
|
+
if (!requestPattern.test(query)) return;
|
|
242
|
+
for (const [signal, weight] of signals) {
|
|
243
|
+
if (lookText.includes(signal)) score += weight;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
addSignals(/美妆|beauty|cosmetic|skincare|护肤/, [["beauty", 4], ["skincare", 4], ["serum", 3], ["fragrance", 2], ["treatment", 2]]);
|
|
247
|
+
addSignals(/高端|luxury|premium|prestige/, [["luxury", 3], ["premium", 3], ["prestige", 3], ["sculptural", 2], ["metallic", 2], ["gold", 2], ["silver", 2]]);
|
|
248
|
+
addSignals(/干净|clean|minimal/, [["clean", 3], ["white", 2], ["minimal", 2], ["bright", 1], ["soft", 1]]);
|
|
249
|
+
addSignals(/冷感|cool|冷调/, [["silver", 2], ["graphite", 2], ["grey", 2], ["icy", 2], ["black", 1], ["violet", 1]]);
|
|
250
|
+
addSignals(/y2k|千禧/, [["y2k", 6]]);
|
|
251
|
+
addSignals(/学院|校园|collegiate|academic/, [["collegiate", 5], ["academic", 5], ["campus", 3]]);
|
|
252
|
+
addSignals(/哥特|gothic|暗黑|grunge/, [["gothic", 4], ["grunge", 4], ["dark", 2]]);
|
|
253
|
+
addSignals(/街头|street/, [["street", 4], ["cargo", 2], ["denim", 1]]);
|
|
254
|
+
addSignals(/地铁|metro|subway|transit/, [["transit", 5], ["metro", 4], ["commuter", 3], ["urban", 1]]);
|
|
255
|
+
addSignals(/机车夹克|皮夹克|moto|leather jacket/, [["leather", 4], ["moto", 11], ["black", 1], ["grey", 1]]);
|
|
256
|
+
addSignals(/中性|男女同款|unisex|androgynous|gender[- ]?neutral/, [["unisex", 5], ["gender-neutral", 5], ["oversized", 2], ["cargo", 2], ["long short", 2], ["knit", 1]]);
|
|
257
|
+
addSignals(/嘻哈|说唱|hip[- ]?hop|rap(?:per)?/, [["hip-hop", 5], ["rap", 5], ["street", 3], ["baggy", 2], ["cargo", 2], ["jersey", 2], ["chain", 2], ["camo", 3], ["wide", 1]]);
|
|
258
|
+
addSignals(/通勤|office|职场|workwear|商务|上班|commute/, [["office", 4], ["commute", 4], ["workwear", 4], ["tailored", 2], ["suit", 2], ["blazer", 2]]);
|
|
259
|
+
addSignals(/极简轻奢|quiet luxury|成熟通勤|mature commute/, [["quiet luxury", 6], ["mature commute", 6], ["trench", 3], ["tailored", 2], ["minimal", 2], ["wool", 1]]);
|
|
260
|
+
addSignals(/杂志|magazine|editorial|时尚大片/, [["magazine", 4], ["editorial", 4], ["studio", 1], ["tailored", 1]]);
|
|
261
|
+
addSignals(/晚宴|gala|black-tie|formal/, [["gala", 4], ["evening", 3], ["formal", 3], ["gown", 2], ["soiree", 2]]);
|
|
262
|
+
addSignals(/红毯|red carpet/, [["red carpet", 6], ["gala", 3], ["carpet", 2], ["train", 2], ["gown", 2]]);
|
|
263
|
+
addSignals(/高级派对|party|cocktail|after-party/, [["party", 4], ["cocktail", 3], ["soiree", 3], ["crystal", 1], ["sequin", 1], ["gown", 1]]);
|
|
264
|
+
addSignals(/运动休闲|athleisure|运动|sport|sportswear|健身|球类|篮球|网球|跑步|track|训练/, [["athleisure", 5], ["sport", 4], ["track", 3], ["running", 2], ["tennis", 2], ["basketball", 2], ["technical", 1], ["fleece", 1], ["hoodie", 1]]);
|
|
265
|
+
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]]);
|
|
266
|
+
return score;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function catalogLookForBrief(manifest, brief) {
|
|
270
|
+
const candidates = (manifest.looks || [])
|
|
271
|
+
.filter((lookRecord) => lookRecord.status === "private-staged")
|
|
272
|
+
.map((lookRecord) => ({ lookRecord, score: scoreLookForBrief(lookRecord, brief) }))
|
|
273
|
+
.sort((a, b) => b.score - a.score || a.lookRecord.id.localeCompare(b.lookRecord.id));
|
|
274
|
+
const top = candidates[0];
|
|
275
|
+
if (!top || top.score < 6) return undefined;
|
|
276
|
+
const durationMatch = brief.match(/(?:^|\D)(\d{1,3})\s*(?:秒|s|sec|second)/i);
|
|
277
|
+
return {
|
|
278
|
+
source: "catalog-match",
|
|
279
|
+
score: top.score,
|
|
280
|
+
look: {
|
|
281
|
+
id: top.lookRecord.id,
|
|
282
|
+
display_name: top.lookRecord.display_name,
|
|
283
|
+
status: top.lookRecord.status,
|
|
284
|
+
...top.lookRecord.look,
|
|
285
|
+
duration_seconds: durationMatch ? Number.parseInt(durationMatch[1], 10) : undefined,
|
|
286
|
+
identity_policy: "persona-is-the-only-identity-reference",
|
|
287
|
+
brand_policy: "original-unbranded-design-only"
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function temporaryLookFromBrief(brief) {
|
|
293
|
+
const beautyRequest = /美妆|beauty|cosmetic|skincare/i.test(brief);
|
|
294
|
+
const coolRequest = /冷感|cool|冷调/i.test(brief);
|
|
295
|
+
const highEndRequest = /高端|luxury|premium/i.test(brief);
|
|
296
|
+
const durationMatch = brief.match(/(?:^|\D)(\d{1,3})\s*(?:秒|s|sec|second)/i);
|
|
297
|
+
const idHash = createHash("sha256").update(brief).digest("hex").slice(0, 8).toUpperCase();
|
|
298
|
+
return {
|
|
299
|
+
id: `TEMP-L-${idHash}`,
|
|
300
|
+
display_name: beautyRequest ? "Temporary Clean Cool Beauty" : "Temporary Text-Derived Look",
|
|
301
|
+
status: "temporary-not-saved",
|
|
302
|
+
source: "generated-from-user-brief",
|
|
303
|
+
family: beautyRequest ? "high-end clean beauty" : "brief-derived original styling",
|
|
304
|
+
silhouette: "minimal architectural neckline with an uncluttered shoulder line",
|
|
305
|
+
garments: highEndRequest ? "original ivory architectural halter top, no visible branding" : "original unbranded minimal styling",
|
|
306
|
+
palette: coolRequest ? "pearl, ice blue-grey, muted cool rose, soft ivory" : "clean neutral palette derived from the request",
|
|
307
|
+
materials: "satin, sheer beauty textures, frosted glass, soft studio surfaces",
|
|
308
|
+
accessories: "minimal or none; no trademarked elements",
|
|
309
|
+
scene: beautyRequest ? "premium beauty studio with a frosted blue-grey gradient and soft reflective surfaces" : "minimal studio scene derived from the request",
|
|
310
|
+
duration_seconds: durationMatch ? Number.parseInt(durationMatch[1], 10) : undefined,
|
|
311
|
+
identity_policy: "persona-is-the-only-identity-reference",
|
|
312
|
+
brand_policy: "original-unbranded-design-only"
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function recommendationForBrief(manifest, brief) {
|
|
317
|
+
const candidates = (manifest.personas || [])
|
|
318
|
+
.filter((persona) => persona.status === "private-staged")
|
|
319
|
+
.map((persona) => ({ persona, score: scorePersonaForBrief(persona, brief) }))
|
|
320
|
+
.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");
|
|
322
|
+
const selected = candidates[0].persona;
|
|
323
|
+
const catalogMatch = catalogLookForBrief(manifest, brief);
|
|
324
|
+
const selectedLook = catalogMatch?.look || temporaryLookFromBrief(brief);
|
|
325
|
+
const ageRequested = /(?:25\s*岁|25\s*year|twenty[- ]?five)/i.test(brief);
|
|
326
|
+
const eastAsiaRequested = /东亚|east\s*asian/i.test(brief);
|
|
327
|
+
return {
|
|
328
|
+
request: brief,
|
|
329
|
+
persona: {
|
|
330
|
+
id: selected.id,
|
|
331
|
+
display_name: selected.display_name,
|
|
332
|
+
match_reason: "Selected from explicit adult Persona metadata and structure-level clean/cool signals; style is not used as identity evidence."
|
|
333
|
+
},
|
|
334
|
+
alternatives: candidates.slice(1, 3).map(({ persona, score }) => ({ id: persona.id, display_name: persona.display_name, score })),
|
|
335
|
+
look: selectedLook,
|
|
336
|
+
look_match: catalogMatch ? { source: catalogMatch.source, score: catalogMatch.score } : { source: "temporary-text-derived", score: 0 },
|
|
337
|
+
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"] : [])
|
|
340
|
+
],
|
|
341
|
+
render_brief: buildRenderBrief(selected, selectedLook),
|
|
342
|
+
preview: {
|
|
343
|
+
status: "ready-for-authorized-image-renderer",
|
|
344
|
+
source_face_policy: "use-selected-persona-only",
|
|
345
|
+
temporary_look_policy: catalogMatch ? "not-applicable-catalog-look-selected" : "do-not-save-unless-owner-promotes-it",
|
|
346
|
+
output: "one unbranded vertical preview image"
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function recommend() {
|
|
352
|
+
const root = rootPath();
|
|
353
|
+
const brief = option("--brief");
|
|
354
|
+
if (!brief) throw new Error("usage: personlib recommend --brief TEXT --library PATH");
|
|
355
|
+
const manifest = readManifest(root);
|
|
356
|
+
const errors = validate(manifest, root);
|
|
357
|
+
if (errors.length) throw new Error(`library validation failed: ${errors.join("; ")}`);
|
|
358
|
+
output({ ok: true, recommendation: recommendationForBrief(manifest, brief) });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function fetchReferencePack() {
|
|
362
|
+
const root = rootPath();
|
|
363
|
+
const brief = option("--brief");
|
|
364
|
+
const outputDir = option("--output-dir");
|
|
365
|
+
if (!brief || !outputDir) throw new Error("usage: personlib fetch --brief TEXT --output-dir PATH [--overwrite] --library PATH");
|
|
366
|
+
const manifest = readManifest(root);
|
|
367
|
+
const errors = validate(manifest, root);
|
|
368
|
+
if (errors.length) throw new Error(`library validation failed: ${errors.join("; ")}`);
|
|
369
|
+
const recommendation = recommendationForBrief(manifest, brief);
|
|
370
|
+
if (recommendation.look.status !== "private-staged") {
|
|
371
|
+
throw new Error("no catalog Look matched this brief; fetch only returns an existing reference pack");
|
|
372
|
+
}
|
|
373
|
+
const persona = (manifest.personas || []).find((entry) => entry.id === recommendation.persona.id);
|
|
374
|
+
const lookRecord = (manifest.looks || []).find((entry) => entry.id === recommendation.look.id);
|
|
375
|
+
if (!persona || !lookRecord) throw new Error("selected records are unavailable for fetch");
|
|
376
|
+
|
|
377
|
+
const destination = resolve(outputDir);
|
|
378
|
+
mkdirSync(destination, { recursive: true });
|
|
379
|
+
const copyReference = (source, role, index = 0) => {
|
|
380
|
+
const extension = extname(source.original_filename || source.relative_path) || ".png";
|
|
381
|
+
const filename = `${role}${index ? `-${index}` : ""}${extension}`;
|
|
382
|
+
const target = join(destination, filename);
|
|
383
|
+
if (existsSync(target) && !has("--overwrite")) throw new Error(`refusing to overwrite ${target}; pass --overwrite to replace it`);
|
|
384
|
+
copyFileSync(join(root, source.relative_path), target);
|
|
385
|
+
return { role, filename };
|
|
386
|
+
};
|
|
387
|
+
const files = [copyReference(persona.source, "persona")];
|
|
388
|
+
for (const [index, source] of (persona.identity_references || []).entries()) {
|
|
389
|
+
files.push(copyReference(source, "persona-reference", index + 1));
|
|
390
|
+
}
|
|
391
|
+
files.push(copyReference(lookRecord.source, "look"));
|
|
392
|
+
const selectionFilename = "selection.json";
|
|
393
|
+
const selectionPath = join(destination, selectionFilename);
|
|
394
|
+
if (existsSync(selectionPath) && !has("--overwrite")) throw new Error(`refusing to overwrite ${selectionPath}; pass --overwrite to replace it`);
|
|
395
|
+
writeFileSync(selectionPath, `${JSON.stringify({
|
|
396
|
+
schema_version: "0.1",
|
|
397
|
+
kind: "persona-look-reference-pack",
|
|
398
|
+
generated_at: new Date().toISOString(),
|
|
399
|
+
recommendation: {
|
|
400
|
+
request: recommendation.request,
|
|
401
|
+
persona: { ...recommendation.persona, identity: persona.identity },
|
|
402
|
+
look: recommendation.look,
|
|
403
|
+
look_match: recommendation.look_match,
|
|
404
|
+
render_brief: recommendation.render_brief,
|
|
405
|
+
source_face_policy: "persona-is-the-only-identity-reference",
|
|
406
|
+
image_status: "source-reference-pack-not-a-generated-composite"
|
|
407
|
+
},
|
|
408
|
+
files
|
|
409
|
+
}, null, 2)}\n`, "utf8");
|
|
410
|
+
output({
|
|
411
|
+
ok: true,
|
|
412
|
+
retrieval: {
|
|
413
|
+
kind: "persona-look-reference-pack",
|
|
414
|
+
image_status: "source-reference-pack-not-a-generated-composite",
|
|
415
|
+
output_dir: destination,
|
|
416
|
+
files: [...files, { role: "selection", filename: selectionFilename }],
|
|
417
|
+
persona: recommendation.persona,
|
|
418
|
+
look: { id: recommendation.look.id, display_name: recommendation.look.display_name },
|
|
419
|
+
render_brief: recommendation.render_brief
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function compose() {
|
|
425
|
+
const root = rootPath();
|
|
426
|
+
const personaId = option("--persona");
|
|
427
|
+
const lookId = option("--look");
|
|
428
|
+
if (!personaId || !lookId) {
|
|
429
|
+
throw new Error("usage: personlib compose --persona P-001 --look L-001 --library PATH");
|
|
430
|
+
}
|
|
431
|
+
const manifest = readManifest(root);
|
|
432
|
+
const errors = validate(manifest, root);
|
|
433
|
+
if (errors.length) throw new Error(`library validation failed: ${errors.join("; ")}`);
|
|
434
|
+
const persona = (manifest.personas || []).find((entry) => entry.id === personaId);
|
|
435
|
+
const lookRecord = (manifest.looks || []).find((entry) => entry.id === lookId);
|
|
436
|
+
if (!persona) throw new Error(`persona not found: ${personaId}`);
|
|
437
|
+
if (!lookRecord) throw new Error(`look not found: ${lookId}`);
|
|
438
|
+
if (persona.status !== "private-staged") throw new Error(`persona is not ready for composition: ${personaId}`);
|
|
439
|
+
if (lookRecord.status !== "private-staged") throw new Error(`look is not ready for composition: ${lookId}`);
|
|
440
|
+
|
|
441
|
+
const look = lookRecord.look;
|
|
442
|
+
output({
|
|
443
|
+
ok: true,
|
|
444
|
+
composition: {
|
|
445
|
+
persona: { id: persona.id, display_name: persona.display_name, identity: persona.identity },
|
|
446
|
+
look: { id: lookRecord.id, display_name: lookRecord.display_name, ...look },
|
|
447
|
+
policies: {
|
|
448
|
+
use_persona_as_only_identity_reference: true,
|
|
449
|
+
look_source_face: "excluded",
|
|
450
|
+
source_logos_and_wordmarks: "replace-with-original-generic-design"
|
|
451
|
+
},
|
|
452
|
+
render_brief: buildRenderBrief(persona, look),
|
|
453
|
+
render_status: "brief-only-no-generation-submitted"
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const root = rootPath();
|
|
460
|
+
if (command === "setup") await runBootstrapSetup(argv.slice(1), { output });
|
|
461
|
+
else if (command === "remote") await runRemote(argv.slice(1), { output, readManifest });
|
|
462
|
+
else if (command === "intake") intake();
|
|
463
|
+
else if (command === "recommend") recommend();
|
|
464
|
+
else if (command === "fetch") fetchReferencePack();
|
|
465
|
+
else if (command === "compose") compose();
|
|
466
|
+
else if (command === "list") {
|
|
467
|
+
const manifest = readManifest(root);
|
|
468
|
+
const type = option("--type");
|
|
469
|
+
if (type && !["persona", "look"].includes(type)) throw new Error("--type must be persona or look");
|
|
470
|
+
const payload = { ok: true, library_id: manifest.library_id };
|
|
471
|
+
if (!type || type === "persona") payload.personas = (manifest.personas || []).map(({ id, display_name, status }) => ({ id, display_name, status }));
|
|
472
|
+
if (!type || type === "look") payload.looks = (manifest.looks || []).map(({ id, display_name, status }) => ({ id, display_name, status }));
|
|
473
|
+
output(payload);
|
|
474
|
+
} else if (command === "show") {
|
|
475
|
+
const personaId = option("--persona");
|
|
476
|
+
const lookId = option("--look");
|
|
477
|
+
if (Boolean(personaId) === Boolean(lookId)) throw new Error("usage: personlib show --persona P-001 | --look L-001 --library PATH");
|
|
478
|
+
const manifest = readManifest(root);
|
|
479
|
+
const collection = personaId ? "personas" : "looks";
|
|
480
|
+
const type = personaId ? "persona" : "look";
|
|
481
|
+
const id = personaId || lookId;
|
|
482
|
+
const record = (manifest[collection] || []).find((entry) => entry.id === id);
|
|
483
|
+
if (!record) throw new Error(`${type} not found: ${id}`);
|
|
484
|
+
output({ ok: true, [type]: record });
|
|
485
|
+
} else if (command === "validate") {
|
|
486
|
+
const errors = validate(readManifest(root), root);
|
|
487
|
+
output({ ok: errors.length === 0, errors });
|
|
488
|
+
if (errors.length) process.exitCode = 1;
|
|
489
|
+
} else {
|
|
490
|
+
output("Usage: personlib <setup|intake|list|show|recommend|fetch|compose|validate|remote> [--library PATH] [--json]");
|
|
491
|
+
process.exitCode = command ? 1 : 0;
|
|
492
|
+
}
|
|
493
|
+
} catch (error) {
|
|
494
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
495
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Private Worker deployment
|
|
2
|
+
|
|
3
|
+
This Worker serves the owner-authorized Persona + Look library to individually enrolled Agents. D1 stores sanitized record metadata and hashes of Agent credentials; R2 stores approved source-reference bytes. There is no anonymous catalog or asset route.
|
|
4
|
+
|
|
5
|
+
The owner has elected open self-registration: any Agent can call `POST /v1/agents/setup` and receive a unique credential, but must still authenticate each later recommendation, composition, doctor, and asset request. The credential is sent once to the local CLI and saved only in a mode-600 local config file.
|
|
6
|
+
|
|
7
|
+
Run these commands only in the owner's Cloudflare account after installing/authenticating Wrangler. Do not place secret values in a command, a file, or shell history.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd cloudflare-worker
|
|
11
|
+
npx wrangler d1 create personlib
|
|
12
|
+
npx wrangler r2 bucket create personlib-assets
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Copy the returned D1 database id into `wrangler.toml`, set `OPEN_ENROLLMENT = "true"`, then create the schema and owner secret:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx wrangler d1 execute personlib --remote --file schema.sql
|
|
19
|
+
npx wrangler secret put OWNER_SYNC_TOKEN
|
|
20
|
+
npx wrangler deploy
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
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
|
+
|
|
25
|
+
```bash
|
|
26
|
+
personlib remote sync --library ./private-library --dry-run --json
|
|
27
|
+
personlib remote sync --library ./private-library --json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
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
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx -y makaron-persona-look-cli setup
|
|
34
|
+
personlib remote doctor --json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
To restrict enrollment again, set `OPEN_ENROLLMENT` to `false`, create `AGENT_REGISTRATION_TOKEN` as a Worker secret, and provide the matching registration token only through the Agent's secret manager. This is not the current requested policy.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
name TEXT NOT NULL,
|
|
4
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
5
|
+
created_at TEXT NOT NULL,
|
|
6
|
+
revoked_at TEXT
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS persona_records (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
display_name TEXT NOT NULL,
|
|
12
|
+
record_json TEXT NOT NULL
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS look_records (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
display_name TEXT NOT NULL,
|
|
18
|
+
record_json TEXT NOT NULL
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS assets (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
object_key TEXT NOT NULL UNIQUE,
|
|
24
|
+
sha256 TEXT NOT NULL,
|
|
25
|
+
mime_type TEXT NOT NULL
|
|
26
|
+
);
|