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
+128 -3
View File
@@ -1182,6 +1182,44 @@ export function sentenceCount(text) {
.filter(Boolean).length;
}
export const MAX_SENTENCE_CHARS = 45;
// 講完就算了:這些開頭都是在解釋自己剛才講的話,真的在講話的人不會這樣。
export const EXPLAINER_OPENERS = [
"我的意思是", "我是想說", "換句話說", "換個說法", "也就是說", "換言之",
"簡單來說", "簡單講", "我剛才說的意思是", "澄清一下", "補充一下",
];
/**
* 講話自然度的機械檢查:一句太長、或在解釋自己剛講的話。
* 「用日常詞、講看得見的東西」抓不到規則,那兩條寫在說話規則裡(見 turnContext)。
*/
export function speechLint(text) {
const issues = [];
const sentences = String(text ?? "")
.split(/[。!?!?…]+|\n+/)
.map((s) => s.trim())
.filter(Boolean);
for (const sentence of sentences) {
const bare = sentence.replace(/[,、,;:「」『』()()\s"'~~—-]/g, "");
if ([...bare].length > MAX_SENTENCE_CHARS) {
issues.push({ kind: "long", chars: [...bare].length, text: sentence.slice(0, 30) });
}
const head = sentence.replace(/^[「『((]+/, "");
const opener = EXPLAINER_OPENERS.find((w) => head.startsWith(w));
if (opener) issues.push({ kind: "explain", opener, text: sentence.slice(0, 30) });
}
return issues;
}
/** 把 speechLint 的問題講成人聽得懂的一句話(CLI 與 hook 共用)。 */
export function speechLintMessage(issue) {
if (issue.kind === "long") {
return `${issue.text}…」這句 ${issue.chars} 字,超過 ${MAX_SENTENCE_CHARS} 字——斷成兩句,或把修飾砍掉`;
}
return `${issue.opener}」是在解釋自己剛講的話——刪掉,講完就算了`;
}
/** 把一則回覆拆成「說出口的句子」:劇場模式一行一句,一般模式整段算一句。 */
export function spokenLines(text, { theater = false } = {}) {
const raw = String(text ?? "").trim();
@@ -1611,16 +1649,73 @@ export function joinRoom(room, persona) {
return meta;
}
export function roomPost(room, speaker, text, { emotion = "", kind = "say" } = {}) {
const entry = { ts: nowIso(), speaker, kind, text, emotion };
/** `--to all`:這句話是對全場說的(發言權開放)。 */
export const ROOM_ALL = "all";
export function roomPost(room, speaker, text, { emotion = "", kind = "say", to = null, bargeIn = null } = {}) {
const entry = { ts: nowIso(), speaker, kind, text, emotion, to: to || ROOM_ALL };
if (bargeIn) entry.barge_in = String(bargeIn).slice(0, 200);
appendJsonl(roomTranscript(room), entry);
return entry;
}
export const roomRead = (room, limit = 30) => readJsonl(roomTranscript(room), limit);
/** 這個聊天室裡的顯示名(拿不到身分就用 slug)。 */
export function roomDisplayName(slug) {
if (!slug || slug === ROOM_ALL) return "全場";
return (personaExists(slug) ? identityFields(slug).Name : "") || slug;
}
/**
* 現在的發言權在誰手上。
*
* 同一個空間裡也會有一對一:最後一句是對某個人說的(`--to <他>`)就進入 `dyad`
* 這時候旁人不該接話——插嘴會把兩個人的私下對話變成公開場面。話題放大(`--to all`
* 或旁人帶理由 `--barge-in`)才回到 `open`。這是 `room post` 的門檻,也是每輪注入
* `<persona-context>`、告訴人格「這句該不該由我接」的依據。
*/
export function roomFloor(room) {
const meta = readJson(roomMembersPath(room), {}) ?? {};
const members = meta.members || [];
const says = roomRead(room, 80).filter((m) => m.kind !== "meta" && m.speaker !== "system");
const last = says[says.length - 1] || null;
const to = last && last.to && last.to !== ROOM_ALL && last.to !== last.speaker ? last.to : null;
const pair = to ? [last.speaker, to] : [];
// 每個成員距離上次發言隔了幾輪:話題放大時要拉誰進來,看這個比看誰比較可憐準。
const quiet = members
.map((persona) => {
for (let i = says.length - 1, turns = 0; i >= 0; i -= 1, turns += 1) {
if (says[i].speaker === persona) return { persona, turns_since: turns, spoken: true };
}
return { persona, turns_since: says.length, spoken: false };
})
.sort((a, b) => b.turns_since - a.turns_since);
return {
room,
members,
topic: meta.topic || "",
mode: to ? "dyad" : "open",
last: last ? { speaker: last.speaker, to: last.to || ROOM_ALL, text: last.text, ts: last.ts } : null,
pair,
addressee: to,
next: to,
silent: to ? members.filter((m) => !pair.includes(m)) : [],
quiet,
};
}
/** 這個人格現在插得進話嗎?(一對一進行中、他又不是當事人 → 要有理由) */
export function roomMayPost(room, speaker) {
const floor = roomFloor(room);
return { allowed: floor.mode !== "dyad" || floor.pair.includes(speaker), floor };
}
/** 劇場模式的對話呈現:`emoji 名字(情緒):內容`,其餘一律不輸出。 */
export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
const meta = readJson(roomMembersPath(room), {}) ?? {};
// 三人以上才標「對誰講」:只有兩個人的時候那是廢話。
const crowded = (meta.members || []).length > 2;
const lines = [];
for (const msg of roomRead(room, limit)) {
if (msg.kind === "meta" || msg.speaker === "system") {
@@ -1631,7 +1726,8 @@ export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
const ident = personaExists(slug) ? identityFields(slug) : {};
const name = ident.Name || slug;
const emoji = ident.Emoji ? `${ident.Emoji} ` : "";
lines.push(`${emoji}${name}${msg.emotion ? `${msg.emotion}` : ""}${msg.text}`);
const arrow = crowded && msg.to && msg.to !== ROOM_ALL && msg.to !== slug ? `${roomDisplayName(msg.to)}` : "";
lines.push(`${emoji}${name}${msg.emotion ? `${msg.emotion}` : ""}${arrow}${msg.text}`);
}
return lines.join("\n");
}
@@ -2179,6 +2275,10 @@ export function turnContext(slug, sessionId, prompt = "") {
`說話規則:回使用者 ${MAX_SENTENCES} 句以內(劇場模式每人每輪也一樣);` +
"推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" +
"要讓他知道你在想,就只報「心想 N 句」,不報內容。",
`講話像講話:**短句**(一句 ${MAX_SENTENCE_CHARS} 字內,長了就斷開)、**日常用詞**(不用書面語、` +
"不用術語、不用成語堆疊)、**多講看得見的東西**(人、動作、東西、當下的場面)而不是概念與感想;" +
"**講完就算了**——不要解釋自己剛講的話(沒有「我的意思是」「換句話說」「也就是說」)," +
"不要補註解、不要收尾總結。這幾條 `room post` 與 `said check` 會實際擋下。",
];
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
const tone = toneDirective(slug);
@@ -2240,6 +2340,31 @@ export function turnContext(slug, sessionId, prompt = "") {
" 不得出現指令、指令輸出、狀態說明、進度、摘要、分析或旁白;所有 CLI 一律加 `--quiet` 並把輸出丟掉。",
` 每個人格每輪 ${MAX_SENTENCES} 句以內;推導走 \`think\`(心裡話);`,
" 近似重複的台詞 `room post` 會直接擋下,換個說法或推進話題,不要硬講同一句。",
);
// 同一個空間裡也會有一對一:對象只有一個人時,旁人不該有台詞。
const floor = roomFloor(rooms[rooms.length - 1]);
if (floor.mode === "dyad") {
lines.push(
` 現在是一對一:${roomDisplayName(floor.last.speaker)}${roomDisplayName(floor.addressee)}` +
`只有 ${roomDisplayName(floor.next)} 該接這句` +
(floor.silent.length
? `${floor.silent.map(roomDisplayName).join("、")} 先安靜——**不要替他們生成台詞,也不要為此啟動他們的 sub agent**。`
: "。"),
);
} else if (floor.last) {
lines.push(" 現在發言權開放(上一句是對全場說的):誰接都可以,但一輪只讓一個人格接。");
}
const cold = floor.quiet.filter((q) => !q.spoken || q.turns_since >= 4);
if (cold.length && floor.members.length > 2) {
lines.push(
` 很久沒講話:${cold.map((q) => roomDisplayName(q.persona)).join("、")}` +
" — 話題碰到他的專長、需要第二意見或使用者點名時才拉進來,不要為了公平硬派台詞。",
);
}
lines.push(
" 發言權由話題決定:講給一個人聽就 `room post --to <他>`;話題放大(出現「我們/大家」、" +
"需要仲裁、要一起決定)就 `--to all` 開回全場,旁人也可以帶 `--barge-in \"<理由>\"` 加入。" +
"現況隨時可查:`room floor`。",
" 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。",
);
} else {