docs(persona-chat): clarify auto-hook boundary #13
@@ -12,8 +12,7 @@ const data = pl.loadSession(sessionId);
|
||||
const host = data.host;
|
||||
|
||||
if (host && pl.personaExists(host)) {
|
||||
const state = pl.decayEmotion(pl.loadEmotion(host));
|
||||
pl.writeJson(pl.emotionPath(host), state);
|
||||
const state = pl.updateEmotion(host, (s) => pl.decayEmotion(s));
|
||||
pl.appendJsonl(pl.journalPath(host), {
|
||||
ts: pl.nowIso(),
|
||||
kind: "session-end",
|
||||
|
||||
+1
-2
@@ -19,8 +19,7 @@ const host = data.host;
|
||||
if (!host || !pl.personaExists(host)) process.exit(0);
|
||||
|
||||
pl.heartbeatLock(host, sessionId);
|
||||
const state = pl.decayEmotion(pl.loadEmotion(host));
|
||||
pl.writeJson(pl.emotionPath(host), state);
|
||||
const state = pl.updateEmotion(host, (s) => pl.decayEmotion(s));
|
||||
|
||||
const theater = Boolean(data.theater) && (data.rooms || []).length > 0;
|
||||
const message = event.last_assistant_message || "";
|
||||
|
||||
+58
-41
@@ -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 syncStatePath = (slug) => path.join(pl.personaDir(slug), "state", "sync.json");
|
||||
|
||||
export function loadSyncState(slug) {
|
||||
const data = pl.readJson(syncStatePath(slug), {}) ?? {};
|
||||
data.areas ??= {};
|
||||
for (const key of AREA_KEYS) data.areas[key] ??= {};
|
||||
return data;
|
||||
/** 補上預設欄位。內容格式不變,只是保證 `state.areas[area]` 一定拿得到物件。 */
|
||||
function normalizeSyncState(data) {
|
||||
const state = data && typeof data === "object" ? data : {};
|
||||
state.areas ??= {};
|
||||
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
|
||||
@@ -686,9 +703,9 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
|
||||
gitOrThrow(["add", "-A"], dir, "git add");
|
||||
const dirty = git(["diff", "--cached", "--quiet"], dir);
|
||||
if (dirty.ok) {
|
||||
const state = loadSyncState(slug);
|
||||
state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() };
|
||||
saveSyncState(slug, state);
|
||||
updateSyncState(slug, (state) => {
|
||||
state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() };
|
||||
});
|
||||
return { ok: true, changed: false, files: staged.length, area, code: theCode };
|
||||
}
|
||||
gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit");
|
||||
@@ -728,16 +745,16 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
|
||||
pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
|
||||
}
|
||||
if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout };
|
||||
const state = loadSyncState(slug);
|
||||
state.code = theCode;
|
||||
state.owner = theOwner;
|
||||
state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length };
|
||||
if (overwrote) {
|
||||
// 每輪對話後的 push 是背景執行、輸出丟掉的,所以覆蓋紀錄一定要落地:
|
||||
// 留在 sync.json 裡等人來認領(`sync status` 會列,Stop hook 會提醒一次)。
|
||||
state.overwrites = [...(state.overwrites || []), { at: pl.nowIso(), ...overwrote }].slice(-OVERWRITE_LOG_KEEP);
|
||||
}
|
||||
saveSyncState(slug, state);
|
||||
updateSyncState(slug, (state) => {
|
||||
state.code = theCode;
|
||||
state.owner = theOwner;
|
||||
state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length };
|
||||
if (overwrote) {
|
||||
// 每輪對話後的 push 是背景執行、輸出丟掉的,所以覆蓋紀錄一定要落地:
|
||||
// 留在 sync.json 裡等人來認領(`sync status` 會列,Stop hook 會提醒一次)。
|
||||
state.overwrites = [...(state.overwrites || []), { at: pl.nowIso(), ...overwrote }].slice(-OVERWRITE_LOG_KEEP);
|
||||
}
|
||||
});
|
||||
return { ok: true, changed: true, files: staged.length, area, code: theCode, overwrote };
|
||||
}
|
||||
|
||||
@@ -748,14 +765,14 @@ export function pendingOverwrites(slug) {
|
||||
|
||||
/** 標記為已回報(同一次覆蓋只吵一次)。回傳這次標掉幾筆。 */
|
||||
export function markOverwritesReported(slug) {
|
||||
const state = loadSyncState(slug);
|
||||
let marked = 0;
|
||||
for (const entry of state.overwrites || []) {
|
||||
if (entry.reported_at) continue;
|
||||
entry.reported_at = pl.nowIso();
|
||||
marked += 1;
|
||||
}
|
||||
if (marked) saveSyncState(slug, state);
|
||||
updateSyncState(slug, (state) => {
|
||||
for (const entry of state.overwrites || []) {
|
||||
if (entry.reported_at) continue;
|
||||
entry.reported_at = pl.nowIso();
|
||||
marked += 1;
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
const written = unstageArea(slug, area, dir, restore);
|
||||
const state = loadSyncState(slug);
|
||||
state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() };
|
||||
saveSyncState(slug, state);
|
||||
updateSyncState(slug, (state) => {
|
||||
state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() };
|
||||
});
|
||||
return { ok: true, area, code: theCode, changed: incoming, written };
|
||||
}
|
||||
|
||||
@@ -923,12 +940,12 @@ export async function importFromRemote(code, { owner = null, slug = null, force
|
||||
} catch {
|
||||
/* 沒有關係圖就算了 */
|
||||
}
|
||||
const state = loadSyncState(target);
|
||||
state.code = code;
|
||||
state.owner = theOwner;
|
||||
if (repo?.html_url) state.repo_url = repo.html_url;
|
||||
state.imported_at = pl.nowIso();
|
||||
saveSyncState(target, state);
|
||||
updateSyncState(target, (state) => {
|
||||
state.code = code;
|
||||
state.owner = theOwner;
|
||||
if (repo?.html_url) state.repo_url = repo.html_url;
|
||||
state.imported_at = pl.nowIso();
|
||||
});
|
||||
const written = [...new Set(AREA_KEYS.flatMap((key) => results[key]?.written || []))].sort();
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
const state = loadSyncState(slug);
|
||||
state.code = theCode;
|
||||
state.owner = theOwner;
|
||||
state.repo_url = repo.html_url;
|
||||
state.initialized_at = state.initialized_at || pl.nowIso();
|
||||
saveSyncState(slug, state);
|
||||
updateSyncState(slug, (state) => {
|
||||
state.code = theCode;
|
||||
state.owner = theOwner;
|
||||
state.repo_url = repo.html_url;
|
||||
state.initialized_at = state.initialized_at || pl.nowIso();
|
||||
});
|
||||
return { code: theCode, owner: theOwner, repo, created, wikiCreated, results };
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -541,8 +541,7 @@ commands.sleep = async ({ flags }) => {
|
||||
await run("prune-short-term", () => pl.pruneShortTermDetail(slug));
|
||||
await run("archive-threads", () => pl.archiveStaleThreads(slug));
|
||||
await run("emotion-decay", () => {
|
||||
const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES);
|
||||
pl.writeJson(pl.emotionPath(slug), state);
|
||||
pl.updateEmotion(slug, (s) => pl.decayEmotionBy(s, pl.SLEEP_DECAY_MINUTES));
|
||||
});
|
||||
await run("reindex", () => pl.rebuildIndex(slug));
|
||||
await run("trim-said", () => pl.trimSaid(slug));
|
||||
@@ -744,8 +743,7 @@ commands.remember = ({ flags }) => {
|
||||
pl.rememberShort(slug, entry);
|
||||
const kept = pl.pruneShortTerm(slug);
|
||||
if (Object.keys(entry.emotion_deltas).length) {
|
||||
const state = pl.applyEmotion(pl.loadEmotion(slug), entry.emotion_deltas, text.slice(0, 80));
|
||||
pl.writeJson(pl.emotionPath(slug), state);
|
||||
const state = pl.updateEmotion(slug, (s) => pl.applyEmotion(s, entry.emotion_deltas, text.slice(0, 80)));
|
||||
pl.appendJsonl(pl.journalPath(slug), {
|
||||
ts: pl.nowIso(), kind: "emotion", trigger: text.slice(0, 120),
|
||||
deltas: entry.emotion_deltas, levels: state.levels, mood: pl.mood(state),
|
||||
|
||||
@@ -870,6 +870,23 @@ console.log("\n並行寫入:read-modify-rewrite 不能吃掉同時 append 的
|
||||
const parallel = joyOf();
|
||||
check("並行 8 次 `emotion --apply` 跟循序 8 次結果一樣(情緒更新不遺失)",
|
||||
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"]);
|
||||
|
||||
// ③ withFileLock 本身:N 個程序各做一次 read-increment-write,一次都不能掉
|
||||
|
||||
Reference in New Issue
Block a user