diff --git a/TARGET.md b/TARGET.md index ae4222e..c947868 100644 --- a/TARGET.md +++ b/TARGET.md @@ -112,10 +112,16 @@ Q5–Q10 六題已於 2026/08/03 由使用者拍板,設計不再需要確認 界線寫進注入文字:**標點管分布、強度歸 emoji** - 驗收:同一句話在四種情緒下輸出,標點分布看得出差別,且都沒有過量 -- [ ] **5.1 訊息的節奏:一輪可以拆成兩則(純文字唯一的「時間」訊號)** - - [ ] `speechBudget()` 之外加**分段預算**:高 arousal 或高羞恥時,同一輪允許輸出兩則短訊, +- [x] **5.1 訊息的節奏:一輪可以拆成兩則(純文字唯一的「時間」訊號)**(2026/08/03 16:26:29 完成) + - [x] `speechBudget()` 之外加**分段預算**:高 arousal 或高羞恥時,同一輪允許輸出兩則短訊, 第二則是**補救、嘴硬或改口**(「⋯剛才那句不算」),而不是補充說明 - - [ ] 節流:每 N 輪最多一次,且只在 arousal 或羞恥度過門檻時 + —— `splitBudget()`/`splitDirective()`。門檻 `SPLIT_AROUSAL_AT = 60`、 + `SPLIT_MODESTY_AT = 60`,對方那句帶怒意或厭惡也算。 + **沒額度就整段不注入**——講「這一輪不能拆成兩則」只是提醒他有這個功能 + - [x] 節流:每 N 輪最多一次,且只在 arousal 或羞恥度過門檻時 + —— `SPLIT_COOLDOWN_TURNS = 4`,冷卻靠 `felt.jsonl` 的 `split` 欄位算 + (`turnsSinceSplit()`)。**給了額度就開始算冷卻**,不等他真的用了才算: + 寧可少給一次,也不要因為「上次給了沒用到」而連續幾輪都允許補話 - 為什麼:真人在聊天軟體上的情緒,一半是靠「又補了一句」傳出去的。這是文字獨有、目前完全沒用到的手段 - 驗收:慌的時候會多一則短的;平靜時永遠只有一則 diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index b512d26..02c7e9f 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -1226,6 +1226,61 @@ export function speechBudget(slug, { state = null, read = null, alone = true, pr return tire({ ...base, mode: "open", sentences: MAX_SENTENCES, thinkMin: 0, note: "想到什麼就講,不用先在心裡繞一圈。" }); } +/** 分段訊息:一輪最多兩則,而且每 N 輪只能一次。 */ +export const SPLIT_COOLDOWN_TURNS = 4; +export const SPLIT_AROUSAL_AT = 60; +export const SPLIT_MODESTY_AT = 60; + +/** 距離上次拆成兩則過了幾輪;從來沒拆過回 null。 */ +export function turnsSinceSplit(slug) { + const rows = readJsonl(feltPath(slug), SPLIT_COOLDOWN_TURNS * 4); + const idx = rows.map((r) => Boolean(r.split)).lastIndexOf(true); + return idx === -1 ? null : rows.length - 1 - idx; +} + +/** + * 這一輪可不可以拆成兩則。 + * + * 真人在聊天軟體上的情緒,一半是靠「又補了一句」傳出去的——那是純文字唯一的 + * **時間**訊號(沒有停頓、沒有語調,只有「他又打了一行」)。 + * + * 但它只有在偶爾出現時才像人:每輪都補一句是話多,不是慌。所以兩道閘門—— + * 張力或羞恥度過門檻、而且 N 輪內沒補過。 + * + * 冷卻是**給了額度就開始算**,不是等他真的用了才算。寧可少給一次, + * 也不要因為「上次給了沒用到」而連續幾輪都允許補話。 + */ +export function splitBudget(slug, { state = null, read = null, prev = null, tone = null } = {}) { + const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); + const tired = slug ? fatigueLevel(slug) : 0; + const m = mood(st, tired); + const modesty = modestyState(slug, { state: st, prev, tone }).value; + const arousal = Math.round(m.arousal); + const pressed = Boolean(read?.signals?.some((s) => s.key === "anger" || s.key === "disgust")); + const base = { arousal, modesty }; + if (arousal < SPLIT_AROUSAL_AT && modesty < SPLIT_MODESTY_AT && !pressed) { + return { ...base, allowed: false, reason: "張力與羞恥度都不到門檻——平靜的時候只回一則" }; + } + const since = turnsSinceSplit(slug); + if (since !== null && since < SPLIT_COOLDOWN_TURNS) { + return { ...base, allowed: false, since, reason: `${since} 輪前才補過一次,${SPLIT_COOLDOWN_TURNS} 輪內不再補` }; + } + return { ...base, allowed: true, since, reason: modesty >= SPLIT_MODESTY_AT ? "羞恥度過門檻" : "張力過門檻" }; +} + +/** 注入用的一行:這一輪可不可以補第二則,以及第二則該是什麼。 */ +export function splitDirective(slug, opts = {}) { + const s = splitBudget(slug, opts); + if (!s.allowed) return ""; + return ( + "這一輪**可以拆成兩則**(張力 " + s.arousal + "/羞恥度 " + s.modesty + "):" + + "第二則要短,而且是**補救、嘴硬或改口**——「⋯剛才那句不算」「我不是那個意思」" + + "「⋯你不要放在心上」。\n" + + " 第二則**不可以是補充說明**(把第一則講得更清楚就只是話多)。" + + `用不用都可以,但用了之後 ${SPLIT_COOLDOWN_TURNS} 輪內不會再有。` + ); +} + /** 注入用的一行:這一輪能講幾句、每句多長、心裡話該有幾句。 */ export function speakDirective(slug, opts = {}) { const b = speechBudget(slug, opts); @@ -1407,7 +1462,7 @@ export const FELT_KEEP = 120; export const feltPath = (slug) => path.join(personaDir(slug), "state", "felt.jsonl"); /** 記一輪:對方帶了什麼、我套用了什麼。 */ -export function recordFelt(slug, { user = null, mine = null, modesty = null, note = "" } = {}) { +export function recordFelt(slug, { user = null, mine = null, modesty = null, note = "", split = false } = {}) { if (!personaExists(slug)) return null; const entry = { ts: nowIso(), @@ -1415,6 +1470,8 @@ export function recordFelt(slug, { user = null, mine = null, modesty = null, not mine: mine && Object.keys(mine).length ? mine : null, modesty: Number.isFinite(Number(modesty)) ? Number(modesty) : null, note: String(note || "").slice(0, 200), + // 分段額度的冷卻靠這一格算:給了就記,不等他真的用了才記(見 splitBudget)。 + ...(split ? { split: true } : {}), }; if (!entry.user && !entry.mine && entry.modesty === null) return null; appendJsonl(feltPath(slug), entry); @@ -5791,10 +5848,15 @@ export function turnContext(slug, sessionId, prompt = "") { const budget = speechBudget(slug, speakOpts); lines[lines.indexOf("@@SPEAK@@")] = speakDirective(slug, speakOpts); lines.push(modestyDirective(slug, speakOpts)); + // 分段預算:純文字唯一的時間訊號(「他又打了一行」)。有額度才注入,沒有就不提—— + // 講「這一輪不能拆成兩則」只是提醒他有這個功能。 + const split = splitDirective(slug, speakOpts); + if (split) lines.push(split); recordFelt(slug, { user: read.signals.length ? read : null, modesty: budget.modesty, note: String(prompt || "").slice(0, 80), + split: Boolean(split), }); // 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。 const tone = toneDirective(slug); diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 147e9d5..3de8368 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -1231,6 +1231,34 @@ check("標點指示有注入,而且講明上限與「強度歸 emoji」", (() const ctxP = pl.turnContext("alpha", S_SPEAK, "那件事後來怎麼了"); return ctxP.includes("此刻的標點") && ctxP.includes("刪節號不連發") && ctxP.includes("強度歸 emoji"); })(), pl.turnContext("alpha", S_SPEAK, "那件事後來怎麼了").slice(0, 500)); + +// 分段訊息:一輪兩則。純文字唯一的「時間」訊號(他又打了一行) +console.log("⑥-f 一輪可以拆成兩則"); +{ + // 低羞恥度的對照組要自己建:plainf 是女性預設 70,本身就過門檻了 + fs.mkdirSync(path.join(H, "calmsplit"), { recursive: true }); + fs.writeFileSync(path.join(H, "calmsplit", "IDENTITY.md"), "- Name: 寅\n- Creature: 人類男性\n"); + fs.writeFileSync(path.join(H, "calmsplit", "SOUL.md"), "不在意別人眼光,該說就說。"); + const calm = pl.splitBudget("calmsplit", {}); + check("張力與羞恥度都不到門檻時不給第二則", + calm.allowed === false && calm.reason.includes("門檻"), JSON.stringify(calm)); + check("平靜時整段不注入(不提醒他有這個功能)", pl.splitDirective("calmsplit", {}) === ""); + check("從來沒拆過回 null", pl.turnsSinceSplit("calmsplit") === null); + const hot = pl.splitBudget("shymax", {}); + check("羞恥度過門檻就給第二則", + hot.allowed === true && hot.modesty >= pl.SPLIT_MODESTY_AT, JSON.stringify(hot)); + check("第二則是補救/嘴硬/改口,不是補充說明", (() => { + const d = pl.splitDirective("shymax", {}); + return d.includes("補救") && d.includes("不可以是補充說明") && d.includes("要短"); + })(), pl.splitDirective("shymax", {})); + pl.recordFelt("shymax", { modesty: 70, split: true }); + const cooling = pl.splitBudget("shymax", {}); + check("給過之後進入冷卻(給了就算,不等他真的用)", + cooling.allowed === false && cooling.since === 0, JSON.stringify(cooling)); + for (let i = 0; i < pl.SPLIT_COOLDOWN_TURNS; i += 1) pl.recordFelt("shymax", { modesty: 70 }); + check("冷卻過了又能再給", pl.splitBudget("shymax", {}).allowed === true, + JSON.stringify(pl.splitBudget("shymax", {}))); +} check("說話規則有注入「話短、日常字、看得見的東西、不要回頭解釋」", (() => { const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講"); return ["話短一點", "平常會說的字", "看得見的東西", "不要回頭解釋"].every((w) => ctxSay.includes(w));