feat(分段訊息): 高張力或高羞恥時一輪可以補第二則

純文字唯一的「時間」訊號是「他又打了一行」——沒有停頓、沒有語調,真人在聊天軟體上
的情緒有一半是靠補那一句傳出去的。這個手段之前完全沒用到。

splitBudget():張力或羞恥度過 60,或對方那句帶怒意/厭惡,才給第二則額度。
splitDirective():第二則要短,而且是補救、嘴硬或改口,不可以是補充說明——
把第一則講得更清楚就只是話多,不是慌。

節流 SPLIT_COOLDOWN_TURNS = 4,冷卻靠 felt.jsonl 新增的 split 欄位算。
冷卻是「給了額度就開始算」,不等他真的用了才算:不然「上次給了沒用到」會讓
連續幾輪都允許補話,每輪都補一句是話多不是慌。

沒額度時整段不注入。講「這一輪不能拆成兩則」等於提醒他有這個功能,反而會想用。

selftest 721 → 728。低羞恥度的對照組另建一個男性人格——plainf 是女性預設 70,
本身就過門檻,拿它當「平靜」的對照會測不出東西。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 08:26:54 +00:00
co-authored by Claude Opus 5
parent f26d0315f2
commit b5a5f0a6bf
3 changed files with 100 additions and 4 deletions
+63 -1
View File
@@ -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);
+28
View File
@@ -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));