From f9b189dd76e02ba9290cdff9ac8ab241885457a2 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 3 Aug 2026 04:22:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(persona=20=E6=93=AC=E7=9C=9F):=20=E8=A8=98?= =?UTF-8?q?=E6=86=B6=E6=9C=83=E7=B3=8A=E6=8E=89=E3=80=81=E6=83=85=E7=B7=92?= =?UTF-8?q?=E6=9C=89=E5=BA=95=E8=89=B2=E8=88=87=E7=96=B2=E5=8B=9E=E3=80=81?= =?UTF-8?q?=E4=BA=BA=E6=A0=BC=E6=9C=89=E6=87=B8=E8=91=97=E7=9A=84=E4=BA=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三個結構性缺口,這批一次補完(使用者拍板做階段 1+2+3 加自我議程)。 記憶只有「精準」與「沒有」兩態 → 連續衰減出來的三態。 memoryStrength() 由 strength/salience/recall_count/距上次回想多久算 retrievability, 低於門檻不刪、降級成 faded(只剩主旨)與 fuzzy(只剩有這件事)。內文因此分兩層 (主旨/細節),衰減先吃細節。被 recall 命中就 strength +8(spacing effect)。 boundary/promise/canon/salience >= 80 永遠清晰,這條沒動。 人格永遠以對方為中心 → state/loops.json 加自我議程。 同時最多 5 條、四種(他沒回答的、他答應的、被打斷的、我想問的),7 天沒進展自動收掉 並留一則「沒下文」的短期記憶。agendaTick 每 3 輪最多讓人格把話題拉回自己的事一次, 對方有明確急事時一律不觸發。 同一句話任何時刻聽起來都一樣 → 疲勞、當日底色、per-persona 破口。 fatigueLevel() 由 hoursAwake 推,壓 arousal 天花板、句數與單句字數、高張情緒的推力; state/mood.json 是緩慢漂移的當日底色,只當反應增益不直接改情緒值,睡覺帶 35% 過去; EMOTION_TELLS 改成預設值,人格可在 IDENTITY.md 的 ## Tells 覆寫。 applyEmotion 另加交互抑制(只有三組互斥)與慣性(連續同向 +8%,封頂 +25%)。 幻覺界線改成「開放試探」(使用者明示):可以說不確定、可以問,不可以斷言。 換來的義務是稽核——每次試探進 state/probe.jsonl,probe audit 看否認率。 放寬界線一定要配一個看得見的數字。 其他:記憶蓋情境戳章(when/where/mood,where 可用 PERSONA_CONTEXT_WHERE=off 關掉); recall 加情境加權但翻不掉語意命中;migrate 就地升格式(冪等、看不懂的檔跳過); bundle v2,import 吃得下 v1 並自動補欄位。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/persona-gitea.mjs | 3 + scripts/persona-lib.mjs | 1016 +++++++++++++++++++++++++++++++++++-- scripts/persona.mjs | 254 +++++++++- 3 files changed, 1228 insertions(+), 45 deletions(-) diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 6cd7fca..96b5b61 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -105,6 +105,9 @@ export const AREAS = { "state/said.jsonl", "state/felt.jsonl", "state/sleep.json", + "state/mood.json", + "state/loops.json", + "state/probe.jsonl", "memory/short-term.jsonl", "memory/inbox/", "mindmap/threads/", diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 4bb5a7b..8be780d 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -265,12 +265,16 @@ export function appendJsonl(file, obj) { * 所以 `transform` 看到的一定是最新的內容,寫回去也不會蓋掉別人剛 append 的行。 * * `transform(rows)` 回傳要留下的列;回傳的陣列跟原本一樣長就不寫(省一次 IO)。 + * + * **那個省 IO 的判斷只對「過濾」成立。** 就地改欄位的呼叫端(`resolveProbe` 把一筆 + * pending 改成 denied)回傳的陣列一樣長,於是一個位元組都沒寫出去——指令回報成功、 + * 硬碟上什麼都沒變。踩過一次,所以留下 `force`:要改欄位就明講。 */ -export function rewriteJsonl(file, transform) { +export function rewriteJsonl(file, transform, { force = false } = {}) { return withFileLock(file, () => { const rows = readJsonl(file); const kept = transform(rows) ?? rows; - if (kept.length !== rows.length) { + if (force || kept.length !== rows.length) { writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : "")); } return { rows, kept }; @@ -479,7 +483,69 @@ export function saturateDelta(level, delta, baseline = null) { return d * Math.pow(Math.max(0, headroom), EMOTION_SATURATION_K); } -export function applyEmotion(state, deltas, trigger = "") { +// --------------------------------------------------------------------------- // +// 交互抑制:剛生完氣沒那麼容易被逗笑 +// +// 只寫三組**明確互斥**的(憤怒↔喜悅、悲傷↔驚喜、厭惡↔信任)。不做全 12×12: +// 那張表沒有人驗得動,而且大部分格子的心理學依據是掰的。 +// +// 規則:X 超出基線夠多(≥ INHIBIT_FLOOR)時,往 Y 的**同向推力**打折。 +// 只壓「更 Y」的方向;要把 Y 拉回基線永遠不打折——否則情緒會卡在原地下不來。 +// --------------------------------------------------------------------------- // + +export const INHIBIT_FLOOR = 18; // 超出基線這麼多才開始抑制 +export const INHIBIT_PAIRS = [["anger", "joy"], ["sadness", "delight"], ["disgust", "trust"]]; +export const INHIBIT_STRENGTH = 0.55; // 抑制滿檔時只剩這麼多推力 + +/** 各情緒此刻受到的抑制係數(1 = 不受抑制)。 */ +export function inhibitionFactors(state) { + const levels = state.levels || {}; + const base = state.baseline || DEFAULT_BASELINE; + const over = (k) => Math.max(0, Number(levels[k] ?? 0) - Number(base[k] ?? 0)); + const out = {}; + for (const [a, b] of INHIBIT_PAIRS) { + for (const [from, to] of [[a, b], [b, a]]) { + const excess = over(from) - INHIBIT_FLOOR; + if (excess <= 0) continue; + // 超出門檻 0 → 不打折;超出 40 以上 → 打到 INHIBIT_STRENGTH + const depth = clamp(excess / 40, 0, 1); + out[to] = Math.min(out[to] ?? 1, 1 - (1 - INHIBIT_STRENGTH) * depth); + } + } + return out; +} + +// 慣性:連續往同一個方向走的時候,同方向的下一筆推得更動。 +// 一路被逗笑的人越來越好笑,一路被踩的人越踩越炸——這是真的,而且輸入已經在手上 +// (`state.streak`)。上限刻意壓在 +25%:這是慣性,不是雪球。 +export const MOMENTUM_STEP = 0.08; +export const MOMENTUM_CAP = 0.25; + +/** 這一批 delta 整體是往正向還是負向走(回 +1 / -1 / 0)。 */ +export function deltaDirection(deltas) { + let net = 0; + for (const [key, raw] of Object.entries(deltas || {})) { + if (!(key in EMOTIONS)) continue; + const d = Number(raw); + if (!Number.isFinite(d)) continue; + net += EMOTIONS[key].polarity * d; + } + return net > 0 ? 1 : net < 0 ? -1 : 0; +} + +/** + * 套用一批情緒 delta。四道調節依序作用(順序有意義,不能換): + * + * ① 單輪預算 一輪之內所有 |delta| 的總和上限 + * ② 外部增益 疲勞壓高張情緒、當日底色放大同極性(由 `opts.slug` 推導) + * ③ 交互抑制 剛生完氣就笑不太出來 + * ④ 慣性 連續同向時同方向再放大一點 + * ⑤ 飽和 越接近端點推得越少(既有) + * + * `opts.slug` 帶了才會有 ②:不帶就只有純粹的情緒學運算(selftest 與匯入資料用)。 + */ +export function applyEmotion(state, deltas, trigger = "", opts = {}) { + const { slug = null } = opts || {}; state = decayEmotion(state); // 先算「想推多少」,超過單輪預算就等比例縮小——一次灌爆的路要堵起來 const wanted = []; @@ -491,19 +557,46 @@ export function applyEmotion(state, deltas, trigger = "") { } const demand = wanted.reduce((sum, [, d]) => sum + Math.abs(d), 0); const budgetScale = demand > EMOTION_TURN_BUDGET ? EMOTION_TURN_BUDGET / demand : 1; + + const tired = slug ? fatigueLevel(slug) : 0; + const tone = slug ? dayMoodGain(slug) : null; + const inhibit = inhibitionFactors(state); + const direction = deltaDirection(deltas); + const streak = state.streak && Number(state.streak.sign) === direction && direction !== 0 + ? Math.max(0, Math.floor(Number(state.streak.n) || 0)) + : 0; + const momentum = 1 + Math.min(MOMENTUM_CAP, MOMENTUM_STEP * streak); + const applied = {}; + const notes = {}; for (const [key, rawDelta] of wanted) { const before = Number(state.levels[key] ?? 0); const base = Number(state.baseline?.[key] ?? DEFAULT_BASELINE[key]); - const effective = saturateDelta(before, rawDelta * budgetScale, base); + const polarity = EMOTIONS[key].polarity; + const toward = rawDelta > 0; // 往「更這個情緒」的方向 + let scale = budgetScale; + // ② 疲勞:只壓高張情緒往上的推力(arousal 權重越高壓越多) + if (tired > 0 && toward) scale *= 1 - 0.4 * tired * EMOTIONS[key].arousal; + // ② 當日底色:跟底色同極性的事情推得更動(底色差的日子壞消息更痛) + if (tone && toward) scale *= polarity === tone.sign ? tone.gain : 1 / tone.gain; + // ③ 抑制:只壓同向,回基線不壓 + if (toward && inhibit[key] !== undefined) scale *= inhibit[key]; + // ④ 慣性:只放大跟整體走向一致的那幾筆 + if (direction !== 0 && polarity * (toward ? 1 : -1) === direction) scale *= momentum; + const effective = saturateDelta(before, rawDelta * scale, base); state.levels[key] = Math.round(clamp(before + effective) * 100) / 100; applied[key] = Math.round((state.levels[key] - before) * 100) / 100; + if (Math.abs(scale - budgetScale) > 0.01) notes[key] = Math.round(scale * 100) / 100; } state.updated_at = nowIso(); state.history_len = Number(state.history_len || 0) + 1; + state.streak = { sign: direction, n: direction === 0 ? 0 : streak + 1, at: nowIso() }; if (Object.keys(applied).length) { state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied }; if (budgetScale < 1) state.last_trigger.budget_scaled = Math.round(budgetScale * 100) / 100; + if (Object.keys(notes).length) state.last_trigger.scaled = notes; + if (tired > 0) state.last_trigger.fatigue = Math.round(tired * 100) / 100; + if (streak > 0) state.last_trigger.momentum = Math.round(momentum * 100) / 100; } return state; } @@ -523,7 +616,32 @@ export function relationGain(slug, who = null) { return { gain: Math.round(gain * 100) / 100, name: node.name || node.id, closeness }; } -export function mood(state) { +// --------------------------------------------------------------------------- // +// 清醒時數 → 疲勞 +// +// `hoursAwake()` 早就在算了,但只用來提醒睡覺。真人熬到第二十小時,話會變短、 +// 反應會變鈍、什麼都激動不起來——那是最便宜的擬真訊號,因為輸入已經在手上。 +// +// 疲勞只壓**上限**,不改方向:睏的人一樣會生氣,只是氣不了那麼大聲。 +// --------------------------------------------------------------------------- // + +export const FATIGUE_START_HOURS = 12; // 這之前完全不算累 +export const FATIGUE_FULL_HOURS = 36; // 這之後就是滿格(再撐也不會更鈍) +export const FATIGUE_AROUSAL_CEILING = 45; // 滿格疲勞時 arousal 的天花板往下壓多少 + +/** 0(精神好)~1(撐很久了)。從沒睡過就當 0——沒有資料不等於很累。 */ +export function fatigueLevel(slug) { + const awake = hoursAwake(slug); + if (awake === null) return 0; + const span = FATIGUE_FULL_HOURS - FATIGUE_START_HOURS; + return clamp((awake - FATIGUE_START_HOURS) / span, 0, 1); +} + +/** + * 心情:十二情緒加權求和。`fatigue` 只壓 arousal 的天花板(0~1,見 `fatigueLevel`)。 + * 疲勞不會讓人變開心或不開心,所以 valence 不動。 + */ +export function mood(state, fatigue = 0) { const levels = state.levels || {}; let valence = 0; let arousal = 0; @@ -533,12 +651,114 @@ export function mood(state) { arousal += EMOTIONS[key].arousal * level; } valence = Math.round(Math.max(-100, Math.min(100, valence / 3)) * 10) / 10; - arousal = Math.round(Math.min(100, arousal / 3) * 10) / 10; + arousal = Math.min(100, arousal / 3); + const tired = clamp(Number(fatigue) || 0, 0, 1); + if (tired > 0) arousal = Math.min(arousal, 100 - FATIGUE_AROUSAL_CEILING * tired); + arousal = Math.round(arousal * 10) / 10; const label = valence >= 30 ? "正向" : valence <= -30 ? "負向" : "中性"; const tempo = arousal >= 55 ? "高張" : arousal >= 25 ? "平穩" : "低張"; return { valence, arousal, label, tempo }; } +/** `mood()` 但自動帶上這個人格的疲勞(要 slug 才算得出來)。 */ +export const moodOf = (slug, state = null) => mood(state ?? loadEmotion(slug), fatigueLevel(slug)); + +// --------------------------------------------------------------------------- // +// 當日心情底色(`state/mood.json`) +// +// 情緒本來只有兩層:此刻的十二情緒,與氣質基線。中間缺的是「今天」—— +// 所以做不出「這句話今天聽了會炸、昨天不會」。底色就是那一層: +// +// 即時情緒(分鐘)→ **當日底色(小時~天)** → 氣質基線(幾乎不動) +// +// 底色不直接改任何情緒值,它只當**反應增益**:底色差的日子,壞消息推得更動。 +// 這樣它是可解釋的(查得到今天累積了什麼),不是給情緒加隨機數。 +// --------------------------------------------------------------------------- // + +export const DAY_MOOD_ALPHA = 0.08; // 每輪把即時心情混進底色多少(漂移要慢) +export const DAY_MOOD_CARRY = 0.35; // 換日/睡覺時帶多少過去(不歸零,昨天的低氣壓還在) +export const DAY_MOOD_FLOOR = 12; // 底色絕對值低於這個就當「今天沒什麼特別的」 +export const DAY_MOOD_MAX_GAIN = 0.4; // 增益上限(1.4 倍封頂) + +export const dayMoodPath = (slug) => path.join(personaDir(slug), "state", "mood.json"); + +export function loadDayMood(slug) { + const data = readJson(dayMoodPath(slug), {}) ?? {}; + const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); + return { + date: typeof data.date === "string" ? data.date : nowIso().slice(0, 10), + valence: clamp(num(data.valence), -100, 100), + arousal: clamp(num(data.arousal), 0, 100), + samples: Math.max(0, Math.floor(num(data.samples))), + updated_at: typeof data.updated_at === "string" ? data.updated_at : null, + }; +} + +/** + * 把此刻的心情混一點進底色(每輪一次,由 `turnContext` 呼叫)。 + * 跨日不是歸零而是帶 35% 過去——昨天的低氣壓不會因為時鐘走過午夜就消失。 + */ +export function updateDayMood(slug, state = null) { + return withFileLock(dayMoodPath(slug), () => { + const day = loadDayMood(slug); + const today = nowIso().slice(0, 10); + if (day.date !== today) { + day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10; + day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10; + day.date = today; + day.samples = 0; + } + const inst = mood(state ?? loadEmotion(slug), fatigueLevel(slug)); + day.valence = Math.round((day.valence * (1 - DAY_MOOD_ALPHA) + inst.valence * DAY_MOOD_ALPHA) * 10) / 10; + day.arousal = Math.round((day.arousal * (1 - DAY_MOOD_ALPHA) + inst.arousal * DAY_MOOD_ALPHA) * 10) / 10; + day.samples += 1; + day.updated_at = nowIso(); + writeJson(dayMoodPath(slug), day); + return day; + }); +} + +/** 睡覺時底色不歸零,只帶一部分過去(跟情緒衰減同一個道理)。 */ +export function sleepDayMood(slug) { + return withFileLock(dayMoodPath(slug), () => { + const day = loadDayMood(slug); + day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10; + day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10; + day.date = nowIso().slice(0, 10); + day.samples = 0; + day.updated_at = nowIso(); + writeJson(dayMoodPath(slug), day); + return day; + }); +} + +/** + * 今天的底色會讓 delta 放大多少。底色平的時候回 `null`(不介入,維持原本的算法)。 + * `sign` 是底色的極性:同極性的推力放大,反極性的縮小。 + */ +export function dayMoodGain(slug) { + const day = loadDayMood(slug); + if (Math.abs(day.valence) < DAY_MOOD_FLOOR) return null; + const depth = clamp((Math.abs(day.valence) - DAY_MOOD_FLOOR) / (100 - DAY_MOOD_FLOOR), 0, 1); + return { + sign: day.valence > 0 ? 1 : -1, + gain: Math.round((1 + DAY_MOOD_MAX_GAIN * depth) * 100) / 100, + valence: day.valence, + }; +} + +/** 注入用的一行;底色平的時候回空字串(不用每輪都講今天很普通)。 */ +export function dayMoodBrief(slug) { + const day = loadDayMood(slug); + const g = dayMoodGain(slug); + if (!g) return ""; + const word = g.sign > 0 ? "今天底子是好的" : "今天底子不太好"; + return ( + `當日心情底色:${word}(valence ${day.valence},累積 ${day.samples} 輪)。` + + `同極性的事今天推得更動(×${g.gain}),反過來的縮小。這是底色不是此刻——不要拿它當台詞講出來。` + ); +} + /** 以「超出 baseline 的幅度」排序,才看得出「此刻被觸動什麼」。 */ export function dominant(state, top = 3) { const levels = state.levels || {}; @@ -573,12 +793,63 @@ export const EMOTION_TELLS = { anxiety: ["句子斷在一半", "疊字(「就、就是」「我、我知道」)", "追一句「這樣可以嗎」"], }; +// 上面那張表是**全人格共用的預設**,而每個人生氣的樣子不一樣:桐人生氣是沉默, +// 亞絲娜生氣是變得更禮貌。同一格情緒,破口完全不同——共用一張表等於所有人格 +// 在高情緒下講起話來都一個樣,那是這個系統最容易被聽出來的地方。 +// +// 覆寫寫在 IDENTITY.md(破口屬於**身分**,不是可變狀態,所以不放 `state/`): +// +// ## Tells +// - anger: 不講話;把事情做完再說;句子只剩動詞 +// - 羞愧: 別過頭;講反話 +// +// 鍵可以用英文 key 或中文名;多條用 `;`、`、`、`;`、`,` 分隔。沒寫的情緒退回預設。 + +const EMOTION_ZH_TO_KEY = Object.fromEntries(EMOTION_KEYS.map((k) => [EMOTIONS[k].zh, k])); + +/** 讀 IDENTITY.md 的 `## Tells` 區塊;沒有就回空物件。 */ +export function personaTells(slug) { + let text; + try { + text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8"); + } catch { + return {}; + } + const m = text.match(/^##+\s*(?:Tells|破口|不自覺會做的事)\s*$/im); + if (!m) return {}; + const rest = text.slice(m.index + m[0].length); + const section = rest.split(/^##\s/m)[0] || ""; + const out = {}; + for (const line of section.split("\n")) { + const row = line.match(/^\s*[-*]\s*([A-Za-z_]+|[一-鿿]{2})\s*[::]\s*(.+)$/); + if (!row) continue; + const key = EMOTION_KEYS.includes(row[1].toLowerCase()) + ? row[1].toLowerCase() + : EMOTION_ZH_TO_KEY[row[1]]; + if (!key) continue; + const tells = stripInjectionMarkers(row[2]) + .split(/[;;、,,]/) + .map((s) => s.trim()) + .filter(Boolean) + .slice(0, 5); + if (tells.length) out[key] = tells; + } + return out; +} + +/** 這個人格在某個情緒下的破口:有自己的就用自己的,沒有才退回全域預設。 */ +export function tellsFor(slug, key) { + const own = slug ? personaTells(slug) : {}; + return own[key] || EMOTION_TELLS[key] || []; +} + /** * 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。 * 強度不夠就不演——低強度的情緒在語氣上是看不出來的。 */ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) { const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); + const own = slug ? personaTells(slug) : {}; // 羞恥敏感度會調「羞愧」這一個破口的門檻:容易害羞的人不用到 40 就藏不住, // 不在意別人眼光的人就算羞愧上來了也不會表現出來。其他十一種不受影響。 const modesty = slug ? modestyOf(slug).value : 50; @@ -590,7 +861,8 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) { key, zh: EMOTIONS[key].zh, level: Math.round(level), - tells: EMOTION_TELLS[key] || [], + tells: own[key] || EMOTION_TELLS[key] || [], + own: Boolean(own[key]), })); } @@ -758,34 +1030,47 @@ export function speechBudget(slug, { state = null, read = null, alone = true, pr const lv = (k) => Number(st.levels[k] ?? 0); const base = { modesty: m.value, trait: m.trait, chars: MAX_SENTENCE_CHARS }; + // 撐太久的人話會變短變鈍。這裡只**收**不放:疲勞不會讓人多講幾句。 + // 「慌」那一格刻意不減句數——慌的形狀就是句子多而碎,累了一樣慌,只是更碎。 + const tired = slug ? fatigueLevel(slug) : 0; + const tire = (b) => { + if (tired <= 0) return { ...b, fatigue: 0 }; + const chars = Math.max(18, Math.round(b.chars * (1 - 0.3 * tired))); + const sentences = b.mode === "spill" ? b.sentences : Math.max(1, Math.round(b.sentences - 1.5 * tired)); + const note = tired >= 0.5 + ? `${b.note} 而且已經撐很久了:話會更短更鈍,不用勉強接話,可以只回一個詞。` + : b.note; + return { ...b, chars, sentences, note, fatigue: Math.round(tired * 100) / 100 }; + }; + // 炸:慌了、火了,或者對方那句話帶著誤會與追問 const pressed = Boolean(read?.signals?.some((s) => s.key === "anger" || s.key === "disgust")); if (m.value >= 60 && (lv("anxiety") >= 55 || lv("anger") >= 45 || pressed)) { - return { + return tire({ ...base, mode: "spill", sentences: 4, chars: 22, thinkMin: 1, note: "現在是慌的/火的:話會變多但**每句更短更碎**——掩飾、急著否認、硬轉話題都可以," + "允許重複和疊字,但不要講出完整流暢的長句,那不是慌。", - }; + }); } // 坦白:憋很久的話,在安全的人面前一次講完 if (m.value >= 60 && alone && lv("trust") >= 75) { - return { + return tire({ ...base, mode: "confess", sentences: 3, thinkMin: 2, note: "只有你們兩個,而且你信他:這種時候憋很久的話會一次講完——句子可以完整," + "講完就空了。**先把心裡話寫完再開口**,那句話正是心裡憋著的那句。", - }; + }); } if (m.value >= 75) { - return { + return tire({ ...base, mode: "shrink", sentences: 2, thinkMin: 2, note: "話會變少,而且說出口的那句常常在**迴避**心裡那句——先把心裡話寫完再開口," + "台詞不可以是心裡話的摘要(心裡想「我一直在等你問」,出口就不能是「我有在等你問」)。", - }; + }); } if (m.value >= 50) { - return { ...base, mode: "shrink", sentences: 3, thinkMin: 1, note: "心裡想到的比講出來的多一點。" }; + return tire({ ...base, mode: "shrink", sentences: 3, thinkMin: 1, note: "心裡想到的比講出來的多一點。" }); } - return { ...base, mode: "open", sentences: MAX_SENTENCES, thinkMin: 0, note: "想到什麼就講,不用先在心裡繞一圈。" }; + return tire({ ...base, mode: "open", sentences: MAX_SENTENCES, thinkMin: 0, note: "想到什麼就講,不用先在心裡繞一圈。" }); } /** 注入用的一行:這一輪能講幾句、每句多長、心裡話該有幾句。 */ @@ -824,13 +1109,16 @@ export function modestyDirective(slug, opts = {}) { export function emotionBrief(slug, state = null) { const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); - const m = mood(st); + const tired = slug ? fatigueLevel(slug) : 0; + const m = mood(st, tired); const top = dominant(st).map(({ key, level }) => `${EMOTIONS[key].zh}(${key}) ${Math.round(level)}`).join(", "); const avg = (keys) => keys.reduce((sum, k) => sum + Number(st.levels[k] ?? 0), 0) / keys.length; + const awake = slug ? hoursAwake(slug) : null; + const tiredNote = tired >= 0.25 && awake !== null ? `|醒著 ${awake} 小時(張力上不去了)` : ""; return ( `情緒:${top}|心情 ${m.label}/${m.tempo}` + `(valence ${m.valence >= 0 ? "+" : ""}${Math.round(m.valence)}, arousal ${Math.round(m.arousal)})` + - `|正向均值 ${Math.round(avg(POSITIVE))} / 負向均值 ${Math.round(avg(NEGATIVE))}` + `|正向均值 ${Math.round(avg(POSITIVE))} / 負向均值 ${Math.round(avg(NEGATIVE))}${tiredNote}` ); } @@ -1349,6 +1637,217 @@ export function hoursAwake(slug) { return Math.round((ageSeconds(at) / 3600) * 10) / 10; } +// --------------------------------------------------------------------------- // +// 未完事項 open loops(`state/loops.json`) +// +// 「被記住」的感覺幾乎不是來自長期記憶檢索,是來自**有人替你懸著一件事**。 +// 檢索是被問了才想起來;懸著是沒人問也還在。差別就在這裡。 +// +// 只留四種,而且同時最多五條——超過五條就不是懸著,是待辦清單,那是助理不是人。 +// question 他沒回答的問題/promise 他答應要做的事/ +// topic 被打斷的話題/mine 我想問但沒問的 +// +// `mine` 那一種同時是**自我議程**的來源:人格自己在意的事,不是為了服務對方而存在的。 +// --------------------------------------------------------------------------- // + +export const LOOP_MAX = 5; // 同時最多懸幾條 +export const LOOP_STALE_DAYS = 7; // 這麼久沒進展就自動收掉,並留一則「沒下文」 +export const LOOP_KINDS = { + question: "他沒回答的問題", + promise: "他答應要做的事", + topic: "被打斷的話題", + mine: "我想問但沒問的", +}; +export const AGENDA_EVERY = 3; // 自我議程最短間隔幾輪(使用者選「明顯一點」,原建議 5–8) + +export const loopsPath = (slug) => path.join(personaDir(slug), "state", "loops.json"); + +/** + * 讀未完事項。**清洗在這裡做,不是只在 `addLoop` 做**——`state/loops.json` 是 + * 同步範圍內的檔(`sync pull` 會覆蓋、`import` 原樣寫入),所以寫入端的長度上限、 + * 筆數上限與換行壓平全都繞得過去。實測灌 51 條、單條兩萬字進去, + * 每一輪 `turnContext` 就照單全收塞進上下文。信任邊界在讀取端。 + */ +export function loadLoops(slug) { + const data = readJson(loopsPath(slug), {}) ?? {}; + const int = (v) => Math.max(0, Math.floor(Number(v) || 0)); + const raw = Array.isArray(data.loops) ? data.loops.filter((l) => l && typeof l === "object") : []; + const loops = []; + let open = 0; + for (const l of raw) { + const status = ["open", "done", "cold", "drop"].includes(l.status) ? l.status : "done"; + // 開著的最多 LOOP_MAX 條:多出來的降級成 cold(不刪,但也不再注入) + const live = status === "open" && open < LOOP_MAX; + if (status === "open") open += 1; + loops.push({ + ...l, + id: injectSafeLine(l.id || `L${loops.length + 1}`, 12) || `L${loops.length + 1}`, + text: injectSafeLine(l.text, 120), + kind: l.kind in LOOP_KINDS ? l.kind : "topic", + about: l.about ? injectSafeLine(l.about, 40) : null, + status: live ? "open" : status === "open" ? "cold" : status, + }); + } + return { + seq: int(data.seq), + turns: int(data.turns), + last_agenda_turn: int(data.last_agenda_turn), + loops: loops.filter((l) => l.text), + }; +} + +export const openLoops = (slug) => loadLoops(slug).loops.filter((l) => l.status === "open"); + +/** + * 開一條未完事項。滿了就回 `{ ok: false, reason }`——**不自動擠掉舊的**: + * 哪一條該收掉是判斷,不是先進先出。 + */ +export function addLoop(slug, { text, kind = "topic", about = null } = {}) { + const body = injectSafeLine(text, 120); + if (!body) return { ok: false, reason: "沒有內容" }; + const type = kind in LOOP_KINDS ? kind : "topic"; + return withFileLock(loopsPath(slug), () => { + const data = loadLoops(slug); + const live = data.loops.filter((l) => l.status === "open"); + if (live.some((l) => l.text === body)) return { ok: false, reason: "同一件事已經懸著了" }; + if (live.length >= LOOP_MAX) { + return { ok: false, reason: `同時最多 ${LOOP_MAX} 條,先 \`loop done\` 收掉一條`, loops: live }; + } + data.seq += 1; + const loop = { + id: `L${data.seq}`, + text: body, + kind: type, + about: about ? injectSafeLine(about, 40) : null, + status: "open", + opened_at: nowIso(), + touched_at: nowIso(), + }; + data.loops.push(loop); + writeJson(loopsPath(slug), data); + return { ok: true, loop, open: live.length + 1 }; + }); +} + +/** 收掉一條(`done` 有下文了/`cold` 沒下文/`drop` 不重要了)。 */ +export function closeLoop(slug, id, status = "done", note = "") { + return withFileLock(loopsPath(slug), () => { + const data = loadLoops(slug); + const loop = data.loops.find((l) => l.id === id && l.status === "open"); + if (!loop) return { ok: false, reason: `沒有開著的 \`${id}\`` }; + loop.status = ["done", "cold", "drop"].includes(status) ? status : "done"; + loop.closed_at = nowIso(); + if (note) loop.note = injectSafeLine(note, 120); + // 收掉的只留最近 20 條,其餘丟掉——歷史該進記憶,不該堆在狀態檔裡 + const closed = data.loops.filter((l) => l.status !== "open"); + if (closed.length > 20) { + const drop = new Set(closed.slice(0, closed.length - 20).map((l) => l.id)); + data.loops = data.loops.filter((l) => !drop.has(l.id)); + } + writeJson(loopsPath(slug), data); + return { ok: true, loop }; + }); +} + +/** 摸一下(有進展但還沒結束),重算逾期。 */ +export function touchLoop(slug, id) { + return withFileLock(loopsPath(slug), () => { + const data = loadLoops(slug); + const loop = data.loops.find((l) => l.id === id && l.status === "open"); + if (!loop) return { ok: false, reason: `沒有開著的 \`${id}\`` }; + loop.touched_at = nowIso(); + writeJson(loopsPath(slug), data); + return { ok: true, loop }; + }); +} + +/** + * 逾期的自動收掉,並各留一則「這件事沒下文」的短期記憶。 + * + * 為什麼要留記憶:懸了一週沒下文**本身就是一件事**。默默刪掉等於假裝沒發生過, + * 那正是機器會做而人不會做的事。 + */ +export function sweepLoops(slug) { + const now = Date.now(); + const cutoff = LOOP_STALE_DAYS * 86_400_000; + const cold = withFileLock(loopsPath(slug), () => { + const data = loadLoops(slug); + const out = []; + for (const loop of data.loops) { + if (loop.status !== "open") continue; + const age = now - (parseIso(loop.touched_at || loop.opened_at)?.getTime() ?? now); + if (age < cutoff) continue; + loop.status = "cold"; + loop.closed_at = nowIso(); + out.push(loop); + } + if (out.length) writeJson(loopsPath(slug), data); + return out; + }); + for (const loop of cold) { + rememberShort(slug, { + role: "persona", + text: `這件事沒下文:${loop.text}(${LOOP_KINDS[loop.kind] || loop.kind},懸了 ${LOOP_STALE_DAYS} 天以上)`, + topics: ["未完事項", "沒下文"], + intent: "loop-cold", + salience: 45, + }); + } + return cold; +} + +/** 注入用:現在懸著哪幾件事。沒有就回空字串。 */ +export function loopsBrief(slug) { + const live = openLoops(slug); + if (!live.length) return ""; + const days = (l) => Math.floor((Date.now() - (parseIso(l.opened_at)?.getTime() ?? Date.now())) / 86_400_000); + const rows = live.map((l) => { + const age = days(l); + return ` - ${l.id}|${LOOP_KINDS[l.kind] || l.kind}:${l.text}${age >= 1 ? `(${age} 天)` : ""}`; + }); + return [ + `還懸著的事(${live.length}/${LOOP_MAX};有下文就 \`persona.mjs loop done --id \`):`, + ...rows, + " 這些是**你自己記著的**,不是待辦清單:時機對了才提,一輪最多提一件," + + "同一件事不要追問兩次(`said check` 會擋)。", + ].join("\n"); +} + +// 自我議程:某些輪把話題拉回自己在意的事,甚至答非所問。 +// +// 這是「像 AI」最頑固的殘留——永遠以對方為中心,每一句都在服務這句話。 +// 使用者選了「明顯一點」,所以間隔壓到 3 輪;但**對方有急事的時候一律不觸發**, +// 那不是有個性,那是白目。 +const URGENT_RE = /(急|馬上|立刻|快點|壞了|掛了|炸了|出事|錯誤|失敗|救|幫我修|來不及|deadline|urgent|asap|error|failed|broken)/i; + +/** 對方這句話有沒有明確的急事。 */ +export const looksUrgent = (prompt) => URGENT_RE.test(String(prompt || "")); + +/** + * 這一輪要不要把話題拉回自己的事。回 `null` 代表不要。 + * 每輪都會遞增輪數計數,所以這個函式一輪只該呼叫一次(`turnContext`)。 + */ +export function agendaTick(slug, prompt = "") { + return withFileLock(loopsPath(slug), () => { + const data = loadLoops(slug); + data.turns += 1; + const turn = data.turns; + let pick = null; + if (!looksUrgent(prompt) && turn - data.last_agenda_turn >= AGENDA_EVERY) { + const mine = data.loops.filter((l) => l.status === "open" && l.kind === "mine"); + const pool = mine.length ? mine : data.loops.filter((l) => l.status === "open"); + if (pool.length) { + // 挑最久沒動的那一條:懸最久的最該講,不用亂數 + pool.sort((a, b) => String(a.touched_at || a.opened_at).localeCompare(String(b.touched_at || b.opened_at))); + pick = pool[0]; + data.last_agenda_turn = turn; + } + } + writeJson(loopsPath(slug), data); + return pick ? { loop: pick, turn } : null; + }); +} + // --------------------------------------------------------------------------- // // session 綁定:誰是 host、邀了哪些 guest、sub agent pin、劇場模式 // --------------------------------------------------------------------------- // @@ -1475,8 +1974,59 @@ export const journalPath = (slug) => { return path.join(personaDir(slug), "journal", `${month}.jsonl`); }; +// --------------------------------------------------------------------------- // +// 情境索引:這件事是「什麼時候、在哪裡、心情怎樣」記下來的 +// +// 在這之前檢索只靠 `topics`/`entities`(語意線索)。人的回想主要不是這樣運作的—— +// 大部分時候是情境把記憶勾出來:同一個時段、同一個地方、同一種心情。 +// +// 這三個欄位順便讓情緒有了**後果**:心情差的時候先想起難過的事。 +// 在這之前情緒只是調語氣的裝飾,改不動任何一件實際發生的事。 +// --------------------------------------------------------------------------- // + +/** 現在是一天的哪一段(回想的時間線索,比時間戳好用)。 */ +export function timeOfDay(at = new Date()) { + const h = at.getHours(); + if (h < 5) return "深夜"; + if (h < 9) return "清晨"; + if (h < 12) return "早上"; + if (h < 14) return "中午"; + if (h < 18) return "下午"; + if (h < 22) return "晚上"; + return "深夜"; +} + +/** 寫入記憶時要蓋上的情境戳章:時段/地點/當時心情。 */ +export function contextStamp(slug, state = null) { + const stamp = { when: timeOfDay() }; + // `where` 記的是工作目錄名,而目錄名常常就是客戶名或內部代號——而短期記憶與 + // 長期記憶都會同步到 Gitea。回想線索值得留,但要留一個關得掉的開關: + // `PERSONA_CONTEXT_WHERE=off` 就只記時段與心情。 + if (String(process.env.PERSONA_CONTEXT_WHERE || "").toLowerCase() !== "off") { + try { + const cwd = process.cwd(); + if (cwd && cwd !== "/") stamp.where = path.basename(cwd).slice(0, 40); + } catch { + /* 取不到就不寫這個欄位 */ + } + } + try { + const m = mood(state ?? loadEmotion(slug), fatigueLevel(slug)); + stamp.mood = m.label; + stamp.valence = Math.round(m.valence); + } catch { + /* 情緒檔壞掉不該讓記憶寫不進去 */ + } + return stamp; +} + export function rememberShort(slug, entry) { entry.ts ??= nowIso(); + // 情境戳章:呼叫端沒指定就自動補(`when`/`where`/`mood`/`valence`) + const stamp = contextStamp(slug); + for (const [k, v] of Object.entries(stamp)) { + if (entry[k] === undefined && v !== undefined) entry[k] = v; + } // `entities` 是自由字串(原樣保留);順手把對得上的關係節點 id 記在 `entity_ids`, // 之後「提到誰」才接得回關係圖。一個都對不上就不寫這個 key(全專案一致:沒命中就不留空欄位)。 if (entry.entity_ids === undefined) { @@ -1609,11 +2159,196 @@ export function longTermEntries(slug) { meta._path = file; meta._name = meta.name || path.basename(name, ".md"); meta._body = body.trim(); + const { gist, detail } = splitGistDetail(meta._body); + meta._gist = gist; + meta._detail = detail; out.push(meta); } return out; } +// --------------------------------------------------------------------------- // +// 回想強度:門檻式遺忘 → 連續衰減 + 模糊態 +// +// 在這之前記憶只有兩態:**精準**(recall 命中就整段取出、內容永不變質)與 +// **沒有**(查不到就禁止提)。真人大部分時間活在中間帶: +// +// 「我記得好像⋯是你說的嗎」——主旨還在,細節掉了。 +// +// 所以低於門檻的記憶**不刪**,降級成模糊態;而且衰減先吃 detail、gist 最後才掉, +// 「記得我們吵過,但忘了為什麼」就變成自然結果,不必人格自己演。 +// +// 材料本來就有(`salience`/`recall_count`/`last_seen` 都在寫),缺的只是這條公式。 +// --------------------------------------------------------------------------- // + +export const MEMORY_FADED_AT = 0.6; // 低於這個 → 只剩主旨 +export const MEMORY_FUZZY_AT = 0.3; // 低於這個 → 只剩「有這件事」 +export const MEMORY_STRENGTH_DEFAULT = 50; +export const MEMORY_STRENGTH_GAIN = 8; // 每被想起一次,強度加多少(spacing effect) +/** 這些型別永遠清晰:承諾、界線、原作設定、以及顯著度夠高的。 */ +export const MEMORY_NEVER_FADES = new Set(["boundary", "promise", "canon"]); +export const MEMORY_NEVER_FADES_SALIENCE = 80; + +/** + * 內文切「主旨」與「細節」。約定是 `主旨:` 與 `細節:` 兩個標籤; + * 沒有標籤的舊檔就用**第一段當主旨、其餘當細節**(migration 前也要讀得動)。 + */ +export function splitGistDetail(body) { + const text = String(body || "").trim(); + if (!text) return { gist: "", detail: "" }; + const m = text.match(/^\s*(?:主旨|gist)\s*[::]\s*([\s\S]*?)(?:\n\s*(?:細節|detail)\s*[::]\s*([\s\S]*))?$/i); + if (m) return { gist: (m[1] || "").trim(), detail: (m[2] || "").trim() }; + const parts = text.split(/\n\s*\n/); + return { gist: parts[0].trim(), detail: parts.slice(1).join("\n\n").trim() }; +} + +/** 把主旨與細節組回檔案內文(`consolidate` 與 migration 共用同一個寫法)。 */ +export function joinGistDetail(gist, detail) { + const g = String(gist || "").trim(); + const d = String(detail || "").trim(); + if (!d) return `主旨:${g}`; + return `主旨:${g}\n\n細節:\n${d}`; +} + +/** + * 這則記憶現在想得起來多少(0~1)與它的狀態。 + * + * 穩定度 = 強度 × 被想起過幾次 × 顯著度;越常想起的衰減越慢(spacing effect)。 + * 保護清單(承諾/界線/canon/顯著度 ≥ 80)一律回 1——那些不准糊掉。 + */ +export function memoryStrength(meta, now = Date.now()) { + // frontmatter 是外部輸入(手改、`sync pull`、`import` 都進得來):`salience: high` + // 這種值一路傳下去會變成 `retrievability: NaN`,注入的字面上就印「模糊(NaN%)」, + // 而且 `NaN >= 門檻` 恆為 false → 那則記憶再也不會被 touchRecall 更新,永久卡住。 + // 所以每個數字都要有退路,不能只靠 `Number()`。 + const numOr = (value, fallback) => { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + }; + const salience = clamp(numOr(meta?.salience, 50)); + const type = String(meta?.type || "fact"); + if (MEMORY_NEVER_FADES.has(type) || salience >= MEMORY_NEVER_FADES_SALIENCE) { + return { retrievability: 1, state: "clear", protected: true, days: 0 }; + } + const strength = clamp(numOr(meta?.strength, MEMORY_STRENGTH_DEFAULT)); + const count = Math.max(0, Math.floor(numOr(meta?.recall_count, 0))); + const seen = meta?.last_seen || meta?.first_seen || null; + // 日期讀不出來時 `ageSeconds` 回 Infinity → 那則記憶會瞬間全糊掉。 + // 讀不出來只代表**不知道多舊**,不代表很舊,所以當成 0 天(維持現狀)。 + const days = seen ? Math.max(0, numOr(ageSeconds(seen) / 86_400, 0)) : 0; + // 穩定度的單位是「天」:這個值就是記憶掉到 37% 需要多久 + const stability = Math.max(1, (strength / 10) * (1 + 0.5 * count) * (0.5 + salience / 100) * 3); + const r = Math.exp(-days / stability); + const state = r >= MEMORY_FADED_AT ? "clear" : r >= MEMORY_FUZZY_AT ? "faded" : "fuzzy"; + return { + retrievability: Math.round(r * 1000) / 1000, + state, + protected: false, + days: Math.round(days * 10) / 10, + stability: Math.round(stability * 10) / 10, + }; +} + +/** + * 依回想強度決定「這則記憶現在能講出多少」。 + * clear 全部 / faded 只剩主旨 / fuzzy 只剩「有這件事」 + */ +export function memoryRecalled(meta, now = Date.now()) { + const s = memoryStrength(meta, now); + if (s.state === "clear") { + return { ...s, gist: meta._gist || meta._body || "", detail: meta._detail || "", hint: "" }; + } + if (s.state === "faded") { + return { + ...s, + gist: meta._gist || (meta._body || "").split("\n")[0] || "", + detail: "", + hint: "細節想不起來了——主旨可以講,細節不可以補。", + }; + } + const topics = Array.isArray(meta.topics) ? meta.topics : meta.topics ? [String(meta.topics)] : []; + return { + ...s, + gist: "", + detail: "", + topics, + hint: "只剩「有這件事」——內容想不起來。要提就用試探句求證,不可以斷言。", + }; +} + +/** + * 就地把長期記憶檔升到新格式(補 `strength`、把內文切成主旨/細節)。 + * + * 使用者拍板「一次改全部」,所以這支是冪等的:已經有 `strength` 的不動、 + * 已經寫成 `主旨:` 的不重切。回報「動了幾個檔」,**不回報內容**。 + * + * 舊檔沒有強度歷史,只好從既有欄位推:顯著度高的、被想起過很多次的,起點就高一點。 + * 這比一律給 50 誠實——那會讓一則被想起十次的核心記憶跟昨天剛寫的閒事一樣脆弱。 + */ +export function migrateLongTerm(slug, { dryRun = false } = {}) { + const stats = { total: 0, strength: 0, split: 0, skipped: 0 }; + for (const meta of longTermEntries(slug)) { + stats.total += 1; + let text; + try { + text = fs.readFileSync(meta._path, "utf8"); + } catch { + stats.skipped += 1; + continue; + } + // 沒有 frontmatter 的檔一律跳過。以前是硬插一行進去,等於憑空幫它生出 + // 一個 front matter 區塊,而下面切內文那一段又會把整個檔當成 front matter + // 再複製一次內文——踩過一次,資料是永久損毀的。看不懂的檔就別動它。 + if (!/^---\r?\n/.test(text)) { + stats.skipped += 1; + continue; + } + let changed = false; + if (!/^strength:/m.test(text)) { + const salience = clamp(Number(meta.salience ?? 50)); + const count = Math.max(0, Math.floor(Number(meta.recall_count ?? 0))); + const derived = Math.round(clamp(40 + salience * 0.3 + count * 5)); + // 每一種插法都要確認**真的插進去了**才記帳,否則 migrate 會回報 + // 「補了 N 個」但硬碟上一個都沒補(回報比沒回報更糟)。 + const before = text; + if (/^salience:(.*?)(\r?)$/m.test(text)) { + // 換行照原檔(CRLF 檔插一行 LF 進去雖然還讀得動,但會留下混行的檔案) + text = text.replace(/^salience:(.*?)(\r?)$/m, (m0, rest, cr) => + `salience:${rest}${cr}\n${`strength: ${derived}`}${cr}`); + } else { + text = text.replace(/^---(\r?\n)/, `---$1strength: ${derived}$1`); + } + if (text !== before) { + stats.strength += 1; + changed = true; + } + } + // 這裡的錨點要跟 `splitGistDetail` 一致(字串開頭,不帶 `m`): + // 帶 `m` 的話「前言\n\n主旨:A」會被誤判成已經切過,那個檔的主旨永遠是錯的。 + if (!/^\s*(?:主旨|gist)\s*[::]/.test(meta._body)) { + const { gist, detail } = splitGistDetail(meta._body); + // front matter 用 `parseFrontMatter` 同一套規則切(`---` 而不是 `\n---\n`), + // 才不會被 CRLF 或內文裡的 markdown 分隔線騙過去。 + const eol = text.includes("\r\n") ? "\r\n" : "\n"; + const parts = text.split("---"); + if (gist && parts.length >= 3) { + const front = `---${parts[1]}---`; + text = `${front}${eol}${eol}${joinGistDetail(gist, detail).replace(/\n/g, eol)}${eol}`; + stats.split += 1; + changed = true; + } + } + if (changed && !dryRun) { + try { + fs.writeFileSync(meta._path, text, "utf8"); + } catch { + stats.skipped += 1; + } + } + } + return stats; +} + export function rebuildIndex(slug) { const entries = longTermEntries(slug); const lines = [ @@ -1671,8 +2406,36 @@ export function keywords(text, limit = 12) { return out; } -/** 以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。 */ -export function recall(slug, query, limit = 5) { +// 情境與情緒一致性的加權。語意線索(關鍵詞)仍然是主力,這幾條只是**排序的偏好**: +// 一個關鍵詞命中值 10,情境最多只加 6,所以它永遠翻不掉真正對題的那一則。 +export const RECALL_MOOD_BONUS = 3; // 跟現在同一種心情下記的 +export const RECALL_WHEN_BONUS = 1.5; // 同一個時段 +export const RECALL_WHERE_BONUS = 1.5; // 同一個地方 +// 想不太起來的往後排,但**只能扣分,不能打折**。 +// 打折(`score *= 0.5 + 0.5*r`)會把總分砍掉一半以上,等於扣十幾分—— +// 實測會讓「命中兩個關鍵詞但三個月沒想起」的正確答案掉到榜尾, +// 被一堆命中一個關鍵詞的今日閒事擠掉。加權永遠不准翻掉語意命中, +// 所以情境(最多 +6)與模糊(最多 −3.5)加起來要小於一個關鍵詞的 10 分。 +export const RECALL_FADE_PENALTY = 3.5; + +/** + * 以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。 + * + * `opts.state` 帶了(或 slug 讀得到情緒)就會加上情境與情緒一致性加權: + * 心情差的時候先想起難過的事,同一個時段、同一個地方記的事互相勾得出來。 + * 每則都會附上 `_recall`(回想強度與模糊態),呼叫端決定要講多少。 + */ +export function recall(slug, query, limit = 5, opts = {}) { + const { state = null, context = true } = opts || {}; + const nowMs = Date.now(); + let here = null; + if (context) { + try { + here = contextStamp(slug, state); + } catch { + here = null; + } + } const tokens = keywords(query, 16); const keySet = new Set(tokens.map((k) => k.toLowerCase())); // 同一個人有很多寫法:關鍵詞若指到某個關係節點,就把節點的 **name** 也當關鍵詞。 @@ -1700,7 +2463,19 @@ export function recall(slug, query, limit = 5) { meta._body || "", ].join(" ").toLowerCase(); const hits = keys.filter((k) => haystack.includes(k)).length; - if (hits) scored.push({ score: hits * 10 + Number(meta.salience || 0) / 10, meta }); + if (!hits) continue; + let score = hits * 10 + Number(meta.salience || 0) / 10; + // 情境線索:同心情/同時段/同地點的記憶更容易被勾出來 + if (here) { + if (meta.mood && here.mood && meta.mood === here.mood) score += RECALL_MOOD_BONUS; + if (meta.when && here.when && meta.when === here.when) score += RECALL_WHEN_BONUS; + if (meta.where && here.where && meta.where === here.where) score += RECALL_WHERE_BONUS; + } + // 想不太起來的排後面,但**不排除**——模糊態也是一種記得 + const strength = memoryStrength(meta, nowMs); + meta._recall = strength; + score -= RECALL_FADE_PENALTY * (1 - strength.retrievability); + scored.push({ score, meta }); } scored.sort((a, b) => b.score - a.score); return scored.slice(0, limit).map((s) => s.meta); @@ -1837,7 +2612,12 @@ export function promotionCandidates(slug) { return { total, pressure, candidates: [...merged.values()] }; } -/** 被回想到就更新 last_seen / recall_count(記憶越常用越不易被淘汰)。 */ +/** + * 被回想到就更新 last_seen / recall_count / strength(記憶越常用越不易被淘汰)。 + * + * 強度往上加就是 spacing effect:想起來一次,下次能撐更久。這條讓 + * 「常被提起的事永遠清晰、被冷落的事慢慢糊掉」自己長出來,不用另外排程。 + */ export function touchRecall(slug, names) { const wanted = new Set(names); const today = nowIso().slice(0, 10); @@ -1850,8 +2630,19 @@ export function touchRecall(slug, names) { continue; } const count = Math.floor(Number(meta.recall_count || 0)) + 1; + const strength = clamp(Number(meta.strength ?? MEMORY_STRENGTH_DEFAULT) + MEMORY_STRENGTH_GAIN); text = text.replace(/^recall_count:.*$/m, `recall_count: ${count}`); text = text.replace(/^last_seen:.*$/m, `last_seen: ${today}`); + // 舊檔沒有 strength 這一行:補在 salience 後面(migration 之前也要能長強度)。 + // 連 salience 都沒有的檔要退回「插在開頭 `---` 的下一行」,否則 replace 靜默不命中, + // 那一類檔案的強度永遠長不出來(spacing effect 對它失效,而且沒有人會知道)。 + if (/^strength:.*$/m.test(text)) { + text = text.replace(/^strength:.*$/m, `strength: ${Math.round(strength)}`); + } else if (/^salience:(.*)$/m.test(text)) { + text = text.replace(/^salience:(.*)$/m, `salience:$1\nstrength: ${Math.round(strength)}`); + } else if (/^---(\r?\n)/.test(text)) { + text = text.replace(/^---(\r?\n)/, `---$1strength: ${Math.round(strength)}$1`); + } try { fs.writeFileSync(meta._path, text, "utf8"); } catch { @@ -2186,6 +2977,93 @@ export function recordSaid(slug, text, { room = null, kind = "reply" } = {}) { export const recentSaid = (slug, limit = 5) => readJsonl(saidPath(slug), limit); +// --------------------------------------------------------------------------- // +// 試探與稽核(`state/probe.jsonl`) +// +// 模糊態允許「是不是上個月那次?」這種試探性求證——使用者明示要開放這條。 +// 但它同時是幻覺的側門,所以側門必須裝計數器:**每一次試探都留下紀錄, +// 被否認也要留**。查得到「我試探了幾次、錯了幾次」,這條路才敢開。 +// +// 界線只有一條,寫在 anti-ai-voice.md 與 SKILL 第 ② 節: +// 可以說不確定,可以問;**不可以斷言**。試探句一定帶問號,一輪最多一次。 +// --------------------------------------------------------------------------- // + +export const probePath = (slug) => path.join(personaDir(slug), "state", "probe.jsonl"); +export const PROBE_KEEP = 300; + +// 試探的句型:帶不確定的措辭 + 問號。斷言句沒有問號,所以擋得住「上個月那次很慘」。 +const PROBE_RE = /(是不是|對吧|對嗎|我記得.*嗎|好像.*吧|還是.*那次|是你說的嗎|沒記錯的話)/; + +/** 這句話是不是「試探性求證」(可以說不確定,但一定要在問)。 */ +export function looksLikeProbe(text) { + const s = String(text || ""); + return PROBE_RE.test(s) && /[??]/.test(s); +} + +/** 這句話像是在補細節卻沒有在問——模糊態下這是要擋的形狀。 */ +export function looksLikeFabrication(text) { + const s = String(text || ""); + return /(我記得|上次|那天|上個月|上禮拜|你說過|你答應過)/.test(s) && !/[??]/.test(s); +} + +/** 記一次試探。`memory` 是被試探的那則長期記憶名稱(沒有就 null)。 */ +export function recordProbe(slug, { text, memory = null, retrievability = null } = {}) { + const body = injectSafeLine(text, 200); + if (!body) return null; + // `ts` 不能當識別:同一秒記兩筆試探就會撞在一起,結案時指到錯的那一筆。 + // 這裡用時間戳加一段亂碼當 id,稽核與 `--at` 都認這個。 + const entry = { + id: `${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}`, + ts: nowIso(), + text: body, + memory: memory ? injectSafeLine(memory, 80) : null, + retrievability: retrievability === null ? null : Math.round(Number(retrievability) * 1000) / 1000, + outcome: "pending", + }; + appendJsonl(probePath(slug), entry); + trimJsonl(probePath(slug), PROBE_KEEP); + return entry; +} + +/** + * 結案:對方確認了(`confirmed`)還是否認了(`denied`)。 + * 沒指定 `at` 就結最近一筆還沒結案的。 + */ +export function resolveProbe(slug, outcome = "confirmed", { at = null, note = "" } = {}) { + const want = ["confirmed", "denied", "unknown"].includes(outcome) ? outcome : "confirmed"; + let hit = null; + // `force: true`:這是就地改欄位,陣列長度不變——不強制寫回的話這一筆結案會靜靜消失 + rewriteJsonl(probePath(slug), (all) => { + const idx = at + ? all.findIndex((r) => (r.id || r.ts) === at && r.outcome === "pending") + : all.map((r, i) => [r, i]).filter(([r]) => r.outcome === "pending").map(([, i]) => i).pop(); + if (idx === undefined || idx === null || idx < 0) return all; + all[idx].outcome = want; + all[idx].resolved_at = nowIso(); + if (note) all[idx].note = injectSafeLine(note, 120); + hit = all[idx]; + return all; + }, { force: true }); + return hit; +} + +/** + * 稽核:試探了幾次、被否認幾次、還有幾次沒結案。 + * 比照 `emotion --audit`——這個數字是這條開放界線唯一的煞車。 + */ +export function probeAudit(slug, limit = 50) { + const rows = readJsonl(probePath(slug), limit); + const by = { pending: 0, confirmed: 0, denied: 0, unknown: 0 }; + for (const row of rows) by[row.outcome] = (by[row.outcome] || 0) + 1; + const closed = by.confirmed + by.denied; + return { + total: rows.length, + ...by, + denied_ratio: closed ? Math.round((by.denied / closed) * 100) : null, + recent: rows.slice(-5), + }; +} + export function saidRepeat(slug, text, opts = {}) { return findRepeat(readJsonl(saidPath(slug), SAID_KEEP), text, opts); } @@ -2940,7 +3818,9 @@ export function roomScript(room, { limit = 30, includeMeta = false } = {}) { // journal/(逐字稿很大且屬隱私,要帶請加 --with-journal)。 export const BUNDLE_FORMAT = "jsc-persona/bundle"; -export const BUNDLE_VERSION = 1; +// 2:長期記憶多了 `strength` 與「主旨/細節」兩層,狀態多了 mood.json/loops.json/probe.jsonl。 +// 舊的 v1 bundle 照樣吃得下——`import` 收完會就地跑一次 migration 補上新欄位。 +export const BUNDLE_VERSION = 2; export const BUNDLE_SKIP = new Set(["state/lock.json", "state/guests.json"]); const MAX_BUNDLE_FILE = 5 * 1024 * 1024; @@ -3065,14 +3945,23 @@ export function importBundle(bundle, targetSlug, { session = null } = {}) { config.imported_at = nowIso(); config.imported_from = { persona: bundle.persona, exported_at: bundle.exported_at || null }; if (session) config.imported_by_session = session; + config.bundle_version_in = Number(bundle.version) || 1; writeJson(configPath(slug), config); + // 吃進來的 bundle 可能是舊格式(v1 沒有 strength、內文也沒切主旨/細節): + // 就地補齊,之後這個人格在本機就只有一種格式,不必到處判版本。 + let migrated = null; + try { + migrated = migrateLongTerm(slug); + } catch { + /* 遷移失敗不該讓匯入整個失敗——INDEX 還是要重建 */ + } rebuildIndex(slug); try { renderRelations(slug); } catch { /* 沒有關係圖就算了 */ } - return { persona: slug, written, rejected }; + return { persona: slug, written, rejected, migrated }; } // --------------------------------------------------------------------------- // @@ -3575,6 +4464,12 @@ export function opsBrief(slug) { /** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */ export function turnContext(slug, sessionId, prompt = "") { const state = updateEmotion(slug, (s) => decayEmotion(s)); + // 當日底色:每輪混一點此刻的心情進去(漂移很慢,一輪看不出來) + try { + updateDayMood(slug, state); + } catch { + /* 底色壞掉不該讓整輪掛掉 */ + } const session = loadSession(sessionId); const lines = [ "", @@ -3591,12 +4486,17 @@ export function turnContext(slug, sessionId, prompt = "") { "「說到底」「本質上」(假裝在講穿本質,其實只是重講一次)、開場的「老實說」、" + "收尾的「總的來說」、該表態時的「各有優缺點」、沒出處的「研究顯示」、" + "用旁白演情緒的「我愣了一下」。這些不要說。", - "**講自己的過去,要真的有那件事**。「我以前⋯」「我原本以為⋯」先 `recall` 查得到才講;" + - "查不到就別講——編一段轉折比講一句空話糟糕得多。情緒也一樣," + - "照現在的情緒值走,不要為了顯得有人味硬加。", + "**講自己的過去,要真的有那件事**。「我以前⋯」「我原本以為⋯」先 `recall` 查得到才講。" + + "查得到但注明「模糊」的,可以用**試探句**求證(「是不是上個月那次?」)——" + + "可以說不確定、可以問,**不可以斷言**:試探句一定帶問號,一輪最多一次," + + "而且要 `persona.mjs probe add` 記一筆。完全查不到就別講,編一段轉折比空話糟糕得多。" + + "情緒也一樣,照現在的情緒值走,不要為了顯得有人味硬加。", `(機械會擋的:一句 ${MAX_SENTENCE_CHARS} 字、上面那些詞、中國用語、半形標點、` + "粗體與清單符號、兩小時內講過的話——`room post` 與 `said check` 直接拒收。)", ]; + // 當日底色(今天累積下來的 valence):平的時候不講,不用每輪都說今天很普通 + const dayLine = dayMoodBrief(slug); + if (dayLine) lines.splice(lines.indexOf("@@SPEAK@@"), 0, dayLine); // 情緒先行:先讀對方那句話帶了什麼,再談我自己的狀態。 const read = readUserEmotion(prompt); if (read.signals.length) lines.push(userEmotionBrief(read)); @@ -3655,14 +4555,48 @@ export function turnContext(slug, sessionId, prompt = "") { lines.push(` - [${who}] ${text}${row.salience ? `(顯著度 ${row.salience})` : ""}`); } } - const hits = prompt ? recall(slug, prompt, 4) : []; - if (hits.length) { - lines.push("相關長期記憶:"); - for (const meta of hits) { - const first = (meta._body || "").split("\n")[0] || ""; - lines.push(` - ${meta._name}|${meta.type || "fact"}|${first.slice(0, 100)}`); + // 未完事項:先掃掉逾期的(各留一則「沒下文」),剩下的注入。 + // 這一段排在長期記憶前面是刻意的——懸著的事比檢索到的事更該先想起來。 + try { + const cold = sweepLoops(slug); + if (cold.length) { + lines.push( + `⚠ ${cold.length} 件事懸超過 ${LOOP_STALE_DAYS} 天沒下文,剛剛自動收掉了` + + `(${cold.map((l) => l.text.slice(0, 24)).join("、")}):已各留一則短期記憶。`, + ); } - touchRecall(slug, hits.map((m) => m._name)); + const loops = loopsBrief(slug); + if (loops) lines.push(loops); + } catch { + /* 未完事項壞掉不該讓整輪掛掉 */ + } + const hits = prompt ? recall(slug, prompt, 4, { state }) : []; + if (hits.length) { + lines.push("相關長期記憶(清晰/模糊是算出來的,不是你決定的):"); + let anyFuzzy = false; + for (const meta of hits) { + const r = memoryRecalled(meta); + const head = ` - ${meta._name}|${meta.type || "fact"}`; + if (r.state === "clear") { + lines.push(`${head}|${(meta._gist || meta._body || "").split("\n")[0].slice(0, 100)}`); + } else if (r.state === "faded") { + anyFuzzy = true; + lines.push(`${head}|⚠ 半模糊(想得起來 ${Math.round(r.retrievability * 100)}%)|` + + `主旨:${r.gist.split("\n")[0].slice(0, 90)}|${r.hint}`); + } else { + anyFuzzy = true; + const topics = (r.topics || []).join("/") || "想不起來是什麼"; + lines.push(`${head}|⚠ 模糊(${Math.round(r.retrievability * 100)}%)|只剩線索:${topics}|${r.hint}`); + } + } + if (anyFuzzy) { + lines.push( + " 模糊的那幾則:**可以說不確定,可以問,不可以斷言**。要提就用試探句求證(帶問號)," + + "講完記一筆 `persona.mjs probe add --text \"<你問的那句>\"`;" + + "對方否認就 `probe deny` 並當場寫一則更正記憶,不要放著。", + ); + } + touchRecall(slug, hits.filter((m) => (m._recall?.retrievability ?? 1) >= MEMORY_FUZZY_AT).map((m) => m._name)); } // 兩段要講同一批人:剛想起來的那幾則記憶提到誰,就把那些節點一起帶進「人際關係」。 // `about_ids` 與 `about` 兩邊**都要試**(union):節點改 id 是支援的操作 @@ -3705,6 +4639,22 @@ export function turnContext(slug, sessionId, prompt = "") { lines.push(`距離上次睡眠已經 ${awake} 小時(短期記憶會越積越多)→ 可以提議 /jsc-persona:persona-sleep。`); } + // 自我議程:這一輪允許不服務他那句話,把話題拉回自己在意的事。 + // 這是「像 AI」最頑固的殘留——永遠以對方為中心。他有急事時一律不觸發。 + try { + const agenda = agendaTick(slug, prompt); + if (agenda) { + lines.push( + `🫱 這一輪可以**先講你自己的事**:${agenda.loop.text}` + + `(${LOOP_KINDS[agenda.loop.kind] || agenda.loop.kind})。`, + " 允許答非所問:先回半句,再把話題拉過去;或者直接問。不用先服務他那句話——" + + "但只拉一次,拉完就讓他接。他要是不接就收掉(`loop done`),不要追第二次。", + ); + } + } catch { + /* 議程壞掉不該讓整輪掛掉 */ + } + if (session.theater && (session.rooms || []).length) { const rooms = session.rooms; lines.push( diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 8b2ca33..b0f7128 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -254,6 +254,27 @@ function renderTemplate(name, mapping) { return text; } +/** + * `--tells "anger=不講話;句子只剩動詞|羞愧=摸後頸"` → IDENTITY.md 的 `## Tells` 條目。 + * + * 情緒之間用 `|` 分,同一種情緒的多條破口用 `;` 或 `;` 分。 + * 沒帶就回空字串:空的區塊等於「這個人格沿用全域預設」,樣板裡的註解會教下一個人怎麼補。 + */ +function renderTells(raw) { + const src = String(raw || "").trim(); + if (!src) return ""; + const lines = []; + for (const group of src.split("|")) { + const idx = group.search(/[=::]/); + if (idx < 0) continue; + const key = group.slice(0, idx).trim(); + const tells = group.slice(idx + 1).split(/[;;、,,]/).map((s) => s.trim()).filter(Boolean).slice(0, 5); + if (!key || !tells.length) continue; + lines.push(`- ${pl.injectSafeLine(key, 12)}: ${tells.map((t) => pl.injectSafeLine(t, 40)).join(";")}`); + } + return lines.join("\n"); +} + // --------------------------------------------------------------------------- // // 子指令 // --------------------------------------------------------------------------- // @@ -296,6 +317,9 @@ commands.create = async ({ flags }) => { VIBE: str(flags.vibe), EMOJI: str(flags.emoji), AVATAR: str(flags.avatar), + // 情緒破口:`--tells "anger=不講話;把事情做完再說|羞愧=摸後頸"`。 + // 沒帶就留空——空的區塊會退回全域預設,而樣板裡的註解會告訴下一個人怎麼補。 + TELLS: renderTells(str(flags.tells)), CREATED: pl.nowIso(), }; for (const filename of ["IDENTITY.md", "SOUL.md", "AGENTS.md", "USER.md"]) { @@ -542,7 +566,11 @@ commands.sleep = async ({ flags }) => { await run("archive-threads", () => pl.archiveStaleThreads(slug)); await run("emotion-decay", () => { pl.updateEmotion(slug, (s) => pl.decayEmotionBy(s, pl.SLEEP_DECAY_MINUTES)); + // 當日底色不歸零,只帶一部分過去——睡一覺不會把昨天的低氣壓抹掉 + pl.sleepDayMood(slug); }); + // 懸太久沒下文的事在這裡收(睡覺是它自然的收尾點),各留一則短期記憶 + await run("sweep-loops", () => ({ cold: pl.sweepLoops(slug).length })); await run("reindex", () => pl.rebuildIndex(slug)); await run("trim-said", () => pl.trimSaid(slug)); await run("archive-journal", () => pl.archiveJournals(slug)); @@ -743,7 +771,7 @@ commands.remember = ({ flags }) => { pl.rememberShort(slug, entry); const kept = pl.pruneShortTerm(slug); if (Object.keys(entry.emotion_deltas).length) { - const state = pl.updateEmotion(slug, (s) => pl.applyEmotion(s, entry.emotion_deltas, text.slice(0, 80))); + const state = pl.updateEmotion(slug, (s) => pl.applyEmotion(s, entry.emotion_deltas, text.slice(0, 80), { slug })); 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), @@ -768,8 +796,18 @@ commands.recall = ({ flags }) => { const hits = pl.recall(slug, query, limit); const lines = [`「${query}」的長期記憶命中 ${hits.length} 則:`]; for (const meta of hits) { - const first = (meta._body || "").split("\n")[0] || ""; - lines.push(`- ${meta._name}|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}|${first.slice(0, 120)}`); + const r = pl.memoryRecalled(meta); + const head = `- ${meta._name}|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}`; + if (r.state === "clear") { + const first = (meta._gist || meta._body || "").split("\n")[0] || ""; + lines.push(`${head}|${first.slice(0, 120)}`); + } else if (r.state === "faded") { + lines.push(`${head}|⚠ 半模糊 ${Math.round(r.retrievability * 100)}%|主旨:${r.gist.split("\n")[0].slice(0, 100)}`); + lines.push(` ${r.hint}`); + } else { + lines.push(`${head}|⚠ 模糊 ${Math.round(r.retrievability * 100)}%|只剩線索:${(r.topics || []).join("/") || "-"}`); + lines.push(` ${r.hint}`); + } } const recents = pl.recentShort(slug, limit); if (recents.length) { @@ -782,7 +820,8 @@ commands.recall = ({ flags }) => { lines.push("心裡話(只有你自己看得到,不要講給他聽):"); for (const row of inner) lines.push(`- 💭 [${row.kind || "infer"}] ${String(row.text || "").slice(0, 110)}`); } - pl.touchRecall(slug, hits.map((m) => m._name)); + // 模糊到只剩線索的那幾則**不算被想起來**:真的想不起來就不該重設它的衰減 + pl.touchRecall(slug, hits.filter((m) => (m._recall?.retrievability ?? 1) >= pl.MEMORY_FUZZY_AT).map((m) => m._name)); emit({ persona: slug, long_term: hits, short_term: recents, inner }, flags.json, lines); }; @@ -850,12 +889,23 @@ commands.said = ({ flags, positional }) => { for (const issue of blockers) lines.push(`⚠ ${pl.speechLintMessage(issue)}。`); // hint 不算不通過(例如講到自己的過去),但要提醒去驗證 for (const issue of lint.filter((i) => i.level === "hint")) lines.push(`· ${pl.speechLintMessage(issue)}。`); + // 模糊態的兩種句型:試探是**提醒**(記一筆),假裝記得是**提醒去查**。 + // 這裡刻意都不擋——擋下去等於把「開放試探」那條界線收回來,稽核才是煞車。 + const probing = pl.looksLikeProbe(text); + const asserting = pl.looksLikeFabrication(text); + if (probing) { + lines.push("· 這句是試探:說出口之後記一筆 `persona.mjs probe add --text \"...\"`," + + "對方回了再 `probe confirm` 或 `probe deny`。"); + } else if (asserting) { + lines.push("· 這句在斷言自己的過去,而且沒有在問:先 `recall` 確認那則記憶還清晰。" + + "只要它是模糊態,就改成帶問號的試探句,不可以把細節補出來。"); + } const clean = !repeat && !tooLong && !blockers.length; if (clean) { lines.push(`✔ 沒講過,${sentences} 句,可以說。`); if (flags.record) pl.recordSaid(slug, text, { kind: "reply" }); } - emit({ persona: slug, repeat, sentences, lint, ok: clean }, flags.json, lines); + emit({ persona: slug, repeat, sentences, lint, probing, asserting, ok: clean }, flags.json, lines); return; } die(`未知 action:${action}(可用 check/list)`); @@ -930,6 +980,13 @@ commands.consolidate = ({ flags }) => { "fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary", ]; if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`); + // 情境戳章與主旨/細節切分:兩者都可以用旗標明寫,沒寫就自動推。 + const stamp = pl.contextStamp(slug); + // `--gist` 明寫時,`--detail` 沒給就是**沒有細節**——退回整份 body 會讓主旨之外 + // 再抄一次全文,那是把「衰減先吃細節」這件事整個抵銷掉。 + const splitBody = str(flags.gist) + ? { gist: str(flags.gist), detail: str(flags.detail) } + : pl.splitGistDetail(body); const about = csv(flags.about).length ? csv(flags.about) : ["user"]; // `about` 是自由字串(原樣保留);對得上關係圖節點的才多寫一行 id,之後 recall 與 // 「人際關係」那段才知道講的是同一個人。一個都對不上就不輸出這一行。 @@ -949,7 +1006,16 @@ commands.consolidate = ({ flags }) => { ...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []), `topics: [${csv(flags.topics).map(fm).filter(Boolean).join(", ")}]`, `salience: ${num(flags.salience, 60)}`, + // 回想強度:連續衰減的起點。已經存在的記憶保留自己長出來的強度, + // 不要因為重寫一次就把「被想起過很多次」的歷史抹平。 + `strength: ${num(flags.strength, Number(existing.strength ?? pl.MEMORY_STRENGTH_DEFAULT))}`, `emotion: ${fm(str(flags.emotion) || "none")}`, + // 情境索引:什麼時候、在哪裡、什麼心情記下來的(回想時的非語意線索)。 + // 推不出來的欄位就不寫——全專案一致:沒有值不留空欄位。 + ...["when", "where", "mood"] + .map((k) => [k, fm(str(flags[k]) || existing[k] || stamp[k] || "")]) + .filter(([, v]) => v) + .map(([k, v]) => `${k}: ${v}`), `rules: ${fm(str(flags.rules) || "manual")}`, `first_seen: ${existing.first_seen || today}`, `last_seen: ${today}`, @@ -957,7 +1023,8 @@ commands.consolidate = ({ flags }) => { `source: ${fm(str(flags.source) || "short-term")}`, "---", "", - body.trim(), + // 主旨/細節兩層:衰減先吃細節,主旨最後才掉。沒有明寫就用第一段當主旨。 + pl.joinGistDetail(splitBody.gist, splitBody.detail), "", ]; pl.writeText(file, front.join("\n")); @@ -984,6 +1051,139 @@ commands.prune = ({ flags }) => { ok(`短期記憶已裁剪,剩 ${kept} 筆(保留上限 ${pl.SHORT_TERM_KEEP} 筆 / ${pl.SHORT_TERM_DAYS} 天)。`); }; +/** + * 把長期記憶檔升到新格式(`strength` 與主旨/細節兩層)。 + * + * `--all` 會掃過**所有**人格——這是唯一一個跨人格的維護指令, + * 所以它只回報「動了幾個檔」,一個字的內容都不印出來(跨人格隔離的界線在這裡不能鬆)。 + */ +commands.migrate = ({ flags }) => { + const session = requireSession(flags); + const dryRun = Boolean(flags["dry-run"]); + const targets = flags.all ? pl.listPersonas() : [hostOf(flags, session)]; + if (!flags.all) requireOwner(targets[0], session); + const rows = []; + for (const slug of targets) { + const stats = pl.migrateLongTerm(slug, { dryRun }); + if (!dryRun && (stats.strength || stats.split)) pl.rebuildIndex(slug); + rows.push({ persona: slug, ...stats }); + } + const lines = [dryRun ? "長期記憶格式遷移(試跑,沒有寫入):" : "長期記憶格式遷移完成:"]; + for (const r of rows) { + lines.push(`- ${r.persona}|共 ${r.total} 則|補 strength ${r.strength}|切主旨/細節 ${r.split}` + + (r.skipped ? `|讀寫失敗 ${r.skipped}` : "")); + } + lines.push(" (只回報數量:跨人格維護不印任何記憶內容。)"); + emit({ dry_run: dryRun, personas: rows }, flags.json, lines); +}; + +// --------------------------------------------------------------------------- // +// loop:懸著的事(未完事項) +// --------------------------------------------------------------------------- // + +commands.loop = ({ flags, positional }) => { + const session = requireSession(flags); + const slug = hostOf(flags, session); + const action = (positional[0] || "list").toLowerCase(); + const show = () => { + const live = pl.openLoops(slug); + const lines = [`\`${slug}\` 現在懸著 ${live.length}/${pl.LOOP_MAX} 件事:`]; + for (const l of live) { + const days = Math.floor((Date.now() - (pl.parseIso(l.opened_at)?.getTime() ?? Date.now())) / 86_400_000); + lines.push(`- ${l.id}|${pl.LOOP_KINDS[l.kind] || l.kind}|${l.text}(開了 ${days} 天)`); + } + if (!live.length) lines.push("- (沒有懸著的事)"); + emit({ persona: slug, loops: live }, flags.json, lines); + }; + if (action === "list") { + requireMember(slug, session, Boolean(flags["as-guest"])); + show(); + return; + } + requireOwner(slug, session); + if (action === "add") { + const res = pl.addLoop(slug, { text: str(flags.text), kind: str(flags.kind) || "topic", about: str(flags.about) }); + if (!res.ok) die(`開不了:${res.reason}。`); + ok(`懸著了:${res.loop.id}|${pl.LOOP_KINDS[res.loop.kind]}|${res.loop.text}(${res.open}/${pl.LOOP_MAX})`); + return; + } + if (action === "done" || action === "drop" || action === "cold") { + const id = str(flags.id); + if (!id) die("需要 `--id`(用 `loop list` 查)。"); + const res = pl.closeLoop(slug, id, action === "done" ? "done" : action, str(flags.note)); + if (!res.ok) die(res.reason); + ok(`收掉了 ${res.loop.id}:${res.loop.text}`); + return; + } + if (action === "touch") { + const id = str(flags.id); + if (!id) die("需要 `--id`。"); + const res = pl.touchLoop(slug, id); + if (!res.ok) die(res.reason); + ok(`${res.loop.id} 有進展,逾期重算。`); + return; + } + if (action === "sweep") { + const cold = pl.sweepLoops(slug); + ok(cold.length + ? `收掉 ${cold.length} 件沒下文的(各留了一則短期記憶):${cold.map((l) => l.text).join("、")}` + : `沒有超過 ${pl.LOOP_STALE_DAYS} 天沒進展的。`); + return; + } + die("用法:`loop add|done|drop|touch|sweep|list`。"); +}; + +// --------------------------------------------------------------------------- // +// probe:模糊記憶的試探與稽核(開放界線唯一的煞車) +// --------------------------------------------------------------------------- // + +commands.probe = ({ flags, positional }) => { + const session = requireSession(flags); + const slug = hostOf(flags, session); + const action = (positional[0] || "audit").toLowerCase(); + if (action === "audit") { + requireMember(slug, session, Boolean(flags["as-guest"])); + const a = pl.probeAudit(slug, num(flags.limit, 50)); + const lines = [ + `\`${slug}\` 的試探稽核(最近 ${a.total} 次):`, + ` 確認 ${a.confirmed}/否認 ${a.denied}/還沒結案 ${a.pending}`, + a.denied_ratio === null + ? " 還沒有結案的試探,算不出錯誤率。" + : ` 結案的裡面有 ${a.denied_ratio}% 被否認` + + (a.denied_ratio >= 40 ? " ⚠ 太高了:模糊態正在被拿來編內容,先收緊到「只能說記不清」。" : "。"), + ]; + for (const row of a.recent) lines.push(` - [${row.outcome}] ${row.text}${row.memory ? `(${row.memory})` : ""}`); + emit({ persona: slug, audit: a }, flags.json, lines); + return; + } + requireOwner(slug, session); + if (action === "add") { + const text = str(flags.text); + if (!text) die("需要 `--text`(你實際問出口的那句試探)。"); + if (!pl.looksLikeProbe(text)) { + die("這句不像試探:試探一定要在**問**(帶問號,例如「是不是上個月那次?」)。\n" + + " 可以說不確定,不可以斷言——斷言就是幻覺,不是模糊記憶。"); + } + const entry = pl.recordProbe(slug, { text, memory: str(flags.memory) }); + ok(`記下這次試探了:${entry.text} → 對方回了之後用 \`probe confirm\` 或 \`probe deny\`。`); + return; + } + if (action === "confirm" || action === "deny") { + const hit = pl.resolveProbe(slug, action === "confirm" ? "confirmed" : "denied", { + at: str(flags.id) || null, // 不指定就結最近一筆還沒結案的 + note: str(flags.note), + }); + if (!hit) die("沒有還沒結案的試探。"); + if (action === "deny") { + ok(`記成「被否認」了:${hit.text}\n 現在就寫一則更正記憶(\`remember\` 或 \`consolidate\`),不要放著。`); + } else { + ok(`記成「對方確認」了:${hit.text}`); + } + return; + } + die("用法:`probe add|confirm|deny|audit`。"); +}; + commands.reindex = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); @@ -1045,7 +1245,7 @@ commands.emotion = ({ flags }) => { deltas = rel.gain === 1 ? raw : Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100])); - s = pl.applyEmotion(s, deltas, str(flags.trigger)); + s = pl.applyEmotion(s, deltas, str(flags.trigger), { slug }); if (rel.gain !== 1) { s.last_trigger = s.last_trigger || {}; s.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain }; @@ -1058,10 +1258,10 @@ commands.emotion = ({ flags }) => { pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) }); pl.appendJsonl(pl.journalPath(slug), { 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.moodOf(slug, state), }); } - const m = pl.mood(state); + const m = pl.moodOf(slug, state); const row = (key) => ` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]})`; const lines = [ @@ -1693,6 +1893,23 @@ commands.import = ({ flags }) => { ]; if (result.rejected.length) lines.push(` ⚠ 略過 ${result.rejected.length} 個路徑不合法的項目:${result.rejected.slice(0, 3).join(", ")}`); if (!checksumOk) lines.push(" ⚠ checksum 不符(--force 略過):內容可能被改過,請自行確認。"); + // 舊 bundle(v1)匯進來會自動升格式。這件事以前只出現在 --json 裡, + // 等於人看的輸出完全沒提——升成功要講,升失敗更要講。 + const inVersion = Number(bundle.version) || 1; + if (inVersion < pl.BUNDLE_VERSION) { + if (result.migrated) { + lines.push( + ` 這是 v${inVersion} 的舊 bundle,已就地升到 v${pl.BUNDLE_VERSION}:` + + `${result.migrated.total} 則長期記憶,補 strength ${result.migrated.strength}、切主旨/細節 ${result.migrated.split}` + + (result.migrated.skipped ? `,${result.migrated.skipped} 則看不懂格式沒動` : "") + "。", + ); + } else { + lines.push( + ` ⚠ 這是 v${inVersion} 的舊 bundle,但自動升格式失敗了。` + + "請手動跑 `persona.mjs migrate --dry-run` 看情況,再決定要不要 `migrate`。", + ); + } + } if (flags.load) { if (data.host && data.host !== result.persona) { lines.push(` ⚠ 本 session 已載入 \`${data.host}\`,未自動載入;要用它請先 release。`); @@ -2297,7 +2514,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 人格與鎖: create --romaji <英文名> --session [--persona <目錄名> --code --name --creature --gender - --vibe --emoji --avatar --baseline --origin --work --no-gitea --public] + --vibe --emoji --avatar --tells --baseline --origin --work --no-gitea --public] + --tells "anger=不講話;句子只剩動詞|羞愧=摸後頸" 情緒破口,寫進 IDENTITY.md 的 + ## Tells 區塊(蓋掉全域預設;沒帶就沿用預設) 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱; 不指定 --persona 就用編號當目錄名。 load --persona --session [--takeover] @@ -2318,15 +2537,26 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C →修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。 預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。 candidates --session 列出達到「短期→長期」條件的候選與依據 - consolidate --session --name --body [--type --about --topics --salience --emotion --rules --source --forget] + consolidate --session --name --body [--type --about --topics --salience --strength --emotion + --when --where --mood --gist --detail --rules --source --forget] + 內文會切成「主旨/細節」兩層:衰減先吃細節,主旨最後才掉。 prune / reindex --session + migrate --session [--all] [--dry-run] [--json] + 長期記憶升格式(補 strength、切主旨/細節)。--all 掃所有人格,只回報數量不印內容。 + loop add|done|drop|touch|sweep|list --session [--text --kind question|promise|topic|mine --id --note] + 懸著的事(同時最多 5 條):他沒回答的問題/他答應要做的事/被打斷的話題/我想問但沒問的。 + 7 天沒進展自動收掉並留一則「沒下文」。mine 那種同時是自我議程的來源。 + probe add|confirm|deny|audit --session [--text --memory --note --id --limit] + 模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。 + audit 看「試探幾次、被否認幾次」——這是開放這條界線唯一的煞車。 情緒與圖: emotion --session [--apply joy=+10,...] [--baseline ...] [--trigger ] [--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta [--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態 [--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向 - 套用時會飽和(越接近端點漲越慢)並受單輪預算限制(|delta| 總和 ≤ 40) + 套用時會飽和、受單輪預算限制(|delta| 總和 ≤ 60),再經交互抑制、慣性、 + 當日底色與疲勞調整——實際生效的值看 last_trigger.scaled mindmap show|thread|list --session [--topic ] [--force] relation node|edge|render|show --session [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity] relation style --session --name [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]