feat(relation): 記憶接回關係圖,並補上唯讀健檢 relation doctor

寫入時把 --entities/--about 的人名解析成節點 id(entity_ids/about_ids),
之後「提到誰」與 turnContext 認人才接得回關係圖;同名有多個候選視為歧義,
兩邊都不寫,不替使用者挑一個。

節點 id 會被原樣寫進 front matter 與 jsonl,所以兩頭都要防:帶換行的 id
可以在 front matter 裡多插一行、覆寫 type,把一則普通記憶變成不該被遺忘的
canon;`:` `[` `]` `,` `#` 也都會改變結構。injectSafeLine 把這些擋掉。

graph.json 壞掉時不再偽裝成空圖:要做決定的 action 直接以非零 exit 擋下
(放它過去等於拿一張空圖覆蓋原檔),relation doctor 自己會報告。

recall 用關係節點的 name 當關鍵詞,但刻意不用 id——id 是內部識別,命中率
高得離譜(關係圖有一個 id 為 user 的節點,就會命中每一則 about: [user]),
一個命中值 +10 會把顯著度 75 的正確答案擠出榜。about_ids 同理不進 haystack。

順手把關係圖改成一次讀完:原本每筆的每個 entity 各讀一遍 graph.json,
實測 30 節點/240 筆 = 720 次讀檔;2000 節點時單輪要 1.2 秒。

selftest 448 → 471 項,全過。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 03:17:21 +00:00
co-authored by Claude Opus 5
parent 34b1dc7942
commit dd16411d2e
3 changed files with 537 additions and 26 deletions
+221 -18
View File
@@ -1477,6 +1477,12 @@ export const journalPath = (slug) => {
export function rememberShort(slug, entry) {
entry.ts ??= nowIso();
// `entities` 是自由字串(原樣保留);順手把對得上的關係節點 id 記在 `entity_ids`
// 之後「提到誰」才接得回關係圖。一個都對不上就不寫這個 key(全專案一致:沒命中就不留空欄位)。
if (entry.entity_ids === undefined) {
const ids = resolveRelationRefs(slug, entry.entities || []);
if (ids.length) entry.entity_ids = ids;
}
appendJsonl(shortTermPath(slug), entry);
return entry;
}
@@ -1619,11 +1625,13 @@ export function rebuildIndex(slug) {
const sorted = [...entries].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0));
for (const meta of sorted) {
const topics = Array.isArray(meta.topics) ? meta.topics : meta.topics ? [String(meta.topics)] : [];
const about = Array.isArray(meta.about) ? meta.about : meta.about ? [String(meta.about)] : [];
const summary = (meta._body.split("\n")[0] || "").slice(0, 110);
lines.push(
`- [${meta._name}](long-term/${path.basename(meta._path)})` +
`${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}` +
`|主題 ${topics.length ? topics.join("/") : "-"}${summary}`,
`|主題 ${topics.length ? topics.join("/") : "-"}` +
`|關於 ${about.length ? about.join("/") : "-"}${summary}`,
);
}
if (lines.length === 4) lines.push("- (尚無長期記憶)");
@@ -1665,9 +1673,26 @@ export function keywords(text, limit = 12) {
/** 以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。 */
export function recall(slug, query, limit = 5) {
const keys = keywords(query, 16).map((k) => k.toLowerCase());
const tokens = keywords(query, 16);
const keySet = new Set(tokens.map((k) => k.toLowerCase()));
// 同一個人有很多寫法:關鍵詞若指到某個關係節點,就把節點的 **name** 也當關鍵詞。
// 這樣 `about: 小林先生` 的記憶被 `xiao-lin` 問起也撈得到。
//
// 節點的 **id 不當關鍵詞**:id 是內部識別,不是人會說出口的字,而它命中率高得離譜——
// 只要關係圖有一個 id 為 `user` 的節點(`relation speaker` 的正常用法),
// `user` 就會命中每一則 `about: [user]``consolidate` 沒帶 `--about` 時的預設值)的記憶。
// 一個 keyword 命中值 +10,而 `salience/10` 整個值域才 0-10 → 顯著度 75 的正確答案
// 會被一堆顯著度 18 的閒事擠出榜(實測「週報什麼時候交」的正確答案掉到第 9 名)。
const nodes = loadRelations(slug).nodes; // 一次讀完(以前每個 token 都重讀一遍 graph.json
for (const token of tokens) {
const node = findRelationNode(slug, token, nodes);
if (node?.name) keySet.add(String(node.name).toLowerCase());
}
const keys = [...keySet];
const scored = [];
for (const meta of longTermEntries(slug)) {
// `about_ids` 刻意**不進 haystack**:它是給 `turnContext` 認人用的內部 id
// 拿它計分等於讓「有沒有做過遷移」決定召回名次(沒有 about_ids 的既有記憶固定少算一個命中)。
const haystack = [
meta._name || "",
Array.isArray(meta.topics) ? meta.topics.join(" ") : "",
@@ -1710,6 +1735,9 @@ function emotionImpact(entry) {
export function promotionCandidates(slug) {
const rows = readJsonl(shortTermPath(slug));
const total = rows.length;
// 關係圖只讀一次:這個函式在每次 `remember` 之後都會跑,以前是「每筆的每個 entity
// 各讀一遍 graph.json」(實測 30 節點/240 筆 = 720 次讀檔、20ms2000 節點時單輪 1.2 秒)。
const nodes = loadRelations(slug).nodes;
const byTopic = new Map();
const byEntity = new Map();
const singles = [];
@@ -1738,9 +1766,18 @@ export function promotionCandidates(slug) {
if (!byTopic.has(topic)) byTopic.set(topic, []);
byTopic.get(topic).push(entry);
}
// 「小林」與「小林先生」是同一個人:先對到關係節點 id 再當 key,不同寫法才會累加成 R5。
// 寫入時解析好的 `row.entity_ids` 直接吃(不要在統計時重新解析、得到跟資料裡不同的答案);
// 剩下的名字對不到節點就退回原字串(跟以前一樣)。同一筆記憶在同一個 key 下只算一次。
const keys = new Set(safeRelationIds(row.entity_ids));
for (const entity of row.entities || []) {
if (!byEntity.has(entity)) byEntity.set(entity, []);
byEntity.get(entity).push(entry);
const id = resolveRelationRefs(slug, [entity], nodes)[0];
const key = id || String(entity ?? "").trim();
if (key) keys.add(key);
}
for (const key of keys) {
if (!byEntity.has(key)) byEntity.set(key, []);
byEntity.get(key).push(entry);
}
});
@@ -2268,10 +2305,60 @@ export const threadPath = (slug, topic) => path.join(personaDir(slug), "mindmap"
export const relationsJson = (slug) => path.join(personaDir(slug), "relations", "graph.json");
export const relationsMmd = (slug) => path.join(personaDir(slug), "relations", "graph.mmd");
/** 關係圖壞掉時的錯誤(`main()` 會把它變成一行看得懂的訊息,不是 stack trace)。 */
export class RelationsError extends Error {
constructor(message) {
super(message);
this.name = "RelationsError";
}
}
/**
* 關係圖。**「檔案不存在」與「檔案壞了」不是同一件事**:
* 前者是正常的(新人格還沒有關係圖)→ 回空圖;後者回空圖但掛上 `_error`。
*
* 不能用 `readJson(..., {})`:那會讓壞檔與空圖的輸出逐字相同(`show` 印「0 節點」、
* exit 0),而下一次任何寫入都用 tmp+rename 原子覆蓋整個檔案 —— 原有節點就永久消失了。
* 呼叫端自己決定怎麼辦:寫入路徑走 `relationsForWrite()` 直接拒絕,
* 每輪都跑的讀取路徑(`turnContext`)退回空圖但把警告印給使用者看。
*/
export function loadRelations(slug) {
const data = readJson(relationsJson(slug), {}) ?? {};
data.nodes ??= [];
data.edges ??= [];
const file = relationsJson(slug);
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
return { nodes: [], edges: [] }; // 還沒有關係圖:這是正常狀態
}
let data;
try {
data = JSON.parse(text);
} catch (err) {
return { nodes: [], edges: [], _error: `關係圖 ${file} 無法解析:${err.message}` };
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
return { nodes: [], edges: [], _error: `關係圖 ${file} 不是一個物件(讀到 ${Array.isArray(data) ? "陣列" : typeof data}` };
}
for (const key of ["nodes", "edges"]) {
if (data[key] === undefined || data[key] === null) data[key] = [];
// 有這個 key 但不是陣列 → 一樣是壞檔(照著寫下去會把原本的內容換成空的)
else if (!Array.isArray(data[key])) return { nodes: [], edges: [], _error: `關係圖 ${file}\`${key}\` 不是陣列` };
}
return data;
}
/**
* 寫入關係圖前的把關:解析失敗就不准覆蓋(原檔留在那裡,還救得回來)。
* 所有會 `writeJson(relationsJson(...))` 的路徑都要先過這裡。
*/
export function relationsForWrite(slug) {
const data = loadRelations(slug);
if (data._error) {
throw new RelationsError(
`${data._error}\n 這次不寫入,免得把原有的節點與連線整個蓋掉。` +
`請先修好這個檔案(或把它移開讓人格從空的關係圖重新開始),再用 \`relation doctor\` 確認。`,
);
}
return data;
}
@@ -2401,13 +2488,83 @@ export function styleRules(slug, node) {
}
/** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */
/** 依名字或 id 找關係節點(找不到回 null)。 */
export function findRelationNode(slug, who) {
/**
* 依名字或 id 找關係節點(找不到回 null)。最後一段是**子字串**比對:
* 這是給互動式呼叫端的方便(`relation style --name 小林` 找得到「小林先生」),
* **不可以**用在寫入路徑上——猜錯了會把稱謂寫成別人的 id,見 `resolveRelationRefs`。
*
* `nodes`:呼叫端已經 `loadRelations` 過就傳進來,省掉重讀 graph.json。
*/
export function findRelationNode(slug, who, nodes = null) {
const key = String(who || "").trim();
if (!key) return null;
const nodes = loadRelations(slug).nodes || [];
return nodes.find((n) => n.id === key) || nodes.find((n) => n.name === key)
|| nodes.find((n) => String(n.name || "").includes(key)) || null;
const pool = nodes || loadRelations(slug).nodes || [];
return pool.find((n) => n.id === key) || pool.find((n) => n.name === key)
|| pool.find((n) => String(n.name || "").includes(key)) || null;
}
// 節點 id 會被原樣寫進長期記憶的 front matter`about_ids: [...]`)與短期記憶的 jsonl。
// 帶換行的 id 就能在 front matter 裡多插一行(實測可以覆寫 `type`,把一則普通記憶
// 變成不該被遺忘的 `canon`);`:``[``]``,``#` 也都會改變 front matter 的結構。
// `--id` 是使用者給的,`sync pull``import` 也會帶進別台機器的 id,所以兩頭都要防。
const RELATION_ID_RE = /^[^\s:[\],#<>"'`]{1,64}$/;
// front matter 與 jsonl 裡的 `about``about_ids``entities``entity_ids` 不保證是陣列
// `about_ids: asuna` 這種手寫的值,`parseFrontMatter` 會給一個字串)。
// 直接 `for..of` 一個字串會逐字元跑,於是一個 id 變成一堆單字元的假 id。
const asList = (value) => (Array.isArray(value) ? value : value === undefined || value === null || value === "" ? [] : [value]);
/** 這個字串可以當關係節點 id 嗎(能安全寫進 front matterjsonl)。 */
export function validRelationId(id) {
return RELATION_ID_RE.test(String(id ?? ""));
}
/** 要寫進記憶的節點 id:先中和注入標記與換行,長得不像 id 的**直接丟掉**(與「對不上就省略」一致)。 */
export function safeRelationIds(ids) {
const out = [];
for (const raw of asList(ids)) {
const one = injectSafeLine(raw);
if (!validRelationId(one) || out.includes(one)) continue;
out.push(one);
}
return out;
}
/** 名字**完全相等**trim 後)的節點:id 或 name 命中都算。回傳全部命中,讓呼叫端判斷歧義。 */
export function matchRelationNodes(nodes, name) {
const key = String(name ?? "").trim();
if (!key) return [];
return (nodes || []).filter(
(n) => String(n?.id ?? "").trim() === key || String(n?.name ?? "").trim() === key,
);
}
/**
* 記憶裡的人名(自由字串)→ 關係圖節點 id。
*
* 記憶寫的是「小林」,關係圖的節點可能叫「小林先生」、id 是 `xiao-lin`——
* 只靠字串比對,兩邊永遠對不上,於是「提到誰」跟「跟誰有關係」是兩份互不相通的資料。
* 這裡多解析一次,對得上就記 id;**對不上就跳過**(不建節點、不報錯、不擋寫入)。
*
* 這是**寫入路徑**,所以只認完全相等的 id 或 name:
* - 不做子字串比對——`--entities 先生` 曾經被解成同事節點「小林先生」,
* R5 據此開出固化候選,人格就固化了一則自己編出來的假記憶。
* - 一個名字對到多於一個節點時視為歧義,一樣不寫:誰先誰贏只是 graph.json 的
* 排列順序,而 `import``sync pull``persona-anime` 都會重排它,
* 同一則記憶在另一台機器上會指到另一個人。歧義清單見 `relation doctor`。
*
* `nodes`:呼叫端已經 `loadRelations` 過就傳進來(`promotionCandidates` 對每筆的每個
* entity 都會呼叫一次,每次重讀一遍 graph.json 是實測會痛的那種慢)。
*/
export function resolveRelationRefs(slug, names, nodes = null) {
const pool = nodes || loadRelations(slug).nodes || [];
const out = [];
for (const name of asList(names)) {
const matched = matchRelationNodes(pool, name);
if (matched.length !== 1) continue; // 對不上,或同名歧義
out.push(matched[0].id);
}
return safeRelationIds(out);
}
export function speakerNode(slug) {
@@ -2457,7 +2614,7 @@ export function toneDirective(slug) {
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
export function stampContact(slug, nameOrId, at = nowIso()) {
const data = loadRelations(slug);
const data = relationsForWrite(slug);
const key = slugify(String(nameOrId || ""));
const node = data.nodes.find((n) => n.id === key || slugify(n.name || "") === key);
if (!node) return false;
@@ -2522,8 +2679,16 @@ export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } =
}
export function upsertRelationNode(slug, node) {
const data = loadRelations(slug);
const data = relationsForWrite(slug);
const nodeId = node.id || slugify(node.name || "");
// id 會被原樣寫進長期記憶的 front matter 與短期記憶的 jsonl → 帶換行或 front matter
// 結構字元的 id 擋在這裡(`slugify` 產的一定合法,只有 `--id` 需要驗)。
if (!validRelationId(nodeId)) {
throw new RelationsError(
`節點 id \`${injectSafeLine(nodeId).slice(0, 60)}\` 不合法:不能有空白、換行,也不能有 \`: [ ] , # < > " ' \`\`` +
`(它會被寫進記憶的 front matter,這些字元可以偽造出別的欄位)。`,
);
}
node.id = nodeId;
let idx = data.nodes.findIndex((n) => n.id === nodeId);
// 同一個人不該因為換了 id(例如原本用名字當 id,後來改用人格編號 ASUNA-01)就多長一個節點:
@@ -2558,7 +2723,7 @@ export function upsertRelationNode(slug, node) {
}
export function upsertRelationEdge(slug, edge) {
const data = loadRelations(slug);
const data = relationsForWrite(slug);
const idx = data.edges.findIndex((e) => e.from === edge.from && e.to === edge.to);
if (idx >= 0) {
for (const [k, v] of Object.entries(edge)) if (v !== null && v !== undefined) data.edges[idx][k] = v;
@@ -2574,7 +2739,8 @@ export function upsertRelationEdge(slug, edge) {
}
export function renderRelations(slug) {
const data = loadRelations(slug);
// graph.mmd 是 graph.json 的投影:來源壞掉時寧可不畫,也不要拿一張空圖蓋掉上一張。
const data = relationsForWrite(slug);
const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))'];
for (const node of data.nodes) {
const nid = mermaidId(node.id);
@@ -2594,7 +2760,11 @@ export function renderRelations(slug) {
return text;
}
export function relationsBrief(slug, names = null, limit = 5) {
/**
* `extraIds`:不經關鍵詞比對、直接併進來的節點(例如剛想起來的長期記憶提到的人)。
* 只補、不排擠——關鍵詞比不到就退回 closeness 前 `limit` 的行為完全不動。
*/
export function relationsBrief(slug, names = null, limit = 5, extraIds = []) {
const data = loadRelations(slug);
let nodes = data.nodes;
if (names?.length) {
@@ -2603,6 +2773,15 @@ export function relationsBrief(slug, names = null, limit = 5) {
nodes = matched.length ? matched : data.nodes;
}
nodes = [...nodes].sort((a, b) => Number(b.closeness || 0) - Number(a.closeness || 0)).slice(0, limit);
if (extraIds?.length) {
const have = new Set(nodes.map((n) => n.id));
for (const id of extraIds) {
const node = data.nodes.find((n) => n.id === id);
if (!node || have.has(node.id)) continue;
have.add(node.id);
nodes.push(node);
}
}
if (!nodes.length) return "";
return nodes
.map((n) => {
@@ -3485,8 +3664,32 @@ export function turnContext(slug, sessionId, prompt = "") {
}
touchRecall(slug, hits.map((m) => m._name));
}
const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null);
// 兩段要講同一批人:剛想起來的那幾則記憶提到誰,就把那些節點一起帶進「人際關係」。
// `about_ids` 與 `about` 兩邊**都要試**union):節點改 id 是支援的操作
// (原本用名字當 id,後來改用人格編號 ASUNA-01),改完之後舊的 `about_ids` 就成了死指標——
// 只看 `about_ids` 的話,有記過 id 的記憶反而比完全沒記過的更早失聯。
// 最多多帶 3 個,不要無限膨脹。
const relData = loadRelations(slug); // 讀一次傳下去(每則記憶的每個人名都要對一次)
const relNodes = relData.nodes;
const memoryRefs = [];
for (const meta of hits) {
const aboutIds = Array.isArray(meta.about_ids) ? meta.about_ids : [];
const about = Array.isArray(meta.about) ? meta.about : meta.about ? [String(meta.about)] : [];
for (const id of resolveRelationRefs(slug, [...aboutIds, ...about], relNodes)) {
if (!memoryRefs.includes(id)) memoryRefs.push(id);
}
}
const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null, 5, memoryRefs.slice(0, 3));
if (rel) lines.push(`人際關係:${rel}`);
// 關係圖壞掉時**不炸掉整輪**(這段每輪都跑,hook 掛掉比少一段脈絡嚴重),
// 但也絕不無聲——這輪的「人際關係」是空的,使用者必須知道為什麼。
if (relData._error) {
lines.push(
`${relData._error}`,
" 這一輪沒有人際關係與語氣層可用(當成空圖處理),而且所有關係圖的寫入都會被拒絕。" +
"先修好那個檔案,再用 `relation doctor` 確認。",
);
}
// 很久沒聯絡但很親近的人 → 這是「主動提議去關心某人」的依據(不是每輪都要提)
const stale = staleContacts(slug);