feat(自傳章節): memory/chapters/ 補上長期記憶與心智圖之間的時期層
長期記憶是一則一件事,心智圖是抽掉時間的語意結構。中間缺的是「那段日子」—— 人講自己的過去是按段講的(「在 SAO 那兩年」),不是按事件清單、也不是按概念圖。 一段一檔,chapter add|list|show|close。to 空著代表還在這一段裡,所以沒有值就 不寫那一行(全專案一致:沒有值不留空欄位)。全部收掉時 currentChapter 回 null ——那是「還沒開始新的一段」,不是退回上一段。 長期記憶的 front matter 多一格 chapter。consolidate --chapter 可以指定,省略就 掛在當下那一段:記憶寫下來的時候人就在某一段裡,事後要重建歸屬幾乎不可能。 (那個 --chapter 是人生的段,不是 novel skip --chapter 的書的章節,註解裡有寫。) chapterBrief() 每輪注入現在這一段,並講明它是時期不是事件——它解釋這陣子為什麼 在意這些事,不是要他講起這一段。 memory/chapters/ 加進 Gitea 的 Wiki 區:一段日子開一次收一次,比長期記憶更低頻。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
/* 語氣檔壞掉不該讓整輪掛掉 */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user