diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index a3c460e..9c95a96 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -1967,6 +1967,33 @@ export function roomRepeat(room, speaker, text, opts = {}) { return findRepeat(rows, text, opts); } +/** + * 這句台詞是不是把心裡話搬上台面了。 + * + * 心裡話外流有兩種:一種是別的人格去讀你的檔案(那個 hook 早就擋死了), + * 另一種比較難防——**你自己把心裡想的原封不動講出來**。劇場模式尤其危險, + * 因為那裡只有台詞,別的人格看得到的就只有你說出口的東西。 + * + * 「台詞不可以是心裡話的摘要」原本只是寫在規則裡靠自律,這裡把它變成機械檢查: + * 跟最近的心裡話太像就擋下來。害羞的人格更要擋——他們心裡那句才是真的。 + */ +export const INNER_LEAK_THRESHOLD = 0.62; +export const INNER_LEAK_WINDOW = 12; + +export function innerLeak(slug, text, { threshold = INNER_LEAK_THRESHOLD } = {}) { + const norm = normalizeSpeech(text); + if (norm.length < REPEAT_MIN_CHARS) return null; + let best = null; + for (const row of readJsonl(innerPath(slug), INNER_LEAK_WINDOW)) { + const score = similarity(text, row.text || ""); + if (score < threshold) continue; + if (!best || score > best.similarity) { + best = { similarity: score, at: row.ts, kind: row.kind || "infer", text: String(row.text || "").slice(0, 120) }; + } + } + return best; +} + /** 心裡話:只進自己的 inner.jsonl,永遠不回顯給使用者。 */ export function recordInner(slug, text, { kind = "infer", room = null } = {}) { const entry = { ts: nowIso(), kind, room, text: String(text ?? "").slice(0, 1200) }; diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 944a39e..7379d6d 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1361,6 +1361,18 @@ commands.room = ({ flags, positional }) => { "挑最想說的那一兩句就好,剩下的寫進心裡話。(真的需要長台詞才加 `--force`。)", ); } + // 台詞不可以是心裡話搬上台面:劇場模式裡別的人格只看得到你說出口的東西, + // 心裡話外流最可能的路徑就是你自己把它講出來。 + if (speakerRole !== "guest" && pl.personaExists(speaker)) { + const leak = pl.innerLeak(speaker, text); + if (leak && !flags.force) { + die( + `這句跟你剛才的心裡話太像了(${leak.similarity}):「${leak.text.slice(0, 40)}…」。` + + "心裡話是只有你自己知道的東西——說出口的那句應該是你**決定要讓他聽到**的版本," + + "不是把心裡那句唸出來。(真的要講開才加 `--force`。)", + ); + } + } // 短句、日常話、講完不解釋、不說 AI 才會說的話——講話的樣子也擋在 CLI 裡,不靠自律。 const lint = pl.speechBlockers(text); if (lint.length && !flags.force) { diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 4ac28f7..a13c2b7 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -1387,6 +1387,35 @@ check("sleeper 型別存在且宣告了唯讀工具限制", (() => { md.includes("回傳值裡不得出現任何記憶內容"); })()); +console.log("\n劇場模式:心裡話不能外流"); +{ + const S_INNER = "sess-inner-8888"; + cli(["load", "--persona", "alpha", "--session", S_INNER, "--takeover"]); + const room3 = JSON.parse(cli(["invite", "--session", S_INNER, "--guest", "beta", "--topic", "心裡話測試", "--json"]).stdout).room; + cli(["think", "--persona", "alpha", "--session", S_INNER, "--text", "我其實一直在等他先開口問我那件事"]); + const leak = cli(["room", "post", "--session", S_INNER, "--room", room3, "--as", "alpha", + "--text", "我其實一直在等你先開口問我那件事。"], { expectOk: false }); + check("台詞跟心裡話太像 → room post 擋下(心裡話不能搬上台面)", + leak.status !== 0 && leak.stderr.includes("心裡話"), leak.stderr.slice(0, 160)); + check("--force 仍可放行(真的要講開的時候)", + cli(["room", "post", "--session", S_INNER, "--room", room3, "--as", "alpha", + "--text", "我其實一直在等你先開口問我那件事。", "--force"]).status === 0); + check("換句話、講別的就過得去", + cli(["room", "post", "--session", S_INNER, "--room", room3, "--as", "alpha", + "--text", "外面雨停了。要不要走走?"]).status === 0); + // 逐字稿裡只有說出口的東西,沒有任何心裡話的欄位 + const rows = pl.roomRead(room3, 20); + check("聊天室逐字稿不含心裡話欄位", + rows.length > 0 && rows.every((r) => !("inner" in r) && !("think" in r)), JSON.stringify(Object.keys(rows[0] || {}))); + check("別的人格讀不到你的 inner.jsonl(hook 擋)", + guard({ session_id: S_OTHER, tool_name: "Read", + tool_input: { file_path: `${H}/alpha/state/inner.jsonl` } }) === "deny"); + check("別的人格也不能用 grep 掃出來", + guard({ session_id: S_OTHER, tool_name: "Grep", tool_input: { path: `${H}/alpha/state` } }) === "deny"); + cli(["leave", "--session", S_INNER, "--guest", "beta"]); + cli(["release", "--session", S_INNER]); +} + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0);