feat: 人格編號(ASUNA-01)與 Gitea 儲存,依更新頻率分檔案區/Wiki 區
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user