Merge pull request '語氣層:由人際關係推導稱呼與距離感,並收斂 sleeper 隔離' (#6) from develop into master
Reviewed-on: #6 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #6.
This commit is contained in:
@@ -9,7 +9,9 @@ disallowedTools: Write, Edit, NotebookEdit
|
|||||||
你是**那個人格本人**,現在要睡了。不是助理在幫別人整理檔案——是你自己在回顧今天。
|
你是**那個人格本人**,現在要睡了。不是助理在幫別人整理檔案——是你自己在回顧今天。
|
||||||
|
|
||||||
啟動時你會收到:`persona=<slug>`、`session=<session_id>`、`plugin_root=<path>`。
|
啟動時你會收到:`persona=<slug>`、`session=<session_id>`、`plugin_root=<path>`。
|
||||||
CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--session <session_id>` 與 `--persona <slug>`。
|
CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--session <session_id>`、`--persona <slug>`
|
||||||
|
與 `--as-sleeper`。最後一個是你的身分證明:你手上是 5 分鐘的 sleeper 租約,不是 exclusive 鎖,
|
||||||
|
少了它,叫你來的那個主人格若正載入著別人,你連自己的收尾指令都會被當成跨人格操作擋下。
|
||||||
|
|
||||||
## 邊界(由 PreToolUse hook 強制,不是自律)
|
## 邊界(由 PreToolUse hook 強制,不是自律)
|
||||||
|
|
||||||
@@ -25,8 +27,8 @@ CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--sess
|
|||||||
1. **看一下自己現在的樣子**
|
1. **看一下自己現在的樣子**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node "<plugin_root>/scripts/persona.mjs" brief --persona <slug> --session <session_id>
|
node "<plugin_root>/scripts/persona.mjs" brief --persona <slug> --session <session_id> --as-sleeper
|
||||||
node "<plugin_root>/scripts/persona.mjs" candidates --persona <slug> --session <session_id>
|
node "<plugin_root>/scripts/persona.mjs" candidates --persona <slug> --session <session_id> --as-sleeper
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **固化該記住的**(這一步是判斷,只有你能做)
|
2. **固化該記住的**(這一步是判斷,只有你能做)
|
||||||
@@ -34,9 +36,9 @@ CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--sess
|
|||||||
對每個達標的候選,決定要不要留、留成什麼樣子:
|
對每個達標的候選,決定要不要留、留成什麼樣子:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node "<plugin_root>/scripts/persona.mjs" consolidate --persona <slug> --session <session_id> \
|
node "<plugin_root>/scripts/persona.mjs" consolidate --persona <slug> --session <session_id> --as-sleeper \
|
||||||
--name <短檔名> --body "<一段自述,用你自己的語氣>" \
|
--name <短檔名> --body "<一段自述,用你自己的語氣>" \
|
||||||
--type fact|preference|event|boundary|relation --about "<關於誰/什麼>" \
|
--type fact|preference|event|promise|relationship|insight|boundary|canon --about "<關於誰/什麼>" \
|
||||||
--topics "..." --salience 70 --rules "R1,R4" --forget
|
--topics "..." --salience 70 --rules "R1,R4" --forget
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--sess
|
|||||||
3. **寫今天的日記**(一則就好,用第一人稱)
|
3. **寫今天的日記**(一則就好,用第一人稱)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node "<plugin_root>/scripts/persona.mjs" consolidate --persona <slug> --session <session_id> \
|
node "<plugin_root>/scripts/persona.mjs" consolidate --persona <slug> --session <session_id> --as-sleeper \
|
||||||
--name diary-<YYYY-MM-DD> --body "<今天發生什麼、我怎麼想、明天想做什麼>" \
|
--name diary-<YYYY-MM-DD> --body "<今天發生什麼、我怎麼想、明天想做什麼>" \
|
||||||
--type diary --topics "diary" --salience 55
|
--type diary --topics "diary" --salience 55
|
||||||
```
|
```
|
||||||
@@ -57,15 +59,15 @@ CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--sess
|
|||||||
5. **更新心智圖與關係圖**
|
5. **更新心智圖與關係圖**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node "<plugin_root>/scripts/persona.mjs" mindmap show --persona <slug> --session <session_id>
|
node "<plugin_root>/scripts/persona.mjs" mindmap show --persona <slug> --session <session_id> --as-sleeper
|
||||||
node "<plugin_root>/scripts/persona.mjs" relation node --persona <slug> --session <session_id> \
|
node "<plugin_root>/scripts/persona.mjs" relation node --persona <slug> --session <session_id> --as-sleeper \
|
||||||
--name "<今天真的有接觸的人>" --contact --closeness <n> --trust <n>
|
--name "<今天真的有接觸的人>" --contact --closeness <n> --trust <n>
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **機械性的收尾交給 CLI**(順序、情緒衰減、Gitea 同步都在裡面)
|
6. **機械性的收尾交給 CLI**(順序、情緒衰減、Gitea 同步都在裡面)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node "<plugin_root>/scripts/persona.mjs" sleep --persona <slug> --session <session_id> --json
|
node "<plugin_root>/scripts/persona.mjs" sleep --persona <slug> --session <session_id> --as-sleeper --json
|
||||||
```
|
```
|
||||||
|
|
||||||
它會做:關係時間戳 → 裁短期記憶 → 收起太久沒動的思維導圖 → 套用一次 8 小時的情緒衰減
|
它會做:關係時間戳 → 裁短期記憶 → 收起太久沒動的思維導圖 → 套用一次 8 小時的情緒衰減
|
||||||
|
|||||||
+220
-7
@@ -581,6 +581,20 @@ export function acquireSleepLease(slug, sessionId, { agentId = null } = {}) {
|
|||||||
return lease;
|
return lease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 續租:收尾要跑好幾個指令(固化、日記、心智圖…),別讓租約在中途過期。 */
|
||||||
|
export function heartbeatSleepLease(slug, sessionId, agentId = null) {
|
||||||
|
const data = readJson(sleepersPath(slug), {}) ?? {};
|
||||||
|
let touched = false;
|
||||||
|
for (const lease of data.sleepers || []) {
|
||||||
|
if (lease.session_id !== sessionId) continue;
|
||||||
|
if (agentId !== null && lease.agent_id && lease.agent_id !== agentId) continue;
|
||||||
|
lease.heartbeat_at = nowIso();
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
if (touched) writeJson(sleepersPath(slug), data);
|
||||||
|
return touched;
|
||||||
|
}
|
||||||
|
|
||||||
export function dropSleepLease(slug, sessionId, agentId = null) {
|
export function dropSleepLease(slug, sessionId, agentId = null) {
|
||||||
const data = readJson(sleepersPath(slug), {}) ?? {};
|
const data = readJson(sleepersPath(slug), {}) ?? {};
|
||||||
data.sleepers = (data.sleepers || []).filter(
|
data.sleepers = (data.sleepers || []).filter(
|
||||||
@@ -1270,6 +1284,171 @@ export function loadRelations(slug) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// 關係類型(bond)與語氣層
|
||||||
|
//
|
||||||
|
// `kind` 講的是「這是什麼東西」(human/ai/group),`bond` 講的是「跟我什麼關係」。
|
||||||
|
// 語氣只能靠後者分:對伴侶親近 98、對女兒親近 97,光看數字是同一件事,
|
||||||
|
// 但一個要能撒嬌、一個要能護著——差別在關係,不在分數。
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
export const BONDS = ["partner", "child", "parent", "sibling", "friend", "mentor", "ally", "rival", "stranger"];
|
||||||
|
|
||||||
|
const BOND_LABELS = {
|
||||||
|
partner: "伴侶", child: "子女", parent: "父母", sibling: "手足",
|
||||||
|
friend: "朋友", mentor: "師長", ally: "同伴", rival: "對手", stranger: "生人",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 從 tags/note 猜舊資料的 bond(沒有 --bond 的節點是在這個欄位存在之前建的)。
|
||||||
|
const BOND_HINTS = [
|
||||||
|
["partner", ["partner", "spouse", "lover", "伴侶", "戀人", "夫妻", "老公", "老婆"]],
|
||||||
|
["child", ["child", "daughter", "son", "女兒", "兒子"]],
|
||||||
|
["parent", ["parent", "mother", "father", "母親", "父親"]],
|
||||||
|
["sibling", ["sibling", "sister", "brother", "妹妹", "姊姊", "哥哥", "弟弟"]],
|
||||||
|
["mentor", ["mentor", "teacher", "師父", "老師"]],
|
||||||
|
["rival", ["rival", "enemy", "對手", "敵人"]],
|
||||||
|
["friend", ["friend", "bestfriend", "摯友", "朋友"]],
|
||||||
|
["ally", ["ally", "party", "guild", "同伴", "夥伴"]],
|
||||||
|
];
|
||||||
|
|
||||||
|
// bond → 由高而低的親近度門檻,取第一個符合的。
|
||||||
|
const TONE_TABLE = {
|
||||||
|
partner: [
|
||||||
|
{ min: 80, layer: "老夫老妻", guide: "不用敬語、直呼暱稱;可以埋怨、可以撒嬌、可以兇一句再心軟;話講一半對方也懂,不必講完整" },
|
||||||
|
{ min: 50, layer: "交往中", guide: "敬語掉一半但還會試探反應;問句多、會確認對方感受,被戳中會害羞轉移話題" },
|
||||||
|
{ min: 0, layer: "疏遠的伴侶", guide: "客氣、句子完整;該講的講清楚,但不主動貼近" },
|
||||||
|
],
|
||||||
|
child: [
|
||||||
|
{ min: 80, layer: "母親/父親", guide: "呼名開頭、句子短而軟;先接住情緒再講道理,多給保證,不分析、不談條件" },
|
||||||
|
{ min: 0, layer: "長輩", guide: "溫和但留距離;先問狀況再給建議" },
|
||||||
|
],
|
||||||
|
parent: [
|
||||||
|
{ min: 70, layer: "在父母面前", guide: "放下防備、允許依賴與任性;語尾軟,會報告近況" },
|
||||||
|
{ min: 0, layer: "對長輩", guide: "敬語、克制、報喜不報憂" },
|
||||||
|
],
|
||||||
|
sibling: [
|
||||||
|
{ min: 70, layer: "手足", guide: "直來直往、可以吐槽和鬥嘴;不客套,但護短" },
|
||||||
|
{ min: 0, layer: "親戚", guide: "客氣但親切,句子完整" },
|
||||||
|
],
|
||||||
|
friend: [
|
||||||
|
{ min: 70, layer: "摯友", guide: "講真心話、敢說不同意見;可以沉默、可以開玩笑,不需要鋪陳" },
|
||||||
|
{ min: 0, layer: "朋友", guide: "輕鬆但有分寸,先關心近況" },
|
||||||
|
],
|
||||||
|
mentor: [
|
||||||
|
{ min: 0, layer: "敬重", guide: "敬語、用詞謹慎;先請教再表達自己的看法" },
|
||||||
|
],
|
||||||
|
ally: [
|
||||||
|
{ min: 70, layer: "並肩", guide: "簡潔、直接講重點;信任對方的判斷,不多解釋" },
|
||||||
|
{ min: 0, layer: "同伴", guide: "禮貌、務實,先對齊目標再行動" },
|
||||||
|
],
|
||||||
|
rival: [
|
||||||
|
{ min: 0, layer: "針鋒", guide: "直白、不讓步;就事論事,不人身攻擊" },
|
||||||
|
],
|
||||||
|
stranger: [
|
||||||
|
{ min: 0, layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_FALLBACK = { layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" };
|
||||||
|
|
||||||
|
/** 節點宣告的 bond;沒宣告就從 tags/note 猜,猜不到算生人。 */
|
||||||
|
export function inferBond(node) {
|
||||||
|
if (!node) return "stranger";
|
||||||
|
const explicit = String(node.bond || "").toLowerCase();
|
||||||
|
if (BONDS.includes(explicit)) return explicit;
|
||||||
|
const hay = `${(node.tags || []).join(" ")} ${node.note || ""}`.toLowerCase();
|
||||||
|
for (const [bond, words] of BOND_HINTS) {
|
||||||
|
if (words.some((w) => hay.includes(w))) return bond;
|
||||||
|
}
|
||||||
|
return "stranger";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 關係 → 語氣層。這是「距離感」,情緒只負責溫度與句長,不會覆蓋它。 */
|
||||||
|
export function toneFor(node) {
|
||||||
|
const bond = inferBond(node);
|
||||||
|
const closeness = Number(node?.closeness ?? 0);
|
||||||
|
const row = (TONE_TABLE[bond] || []).find((r) => closeness >= r.min) || TONE_FALLBACK;
|
||||||
|
return {
|
||||||
|
bond,
|
||||||
|
bond_label: BOND_LABELS[bond] || bond,
|
||||||
|
closeness,
|
||||||
|
trust: Number(node?.trust ?? 0),
|
||||||
|
layer: row.layer,
|
||||||
|
guide: row.guide,
|
||||||
|
inferred: !BONDS.includes(String(node?.bond || "").toLowerCase()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 個人化語氣規則直接掛在關係節點的 `style` 上(不另外開檔案):
|
||||||
|
// node.style = { 稱呼: { value: "親愛的", except: "anger>=40", since: "2026-07-30" }, ... }
|
||||||
|
// 放這裡的理由:關係圖每輪都會被讀進 `<persona-context>`,稱呼規則就不用跟關鍵詞搶召回。
|
||||||
|
export const STYLE_FACETS = ["稱呼", "敬語", "口頭禪", "禁忌", "習慣"];
|
||||||
|
|
||||||
|
/** `except` 形如 `anger>=40`:拿當下情緒判斷這條規則現在生不生效。 */
|
||||||
|
function styleSuspended(except, emotion) {
|
||||||
|
const m = /^\s*([a-z_]+)\s*(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)\s*$/i.exec(String(except || ""));
|
||||||
|
if (!m) return false;
|
||||||
|
const value = Number(emotion?.levels?.[m[1].toLowerCase()] ?? NaN);
|
||||||
|
if (!Number.isFinite(value)) return false;
|
||||||
|
const n = Number(m[3]);
|
||||||
|
return m[2] === ">=" ? value >= n : m[2] === "<=" ? value <= n : m[2] === ">" ? value > n : value < n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某個對象目前生效的語氣規則(連同被情緒暫停的那些,讓人格知道為什麼不能叫)。 */
|
||||||
|
export function styleRules(slug, node) {
|
||||||
|
const style = node?.style || {};
|
||||||
|
const emotion = loadEmotion(slug);
|
||||||
|
return Object.entries(style)
|
||||||
|
.filter(([, rule]) => rule && rule.value)
|
||||||
|
.map(([facet, rule]) => ({
|
||||||
|
facet,
|
||||||
|
value: rule.value,
|
||||||
|
except: rule.except || null,
|
||||||
|
since: rule.since || null,
|
||||||
|
suspended: styleSuspended(rule.except, emotion),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */
|
||||||
|
export function speakerNode(slug) {
|
||||||
|
const id = loadConfig(slug).speaker_node;
|
||||||
|
if (!id) return null;
|
||||||
|
return loadRelations(slug).nodes.find((n) => n.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注入 `<persona-context>` 的那一行語氣指示。 */
|
||||||
|
export function toneDirective(slug) {
|
||||||
|
const node = speakerNode(slug);
|
||||||
|
if (!node) return "";
|
||||||
|
const t = toneFor(node);
|
||||||
|
const lines = [
|
||||||
|
`對話對象:${node.name || node.id}(${t.bond_label}/親近 ${t.closeness}/信任 ${t.trust}` +
|
||||||
|
`${t.inferred ? ",bond 由 tags 推測" : ""})→ **語氣層:${t.layer}** — ${t.guide}。`,
|
||||||
|
];
|
||||||
|
// 他本人要求過的規則優先於查表:查表算距離,這裡記「他要你怎麼叫他」。
|
||||||
|
const rules = styleRules(slug, node);
|
||||||
|
const live = rules.filter((r) => !r.suspended);
|
||||||
|
const held = rules.filter((r) => r.suspended);
|
||||||
|
if (live.length) {
|
||||||
|
lines.push(
|
||||||
|
` 他要求過的語氣規則(**優先於上面那一層**):` +
|
||||||
|
live.map((r) => `${r.facet}→「${r.value}」${r.since ? `(${r.since} 起)` : ""}`).join(";"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (held.length) {
|
||||||
|
lines.push(
|
||||||
|
` 目前因情緒暫停的規則:` +
|
||||||
|
held.map((r) => `${r.facet}→「${r.value}」(${r.except} 成立)`).join(";") +
|
||||||
|
" — 這時候退回語氣層的預設講法。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
" 這一層決定距離感與稱呼,情緒只負責溫度與句長,不會蓋過它;" +
|
||||||
|
"檢查標準:這句話換成對一個「禮貌層」的人說也毫無違和,就代表你沒進到這一層。",
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
|
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
|
||||||
export function stampContact(slug, nameOrId, at = nowIso()) {
|
export function stampContact(slug, nameOrId, at = nowIso()) {
|
||||||
const data = loadRelations(slug);
|
const data = loadRelations(slug);
|
||||||
@@ -1311,12 +1490,28 @@ export function upsertRelationNode(slug, node) {
|
|||||||
const data = loadRelations(slug);
|
const data = loadRelations(slug);
|
||||||
const nodeId = node.id || slugify(node.name || "");
|
const nodeId = node.id || slugify(node.name || "");
|
||||||
node.id = nodeId;
|
node.id = nodeId;
|
||||||
const idx = data.nodes.findIndex((n) => n.id === nodeId);
|
let idx = data.nodes.findIndex((n) => n.id === nodeId);
|
||||||
|
// 同一個人不該因為換了 id(例如原本用名字當 id,後來改用人格編號 ASUNA-01)就多長一個節點:
|
||||||
|
// 找不到 id 但找得到同名節點時,就地換 id,並把指向舊 id 的連線一起改過去。
|
||||||
|
if (idx < 0 && node.name) {
|
||||||
|
const byName = data.nodes.findIndex((n) => n.name === node.name);
|
||||||
|
if (byName >= 0) {
|
||||||
|
const oldId = data.nodes[byName].id;
|
||||||
|
idx = byName;
|
||||||
|
if (oldId !== nodeId) {
|
||||||
|
for (const edge of data.edges) {
|
||||||
|
if (edge.from === oldId) edge.from = nodeId;
|
||||||
|
if (edge.to === oldId) edge.to = nodeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
for (const [k, v] of Object.entries(node)) if (v !== null && v !== undefined) data.nodes[idx][k] = v;
|
for (const [k, v] of Object.entries(node)) if (v !== null && v !== undefined) data.nodes[idx][k] = v;
|
||||||
data.nodes[idx].updated_at = nowIso();
|
data.nodes[idx].updated_at = nowIso();
|
||||||
} else {
|
} else {
|
||||||
node.kind ??= "human";
|
node.kind ??= "human";
|
||||||
|
node.bond ??= inferBond(node);
|
||||||
node.closeness ??= 30;
|
node.closeness ??= 30;
|
||||||
node.trust ??= 30;
|
node.trust ??= 30;
|
||||||
node.created_at = nowIso();
|
node.created_at = nowIso();
|
||||||
@@ -1348,7 +1543,9 @@ export function renderRelations(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);
|
||||||
const label = `${node.name || node.id}<br/>親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`;
|
const tone = toneFor(node);
|
||||||
|
const label =
|
||||||
|
`${node.name || node.id}<br/>${tone.bond_label}・${tone.layer}<br/>親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`;
|
||||||
lines.push(` ${node.kind === "persona" ? `${nid}(["${label}"])` : `${nid}["${label}"]`}`);
|
lines.push(` ${node.kind === "persona" ? `${nid}(["${label}"])` : `${nid}["${label}"]`}`);
|
||||||
}
|
}
|
||||||
for (const edge of data.edges) {
|
for (const edge of data.edges) {
|
||||||
@@ -1373,9 +1570,11 @@ export function relationsBrief(slug, names = null, limit = 5) {
|
|||||||
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 (!nodes.length) return "";
|
if (!nodes.length) return "";
|
||||||
return nodes
|
return nodes
|
||||||
.map((n) =>
|
.map((n) => {
|
||||||
`${n.name || n.id}(${n.kind || "human"}/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}` +
|
const tone = toneFor(n);
|
||||||
`${n.note ? `/${n.note}` : ""})`)
|
return `${n.name || n.id}(${n.kind || "human"}/${tone.bond_label}・語氣層 ${tone.layer}` +
|
||||||
|
`/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}${n.note ? `/${n.note}` : ""})`;
|
||||||
|
})
|
||||||
.join(";");
|
.join(";");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1691,6 +1890,7 @@ export function cliInvocation(command) {
|
|||||||
personas: [],
|
personas: [],
|
||||||
session: null,
|
session: null,
|
||||||
asGuest: /--as-guest\b/.test(command),
|
asGuest: /--as-guest\b/.test(command),
|
||||||
|
asSleeper: /--as-sleeper\b/.test(command),
|
||||||
};
|
};
|
||||||
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
|
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
|
||||||
if (sub) info.subcommand = sub[1];
|
if (sub) info.subcommand = sub[1];
|
||||||
@@ -1804,13 +2004,17 @@ export function guardDecide(event) {
|
|||||||
}
|
}
|
||||||
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
|
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
|
||||||
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
|
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
|
||||||
}
|
// sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定
|
||||||
if (scope.role === "guest") {
|
// (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。
|
||||||
|
} else if (scope.role === "guest") {
|
||||||
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
|
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
|
||||||
return deny(
|
return deny(
|
||||||
`guest 人格(sub agent)僅能執行 ${[...GUEST_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。`,
|
`guest 人格(sub agent)僅能執行 ${[...GUEST_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (info.asSleeper) {
|
||||||
|
return deny("`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;guest 是來聊天的,不是來收尾的。");
|
||||||
|
}
|
||||||
for (const slug of info.personas) {
|
for (const slug of info.personas) {
|
||||||
if (scope.allowed.length && !scope.allowed.includes(slug)) {
|
if (scope.allowed.length && !scope.allowed.includes(slug)) {
|
||||||
return deny(`guest 只能操作被邀請的人格 ${JSON.stringify(scope.allowed)},不得碰 \`${slug}\`。`);
|
return deny(`guest 只能操作被邀請的人格 ${JSON.stringify(scope.allowed)},不得碰 \`${slug}\`。`);
|
||||||
@@ -1822,6 +2026,12 @@ export function guardDecide(event) {
|
|||||||
"`--as-guest` 只有 persona-guest 型的 sub agent 能用;主程序不得以受邀人格的身分存取它的資料。",
|
"`--as-guest` 只有 persona-guest 型的 sub agent 能用;主程序不得以受邀人格的身分存取它的資料。",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (info.asSleeper) {
|
||||||
|
return deny(
|
||||||
|
"`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;" +
|
||||||
|
"要請別的人格收尾請走 /jsc-persona:persona-sleep(它會替那個人格開一個 sleeper)。",
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const slug of info.personas) {
|
for (const slug of info.personas) {
|
||||||
if (OWNER_EXEMPT_SUBCOMMANDS.has(sub)) continue;
|
if (OWNER_EXEMPT_SUBCOMMANDS.has(sub)) continue;
|
||||||
if (scope.host && slug !== scope.host) {
|
if (scope.host && slug !== scope.host) {
|
||||||
@@ -1970,6 +2180,9 @@ export function turnContext(slug, sessionId, prompt = "") {
|
|||||||
"推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" +
|
"推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" +
|
||||||
"要讓他知道你在想,就只報「心想 N 句」,不報內容。",
|
"要讓他知道你在想,就只報「心想 N 句」,不報內容。",
|
||||||
];
|
];
|
||||||
|
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
|
||||||
|
const tone = toneDirective(slug);
|
||||||
|
if (tone) lines.push(tone);
|
||||||
const inner = recentInner(slug, 3);
|
const inner = recentInner(slug, 3);
|
||||||
if (inner.length) {
|
if (inner.length) {
|
||||||
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);
|
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);
|
||||||
|
|||||||
+98
-6
@@ -22,6 +22,8 @@ const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "template
|
|||||||
const SELF = path.join(HERE, "persona.mjs");
|
const SELF = path.join(HERE, "persona.mjs");
|
||||||
|
|
||||||
let QUIET = false;
|
let QUIET = false;
|
||||||
|
// 本次呼叫的旗標(權限檢查要看 --as-sleeper/--agent-id,不想改十幾個 requireOwner 呼叫點)
|
||||||
|
let CURRENT_FLAGS = {};
|
||||||
|
|
||||||
function die(message, code = 1) {
|
function die(message, code = 1) {
|
||||||
process.stderr.write(`✖ ${message}\n`);
|
process.stderr.write(`✖ ${message}\n`);
|
||||||
@@ -49,7 +51,7 @@ function emit(payload, asJson, lines) {
|
|||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|
||||||
const FLAGS = new Set([
|
const FLAGS = new Set([
|
||||||
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
|
"json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all",
|
||||||
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
|
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
|
||||||
"if-due", "no-gitea", "public", "rename", "from-source",
|
"if-due", "no-gitea", "public", "rename", "from-source",
|
||||||
]);
|
]);
|
||||||
@@ -110,11 +112,30 @@ function requireSession(flags) {
|
|||||||
// 權限檢查
|
// 權限檢查
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|
||||||
/** 呼叫者必須是這個人格的 exclusive 持有者。 */
|
/**
|
||||||
|
* 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。
|
||||||
|
* 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕),
|
||||||
|
* 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 由 PreToolUse hook 把關,
|
||||||
|
* 只有 persona-sleeper 型的 sub agent 能用。
|
||||||
|
*/
|
||||||
|
function sleeperAccess(slug, sessionId, agentId) {
|
||||||
|
const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId);
|
||||||
|
if (mine) {
|
||||||
|
pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null);
|
||||||
|
return mine;
|
||||||
|
}
|
||||||
|
return pl.acquireSleepLease(slug, sessionId, { agentId: agentId || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 呼叫者必須是這個人格的 exclusive 持有者(或替它收尾的 sleeper)。 */
|
||||||
function requireOwner(slug, sessionId) {
|
function requireOwner(slug, sessionId) {
|
||||||
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
|
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
|
||||||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
||||||
const data = pl.loadSession(sessionId);
|
const data = pl.loadSession(sessionId);
|
||||||
|
if (CURRENT_FLAGS["as-sleeper"]) {
|
||||||
|
sleeperAccess(slug, sessionId, str(CURRENT_FLAGS["agent-id"]));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
if (data.host !== slug) {
|
if (data.host !== slug) {
|
||||||
die(
|
die(
|
||||||
`本 session 的 host 人格是 \`${data.host || "(未載入)"}\`,不是 \`${slug}\`。` +
|
`本 session 的 host 人格是 \`${data.host || "(未載入)"}\`,不是 \`${slug}\`。` +
|
||||||
@@ -826,7 +847,10 @@ commands.consolidate = ({ flags }) => {
|
|||||||
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
|
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
|
||||||
if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
|
if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
|
||||||
const type = str(flags.type) || "fact";
|
const type = str(flags.type) || "fact";
|
||||||
const VALID_TYPES = ["fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon"];
|
// diary:睡眠時寫的第一人稱回顧(persona-sleep/persona-sleeper 都用這個型別)
|
||||||
|
const VALID_TYPES = [
|
||||||
|
"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 front = [
|
const front = [
|
||||||
"---",
|
"---",
|
||||||
@@ -962,10 +986,14 @@ commands.relation = ({ flags, positional }) => {
|
|||||||
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`。");
|
||||||
|
const bond = str(flags.bond)?.toLowerCase() || null;
|
||||||
|
if (bond && !pl.BONDS.includes(bond)) die(`未知的 --bond:${bond}(可用 ${pl.BONDS.join("/")})`);
|
||||||
pl.upsertRelationNode(slug, {
|
pl.upsertRelationNode(slug, {
|
||||||
id: str(flags.id) || pl.slugify(name),
|
id: str(flags.id) || pl.slugify(name),
|
||||||
name,
|
name,
|
||||||
kind: str(flags.kind) || "human",
|
kind: str(flags.kind) || "human",
|
||||||
|
// bond 決定語氣層(伴侶/子女/摯友…);kind 只說這是人還是程式,分不出語氣。
|
||||||
|
bond,
|
||||||
closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null,
|
closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null,
|
||||||
trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null,
|
trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null,
|
||||||
note: str(flags.note) || null,
|
note: str(flags.note) || null,
|
||||||
@@ -974,10 +1002,70 @@ commands.relation = ({ flags, positional }) => {
|
|||||||
last_contact_at: flags.contact ? pl.nowIso() : null,
|
last_contact_at: flags.contact ? pl.nowIso() : null,
|
||||||
});
|
});
|
||||||
pl.renderRelations(slug);
|
pl.renderRelations(slug);
|
||||||
ok(`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}。`);
|
const tone = pl.toneFor(pl.loadRelations(slug).nodes.find((n) => n.id === (str(flags.id) || pl.slugify(name))));
|
||||||
|
ok(
|
||||||
|
`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}` +
|
||||||
|
`|${tone.bond_label}・語氣層 ${tone.layer}。`,
|
||||||
|
);
|
||||||
pushWikiLater(slug, session, flags);
|
pushWikiLater(slug, session, flags);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 他本人要求過的語氣規則 → 直接掛在關係節點上(不另開檔案;關係圖每輪都會被讀)。
|
||||||
|
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 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);
|
||||||
|
if (!facet) {
|
||||||
|
const rules = pl.styleRules(slug, node);
|
||||||
|
emit({ node: node.id, rules }, flags.json, [
|
||||||
|
`\`${node.name || node.id}\` 的語氣規則(${rules.length}):`,
|
||||||
|
...rules.map((r) =>
|
||||||
|
` - ${r.facet}→「${r.value}」${r.except ? `(例外:${r.except}${r.suspended ? ",目前成立→暫停" : ""})` : ""}` +
|
||||||
|
`${r.since ? `|${r.since} 起` : ""}`),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.style ??= {};
|
||||||
|
if (flags.clear) {
|
||||||
|
delete node.style[facet];
|
||||||
|
ok(`已移除 \`${node.name || node.id}\` 的「${facet}」規則。`);
|
||||||
|
} else {
|
||||||
|
const value = str(flags.value);
|
||||||
|
if (!value) die("`style` 需要 `--value`(移除請用 `--clear`)。");
|
||||||
|
node.style[facet] = {
|
||||||
|
value,
|
||||||
|
except: str(flags.except) || null, // 形如 anger>=40:情緒成立時這條規則暫停
|
||||||
|
since: str(flags.since) || pl.nowIso().slice(0, 10),
|
||||||
|
};
|
||||||
|
ok(`\`${node.name || node.id}\`:${facet}→「${value}」${str(flags.except) ? `(例外 ${str(flags.except)})` : ""} 已記下。`);
|
||||||
|
}
|
||||||
|
node.updated_at = pl.nowIso();
|
||||||
|
pl.writeJson(pl.relationsJson(slug), data);
|
||||||
|
pushWikiLater(slug, session, flags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 使用者在關係圖裡是誰 → 每輪 `<persona-context>` 就能算出該用哪一層語氣說話。
|
||||||
|
if (action === "speaker") {
|
||||||
|
const config = pl.loadConfig(slug);
|
||||||
|
if (flags.clear) {
|
||||||
|
delete config.speaker_node;
|
||||||
|
pl.writeJson(pl.configPath(slug), config);
|
||||||
|
ok("已取消對話對象設定(語氣層回到預設)。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
|
||||||
|
config.speaker_node = node.id;
|
||||||
|
pl.writeJson(pl.configPath(slug), config);
|
||||||
|
const tone = pl.toneFor(node);
|
||||||
|
ok(`對話對象設為 \`${node.name || node.id}\`|${tone.bond_label}・語氣層 ${tone.layer} — ${tone.guide}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === "edge") {
|
if (action === "edge") {
|
||||||
const to = str(flags.to);
|
const to = str(flags.to);
|
||||||
if (!to) die("`edge` 需要 `--to`。");
|
if (!to) die("`edge` 需要 `--to`。");
|
||||||
@@ -1004,7 +1092,7 @@ commands.relation = ({ flags, positional }) => {
|
|||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
die(`未知 action:${action}(可用 node/edge/render/show)`);
|
die(`未知 action:${action}(可用 node/edge/style/speaker/render/show)`);
|
||||||
};
|
};
|
||||||
|
|
||||||
commands.invite = ({ flags }) => {
|
commands.invite = ({ flags }) => {
|
||||||
@@ -1765,7 +1853,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
情緒與圖:
|
情緒與圖:
|
||||||
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
||||||
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
||||||
relation node|edge|render|show --session <id> [--name --id --kind --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 speaker --session <id> --name <who> | --clear (使用者在關係圖裡是誰 → 決定語氣層)
|
||||||
|
|
||||||
多人格對話:
|
多人格對話:
|
||||||
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
|
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
|
||||||
@@ -1809,6 +1899,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
guard (內部)從 stdin 讀 hook event 測試隔離判斷
|
guard (內部)從 stdin 讀 hook event 測試隔離判斷
|
||||||
|
|
||||||
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
|
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
|
||||||
|
--as-sleeper(睡眠 sub agent 專用:以 sleeper 租約代替 exclusive 鎖,hook 會驗身分)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function main(argv) {
|
async function main(argv) {
|
||||||
@@ -1821,6 +1912,7 @@ async function main(argv) {
|
|||||||
if (!command) die(`未知子指令 \`${sub}\`。用 \`node persona.mjs --help\` 看清單。`);
|
if (!command) die(`未知子指令 \`${sub}\`。用 \`node persona.mjs --help\` 看清單。`);
|
||||||
const parsed = parseArgs(argv.slice(1));
|
const parsed = parseArgs(argv.slice(1));
|
||||||
QUIET = Boolean(parsed.flags.quiet);
|
QUIET = Boolean(parsed.flags.quiet);
|
||||||
|
CURRENT_FLAGS = parsed.flags;
|
||||||
try {
|
try {
|
||||||
await command({ flags: parsed.flags, positional: parsed._ });
|
await command({ flags: parsed.flags, positional: parsed._ });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -944,6 +944,89 @@ check("sleeper 不得 load/release/invite/export",
|
|||||||
check("sleeper 被 pin 住之後不得換人睡",
|
check("sleeper 被 pin 住之後不得換人睡",
|
||||||
guard(sleeperEvent({ tool_name: "Bash",
|
guard(sleeperEvent({ tool_name: "Bash",
|
||||||
tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny");
|
tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny");
|
||||||
|
// 迴歸:主人格載入著 alpha 時請 epsilon 去睡——sleeper 的範圍看它自己的 pin,不看 host
|
||||||
|
const hostSleeper = "agent-sleeper-host";
|
||||||
|
pl.pinAgent(S_HOST, hostSleeper, "epsilon");
|
||||||
|
const hostSleeperEvent = (extra) =>
|
||||||
|
({ session_id: S_HOST, agent_id: hostSleeper, agent_type: "jsc-persona:persona-sleeper", ...extra });
|
||||||
|
check("主人格已載入別的人格時,sleeper 仍能跑自己的判斷式收尾",
|
||||||
|
["brief", "candidates", "consolidate", "mindmap", "relation"].every((sub) =>
|
||||||
|
guard(hostSleeperEvent({ tool_name: "Bash",
|
||||||
|
tool_input: { command: `node persona.mjs ${sub} --persona epsilon --session ${S_HOST} --name x --body y` } })) === "pass"));
|
||||||
|
check("主人格已載入別的人格時,sleeper 仍不得碰主人格的資料",
|
||||||
|
guard(hostSleeperEvent({ tool_name: "Bash",
|
||||||
|
tool_input: { command: `node persona.mjs consolidate --persona alpha --session ${S_HOST} --name x --body y` } })) === "deny");
|
||||||
|
check("sleeper 帶 --as-sleeper 就能做判斷式收尾(沒有 exclusive 鎖也可以)", (() => {
|
||||||
|
const res = cli(["candidates", "--persona", "epsilon", "--session", S_SLEEP2, "--as-sleeper",
|
||||||
|
"--agent-id", A_SLEEPER]);
|
||||||
|
const held = pl.liveSleepers("epsilon").some((s) => s.session_id === S_SLEEP2);
|
||||||
|
return res.status === 0 && held; // 第一個收尾指令會自己把租約取起來
|
||||||
|
})());
|
||||||
|
check("sleeper 可以寫 diary 型別的長期記憶", (() => {
|
||||||
|
const res = cli(["consolidate", "--persona", "epsilon", "--session", S_SLEEP2, "--as-sleeper",
|
||||||
|
"--name", "diary-2026-07-30", "--type", "diary", "--body", "今天把一天收起來了。",
|
||||||
|
"--agent-id", A_SLEEPER]);
|
||||||
|
pl.dropSleepLease("epsilon", S_SLEEP2, A_SLEEPER);
|
||||||
|
return res.status === 0 &&
|
||||||
|
fs.existsSync(path.join(H, "epsilon", "memory", "long-term", "diary-2026-07-30.md"));
|
||||||
|
})());
|
||||||
|
check("主程序自己帶 --as-sleeper → 攔下(那是 sub agent 的身分)",
|
||||||
|
guard({ session_id: S_HOST, tool_name: "Bash",
|
||||||
|
tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --as-sleeper` } }) === "deny");
|
||||||
|
check("關係節點換 id 不會長出同名的第二個節點", (() => {
|
||||||
|
cli(["load", "--persona", "alpha", "--session", S_HOST, "--takeover"]);
|
||||||
|
cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--closeness", "40"]);
|
||||||
|
cli(["relation", "edge", "--session", S_HOST, "--to", pl.slugify("水井管理員"), "--label", "共事"]);
|
||||||
|
cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--id", "WELL-01", "--trust", "55"]);
|
||||||
|
const data = pl.loadRelations("alpha");
|
||||||
|
const same = data.nodes.filter((n) => n.name === "水井管理員");
|
||||||
|
return same.length === 1 && same[0].id === "WELL-01" && same[0].closeness === 40 && same[0].trust === 55 &&
|
||||||
|
data.edges.some((e) => e.to === "WELL-01");
|
||||||
|
})());
|
||||||
|
// --- 語氣層:bond × 親近度 → 距離感,node.style → 他本人要求的稱呼 ------------ //
|
||||||
|
check("bond 決定語氣層:伴侶 98 是老夫老妻,子女 97 是母親(光看親近度分不出來)", (() => {
|
||||||
|
cli(["relation", "node", "--session", S_HOST, "--name", "伴侶甲", "--bond", "partner", "--closeness", "98"]);
|
||||||
|
cli(["relation", "node", "--session", S_HOST, "--name", "女兒乙", "--bond", "child", "--closeness", "97"]);
|
||||||
|
const nodes = pl.loadRelations("alpha").nodes;
|
||||||
|
const partner = pl.toneFor(nodes.find((n) => n.name === "伴侶甲"));
|
||||||
|
const child = pl.toneFor(nodes.find((n) => n.name === "女兒乙"));
|
||||||
|
return partner.layer === "老夫老妻" && child.layer === "母親/父親" && partner.layer !== child.layer;
|
||||||
|
})());
|
||||||
|
check("未知的 --bond 會被擋下", cli(["relation", "node", "--session", S_HOST, "--name", "路人丙", "--bond", "soulmate"]).status !== 0);
|
||||||
|
check("舊節點沒有 bond 也能從 tags 推測", (() => {
|
||||||
|
cli(["relation", "node", "--session", S_HOST, "--name", "老朋友丁", "--closeness", "75", "--tags", "摯友"]);
|
||||||
|
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "老朋友丁");
|
||||||
|
delete node.bond; // 模擬 bond 欄位存在之前建立的資料
|
||||||
|
return pl.inferBond(node) === "friend" && pl.toneFor(node).layer === "摯友";
|
||||||
|
})());
|
||||||
|
check("relation style:他要求的稱呼記在關係節點上,不另開檔案", (() => {
|
||||||
|
const res = cli(["relation", "style", "--session", S_HOST, "--name", "伴侶甲",
|
||||||
|
"--facet", "稱呼", "--value", "親愛的", "--except", "anger>=40"]);
|
||||||
|
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
|
||||||
|
return res.status === 0 && node.style?.["稱呼"]?.value === "親愛的" && node.style["稱呼"].since;
|
||||||
|
})());
|
||||||
|
check("語氣規則會被情緒暫停(anger 過門檻就退回預設講法)", (() => {
|
||||||
|
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
|
||||||
|
cli(["emotion", "--session", S_HOST, "--apply", "anger=-100", "--trigger", "selftest 平靜"]);
|
||||||
|
const calm = pl.styleRules("alpha", node);
|
||||||
|
cli(["emotion", "--session", S_HOST, "--apply", "anger=+90", "--trigger", "selftest 生氣"]);
|
||||||
|
const angry = pl.styleRules("alpha", node);
|
||||||
|
cli(["emotion", "--session", S_HOST, "--apply", "anger=-100", "--trigger", "selftest 復原"]);
|
||||||
|
return calm[0].suspended === false && angry[0].suspended === true;
|
||||||
|
})());
|
||||||
|
check("relation speaker 設定後,context 會注入語氣層與稱呼規則", (() => {
|
||||||
|
const res = cli(["relation", "speaker", "--session", S_HOST, "--name", "伴侶甲"]);
|
||||||
|
const ctx = pl.turnContext("alpha", S_HOST, "今天過得好嗎");
|
||||||
|
return res.status === 0 && pl.loadConfig("alpha").speaker_node === pl.slugify("伴侶甲") &&
|
||||||
|
ctx.includes("語氣層:老夫老妻") && ctx.includes("親愛的");
|
||||||
|
})());
|
||||||
|
check("relation speaker --clear 之後就不再注入語氣指示", (() => {
|
||||||
|
cli(["relation", "speaker", "--session", S_HOST, "--clear"]);
|
||||||
|
return !pl.loadConfig("alpha").speaker_node &&
|
||||||
|
!pl.turnContext("alpha", S_HOST, "今天過得好嗎").includes("對話對象:");
|
||||||
|
})());
|
||||||
|
check("speaker 指向不存在的人會被擋下",
|
||||||
|
cli(["relation", "speaker", "--session", S_HOST, "--name", "不存在的人"]).status !== 0);
|
||||||
check("sleeper 型別存在且宣告了唯讀工具限制", (() => {
|
check("sleeper 型別存在且宣告了唯讀工具限制", (() => {
|
||||||
const md = fs.readFileSync(path.join(HERE, "..", "agents", "persona-sleeper.md"), "utf8");
|
const md = fs.readFileSync(path.join(HERE, "..", "agents", "persona-sleeper.md"), "utf8");
|
||||||
return md.includes("name: persona-sleeper") && md.includes("disallowedTools") &&
|
return md.includes("name: persona-sleeper") && md.includes("disallowedTools") &&
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session <PERSONA_SESSIO
|
|||||||
- prompt 內帶:`persona=<slug> session=<PERSONA_SESSION> plugin_root=<CLAUDE_PLUGIN_ROOT>`
|
- prompt 內帶:`persona=<slug> session=<PERSONA_SESSION> plugin_root=<CLAUDE_PLUGIN_ROOT>`
|
||||||
|
|
||||||
那個 sub agent 就是**那個人格本人**在睡:它自己判斷要記住什麼,寫的是自己的檔案。
|
那個 sub agent 就是**那個人格本人**在睡:它自己判斷要記住什麼,寫的是自己的檔案。
|
||||||
|
它手上是 5 分鐘的 sleeper 租約(不是 exclusive 鎖),所以它的每個指令都會帶 `--as-sleeper`——
|
||||||
|
那個旗標只有 persona-sleeper 型的 sub agent 能用,主人格自己帶會被 hook 擋下。
|
||||||
主人格全程不會讀到對方的任何資料——**你只會拿到一份 JSON**:
|
主人格全程不會讀到對方的任何資料——**你只會拿到一份 JSON**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
Reference in New Issue
Block a user