feat: 語氣診療 skill、聊天室的發言權、講話再加四條規則

三件事,都是「講話要像人在講話」的延伸。

1. `persona-therapist`(新 skill):以第三方心理醫生的身分診斷一段對話。
   雙方各給姓名/關係/情緒/逐字稿,用三把尺逐句標記——與關係不符
   (語氣層錯位)、與事實不符(絕對化、讀心)、與目的不符(他要被理解,
   講出來的話保證換到防衛)。標記掛在說那句話的人身上,修正只寫給他,
   格式是「你其實想要的 → 對方收到的 → 改寫」,改寫必須留在同一個語氣層。
   基線直接用 `TONE_TABLE`(已載入人格可 `relation show` 唯讀取得),
   破壞模式表 P1–P16 放在 `reference/patterns.md`。不附身、不取鎖、不寫回,
   出現自傷/暴力/受控訊號時停掉語氣分析改為安全優先。

2. 聊天室的發言權:同一個空間裡也會有一對一。`room post --to <他>` 進
   一對一(旁人插話直接被擋,要帶 `--barge-in "<理由>"`,理由留在逐字稿)、
   `--to all` 把話題開回全場。新指令 `room floor` 回報誰對誰在講、該誰接、
   誰先安靜、誰隔了幾輪沒開口。同一份現況每輪注入 `<persona-context>`,
   並明講「不要替沒被指名的人生成台詞,也不要為此啟動他的 sub agent」。
   三人以上時 `room script` 標出對象(`🪼 Alpha(喜悅42) → Beta:…`),
   兩個人的聊天室不標。舊逐字稿沒有 `to` → 視為全場,行為不變。

3. 講話的樣子再加四條:短句(一句 45 字內)、日常用詞、多講看得見的
   東西(人、動作、物件、當下的場面)、**不要解釋自己的話**。前兩條與
   第四條的句長/「我的意思是」這類開頭由 `speechLint()` 機械攔截
   (`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與
   具體度抓不到規則,走每輪注入的說話規則。

selftest 244 項全綠(新增 16 項:發言權 10、講話的樣子 6)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 10:02:34 +00:00
co-authored by Claude Opus 5
parent 05d9b615d1
commit 0648c3b914
11 changed files with 591 additions and 22 deletions
+63
View File
@@ -239,6 +239,45 @@ check("多 guest 同時邀請成功",
const multiMeta = pl.readJson(pl.roomMembersPath(multiRoom)) ?? {};
check("同一個 room 內可同時掛多位 guest", Array.isArray(multiMeta.members) &&
["alpha", "beta", "omega"].every((s) => multiMeta.members.includes(s)), JSON.stringify(multiMeta));
// 同一個空間裡的一對一:對象只有一個人時,旁人不該有台詞。
cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "alpha",
"--to", "beta", "--text", "beta,那台鐘後來怎麼了?"]);
const floorDyad = JSON.parse(cli(["room", "floor", "--session", S_HOST, "--room", multiRoom, "--json"]).stdout);
check("room floor 認得一對一(誰該接、誰先安靜)",
floorDyad.mode === "dyad" && floorDyad.next === "beta" && floorDyad.silent.includes("omega") &&
!floorDyad.silent.includes("alpha"), JSON.stringify(floorDyad));
const floorText = cli(["room", "floor", "--session", S_HOST, "--room", multiRoom]).stdout;
check("room floor 的人話版本說清楚誰該接、誰安靜",
floorText.includes("一對一") && floorText.includes("該接話的只有 Beta") && floorText.includes("先安靜:Omega"),
JSON.stringify(floorText));
const barge = cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "omega", "--as-guest",
"--text", "我也覺得那台鐘怪。"], { expectOk: false });
check("一對一進行中,旁人插話會被擋下",
barge.status !== 0 && barge.stderr.includes("一對一"), barge.stderr.slice(0, 200));
check("被指名的人可以接話",
cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "beta", "--as-guest",
"--to", "alpha", "--text", "還在慢三分鐘,我不修了。"]).status === 0);
check("帶理由的 --barge-in 可以插話(話題放大)",
cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "omega", "--as-guest",
"--barge-in", "話題轉到大家都要決定的事", "--to", "all", "--text", "那我們要不要一起去看那座鐘?"]).status === 0);
const floorOpen = JSON.parse(cli(["room", "floor", "--session", S_HOST, "--room", multiRoom, "--json"]).stdout);
check("--to all 之後發言權回到全場", floorOpen.mode === "open" && floorOpen.silent.length === 0,
JSON.stringify(floorOpen));
check("插話的理由留在逐字稿裡",
pl.roomRead(multiRoom).some((m) => m.speaker === "omega" && m.barge_in?.includes("大家都要決定")));
const multiScript = cli(["room", "script", "--session", S_HOST, "--room", multiRoom]).stdout;
check("三人以上的對話稿會標出對象(Alpha → Beta", /Alpha[^]* → Beta/.test(multiScript),
JSON.stringify(multiScript.slice(0, 200)));
check("--to 不在場的人會被擋下",
cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "alpha",
"--to", "不在場的人", "--text", "在嗎?"], { expectOk: false }).status !== 0);
check("劇場模式的 context 會說明一對一時旁人不要有台詞", (() => {
cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "alpha",
"--to", "beta", "--text", "beta,你自己一個人去嗎?"], { expectOk: true });
const ctxRoom = pl.turnContext("alpha", S_HOST, "繼續");
return ctxRoom.includes("一對一") && ctxRoom.includes("不要替他們生成台詞") && ctxRoom.includes("room floor");
})(), pl.turnContext("alpha", S_HOST, "繼續").slice(-500));
cli(["leave", "--session", S_HOST, "--guest", "beta"]);
cli(["leave", "--session", S_HOST, "--guest", "omega"]);
@@ -354,6 +393,30 @@ const longPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--
"--text", "第一句在這。第二句在這。第三句在這。第四句在這。"], { expectOk: false });
check("room post 擋下超過三句的發言",
longPost.status !== 0 && longPost.stderr.includes("超過"), longPost.stderr.slice(0, 160));
// 講話的樣子:短句、日常話、講完不解釋。
const longSentence = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
"--text", "這件事情其實從很久以前就一直放在我心裡面沒有講出來過,因為我一直覺得時機不太對而且你最近也真的很忙。"],
{ expectOk: false });
check("room post 擋下太長的句子",
longSentence.status !== 0 && longSentence.stderr.includes("字"), longSentence.stderr.slice(0, 200));
const explainPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
"--text", "我的意思是,那台鐘該修了。"], { expectOk: false });
check("room post 擋下解釋自己的話(我的意思是…)",
explainPost.status !== 0 && explainPost.stderr.includes("講完就算了"), explainPost.stderr.slice(0, 200));
check("--force 仍可放行(真的需要長台詞時)",
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
"--text", "我的意思是,那台鐘該修了。", "--force"]).status === 0);
check("日常短句照樣通過",
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
"--text", "桌上那杯茶都冷了。要不要再泡一杯?"]).status === 0);
const lintCheck = JSON.parse(cli(["said", "check", "--persona", "alpha", "--session", S_SPEAK,
"--text", "換句話說,我等一下再過去。", "--json"]).stdout);
check("said check 也會報講話的樣子",
lintCheck.ok === false && lintCheck.lint.some((i) => i.kind === "explain"), JSON.stringify(lintCheck.lint));
check("說話規則有注入「短句/日常用詞/看得見的東西/不要解釋」", (() => {
const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講");
return ["短句", "日常用詞", "看得見的東西", "不要解釋自己剛講的話"].every((w) => ctxSay.includes(w));
})(), pl.turnContext("alpha", S_SPEAK, "隨便講").slice(0, 600));
check("說出口的話有進 said.jsonl",
pl.readJsonl(pl.saidPath("alpha")).some((r) => String(r.text).includes("鐘修得比上次穩")));
hook("turn_end.mjs", { session_id: S_SPEAK, last_assistant_message: "🪼 Alpha(平靜50):那就這樣說定了。" });