From 0527a3cad0058c08a5e3cb2c467dea8ac162bf88 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 06:30:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(relation):=20=E8=A6=AA=E8=BF=91=E5=BA=A6?= =?UTF-8?q?=E6=9C=83=E6=94=B9=E8=AE=8A=E6=83=85=E7=B7=92=E7=9A=84=E4=BB=BD?= =?UTF-8?q?=E9=87=8F=EF=BC=9B=E6=8F=90=E5=88=B0=20=E2=89=A0=20=E6=8E=A5?= =?UTF-8?q?=E8=A7=B8=EF=BC=88v0.0.8=20=E5=90=8C=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 親近度 → 情緒的份量(`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) --- AGENTS.md | 5 ++++ README.md | 10 ++++++++ scripts/persona-lib.mjs | 53 +++++++++++++++++++++++++++++++++++++++++ scripts/persona.mjs | 21 ++++++++++------ scripts/selftest.mjs | 45 +++++++++++++++++++++++++++++++++- 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11ed819..876c870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,11 @@ 護欄:`MODESTY_GAIN` < 1(正回饋要收斂)、`MODESTY_MAX_STEP`、`MODESTY_PUSH_CAP`、 只算超出基線的部分、慢的情緒(半衰期長)推力打折。斷路器是惱羞成怒(anger 負權重)。 **注意 `Number(null) === 0`**:沒有上一輪時要退回 trait,不是退回 0(踩過)。 +8f. **親近度會改變情緒的份量**:`relationGain()` 依關係節點的親近度把 delta 乘 0.7–1.35, + `emotion --apply [--from <對象>]`。只調幅度不調方向。 +8g. **提到 ≠ 接觸**:睡眠只替 `contactsFromRooms()`(同房且雙方都發過言)的人蓋 + `last_contact_at`。掃 `entities` 會讓「日記裡寫到某人」把他的沉默計時歸零, + 主動關心因此永遠不觸發。真人節點走 `relation node --contact`。 8c. **性別只給一個預設值,不是套在個性上的係數**:`IDENTITY.md` 的 `Gender` 欄位 (女性/男性/非二元/未指定,建立時要問不要猜)唯一的作用是給**羞恥敏感度**一個預設 (62/38/50)。`modestyOf()` 會用 IDENTITY 與 SOUL 的描述往上或往下推(`MODESTY_SIGNALS`, diff --git a/README.md b/README.md index 20bf10d..6d623b6 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,11 @@ flowchart TB 那是底色不是此刻的事。天然的斷路器是**惱羞成怒**:被逗到極限翻臉,`anger` 是負權重, 羞恥自己就掉下來,而且會掉到比原本更低。 +**親近度會改變情緒的份量**(`relationGain()`):同一句話,從枕邊人嘴裡跟從生人嘴裡出來, +衝擊本來就不一樣。`emotion --apply` 會依對象的親近度把 delta 乘上 0.7–1.35 +(親近 96 → ×1.28、親近 8 → ×0.75),`--from <對象>` 可以指定是誰引起的。 +**只調幅度、不調方向**——誰講的都不會讓難過變成高興。 + **情緒調節**:delta 不再是加完直接 `clamp`。**飽和**——越接近端點同方向漲得越慢 (`headroom^K`,永遠逼近 100 但到不了;往 baseline 回的方向不壓);**單輪預算**—— 所有 `|delta|` 總和上限 60,超過等比例縮小。**偏差看得見**——`emotion --audit` @@ -179,6 +184,11 @@ flowchart TB | **R5** | 同一人物(entity)≥ 2 筆 | `relationship`(並更新關係圖) | | **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間。**真的會清**:超過軟上限 120 筆時從顯著度最低、最舊的開始裁,但顯著度 ≥ 80 或 `intent=commit`(承諾/界線)與 24 小時內的新紀錄一律不動;裁掉幾筆會寫進 `sleep` 的回報,不做無聲的裁切 | +**提到 ≠ 接觸**:主動關心的依據是 `last_contact_at`,睡眠只替**真的講到話的人**蓋章 +(`contactsFromRooms()`:同一個聊天室裡我發過言、他也發過言)。以前是掃短期記憶的 +`entities`——那等於「我在日記裡寫到他」就算「我跟他接觸過」,沉默計時被無聲重置, +關心名單永遠是空的。沒有聊天室的對象(真人節點)走 `relation node --contact` 明確蓋章。 + 沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑 `/jsc-persona:persona-memory`。 diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 9c95a96..87ffa8c 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -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 = []; diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 7379d6d..b45046a 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -495,12 +495,9 @@ commands.sleep = async ({ flags }) => { const hours = pl.hoursAwake(slug); await run("relation-contact", () => { - // 今天提到過的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據) - const seen = new Set(); - for (const row of pl.readJsonl(pl.shortTermPath(slug), 200)) { - for (const name of row.entities || []) seen.add(name); - } - for (const name of seen) pl.stampContact(slug, name); + // 真的講到話的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)。 + // **提到不是接觸**:在日記裡寫到某個人,不該把他的沉默計時歸零。 + for (const name of pl.contactsFromRooms(slug)) pl.stampContact(slug, name); pl.renderRelations(slug); }); // 不做無聲的裁切:清掉幾筆、為什麼清、保護了幾筆,都要寫進回報 @@ -969,8 +966,17 @@ commands.emotion = ({ flags }) => { } if (flags.apply) { if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。"); - const deltas = parseDeltas(flags.apply); + const raw = parseDeltas(flags.apply); + // 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣 + const rel = pl.relationGain(slug, str(flags.from) || null); + const deltas = rel.gain === 1 + ? raw + : Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100])); state = pl.applyEmotion(state, deltas, str(flags.trigger)); + if (rel.gain !== 1) { + state.last_trigger = state.last_trigger || {}; + state.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain }; + } pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) }); pl.appendJsonl(pl.journalPath(slug), { ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger), @@ -1996,6 +2002,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 情緒與圖: emotion --session [--apply joy=+10,...] [--baseline ...] [--trigger ] + [--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta [--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態 [--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向 套用時會飽和(越接近端點漲越慢)並受單輪預算限制(|delta| 總和 ≤ 40) diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index a13c2b7..6058ccf 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -1198,7 +1198,14 @@ check("預設保留載入鎖(--release 才收工)", sleepJson.kept_lock === true && pl.lockStatus("epsilon").locked); check("睡眠寫下 state/sleep.json 與距上次睡眠的時數", pl.loadSleepState("epsilon").last_slept_at !== null && pl.hoursAwake("epsilon") !== null); -check("關係時間戳有蓋上(主動關心的依據)", (() => { +// 提到 ≠ 接觸:只在短期記憶裡被提到的人,睡眠不會把他的沉默計時歸零 +check("只被提到的人不會被蓋時間戳", (() => { + const node = pl.loadRelations("epsilon").nodes.find((n) => n.id === "zeta"); + return !node?.last_contact_at; +})(), JSON.stringify(pl.loadRelations("epsilon").nodes.find((n) => n.id === "zeta"))); +// 真的接觸過要蓋得上:沒有聊天室的對象走 `relation node --contact` 明確蓋章 +cli(["relation", "node", "--persona", "epsilon", "--session", S_SLEEP, "--name", "zeta", "--contact"]); +check("真的接觸過就蓋得上時間戳(主動關心的依據)", (() => { const node = pl.loadRelations("epsilon").nodes.find((n) => n.id === "zeta"); return Boolean(node?.last_contact_at); })()); @@ -1387,6 +1394,42 @@ check("sleeper 型別存在且宣告了唯讀工具限制", (() => { md.includes("回傳值裡不得出現任何記憶內容"); })()); +console.log("\n親近度 → 情緒的份量;提到 ≠ 接觸"); +{ + const S_REL = "sess-rel-6666"; + cli(["load", "--persona", "alpha", "--session", S_REL, "--takeover"]); + cli(["relation", "node", "--persona", "alpha", "--session", S_REL, "--name", "枕邊人", + "--kind", "human", "--bond", "partner", "--closeness", "96", "--trust", "95"]); + cli(["relation", "node", "--persona", "alpha", "--session", S_REL, "--name", "路人甲", + "--kind", "human", "--bond", "stranger", "--closeness", "8", "--trust", "10"]); + const close = pl.relationGain("alpha", "枕邊人"); + const far = pl.relationGain("alpha", "路人甲"); + check("親近的人講的話,份量比較重", close.gain > 1.2 && far.gain < 0.85, `${close.gain} vs ${far.gain}`); + check("找不到對象就不放大也不縮小", pl.relationGain("alpha", "不存在的人").gain === 1); + // 同樣的 delta,來自不同的人,落下來的量不一樣 + const before = pl.loadEmotion("alpha").levels.joy; + cli(["emotion", "--persona", "alpha", "--session", S_REL, "--apply", "joy=+10", + "--from", "枕邊人", "--trigger", "她說的"]); + const afterClose = pl.loadEmotion("alpha").levels.joy - before; + cli(["emotion", "--persona", "alpha", "--session", S_REL, "--baseline", `joy=${Math.round(before)}`]); + const before2 = pl.loadEmotion("alpha").levels.joy; + cli(["emotion", "--persona", "alpha", "--session", S_REL, "--apply", "joy=+10", + "--from", "路人甲", "--trigger", "路人說的"]); + const afterFar = pl.loadEmotion("alpha").levels.joy - before2; + check("同一句話,枕邊人講的比路人講的更動搖你", afterClose > afterFar, `close=${afterClose} far=${afterFar}`); + check("只調幅度、不調方向(都還是往上)", afterClose > 0 && afterFar > 0); + + // 提到 ≠ 接觸 + cli(["relation", "node", "--persona", "alpha", "--session", S_REL, "--name", "只被提到的人", + "--kind", "human", "--bond", "friend", "--closeness", "80"]); + cli(["remember", "--persona", "alpha", "--session", S_REL, "--role", "persona", + "--text", "我在日記裡寫到只被提到的人", "--entities", "只被提到的人", "--salience", "50"]); + const real = pl.contactsFromRooms("alpha"); + check("被提到的人不算接觸過(睡眠不再拿 entities 蓋時間戳)", + !real.includes("只被提到的人"), JSON.stringify(real)); + cli(["release", "--session", S_REL]); +} + console.log("\n劇場模式:心裡話不能外流"); { const S_INNER = "sess-inner-8888";