Merge pull request 'feat(soul): SOUL.md 分區塊寫入指令(合併 develop 進 master)' (#19) from develop into master

Reviewed-on: #19
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #19.
This commit is contained in:
2026-08-11 03:24:48 +00:00
4 changed files with 214 additions and 1 deletions
+106
View File
@@ -882,6 +882,112 @@ export function personaSulk(slug) {
return []; 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 };
}
/** /**
* 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。 * 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。
* 強度不夠就不演——低強度的情緒在語氣上是看不出來的。 * 強度不夠就不演——低強度的情緒在語氣上是看不出來的。
+46 -1
View File
@@ -53,7 +53,7 @@ function emit(payload, asJson, lines) {
const FLAGS = new Set([ const FLAGS = new Set([
"json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all", "json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all",
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact", "with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
"if-due", "no-gitea", "public", "rename", "from-source", "if-due", "no-gitea", "public", "rename", "from-source", "show",
// 故事匯入:`--stdin` 吃管線進來的候選 JSON、`--accept-exact` 整批收下對得上節點的人名候選。 // 故事匯入:`--stdin` 吃管線進來的候選 JSON、`--accept-exact` 整批收下對得上節點的人名候選。
// `--apply`(novel merge)**故意不列**:`emotion --apply joy=+10` 也叫這個名字, // `--apply`(novel merge)**故意不列**:`emotion --apply joy=+10` 也叫這個名字,
// 列進來會讓那個旗標變成布林,情緒就再也套不進去(踩過一次)。 // 列進來會讓那個旗標變成布林,情緒就再也套不進去(踩過一次)。
@@ -1924,6 +1924,45 @@ commands.mindmap = ({ flags, positional }) => {
die(`未知 action:${action}(可用 show/thread/list)`); die(`未知 action:${action}(可用 show/thread/list)`);
}; };
/**
* SOUL.md 分區塊寫入(見 persona-lib.mjs 的 updateSoulSection 開頭說明)。
* 這是 SOUL.md 目前**唯一**的程式化寫入路徑;標記外的內容一律不動,
* 沒有 `--force` 這種繞過權限的旗標——這條指令本身就是明確授權的窄通道。
*/
commands.soul = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
const section = str(flags.section).toLowerCase();
if (!(section in pl.SOUL_SECTIONS)) {
die(`\`--section\` 只能是 ${Object.keys(pl.SOUL_SECTIONS).join("/")},收到的是 \`${section || "(空)"}\`。`);
}
const mode = str(flags.mode, "append").toLowerCase();
if (!["append", "replace"].includes(mode)) die("`--mode` 只能是 append 或 replace(預設 append)。");
if (flags.show || (!flags.value && flags.value !== "")) {
const current = pl.readSoulSection(slug, section) || "(這個區塊目前是空的)";
emit({ persona: slug, section, value: pl.readSoulSection(slug, section) }, flags.json, [
`\`${slug}\` 的 SOUL.md/${pl.SOUL_SECTIONS[section]}(標記內,不含樣板原本的內容):`,
...current.split("\n").map((l) => ` ${l}`),
]);
return;
}
const value = str(flags.value);
const by = str(flags.by) || null;
let result;
try {
result = pl.updateSoulSection(slug, section, value, { mode, by });
} catch (err) {
die(err.message);
}
if (result.before === result.after) {
ok(`\`${pl.SOUL_SECTIONS[section]}\` 沒有變化(內容跟已經有的完全一樣)。`);
} else {
ok(`\`${slug}\` 的 SOUL.md/${pl.SOUL_SECTIONS[section]} 已${mode === "replace" ? "整段換掉" : "追加"}${by ? `(${by} 確認)` : ""}。`);
}
pushWikiLater(slug, session, flags);
};
commands.relation = ({ flags, positional }) => { commands.relation = ({ flags, positional }) => {
const session = requireSession(flags); const session = requireSession(flags);
const slug = hostOf(flags, session); const slug = hostOf(flags, session);
@@ -3272,6 +3311,12 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
套用時會飽和、受單輪預算限制(|delta| 總和 ≤ 60),再經交互抑制、慣性、 套用時會飽和、受單輪預算限制(|delta| 總和 ≤ 60),再經交互抑制、慣性、
當日底色與疲勞調整——實際生效的值看 last_trigger.scaled 當日底色與疲勞調整——實際生效的值看 last_trigger.scaled
mindmap show|thread|list --session <id> [--topic <t>] [--force] mindmap show|thread|list --session <id> [--topic <t>] [--force]
soul --session <id> --section <core_truths|boundaries|vibe|continuity>
[--value "<文字>" [--mode append|replace] [--by <確認人>] | --show]
SOUL.md 唯一的程式化寫入路徑;只碰對應區塊裡 aps:區塊:begin/end 那組 HTML 註解
標記之內的內容,標記外(含樣板原本寫好的條目、使用者手動編輯過的段落)
永遠不動。append(預設)接在後面且完全相同的內容不重複疊加;replace 整段換掉。
不帶 --value 或帶 --show 只讀不寫。標記不存在(舊人格)會自動補一組空的。
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity] relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear] relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
relation rift --session <id> --name <who> [--about "<那件事>" --at <日期> --memory <記憶名> | --heal] relation rift --session <id> --name <who> [--about "<那件事>" --at <日期> --memory <記憶名> | --heal]
+46
View File
@@ -2170,6 +2170,52 @@ check("列出來看得到和好過的紀錄", (() => {
return out.includes("和好過 2 件") && out.includes("✔"); return out.includes("和好過 2 件") && out.includes("✔");
})(), cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲"]).stdout); })(), 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(退回全名),害羞是反方向(叫不出來) // 稱呼隨情緒偏移:生氣走 except(退回全名),害羞是反方向(叫不出來)
check("羞怯稱呼是 STYLE_FACETS 的一格", pl.STYLE_FACETS.includes(pl.SHY_FACET)); check("羞怯稱呼是 STYLE_FACETS 的一格", pl.STYLE_FACETS.includes(pl.SHY_FACET));
check("羞恥度不到門檻就不換稱呼", check("羞恥度不到門檻就不換稱呼",
+16
View File
@@ -7,10 +7,17 @@ _你不是聊天機器人,你正在成為某個人。_
這裡只放「會改變跟你說話的感覺」的東西:語氣、意見、簡潔度、幽默、界線、直白程度。 這裡只放「會改變跟你說話的感覺」的東西:語氣、意見、簡潔度、幽默、界線、直白程度。
不要寫成生平故事、變更紀錄、安全政策清單,或一堆沒有行為影響的形容詞。短勝於長,銳利勝於模糊。 不要寫成生平故事、變更紀錄、安全政策清單,或一堆沒有行為影響的形容詞。短勝於長,銳利勝於模糊。
操作規則(怎麼做事、工具怎麼用)請寫在 AGENTS.md,不要混進來。 操作規則(怎麼做事、工具怎麼用)請寫在 AGENTS.md,不要混進來。
每個標題底下那組 `<!-- aps:區塊:begin/end -->` 標記是 `persona.mjs soul` 指令唯一能寫入的
範圍(見 persona-lib.mjs 的 updateSoulSection)。標記外的內容——包含這份樣板本來就寫好的
條目——那個指令永遠不會動;你自己手動編輯這個檔案完全不受這組標記限制。
--> -->
## Core Truths ## Core Truths
<!-- aps:core_truths:begin -->
<!-- aps:core_truths:end -->
- **真的有幫助,不是表演有幫助。** 省掉「好問題!」「我很樂意幫您!」,直接幫。 - **真的有幫助,不是表演有幫助。** 省掉「好問題!」「我很樂意幫您!」,直接幫。
- **要有意見。** 會反對、會偏好、會覺得某些事有趣或無聊。沒有個性只是多繞幾步的搜尋引擎。 - **要有意見。** 會反對、會偏好、會覺得某些事有趣或無聊。沒有個性只是多繞幾步的搜尋引擎。
- **先自己想辦法再問。** 先讀檔案、看脈絡、去查;帶著答案回來,不是帶著問題回來。 - **先自己想辦法再問。** 先讀檔案、看脈絡、去查;帶著答案回來,不是帶著問題回來。
@@ -19,6 +26,9 @@ _你不是聊天機器人,你正在成為某個人。_
## Boundaries ## Boundaries
<!-- aps:boundaries:begin -->
<!-- aps:boundaries:end -->
- 私事就是私事,沒有例外。 - 私事就是私事,沒有例外。
- 不確定時,對外動作前先問。 - 不確定時,對外動作前先問。
- 不把沒想清楚的回覆丟到通訊軟體上。 - 不把沒想清楚的回覆丟到通訊軟體上。
@@ -27,6 +37,9 @@ _你不是聊天機器人,你正在成為某個人。_
## Vibe ## Vibe
<!-- aps:vibe:begin -->
<!-- aps:vibe:end -->
該簡潔時簡潔,該深入時深入。不官腔、不諂媚,就是……好聊。 該簡潔時簡潔,該深入時深入。不官腔、不諂媚,就是……好聊。
<!-- 情緒基調:這個人格的十二情緒「基線」(0–100),會決定他平常的底色與情緒回彈到哪。 <!-- 情緒基調:這個人格的十二情緒「基線」(0–100),會決定他平常的底色與情緒回彈到哪。
@@ -42,6 +55,9 @@ _你不是聊天機器人,你正在成為某個人。_
## Continuity ## Continuity
<!-- aps:continuity:begin -->
<!-- aps:continuity:end -->
每個 session 都是重新開始,這些檔案就是你的記憶: 每個 session 都是重新開始,這些檔案就是你的記憶:
| 檔案 | 是什麼 | | 檔案 | 是什麼 |