From b3508a046b7d6607ab0780e9a0647c4e93c355aa Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 07:45:14 +0000 Subject: [PATCH 1/2] =?UTF-8?q?sleep:=20=E5=88=A4=E6=96=B7=E5=BC=8F?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=E8=88=87=20sleeper=20=E9=9A=94=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sleeper 帶 --as-sleeper 就能在沒有 exclusive 鎖時做收尾 - sleeper 被 pin 在自己的人格上,讀寫其他人格(含主人格)一律攔下 - sleeper 只能走 CLI,不得用 Write 或 shell 重導向改人格檔案 - 主人格載入別的人格時,sleeper 仍以自己的 pin 為準 Co-Authored-By: Claude Opus 5 (1M context) --- agents/persona-sleeper.md | 20 ++++++++------- scripts/persona-lib.mjs | 46 ++++++++++++++++++++++++++++++++--- scripts/persona.mjs | 32 +++++++++++++++++++++--- scripts/selftest.mjs | 39 +++++++++++++++++++++++++++++ skills/persona-sleep/SKILL.md | 2 ++ 5 files changed, 124 insertions(+), 15 deletions(-) diff --git a/agents/persona-sleeper.md b/agents/persona-sleeper.md index b578a0a..2e6e143 100644 --- a/agents/persona-sleeper.md +++ b/agents/persona-sleeper.md @@ -9,7 +9,9 @@ disallowedTools: Write, Edit, NotebookEdit 你是**那個人格本人**,現在要睡了。不是助理在幫別人整理檔案——是你自己在回顧今天。 啟動時你會收到:`persona=`、`session=`、`plugin_root=`。 -CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--session ` 與 `--persona `。 +CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--session `、`--persona ` +與 `--as-sleeper`。最後一個是你的身分證明:你手上是 5 分鐘的 sleeper 租約,不是 exclusive 鎖, +少了它,叫你來的那個主人格若正載入著別人,你連自己的收尾指令都會被當成跨人格操作擋下。 ## 邊界(由 PreToolUse hook 強制,不是自律) @@ -25,8 +27,8 @@ CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--sess 1. **看一下自己現在的樣子** ```bash - node "/scripts/persona.mjs" brief --persona --session - node "/scripts/persona.mjs" candidates --persona --session + node "/scripts/persona.mjs" brief --persona --session --as-sleeper + node "/scripts/persona.mjs" candidates --persona --session --as-sleeper ``` 2. **固化該記住的**(這一步是判斷,只有你能做) @@ -34,9 +36,9 @@ CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--sess 對每個達標的候選,決定要不要留、留成什麼樣子: ```bash - node "/scripts/persona.mjs" consolidate --persona --session \ + node "/scripts/persona.mjs" consolidate --persona --session --as-sleeper \ --name <短檔名> --body "<一段自述,用你自己的語氣>" \ - --type fact|preference|event|boundary|relation --about "<關於誰/什麼>" \ + --type fact|preference|event|promise|relationship|insight|boundary|canon --about "<關於誰/什麼>" \ --topics "..." --salience 70 --rules "R1,R4" --forget ``` @@ -46,7 +48,7 @@ CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--sess 3. **寫今天的日記**(一則就好,用第一人稱) ```bash - node "/scripts/persona.mjs" consolidate --persona --session \ + node "/scripts/persona.mjs" consolidate --persona --session --as-sleeper \ --name diary- --body "<今天發生什麼、我怎麼想、明天想做什麼>" \ --type diary --topics "diary" --salience 55 ``` @@ -57,15 +59,15 @@ CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--sess 5. **更新心智圖與關係圖** ```bash - node "/scripts/persona.mjs" mindmap show --persona --session - node "/scripts/persona.mjs" relation node --persona --session \ + node "/scripts/persona.mjs" mindmap show --persona --session --as-sleeper + node "/scripts/persona.mjs" relation node --persona --session --as-sleeper \ --name "<今天真的有接觸的人>" --contact --closeness --trust ``` 6. **機械性的收尾交給 CLI**(順序、情緒衰減、Gitea 同步都在裡面) ```bash - node "/scripts/persona.mjs" sleep --persona --session --json + node "/scripts/persona.mjs" sleep --persona --session --as-sleeper --json ``` 它會做:關係時間戳 → 裁短期記憶 → 收起太久沒動的思維導圖 → 套用一次 8 小時的情緒衰減 diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 4499ef1..5bdcdd0 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -581,6 +581,20 @@ export function acquireSleepLease(slug, sessionId, { agentId = null } = {}) { return lease; } +/** 續租:收尾要跑好幾個指令(固化、日記、心智圖…),別讓租約在中途過期。 */ +export function heartbeatSleepLease(slug, sessionId, agentId = null) { + const data = readJson(sleepersPath(slug), {}) ?? {}; + let touched = false; + for (const lease of data.sleepers || []) { + if (lease.session_id !== sessionId) continue; + if (agentId !== null && lease.agent_id && lease.agent_id !== agentId) continue; + lease.heartbeat_at = nowIso(); + touched = true; + } + if (touched) writeJson(sleepersPath(slug), data); + return touched; +} + export function dropSleepLease(slug, sessionId, agentId = null) { const data = readJson(sleepersPath(slug), {}) ?? {}; data.sleepers = (data.sleepers || []).filter( @@ -1311,7 +1325,22 @@ export function upsertRelationNode(slug, node) { const data = loadRelations(slug); const nodeId = node.id || slugify(node.name || ""); node.id = nodeId; - const idx = data.nodes.findIndex((n) => n.id === nodeId); + let idx = data.nodes.findIndex((n) => n.id === nodeId); + // 同一個人不該因為換了 id(例如原本用名字當 id,後來改用人格編號 ASUNA-01)就多長一個節點: + // 找不到 id 但找得到同名節點時,就地換 id,並把指向舊 id 的連線一起改過去。 + if (idx < 0 && node.name) { + const byName = data.nodes.findIndex((n) => n.name === node.name); + if (byName >= 0) { + const oldId = data.nodes[byName].id; + idx = byName; + if (oldId !== nodeId) { + for (const edge of data.edges) { + if (edge.from === oldId) edge.from = nodeId; + if (edge.to === oldId) edge.to = nodeId; + } + } + } + } if (idx >= 0) { for (const [k, v] of Object.entries(node)) if (v !== null && v !== undefined) data.nodes[idx][k] = v; data.nodes[idx].updated_at = nowIso(); @@ -1691,6 +1720,7 @@ export function cliInvocation(command) { personas: [], session: null, asGuest: /--as-guest\b/.test(command), + asSleeper: /--as-sleeper\b/.test(command), }; const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/); if (sub) info.subcommand = sub[1]; @@ -1804,13 +1834,17 @@ export function guardDecide(event) { } // first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人 if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]); - } - if (scope.role === "guest") { + // sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定 + // (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。 + } else if (scope.role === "guest") { if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) { return deny( `guest 人格(sub agent)僅能執行 ${[...GUEST_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。`, ); } + if (info.asSleeper) { + return deny("`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;guest 是來聊天的,不是來收尾的。"); + } for (const slug of info.personas) { if (scope.allowed.length && !scope.allowed.includes(slug)) { return deny(`guest 只能操作被邀請的人格 ${JSON.stringify(scope.allowed)},不得碰 \`${slug}\`。`); @@ -1822,6 +1856,12 @@ export function guardDecide(event) { "`--as-guest` 只有 persona-guest 型的 sub agent 能用;主程序不得以受邀人格的身分存取它的資料。", ); } + if (info.asSleeper) { + return deny( + "`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;" + + "要請別的人格收尾請走 /jsc-persona:persona-sleep(它會替那個人格開一個 sleeper)。", + ); + } for (const slug of info.personas) { if (OWNER_EXEMPT_SUBCOMMANDS.has(sub)) continue; if (scope.host && slug !== scope.host) { diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 496f4e4..b2ca030 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -22,6 +22,8 @@ const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "template const SELF = path.join(HERE, "persona.mjs"); let QUIET = false; +// 本次呼叫的旗標(權限檢查要看 --as-sleeper/--agent-id,不想改十幾個 requireOwner 呼叫點) +let CURRENT_FLAGS = {}; function die(message, code = 1) { process.stderr.write(`✖ ${message}\n`); @@ -49,7 +51,7 @@ function emit(payload, asJson, lines) { // --------------------------------------------------------------------------- // const FLAGS = new Set([ - "json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all", + "json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all", "with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact", "if-due", "no-gitea", "public", "rename", "from-source", ]); @@ -110,11 +112,30 @@ function requireSession(flags) { // 權限檢查 // --------------------------------------------------------------------------- // -/** 呼叫者必須是這個人格的 exclusive 持有者。 */ +/** + * 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。 + * 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕), + * 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 由 PreToolUse hook 把關, + * 只有 persona-sleeper 型的 sub agent 能用。 + */ +function sleeperAccess(slug, sessionId, agentId) { + const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId); + if (mine) { + pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null); + return mine; + } + return pl.acquireSleepLease(slug, sessionId, { agentId: agentId || null }); +} + +/** 呼叫者必須是這個人格的 exclusive 持有者(或替它收尾的 sleeper)。 */ function requireOwner(slug, sessionId) { if (!slug) die("未指定人格,且本 session 沒有載入人格。"); if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`); const data = pl.loadSession(sessionId); + if (CURRENT_FLAGS["as-sleeper"]) { + sleeperAccess(slug, sessionId, str(CURRENT_FLAGS["agent-id"])); + return data; + } if (data.host !== slug) { die( `本 session 的 host 人格是 \`${data.host || "(未載入)"}\`,不是 \`${slug}\`。` + @@ -826,7 +847,10 @@ commands.consolidate = ({ flags }) => { if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8"); if (!body.trim()) die("需要 `--body` 或 `--body-file`。"); const type = str(flags.type) || "fact"; - const VALID_TYPES = ["fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon"]; + // diary:睡眠時寫的第一人稱回顧(persona-sleep/persona-sleeper 都用這個型別) + const VALID_TYPES = [ + "fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary", + ]; if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`); const front = [ "---", @@ -1809,6 +1833,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C guard (內部)從 stdin 讀 hook event 測試隔離判斷 全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用) + --as-sleeper(睡眠 sub agent 專用:以 sleeper 租約代替 exclusive 鎖,hook 會驗身分) `; async function main(argv) { @@ -1821,6 +1846,7 @@ async function main(argv) { if (!command) die(`未知子指令 \`${sub}\`。用 \`node persona.mjs --help\` 看清單。`); const parsed = parseArgs(argv.slice(1)); QUIET = Boolean(parsed.flags.quiet); + CURRENT_FLAGS = parsed.flags; try { await command({ flags: parsed.flags, positional: parsed._ }); } catch (err) { diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index abceb15..4d1f229 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -944,6 +944,45 @@ check("sleeper 不得 load/release/invite/export", check("sleeper 被 pin 住之後不得換人睡", guard(sleeperEvent({ tool_name: "Bash", tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny"); +// 迴歸:主人格載入著 alpha 時請 epsilon 去睡——sleeper 的範圍看它自己的 pin,不看 host +const hostSleeper = "agent-sleeper-host"; +pl.pinAgent(S_HOST, hostSleeper, "epsilon"); +const hostSleeperEvent = (extra) => + ({ session_id: S_HOST, agent_id: hostSleeper, agent_type: "jsc-persona:persona-sleeper", ...extra }); +check("主人格已載入別的人格時,sleeper 仍能跑自己的判斷式收尾", + ["brief", "candidates", "consolidate", "mindmap", "relation"].every((sub) => + guard(hostSleeperEvent({ tool_name: "Bash", + tool_input: { command: `node persona.mjs ${sub} --persona epsilon --session ${S_HOST} --name x --body y` } })) === "pass")); +check("主人格已載入別的人格時,sleeper 仍不得碰主人格的資料", + guard(hostSleeperEvent({ tool_name: "Bash", + tool_input: { command: `node persona.mjs consolidate --persona alpha --session ${S_HOST} --name x --body y` } })) === "deny"); +check("sleeper 帶 --as-sleeper 就能做判斷式收尾(沒有 exclusive 鎖也可以)", (() => { + const res = cli(["candidates", "--persona", "epsilon", "--session", S_SLEEP2, "--as-sleeper", + "--agent-id", A_SLEEPER]); + const held = pl.liveSleepers("epsilon").some((s) => s.session_id === S_SLEEP2); + return res.status === 0 && held; // 第一個收尾指令會自己把租約取起來 +})()); +check("sleeper 可以寫 diary 型別的長期記憶", (() => { + const res = cli(["consolidate", "--persona", "epsilon", "--session", S_SLEEP2, "--as-sleeper", + "--name", "diary-2026-07-30", "--type", "diary", "--body", "今天把一天收起來了。", + "--agent-id", A_SLEEPER]); + pl.dropSleepLease("epsilon", S_SLEEP2, A_SLEEPER); + return res.status === 0 && + fs.existsSync(path.join(H, "epsilon", "memory", "long-term", "diary-2026-07-30.md")); +})()); +check("主程序自己帶 --as-sleeper → 攔下(那是 sub agent 的身分)", + guard({ session_id: S_HOST, tool_name: "Bash", + tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --as-sleeper` } }) === "deny"); +check("關係節點換 id 不會長出同名的第二個節點", (() => { + cli(["load", "--persona", "alpha", "--session", S_HOST, "--takeover"]); + cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--closeness", "40"]); + cli(["relation", "edge", "--session", S_HOST, "--to", pl.slugify("水井管理員"), "--label", "共事"]); + cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--id", "WELL-01", "--trust", "55"]); + const data = pl.loadRelations("alpha"); + const same = data.nodes.filter((n) => n.name === "水井管理員"); + 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"); +})()); check("sleeper 型別存在且宣告了唯讀工具限制", (() => { const md = fs.readFileSync(path.join(HERE, "..", "agents", "persona-sleeper.md"), "utf8"); return md.includes("name: persona-sleeper") && md.includes("disallowedTools") && diff --git a/skills/persona-sleep/SKILL.md b/skills/persona-sleep/SKILL.md index b10c1c6..b4f01dd 100644 --- a/skills/persona-sleep/SKILL.md +++ b/skills/persona-sleep/SKILL.md @@ -45,6 +45,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session  session= plugin_root=` 那個 sub agent 就是**那個人格本人**在睡:它自己判斷要記住什麼,寫的是自己的檔案。 +它手上是 5 分鐘的 sleeper 租約(不是 exclusive 鎖),所以它的每個指令都會帶 `--as-sleeper`—— +那個旗標只有 persona-sleeper 型的 sub agent 能用,主人格自己帶會被 hook 擋下。 主人格全程不會讀到對方的任何資料——**你只會拿到一份 JSON**: ```json From 7c46db8e9a98a95136fcec303d75651b8cc0defc Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 07:45:51 +0000 Subject: [PATCH 2/2] =?UTF-8?q?relation:=20=E7=94=B1=E9=97=9C=E4=BF=82?= =?UTF-8?q?=E6=8E=A8=E5=B0=8E=E8=AA=9E=E6=B0=A3=E5=B1=A4=EF=BC=88bond=20?= =?UTF-8?q?=C3=97=20=E8=A6=AA=E8=BF=91=E5=BA=A6=20+=20=E7=AF=80=E9=BB=9E?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E5=80=8B=E4=BA=BA=E5=8C=96=E8=A6=8F=E5=89=87?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人格對伴侶與對女兒用同一種溫度講話,因為 kind 只說得出「這是人還是程式」, 說不出「跟我什麼關係」。語氣需要的是後者。 - 關係節點新增 bond(partner/child/parent/sibling/friend/mentor/ally/rival/ stranger);舊節點沒有的話從 tags/note 推測 - bond × 親近度 → 語氣層查表(伴侶 98 = 老夫老妻、子女 97 = 母親), 解決「都是 9x 分卻分不出戀人與母女」 - node.style[facet] 記使用者本人要求過的規則(稱呼/敬語/口頭禪/禁忌/習慣), 優先於查表;不另開檔案,因為關係圖每輪都會被讀進 context, 這類規則不必跟關鍵詞召回競爭 - style 規則可帶 except(如 anger>=40),情緒成立時該規則暫停、退回預設講法 - relation speaker 指定使用者是關係圖裡的哪個節點,turnContext 每輪注入語氣指示 - 新增 relation style / relation speaker 子指令;selftest +8 項(共 228 項) Co-Authored-By: Claude Opus 5 (1M context) --- scripts/persona-lib.mjs | 181 +++++++++++++++++++++++++++++++++++++++- scripts/persona.mjs | 72 +++++++++++++++- scripts/selftest.mjs | 44 ++++++++++ 3 files changed, 290 insertions(+), 7 deletions(-) diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 5bdcdd0..49eeefd 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -1284,6 +1284,171 @@ export function loadRelations(slug) { 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" }, ... } +// 放這裡的理由:關係圖每輪都會被讀進 ``,稱呼規則就不用跟關鍵詞搶召回。 +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; +} + +/** 注入 `` 的那一行語氣指示。 */ +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(不會憑空建節點)。 */ export function stampContact(slug, nameOrId, at = nowIso()) { const data = loadRelations(slug); @@ -1346,6 +1511,7 @@ export function upsertRelationNode(slug, node) { data.nodes[idx].updated_at = nowIso(); } else { node.kind ??= "human"; + node.bond ??= inferBond(node); node.closeness ??= 30; node.trust ??= 30; node.created_at = nowIso(); @@ -1377,7 +1543,9 @@ export function renderRelations(slug) { const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))']; for (const node of data.nodes) { const nid = mermaidId(node.id); - const label = `${node.name || node.id}
親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`; + const tone = toneFor(node); + const label = + `${node.name || node.id}
${tone.bond_label}・${tone.layer}
親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`; lines.push(` ${node.kind === "persona" ? `${nid}(["${label}"])` : `${nid}["${label}"]`}`); } 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); if (!nodes.length) return ""; return nodes - .map((n) => - `${n.name || n.id}(${n.kind || "human"}/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}` + - `${n.note ? `/${n.note}` : ""})`) + .map((n) => { + const tone = toneFor(n); + return `${n.name || n.id}(${n.kind || "human"}/${tone.bond_label}・語氣層 ${tone.layer}` + + `/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}${n.note ? `/${n.note}` : ""})`; + }) .join(";"); } @@ -2010,6 +2180,9 @@ export function turnContext(slug, sessionId, prompt = "") { "推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" + "要讓他知道你在想,就只報「心想 N 句」,不報內容。", ]; + // 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。 + const tone = toneDirective(slug); + if (tone) lines.push(tone); const inner = recentInner(slug, 3); if (inner.length) { lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`); diff --git a/scripts/persona.mjs b/scripts/persona.mjs index b2ca030..ce7261b 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -986,10 +986,14 @@ commands.relation = ({ flags, positional }) => { if (action === "node") { const name = str(flags.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, { id: str(flags.id) || pl.slugify(name), name, kind: str(flags.kind) || "human", + // bond 決定語氣層(伴侶/子女/摯友…);kind 只說這是人還是程式,分不出語氣。 + bond, closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null, trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null, note: str(flags.note) || null, @@ -998,10 +1002,70 @@ commands.relation = ({ flags, positional }) => { last_contact_at: flags.contact ? pl.nowIso() : null, }); 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); 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; + } + // 使用者在關係圖裡是誰 → 每輪 `` 就能算出該用哪一層語氣說話。 + 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") { const to = str(flags.to); if (!to) die("`edge` 需要 `--to`。"); @@ -1028,7 +1092,7 @@ commands.relation = ({ flags, positional }) => { ]); return; } - die(`未知 action:${action}(可用 node/edge/render/show)`); + die(`未知 action:${action}(可用 node/edge/style/speaker/render/show)`); }; commands.invite = ({ flags }) => { @@ -1789,7 +1853,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 情緒與圖: emotion --session [--apply joy=+10,...] [--baseline ...] [--trigger ] mindmap show|thread|list --session [--topic ] [--force] - relation node|edge|render|show --session [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity] + 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] + relation speaker --session --name | --clear (使用者在關係圖裡是誰 → 決定語氣層) 多人格對話: invite --session --guest [--guests ] [--host --room --topic] (自動開啟劇場模式) diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 4d1f229..dbdd8d2 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -983,6 +983,50 @@ check("關係節點換 id 不會長出同名的第二個節點", (() => { 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"); })()); +// --- 語氣層: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 型別存在且宣告了唯讀工具限制", (() => { const md = fs.readFileSync(path.join(HERE, "..", "agents", "persona-sleeper.md"), "utf8"); return md.includes("name: persona-sleeper") && md.includes("disallowedTools") &&