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
+134 -8
View File
@@ -930,21 +930,31 @@ commands.consolidate = ({ flags }) => {
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary",
];
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}`);
const about = csv(flags.about).length ? csv(flags.about) : ["user"];
// `about` 是自由字串(原樣保留);對得上關係圖節點的才多寫一行 id,之後 recall 與
// 「人際關係」那段才知道講的是同一個人。一個都對不上就不輸出這一行。
// `resolveRelationRefs` 已經把不能寫進 front matter 的 id 濾掉了(帶換行的節點 id
// 可以在這裡多插一行、覆寫下面的 `type`,把一則 fact 變成不該被遺忘的 canon)。
const aboutIds = pl.resolveRelationRefs(slug, about);
// front matter 的每個值都只能是一行:帶換行的旗標值同樣能偽造出別的欄位,
// 而 `parseFrontMatter` 取後出現的值 → 後面宣告的 type/salience 會被前面偽造的蓋掉。
const fm = (value) => pl.injectSafeLine(value);
const front = [
"---",
`name: ${name}`,
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。
`title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
`type: ${type}`,
`about: [${csv(flags.about).join(", ") || "user"}]`,
`topics: [${csv(flags.topics).join(", ")}]`,
`about: [${about.map(fm).filter(Boolean).join(", ")}]`,
...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []),
`topics: [${csv(flags.topics).map(fm).filter(Boolean).join(", ")}]`,
`salience: ${num(flags.salience, 60)}`,
`emotion: ${str(flags.emotion) || "none"}`,
`rules: ${str(flags.rules) || "manual"}`,
`emotion: ${fm(str(flags.emotion) || "none")}`,
`rules: ${fm(str(flags.rules) || "manual")}`,
`first_seen: ${existing.first_seen || today}`,
`last_seen: ${today}`,
`recall_count: ${existing.recall_count || 0}`,
`source: ${str(flags.source) || "short-term"}`,
`source: ${fm(str(flags.source) || "short-term")}`,
"---",
"",
body.trim(),
@@ -1115,6 +1125,15 @@ commands.relation = ({ flags, positional }) => {
const slug = hostOf(flags, session);
requireOwner(slug, session);
const action = positional[0] || "show";
// graph.json 壞掉時「空圖」與「壞檔」不能長得一樣:要做決定的 action 直接擋下來
// (放它過去就是拿一張空圖去覆蓋原檔)。doctor 自己會報告,不走這裡。
const graphOrDie = () => {
const data = pl.loadRelations(slug);
if (data._error) {
die(`${data._error}\n 先修好這個檔案(或把它移開讓人格從空的關係圖重新開始),再用 \`relation doctor\` 確認。`);
}
return data;
};
if (action === "node") {
const name = str(flags.name);
if (!name) die("`node` 需要 `--name`。");
@@ -1146,7 +1165,7 @@ commands.relation = ({ flags, positional }) => {
if (action === "style") {
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`style` 需要 `--name`(或 `--id`)。");
const data = pl.loadRelations(slug);
const data = graphOrDie();
const node = data.nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
const facet = str(flags.facet);
@@ -1190,7 +1209,7 @@ commands.relation = ({ flags, positional }) => {
}
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`speaker` 需要 `--name`(或 `--id`),取消請用 `--clear`。");
const node = pl.loadRelations(slug).nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
const node = graphOrDie().nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
config.speaker_node = node.id;
pl.writeJson(pl.configPath(slug), config);
@@ -1218,13 +1237,115 @@ commands.relation = ({ flags, positional }) => {
}
if (action === "show") {
const data = pl.loadRelations(slug);
// 壞檔不能印成「0 節點」了事(那跟空圖逐字相同,看不出東西還在不在)
emit(data, flags.json, [
`人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`,
...(data._error ? [`${data._error}(節點可能還在檔案裡,只是讀不出來 → \`relation doctor\``] : []),
pl.relationsBrief(slug, null, 20) || "(空)",
]);
return;
}
die(`未知 action${action}(可用 node/edge/style/speaker/render/show`);
// 健檢:記憶裡的人名與關係圖節點對不對得上。**唯讀**——只印報告,一個檔案都不改。
if (action === "doctor") {
const graph = pl.loadRelations(slug);
// 壞檔不能報「節點 0 個、0 問題」(那跟空圖逐字相同,exit 也一樣是 0)。
if (graph._error) {
die(
`${graph._error}\n 健檢無法進行:這不是「沒有關係圖」,是「讀不出來」。` +
`\n 所有關係圖的寫入都會被拒絕(免得覆蓋掉原檔),修好之後再跑一次。`,
);
}
const nodes = graph.nodes;
const mentioned = new Set(); // 有記憶提到、且對得上節點的 id
const missingAbout = new Map();
const missingEntities = new Map();
const dangling = new Map(); // about_idsentity_ids 指到不存在的節點
const ambiguous = new Map(); // 一個名字對到多個節點 → 兩邊都不寫
// `user``self` 是 CLI 自己填的佔位字(`consolidate` 沒帶 `--about` 就是 `about: [user]`),
// 不是人名。不跳過的話它會是筆數最高那一行,把真正的問題壓到看不見。
const PLACEHOLDERS = new Set(["user", "self"]);
const bump = (map, key) => map.set(key, (map.get(key) || 0) + 1);
const sortByCount = (map) => [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
// 用**完全相等**比對,跟 `resolveRelationRefs`(寫入路徑)同一套規則:
// 拿 `findRelationNode` 的子字串 fallback 來健檢,會把「猜對的」當成「對上的」,
// 於是「先生 → 小林先生」這種誤配在報告上是 0 問題。
// front matterjsonl 的值不保證是陣列(`about: 王經理` 會給一個字串)→ 一律先攤成清單,
// 不然 `for..of` 會逐字元跑,一個人名變成一堆單字的假人名。
const asList = (v) => (Array.isArray(v) ? v : v === undefined || v === null || v === "" ? [] : [v]);
const tally = (names, missing) => {
for (const raw of asList(names)) {
const name = String(raw).trim();
if (!name) continue;
const matched = pl.matchRelationNodes(nodes, name);
if (matched.length === 1) mentioned.add(matched[0].id);
else if (matched.length > 1) ambiguous.set(name, matched.map((n) => String(n.id)));
else if (!PLACEHOLDERS.has(name.toLowerCase())) bump(missing, name);
}
};
const tallyIds = (ids) => {
for (const raw of asList(ids)) {
const id = String(raw).trim();
if (!id) continue;
// id 不是人名:對不上就是「死指標」(節點被改 id 或被刪掉),單獨列一類
if (nodes.some((n) => String(n.id).trim() === id)) mentioned.add(id);
else bump(dangling, id);
}
};
for (const meta of pl.longTermEntries(slug)) {
tally(meta.about, missingAbout);
tallyIds(meta.about_ids);
}
for (const row of pl.readJsonl(pl.shortTermPath(slug))) {
tally(row.entities, missingEntities);
tallyIds(row.entity_ids);
}
const orphans = nodes
.filter((n) => !mentioned.has(n.id))
.map((n) => ({ id: n.id, name: n.name || n.id, closeness: Number(n.closeness ?? 0) }))
.sort((a, b) => b.closeness - a.closeness);
const aboutRows = sortByCount(missingAbout);
const entityRows = sortByCount(missingEntities);
const danglingRows = sortByCount(dangling);
const ambiguousRows = [...ambiguous.entries()].sort((a, b) => a[0].localeCompare(b[0]));
const lines = [`人格 \`${slug}\` 關係圖健檢(只讀,不改記憶與關係圖):節點 ${nodes.length}`];
lines.push(`長期記憶 about 對不到節點的人名(${aboutRows.length}):`);
lines.push(...(aboutRows.length ? aboutRows.map(([n, c]) => ` - ${n}${c} 則)`) : [" (無)"]));
lines.push(`短期記憶 entities 對不到節點的人名(${entityRows.length}):`);
lines.push(...(entityRows.length ? entityRows.map(([n, c]) => ` - ${n}${c} 筆)`) : [" (無)"]));
lines.push(`同名歧義:一個名字對到多個節點,兩邊都不會寫進 about_idsentity_ids${ambiguousRows.length}):`);
lines.push(...(ambiguousRows.length
? ambiguousRows.map(([n, ids]) => ` - ${n}${ids.join("")}(改掉其中一個的名字或 id`)
: [" (無)"]));
lines.push(`about_idsentity_ids 指到不存在的節點(${danglingRows.length}):`);
lines.push(...(danglingRows.length
? danglingRows.map(([id, c]) => ` - ${id}${c} 處;節點被改 id 或被刪掉了)`)
: [" (無)"]));
lines.push(`關係圖裡有節點、但沒有任何記憶提到(${orphans.length}):`);
lines.push(...(orphans.length
? orphans.map((n) => ` - ${n.name}${n.id}/親近 ${n.closeness}`)
: [" (無)"]));
lines.push(
`總計:節點 ${nodes.length}/被記憶提到 ${mentioned.size}/沒人提到 ${orphans.length}` +
`|對不到節點的人名:長期 ${aboutRows.length} 種、短期 ${entityRows.length}` +
`|同名歧義 ${ambiguousRows.length} 個|死指標 ${danglingRows.length} 個。`,
);
emit(
{
persona: slug,
nodes: nodes.length,
mentioned: [...mentioned],
unresolved_about: aboutRows.map(([name, count]) => ({ name, count })),
unresolved_entities: entityRows.map(([name, count]) => ({ name, count })),
ambiguous_names: ambiguousRows.map(([name, ids]) => ({ name, ids })),
dangling_ids: danglingRows.map(([id, count]) => ({ id, count })),
unmentioned_nodes: orphans,
},
flags.json,
lines,
);
return;
}
die(`未知 action${action}(可用 node/edge/style/speaker/render/show/doctor`);
};
commands.invite = ({ flags }) => {
@@ -2210,6 +2331,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
relation speaker --session <id> --name <who> | --clear (使用者在關係圖裡是誰 → 決定語氣層)
relation doctor --session <id> [--json] 記憶裡的人名對不對得上關係圖節點:對不到的人名、
同名歧義、指到不存在節點的 id、沒人提到的節點。
**唯讀**(一個檔案都不改);graph.json 壞掉時以非零結束
多人格對話:
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
@@ -2284,6 +2408,8 @@ async function main(argv) {
await command({ flags: parsed.flags, positional: parsed._ });
} catch (err) {
if (err instanceof pl.LockError) die(err.message);
// 關係圖壞掉/id 不合法:使用者要看得懂的一行,不是 stack trace
if (err instanceof pl.RelationsError) die(err.message);
throw err;
}
return 0;