merge(data): 並行鎖/consolidate 保護/情緒校驗/speechLint 誤殺修正
This commit is contained in:
+241
-68
@@ -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) {
|
||||
@@ -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) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 情緒的 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;
|
||||
}
|
||||
@@ -271,13 +431,7 @@ 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;
|
||||
}
|
||||
@@ -292,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;
|
||||
}
|
||||
@@ -1143,16 +1291,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(同步時省流量,也不再被讀)。 */
|
||||
@@ -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 (String(row?.intent || "").includes("commit")) return true;
|
||||
const age = now - (parseIso(row?.ts)?.getTime() ?? now);
|
||||
@@ -1352,36 +1497,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,
|
||||
};
|
||||
}
|
||||
@@ -1391,6 +1536,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) {
|
||||
@@ -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` 一律拿掉再比對——「我最近戒掉『賦能』這個詞」是在**提及**那個詞,
|
||||
* 不是在用它。這一條是 speak-human-tw 自己踩到的坑:它的文件裡出現「...」與彎引號,
|
||||
* 正是因為那幾行在說「不要用這些」。
|
||||
*
|
||||
* 書名號《》也算:「我在看《說到底》這本書」講的是書名,不是在說教。
|
||||
*/
|
||||
function speechBody(text) {
|
||||
return String(text ?? "")
|
||||
.replace(/`[^`]*`/g, " ")
|
||||
.replace(/「[^」]*」|『[^』]*』|"[^"]*"/g, " ");
|
||||
.replace(/「[^」]*」|『[^』]*』|《[^》]*》|"[^"]*"/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1846,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) });
|
||||
@@ -1953,11 +2131,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 重複記)。 */
|
||||
@@ -3221,8 +3395,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 = [
|
||||
"<persona-context>",
|
||||
|
||||
Reference in New Issue
Block a user