feat: 建立人格並補齊資料後產生圖示(SVG + PNG,零外部依賴)

`icon generate|show`:由**編號、Name、Emoji** 雜湊出配色與圖案,產出
512×512 的幾何徽章 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。

圖案(SVG 與 PNG 共用同一組單位座標與同一份 5×7 點陣字,**輸出的是同一張圖**):
  * 雙色對角漸層底
  * 5×5 左右對稱點陣紋(identicon 式)
  * 中央兩個字母=編號前兩字(ASUNA-01 → AS),依背景亮度自動選黑/白

同一個人格永遠得到同一張圖(純函數,無隨機);ASUNA-01 與 ASUNA-02 明顯不同。

為什麼自己畫:這台機器(及多數伺服器)沒有 rsvg/inkscape/imagemagick,
沒有影像函式庫,也沒有 emoji 字型,而本專案禁止 npm 依賴。所以
scripts/persona-icon.mjs 自己柵格化(3× 超取樣 + 盒式縮減當反鋸齒),
再用內建 zlib 手工組出 IHDR/IDAT/IEND 與 CRC32。
emoji 無法柵格化,因此不作為圖形,但仍參與配色雜湊。

時機:**資料補齊之後才產生**——配色與字母綁在最終身分上,太早跑會對不上。
persona-create 第 7 步、persona-anime 第 8 步都補了這個步驟;改過身分用 --force 重畫。

selftest 128 項全綠(新增第 ⑭ 節:PNG chunk 合法性、決定性、不同人格不同圖、
圖示屬 Wiki 區、匯出用 base64 帶走二進位、guest 不得重畫)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:03:45 +00:00
co-authored by Claude Opus 5
parent e986686d0e
commit d067907225
12 changed files with 580 additions and 11 deletions
+42
View File
@@ -19,6 +19,7 @@ process.env.PERSONA_GITEA = "off";
const pl = await import("./persona-lib.mjs");
const gt = await import("./persona-gitea.mjs");
const ic = await import("./persona-icon.mjs");
const CLI = path.join(HERE, "persona.mjs");
const HOOKS = path.join(HERE, "..", "hooks");
@@ -442,6 +443,7 @@ check("大寫編號目錄一樣受跨人格隔離保護",
check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)",
guard({ session_id: S_CODE, tool_name: "Bash",
tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny");
cli(["icon", "generate", "--session", S_SPEAK, "--size", "64"]); // 讓分區檢查也涵蓋圖示
// 分區必須「不重不漏」:人格產生的每個檔案都要恰好屬於一區,否則同步會默默漏資料
const AREA_EXEMPT = new Set(["state/lock.json", "state/guests.json", "state/sync.json"]);
const covered = (rel) =>
@@ -477,6 +479,46 @@ check("匯出不會把 .sync 的 git clone 打包進去", (() => {
return !Object.keys(b.files).some((f) => f.startsWith(".sync"));
})());
console.log("⑭ 人格圖示(SVG + PNG,零外部依賴)");
const iconSvg = fs.readFileSync(ic.iconSvgPath("alpha"), "utf8");
const iconPng = fs.readFileSync(ic.iconPngPath("alpha"));
check("兩種格式都產生了", ic.hasIcon("alpha"));
check("PNG 檔頭合法且尺寸正確", (() => {
const sig = iconPng.subarray(0, 8).toString("hex") === "89504e470d0a1a0a";
const type = iconPng.subarray(12, 16).toString("ascii") === "IHDR";
return sig && type && iconPng.readUInt32BE(16) === 64 && iconPng.readUInt32BE(20) === 64 &&
iconPng[24] === 8 && iconPng[25] === 6; // 8-bit RGBA
})(), iconPng.subarray(0, 30).toString("hex"));
check("PNG 以 IEND 結尾(chunk 完整)",
iconPng.subarray(iconPng.length - 8, iconPng.length - 4).toString("ascii") === "IEND");
check("SVG 有 viewBox、漸層與字母方塊",
iconSvg.includes(`viewBox="0 0 64 64"`) && iconSvg.includes("linearGradient") &&
(iconSvg.match(/<rect /g) || []).length > 10, iconSvg.slice(0, 80));
check("圖示是決定性的(同一個人格永遠同一張圖)", (() => {
const a = ic.renderPng(ic.iconSpec("alpha"), 32);
const b = ic.renderPng(ic.iconSpec("alpha"), 32);
return Buffer.compare(a, b) === 0 && ic.renderSvg(ic.iconSpec("alpha"), 32) === ic.renderSvg(ic.iconSpec("alpha"), 32);
})());
check("不同人格的圖示不一樣",
Buffer.compare(ic.renderPng(ic.iconSpec("alpha"), 32), ic.renderPng(ic.iconSpec("GAMMA-01"), 32)) !== 0);
check("字母取自編號(ALPHA-01 → AL", ic.iconSpec("alpha").letters === "AL",
ic.iconSpec("alpha").letters);
check("已有圖示時不加 --force 會被擋",
cli(["icon", "generate", "--session", S_SPEAK], { expectOk: false }).status !== 0);
check("--force 可以重畫", cli(["icon", "generate", "--session", S_SPEAK, "--size", "64", "--force"]).status === 0);
check("圖示屬於 Wiki 區(低頻的身分資料)",
covered("icon.svg")[0] === "wiki" && covered("icon.png")[0] === "wiki");
check("匯出會用 base64 帶走 PNG(二進位不會壞掉)", (() => {
const { bundle: b } = pl.exportBundle("alpha");
const entry = b.files["icon.png"];
return entry?.encoding === "base64" &&
Buffer.compare(Buffer.from(entry.content, "base64"), iconPng) === 0;
})());
check("guest 只能看不能重畫圖示",
pl.GUEST_SAFE_SUBCOMMANDS.has("icon") === false ||
guard({ session_id: S_HOST, agent_id: "guest-9", agent_type: "jsc-persona:persona-guest", tool_name: "Bash",
tool_input: { command: `node persona.mjs icon generate --persona beta --session ${S_HOST} --as-guest` } }) === "deny");
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
process.exit(failed ? 1 : 0);