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
+82 -7
View File
@@ -804,11 +804,14 @@ commands.said = ({ flags, positional }) => {
);
}
if (tooLong) lines.push(`⚠ 這段有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限 → 砍到重點。`);
if (!repeat && !tooLong) {
const lint = pl.speechLint(text);
for (const issue of lint) lines.push(`${pl.speechLintMessage(issue)}`);
const clean = !repeat && !tooLong && !lint.length;
if (clean) {
lines.push(`✔ 沒說過,${sentences} 句,可以說。`);
if (flags.record) pl.recordSaid(slug, text, { kind: "reply" });
}
emit({ persona: slug, repeat, sentences, ok: !repeat && !tooLong }, flags.json, lines);
emit({ persona: slug, repeat, sentences, lint, ok: clean }, flags.json, lines);
return;
}
die(`未知 action${action}(可用 check/list`);
@@ -1214,10 +1217,68 @@ commands.room = ({ flags, positional }) => {
if (!(data.rooms || []).includes(room)) {
die(`聊天室 \`${room}\` 不屬於本 session(可用:${(data.rooms || []).join(", ") || "(無)"})。`);
}
const members = (pl.readJson(pl.roomMembersPath(room), {}) ?? {}).members || [];
const nameOf = (slug) => pl.roomDisplayName(slug);
/** `--to`:對誰說。接 slug、人格的 Name,或 all/大家(對全場)。 */
const resolveAddressee = (raw) => {
const value = String(raw).trim();
if (/^(all|全部|大家|全場|everyone)$/i.test(value)) return pl.ROOM_ALL;
const hit =
members.find((m) => m.toLowerCase() === value.toLowerCase()) ||
members.find((m) => nameOf(m) === value);
if (!hit) {
die(
`\`${value}\` 不在聊天室 \`${room}\` 裡(成員:${members.map((m) => `${nameOf(m)}${m}`).join("、") || "(無)"})。` +
"對全場說話用 `--to all`。",
);
}
return hit;
};
if (action === "floor") {
const floor = pl.roomFloor(room);
const lines = [`聊天室 \`${room}\`|成員 ${floor.members.map(nameOf).join("、") || "(無)"}|主題 ${floor.topic || "-"}`];
if (!floor.last) {
lines.push("還沒有人發言:誰開場都可以。");
} else if (floor.mode === "dyad") {
lines.push(
`現在:${nameOf(floor.last.speaker)}${nameOf(floor.addressee)}(一對一)`,
` 該接話的只有 ${nameOf(floor.next)};先安靜:${floor.silent.map(nameOf).join("、") || "(無)"}` +
"(不要替他們寫台詞,也不要為此啟動他們的 sub agent",
" 話題真的放大了才動:當事人下一句加 `--to all` 開回全場,或旁人 " +
'`room post --as <他> --barge-in "<為什麼現在該他講>"`。',
);
} else {
lines.push(`現在:全場開放(${nameOf(floor.last.speaker)} 對大家說)→ 誰接都可以,一次讓一個人格接一輪。`);
}
const cold = floor.quiet.filter((q) => !q.spoken || q.turns_since >= 4);
if (cold.length) {
lines.push(
`很久沒講話:${cold.map((q) => `${nameOf(q.persona)}${q.spoken ? `${q.turns_since} 輪前` : "還沒開口"}`).join("、")}` +
" — 話題碰到他的專長或使用者點名時再拉進來,不要為了公平硬派台詞。",
);
}
emit(floor, flags.json, lines);
return;
}
if (action === "post") {
const speaker = str(flags.as) || data.host;
if (!speaker) die("需要 `--as <persona>`。");
const [, speakerRole] = requireMember(speaker, session, Boolean(flags["as-guest"]));
const to = flags.to ? resolveAddressee(flags.to) : null;
if (to === speaker) die("`--to` 不能指向自己(那是心裡話,請用 `think`)。");
// 一對一進行中,旁人要插話得說得出理由——「大家都在同一個空間」不等於每句都該有人接。
const { allowed, floor } = pl.roomMayPost(room, speaker);
const bargeIn = str(flags["barge-in"]) || str(flags.broaden);
if (!allowed && !bargeIn && !flags.force) {
die(
`現在是 ${nameOf(floor.pair[0])}${nameOf(floor.pair[1])} 的一對一` +
`(最後一句是 ${nameOf(floor.last.speaker)}${nameOf(floor.addressee)} 說的),` +
`${nameOf(speaker)} 不該接這句——旁人插嘴會把兩個人的話變成公開場面。\n` +
" 話題真的放大了(出現「我們/大家」、需要第二意見、講到他的專長、使用者點名)才插話," +
'並帶 `--barge-in "<為什麼現在該他講>"`;否則讓當事人先用 `--to all` 把話題開回全場。',
);
}
let text = str(flags.text);
if (flags["text-file"]) text = fs.readFileSync(str(flags["text-file"]), "utf8").trim();
if (!text) die("需要 `--text` 或 `--text-file`。");
@@ -1246,16 +1307,25 @@ commands.room = ({ flags, positional }) => {
"(真的需要長段落才加 `--force`。)",
);
}
// 短句、日常話、講完不解釋——講話的樣子也擋在 CLI 裡,不靠自律。
const lint = pl.speechLint(text);
if (lint.length && !flags.force) {
die(`講話規則:${lint.map(pl.speechLintMessage).join("")}。(真的需要才加 \`--force\`。)`);
}
let emotion = str(flags.emotion);
if (!emotion && pl.personaExists(speaker)) {
emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
.join("/");
}
const entry = pl.roomPost(room, speaker, text, { emotion });
const entry = pl.roomPost(room, speaker, text, { emotion, to, bargeIn });
// guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到)
if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" });
ok(`\`${speaker}\` 已發言於 \`${room}\`(情緒 ${emotion})。`);
const dyad = to && to !== pl.ROOM_ALL;
ok(
`\`${speaker}\` 已發言於 \`${room}\`${dyad ? `${nameOf(to)}` : "對全場"}/情緒 ${emotion})。` +
(dyad ? `接下來只有 ${nameOf(to)} 該回話,其他人先安靜。` : ""),
);
if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`);
return;
}
@@ -1264,7 +1334,8 @@ commands.room = ({ flags, positional }) => {
const meta = pl.readJson(pl.roomMembersPath(room), {}) ?? {};
const lines = [`聊天室 \`${room}\`|成員 ${(meta.members || []).join(", ")}|主題 ${meta.topic || "-"}`];
for (const row of rows) {
lines.push(`[${row.ts}] ${row.speaker}${row.emotion ? `${row.emotion}` : ""}${row.text}`);
const arrow = row.to && row.to !== pl.ROOM_ALL && row.to !== row.speaker ? `${row.to}` : "";
lines.push(`[${row.ts}] ${row.speaker}${row.emotion ? `${row.emotion}` : ""}${arrow}${row.text}`);
}
emit({ room, meta, messages: rows }, flags.json, lines);
return;
@@ -1275,7 +1346,7 @@ commands.room = ({ flags, positional }) => {
process.stdout.write(`${text}\n`);
return;
}
die(`未知 action${action}(可用 post/read/script/list/theater`);
die(`未知 action${action}(可用 post/read/script/floor/list/theater`);
};
/**
@@ -1867,8 +1938,12 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
多人格對話:
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
leave --session <id> --guest <slug> [--room]
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
room post|read|script|floor|list|theater --session <id> [--room --as --to --barge-in --text --text-file
--emotion --limit --on --off --with-meta]
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
--to <slug|名字|all> 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶
--barge-in "<理由>");--to all → 把話題開回全場。
floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口
圖示(建立人格並補齊資料後跑):
icon search|measure|faces|headshot|cutout|generate|show --session <id>