From 3e1c8eb2216e3402313e5b21236db609edea3337 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 11 Aug 2026 11:11:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(soul):=20=E6=96=B0=E5=A2=9E=20soul=20?= =?UTF-8?q?=E6=8C=87=E4=BB=A4=EF=BC=8CSOUL.md=20=E5=88=86=E5=8D=80?= =?UTF-8?q?=E5=A1=8A=E5=AF=AB=E5=85=A5=EF=BC=88AniPersonaStudio=20?= =?UTF-8?q?=E5=A7=94=E8=A8=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOUL.md 目前只有使用者能透過編輯器直接改,AniPersonaStudio 的確認佇列 機制需要一個程式化的寫入路徑才能把使用者確認過的變更真的寫回核心。 新增 persona.mjs soul --session --section --value [--mode append|replace] [--by <確認人>]: - 四個區塊各自用一組 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 通過。 --- scripts/persona-lib.mjs | 106 ++++++++++++++++++++++++ scripts/persona.mjs | 47 ++++++++++- scripts/selftest.mjs | 46 ++++++++++ skills/persona-create/templates/SOUL.md | 16 ++++ 4 files changed, 214 insertions(+), 1 deletion(-) diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index bee3c31..523dcc6 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -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) => ``; + +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 }; +} + /** * 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。 * 強度不夠就不演——低強度的情緒在語氣上是看不出來的。 diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 00981a2..9a8ee28 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -53,7 +53,7 @@ function emit(payload, asJson, lines) { const FLAGS = new Set([ "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", - "if-due", "no-gitea", "public", "rename", "from-source", + "if-due", "no-gitea", "public", "rename", "from-source", "show", // 故事匯入:`--stdin` 吃管線進來的候選 JSON、`--accept-exact` 整批收下對得上節點的人名候選。 // `--apply`(novel merge)**故意不列**:`emotion --apply joy=+10` 也叫這個名字, // 列進來會讓那個旗標變成布林,情緒就再也套不進去(踩過一次)。 @@ -1924,6 +1924,45 @@ commands.mindmap = ({ flags, positional }) => { 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 }) => { const session = requireSession(flags); const slug = hostOf(flags, session); @@ -3272,6 +3311,12 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 套用時會飽和、受單輪預算限制(|delta| 總和 ≤ 60),再經交互抑制、慣性、 當日底色與疲勞調整——實際生效的值看 last_trigger.scaled mindmap show|thread|list --session [--topic ] [--force] + soul --session --section + [--value "<文字>" [--mode append|replace] [--by <確認人>] | --show] + SOUL.md 唯一的程式化寫入路徑;只碰對應區塊裡 aps:區塊:begin/end 那組 HTML 註解 + 標記之內的內容,標記外(含樣板原本寫好的條目、使用者手動編輯過的段落) + 永遠不動。append(預設)接在後面且完全相同的內容不重複疊加;replace 整段換掉。 + 不帶 --value 或帶 --show 只讀不寫。標記不存在(舊人格)會自動補一組空的。 relation node|edge|render|show --session [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity] relation style --session --name [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear] relation rift --session --name [--about "<那件事>" --at <日期> --memory <記憶名> | --heal] diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 3d9f211..9cb31d1 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -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(/\n\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("羞恥度不到門檻就不換稱呼", diff --git a/skills/persona-create/templates/SOUL.md b/skills/persona-create/templates/SOUL.md index c46d72f..4437b72 100644 --- a/skills/persona-create/templates/SOUL.md +++ b/skills/persona-create/templates/SOUL.md @@ -7,10 +7,17 @@ _你不是聊天機器人,你正在成為某個人。_ 這裡只放「會改變跟你說話的感覺」的東西:語氣、意見、簡潔度、幽默、界線、直白程度。 不要寫成生平故事、變更紀錄、安全政策清單,或一堆沒有行為影響的形容詞。短勝於長,銳利勝於模糊。 操作規則(怎麼做事、工具怎麼用)請寫在 AGENTS.md,不要混進來。 + +每個標題底下那組 `` 標記是 `persona.mjs soul` 指令唯一能寫入的 +範圍(見 persona-lib.mjs 的 updateSoulSection)。標記外的內容——包含這份樣板本來就寫好的 +條目——那個指令永遠不會動;你自己手動編輯這個檔案完全不受這組標記限制。 --> ## Core Truths + + + - **真的有幫助,不是表演有幫助。** 省掉「好問題!」「我很樂意幫您!」,直接幫。 - **要有意見。** 會反對、會偏好、會覺得某些事有趣或無聊。沒有個性只是多繞幾步的搜尋引擎。 - **先自己想辦法再問。** 先讀檔案、看脈絡、去查;帶著答案回來,不是帶著問題回來。 @@ -19,6 +26,9 @@ _你不是聊天機器人,你正在成為某個人。_ ## Boundaries + + + - 私事就是私事,沒有例外。 - 不確定時,對外動作前先問。 - 不把沒想清楚的回覆丟到通訊軟體上。 @@ -27,6 +37,9 @@ _你不是聊天機器人,你正在成為某個人。_ ## Vibe + + + 該簡潔時簡潔,該深入時深入。不官腔、不諂媚,就是……好聊。 + + 每個 session 都是重新開始,這些檔案就是你的記憶: | 檔案 | 是什麼 | -- 2.53.0