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:
+82
-7
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user