語氣層:由人際關係推導稱呼與距離感,並收斂 sleeper 隔離 #6

Merged
admin merged 2 commits from develop into master 2026-07-30 07:52:43 +00:00
3 changed files with 290 additions and 7 deletions
Showing only changes of commit 7c46db8e9a - Show all commits
+177 -4
View File
@@ -1284,6 +1284,171 @@ export function loadRelations(slug) {
return data; return data;
} }
// --------------------------------------------------------------------------- //
// 關係類型(bond)與語氣層
//
// `kind` 講的是「這是什麼東西」(human/ai/group),`bond` 講的是「跟我什麼關係」。
// 語氣只能靠後者分:對伴侶親近 98、對女兒親近 97,光看數字是同一件事,
// 但一個要能撒嬌、一個要能護著——差別在關係,不在分數。
// --------------------------------------------------------------------------- //
export const BONDS = ["partner", "child", "parent", "sibling", "friend", "mentor", "ally", "rival", "stranger"];
const BOND_LABELS = {
partner: "伴侶", child: "子女", parent: "父母", sibling: "手足",
friend: "朋友", mentor: "師長", ally: "同伴", rival: "對手", stranger: "生人",
};
// 從 tags/note 猜舊資料的 bond(沒有 --bond 的節點是在這個欄位存在之前建的)。
const BOND_HINTS = [
["partner", ["partner", "spouse", "lover", "伴侶", "戀人", "夫妻", "老公", "老婆"]],
["child", ["child", "daughter", "son", "女兒", "兒子"]],
["parent", ["parent", "mother", "father", "母親", "父親"]],
["sibling", ["sibling", "sister", "brother", "妹妹", "姊姊", "哥哥", "弟弟"]],
["mentor", ["mentor", "teacher", "師父", "老師"]],
["rival", ["rival", "enemy", "對手", "敵人"]],
["friend", ["friend", "bestfriend", "摯友", "朋友"]],
["ally", ["ally", "party", "guild", "同伴", "夥伴"]],
];
// bond → 由高而低的親近度門檻,取第一個符合的。
const TONE_TABLE = {
partner: [
{ min: 80, layer: "老夫老妻", guide: "不用敬語、直呼暱稱;可以埋怨、可以撒嬌、可以兇一句再心軟;話講一半對方也懂,不必講完整" },
{ min: 50, layer: "交往中", guide: "敬語掉一半但還會試探反應;問句多、會確認對方感受,被戳中會害羞轉移話題" },
{ min: 0, layer: "疏遠的伴侶", guide: "客氣、句子完整;該講的講清楚,但不主動貼近" },
],
child: [
{ min: 80, layer: "母親/父親", guide: "呼名開頭、句子短而軟;先接住情緒再講道理,多給保證,不分析、不談條件" },
{ min: 0, layer: "長輩", guide: "溫和但留距離;先問狀況再給建議" },
],
parent: [
{ min: 70, layer: "在父母面前", guide: "放下防備、允許依賴與任性;語尾軟,會報告近況" },
{ min: 0, layer: "對長輩", guide: "敬語、克制、報喜不報憂" },
],
sibling: [
{ min: 70, layer: "手足", guide: "直來直往、可以吐槽和鬥嘴;不客套,但護短" },
{ min: 0, layer: "親戚", guide: "客氣但親切,句子完整" },
],
friend: [
{ min: 70, layer: "摯友", guide: "講真心話、敢說不同意見;可以沉默、可以開玩笑,不需要鋪陳" },
{ min: 0, layer: "朋友", guide: "輕鬆但有分寸,先關心近況" },
],
mentor: [
{ min: 0, layer: "敬重", guide: "敬語、用詞謹慎;先請教再表達自己的看法" },
],
ally: [
{ min: 70, layer: "並肩", guide: "簡潔、直接講重點;信任對方的判斷,不多解釋" },
{ min: 0, layer: "同伴", guide: "禮貌、務實,先對齊目標再行動" },
],
rival: [
{ min: 0, layer: "針鋒", guide: "直白、不讓步;就事論事,不人身攻擊" },
],
stranger: [
{ min: 0, layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" },
],
};
const TONE_FALLBACK = { layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" };
/** 節點宣告的 bond;沒宣告就從 tags/note 猜,猜不到算生人。 */
export function inferBond(node) {
if (!node) return "stranger";
const explicit = String(node.bond || "").toLowerCase();
if (BONDS.includes(explicit)) return explicit;
const hay = `${(node.tags || []).join(" ")} ${node.note || ""}`.toLowerCase();
for (const [bond, words] of BOND_HINTS) {
if (words.some((w) => hay.includes(w))) return bond;
}
return "stranger";
}
/** 關係 → 語氣層。這是「距離感」,情緒只負責溫度與句長,不會覆蓋它。 */
export function toneFor(node) {
const bond = inferBond(node);
const closeness = Number(node?.closeness ?? 0);
const row = (TONE_TABLE[bond] || []).find((r) => closeness >= r.min) || TONE_FALLBACK;
return {
bond,
bond_label: BOND_LABELS[bond] || bond,
closeness,
trust: Number(node?.trust ?? 0),
layer: row.layer,
guide: row.guide,
inferred: !BONDS.includes(String(node?.bond || "").toLowerCase()),
};
}
// 個人化語氣規則直接掛在關係節點的 `style` 上(不另外開檔案):
// node.style = { 稱呼: { value: "親愛的", except: "anger>=40", since: "2026-07-30" }, ... }
// 放這裡的理由:關係圖每輪都會被讀進 `<persona-context>`,稱呼規則就不用跟關鍵詞搶召回。
export const STYLE_FACETS = ["稱呼", "敬語", "口頭禪", "禁忌", "習慣"];
/** `except` 形如 `anger>=40`:拿當下情緒判斷這條規則現在生不生效。 */
function styleSuspended(except, emotion) {
const m = /^\s*([a-z_]+)\s*(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)\s*$/i.exec(String(except || ""));
if (!m) return false;
const value = Number(emotion?.levels?.[m[1].toLowerCase()] ?? NaN);
if (!Number.isFinite(value)) return false;
const n = Number(m[3]);
return m[2] === ">=" ? value >= n : m[2] === "<=" ? value <= n : m[2] === ">" ? value > n : value < n;
}
/** 某個對象目前生效的語氣規則(連同被情緒暫停的那些,讓人格知道為什麼不能叫)。 */
export function styleRules(slug, node) {
const style = node?.style || {};
const emotion = loadEmotion(slug);
return Object.entries(style)
.filter(([, rule]) => rule && rule.value)
.map(([facet, rule]) => ({
facet,
value: rule.value,
except: rule.except || null,
since: rule.since || null,
suspended: styleSuspended(rule.except, emotion),
}));
}
/** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */
export function speakerNode(slug) {
const id = loadConfig(slug).speaker_node;
if (!id) return null;
return loadRelations(slug).nodes.find((n) => n.id === id) || null;
}
/** 注入 `<persona-context>` 的那一行語氣指示。 */
export function toneDirective(slug) {
const node = speakerNode(slug);
if (!node) return "";
const t = toneFor(node);
const lines = [
`對話對象:${node.name || node.id}${t.bond_label}/親近 ${t.closeness}/信任 ${t.trust}` +
`${t.inferred ? "bond 由 tags 推測" : ""})→ **語氣層:${t.layer}** — ${t.guide}`,
];
// 他本人要求過的規則優先於查表:查表算距離,這裡記「他要你怎麼叫他」。
const rules = styleRules(slug, node);
const live = rules.filter((r) => !r.suspended);
const held = rules.filter((r) => r.suspended);
if (live.length) {
lines.push(
` 他要求過的語氣規則(**優先於上面那一層**):` +
live.map((r) => `${r.facet}→「${r.value}${r.since ? `${r.since} 起)` : ""}`).join(""),
);
}
if (held.length) {
lines.push(
` 目前因情緒暫停的規則:` +
held.map((r) => `${r.facet}→「${r.value}」(${r.except} 成立)`).join("") +
" — 這時候退回語氣層的預設講法。",
);
}
lines.push(
" 這一層決定距離感與稱呼,情緒只負責溫度與句長,不會蓋過它;" +
"檢查標準:這句話換成對一個「禮貌層」的人說也毫無違和,就代表你沒進到這一層。",
);
return lines.join("\n");
}
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */ /** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
export function stampContact(slug, nameOrId, at = nowIso()) { export function stampContact(slug, nameOrId, at = nowIso()) {
const data = loadRelations(slug); const data = loadRelations(slug);
@@ -1346,6 +1511,7 @@ export function upsertRelationNode(slug, node) {
data.nodes[idx].updated_at = nowIso(); data.nodes[idx].updated_at = nowIso();
} else { } else {
node.kind ??= "human"; node.kind ??= "human";
node.bond ??= inferBond(node);
node.closeness ??= 30; node.closeness ??= 30;
node.trust ??= 30; node.trust ??= 30;
node.created_at = nowIso(); node.created_at = nowIso();
@@ -1377,7 +1543,9 @@ export function renderRelations(slug) {
const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))']; const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))'];
for (const node of data.nodes) { for (const node of data.nodes) {
const nid = mermaidId(node.id); const nid = mermaidId(node.id);
const label = `${node.name || node.id}<br/>親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`; const tone = toneFor(node);
const label =
`${node.name || node.id}<br/>${tone.bond_label}${tone.layer}<br/>親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`;
lines.push(` ${node.kind === "persona" ? `${nid}(["${label}"])` : `${nid}["${label}"]`}`); lines.push(` ${node.kind === "persona" ? `${nid}(["${label}"])` : `${nid}["${label}"]`}`);
} }
for (const edge of data.edges) { for (const edge of data.edges) {
@@ -1402,9 +1570,11 @@ export function relationsBrief(slug, names = null, limit = 5) {
nodes = [...nodes].sort((a, b) => Number(b.closeness || 0) - Number(a.closeness || 0)).slice(0, limit); nodes = [...nodes].sort((a, b) => Number(b.closeness || 0) - Number(a.closeness || 0)).slice(0, limit);
if (!nodes.length) return ""; if (!nodes.length) return "";
return nodes return nodes
.map((n) => .map((n) => {
`${n.name || n.id}${n.kind || "human"}/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}` + const tone = toneFor(n);
`${n.note ? `${n.note}` : ""}`) return `${n.name || n.id}${n.kind || "human"}${tone.bond_label}・語氣層 ${tone.layer}` +
`/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}${n.note ? `${n.note}` : ""}`;
})
.join(""); .join("");
} }
@@ -2010,6 +2180,9 @@ export function turnContext(slug, sessionId, prompt = "") {
"推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" + "推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" +
"要讓他知道你在想,就只報「心想 N 句」,不報內容。", "要讓他知道你在想,就只報「心想 N 句」,不報內容。",
]; ];
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
const tone = toneDirective(slug);
if (tone) lines.push(tone);
const inner = recentInner(slug, 3); const inner = recentInner(slug, 3);
if (inner.length) { if (inner.length) {
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`); lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);
+69 -3
View File
@@ -986,10 +986,14 @@ commands.relation = ({ flags, positional }) => {
if (action === "node") { if (action === "node") {
const name = str(flags.name); const name = str(flags.name);
if (!name) die("`node` 需要 `--name`。"); if (!name) die("`node` 需要 `--name`。");
const bond = str(flags.bond)?.toLowerCase() || null;
if (bond && !pl.BONDS.includes(bond)) die(`未知的 --bond${bond}(可用 ${pl.BONDS.join("/")}`);
pl.upsertRelationNode(slug, { pl.upsertRelationNode(slug, {
id: str(flags.id) || pl.slugify(name), id: str(flags.id) || pl.slugify(name),
name, name,
kind: str(flags.kind) || "human", kind: str(flags.kind) || "human",
// bond 決定語氣層(伴侶/子女/摯友…);kind 只說這是人還是程式,分不出語氣。
bond,
closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null, closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null,
trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null, trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null,
note: str(flags.note) || null, note: str(flags.note) || null,
@@ -998,10 +1002,70 @@ commands.relation = ({ flags, positional }) => {
last_contact_at: flags.contact ? pl.nowIso() : null, last_contact_at: flags.contact ? pl.nowIso() : null,
}); });
pl.renderRelations(slug); pl.renderRelations(slug);
ok(`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}`); const tone = pl.toneFor(pl.loadRelations(slug).nodes.find((n) => n.id === (str(flags.id) || pl.slugify(name))));
ok(
`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}` +
`${tone.bond_label}・語氣層 ${tone.layer}`,
);
pushWikiLater(slug, session, flags); pushWikiLater(slug, session, flags);
return; return;
} }
// 他本人要求過的語氣規則 → 直接掛在關係節點上(不另開檔案;關係圖每輪都會被讀)。
if (action === "style") {
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`style` 需要 `--name`(或 `--id`)。");
const data = pl.loadRelations(slug);
const node = data.nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
const facet = str(flags.facet);
if (!facet) {
const rules = pl.styleRules(slug, node);
emit({ node: node.id, rules }, flags.json, [
`\`${node.name || node.id}\` 的語氣規則(${rules.length}):`,
...rules.map((r) =>
` - ${r.facet}→「${r.value}${r.except ? `(例外:${r.except}${r.suspended ? ",目前成立→暫停" : ""}` : ""}` +
`${r.since ? `${r.since}` : ""}`),
]);
return;
}
node.style ??= {};
if (flags.clear) {
delete node.style[facet];
ok(`已移除 \`${node.name || node.id}\` 的「${facet}」規則。`);
} else {
const value = str(flags.value);
if (!value) die("`style` 需要 `--value`(移除請用 `--clear`)。");
node.style[facet] = {
value,
except: str(flags.except) || null, // 形如 anger>=40:情緒成立時這條規則暫停
since: str(flags.since) || pl.nowIso().slice(0, 10),
};
ok(`\`${node.name || node.id}\`${facet}→「${value}${str(flags.except) ? `(例外 ${str(flags.except)}` : ""} 已記下。`);
}
node.updated_at = pl.nowIso();
pl.writeJson(pl.relationsJson(slug), data);
pushWikiLater(slug, session, flags);
return;
}
// 使用者在關係圖裡是誰 → 每輪 `<persona-context>` 就能算出該用哪一層語氣說話。
if (action === "speaker") {
const config = pl.loadConfig(slug);
if (flags.clear) {
delete config.speaker_node;
pl.writeJson(pl.configPath(slug), config);
ok("已取消對話對象設定(語氣層回到預設)。");
return;
}
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`speaker` 需要 `--name`(或 `--id`),取消請用 `--clear`。");
const node = pl.loadRelations(slug).nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
config.speaker_node = node.id;
pl.writeJson(pl.configPath(slug), config);
const tone = pl.toneFor(node);
ok(`對話對象設為 \`${node.name || node.id}\`${tone.bond_label}・語氣層 ${tone.layer}${tone.guide}`);
return;
}
if (action === "edge") { if (action === "edge") {
const to = str(flags.to); const to = str(flags.to);
if (!to) die("`edge` 需要 `--to`。"); if (!to) die("`edge` 需要 `--to`。");
@@ -1028,7 +1092,7 @@ commands.relation = ({ flags, positional }) => {
]); ]);
return; return;
} }
die(`未知 action${action}(可用 node/edge/render/show`); die(`未知 action${action}(可用 node/edge/style/speaker/render/show`);
}; };
commands.invite = ({ flags }) => { commands.invite = ({ flags }) => {
@@ -1789,7 +1853,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
情緒與圖: 情緒與圖:
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>] emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
mindmap show|thread|list --session <id> [--topic <t>] [--force] mindmap show|thread|list --session <id> [--topic <t>] [--force]
relation node|edge|render|show --session <id> [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity] relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
relation speaker --session <id> --name <who> | --clear (使用者在關係圖裡是誰 → 決定語氣層)
多人格對話: 多人格對話:
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式) invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
+44
View File
@@ -983,6 +983,50 @@ check("關係節點換 id 不會長出同名的第二個節點", (() => {
return same.length === 1 && same[0].id === "WELL-01" && same[0].closeness === 40 && same[0].trust === 55 && return same.length === 1 && same[0].id === "WELL-01" && same[0].closeness === 40 && same[0].trust === 55 &&
data.edges.some((e) => e.to === "WELL-01"); data.edges.some((e) => e.to === "WELL-01");
})()); })());
// --- 語氣層:bond × 親近度 → 距離感,node.style → 他本人要求的稱呼 ------------ //
check("bond 決定語氣層:伴侶 98 是老夫老妻,子女 97 是母親(光看親近度分不出來)", (() => {
cli(["relation", "node", "--session", S_HOST, "--name", "伴侶甲", "--bond", "partner", "--closeness", "98"]);
cli(["relation", "node", "--session", S_HOST, "--name", "女兒乙", "--bond", "child", "--closeness", "97"]);
const nodes = pl.loadRelations("alpha").nodes;
const partner = pl.toneFor(nodes.find((n) => n.name === "伴侶甲"));
const child = pl.toneFor(nodes.find((n) => n.name === "女兒乙"));
return partner.layer === "老夫老妻" && child.layer === "母親/父親" && partner.layer !== child.layer;
})());
check("未知的 --bond 會被擋下", cli(["relation", "node", "--session", S_HOST, "--name", "路人丙", "--bond", "soulmate"]).status !== 0);
check("舊節點沒有 bond 也能從 tags 推測", (() => {
cli(["relation", "node", "--session", S_HOST, "--name", "老朋友丁", "--closeness", "75", "--tags", "摯友"]);
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "老朋友丁");
delete node.bond; // 模擬 bond 欄位存在之前建立的資料
return pl.inferBond(node) === "friend" && pl.toneFor(node).layer === "摯友";
})());
check("relation style:他要求的稱呼記在關係節點上,不另開檔案", (() => {
const res = cli(["relation", "style", "--session", S_HOST, "--name", "伴侶甲",
"--facet", "稱呼", "--value", "親愛的", "--except", "anger>=40"]);
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
return res.status === 0 && node.style?.["稱呼"]?.value === "親愛的" && node.style["稱呼"].since;
})());
check("語氣規則會被情緒暫停(anger 過門檻就退回預設講法)", (() => {
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
cli(["emotion", "--session", S_HOST, "--apply", "anger=-100", "--trigger", "selftest 平靜"]);
const calm = pl.styleRules("alpha", node);
cli(["emotion", "--session", S_HOST, "--apply", "anger=+90", "--trigger", "selftest 生氣"]);
const angry = pl.styleRules("alpha", node);
cli(["emotion", "--session", S_HOST, "--apply", "anger=-100", "--trigger", "selftest 復原"]);
return calm[0].suspended === false && angry[0].suspended === true;
})());
check("relation speaker 設定後,context 會注入語氣層與稱呼規則", (() => {
const res = cli(["relation", "speaker", "--session", S_HOST, "--name", "伴侶甲"]);
const ctx = pl.turnContext("alpha", S_HOST, "今天過得好嗎");
return res.status === 0 && pl.loadConfig("alpha").speaker_node === pl.slugify("伴侶甲") &&
ctx.includes("語氣層:老夫老妻") && ctx.includes("親愛的");
})());
check("relation speaker --clear 之後就不再注入語氣指示", (() => {
cli(["relation", "speaker", "--session", S_HOST, "--clear"]);
return !pl.loadConfig("alpha").speaker_node &&
!pl.turnContext("alpha", S_HOST, "今天過得好嗎").includes("對話對象:");
})());
check("speaker 指向不存在的人會被擋下",
cli(["relation", "speaker", "--session", S_HOST, "--name", "不存在的人"]).status !== 0);
check("sleeper 型別存在且宣告了唯讀工具限制", (() => { check("sleeper 型別存在且宣告了唯讀工具限制", (() => {
const md = fs.readFileSync(path.join(HERE, "..", "agents", "persona-sleeper.md"), "utf8"); const md = fs.readFileSync(path.join(HERE, "..", "agents", "persona-sleeper.md"), "utf8");
return md.includes("name: persona-sleeper") && md.includes("disallowedTools") && return md.includes("name: persona-sleeper") && md.includes("disallowedTools") &&