diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 14c0a56..9f866b7 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -132,6 +132,8 @@ export const AREAS = { "icon/", "memory/INDEX.md", "memory/long-term/", + // 自傳章節:一段日子開一次、收一次,比長期記憶更低頻 + "memory/chapters/", // 語氣層跟 IDENTITY/SOUL 同一區:它是低頻的設定,不是每輪都在變的活狀態 "voice/", "mindmap/semantic.mmd", diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 7ea0324..522c998 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -2647,6 +2647,7 @@ export function writeLongTermMemory(slug, { source = "", firstSeen = "", lastSeen = "", + chapter = "", extra = {}, } = {}) { const file = path.join(longTermDir(slug), `${name}.md`); @@ -2687,6 +2688,12 @@ export function writeLongTermMemory(slug, { .filter(([, v]) => v) .map(([k, v]) => `${k}: ${v}`), `rules: ${fm(rules) || "manual"}`, + // 這則記憶屬於哪一段(自傳章節)。沒指定就沿用既有值,再退回**當下**那一段—— + // 記憶寫下來的時候人就在某一段裡,事後要重建歸屬幾乎不可能。 + ...(() => { + const key = slugify(fm(chapter) || existing.chapter || currentChapter(slug)?.name || ""); + return key ? [`chapter: ${key}`] : []; + })(), `first_seen: ${fm(firstSeen) || existing.first_seen || today}`, `last_seen: ${fm(lastSeen) || today}`, `recall_count: ${existing.recall_count || 0}`, @@ -2707,6 +2714,108 @@ export function writeLongTermMemory(slug, { return { file, name, existing }; } +// --------------------------------------------------------------------------- // +// 自傳章節(`memory/chapters/`):長期記憶與心智圖之間的**時期層** +// +// 長期記憶是一則一件事,心智圖是抽掉時間的語意結構。中間缺的是「那段日子」—— +// 「在 SAO 那兩年」「跟他一起做 persona 這段」。人講自己的過去是按段講的, +// 不是按事件清單講的,也不是按概念圖講的。 +// +// 一段一檔,front matter 記起訊日期,`to` 空著代表**還在這一段裡**。 +// --------------------------------------------------------------------------- // + +export const chaptersDir = (slug) => path.join(personaDir(slug), "memory", "chapters"); +export const chapterPath = (slug, name) => path.join(chaptersDir(slug), `${slugify(name)}.md`); + +export function writeChapter(slug, { name, title = "", from = "", to = "", body = "" } = {}) { + const key = slugify(name); + if (!key) return null; + const file = chapterPath(slug, key); + let existing = {}; + if (fs.existsSync(file)) [existing] = parseFrontMatter(fs.readFileSync(file, "utf8")); + const fm = (value) => injectSafeLine(value); + const front = [ + "---", + `name: ${key}`, + `title: ${fm(title || existing.title || name).slice(0, 120)}`, + `from: ${fm(from) || existing.from || nowIso().slice(0, 10)}`, + // `to` 空著=還在這一段裡。所以沒有值就**不寫這一行**,不要寫成 `to: `。 + ...((fm(to) || existing.to) ? [`to: ${fm(to) || existing.to}`] : []), + `updated_at: ${nowIso()}`, + "---", + "", + String(body || existing._body || "").trim(), + "", + ]; + fs.mkdirSync(chaptersDir(slug), { recursive: true }); + writeText(file, front.join("\n")); + return { file, name: key, existing }; +} + +/** 全部章節,按起始日期排序(讀不到目錄就回空陣列)。 */ +export function loadChapters(slug) { + let files = []; + try { + files = fs.readdirSync(chaptersDir(slug)).filter((f) => f.endsWith(".md")).sort(); + } catch { + return []; + } + const out = []; + for (const f of files) { + const file = path.join(chaptersDir(slug), f); + let text; + try { + text = fs.readFileSync(file, "utf8"); + } catch { + continue; + } + const [meta, body] = parseFrontMatter(text); + out.push({ + name: meta.name || path.basename(f, ".md"), + title: injectSafeLine(meta.title || meta.name || path.basename(f, ".md")), + from: injectSafeLine(meta.from || ""), + to: injectSafeLine(meta.to || ""), + open: !meta.to, + gist: injectSafeLine(splitGistDetail(body.trim()).gist, 200), + _path: file, + }); + } + return out.sort((a, b) => String(a.from).localeCompare(String(b.from))); +} + +/** + * 現在在哪一段:最後一個還沒收掉(`to` 空著)的章節。 + * + * 全部都收掉了就回 null——那是「還沒開始新的一段」,不是「回到上一段」。 + */ +export function currentChapter(slug) { + const open = loadChapters(slug).filter((c) => c.open); + return open.length ? open[open.length - 1] : null; +} + +/** 收掉一段(寫上 `to`)。找不到那一段回 null。 */ +export function closeChapter(slug, name, at = nowIso().slice(0, 10)) { + const key = slugify(name); + if (!fs.existsSync(chapterPath(slug, key))) return null; + return writeChapter(slug, { name: key, to: at }); +} + +/** 這一段裡的長期記憶(靠 front matter 的 `chapter` 掛上去)。 */ +export const chapterMemories = (slug, name) => + longTermEntries(slug).filter((m) => String(m.chapter || "") === slugify(name)); + +/** 注入用的一行:他現在在自己人生的哪一段。沒有進行中的章節就整段不出現。 */ +export function chapterBrief(slug) { + const now = currentChapter(slug); + if (!now) return ""; + const before = loadChapters(slug).filter((c) => !c.open).length; + return ( + `現在這一段:**${now.title}**(${now.from} 起${before ? `,前面還有 ${before} 段` : ""})` + + `${now.gist ? `——${now.gist}` : ""}\n` + + " 這是**時期**不是事件:它解釋你這陣子為什麼在意這些事,不是要你講起這一段。" + ); +} + export function rebuildIndex(slug) { const entries = longTermEntries(slug); const lines = [ @@ -6016,6 +6125,9 @@ export function turnContext(slug, sessionId, prompt = "") { // 慣例:程序性記憶,不必被問到(沒有人為了照慣例做事先去回想它)。 const habits = habitBrief(slug); if (habits) lines.push(habits); + // 時期層:他現在在自己人生的哪一段(解釋這陣子為什麼在意這些事)。 + const chapter = chapterBrief(slug); + if (chapter) lines.push(chapter); } catch { /* 語氣檔壞掉不該讓整輪掛掉 */ } diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 79373c7..d8139e4 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1056,6 +1056,9 @@ commands.consolidate = ({ flags }) => { mood: str(flags.mood), rules: str(flags.rules), source: str(flags.source), + // 自傳章節(`memory/chapters/`)的段名,**不是** `novel skip --chapter` 那個書的章節。 + // 省略就用當下進行中的那一段(見 writeLongTermMemory)。 + chapter: str(flags.chapter), }); const total = pl.rebuildIndex(slug); // 來源短期記憶標成判斷過:不標的話這幾筆下一輪又會被算成固化候選。 @@ -1223,6 +1226,71 @@ commands.probe = ({ flags, positional }) => { die("用法:`probe add|confirm|deny|audit`。"); }; +// --------------------------------------------------------------------------- // +// chapter:自傳章節(`memory/chapters/`)——長期記憶與心智圖之間的時期層 +// +// 人講自己的過去是按段講的(「在 SAO 那兩年」),不是按事件清單、也不是按概念圖。 +// --------------------------------------------------------------------------- // + +commands.chapter = ({ flags, positional }) => { + const session = requireSession(flags); + const slug = hostOf(flags, session); + const action = (positional[0] || "list").toLowerCase(); + if (action === "list") { + requireMember(slug, session, Boolean(flags["as-guest"])); + const rows = pl.loadChapters(slug); + const now = pl.currentChapter(slug); + emit({ persona: slug, chapters: rows, current: now?.name || null }, flags.json, [ + `\`${slug}\` 的自傳章節(${rows.length} 段${now ? `,現在在「${now.title}」` : ",目前沒有進行中的段"}):`, + ...rows.map((c) => ` - ${c.title}|${c.from} → ${c.to || "現在"}` + + `|記憶 ${pl.chapterMemories(slug, c.name).length} 則${c.open ? "|**進行中**" : ""}`), + ...(rows.length ? [] : [" (還沒有。用 `chapter add --name <段名> --body <這段是什麼>` 開一段)"]), + ]); + return; + } + if (action === "show") { + requireMember(slug, session, Boolean(flags["as-guest"])); + const name = str(flags.name) || positional[1]; + if (!name) die("`chapter show` 需要 `--name`。"); + const chapter = pl.loadChapters(slug).find((c) => c.name === pl.slugify(name)); + if (!chapter) die(`找不到章節 \`${name}\`(用 \`chapter list\` 看有哪些)。`); + const mems = pl.chapterMemories(slug, chapter.name); + emit({ chapter, memories: mems.map((m) => m._name) }, flags.json, [ + `**${chapter.title}**(${chapter.from} → ${chapter.to || "現在"})`, + chapter.gist || "(這一段還沒寫說明)", + `這一段裡的長期記憶 ${mems.length} 則:`, + ...mems.map((m) => ` - ${m.title || m._name}(${m.type})`), + ]); + return; + } + requireOwner(slug, session); + if (action === "add") { + const name = str(flags.name); + if (!name) die("`chapter add` 需要 `--name`(例如「在 SAO 那兩年」)。"); + const res = pl.writeChapter(slug, { + name, + title: str(flags.title) || name, + from: str(flags.from), + to: str(flags.to), + body: str(flags.body), + }); + if (!res) die("`--name` 取不出可用的段名(全是符號?)。"); + ok(`章節「${str(flags.title) || name}」寫好了:${res.file}`); + pushWikiLater(slug, session, flags); // memory/chapters/ 屬於 Wiki 區(低頻) + return; + } + if (action === "close") { + const name = str(flags.name) || positional[1]; + if (!name) die("`chapter close` 需要 `--name`。"); + const res = pl.closeChapter(slug, name, str(flags.at) || undefined); + if (!res) die(`找不到章節 \`${name}\`。`); + ok(`「${name}」這一段收掉了。開新的一段用 \`chapter add\`。`); + pushWikiLater(slug, session, flags); + return; + } + die("用法:`chapter add|list|show|close`。"); +}; + // --------------------------------------------------------------------------- // // voice:語氣樣本與情緒反應(`voice/`) // @@ -3102,11 +3170,16 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C loop add|done|drop|touch|sweep|list --session [--text --kind question|promise|topic|mine --id --note] 懸著的事(同時最多 5 條):他沒回答的問題/他答應要做的事/被打斷的話題/我想問但沒問的。 7 天沒進展自動收掉並留一則「沒下文」。mine 那種同時是自我議程的來源。 - voice add|list|show --session --kind sample|reaction + voice add|list|show --session --kind sample|reaction|idiolect [--text --to --scene] sample:他自己講過的原句(照抄不改寫) [--event --action --emotion] reaction:事件 → 他做了什麼(不記「他感覺到什麼」) + [--facet 自稱|句尾|口癖|不說 --value] idiolect:語域(把名字遮掉還認得出是誰的那幾格) show 看這一輪會注入什麼(每輪最多 3 條——全注入會變成照抄舊台詞)。 寫在 voice/ 這一層:匯入流程可以直接寫,個性(SOUL.md)只有你能改。 + chapter add|list|show|close --session [--name --title --from --to --at --body] + 自傳章節(memory/chapters/):長期記憶與心智圖之間的**時期層**—— + 「在 SAO 那兩年」。to 空著=還在這一段裡,close 才寫上結束日期。 + consolidate 的 --chapter 把一則記憶掛到某一段(省略就掛在當下那一段)。 probe add|confirm|deny|audit --session [--text --memory --note --id --limit] 模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。 audit 看「試探幾次、被否認幾次」——這是開放這條界線唯一的煞車。