From d9f865d7a44447fcd924e22d69c371ed80b6c211 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 09:19:39 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix(persona-lib):=20=E6=AA=94=E6=A1=88?= =?UTF-8?q?=E5=B1=A4=E4=BA=92=E6=96=A5=E9=8E=96=EF=BC=8Cread-modify-rewrit?= =?UTF-8?q?e=20=E4=B8=8D=E5=86=8D=E5=90=83=E6=8E=89=E4=B8=A6=E8=A1=8C?= =?UTF-8?q?=E7=9A=84=20append?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit short-term.jsonl / said.jsonl 的裁切是「整檔讀進來 → 過濾 → writeText 覆蓋」, 中間沒有任何鎖。而同一個 session 的 sub agent 與主程序共用同一把人格鎖(設計如此), 所以兩邊真的會同時寫——實測背景 prune 進行中 append 30 筆 salience 95 的承諾, 會被吃掉 1~9 筆,正是 shortTermProtected() 明文要保護的那一類。 emotion.json 更嚴重:並行 8 次 `--apply joy=+5`,循序得 50.24,並行只得 38.91(增量遺失 45%)。 加一把用 `fs.openSync(path, "wx")` sentinel 做的檔案鎖: * withFileLock(file, fn):拿不到就退讓重試,超過 15 秒的殘留鎖視為死鎖並接手, 真的等不到就直接做(寧可冒一次競態,也不要因為殘留鎖檔讓人格從此寫不進東西)。 sentinel 放 .runtime/locks/,不落在人格目錄裡,不會被同步上去。 * rewriteJsonl(file, transform):整檔改寫的唯一入口,讀與寫都在鎖裡。 * updateJson / updateEmotion:JSON 檔的 read-modify-write 同樣進鎖。 * appendJsonl 也拿同一把鎖,否則 append 仍會落在別人的讀與寫之間被覆蓋掉。 改用新入口的:pruneShortTermDetail、trimSaid、trimJsonl、turnContext 的情緒衰減、 persona.mjs 的 `emotion --apply`(讀→衰減→套用→寫回整段在鎖裡)。 selftest +5:並行 append vs prune 一筆不掉、並行 emotion 與循序同值、 withFileLock 互斥(12 程序各加一次=12)、過期鎖檔可接手、鎖檔不落在人格目錄。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/persona-lib.mjs | 202 +++++++++++++++++++++++++++++++--------- scripts/persona.mjs | 50 ++++++---- scripts/selftest.mjs | 106 ++++++++++++++++++++- 3 files changed, 295 insertions(+), 63 deletions(-) diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 1ee6819..bd4ca59 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -170,10 +170,120 @@ export function writeText(file, text) { fs.renameSync(tmp, file); } -/** 單行 append(O_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) { 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) { @@ -266,6 +376,20 @@ export function loadEmotion(slug) { 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; + }); +} + /** 情緒朝 baseline 指數衰減;半衰期依情緒種類不同。 */ export function decayEmotion(state, now = new Date()) { const last = parseIso(state.updated_at) ?? now; @@ -1143,16 +1267,13 @@ export function archiveStaleThreads(slug, days = THREAD_STALE_DAYS) { /** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */ 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; - let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff); - kept = kept.slice(-lines); - if (kept.length !== rows.length) { - writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : "")); - } - return kept.length; + return rewriteJsonl(saidPath(slug), (rows) => { + if (!rows.length) return rows; + return rows + .filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff) + .slice(-lines); + }).kept.length; } /** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */ @@ -1352,36 +1473,36 @@ function shortTermProtected(row, now = Date.now()) { * 「還沒經過判斷就把今天清掉」是這裡最不能犯的錯。 */ 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 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" : "")); - } + const stats = { by_age: 0, by_capacity: 0 }; + // 讀 → 過濾 → 覆蓋整段都在檔案鎖裡:中途 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:超過軟上限,從「顯著度最低、最舊」開始清,但保護清單裡的不動 + if (live.length > SHORT_TERM_SOFT_CAP) { + const droppable = live + .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 = live.length - SHORT_TERM_SOFT_CAP; + const drop = new Set(droppable.slice(0, need).map((x) => x.i)); + stats.by_capacity = drop.size; + live = live.filter((_, i) => !drop.has(i)); + } + 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 { kept: kept.length, dropped: rows.length - kept.length, - by_age: byAge, - by_capacity: byCapacity + byHardCap, + by_age: stats.by_age, + by_capacity: stats.by_capacity, protected: kept.filter((r) => shortTermProtected(r, now)).length, }; } @@ -1953,11 +2074,7 @@ export function findRepeat(entries, text, { } function trimJsonl(file, keep) { - const rows = readJsonl(file); - 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; + return rewriteJsonl(file, (rows) => (rows.length <= keep * 1.5 ? rows : rows.slice(-keep))).kept.length; } /** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */ @@ -3116,8 +3233,7 @@ export function opsBrief(slug) { /** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */ export function turnContext(slug, sessionId, prompt = "") { - const state = decayEmotion(loadEmotion(slug)); - writeJson(emotionPath(slug), state); + const state = updateEmotion(slug, (s) => decayEmotion(s)); const session = loadSession(sessionId); const lines = [ "", diff --git a/scripts/persona.mjs b/scripts/persona.mjs index b45046a..9381232 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -931,12 +931,6 @@ commands.emotion = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); 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` 可以單獨拿來測詞表) const readText = str(flags.read); const read = readText ? pl.readUserEmotion(readText) : null; @@ -964,26 +958,44 @@ commands.emotion = ({ flags }) => { ]); return; } + if (flags.apply && role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。"); + // 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣 + const rel = flags.apply ? pl.relationGain(slug, str(flags.from) || null) : null; + 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 + : Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100])); + s = pl.applyEmotion(s, deltas, str(flags.trigger)); + if (rel.gain !== 1) { + s.last_trigger = s.last_trigger || {}; + s.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain }; + } + } + return s; + }); + } if (flags.apply) { - if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。"); - const raw = parseDeltas(flags.apply); - // 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣 - const rel = pl.relationGain(slug, str(flags.from) || null); - const deltas = rel.gain === 1 - ? raw - : Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100])); - state = pl.applyEmotion(state, deltas, str(flags.trigger)); - if (rel.gain !== 1) { - state.last_trigger = state.last_trigger || {}; - state.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain }; - } 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, 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 row = (key) => ` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]})`; diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 0bb714e..de4dc04 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -8,7 +8,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import zlib from "node:zlib"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -687,6 +687,110 @@ console.log("\n短期記憶:容量壓力(R6)真的會清,而且有保護 typeof detail.dropped === "number" && typeof detail.protected === "number", JSON.stringify(detail)); } +// --------------------------------------------------------------------------- // +// 並行寫入不掉資料 +// +// 人格鎖擋的是「兩個人格同時被載入」,不是「兩個程序同時寫同一個檔」——同一個 session +// 的 sub agent 與主程序共用同一把人格鎖(設計如此),所以兩邊真的會同時寫。 +// 修之前:背景 prune 進行中寫入 30 筆 salience 95 的承諾會掉 1~9 筆; +// 並行 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)); +} + console.log("\n情緒:飽和、單輪預算、走向、偏差稽核"); let sat = pl.defaultEmotionState(); for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌"); From 1d5b049152adb8e6a03215a9fcba398dd0bd8219 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 09:23:35 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(consolidate):=20--forget=20=E5=A5=97?= =?UTF-8?q?=E4=B8=8A=E7=9F=AD=E6=9C=9F=E8=A8=98=E6=86=B6=E7=9A=84=E4=BF=9D?= =?UTF-8?q?=E8=AD=B7=EF=BC=9B=E9=95=B7=E6=9C=9F=E8=A8=98=E6=86=B6=E6=92=9E?= =?UTF-8?q?=E5=90=8D=E4=B8=8D=E5=86=8D=E9=9D=9C=E9=BB=98=E8=A6=86=E8=93=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兩個資料遺失路徑: ① `--forget N` 無條件刪掉 salience < N 的全部短期記憶,完全不套 shortTermProtected() (salience ≥ 80/intent=commit/24 小時內)。prune 有這層保護,這裡沒有。 實測 `--forget 200` 把一筆 intent=commit、salience 95、剛寫入的承諾一起刪掉, 而 R4 明文寫「不可遺忘」。改走新的 forgetShortTerm(),跟 prune 同一層保護, 並回報「有幾筆低於門檻但受保護、沒有刪」。 ② slugify() 把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md`, writeText 直接覆蓋,只保留舊檔的 first_seen/recall_count,內文靜默被換掉。 front matter 新增 `title:`(原本的 --name),撞名時 die 並給一個沒被占用的檔名 (`--name 我的貓-2`);真的要覆蓋同一則就加 --force。 selftest +11:--forget 不刪承諾/界線/今天的紀錄但該刪的照刪、撞名被擋、 舊內文原封不動、訊息給得出替代檔名、同名再寫是更新、title 有寫入、--force 仍可覆蓋。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/persona-lib.mjs | 26 ++++++++++++++++++++- scripts/persona.mjs | 28 ++++++++++++++++++---- scripts/selftest.mjs | 52 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index bd4ca59..bb90d47 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -1458,7 +1458,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 (String(row?.intent || "").includes("commit")) return true; const age = now - (parseIso(row?.ts)?.getTime() ?? now); @@ -1512,6 +1512,30 @@ export function pruneShortTerm(slug) { 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 function parseFrontMatter(text) { diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 9381232..5ddfac9 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -871,6 +871,21 @@ commands.consolidate = ({ flags }) => { const today = pl.nowIso().slice(0, 10); let existing = {}; if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8")); + // slugify 會把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md` 上。 + // 以前直接覆蓋,只留下舊檔的 first_seen/recall_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); if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8"); if (!body.trim()) die("需要 `--body` 或 `--body-file`。"); @@ -883,6 +898,8 @@ commands.consolidate = ({ flags }) => { const front = [ "---", `name: ${name}`, + // 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。 + `title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`, `type: ${type}`, `about: [${csv(flags.about).join(", ") || "user"}]`, `topics: [${csv(flags.topics).join(", ")}]`, @@ -902,10 +919,13 @@ commands.consolidate = ({ flags }) => { const total = pl.rebuildIndex(slug); const forget = num(flags.forget, null); if (forget !== null) { - const rows = pl.readJsonl(pl.shortTermPath(slug)); - const keep = rows.filter((r) => Number(r.salience || 0) >= forget); - pl.writeText(pl.shortTermPath(slug), keep.map((r) => JSON.stringify(r)).join("\n") + (keep.length ? "\n" : "")); - say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`); + // 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4) + const cut = pl.forgetShortTerm(slug, forget); + say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${cut.kept} 筆。`); + if (cut.protected) { + say(` 其中 ${cut.protected} 筆低於門檻但受保護,沒有刪:` + + `顯著度 ≥ ${pl.SHORT_TERM_PROTECT_SALIENCE}、intent=commit,或 ${pl.SHORT_TERM_PROTECT_HOURS} 小時內。`); + } } ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`); pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了 diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index de4dc04..6460188 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -169,6 +169,58 @@ check("關鍵詞可檢索到", pl.recall("alpha", "早上 會議").map((m) => m._name).join() === "hates-morning-meetings"); 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_seen/recall_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("⑥ 短期 → 長期的轉入條件"); check("有六條成文條件", pl.PROMOTION_RULES.length === 6); check("R1 高顯著度會成為候選", From 94a86c970354fbae3bcb2020e7a5df07fdd5da80 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 09:26:47 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(emotion):=20loadEmotion=20=E9=A9=97?= =?UTF-8?q?=E8=AD=89=E6=AA=94=E6=A1=88=E5=85=A7=E5=AE=B9=EF=BC=8C=E8=A1=B0?= =?UTF-8?q?=E6=B8=9B=E4=B8=80=E5=BE=8B=20clamp=20=E4=B8=94=E5=8D=8A?= =?UTF-8?q?=E8=A1=B0=E6=9C=9F=E5=BF=85=E9=A0=88=E7=82=BA=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emotion.json 是外部輸入(import/sync pull/手改都會進到這裡),但 loadEmotion() 用 `??=` 只補缺、不驗合法性,decayEmotion() 完全不 clamp。只要一份不合法的檔案: * baseline.joy = 5000 → decay 後 joy = 4378.79,mood 卡在 valence 100 / arousal 100 * levels.anger = "很生氣" → decay 後 anger = NaN,注入的上下文印出 valence NaN * half_life_minutes.joy ≤ 0 → factor 恆為 0,那個情緒從此累積不起來(連三次 +30 結果不變) CLI 的 --baseline 與 --apply 本來就有保護,破口純粹在檔案入口,所以擋在 loadEmotion(): levels/baseline clamp 到 0–100、非有限數字退回預設、half_life 非正數退回該情緒的預設值。 decayEmotion/decayEmotionBy 抽出共用的 applyDecay(),同樣重驗一次並 clamp 結果—— 它們是 export 的,呼叫端有可能餵手寫或匯入來的狀態,不能假設一定經過 loadEmotion()。 selftest +9:baseline 超界被 clamp、非數字不變 NaN、上下文不印 NaN、 四種壞掉的半衰期都退回預設、壞半衰期下情緒仍累積得起來、decayEmotionBy 同樣擋得住。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/persona-lib.mjs | 64 ++++++++++++++++++++++++++++------------- scripts/selftest.mjs | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 20 deletions(-) diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index bb90d47..524d6d6 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -362,16 +362,33 @@ 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) { let state = readJson(emotionPath(slug)); if (!state || typeof state !== "object" || !state.levels) state = defaultEmotionState(); - state.baseline ??= {}; - state.levels ??= {}; - state.half_life_minutes ??= {}; + if (!state.baseline || typeof state.baseline !== "object") state.baseline = {}; + if (!state.levels || typeof state.levels !== "object") state.levels = {}; + if (!state.half_life_minutes || typeof state.half_life_minutes !== "object") state.half_life_minutes = {}; for (const key of EMOTION_KEYS) { - state.baseline[key] ??= DEFAULT_BASELINE[key]; - state.levels[key] ??= state.baseline[key]; - state.half_life_minutes[key] ??= EMOTIONS[key].halfLife; + state.baseline[key] = sanelevel(state.baseline[key], DEFAULT_BASELINE[key]); + state.levels[key] = sanelevel(state.levels[key], state.baseline[key]); + const half = Number(state.half_life_minutes[key]); + state.half_life_minutes[key] = Number.isFinite(half) && half > 0 ? half : EMOTIONS[key].halfLife; } return state; } @@ -390,18 +407,31 @@ export function updateEmotion(slug, mutate) { }); } +/** + * 把 `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; +} + /** 情緒朝 baseline 指數衰減;半衰期依情緒種類不同。 */ export function decayEmotion(state, now = new Date()) { const last = parseIso(state.updated_at) ?? now; const minutes = Math.max(0, (now.getTime() - last.getTime()) / 60_000); if (minutes <= 0) return state; - for (const key of EMOTION_KEYS) { - 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; - } + applyDecay(state, minutes); state.updated_at = iso(now); return state; } @@ -416,13 +446,7 @@ export function decayEmotion(state, now = new Date()) { export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) { const mins = Math.max(0, Number(minutes) || 0); if (!mins) return state; - for (const key of EMOTION_KEYS) { - 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; - } + applyDecay(state, mins); state.updated_at = nowIso(); return state; } diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 6460188..da1a668 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -843,6 +843,61 @@ console.log("\n並行寫入:read-modify-rewrite 不能吃掉同時 append 的 !fs.existsSync(staleLock), pl.fileLockPath(victim)); } +// --------------------------------------------------------------------------- // +// 情緒的檔案入口要驗證:emotion.json 是外部輸入(import/sync 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 = 4378,mood 卡在 valence 100 / arousal 100 + write((st) => { st.baseline.joy = 5000; }); + const over = pl.decayEmotion(pl.loadEmotion("badstate")); + check("baseline 超出 0–100 會被 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情緒:飽和、單輪預算、走向、偏差稽核"); let sat = pl.defaultEmotionState(); for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌"); From 197ad3416f6886e557a7211c058236c6196dac53 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 09:29:53 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(speech):=20=E4=BF=AE=E6=8E=89=E4=B8=89?= =?UTF-8?q?=E5=80=8B=E8=AA=A4=E6=AE=BA=E2=80=94=E2=80=94=E6=9B=B8=E5=90=8D?= =?UTF-8?q?=E8=99=9F=E3=80=81=E8=AE=8A=E9=AB=94=E9=81=B8=E6=93=87=E5=AD=90?= =?UTF-8?q?=20emoji=E3=80=81=E5=BC=95=E8=BF=B0=E7=AE=97=E9=80=B2=E5=8F=A5?= =?UTF-8?q?=E9=95=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三個都是「該放行卻被擋」,而且都只有幾行: * speechBody() 的「提及 ≠ 使用」豁免清單漏了《》: 「我在看《說到底》這本書」被判 preach,「他推薦我看《質量與信息》」被判中國用語。 * EMOJI_RE 把 \u{FE0F} 寫在字元類裡,`❤️`(U+2764 + VS16)被算成兩個 emoji 而誤擋; ZWJ 家族(👨‍👩‍👦)算三個、膚色(👍🏽)算兩個。改成「底字 + 變體/膚色 + ZWJ 續接」算一個。 * 句長檢查用的是 sentence 而不是 body,引號內容與 `code` 都算進字數—— 引述使用者原話必被擋,等於不准引述。 「質量」「信息」這類語境相依的中國用語誤判沒有動(那要語意判斷,成本不對)。 selftest +11:SNF 放行 7 條(書名號兩種、三種 emoji 組合、引述長原話與長 code), SF 仍擋 4 條(書名號之外真的在說教、引號之外自己講的長句、兩個 emoji、 一個 emoji 加一個帶變體選擇子的)——豁免只針對提及與引述,不是整句放行。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/persona-lib.mjs | 17 +++++++++++++---- scripts/selftest.mjs | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 524d6d6..a67f369 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -1985,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"); /** * 只留「真的說出口、而且是在使用而不是在討論」的部分。 @@ -1993,11 +1998,13 @@ const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu; * 引號裡的原話與 `code` 一律拿掉再比對——「我最近戒掉『賦能』這個詞」是在**提及**那個詞, * 不是在用它。這一條是 speak-human-tw 自己踩到的坑:它的文件裡出現「...」與彎引號, * 正是因為那幾行在說「不要用這些」。 + * + * 書名號《》也算:「我在看《說到底》這本書」講的是書名,不是在說教。 */ function speechBody(text) { return String(text ?? "") .replace(/`[^`]*`/g, " ") - .replace(/「[^」]*」|『[^』]*』|"[^"]*"/g, " "); + .replace(/「[^」]*」|『[^』]*』|《[^》]*》|"[^"]*"/g, " "); } /** @@ -2015,14 +2022,16 @@ export function speechLint(text) { .map((s) => s.trim()) .filter(Boolean); 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) { issues.push({ kind: "long", level: "block", chars: [...bare].length, text: sentence.slice(0, 30) }); } const head = sentence.replace(/^[「『((]+/, ""); const opener = EXPLAINER_OPENERS.find((w) => head.startsWith(w)); 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)); if (hedges.length >= 2) { issues.push({ kind: "hedge", level: "block", words: hedges, text: sentence.slice(0, 30) }); diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index da1a668..43ff0c0 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -513,6 +513,11 @@ const SF_CASES = [ ["中國用語", "那個視頻我看完了。", "cn"], ["半形標點", "我看完了,你呢?", "halfwidth"], ["排版殘留", "**重點**:鐘修好了。", "markdown"], + // 放寬《》與句長之後,這幾條仍然要擋——豁免只針對「提及/引述」,不是整句放行 + ["書名號之外真的在說教", "我在看《時間簡史》。說到底,問題不在零件。", "preach"], + ["自己講的長句(引號之外)", `我${"想跟你說一件從去年就一直放在心上沒講出口的事情".repeat(2)}。`, "long"], + ["兩個 emoji", "修好了 🔧 開心 🎉", "emoji"], + ["一個 emoji 加一個帶變體選擇子的", "修好了 🔧 給你 ❤️", "emoji"], ]; for (const [label, text, kind] of SF_CASES) { const issues = pl.speechLint(text); @@ -536,6 +541,16 @@ const SNF_CASES = [ ["一次「不是 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) { const issues = pl.speechBlockers(text);