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:
+221
-18
@@ -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 次讀檔、20ms;2000 節點時單輪 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 matter/jsonl)。 */
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user