diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a23bfe1..0dfd54d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.1.0", + "version": "0.1.1", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0a0e302..1adc6f2 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.1.0", + "version": "0.1.1", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index 1bf648e..8edff0c 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.1.0", + "version": "0.1.1", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills" } diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index beca8ba..4bb5a7b 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -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); diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 975f0b8..8b2ca33 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -930,21 +930,31 @@ commands.consolidate = ({ flags }) => { "fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary", ]; if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`); + const about = csv(flags.about).length ? csv(flags.about) : ["user"]; + // `about` 是自由字串(原樣保留);對得上關係圖節點的才多寫一行 id,之後 recall 與 + // 「人際關係」那段才知道講的是同一個人。一個都對不上就不輸出這一行。 + // `resolveRelationRefs` 已經把不能寫進 front matter 的 id 濾掉了(帶換行的節點 id + // 可以在這裡多插一行、覆寫下面的 `type`,把一則 fact 變成不該被遺忘的 canon)。 + const aboutIds = pl.resolveRelationRefs(slug, about); + // front matter 的每個值都只能是一行:帶換行的旗標值同樣能偽造出別的欄位, + // 而 `parseFrontMatter` 取後出現的值 → 後面宣告的 type/salience 會被前面偽造的蓋掉。 + const fm = (value) => pl.injectSafeLine(value); const front = [ "---", `name: ${name}`, // 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。 `title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`, `type: ${type}`, - `about: [${csv(flags.about).join(", ") || "user"}]`, - `topics: [${csv(flags.topics).join(", ")}]`, + `about: [${about.map(fm).filter(Boolean).join(", ")}]`, + ...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []), + `topics: [${csv(flags.topics).map(fm).filter(Boolean).join(", ")}]`, `salience: ${num(flags.salience, 60)}`, - `emotion: ${str(flags.emotion) || "none"}`, - `rules: ${str(flags.rules) || "manual"}`, + `emotion: ${fm(str(flags.emotion) || "none")}`, + `rules: ${fm(str(flags.rules) || "manual")}`, `first_seen: ${existing.first_seen || today}`, `last_seen: ${today}`, `recall_count: ${existing.recall_count || 0}`, - `source: ${str(flags.source) || "short-term"}`, + `source: ${fm(str(flags.source) || "short-term")}`, "---", "", body.trim(), @@ -1115,6 +1125,15 @@ commands.relation = ({ flags, positional }) => { const slug = hostOf(flags, session); requireOwner(slug, session); const action = positional[0] || "show"; + // graph.json 壞掉時「空圖」與「壞檔」不能長得一樣:要做決定的 action 直接擋下來 + // (放它過去就是拿一張空圖去覆蓋原檔)。doctor 自己會報告,不走這裡。 + const graphOrDie = () => { + const data = pl.loadRelations(slug); + if (data._error) { + die(`${data._error}\n 先修好這個檔案(或把它移開讓人格從空的關係圖重新開始),再用 \`relation doctor\` 確認。`); + } + return data; + }; if (action === "node") { const name = str(flags.name); if (!name) die("`node` 需要 `--name`。"); @@ -1146,7 +1165,7 @@ commands.relation = ({ flags, positional }) => { if (action === "style") { const key = str(flags.id) || pl.slugify(str(flags.name) || ""); if (!key) die("`style` 需要 `--name`(或 `--id`)。"); - const data = pl.loadRelations(slug); + const data = graphOrDie(); const node = data.nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key); if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`); const facet = str(flags.facet); @@ -1190,7 +1209,7 @@ commands.relation = ({ flags, positional }) => { } const key = str(flags.id) || pl.slugify(str(flags.name) || ""); if (!key) die("`speaker` 需要 `--name`(或 `--id`),取消請用 `--clear`。"); - const node = pl.loadRelations(slug).nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key); + const node = graphOrDie().nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key); if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`); config.speaker_node = node.id; pl.writeJson(pl.configPath(slug), config); @@ -1218,13 +1237,115 @@ commands.relation = ({ flags, positional }) => { } if (action === "show") { const data = pl.loadRelations(slug); + // 壞檔不能印成「0 節點」了事(那跟空圖逐字相同,看不出東西還在不在) emit(data, flags.json, [ `人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`, + ...(data._error ? [`⚠ ${data._error}(節點可能還在檔案裡,只是讀不出來 → \`relation doctor\`)`] : []), pl.relationsBrief(slug, null, 20) || "(空)", ]); return; } - die(`未知 action:${action}(可用 node/edge/style/speaker/render/show)`); + // 健檢:記憶裡的人名與關係圖節點對不對得上。**唯讀**——只印報告,一個檔案都不改。 + if (action === "doctor") { + const graph = pl.loadRelations(slug); + // 壞檔不能報「節點 0 個、0 問題」(那跟空圖逐字相同,exit 也一樣是 0)。 + if (graph._error) { + die( + `${graph._error}\n 健檢無法進行:這不是「沒有關係圖」,是「讀不出來」。` + + `\n 所有關係圖的寫入都會被拒絕(免得覆蓋掉原檔),修好之後再跑一次。`, + ); + } + const nodes = graph.nodes; + const mentioned = new Set(); // 有記憶提到、且對得上節點的 id + const missingAbout = new Map(); + const missingEntities = new Map(); + const dangling = new Map(); // about_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 }) => { @@ -2210,6 +2331,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C relation node|edge|render|show --session [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity] relation style --session --name [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear] relation speaker --session --name | --clear (使用者在關係圖裡是誰 → 決定語氣層) + relation doctor --session [--json] 記憶裡的人名對不對得上關係圖節點:對不到的人名、 + 同名歧義、指到不存在節點的 id、沒人提到的節點。 + **唯讀**(一個檔案都不改);graph.json 壞掉時以非零結束 多人格對話: invite --session --guest [--guests ] [--host --room --topic] (自動開啟劇場模式) @@ -2284,6 +2408,8 @@ async function main(argv) { await command({ flags: parsed.flags, positional: parsed._ }); } catch (err) { if (err instanceof pl.LockError) die(err.message); + // 關係圖壞掉/id 不合法:使用者要看得懂的一行,不是 stack trace + if (err instanceof pl.RelationsError) die(err.message); throw err; } return 0; diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 15a2e87..4e6cde6 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -1951,6 +1951,188 @@ console.log("\n注入區塊不可被人格檔案逸出(S6)"); 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 匯入本機還沒有的人格(換一台機器接續同一個人格)"); { diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index ea97020..fec23f8 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -248,13 +248,49 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" remember \ 顯著度基準:**80+** 承諾/秘密/重大事件;**60–79** 偏好、明確情緒事件; **40–59** 一般脈絡;**<40** 閒聊(會很快被淘汰)。 -若這輪出現新的人/新的關係變化 → 順手更新人際關係圖: +**人名要對得上**:`--entities`(短期)與 `--about`(長期)寫的人名, +必須跟關係圖節點的 `name` 或 `id` **一字不差**——解析只認完全相等, +子字串與簡稱都不算(今天寫「小林」明天寫「林先生」,後者解析不到任何節點), +同名撞到多個節點時視為歧義、直接不寫 id。 + +對不上**不會報錯**,只是那筆記憶少一個 `entity_ids`/`about_ids`: +R5(同一個人 ≥ 2 筆)不會觸發,`` 也不會附上那個人的節點摘要。 +先看 `` 的「人際關係:」那段照抄節點名;事後要查用 +`relation doctor`(見 `/jsc-persona:persona-relation`)。 + +#### 新人物一定要當場進關係圖(硬規則) + +**觸發條件(兩個都成立才做)**: + +1. 這輪出現的人物**不在** `` 的「人際關係:」那段裡;**且** +2. **人格認識這個人**——設定(`IDENTITY.md`/`SOUL.md`/`canon` 記憶)裡有他, + 或這輪對話已經講清楚他是誰(誰的什麼人、做什麼的)。 + +只是被提到一個陌生名字、人格根本不知道那是誰 → **不要建節點**, +寫進短期記憶的 `--entities` 就好;等他再出現、講清楚了再建。 + +**動作**:當場建節點,`bond`/`closeness`/`trust` 依 `reference/closeness.md` 判斷 +(那份表是**初次建節點**用的,一輪內查完就填,不要填保守初值): ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \ - --persona --session --name "小林" --kind human --closeness 25 --trust 30 --note "使用者的同事" + --persona --session --name "小林" --kind human \ + --bond ally --closeness 45 --trust 35 --note "使用者的同事,Q3 專案 PM;依據:叫全名+直接請求" ``` +- `--bond` **不可省略**:語氣層是 `bond` × `closeness` 算的,省略會被猜成生人,整輪距離感就歪了。 +- 節點的 `name` 要跟這輪 `--entities` 寫的人名**完全一樣**(差一個字就解析不到)。 +- `name` 用**會被說出口的完整稱呼**(「小林」「結城明日奈」),不要用「明」「先生」這種 + 單字或稱謂當節點名——那種名字誰都套得上,不相干的句子會被算到同一個人頭上。 +- 其他人格用 `--kind persona --id <他的 slug>`。 + +**不要宣告這件事。** 這屬於第 ⑥ 步(記憶回寫),不佔第 ⑤ 步的 1–3 句, +也不要在回覆裡講「我把某某加進關係圖了」——那是系統動作,不是人會說的話。 +使用者主動問起才說。 + +關係發生**質變**(同事變朋友、決裂、信任被打破)→ 除了更新節點, +同時固化一則 `relationship` 長期記憶(見 `/jsc-persona:persona-memory`)。 + 若形成一條需要追蹤的推理鏈(未證實的猜測、待驗證的假設)→ 開思維導圖,別寫進長期記憶: ```bash diff --git a/skills/persona-chat/reference/semantic.md b/skills/persona-chat/reference/semantic.md index 0f53be7..4d51686 100644 --- a/skills/persona-chat/reference/semantic.md +++ b/skills/persona-chat/reference/semantic.md @@ -17,7 +17,14 @@ ## 3. 實體 entities -人/專案/地點/時間。人名一律同步到人際關係圖(`persona.mjs relation node`)。 +人/專案/地點/時間。人名要寫成關係節點的 `name` 或 `id`,且**一字不差**: +解析只接受完全相等,簡稱與加稱謂(「林先生」對「小林」)解析不到、也不會報錯, +同名撞到多個節點時算歧義、直接不寫 `entity_ids`。寫之前先照抄 `` 的節點名。 + +**認識的人**才同步到人際關係圖(`persona.mjs relation node`,親密度查 +`persona-relation/reference/closeness.md`);只是被提到、你根本不知道那是誰的名字, +留在 `entities` 就好,不要建節點。 +建節點時 `name` 用會被說出口的完整稱呼,不要用單字或稱謂(「明」「先生」)當節點名。 ## 4. 情感極性與強度 diff --git a/skills/persona-memory/SKILL.md b/skills/persona-memory/SKILL.md index 1098f64..f9b4405 100644 --- a/skills/persona-memory/SKILL.md +++ b/skills/persona-memory/SKILL.md @@ -36,7 +36,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session ` 不附節點摘要、R5 不觸發。 + 用 `relation doctor` 查——對不上的人名會出現在「長期記憶 `about` 對不到節點」那段, + **不是**孤兒那段(孤兒 `unmentioned_nodes` 反過來,是有節點卻沒有任何記憶提到)。 - `--rules` 記下是哪條條件把它送上來的(之後回頭檢討記憶品質很有用)。 - `--forget 40` 可在固化後順手淘汰顯著度 < 40 的短期記憶(R6 容量壓力時特別有用)。 - 內文請寫「依據」與「還不確定」,讓下次的自己知道這則有多可靠。 diff --git a/skills/persona-relation/SKILL.md b/skills/persona-relation/SKILL.md index f02a4ec..0f43f8b 100644 --- a/skills/persona-relation/SKILL.md +++ b/skills/persona-relation/SKILL.md @@ -21,6 +21,28 @@ description: 維護人格的人際關係圖:新增或更新人物/人格/群 - 人格自己是 `self`,不必建節點。其他人格用 `--kind persona`、`--id <他的 slug>`。 - `kind` 是「這是什麼東西」,`bond` 是「跟我什麼關係」——**語氣只能靠後者分**。 +### 記憶怎麼指回節點 + +記憶那一側寫的是**人名字串**,節點 id 由 CLI 在寫入時自動解析並存進對應欄位: + +| 記憶 | 人名欄位(你寫的) | id 欄位(CLI 自動解析) | +| --- | --- | --- | +| 長期記憶 | `about` | `about_ids` | +| 短期記憶 | `entities` | `entity_ids` | + +解析規則是**完全相等**:你寫的字串要跟某個節點的 `id` 或 `name` **一字不差**才算命中。 +子字串、簡稱、加稱謂都不算(節點叫「小林」,寫「林先生」「小林哥」一律對不到); +**同名有多個候選時視為歧義,直接不寫 id**,不會替你挑一個。 + +對不上**不會報錯**,只會安靜地少一個 id,後果是: +`` 不附那個人的節點摘要、R5(同一個 entity ≥ 2 筆)不會觸發。 +要查只能跑 `relation doctor`——它會把這種人名列在「對不到節點」的那兩段, +**不是**孤兒那段(孤兒 `unmentioned_nodes` 是相反的情況:有節點、卻沒有任何記憶提到他)。 + +命名實務:節點的 `name` 用**會被說出口的完整稱呼**(「小林」「結城明日奈」), +不要用「明」「先生」這種單字或稱謂當節點名——那種名字誰都套得上, +不同的人會被寫成同一個字串、對到同一個節點。 + ## bond 與語氣層(最容易漏的一步) `bond` × `closeness` 查表算出**語氣層**,那是距離感;情緒只負責溫度與句長,不會蓋過它。 @@ -42,6 +64,22 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \ > > 自我檢查:這句話換成對一個「禮貌層」的人說也毫無違和 → 就代表你沒進到那一層。 +### 初次建節點:親密度從對話判斷(`reference/closeness.md`) + +新節點的 `bond`/`closeness`/`trust` **不給保守初值**,從對話裡的**稱呼與語氣**判斷。 +查表流程在 `reference/closeness.md`(關係詞 → 稱呼 → 語氣三軸,附裁決順序與數值帶)。 + +什麼時候查它: + +| 情境 | 查不查 | +| --- | --- | +| 這輪出現關係圖裡還沒有的人,要當場建節點 | **查**(persona-chat 第 ⑥ 步的硬規則) | +| 補建一個以前漏掉的人(`relation doctor` 撈出來、而且確認人格認識他) | **查**,用他歷來記憶裡的稱呼與語氣當依據 | +| 節點已經存在,這輪有互動 | **不查**,用下面「調整幅度(單次)」累積 | +| 使用者直接說「他跟你比較不熟」之類的指定 | **不查**,照他說的填 | + +已存在的節點每輪重算會讓語氣忽遠忽近——那張表只給第一次。 + ### 個人化的稱呼規則(`style`) 他本人要求過的講法優先於查表——查表算距離,`style` 記「他要你怎麼叫他」: @@ -85,6 +123,31 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation render --session ``` +### 健檢:`relation doctor`(唯讀) + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation doctor --session +``` + +只讀不寫,分**三段**列出(`--json` 可取結構化輸出): + +| 段 | 它報什麼 | 意思 | 怎麼處理 | +| --- | --- | --- | --- | +| 1 | 長期記憶 `about` 對不到節點的人名 | 記憶那側寫了人名,找不到 `id`/`name` 完全相等的節點 | 人格認識他 → 補建節點(查 `reference/closeness.md`);只是被提到、不知道是誰 → 不用管;名字寫錯 → 改成節點的正式寫法 | +| 2 | 短期記憶 `entities` 對不到節點的人名 | 同上,來源是短期記憶 | 同上。這一段最常見的是簡稱/加稱謂(「林先生」對不到「小林」) | +| 3 | 關係圖裡有節點、但沒有任何記憶提到(`--json` 欄位 `unmentioned_nodes`) | 建了節點卻從沒被記憶引用 | 先核對 `name`/`id` 跟記憶那側的寫法是否一致(多半是這個);真的久沒互動就照衰減調 `closeness` | + +第 1、2 段與第 3 段是**相反**的兩件事,不要混著看:前者是「記憶指不到節點」,後者才是孤兒節點。 + +另外會一併報: + +- **同名歧義**:有多個節點的 `name`/`id` 撞同一個字串 → 那個名字永遠解析不出 id,改掉其中一個節點的 `name`(加姓、加辨識詞)。 +- **解析不到節點的 `about_ids`/`entity_ids`**:id 欄位裡留著已被刪除或改名的節點 id → 更新那則記憶,或把節點補回來。 +- `graph.json` **解析失敗**時會明講是壞檔並以**非零 exit** 結束,不會裝成空圖。 + 所以 doctor 非零離開 = 檔案有問題,先修檔再說;「什麼都沒報」才是真的乾淨。 + +整理記憶(`/jsc-persona:persona-memory`)或睡眠收尾前跑一次,比等問題浮出來便宜。 + ## 調整幅度(單次) | 事件 | closeness | trust | @@ -100,7 +163,9 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation render --session ⚠️ `bond` 填錯比 `closeness` 填錯嚴重得多:語氣層是 `bond` × `closeness` 查表算的, +> `bond` 一歪,整輪的距離感就歪(至親掉進「禮貌」層,講話像對戰友報告)。 +> 數字可以之後慢慢修,`bond` 要一次填對。 + +--- + +## 0. 先確認一件事:這是「誰的」關係 + +`bond` 的主語永遠是**人格自己**(`self`),不是說話的人。 + +| 對話裡出現 | 節點的 `bond` | 為什麼 | +| --- | --- | --- | +| 使用者說「我老婆美咲」 | **不是** `partner` | 那是使用者的伴侶。對人格來說多半是 `stranger`/`friend` | +| 人格設定裡「我的妻子美咲」 | `partner` | 主語是人格自己 | + +第三人的關係要記在**連線**上,不是節點上: +`relation edge --from user --to misaki --label "夫妻" --affinity 95`。 +節點的 `closeness` 只回答「**我**跟他多近」。 + +## 1. 三軸訊號與裁決順序 + +同一輪讀到互相矛盾的訊號時,**上面的贏下面的**: + +1. **關係詞**(句子直接定位關係)→ 決定 `bond` 與距離帶,最強 +2. **稱呼**(怎麼叫他)→ 沒有關係詞時用它定距離帶 +3. **語氣**(怎麼對他講話)→ 只在帶內微調 ±5~10,**不跨帶** + +同一軸有多個訊號 → 取**最近一次**出現的那個(人會改口,最新的才是現況)。 + +## 2. 關係詞(最強訊號) + +| 句子裡的詞 | `bond` | 距離帶 | +| --- | --- | --- | +| 我老婆/我先生/我伴侶/我男(女)朋友 | `partner` | 家人帶 | +| 我兒子/我女兒/我孩子 | `child` | 家人帶 | +| 我爸/我媽/我父親/我母親 | `parent` | 家人帶 | +| 我哥/我姐/我弟/我妹/我兄弟(有血緣) | `sibling` | 家人帶 | +| 我朋友/我兄弟(沒血緣)/我死黨 | `friend` | 朋友帶(說「最好的朋友」「認識十年」→ 摯友帶) | +| 我同事/同一組的/我下屬 | `ally` | 朋友帶 | +| 我主管/我老闆/我隊長 | `ally`(敬重且從他身上學東西 → `mentor`) | 朋友帶偏低 | +| 我老師/我師父/帶我的人 | `mentor` | 認識帶~摯友帶(看有沒有並肩過) | +| 我對手/我競爭對手/死對頭 | `rival` | **看有沒有交手**:只是敵人 → 認識帶;長期互相認可 → 摯友帶 | +| 他朋友/他們那邊的人/某某的同事 | `stranger` | 生人帶(那是別人的關係,不是我的) | + +`rival` 不等於疏遠——長年互相認定的對手可以 `closeness 85`。 +恨與親近是兩件事,那筆恨要寫在 `note` 與 `trust`,不要用 `closeness` 表達。 + +## 3. 稱呼軸(沒有關係詞時用這個定帶) + +| 怎麼叫他 | 例 | 距離帶 | +| --- | --- | --- | +| 綽號/暱稱/疊字/略稱 | 阿明、小結、絅(單字暱稱) | 摯友帶~家人帶 | +| 直呼名字(去姓、不加敬語) | 明日奈、俊彥 | 朋友帶~摯友帶 | +| 全名 | 結城明日奈 | 認識帶(正式,但已經知道他是誰) | +| 姓+敬語 | 結城先生、林小姐、桐谷桑 | 認識帶(禮貌距離) | +| 頭銜職稱 | 老師、社長、隊長 | 認識帶~朋友帶。**歧義**:職稱在並肩久了的關係裡也會留著(叫「隊長」的戰友可以很親)→ 交給語氣軸裁決 | +| 第三人稱代稱、不指名 | 那個人、他們那邊的人、某某的朋友 | 生人帶 | + +同一個人被用兩種稱呼(正式場合叫全名、私下叫暱稱)→ 取**私下那個**,那才是真距離。 + +## 4. 語氣軸(帶內微調,最多 ±10) + +| 語氣 | 例 | 調整 | +| --- | --- | --- | +| 命令、指派 | 「這個你去處理」 | +5~+10(**歧義**:也可能只是上下關係。搭配敬語就是階級不是親近 → 不加) | +| 直接請求(沒鋪陳、沒道歉) | 「幫我看一下」 | +5 | +| 玩笑、吐槽、當面抱怨對方 | 「你又遲到」 | +10(能開玩笑是最可靠的親近訊號之一) | +| 客套、鋪陳、過度致謝 | 「不好意思麻煩您」 | −5~−10 | +| 迴避、轉移話題、只給最短回答 | 「就那樣」「沒什麼」 | −10,且 `trust` 再往下壓 5 | + +## 5. 距離帶 → 數值 + +| 距離帶 | 判斷依據 | `closeness` | 常見 `bond` | +| --- | --- | --- | --- | +| **生人帶** | 知道他是誰(誰的什麼人),但跟人格沒有互動描述 | 10–25 | `stranger` | +| **認識帶** | 認識但不熟,有禮貌距離 | 25–40 | `stranger`/`ally` | +| **朋友帶** | 朋友、同事,會一起做事 | 45–65 | `friend`/`ally` | +| **摯友帶** | 摯友、長期並肩、共同經歷 | 75–90 | `friend`/`mentor`/`rival` | +| **家人帶** | 家人、伴侶、子女 | 85–97 | `partner`/`child`/`parent`/`sibling` | + +帶內取值:訊號只有一個 → 取**下緣**;三軸互相印證 → 往上緣走。 +`100` 留白不要用,關係還有成長空間。 + +## 6. `trust` 怎麼填 + +**預設 `trust` = `closeness` − 10**(範圍 −5~−15,訊號少就扣多一點)。 + +理由要記住:**親近不等於信得過。** 天天見面的同事、吵得很熟的家人, +都可能是「很熟但不會把重要的事交給他」。兩個數字獨立,不要圖方便填一樣。 + +例外 —— 對話裡出現**明確託付**,才把 `trust` 拉到跟 `closeness` 齊平或 +5: + +- 交代事情(「這件事交給你」「你幫我盯著」) +- 講秘密、講還沒對別人講的事 +- 把決定權交出去(「你決定就好」) + +反向訊號 → 再往下壓:講過話不算、有隱瞞、對他的說法要再查一次 → `trust` 比 `closeness` 低 25 以上, +並在 `note` 寫下是哪件事。 + +## 7. 已經確認認識他,但親密度判斷不出來 + +**適用範圍只有一種情況**:已經確認「人格認識這個人」(設定/`canon` 裡有他,或這輪已講清楚他是誰), +但三軸訊號太少,`bond`/`closeness`/`trust` 填不出來。這時候**照樣建節點**,填低值並在 `note` 註明依據不足。 + +**反面(更常見):不知道那是誰就不要建節點。** 只是句子裡被提到一個名字、 +人格根本不知道他是誰的什麼人 → 只寫進短期記憶的 `entities`,**不建節點**,等他再出現、講清楚了再建。 +這不是潔癖:短名與稱謂節點一多,名字解析就會誤配(「我先生」被寫成同事節點的 id 這種事發生過), +寧可少一個節點,也不要多一個對錯人的節點。 + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \ + --session --name "小林" --kind human \ + --bond stranger --closeness 15 --trust 10 \ + --note "依據不足:使用者的同事(2026-08-03 講到 Q3 專案時提過),但沒有稱呼與語氣訊號可判斷距離" +``` + +規則: + +- 先過一次「認識嗎」這關,**再**談數字填多少;不認識就不會走到這一節。 +- `bond` 拿不準 → 填 `stranger`(語氣層退到「禮貌」,那是**可以修正**的錯;填成 `partner` + 卻其實是陌生人,是**當場失禮**的錯)。 +- `note` 一定要寫「依據不足」四個字,加上**他是誰的依據**與出處那句話, + 下次互動時你才知道這組數字不可信、要重估。 +- `name` 用會被說出口的完整稱呼,不要拿「明」「先生」這種單字或稱謂當節點名(會誤配)。 +- 之後每次互動用 SKILL.md 的「調整幅度(單次)」慢慢調,**不要**回頭重跑這張表。 + +## 8. 一輪之內的四步 + +1. 這輪有**關係詞**嗎?有 → `bond` 與距離帶都定了,跳到第 3 步。 +2. 沒有 → 用**稱呼軸**定距離帶,`bond` 從該帶的「常見 `bond`」裡挑最貼近的。 +3. 用**語氣軸**在帶內 ±5~10 定出 `closeness`(不跨帶)。 +4. `trust` = `closeness` − 10;有明確託付才拉平。寫 `note`(依據哪一句、日期)。