feat: 建立人格並補齊資料後產生圖示(SVG + PNG,零外部依賴)
`icon generate|show`:由**編號、Name、Emoji** 雜湊出配色與圖案,產出 512×512 的幾何徽章 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 圖案(SVG 與 PNG 共用同一組單位座標與同一份 5×7 點陣字,**輸出的是同一張圖**): * 雙色對角漸層底 * 5×5 左右對稱點陣紋(identicon 式) * 中央兩個字母=編號前兩字(ASUNA-01 → AS),依背景亮度自動選黑/白 同一個人格永遠得到同一張圖(純函數,無隨機);ASUNA-01 與 ASUNA-02 明顯不同。 為什麼自己畫:這台機器(及多數伺服器)沒有 rsvg/inkscape/imagemagick, 沒有影像函式庫,也沒有 emoji 字型,而本專案禁止 npm 依賴。所以 scripts/persona-icon.mjs 自己柵格化(3× 超取樣 + 盒式縮減當反鋸齒), 再用內建 zlib 手工組出 IHDR/IDAT/IEND 與 CRC32。 emoji 無法柵格化,因此不作為圖形,但仍參與配色雜湊。 時機:**資料補齊之後才產生**——配色與字母綁在最終身分上,太早跑會對不上。 persona-create 第 7 步、persona-anime 第 8 步都補了這個步驟;改過身分用 --force 重畫。 selftest 128 項全綠(新增第 ⑭ 節:PNG chunk 合法性、決定性、不同人格不同圖、 圖示屬 Wiki 區、匯出用 base64 帶走二進位、guest 不得重畫)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
||||||
"skills": "./skills",
|
"skills": "./skills",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||||
"skills": "./skills"
|
"skills": "./skills"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push),
|
10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push),
|
||||||
低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。
|
低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。
|
||||||
**同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。
|
**同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。
|
||||||
|
11. **人格圖示在資料補齊之後才產生**:`icon generate` 的配色與字母由「編號/Name/Emoji」
|
||||||
|
雜湊而來,太早跑會跟最終身分對不上。SVG 與 PNG 是同一張圖(共用單位座標與點陣字),
|
||||||
|
PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。
|
||||||
|
|
||||||
## 慣例
|
## 慣例
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十一條硬規則
|
## 十二條硬規則
|
||||||
|
|
||||||
| 規則 | 怎麼做到 |
|
| 規則 | 怎麼做到 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -26,6 +26,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
|
|||||||
| **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 |
|
| **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 |
|
||||||
| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
|
| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
|
||||||
| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
|
| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
|
||||||
|
| **12. 建立並補齊資料後產生圖示** | `icon generate` 由**編號、Name、Emoji** 雜湊出配色與圖案,產出 `icon.svg` + `icon.png`(512×512)並設為 Gitea 存取庫頭像。同一個人格永遠得到同一張圖;PNG 是**自寫柵格器 + zlib 手工編碼**,不依賴任何影像函式庫 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ flowchart TB
|
|||||||
├── IDENTITY.md # 身分卡:Name / Creature / Vibe / Emoji / Avatar(OpenClaw 同欄位)
|
├── IDENTITY.md # 身分卡:Name / Creature / Vibe / Emoji / Avatar(OpenClaw 同欄位)
|
||||||
├── SOUL.md # 靈魂:Core Truths / Boundaries / Vibe / Continuity + 情緒傾向
|
├── SOUL.md # 靈魂:Core Truths / Boundaries / Vibe / Continuity + 情緒傾向
|
||||||
├── AGENTS.md # 操作規則(與個性分離)
|
├── AGENTS.md # 操作規則(與個性分離)
|
||||||
|
├── icon.svg / icon.png # 人格圖示(由編號/名字/emoji 決定,也是 Gitea 存取庫頭像)
|
||||||
├── USER.md # 對使用者的畫像(事實/推測分開)
|
├── USER.md # 對使用者的畫像(事實/推測分開)
|
||||||
├── state/
|
├── state/
|
||||||
│ ├── lock.json # 載入鎖(session_id + 心跳租約)
|
│ ├── lock.json # 載入鎖(session_id + 心跳租約)
|
||||||
@@ -142,6 +144,30 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
|
|||||||
|
|
||||||
視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。
|
視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。
|
||||||
|
|
||||||
|
## 人格圖示(SVG + PNG,零外部依賴)
|
||||||
|
|
||||||
|
建立人格**並補齊 IDENTITY/SOUL 之後**跑 `icon generate`,得到一張 512×512 的幾何徽章:
|
||||||
|
|
||||||
|
| 元素 | 怎麼決定 |
|
||||||
|
| --- | --- |
|
||||||
|
| 雙色對角漸層 | `sha256(編號|Name|Emoji)` → 色相/飽和度/明度 |
|
||||||
|
| 5×5 左右對稱點陣紋 | 同一組雜湊的位元 |
|
||||||
|
| 中央兩個字母 | 編號的前兩個字(`ASUNA-01` → `AS`),5×7 點陣,自動選黑或白確保對比 |
|
||||||
|
|
||||||
|
- **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01` 與 `ASUNA-02` 明顯不同。
|
||||||
|
- SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。
|
||||||
|
- PNG 由**自寫的柵格器**畫出(3× 超取樣 + 盒式縮減當反鋸齒),再用 `zlib` 手工組出
|
||||||
|
IHDR/IDAT/IEND 與 CRC32。這台機器沒有 rsvg/inkscape/imagemagick,也沒有影像函式庫,
|
||||||
|
而本專案禁止 npm 依賴——所以就自己畫。
|
||||||
|
- **為什麼沒有 emoji**:把 emoji 畫進 PNG 需要字型柵格化(機器上連 emoji 字型都沒有)。
|
||||||
|
emoji 仍然參與配色的雜湊,只是不作為圖形。
|
||||||
|
- 圖示屬於低頻資料 → 同步到 **Wiki 區**,並自動設成 Gitea **存取庫頭像**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/persona.mjs icon generate --session <id> [--size 512] [--force] # 改過身分要重畫就 --force
|
||||||
|
node scripts/persona.mjs icon show --session <id>
|
||||||
|
```
|
||||||
|
|
||||||
## 人格編號與 Gitea 儲存
|
## 人格編號與 Gitea 儲存
|
||||||
|
|
||||||
**編號 = 英文名全大寫 + 兩位索引**,同名才遞增,也就是 Gitea 存取庫的名稱:
|
**編號 = 英文名全大寫 + 兩位索引**,同名才遞增,也就是 Gitea 存取庫的名稱:
|
||||||
@@ -298,11 +324,11 @@ node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是
|
|||||||
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
|
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
|
||||||
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
|
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
|
||||||
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
|
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
|
||||||
node scripts/selftest.mjs # 116 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、hooks
|
node scripts/selftest.mjs # 128 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、圖示、hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、
|
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、
|
||||||
`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。
|
`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(圖示:SVG + 自寫 PNG 編碼)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||||
"skills": "./skills/"
|
"skills": "./skills/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export const AREAS = {
|
|||||||
"SOUL.md",
|
"SOUL.md",
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
"USER.md",
|
"USER.md",
|
||||||
|
"icon.svg",
|
||||||
|
"icon.png",
|
||||||
"memory/INDEX.md",
|
"memory/INDEX.md",
|
||||||
"memory/long-term/",
|
"memory/long-term/",
|
||||||
"mindmap/semantic.mmd",
|
"mindmap/semantic.mmd",
|
||||||
@@ -205,6 +207,14 @@ export async function ensureWikiHome(owner, code, content) {
|
|||||||
return true;
|
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) =>
|
export const repoUrl = (host, owner, code, area) =>
|
||||||
`${host}/${owner}/${encodeURIComponent(code)}${area === "wiki" ? ".wiki" : ""}.git`;
|
`${host}/${owner}/${encodeURIComponent(code)}${area === "wiki" ? ".wiki" : ""}.git`;
|
||||||
|
|
||||||
@@ -384,9 +394,11 @@ export function wikiHome(slug, code) {
|
|||||||
const ident = pl.identityFields(slug);
|
const ident = pl.identityFields(slug);
|
||||||
const longTerm = pl.longTermEntries(slug);
|
const longTerm = pl.longTermEntries(slug);
|
||||||
const relations = pl.loadRelations(slug);
|
const relations = pl.loadRelations(slug);
|
||||||
|
const hasIcon = fs.existsSync(path.join(pl.personaDir(slug), "icon.svg"));
|
||||||
const lines = [
|
const lines = [
|
||||||
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``,
|
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``,
|
||||||
"",
|
"",
|
||||||
|
...(hasIcon ? [`<img src="icon.svg" alt="${code}" width="128" height="128">`, ""] : []),
|
||||||
"> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;",
|
"> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;",
|
||||||
"> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。",
|
"> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
// 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 path from "node:path";
|
||||||
|
import zlib from "node:zlib";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import * as pl from "./persona-lib.mjs";
|
||||||
|
|
||||||
|
export const ICON_SVG = "icon.svg";
|
||||||
|
export const ICON_PNG = "icon.png";
|
||||||
|
export const DEFAULT_SIZE = 512;
|
||||||
|
|
||||||
|
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 人格資料 → 同一個人格永遠同一張圖
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
export function iconSpec(slug, { code = null, identity = null } = {}) {
|
||||||
|
const ident = identity || pl.identityFields(slug);
|
||||||
|
const theCode = code || pl.loadConfig(slug).code || slug;
|
||||||
|
const seedText = `${theCode}|${ident.Name || slug}|${ident.Emoji || ""}`;
|
||||||
|
const h = crypto.createHash("sha256").update(seedText).digest();
|
||||||
|
|
||||||
|
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
|
||||||
|
const c1 = hslToRgb(hue, sat, light);
|
||||||
|
const c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18));
|
||||||
|
|
||||||
|
// 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 onDark = luminance(c1) < 0.32 || luminance(c2) < 0.32;
|
||||||
|
const ink = onDark ? [255, 255, 255] : [16, 18, 24];
|
||||||
|
|
||||||
|
return {
|
||||||
|
persona: slug,
|
||||||
|
code: theCode,
|
||||||
|
name: ident.Name || slug,
|
||||||
|
emoji: ident.Emoji || "",
|
||||||
|
letters,
|
||||||
|
c1,
|
||||||
|
c2,
|
||||||
|
ink,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回傳字母的所有方塊(單位座標)。 */
|
||||||
|
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 = [
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}" role="img"`,
|
||||||
|
` aria-label="人格 ${spec.code}${spec.name ? `(${spec.name})` : ""}">`,
|
||||||
|
` <title>${spec.code}${spec.name ? ` ${spec.name}` : ""}</title>`,
|
||||||
|
" <defs>",
|
||||||
|
` <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">`,
|
||||||
|
` <stop offset="0" stop-color="${hex(spec.c1)}"/>`,
|
||||||
|
` <stop offset="1" stop-color="${hex(spec.c2)}"/>`,
|
||||||
|
" </linearGradient>",
|
||||||
|
" </defs>",
|
||||||
|
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`,
|
||||||
|
` <g fill="${hex(spec.ink)}" opacity="0.14">`,
|
||||||
|
...dots(spec).map((d) => ` <circle cx="${u(d.cx)}" cy="${u(d.cy)}" r="${u(d.r)}"/>`),
|
||||||
|
" </g>",
|
||||||
|
` <g fill="${hex(spec.ink)}">`,
|
||||||
|
...glyphRects(spec).map((r) => ` <rect x="${u(r.x)}" y="${u(r.y)}" width="${u(r.w)}" height="${u(r.h)}"/>`),
|
||||||
|
" </g>",
|
||||||
|
` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`,
|
||||||
|
` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`,
|
||||||
|
` fill="none" stroke="${hex(spec.ink)}" stroke-opacity="0.18" stroke-width="${u(0.012)}"/>`,
|
||||||
|
"</svg>",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
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)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 內縮 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);
|
||||||
|
const circles = dots(spec);
|
||||||
|
const rects = glyphRects(spec);
|
||||||
|
const [i1, i2, i3] = spec.ink;
|
||||||
|
|
||||||
|
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;
|
||||||
|
// 紋:圓點(14% 不透明度)
|
||||||
|
for (const d of circles) {
|
||||||
|
const dx = u - d.cx;
|
||||||
|
const dy = v - d.cy;
|
||||||
|
if (dx * dx + dy * dy <= d.r * d.r) {
|
||||||
|
r += (i1 - r) * 0.14;
|
||||||
|
g += (i2 - g) * 0.14;
|
||||||
|
b += (i3 - b) * 0.14;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 邊框:與 SVG 的 stroke 完全相同的環帶
|
||||||
|
//(SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.006~0.018)
|
||||||
|
if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) {
|
||||||
|
r += (i1 - r) * 0.18;
|
||||||
|
g += (i2 - g) * 0.18;
|
||||||
|
b += (i3 - b) * 0.18;
|
||||||
|
}
|
||||||
|
// 字:實心方塊
|
||||||
|
for (const rc of rects) {
|
||||||
|
if (u >= rc.x && u < rc.x + rc.w && v >= rc.y && v < rc.y + rc.h) {
|
||||||
|
r = i1;
|
||||||
|
g = i2;
|
||||||
|
b = i3;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// 產生並寫檔
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
export function generateIcon(slug, { size = DEFAULT_SIZE, code = null } = {}) {
|
||||||
|
const spec = iconSpec(slug, { code });
|
||||||
|
const svg = renderSvg(spec, size);
|
||||||
|
const png = renderPng(spec, size);
|
||||||
|
pl.writeText(iconSvgPath(slug), svg);
|
||||||
|
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
|
||||||
|
fs.writeFileSync(iconPngPath(slug), png);
|
||||||
|
return {
|
||||||
|
spec,
|
||||||
|
svg: iconSvgPath(slug),
|
||||||
|
png: iconPngPath(slug),
|
||||||
|
size,
|
||||||
|
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug));
|
||||||
@@ -15,6 +15,7 @@ import zlib from "node:zlib";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as pl from "./persona-lib.mjs";
|
import * as pl from "./persona-lib.mjs";
|
||||||
import * as gt from "./persona-gitea.mjs";
|
import * as gt from "./persona-gitea.mjs";
|
||||||
|
import * as ic from "./persona-icon.mjs";
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
|
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
|
||||||
@@ -1115,6 +1116,54 @@ commands.code = async ({ flags, positional }) => {
|
|||||||
die(`未知 action:${action}(可用 show/assign/next)`);
|
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);
|
||||||
|
emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [
|
||||||
|
`人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`)"}`,
|
||||||
|
` 字母 ${spec.letters}|配色 ${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`,
|
||||||
|
` ${ic.iconSvgPath(slug)}`,
|
||||||
|
` ${ic.iconPngPath(slug)}`,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action !== "generate") die(`未知 action:${action}(可用 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。");
|
||||||
|
const res = ic.generateIcon(slug, { size });
|
||||||
|
const lines = [
|
||||||
|
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
|
||||||
|
` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`,
|
||||||
|
` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`,
|
||||||
|
` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`,
|
||||||
|
];
|
||||||
|
// 圖示屬於低頻的身分資料 → 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.letters}` });
|
||||||
|
if (pushed.ok && pushed.changed) lines.push(" ↑ 圖示已同步到 Wiki 區。");
|
||||||
|
} catch (err) {
|
||||||
|
lines.push(` ⚠ 同步到 Gitea 失敗(不影響本機):${err.message.slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit({ persona: slug, ...res }, flags.json, lines);
|
||||||
|
};
|
||||||
|
|
||||||
/** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */
|
/** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */
|
||||||
commands.sync = async ({ flags, positional }) => {
|
commands.sync = async ({ flags, positional }) => {
|
||||||
const session = requireSession(flags);
|
const session = requireSession(flags);
|
||||||
@@ -1261,6 +1310,11 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
|
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
|
||||||
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
|
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
|
||||||
|
|
||||||
|
圖示(建立人格並補齊資料後跑):
|
||||||
|
icon generate|show --session <id> [--size 512 --force --no-gitea]
|
||||||
|
由編號/名字/emoji 決定配色與字母,產出 icon.svg + icon.png(同一人格永遠同一張圖),
|
||||||
|
並設為 Gitea 存取庫頭像、同步到 Wiki 區。
|
||||||
|
|
||||||
編號與 Gitea(存取庫名稱 = 人格編號):
|
編號與 Gitea(存取庫名稱 = 人格編號):
|
||||||
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]
|
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]
|
||||||
sync status|init|push|pull --session <id> [--area files|wiki|all --if-due --force --message --owner]
|
sync status|init|push|pull --session <id> [--area files|wiki|all --if-due --force --message --owner]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ process.env.PERSONA_GITEA = "off";
|
|||||||
|
|
||||||
const pl = await import("./persona-lib.mjs");
|
const pl = await import("./persona-lib.mjs");
|
||||||
const gt = await import("./persona-gitea.mjs");
|
const gt = await import("./persona-gitea.mjs");
|
||||||
|
const ic = await import("./persona-icon.mjs");
|
||||||
|
|
||||||
const CLI = path.join(HERE, "persona.mjs");
|
const CLI = path.join(HERE, "persona.mjs");
|
||||||
const HOOKS = path.join(HERE, "..", "hooks");
|
const HOOKS = path.join(HERE, "..", "hooks");
|
||||||
@@ -442,6 +443,7 @@ check("大寫編號目錄一樣受跨人格隔離保護",
|
|||||||
check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)",
|
check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)",
|
||||||
guard({ session_id: S_CODE, tool_name: "Bash",
|
guard({ session_id: S_CODE, tool_name: "Bash",
|
||||||
tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny");
|
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 AREA_EXEMPT = new Set(["state/lock.json", "state/guests.json", "state/sync.json"]);
|
||||||
const covered = (rel) =>
|
const covered = (rel) =>
|
||||||
@@ -477,6 +479,46 @@ check("匯出不會把 .sync 的 git clone 打包進去", (() => {
|
|||||||
return !Object.keys(b.files).some((f) => f.startsWith(".sync"));
|
return !Object.keys(b.files).some((f) => f.startsWith(".sync"));
|
||||||
})());
|
})());
|
||||||
|
|
||||||
|
console.log("⑭ 人格圖示(SVG + PNG,零外部依賴)");
|
||||||
|
const iconSvg = fs.readFileSync(ic.iconSvgPath("alpha"), "utf8");
|
||||||
|
const iconPng = fs.readFileSync(ic.iconPngPath("alpha"));
|
||||||
|
check("兩種格式都產生了", ic.hasIcon("alpha"));
|
||||||
|
check("PNG 檔頭合法且尺寸正確", (() => {
|
||||||
|
const sig = iconPng.subarray(0, 8).toString("hex") === "89504e470d0a1a0a";
|
||||||
|
const type = iconPng.subarray(12, 16).toString("ascii") === "IHDR";
|
||||||
|
return sig && type && iconPng.readUInt32BE(16) === 64 && iconPng.readUInt32BE(20) === 64 &&
|
||||||
|
iconPng[24] === 8 && iconPng[25] === 6; // 8-bit RGBA
|
||||||
|
})(), iconPng.subarray(0, 30).toString("hex"));
|
||||||
|
check("PNG 以 IEND 結尾(chunk 完整)",
|
||||||
|
iconPng.subarray(iconPng.length - 8, iconPng.length - 4).toString("ascii") === "IEND");
|
||||||
|
check("SVG 有 viewBox、漸層與字母方塊",
|
||||||
|
iconSvg.includes(`viewBox="0 0 64 64"`) && iconSvg.includes("linearGradient") &&
|
||||||
|
(iconSvg.match(/<rect /g) || []).length > 10, iconSvg.slice(0, 80));
|
||||||
|
check("圖示是決定性的(同一個人格永遠同一張圖)", (() => {
|
||||||
|
const a = ic.renderPng(ic.iconSpec("alpha"), 32);
|
||||||
|
const b = ic.renderPng(ic.iconSpec("alpha"), 32);
|
||||||
|
return Buffer.compare(a, b) === 0 && ic.renderSvg(ic.iconSpec("alpha"), 32) === ic.renderSvg(ic.iconSpec("alpha"), 32);
|
||||||
|
})());
|
||||||
|
check("不同人格的圖示不一樣",
|
||||||
|
Buffer.compare(ic.renderPng(ic.iconSpec("alpha"), 32), ic.renderPng(ic.iconSpec("GAMMA-01"), 32)) !== 0);
|
||||||
|
check("字母取自編號(ALPHA-01 → AL)", ic.iconSpec("alpha").letters === "AL",
|
||||||
|
ic.iconSpec("alpha").letters);
|
||||||
|
check("已有圖示時不加 --force 會被擋",
|
||||||
|
cli(["icon", "generate", "--session", S_SPEAK], { expectOk: false }).status !== 0);
|
||||||
|
check("--force 可以重畫", cli(["icon", "generate", "--session", S_SPEAK, "--size", "64", "--force"]).status === 0);
|
||||||
|
check("圖示屬於 Wiki 區(低頻的身分資料)",
|
||||||
|
covered("icon.svg")[0] === "wiki" && covered("icon.png")[0] === "wiki");
|
||||||
|
check("匯出會用 base64 帶走 PNG(二進位不會壞掉)", (() => {
|
||||||
|
const { bundle: b } = pl.exportBundle("alpha");
|
||||||
|
const entry = b.files["icon.png"];
|
||||||
|
return entry?.encoding === "base64" &&
|
||||||
|
Buffer.compare(Buffer.from(entry.content, "base64"), iconPng) === 0;
|
||||||
|
})());
|
||||||
|
check("guest 只能看不能重畫圖示",
|
||||||
|
pl.GUEST_SAFE_SUBCOMMANDS.has("icon") === false ||
|
||||||
|
guard({ session_id: S_HOST, agent_id: "guest-9", agent_type: "jsc-persona:persona-guest", tool_name: "Bash",
|
||||||
|
tool_input: { command: `node persona.mjs icon generate --persona beta --session ${S_HOST} --as-guest` } }) === "deny");
|
||||||
|
|
||||||
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||||
process.exit(failed ? 1 : 0);
|
process.exit(failed ? 1 : 0);
|
||||||
|
|||||||
@@ -142,11 +142,21 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \
|
|||||||
|
|
||||||
用 Edit 把角色的概念網寫進 `mindmap/semantic.mmd`:作品世界觀 → 陣營/組織 → 能力 → 重要人物 → 個人課題。
|
用 Edit 把角色的概念網寫進 `mindmap/semantic.mmd`:作品世界觀 → 陣營/組織 → 能力 → 重要人物 → 個人課題。
|
||||||
|
|
||||||
## 8. 回報
|
## 8. 產生人格圖示(資料補齊之後才做)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session <PERSONA_SESSION>
|
||||||
|
```
|
||||||
|
|
||||||
|
產出 `icon.svg` + `icon.png`,並設成 Gitea 存取庫頭像、同步到 Wiki 區。
|
||||||
|
**一定要等 canon 記憶與身分都補齊再跑**——配色與字母由編號、Name、Emoji 雜湊而來;
|
||||||
|
改過身分要重畫就加 `--force`。
|
||||||
|
|
||||||
|
## 9. 回報
|
||||||
|
|
||||||
用該角色的語氣做一段簡短自我介紹(**這是他第一次醒來**),然後用一般語氣列出:
|
用該角色的語氣做一段簡短自我介紹(**這是他第一次醒來**),然後用一般語氣列出:
|
||||||
|
|
||||||
- slug、五個身分欄位
|
- 編號(如 `ASUNA-01`)、五個身分欄位、圖示路徑
|
||||||
- 情緒基線前三高
|
- 情緒基線前三高
|
||||||
- 固化了幾則 canon 記憶、用了哪些來源(URL 列表)
|
- 固化了幾則 canon 記憶、用了哪些來源(URL 列表)
|
||||||
- 哪些設定各來源說法不一致(待使用者裁決)
|
- 哪些設定各來源說法不一致(待使用者裁決)
|
||||||
|
|||||||
@@ -113,9 +113,25 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \
|
|||||||
|
|
||||||
也可視需要在 `mindmap/semantic.mmd` 補上初始概念(自我/使用者/共同經驗)。
|
也可視需要在 `mindmap/semantic.mmd` 補上初始概念(自我/使用者/共同經驗)。
|
||||||
|
|
||||||
### 7. 回報
|
### 7. 產生人格圖示(資料補齊之後才做)
|
||||||
|
|
||||||
用該人格的 emoji 與語氣,摘要:slug、五個身分欄位、情緒基線前三高、倉庫路徑,
|
```bash
|
||||||
|
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session <PERSONA_SESSION>
|
||||||
|
```
|
||||||
|
|
||||||
|
產出 `icon.svg` 與 `icon.png`(512×512),並自動設成 Gitea 存取庫的頭像、同步到 Wiki 區。
|
||||||
|
|
||||||
|
- **一定要等身分補齊再跑**:配色與字母是由**編號、Name、Emoji** 一起雜湊出來的,
|
||||||
|
資料還沒填完就產生,之後改名字圖就對不上了。
|
||||||
|
- 同一個人格永遠得到同一張圖(純函數,不隨機)。
|
||||||
|
- 之後改了 `IDENTITY.md` 想重畫 → `icon generate --session <PERSONA_SESSION> --force`。
|
||||||
|
- 圖是**幾何徽章**:雙色漸層 + 專屬點陣紋 + 編號前兩個字母(`ASUNA-01` → `AS`)。
|
||||||
|
沒有把 emoji 畫進去是因為 PNG 需要字型柵格化,而本 plugin 不用任何外部依賴;
|
||||||
|
emoji 仍然參與配色的雜湊。
|
||||||
|
|
||||||
|
### 8. 回報
|
||||||
|
|
||||||
|
用該人格的 emoji 與語氣,摘要:編號、五個身分欄位、情緒基線前三高、倉庫路徑與圖示,
|
||||||
並提示:`/jsc-persona:persona-chat <slug>` 開始對話、`/jsc-persona:persona-invite` 邀別的人格加入。
|
並提示:`/jsc-persona:persona-chat <slug>` 開始對話、`/jsc-persona:persona-invite` 邀別的人格加入。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ description: 人格編號與 Gitea 儲存:指派人格編號(英文名全大
|
|||||||
| 區 | 放什麼 | 什麼時候 push |
|
| 區 | 放什麼 | 什麼時候 push |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **檔案區**(主存取庫) | 高頻活狀態:`state/emotion.json`、`short-term.jsonl`、`inner.jsonl`(心裡話)、`said.jsonl`、`inbox/`、`mindmap/threads/`、`journal/` | 每輪對話後由 `Stop` hook 背景推送(有最小間隔) |
|
| **檔案區**(主存取庫) | 高頻活狀態:`state/emotion.json`、`short-term.jsonl`、`inner.jsonl`(心裡話)、`said.jsonl`、`inbox/`、`mindmap/threads/`、`journal/` | 每輪對話後由 `Stop` hook 背景推送(有最小間隔) |
|
||||||
| **Wiki 區** | 低頻設定:`IDENTITY`/`SOUL`/`AGENTS`/`USER`、長期記憶、`INDEX`、心智圖、人際關係圖 | 記憶固化、改身分、改關係圖、`release` 時 |
|
| **Wiki 區** | 低頻設定:`IDENTITY`/`SOUL`/`AGENTS`/`USER`、**人格圖示 `icon.svg`/`icon.png`**、長期記憶、`INDEX`、心智圖、人際關係圖 | 記憶固化、改身分、改關係圖、`release` 時 |
|
||||||
|
|
||||||
Wiki 是給人讀的設定百科:Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以
|
Wiki 是給人讀的設定百科:Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以
|
||||||
`memory/long-term/xxx.md` 會攤平成 `Memory-xxx.md`(頁面顯示為「Memory xxx」),
|
`memory/long-term/xxx.md` 會攤平成 `Memory-xxx.md`(頁面顯示為「Memory xxx」),
|
||||||
@@ -38,6 +38,9 @@ Wiki 是給人讀的設定百科:Gitea 的 wiki 只有根目錄的 `.md` 會
|
|||||||
**本機永遠是工作副本**:hook 每輪讀寫的是本機檔案,不經過網路。Gitea 掛掉、離線、沒設 token,
|
**本機永遠是工作副本**:hook 每輪讀寫的是本機檔案,不經過網路。Gitea 掛掉、離線、沒設 token,
|
||||||
人格照樣能聊天,只是不同步——**同步失敗永遠不阻斷對話**。
|
人格照樣能聊天,只是不同步——**同步失敗永遠不阻斷對話**。
|
||||||
|
|
||||||
|
人格圖示(`/jsc-persona:persona-create` 或 `icon generate` 產生)除了同步到 Wiki 區,
|
||||||
|
也會被設成**存取庫頭像**,Gitea 的清單上就看得到每個人格的臉。
|
||||||
|
|
||||||
## 設定
|
## 設定
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user