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