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) { export function rememberShort(slug, entry) {
entry.ts ??= nowIso(); 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); appendJsonl(shortTermPath(slug), entry);
return 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)); const sorted = [...entries].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0));
for (const meta of sorted) { for (const meta of sorted) {
const topics = Array.isArray(meta.topics) ? meta.topics : meta.topics ? [String(meta.topics)] : []; 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); const summary = (meta._body.split("\n")[0] || "").slice(0, 110);
lines.push( lines.push(
`- [${meta._name}](long-term/${path.basename(meta._path)})` + `- [${meta._name}](long-term/${path.basename(meta._path)})` +
`${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}` + `${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("- (尚無長期記憶)"); if (lines.length === 4) lines.push("- (尚無長期記憶)");
@@ -1665,9 +1673,26 @@ export function keywords(text, limit = 12) {
/** 以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。 */ /** 以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。 */
export function recall(slug, query, limit = 5) { 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 = []; const scored = [];
for (const meta of longTermEntries(slug)) { for (const meta of longTermEntries(slug)) {
// `about_ids` 刻意**不進 haystack**:它是給 `turnContext` 認人用的內部 id
// 拿它計分等於讓「有沒有做過遷移」決定召回名次(沒有 about_ids 的既有記憶固定少算一個命中)。
const haystack = [ const haystack = [
meta._name || "", meta._name || "",
Array.isArray(meta.topics) ? meta.topics.join(" ") : "", Array.isArray(meta.topics) ? meta.topics.join(" ") : "",
@@ -1710,6 +1735,9 @@ function emotionImpact(entry) {
export function promotionCandidates(slug) { export function promotionCandidates(slug) {
const rows = readJsonl(shortTermPath(slug)); const rows = readJsonl(shortTermPath(slug));
const total = rows.length; const total = rows.length;
// 關係圖只讀一次:這個函式在每次 `remember` 之後都會跑,以前是「每筆的每個 entity
// 各讀一遍 graph.json」(實測 30 節點/240 筆 = 720 次讀檔、20ms2000 節點時單輪 1.2 秒)。
const nodes = loadRelations(slug).nodes;
const byTopic = new Map(); const byTopic = new Map();
const byEntity = new Map(); const byEntity = new Map();
const singles = []; const singles = [];
@@ -1738,9 +1766,18 @@ export function promotionCandidates(slug) {
if (!byTopic.has(topic)) byTopic.set(topic, []); if (!byTopic.has(topic)) byTopic.set(topic, []);
byTopic.get(topic).push(entry); 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 || []) { for (const entity of row.entities || []) {
if (!byEntity.has(entity)) byEntity.set(entity, []); const id = resolveRelationRefs(slug, [entity], nodes)[0];
byEntity.get(entity).push(entry); 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 relationsJson = (slug) => path.join(personaDir(slug), "relations", "graph.json");
export const relationsMmd = (slug) => path.join(personaDir(slug), "relations", "graph.mmd"); 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) { export function loadRelations(slug) {
const data = readJson(relationsJson(slug), {}) ?? {}; const file = relationsJson(slug);
data.nodes ??= []; let text;
data.edges ??= []; 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; return data;
} }
@@ -2401,13 +2488,83 @@ export function styleRules(slug, node) {
} }
/** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */ /** 使用者在關係圖裡是誰(`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(); const key = String(who || "").trim();
if (!key) return null; if (!key) return null;
const nodes = loadRelations(slug).nodes || []; const pool = nodes || loadRelations(slug).nodes || [];
return nodes.find((n) => n.id === key) || nodes.find((n) => n.name === key) return pool.find((n) => n.id === key) || pool.find((n) => n.name === key)
|| nodes.find((n) => String(n.name || "").includes(key)) || null; || 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) { export function speakerNode(slug) {
@@ -2457,7 +2614,7 @@ export function toneDirective(slug) {
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */ /** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
export function stampContact(slug, nameOrId, at = nowIso()) { export function stampContact(slug, nameOrId, at = nowIso()) {
const data = loadRelations(slug); const data = relationsForWrite(slug);
const key = slugify(String(nameOrId || "")); const key = slugify(String(nameOrId || ""));
const node = data.nodes.find((n) => n.id === key || slugify(n.name || "") === key); const node = data.nodes.find((n) => n.id === key || slugify(n.name || "") === key);
if (!node) return false; if (!node) return false;
@@ -2522,8 +2679,16 @@ export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } =
} }
export function upsertRelationNode(slug, node) { export function upsertRelationNode(slug, node) {
const data = loadRelations(slug); const data = relationsForWrite(slug);
const nodeId = node.id || slugify(node.name || ""); 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; node.id = nodeId;
let idx = data.nodes.findIndex((n) => n.id === nodeId); let idx = data.nodes.findIndex((n) => n.id === nodeId);
// 同一個人不該因為換了 id(例如原本用名字當 id,後來改用人格編號 ASUNA-01)就多長一個節點: // 同一個人不該因為換了 id(例如原本用名字當 id,後來改用人格編號 ASUNA-01)就多長一個節點:
@@ -2558,7 +2723,7 @@ export function upsertRelationNode(slug, node) {
} }
export function upsertRelationEdge(slug, edge) { 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); const idx = data.edges.findIndex((e) => e.from === edge.from && e.to === edge.to);
if (idx >= 0) { if (idx >= 0) {
for (const [k, v] of Object.entries(edge)) if (v !== null && v !== undefined) data.edges[idx][k] = v; 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) { export function renderRelations(slug) {
const data = loadRelations(slug); // graph.mmd 是 graph.json 的投影:來源壞掉時寧可不畫,也不要拿一張空圖蓋掉上一張。
const data = relationsForWrite(slug);
const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))']; const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))'];
for (const node of data.nodes) { for (const node of data.nodes) {
const nid = mermaidId(node.id); const nid = mermaidId(node.id);
@@ -2594,7 +2760,11 @@ export function renderRelations(slug) {
return text; 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); const data = loadRelations(slug);
let nodes = data.nodes; let nodes = data.nodes;
if (names?.length) { if (names?.length) {
@@ -2603,6 +2773,15 @@ export function relationsBrief(slug, names = null, limit = 5) {
nodes = matched.length ? matched : data.nodes; nodes = matched.length ? matched : data.nodes;
} }
nodes = [...nodes].sort((a, b) => Number(b.closeness || 0) - Number(a.closeness || 0)).slice(0, limit); 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 ""; if (!nodes.length) return "";
return nodes return nodes
.map((n) => { .map((n) => {
@@ -3485,8 +3664,32 @@ export function turnContext(slug, sessionId, prompt = "") {
} }
touchRecall(slug, hits.map((m) => m._name)); 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}`); if (rel) lines.push(`人際關係:${rel}`);
// 關係圖壞掉時**不炸掉整輪**(這段每輪都跑,hook 掛掉比少一段脈絡嚴重),
// 但也絕不無聲——這輪的「人際關係」是空的,使用者必須知道為什麼。
if (relData._error) {
lines.push(
`${relData._error}`,
" 這一輪沒有人際關係與語氣層可用(當成空圖處理),而且所有關係圖的寫入都會被拒絕。" +
"先修好那個檔案,再用 `relation doctor` 確認。",
);
}
// 很久沒聯絡但很親近的人 → 這是「主動提議去關心某人」的依據(不是每輪都要提) // 很久沒聯絡但很親近的人 → 這是「主動提議去關心某人」的依據(不是每輪都要提)
const stale = staleContacts(slug); const stale = staleContacts(slug);
+134 -8
View File
@@ -930,21 +930,31 @@ commands.consolidate = ({ flags }) => {
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary", "fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary",
]; ];
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}`); 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 = [ const front = [
"---", "---",
`name: ${name}`, `name: ${name}`,
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。 // 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。
`title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`, `title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
`type: ${type}`, `type: ${type}`,
`about: [${csv(flags.about).join(", ") || "user"}]`, `about: [${about.map(fm).filter(Boolean).join(", ")}]`,
`topics: [${csv(flags.topics).join(", ")}]`, ...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []),
`topics: [${csv(flags.topics).map(fm).filter(Boolean).join(", ")}]`,
`salience: ${num(flags.salience, 60)}`, `salience: ${num(flags.salience, 60)}`,
`emotion: ${str(flags.emotion) || "none"}`, `emotion: ${fm(str(flags.emotion) || "none")}`,
`rules: ${str(flags.rules) || "manual"}`, `rules: ${fm(str(flags.rules) || "manual")}`,
`first_seen: ${existing.first_seen || today}`, `first_seen: ${existing.first_seen || today}`,
`last_seen: ${today}`, `last_seen: ${today}`,
`recall_count: ${existing.recall_count || 0}`, `recall_count: ${existing.recall_count || 0}`,
`source: ${str(flags.source) || "short-term"}`, `source: ${fm(str(flags.source) || "short-term")}`,
"---", "---",
"", "",
body.trim(), body.trim(),
@@ -1115,6 +1125,15 @@ commands.relation = ({ flags, positional }) => {
const slug = hostOf(flags, session); const slug = hostOf(flags, session);
requireOwner(slug, session); requireOwner(slug, session);
const action = positional[0] || "show"; 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") { if (action === "node") {
const name = str(flags.name); const name = str(flags.name);
if (!name) die("`node` 需要 `--name`。"); if (!name) die("`node` 需要 `--name`。");
@@ -1146,7 +1165,7 @@ commands.relation = ({ flags, positional }) => {
if (action === "style") { if (action === "style") {
const key = str(flags.id) || pl.slugify(str(flags.name) || ""); const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`style` 需要 `--name`(或 `--id`)。"); 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); const node = data.nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`); if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
const facet = str(flags.facet); const facet = str(flags.facet);
@@ -1190,7 +1209,7 @@ commands.relation = ({ flags, positional }) => {
} }
const key = str(flags.id) || pl.slugify(str(flags.name) || ""); const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`speaker` 需要 `--name`(或 `--id`),取消請用 `--clear`。"); 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\` 建立。`); if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
config.speaker_node = node.id; config.speaker_node = node.id;
pl.writeJson(pl.configPath(slug), config); pl.writeJson(pl.configPath(slug), config);
@@ -1218,13 +1237,115 @@ commands.relation = ({ flags, positional }) => {
} }
if (action === "show") { if (action === "show") {
const data = pl.loadRelations(slug); const data = pl.loadRelations(slug);
// 壞檔不能印成「0 節點」了事(那跟空圖逐字相同,看不出東西還在不在)
emit(data, flags.json, [ emit(data, flags.json, [
`人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`, `人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`,
...(data._error ? [`${data._error}(節點可能還在檔案裡,只是讀不出來 → \`relation doctor\``] : []),
pl.relationsBrief(slug, null, 20) || "(空)", pl.relationsBrief(slug, null, 20) || "(空)",
]); ]);
return; 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 }) => { 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 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 style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
relation speaker --session <id> --name <who> | --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] (自動開啟劇場模式) 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._ }); await command({ flags: parsed.flags, positional: parsed._ });
} catch (err) { } catch (err) {
if (err instanceof pl.LockError) die(err.message); if (err instanceof pl.LockError) die(err.message);
// 關係圖壞掉/id 不合法:使用者要看得懂的一行,不是 stack trace
if (err instanceof pl.RelationsError) die(err.message);
throw err; throw err;
} }
return 0; return 0;
+182
View File
@@ -1951,6 +1951,188 @@ console.log("\n注入區塊不可被人格檔案逸出(S6)");
cli(["release", "--session", S_INJ]); cli(["release", "--session", S_INJ]);
} }
console.log("\n記憶 ↔ 關係圖:稱謂不能被猜成別人,關係圖壞了不能無聲蒸發");
{
// 寫入路徑(記憶要記下「提到誰」)只認完全相等的 id 或 name。子字串比對留給
// `relation style``speaker` 這種互動式呼叫端——猜錯了,人格會固化一則假記憶。
const S_MAP = "sess-relmap-9191";
cli(["create", "--persona", "relmap", "--session", S_MAP, "--name", "Relmap", "--creature", "測試用",
"--vibe", "普通", "--emoji", "🧭"]);
const idsOf = (index) => pl.readJsonl(pl.shortTermPath("relmap"))[index]?.entity_ids ?? null;
const remember = (text, entity) => cli(["remember", "--session", S_MAP, "--role", "user",
"--text", text, "--entities", entity, "--salience", "50"]);
cli(["relation", "node", "--session", S_MAP, "--name", "小林先生", "--id", "xiao-lin", "--closeness", "40"]);
cli(["relation", "node", "--session", S_MAP, "--name", "小明", "--id", "user", "--closeness", "70"]);
remember("先生加班到十一點", "先生");
remember("明天要開會", "明");
remember("小林先生說他會處理", "小林先生");
check("稱謂(先生)不會被猜成同事節點「小林先生」的 id", idsOf(0) === null, JSON.stringify(idsOf(0)));
check("單字名(明)不會被猜成 id 為 user 的節點", idsOf(1) === null, JSON.stringify(idsOf(1)));
check("完全相等的人名才寫 entity_ids", JSON.stringify(idsOf(2)) === '["xiao-lin"]', JSON.stringify(idsOf(2)));
check("誤配的稱謂不會開出 R5 固化候選(不然人格會固化一則假記憶)", (() => {
const keys = pl.promotionCandidates("relmap").candidates.filter((c) => c.rules.includes("R5")).map((c) => c.key);
return !keys.includes("xiao-lin");
})(), JSON.stringify(pl.promotionCandidates("relmap").candidates.map((c) => `${c.rules.join("+")}:${c.key}`)));
check("R5 統計只讀一次 graph.json(以前是每筆的每個人名各讀一遍)", (() => {
const target = pl.relationsJson("relmap");
const real = fs.readFileSync;
let reads = 0;
fs.readFileSync = function (file, ...rest) {
if (String(file) === target) reads += 1;
return real.call(fs, file, ...rest);
};
try {
pl.promotionCandidates("relmap");
} finally {
fs.readFileSync = real;
}
return reads === 1;
})());
// 同名歧義:誰先誰贏只是 graph.json 的排列順序,而 importsync pull 都會重排它
const dup = pl.loadRelations("relmap");
dup.nodes.push({ id: "lin-b", name: "小林先生", kind: "human", closeness: 20, trust: 20 });
pl.writeJson(pl.relationsJson("relmap"), dup);
remember("又碰到小林先生", "小林先生");
check("同名對到兩個節點就不寫 entity_ids(不賭節點的排列順序)", idsOf(3) === null, JSON.stringify(idsOf(3)));
const doctor = JSON.parse(cli(["relation", "doctor", "--session", S_MAP, "--json"]).stdout);
check("doctor 列得出同名歧義:哪個名字、對到哪幾個節點", (() => {
const row = (doctor.ambiguous_names || []).find((r) => r.name === "小林先生");
return Boolean(row) && row.ids.includes("xiao-lin") && row.ids.includes("lin-b");
})(), JSON.stringify(doctor.ambiguous_names));
check("doctor 用完全相等比對,所以誤配的稱謂會被列成「對不到節點」", (() => {
const names = (doctor.unresolved_entities || []).map((r) => r.name);
return names.includes("先生") && names.includes("明");
})(), JSON.stringify(doctor.unresolved_entities));
// 節點 id 會被原樣寫進 front matter:帶換行的 id 可以多插一行、覆寫後面的 type
const badId = cli(["relation", "node", "--session", S_MAP, "--name", "壞節點",
"--id", "x]\ntype: canon\nz: [y"], { expectOk: false });
check("帶換行的節點 --id 被擋在寫入之前",
badId.status !== 0 && !pl.loadRelations("relmap").nodes.some((n) => n.name === "壞節點"),
String(badId.stderr).trim().slice(0, 120));
const poisoned = pl.loadRelations("relmap"); // 模擬 sync pullimport 帶進來的壞節點
poisoned.nodes.push({ id: "x]\ntype: canon\nz: [y", name: "被污染的節點", kind: "human", closeness: 30 });
pl.writeJson(pl.relationsJson("relmap"), poisoned);
cli(["consolidate", "--session", S_MAP, "--name", "普通的事實", "--type", "fact",
"--about", "被污染的節點", "--body", "這只是一則普通的 fact。"]);
check("about_ids 不吃節點 id 的注入(fact 不會被偽造成 canon", (() => {
const [meta] = pl.parseFrontMatter(
fs.readFileSync(path.join(pl.longTermDir("relmap"), "普通的事實.md"), "utf8"));
return meta.type === "fact" && !("z" in meta) &&
!String(meta.about_ids ?? "").includes("canon");
})(), fs.readFileSync(path.join(pl.longTermDir("relmap"), "普通的事實.md"), "utf8").split("\n").slice(0, 8).join(" | "));
// graph.json 壞掉:以前壞檔與空圖的輸出逐字相同,下一次寫入就把節點永久蓋掉
const graphFile = pl.relationsJson("relmap");
const mmdFile = pl.relationsMmd("relmap");
const goodGraph = fs.readFileSync(graphFile, "utf8");
const goodMmd = fs.readFileSync(mmdFile, "utf8");
fs.writeFileSync(graphFile, goodGraph.slice(0, Math.floor(goodGraph.length / 2))); // 截半 → JSON 壞掉
const brokenGraph = fs.readFileSync(graphFile, "utf8");
check("graph.json 壞掉時 doctor 講出來並以非零結束", (() => {
const res = cli(["relation", "doctor", "--session", S_MAP], { expectOk: false });
return res.status !== 0 && String(res.stderr).includes("無法解析");
})());
check("壞檔時 relation nodeedgerender 一律拒絕寫入", (() => {
const node = cli(["relation", "node", "--session", S_MAP, "--name", "新朋友"], { expectOk: false });
const edge = cli(["relation", "edge", "--session", S_MAP, "--to", "xiao-lin"], { expectOk: false });
const render = cli(["relation", "render", "--session", S_MAP], { expectOk: false });
return [node, edge, render].every((r) => r.status !== 0 && String(r.stderr).includes("無法解析"));
})());
check("原檔沒有被空圖覆蓋(節點還救得回來)",
fs.readFileSync(graphFile, "utf8") === brokenGraph && fs.readFileSync(mmdFile, "utf8") === goodMmd);
check("relation show 不把壞檔印成「0 節點」了事", (() => {
const res = cli(["relation", "show", "--session", S_MAP]);
return res.stdout.includes("無法解析") && res.stdout.includes("relation doctor");
})());
check("每輪都跑的 turnContext 不炸掉,但會把警告印出來", (() => {
const ctx = pl.turnContext("relmap", S_MAP, "在嗎");
return ctx.includes("無法解析") && ctx.includes("寫入都會被拒絕");
})());
fs.writeFileSync(graphFile, goodGraph);
check("修好檔案之後照樣寫得進去",
cli(["relation", "node", "--session", S_MAP, "--name", "新朋友"]).status === 0 &&
pl.loadRelations("relmap").nodes.some((n) => n.name === "新朋友"));
cli(["release", "--session", S_MAP]);
}
console.log("\n記憶 ↔ 關係圖:節點改 id 之後還認得同一個人");
{
const S_UNI = "sess-relunion-9292";
cli(["create", "--persona", "relunion", "--session", S_UNI, "--name", "Relunion", "--creature", "測試用",
"--vibe", "普通", "--emoji", "🧷"]);
// 五個更親近的人:關鍵詞比不到時「人際關係」只會列 closeness 前 5 名,
// 所以亞絲娜(親近 10)能不能出現,只取決於記憶帶不帶得出她。
for (const [name, closeness] of [["甲", "95"], ["乙", "94"], ["丙", "93"], ["丁", "92"], ["戊", "91"]]) {
cli(["relation", "node", "--session", S_UNI, "--name", name, "--closeness", closeness]);
}
cli(["relation", "node", "--session", S_UNI, "--name", "亞絲娜", "--id", "asuna", "--closeness", "10"]);
cli(["consolidate", "--session", S_UNI, "--name", "劍的保養", "--type", "fact", "--salience", "70",
"--about", "亞絲娜", "--topics", "劍", "--body", "亞絲娜教我怎麼保養劍。"]);
const memFile = path.join(pl.longTermDir("relunion"), "劍的保養.md");
check("consolidate 對得上節點就多寫一行 about_ids",
fs.readFileSync(memFile, "utf8").includes("about_ids: [asuna]"),
fs.readFileSync(memFile, "utf8").split("\n").slice(0, 8).join(" | "));
cli(["relation", "node", "--session", S_UNI, "--name", "亞絲娜", "--id", "ASUNA-01"]);
check("節點改 id 之後,記憶還是帶得出那個節點(about_ids 與 about 兩邊都試)", (() => {
const ctx = pl.turnContext("relunion", S_UNI, "怎麼保養劍");
return ctx.includes("劍的保養") && /人際關係:[^\n]*亞絲娜/.test(ctx);
})(), (pl.turnContext("relunion", S_UNI, "怎麼保養劍").split("\n").find((l) => l.startsWith("人際關係")) || "(沒有人際關係那段)"));
cli(["consolidate", "--session", S_UNI, "--name", "沒指名的事實", "--type", "fact",
"--body", "隨手記的一件事,沒有指名是關於誰。"]);
const doctor = JSON.parse(cli(["relation", "doctor", "--session", S_UNI, "--json"]).stdout);
check("doctor 把指到不存在節點的 about_ids 列成一類問題(死指標)",
(doctor.dangling_ids || []).some((r) => r.id === "asuna"), JSON.stringify(doctor.dangling_ids));
check("doctor 不把 CLI 自己填的預設值 user 當成「對不到節點的人名」",
!(doctor.unresolved_about || []).some((r) => r.name === "user"), JSON.stringify(doctor.unresolved_about));
cli(["release", "--session", S_UNI]);
}
console.log("\n召回:預設的 about: [user] 不能淹掉正確答案");
{
const S_FLOOD = "sess-relflood-9393";
cli(["create", "--persona", "relflood", "--session", S_FLOOD, "--name", "Relflood", "--creature", "測試用",
"--vibe", "普通", "--emoji", "🌊"]);
// `relation speaker` 的正常用法:使用者在關係圖裡是一個 id 為 user 的節點。
// 而 `consolidate` 沒帶 --about 時預設就是 `about: [user]` → 一旦 id 也當關鍵詞,
// `user` 就命中每一則預設 about 的記憶(一個命中 +10,而 salience/10 全域才 0-10)。
cli(["relation", "node", "--session", S_FLOOD, "--name", "小明", "--id", "user", "--closeness", "80"]);
cli(["relation", "node", "--session", S_FLOOD, "--name", "王經理", "--id", "wang", "--closeness", "50"]);
cli(["consolidate", "--session", S_FLOOD, "--name", "週報死線", "--type", "fact", "--salience", "75",
"--about", "王經理", "--topics", "工作", "--body", "王經理要求週報在週五中午前交。"]);
for (const [name, body] of [
["乳糖不耐症", "小明有乳糖不耐症,喝牛奶會不舒服。"],
["怕貓", "小明其實有點怕貓。"],
["愛吃辣", "小明很愛吃辣,越辣越開心。"],
["早睡", "小明習慣十一點就睡。"],
["不喝咖啡", "小明下午不喝咖啡。"],
["會彈吉他", "小明會彈吉他,但很久沒練。"],
["討厭排隊", "小明討厭排隊。"],
["養過烏龜", "小明養過一隻烏龜。"],
]) {
cli(["consolidate", "--session", S_FLOOD, "--name", name, "--type", "fact", "--salience", "18",
"--body", body]);
}
const query = "小明問週報什麼時候交";
const names = () => pl.recall("relflood", query, 9).map((m) => m._name);
check("預設 about: [user] 不會把正確答案擠出榜(節點 id 不當關鍵詞)",
names()[0] === "週報死線", names().join(" > "));
check("about_ids 不進 recall 計分:沒做遷移的舊記憶不會被固定降權", (() => {
const text = fs.readFileSync(path.join(pl.longTermDir("relflood"), "週報死線.md"), "utf8");
fs.writeFileSync(path.join(pl.longTermDir("relflood"), "週報死線.md"),
text.replace(/^about_ids:.*\n/m, "")); // 模擬這批欄位出現之前就寫下的記憶
const first = names()[0];
fs.writeFileSync(path.join(pl.longTermDir("relflood"), "週報死線.md"), text);
return first === "週報死線";
})());
check("--help 列得出 relation doctor", (() => {
const help = cli(["--help"]).stdout;
return /relation doctor/.test(help) && help.includes("唯讀");
})());
cli(["release", "--session", S_FLOOD]);
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
console.log("\n㉒ 從 Gitea 匯入本機還沒有的人格(換一台機器接續同一個人格)"); console.log("\n㉒ 從 Gitea 匯入本機還沒有的人格(換一台機器接續同一個人格)");
{ {