feat(soul): 新增 soul 指令,SOUL.md 分區塊寫入(AniPersonaStudio 委託)

SOUL.md 目前只有使用者能透過編輯器直接改,AniPersonaStudio 的確認佇列
機制需要一個程式化的寫入路徑才能把使用者確認過的變更真的寫回核心。

新增 persona.mjs soul --session <id> --section <core_truths|boundaries|
vibe|continuity> --value <text> [--mode append|replace] [--by <確認人>]:

- 四個區塊各自用一組 <!-- aps:區塊:begin/end --> HTML 註解標記框住可寫範圍,
  標記外的內容(樣板原本寫好的條目、使用者手動編輯過的段落)永遠不動
- append(預設)接在後面,完全相同的內容不重複疊加;replace 整段換掉
- 標記不存在時(舊人格)自動在對應的 ## 標題後面補一組空的,不需要重建人格
- 權限模型跟 relation/mindmap 一致:requireSession + requireOwner
- --by 有帶會寫一筆 journal 留審計紀錄;成功後照既有慣例背景推 Wiki 區

SOUL.md 樣板補上四組標記;persona-lib.mjs 新增 SOUL_SECTIONS/
readSoulSection/updateSoulSection;selftest.mjs +9 案例(append/replace/
去重/權限/journal/舊人格自動補標記)。全部 781 項 selftest 通過。
This commit is contained in:
Jeffery
2026-08-11 11:11:41 +08:00
parent cbbb15ee58
commit 3e1c8eb221
4 changed files with 214 additions and 1 deletions
+46
View File
@@ -2170,6 +2170,52 @@ check("列出來看得到和好過的紀錄", (() => {
return out.includes("和好過 2 件") && out.includes("✔");
})(), cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲"]).stdout);
// soul:SOUL.md 分區塊寫入(AniPersonaStudio §7-26/§7-30 需要的核心指令)
check("soul --show:還沒寫過的區塊是空的,不會報錯", (() => {
const res = cli(["soul", "--session", S_HOST, "--section", "vibe", "--show"]);
return res.status === 0 && res.stdout.includes("目前是空的");
})());
check("soul:append 寫入,標記外的樣板內容原封不動", (() => {
const before = fs.readFileSync(path.join(pl.personaDir("alpha"), "SOUL.md"), "utf8");
const res = cli(["soul", "--session", S_HOST, "--section", "vibe", "--value", "說話會夾雜深海生物的冷知識", "--by", "selftest"]);
const after = fs.readFileSync(path.join(pl.personaDir("alpha"), "SOUL.md"), "utf8");
return res.status === 0 &&
after.includes("說話會夾雜深海生物的冷知識") &&
after.includes("該簡潔時簡潔,該深入時深入") && // 樣板原本寫好的句子還在
before.includes("該簡潔時簡潔,該深入時深入");
})());
check("soul:append 兩次一模一樣的內容不會疊成兩份", (() => {
cli(["soul", "--session", S_HOST, "--section", "vibe", "--value", "喜歡引用深海壓力的比喻"]);
const res = cli(["soul", "--session", S_HOST, "--section", "vibe", "--value", "喜歡引用深海壓力的比喻"]);
const section = pl.readSoulSection("alpha", "vibe");
const hits = section.split("\n").filter((l) => l.trim() === "喜歡引用深海壓力的比喻").length;
return res.status === 0 && hits === 1;
})(), pl.readSoulSection("alpha", "vibe"));
check("soul:replace 整段換掉,不是疊加", (() => {
cli(["soul", "--session", S_HOST, "--section", "core_truths", "--value", "第一條", "--mode", "replace"]);
cli(["soul", "--session", S_HOST, "--section", "core_truths", "--value", "第二條", "--mode", "replace"]);
const section = pl.readSoulSection("alpha", "core_truths");
return section === "第二條";
})(), pl.readSoulSection("alpha", "core_truths"));
check("soul:未知區塊會被擋下", cli(["soul", "--session", S_HOST, "--section", "nonsense", "--value", "x"], { expectOk: false }).status !== 0);
check("soul:沒有 session/owner 權限就不能寫(跟 relation/mindmap 同一套權限模型)",
cli(["soul", "--session", S_OTHER, "--persona", "alpha", "--section", "vibe", "--value", "冒充"], { expectOk: false }).status !== 0);
check("soul:journal 留下審計紀錄(--by 是誰確認的)", (() => {
const lines = fs.readFileSync(pl.journalPath("alpha"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
return lines.some((l) => l.kind === "soul" && l.section === "vibe" && l.by === "selftest");
})());
check("soul:舊人格沒有標記時會自動補一組空的,不用重建人格", (() => {
const file = path.join(pl.personaDir("alpha"), "SOUL.md");
const original = fs.readFileSync(file, "utf8");
// 模擬「這個人格是套用新版模板之前建立的」:把 continuity 的標記整組拿掉
fs.writeFileSync(file, original.replace(/<!-- aps:continuity:begin -->\n<!-- aps:continuity:end -->\n\n/, ""));
const res = cli(["soul", "--session", S_HOST, "--section", "continuity", "--value", "額外記得:deadline 前會更沉默"]);
const ok1 = res.status === 0 && pl.readSoulSection("alpha", "continuity") === "額外記得:deadline 前會更沉默";
const after = fs.readFileSync(file, "utf8");
const tableIntact = after.includes("| `IDENTITY.md` | 身分卡");
return ok1 && tableIntact;
})(), fs.readFileSync(path.join(pl.personaDir("alpha"), "SOUL.md"), "utf8"));
// 稱呼隨情緒偏移:生氣走 except(退回全名),害羞是反方向(叫不出來)
check("羞怯稱呼是 STYLE_FACETS 的一格", pl.STYLE_FACETS.includes(pl.SHY_FACET));
check("羞恥度不到門檻就不換稱呼",