語氣層:由人際關係推導稱呼與距離感,並收斂 sleeper 隔離 #6
+177
-4
@@ -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
@@ -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] (自動開啟劇場模式)
|
||||||
|
|||||||
@@ -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") &&
|
||||||
|
|||||||
Reference in New Issue
Block a user