docs(persona-chat): clarify auto-hook boundary #13

Merged
admin merged 19 commits from pr/persona-master-sync-20260731 into master 2026-07-31 17:37:42 +00:00
3 changed files with 523 additions and 92 deletions
Showing only changes of commit 9b5de053e3 - Show all commits
+235 -62
View File
@@ -170,10 +170,120 @@ export function writeText(file, text) {
fs.renameSync(tmp, file); fs.renameSync(tmp, file);
} }
/** 單行 appendO_APPEND 對單行寫入是原子的),guest 也能安全使用。 */ // --------------------------------------------------------------------------- //
// 檔案層互斥鎖
//
// 人格鎖(`state/lock.json`)擋的是「兩個人格同時被載入」,不是「兩個程序同時寫同一個檔」:
// 同一個 session 的 sub agent 與主程序**共用同一把人格鎖**(設計如此),所以兩邊真的會
// 同時寫。而 said / short-term 的裁切是「整檔讀進來 → 過濾 → 覆蓋」,中間任何一筆 append
// 都會被那次覆蓋吃掉——實測背景 prune 進行中寫入 30 筆 salience 95 的承諾,會掉幾筆。
//
// 所以:凡是碰同一個檔案的 append 與 rewrite,一律先拿這把鎖。
// sentinel 檔用 `O_EXCL` 建(跨程序原子),放在 `.runtime/locks/` 而不是人格目錄裡,
// 免得殘留的鎖檔被同步到 Gitea 或被 guard 掃到。
// --------------------------------------------------------------------------- //
export const FILE_LOCK_STALE_MS = 15_000; // 這麼久沒放掉就當持有者死了(避免整個系統卡住)
export const FILE_LOCK_TIMEOUT_MS = 10_000;
const SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
/** 同步小睡(這裡不能用 await:呼叫端全是同步 API)。 */
function sleepSync(ms) {
if (ms > 0) Atomics.wait(SLEEP_BUF, 0, 0, ms);
}
export function fileLockPath(file) {
const key = crypto.createHash("md5").update(path.resolve(file)).digest("hex").slice(0, 16);
return path.join(runtimeDir(), "locks", `${path.basename(file)}.${key}.lock`);
}
/**
* 拿著 `file` 的互斥鎖跑 `fn()`,回傳 `fn` 的結果。
*
* 拿不到就短暫重試(含亂數退讓,避免兩邊同步互踩);超過 `FILE_LOCK_STALE_MS`
* 沒被放掉的鎖視為死鎖並搶走。真的等不到就直接做——寧可冒一次競態,
* 也不要因為一個殘留的鎖檔讓人格從此寫不進東西。
*/
export function withFileLock(file, fn, { timeoutMs = FILE_LOCK_TIMEOUT_MS } = {}) {
const lock = fileLockPath(file);
fs.mkdirSync(path.dirname(lock), { recursive: true });
const deadline = Date.now() + timeoutMs;
let fd = null;
while (fd === null) {
try {
fd = fs.openSync(lock, "wx", 0o600);
} catch (err) {
if (err.code !== "EEXIST") throw err;
let stat = null;
try {
stat = fs.statSync(lock);
} catch {
continue; // 剛好被放掉了,再試一次
}
if (Date.now() - stat.mtimeMs > FILE_LOCK_STALE_MS) {
try {
fs.unlinkSync(lock);
} catch {
/* 別人先清掉了 */
}
continue;
}
if (Date.now() > deadline) break; // 等太久:不擋住呼叫端
sleepSync(1 + Math.floor(Math.random() * 5));
}
}
try {
if (fd !== null) fs.writeSync(fd, `${process.pid}\n`);
return fn();
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
try {
fs.unlinkSync(lock);
} catch {
/* ignore */
}
}
}
}
/** 單行 append。拿檔案鎖,才不會被同時進行的整檔覆蓋(prune/trim)吃掉。 */
export function appendJsonl(file, obj) { export function appendJsonl(file, obj) {
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, JSON.stringify(obj) + "\n", { encoding: "utf8", mode: 0o600 }); const line = JSON.stringify(obj) + "\n";
withFileLock(file, () => {
fs.appendFileSync(file, line, { encoding: "utf8", mode: 0o600 });
});
}
/**
* 「整檔讀進來 → 過濾 → 覆蓋」的唯一入口:整段在鎖裡面做,
* 所以 `transform` 看到的一定是最新的內容,寫回去也不會蓋掉別人剛 append 的行。
*
* `transform(rows)` 回傳要留下的列;回傳的陣列跟原本一樣長就不寫(省一次 IO)。
*/
export function rewriteJsonl(file, transform) {
return withFileLock(file, () => {
const rows = readJsonl(file);
const kept = transform(rows) ?? rows;
if (kept.length !== rows.length) {
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
}
return { rows, kept };
});
}
/** JSON 檔的 read-modify-write`mutate(data)` 回傳要寫回去的物件。 */
export function updateJson(file, mutate, fallback = null) {
return withFileLock(file, () => {
const next = mutate(readJson(file, fallback));
writeJson(file, next);
return next;
});
} }
export function readJsonl(file, limit = null) { export function readJsonl(file, limit = null) {
@@ -252,16 +362,66 @@ export function defaultEmotionState(baseline = {}) {
}; };
} }
/** 0–100 之間的合法數字;不是數字(NaN/字串/null)就退回 `fallback`。 */
function sanelevel(value, fallback) {
const num = Number(value);
return Number.isFinite(num) ? clamp(num) : clamp(fallback);
}
/**
* 讀情緒狀態。**檔案是外部輸入**`import``sync pull`/手改都會進到這裡),
* 所以不能只補缺、還要驗合法性——以前用 `??=` 只補 undefined,結果:
*
* * `baseline.joy = 5000` → 衰減後 joy 破千,mood 卡在 valence/arousal 100
* * `levels.anger = "很生氣"` → 衰減後 anger = NaN,注入的上下文印出 valence NaN
* * `half_life_minutes.joy = 0` 或負數 → factor 恆為 0,那個情緒從此累積不起來
*
* CLI 的 `--baseline``--apply` 本來就有保護,破口純粹在檔案入口,所以擋在這裡。
*/
export function loadEmotion(slug) { export function loadEmotion(slug) {
let state = readJson(emotionPath(slug)); let state = readJson(emotionPath(slug));
if (!state || typeof state !== "object" || !state.levels) state = defaultEmotionState(); if (!state || typeof state !== "object" || !state.levels) state = defaultEmotionState();
state.baseline ??= {}; if (!state.baseline || typeof state.baseline !== "object") state.baseline = {};
state.levels ??= {}; if (!state.levels || typeof state.levels !== "object") state.levels = {};
state.half_life_minutes ??= {}; if (!state.half_life_minutes || typeof state.half_life_minutes !== "object") state.half_life_minutes = {};
for (const key of EMOTION_KEYS) { for (const key of EMOTION_KEYS) {
state.baseline[key] ??= DEFAULT_BASELINE[key]; state.baseline[key] = sanelevel(state.baseline[key], DEFAULT_BASELINE[key]);
state.levels[key] ??= state.baseline[key]; state.levels[key] = sanelevel(state.levels[key], state.baseline[key]);
state.half_life_minutes[key] ??= EMOTIONS[key].halfLife; const half = Number(state.half_life_minutes[key]);
state.half_life_minutes[key] = Number.isFinite(half) && half > 0 ? half : EMOTIONS[key].halfLife;
}
return state;
}
/**
* 情緒的 read-modify-write`mutate(state)` 回傳要寫回去的狀態,整段在檔案鎖裡。
*
* `emotion.json` 是最容易掉更新的一個檔——它每輪都被讀出來、改一點、整份寫回去。
* 實測連續 8 次 `--apply joy=+5`,並行時大約有四成的增量直接消失。
*/
export function updateEmotion(slug, mutate) {
return withFileLock(emotionPath(slug), () => {
const next = mutate(loadEmotion(slug));
writeJson(emotionPath(slug), next);
return next;
});
}
/**
* 把 `minutes` 分鐘的衰減直接套上去(decayEmotion 與 decayEmotionBy 共用)。
*
* 每個值都重新驗過一次:這兩個函式是 export 的,呼叫端有可能餵進手寫或匯入來的狀態,
* 不能假設它一定經過 `loadEmotion()`。半衰期 ≤ 0 會讓 factor 恆為 0(那個情緒從此
* 累積不起來),所以一律退回該情緒的預設半衰期。
*/
function applyDecay(state, minutes) {
for (const key of EMOTION_KEYS) {
const rawHalf = Number(state.half_life_minutes?.[key]);
const half = Number.isFinite(rawHalf) && rawHalf > 0 ? rawHalf : EMOTIONS[key].halfLife;
const base = sanelevel(state.baseline?.[key], DEFAULT_BASELINE[key]);
const level = sanelevel(state.levels?.[key], base);
const factor = Math.pow(0.5, minutes / half);
state.levels[key] = Math.round(clamp(base + (level - base) * factor) * 100) / 100;
} }
return state; return state;
} }
@@ -271,13 +431,7 @@ export function decayEmotion(state, now = new Date()) {
const last = parseIso(state.updated_at) ?? now; const last = parseIso(state.updated_at) ?? now;
const minutes = Math.max(0, (now.getTime() - last.getTime()) / 60_000); const minutes = Math.max(0, (now.getTime() - last.getTime()) / 60_000);
if (minutes <= 0) return state; if (minutes <= 0) return state;
for (const key of EMOTION_KEYS) { applyDecay(state, minutes);
const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife);
const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]);
const level = Number(state.levels[key] ?? base);
const factor = half > 0 ? Math.pow(0.5, minutes / half) : 0;
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
}
state.updated_at = iso(now); state.updated_at = iso(now);
return state; return state;
} }
@@ -292,13 +446,7 @@ export function decayEmotion(state, now = new Date()) {
export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) { export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) {
const mins = Math.max(0, Number(minutes) || 0); const mins = Math.max(0, Number(minutes) || 0);
if (!mins) return state; if (!mins) return state;
for (const key of EMOTION_KEYS) { applyDecay(state, mins);
const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife);
const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]);
const level = Number(state.levels[key] ?? base);
const factor = half > 0 ? Math.pow(0.5, mins / half) : 0;
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
}
state.updated_at = nowIso(); state.updated_at = nowIso();
return state; return state;
} }
@@ -1143,16 +1291,13 @@ export function archiveStaleThreads(slug, days = THREAD_STALE_DAYS) {
/** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */ /** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */
export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) { export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) {
const file = saidPath(slug);
const rows = readJsonl(file);
if (!rows.length) return 0;
const cutoff = Date.now() - hours * 3_600_000; const cutoff = Date.now() - hours * 3_600_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff); return rewriteJsonl(saidPath(slug), (rows) => {
kept = kept.slice(-lines); if (!rows.length) return rows;
if (kept.length !== rows.length) { return rows
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : "")); .filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff)
} .slice(-lines);
return kept.length; }).kept.length;
} }
/** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */ /** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */
@@ -1337,7 +1482,7 @@ export function rememberShort(slug, entry) {
} }
/** 這一則短期記憶能不能被容量壓力清掉(承諾與界線永遠不能)。 */ /** 這一則短期記憶能不能被容量壓力清掉(承諾與界線永遠不能)。 */
function shortTermProtected(row, now = Date.now()) { export function shortTermProtected(row, now = Date.now()) {
if (Number(row?.salience || 0) >= SHORT_TERM_PROTECT_SALIENCE) return true; if (Number(row?.salience || 0) >= SHORT_TERM_PROTECT_SALIENCE) return true;
if (String(row?.intent || "").includes("commit")) return true; if (String(row?.intent || "").includes("commit")) return true;
const age = now - (parseIso(row?.ts)?.getTime() ?? now); const age = now - (parseIso(row?.ts)?.getTime() ?? now);
@@ -1352,36 +1497,36 @@ function shortTermProtected(row, now = Date.now()) {
* 「還沒經過判斷就把今天清掉」是這裡最不能犯的錯。 * 「還沒經過判斷就把今天清掉」是這裡最不能犯的錯。
*/ */
export function pruneShortTermDetail(slug) { export function pruneShortTermDetail(slug) {
const file = shortTermPath(slug);
const rows = readJsonl(file);
if (!rows.length) return { kept: 0, dropped: 0, by_age: 0, by_capacity: 0, protected: 0 };
const now = Date.now(); const now = Date.now();
const cutoff = now - SHORT_TERM_DAYS * 86_400_000; const cutoff = now - SHORT_TERM_DAYS * 86_400_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? now) >= cutoff); const stats = { by_age: 0, by_capacity: 0 };
const byAge = rows.length - kept.length; // 讀 → 過濾 → 覆蓋整段都在檔案鎖裡:中途 append 進來的新紀錄不會被這次覆蓋吃掉
const { rows, kept } = rewriteJsonl(shortTermPath(slug), (all) => {
if (!all.length) return all;
let live = all.filter((r) => (parseIso(r.ts)?.getTime() ?? now) >= cutoff);
stats.by_age = all.length - live.length;
// R6:超過軟上限,從「顯著度最低、最舊」開始清,但保護清單裡的不動 // R6:超過軟上限,從「顯著度最低、最舊」開始清,但保護清單裡的不動
let byCapacity = 0; if (live.length > SHORT_TERM_SOFT_CAP) {
if (kept.length > SHORT_TERM_SOFT_CAP) { const droppable = live
const droppable = kept
.map((row, i) => ({ row, i, protectedRow: shortTermProtected(row, now) })) .map((row, i) => ({ row, i, protectedRow: shortTermProtected(row, now) }))
.filter((x) => !x.protectedRow) .filter((x) => !x.protectedRow)
.sort((a, b) => (Number(a.row.salience || 0) - Number(b.row.salience || 0)) || (a.i - b.i)); .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 need = live.length - SHORT_TERM_SOFT_CAP;
const drop = new Set(droppable.slice(0, need).map((x) => x.i)); const drop = new Set(droppable.slice(0, need).map((x) => x.i));
byCapacity = drop.size; stats.by_capacity = drop.size;
kept = kept.filter((_, i) => !drop.has(i)); live = live.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" : ""));
} }
const before = live.length;
live = live.slice(-SHORT_TERM_KEEP);
stats.by_capacity += before - live.length;
return live;
});
if (!rows.length) return { kept: 0, dropped: 0, by_age: 0, by_capacity: 0, protected: 0 };
return { return {
kept: kept.length, kept: kept.length,
dropped: rows.length - kept.length, dropped: rows.length - kept.length,
by_age: byAge, by_age: stats.by_age,
by_capacity: byCapacity + byHardCap, by_capacity: stats.by_capacity,
protected: kept.filter((r) => shortTermProtected(r, now)).length, protected: kept.filter((r) => shortTermProtected(r, now)).length,
}; };
} }
@@ -1391,6 +1536,30 @@ export function pruneShortTerm(slug) {
return pruneShortTermDetail(slug).kept; return pruneShortTermDetail(slug).kept;
} }
/**
* 固化完之後主動淘汰顯著度低於 `minSalience` 的短期記憶(`consolidate --forget`)。
*
* **跟 prune 套同一層保護**:顯著度 ≥ 80、`intent=commit`、24 小時內的紀錄一則都不動。
* 以前這裡是無條件 filter`--forget 200` 會把一筆剛寫入、salience 95 的承諾一起刪掉,
* 而 R4 明文寫「不可遺忘」——固化的動作不該變成遺忘承諾的路。
*/
export function forgetShortTerm(slug, minSalience) {
const floor = Number(minSalience);
if (!Number.isFinite(floor)) return { kept: 0, dropped: 0, protected: 0 };
const now = Date.now();
let saved = 0;
const { rows, kept } = rewriteJsonl(shortTermPath(slug), (all) =>
all.filter((r) => {
if (Number(r.salience || 0) >= floor) return true;
if (shortTermProtected(r, now)) {
saved += 1;
return true;
}
return false;
}));
return { kept: kept.length, dropped: rows.length - kept.length, protected: saved };
}
export const recentShort = (slug, limit = 8) => readJsonl(shortTermPath(slug), limit); export const recentShort = (slug, limit = 8) => readJsonl(shortTermPath(slug), limit);
export function parseFrontMatter(text) { export function parseFrontMatter(text) {
@@ -1816,7 +1985,12 @@ export const CN_WORDS = {
賦能: "具體說讓誰能做到什麼", 閉環: "具體說從哪裡接到哪裡", 抓手: "切入點", 顆粒度: "細緻度", 賦能: "具體說讓誰能做到什麼", 閉環: "具體說從哪裡接到哪裡", 抓手: "切入點", 顆粒度: "細緻度",
}; };
const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu; // 一個 emoji = 底字 + 變體選擇子/膚色 + ZWJ 接上去的後續。
// 以前 `\u{FE0F}` 直接寫在字元類裡,`❤️`U+2764 + VS16)會被算成兩個而誤擋;
// 👨‍👩‍👦 這種 ZWJ 家族同理。
const EMOJI_BASE = "[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]";
const EMOJI_TAIL = "(?:[\\u{1F3FB}-\\u{1F3FF}]|\\u{FE0F}|\\u{20E3})*";
const EMOJI_RE = new RegExp(`${EMOJI_BASE}${EMOJI_TAIL}(?:\\u{200D}${EMOJI_BASE}${EMOJI_TAIL})*`, "gu");
/** /**
* 只留「真的說出口、而且是在使用而不是在討論」的部分。 * 只留「真的說出口、而且是在使用而不是在討論」的部分。
@@ -1824,11 +1998,13 @@ const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu;
* 引號裡的原話與 `code` 一律拿掉再比對——「我最近戒掉『賦能』這個詞」是在**提及**那個詞, * 引號裡的原話與 `code` 一律拿掉再比對——「我最近戒掉『賦能』這個詞」是在**提及**那個詞,
* 不是在用它。這一條是 speak-human-tw 自己踩到的坑:它的文件裡出現「...」與彎引號, * 不是在用它。這一條是 speak-human-tw 自己踩到的坑:它的文件裡出現「...」與彎引號,
* 正是因為那幾行在說「不要用這些」。 * 正是因為那幾行在說「不要用這些」。
*
* 書名號《》也算:「我在看《說到底》這本書」講的是書名,不是在說教。
*/ */
function speechBody(text) { function speechBody(text) {
return String(text ?? "") return String(text ?? "")
.replace(/`[^`]*`/g, " ") .replace(/`[^`]*`/g, " ")
.replace(/「[^」]*」|『[^』]*』|"[^"]*"/g, " "); .replace(/「[^」]*」|『[^』]*』|《[^》]*》|"[^"]*"/g, " ");
} }
/** /**
@@ -1846,14 +2022,16 @@ export function speechLint(text) {
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
sentences.forEach((sentence, index) => { sentences.forEach((sentence, index) => {
const bare = sentence.replace(/[,、,;:「」『』()()\s"'~~—-]/g, ""); const body = speechBody(sentence);
// 句長算的是「自己講的字」:引述使用者原話與 `code` 不該算進去,
// 不然「他跟我說『……(很長一段)』」必被擋,等於不准引述。
const bare = body.replace(/[,、,;:「」『』《》()()\s"'~~—-]/g, "");
if ([...bare].length > MAX_SENTENCE_CHARS) { if ([...bare].length > MAX_SENTENCE_CHARS) {
issues.push({ kind: "long", level: "block", chars: [...bare].length, text: sentence.slice(0, 30) }); issues.push({ kind: "long", level: "block", chars: [...bare].length, text: sentence.slice(0, 30) });
} }
const head = sentence.replace(/^[「『((]+/, ""); const head = sentence.replace(/^[「『((]+/, "");
const opener = EXPLAINER_OPENERS.find((w) => head.startsWith(w)); const opener = EXPLAINER_OPENERS.find((w) => head.startsWith(w));
if (opener) issues.push({ kind: "explain", level: "block", opener, text: sentence.slice(0, 30) }); if (opener) issues.push({ kind: "explain", level: "block", opener, text: sentence.slice(0, 30) });
const body = speechBody(sentence);
const hedges = HEDGE_WORDS.filter((w) => body.includes(w)); const hedges = HEDGE_WORDS.filter((w) => body.includes(w));
if (hedges.length >= 2) { if (hedges.length >= 2) {
issues.push({ kind: "hedge", level: "block", words: hedges, text: sentence.slice(0, 30) }); issues.push({ kind: "hedge", level: "block", words: hedges, text: sentence.slice(0, 30) });
@@ -1953,11 +2131,7 @@ export function findRepeat(entries, text, {
} }
function trimJsonl(file, keep) { function trimJsonl(file, keep) {
const rows = readJsonl(file); return rewriteJsonl(file, (rows) => (rows.length <= keep * 1.5 ? rows : rows.slice(-keep))).kept.length;
if (rows.length <= keep * 1.5) return rows.length;
const kept = rows.slice(-keep);
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
return kept.length;
} }
/** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */ /** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */
@@ -3221,8 +3395,7 @@ export function opsBrief(slug) {
/** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */ /** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */
export function turnContext(slug, sessionId, prompt = "") { export function turnContext(slug, sessionId, prompt = "") {
const state = decayEmotion(loadEmotion(slug)); const state = updateEmotion(slug, (s) => decayEmotion(s));
writeJson(emotionPath(slug), state);
const session = loadSession(sessionId); const session = loadSession(sessionId);
const lines = [ const lines = [
"<persona-context>", "<persona-context>",
+51 -19
View File
@@ -902,6 +902,21 @@ commands.consolidate = ({ flags }) => {
const today = pl.nowIso().slice(0, 10); const today = pl.nowIso().slice(0, 10);
let existing = {}; let existing = {};
if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8")); if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8"));
// slugify 會把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md` 上。
// 以前直接覆蓋,只留下舊檔的 first_seenrecall_count,內文靜默被換成別則記憶。
// 這裡改成擋下來:不同的 --name 撞到同一個檔就 die,並給一個沒被占用的檔名。
const priorTitle = existing.title ?? existing.name ?? null;
if (priorTitle !== null && priorTitle !== rawName && !flags.force) {
let alt = name;
for (let i = 2; i < 100 && fs.existsSync(path.join(pl.longTermDir(slug), `${alt}.md`)); i += 1) {
alt = `${name}-${i}`;
}
die(
`\`${file}\` 已經是「${priorTitle}」的長期記憶,但這次的 --name 是「${rawName}」——` +
`兩個名字 slugify 之後都是 \`${name}\`,寫下去會把舊的內文換掉。\n` +
` 換個名字(例如 \`--name ${alt}\`),或確定要覆蓋同一則就加 --force。`,
);
}
let body = str(flags.body); let body = str(flags.body);
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8"); if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
if (!body.trim()) die("需要 `--body` 或 `--body-file`。"); if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
@@ -914,6 +929,8 @@ commands.consolidate = ({ flags }) => {
const front = [ const front = [
"---", "---",
`name: ${name}`, `name: ${name}`,
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。
`title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
`type: ${type}`, `type: ${type}`,
`about: [${csv(flags.about).join(", ") || "user"}]`, `about: [${csv(flags.about).join(", ") || "user"}]`,
`topics: [${csv(flags.topics).join(", ")}]`, `topics: [${csv(flags.topics).join(", ")}]`,
@@ -933,10 +950,13 @@ commands.consolidate = ({ flags }) => {
const total = pl.rebuildIndex(slug); const total = pl.rebuildIndex(slug);
const forget = num(flags.forget, null); const forget = num(flags.forget, null);
if (forget !== null) { if (forget !== null) {
const rows = pl.readJsonl(pl.shortTermPath(slug)); // 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4)
const keep = rows.filter((r) => Number(r.salience || 0) >= forget); const cut = pl.forgetShortTerm(slug, forget);
pl.writeText(pl.shortTermPath(slug), keep.map((r) => JSON.stringify(r)).join("\n") + (keep.length ? "\n" : "")); say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${cut.kept} 筆。`);
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`); if (cut.protected) {
say(` 其中 ${cut.protected} 筆低於門檻但受保護,沒有刪:` +
`顯著度 ≥ ${pl.SHORT_TERM_PROTECT_SALIENCE}、intent=commit,或 ${pl.SHORT_TERM_PROTECT_HOURS} 小時內。`);
}
} }
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`); ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了 pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了
@@ -962,12 +982,6 @@ commands.emotion = ({ flags }) => {
const session = requireSession(flags); const session = requireSession(flags);
const slug = hostOf(flags, session); const slug = hostOf(flags, session);
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"])); const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
let state = pl.decayEmotion(pl.loadEmotion(slug));
if (flags.baseline) {
for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) {
if (key in pl.EMOTIONS) state.baseline[key] = pl.clamp(value);
}
}
// 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表) // 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表)
const readText = str(flags.read); const readText = str(flags.read);
const read = readText ? pl.readUserEmotion(readText) : null; const read = readText ? pl.readUserEmotion(readText) : null;
@@ -995,26 +1009,44 @@ commands.emotion = ({ flags }) => {
]); ]);
return; return;
} }
if (flags.apply) { if (flags.apply && role === "guest") die("guestsub agent)不得改寫人格的情緒狀態。");
if (role === "guest") die("guestsub agent)不得改寫人格的情緒狀態。");
const raw = parseDeltas(flags.apply);
// 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣 // 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣
const rel = pl.relationGain(slug, str(flags.from) || null); const rel = flags.apply ? pl.relationGain(slug, str(flags.from) || null) : null;
const deltas = rel.gain === 1 let deltas = null;
let state;
if (role === "guest") {
// guest 不寫回,所以也不需要鎖
state = pl.decayEmotion(pl.loadEmotion(slug));
} else {
// 讀 → 衰減 → 套用 → 寫回整段在檔案鎖裡:並行的 `--apply` 才不會互相覆蓋
state = pl.updateEmotion(slug, (s) => {
s = pl.decayEmotion(s);
if (flags.baseline) {
for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) {
if (key in pl.EMOTIONS) s.baseline[key] = pl.clamp(value);
}
}
if (flags.apply) {
const raw = parseDeltas(flags.apply);
deltas = rel.gain === 1
? raw ? raw
: Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100])); : Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100]));
state = pl.applyEmotion(state, deltas, str(flags.trigger)); s = pl.applyEmotion(s, deltas, str(flags.trigger));
if (rel.gain !== 1) { if (rel.gain !== 1) {
state.last_trigger = state.last_trigger || {}; s.last_trigger = s.last_trigger || {};
state.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain }; s.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain };
} }
}
return s;
});
}
if (flags.apply) {
pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) }); pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) });
pl.appendJsonl(pl.journalPath(slug), { pl.appendJsonl(pl.journalPath(slug), {
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger), ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger),
deltas, applied: state.last_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);
const m = pl.mood(state); const m = pl.mood(state);
const row = (key) => const row = (key) =>
` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]}`; ` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]}`;
+227 -1
View File
@@ -8,7 +8,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import zlib from "node:zlib"; import zlib from "node:zlib";
import { spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url)); const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -213,6 +213,58 @@ check("關鍵詞可檢索到",
pl.recall("alpha", "早上 會議").map((m) => m._name).join() === "hates-morning-meetings"); pl.recall("alpha", "早上 會議").map((m) => m._name).join() === "hates-morning-meetings");
check("情緒事件寫進 journal", pl.readJsonl(pl.journalPath("alpha")).some((r) => r.kind === "emotion")); check("情緒事件寫進 journal", pl.readJsonl(pl.journalPath("alpha")).some((r) => r.kind === "emotion"));
// --------------------------------------------------------------------------- //
// consolidate 的兩個資料遺失路徑
{
// ① `--forget N` 以前是無條件刪掉 salience < N 的全部短期記憶,完全不套 shortTermProtected()
// `--forget 200` 會把一筆 intent=commit、salience 95、剛寫入的承諾一起刪掉,而 R4 寫的是「不可遺忘」。
const stFile = path.join(H, "forgetter", "memory", "short-term.jsonl");
fs.mkdirSync(path.dirname(stFile), { recursive: true });
const stale = pl.iso(new Date(Date.now() - 5 * 86_400_000));
fs.writeFileSync(stFile, [
{ ts: pl.nowIso(), text: "我答應你週五之前把那件事做完", salience: 95, intent: "commit" },
{ ts: stale, text: "界線:不要替我做決定", salience: 88 },
{ ts: pl.nowIso(), text: "今天剛講的閒聊", salience: 10 },
{ ts: stale, text: "上週的閒聊", salience: 10 },
].map((r) => JSON.stringify(r)).join("\n") + "\n");
const cut = pl.forgetShortTerm("forgetter", 200);
const left = pl.readJsonl(stFile).map((r) => r.text);
check("`--forget` 不刪 intent=commit 的承諾(R4:不可遺忘)",
left.includes("我答應你週五之前把那件事做完"), left.join(""));
check("`--forget` 不刪顯著度 ≥ 80 的界線", left.includes("界線:不要替我做決定"));
check("`--forget` 不刪 24 小時內的新紀錄(還沒機會被固化)", left.includes("今天剛講的閒聊"));
check("`--forget` 該刪的還是有刪,而且會回報保護了幾筆",
!left.includes("上週的閒聊") && cut.dropped === 1 && cut.protected === 3, JSON.stringify(cut));
// ② slugify 把標點吃掉:「我的貓」「我的貓?」「我的貓!!」都落在同一個檔名上,
// 以前 writeText 直接覆蓋,只留舊檔的 first_seenrecall_count,內文靜默被換掉。
check("slugify 確實會讓這三個名字撞在一起",
pl.slugify("我的貓") === pl.slugify("我的貓?") && pl.slugify("我的貓") === pl.slugify("我的貓!!"));
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓", "--body", "牠叫小黑。"]);
const catFile = path.join(pl.longTermDir("alpha"), `${pl.slugify("我的貓")}.md`);
check("撞名的第二則會被擋下,不會靜默覆蓋",
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
"--body", "完全不同的內容。"], { expectOk: false }).status !== 0);
check("被擋下之後舊的內文原封不動",
fs.readFileSync(catFile, "utf8").includes("牠叫小黑。") &&
!fs.readFileSync(catFile, "utf8").includes("完全不同的內容。"));
check("擋下的訊息會給一個沒被占用的檔名",
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
"--body", "x"], { expectOk: false }).stderr.includes(`--name ${pl.slugify("我的貓")}-2`),
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
"--body", "x"], { expectOk: false }).stderr.slice(0, 200));
check("同一個 --name 再寫一次是更新,不算撞名",
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓",
"--body", "牠叫小黑,十二歲。"]).status === 0 &&
fs.readFileSync(catFile, "utf8").includes("十二歲"));
check("front matter 留著原本的 --name(下次靠這行認出不是同一則)",
pl.parseFrontMatter(fs.readFileSync(catFile, "utf8"))[0].title === "我的貓");
check("真的要覆蓋還是走得通(--force",
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
"--body", "換成別則了。", "--force"]).status === 0 &&
fs.readFileSync(catFile, "utf8").includes("換成別則了。"));
}
console.log("⑥ 短期 → 長期的轉入條件"); console.log("⑥ 短期 → 長期的轉入條件");
check("有六條成文條件", pl.PROMOTION_RULES.length === 6); check("有六條成文條件", pl.PROMOTION_RULES.length === 6);
check("R1 高顯著度會成為候選", check("R1 高顯著度會成為候選",
@@ -505,6 +557,11 @@ const SF_CASES = [
["中國用語", "那個視頻我看完了。", "cn"], ["中國用語", "那個視頻我看完了。", "cn"],
["半形標點", "我看完了,你呢?", "halfwidth"], ["半形標點", "我看完了,你呢?", "halfwidth"],
["排版殘留", "**重點**:鐘修好了。", "markdown"], ["排版殘留", "**重點**:鐘修好了。", "markdown"],
// 放寬《》與句長之後,這幾條仍然要擋——豁免只針對「提及/引述」,不是整句放行
["書名號之外真的在說教", "我在看《時間簡史》。說到底,問題不在零件。", "preach"],
["自己講的長句(引號之外)", `${"想跟你說一件從去年就一直放在心上沒講出口的事情".repeat(2)}`, "long"],
["兩個 emoji", "修好了 🔧 開心 🎉", "emoji"],
["一個 emoji 加一個帶變體選擇子的", "修好了 🔧 給你 ❤️", "emoji"],
]; ];
for (const [label, text, kind] of SF_CASES) { for (const [label, text, kind] of SF_CASES) {
const issues = pl.speechLint(text); const issues = pl.speechLint(text);
@@ -528,6 +585,16 @@ const SNF_CASES = [
["一次「不是 A 而是 B」", "我要的不是最快,而是修得久。"], ["一次「不是 A 而是 B」", "我要的不是最快,而是修得久。"],
["一個破折號", "那台鐘——我留著。"], ["一個破折號", "那台鐘——我留著。"],
["日常短句", "桌上那杯茶冷了。要不要再泡一杯?"], ["日常短句", "桌上那杯茶冷了。要不要再泡一杯?"],
// 書名號也是「提及 ≠ 使用」:以前豁免清單漏了《》,這句被判 preach
["書名裡剛好有黑名單的詞", "我在看《說到底》這本書。"],
["書名裡的中國用語", "他推薦我看《質量與信息》。"],
// ❤️ 是 U+2764 + VS16;以前 EMOJI_RE 含 \u{FE0F},這一個字被算成兩個而誤擋
["帶變體選擇子的單一 emoji", "這個給你 ❤️"],
["ZWJ 組成的單一 emoji", "路上遇到他們 👨‍👩‍👦"],
["帶膚色的單一 emoji", "收到 👍🏽"],
// 句長以前算的是整句(含引號內容),引述使用者原話必被擋
["引述使用者的長原話", `他傳來「${"我今天真的很累而且不知道要從哪裡開始講起".repeat(2)}」,我看了兩次。`],
["引述長 code", `跑的是 \`${"x".repeat(80)}\` 這一行。`],
]; ];
for (const [label, text] of SNF_CASES) { for (const [label, text] of SNF_CASES) {
const issues = pl.speechBlockers(text); const issues = pl.speechBlockers(text);
@@ -731,6 +798,165 @@ console.log("\n短期記憶:容量壓力(R6)真的會清,而且有保護
typeof detail.dropped === "number" && typeof detail.protected === "number", JSON.stringify(detail)); typeof detail.dropped === "number" && typeof detail.protected === "number", JSON.stringify(detail));
} }
// --------------------------------------------------------------------------- //
// 並行寫入不掉資料
//
// 人格鎖擋的是「兩個人格同時被載入」,不是「兩個程序同時寫同一個檔」——同一個 session
// 的 sub agent 與主程序共用同一把人格鎖(設計如此),所以兩邊真的會同時寫。
// 修之前:背景 prune 進行中寫入 30 筆 salience 95 的承諾會掉 19 筆;
// 並行 8 次 `emotion --apply joy=+5` 大約有四成的增量消失。
console.log("\n並行寫入:read-modify-rewrite 不能吃掉同時 append 的資料");
{
const LIB = path.join(HERE, "persona-lib.mjs");
// 所有子程序睡到同一個時間點才起跑,不然它們只會一個接一個跑、撞不在一起
const BARRIER = `
const _b = new Int32Array(new SharedArrayBuffer(4));
const waitUntil = (t) => { const d = t - Date.now(); if (d > 0) Atomics.wait(_b, 0, 0, d); };
`;
const child = (code) =>
new Promise((resolve) => {
const p = spawn(process.execPath, ["--input-type=module", "-e", code],
{ env: { ...process.env }, stdio: "ignore" });
p.on("exit", resolve);
});
// ① prune 的整檔覆蓋 vs. 同時 append 的受保護紀錄
const raceHome = path.join(H, "racer", "memory");
const raceFile = path.join(raceHome, "short-term.jsonl");
fs.mkdirSync(raceHome, { recursive: true });
const staleTs = pl.iso(new Date(Date.now() - 5 * 86_400_000));
fs.writeFileSync(raceFile, Array.from({ length: 200 }, (_, i) =>
JSON.stringify({ ts: staleTs, text: `${i}`, salience: 10, intent: "chat" })).join("\n") + "\n");
let startAt = Date.now() + 700;
await Promise.all([
child(`${BARRIER}
const pl = await import(${JSON.stringify(LIB)});
waitUntil(${startAt});
for (let i = 0; i < 300; i += 1) pl.pruneShortTermDetail("racer");`),
child(`${BARRIER}
const pl = await import(${JSON.stringify(LIB)});
waitUntil(${startAt});
const b = new Int32Array(new SharedArrayBuffer(4));
for (let i = 0; i < 30; i += 1) {
pl.rememberShort("racer", { text: "承諾 " + i, salience: 95, intent: "commit" });
Atomics.wait(b, 0, 0, 5);
}`),
// 一邊有人在聊天,prune 才會一路都有事做(不然它清到軟上限就不再覆寫了)
child(`${BARRIER}
const pl = await import(${JSON.stringify(LIB)});
waitUntil(${startAt});
const ts = pl.iso(new Date(Date.now() - 5 * 86400000));
for (let i = 0; i < 400; i += 1) pl.appendJsonl(pl.shortTermPath("racer"), { ts, text: "雜訊 " + i, salience: 5 });`),
]);
const survivors = pl.readJsonl(raceFile).filter((r) => String(r.text || "").startsWith("承諾"));
check("背景 prune 進行中 append 的承諾,一筆都不能掉",
survivors.length === 30, `活下 ${survivors.length}/30 筆`);
// ② emotion.json 的 read-modify-write:並行要跟循序算出同一個值
cli(["create", "--persona", "racee", "--session", "sess-race-9999", "--name", "Racee",
"--creature", "沙漏", "--vibe", "安靜", "--emoji", "⏳"]);
const joyOf = () => pl.readJson(pl.emotionPath("racee")).levels.joy;
const resetJoy = () => pl.writeJson(pl.emotionPath("racee"), pl.defaultEmotionState());
resetJoy();
for (let i = 0; i < 8; i += 1) cli(["emotion", "--session", "sess-race-9999", "--apply", "joy=+5"]);
const sequential = joyOf();
resetJoy();
startAt = Date.now() + 900;
await Promise.all(Array.from({ length: 8 }, () => child(`${BARRIER}
const { spawnSync } = await import("node:child_process");
waitUntil(${startAt});
spawnSync(process.execPath, [${JSON.stringify(CLI)}, "emotion",
"--session", "sess-race-9999", "--apply", "joy=+5"], { stdio: "ignore" });`)));
const parallel = joyOf();
check("並行 8 次 `emotion --apply` 跟循序 8 次結果一樣(情緒更新不遺失)",
Math.abs(parallel - sequential) < 0.01, `循序 ${sequential} / 並行 ${parallel}`);
cli(["release", "--session", "sess-race-9999"]);
// ③ withFileLock 本身:N 個程序各做一次 read-increment-write,一次都不能掉
const counter = path.join(H, "racer", "counter.json");
fs.writeFileSync(counter, JSON.stringify({ n: 0 }));
startAt = Date.now() + 700;
await Promise.all(Array.from({ length: 12 }, () => child(`${BARRIER}
const pl = await import(${JSON.stringify(LIB)});
waitUntil(${startAt});
pl.updateJson(${JSON.stringify(counter)}, (d) => {
const n = (d && d.n) || 0;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 8); // 撐開讀與寫之間的空窗
return { n: n + 1 };
}, { n: 0 });`)));
check("withFileLock 真的互斥:12 個程序各加一次,結果就是 12",
pl.readJson(counter).n === 12, `n=${pl.readJson(counter).n}`);
// ④ 殘留的鎖檔不能讓人格從此寫不進東西
const victim = path.join(H, "racer", "stale.jsonl");
fs.writeFileSync(victim, "");
const staleLock = pl.fileLockPath(victim);
fs.mkdirSync(path.dirname(staleLock), { recursive: true });
fs.writeFileSync(staleLock, "999999\n");
const old = new Date(Date.now() - pl.FILE_LOCK_STALE_MS - 5_000);
fs.utimesSync(staleLock, old, old);
pl.appendJsonl(victim, { text: "死鎖之後還是要寫得進去" });
check("過期的鎖檔會被接手,不會把人格鎖死", pl.readJsonl(victim).length === 1);
check("鎖檔放在 .runtime/locks,不會混進人格目錄(也就不會被同步上去)",
pl.fileLockPath(victim).startsWith(path.join(pl.runtimeDir(), "locks")) &&
!fs.existsSync(staleLock), pl.fileLockPath(victim));
}
// --------------------------------------------------------------------------- //
// 情緒的檔案入口要驗證:emotion.json 是外部輸入(importsync pull/手改都會進來),
// CLI 的 --baseline--apply 有保護,破口在檔案這一側。
console.log("\n情緒:不合法的 emotion.json 不能污染整個狀態");
{
cli(["create", "--persona", "badstate", "--session", "sess-bad-8888", "--name", "Bad",
"--creature", "壞掉的溫度計", "--vibe", "亂跳", "--emoji", "🌡"]);
const anHourAgo = pl.iso(new Date(Date.now() - 3_600_000));
const write = (patch) => {
const st = pl.defaultEmotionState();
st.updated_at = anHourAgo;
patch(st);
pl.writeJson(pl.emotionPath("badstate"), st);
};
// ① 超出範圍的 baseline:以前 decay 後 joy = 4378mood 卡在 valence 100 / arousal 100
write((st) => { st.baseline.joy = 5000; });
const over = pl.decayEmotion(pl.loadEmotion("badstate"));
check("baseline 超出 0100 會被 clamp(不會把 mood 頂到滿)",
over.baseline.joy === 100 && over.levels.joy <= 100 && pl.mood(over).valence < 100,
`baseline=${over.baseline.joy} joy=${over.levels.joy} valence=${pl.mood(over).valence}`);
// ② 非數字的 levels:以前 decay 後 anger = NaN,注入的上下文直接印出 valence NaN
write((st) => { st.levels.anger = "很生氣"; st.levels.joy = null; });
const nan = pl.decayEmotion(pl.loadEmotion("badstate"));
check("非數字的情緒值退回預設,不會變成 NaN",
Number.isFinite(nan.levels.anger) && Number.isFinite(nan.levels.joy), JSON.stringify(nan.levels));
check("注入的上下文不會印出 NaN",
!pl.emotionBrief("badstate", nan).includes("NaN") && Number.isFinite(pl.mood(nan).valence),
pl.emotionBrief("badstate", nan));
// ③ 半衰期 ≤ 0:以前 factor 恆為 0,那個情緒從此累積不起來(連三次 +30 結果都一樣)
for (const bad of [-10, 0, "很久", null]) {
write((st) => { st.half_life_minutes.joy = bad; });
check(`half_life=${JSON.stringify(bad)} 會退回預設半衰期`,
pl.loadEmotion("badstate").half_life_minutes.joy === pl.EMOTIONS.joy.halfLife);
}
write((st) => { st.half_life_minutes.joy = -10; });
const grow = [];
for (let i = 0; i < 3; i += 1) {
cli(["emotion", "--session", "sess-bad-8888", "--apply", "joy=+30"]);
grow.push(pl.readJson(pl.emotionPath("badstate")).levels.joy);
}
check("半衰期壞掉時情緒仍然累積得起來(以前三次 +30 一動也不動)",
grow[1] > grow[0] && grow[2] > grow[1], grow.join(" → "));
// ④ 睡眠的固定衰減走同一條路
write((st) => { st.half_life_minutes.sadness = 0; st.levels.sadness = 90; });
const slept = pl.decayEmotionBy(pl.loadEmotion("badstate"), pl.SLEEP_DECAY_MINUTES);
check("decayEmotionBy 也擋得住壞掉的半衰期(不會一覺把情緒歸零)",
slept.levels.sadness > pl.DEFAULT_BASELINE.sadness && slept.levels.sadness < 90,
`sadness=${slept.levels.sadness}`);
cli(["release", "--session", "sess-bad-8888"]);
}
console.log("\n情緒:飽和、單輪預算、走向、偏差稽核"); console.log("\n情緒:飽和、單輪預算、走向、偏差稽核");
let sat = pl.defaultEmotionState(); let sat = pl.defaultEmotionState();
for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌"); for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌");