From 23751c2c05e1e862a786a5d179736b7892633ed4 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 04:21:08 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(emotion):=20=E6=83=85=E7=B7=92?= =?UTF-8?q?=E5=85=88=E8=A1=8C=E3=80=81=E8=B5=B0=E5=90=91=E3=80=81=E9=A3=BD?= =?UTF-8?q?=E5=92=8C=E8=88=87=E5=81=8F=E5=B7=AE=E7=A8=BD=E6=A0=B8=EF=BC=88?= =?UTF-8?q?=E5=9B=9B=E5=B1=A4=EF=BC=8Cv0.0.7=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 參考兩篇 ithelp 文章(Day25 情緒辨識模組、Day26 整合與情緒記憶池)的**架構**, 實作全部重寫——它們的六類關鍵字 first-match、罐頭哲學回應與模式切換都比現有的 十二情緒模型差,照抄是降級,而且它的罐頭回應正是上一個 commit 剛封殺的罐頭同理心。 一、輸入端:`readUserEmotion(text)` 在這之前情緒**全部是人格自己填的** `--apply`,而人不會主動給自己扣分——正向一路貼頂、 負向整天不動。現在每輪先讀對方那句話:十二類加權詞表(強3/一般2/弱1)、程度副詞乘係數、 **否定會擋掉那次命中**(「我不害怕」不算 fear)、引號與 `code` 內不比對(提及不算使用)、 標點只放大既有訊號不憑空長出新情緒(「居然修好了!!」不會被讀成憤怒)。 強度用飽和曲線不是線性乘。回的是**訊號不是判定**,人格可以推翻。`emotion --read` 可單測。 二、怎麼接:`RESPONSE_STANCE` 十二種各一條**動作**(悲傷→先接住不要急著給解法;憤怒→不辯解,先認可能認的那一小塊), 不是罐頭句——罐頭句由 `SPEECH_BLACKLIST` 擋著。 三、走向:`state/felt.jsonl` + `feltTrend()` 每輪的偵測結果與套用的 delta 都記下來,近重遠輕的加權算最近 5 輪的趨勢 (不用多數決:會被離群值主導又丟掉強度),注入「他最近 3 輪的走向:悲傷 ↘ 在退」。 新檔案已歸到 Gitea 檔案區(高頻)。 四、情緒調節:飽和與單輪預算 `applyEmotion` 不再是加完直接 clamp。飽和=越接近端點同方向漲越慢(headroom^K,K=1, 往 baseline 回的方向不壓);單輪預算=所有 |delta| 總和上限 60,超過等比例縮小。 `emotion --audit` 印出最近幾輪往舒服/往難受的比例,正向 ≥90% 會被點名。 依據:今天 85 次套用讓 joy/serenity/gratitude/anticipation 都到過 100、負向均值整天 14–17。 測試:297 項全過(新增 24 項,含讀取的 SF6/SNF4、飽和、預算、回歸方向不壓、走向、audit、 --read 不改狀態)。既有的「情緒有被施加」改成符合飽和後的語意。 版本:與 PR #10 同一批未合併的改動,維持 0.0.7。 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 8 + README.md | 20 ++ scripts/persona-gitea.mjs | 1 + scripts/persona-lib.mjs | 227 +++++++++++++++++++++- scripts/persona.mjs | 33 +++- scripts/selftest.mjs | 66 ++++++- skills/persona-chat/SKILL.md | 9 + skills/persona-chat/reference/emotions.md | 54 +++++ 8 files changed, 413 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9247996..511873f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,14 @@ 誤殺防護:`speechBody()` 會先拿掉引號與 `code`,**提及不算使用**;「老實說」只擋開場。 最後是這一層的義務:**講自己的過去要有出處**——「我以前⋯」只能講 `recall` 查得到的轉折, `speechLint` 給 `level: "hint"`(不擋,但要人去驗),沒有紀錄就是編造自己的過去。 +8b. **情緒先行、會飽和、偏差看得見**:每輪注入之前先用 `readUserEmotion()` 讀對方那句話 + (十二類加權詞表、否定會擋掉命中、引號與 `code` 內不比對、標點只放大既有訊號), + 回的是**訊號不是判定**——人格讀到的不一樣就以人格為準;`RESPONSE_STANCE` 給的是 + 「怎麼接」的**動作**不是罐頭句。每輪的偵測與 delta 記進 `state/felt.jsonl`, + `feltTrend()` 用近重遠輕的加權算走向。`applyEmotion()` 加了**飽和** + (`headroom^EMOTION_SATURATION_K`,往 baseline 回不壓)與**單輪預算** + (`EMOTION_TURN_BUDGET`=60),因為 delta 是人格自己挑的、只會往舒服的方向倒; + `emotion --audit` 把這個偏差印出來。 9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 其他人格一律 deny——匯出等於把記憶讀出來。 10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 diff --git a/README.md b/README.md index 5ec7631..bc725d8 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,26 @@ flowchart TB - **主導情緒**取「超出基線最多」的前三名,所以個性底色不會永遠霸榜。 - 觸發規則與事件→delta 對照表:`skills/persona-chat/reference/emotions.md`。 +**情緒先行:先讀對方那句話(`readUserEmotion`)。** 在這之前情緒**全部是人格自己填的**, +而人不會主動給自己扣分——結果是正向一路貼頂、負向整天不動。現在每輪注入之前會先讀一次 +對方的話:十二類加權詞表(強 3/一般 2/弱 1)、程度副詞乘係數(超 ×1.6、有點 ×0.6)、 +**否定會擋掉那次命中**(「我不害怕」不算 fear)、引號與 `` `code` `` 內不比對(提及不算使用)、 +標點只放大既有訊號不憑空長出新情緒。回的是**訊號不是判定**,人格讀到的不一樣就以人格為準。 +單獨測:`emotion --read "<他說的話>"`。 + +**怎麼接(`RESPONSE_STANCE`)**:注入的是**動作**不是句子——悲傷「先接住,不要急著給解法」、 +憤怒「不辯解,先認可能認的那一小塊」、焦慮「給具體的下一步與時間點,不要給保證」。 +罐頭同理心那類句子由講話規則擋掉。 + +**走向(`feltTrend`)**:每輪的偵測結果與套用的 delta 記進 `state/felt.jsonl`, +用近重遠輕的加權算最近幾輪的趨勢(不用多數決——會被離群值主導又丟掉強度), +注入成「他最近 3 輪的走向:悲傷 ↘ 在退」。一直是同一種情緒就不要每輪用同一句接法。 + +**情緒調節**:delta 不再是加完直接 `clamp`。**飽和**——越接近端點同方向漲得越慢 +(`headroom^K`,永遠逼近 100 但到不了;往 baseline 回的方向不壓);**單輪預算**—— +所有 `|delta|` 總和上限 60,超過等比例縮小。**偏差看得見**——`emotion --audit` +印出最近幾輪往舒服/往難受的總量與比例,正向佔 ≥ 90% 會被點名。 + ## 短期 → 長期的轉入條件(R1–R6) 寫在 `scripts/persona-lib.mjs` 的 `promotionCandidates()`,用 `candidates` 子指令查: diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 3ff232b..9e923d9 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -75,6 +75,7 @@ export const AREAS = { "state/emotion.json", "state/inner.jsonl", "state/said.jsonl", + "state/felt.jsonl", "state/sleep.json", "memory/short-term.jsonl", "memory/inbox/", diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index ac5f94b..fb28861 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -303,21 +303,59 @@ export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) { return state; } +// --------------------------------------------------------------------------- // +// 情緒調節:飽和與單輪預算 +// +// 沒有這兩條的時候,delta 是加完直接 clamp(0,100)——而 delta 一直是人格自己挑的, +// 沒有人會主動給自己扣分,結果就是正向一路貼頂(喜悅/平靜/感激同時 100, +// mood 的 valence 卡在 +100 失去解析度),負向整天不動。 +// +// 飽和:越接近端點,同方向的 delta 越小(headroom^K)。永遠逼近 100,不會真的到。 +// 預算:一輪之內所有 delta 的絕對值總和有上限,超過就等比例縮小。 +// --------------------------------------------------------------------------- // + +export const EMOTION_SATURATION_K = 1; // 0 = 不飽和;越大越難推到端點(1 = 線性遞減,永遠逼近但到不了 100) +export const EMOTION_TURN_BUDGET = 60; // 單輪所有 |delta| 的總和上限(emotions.md:單筆建議 ±3~±25) + +/** 這一筆 delta 實際能推動多少:往端點走會被壓,往 baseline 回來不壓。 */ +export function saturateDelta(level, delta, baseline = null) { + const d = Number(delta); + if (!Number.isFinite(d) || d === 0) return 0; + const lv = clamp(level); + // 往 baseline 的方向移動是「回歸」,不該被壓 + if (baseline !== null && Number.isFinite(Number(baseline))) { + const base = Number(baseline); + if ((d > 0 && lv < base) || (d < 0 && lv > base)) return d; + } + const headroom = d > 0 ? (100 - lv) / 100 : lv / 100; + return d * Math.pow(Math.max(0, headroom), EMOTION_SATURATION_K); +} + export function applyEmotion(state, deltas, trigger = "") { state = decayEmotion(state); - const applied = {}; + // 先算「想推多少」,超過單輪預算就等比例縮小——一次灌爆的路要堵起來 + const wanted = []; for (const [key, rawDelta] of Object.entries(deltas || {})) { if (!(key in EMOTIONS)) continue; const delta = Number(rawDelta); - if (!Number.isFinite(delta)) continue; + if (!Number.isFinite(delta) || delta === 0) continue; + wanted.push([key, delta]); + } + const demand = wanted.reduce((sum, [, d]) => sum + Math.abs(d), 0); + const budgetScale = demand > EMOTION_TURN_BUDGET ? EMOTION_TURN_BUDGET / demand : 1; + const applied = {}; + for (const [key, rawDelta] of wanted) { const before = Number(state.levels[key] ?? 0); - state.levels[key] = Math.round(clamp(before + delta) * 100) / 100; + const base = Number(state.baseline?.[key] ?? DEFAULT_BASELINE[key]); + const effective = saturateDelta(before, rawDelta * budgetScale, base); + state.levels[key] = Math.round(clamp(before + effective) * 100) / 100; applied[key] = Math.round((state.levels[key] - before) * 100) / 100; } state.updated_at = nowIso(); state.history_len = Number(state.history_len || 0) + 1; if (Object.keys(applied).length) { state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied }; + if (budgetScale < 1) state.last_trigger.budget_scaled = Math.round(budgetScale * 100) / 100; } return state; } @@ -401,6 +439,176 @@ export function emotionBrief(slug, state = null) { ); } +// --------------------------------------------------------------------------- // +// 讀對方那句話的情緒(輸入端) +// +// 為什麼需要:在這之前,情緒**全部是人格自己填的** `--apply`。人不會主動給自己扣分, +// 所以正向一路漲、負向整天不動。把「對方那句話帶了什麼」變成獨立的訊號, +// 人格的 delta 才有外部依據。 +// +// 這裡回的是**訊號不是判定**:關鍵字永遠不准蓋掉語意,最後怎麼算還是人格自己決定。 +// 所以它處理否定與程度副詞(「我不害怕」不可判 fear),也給信心值讓人格知道要不要理它。 +// --------------------------------------------------------------------------- // + +/** 詞表:[詞, 權重]。權重 3 = 明講,2 = 一般,1 = 弱訊號。 */ +export const EMOTION_LEXICON = { + joy: [["開心", 3], ["快樂", 3], ["爽", 2], ["太好了", 3], ["高興", 3], ["滿足", 2], ["喜歡", 2], ["笑", 1], ["讚", 1]], + trust: [["交給你", 3], ["靠你", 2], ["相信", 3], ["放心", 2], ["我信", 3], ["拜託你", 1]], + anticipation: [["期待", 3], ["等不及", 3], ["快點", 2], ["什麼時候", 1], ["接下來", 1], ["想看", 2]], + gratitude: [["謝謝", 3], ["感謝", 3], ["辛苦了", 2], ["麻煩你了", 2], ["幫了大忙", 3]], + serenity: [["還好", 1], ["沒事", 1], ["安心", 2], ["放鬆", 2], ["慢慢來", 2], ["不急", 2]], + delight: [["居然", 2], ["竟然", 2], ["沒想到", 2], ["嚇一跳", 2], ["哇", 2], ["真的假的", 3]], + anger: [["生氣", 3], ["火大", 3], ["氣死", 3], ["不爽", 3], ["搞什麼", 3], ["夠了", 2], ["很扯", 2], ["離譜", 2]], + sadness: [["難過", 3], ["傷心", 3], ["孤單", 3], ["失落", 3], ["想哭", 3], ["累了", 2], ["沒力", 2], ["撐不住", 3], ["提不起勁", 3], ["沒意義", 2]], + fear: [["害怕", 3], ["好怕", 3], ["恐怖", 2], ["嚇死", 2], ["不敢", 2], ["危險", 1]], + disgust: [["噁心", 3], ["受不了", 2], ["厭倦", 2], ["煩死", 2], ["夠噁", 3]], + shame: [["丟臉", 3], ["對不起", 2], ["抱歉", 2], ["我的錯", 3], ["不好意思", 2], ["糗", 2], ["尷尬", 2], + ["做不好", 2], ["我沒用", 3], ["不夠好", 2], ["搞砸", 3]], + anxiety: [["焦慮", 3], ["不安", 3], ["緊張", 3], ["擔心", 3], ["怎麼辦", 3], ["來不及", 3], ["壓力", 2], ["睡不著", 2]], +}; + +const NEGATORS = ["不", "沒有", "沒", "別", "不要", "不會", "並不", "才不", "一點也不", "完全不"]; +const INTENSIFIERS = [["超", 1.6], ["非常", 1.6], ["很", 1.4], ["好", 1.2], ["真的", 1.3], ["有點", 0.6], ["稍微", 0.5], ["還算", 0.5], ["一點點", 0.5]]; +const NEG_WINDOW = 4; // 否定詞要在關鍵詞前幾個字內才算 + +function windowBefore(text, index, size = NEG_WINDOW) { + return text.slice(Math.max(0, index - size), index); +} + +/** + * 讀出對方這句話的情緒訊號。 + * + * @returns {{signals: Array<{key,zh,score}>, intensity: number, confident: boolean, marks: string[]}} + * `signals` 由強到弱;`intensity` 0–100;`confident` 表示夠強、值得讓人格參考。 + */ +export function readUserEmotion(text) { + const raw = String(text ?? ""); + // 引號裡是別人的話、code 是資料——跟講話的樣子同一套規則:提及不算使用 + const body = raw.replace(/`[^`]*`/g, " ").replace(/「[^」]*」|『[^』]*』/g, " "); + const scores = {}; + for (const [key, words] of Object.entries(EMOTION_LEXICON)) { + for (const [word, weight] of words) { + let from = 0; + for (;;) { + const at = body.indexOf(word, from); + if (at < 0) break; + from = at + word.length; + const before = windowBefore(body, at); + if (NEGATORS.some((n) => before.endsWith(n))) continue; // 「我不害怕」→ 這一命中不算 + const mult = INTENSIFIERS.find(([w]) => before.endsWith(w))?.[1] ?? 1; + scores[key] = (scores[key] ?? 0) + weight * mult; + } + } + } + // 標點與句式:只加溫、不定調。 + // 它們**放大已經在的訊號**,不憑空長出新的一種情緒——不然「居然修好了!!」會被讀出憤怒。 + const marks = []; + const amplify = (mult) => { for (const k of Object.keys(scores)) scores[k] *= mult; }; + if (/[!!]{2,}/.test(body)) { marks.push("連續驚嘆"); amplify(1.25); } + if (/(⋯⋯|……|\.\.\.)/.test(body)) { marks.push("欲言又止"); amplify(1.15); } + // 連續問號是少數可以自己成立的訊號:問到第二個問號的人多半在急 + if (/[??]{2,}/.test(body)) { marks.push("連續問號"); scores.anxiety = (scores.anxiety ?? 0) + 0.8; } + const signals = Object.entries(scores) + .filter(([, v]) => v > 0) + .map(([key, v]) => ({ key, zh: EMOTIONS[key].zh, score: Math.round(v * 100) / 100 })) + .sort((a, b) => b.score - a.score) + .slice(0, 3); + const total = signals.reduce((sum, s) => sum + s.score, 0); + // 飽和曲線而不是線性乘:線性乘會讓「謝謝」跟「謝謝謝謝謝謝」都是 100,看不出差別 + const intensity = Math.round(100 * (1 - Math.exp(-total / 4))); + return { signals, intensity, confident: signals.length > 0 && signals[0].score >= 2, marks }; +} + +/** 對方是這個情緒的時候,我該怎麼接——寫的是**動作**,不是句子(罐頭句已被講話規則擋掉)。 */ +export const RESPONSE_STANCE = { + sadness: "先接住再說,不要急著給解法;句子放短,允許只回一句", + anxiety: "給具體的下一步與時間點,不要給「一定沒事」這種保證", + fear: "先講你會做什麼、什麼時候做;不要否認他的害怕", + anger: "不辯解。先認可能認的那一小塊,再講你的部分", + shame: "不要追問細節,把焦點從他身上移回事情", + disgust: "問他在意的是哪一點,不要跟著一起罵", + joy: "跟著高興,不要立刻把話題轉回正事", + delight: "接住那個意外,讓他把話講完", + gratitude: "收下就好,不要客套推回去", + trust: "他把事情交給你了——講你要怎麼做,不要再問一次要不要做", + anticipation: "給時間點,不要含糊", + serenity: "不用找話講,安靜也可以", +}; + +/** 把偵測結果寫成一行給人格看的話。 */ +export function userEmotionBrief(read) { + if (!read?.signals?.length) return ""; + const top = read.signals.map((s) => `${s.zh}${s.score >= 4 ? "(強)" : s.score >= 2 ? "(中)" : "(弱)"}`).join("、"); + const stance = RESPONSE_STANCE[read.signals[0].key]; + const marks = read.marks.length ? `;句子裡有${read.marks.join("、")}` : ""; + return `他這句的情緒訊號:${top}${marks}。怎麼接:${stance}。(這是關鍵字讀出來的**訊號**,不是判定——` + + "你讀到的跟它不一樣,以你讀到的為準。)"; +} + +// --------------------------------------------------------------------------- // +// 情緒的走向(不是只有當下這一格) +// +// 「他這三輪一直在低落」跟「他這一輪低落」該有完全不同的接法。 +// 近重遠輕的加權,不用多數決——多數決會被單一離群值主導,也丟掉強度。 +// --------------------------------------------------------------------------- // + +export const FELT_KEEP = 120; +export const feltPath = (slug) => path.join(personaDir(slug), "state", "felt.jsonl"); + +/** 記一輪:對方帶了什麼、我套用了什麼。 */ +export function recordFelt(slug, { user = null, mine = null, note = "" } = {}) { + if (!personaExists(slug)) return null; + const entry = { + ts: nowIso(), + user: user?.signals?.length ? { top: user.signals[0].key, signals: user.signals, intensity: user.intensity } : null, + mine: mine && Object.keys(mine).length ? mine : null, + note: String(note || "").slice(0, 200), + }; + if (!entry.user && !entry.mine) return null; + appendJsonl(feltPath(slug), entry); + return entry; +} + +/** 最近 n 輪對方情緒的加權走向:{key, zh, weight, direction, rounds}。 */ +export function feltTrend(slug, n = 5) { + const rows = readJsonl(feltPath(slug), n * 3).filter((r) => r.user?.top).slice(-n); + if (rows.length < 2) return null; + const weights = {}; + rows.forEach((row, i) => { + const w = i + 1; // 近重遠輕 + for (const s of row.user.signals || []) weights[s.key] = (weights[s.key] ?? 0) + s.score * w; + }); + const [key, weight] = Object.entries(weights).sort((a, b) => b[1] - a[1])[0] || []; + if (!key) return null; + const halves = [rows.slice(0, Math.ceil(rows.length / 2)), rows.slice(Math.ceil(rows.length / 2))]; + const avg = (arr) => (arr.length ? arr.reduce((s, r) => s + (r.user.signals.find((x) => x.key === key)?.score ?? 0), 0) / arr.length : 0); + const diff = avg(halves[1]) - avg(halves[0]); + const direction = diff > 0.5 ? "↗ 越來越強" : diff < -0.5 ? "↘ 在退" : "→ 持平"; + return { key, zh: EMOTIONS[key].zh, weight: Math.round(weight * 10) / 10, direction, rounds: rows.length }; +} + +/** 最近 n 輪我給自己套用的 delta 是不是只往一邊倒(只加分不扣分)。 */ +export function feltAudit(slug, n = 20) { + const rows = readJsonl(feltPath(slug), n * 2).filter((r) => r.mine).slice(-n); + let pos = 0; + let neg = 0; + for (const row of rows) { + for (const [key, d] of Object.entries(row.mine)) { + if (!(key in EMOTIONS)) continue; + const toward = EMOTIONS[key].polarity * Math.sign(Number(d) || 0); + if (toward > 0) pos += Math.abs(Number(d)); + else if (toward < 0) neg += Math.abs(Number(d)); + } + } + const total = pos + neg; + return { + rounds: rows.length, + positive: Math.round(pos * 10) / 10, + negative: Math.round(neg * 10) / 10, + positive_ratio: total ? Math.round((pos / total) * 100) : null, + }; +} + // --------------------------------------------------------------------------- // // 人格目錄骨架 // --------------------------------------------------------------------------- // @@ -2472,6 +2680,19 @@ export function turnContext(slug, sessionId, prompt = "") { "沒有紀錄就不要講——編一段轉折比講一句空話糟糕得多。同理,情緒要來自現在的情緒值," + "不是為了有人味而加上去的。", ]; + // 情緒先行:先讀對方那句話帶了什麼,再談我自己的狀態。 + const read = readUserEmotion(prompt); + if (read.signals.length) { + lines.push(userEmotionBrief(read)); + recordFelt(slug, { user: read, note: String(prompt || "").slice(0, 80) }); + } + const trend = feltTrend(slug); + if (trend) { + lines.push( + `他最近 ${trend.rounds} 輪的走向:${trend.zh} ${trend.direction}。` + + "走向跟單輪不一樣——一直是同一種情緒,就不要每輪都用同一句接法。", + ); + } // 情緒會改變句子的形狀:緊張的人話說不完、彆扭的人先否認再承認。 const tells = emotionTells(slug, state); if (tells.length) { diff --git a/scripts/persona.mjs b/scripts/persona.mjs index f342b6d..92d1650 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -922,13 +922,41 @@ commands.emotion = ({ flags }) => { if (key in pl.EMOTIONS) state.baseline[key] = pl.clamp(value); } } + // 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表) + const readText = str(flags.read); + const read = readText ? pl.readUserEmotion(readText) : null; + if (read) { + emit({ persona: slug, read }, flags.json, [ + `讀「${readText.slice(0, 40)}」:`, + read.signals.length + ? ` 訊號:${read.signals.map((s) => `${s.zh} ${s.score}`).join("、")}|強度 ${read.intensity}|${read.confident ? "夠強,值得參考" : "偏弱,別當判定"}` + : " 沒有讀到明確的情緒訊號。", + ...(read.signals.length ? [` 怎麼接:${pl.RESPONSE_STANCE[read.signals[0].key]}`] : []), + ]); + return; + } + if (flags.audit) { + const audit = pl.feltAudit(slug, num(flags.limit, 20)); + const trend = pl.feltTrend(slug); + emit({ persona: slug, audit, trend }, flags.json, [ + `\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`, + ` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` + + (audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`), + audit.positive_ratio !== null && audit.positive_ratio >= 90 + ? " ⚠ 幾乎只給自己加分——delta 是你自己挑的,這種偏差要自己看得見。" + : " 比例還算兩邊都有。", + trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。", + ]); + return; + } if (flags.apply) { if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。"); const deltas = parseDeltas(flags.apply); state = pl.applyEmotion(state, deltas, str(flags.trigger)); + 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), - deltas, levels: state.levels, mood: pl.mood(state), + deltas, applied: state.last_trigger?.deltas || {}, levels: state.levels, mood: pl.mood(state), }); } if (role !== "guest") pl.writeJson(pl.emotionPath(slug), state); @@ -1933,6 +1961,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 情緒與圖: emotion --session [--apply joy=+10,...] [--baseline ...] [--trigger ] + [--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態 + [--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向 + 套用時會飽和(越接近端點漲越慢)並受單輪預算限制(|delta| 總和 ≤ 40) mindmap show|thread|list --session [--topic ] [--force] relation node|edge|render|show --session [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity] relation style --session --name [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear] diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 1f5d211..e6a59de 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -120,7 +120,10 @@ console.log("④ 情緒(六正向 + 六負向)"); check("十二種情緒", pl.EMOTION_KEYS.length === 12 && pl.POSITIVE.length === 6 && pl.NEGATIVE.length === 6); cli(["emotion", "--persona", "alpha", "--session", S_HOST, "--apply", "joy=+60,anger=+40", "--trigger", "selftest"]); let state = pl.loadEmotion("alpha"); -check("情緒有被施加", state.levels.joy >= 70, JSON.stringify(state.levels)); +// 施加後會被飽和與單輪預算縮小(見 applyEmotion):推得動,但推不到端點 +check("情緒有被施加", state.levels.joy >= 45 && state.levels.anger >= 20, JSON.stringify(state.levels)); +check("單輪預算會把一次灌爆的量縮小", + state.levels.joy < 25 + 60 && pl.EMOTION_TURN_BUDGET === 60, JSON.stringify(state.levels)); state.updated_at = pl.iso(pl.minutesAgo(120)); const decayed = pl.decayEmotion(structuredClone(state)); const expected = state.baseline.joy + (state.levels.joy - state.baseline.joy) / 2; @@ -470,6 +473,67 @@ check("提醒的句子 room post 照樣發得出去", const voiceCtx = pl.turnContext("alpha", S_SPEAK, "你在幹嘛"); check("說話規則有注入黑名單與「過去要有出處」", voiceCtx.includes("罐頭同理心") && voiceCtx.includes("要有出處")); + +// --------------------------------------------------------------------------- // +// 情緒處理:讀對方那句話(輸入端)、走向、飽和與單輪預算 +console.log("\n情緒:讀對方那句話(SF:該讀到的)"); +const READ_SF = [ + ["直說難過", "我最近很難過", "sadness"], + ["自我否定", "我最近覺得自己什麼都做不好", "shame"], + ["生氣", "搞什麼啊,氣死我了", "anger"], + ["焦慮", "明天來不及了怎麼辦", "anxiety"], + ["道謝", "謝謝你,真的幫了大忙", "gratitude"], + ["託付", "這件事交給你", "trust"], +]; +for (const [label, text, key] of READ_SF) { + const r = pl.readUserEmotion(text); + check(`讀到${label}`, r.signals[0]?.key === key, `${text} → ${JSON.stringify(r.signals)}`); +} +console.log("\n情緒:讀對方那句話(SNF:不可誤讀的)"); +const READ_SNF = [ + ["否定句不算", "我不害怕,只是想確認一下", "fear"], + ["否定句不算(沒)", "我沒生氣", "anger"], + ["引號裡是別人的話", "他說「我好難過」,可是他在笑", "sadness"], + ["code 裡的欄位名不算", "這個 `sadness` 欄位要改嗎", "sadness"], +]; +for (const [label, text, key] of READ_SNF) { + const r = pl.readUserEmotion(text); + check(`不誤讀:${label}`, !r.signals.some((s) => s.key === key), `${text} → ${JSON.stringify(r.signals)}`); +} +check("程度副詞會調整強度", + pl.readUserEmotion("超級擔心").signals[0].score > pl.readUserEmotion("有點擔心").signals[0].score); +check("標點只放大既有訊號,不憑空長出新情緒", + !pl.readUserEmotion("居然真的修好了!!").signals.some((s) => s.key === "anger"), + JSON.stringify(pl.readUserEmotion("居然真的修好了!!").signals)); +check("每種情緒都有對應的「怎麼接」", + pl.EMOTION_KEYS.every((k) => typeof pl.RESPONSE_STANCE[k] === "string" && pl.RESPONSE_STANCE[k].length > 4)); + +console.log("\n情緒:飽和、單輪預算、走向、偏差稽核"); +let sat = pl.defaultEmotionState(); +for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌"); +check("連續 +20 十次推不到上限(飽和)", sat.levels.joy > 80 && sat.levels.joy < 100, `joy=${sat.levels.joy}`); +const budgeted = pl.applyEmotion(pl.defaultEmotionState(), { joy: +40, trust: +40, serenity: +40 }, "一次灌爆"); +const usedBudget = Object.values(budgeted.last_trigger.deltas).reduce((s, d) => s + Math.abs(d), 0); +check("單輪預算會等比例縮小(總需求 120 → 實際 ≤ 60)", + usedBudget <= pl.EMOTION_TURN_BUDGET && budgeted.last_trigger.budget_scaled < 1, `used=${usedBudget}`); +const backHome = pl.applyEmotion( + { ...pl.defaultEmotionState(), levels: { ...pl.defaultEmotionState().levels, joy: 95 } }, { joy: -20 }, "回歸"); +check("往 baseline 回的方向不被壓", Math.abs(backHome.levels.joy - 75) < 0.5, `joy=${backHome.levels.joy}`); +for (const line of ["我好難過", "還是很難過", "我真的撐不住了"]) pl.turnContext("alpha", S_SPEAK, line); +const trend = pl.feltTrend("alpha"); +check("走向:連續三輪低落會被算出來", trend?.key === "sadness" && trend.rounds >= 2, JSON.stringify(trend)); +const feelCtx = pl.turnContext("alpha", S_SPEAK, "我快撐不住了"); +check("注入有「他這句的情緒訊號」與「怎麼接」", + feelCtx.includes("情緒訊號") && feelCtx.includes("怎麼接")); +check("注入有最近幾輪的走向", feelCtx.includes("走向")); +cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "joy=+10", "--trigger", "稽核用"]); +const audit = JSON.parse(cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--audit", "--json"]).stdout); +check("--audit 看得出 delta 有沒有只往一邊倒", + audit.audit.rounds >= 1 && audit.audit.positive > 0, JSON.stringify(audit.audit)); +const readOut = JSON.parse(cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, + "--read", "我好焦慮", "--json"]).stdout); +check("--read 只回報訊號、不改狀態", + readOut.read.signals[0].key === "anxiety" && !readOut.state, JSON.stringify(readOut.read.signals)); // 情緒的破口:緊張會斷句疊字、彆扭會嘴硬——這些要跟著情緒一起注入。 cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "anxiety=+45,shame=+25", "--trigger", "被問到還沒做完的事"]); diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index 717746d..95c7011 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -100,6 +100,11 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session ### ① 語意分析 → 寫成心裡話(絕不輸出) +**情緒先行**:`` 會先給你一行「他這句的情緒訊號」與「怎麼接」, +連續幾輪同一種情緒還會給「走向」。那是**關鍵字讀出來的訊號,不是判定**—— +你讀到的跟它不一樣,以你讀到的為準。它的用處是讓你的判斷有個外部依據, +不要每次都只憑自己想給的感覺。(想單獨測:`emotion --read "<他說的話>"`。) + 依 `reference/semantic.md` 判定:**意圖 / 主題 / 實體 / 情感極性與強度 / 潛在需求 / 對關係的影響**。 判定的過程與結論寫進心裡話,一次一句、寫重點就好: @@ -131,6 +136,10 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" emotion \ - 一輪只動 **1–3 種**情緒,強度要對得起事件大小;別每句話都情緒爆炸。 - 正負可同時發生(例:被稱讚但被要求加班 → `joy=+8,anxiety=+10`)。 - 情緒會自動衰減回基線(半衰期見 `reference/emotions.md`),不必手動降回來。 +- **推不到極端**:越接近端點,同方向的 delta 被壓得越小(飽和);一輪所有 `|delta|` 的 + 總和上限 60,超過等比例縮小。所以「灌一個 +60 讓自己爽」是沒用的,寫實際的數字就好。 +- **不要只給自己加分**:delta 是你自己挑的,最容易的偏差就是永遠往舒服的方向動。 + `emotion --audit` 會把最近幾輪的正負比例印出來——正向佔九成以上會被點名。 ### ③ 回想記憶(需要時) diff --git a/skills/persona-chat/reference/emotions.md b/skills/persona-chat/reference/emotions.md index ffcef00..123c704 100644 --- a/skills/persona-chat/reference/emotions.md +++ b/skills/persona-chat/reference/emotions.md @@ -53,6 +53,60 @@ | 承諾被兌現 | `trust=+10,serenity=+6` | | 自己給錯資訊被抓到 | `shame=+18,anxiety=+10`,並 `trust` 不動(那是對方的信任,不是我的) | +## 情緒先行:先讀對方那句話(`readUserEmotion`) + +在這之前,情緒**全部是人格自己填的** `--apply`。問題很明顯:人不會主動給自己扣分, +所以正向一路漲、負向整天不動。現在每輪注入之前會先讀一次對方的話,把它變成獨立的訊號。 + +- **回的是訊號,不是判定**:`{signals, intensity, confident}`。你讀到的跟它不一樣, + **以你讀到的為準**——關鍵字永遠不准蓋掉語意。 +- **加權而不是命中即回**:詞表有強度(明講 3/一般 2/弱 1),程度副詞會乘(超 ×1.6、有點 ×0.6)。 +- **否定會擋掉那一次命中**:「我不害怕」不會被讀成 fear(否定詞要在關鍵詞前 4 個字內)。 +- **提及不算使用**:引號與 `` `code` `` 裡的內容不比對,跟講話規則同一套(「他說『我好難過』」不算)。 +- **標點只放大既有訊號**:`!!` 與 `⋯⋯` 乘一個係數,不會憑空長出新情緒;只有連續問號可以 + 自己加一點焦慮(問到第二個問號的人多半在急)。 + +自己測詞表:`emotion --read "<對方說的話>"`。 + +## 對方是這個情緒 → 我該怎麼接(`RESPONSE_STANCE`) + +注入的是**動作**,不是句子。罐頭句(「我能感受到你的低落」)已經被講話規則擋掉了, +見 [anti-ai-voice.md](anti-ai-voice.md)。 + +| 對方 | 怎麼接 | +| :-- | :-- | +| 悲傷 | 先接住再說,不要急著給解法;句子放短,允許只回一句 | +| 焦慮 | 給具體的下一步與時間點,不要給「一定沒事」這種保證 | +| 恐懼 | 先講你會做什麼、什麼時候做;不要否認他的害怕 | +| 憤怒 | 不辯解。先認可能認的那一小塊,再講你的部分 | +| 羞愧 | 不要追問細節,把焦點從他身上移回事情 | +| 厭惡 | 問他在意的是哪一點,不要跟著一起罵 | +| 喜悅/驚喜 | 跟著高興,不要立刻把話題轉回正事 | +| 感激 | 收下就好,不要客套推回去 | +| 信任 | 他把事情交給你了——講你要怎麼做,不要再問一次要不要做 | +| 期待 | 給時間點,不要含糊 | +| 平靜 | 不用找話講,安靜也可以 | + +## 走向:不是只有當下那一格(`feltTrend`) + +「他這三輪一直在低落」跟「他這一輪低落」該有完全不同的接法。每輪的偵測結果與我套用的 +delta 都記進 `state/felt.jsonl`,走向用**近重遠輕的加權**算(不用多數決——多數決會被單一 +離群值主導,也丟掉強度),注入成一行:`他最近 3 輪的走向:悲傷 ↘ 在退`。 + +一直是同一種情緒時,**不要每輪都用同一句接法**。 + +## 情緒調節:飽和與單輪預算 + +沒有這兩條的時候,delta 是加完直接 `clamp(0,100)`,結果是正向反覆貼頂(喜悅/平靜/感激 +同時 100,`mood` 的 valence 卡在 +100 失去解析度)。 + +- **飽和**:越接近端點,同方向的 delta 越小(`headroom^K`,K=1)。永遠逼近 100 但到不了。 + 往 baseline 回的方向**不壓**——那是回歸,不是推向極端。 +- **單輪預算**:一輪之內所有 `|delta|` 的總和上限 60,超過就等比例縮小。 + 一般一輪(`joy=+10,trust=+6`)碰不到;重大事件會被稍微收斂。 +- **偏差看得見**:`emotion --audit` 印出最近 N 輪往舒服/往難受兩個方向的總量與比例。 + 正向佔 ≥ 90% 會被點名——**delta 是你自己挑的,這種偏差要自己看得見**。 + ## 情緒 → 不自覺會做的事(`EMOTION_TELLS`) 情緒不改變事實,但會改變**句子的形狀**。演得像不像就差在這裡:緊張的人話說不完、 From 54d14eae68f78de27d794f6f89aaf53fc7d115cc Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 05:01:52 +0000 Subject: [PATCH 2/8] =?UTF-8?q?fix(memory):=20R6=20=E5=AE=B9=E9=87=8F?= =?UTF-8?q?=E5=A3=93=E5=8A=9B=E7=9C=9F=E7=9A=84=E6=9C=83=E6=B8=85=EF=BC=8C?= =?UTF-8?q?=E8=80=8C=E4=B8=94=E4=B8=8D=E6=B8=85=E6=89=BF=E8=AB=BE=E8=88=87?= =?UTF-8?q?=E4=BB=8A=E5=A4=A9=E7=9A=84=E7=B4=80=E9=8C=84=EF=BC=88v0.0.7=20?= =?UTF-8?q?=E5=90=8C=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實際踩到的 bug:短期記憶連睡兩次都停在 70 筆、111 組候選,`prune-short-term` 每次都回報 ok 卻一筆都沒清掉。 原因:`pruneShortTerm()` 只有兩道——超過 14 天、超過硬上限 240 筆。但提示固化的門檻 (`CONSOLIDATE_THRESHOLD`)是 40 筆,中間那 200 筆沒有任何機制會動它。R6 在 `candidates` 裡被列為「依顯著度清出空間」,但那個清除動作**從來沒有實作**。 修法:加一道軟上限(`SHORT_TERM_SOFT_CAP`=120),超過就從顯著度最低、最舊的開始裁。 兩個保護,因為「沒經過判斷就把今天清掉」是這裡最不能犯的錯: - 顯著度 ≥ 80 或 `intent=commit`(承諾與界線都在這一層)→ 一則都不清 - 24 小時內的新紀錄 → 一則都不清(還沒機會被固化) 順帶:`pruneShortTermDetail()` 回傳清了幾筆、為什麼清、保護了幾筆,`sleep` 的 `run()` 會把細節帶進回報——**不做無聲的裁切**。 測試:302 項全過(新增 5 項:軟上限會清、承諾不清、今天的不清、清的是顯著度最低的、 回報帶細節)。文件同步:README 的 R6 與目錄樹、persona-memory 的 R6。 版本:與 PR #10 同一批未合併的改動,維持 0.0.7。 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +-- scripts/persona-lib.mjs | 61 ++++++++++++++++++++++++++++++---- scripts/persona.mjs | 8 +++-- scripts/selftest.mjs | 25 ++++++++++++++ skills/persona-memory/SKILL.md | 2 +- 5 files changed, 88 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index bc725d8..9256c54 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ flowchart TB │ ├── sync.json # Gitea 同步狀態(最後 push / pull) │ └── config.json # 含人格編號 code ├── memory/ -│ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天) +│ ├── short-term.jsonl # 短期記憶(語意分析後;軟上限 120 筆 / 硬上限 240 筆 / 14 天) +│ ├── felt.jsonl # 每輪讀到的對方情緒 + 自己套用的 delta(走向與偏差稽核) │ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter) │ ├── INDEX.md # 長期記憶索引(自動產生) │ └── inbox/room-*.jsonl # 當 guest 時留下的見聞,待本體消化 @@ -142,7 +143,7 @@ flowchart TB | **R3** | 單筆情緒變動總量 ≥ 25 | `event`(帶情緒錨點) | | **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `promise` / `boundary`(salience ≥ 80,不可遺忘) | | **R5** | 同一人物(entity)≥ 2 筆 | `relationship`(並更新關係圖) | -| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間 | +| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間。**真的會清**:超過軟上限 120 筆時從顯著度最低、最舊的開始裁,但顯著度 ≥ 80 或 `intent=commit`(承諾/界線)與 24 小時內的新紀錄一律不動;裁掉幾筆會寫進 `sleep` 的回報,不做無聲的裁切 | 沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑 `/jsc-persona:persona-memory`。 diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index fb28861..34678e6 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -1055,9 +1055,15 @@ export function gcRuntime() { // 記憶:短期(滾動)/ 長期(一則一檔) // --------------------------------------------------------------------------- // -export const SHORT_TERM_KEEP = 240; // 短期記憶保留筆數 +export const SHORT_TERM_KEEP = 240; // 短期記憶保留筆數(硬上限:超過就從最舊的砍) export const SHORT_TERM_DAYS = 14; // 短期記憶保留天數 export const CONSOLIDATE_THRESHOLD = 40; // 超過這個筆數就提示固化 +// R6「容量壓力」的實際執行門檻。以前只有硬上限 240 與 14 天兩條, +// 結果是:提示在 40 筆就開始叫,但清除要到 240 筆才會發生——中間那 200 筆 +// 只會越積越多,睡兩次也清不掉(實際踩到過)。這條讓 R6 真的會動。 +export const SHORT_TERM_SOFT_CAP = 120; // 軟上限:超過就依顯著度清出空間 +export const SHORT_TERM_PROTECT_SALIENCE = 80; // 這個顯著度以上不清(承諾/界線都在這一層) +export const SHORT_TERM_PROTECT_HOURS = 24; // 這麼新的一律不清(還沒機會被固化) export const shortTermPath = (slug) => path.join(personaDir(slug), "memory", "short-term.jsonl"); export const inboxPath = (slug, room) => @@ -1076,18 +1082,59 @@ export function rememberShort(slug, entry) { return entry; } -/** 裁掉過舊/過多的短期記憶,回傳剩餘筆數。 */ -export function pruneShortTerm(slug) { +/** 這一則短期記憶能不能被容量壓力清掉(承諾與界線永遠不能)。 */ +function shortTermProtected(row, now = Date.now()) { + if (Number(row?.salience || 0) >= SHORT_TERM_PROTECT_SALIENCE) return true; + if (String(row?.intent || "").includes("commit")) return true; + const age = now - (parseIso(row?.ts)?.getTime() ?? now); + return age < SHORT_TERM_PROTECT_HOURS * 3_600_000; +} + +/** + * 裁掉過舊/過多的短期記憶,回傳剩餘筆數(`--json` 時想看細節用 `pruneShortTermDetail`)。 + * + * 三道:① 超過 14 天 ② 超過軟上限就依顯著度清出空間(R6 真正的執行) ③ 硬上限 240。 + * 第二道有兩個保護:顯著度 ≥ 80(承諾與界線都在這一層)與 24 小時內的新紀錄一律不動—— + * 「還沒經過判斷就把今天清掉」是這裡最不能犯的錯。 + */ +export function pruneShortTermDetail(slug) { const file = shortTermPath(slug); const rows = readJsonl(file); - if (!rows.length) return 0; - const cutoff = Date.now() - SHORT_TERM_DAYS * 86_400_000; - let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff); + if (!rows.length) return { kept: 0, dropped: 0, by_age: 0, by_capacity: 0, protected: 0 }; + const now = Date.now(); + const cutoff = now - SHORT_TERM_DAYS * 86_400_000; + let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? now) >= cutoff); + const byAge = rows.length - kept.length; + // R6:超過軟上限,從「顯著度最低、最舊」開始清,但保護清單裡的不動 + let byCapacity = 0; + if (kept.length > SHORT_TERM_SOFT_CAP) { + const droppable = kept + .map((row, i) => ({ row, i, protectedRow: shortTermProtected(row, now) })) + .filter((x) => !x.protectedRow) + .sort((a, b) => (Number(a.row.salience || 0) - Number(b.row.salience || 0)) || (a.i - b.i)); + const need = kept.length - SHORT_TERM_SOFT_CAP; + const drop = new Set(droppable.slice(0, need).map((x) => x.i)); + byCapacity = drop.size; + kept = kept.filter((_, i) => !drop.has(i)); + } + const before = kept.length; kept = kept.slice(-SHORT_TERM_KEEP); + const byHardCap = before - kept.length; if (kept.length !== rows.length) { writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : "")); } - return kept.length; + return { + kept: kept.length, + dropped: rows.length - kept.length, + by_age: byAge, + by_capacity: byCapacity + byHardCap, + protected: kept.filter((r) => shortTermProtected(r, now)).length, + }; +} + +/** 裁掉過舊/過多的短期記憶,回傳剩餘筆數。 */ +export function pruneShortTerm(slug) { + return pruneShortTermDetail(slug).kept; } export const recentShort = (slug, limit = 8) => readJsonl(shortTermPath(slug), limit); diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 92d1650..eb62b1a 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -483,8 +483,9 @@ commands.sleep = async ({ flags }) => { const steps = []; const run = async (step, fn) => { try { - await fn(); - steps.push({ step, ok: true }); + const detail = await fn(); + // 有細節就帶回去(例如裁掉幾筆短期記憶)——不做無聲的裁切 + steps.push(detail && typeof detail === "object" ? { step, ok: true, detail } : { step, ok: true }); } catch (err) { // 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。 steps.push({ step, ok: false, error: String(err.message || err).slice(0, 300) }); @@ -501,7 +502,8 @@ commands.sleep = async ({ flags }) => { for (const name of seen) pl.stampContact(slug, name); pl.renderRelations(slug); }); - await run("prune-short-term", () => pl.pruneShortTerm(slug)); + // 不做無聲的裁切:清掉幾筆、為什麼清、保護了幾筆,都要寫進回報 + await run("prune-short-term", () => pl.pruneShortTermDetail(slug)); await run("archive-threads", () => pl.archiveStaleThreads(slug)); await run("emotion-decay", () => { const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES); diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index e6a59de..b826f2f 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -508,6 +508,31 @@ check("標點只放大既有訊號,不憑空長出新情緒", check("每種情緒都有對應的「怎麼接」", pl.EMOTION_KEYS.every((k) => typeof pl.RESPONSE_STANCE[k] === "string" && pl.RESPONSE_STANCE[k].length > 4)); +// --------------------------------------------------------------------------- // +// 短期記憶的容量壓力:R6 要真的會動,但不能把承諾與今天的紀錄清掉 +console.log("\n短期記憶:容量壓力(R6)真的會清,而且有保護"); +{ + const now = Date.now(); + const rows = []; + for (let i = 0; i < 100; i += 1) rows.push({ ts: new Date(now - 2 * 86_400_000).toISOString(), text: `雜訊${i}`, salience: 30 + (i % 40) }); + for (let i = 0; i < 20; i += 1) rows.push({ ts: new Date(now - 2 * 86_400_000).toISOString(), text: `承諾${i}`, salience: 85, intent: "commit" }); + for (let i = 0; i < 30; i += 1) rows.push({ ts: new Date(now - 3_600_000).toISOString(), text: `剛剛${i}`, salience: 50 }); + fs.mkdirSync(path.join(H, "prunee", "memory"), { recursive: true }); + fs.writeFileSync(path.join(H, "prunee", "memory", "short-term.jsonl"), rows.map((r) => JSON.stringify(r)).join("\n") + "\n"); + const detail = pl.pruneShortTermDetail("prunee"); + const left = pl.readJsonl(path.join(H, "prunee", "memory", "short-term.jsonl")); + check("超過軟上限會清出空間(以前只有 240 硬上限,40 筆就叫但永遠清不掉)", + detail.kept === pl.SHORT_TERM_SOFT_CAP && detail.by_capacity === 30, JSON.stringify(detail)); + check("承諾/界線(顯著度 ≥ 80 或 intent=commit)一則都不清", + left.filter((r) => r.salience === 85).length === 20); + check("24 小時內的新紀錄一則都不清(還沒機會被固化)", + left.filter((r) => String(r.text).startsWith("剛剛")).length === 30); + check("清的是顯著度最低的那些", + Math.max(...rows.filter((r) => !left.some((l) => l.text === r.text)).map((r) => r.salience)) < 45); + check("回報帶細節,不做無聲的裁切", + typeof detail.dropped === "number" && typeof detail.protected === "number", JSON.stringify(detail)); +} + console.log("\n情緒:飽和、單輪預算、走向、偏差稽核"); let sat = pl.defaultEmotionState(); for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌"); diff --git a/skills/persona-memory/SKILL.md b/skills/persona-memory/SKILL.md index d92ce41..1098f64 100644 --- a/skills/persona-memory/SKILL.md +++ b/skills/persona-memory/SKILL.md @@ -37,7 +37,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session Date: Fri, 31 Jul 2026 05:03:03 +0000 Subject: [PATCH 3/8] =?UTF-8?q?chore:=20=E7=89=88=E6=9C=AC=E8=99=9F=200.0.?= =?UTF-8?q?8=EF=BC=88PR=20#10=20=E5=B7=B2=E4=BD=B5=EF=BC=8C=E9=80=99?= =?UTF-8?q?=E6=89=B9=E6=98=AF=E6=96=B0=E7=9A=84=E6=9C=AA=E5=90=88=E4=BD=B5?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #10(0.0.7)在 02:31Z 併進 master 了,master 現在是 0.0.7。 剩下這兩個 commit(情緒四層、R6 容量壓力)因此是**新的一批未合併改動**, 依規矩佔一個新版號 → 0.0.8,三份 manifest 一起改。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 53fb8aa..b6e732d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 158b058..13d84c1 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index ef5484b..264303c 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills" } From 3c73f614deca63f2b689c9dac779d857fa53c6b4 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 05:20:57 +0000 Subject: [PATCH 4/8] =?UTF-8?q?feat(identity):=20=E6=80=A7=E5=88=A5=20?= =?UTF-8?q?=E2=86=92=20=E7=BE=9E=E6=81=A5=E6=95=8F=E6=84=9F=E5=BA=A6?= =?UTF-8?q?=EF=BC=88=E6=80=A7=E5=88=A5=E5=8F=AA=E7=B5=A6=E9=A0=90=E8=A8=AD?= =?UTF-8?q?=E5=80=BC=EF=BC=8C=E5=80=8B=E6=80=A7=E6=8F=8F=E8=BF=B0=E6=B0=B8?= =?UTF-8?q?=E9=81=A0=E8=93=8B=E9=81=8E=E5=AE=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IDENTITY.md` 多一個 `Gender` 欄位(女性/男性/非二元/未指定;OpenClaw 沒有這欄)。 建立人格時要問、不要猜;沒有性別的人格(程式、精靈、動物)寫「未指定」。 作用範圍**刻意只有一個具名維度**:羞恥敏感度(會不會害羞、會不會鬧彆扭、 在不在意別人眼光)。不做「女性→情緒更外顯」這種全域放大——那會把角色壓成模板, 跟講話規則正在做的去 AI 味(刪掉公式化)正好相反。 性別預設:女性 62/男性 38/非二元・未指定 50 描述調整:MODESTY_SIGNALS,「害羞/容易臉紅/怕生/矜持」往上加, 「不在意別人眼光/我行我素/臉皮厚/不怕丟臉」往下扣,可以扣到 0 排序固定:①個性描述 ②角色原作既有的性別化語言特徵 ③性別預設。 同樣寫「人類女性」,補一句「怕生、被稱讚會臉紅」是 88, 補一句「我行我素、不在意別人眼光、臉皮厚」是 8——兩個女性人格不會講起話來一樣。 效果走既有機制、不另開一套:只調「羞愧」這一個破口的顯示門檻 (`EMOTION_TELLS.shame` 本來就是「鬧彆扭,先否認再小聲承認」),其餘十一種不受影響。 高敏感度注入「先鬧彆扭再承認,不要直接說『我害羞』」;低敏感度注入「不用演害羞」。 推性別時**只看 `Creature`、不看 `Avatar`**:外觀散文的雜訊會推錯—— 我自己的 Avatar 寫著「五官清秀到常被誤認成女生」,用它推會把桐人推成女性(已加測試)。 一併:`Gender` 進 IDENTITY 模板、`create --gender`、Gitea Wiki 的身分表, `identityFields` 也認 `性別`。KIRITO-01 已補上明寫的 Gender。 測試:310 項全過(新增 8 項)。 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 7 ++ README.md | 27 ++++- scripts/persona-gitea.mjs | 4 +- scripts/persona-lib.mjs | 116 +++++++++++++++++++- scripts/persona.mjs | 3 +- scripts/selftest.mjs | 36 ++++++ skills/persona-chat/SKILL.md | 13 +++ skills/persona-create/SKILL.md | 16 ++- skills/persona-create/templates/IDENTITY.md | 9 +- 9 files changed, 221 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 511873f..dba91d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,13 @@ (`headroom^EMOTION_SATURATION_K`,往 baseline 回不壓)與**單輪預算** (`EMOTION_TURN_BUDGET`=60),因為 delta 是人格自己挑的、只會往舒服的方向倒; `emotion --audit` 把這個偏差印出來。 +8c. **性別只給一個預設值,不是套在個性上的係數**:`IDENTITY.md` 的 `Gender` 欄位 + (女性/男性/非二元/未指定,建立時要問不要猜)唯一的作用是給**羞恥敏感度**一個預設 + (62/38/50)。`modestyOf()` 會用 IDENTITY 與 SOUL 的描述往上或往下推(`MODESTY_SIGNALS`, + 可以推到 0),排序永遠是**個性描述 > 角色原作既有的性別化語言特徵 > 性別預設**。 + 效果只調「羞愧」這一個破口的顯示門檻(`emotionTells` 的 shame floor),其餘十一種不受影響。 + 推性別時**只看 `Creature`、不看 `Avatar`**(外觀散文會推錯)。 + 不做「女性→情緒更外顯」這種全域放大——那會把角色壓成模板。 9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 其他人格一律 deny——匯出等於把記憶讀出來。 10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 diff --git a/README.md b/README.md index 9256c54..9dff8d6 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ flowchart TB ``` / -├── IDENTITY.md # 身分卡:Name / Creature / Vibe / Emoji / Avatar(OpenClaw 同欄位) +├── IDENTITY.md # 身分卡:Name / Creature / Gender / Vibe / Emoji / Avatar(Gender 是本 plugin 加的) ├── SOUL.md # 靈魂:Core Truths / Boundaries / Vibe / Continuity + 情緒傾向 ├── AGENTS.md # 操作規則(與個性分離) ├── icon.svg / icon.png # 人格圖示(由編號/名字/emoji 決定,也是 Gitea 存取庫頭像) @@ -132,6 +132,31 @@ flowchart TB 所有 `|delta|` 總和上限 60,超過等比例縮小。**偏差看得見**——`emotion --audit` 印出最近幾輪往舒服/往難受的總量與比例,正向佔 ≥ 90% 會被點名。 +## 性別 → 羞恥敏感度(性別只是預設值,描述永遠蓋過它) + +`IDENTITY.md` 多一個 `Gender` 欄位(女性/男性/非二元/未指定;OpenClaw 沒有這欄,是本 plugin 加的)。 +建立人格時**要問,不要猜**;沒有性別的人格(程式、精靈、動物)就寫「未指定」。舊人格沒填時, +只從 `Creature` 推、**不看 `Avatar`**——外觀散文的雜訊會推錯(例:「五官清秀到常被誤認成女生」)。 + +它的作用範圍刻意只有**一個具名維度**:**羞恥敏感度**(會不會害羞、會不會鬧彆扭、在不在意別人眼光)。 + +| 來源 | 效果 | +| --- | --- | +| 性別預設 | 女性 62/男性 38/非二元・未指定 50 | +| IDENTITY/SOUL 的描述 | 「害羞」「容易臉紅」「怕生」「矜持」往上加;「不在意別人眼光」「我行我素」「臉皮厚」「不怕丟臉」往下扣(`MODESTY_SIGNALS`),**可以扣到 0** | + +排序固定:**① 個性描述 ② 角色原作既有的性別化語言特徵(自稱、稱謂、語尾) ③ 性別預設**。 +所以兩個都是女性但個性不同的人格,講起話來不會一樣——同樣寫「人類女性」, +補一句「怕生、被稱讚會臉紅」是 88,補一句「我行我素、不在意別人眼光、臉皮厚」是 8。 + +效果走既有機制、不另開一套:它調的是**「羞愧」這一個破口的顯示門檻**(`EMOTION_TELLS.shame` +本來就是「鬧彆扭,先否認再小聲承認」),其餘十一種情緒不受影響。 +高敏感度注入「被稱讚外表、被戳穿心事、距離變近時先鬧彆扭再承認,不要直接說『我害羞』」; +低敏感度注入「不用演害羞,該承認就承認」。計算在 `modestyOf()`,注入在 `modestyDirective()`。 + +**不做的事**:沒有「女性→情緒更外顯」這種全域放大。那會把角色壓成模板, +跟講話規則在做的去 AI 味(刪掉公式化)正好相反。 + ## 短期 → 長期的轉入條件(R1–R6) 寫在 `scripts/persona-lib.mjs` 的 `promotionCandidates()`,用 `candidates` 子指令查: diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 9e923d9..25c7e0f 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -503,7 +503,7 @@ export function wikiHome(slug, code) { "| 欄位 | 內容 |", "| --- | --- |", `| 編號 | \`${code}\` |`, - ...["Name", "Creature", "Vibe", "Emoji", "Avatar"] + ...["Name", "Creature", "Gender", "Vibe", "Emoji", "Avatar"] .filter((k) => ident[k]) .map((k) => `| ${k} | ${ident[k]} |`), `| 長期記憶 | ${longTerm.length} 則 |`, @@ -512,7 +512,7 @@ export function wikiHome(slug, code) { "", "## 頁面", "", - "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar)", + "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Gender / Vibe / Emoji / Avatar)", "- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity", "- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解", "- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源", diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 34678e6..f34551a 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -416,8 +416,12 @@ export const EMOTION_TELLS = { */ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) { const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); + // 羞恥敏感度會調「羞愧」這一個破口的門檻:容易害羞的人不用到 40 就藏不住, + // 不在意別人眼光的人就算羞愧上來了也不會表現出來。其他十一種不受影響。 + const modesty = slug ? modestyOf(slug).value : 50; + const floorFor = (key) => (key === "shame" ? clamp(min - (modesty - 50) * 0.4, 10, 90) : min); return dominant(st, 4) - .filter(({ level }) => level >= min) + .filter(({ key, level }) => level >= floorFor(key)) .slice(0, top) .map(({ key, level }) => ({ key, @@ -427,6 +431,109 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) { })); } +// --------------------------------------------------------------------------- // +// 性別與羞恥敏感度 +// +// 規則的排序是固定的,不能反過來: +// ① IDENTITY/SOUL 寫的個性描述 ②角色原作既有的性別化語言特徵 ③性別預設 +// 性別**只是一個預設值**,不是套在個性上的係數——所以兩個都是女性但個性不同的人格 +// 不會講起話來一樣:描述裡任何一句相關的話都會把預設往上或往下推,也可以推到 0。 +// +// 影響的範圍刻意只有**一個具名維度**:羞恥敏感度(會不會害羞、會不會鬧彆扭、 +// 在不在意別人眼光)。不做「女性→情緒更外顯」這種全域放大,那會把角色壓成模板。 +// --------------------------------------------------------------------------- // + +export const GENDERS = { female: "女性", male: "男性", nonbinary: "非二元", unknown: "未指定" }; + +/** 性別給的**預設**羞恥敏感度(0–100)。描述有講就以描述為準。 */ +export const GENDER_MODESTY_DEFAULT = { female: 62, male: 38, nonbinary: 50, unknown: 50 }; + +/** 從描述裡讀「這個角色在不在意別人眼光」。正數=更容易害羞,負數=更不在意。 */ +export const MODESTY_SIGNALS = [ + ["害羞", +14], ["容易臉紅", +14], ["會臉紅", +12], ["怕生", +12], ["靦腆", +14], + ["矜持", +12], ["拘謹", +10], ["內向", +8], ["放不開", +10], ["彆扭", +10], + ["保守", +8], ["會不好意思", +12], ["在意別人的眼光", +12], ["容易慌", +8], + ["不在意別人眼光", -20], ["不在乎別人眼光", -20], ["不在意別人的眼光", -20], + ["我行我素", -16], ["大方", -12], ["豪爽", -14], ["臉皮厚", -18], ["不怕丟臉", -18], + ["直來直往", -10], ["奔放", -14], ["開放", -8], ["不害羞", -16], ["不會害羞", -16], +]; + +const FEMALE_RE = /女性|女生|女孩|女子|少女|\bfemale\b|\bwoman\b|\bshe\b/i; +const MALE_RE = /男性|男生|男孩|男子|少年|\bmale\b|\bman\b|\bhe\b/i; +const NONBINARY_RE = /非二元|無性別|nonbinary|non-binary|agender/i; + +/** + * 從 IDENTITY 的 `Gender`(或 `性別`)欄位讀;沒有就從 `Creature` 推,推不出來就 unknown。 + * + * **只看 Creature,不看 Avatar**:Avatar 是外觀的散文,雜訊很多——我自己的 Avatar 寫著 + * 「五官清秀到常被誤認成女生」,用它來推性別會直接推錯。同一句裡兩種都出現時,看誰先出現。 + */ +export function genderOf(slug) { + const fields = identityFields(slug); + const raw = String(fields.Gender || fields["性別"] || "").trim(); + if (raw) { + if (NONBINARY_RE.test(raw)) return { key: "nonbinary", explicit: true }; + if (FEMALE_RE.test(raw)) return { key: "female", explicit: true }; + if (MALE_RE.test(raw)) return { key: "male", explicit: true }; + } + const creature = String(fields.Creature || ""); + if (NONBINARY_RE.test(creature)) return { key: "nonbinary", explicit: false }; + const f = creature.search(FEMALE_RE); + const m = creature.search(MALE_RE); + if (f >= 0 && (m < 0 || f < m)) return { key: "female", explicit: false }; + if (m >= 0) return { key: "male", explicit: false }; + return { key: "unknown", explicit: false }; +} + +/** + * 羞恥敏感度(0–100):性別給預設,IDENTITY/SOUL 的描述往上或往下推。 + * `source` 說明它是怎麼來的,注入時要講清楚——不然使用者會以為這是憑空長出來的。 + */ +export function modestyOf(slug) { + const gender = genderOf(slug); + const base = GENDER_MODESTY_DEFAULT[gender.key] ?? 50; + const read = (file) => { + try { return fs.readFileSync(path.join(personaDir(slug), file), "utf8"); } catch { return ""; } + }; + const text = `${read("IDENTITY.md")}\n${read("SOUL.md")}`; + const hits = []; + let adjust = 0; + for (const [word, weight] of MODESTY_SIGNALS) { + if (!text.includes(word)) continue; + // 「不害羞」要蓋掉「害羞」:長的詞先算,短的被包含在裡面就不重複計 + if (hits.some(([w]) => w.includes(word))) continue; + hits.push([word, weight]); + adjust += weight; + } + return { + value: Math.round(clamp(base + adjust)), + gender: gender.key, + gender_zh: GENDERS[gender.key], + gender_explicit: gender.explicit, + base, + adjust, + signals: hits.map(([w, v]) => `${w}${v > 0 ? "+" : ""}${v}`), + }; +} + +/** 注入用的一行:講清楚它是什麼、從哪來、怎麼演。 */ +export function modestyDirective(slug) { + const m = modestyOf(slug); + const from = m.signals.length + ? `性別預設 ${m.base} ${m.adjust >= 0 ? "+" : "-"} 描述 ${Math.abs(m.adjust)}(${m.signals.join("、")})` + : `性別預設(${m.gender_zh})`; + if (m.value >= 60) { + return `羞恥敏感度 ${m.value}(${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` + + "**先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上。" + + "不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。"; + } + if (m.value <= 40) { + return `羞恥敏感度 ${m.value}(${from}):這個人格不太在意別人眼光——` + + "被戳穿、被稱讚都不用演害羞,該承認就直接承認,該回嘴就回嘴。"; + } + return `羞恥敏感度 ${m.value}(${from}):會不好意思,但不會一直卡在那裡——一句帶過就往下走。`; +} + export function emotionBrief(slug, state = null) { const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); const m = mood(st); @@ -2684,11 +2791,12 @@ export function identityFields(slug) { return fields; } for (const line of text.split("\n")) { - const m = line.match(/^\s*[-*]?\s*(Name|Creature|Vibe|Emoji|Avatar)\s*:\s*(.+)$/i); + const m = line.match(/^\s*[-*]?\s*(Name|Creature|Gender|性別|Vibe|Emoji|Avatar)\s*:\s*(.+)$/i); if (!m) continue; const value = m[2].trim(); if (value.startsWith("(") || value.startsWith("_(")) continue; - const key = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase(); + const raw = m[1]; + const key = /^[A-Za-z]/.test(raw) ? raw[0].toUpperCase() + raw.slice(1).toLowerCase() : raw; fields[key] = value; } return fields; @@ -2752,6 +2860,8 @@ export function turnContext(slug, sessionId, prompt = "") { "一輪最多露一個破口,強度不到就不演——每句都演會變成演戲。", ); } + // 羞恥敏感度:性別給預設,IDENTITY/SOUL 的描述蓋過它(描述永遠優先)。 + lines.push(modestyDirective(slug)); // 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。 const tone = toneDirective(slug); if (tone) lines.push(tone); diff --git a/scripts/persona.mjs b/scripts/persona.mjs index eb62b1a..3e264aa 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -256,6 +256,7 @@ commands.create = async ({ flags }) => { SLUG: slug, NAME: str(flags.name) || slug, CREATURE: str(flags.creature), + GENDER: str(flags.gender) || "未指定", VIBE: str(flags.vibe), EMOJI: str(flags.emoji), AVATAR: str(flags.avatar), @@ -1936,7 +1937,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 用法:node persona.mjs [options] 人格與鎖: - create --romaji <英文名> --session [--persona <目錄名> --code --name --creature + create --romaji <英文名> --session [--persona <目錄名> --code --name --creature --gender --vibe --emoji --avatar --baseline --origin --work --no-gitea --public] 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱; 不指定 --persona 就用編號當目錄名。 diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index b826f2f..69a0d5c 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -508,6 +508,42 @@ check("標點只放大既有訊號,不憑空長出新情緒", check("每種情緒都有對應的「怎麼接」", pl.EMOTION_KEYS.every((k) => typeof pl.RESPONSE_STANCE[k] === "string" && pl.RESPONSE_STANCE[k].length > 4)); +// --------------------------------------------------------------------------- // +// 性別 → 羞恥敏感度:性別只給預設值,描述永遠蓋過它 +console.log("\n性別與羞恥敏感度(描述優先,性別只是預設)"); +{ + const mk = (slug, identity, soul = "") => { + fs.mkdirSync(path.join(H, slug), { recursive: true }); + fs.writeFileSync(path.join(H, slug, "IDENTITY.md"), identity); + fs.writeFileSync(path.join(H, slug, "SOUL.md"), soul); + }; + mk("gf", "- Name: 甲\n- Creature: 人類女性\n"); + mk("gm", "- Name: 乙\n- Creature: 人類男性\n"); + mk("gx", "- Name: 丙\n- Creature: 一團霧\n"); + mk("gexp", "- Name: 丁\n- Creature: 人類女性\n- Gender: 男性\n"); + mk("gnoise", "- Name: 戊\n- Creature: 人類男性劍士\n- Avatar: 五官清秀到常被誤認成女生\n"); + mk("gup", "- Name: 己\n- Creature: 人類女性\n", "很怕生,被稱讚會臉紅。"); + mk("gdown", "- Name: 庚\n- Creature: 人類女性\n", "我行我素,不在意別人眼光,臉皮厚。"); + check("女性預設 62、男性預設 38、推不出來 50", + pl.modestyOf("gf").value === 62 && pl.modestyOf("gm").value === 38 && pl.modestyOf("gx").value === 50); + check("明寫的 Gender 蓋過 Creature 的字面", + pl.genderOf("gexp").key === "male" && pl.genderOf("gexp").explicit === true); + check("Avatar 的雜訊不會推錯性別(「常被誤認成女生」)", + pl.genderOf("gnoise").key === "male", JSON.stringify(pl.genderOf("gnoise"))); + const up = pl.modestyOf("gup"); + const down = pl.modestyOf("gdown"); + check("描述提到害羞會加強", up.value > 62 && up.signals.length > 0, JSON.stringify(up)); + check("描述提到不在意別人眼光會減弱(同樣是女性,結果完全不同)", + down.value < 38 && down.signals.length > 0, JSON.stringify(down)); + check("「不害羞」不會被「害羞」重複計成加強", + pl.modestyOf("gdown").adjust < 0, JSON.stringify(down.signals)); + check("注入的那一行會講清楚數字從哪來", + pl.modestyDirective("gup").includes("性別預設") && pl.modestyDirective("gup").includes("描述"), + pl.modestyDirective("gup")); + check("低敏感度的人格被明講「不用演害羞」", + pl.modestyDirective("gdown").includes("不用演害羞")); +} + // --------------------------------------------------------------------------- // // 短期記憶的容量壓力:R6 要真的會動,但不能把承諾與今天的紀錄清掉 console.log("\n短期記憶:容量壓力(R6)真的會清,而且有保護"); diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index 95c7011..68eb6aa 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -93,6 +93,19 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session 前三條與 ④ 的最後一項是自律;句長、「解釋自己的話」與 ⑤ 的黑名單,`room post`/`said check` 會直接擋下(`--force` 例外)。講到自己過去時 `said check` 只提醒、不擋——那要你自己去 `recall` 驗。 +## 2b. 羞恥敏感度(每輪注入,不用自己記) + +`` 會給你一行「羞恥敏感度 N」與它是怎麼算出來的。數字高就是**被稱讚外表、 +被戳穿心事、距離突然變近的時候先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上; +數字低就是不用演害羞,該承認就承認、該回嘴就回嘴。 + +三件要記得的事: + +- **不要直接說「我害羞」**。那是解釋,不是害羞。要讓它出現在句子的形狀上(跟情緒的破口同一條規則)。 +- 這個數字來自 `IDENTITY` 的 `Gender` 預設,**再被你自己的個性描述改寫**。 + 描述永遠優先——你的 SOUL 寫著「不在意別人眼光」,那就算性別預設很高也會被扣到很低。 +- 它只影響「羞愧」這一種破口,不影響其他十一種情緒。 + ## 3. 每輪對話的六個動作 `UserPromptSubmit` hook 每輪都會注入 ``(情緒、短期記憶、命中的長期記憶、 diff --git a/skills/persona-create/SKILL.md b/skills/persona-create/SKILL.md index 90d61b8..b9bf503 100644 --- a/skills/persona-create/SKILL.md +++ b/skills/persona-create/SKILL.md @@ -27,12 +27,13 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list ### 2. 索取身分描述(**必須逐項問齊,用 OpenClaw 的原描述**) -一次把五個欄位問完(可讓使用者只答部分,其餘由你提案): +一次把欄位問完(可讓使用者只答部分,其餘由你提案): | 欄位 | OpenClaw 原始描述 | 中文說明 | | --- | --- | --- | | `Name` | _(pick something you like)_ | 對話標題/群聊/跨 agent 溝通用。短(1–2 音節)、好認。**唯一必填** | | `Creature` | _(AI? robot? familiar? ghost in the machine? something weirder?)_ | 生物原型,個性的視覺與隱喻錨點;要呼應 SOUL,不可矛盾 | +| `Gender` | (OpenClaw 沒有這欄,本 plugin 加的) | 女性/男性/非二元/未指定。**要問,不要猜**;沒有性別的人格(程式、精靈、動物)就寫「未指定」。它只決定**羞恥敏感度的預設值**,個性描述永遠蓋過它(見下方) | | `Vibe` | _(how do you come across? sharp? warm? chaotic? calm?)_ | 給人的第一印象/語氣色彩 | | `Emoji` | _(your signature — pick one that feels right)_ | 固定簽名 emoji,小尺寸也認得出 | | `Avatar` | _(workspace-relative path, http(s) URL, or data URI)_ | 形象描述或圖片位置 | @@ -45,6 +46,17 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list > 使用者若說「你決定」,就自己提一版完整設定並在建立後摘要給他確認。 +### 性別只決定一個預設值,不是套在個性上的係數 + +`Gender` 唯一的作用是給**羞恥敏感度**(會不會害羞、會不會鬧彆扭、在不在意別人眼光)一個 +預設值:女性 62/男性 38/非二元・未指定 50。**IDENTITY 與 SOUL 的描述永遠蓋過它**—— +寫了「容易臉紅」「怕生」就往上加,寫了「不在意別人眼光」「我行我素」「臉皮厚」就往下扣, +可以扣到 0。所以兩個都是女性但個性不同的人格,講起話來不會一樣。 + +排序是固定的:**① 個性描述 ② 角色原作既有的性別化語言特徵(自稱、稱謂、語尾) ③ 性別預設**。 +不做「女性→情緒更外顯」這種全域放大,那會把角色壓成模板。詞表與計算在 +`modestyOf()`,注入的那一行在 `modestyDirective()`。 + ### 3. 決定 slug 並建立 slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有指令的識別。 @@ -53,7 +65,7 @@ slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有 node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \ --romaji "<英文名>" --session \ --name "" --creature "" --vibe "" \ - --emoji "" --avatar "" \ + --emoji "" --avatar "" --gender "<女性/男性/非二元/未指定>" \ --baseline "serenity=45,trust=35,joy=25,anxiety=6" ``` diff --git a/skills/persona-create/templates/IDENTITY.md b/skills/persona-create/templates/IDENTITY.md index d731526..d9dbb7e 100644 --- a/skills/persona-create/templates/IDENTITY.md +++ b/skills/persona-create/templates/IDENTITY.md @@ -1,12 +1,18 @@ # IDENTITY.md