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
5 changed files with 79 additions and 49 deletions
Showing only changes of commit bc571cf37f - Show all commits
+1 -2
View File
@@ -12,8 +12,7 @@ const data = pl.loadSession(sessionId);
const host = data.host; const host = data.host;
if (host && pl.personaExists(host)) { if (host && pl.personaExists(host)) {
const state = pl.decayEmotion(pl.loadEmotion(host)); const state = pl.updateEmotion(host, (s) => pl.decayEmotion(s));
pl.writeJson(pl.emotionPath(host), state);
pl.appendJsonl(pl.journalPath(host), { pl.appendJsonl(pl.journalPath(host), {
ts: pl.nowIso(), ts: pl.nowIso(),
kind: "session-end", kind: "session-end",
+1 -2
View File
@@ -19,8 +19,7 @@ const host = data.host;
if (!host || !pl.personaExists(host)) process.exit(0); if (!host || !pl.personaExists(host)) process.exit(0);
pl.heartbeatLock(host, sessionId); pl.heartbeatLock(host, sessionId);
const state = pl.decayEmotion(pl.loadEmotion(host)); const state = pl.updateEmotion(host, (s) => pl.decayEmotion(s));
pl.writeJson(pl.emotionPath(host), state);
const theater = Boolean(data.theater) && (data.rooms || []).length > 0; const theater = Boolean(data.theater) && (data.rooms || []).length > 0;
const message = event.last_assistant_message || ""; const message = event.last_assistant_message || "";
+35 -18
View File
@@ -138,14 +138,31 @@ export const AREA_KEYS = Object.keys(AREAS);
export const syncDir = (slug, area) => path.join(pl.personaDir(slug), SYNC_DIRNAME, area); export const syncDir = (slug, area) => path.join(pl.personaDir(slug), SYNC_DIRNAME, area);
export const syncStatePath = (slug) => path.join(pl.personaDir(slug), "state", "sync.json"); export const syncStatePath = (slug) => path.join(pl.personaDir(slug), "state", "sync.json");
export function loadSyncState(slug) { /** 補上預設欄位。內容格式不變,只是保證 `state.areas[area]` 一定拿得到物件。 */
const data = pl.readJson(syncStatePath(slug), {}) ?? {}; function normalizeSyncState(data) {
data.areas ??= {}; const state = data && typeof data === "object" ? data : {};
for (const key of AREA_KEYS) data.areas[key] ??= {}; state.areas ??= {};
return data; for (const key of AREA_KEYS) state.areas[key] ??= {};
return state;
} }
export const saveSyncState = (slug, data) => pl.writeJson(syncStatePath(slug), data); export function loadSyncState(slug) {
return normalizeSyncState(pl.readJson(syncStatePath(slug), {}) ?? {});
}
/**
* `sync.json` 的 read-modify-write:整段在檔案鎖裡面做,`mutate(state)` 就地改就好。
*
* 這個檔會被前景指令與背景 `sync push`(Stop hook 每輪都可能起一個)同時寫。
* 以前是各自「讀出來、改幾筆、整份寫回去」,兩邊撞上時後寫的會把前一個的
* `pushed_at``overwrites` 整段蓋掉——覆蓋紀錄就這樣安靜地消失。
*/
export function updateSyncState(slug, mutate) {
return pl.updateJson(syncStatePath(slug), (data) => {
const state = normalizeSyncState(data);
return mutate(state) ?? state;
}, {});
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// 環境與 API // 環境與 API
@@ -686,9 +703,9 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
gitOrThrow(["add", "-A"], dir, "git add"); gitOrThrow(["add", "-A"], dir, "git add");
const dirty = git(["diff", "--cached", "--quiet"], dir); const dirty = git(["diff", "--cached", "--quiet"], dir);
if (dirty.ok) { if (dirty.ok) {
const state = loadSyncState(slug); updateSyncState(slug, (state) => {
state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() }; state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() };
saveSyncState(slug, state); });
return { ok: true, changed: false, files: staged.length, area, code: theCode }; return { ok: true, changed: false, files: staged.length, area, code: theCode };
} }
gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit"); gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit");
@@ -728,7 +745,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
} }
if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout }; if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout };
const state = loadSyncState(slug); updateSyncState(slug, (state) => {
state.code = theCode; state.code = theCode;
state.owner = theOwner; state.owner = theOwner;
state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length }; state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length };
@@ -737,7 +754,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
// 留在 sync.json 裡等人來認領(`sync status` 會列,Stop hook 會提醒一次)。 // 留在 sync.json 裡等人來認領(`sync status` 會列,Stop hook 會提醒一次)。
state.overwrites = [...(state.overwrites || []), { at: pl.nowIso(), ...overwrote }].slice(-OVERWRITE_LOG_KEEP); state.overwrites = [...(state.overwrites || []), { at: pl.nowIso(), ...overwrote }].slice(-OVERWRITE_LOG_KEEP);
} }
saveSyncState(slug, state); });
return { ok: true, changed: true, files: staged.length, area, code: theCode, overwrote }; return { ok: true, changed: true, files: staged.length, area, code: theCode, overwrote };
} }
@@ -748,14 +765,14 @@ export function pendingOverwrites(slug) {
/** 標記為已回報(同一次覆蓋只吵一次)。回傳這次標掉幾筆。 */ /** 標記為已回報(同一次覆蓋只吵一次)。回傳這次標掉幾筆。 */
export function markOverwritesReported(slug) { export function markOverwritesReported(slug) {
const state = loadSyncState(slug);
let marked = 0; let marked = 0;
updateSyncState(slug, (state) => {
for (const entry of state.overwrites || []) { for (const entry of state.overwrites || []) {
if (entry.reported_at) continue; if (entry.reported_at) continue;
entry.reported_at = pl.nowIso(); entry.reported_at = pl.nowIso();
marked += 1; marked += 1;
} }
if (marked) saveSyncState(slug, state); });
return marked; return marked;
} }
@@ -798,9 +815,9 @@ export async function pullArea(slug, area, { code = null, owner = null, force =
if (!fs.existsSync(path.join(root, cloneNameToRel(area, dir, name)))) restore.add(name); if (!fs.existsSync(path.join(root, cloneNameToRel(area, dir, name)))) restore.add(name);
} }
const written = unstageArea(slug, area, dir, restore); const written = unstageArea(slug, area, dir, restore);
const state = loadSyncState(slug); updateSyncState(slug, (state) => {
state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() }; state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() };
saveSyncState(slug, state); });
return { ok: true, area, code: theCode, changed: incoming, written }; return { ok: true, area, code: theCode, changed: incoming, written };
} }
@@ -923,12 +940,12 @@ export async function importFromRemote(code, { owner = null, slug = null, force
} catch { } catch {
/* 沒有關係圖就算了 */ /* 沒有關係圖就算了 */
} }
const state = loadSyncState(target); updateSyncState(target, (state) => {
state.code = code; state.code = code;
state.owner = theOwner; state.owner = theOwner;
if (repo?.html_url) state.repo_url = repo.html_url; if (repo?.html_url) state.repo_url = repo.html_url;
state.imported_at = pl.nowIso(); state.imported_at = pl.nowIso();
saveSyncState(target, state); });
const written = [...new Set(AREA_KEYS.flatMap((key) => results[key]?.written || []))].sort(); const written = [...new Set(AREA_KEYS.flatMap((key) => results[key]?.written || []))].sort();
return { persona: target, code, owner: theOwner, repo, results, written, overwrote_local: !fresh }; return { persona: target, code, owner: theOwner, repo, results, written, overwrote_local: !fresh };
} }
@@ -954,12 +971,12 @@ export async function initRemote(slug, { code = null, owner = null, private_ = t
message: `init(${area}): ${AREAS[area].why}`, message: `init(${area}): ${AREAS[area].why}`,
}); });
} }
const state = loadSyncState(slug); updateSyncState(slug, (state) => {
state.code = theCode; state.code = theCode;
state.owner = theOwner; state.owner = theOwner;
state.repo_url = repo.html_url; state.repo_url = repo.html_url;
state.initialized_at = state.initialized_at || pl.nowIso(); state.initialized_at = state.initialized_at || pl.nowIso();
saveSyncState(slug, state); });
return { code: theCode, owner: theOwner, repo, created, wikiCreated, results }; return { code: theCode, owner: theOwner, repo, created, wikiCreated, results };
} }
+2 -4
View File
@@ -541,8 +541,7 @@ commands.sleep = async ({ flags }) => {
await run("prune-short-term", () => pl.pruneShortTermDetail(slug)); await run("prune-short-term", () => pl.pruneShortTermDetail(slug));
await run("archive-threads", () => pl.archiveStaleThreads(slug)); await run("archive-threads", () => pl.archiveStaleThreads(slug));
await run("emotion-decay", () => { await run("emotion-decay", () => {
const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES); pl.updateEmotion(slug, (s) => pl.decayEmotionBy(s, pl.SLEEP_DECAY_MINUTES));
pl.writeJson(pl.emotionPath(slug), state);
}); });
await run("reindex", () => pl.rebuildIndex(slug)); await run("reindex", () => pl.rebuildIndex(slug));
await run("trim-said", () => pl.trimSaid(slug)); await run("trim-said", () => pl.trimSaid(slug));
@@ -744,8 +743,7 @@ commands.remember = ({ flags }) => {
pl.rememberShort(slug, entry); pl.rememberShort(slug, entry);
const kept = pl.pruneShortTerm(slug); const kept = pl.pruneShortTerm(slug);
if (Object.keys(entry.emotion_deltas).length) { if (Object.keys(entry.emotion_deltas).length) {
const state = pl.applyEmotion(pl.loadEmotion(slug), entry.emotion_deltas, text.slice(0, 80)); const state = pl.updateEmotion(slug, (s) => pl.applyEmotion(s, entry.emotion_deltas, text.slice(0, 80)));
pl.writeJson(pl.emotionPath(slug), state);
pl.appendJsonl(pl.journalPath(slug), { pl.appendJsonl(pl.journalPath(slug), {
ts: pl.nowIso(), kind: "emotion", trigger: text.slice(0, 120), ts: pl.nowIso(), kind: "emotion", trigger: text.slice(0, 120),
deltas: entry.emotion_deltas, levels: state.levels, mood: pl.mood(state), deltas: entry.emotion_deltas, levels: state.levels, mood: pl.mood(state),
+17
View File
@@ -870,6 +870,23 @@ console.log("\n並行寫入:read-modify-rewrite 不能吃掉同時 append 的
const parallel = joyOf(); const parallel = joyOf();
check("並行 8 次 `emotion --apply` 跟循序 8 次結果一樣(情緒更新不遺失)", check("並行 8 次 `emotion --apply` 跟循序 8 次結果一樣(情緒更新不遺失)",
Math.abs(parallel - sequential) < 0.01, `循序 ${sequential} / 並行 ${parallel}`); Math.abs(parallel - sequential) < 0.01, `循序 ${sequential} / 並行 ${parallel}`);
// ②-b `remember --emotion` 是每一輪對話都會走的熱路徑,它的情緒寫入也必須在鎖裡
const sayArgs = ["remember", "--session", "sess-race-9999", "--role", "user",
"--text", "熱路徑", "--salience", "10", "--emotion", "joy=+5"];
resetJoy();
for (let i = 0; i < 8; i += 1) cli(sayArgs);
const saySequential = 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)}, ...${JSON.stringify(sayArgs)}], { stdio: "ignore" });`)));
const sayParallel = joyOf();
check("並行 8 次 `remember --emotion`(對話熱路徑)跟循序結果一樣",
Math.abs(sayParallel - saySequential) < 0.01, `循序 ${saySequential} / 並行 ${sayParallel}`);
cli(["release", "--session", "sess-race-9999"]); cli(["release", "--session", "sess-race-9999"]);
// ③ withFileLock 本身:N 個程序各做一次 read-increment-write,一次都不能掉 // ③ withFileLock 本身:N 個程序各做一次 read-increment-write,一次都不能掉