From e986686d0e3ce3541a1bb86a00ca786bac1da32e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 01:49:27 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=BA=E6=A0=BC=E7=B7=A8=E8=99=9F?= =?UTF-8?q?=EF=BC=88ASUNA-01=EF=BC=89=E8=88=87=20Gitea=20=E5=84=B2?= =?UTF-8?q?=E5=AD=98=EF=BC=8C=E4=BE=9D=E6=9B=B4=E6=96=B0=E9=A0=BB=E7=8E=87?= =?UTF-8?q?=E5=88=86=E6=AA=94=E6=A1=88=E5=8D=80/Wiki=20=E5=8D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 人格編號 - 編號 = 英文名全大寫 + 兩位索引,同名才遞增:ASUNA-01 / YUI-01 / ASUNA-02。 - 編號同時是新人格的本機目錄名與 Gitea 存取庫名稱(`create` 不給 --persona 就用編號當目錄名;既有小寫 slug 仍然合法,不會被鎖在門外)。 - 中文名由 skill 提議羅馬拼音、使用者確認後帶 `--romaji` 進來; CLI 只吃拉丁字母,避免拼音在程式裡亂猜。 - 新增 `code show|assign|next`;`code assign --rename` 可把既有人格的 目錄名一併改成編號(會轉移鎖與 session 綁定)。 2. Gitea 儲存(scripts/persona-gitea.mjs) - 每個人格一個私有存取庫,庫名 = 編號。本機仍是工作副本,hook 每輪 讀寫本機檔案,**同步失敗永遠不阻斷對話**。 - 依更新頻率分區: 檔案區(高頻)emotion / short-term / inner / said / inbox / mindmap threads / journal → 每輪由 Stop hook 背景 push Wiki 區(低頻)IDENTITY SOUL AGENTS USER / 長期記憶 / INDEX / 心智圖 / 關係圖 → 固化、改身分關係、release 時 push - Gitea 的 wiki 只有根目錄 .md 會變成頁面(1.27 實測子目錄頁面 404), 所以 Wiki 區攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json`, pull 時還原;Home 頁自動列出所有長期記憶連結。 - 新增 `sync status|init|push|pull`;載入時先 pull,兩邊都改過同一個 檔案就停下來不覆蓋本機,交由使用者決定。 - push 遇到 non-fast-forward 會對齊遠端後把工作副本重新疊上去再推。 - 認證走 http.extraHeader(GIT_CONFIG_* 環境變數),token 不寫進 .git/config 也不進 process 參數。 3. 順帶修正 - guard 的 `--persona` 解析原本只吃小寫,大寫編號會漏掉整個跨人格檢查。 - pull 原本會無差別覆蓋本機檔案,本機較新但還沒 push 的內容會被蓋掉; 改成只寫回「遠端真的改過的」與「本機缺少的」。 - `create` 原本吞掉 Gitea 首次推送的結果,失敗是靜默的。 新增 skill:persona-sync(編號、同步、既有人格遷移、衝突處理)。 selftest 116 項全綠(新增 ⑬,含「分區不重不漏」檢查;測試強制 PERSONA_GITEA=off)。 另對真實 Gitea 跑過端對端(建庫→兩區推送→改動→拉回→release),測試用存取庫已刪除。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 8 + README.md | 70 +++- hooks/turn_end.mjs | 10 + plugin.json | 2 +- scripts/persona-gitea.mjs | 585 +++++++++++++++++++++++++++++++++ scripts/persona-lib.mjs | 7 +- scripts/persona.mjs | 285 +++++++++++++++- scripts/selftest.mjs | 71 ++++ skills/persona-anime/SKILL.md | 6 +- skills/persona-create/SKILL.md | 11 +- skills/persona-status/SKILL.md | 6 +- skills/persona-sync/SKILL.md | 109 ++++++ 14 files changed, 1145 insertions(+), 29 deletions(-) create mode 100644 scripts/persona-gitea.mjs create mode 100644 skills/persona-sync/SKILL.md 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..416c696 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,11 @@ 這三條在劇場模式一樣生效。 8. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 其他人格一律 deny——匯出等於把記憶讀出來。 +9. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 + 本機目錄名與 **Gitea 存取庫名稱**。中文名要先轉羅馬拼音並跟使用者確認拼法。 +10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push), + 低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。 + **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 ## 慣例 @@ -38,3 +43,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..0d652c2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 --- -## 九條硬規則 +## 十一條硬規則 | 規則 | 怎麼做到 | | --- | --- | @@ -24,6 +24,8 @@ 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/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | --- @@ -43,15 +45,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` 覆寫) @@ -68,7 +78,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 +92,8 @@ flowchart TB │ ├── graph.json # 人際關係圖(親近度/信任度/連線) │ └── graph.mmd # Mermaid 呈現(自動產生) └── journal/YYYY-MM.jsonl # 原始逐字 + 情緒史(hook 自動寫) + +(另有 `.sync/files/`、`.sync/wiki/`:兩個同步區的 git clone 快取,可安全刪除) ``` ## 十二情緒 @@ -129,6 +142,40 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。 +## 人格編號與 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 +255,12 @@ node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json - **Claude Code / Antigravity**:`/jsc-persona:persona-transfer` **Codex**:`$persona-transfer` +### `persona-sync` + +人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-sync` **Codex**:`$persona-sync` + ### `persona-memory` 記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。 @@ -243,12 +296,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 # 116 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 -`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。 +`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`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..5b3d56f --- /dev/null +++ b/scripts/persona-gitea.mjs @@ -0,0 +1,585 @@ +// 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", + "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; +} + +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", 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; + 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 首頁:讓 Gitea 上點進去就看得懂這是誰。 */ +export function wikiHome(slug, code) { + const ident = pl.identityFields(slug); + const longTerm = pl.longTermEntries(slug); + const relations = pl.loadRelations(slug); + const lines = [ + `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``, + "", + "> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;", + "> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。", + "", + "| 欄位 | 內容 |", + "| --- | --- |", + `| 編號 | \`${code}\` |`, + ...["Name", "Creature", "Vibe", "Emoji", "Avatar"] + .filter((k) => ident[k]) + .map((k) => `| ${k} | ${ident[k]} |`), + `| 長期記憶 | ${longTerm.length} 則 |`, + `| 關係人 | ${relations.nodes.length} 位 |`, + `| 最後同步 | ${pl.nowIso()} |`, + "", + "## 頁面", + "", + "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar)", + "- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity", + "- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解", + "- [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` — 原始資料", + "- `_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)); + 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)); + 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 }; +} + +/** 建立 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-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..9aa1d40 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -14,9 +14,11 @@ 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"; 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 +50,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", ]); function parseArgs(argv) { @@ -154,6 +157,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 +178,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 +218,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 +235,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 +270,7 @@ commands.list = ({ flags }) => { } return { persona: slug, + code: gt.personaCode(slug), identity: pl.identityBrief(slug), locked: status.locked, stale: status.stale, @@ -236,16 +286,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 +315,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 +353,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 +643,7 @@ commands.consolidate = ({ flags }) => { say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`); } ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`); + pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了 }; commands.prune = ({ flags }) => { @@ -572,6 +659,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 +760,7 @@ commands.relation = ({ flags, positional }) => { }); pl.renderRelations(slug); ok(`關係節點 \`${name}\` 已更新。`); + pushWikiLater(slug, session, flags); return; } if (action === "edge") { @@ -685,6 +774,7 @@ commands.relation = ({ flags, positional }) => { }); pl.renderRelations(slug); ok(`關係連線 ${str(flags.from) || "self"} → ${to} 已更新。`); + pushWikiLater(slug, session, flags); return; } if (action === "render") { @@ -951,6 +1041,164 @@ 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)`); +}; + +/** 人格與 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 === "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)`); +}; + commands.gc = ({ flags }) => { const removed = pl.gcRuntime(); emit(removed, flags.json, [ @@ -981,7 +1229,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 +1261,15 @@ 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) -搬家: +編號與 Gitea(存取庫名稱 = 人格編號): + code show|assign|next --session [--romaji <英文名> --code --rename --force --public] + sync status|init|push|pull --session [--area files|wiki|all --if-due --force --message --owner] + 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 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 +1280,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 +1291,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 +1299,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/selftest.mjs b/scripts/selftest.mjs index 45311d7..21222cd 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -14,8 +14,11 @@ 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 CLI = path.join(HERE, "persona.mjs"); const HOOKS = path.join(HERE, "..", "hooks"); @@ -406,6 +409,74 @@ 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"); +// 分區必須「不重不漏」:人格產生的每個檔案都要恰好屬於一區,否則同步會默默漏資料 +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(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-anime/SKILL.md b/skills/persona-anime/SKILL.md index 0da79cb..b73fc87 100644 --- a/skills/persona-anime/SKILL.md +++ b/skills/persona-anime/SKILL.md @@ -67,13 +67,17 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \ - --persona --session \ + --romaji "<角色的官方英文名>" --session \ --name "" --creature "" --vibe "" \ --emoji "" --avatar "<外觀描述>" \ --origin anime --work "<作品名>" \ --baseline "<依角色個性設定的十二情緒基線>" ``` +`--romaji` 用**官方英文/羅馬拼音名**(維基或官方英文版的寫法,如 `Asuna`、`Yui`、`Rem`), +它會變成人格編號 `ASUNA-01` 與 Gitea 存取庫名稱。同一個角色要做第二份設定時, +索引會自動變成 `-02`。 + 情緒基線抓法(0–100,總體別全部拉高): | 角色類型 | 建議基線 | diff --git a/skills/persona-create/SKILL.md b/skills/persona-create/SKILL.md index 32dc213..5a2e2bb 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**。 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..ed0f8b9 --- /dev/null +++ b/skills/persona-sync/SKILL.md @@ -0,0 +1,109 @@ +--- +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`、長期記憶、`INDEX`、心智圖、人際關係圖 | 記憶固化、改身分、改關係圖、`release` 時 | + +Wiki 是給人讀的設定百科:Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以 +`memory/long-term/xxx.md` 會攤平成 `Memory-xxx.md`(頁面顯示為「Memory xxx」), +原始路徑記在 `_paths.json`,拉回來時自動還原。Home 頁會自動列出所有長期記憶的連結。 + +**本機永遠是工作副本**:hook 每輪讀寫的是本機檔案,不經過網路。Gitea 掛掉、離線、沒設 token, +人格照樣能聊天,只是不同步——**同步失敗永遠不阻斷對話**。 + +## 設定 + +```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 無亂碼。