Files
persona/hooks/turn_end.mjs
T
jiantw83andClaude Opus 5 bc571cf37f fix(data): 四處情緒/sync 狀態的讀改寫還沒進鎖,等於繞過了上次的修正
B4 加了 updateJson/updateEmotion,但這幾個呼叫點還是「loadEmotion → 改 →
writeJson」的裸讀改寫,鎖形同不存在:

- sleep 的 emotion-decay、remember --emotion(每輪對話的熱路徑)、
  Stop hook 與 SessionEnd hook 的時間衰減,四處都改走 pl.updateEmotion()。
  衰減與 delta 的算法一個字都沒動,只是把讀與寫收進同一把鎖裡。
- sync.json 同理:背景 sync push 跟前景指令會同時寫它,兩邊撞上時後寫的會把
  pushed_at/overwrites 整段蓋掉——覆蓋紀錄就這樣安靜地消失。新增
  updateSyncState()(帶鎖),六處 loadSyncState + saveSyncState 成對使用全部
  改過去;補預設欄位的邏輯抽成 normalizeSyncState() 給兩邊共用。
  saveSyncState 沒有呼叫端了,直接移掉,免得下次又有人拿它裸寫。

sync.json 的欄位格式與 overwrites 留 10 筆的行為都沒變。

selftest 448 項(+1):新增「並行 8 次 remember --emotion 跟循序結果一樣」,
把對話熱路徑的情緒寫入也蓋進競態測試——原本只蓋到 emotion --apply。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:35:06 +00:00

70 lines
3.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Stop:情緒隨時間衰減、續租、記錄逐字,並在達到「短期→長期」條件時提醒固化。
// 劇場模式進行中不發任何提醒(那會破壞「只顯示人格對話」)。
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
import * as gt from "../scripts/persona-gitea.mjs";
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "persona.mjs");
const event = readEvent();
if (!event) process.exit(0);
const sessionId = event.session_id || "unknown";
const data = pl.loadSession(sessionId);
const host = data.host;
if (!host || !pl.personaExists(host)) process.exit(0);
pl.heartbeatLock(host, sessionId);
const state = pl.updateEmotion(host, (s) => pl.decayEmotion(s));
const theater = Boolean(data.theater) && (data.rooms || []).length > 0;
const message = event.last_assistant_message || "";
if (message) {
pl.appendJsonl(pl.journalPath(host), {
ts: pl.nowIso(),
kind: "utterance",
role: "persona",
text: message.slice(0, 4000),
mood: pl.mood(state),
});
// 說出口的話存進 said.jsonl,下一輪的 <persona-context> 會提醒「這些別再說一次」。
// recordSaid 內建 10 分鐘去重,所以劇場模式裡 room post 已記過的台詞不會重覆記。
for (const line of pl.spokenLines(message, { theater })) {
pl.recordSaid(host, line, { kind: theater ? "room" : "reply" });
}
}
// 檔案區=高頻活狀態:每輪結束後背景推上 Gitea(有最小間隔、失敗不阻斷這一輪)
if (!gt.giteaProblem() && gt.personaCode(host) && gt.pushDue(host, "files")) {
gt.pushInBackground(host, "files", sessionId, CLI);
}
const out = { suppressOutput: true };
// 背景 push 撞到別台機器時是「本機覆蓋遠端」。那條路徑會把帳記在 sync.json,
// 但它是 detached 跑的、輸出丟掉,所以由這裡認領並回報一次——劇場模式也照報(那是資料被蓋掉)。
const overwrites = gt.pendingOverwrites(host);
if (overwrites.length) {
gt.markOverwritesReported(host);
const files = [...new Set(overwrites.flatMap((o) => o.files || []))];
const last = overwrites.at(-1);
out.systemMessage =
`[jsc-persona] ⚠ \`${host}\` 同步到 Gitea 時**以本機為準覆蓋了遠端** ${files.length} 個檔案` +
`${files.slice(0, 5).join(", ")}${files.length > 5 ? "…" : ""}),上一版是 ${String(last.previous).slice(0, 8)}。` +
"別台機器可能正在用同一個人格;細節見 `sync status`。";
}
if (!theater) {
const { total, candidates } = pl.promotionCandidates(host);
if (candidates.length) {
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
out.systemMessage =
`${out.systemMessage ? `${out.systemMessage}\n` : ""}` +
`[jsc-persona] \`${host}\` 短期記憶 ${total} 筆,${candidates.length} 組已達固化條件(${rules}` +
"→ 建議執行 /jsc-persona:persona-memory。";
}
}
respond(out);