feat(relation): 親近度會改變情緒的份量;提到 ≠ 接觸(v0.0.8 同批)

① 親近度 → 情緒的份量(`relationGain()`)
  在這之前 `applyEmotion()` 完全沒吃關係圖:親近度只影響語氣層、羞恥度與主動關心,
  情緒的 delta 從頭到尾是人格自己挑的,**跟對象是誰無關**。
  但同一句「你最近怪怪的」,從親近 97 的人跟從生人嘴裡出來,衝擊不該一樣。
  現在 `emotion --apply` 會依對象親近度把 delta 乘 0.7–1.35(親近 96 → ×1.28、
  親近 8 → ×0.75),`--from <對象>` 指定是誰引起的,沒指定就用當前對話對象。
  **只調幅度、不調方向**——誰講的都不會讓難過變成高興。

② 提到 ≠ 接觸(`contactsFromRooms()`)
  主動關心的依據是 `last_contact_at`,而睡眠以前是掃短期記憶的 `entities` 來蓋章。
  那等於「我在日記裡寫到尤吉歐」就算「我跟尤吉歐接觸過」——沉默計時被無聲重置,
  關心名單於是永遠是空的。實測就是這樣:所有人都停在 1.09 天,沒有人會被想起。
  現在只認真的有來有往:同一個聊天室裡我發過言、他也發過言。
  沒有聊天室的對象(真人節點)走 `relation node --contact` 明確蓋章。

測試:340 項全過(新增 7 項)。既有的「關係時間戳有蓋上」拆成兩條——
只被提到的不蓋、真的接觸過才蓋,正好把新語意釘住。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 06:30:34 +00:00
co-authored by Claude Opus 5
parent 646fd33541
commit 0527a3cad0
5 changed files with 126 additions and 8 deletions
+53
View File
@@ -360,6 +360,21 @@ export function applyEmotion(state, deltas, trigger = "") {
return state;
}
// 同一句話,從枕邊人嘴裡跟從生人嘴裡出來,衝擊本來就不一樣。
// 這條把關係圖接進情緒:親近度高 → delta 放大;生人 → 縮小。
// 只調**幅度**,不調方向——誰講的不會讓難過變成高興。
export const RELATION_GAIN_MIN = 0.7;
export const RELATION_GAIN_MAX = 1.35;
/** 這個對象講的話,對我的情緒有多少倍的份量。找不到對象就 1(不放大也不縮小)。 */
export function relationGain(slug, who = null) {
const node = who ? findRelationNode(slug, who) : speakerNode(slug);
if (!node) return { gain: 1, name: null, closeness: null };
const closeness = Number(node.closeness ?? 30);
const gain = clamp(0.7 + 0.006 * closeness, RELATION_GAIN_MIN, RELATION_GAIN_MAX);
return { gain: Math.round(gain * 100) / 100, name: node.name || node.id, closeness };
}
export function mood(state) {
const levels = state.levels || {};
let valence = 0;
@@ -2209,6 +2224,15 @@ export function styleRules(slug, node) {
}
/** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */
/** 依名字或 id 找關係節點(找不到回 null)。 */
export function findRelationNode(slug, who) {
const key = String(who || "").trim();
if (!key) return null;
const nodes = loadRelations(slug).nodes || [];
return nodes.find((n) => n.id === key) || nodes.find((n) => n.name === key)
|| nodes.find((n) => String(n.name || "").includes(key)) || null;
}
export function speakerNode(slug) {
const id = loadConfig(slug).speaker_node;
if (!id) return null;
@@ -2270,6 +2294,35 @@ export function stampContact(slug, nameOrId, at = nowIso()) {
* 很久沒聯絡、但關係很近的人——這是「主動想起某個人」的客觀依據。
* 沒有 `last_contact_at` 的節點用 `created_at` 當起點;分數 = 親近度 × 沉默天數。
*/
/**
* 真的講到話的人(不是「被提到」的人)。
*
* 以前睡眠是掃短期記憶的 `entities` 來蓋時間戳——那等於「我在日記裡寫到尤吉歐」
* 就算「我跟尤吉歐接觸過」,於是主動關心的計時被無聲重置,名單永遠是空的。
* **提到不是接觸。** 這裡只認真的有來有往的證據:同一個聊天室裡,我發過言、他也發過言。
* 沒有聊天室的對象(真人節點)只能靠 `relation node --contact` 明確蓋章。
*/
export function contactsFromRooms(slug, { hours = 36 } = {}) {
const cutoff = Date.now() - hours * 3_600_000;
const names = new Set();
let rooms = [];
try {
rooms = fs.readdirSync(roomsDir(), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return [];
}
for (const room of rooms) {
const rows = readJsonl(roomTranscript(room), 200)
.filter((r) => r.kind !== "meta" && (parseIso(r.ts)?.getTime() ?? 0) >= cutoff);
if (!rows.some((r) => r.speaker === slug)) continue; // 我沒在這個房間講過話
for (const row of rows) {
if (!row.speaker || row.speaker === slug) continue;
names.add(roomDisplayName(row.speaker));
}
}
return [...names];
}
export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } = {}) {
const data = loadRelations(slug);
const out = [];