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
+106
View File
@@ -882,6 +882,112 @@ export function personaSulk(slug) {
return [];
}
// --------------------------------------------------------------------------- //
// SOUL.md 分區塊讀寫
//
// SOUL.md 是「只有使用者能改」的個性層(見樣板檔頭:「操作規則寫 AGENTS.md,
// 不要混進來」是既有原則,這裡延伸的是「誰能改」)。這裡開的不是讓 AI 自由改寫個性的後門——
// 四個區塊(Core Truths/Boundaries/Vibe/Continuity)各自用一組 HTML 註解標記框住
// 「這個函式能碰的範圍」,標記**之外**的內容(樣板原本寫好的條目、使用者手動編輯過的段落)
// 永遠不會被這裡的函式動到,且一定要有呼叫端(`persona.mjs soul`)明確帶 session/owner
// 權限才能寫——跟直接用編輯器改檔案的權限模型一樣,只是多一個「透過確認佇列寫入」的窄通道。
// 標記不存在時(舊人格、模板尚未套用過這個版本)會自動在對應的 `## 標題` 後面補一組空的,
// 不需要重建人格就能開始用。
// --------------------------------------------------------------------------- //
export const SOUL_SECTIONS = {
core_truths: "Core Truths",
boundaries: "Boundaries",
vibe: "Vibe",
continuity: "Continuity",
};
const soulMarker = (section, which) => `<!-- aps:${section}:${which} -->`;
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** 找不到標記時,在對應的 `## 標題` 後面插入一組空標記;連標題都找不到就回 null。 */
function ensureSoulMarkers(text, section, heading) {
const begin = soulMarker(section, "begin");
const end = soulMarker(section, "end");
if (text.includes(begin) && text.includes(end)) return text;
const headingRe = new RegExp(`^##\\s*${escapeRegExp(heading)}\\s*$`, "m");
const m = text.match(headingRe);
if (!m) return null;
const insertAt = m.index + m[0].length;
return `${text.slice(0, insertAt)}\n\n${begin}\n${end}\n${text.slice(insertAt)}`;
}
function soulFilePath(slug) {
return path.join(personaDir(slug), "SOUL.md");
}
/** 讀某個區塊標記內的原文(trim 過);標記與標題都不存在就回 null。 */
export function readSoulSection(slug, section) {
if (!(section in SOUL_SECTIONS)) return null;
let text;
try {
text = fs.readFileSync(soulFilePath(slug), "utf8");
} catch {
return null;
}
const begin = soulMarker(section, "begin");
const end = soulMarker(section, "end");
const bi = text.indexOf(begin);
const ei = text.indexOf(end);
if (bi === -1 || ei === -1 || ei < bi) return null;
return text.slice(bi + begin.length, ei).trim();
}
/**
* 寫入某個區塊標記內的內容。
* `mode`:`"append"`(預設,接在既有內容後面;完全相同的一行不會重複疊加)或
* `"replace"`(整段換掉)。標記外的內容一律原樣保留;標記不存在就先補一組空的。
* `by` 有帶就寫一筆 journal(不寫進檔案本身,SOUL.md 不因為審計紀錄越長越亂)。
* @returns {{before:string, after:string}} 標記內的內容(trim 過),呼叫端可以拿來比較有沒有真的變。
*/
export function updateSoulSection(slug, section, value, { mode = "append", by = null } = {}) {
if (!(section in SOUL_SECTIONS)) throw new Error(`未知的 SOUL 區塊:${section}(可用:${Object.keys(SOUL_SECTIONS).join("/")})`);
const file = soulFilePath(slug);
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
throw new Error(`讀不到 ${file}`);
}
const ensured = ensureSoulMarkers(text, section, SOUL_SECTIONS[section]);
if (ensured === null) {
throw new Error(`SOUL.md 裡找不到「## ${SOUL_SECTIONS[section]}」標題,沒辦法安全插入標記(樣板被改得太多了嗎?)。`);
}
const begin = soulMarker(section, "begin");
const end = soulMarker(section, "end");
const bi = ensured.indexOf(begin);
const ei = ensured.indexOf(end);
const before = ensured.slice(bi + begin.length, ei).trim();
const cleanValue = String(value ?? "").trim();
let after;
if (mode === "replace") {
after = cleanValue;
} else if (!cleanValue) {
after = before;
} else if (before.split("\n").some((line) => line.trim() === cleanValue)) {
// 完全一樣的內容不重複疊加(呼叫端可能因為重試或重複確認而送同一筆)
after = before;
} else {
after = before ? `${before}\n${cleanValue}` : cleanValue;
}
const rebuilt = `${ensured.slice(0, bi + begin.length)}\n${after}\n${ensured.slice(ei)}`;
writeText(file, rebuilt);
if (by) {
appendJsonl(journalPath(slug), { ts: nowIso(), kind: "soul", section, mode, by, value: cleanValue });
}
return { before, after };
}
/**
* 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。
* 強度不夠就不演——低強度的情緒在語氣上是看不出來的。