feat(relation): 記憶接回關係圖 + relation doctor 健檢(0.1.1) #14
+221
-18
@@ -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 次讀檔、20ms;2000 節點時單輪 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 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) {
|
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
@@ -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_ids/entity_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 matter/jsonl 的值不保證是陣列(`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_ids/entity_ids(${ambiguousRows.length}):`);
|
||||||
|
lines.push(...(ambiguousRows.length
|
||||||
|
? ambiguousRows.map(([n, ids]) => ` - ${n} → ${ids.join("/")}(改掉其中一個的名字或 id)`)
|
||||||
|
: [" (無)"]));
|
||||||
|
lines.push(`about_ids/entity_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;
|
||||||
|
|||||||
@@ -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 的排列順序,而 import/sync 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 pull/import 帶進來的壞節點
|
||||||
|
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 node/edge/render 一律拒絕寫入", (() => {
|
||||||
|
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 匯入本機還沒有的人格(換一台機器接續同一個人格)");
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user