diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 90068db..d68932e 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
- "version": "0.0.2",
+ "version": "0.0.3",
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills",
"author": {
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index c9e6844..c5c6c4e 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
- "version": "0.0.2",
+ "version": "0.0.3",
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
"skills": "./skills"
}
diff --git a/AGENTS.md b/AGENTS.md
index c03af6c..a0a709d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,6 +27,30 @@
這三條在劇場模式一樣生效。
8. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」,
其他人格一律 deny——匯出等於把記憶讀出來。
+9. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的
+ 本機目錄名與 **Gitea 存取庫名稱**。中文名要先轉羅馬拼音並跟使用者確認拼法。
+10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push),
+ 低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。
+ **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。
+11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字),
+ PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。
+12. **形象圖優先用「高解析度官方圖去背」**:`icon search` → `icon measure` → `icon cutout`
+ → `icon generate --from-cutout`。找圖時**優先官方設定稿**(Full Body/Character Design/
+ Avatar):解析度高,而且多半是透明底或白底,去背幾乎免費。挑的那張要**對得上該人格
+ 「最新一次登場」的形態**(同一個角色有很多套造型)。
+ 每一步都要**用 Read 打開確認**:去背有沒有殘留、構圖對不對。
+ 找不到可用官方圖才退回 `--features` 的向量重繪。
+13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、
+ 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。
+ plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。
+14. **Wiki 必須保存並同步形象圖**:`icon.svg`、`icon.png` 與 `icon/`(向量原稿 + 512/1024)
+ 都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證**,
+ `sync verify` 可隨時檢查。兩個容易踩的坑:
+ * Wiki 頁面**只能用 Markdown 圖片語法** ``——Gitea 只改寫這種語法為
+ `/wiki/raw/...`;HTML `
` 不會被改寫,瀏覽器會解析成 `/wiki/icon.png`
+ 而變成破圖(看起來就像「沒有同步」)。
+ * Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。
+ * 攤平只對 `.md` 做(頁面必須在根層);圖片等附件保留資料夾結構,`/wiki/raw/<資料夾>/<檔>` 讀得到。
## 慣例
@@ -38,3 +62,6 @@
`exportBundle`/`importBundle`)後,**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試。
- 改動重複判定門檻(`similarity`/`REPEAT_THRESHOLD`)後,要用 selftest ⑪ 的對照案例確認
「換句話說同一件事」被擋、「只換關鍵詞」放行。
+- 改動同步分區(`persona-gitea.mjs` 的 `AREAS`)後,selftest ⑬ 的「不重不漏」檢查必須維持全綠:
+ 人格產生的每個檔案都要**恰好**屬於一區,否則同步會默默漏掉資料。
+- selftest 自己會設 `PERSONA_GITEA=off`,**絕對不要**讓測試碰到真的 Gitea。
diff --git a/README.md b/README.md
index 1b2b243..ef7a318 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
---
-## 九條硬規則
+## 十三條硬規則
| 規則 | 怎麼做到 |
| --- | --- |
@@ -24,6 +24,10 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
| **7. 短期記憶轉入長期記憶有成文條件** | `R1`–`R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 |
| **8. 講話像人:推導藏起來、一到三句、不重複** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言——劇場模式同樣適用 |
| **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 |
+| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
+| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
+| **12. 形象圖來自高解析度官方圖,去背後合成** | `icon search` 從 Fandom 撈官方圖並依「解析度+是否官方設定稿」排序(設定稿多為透明/白底、773×1056 起跳)→ `icon measure` 確認臉夠大、背景好去 → `icon cutout` 去背成透明 PNG(原生 alpha /單色底/GrabCut 三條路徑)→ `icon generate --from-cutout` 裁頭肩、合成到角色配色的漸層底。找不到官方圖才退回依人格資料重繪的向量形象 |
+| **13. Wiki 必須保存並同步形象圖** | `icon.svg`、`icon.png` 與 **`icon/` 資料夾(向量原稿 + 512/1024 高解析度)** 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示與來源。Wiki 頁面一律用 **Markdown 圖片語法**(Gitea 只改寫這種語法為 `/wiki/raw/...`;HTML `
` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` |
---
@@ -43,15 +47,23 @@ flowchart TB
subgraph G["Sub Agent(受邀人格,唯讀)"]
GA["persona-guest"]
end
- subgraph S["人格倉庫 ~/.claude/personas"]
- PA["alpha/|IDENTITY SOUL 記憶 情緒 心智圖 關係圖"]
- PB["beta/|…"]
+ subgraph S["人格倉庫 ~/.claude/personas(工作副本)"]
+ PA["ASUNA-01/|IDENTITY SOUL 記憶 情緒 心智圖 關係圖"]
+ PB["YUI-01/|…"]
RM[".rooms/room/transcript.jsonl"]
end
+ subgraph GT["Gitea(存取庫名稱 = 人格編號)"]
+ GF["ASUNA-01 檔案區
高頻活狀態"]
+ GW["ASUNA-01 Wiki
低頻設定百科"]
+ end
P -->|"只能碰自己"| PA
GA -->|"只能碰自己"| PB
P <-->|"唯一合法交流管道"| RM
GA <--> RM
+ PA -->|"每輪背景 push"| GF
+ PA -->|"固化/改身分/release"| GW
+ GF -.->|"載入時 pull"| PA
+ GW -.-> PA
```
## 人格倉庫(預設 `~/.claude/personas//`,可用 `PERSONA_HOME` 覆寫)
@@ -61,6 +73,7 @@ flowchart TB
├── IDENTITY.md # 身分卡:Name / Creature / Vibe / Emoji / Avatar(OpenClaw 同欄位)
├── SOUL.md # 靈魂:Core Truths / Boundaries / Vibe / Continuity + 情緒傾向
├── AGENTS.md # 操作規則(與個性分離)
+├── icon.svg / icon.png # 人格圖示(由編號/名字/emoji 決定,也是 Gitea 存取庫頭像)
├── USER.md # 對使用者的畫像(事實/推測分開)
├── state/
│ ├── lock.json # 載入鎖(session_id + 心跳租約)
@@ -68,7 +81,8 @@ flowchart TB
│ ├── emotion.json # 十二情緒 levels / baseline / 半衰期
│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口)
│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言)
-│ └── config.json
+│ ├── sync.json # Gitea 同步狀態(最後 push / pull)
+│ └── config.json # 含人格編號 code
├── memory/
│ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天)
│ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter)
@@ -81,6 +95,8 @@ flowchart TB
│ ├── graph.json # 人際關係圖(親近度/信任度/連線)
│ └── graph.mmd # Mermaid 呈現(自動產生)
└── journal/YYYY-MM.jsonl # 原始逐字 + 情緒史(hook 自動寫)
+
+(另有 `.sync/files/`、`.sync/wiki/`:兩個同步區的 git clone 快取,可安全刪除)
```
## 十二情緒
@@ -129,6 +145,117 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。
+## 人格圖示(SVG + PNG,零外部依賴)
+
+建立人格**並補齊 IDENTITY/SOUL 之後**產生,512×512。**優先是人物形象圖(看得到臉)**,
+依環境有無工具分三種樣式:
+
+流程:**找官方圖 → 量測 → 去背 → 裁頭肩 → 合成**
+
+| 步驟 | 指令 | 產出 |
+| --- | --- | --- |
+| 1. 找圖 | `icon search --wiki --page <角色>` | 候選清單,📐 = 官方設定稿(優先) |
+| 2. 量測 | `icon measure --photo <網址>` | 解析度、臉多大、背景透明/單色/有場景 |
+| 3. 去背 | `icon cutout --photo <網址>` | `icon/portrait-cutout.png`(透明 PNG) |
+| 4. 合成 | `icon generate --from-cutout --palette ...` | `icon.svg` + `icon.png` + `icon/` 多解析度 |
+
+去背三條路徑自動選:**原生 alpha**(官方設定稿常見,完美)→ **單色底移除**(很好)→
+**GrabCut**(有場景時,邊緣普通;黑髮角色容易被誤切,這時該換設定稿)。
+
+| 樣式 | 條件 | 長什麼樣 |
+| --- | --- | --- |
+| **`cutout`** | 有去背圖 → 最佳 | 官方原圖裁頭肩,疊在角色配色的漸層底上 |
+| **`portrait`** | 沒有可用官方圖 | 依特徵重繪的人物頭像,五官俱全 |
+| **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 |
+
+柵格器對每個圖形先算 bounding box 再逐點測試,1024×1024(3× 超取樣)約 1.8 秒。
+
+**找到的圖片只能當底稿**:產出的 SVG 沒有 ``、沒有 base64、沒有外連,每個像素都是畫出來的。
+
+裁底稿需要的工具缺了,CLI **會印出安裝指令**,不會靜默降級:
+
+```bash
+python3 -m venv ~/.cache/jsc-persona/venv
+~/.cache/jsc-persona/venv/bin/pip install pillow "opencv-python-headless<5" # OpenCV 5 拿掉了 CascadeClassifier
+curl -sL -o ~/.cache/jsc-persona/lbpcascade_animeface.xml \
+ https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml
+```
+
+(venv 放這個路徑會被自動偵測;也可用 `PERSONA_PYTHON` 指定。)
+
+**配色一律取自實際看過的參考圖**
+
+| 元素 | 取自照片的哪裡 |
+| --- | --- |
+| 對角漸層 | `hair`(髮色)→ `accent`(服裝主色) |
+| 外框 | `eye`(瞳色) |
+| 點陣紋 | `light`(最亮的部位) |
+| 中央兩個字母 | 編號前兩字(`ASUNA-01` → `AS`) |
+
+`--palette` 必須配 `--source-url`(CLI 強制):配色是從哪張圖來的要留得下來。
+兩個顏色一深一淺時(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓到對比 ≥ 3.2,
+確保字讀得到——顏色仍然是照片來的,只是收斂色階。
+
+沒有參考圖時(原創人格)才退回雜湊配色:`sha256(編號|Name|Emoji)` 決定漸層與點陣紋。
+
+- **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01` 與 `ASUNA-02` 明顯不同。
+- SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。
+- PNG 由**自寫的柵格器**畫出(3× 超取樣 + 盒式縮減當反鋸齒),再用 `zlib` 手工組出
+ IHDR/IDAT/IEND 與 CRC32。這台機器沒有 rsvg/inkscape/imagemagick,也沒有影像函式庫,
+ 而本專案禁止 npm 依賴——所以就自己畫。
+- **為什麼不直接用找到的圖**:那是別人的美術作品。底稿只用來「看」,圖示由本工具重畫。
+- **為什麼沒有 emoji**:把 emoji 畫進 PNG 需要字型柵格化,環境裡連 emoji 字型都沒有;
+ emoji 仍參與雜湊配色。
+- 圖示屬於低頻資料 → `icon.svg` 與 `icon.png` 都同步到 **Wiki 區**,並自動產生一頁 **Icon**
+ 展示兩種格式與來源;PNG 同時設成 Gitea **存取庫頭像**。
+
+```bash
+node scripts/persona.mjs icon faces --session --photo "<圖片網址>"
+node scripts/persona.mjs icon headshot --session --photo "<圖片網址>" --pick 1
+
+node scripts/persona.mjs icon generate --session --force \
+ --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3,skin=#f7ddc4" \
+ --features "hairstyle=straight,length=very-long,fringe=parted,eyes=almond,expression=calm,collar=v,ahoge=yes" \
+ --source-url "<底稿那張圖的網址>" --source-note "<作品(年份)+重繪依據>"
+
+node scripts/persona.mjs icon show --session # 樣式、配色、特徵、來源
+node scripts/persona.mjs sync verify --session --area wiki # 確認 Wiki 真的同步了
+```
+
+## 人格編號與 Gitea 儲存
+
+**編號 = 英文名全大寫 + 兩位索引**,同名才遞增,也就是 Gitea 存取庫的名稱:
+
+```
+亞絲娜(第一個) → ASUNA-01 結衣 → YUI-01 另一個亞絲娜 → ASUNA-02
+```
+
+每個人格一個**私有存取庫**,內容依**更新頻率**分兩區:
+
+| 區 | 放什麼 | 何時 push |
+| --- | --- | --- |
+| **檔案區**(主存取庫) | 高頻活狀態:`emotion.json`、`short-term.jsonl`、`inner.jsonl`、`said.jsonl`、`inbox/`、`mindmap/threads/`、`journal/` | 每輪對話後由 `Stop` hook 背景推送(`PERSONA_SYNC_MIN_SECONDS` 節流) |
+| **Wiki 區** | 低頻設定:`IDENTITY`/`SOUL`/`AGENTS`/`USER`、長期記憶、`INDEX`、心智圖、關係圖 | 記憶固化、改身分/關係圖、`release` 時 |
+
+- **本機永遠是工作副本**:hook 每輪讀寫本機檔案,不經網路;Gitea 掛掉照樣能聊天。
+ **同步失敗永遠不阻斷對話。**
+- 載入人格時會先 `pull`;兩邊都改過同一個檔案就**停下來不覆蓋本機**,由使用者決定保留哪一邊。
+- Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面(1.27 實測子目錄頁面 404),所以
+ `memory/long-term/xxx.md` 攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json`;
+ Wiki 首頁自動列出所有長期記憶的連結,變成真的讀得下去的「設定百科」。
+
+```bash
+export GITEA_HOST=https://gitea.example.com
+export GITEA_TOKEN=<個人存取權杖>
+export PERSONA_GITEA_OWNER=<帳號或組織> # 選填,預設 token 本人
+export PERSONA_GITEA=off # 需要時整個關掉
+
+node scripts/persona.mjs code assign --session --romaji Asuna --rename # 既有人格遷移
+node scripts/persona.mjs sync status|init|push|pull --session [--area files|wiki|all]
+```
+
+存取庫**預設私有**——人格裡是使用者的個人記憶,公開必須由使用者明講(`--public`)。
+
## 匯出 / 匯入(人格搬家)
```bash
@@ -208,6 +335,18 @@ node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json
- **Claude Code / Antigravity**:`/jsc-persona:persona-transfer` **Codex**:`$persona-transfer`
+### `persona-icon`
+
+依人格**最新一次登場**的官方視覺產生圖示:上網查最新造型 → 下載並親眼看過參考圖 → 取髮色/瞳色/服裝色 → 繪製 `icon.svg` + `icon.png`,來源網址一併存證。
+
+- **Claude Code / Antigravity**:`/jsc-persona:persona-icon` **Codex**:`$persona-icon`
+
+### `persona-sync`
+
+人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。
+
+- **Claude Code / Antigravity**:`/jsc-persona:persona-sync` **Codex**:`$persona-sync`
+
### `persona-memory`
記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。
@@ -243,12 +382,13 @@ node scripts/persona.mjs candidates --session # 看哪些短
node scripts/persona.mjs think --session --text "<推導>" # 心裡話(只回報「心想 N 句」)
node scripts/persona.mjs said check --session --text "<話>" # 這句是不是又要說一次?
node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用)
-node scripts/persona.mjs export --session --out lumi.json # 人格搬家
-node scripts/selftest.mjs # 102 項驗證:鎖、隔離、情緒、固化條件、說話節制、劇場模式、匯出匯入、hooks
+node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔)
+node scripts/persona.mjs sync status --session # Gitea 同步狀態
+node scripts/selftest.mjs # 175 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、hooks
```
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、
-`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。
+`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(形象圖:SVG + 自寫 PNG 編碼)、`scripts/portrait.py`(選用:照片裁臉)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。
---
diff --git a/hooks/turn_end.mjs b/hooks/turn_end.mjs
index 355f7ad..26bc45e 100644
--- a/hooks/turn_end.mjs
+++ b/hooks/turn_end.mjs
@@ -2,8 +2,13 @@
// Stop:情緒隨時間衰減、續租、記錄逐字,並在達到「短期→長期」條件時提醒固化。
// 劇場模式進行中不發任何提醒(那會破壞「只顯示人格對話」)。
+import path from "node:path";
+import { fileURLToPath } from "node:url";
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
+import * as gt from "../scripts/persona-gitea.mjs";
+
+const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "persona.mjs");
const event = readEvent();
if (!event) process.exit(0);
@@ -34,6 +39,11 @@ if (message) {
}
}
+// 檔案區=高頻活狀態:每輪結束後背景推上 Gitea(有最小間隔、失敗不阻斷這一輪)
+if (!gt.giteaProblem() && gt.personaCode(host) && gt.pushDue(host, "files")) {
+ gt.pushInBackground(host, "files", sessionId, CLI);
+}
+
const out = { suppressOutput: true };
if (!theater) {
const { total, candidates } = pl.promotionCandidates(host);
diff --git a/plugin.json b/plugin.json
index 0c4b795..b78d576 100644
--- a/plugin.json
+++ b/plugin.json
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
- "version": "0.0.2",
+ "version": "0.0.3",
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills/"
}
diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs
new file mode 100644
index 0000000..cb1b2da
--- /dev/null
+++ b/scripts/persona-gitea.mjs
@@ -0,0 +1,728 @@
+// persona-gitea.mjs — 人格的 Gitea 儲存層(人格編號 = 存取庫名稱)
+//
+// 設計:
+// * 本機 `~/.claude/personas//` 仍是**工作副本**,hook 每輪照常讀寫本機檔案(零延遲)。
+// * Gitea 上每個人格一個私有存取庫,名稱就是人格編號(例如 `ASUNA-01`)。
+// * 依**更新頻率**分兩區:
+// - 檔案區(主存取庫):每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿…)
+// - Wiki 區:低頻的身分與長期結構(IDENTITY/SOUL、長期記憶、心智圖、關係圖),當設定百科看
+// * 兩區各自 clone 在 `/.sync//`,push 前把工作副本的檔案複製進去再 commit。
+// (不在人格目錄本身放 .git:一個目錄要同時屬於兩個 repo 是行不通的。)
+//
+// 網路一律**失敗不阻斷**:Gitea 掛掉、沒設 token、離線,人格照樣能聊天。
+
+import fs from "node:fs";
+import path from "node:path";
+import crypto from "node:crypto";
+import { spawn, spawnSync } from "node:child_process";
+import * as pl from "./persona-lib.mjs";
+
+export const SYNC_DIRNAME = ".sync";
+export const DEFAULT_MIN_PUSH_SECONDS = 60;
+
+// --------------------------------------------------------------------------- //
+// 人格編號:英文名全大寫 + 兩位索引(同名才遞增)
+// --------------------------------------------------------------------------- //
+
+export const CODE_RE = /^[A-Z][A-Z0-9]{0,23}-\d{2}$/;
+
+export const validCode = (code) => typeof code === "string" && CODE_RE.test(code);
+
+/** `Asuna` / `asuna sao` / `Shen Yu` → `ASUNA` / `ASUNASAO` / `SHENYU`。非拉丁字元一律拒絕。 */
+export function normalizeRomaji(romaji) {
+ const raw = String(romaji ?? "").normalize("NFKD").replace(/[̀-ͯ]/g, "");
+ const letters = raw.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
+ if (!letters || !/^[A-Z]/.test(letters)) return null;
+ return letters.slice(0, 24);
+}
+
+export const codePrefix = (code) => String(code ?? "").split("-")[0] || "";
+
+/** 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 */
+export function nextCode(romaji) {
+ const prefix = normalizeRomaji(romaji);
+ if (!prefix) return null;
+ let max = 0;
+ for (const slug of pl.listPersonas()) {
+ for (const candidate of [pl.loadConfig(slug).code, slug]) {
+ if (!validCode(candidate) || codePrefix(candidate) !== prefix) continue;
+ max = Math.max(max, Number(candidate.split("-")[1]) || 0);
+ }
+ }
+ if (max >= 99) return null;
+ return `${prefix}-${String(max + 1).padStart(2, "0")}`;
+}
+
+/** 這個人格的編號:config.code 優先,其次目錄名本身就是編號。 */
+export function personaCode(slug) {
+ const code = pl.loadConfig(slug).code;
+ if (validCode(code)) return code;
+ return validCode(slug) ? slug : null;
+}
+
+// --------------------------------------------------------------------------- //
+// 兩個儲存區:依更新頻率切
+// --------------------------------------------------------------------------- //
+
+export const AREAS = {
+ // 高頻:每輪對話都在變 → 主存取庫的檔案區,Gitea 網頁上一眼看到最新狀態
+ files: {
+ key: "files",
+ label: "檔案區",
+ why: "高頻:每輪對話都在變",
+ paths: [
+ "state/config.json",
+ "state/emotion.json",
+ "state/inner.jsonl",
+ "state/said.jsonl",
+ "memory/short-term.jsonl",
+ "memory/inbox/",
+ "mindmap/threads/",
+ "journal/",
+ ],
+ },
+ // 低頻:身分與長期結構 → Wiki,當「設定百科」讀
+ wiki: {
+ key: "wiki",
+ label: "Wiki 區",
+ why: "低頻:身分與長期結構,當設定百科看",
+ paths: [
+ "IDENTITY.md",
+ "SOUL.md",
+ "AGENTS.md",
+ "USER.md",
+ "icon.svg",
+ "icon.png",
+ "icon/",
+ "memory/INDEX.md",
+ "memory/long-term/",
+ "mindmap/semantic.mmd",
+ "relations/graph.json",
+ "relations/graph.mmd",
+ ],
+ },
+};
+
+export const AREA_KEYS = Object.keys(AREAS);
+
+export const syncDir = (slug, area) => path.join(pl.personaDir(slug), SYNC_DIRNAME, area);
+export const syncStatePath = (slug) => path.join(pl.personaDir(slug), "state", "sync.json");
+
+export function loadSyncState(slug) {
+ const data = pl.readJson(syncStatePath(slug), {}) ?? {};
+ data.areas ??= {};
+ for (const key of AREA_KEYS) data.areas[key] ??= {};
+ return data;
+}
+
+export const saveSyncState = (slug, data) => pl.writeJson(syncStatePath(slug), data);
+
+// --------------------------------------------------------------------------- //
+// 環境與 API
+// --------------------------------------------------------------------------- //
+
+export function giteaEnv() {
+ const host = String(process.env.PERSONA_GITEA_HOST || process.env.GITEA_HOST || "").replace(/\/+$/, "");
+ const token = String(process.env.PERSONA_GITEA_TOKEN || process.env.GITEA_TOKEN || "");
+ const owner = String(process.env.PERSONA_GITEA_OWNER || "");
+ const off = /^(0|off|false|no)$/i.test(String(process.env.PERSONA_GITEA || ""));
+ return { host, token, owner, enabled: Boolean(host && token) && !off, disabled: off };
+}
+
+export function giteaProblem() {
+ const env = giteaEnv();
+ if (env.disabled) return "PERSONA_GITEA 被設為關閉。";
+ if (!env.host) return "沒有 `PERSONA_GITEA_HOST`/`GITEA_HOST`。";
+ if (!env.token) return "沒有 `PERSONA_GITEA_TOKEN`/`GITEA_TOKEN`。";
+ return null;
+}
+
+async function api(method, route, body = null) {
+ const { host, token } = giteaEnv();
+ const res = await fetch(`${host}/api/v1${route}`, {
+ method,
+ headers: {
+ Authorization: `token ${token}`,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: body === null ? undefined : JSON.stringify(body),
+ });
+ const text = await res.text();
+ let json = null;
+ try {
+ json = text ? JSON.parse(text) : null;
+ } catch {
+ json = null;
+ }
+ return { ok: res.ok, status: res.status, json, text };
+}
+
+const ownerCachePath = () => path.join(pl.runtimeDir(), "gitea.json");
+
+/** 存取庫的擁有者:`PERSONA_GITEA_OWNER` 優先,否則用 token 本人的帳號(會快取)。 */
+export async function resolveOwner() {
+ const env = giteaEnv();
+ if (env.owner) return env.owner;
+ const cached = pl.readJson(ownerCachePath(), {}) ?? {};
+ if (cached.host === env.host && cached.login) return cached.login;
+ const res = await api("GET", "/user");
+ if (!res.ok || !res.json?.login) throw new Error(`取不到 Gitea 帳號(HTTP ${res.status}):${res.text.slice(0, 120)}`);
+ pl.writeJson(ownerCachePath(), { host: env.host, login: res.json.login, cached_at: pl.nowIso() });
+ return res.json.login;
+}
+
+export async function getRepo(owner, code) {
+ const res = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}`);
+ return res.ok ? res.json : null;
+}
+
+/** 建立(或沿用)人格的私有存取庫。存取庫名稱 = 人格編號。 */
+export async function ensureRepo(owner, code, { description = "", private_ = true } = {}) {
+ const existing = await getRepo(owner, code);
+ if (existing) return { repo: existing, created: false };
+ const env = giteaEnv();
+ const me = await resolveOwner();
+ const route = owner === me ? "/user/repos" : `/orgs/${owner}/repos`;
+ const res = await api("POST", route, {
+ name: code,
+ private: private_,
+ description: description || `jsc-persona 人格 ${code}`,
+ auto_init: false,
+ });
+ if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
+ void env;
+ return { repo: res.json, created: true };
+}
+
+/** Wiki 的 git repo 要有第一頁才會存在,用 API 建 Home 頁。 */
+export async function ensureWikiHome(owner, code, content) {
+ const page = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}/wiki/page/Home`);
+ if (page.ok) return false;
+ const res = await api("POST", `/repos/${owner}/${encodeURIComponent(code)}/wiki/new`, {
+ title: "Home",
+ content_base64: Buffer.from(content, "utf8").toString("base64"),
+ message: "init: 人格設定百科",
+ });
+ if (!res.ok) throw new Error(`建立 Wiki 首頁失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
+ return true;
+}
+
+/** 把人格圖示設成存取庫頭像(Gitea 各處的清單就會顯示這個人格的臉)。 */
+export async function setRepoAvatar(owner, code, pngBuffer) {
+ const res = await api("POST", `/repos/${owner}/${encodeURIComponent(code)}/avatar`, {
+ image: Buffer.from(pngBuffer).toString("base64"),
+ });
+ return res.ok;
+}
+
+export const repoUrl = (host, owner, code, area) =>
+ `${host}/${owner}/${encodeURIComponent(code)}${area === "wiki" ? ".wiki" : ""}.git`;
+
+// --------------------------------------------------------------------------- //
+// git(用 CLI,認證走 http.extraHeader,不把 token 寫進 .git/config 也不塞進參數)
+// --------------------------------------------------------------------------- //
+
+function gitEnv() {
+ const { token } = giteaEnv();
+ const env = {
+ ...process.env,
+ GIT_TERMINAL_PROMPT: "0",
+ GIT_AUTHOR_NAME: process.env.PERSONA_GIT_NAME || "jsc-persona",
+ GIT_AUTHOR_EMAIL: process.env.PERSONA_GIT_EMAIL || "persona@localhost",
+ GIT_COMMITTER_NAME: process.env.PERSONA_GIT_NAME || "jsc-persona",
+ GIT_COMMITTER_EMAIL: process.env.PERSONA_GIT_EMAIL || "persona@localhost",
+ };
+ if (token) {
+ env.GIT_CONFIG_COUNT = "1";
+ env.GIT_CONFIG_KEY_0 = "http.extraHeader";
+ env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
+ }
+ return env;
+}
+
+export function git(args, cwd = null) {
+ const proc = spawnSync("git", args, { cwd: cwd || undefined, env: gitEnv(), encoding: "utf8" });
+ return {
+ ok: proc.status === 0,
+ status: proc.status,
+ stdout: String(proc.stdout || "").trim(),
+ stderr: String(proc.stderr || "").trim(),
+ };
+}
+
+function gitOrThrow(args, cwd, what) {
+ const res = git(args, cwd);
+ if (!res.ok) throw new Error(`${what} 失敗:git ${args.join(" ")}\n ${res.stderr || res.stdout}`);
+ return res;
+}
+
+/** 確保 `/.sync//` 是該區的 clone;空存取庫也能處理。 */
+export function ensureClone(slug, area, url) {
+ const dir = syncDir(slug, area);
+ if (fs.existsSync(path.join(dir, ".git"))) {
+ git(["remote", "set-url", "origin", url], dir);
+ return dir;
+ }
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
+ fs.rmSync(dir, { recursive: true, force: true });
+ const cloned = git(["clone", "--quiet", url, dir]);
+ if (!cloned.ok) {
+ // 空存取庫 clone 會警告但成功;真的失敗才自己 init
+ fs.mkdirSync(dir, { recursive: true });
+ gitOrThrow(["init", "--quiet", "-b", "main"], dir, "初始化");
+ gitOrThrow(["remote", "add", "origin", url], dir, "設定 remote");
+ }
+ return dir;
+}
+
+// --------------------------------------------------------------------------- //
+// 檔案搬運:工作副本 <-> clone
+// --------------------------------------------------------------------------- //
+
+function listAreaFiles(root, area) {
+ const out = [];
+ for (const rel of AREAS[area].paths) {
+ const abs = path.join(root, rel);
+ if (rel.endsWith("/")) {
+ let entries = [];
+ try {
+ entries = fs.readdirSync(abs, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+ for (const entry of entries) {
+ if (entry.isFile()) out.push(path.posix.join(rel.replace(/\/$/, ""), entry.name));
+ }
+ continue;
+ }
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel);
+ }
+ return out.sort();
+}
+
+function listTrackedFiles(dir) {
+ const res = git(["ls-files"], dir);
+ return res.ok ? res.stdout.split("\n").map((s) => s.trim()).filter(Boolean).sort() : [];
+}
+
+export const WIKI_MANIFEST = "_paths.json";
+const WIKI_RESERVED = new Set(["Home.md", "Icon.md", WIKI_MANIFEST]);
+const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" };
+
+/**
+ * Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404),
+ * 所以低頻區的檔案要攤平成根層檔名(`memory/long-term/x.md` → `Memory-x.md`,
+ * 網頁上顯示為「Memory x」),再用 `_paths.json` 記住原本的路徑,pull 時才還原得回去。
+ */
+export function wikiName(rel) {
+ if (!rel.includes("/")) return rel;
+ // 只有 .md 需要攤平——Gitea 只把「根目錄的 .md」當成頁面。
+ // 其他附件(圖片、mmd、json)放子資料夾沒問題:實測 /wiki/raw/icon/portrait.svg 取得到,
+ // 而且 Markdown 圖片語法會被自動改寫成那個 raw 路徑。
+ if (!/\.md$/i.test(rel)) return rel;
+ const parts = rel.split("/");
+ const file = parts.pop();
+ return `${WIKI_PREFIX[parts[0]] || parts[0]}-${file}`;
+}
+
+/** 工作副本相對路徑 → clone 內的檔名。攤平後撞名的話補上短雜湊。 */
+function buildNameMap(root, area) {
+ const map = new Map();
+ const taken = new Map();
+ for (const rel of listAreaFiles(root, area)) {
+ let name = area === "wiki" ? wikiName(rel) : rel;
+ if (taken.has(name) && taken.get(name) !== rel) {
+ const ext = path.extname(name);
+ const hash = crypto.createHash("md5").update(rel).digest("hex").slice(0, 6);
+ name = `${name.slice(0, name.length - ext.length)}~${hash}${ext}`;
+ }
+ taken.set(name, rel);
+ map.set(rel, name);
+ }
+ return map;
+}
+
+const readManifest = (area, dir) =>
+ area === "wiki" ? pl.readJson(path.join(dir, WIKI_MANIFEST), {}) ?? {} : {};
+
+/** clone 內的檔名 → 工作副本相對路徑。 */
+export function cloneNameToRel(area, dir, name) {
+ if (area !== "wiki") return name;
+ return readManifest(area, dir)[name] || name;
+}
+
+/** 把工作副本裡屬於這一區的檔案複製進 clone;clone 裡多出來的(已刪除的)一併移除。 */
+function stageArea(slug, area, dir) {
+ const root = pl.personaDir(slug);
+ const map = buildNameMap(root, area);
+ const keep = new Set(map.values());
+ if (area === "wiki") for (const name of WIKI_RESERVED) keep.add(name);
+ for (const [rel, name] of map) {
+ const target = path.join(dir, name);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.copyFileSync(path.join(root, rel), target);
+ }
+ if (area === "wiki") {
+ const manifest = {};
+ for (const [rel, name] of map) manifest[name] = rel;
+ pl.writeText(path.join(dir, WIKI_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
+ }
+ for (const name of listTrackedFiles(dir)) {
+ if (keep.has(name)) continue;
+ fs.rmSync(path.join(dir, name), { force: true });
+ }
+ return [...map.keys()];
+}
+
+/** 把 clone 裡的檔案寫回工作副本(wiki 區依 `_paths.json` 還原成原本的路徑)。 */
+function unstageArea(slug, area, dir, only = null) {
+ const root = pl.personaDir(slug);
+ const manifest = readManifest(area, dir);
+ const written = [];
+ for (const name of listTrackedFiles(dir)) {
+ if (area === "wiki" && WIKI_RESERVED.has(name)) continue;
+ if (only && !only.has(name)) continue;
+ const src = path.join(dir, name);
+ if (!fs.existsSync(src)) continue;
+ const rel = manifest[name] || name;
+ const target = path.join(root, rel);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.copyFileSync(src, target);
+ written.push(rel);
+ }
+ return written;
+}
+
+/**
+ * Wiki 的「形象圖」頁:把 icon.svg 與 icon.png 都保存在 Wiki 並展示出來,
+ * 附上這張圖是從哪張參考照片來的(可查證)。
+ */
+export function wikiIconPage(slug, code) {
+ const ident = pl.identityFields(slug);
+ const config = pl.loadConfig(slug);
+ const icon = config.icon || {};
+ const src = icon.source || {};
+ const root = pl.personaDir(slug);
+ const hasSvg = fs.existsSync(path.join(root, "icon.svg"));
+ const hasPng = fs.existsSync(path.join(root, "icon.png"));
+ let renders = [];
+ try {
+ renders = fs.readdirSync(path.join(root, "icon"))
+ .filter((n) => /\.(svg|png)$/i.test(n))
+ .sort()
+ .map((name) => ({
+ name,
+ ext: name.split(".").pop(),
+ note: /(\d+)/.test(name) ? `${name.match(/(\d+)/)[1]}×${name.match(/(\d+)/)[1]} 高解析度` : "向量原稿",
+ }));
+ } catch {
+ renders = [];
+ }
+ const styleLabel = {
+ photo: "真實照片裁臉",
+ portrait: "向量人物形象(有臉),配色取自參考照片",
+ badge: "編號徽章(沒有參考照片時的樣式)",
+ }[icon.style || "badge"];
+ return [
+ `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} 的形象圖`,
+ "",
+ `\`${code}\` ${styleLabel}`,
+ "",
+ // Gitea 只會改寫 **Markdown 圖片語法** 的路徑(→ /wiki/raw/...);
+ // 用 HTML
會被瀏覽器當成相對於頁面網址,變成 303 破圖。
+ ...(hasSvg || hasPng
+ ? [
+ ...(hasPng ? [``, ""] : []),
+ "| 格式 | 檔案 | 用途 |",
+ "| --- | --- | --- |",
+ ...(hasSvg ? [`| SVG | [icon.svg](icon.svg) | 向量,可無限放大 |`] : []),
+ ...(hasPng ? [`| PNG | [icon.png](icon.png) | 點陣,存取庫頭像 |`] : []),
+ ...renders.map((r) => `| ${r.ext.toUpperCase()} | [icon/${r.name}](icon/${r.name}) | ${r.note} |`),
+ "",
+ ]
+ : ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]),
+ "## 這張圖怎麼來的",
+ "",
+ "| 欄位 | 內容 |",
+ "| --- | --- |",
+ `| 樣式 | ${icon.style || "badge"} |`,
+ `| 尺寸 | ${icon.size || "?"}×${icon.size || "?"} |`,
+ `| 調色盤 | ${icon.palette || "(由編號雜湊)"} |`,
+ `| 參考來源 | ${src.url ? `[${src.url}](${src.url})` : "(無)"} |`,
+ `| 造型說明 | ${src.note || "—"} |`,
+ `| 參考日期 | ${src.date || "—"} |`,
+ `| 產生時間 | ${icon.generated_at || "—"} |`,
+ "",
+ icon.style === "photo"
+ ? "> 由 jsc-persona 從上述參考圖**裁出臉部**產生(自動臉部偵測),並套上圓角與瞳色外框。"
+ : "> 由 jsc-persona 產生:配色取自角色**最新一次登場**的官方視覺。",
+ "> 這裡保存的是產生出來的形象圖(SVG + PNG),供人格身分辨識使用。",
+ "",
+ ].join("\n");
+}
+
+/** Wiki 首頁:讓 Gitea 上點進去就看得懂這是誰。 */
+export function wikiHome(slug, code) {
+ const ident = pl.identityFields(slug);
+ const longTerm = pl.longTermEntries(slug);
+ const relations = pl.loadRelations(slug);
+ const hasIcon = fs.existsSync(path.join(pl.personaDir(slug), "icon.png"));
+ const lines = [
+ `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``,
+ "",
+ ...(hasIcon ? [``, ""] : []),
+ "> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;",
+ "> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。",
+ "",
+ "| 欄位 | 內容 |",
+ "| --- | --- |",
+ `| 編號 | \`${code}\` |`,
+ ...["Name", "Creature", "Vibe", "Emoji", "Avatar"]
+ .filter((k) => ident[k])
+ .map((k) => `| ${k} | ${ident[k]} |`),
+ `| 長期記憶 | ${longTerm.length} 則 |`,
+ `| 關係人 | ${relations.nodes.length} 位 |`,
+ `| 圖示更新 | ${pl.loadConfig(slug).icon?.generated_at || "—"} |`,
+ "",
+ "## 頁面",
+ "",
+ "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar)",
+ "- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity",
+ "- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解",
+ "- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源",
+ "- [Memory INDEX](Memory-INDEX) — 長期記憶索引",
+ "",
+ "### 長期記憶",
+ "",
+ ];
+ const memos = [...longTerm].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0));
+ for (const meta of memos.slice(0, 50)) {
+ const page = wikiName(`memory/long-term/${path.basename(meta._path)}`).replace(/\.md$/, "");
+ const first = (meta._body || "").split("\n")[0] || "";
+ lines.push(`- [${meta._name}](${page})|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}|${first.slice(0, 60)}`);
+ }
+ if (!memos.length) lines.push("(還沒有長期記憶)");
+ if (memos.length > 50) lines.push(`…以及另外 ${memos.length - 50} 則,見 [Memory INDEX](Memory-INDEX)。`);
+ lines.push(
+ "",
+ "### 其他",
+ "",
+ "- `Mindmap-semantic.mmd` — 心智圖(Mermaid)",
+ "- `Relations-graph.mmd` — 人際關係圖(Mermaid)/ `Relations-graph.json` — 原始資料",
+ "- `icon/` — 高解析度形象圖(多尺寸) - `_paths.json` — 攤平前的路徑對照(勿手改)",
+ "",
+ "> Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以子目錄的檔案在這裡是攤平的檔名。",
+ "",
+ );
+ return lines.join("\n");
+}
+
+// --------------------------------------------------------------------------- //
+// push / pull
+// --------------------------------------------------------------------------- //
+
+export function minPushSeconds() {
+ const raw = Number(process.env.PERSONA_SYNC_MIN_SECONDS);
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_MIN_PUSH_SECONDS;
+}
+
+export function pushDue(slug, area) {
+ const last = loadSyncState(slug).areas[area]?.pushed_at;
+ return !last || pl.ageSeconds(last) >= minPushSeconds();
+}
+
+export async function pushArea(slug, area, { message = "", code = null, owner = null } = {}) {
+ const problem = giteaProblem();
+ if (problem) return { ok: false, skipped: true, reason: problem };
+ const theCode = code || personaCode(slug);
+ if (!theCode) return { ok: false, skipped: true, reason: `人格 \`${slug}\` 還沒有編號,先跑 \`code assign\`。` };
+ const theOwner = owner || (await resolveOwner());
+ const { host } = giteaEnv();
+ const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
+ if (area === "wiki") {
+ pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
+ pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
+ }
+ const staged = stageArea(slug, area, dir);
+ gitOrThrow(["add", "-A"], dir, "git add");
+ const dirty = git(["diff", "--cached", "--quiet"], dir);
+ if (dirty.ok) {
+ const state = loadSyncState(slug);
+ state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() };
+ saveSyncState(slug, state);
+ return { ok: true, changed: false, files: staged.length, area, code: theCode };
+ }
+ gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit");
+ let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
+ if (!pushed.ok) {
+ // 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源,
+ // 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
+ if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) {
+ git(["reset", "--hard", "--quiet", `origin/${branch}`], dir);
+ stageArea(slug, area, dir);
+ if (area === "wiki") {
+ pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
+ pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
+ }
+ git(["add", "-A"], dir);
+ if (!git(["diff", "--cached", "--quiet"], dir).ok) {
+ git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir);
+ }
+ }
+ pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
+ }
+ if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout };
+ const state = loadSyncState(slug);
+ state.code = theCode;
+ state.owner = theOwner;
+ state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length };
+ saveSyncState(slug, state);
+ return { ok: true, changed: true, files: staged.length, area, code: theCode };
+}
+
+/**
+ * 拉回遠端內容。
+ * 衝突判定:clone 裡「還沒 commit 的本機改動」若正好也被遠端改到 → 停下來,不覆蓋本機。
+ */
+export async function pullArea(slug, area, { code = null, owner = null, force = false } = {}) {
+ const problem = giteaProblem();
+ if (problem) return { ok: false, skipped: true, reason: problem };
+ const theCode = code || personaCode(slug);
+ if (!theCode) return { ok: false, skipped: true, reason: `人格 \`${slug}\` 還沒有編號。` };
+ const theOwner = owner || (await resolveOwner());
+ const { host } = giteaEnv();
+ const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
+ stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼
+ const localChanged = new Set(
+ git(["status", "--porcelain"], dir)
+ .stdout.split("\n")
+ .map((l) => l.slice(3).trim())
+ .filter(Boolean),
+ );
+ const fetched = git(["fetch", "--quiet", "origin"], dir);
+ if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" };
+ const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
+ const remoteRef = `origin/${head}`;
+ const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir);
+ if (!exists.ok) return { ok: true, area, code: theCode, empty: true, changed: [] };
+ const incoming = git(["diff", "--name-only", "HEAD", remoteRef], dir).stdout.split("\n").filter(Boolean);
+ const conflicts = incoming.filter((f) => localChanged.has(f));
+ if (conflicts.length && !force) {
+ git(["checkout", "--", "."], dir);
+ return { ok: false, area, code: theCode, conflicts };
+ }
+ git(["checkout", "--", "."], dir);
+ const reset = git(["reset", "--hard", "--quiet", remoteRef], dir);
+ if (!reset.ok) return { ok: false, area, reason: reset.stderr };
+ // 只寫回「遠端真的改過的」與「本機缺少的」。
+ // 不能無差別覆蓋:本機有較新但還沒 push 的內容時,那會把它蓋掉。
+ const root = pl.personaDir(slug);
+ const restore = new Set(incoming);
+ for (const name of listTrackedFiles(dir)) {
+ if (area === "wiki" && WIKI_RESERVED.has(name)) continue;
+ if (!fs.existsSync(path.join(root, cloneNameToRel(area, dir, name)))) restore.add(name);
+ }
+ const written = unstageArea(slug, area, dir, restore);
+ const state = loadSyncState(slug);
+ state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() };
+ saveSyncState(slug, state);
+ return { ok: true, area, code: theCode, changed: incoming, written };
+}
+
+/**
+ * 驗證某一區「本機 = 遠端」。
+ * push 回報成功不等於遠端真的有東西(網路中斷、權限、非快轉都可能),
+ * 形象圖這種一定要出現在 Wiki 的檔案更需要一個明確的檢查點。
+ */
+export async function verifyArea(slug, area, { code = null, owner = null } = {}) {
+ const problem = giteaProblem();
+ if (problem) return { ok: false, skipped: true, area, reason: problem };
+ const theCode = code || personaCode(slug);
+ if (!theCode) return { ok: false, skipped: true, area, reason: "沒有人格編號" };
+ const theOwner = owner || (await resolveOwner());
+ const { host } = giteaEnv();
+ const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
+ if (!git(["fetch", "--quiet", "origin"], dir).ok) {
+ return { ok: false, area, reason: "fetch 失敗(連不上遠端)" };
+ }
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
+ const local = git(["rev-parse", "HEAD"], dir).stdout;
+ const remote = git(["rev-parse", `origin/${branch}`], dir).stdout;
+ // 再把工作副本疊上去,看看還有沒有沒推的差異
+ if (area === "wiki") {
+ pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
+ pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
+ }
+ const files = stageArea(slug, area, dir);
+ const dirty = git(["status", "--porcelain"], dir)
+ // git() 會把輸出 trim 掉,所以 porcelain 開頭那個空白可能已經不見了(" M x" → "M x")
+ .stdout.split("\n").map((l) => l.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim()).filter(Boolean);
+ git(["checkout", "--", "."], dir);
+ git(["clean", "-qfd"], dir);
+ return {
+ ok: Boolean(local) && local === remote && dirty.length === 0,
+ area,
+ code: theCode,
+ files: files.length,
+ local,
+ remote,
+ pending: dirty,
+ };
+}
+
+/** 形象圖(SVG + PNG)是不是真的在 Wiki 上、而且和本機一致。 */
+export async function verifyIconInWiki(slug, opts = {}) {
+ const res = await verifyArea(slug, "wiki", opts);
+ if (res.skipped || !res.code) return res;
+ const missing = ["icon.svg", "icon.png"].filter((f) => res.pending.includes(f));
+ const dir = syncDir(slug, "wiki");
+ const present = ["icon.svg", "icon.png"].filter((f) => fs.existsSync(path.join(dir, f)));
+ return { ...res, icon_present: present, icon_pending: missing, ok: res.ok && present.length === 2 };
+}
+
+/** 建立 Gitea 上的存取庫與 Wiki,並把兩區都推上去。 */
+export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) {
+ const problem = giteaProblem();
+ if (problem) throw new Error(problem);
+ const theCode = code || personaCode(slug);
+ if (!validCode(theCode)) throw new Error(`人格 \`${slug}\` 沒有合法編號(需 ASUNA-01 這種格式)。`);
+ const theOwner = owner || (await resolveOwner());
+ const ident = pl.identityFields(slug);
+ const { repo, created } = await ensureRepo(theOwner, theCode, {
+ description: `jsc-persona 人格 ${theCode}${ident.Name ? `(${ident.Name})` : ""}`,
+ private_,
+ });
+ const wikiCreated = await ensureWikiHome(theOwner, theCode, wikiHome(slug, theCode));
+ const results = {};
+ for (const area of AREA_KEYS) {
+ results[area] = await pushArea(slug, area, {
+ code: theCode,
+ owner: theOwner,
+ message: `init(${area}): ${AREAS[area].why}`,
+ });
+ }
+ const state = loadSyncState(slug);
+ state.code = theCode;
+ state.owner = theOwner;
+ state.repo_url = repo.html_url;
+ state.initialized_at = state.initialized_at || pl.nowIso();
+ saveSyncState(slug, state);
+ return { code: theCode, owner: theOwner, repo, created, wikiCreated, results };
+}
+
+/** 背景 push(給 hook 用):不等結果、不阻斷這一輪。 */
+export function pushInBackground(slug, area, sessionId, cliPath) {
+ try {
+ const child = spawn(
+ process.execPath,
+ [cliPath, "sync", "push", "--persona", slug, "--session", sessionId, "--area", area, "--if-due", "--quiet"],
+ { detached: true, stdio: "ignore", env: process.env },
+ );
+ child.unref();
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs
new file mode 100644
index 0000000..35e5079
--- /dev/null
+++ b/scripts/persona-icon.mjs
@@ -0,0 +1,1089 @@
+// persona-icon.mjs — 由人格資料產生圖示(SVG + PNG),零外部依賴
+//
+// 為什麼是「幾何圖形 + 字母」而不是 emoji:
+// 這台機器(以及大部分伺服器)沒有 rsvg/inkscape/imagemagick,也沒有 emoji 字型,
+// 而本專案的規則是只用 Node 內建模組。把 emoji 畫進 PNG 需要字型柵格化,做不到。
+// 所以圖案只用「我能在 SVG 與自寫柵格器裡畫出完全相同結果」的元素:
+// 圓角矩形、線性漸層、圓點、以及 5×7 點陣字母。**兩種格式輸出的是同一張圖。**
+// emoji 仍然參與雜湊,所以它會影響配色。
+//
+// 圖案(512×512 圓角方形徽章):
+// 底:由人格編號雜湊出的雙色對角漸層
+// 紋:5×5 左右對稱的圓點(identicon 式,每個人格都不一樣)
+// 字:編號英文名的前兩個字母(ASUNA-01 → AS),5×7 點陣,自動選黑或白以確保對比
+//
+// 同一個人格永遠得到同一張圖(純函數 of 編號/名字/emoji)。
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import zlib from "node:zlib";
+import crypto from "node:crypto";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import * as pl from "./persona-lib.mjs";
+
+export const ICON_SVG = "icon.svg";
+export const ICON_PNG = "icon.png";
+export const DEFAULT_SIZE = 512;
+/** icon/ 資料夾裡要輸出的解析度(512 給頭像、1024 給高解析用途)。 */
+export const RENDER_SIZES = [512, 1024];
+
+export const iconSvgPath = (slug) => path.join(pl.personaDir(slug), ICON_SVG);
+export const iconPngPath = (slug) => path.join(pl.personaDir(slug), ICON_PNG);
+
+// --------------------------------------------------------------------------- //
+// 5×7 點陣字(SVG 與 PNG 共用同一份資料,兩邊才會長得一模一樣)
+// --------------------------------------------------------------------------- //
+
+const FONT = {
+ A: "01110,10001,10001,11111,10001,10001,10001",
+ B: "11110,10001,10001,11110,10001,10001,11110",
+ C: "01111,10000,10000,10000,10000,10000,01111",
+ D: "11110,10001,10001,10001,10001,10001,11110",
+ E: "11111,10000,10000,11110,10000,10000,11111",
+ F: "11111,10000,10000,11110,10000,10000,10000",
+ G: "01110,10001,10000,10111,10001,10001,01111",
+ H: "10001,10001,10001,11111,10001,10001,10001",
+ I: "11111,00100,00100,00100,00100,00100,11111",
+ J: "00111,00010,00010,00010,00010,10010,01100",
+ K: "10001,10010,10100,11000,10100,10010,10001",
+ L: "10000,10000,10000,10000,10000,10000,11111",
+ M: "10001,11011,10101,10101,10001,10001,10001",
+ N: "10001,11001,10101,10011,10001,10001,10001",
+ O: "01110,10001,10001,10001,10001,10001,01110",
+ P: "11110,10001,10001,11110,10000,10000,10000",
+ Q: "01110,10001,10001,10001,10101,10010,01101",
+ R: "11110,10001,10001,11110,10100,10010,10001",
+ S: "01111,10000,10000,01110,00001,00001,11110",
+ T: "11111,00100,00100,00100,00100,00100,00100",
+ U: "10001,10001,10001,10001,10001,10001,01110",
+ V: "10001,10001,10001,10001,10001,01010,00100",
+ W: "10001,10001,10001,10101,10101,11011,10001",
+ X: "10001,10001,01010,00100,01010,10001,10001",
+ Y: "10001,10001,01010,00100,00100,00100,00100",
+ Z: "11111,00001,00010,00100,01000,10000,11111",
+ 0: "01110,10001,10011,10101,11001,10001,01110",
+ 1: "00100,01100,00100,00100,00100,00100,01110",
+ 2: "01110,10001,00001,00010,00100,01000,11111",
+ 3: "11111,00010,00100,00010,00001,10001,01110",
+ 4: "00010,00110,01010,10010,11111,00010,00010",
+ 5: "11111,10000,11110,00001,00001,10001,01110",
+ 6: "00110,01000,10000,11110,10001,10001,01110",
+ 7: "11111,00001,00010,00100,01000,01000,01000",
+ 8: "01110,10001,10001,01110,10001,10001,01110",
+ 9: "01110,10001,10001,01111,00001,00010,01100",
+};
+
+const glyph = (ch) => (FONT[ch] || FONT.O).split(",").map((row) => row.split("").map(Number));
+
+// --------------------------------------------------------------------------- //
+// 顏色
+// --------------------------------------------------------------------------- //
+
+/** h 0–360, s 0–1, l 0–1 → [r,g,b] 0–255 */
+export function hslToRgb(h, s, l) {
+ const c = (1 - Math.abs(2 * l - 1)) * s;
+ const hp = (((h % 360) + 360) % 360) / 60;
+ const x = c * (1 - Math.abs((hp % 2) - 1));
+ const [r1, g1, b1] =
+ hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x]
+ : hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x];
+ const m = l - c / 2;
+ return [r1 + m, g1 + m, b1 + m].map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255));
+}
+
+const hex = ([r, g, b]) => `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
+
+/** 相對亮度(sRGB → 線性),用來決定字要黑還是白。 */
+function luminance([r, g, b]) {
+ const lin = [r, g, b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
+}
+
+// --------------------------------------------------------------------------- //
+// 圖案規格:純函數 of 人格資料 → 同一個人格永遠同一張圖
+// --------------------------------------------------------------------------- //
+
+const WHITE = [255, 255, 255];
+const BLACK = [16, 18, 24];
+
+/** WCAG 對比度(1–21)。 */
+function contrast(a, b) {
+ const la = luminance(a);
+ const lb = luminance(b);
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
+}
+
+const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
+
+/** 字要黑還是白:取「對兩端漸層色的最差對比」最好的那個。 */
+function pickInk(c1, c2) {
+ const worst = (ink) => Math.min(contrast(ink, c1), contrast(ink, c2));
+ return worst(WHITE) >= worst(BLACK) ? WHITE : BLACK;
+}
+
+/**
+ * 照片取來的兩個顏色可能一深一淺(例:藍黑髮 + 淡粉洋裝),
+ * 那樣不論字用黑或白,都會有一端糊掉。這裡把較亮的一端往較暗的一端壓,
+ * 直到最差對比達標——顏色仍然是照片來的,只是收斂色階。
+ */
+function harmonize(c1, c2, target = 3.2) {
+ let a = c1;
+ let b = c2;
+ for (let step = 0; step < 6; step += 1) {
+ const ink = pickInk(a, b);
+ if (Math.min(contrast(ink, a), contrast(ink, b)) >= target) break;
+ if (luminance(a) > luminance(b)) a = mix(a, b, 0.18);
+ else b = mix(b, a, 0.18);
+ }
+ return [a, b];
+}
+
+// --------------------------------------------------------------------------- //
+// 取自照片的配色
+// --------------------------------------------------------------------------- //
+//
+// 「依照片產生圖示」在這裡的做法:**由 AI 上網找到該人格最新的官方視覺、看過那張圖、
+// 萃取出五個代表色**,再交給這支程式繪製。不是把原圖裁進圖示裡——
+// 1) 沒有任何影像解碼器可用(見檔頭),JPEG/WebP 根本讀不進來;
+// 2) 把他人的美術作品原樣放進存取庫是散布,不是引用。
+// 取配色是有依據又不搬運原圖的做法,來源網址與描述會一起記進 config.json 備查。
+
+export const PALETTE_KEYS = ["hair", "eye", "accent", "secondary", "light", "skin"];
+
+export function parseHexColor(value) {
+ const m = String(value ?? "").trim().match(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i);
+ if (!m) return null;
+ const hexStr = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
+ return [0, 2, 4].map((i) => parseInt(hexStr.slice(i, i + 2), 16));
+}
+
+/** `hair=#d9a45b,eye=#9e5b3e,...` → { hair:[r,g,b], ... };缺 hair/accent 視為無效。 */
+export function parsePalette(raw) {
+ if (!raw) return null;
+ const out = {};
+ for (const chunk of String(raw).split(",")) {
+ const idx = chunk.indexOf("=");
+ if (idx < 0) continue;
+ const key = chunk.slice(0, idx).trim().toLowerCase();
+ const rgb = parseHexColor(chunk.slice(idx + 1));
+ if (PALETTE_KEYS.includes(key) && rgb) out[key] = rgb;
+ }
+ if (!out.hair || !out.accent) return null;
+ out.eye ??= out.accent;
+ out.secondary ??= out.accent;
+ out.light ??= [246, 240, 236];
+ out.skin ??= mix(out.light, [242, 201, 168], 0.65); // 沒指定就用偏暖的膚色
+ return out;
+}
+
+export const paletteToString = (p) =>
+ PALETTE_KEYS.filter((k) => p?.[k]).map((k) => `${k}=${hex(p[k])}`).join(",");
+
+export const STYLES = ["portrait", "badge"];
+
+export function iconSpec(slug, {
+ code = null, identity = null, palette = null, style = null, features = null,
+} = {}) {
+ const ident = identity || pl.identityFields(slug);
+ const config = pl.loadConfig(slug);
+ const theCode = code || config.code || slug;
+ const seedText = `${theCode}|${ident.Name || slug}|${ident.Emoji || ""}`;
+ const h = crypto.createHash("sha256").update(seedText).digest();
+
+ // 5×5 左右對稱的點陣(只決定左邊三行,鏡射過去)——不論哪種配色都保留這個專屬紋路
+ const pattern = [];
+ for (let y = 0; y < 5; y += 1) {
+ const row = [];
+ for (let x = 0; x < 3; x += 1) row.push((h[8 + y * 3 + x] & 1) === 1);
+ pattern.push([...row, row[1], row[0]]);
+ }
+ const letters = String(theCode).replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 2) || "P";
+
+ const pal = palette || (config.icon?.palette ? parsePalette(config.icon.palette) : null);
+ let c1;
+ let c2;
+ let ring;
+ let dot;
+ let ringAlpha;
+ let dotAlpha;
+ if (pal) {
+ // 照片配色:髮色 → 主服裝色的漸層,瞳色當外框,亮色當紋路
+ c1 = pal.hair;
+ c2 = pal.accent;
+ ring = pal.eye;
+ dot = pal.light;
+ ringAlpha = 0.55;
+ dotAlpha = 0.2;
+ } else {
+ // 雜湊配色:沒有參考照片時的預設
+ const hue = (h[0] * 360) / 256;
+ const hue2 = (hue + 40 + (h[1] % 80)) % 360;
+ const sat = 0.52 + (h[2] % 30) / 100; // 0.52–0.81
+ const light = 0.36 + (h[3] % 18) / 100; // 0.36–0.53
+ c1 = hslToRgb(hue, sat, light);
+ c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18));
+ ring = null;
+ dot = null;
+ ringAlpha = 0.18;
+ dotAlpha = 0.14;
+ }
+ [c1, c2] = harmonize(c1, c2);
+ const ink = pickInk(c1, c2);
+
+ return {
+ persona: slug,
+ code: theCode,
+ name: ident.Name || slug,
+ emoji: ident.Emoji || "",
+ letters,
+ c1,
+ c2,
+ ink,
+ ring: ring || ink,
+ dot: dot || ink,
+ ringAlpha,
+ dotAlpha,
+ // 有調色盤(=看過大頭照)就重繪人物形象;沒有的話只能畫徽章
+ // 只認得 STYLES 裡的樣式:config 可能殘留舊版本寫進去的值(例如已移除的 photo)
+ style: [style, config.icon?.style].find((v) => STYLES.includes(v)) || (pal ? "portrait" : "badge"),
+ palette: pal,
+ features: features || parseFeatures(config.icon?.features || null),
+ source: pal ? config.icon?.source || null : null,
+ pattern,
+ seed: h.subarray(0, 8).toString("hex"),
+ };
+}
+
+// --------------------------------------------------------------------------- //
+// 幾何:SVG 與柵格器共用同一組座標(單位為 0–1,最後乘上 size)
+// --------------------------------------------------------------------------- //
+
+const GEO = {
+ radius: 0.22, // 圓角半徑
+ patternInset: 0.12,
+ patternCell: 0.152,
+ dotRadius: 0.038,
+ glyphCell: 1 / 16,
+};
+
+/** 回傳圖案中所有圓點(單位座標)。 */
+function dots(spec) {
+ const out = [];
+ for (let y = 0; y < 5; y += 1) {
+ for (let x = 0; x < 5; x += 1) {
+ if (!spec.pattern[y][x]) continue;
+ out.push({
+ cx: GEO.patternInset + GEO.patternCell * (x + 0.5),
+ cy: GEO.patternInset + GEO.patternCell * (y + 0.5),
+ r: GEO.dotRadius,
+ });
+ }
+ }
+ return out;
+}
+
+// --------------------------------------------------------------------------- //
+// 圖形清單:SVG 與柵格器都從這份清單畫,兩邊不可能長得不一樣
+// --------------------------------------------------------------------------- //
+
+const shade = (c, t) => mix(c, [0, 0, 0], t);
+const tint = (c, t) => mix(c, [255, 255, 255], t);
+
+const rect = (x, y, w, h, fill, alpha = 1) => ({ type: "rect", x, y, w, h, fill, alpha });
+const ellipse = (cx, cy, rx, ry, fill, alpha = 1) => ({ type: "ellipse", cx, cy, rx, ry, fill, alpha });
+const circle = (cx, cy, r, fill, alpha = 1) => ellipse(cx, cy, r, r, fill, alpha);
+/** 多邊形:畫髮尾、緞帶、V 領、呆毛這些橢圓做不出來的形狀。 */
+const poly = (points, fill, alpha = 1) => ({ type: "poly", points, fill, alpha });
+
+// --------------------------------------------------------------------------- //
+// 五官與造型的特徵:這些決定「重新繪製」出來的人長什麼樣
+// --------------------------------------------------------------------------- //
+
+export const FEATURE_SPEC = {
+ hairstyle: ["straight", "twintails", "ponytail", "bob", "braid"],
+ length: ["short", "medium", "long", "very-long"],
+ fringe: ["blunt", "parted", "swept", "curtain"],
+ eyes: ["round", "almond", "sharp", "droopy"],
+ expression: ["gentle", "bright", "calm", "neutral"],
+ accessory: ["none", "ribbon", "clip", "flower", "hairband"],
+ side: ["left", "right"],
+ collar: ["round", "v", "high", "sailor"],
+ ahoge: ["no", "yes"],
+};
+
+export const DEFAULT_FEATURES = {
+ hairstyle: "straight",
+ length: "long",
+ fringe: "parted",
+ eyes: "almond",
+ expression: "gentle",
+ accessory: "none",
+ side: "right",
+ collar: "round",
+ ahoge: "no",
+};
+
+/** `hairstyle=twintails,fringe=blunt,...` → 正規化過的特徵物件(不認得的值一律回退預設)。 */
+export function parseFeatures(raw) {
+ const out = { ...DEFAULT_FEATURES };
+ if (!raw) return out;
+ for (const chunk of String(raw).split(",")) {
+ const idx = chunk.indexOf("=");
+ if (idx < 0) continue;
+ const key = chunk.slice(0, idx).trim().toLowerCase();
+ const value = chunk.slice(idx + 1).trim().toLowerCase();
+ if (FEATURE_SPEC[key] && FEATURE_SPEC[key].includes(value)) out[key] = value;
+ }
+ return out;
+}
+
+export const featuresToString = (f) =>
+ Object.keys(DEFAULT_FEATURES).map((k) => `${k}=${f[k]}`).join(",");
+
+/**
+ * 人物形象(有臉)。全部用橢圓與矩形拼出來,所以 SVG 與自寫柵格器畫得出一模一樣的結果。
+ * 顏色一律來自參考照片萃取的調色盤:髮色、瞳色、服裝色、膚色。
+ */
+function portraitShapes(spec) {
+ const p = spec.palette || {};
+ const hair = p.hair || spec.c1;
+ const eye = p.eye || spec.ring;
+ const cloth = p.accent || spec.c2;
+ const cloth2 = p.secondary || cloth;
+ const skin = p.skin || [244, 214, 187];
+ const hairDark = shade(hair, 0.32);
+ const skinShade = shade(skin, 0.12);
+ const mouth = shade(cloth, 0.25);
+
+ const f = spec.features || DEFAULT_FEATURES;
+ const out = [];
+ const mirror = f.side === "left" ? -1 : 1;
+ const sideX = (base) => 0.5 + (base - 0.5) * mirror;
+
+ // ── 頭髮長度:側髮鋪到哪裡
+ const bottom = { short: 0.66, medium: 0.78, long: 0.90, "very-long": 1.02 }[f.length] || 0.90;
+ const sideCy = (0.46 + bottom) / 2;
+ const sideRy = (bottom - 0.46) / 2;
+
+ // ── 肩膀與衣服
+ out.push(ellipse(0.5, 1.18, 0.47, 0.36, cloth));
+ if (f.collar === "round") out.push(ellipse(0.5, 1.14, 0.175, 0.22, cloth2));
+ else if (f.collar === "high") out.push(rect(0.30, 0.90, 0.40, 0.10, cloth2));
+ else if (f.collar === "sailor") {
+ out.push(poly([[0.30, 0.92], [0.50, 1.06], [0.70, 0.92], [0.70, 1.10], [0.30, 1.10]], cloth2));
+ } else if (f.collar === "v") {
+ out.push(poly([[0.428, 0.900], [0.572, 0.900], [0.500, 1.030]], skinShade));
+ }
+ // 脖子
+ out.push(rect(0.442, 0.64, 0.116, 0.15, skinShade));
+
+ // ── 後髮
+ out.push(ellipse(0.5, 0.50, 0.315, 0.395, hairDark));
+ if (f.hairstyle === "twintails") {
+ out.push(ellipse(sideX(0.185), sideCy + 0.03, 0.085, sideRy, hairDark));
+ out.push(ellipse(sideX(0.815), sideCy + 0.03, 0.085, sideRy, hairDark));
+ out.push(ellipse(sideX(0.255), 0.415, 0.040, 0.030, cloth2)); // 髮束
+ out.push(ellipse(sideX(0.745), 0.415, 0.040, 0.030, cloth2));
+ } else if (f.hairstyle === "ponytail") {
+ out.push(ellipse(sideX(0.815), sideCy, 0.075, sideRy * 1.05, hairDark));
+ out.push(ellipse(sideX(0.735), 0.40, 0.042, 0.032, cloth2));
+ out.push(ellipse(0.245, 0.62, 0.062, 0.14, hairDark));
+ } else if (f.hairstyle === "bob") {
+ out.push(ellipse(0.215, 0.60, 0.090, 0.175, hairDark));
+ out.push(ellipse(0.785, 0.60, 0.090, 0.175, hairDark));
+ } else if (f.hairstyle === "braid") {
+ out.push(ellipse(0.235, sideCy, 0.072, sideRy, hairDark));
+ out.push(ellipse(0.765, sideCy, 0.072, sideRy, hairDark));
+ for (let i = 0; i < 3; i += 1) {
+ out.push(ellipse(sideX(0.775), 0.60 + i * 0.11, 0.055, 0.048, shade(hair, 0.18)));
+ }
+ } else {
+ out.push(ellipse(0.235, sideCy, 0.078, sideRy, hairDark));
+ out.push(ellipse(0.765, sideCy, 0.078, sideRy, hairDark));
+ }
+ // 側髮的反光,讓長髮有圓柱感
+ out.push(ellipse(0.221, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32));
+ out.push(ellipse(0.779, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32));
+ // 呆毛
+ if (f.ahoge === "yes") out.push(poly([[0.482, 0.180], [0.548, 0.070], [0.540, 0.190]], hair));
+
+ // ── 臉
+ out.push(ellipse(0.5, 0.500, 0.234, 0.252, skin));
+
+ // ── 瀏海
+ if (f.fringe === "blunt") {
+ out.push(ellipse(0.5, 0.300, 0.248, 0.150, hair));
+ out.push(rect(0.252, 0.300, 0.496, 0.088, hair));
+ } else if (f.fringe === "swept") {
+ out.push(ellipse(sideX(0.560), 0.320, 0.235, 0.160, hair));
+ out.push(poly([[sideX(0.30), 0.30], [sideX(0.78), 0.30], [sideX(0.30), 0.47]], hair));
+ } else if (f.fringe === "curtain") {
+ out.push(ellipse(0.5, 0.290, 0.235, 0.140, hair));
+ out.push(ellipse(0.360, 0.400, 0.090, 0.155, hair));
+ out.push(ellipse(0.640, 0.400, 0.090, 0.155, hair));
+ } else {
+ out.push(ellipse(0.5, 0.320, 0.244, 0.145, hair));
+ out.push(ellipse(0.318, 0.410, 0.068, 0.140, hair));
+ out.push(ellipse(0.682, 0.410, 0.068, 0.140, hair));
+ }
+ // 瀏海高光:一道亮弧 + 幾綹髮絲,頭髮才不會是一塊死色
+ out.push(ellipse(0.5, 0.230, 0.126, 0.022, tint(hair, 0.40),
+ luminance(hair) < 0.12 ? 0.13 : 0.24));
+ for (const [sx, sy, sw] of [[0.386, 0.330, 0.013], [0.596, 0.330, 0.013]]) {
+ out.push(poly([[sx, sy - 0.075], [sx + sw, sy - 0.075], [sx + sw * 0.4, sy + 0.055]],
+ shade(hair, 0.16), 0.35));
+ }
+
+ // 鬢髮(貼著臉頰的兩束)
+ out.push(ellipse(0.250, 0.560, 0.032, 0.185, hair));
+ out.push(ellipse(0.750, 0.560, 0.032, 0.185, hair));
+
+ // ── 眉毛(表情會影響傾斜)
+ const browY = f.expression === "bright" ? 0.408 : 0.414;
+ // 眉毛要「看得到但不搶戲」:深髮角色往膚色提亮才不會和瀏海連成一片,
+ // 淺髮角色則要比頭髮更深,否則會變成兩條灰槓。
+ const brow = luminance(hair) < 0.12 ? mix(hairDark, skin, 0.16) : shade(hair, 0.48);
+ const browTilt = { gentle: 0.006, bright: 0.010, calm: 0, neutral: 0 }[f.expression] ?? 0;
+ out.push(poly([[0.378, browY + browTilt], [0.458, browY], [0.458, browY + 0.014],
+ [0.376, browY + browTilt + 0.014]], brow));
+ out.push(poly([[0.542, browY], [0.622, browY + browTilt], [0.622, browY + browTilt + 0.014],
+ [0.540, browY + 0.014]], brow));
+
+ // ── 眼睛
+ const eyeGeo = {
+ round: { rx: 0.070, ry: 0.084 },
+ almond: { rx: 0.074, ry: 0.073 },
+ sharp: { rx: 0.076, ry: 0.060 },
+ droopy: { rx: 0.072, ry: 0.078 },
+ }[f.eyes] || { rx: 0.063, ry: 0.064 };
+ const eyeY = f.eyes === "droopy" ? 0.558 : 0.552;
+ const lash = shade(hairDark, 0.35);
+ for (const [cx, hl, dir] of [[0.404, 0.385, -1], [0.596, 0.577, 1]]) {
+ // 眼白 → 虹膜(上深下淺的漸層感)→ 瞳孔 → 高光 ×2 → 眼線與睫毛
+ out.push(ellipse(cx, eyeY, eyeGeo.rx, eyeGeo.ry, [252, 252, 255]));
+ out.push(ellipse(cx, eyeY + 0.004, eyeGeo.rx * 0.80, eyeGeo.ry * 0.84, shade(eye, 0.30)));
+ out.push(ellipse(cx, eyeY + 0.012, eyeGeo.rx * 0.76, eyeGeo.ry * 0.72, eye));
+ out.push(ellipse(cx, eyeY + 0.024, eyeGeo.rx * 0.62, eyeGeo.ry * 0.42, tint(eye, 0.28)));
+ out.push(ellipse(cx, eyeY + 0.010, eyeGeo.rx * 0.32, eyeGeo.ry * 0.44, shade(eye, 0.78)));
+ out.push(ellipse(hl, eyeY - 0.019, 0.019, 0.016, [255, 255, 255]));
+ out.push(circle(cx + dir * eyeGeo.rx * 0.38, eyeY + eyeGeo.ry * 0.42, 0.010, [255, 255, 255], 0.85));
+ // 上眼線 + 外眼角睫毛
+ out.push(ellipse(cx, eyeY - eyeGeo.ry * 0.94, eyeGeo.rx * 0.98, eyeGeo.ry * 0.13, lash));
+ out.push(poly([
+ [cx + dir * eyeGeo.rx * 0.80, eyeY - eyeGeo.ry * 1.00],
+ [cx + dir * eyeGeo.rx * 1.22, eyeY - eyeGeo.ry * (f.eyes === "sharp" ? 1.24 : 1.08)],
+ [cx + dir * eyeGeo.rx * 1.02, eyeY - eyeGeo.ry * 0.66],
+ ], lash));
+ if (f.eyes === "droopy") {
+ out.push(ellipse(cx + dir * eyeGeo.rx * 0.7, eyeY + eyeGeo.ry * 0.85, eyeGeo.rx * 0.5,
+ eyeGeo.ry * 0.16, lash, 0.75));
+ }
+ }
+
+ // ── 下巴/脖子的陰影,臉才立體
+ out.push(ellipse(0.5, 0.706, 0.104, 0.032, shade(skin, 0.14), 0.32));
+
+ // ── 鼻子(很小一點,但少了它嘴巴會被看成鼻子)
+ out.push(ellipse(0.5, 0.638, 0.010, 0.007, shade(skin, 0.22)));
+
+ // ── 腮紅與嘴(表情)
+ if (f.expression !== "calm") {
+ out.push(ellipse(0.318, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20));
+ out.push(ellipse(0.682, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20));
+ }
+ if (f.expression === "bright") {
+ out.push(ellipse(0.5, 0.690, 0.046, 0.022, mouth));
+ out.push(rect(0.454, 0.676, 0.092, 0.009, shade(mouth, 0.35)));
+ out.push(ellipse(0.5, 0.683, 0.034, 0.008, tint(mouth, 0.6)));
+ } else if (f.expression === "calm" || f.expression === "neutral") {
+ out.push(rect(0.480, 0.686, 0.040, 0.009, mouth));
+ } else {
+ out.push(ellipse(0.5, 0.688, 0.024, 0.013, mouth));
+ }
+
+ // ── 髮飾
+ const ax = sideX(0.775);
+ if (f.accessory === "ribbon") {
+ out.push(poly([[ax, 0.330], [ax + 0.085 * mirror, 0.288], [ax + 0.085 * mirror, 0.376]], cloth2));
+ out.push(poly([[ax, 0.330], [ax - 0.075 * mirror, 0.292], [ax - 0.075 * mirror, 0.372]], cloth2));
+ out.push(circle(ax, 0.332, 0.026, shade(cloth2, 0.18)));
+ } else if (f.accessory === "clip") {
+ out.push(rect(ax - 0.055, 0.318, 0.110, 0.026, cloth2));
+ out.push(rect(ax - 0.055, 0.352, 0.075, 0.022, tint(cloth2, 0.25)));
+ } else if (f.accessory === "flower") {
+ for (let i = 0; i < 5; i += 1) {
+ const a = (i / 5) * Math.PI * 2;
+ out.push(circle(ax + Math.cos(a) * 0.042, 0.335 + Math.sin(a) * 0.042, 0.030, cloth2));
+ }
+ out.push(circle(ax, 0.335, 0.024, tint(cloth2, 0.45)));
+ } else if (f.accessory === "hairband") {
+ out.push(ellipse(0.5, 0.268, 0.246, 0.052, cloth2));
+ out.push(ellipse(0.5, 0.300, 0.238, 0.052, hair));
+ }
+
+ return out;
+}
+
+/** 徽章樣式(沒有參考照片時):點陣紋 + 編號字母。 */
+function badgeShapes(spec) {
+ return [
+ ...dots(spec).map((d) => circle(d.cx, d.cy, d.r, spec.dot, spec.dotAlpha)),
+ ...glyphRects(spec).map((r) => rect(r.x, r.y, r.w, r.h, spec.ink)),
+ ];
+}
+
+export function iconShapes(spec) {
+ return spec.style === "portrait" ? portraitShapes(spec) : badgeShapes(spec);
+}
+
+/** 回傳字母的所有方塊(單位座標)。 */
+function glyphRects(spec) {
+ const cell = GEO.glyphCell;
+ const chars = [...spec.letters];
+ const width = chars.length * 5 * cell + (chars.length - 1) * cell;
+ const left = 0.5 - width / 2;
+ const top = 0.5 - (7 * cell) / 2;
+ const out = [];
+ chars.forEach((ch, i) => {
+ const g = glyph(ch);
+ const ox = left + i * 6 * cell;
+ for (let y = 0; y < 7; y += 1) {
+ for (let x = 0; x < 5; x += 1) {
+ if (g[y][x]) out.push({ x: ox + x * cell, y: top + y * cell, w: cell, h: cell });
+ }
+ }
+ });
+ return out;
+}
+
+// --------------------------------------------------------------------------- //
+// SVG
+// --------------------------------------------------------------------------- //
+
+export function renderSvg(spec, size = DEFAULT_SIZE) {
+ const S = size;
+ const u = (v) => Math.round(v * S * 1000) / 1000;
+ const lines = [
+ `",
+ "",
+ ];
+ return lines.join("\n");
+}
+
+// --------------------------------------------------------------------------- //
+// PNG:自己柵格化 + 自己編碼(zlib 是內建的,不需要任何影像函式庫)
+// --------------------------------------------------------------------------- //
+
+const CRC_TABLE = (() => {
+ const table = new Int32Array(256);
+ for (let n = 0; n < 256; n += 1) {
+ let c = n;
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ table[n] = c;
+ }
+ return table;
+})();
+
+function crc32(buf) {
+ let c = 0xffffffff;
+ for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+function pngChunk(type, data) {
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(data.length, 0);
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(body), 0);
+ return Buffer.concat([len, body, crc]);
+}
+
+/** RGBA buffer → PNG(8-bit RGBA、無交錯、filter 0)。 */
+export function encodePng(rgba, width, height) {
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(width, 0);
+ ihdr.writeUInt32BE(height, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 6; // color type: RGBA
+ ihdr[10] = 0; // compression
+ ihdr[11] = 0; // filter
+ ihdr[12] = 0; // interlace
+ const stride = width * 4;
+ const raw = Buffer.alloc((stride + 1) * height);
+ for (let y = 0; y < height; y += 1) {
+ raw[y * (stride + 1)] = 0; // filter type: None
+ rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
+ }
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ pngChunk("IHDR", ihdr),
+ pngChunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
+ pngChunk("IEND", Buffer.alloc(0)),
+ ]);
+}
+
+/** 點是否在多邊形內(crossing number;與 SVG 的 nonzero 對簡單多邊形結果一致)。 */
+function pointInPoly(x, y, pts) {
+ let inside = false;
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i += 1) {
+ const [xi, yi] = pts[i];
+ const [xj, yj] = pts[j];
+ if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
+ }
+ return inside;
+}
+
+/** 內縮 inset 之後的圓角矩形內外判定(用來畫出與 SVG 描邊相同的環帶)。 */
+function insideInset(x, y, inset) {
+ const span = 1 - 2 * inset;
+ return insideRounded((x - inset) / span, (y - inset) / span, (GEO.radius - inset) / span);
+}
+
+/** 圓角矩形的內外判定(單位座標)。 */
+function insideRounded(x, y, r) {
+ if (x < 0 || y < 0 || x > 1 || y > 1) return false;
+ const cx = Math.min(Math.max(x, r), 1 - r);
+ const cy = Math.min(Math.max(y, r), 1 - r);
+ const dx = x - cx;
+ const dy = y - cy;
+ return dx * dx + dy * dy <= r * r;
+}
+
+/**
+ * 畫出與 SVG 完全相同的圖案。
+ * 用 3× 超取樣再做盒式縮減當作反鋸齒——沒有第三方繪圖庫,這是最省事又夠好的做法。
+ */
+export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
+ const SS = Math.max(1, Math.min(4, supersample));
+ const big = size * SS;
+ const acc = Buffer.alloc(big * big * 4);
+ // 先算每個圖形的外接矩形:不加這個,1024×3 超取樣要跑上億次點測試
+ const shapes = iconShapes(spec).map((sh) => {
+ if (sh.type === "rect") return { ...sh, x0: sh.x, x1: sh.x + sh.w, y0: sh.y, y1: sh.y + sh.h };
+ if (sh.type === "poly") {
+ const xs = sh.points.map((q) => q[0]);
+ const ys = sh.points.map((q) => q[1]);
+ return { ...sh, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
+ }
+ return { ...sh, x0: sh.cx - sh.rx, x1: sh.cx + sh.rx, y0: sh.cy - sh.ry, y1: sh.cy + sh.ry };
+ });
+ const [g1, g2, g3] = spec.ring;
+
+ for (let py = 0; py < big; py += 1) {
+ const v = (py + 0.5) / big;
+ for (let px = 0; px < big; px += 1) {
+ const u = (px + 0.5) / big;
+ const o = (py * big + px) * 4;
+ if (!insideRounded(u, v, GEO.radius)) continue; // 圓角外=透明
+ // 底:對角線性漸層(與 SVG 的 x1,y1=0,0 → x2,y2=1,1 相同)
+ const t = Math.min(1, Math.max(0, (u + v) / 2));
+ let r = spec.c1[0] + (spec.c2[0] - spec.c1[0]) * t;
+ let g = spec.c1[1] + (spec.c2[1] - spec.c1[1]) * t;
+ let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t;
+ // 圖形清單:由後往前疊,與 SVG 的繪製順序相同
+ for (const sh of shapes) {
+ if (u < sh.x0 || u > sh.x1 || v < sh.y0 || v > sh.y1) continue;
+ let hit;
+ if (sh.type === "rect") {
+ hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h;
+ } else if (sh.type === "poly") {
+ hit = pointInPoly(u, v, sh.points);
+ } else {
+ const dx = (u - sh.cx) / sh.rx;
+ const dy = (v - sh.cy) / sh.ry;
+ hit = dx * dx + dy * dy <= 1;
+ }
+ if (!hit) continue;
+ const a = sh.alpha;
+ r += (sh.fill[0] - r) * a;
+ g += (sh.fill[1] - g) * a;
+ b += (sh.fill[2] - b) * a;
+ }
+ // 邊框:與 SVG 的 stroke 完全相同的環帶
+ //(SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.006~0.018)
+ if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) {
+ r += (g1 - r) * spec.ringAlpha;
+ g += (g2 - g) * spec.ringAlpha;
+ b += (g3 - b) * spec.ringAlpha;
+ }
+ acc[o] = Math.round(r);
+ acc[o + 1] = Math.round(g);
+ acc[o + 2] = Math.round(b);
+ acc[o + 3] = 255;
+ }
+ }
+
+ if (SS === 1) return encodePng(acc, size, size);
+ // 盒式縮減(連 alpha 一起平均,圓角邊緣才會平滑)
+ const out = Buffer.alloc(size * size * 4);
+ const n = SS * SS;
+ for (let y = 0; y < size; y += 1) {
+ for (let x = 0; x < size; x += 1) {
+ let r = 0;
+ let g = 0;
+ let b = 0;
+ let a = 0;
+ for (let sy = 0; sy < SS; sy += 1) {
+ const row = (y * SS + sy) * big;
+ for (let sx = 0; sx < SS; sx += 1) {
+ const o = (row + x * SS + sx) * 4;
+ const av = acc[o + 3];
+ r += acc[o] * av;
+ g += acc[o + 1] * av;
+ b += acc[o + 2] * av;
+ a += av;
+ }
+ }
+ const o = (y * size + x) * 4;
+ out[o] = a ? Math.round(r / a) : 0;
+ out[o + 1] = a ? Math.round(g / a) : 0;
+ out[o + 2] = a ? Math.round(b / a) : 0;
+ out[o + 3] = Math.round(a / n);
+ }
+ }
+ return encodePng(out, size, size);
+}
+
+// --------------------------------------------------------------------------- //
+// 產生並寫檔
+// --------------------------------------------------------------------------- //
+
+/**
+ * 產生圖示。有 `photo` 且工具齊全 → 用真實照片裁臉;否則畫向量人物形象(一樣有臉)。
+ * 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。
+ */
+export function generateIcon(slug, {
+ size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, features = null,
+ cutout = null, pick = null, face = null, zoom = 2.15,
+} = {}) {
+ const spec = iconSpec(slug, { code, palette, style, features });
+ // 有去背圖就用它合成(官方原圖,解析度高);沒有就回到向量重繪
+ let compose = null;
+ if (cutout && fs.existsSync(cutout)) {
+ const bg = spec.palette
+ ? `${hex(tint(spec.palette.light || spec.c1, 0.35))},${hex(spec.palette.accent || spec.c2)}`
+ : `${hex(spec.c1)},${hex(spec.c2)}`;
+ compose = composeIcon(cutout, iconPngPath(slug), { size, bg, pick, face, zoom });
+ if (compose.ok) spec.style = "cutout";
+ }
+ const svg = compose?.ok ? null : renderSvg(spec, size);
+ const png = compose?.ok ? fs.readFileSync(iconPngPath(slug)) : renderPng(spec, size);
+ const dir = path.join(pl.personaDir(slug), "icon");
+ fs.mkdirSync(dir, { recursive: true });
+ fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
+ if (!compose?.ok) fs.writeFileSync(iconPngPath(slug), png);
+ // SVG:向量樣式直接輸出;去背合成則包一層自成一體的 SVG(base64 內嵌,不外連)
+ const finalSvg = svg || wrapPngSvg(spec, png, size);
+ pl.writeText(iconSvgPath(slug), finalSvg);
+ // icon/ 資料夾:原稿 + 多個解析度,Gitea 上點得開也拿得走
+ const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(finalSvg, "utf8") }];
+ pl.writeText(path.join(dir, "portrait.svg"), finalSvg);
+ for (const px of RENDER_SIZES) {
+ let buf;
+ if (compose?.ok) {
+ const target = path.join(dir, `portrait-${px}.png`);
+ const res = composeIcon(cutout, target, {
+ size: px,
+ bg: spec.palette
+ ? `${hex(tint(spec.palette.light || spec.c1, 0.35))},${hex(spec.palette.accent || spec.c2)}`
+ : `${hex(spec.c1)},${hex(spec.c2)}`,
+ pick, face, zoom,
+ });
+ buf = res.ok ? fs.readFileSync(target) : png;
+ if (!res.ok) fs.writeFileSync(target, buf);
+ } else {
+ buf = px === size ? png : renderPng(spec, px);
+ fs.writeFileSync(path.join(dir, `portrait-${px}.png`), buf);
+ }
+ renders.push({ name: `portrait-${px}.png`, bytes: buf.length });
+ }
+ // 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」
+ const config = pl.loadConfig(slug);
+ config.icon = {
+ ...(config.icon || {}),
+ letters: spec.letters,
+ style: spec.style,
+ size,
+ generated_at: pl.nowIso(),
+ palette: spec.palette ? paletteToString(spec.palette) : null,
+ features: featuresToString(spec.features),
+ source: source || spec.source || null,
+ cutout: compose?.ok ? { file: CUTOUT_PNG, compose: compose.person || null } : null,
+ };
+ pl.writeJson(pl.configPath(slug), config);
+ return {
+ spec,
+ svg: iconSvgPath(slug),
+ png: iconPngPath(slug),
+ size,
+ renders,
+ bytes: { svg: Buffer.byteLength(finalSvg, "utf8"), png: png.length },
+ };
+}
+
+/** 去背合成的圖示:SVG 用 base64 內嵌同一張 PNG,兩種格式看到的是同一張圖,且不外連。 */
+export function wrapPngSvg(spec, pngBuffer, size = DEFAULT_SIZE) {
+ const S = size;
+ return [
+ `",
+ "",
+ ].join("\n");
+}
+
+export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug));
+
+// --------------------------------------------------------------------------- //
+// 照片裁臉(選用的加值路徑)
+// --------------------------------------------------------------------------- //
+//
+// 本體零依賴,但如果環境裡有 Pillow(+可選的 OpenCV 動漫臉偵測),
+// 圖示就能直接用真實照片裁出的臉。缺工具時 `toolReport()` 會給出安裝指令。
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+export const PORTRAIT_PY = path.join(HERE, "portrait.py");
+export const CASCADE_URL =
+ "https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml";
+
+export function cascadePath() {
+ if (process.env.PERSONA_ANIME_CASCADE) return process.env.PERSONA_ANIME_CASCADE;
+ return path.join(os.homedir(), ".cache", "jsc-persona", "lbpcascade_animeface.xml");
+}
+
+/** 找一個能用的 python(優先吃 PERSONA_PYTHON,其次 venv,最後系統 python3)。 */
+export function pythonPath() {
+ const candidates = [
+ process.env.PERSONA_PYTHON,
+ path.join(os.homedir(), ".cache", "jsc-persona", "venv", "bin", "python3"),
+ "python3",
+ ].filter(Boolean);
+ for (const bin of candidates) {
+ const probe = spawnSync(bin, ["-c", "import sys;print(sys.version_info[0])"], { encoding: "utf8" });
+ if (probe.status === 0) return bin;
+ }
+ return null;
+}
+
+const pyHas = (bin, mod) =>
+ spawnSync(bin, ["-c", `import ${mod}`], { encoding: "utf8" }).status === 0;
+
+/** 目前有哪些工具、缺什麼、怎麼補。 */
+export function toolReport() {
+ const python = pythonPath();
+ const pillow = Boolean(python) && pyHas(python, "PIL");
+ const cv2 = Boolean(python) && pyHas(python, "cv2");
+ const cascade = fs.existsSync(cascadePath());
+ const venv = path.join(os.homedir(), ".cache", "jsc-persona", "venv");
+ const missing = [];
+ if (!python) {
+ missing.push({
+ what: "python3",
+ why: "解碼照片(JPEG/WebP/PNG)與裁切都靠它",
+ how: "sudo apt-get install -y python3 python3-venv",
+ });
+ }
+ if (!pillow) {
+ missing.push({
+ what: "Pillow",
+ why: "沒有它就無法把照片解碼成像素,也就無法裁臉",
+ how: `python3 -m venv ${venv} && ${venv}/bin/pip install -q pillow`,
+ });
+ }
+ if (!cv2) {
+ missing.push({
+ what: "OpenCV(opencv-python-headless)",
+ why: "自動找出臉的位置;沒有它就只能用「上方中央」的經驗法則裁切",
+ how: `${venv}/bin/pip install -q opencv-python-headless`,
+ });
+ }
+ if (!cascade) {
+ missing.push({
+ what: "動漫臉偵測模型 lbpcascade_animeface.xml",
+ why: "OpenCV 內建的模型認不出動漫臉,要這個才準",
+ how:
+ `mkdir -p ${path.dirname(cascadePath())} && curl -sL -o ${cascadePath()} ${CASCADE_URL}`,
+ });
+ }
+ return {
+ python,
+ pillow,
+ cv2,
+ cascade,
+ ready: Boolean(python && pillow),
+ faceDetection: Boolean(python && pillow && cv2),
+ animeFaceDetection: Boolean(python && pillow && cv2 && cascade),
+ missing,
+ venv,
+ };
+}
+
+/** 缺工具時要印給使用者看的提示(一行 what/why,一行指令)。 */
+export function installHintLines(report) {
+ if (!report.missing.length) return [];
+ const lines = [" ⚠ 少了這些工具,暫時無法用真實照片裁臉(先用向量人物形象代替):"];
+ for (const m of report.missing) {
+ lines.push(` • ${m.what} — ${m.why}`);
+ lines.push(` ${m.how}`);
+ }
+ lines.push(" 裝好之後重跑 `icon generate --photo <圖片> --force` 就會換成照片裁臉版。");
+ return lines;
+}
+
+/** 取得參考照片:URL 就抓下來,本機路徑就直接用。 */
+export async function fetchPhoto(source, destDir) {
+ if (/^https?:\/\//i.test(source)) {
+ const res = await fetch(source, { headers: { "User-Agent": "jsc-persona/icon" } });
+ if (!res.ok) throw new Error(`下載參考照片失敗(HTTP ${res.status}):${source}`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ fs.mkdirSync(destDir, { recursive: true });
+ const ext = (source.split("?")[0].match(/\.(png|jpe?g|webp|gif)$/i)?.[1] || "img").toLowerCase();
+ const file = path.join(destDir, `reference.${ext}`);
+ fs.writeFileSync(file, buf);
+ return file;
+ }
+ const abs = path.resolve(source);
+ if (!fs.existsSync(abs)) throw new Error(`找不到參考照片:${abs}`);
+ return abs;
+}
+
+export const CUTOUT_PNG = "icon/portrait-cutout.png";
+export const cutoutPath = (slug) => path.join(pl.personaDir(slug), "icon", "portrait-cutout.png");
+
+/** 呼叫 portrait.py 的共用包裝。 */
+function runPortrait(args) {
+ const report = toolReport();
+ if (!report.ready) return { ok: false, report };
+ const full = [PORTRAIT_PY, ...args];
+ if (report.cascade && !args.includes("--cascade")) full.push("--cascade", cascadePath());
+ const proc = spawnSync(report.python, full, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
+ if (proc.status !== 0) {
+ return { ok: false, report, reason: String(proc.stderr || proc.stdout).trim().slice(0, 300) };
+ }
+ try {
+ return { ...JSON.parse(String(proc.stdout).trim().split("\n").pop()), report };
+ } catch {
+ return { ok: false, report, reason: "portrait.py 的輸出不是 JSON" };
+ }
+}
+
+/** 列出參考圖裡偵測到的所有臉(多角色的圖要先看這個再挑)。 */
+export const listFaces = (imagePath) => runPortrait(["--mode", "faces", "--input", imagePath]);
+
+/** 量測一張候選圖:解析度、臉多大、背景好不好去。 */
+export const measureImage = (imagePath) => runPortrait(["--mode", "measure", "--input", imagePath]);
+
+/** 去背,輸出透明 PNG。優先沿用原圖既有的 alpha(官方人設圖多半就是透明底)。 */
+export const cutoutImage = (imagePath, outPath, { pick = null, face = null } = {}) =>
+ runPortrait([
+ "--mode", "cutout", "--input", imagePath, "--output", outPath,
+ ...(face ? ["--face", face] : pick ? ["--pick", pick] : []),
+ ]);
+
+/** 把去背圖裁成頭肩、疊到圓角漸層底上,產出最終圖示。 */
+export const composeIcon = (cutout, outPath, { size = DEFAULT_SIZE, bg = null, pick = null,
+ face = null, zoom = 2.15, crop = "head" } = {}) =>
+ runPortrait([
+ "--mode", "compose", "--input", cutout, "--output", outPath,
+ "--size", String(size), "--radius", String(GEO.radius), "--zoom", String(zoom), "--crop", crop,
+ ...(bg ? ["--bg", bg] : []),
+ ...(face ? ["--face", face] : pick ? ["--pick", pick] : []),
+ ]);
+
+/**
+ * 從 Fandom wiki 找出這個角色的高解析度官方圖。
+ * 官方人設圖(Full Body / Character Design)通常是透明底或白底,去背幾乎免費,
+ * 而且解析度遠高於角色資料庫的縮圖——這是「找圖」這一步最該優先的來源。
+ */
+export async function wikiImageCandidates(wiki, page, { limit = 60 } = {}) {
+ const url = `https://${wiki}.fandom.com/api.php?action=query&generator=images` +
+ `&titles=${encodeURIComponent(page)}&gimlimit=${limit}&prop=imageinfo&iiprop=url|size&format=json`;
+ const res = await fetch(url, { headers: { "User-Agent": "jsc-persona/icon" } });
+ if (!res.ok) throw new Error(`Fandom API 失敗(HTTP ${res.status})`);
+ const json = await res.json();
+ const pages = json?.query?.pages || {};
+ const rows = Object.values(pages)
+ .map((entry) => ({ title: entry.title, info: entry.imageinfo?.[0] }))
+ .filter((r) => r.info?.width)
+ .map((r) => {
+ const px = r.info.width * r.info.height;
+ // 「官方設定稿」的關鍵字:這類圖解析度高、背景乾淨,最適合當形象圖
+ const settei = /full.?body|character.?design|concept|profile|settei|avatar/i.test(r.title);
+ return {
+ title: r.title,
+ url: String(r.info.url).split("/revision")[0],
+ width: r.info.width,
+ height: r.info.height,
+ pixels: px,
+ official_sheet: settei,
+ score: Math.round(Math.log2(px) * 10) / 10 + (settei ? 8 : 0),
+ };
+ })
+ .sort((a, b) => b.score - a.score);
+ return rows;
+}
+
+/**
+ * 從參考圖裁出**大頭照**。這張是給 AI 看的**參考**,不是圖示本身——
+ * 圖示一律由 renderSvg/renderPng 依人格資料重新繪製。
+ */
+export function cropHeadshot(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) {
+ const report = toolReport();
+ if (!report.ready) return { ok: false, report };
+ const args = [PORTRAIT_PY, "--input", imagePath, "--output", outPath, "--size", String(size),
+ "--radius", String(GEO.radius)];
+ if (report.cascade) args.push("--cascade", cascadePath());
+ if (face) args.push("--face", face);
+ else if (pick) args.push("--pick", pick);
+ const proc = spawnSync(report.python, args, { encoding: "utf8" });
+ if (proc.status !== 0) {
+ return { ok: false, report, reason: (proc.stderr || proc.stdout || "").trim().slice(0, 300) };
+ }
+ let info = null;
+ try {
+ info = JSON.parse(String(proc.stdout).trim().split("\n").pop());
+ } catch {
+ info = null;
+ }
+ if (!info?.ok) return { ok: false, report, reason: info?.reason || "portrait.py 沒有回報成功" };
+ return { ok: true, report, info };
+}
+
diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs
index d6267f9..1875bee 100644
--- a/scripts/persona-lib.mjs
+++ b/scripts/persona-lib.mjs
@@ -39,7 +39,9 @@ export const sessionsDir = () => path.join(runtimeDir(), "sessions");
export const roomsDir = () => path.join(personaHome(), ROOMS_DIRNAME);
export const personaDir = (slug) => path.join(personaHome(), slug);
-const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
+// 人格目錄名。新建的人格一律是**人格編號**(`ASUNA-01`:英文名全大寫+兩位索引),
+// 但舊的小寫 slug(`asuna-sao`)仍然合法,才不會把既有人格鎖在門外。
+const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,47}$/;
const RESERVED_SLUGS = new Set([RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."]);
export function validSlug(slug) {
@@ -1433,7 +1435,8 @@ export function cliInvocation(command) {
};
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
if (sub) info.subcommand = sub[1];
- info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([a-z0-9-]+)/g)].map((m) => m[1]);
+ // 人格名可能是大寫的編號(ASUNA-01),漏掉大寫等於漏掉整個跨人格檢查
+ info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([A-Za-z0-9-]+)/g)].map((m) => m[1]);
const sess = command.match(/--session[= ]+['"]?([^\s'"]+)/);
if (sess) info.session = sess[1];
return info;
diff --git a/scripts/persona.mjs b/scripts/persona.mjs
index 6f66b2e..675a97b 100644
--- a/scripts/persona.mjs
+++ b/scripts/persona.mjs
@@ -14,9 +14,12 @@ import process from "node:process";
import zlib from "node:zlib";
import { fileURLToPath } from "node:url";
import * as pl from "./persona-lib.mjs";
+import * as gt from "./persona-gitea.mjs";
+import * as ic from "./persona-icon.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
+const SELF = path.join(HERE, "persona.mjs");
let QUIET = false;
@@ -48,6 +51,7 @@ function emit(payload, asJson, lines) {
const FLAGS = new Set([
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
"with-journal", "gzip", "record", "load", "allow-repeat",
+ "if-due", "no-gitea", "public", "rename", "from-source",
]);
function parseArgs(argv) {
@@ -154,6 +158,15 @@ function requireMember(slug, sessionId, asGuest = false) {
const hostOf = (flags, session) => str(flags.persona) || pl.loadSession(session).host;
+/**
+ * 里程碑事件(記憶固化、身分/關係變更)之後,把 Wiki 區推上去。
+ * 背景執行、失敗不阻斷:同步永遠不該卡住對話。
+ */
+function pushWikiLater(slug, session, flags) {
+ if (flags["no-gitea"] || gt.giteaProblem() || !gt.personaCode(slug)) return;
+ gt.pushInBackground(slug, "wiki", session, SELF);
+}
+
function renderTemplate(name, mapping) {
let text = fs.readFileSync(path.join(TEMPLATE_DIR, name), "utf8");
for (const [key, value] of Object.entries(mapping)) text = text.replaceAll(`{{${key}}}`, String(value));
@@ -166,10 +179,25 @@ function renderTemplate(name, mapping) {
const commands = {};
-commands.create = ({ flags }) => {
+commands.create = async ({ flags }) => {
const session = requireSession(flags);
- const slug = str(flags.persona);
- if (!pl.validSlug(slug)) die("slug 只能是小寫英數與連字號(最長 48 字),例如 `lumi`、`shen-yu`。");
+ // 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。
+ let code = str(flags.code);
+ if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\`。`);
+ if (!code) {
+ const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona));
+ if (!base) {
+ die(
+ "需要 `--romaji <英文名>`:人格編號是英文名全大寫加索引(例:Asuna → `ASUNA-01`)。" +
+ "中文名請先轉成羅馬拼音並跟使用者確認拼法,再帶進來。",
+ );
+ }
+ code = gt.nextCode(base);
+ if (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`);
+ }
+ // 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格)
+ const slug = str(flags.persona) || code;
+ if (!pl.validSlug(slug)) die("人格目錄名只能是英數與連字號(最長 48 字),建議直接用編號,例如 `ASUNA-01`。");
if (pl.personaExists(slug) && !flags.force) {
die(`人格 \`${slug}\` 已存在(${pl.personaDir(slug)})。要覆寫請加 --force。`);
}
@@ -191,12 +219,14 @@ commands.create = ({ flags }) => {
pl.writeJson(pl.emotionPath(slug), pl.defaultEmotionState(parseDeltas(flags.baseline)));
pl.writeJson(pl.configPath(slug), {
persona: slug,
+ code,
+ romaji: gt.codePrefix(code),
display_name: mapping.NAME,
created_at: pl.nowIso(),
created_by_session: session,
origin: str(flags.origin) || "custom",
source_work: str(flags.work),
- schema: 1,
+ schema: 2,
});
pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] });
pl.writeText(
@@ -206,8 +236,28 @@ commands.create = ({ flags }) => {
pl.rebuildIndex(slug);
pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null });
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
- ok(`人格 \`${slug}\` 建立於 ${root},已取得載入鎖並綁定本 session。`);
+ ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`);
say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`);
+ // Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。
+ if (!flags["no-gitea"] && !gt.giteaProblem()) {
+ try {
+ const info = await gt.initRemote(slug, { code, private_: !flags.public });
+ say(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"},${info.repo.private ? "私有" : "公開"})`);
+ for (const key of gt.AREA_KEYS) {
+ const res = info.results[key] || {};
+ say(
+ res.ok
+ ? ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):${res.changed ? `已推送 ${res.files} 個檔案` : "目前沒有內容"}`
+ : ` ⚠ ${gt.AREAS[key].label}推送失敗:${String(res.reason).slice(0, 160)}`,
+ );
+ }
+ } catch (err) {
+ say(` ⚠ Gitea 存取庫建立失敗(不影響本機使用):${err.message}`);
+ say(" 之後可用 `sync init` 補建。");
+ }
+ } else if (!flags["no-gitea"]) {
+ say(` ℹ 未同步到 Gitea:${gt.giteaProblem()}`);
+ }
};
commands.list = ({ flags }) => {
@@ -221,6 +271,7 @@ commands.list = ({ flags }) => {
}
return {
persona: slug,
+ code: gt.personaCode(slug),
identity: pl.identityBrief(slug),
locked: status.locked,
stale: status.stale,
@@ -236,16 +287,17 @@ commands.list = ({ flags }) => {
for (const r of rows) {
const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒";
lines.push(
- `- \`${r.persona}\` ${state}` +
+ `- \`${r.persona}\`${r.code && r.code !== r.persona ? `(編號 ${r.code})` : ""} ${state}` +
(r.locked ? `(session ${r.owner_session}…, cwd ${r.owner_cwd})` : "") +
`|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
+ (r.code ? "" : "|⚠ 尚無編號") +
(r.identity ? `|${r.identity}` : ""),
);
}
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
};
-commands.load = ({ flags }) => {
+commands.load = async ({ flags }) => {
const session = requireSession(flags);
const slug = str(flags.persona);
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
@@ -264,9 +316,32 @@ commands.load = ({ flags }) => {
die(`${err.message}\n 若確定那個程序已結束,可加 --takeover 接手。`);
}
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
+ // 載入時先把遠端拉回來(別台機器可能動過),衝突就停下來讓使用者決定
+ const pulled = [];
+ if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
+ for (const area of gt.AREA_KEYS) {
+ try {
+ pulled.push(await gt.pullArea(slug, area));
+ } catch (err) {
+ pulled.push({ ok: false, area, reason: err.message });
+ }
+ }
+ }
pl.pruneShortTerm(slug);
pl.rebuildIndex(slug);
const lines = [`✔ 已載入人格 \`${slug}\`(exclusive,session ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s)`];
+ for (const res of pulled) {
+ if (res.conflicts?.length) {
+ lines.push(
+ `⚠ ${gt.AREAS[res.area].label}有衝突,**沒有覆蓋本機**:${res.conflicts.slice(0, 5).join(", ")}` +
+ "。請告訴使用者:本機與 Gitea 都改過同一份資料,要保留哪一邊(`sync pull --force` 會以遠端為準)。",
+ );
+ } else if (res.ok && res.written?.length) {
+ lines.push(`↓ ${gt.AREAS[res.area].label}從 Gitea 拉回 ${res.written.length} 個檔案。`);
+ } else if (!res.ok && !res.skipped) {
+ lines.push(`⚠ ${gt.AREAS[res.area]?.label || res.area}同步失敗(不影響本機):${String(res.reason).slice(0, 120)}`);
+ }
+ }
if (lock.took_over_from) {
const prev = lock.took_over_from;
lines.push(
@@ -279,11 +354,23 @@ commands.load = ({ flags }) => {
emit({ persona: slug, lock, context }, flags.json, lines);
};
-commands.release = ({ flags }) => {
+commands.release = async ({ flags }) => {
const session = requireSession(flags);
const data = pl.loadSession(session);
const slug = str(flags.persona) || data.host;
if (!slug) die("本 session 沒有載入任何人格。");
+ // 收工前把兩區都推上去(失敗不阻斷釋放,人格不能被鎖在網路問題裡)
+ if (!flags["no-gitea"] && !gt.giteaProblem() && pl.personaExists(slug) && gt.personaCode(slug)) {
+ for (const area of gt.AREA_KEYS) {
+ try {
+ const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowIso()}` });
+ if (res.ok && res.changed) say(` ↑ ${gt.AREAS[area].label}已推上 Gitea。`);
+ else if (!res.ok && !res.skipped) say(` ⚠ ${gt.AREAS[area].label}推送失敗:${String(res.reason).slice(0, 120)}`);
+ } catch (err) {
+ say(` ⚠ ${gt.AREAS[area].label}推送失敗:${err.message.slice(0, 120)}`);
+ }
+ }
+ }
const released = pl.unbindSession(session);
ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest:${released.guests.join(", ")}` : "。"}`);
};
@@ -557,6 +644,7 @@ commands.consolidate = ({ flags }) => {
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`);
}
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
+ pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了
};
commands.prune = ({ flags }) => {
@@ -572,6 +660,7 @@ commands.reindex = ({ flags }) => {
const slug = hostOf(flags, session);
requireOwner(slug, session);
ok(`INDEX.md 重建完成(${pl.rebuildIndex(slug)} 則長期記憶)。`);
+ pushWikiLater(slug, session, flags);
};
commands.emotion = ({ flags }) => {
@@ -672,6 +761,7 @@ commands.relation = ({ flags, positional }) => {
});
pl.renderRelations(slug);
ok(`關係節點 \`${name}\` 已更新。`);
+ pushWikiLater(slug, session, flags);
return;
}
if (action === "edge") {
@@ -685,6 +775,7 @@ commands.relation = ({ flags, positional }) => {
});
pl.renderRelations(slug);
ok(`關係連線 ${str(flags.from) || "self"} → ${to} 已更新。`);
+ pushWikiLater(slug, session, flags);
return;
}
if (action === "render") {
@@ -951,6 +1042,431 @@ commands.import = ({ flags }) => {
emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines);
};
+/**
+ * 人格編號:英文名全大寫 + 兩位索引(同名才遞增),也就是 Gitea 存取庫的名稱。
+ * 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。
+ */
+commands.code = async ({ flags, positional }) => {
+ const session = requireSession(flags);
+ const action = positional[0] || "show";
+ if (action === "next") {
+ const base = gt.normalizeRomaji(str(flags.romaji));
+ if (!base) die("需要 `--romaji <英文名>`(只能是拉丁字母與數字)。");
+ const next = gt.nextCode(base);
+ if (!next) die(`\`${base}\` 的編號已經用到 99。`);
+ emit({ romaji: base, code: next }, flags.json, [`\`${base}\` 的下一個可用編號:\`${next}\``]);
+ return;
+ }
+ const slug = hostOf(flags, session);
+ if (action === "show") {
+ requireMember(slug, session, Boolean(flags["as-guest"]));
+ const code = gt.personaCode(slug);
+ const state = gt.loadSyncState(slug);
+ emit({ persona: slug, code, sync: state }, flags.json, [
+ `人格 \`${slug}\` 編號:${code ? `\`${code}\`` : "(尚未指派,用 `code assign --romaji <英文名>`)"}`,
+ code ? ` Gitea 存取庫:${state.repo_url || `${gt.giteaEnv().host}/${state.owner || "?"}/${code}`}` : "",
+ ].filter(Boolean));
+ return;
+ }
+ if (action === "assign") {
+ requireOwner(slug, session);
+ const existing = gt.personaCode(slug);
+ if (existing && !flags.force) die(`人格 \`${slug}\` 已有編號 \`${existing}\`。要重新指派請加 --force。`);
+ let code = str(flags.code);
+ if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`);
+ if (!code) {
+ const base = gt.normalizeRomaji(str(flags.romaji) || slug);
+ if (!base) die("需要 `--romaji <英文名>`(中文名請先轉羅馬拼音並跟使用者確認拼法)。");
+ code = gt.nextCode(base);
+ if (!code) die(`\`${base}\` 的編號已經用到 99。`);
+ }
+ const config = pl.loadConfig(slug);
+ config.persona = slug;
+ config.code = code;
+ config.romaji = gt.codePrefix(code);
+ config.schema = 2;
+ pl.writeJson(pl.configPath(slug), config);
+ const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\`。`];
+ let current = slug;
+ if (flags.rename && slug !== code) {
+ if (pl.personaExists(code)) die(`目錄 \`${code}\` 已存在,無法改名。`);
+ // 目錄名改成編號:先放掉自己的鎖 → 改名 → 重新取得鎖並重綁 session
+ pl.releaseLock(slug, session);
+ fs.renameSync(pl.personaDir(slug), pl.personaDir(code));
+ fs.rmSync(path.join(pl.personaDir(code), gt.SYNC_DIRNAME), { recursive: true, force: true });
+ const renamed = pl.loadConfig(code);
+ renamed.persona = code;
+ pl.writeJson(pl.configPath(code), renamed);
+ pl.acquireLock(code, session, { cwd: str(flags.cwd) || null });
+ pl.bindHost(session, code, { cwd: str(flags.cwd) || null });
+ current = code;
+ lines.push(` 目錄已改名:${pl.personaDir(code)}(.sync 快取已清掉,下次 push 會重新 clone)`);
+ }
+ if (!flags["no-gitea"] && !gt.giteaProblem()) {
+ try {
+ const info = await gt.initRemote(current, { code, private_: !flags.public });
+ lines.push(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"})`);
+ } catch (err) {
+ lines.push(` ⚠ Gitea 存取庫建立失敗(不影響本機):${err.message}`);
+ }
+ }
+ emit({ persona: current, code }, flags.json, lines);
+ return;
+ }
+ die(`未知 action:${action}(可用 show/assign/next)`);
+};
+
+/**
+ * 由人格資料產生圖示(SVG + PNG)。**建立人格並補齊 IDENTITY/SOUL 之後再跑**,
+ * 這樣配色與字母才會對得上最終的身分。同一個人格永遠得到同一張圖。
+ */
+commands.icon = async ({ flags, positional }) => {
+ const session = requireSession(flags);
+ const slug = hostOf(flags, session);
+ const action = positional[0] || "generate";
+ if (action === "show") {
+ requireMember(slug, session, Boolean(flags["as-guest"]));
+ const spec = ic.iconSpec(slug);
+ const src = spec.source || {};
+ emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [
+ `人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`)"}`,
+ ` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` +
+ `:${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`,
+ ...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []),
+ ...(spec.style === "portrait" ? [` 特徵:${ic.featuresToString(spec.features)}`] : []),
+ ...(src.url ? [` 參考來源:${src.url}${src.note ? `(${src.note})` : ""}${src.date ? `|${src.date}` : ""}`] : []),
+ ` ${ic.iconSvgPath(slug)}`,
+ ` ${ic.iconPngPath(slug)}`,
+ ]);
+ return;
+ }
+ if (action === "search") {
+ // 找圖第一步:從 Fandom wiki 撈這個角色的官方圖,依「解析度 + 是不是官方設定稿」排序
+ requireMember(slug, session, Boolean(flags["as-guest"]));
+ const wiki = str(flags.wiki);
+ const page = str(flags.page);
+ if (!wiki || !page) die("需要 `--wiki ` 與 `--page <角色頁名>`(例:--wiki swordartonline --page Yui)。");
+ let rows;
+ try {
+ rows = await ic.wikiImageCandidates(wiki, page, { limit: num(flags.limit, 60) });
+ } catch (err) {
+ die(err.message);
+ }
+ const top = rows.slice(0, num(flags.top, 12));
+ emit({ wiki, page, candidates: top }, flags.json, [
+ `\`${page}\` 在 ${wiki}.fandom.com 的圖片候選(依解析度與是否官方設定稿排序):`,
+ ...top.map((r, i) =>
+ ` #${i} ${String(r.width).padStart(5)}×${String(r.height).padEnd(5)}` +
+ `${r.official_sheet ? " 📐官方設定稿" : " "} ${r.title}\n ${r.url}`),
+ " 官方設定稿(Full Body/Character Design)通常是透明底或白底,去背幾乎免費,優先選它。",
+ " 選好之後:`icon measure --photo <網址>` 看臉夠不夠大,再 `icon cutout --photo <網址>`。",
+ ]);
+ return;
+ }
+ if (action === "measure") {
+ requireMember(slug, session, Boolean(flags["as-guest"]));
+ const want = str(flags.photo);
+ if (!want) die("需要 `--photo <圖片路徑或網址>`。");
+ let file;
+ try {
+ file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
+ } catch (err) {
+ die(err.message);
+ }
+ const res = ic.measureImage(file);
+ if (!res.ok) {
+ emit(res, flags.json, [`✖ 量測失敗:${res.reason || "工具不足"}`,
+ ...ic.installHintLines(res.report || ic.toolReport())]);
+ process.exit(1);
+ }
+ emit(res, flags.json, [
+ `解析度 ${res.size.join("×")}(${(res.pixels / 1e6).toFixed(2)} MP)|找到 ${res.faces_found} 張臉` +
+ `|臉佔長邊 ${(res.face_ratio * 100).toFixed(0)}%`,
+ `背景:${res.transparent ? "透明底(最佳)" : res.background.plain ? "單色底(好去背)" : "有場景(要靠 GrabCut,可能不乾淨)"}` +
+ `|去背難度:${res.cutout_easy ? "容易" : "偏難"}`,
+ res.cutout_easy
+ ? " → 這張可以用。`icon cutout --photo <同一張>`"
+ : " → 建議換一張官方設定稿(`icon search` 裡標 📐 的),去背會乾淨很多。",
+ ]);
+ return;
+ }
+ if (action === "cutout") {
+ requireOwner(slug, session);
+ const want = str(flags.photo);
+ if (!want) die("需要 `--photo <圖片路徑或網址>`。");
+ let file;
+ try {
+ file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
+ } catch (err) {
+ die(err.message);
+ }
+ const out = ic.cutoutPath(slug);
+ fs.mkdirSync(path.dirname(out), { recursive: true });
+ const res = ic.cutoutImage(file, out, {
+ pick: str(flags.pick) || null,
+ face: str(flags.face) || null,
+ });
+ if (!res.ok) {
+ emit(res, flags.json, [`✖ 去背失敗:${res.reason || "工具不足"}`,
+ ...ic.installHintLines(res.report || ic.toolReport())]);
+ process.exit(1);
+ }
+ const method = { "source-alpha": "原圖本來就是透明底", "plain-background": "單色底去除",
+ grabcut: "GrabCut(有場景,邊緣可能不完美)" }[res.method] || res.method;
+ emit({ persona: slug, ...res }, flags.json, [
+ `✔ 去背完成:${out}`,
+ ` 方式:${method}|原圖 ${res.source_size.join("×")} → 去背後 ${res.output_size.join("×")}` +
+ `|不透明佔比 ${(res.opaque_ratio * 100).toFixed(0)}%`,
+ " 請用 Read 打開確認邊緣乾不乾淨,再 `icon generate --from-cutout --force`。",
+ ]);
+ return;
+ }
+ if (action === "headshot") {
+ // 裁出「參考用大頭照」。這張不是圖示,也不會同步出去——它是給 AI 看的底稿。
+ requireOwner(slug, session);
+ const want = str(flags.photo);
+ if (!want) die("需要 `--photo <圖片路徑或網址>`(該人格最新登場的官方視覺)。");
+ let file;
+ try {
+ file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
+ } catch (err) {
+ die(err.message);
+ }
+ const out = path.join(pl.personaDir(slug), ".sync", "headshot.png");
+ const cropped = ic.cropHeadshot(file, out, num(flags.size, 384), {
+ pick: str(flags.pick) || null,
+ face: str(flags.face) || null,
+ });
+ if (!cropped.ok) {
+ emit(cropped, flags.json, [
+ `✖ 裁不出大頭照:${cropped.reason || "工具不足"}`,
+ ...ic.installHintLines(cropped.report || ic.toolReport()),
+ ]);
+ process.exit(1);
+ }
+ emit({ persona: slug, file: out, ...cropped.info }, flags.json, [
+ `✔ 大頭照已裁出:${out}`,
+ ` 偵測方式 ${cropped.info.method}|原圖 ${cropped.info.source_size.join("×")}|` +
+ `找到 ${cropped.info.faces_found} 張臉|裁切框 ${JSON.stringify(cropped.info.box)}`,
+ " ⚠ 這張是**參考底稿**,不是圖示,也不會同步到 Gitea。",
+ " 請用 Read 打開它,確認是本人,再依看到的髮型/眼型/配件下 `icon generate --features ...`。",
+ ]);
+ return;
+ }
+ if (action === "faces") {
+ requireMember(slug, session, Boolean(flags["as-guest"]));
+ const want = str(flags.photo);
+ if (!want) die("需要 `--photo <圖片路徑或網址>`。");
+ let file;
+ try {
+ file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
+ } catch (err) {
+ die(err.message);
+ }
+ const found = ic.listFaces(file);
+ if (!found.ok) {
+ emit(found, flags.json, [
+ `\u2716 \u7121\u6cd5\u5075\u6e2c\u81c9\uff1a${found.reason || "\u5de5\u5177\u4e0d\u8db3"}`,
+ ...ic.installHintLines(found.report || ic.toolReport()),
+ ]);
+ return;
+ }
+ emit(found, flags.json, [
+ `\u53c3\u8003\u7167\u7247 ${found.size[0]}\u00d7${found.size[1]}\uff5c\u5075\u6e2c\u65b9\u5f0f ${found.method || "\uff08\u7121\uff09"}\uff5c\u627e\u5230 ${found.faces.length} \u5f35\u81c9\uff1a`,
+ ...found.faces.map((f) => ` #${f.index} ${f.w}\u00d7${f.h} @(${f.x},${f.y})\u3000\u4e2d\u5fc3 ${f.center.join(",")}`),
+ " \u591a\u89d2\u8272\u7684\u5716\u8acb\u5148\u770b\u904e\u539f\u5716\u518d\u6311\uff1a`icon generate --photo <\u5716> --pick <\u7d22\u5f15>`\uff08\u6216 `--face x,y,w,h`\uff09\u3002",
+ ]);
+ return;
+ }
+ if (action !== "generate") die(`未知 action:${action}(可用 search/measure/faces/headshot/cutout/generate/show)`);
+ requireOwner(slug, session);
+ if (ic.hasIcon(slug) && !flags.force) {
+ die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`);
+ }
+ const size = num(flags.size, ic.DEFAULT_SIZE);
+ if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 16–2048。");
+ let palette = null;
+ if (flags.palette) {
+ palette = ic.parsePalette(str(flags.palette));
+ if (!palette) {
+ die(
+ "`--palette` 格式錯誤。要 `hair=#rrggbb,eye=#rrggbb,accent=#rrggbb,secondary=#rrggbb,light=#rrggbb`," +
+ "其中 hair 與 accent 必填(顏色請取自你實際看過的參考照片)。",
+ );
+ }
+ if (!str(flags["source-url"])) {
+ die("用 `--palette` 就必須帶 `--source-url`:配色是從哪張圖取的要留得下來(可查證)。");
+ }
+ }
+ const source = str(flags["source-url"])
+ ? {
+ url: str(flags["source-url"]),
+ note: str(flags["source-note"]) || null,
+ date: str(flags["source-date"]) || pl.nowIso().slice(0, 10),
+ }
+ : null;
+ const style = str(flags.style) || null;
+ if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`);
+ const features = flags.features ? ic.parseFeatures(str(flags.features)) : null;
+ // --from-cutout:用去背好的官方原圖合成(解析度高、忠於原作)
+ const cutout = flags["from-cutout"] && fs.existsSync(ic.cutoutPath(slug)) ? ic.cutoutPath(slug) : null;
+ if (flags["from-cutout"] && !cutout) {
+ die(`還沒有去背圖。先跑 \`icon search\` 找官方設定稿 → \`icon cutout --photo <網址>\`。`);
+ }
+ const res = ic.generateIcon(slug, {
+ size, palette, source, style, features, cutout,
+ pick: str(flags.pick) || null,
+ face: str(flags.face) || null,
+ zoom: num(flags.zoom, 2.15),
+ });
+ const lines = [
+ `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
+ ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`,
+ ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`,
+ ` icon/:${res.renders.map((r) => `${r.name} ${(r.bytes / 1024).toFixed(0)}KB`).join("、")}`,
+ res.spec.style === "cutout"
+ ? ` 形象圖:官方原圖去背後合成(頭肩構圖,解析度取自原圖)`
+ : res.spec.style === "portrait"
+ ? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)`
+ : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`,
+ ...(res.spec.style === "portrait"
+ ? [` 配色:${ic.paletteToString(res.spec.palette)}`,
+ ` 特徵:${ic.featuresToString(res.spec.features)}`]
+ : res.spec.style === "cutout" && res.spec.palette
+ ? [` 底色:取自 ${ic.paletteToString(res.spec.palette)}`]
+ : []),
+ ...(source?.url || res.spec.source?.url
+ ? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`]
+ : []),
+ ];
+ // 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像
+ if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
+ try {
+ const owner = await gt.resolveOwner();
+ const okAvatar = await gt.setRepoAvatar(owner, gt.personaCode(slug), fs.readFileSync(res.png));
+ if (okAvatar) lines.push(" 📦 已設為 Gitea 存取庫頭像。");
+ const pushed = await gt.pushArea(slug, "wiki", { message: `icon: 重繪人格形象圖 ${res.spec.code}` });
+ if (pushed.ok && pushed.changed) lines.push(" ↑ 形象圖已同步到 Wiki 區。");
+ // 推完一定要回頭確認 Wiki 真的有這兩個檔案,且與本機一致
+ const check = await gt.verifyIconInWiki(slug);
+ lines.push(
+ check.ok
+ ? ` ✔ Wiki 已保存形象圖並與本機一致(${check.icon_present.join(" + ")})`
+ : ` ✖ Wiki 形象圖驗證未通過:${check.reason || `缺少或未同步 ${(check.icon_pending || []).join(", ") || "?"}`}` +
+ " → 跑 `sync push --area wiki` 再 `sync verify --area wiki`。",
+ );
+ } catch (err) {
+ lines.push(` ⚠ 同步到 Gitea 失敗(不影響本機):${err.message.slice(0, 120)}`);
+ }
+ }
+ emit({ persona: slug, ...res }, flags.json, lines);
+};
+
+/** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */
+commands.sync = async ({ flags, positional }) => {
+ const session = requireSession(flags);
+ const slug = hostOf(flags, session);
+ requireOwner(slug, session);
+ const action = positional[0] || "status";
+ const area = str(flags.area) || "all";
+ if (area !== "all" && !gt.AREA_KEYS.includes(area)) die(`--area 只能是 ${gt.AREA_KEYS.join("/")}/all。`);
+ const areas = area === "all" ? gt.AREA_KEYS : [area];
+
+ if (action === "status") {
+ const state = gt.loadSyncState(slug);
+ const problem = gt.giteaProblem();
+ emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [
+ `人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}|Gitea ${problem ? `⚠ ${problem}` : "✔ 已設定"}`,
+ ` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`)"}`,
+ ...gt.AREA_KEYS.map((key) => {
+ const info = state.areas[key] || {};
+ return ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):` +
+ `最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}|${info.files ?? "?"} 個檔案`;
+ }),
+ ]);
+ return;
+ }
+ const problem = gt.giteaProblem();
+ if (problem) die(`Gitea 尚未設定:${problem}(設 GITEA_HOST 與 GITEA_TOKEN,或用 PERSONA_GITEA_* 覆寫)`);
+
+ if (action === "init") {
+ let code = gt.personaCode(slug);
+ if (!code) die(`人格 \`${slug}\` 還沒有編號。先跑 \`code assign --romaji <英文名>\`。`);
+ const info = await gt.initRemote(slug, { code, owner: str(flags.owner) || null, private_: !flags.public });
+ emit(info, flags.json, [
+ `✔ 人格 \`${slug}\`(\`${info.code}\`)已對應到 Gitea:${info.repo.html_url}`,
+ ` ${info.created ? "存取庫已建立" : "沿用既有存取庫"}|${info.repo.private ? "私有" : "公開"}|Wiki ${info.wikiCreated ? "已建立" : "已存在"}`,
+ ...gt.AREA_KEYS.map((key) =>
+ ` ${gt.AREAS[key].label}:${info.results[key]?.ok ? `${info.results[key].files} 個檔案已推送` : `⚠ ${info.results[key]?.reason || "失敗"}`}`),
+ ]);
+ return;
+ }
+ if (action === "push") {
+ const out = [];
+ for (const key of areas) {
+ if (flags["if-due"] && !gt.pushDue(slug, key)) {
+ out.push({ area: key, ok: true, skipped: true, reason: "未到最小間隔" });
+ continue;
+ }
+ try {
+ out.push(await gt.pushArea(slug, key, { message: str(flags.message) }));
+ } catch (err) {
+ out.push({ area: key, ok: false, reason: err.message });
+ }
+ }
+ emit({ persona: slug, results: out }, flags.json, out.map((r) =>
+ r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})`
+ : r.ok ? `✔ ${gt.AREAS[r.area].label}:${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}`
+ : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`));
+ return;
+ }
+ if (action === "verify") {
+ const out = [];
+ for (const key of areas) {
+ try {
+ out.push(await gt.verifyArea(slug, key));
+ } catch (err) {
+ out.push({ area: key, ok: false, reason: err.message });
+ }
+ }
+ const bad = out.filter((r) => !r.ok && !r.skipped);
+ emit({ persona: slug, results: out, ok: bad.length === 0 }, flags.json, out.map((r) =>
+ r.skipped
+ ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})`
+ : r.ok
+ ? `✔ ${gt.AREAS[r.area].label}:本機與 Gitea 一致(${r.files} 個檔案)`
+ : `✖ ${gt.AREAS[r.area].label}:不一致` +
+ (r.pending?.length ? `,還沒推上去的檔案:${r.pending.slice(0, 8).join(", ")}` : "") +
+ (r.reason ? `(${r.reason})` : "")));
+ if (bad.length) process.exit(1);
+ return;
+ }
+ if (action === "pull") {
+ const out = [];
+ for (const key of areas) {
+ try {
+ out.push(await gt.pullArea(slug, key, { force: Boolean(flags.force) }));
+ } catch (err) {
+ out.push({ area: key, ok: false, reason: err.message });
+ }
+ }
+ pl.rebuildIndex(slug);
+ emit({ persona: slug, results: out }, flags.json, out.map((r) =>
+ r.conflicts?.length
+ ? `✖ ${gt.AREAS[r.area].label}:本機與遠端都改過 ${r.conflicts.slice(0, 5).join(", ")};` +
+ "沒有覆蓋本機。確定要以遠端為準才加 --force。"
+ : r.ok
+ ? `✔ ${gt.AREAS[r.area].label}:` +
+ (r.empty
+ ? "遠端還是空的"
+ : `遠端有 ${r.changed?.length || 0} 個檔案更新,寫回本機 ${r.written?.length || 0} 個` +
+ `${(r.written?.length || 0) > (r.changed?.length || 0) ? "(含補回本機缺少的檔案)" : ""}`)
+ : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`));
+ return;
+ }
+ die(`未知 action:${action}(可用 init/push/pull/status/verify)`);
+};
+
commands.gc = ({ flags }) => {
const removed = pl.gcRuntime();
emit(removed, flags.json, [
@@ -981,7 +1497,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
用法:node persona.mjs [options]
人格與鎖:
- create --persona --session [--name --creature --vibe --emoji --avatar --baseline --origin --work]
+ create --romaji <英文名> --session [--persona <目錄名> --code --name --creature
+ --vibe --emoji --avatar --baseline --origin --work --no-gitea --public]
+ 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱;
+ 不指定 --persona 就用編號當目錄名。
load --persona --session [--takeover]
release --session [--persona ]
list 列出人格與鎖狀態
@@ -1010,7 +1529,34 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
room post|read|script|list|theater --session [--room --as --text --text-file --emotion --limit --on --off --with-meta]
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
-搬家:
+圖示(建立人格並補齊資料後跑):
+ icon search|measure|faces|headshot|cutout|generate|show --session
+ search --wiki --page <角色頁> 找官方圖,依解析度/是否設定稿排序
+ measure --photo <網址或路徑> 解析度、臉多大、背景好不好去
+ faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看)
+ headshot --photo <...> [--pick <索引>|--face x,y,w,h] [--size 384]
+ 裁出**參考用大頭照**到 .sync/headshot.png(不是圖示、不同步)
+ cutout --photo <...> [--pick <索引>] 去背 → icon/portrait-cutout.png
+ generate [--size 512 --force --no-gitea --style portrait|badge]
+ [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.."]
+ [--features "hairstyle=..,length=..,fringe=..,eyes=..,expression=..,accessory=..,side=..,collar=..,ahoge=.."]
+ [--source-url <來源網址> --source-note <說明> --source-date ]
+ [--from-cutout] [--zoom 2.15] [--pick <索引>]
+ 帶 --from-cutout → 用去背好的官方原圖合成(頭肩構圖,最忠於原作)。
+ 否則依人格資料重新繪製;沒帶 --palette → 徽章樣式。
+ show 看目前的樣式、配色、特徵與來源
+ 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
+
+編號與 Gitea(存取庫名稱 = 人格編號):
+ code show|assign|next --session [--romaji <英文名> --code --rename --force --public]
+ sync status|init|push|pull|verify --session [--area files|wiki|all --if-due --force --message --owner]
+ verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步)
+ 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push
+ Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push
+ 環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER),
+ PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔
+
+搬家(離線檔案):
export --session [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
import --session --file <檔案> [--persona <新 slug> --force --load]
@@ -1021,7 +1567,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
`;
-function main(argv) {
+async function main(argv) {
const sub = argv[0];
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
process.stdout.write(HELP);
@@ -1032,7 +1578,7 @@ function main(argv) {
const parsed = parseArgs(argv.slice(1));
QUIET = Boolean(parsed.flags.quiet);
try {
- command({ flags: parsed.flags, positional: parsed._ });
+ await command({ flags: parsed.flags, positional: parsed._ });
} catch (err) {
if (err instanceof pl.LockError) die(err.message);
throw err;
@@ -1040,4 +1586,4 @@ function main(argv) {
return 0;
}
-process.exit(main(process.argv.slice(2)));
+process.exit(await main(process.argv.slice(2)));
diff --git a/scripts/portrait.py b/scripts/portrait.py
new file mode 100644
index 0000000..7379a93
--- /dev/null
+++ b/scripts/portrait.py
@@ -0,0 +1,369 @@
+#!/usr/bin/env python3
+"""人格形象圖的影像處理:量測、找臉、裁大頭照、去背、合成成圖示。
+
+這支腳本是**選用的加值工具**:jsc-persona 本體只用 Node 內建模組,沒有它也能畫出
+向量人物形象;裝了 Pillow(+可選的 OpenCV 臉部偵測)之後,就能改用官方圖去背當形象圖。
+
+模式:
+ --mode measure 量測:尺寸、臉的位置、背景是不是單色(決定去背好不好做)
+ --mode faces 列出偵測到的所有臉
+ --mode headshot 裁出大頭照(給人看的底稿)
+ --mode cutout 去背,輸出透明 PNG
+ --mode compose 把去背圖合成到圓角漸層底上,產出最終圖示
+
+輸出(stdout):一行 JSON。失敗時 ok=false 並附上 hint。
+"""
+
+import argparse
+import json
+import os
+import sys
+
+
+def emit(payload):
+ print(json.dumps(payload, ensure_ascii=False))
+ sys.exit(0)
+
+
+def fail(reason, hint=None):
+ emit({"ok": False, "reason": reason, "hint": hint})
+
+
+def load_cv2():
+ try:
+ import cv2
+ return cv2
+ except ImportError:
+ return None
+
+
+def detect_faces(path, cascade_path):
+ """回傳 (faces, method)。faces = [(x,y,w,h), ...]。"""
+ cv2 = load_cv2()
+ if cv2 is None:
+ return [], None
+ candidates = []
+ if cascade_path and os.path.exists(cascade_path):
+ candidates.append((cascade_path, "anime-cascade"))
+ builtin = getattr(getattr(cv2, "data", None), "haarcascades", "")
+ if builtin:
+ frontal = os.path.join(builtin, "haarcascade_frontalface_default.xml")
+ if os.path.exists(frontal):
+ candidates.append((frontal, "frontal-cascade"))
+ try:
+ image = cv2.imread(path)
+ if image is None:
+ return [], None
+ gray = cv2.equalizeHist(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY))
+ except Exception:
+ return [], None
+ for xml, method in candidates:
+ try:
+ clf = cv2.CascadeClassifier(xml)
+ if clf.empty():
+ continue
+ except Exception:
+ continue
+ for sf, mn, ms in [(1.05, 5, 40), (1.05, 3, 32), (1.02, 2, 24)]:
+ try:
+ faces = clf.detectMultiScale(gray, scaleFactor=sf, minNeighbors=mn, minSize=(ms, ms))
+ except Exception:
+ continue
+ if len(faces):
+ return [tuple(int(v) for v in f) for f in faces], method
+ return [], None
+
+
+def choose(faces, pick):
+ if not faces:
+ return None
+ if pick is None or pick == "largest":
+ return max(faces, key=lambda f: f[2] * f[3])
+ if pick == "leftmost":
+ return min(faces, key=lambda f: f[0])
+ if pick == "rightmost":
+ return max(faces, key=lambda f: f[0] + f[2])
+ if pick == "topmost":
+ return min(faces, key=lambda f: f[1])
+ try:
+ return sorted(faces, key=lambda f: f[0])[int(pick)]
+ except (ValueError, IndexError):
+ return None
+
+
+def background_report(img):
+ """看四個角與外框一圈:底色一不一致、是不是淺色。單色底=去背可以做得很乾淨。"""
+ w, h = img.size
+ px = img.convert("RGB").load()
+ samples = []
+ step = max(1, min(w, h) // 60)
+ for x in range(0, w, step):
+ samples.append(px[x, 0])
+ samples.append(px[x, h - 1])
+ for y in range(0, h, step):
+ samples.append(px[0, y])
+ samples.append(px[w - 1, y])
+ avg = tuple(sum(c[i] for c in samples) / len(samples) for i in range(3))
+ var = sum(max(abs(c[i] - avg[i]) for i in range(3)) for c in samples) / len(samples)
+ return {
+ "color": [round(v) for v in avg],
+ "spread": round(var, 1), # 越小越單色
+ "plain": bool(var < 18), # 單色底
+ "light": bool(sum(avg) / 3 > 200),
+ }
+
+
+def alpha_from_plain_bg(img, bg_color, tol=34, feather=1.2):
+ """單色底去背:離底色越近越透明,再對邊緣做一點羽化。"""
+ from PIL import Image, ImageFilter
+ import math
+ rgb = img.convert("RGB")
+ w, h = rgb.size
+ px = rgb.load()
+ mask = Image.new("L", (w, h), 255)
+ mp = mask.load()
+ br, bg_, bb = bg_color
+ hard = tol * tol
+ soft = (tol * 2.1) ** 2
+ for y in range(h):
+ for x in range(w):
+ r, g, b = px[x, y]
+ d = (r - br) ** 2 + (g - bg_) ** 2 + (b - bb) ** 2
+ if d <= hard:
+ mp[x, y] = 0
+ elif d < soft:
+ mp[x, y] = int(255 * (math.sqrt(d) - tol) / (tol * 1.1))
+ # 只留最大的一塊,避免把角色身上和底色相近的區塊也挖掉
+ cv2 = load_cv2()
+ if cv2 is not None:
+ import numpy as np
+ m = np.array(mask)
+ binary = (m > 96).astype("uint8")
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
+ n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
+ if n > 1:
+ largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
+ keep = (labels == largest)
+ # 洞(例如手臂圍出的空隙)補回來
+ filled = cv2.morphologyEx(keep.astype("uint8"), cv2.MORPH_CLOSE, np.ones((15, 15), np.uint8))
+ m = np.where(filled > 0, m, 0)
+ mask = Image.fromarray(m)
+ return mask.filter(ImageFilter.GaussianBlur(feather))
+
+
+def alpha_from_grabcut(path, face, iters=8):
+ """有背景的圖:用臉的位置當前景種子跑 GrabCut。"""
+ cv2 = load_cv2()
+ if cv2 is None:
+ return None
+ import numpy as np
+ from PIL import Image
+ img = cv2.imread(path)
+ if img is None:
+ return None
+ h, w = img.shape[:2]
+ mask = np.full((h, w), cv2.GC_PR_BGD, np.uint8)
+ mask[int(h * 0.03):int(h * 0.99), int(w * 0.05):int(w * 0.95)] = cv2.GC_PR_FGD
+ if face is not None:
+ fx, fy, fw, fh = face
+ cx, cy = fx + fw // 2, fy + fh // 2
+ cv2.ellipse(mask, (cx, cy), (int(fw * 0.42), int(fh * 0.48)), 0, 0, 360, cv2.GC_FGD, -1)
+ cv2.ellipse(mask, (cx, int(cy - fh * 0.28)), (int(fw * 0.80), int(fh * 0.70)), 0, 0, 360,
+ cv2.GC_FGD, -1)
+ cv2.rectangle(mask, (int(cx - fw * 0.85), int(cy + fh * 0.8)), (int(cx + fw * 0.85), h - 1),
+ cv2.GC_FGD, -1)
+ b = max(2, int(min(w, h) * 0.015))
+ mask[:b, :] = cv2.GC_BGD
+ mask[-b:, :] = cv2.GC_BGD
+ mask[:, :b] = cv2.GC_BGD
+ mask[:, -b:] = cv2.GC_BGD
+ bgd, fgd = np.zeros((1, 65), np.float64), np.zeros((1, 65), np.float64)
+ try:
+ cv2.grabCut(img, mask, None, bgd, fgd, iters, cv2.GC_INIT_WITH_MASK)
+ except Exception:
+ return None
+ m = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD), 255, 0).astype("uint8")
+ n, labels, stats, _ = cv2.connectedComponentsWithStats((m > 0).astype("uint8"), 8)
+ if n > 1:
+ largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
+ m = np.where(labels == largest, 255, 0).astype("uint8")
+ m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
+ m = cv2.GaussianBlur(m, (5, 5), 0)
+ return Image.fromarray(m)
+
+
+def head_box(img_size, face, zoom):
+ """由臉的框推出「頭肩構圖」的正方形裁切框。"""
+ W, H = img_size
+ if face is None:
+ side = min(W, H)
+ cx, cy = W / 2, min(H / 2, side * 0.42)
+ else:
+ x, y, w, h = face
+ cx, cy = x + w / 2, y + h / 2 - h * 0.06
+ side = min(max(w, h) * zoom, min(W, H))
+ left = int(max(0, min(W - side, cx - side / 2)))
+ top = int(max(0, min(H - side, cy - side / 2)))
+ return (left, top, int(left + side), int(top + side))
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--mode", default="headshot",
+ choices=["measure", "faces", "headshot", "cutout", "compose"])
+ ap.add_argument("--input", required=True)
+ ap.add_argument("--output")
+ ap.add_argument("--size", type=int, default=512)
+ ap.add_argument("--cascade", default=None)
+ ap.add_argument("--radius", type=float, default=0.22)
+ ap.add_argument("--pick", default=None)
+ ap.add_argument("--face", default=None, help="直接指定臉的框 x,y,w,h")
+ ap.add_argument("--zoom", type=float, default=2.1, help="裁切框相對臉的倍率")
+ ap.add_argument("--bg", default=None, help="compose 的底色漸層,例如 #d9a45b,#c0392b")
+ ap.add_argument("--crop", default="head", choices=["head", "full"],
+ help="compose 時裁頭肩(預設)還是用整張")
+ args = ap.parse_args()
+
+ try:
+ from PIL import Image
+ except ImportError:
+ fail("缺少 Pillow", "pip install pillow")
+
+ try:
+ img = Image.open(args.input)
+ img.load()
+ except Exception as exc:
+ fail(f"讀不到圖片:{exc}", "確認檔案完整;WebP 需要較新的 Pillow")
+
+ W, H = img.size
+ faces, method = detect_faces(args.input, args.cascade)
+ face = None
+ if args.face:
+ try:
+ face = tuple(int(v) for v in args.face.split(","))
+ method = "manual-box"
+ except ValueError:
+ fail("--face 格式要是 x,y,w,h")
+ else:
+ face = choose(faces, args.pick)
+
+ if args.mode == "measure":
+ bg = background_report(img)
+ big = face[2] if face else 0
+ has_alpha = False
+ if img.mode in ("RGBA", "LA", "PA"):
+ lo, hi = img.convert("RGBA").getchannel("A").getextrema()
+ has_alpha = lo < 16 and hi > 200
+ emit({
+ "ok": True, "size": [W, H], "pixels": W * H, "faces_found": len(faces),
+ "face": list(face) if face else None, "face_ratio": round(big / max(W, H), 3) if face else 0,
+ "method": method, "background": bg, "transparent": has_alpha,
+ "cutout_easy": bool(has_alpha or bg["plain"]),
+ })
+
+ if args.mode == "faces":
+ emit({
+ "ok": True, "size": [W, H], "method": method,
+ "faces": [{"index": i, "x": f[0], "y": f[1], "w": f[2], "h": f[3],
+ "center": [f[0] + f[2] // 2, f[1] + f[3] // 2]}
+ for i, f in enumerate(sorted(faces, key=lambda f: f[0]))],
+ })
+
+ if not args.output:
+ fail("這個模式需要 --output")
+
+ if args.mode == "headshot":
+ from PIL import ImageDraw
+ box = head_box((W, H), face, args.zoom)
+ out = img.convert("RGB").crop(box).resize((args.size, args.size), Image.LANCZOS).convert("RGBA")
+ radius = int(args.size * args.radius)
+ mask = Image.new("L", (args.size, args.size), 0)
+ ImageDraw.Draw(mask).rounded_rectangle([0, 0, args.size - 1, args.size - 1], radius=radius, fill=255)
+ out.putalpha(mask)
+ out.save(args.output, "PNG", optimize=True)
+ emit({"ok": True, "mode": "headshot", "method": method, "faces_found": len(faces),
+ "source_size": [W, H], "face": list(face) if face else None, "box": list(box),
+ "size": args.size, "output": args.output})
+
+ if args.mode == "cutout":
+ bg = background_report(img)
+ # 最好的情況:官方人設圖多半本來就是透明底 PNG,直接沿用既有 alpha
+ existing = None
+ if img.mode in ("RGBA", "LA", "PA"):
+ a = img.convert("RGBA").getchannel("A")
+ lo, hi = a.getextrema()
+ if lo < 16 and hi > 200:
+ existing = a
+ if existing is not None:
+ alpha = existing
+ used = "source-alpha"
+ elif bg["plain"]:
+ alpha = alpha_from_plain_bg(img, bg["color"])
+ used = "plain-background"
+ else:
+ alpha = alpha_from_grabcut(args.input, face)
+ used = "grabcut"
+ if alpha is None:
+ fail("這張圖的背景不是單色,而 OpenCV 不可用,無法去背",
+ "換一張官方人設圖(通常是白底),或安裝 opencv-python-headless<5")
+ rgba = img.convert("RGBA")
+ rgba.putalpha(alpha)
+ # 裁到實際內容的範圍,邊界不留大片透明
+ bbox = rgba.getbbox()
+ if bbox:
+ rgba = rgba.crop(bbox)
+ rgba.save(args.output, "PNG", optimize=True)
+ hist = rgba.getchannel("A").histogram()
+ opaque = sum(hist[129:])
+ emit({"ok": True, "mode": "cutout", "method": used, "background": bg,
+ "source_size": [W, H], "output_size": list(rgba.size),
+ "opaque_ratio": round(opaque / (rgba.size[0] * rgba.size[1]), 3),
+ "output": args.output})
+
+ if args.mode == "compose":
+ from PIL import ImageDraw
+ cut = img.convert("RGBA")
+ S = args.size
+ colors = [(90, 110, 150), (40, 50, 80)]
+ if args.bg:
+ parts = [p.strip().lstrip("#") for p in args.bg.split(",")]
+ try:
+ colors = [tuple(int(p[i:i + 2], 16) for i in (0, 2, 4)) for p in parts[:2]]
+ except ValueError:
+ pass
+ if len(colors) == 1:
+ colors *= 2
+ canvas = Image.new("RGBA", (S, S), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(canvas)
+ for i in range(S):
+ t = i / max(1, S - 1)
+ c = tuple(int(colors[0][k] + (colors[1][k] - colors[0][k]) * t) for k in range(3))
+ draw.line([(0, i), (S, i)], fill=c + (255,))
+ # 官方人設圖常是「正面+背面」兩張並排的設定稿,整張塞進去會變成兩個人。
+ # 所以預設先裁到頭肩構圖(用臉的位置),要整張再指定 --crop full。
+ if args.crop == "head":
+ cfaces, _ = detect_faces(args.input, args.cascade)
+ cface = choose(cfaces, args.pick)
+ if args.face:
+ try:
+ cface = tuple(int(v) for v in args.face.split(","))
+ except ValueError:
+ pass
+ if cface is not None:
+ cut = cut.crop(head_box(cut.size, cface, args.zoom))
+ cw, ch = cut.size
+ scale = (S * 0.98) / max(cw, ch)
+ nw, nh = max(1, int(cw * scale)), max(1, int(ch * scale))
+ person = cut.resize((nw, nh), Image.LANCZOS)
+ canvas.alpha_composite(person, (int((S - nw) / 2), int((S - nh) / 2)))
+ radius = int(S * args.radius)
+ mask = Image.new("L", (S, S), 0)
+ ImageDraw.Draw(mask).rounded_rectangle([0, 0, S - 1, S - 1], radius=radius, fill=255)
+ out = Image.new("RGBA", (S, S), (0, 0, 0, 0))
+ out.paste(canvas, (0, 0), mask)
+ out.save(args.output, "PNG", optimize=True)
+ emit({"ok": True, "mode": "compose", "size": S, "person": [nw, nh], "output": args.output})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs
index 45311d7..ad17cbc 100644
--- a/scripts/selftest.mjs
+++ b/scripts/selftest.mjs
@@ -14,8 +14,12 @@ import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STORE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-selftest-"));
process.env.PERSONA_HOME = STORE;
+// 自我測試絕對不碰真的 Gitea:不建存取庫、不 push。同步邏輯只測不需要網路的部分。
+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");
@@ -406,6 +410,343 @@ check("guard:匯入新人格允許(只寫新目錄,不讀別人)",
tool_input: { command: `node persona.mjs import --persona gamma --session ${S_SPEAK} --file /tmp/b.json` } }) === "pass");
fs.rmSync(OUT, { recursive: true, force: true });
+console.log("⑬ 人格編號與 Gitea 分區");
+check("羅馬拼音正規化:只吃拉丁字母",
+ gt.normalizeRomaji("Asuna") === "ASUNA" && gt.normalizeRomaji("shen yu") === "SHENYU" &&
+ gt.normalizeRomaji("亞絲娜") === null && gt.normalizeRomaji("") === null);
+check("編號格式:英文名全大寫 + 兩位索引",
+ gt.validCode("ASUNA-01") && gt.validCode("SHENYU-12") &&
+ !gt.validCode("asuna-01") && !gt.validCode("ASUNA-1") && !gt.validCode("ASUNA"));
+check("建立人格時自動產生編號(alpha → ALPHA-01)", gt.personaCode("alpha") === "ALPHA-01",
+ String(gt.personaCode("alpha")));
+check("同名才遞增,不同名各自從 01 開始",
+ gt.nextCode("Alpha") === "ALPHA-02" && gt.nextCode("Beta") === "BETA-02" && gt.nextCode("Lumi") === "LUMI-01",
+ `${gt.nextCode("Alpha")} / ${gt.nextCode("Beta")} / ${gt.nextCode("Lumi")}`);
+const S_CODE = "sess-code-4444";
+cli(["create", "--persona", "gamma", "--romaji", "Gamma", "--session", S_CODE, "--name", "Gamma", "--emoji", "🜂"]);
+check("編號寫進 config.json", pl.loadConfig("gamma").code === "GAMMA-01" && pl.loadConfig("gamma").romaji === "GAMMA");
+const codeShow = cli(["code", "show", "--session", S_CODE, "--json"]);
+check("code show 回報編號", (() => {
+ try {
+ return JSON.parse(codeShow.stdout).code === "GAMMA-01";
+ } catch {
+ return false;
+ }
+})(), codeShow.stdout.slice(0, 120));
+cli(["code", "assign", "--session", S_CODE, "--code", "GAMMA-01", "--rename", "--force"]);
+check("--rename 把目錄名改成編號", pl.personaExists("GAMMA-01") && !pl.personaExists("gamma"));
+check("改名後鎖與 session 綁定都跟著轉移",
+ pl.lockStatus("GAMMA-01").locked && pl.loadSession(S_CODE).host === "GAMMA-01" &&
+ pl.loadConfig("GAMMA-01").persona === "GAMMA-01");
+check("大寫編號目錄一樣受跨人格隔離保護",
+ guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/GAMMA-01/SOUL.md` } }) === "deny");
+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) =>
+ gt.AREA_KEYS.filter((key) =>
+ gt.AREAS[key].paths.some((p) => (p.endsWith("/") ? rel.startsWith(p) : rel === p)));
+const allFiles = [];
+(function walk(dir, base = "") {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name.startsWith(".")) continue;
+ const rel = base ? `${base}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), rel);
+ else allFiles.push(rel);
+ }
+})(pl.personaDir("alpha"));
+const uncovered = allFiles.filter((f) => !AREA_EXEMPT.has(f) && covered(f).length !== 1);
+check("檔案區/Wiki 區的切分不重不漏(每個檔案恰好屬於一區)", uncovered.length === 0,
+ uncovered.map((f) => `${f}→${covered(f).length}`).join(", "));
+check("高頻資料在檔案區、低頻資料在 Wiki 區",
+ covered("state/emotion.json")[0] === "files" && covered("memory/short-term.jsonl")[0] === "files" &&
+ covered("journal/2026-01.jsonl")[0] === "files" && covered("state/said.jsonl")[0] === "files" &&
+ covered("IDENTITY.md")[0] === "wiki" && covered("memory/long-term/x.md")[0] === "wiki" &&
+ covered("relations/graph.json")[0] === "wiki");
+check("沒設定 Gitea 時同步只是略過,不會爆炸", (() => {
+ const res = cli(["sync", "status", "--session", S_CODE]);
+ const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false });
+ return res.status === 0 && res.stdout.includes("Gitea") && push.status !== 0 &&
+ push.stderr.includes("尚未設定");
+})());
+check("匯出不會把 .sync 的 git clone 打包進去", (() => {
+ fs.mkdirSync(path.join(pl.personaDir("GAMMA-01"), ".sync", "files"), { recursive: true });
+ fs.writeFileSync(path.join(pl.personaDir("GAMMA-01"), ".sync", "files", "junk.txt"), "x");
+ const { bundle: b } = pl.exportBundle("GAMMA-01");
+ 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(/ 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("⑮ 依參考照片配色的圖示");
+const PAL = "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3";
+const SRC = "https://example.invalid/key-visual.png";
+check("調色盤解析:hair 與 accent 必填、支援 #abc 縮寫", (() => {
+ const ok = ic.parsePalette(PAL);
+ const short = ic.parsePalette("hair=#abc,accent=#123456");
+ return ok?.hair?.join() === "217,164,91" && ok.eye.join() === "158,91,62" &&
+ short?.hair?.join() === "170,187,204" &&
+ ic.parsePalette("eye=#ffffff") === null && ic.parsePalette("garbage") === null;
+})());
+check("`--palette` 沒帶 `--source-url` 會被擋(配色要有出處)",
+ cli(["icon", "generate", "--session", S_CODE, "--force", "--palette", PAL], { expectOk: false }).status !== 0);
+check("格式錯誤的 `--palette` 會被擋",
+ cli(["icon", "generate", "--session", S_CODE, "--force", "--palette", "hair=紅色",
+ "--source-url", SRC], { expectOk: false }).status !== 0);
+cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64",
+ "--palette", PAL, "--source-url", SRC, "--source-note", "測試用主視覺", "--source-date", "2026-07-30"]);
+const palSpec = ic.iconSpec("GAMMA-01");
+check("圖示改用照片配色(瞳色當外框、亮色當紋路)",
+ palSpec.palette !== null && palSpec.ring.join() === "158,91,62" && palSpec.dot.join() === "242,235,227");
+check("來源網址與說明寫進 config(可查證)", (() => {
+ const icon = pl.loadConfig("GAMMA-01").icon || {};
+ return icon.palette.startsWith(PAL) && icon.source?.url === SRC &&
+ icon.source?.note === "測試用主視覺" && icon.source?.date === "2026-07-30";
+})(), JSON.stringify(pl.loadConfig("GAMMA-01").icon));
+check("照片配色與雜湊配色畫出來不一樣", (() => {
+ const withPal = ic.renderPng(palSpec, 32);
+ const hashOnly = ic.renderPng({ ...palSpec, palette: null, c1: [40, 120, 184], c2: [200, 102, 214],
+ ring: palSpec.ink, dot: palSpec.ink, ringAlpha: 0.18, dotAlpha: 0.14 }, 32);
+ return Buffer.compare(withPal, hashOnly) !== 0;
+})());
+check("不帶 --palette 重畫會沿用已存的配色(不會變回雜湊色)", (() => {
+ cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64"]);
+ const again = ic.iconSpec("GAMMA-01");
+ return again.palette !== null && ic.paletteToString(again.palette).startsWith(PAL);
+})());
+check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", (() => {
+ // 藍黑髮 + 淡粉洋裝:不收斂的話不論黑字白字都會有一端糊掉
+ const spec = ic.iconSpec("GAMMA-01", {
+ palette: ic.parsePalette("hair=#1b1b22,eye=#6b4a2f,accent=#f2b6cb,secondary=#4a7bc8,light=#fbeff3"),
+ });
+ const lum = (c) => {
+ const f = c.map((v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4));
+ return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
+ };
+ const ratio = (a, b) => (Math.max(lum(a), lum(b)) + 0.05) / (Math.min(lum(a), lum(b)) + 0.05);
+ return Math.min(ratio(spec.ink, spec.c1), ratio(spec.ink, spec.c2)) >= 3;
+})());
+
+console.log("⑯ 人物形象圖(有臉)與照片裁臉工具");
+const facePal = ic.parsePalette("hair=#1b1b22,eye=#6b4a2f,accent=#f2b6cb,secondary=#4a7bc8,light=#fbeff3");
+const faceSpec = ic.iconSpec("GAMMA-01", { palette: facePal, style: "portrait" });
+check("有調色盤時預設畫「人物形象」而不是徽章",
+ ic.iconSpec("GAMMA-01", { palette: facePal }).style === "portrait" &&
+ ic.iconSpec("alpha").style !== "portrait", ic.iconSpec("alpha").style);
+check("形象圖真的畫了五官(眼白/虹膜/瞳孔/嘴都在)", (() => {
+ const shapes = ic.iconShapes(faceSpec);
+ const eyeWhite = shapes.filter((sh) => sh.fill.join() === "252,252,255").length;
+ const iris = shapes.filter((sh) => sh.fill.join() === facePal.eye.join()).length;
+ const highlight = shapes.filter((sh) => sh.fill.join() === "255,255,255").length;
+ // 兩隻眼睛:各有眼白、虹膜、至少一個高光;整體圖形數要夠(含頭髮/五官/衣服)
+ return shapes.length >= 30 && eyeWhite === 2 && iris >= 2 && highlight >= 2;
+})(), `圖形數 ${ic.iconShapes(faceSpec).length}`);
+check("形象圖用的是照片配色(髮色當底、瞳色當眼睛)",
+ faceSpec.c1.join() === facePal.hair.join() || faceSpec.c2.join() !== faceSpec.c1.join());
+check("SVG 與 PNG 出自同一份圖形清單(SVG 有對應數量的 ellipse)", (() => {
+ const svg = ic.renderSvg(faceSpec, 64);
+ const shapes = ic.iconShapes(faceSpec);
+ const ellipses = (svg.match(/ sh.type === "ellipse").length &&
+ rects >= shapes.filter((sh) => sh.type === "rect").length;
+})());
+check("--style 可以強制畫回徽章",
+ ic.iconSpec("GAMMA-01", { palette: facePal, style: "badge" }).style === "badge");
+const tools = ic.toolReport();
+check("工具偵測會回報缺什麼與怎麼裝",
+ typeof tools.ready === "boolean" && Array.isArray(tools.missing) &&
+ tools.missing.every((m) => m.what && m.why && m.how));
+check("缺工具時的提示含安裝指令", (() => {
+ const fake = { missing: [{ what: "Pillow", why: "解碼照片", how: "pip install pillow" }] };
+ const lines = ic.installHintLines(fake);
+ return lines.length >= 3 && lines.join("\n").includes("pip install pillow");
+})());
+check("工具齊全時提示為空", ic.installHintLines({ missing: [] }).length === 0);
+check("`icon faces` 需要 --photo",
+ cli(["icon", "faces", "--session", S_CODE], { expectOk: false }).status !== 0);
+check("形象圖同步到 Wiki 區(svg 與 png 都在)",
+ covered("icon.svg")[0] === "wiki" && covered("icon.png")[0] === "wiki");
+check("Wiki 有專頁保存形象圖,並寫明來源", (() => {
+ const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01");
+ return page.includes("形象圖") && page.includes("icon.svg") && page.includes("icon.png") &&
+ page.includes("參考來源") && page.includes("https://example.invalid/key-visual.png");
+})(), gt.wikiIconPage("GAMMA-01", "GAMMA-01").slice(0, 120));
+check("Wiki 的保留檔不會被同步流程刪掉",
+ ["Home.md", "Icon.md"].every((f) => gt.wikiHome && typeof gt.wikiIconPage === "function"));
+
+console.log("⑰ 依人格資料重繪(不直接使用網路圖)與 Wiki 同步驗證");
+check("特徵解析:只吃認得的值,其餘回退預設", (() => {
+ const f = ic.parseFeatures("hairstyle=twintails,eyes=round,accessory=flower,bogus=x,expression=???");
+ return f.hairstyle === "twintails" && f.eyes === "round" && f.accessory === "flower" &&
+ f.expression === ic.DEFAULT_FEATURES.expression && !("bogus" in f);
+})(), JSON.stringify(ic.parseFeatures("hairstyle=twintails,bogus=x")));
+const basePal = ic.parsePalette("hair=#d9a45b,eye=#9e5b3e,accent=#c0392b");
+const draw = (feat) => ic.renderPng(ic.iconSpec("GAMMA-01",
+ { palette: basePal, style: "portrait", features: ic.parseFeatures(feat) }), 32);
+check("換髮型會畫出不同的圖",
+ Buffer.compare(draw("hairstyle=straight"), draw("hairstyle=twintails")) !== 0);
+check("換眼型會畫出不同的圖",
+ Buffer.compare(draw("eyes=round"), draw("eyes=sharp")) !== 0);
+check("換表情會畫出不同的圖",
+ Buffer.compare(draw("expression=calm"), draw("expression=bright")) !== 0);
+check("加髮飾/呆毛會多出圖形", (() => {
+ const plain = ic.iconShapes(ic.iconSpec("GAMMA-01",
+ { palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=none,ahoge=no") }));
+ const fancy = ic.iconShapes(ic.iconSpec("GAMMA-01",
+ { palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=flower,ahoge=yes") }));
+ return fancy.length > plain.length + 3;
+})());
+check("多邊形在 SVG 與柵格器都畫得出來(呆毛用的是 polygon)", (() => {
+ const spec = ic.iconSpec("GAMMA-01",
+ { palette: basePal, style: "portrait", features: ic.parseFeatures("ahoge=yes") });
+ const shapes = ic.iconShapes(spec);
+ const polys = shapes.filter((sh) => sh.type === "poly").length;
+ const svg = ic.renderSvg(spec, 64);
+ return polys > 0 && (svg.match(/ {
+ const cfg = pl.loadConfig("GAMMA-01");
+ cfg.icon = { ...(cfg.icon || {}), style: "photo" }; // 舊版本寫進去、現已移除的樣式
+ pl.writeJson(pl.configPath("GAMMA-01"), cfg);
+ return ic.iconSpec("GAMMA-01").style === "portrait";
+})(), ic.iconSpec("GAMMA-01").style);
+check("`icon headshot` 需要 --photo",
+ cli(["icon", "headshot", "--session", S_CODE], { expectOk: false }).status !== 0);
+check("重繪不會把來源圖塞進圖示(SVG 沒有內嵌影像)", (() => {
+ const svg = fs.readFileSync(ic.iconSvgPath("GAMMA-01"), "utf8");
+ // xmlns 本來就有 http,所以只看「有沒有內嵌影像」
+ return !svg.includes(" {
+ cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64",
+ "--features", "hairstyle=twintails,accessory=ribbon"]);
+ const feat = pl.loadConfig("GAMMA-01").icon?.features || "";
+ return feat.includes("hairstyle=twintails") && feat.includes("accessory=ribbon");
+})(), pl.loadConfig("GAMMA-01").icon?.features);
+check("沒設定 Gitea 時 sync verify 是「略過」不是崩潰", (() => {
+ const res = cli(["sync", "verify", "--session", S_CODE], { expectOk: false });
+ return res.stderr.includes("尚未設定") || res.stdout.includes("略過");
+})());
+check("Wiki 形象圖頁同時列出 SVG 與 PNG", (() => {
+ const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01");
+ return page.includes("icon.svg") && page.includes("icon.png") && page.includes("SVG") && page.includes("PNG");
+})());
+check("Wiki 首頁不含每次都變的時間戳(否則永遠驗不過)", (() => {
+ const a = gt.wikiHome("GAMMA-01", "GAMMA-01");
+ const b = gt.wikiHome("GAMMA-01", "GAMMA-01");
+ return a === b;
+})());
+
+console.log("⑱ 高解析度輸出與 Wiki 圖片實際可讀");
+check("icon/ 資料夾輸出向量原稿與多個解析度", (() => {
+ cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64",
+ "--palette", "hair=#d9a45b,accent=#c0392b", "--source-url", "https://example.invalid/x.png"]);
+ const dir = path.join(pl.personaDir("GAMMA-01"), "icon");
+ const names = fs.readdirSync(dir).sort();
+ return names.includes("portrait.svg") && ic.RENDER_SIZES.every((px) => names.includes(`portrait-${px}.png`));
+})(), (() => { try { return fs.readdirSync(path.join(pl.personaDir("GAMMA-01"), "icon")).join(","); } catch { return "(無)"; } })());
+check("高解析度 PNG 的實際尺寸正確", (() => {
+ const buf = fs.readFileSync(path.join(pl.personaDir("GAMMA-01"), "icon", "portrait-1024.png"));
+ return buf.readUInt32BE(16) === 1024 && buf.readUInt32BE(20) === 1024;
+})());
+check("icon/ 屬於 Wiki 區(會被同步)", covered("icon/portrait-1024.png")[0] === "wiki");
+check("Wiki 只攤平 .md,圖片保留資料夾結構", (() => {
+ // Gitea 只把「根目錄的 .md」當頁面,但 /wiki/raw/<資料夾>/<圖> 取得到(實測)
+ return gt.wikiName("memory/long-term/x.md") === "Memory-x.md" &&
+ gt.wikiName("icon/portrait-1024.png") === "icon/portrait-1024.png" &&
+ gt.wikiName("relations/graph.json") === "relations/graph.json";
+})(), `${gt.wikiName("icon/portrait-1024.png")} / ${gt.wikiName("memory/long-term/x.md")}`);
+check("Wiki 頁面用 Markdown 圖片語法(HTML
不會被改寫路徑)", (() => {
+ const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01");
+ const home = gt.wikiHome("GAMMA-01", "GAMMA-01");
+ return page.includes("![") && !page.includes("
l.includes("img") || l.includes("![")));
+check("Icon 頁會列出 icon/ 裡的每個檔案", (() => {
+ const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01");
+ return ic.RENDER_SIZES.every((px) => page.includes(`icon/portrait-${px}.png`));
+})());
+check("柵格器有做 bounding box 裁剪(1024 才跑得動)", (() => {
+ const spec = ic.iconSpec("GAMMA-01");
+ const t0 = Date.now();
+ ic.renderPng(spec, 512);
+ return Date.now() - t0 < 4000; // 沒有裁剪的話會慢好幾倍
+})());
+
+console.log("⑲ 找圖 → 去背 → 合成");
+check("找圖:官方設定稿加權高於一般截圖", (() => {
+ // wikiImageCandidates 的評分:解析度取 log2,命中 Full Body/Character Design 再加 8
+ const big = { title: "File:Scene.png", width: 1920, height: 1080 };
+ const sheet = { title: "File:Yui's ALO Pixie Form Full Body.png", width: 773, height: 1056 };
+ const score = (r) => Math.round(Math.log2(r.width * r.height) * 10) / 10 +
+ (/full.?body|character.?design|concept|profile|settei|avatar/i.test(r.title) ? 8 : 0);
+ return score(sheet) > score(big);
+})());
+check("cutout 的三條路徑都有實作", (() => {
+ const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8");
+ return py.includes("source-alpha") && py.includes("plain-background") && py.includes("grabcut");
+})());
+check("portrait.py 有 measure/faces/headshot/cutout/compose 五個模式", (() => {
+ const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8");
+ return ["measure", "faces", "headshot", "cutout", "compose"].every((m) => py.includes(`"${m}"`));
+})());
+check("compose 預設裁頭肩(官方設定稿常是正反兩面,整張會變兩個人)", (() => {
+ const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8");
+ return py.includes('args.crop == "head"') && py.includes("head_box");
+})());
+check("去背圖路徑在 icon/(Wiki 區涵蓋)",
+ ic.CUTOUT_PNG === "icon/portrait-cutout.png" && covered("icon/portrait-cutout.png")[0] === "wiki");
+check("cutout 樣式的 SVG 內嵌同一張 PNG(自成一體、不外連)", (() => {
+ const spec = ic.iconSpec("GAMMA-01", { palette: ic.parsePalette("hair=#aaa,accent=#333") });
+ const svg = ic.wrapPngSvg(spec, Buffer.from([0x89, 0x50, 0x4e, 0x47]), 64);
+ return svg.includes("data:image/png;base64,") && !svg.includes("http://example") &&
+ svg.includes(" --session \
+ --romaji "<角色的官方英文名>" --session \
--name "" --creature "" --vibe "" \
--emoji "" --avatar "<外觀描述>" \
--origin anime --work "<作品名>" \
--baseline "<依角色個性設定的十二情緒基線>"
```
+`--romaji` 用**官方英文/羅馬拼音名**(維基或官方英文版的寫法,如 `Asuna`、`Yui`、`Rem`),
+它會變成人格編號 `ASUNA-01` 與 Gitea 存取庫名稱。同一個角色要做第二份設定時,
+索引會自動變成 `-02`。
+
情緒基線抓法(0–100,總體別全部拉高):
| 角色類型 | 建議基線 |
@@ -138,11 +142,28 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \
用 Edit 把角色的概念網寫進 `mindmap/semantic.mmd`:作品世界觀 → 陣營/組織 → 能力 → 重要人物 → 個人課題。
-## 8. 回報
+## 8. 產生人格圖示(資料補齊之後才做)
+
+動漫角色**一定要走 `/jsc-persona:persona-icon`**:你在第 2 步已經上網查過這個角色,
+順著找出他**最新一次登場**的官方視覺(新作主視覺/官方角色圖),下載、**親眼看過**,
+再把髮色/瞳色/服裝色餵給 CLI:
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \
+ --session --force \
+ --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.." \
+ --source-url "<那張圖的網址>" --source-note "<作品(年份)+造型描述>"
+```
+
+**沒看過圖就不要填顏色**;真的找不到官方圖就不帶 `--palette`,改用編號雜湊配色並告知使用者。
+產出的 `icon.svg` + `icon.png` 會設成 Gitea 存取庫頭像並同步到 Wiki 區。
+**要等 canon 記憶與身分都補齊再跑。**
+
+## 9. 回報
用該角色的語氣做一段簡短自我介紹(**這是他第一次醒來**),然後用一般語氣列出:
-- slug、五個身分欄位
+- 編號(如 `ASUNA-01`)、五個身分欄位、圖示路徑
- 情緒基線前三高
- 固化了幾則 canon 記憶、用了哪些來源(URL 列表)
- 哪些設定各來源說法不一致(待使用者裁決)
diff --git a/skills/persona-create/SKILL.md b/skills/persona-create/SKILL.md
index 32dc213..90d61b8 100644
--- a/skills/persona-create/SKILL.md
+++ b/skills/persona-create/SKILL.md
@@ -51,12 +51,21 @@ slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
- --persona --session \
+ --romaji "<英文名>" --session \
--name "" --creature "" --vibe "" \
--emoji "" --avatar "" \
--baseline "serenity=45,trust=35,joy=25,anxiety=6"
```
+**`--romaji` 決定人格編號**(英文名全大寫+兩位索引,例如 `Asuna` → `ASUNA-01`)。
+編號同時是本機目錄名與 Gitea 存取庫的名稱,所以:中文/日文名字要**先提議羅馬拼音、
+拿給使用者確認拼法**再送出(`亞絲娜 → Asuna`、`沈宇 → Shen Yu`)。想先看號碼會長怎樣:
+`code next --romaji "<英文名>" --session `。
+
+設好 `GITEA_HOST`/`GITEA_TOKEN` 時,`create` 會順手在 Gitea 開一個**私有**存取庫
+(名稱=編號)並推第一版;沒設定或加 `--no-gitea` 就只在本機建立。細節見
+`/jsc-persona:persona-sync`。
+
`create` 會:建立 `IDENTITY.md`/`SOUL.md`/`AGENTS.md`/`USER.md`、十二情緒狀態、
記憶與心智圖骨架、人際關係圖,並**立刻取得這個人格的載入鎖並綁到本 session**。
@@ -104,9 +113,30 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \
也可視需要在 `mindmap/semantic.mmd` 補上初始概念(自我/使用者/共同經驗)。
-### 7. 回報
+### 7. 產生人格圖示(資料補齊之後才做)
-用該人格的 emoji 與語氣,摘要:slug、五個身分欄位、情緒基線前三高、倉庫路徑,
+角色若有官方視覺(動漫/遊戲角色),走 **`/jsc-persona:persona-icon`**:
+上網找出他最新一次登場的官方圖 → 親眼看過 → 取髮色/瞳色/服裝色 → 用那組配色繪製。
+
+原創人格沒有參考圖,直接用編號雜湊的配色即可:
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session
+```
+
+產出 `icon.svg` 與 `icon.png`(512×512),並自動設成 Gitea 存取庫的頭像、同步到 Wiki 區。
+
+- **一定要等身分補齊再跑**:配色與字母是由**編號、Name、Emoji** 一起雜湊出來的,
+ 資料還沒填完就產生,之後改名字圖就對不上了。
+- 同一個人格永遠得到同一張圖(純函數,不隨機)。
+- 之後改了 `IDENTITY.md` 想重畫 → `icon generate --session --force`。
+- 圖是**幾何徽章**:雙色漸層 + 專屬點陣紋 + 編號前兩個字母(`ASUNA-01` → `AS`)。
+ 沒有把 emoji 畫進去是因為 PNG 需要字型柵格化,而本 plugin 不用任何外部依賴;
+ emoji 仍然參與配色的雜湊。
+
+### 8. 回報
+
+用該人格的 emoji 與語氣,摘要:編號、五個身分欄位、情緒基線前三高、倉庫路徑與圖示,
並提示:`/jsc-persona:persona-chat ` 開始對話、`/jsc-persona:persona-invite` 邀別的人格加入。
---
diff --git a/skills/persona-icon/SKILL.md b/skills/persona-icon/SKILL.md
new file mode 100644
index 0000000..3b00588
--- /dev/null
+++ b/skills/persona-icon/SKILL.md
@@ -0,0 +1,142 @@
+---
+name: persona-icon
+description: 產生或更新人格的形象圖(icon.svg + icon.png):先找出該人格「最新一次登場」對應的**高解析度官方圖**(優先官方人設稿/設定圖,它們通常是透明底或白底、解析度遠高於資料庫縮圖),量測解析度與臉的大小後**去背**成透明 PNG,再裁成頭肩構圖、合成到取自角色配色的漸層底上,成為存取庫頭像並同步到 Gitea Wiki(含 icon/ 多解析度)。找不到可用的官方圖時,退回依人格資料重新繪製的向量人物形象。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說圖示不像或不夠清楚、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime)、同步到 Gitea(persona-sync)。
+---
+
+# 🎨 persona-icon — 依最新造型產生人格形象圖(有臉)
+
+**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `)
+**時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——形象綁在最終身分上。
+**目標**:圖示要是**人物形象圖,看得到臉**;退而求其次才是抽象徽章。
+
+**流程**:找高解析度官方圖 → 量測 → **去背** → 裁頭肩 → 合成 → 更新頭像與 Wiki。
+
+> 找不到可用的官方圖時(沒有設定稿、只有場景截圖且去背不乾淨),
+> 才退回「依人格資料重新繪製」的向量形象(`--style portrait`)。
+
+---
+
+## 為什麼要上網找圖
+
+圖示要像這個人格,就得用他**現在**的樣子:動漫角色會換造型、換作品、換髮色。
+所以流程是「查最新登場 → 看那張圖 → 取色 → 畫」,而不是憑印象填色。
+
+## 步驟
+
+### 1. 查出「最新一次登場」
+
+用 WebSearch 確認這個角色最近的官方動向(新作、新章節、新造型)與**對應的形態名稱**
+(例:亞絲娜 → 2026《Unanswered//butterfly》重述早期艾恩葛朗特 → 對應 **SAO Avatar**;
+結衣 → 現行 Unital Ring 章 → 對應 **ALO Pixie Form**)。
+
+### 2. 找高解析度官方圖
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon search --session --wiki --page <角色頁名>
+```
+
+結果依「解析度 + 是不是官方設定稿」排序,標 📐 的是設定稿(`Full Body`/`Character Design`/
+`Avatar`)。**優先選設定稿**:
+
+- 解析度高(常見 773×1056、1280×880),遠勝角色資料庫的 230px 縮圖
+- **多半是透明底或純白底 → 去背幾乎免費、邊緣乾淨**
+- 是官方繪製的正式立繪,不是動畫截圖
+
+挑「與第 1 步的形態相符」的那一張——同一個角色會有很多套造型。
+
+### 3. 量測
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon measure --session --photo "<圖片網址>"
+```
+
+會回報解析度、找到幾張臉、臉佔長邊多少、背景是**透明/單色/有場景**、去背難不難。
+`去背難度:偏難` 就回第 2 步換一張設定稿,不要硬做。
+
+### 4. 去背
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon cutout --session --photo "<圖片網址>" [--pick <索引>]
+```
+
+三條路徑,自動選最好的那條:
+
+| 方式 | 什麼時候 | 品質 |
+| --- | --- | --- |
+| `source-alpha` | 原圖本來就是透明底(官方設定稿常見) | 完美 |
+| `plain-background` | 純白/單色底 | 很好 |
+| `grabcut` | 有場景,用臉的位置當前景種子 | 普通,邊緣可能不乾淨 |
+
+輸出到 `icon/portrait-cutout.png`。**用 Read 打開確認**:邊緣有沒有殘留、頭髮有沒有被切掉。
+黑髮角色在有場景的圖上特別容易被 GrabCut 誤切——真的不行就換設定稿。
+
+### 5. 合成圖示
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force --from-cutout [--zoom 1.85] [--pick <索引>] \
+ --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.." \
+ --source-url "<那張圖的網址>" --source-note "<作品/形態+為什麼選這張>"
+```
+
+- 自動裁成**頭肩構圖**(官方設定稿常是正反兩面並排,不裁會變成兩個人;`--zoom` 調鬆緊)。
+- 底色漸層取自 `--palette`(`light` → `accent`),所以要先看過圖再填顏色。
+- 產出 `icon.svg`(內嵌同一張 PNG,自成一體不外連)、`icon.png`,以及
+ `icon/portrait.svg`、`icon/portrait-512.png`、`icon/portrait-1024.png`。
+- **畫完用 Read 打開看一眼**,構圖不好就調 `--zoom` 或 `--face` 重來。
+
+### 6. 沒有官方圖時:向量重繪(有臉)
+
+裁出大頭照當底稿、親眼看過、讀出特徵,再重繪:
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon headshot --session --photo "<圖片網址>" --pick <索引> # 底稿,不同步
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force --palette "..." --features "hairstyle=..,eyes=..,expression=.."
+```
+
+特徵可選值:`hairstyle` straight/twintails/ponytail/bob/braid、`length` short/medium/long/very-long、
+`fringe` blunt/parted/swept/curtain、`eyes` round/almond/sharp/droopy、
+`expression` gentle/bright/calm/neutral、`accessory` none/ribbon/clip/flower/hairband、
+`side` left/right、`collar` round/v/high/sailor、`ahoge` yes/no。
+
+### 7. 回報
+
+一句話說明:用了哪部作品哪一年的視覺、取到什麼配色、圖示放哪裡。
+把來源網址附上,讓使用者可以自己核對。
+
+## 三種樣式
+
+| 樣式 | 什麼時候用 | 長什麼樣 |
+| --- | --- | --- |
+| `portrait` | 有底稿(有調色盤)→ **預設** | 依特徵重繪的人物頭像:髮型、瀏海、眼型、表情、髮飾、領口全部照特徵畫 |
+| `badge` | 完全沒有參考圖(原創人格) | 雙色漸層徽章 + 編號前兩個字母 |
+
+- 兩種樣式都由 Node 自己柵格化,SVG 與 PNG **共用同一份圖形清單**(橢圓/矩形/多邊形),
+ 兩邊必然一致;PNG 是自寫的柵格器 + zlib 編碼,不依賴任何影像函式庫。
+- 每次產生會輸出:`icon.svg`/`icon.png`(512,頭像用)+ `icon/portrait.svg`、
+ `icon/portrait-512.png`、`icon/portrait-1024.png`(高解析度),全部同步到 Wiki。
+- SVG **不會內嵌任何影像**——沒有 ``、沒有 base64、沒有外連。
+- `badge` 樣式下若兩個顏色一深一淺(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓,
+ 確保字讀得到。
+
+## 界線
+
+- **找到的圖片只能當底稿**:裁出來的大頭照留在 `.sync/`,不進圖示、不同步、不發佈。
+ 圖示的每個像素都是本工具依特徵畫出來的。
+- 底稿的來源網址會記在 `config.json`,方便日後核對,但不重製原圖。
+
+## Wiki 必須同步
+
+`icon generate` 推完 Wiki 會**自動回頭驗證**,確認 `icon.svg` 與 `icon.png` 真的在 Wiki 上
+而且與本機一致;不一致會直接報錯。也可以手動查:
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync verify --session --area wiki
+```
+
+## 邊界
+
+- 只能對「本 session 已載入的人格」產生圖示。
+- 已有圖示時要重畫必須加 `--force`。
+- 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。
+- `.sync/reference.*` 與 `.sync/headshot.png` 是底稿快取,可以安全刪除(不會同步出去)。
+- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
diff --git a/skills/persona-status/SKILL.md b/skills/persona-status/SKILL.md
index 49cc527..3982b85 100644
--- a/skills/persona-status/SKILL.md
+++ b/skills/persona-status/SKILL.md
@@ -21,9 +21,13 @@ description: 查看與管理人格的載入狀態:列出所有人格、誰被
## 常用指令
```bash
-# 全部人格 + 鎖狀態 + 記憶量
+# 全部人格 + 編號 + 鎖狀態 + 記憶量(`⚠ 尚無編號` 表示還沒同步到 Gitea)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
+# 這個人格的編號與 Gitea 存取庫
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" code show --session
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync status --session
+
# 單一人格的鎖細節(owner session/cwd/心跳、guest 名單、情緒)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" status --persona
diff --git a/skills/persona-sync/SKILL.md b/skills/persona-sync/SKILL.md
new file mode 100644
index 0000000..3119665
--- /dev/null
+++ b/skills/persona-sync/SKILL.md
@@ -0,0 +1,112 @@
+---
+name: persona-sync
+description: 人格編號與 Gitea 儲存:指派人格編號(英文名全大寫+索引,例如 ASUNA-01)、在 Gitea 為每個人格開一個以編號命名的私有存取庫、把高頻的活狀態同步到檔案區、把低頻的身分與長期記憶同步到 Wiki 區,以及把既有人格遷移上去。當使用者說要把人格存到 Gitea、備份或同步人格、在另一台機器接續同一個人格、問人格的編號是什麼、想看人格在 Gitea 上的存取庫、或同步出現衝突時觸發。不適用於:建立新人格(persona-create,它會自動開存取庫)、離線的單檔匯出匯入(persona-transfer)、記憶固化(persona-memory)。
+---
+
+# 🔗 persona-sync — 人格編號與 Gitea 儲存
+
+**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(一律帶 `--session `)
+
+---
+
+## 人格編號
+
+**編號 = 英文名全大寫 + 兩位索引**,同名才遞增:
+
+```
+亞絲娜(第一個) → ASUNA-01
+結衣 → YUI-01
+另一個亞絲娜 → ASUNA-02
+```
+
+- 編號**就是 Gitea 存取庫的名稱**,也是新人格的本機目錄名。
+- 中文/日文名字要先轉**羅馬拼音**:由你提議拼法(`亞絲娜 → Asuna`、`沈宇 → Shen Yu`),
+ **拿給使用者確認再送出**——拼錯了會變成一個很難改的編號。
+- 查下一個可用編號:`code next --romaji Asuna --session `。
+
+## 資料放哪裡(依更新頻率切)
+
+| 區 | 放什麼 | 什麼時候 push |
+| --- | --- | --- |
+| **檔案區**(主存取庫) | 高頻活狀態:`state/emotion.json`、`short-term.jsonl`、`inner.jsonl`(心裡話)、`said.jsonl`、`inbox/`、`mindmap/threads/`、`journal/` | 每輪對話後由 `Stop` hook 背景推送(有最小間隔) |
+| **Wiki 區** | 低頻設定:`IDENTITY`/`SOUL`/`AGENTS`/`USER`、**人格圖示 `icon.svg`/`icon.png`**、長期記憶、`INDEX`、心智圖、人際關係圖 | 記憶固化、改身分、改關係圖、`release` 時 |
+
+Wiki 是給人讀的設定百科:Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以
+`memory/long-term/xxx.md` 會攤平成 `Memory-xxx.md`(頁面顯示為「Memory xxx」),
+原始路徑記在 `_paths.json`,拉回來時自動還原。Home 頁會自動列出所有長期記憶的連結。
+
+**本機永遠是工作副本**:hook 每輪讀寫的是本機檔案,不經過網路。Gitea 掛掉、離線、沒設 token,
+人格照樣能聊天,只是不同步——**同步失敗永遠不阻斷對話**。
+
+人格圖示(`/jsc-persona:persona-create` 或 `icon generate` 產生)除了同步到 Wiki 區,
+也會被設成**存取庫頭像**,Gitea 的清單上就看得到每個人格的臉。
+
+## 設定
+
+```bash
+export GITEA_HOST=https://gitea.example.com # 或 PERSONA_GITEA_HOST
+export GITEA_TOKEN=<個人存取權杖> # 或 PERSONA_GITEA_TOKEN
+export PERSONA_GITEA_OWNER=<帳號或組織> # 選填,預設是 token 本人的帳號
+export PERSONA_GITEA=off # 需要時整個關掉
+export PERSONA_SYNC_MIN_SECONDS=60 # 檔案區背景 push 的最小間隔
+```
+
+存取庫**預設私有**(人格裡有使用者的個人記憶)。要公開必須是使用者明講,才加 `--public`。
+
+## 常用流程
+
+```bash
+# 現在的同步狀態(編號、存取庫、兩區的最後 push/pull)
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync status --session
+
+# 手動推送(--area files|wiki|all)
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync push --session --area all
+
+# 從 Gitea 拉回(載入人格時會自動做一次)
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session
+```
+
+### 把既有人格遷移上去
+
+既有人格(還沒有編號的)要兩步:
+
+```bash
+# 1) 載入它(同步只能動本 session 載入的那個人格)
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona <舊 slug> --session
+
+# 2) 指派編號 + 開存取庫 + 首次推送;--rename 連本機目錄名也改成編號
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" code assign \
+ --session --romaji "<英文名>" --rename
+```
+
+`--rename` 之後這個人格就用編號稱呼(`--persona ASUNA-01`)。不加 `--rename` 也可以,
+目錄名維持原樣、只是多了一個編號與對應的存取庫。**遷移前先跟使用者確認**:
+這會把他的個人記憶送上 Gitea(私有庫,但仍是上傳)。
+
+### 在另一台機器接續同一個人格
+
+在新機器上設好 `GITEA_HOST`/`GITEA_TOKEN`,然後:
+
+```bash
+node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session
+```
+
+(人格目錄還不存在的話,先用 `/jsc-persona:persona-transfer` 匯入一份,或手動 clone 存取庫。)
+
+## 衝突
+
+兩台機器都改過同一個檔案時,`load` 與 `sync pull` 會**停下來不覆蓋本機**,並列出衝突的檔案。
+這時候:
+
+1. 用一句話告訴使用者哪幾個檔案兩邊都動過。
+2. 問他要哪一邊:
+ - 以 **Gitea** 為準 → `sync pull --force`(本機那份會被覆蓋)
+ - 以 **本機** 為準 → `sync push`(會蓋掉遠端)
+3. **不要自己選**。記憶被蓋掉是不可逆的。
+
+## 邊界
+
+- 只能同步「本 session 目前載入的人格」——跨人格同步等於跨人格讀取,會被 hook 擋下。
+- guest(`persona-guest` sub agent)不能同步,它對人格檔案唯讀。
+- `.sync/` 是兩區的 git clone 快取,**不要手動編輯**;砍掉它不會掉資料,下次 push 會重新 clone。
+- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。