docs(persona-chat): clarify auto-hook boundary #13
+235
-62
@@ -170,10 +170,120 @@ export function writeText(file, text) {
|
|||||||
fs.renameSync(tmp, file);
|
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) {
|
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
@@ -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_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);
|
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("guest(sub agent)不得改寫人格的情緒狀態。");
|
||||||
if (role === "guest") die("guest(sub 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
@@ -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_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("⑥ 短期 → 長期的轉入條件");
|
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 的承諾會掉 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// 情緒的檔案入口要驗證: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情緒:飽和、單輪預算、走向、偏差稽核");
|
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 }, "連灌");
|
||||||
|
|||||||
Reference in New Issue
Block a user