feat: 改寫為 Node.js,新增動漫角色建人格、劇場模式與記憶固化條件

腳本全面從 Python 改寫為 Node.js(ESM,只用內建模組,無 npm 依賴):
scripts/persona-lib.mjs(核心)、scripts/persona.mjs(CLI)、hooks/*.mjs(六個
hook)、scripts/selftest.mjs(68 項自我測試,全綠)。

新增:
- persona-anime skill:用「動漫作品+角色名」建立人格,先上網蒐集至少三個獨立
  來源的公開設定,映射成 OpenClaw 的 IDENTITY 五欄位與 SOUL 四段落,再固化成
  canon 基礎記憶(每則帶來源 URL)+原作人際關係圖+依角色型別的情緒基線;
  必寫 roleplay-frame 界線記憶(非官方、非本人)。
- 劇場模式:invite 後只顯示人格對話(`名字:內容`)。UserPromptSubmit hook 每輪
  注入強制規則、Stop hook 完全靜音,CLI 新增 --quiet 與 room script(乾淨對話稿)。
  leave 後沒客人自動關閉,也可用 room theater --on/--off 手動切換。
- 短期→長期記憶的成文轉入條件 R1–R6(promotionCandidates)與 candidates 子指令,
  hook 在達標時提醒固化;長期記憶新增 canon 型別與 rules 欄位。
- 人格改為「由使用者呼叫才載入」:SessionStart hook 只列出可用人格,不自動附身。

其他:版本號改回 0.0.1;README/AGENTS.md 同步更新。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 16:03:27 +00:00
co-authored by Claude Opus 5
parent 4426ede979
commit 2005f26c94
39 changed files with 1878 additions and 2695 deletions
+14
View File
@@ -0,0 +1,14 @@
// _hook.mjs — hook 共用小工具:讀 stdin 的 JSON event、輸出 hook 回應。
import fs from "node:fs";
export function readEvent() {
try {
return JSON.parse(fs.readFileSync(0, "utf8"));
} catch {
return null;
}
}
export function respond(payload) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
// PreToolUse guard:人格鎖驗證 + 跨人格資料隔離(唯一的強制執行點)。
//
// 擋下的情形:
// * 讀寫非「本 session 當前人格」的人格目錄(含 Read/Write/Edit/Glob/Grep/Bash
// * guestpersona-guest sub agent)寫入任何人格檔案,或換讀別的人格
// * CLI 帶假的 --session(冒用其他程序身分)/主程序冒用 --as-guest
// * 目標人格的鎖屬於其他還活著的程序
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
const event = readEvent();
if (!event) process.exit(0);
let result;
try {
result = pl.guardDecide(event);
} catch (err) {
// guard 自己壞掉不該擋住整個 session
process.stderr.write(`persona guard 內部錯誤:${err.message}\n`);
process.exit(0);
}
if (result.decision !== "deny") process.exit(0);
respond({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: `[jsc-persona 隔離] ${result.reason}`,
},
});
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""PreToolUse guard:人格鎖驗證 + 跨人格資料隔離(唯一的強制執行點)。
擋下的情形:
* 讀寫非「本 session 當前人格」的人格目錄(含 Read/Write/Edit/Glob/Grep/Bash
* guestpersona-guest sub agent)寫入任何人格檔案,或換讀別的人格
* CLI 帶假的 --session(冒用其他程序身分)
* 目標人格的鎖屬於其他還活著的程序
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
try:
decision, reason = pl.guard_decide(event)
except Exception as exc: # guard 自己壞掉不該擋住整個 session
print(f"persona guard 內部錯誤:{exc}", file=sys.stderr)
return 0
if decision != "deny":
return 0
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"[jsc-persona 隔離] {reason}",
}
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6 -6
View File
@@ -5,7 +5,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session_start.mjs\"",
"timeout": 20,
"statusMessage": "載入人格執行環境…"
}
@@ -17,7 +17,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/prompt_submit.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/prompt_submit.mjs\"",
"timeout": 20,
"statusMessage": "回想記憶與情緒…"
}
@@ -30,7 +30,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs\"",
"timeout": 15,
"statusMessage": "檢查人格隔離…"
}
@@ -42,7 +42,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/turn_end.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/turn_end.mjs\"",
"timeout": 20
}
]
@@ -53,7 +53,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent_stop.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent_stop.mjs\"",
"timeout": 15
}
]
@@ -64,7 +64,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/session_end.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session_end.mjs\"",
"timeout": 20
}
]
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
// UserPromptSubmit:每輪注入「人格 + 情緒 + 記憶 + 關係」上下文,並記錄原始逐字。
//
// 短期記憶(memory/short-term.jsonl)由 skill 做完語意分析後才寫入(有主題/實體/
// 顯著度/情緒變化);這裡只寫 journal 原始逐字,避免同一句話被記兩次。
// 劇場模式(多人格對話)進行中時,額外強制「只輸出人格對話」。
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
const event = readEvent();
if (!event) process.exit(0);
const sessionId = event.session_id || "unknown";
const prompt = event.prompt || "";
const data = pl.loadSession(sessionId);
const host = data.host;
if (!host || !pl.personaExists(host)) process.exit(0);
pl.heartbeatLock(host, sessionId);
for (const [guest, info] of Object.entries(data.guests || {})) {
if (pl.personaExists(guest)) pl.addGuestLease(guest, sessionId, info.room || "", host);
}
pl.appendJsonl(pl.journalPath(host), {
ts: pl.nowIso(),
kind: "utterance",
role: "user",
text: prompt.slice(0, 4000),
});
const theater = Boolean(data.theater) && (data.rooms || []).length > 0;
const hint = theater
? "\n🎭 劇場模式:本輪輸出**只能**是人格對話(`名字:內容`,一行一句)。" +
"不要輸出指令、指令結果、狀態、分析、旁白或摘要;CLI 一律加 `--quiet` 並把 stdout 丟到 /dev/null。" +
"\n流程請照 /jsc-persona:persona-invite。"
: "\n回覆前請:①做語意分析(意圖/主題/實體/情感)②依十二情緒更新狀態" +
" ③以人格語氣回覆 ④用 `persona.mjs remember` 寫回短期記憶。" +
"\n情緒與記憶指令請參考 /jsc-persona:persona-chat。";
respond({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: pl.turnContext(host, sessionId, prompt) + hint,
},
suppressOutput: true,
});
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""UserPromptSubmit:每輪注入「人格 + 情緒 + 記憶 + 關係」上下文,並記錄原始逐字。
短期記憶(memory/short-term.jsonl)由 skill 做完語意分析後才寫入(有主題/實體/
顯著度/情緒變化);這裡只寫 journal 原始逐字,避免同一句話被記兩次。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
prompt = event.get("prompt") or ""
data = pl.load_session(session_id)
host = data.get("host")
if not host or not pl.persona_exists(host):
return 0
pl.heartbeat_lock(host, session_id)
for guest, info in (data.get("guests") or {}).items():
if pl.persona_exists(guest):
pl.add_guest_lease(guest, session_id, info.get("room", ""), host)
pl.append_jsonl(pl.journal_path(host), {
"ts": pl.iso(), "kind": "utterance", "role": "user", "text": prompt[:4000],
})
context = pl.turn_context(host, session_id, prompt)
hint = (
"\n回覆前請:①做語意分析(意圖/主題/實體/情感)②依十二情緒更新狀態"
" ③以人格語氣回覆 ④用 `persona.py remember` 寫回短期記憶。"
"\n情緒與記憶指令請參考 /jsc-persona:persona-chat。"
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context + hint,
},
"suppressOutput": True,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
// SessionEnd:釋放人格鎖與所有 guest 租約,人格才能被下一個程序載入。
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
const event = readEvent();
if (!event) process.exit(0);
const sessionId = event.session_id || "unknown";
const data = pl.loadSession(sessionId);
const host = data.host;
if (host && pl.personaExists(host)) {
const state = pl.decayEmotion(pl.loadEmotion(host));
pl.writeJson(pl.emotionPath(host), state);
pl.appendJsonl(pl.journalPath(host), {
ts: pl.nowIso(),
kind: "session-end",
session: sessionId.slice(0, 8),
mood: pl.mood(state),
});
}
const released = pl.unbindSession(sessionId);
pl.gcRuntime();
const out = { suppressOutput: true };
if (released.host) {
out.systemMessage =
`[jsc-persona] 已釋放 \`${released.host}\` 的載入鎖` +
(released.guests.length ? `guest${released.guests.join(", ")}` : "");
}
respond(out);
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""SessionEnd:釋放人格鎖與所有 guest 租約,人格才能被下一個程序載入。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
data = pl.load_session(session_id)
host = data.get("host")
if host and pl.persona_exists(host):
state = pl.decay_emotion(pl.load_emotion(host))
pl.write_json(pl.emotion_path(host), state)
pl.append_jsonl(pl.journal_path(host), {
"ts": pl.iso(), "kind": "session-end", "session": session_id[:8],
"mood": pl.mood(state),
})
released = pl.unbind_session(session_id)
pl.gc_runtime()
out: dict = {"suppressOutput": True}
if released["host"]:
out["systemMessage"] = (
f"[jsc-persona] 已釋放 `{released['host']}` 的載入鎖"
+ (f"guest{released['guests']}" if released["guests"] else "")
)
print(json.dumps(out, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
// SessionStart:清理死鎖、續租,並把 PERSONA_SESSION 與人格清單注入上下文。
//
// session_id 只有 hook 拿得到,所以這裡把它注入上下文;後續 skill 呼叫 CLI 時
// 必須帶 `--session <這個值>`guard hook 會驗證,程序無法冒用別人的身分。
//
// 人格「由使用者呼叫才載入」:這裡只列出可用人格,不自動載入沒綁過的人格。
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
const event = readEvent();
if (!event) process.exit(0);
const sessionId = event.session_id || "unknown";
const source = event.source || "startup";
const cwd = event.cwd || null;
pl.gcRuntime();
const data = pl.loadSession(sessionId);
const host = data.host;
const lines = [
"<persona-runtime>",
`PERSONA_SESSION=${sessionId}`,
`人格倉庫:${pl.personaHome()}`,
"規則:",
` 1. 呼叫 persona.mjs 一律帶 \`--session ${sessionId}\`(值不符會被 hook 拒絕)。`,
" 2. 人格**只有使用者叫你載入時才載入**/jsc-persona:persona-chat <slug>),不要自己挑一個人格附身。",
" 3. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。",
" 4. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。",
];
if (host && pl.personaExists(host)) {
// resume / compact / clear:把鎖續租回來,並帶回人格狀態
try {
pl.acquireLock(host, sessionId, { cwd });
lines.push(`已接續人格 \`${host}\`session 恢復:${source})。`);
lines.push(pl.turnContext(host, sessionId));
} catch (err) {
lines.push(`⚠ 無法接續人格 \`${host}\`${err.message}`);
}
} else {
const personas = pl.listPersonas();
if (personas.length) {
lines.push("尚未載入人格。可用人格(等使用者指定):");
for (const slug of personas) {
const status = pl.lockStatus(slug);
lines.push(` - ${status.locked ? "🔒" : status.stale ? "⚠" : "🔓"} \`${slug}\` ${pl.identityBrief(slug)}`);
}
lines.push("載入方式:/jsc-persona:persona-chat <slug>");
} else {
lines.push("尚無任何人格。可用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime(動漫作品+角色名)建立。");
}
}
lines.push("</persona-runtime>");
respond({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: lines.join("\n"),
},
suppressOutput: true,
});
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""SessionStart:清理死鎖、續租、並把 PERSONA_SESSION 與人格清單注入上下文。
session_id 只有 hook 拿得到,所以這裡把它注入上下文;後續 skill 呼叫 CLI 時
必須帶 `--session <這個值>`guard hook 會驗證,程序無法冒用別人的身分。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
source = event.get("source") or "startup"
cwd = event.get("cwd")
pl.gc_runtime()
data = pl.load_session(session_id)
host = data.get("host")
lines = [
"<persona-runtime>",
f"PERSONA_SESSION={session_id}",
f"人格倉庫:{pl.persona_home()}",
"規則:",
" 1. 呼叫 persona.py 一律帶 `--session " + session_id + "`(值不符會被 hook 拒絕)。",
" 2. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。",
" 3. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。",
]
if host and pl.persona_exists(host):
# resume / compact / clear:把鎖續租回來,並帶回人格狀態
try:
pl.acquire_lock(host, session_id, cwd=cwd)
lines.append(f"已接續人格 `{host}`session 恢復:{source})。")
lines.append(pl.turn_context(host, session_id))
except pl.LockError as exc:
lines.append(f"⚠ 無法接續人格 `{host}`{exc}")
else:
personas = pl.list_personas()
if personas:
lines.append("尚未載入人格。可用人格:")
for slug in personas:
status = pl.lock_status(slug)
mark = "🔒" if status["locked"] else "🔓"
lines.append(f" - {mark} `{slug}` {pl.identity_brief(slug)}")
lines.append("載入方式:/jsc-persona:persona-chat <slug>")
else:
lines.append("尚無任何人格,可用 /jsc-persona:persona-create 建立。")
lines.append("</persona-runtime>")
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "\n".join(lines),
},
"suppressOutput": True,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
// SubagentStopguest sub agent 結束時解除它的 pin(下次啟動要重新 first-touch),並續租。
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
const event = readEvent();
if (!event) process.exit(0);
const sessionId = event.session_id || "unknown";
const agentId = event.agent_id;
const agentType = String(event.agent_type || "");
if (!agentId || !agentType.includes("persona-guest")) process.exit(0);
const data = pl.loadSession(sessionId);
const pins = data.pins || {};
const slug = pins[agentId];
if (slug !== undefined) {
delete pins[agentId];
data.pins = pins;
pl.saveSession(sessionId, data);
const info = (data.guests || {})[slug];
if (info?.room) pl.addGuestLease(slug, sessionId, info.room, data.host || "");
}
respond({ suppressOutput: true });
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""SubagentStopguest sub agent 結束時解除它的 pin(下次啟動要重新 first-touch)。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
agent_id = event.get("agent_id")
agent_type = str(event.get("agent_type") or "")
if not agent_id or "persona-guest" not in agent_type:
return 0
data = pl.load_session(session_id)
pins = data.get("pins") or {}
slug = pins.pop(agent_id, None)
if slug is not None:
pl.save_session(session_id, data)
info = (data.get("guests") or {}).get(slug) or {}
if info.get("room"):
pl.add_guest_lease(slug, session_id, info["room"], data.get("host") or "")
print(json.dumps({"suppressOutput": True}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
// Stop:情緒隨時間衰減、續租、記錄逐字,並在達到「短期→長期」條件時提醒固化。
// 劇場模式進行中不發任何提醒(那會破壞「只顯示人格對話」)。
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
const event = readEvent();
if (!event) process.exit(0);
const sessionId = event.session_id || "unknown";
const data = pl.loadSession(sessionId);
const host = data.host;
if (!host || !pl.personaExists(host)) process.exit(0);
pl.heartbeatLock(host, sessionId);
const state = pl.decayEmotion(pl.loadEmotion(host));
pl.writeJson(pl.emotionPath(host), state);
const message = event.last_assistant_message || "";
if (message) {
pl.appendJsonl(pl.journalPath(host), {
ts: pl.nowIso(),
kind: "utterance",
role: "persona",
text: message.slice(0, 4000),
mood: pl.mood(state),
});
}
const out = { suppressOutput: true };
const theater = Boolean(data.theater) && (data.rooms || []).length > 0;
if (!theater) {
const { total, candidates } = pl.promotionCandidates(host);
if (candidates.length) {
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
out.systemMessage =
`[jsc-persona] \`${host}\` 短期記憶 ${total} 筆,${candidates.length} 組已達固化條件(${rules}` +
"→ 建議執行 /jsc-persona:persona-memory。";
}
}
respond(out);
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Stop:情緒隨時間衰減、續租、記錄逐字、必要時提醒固化記憶。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
data = pl.load_session(session_id)
host = data.get("host")
if not host or not pl.persona_exists(host):
return 0
pl.heartbeat_lock(host, session_id)
state = pl.decay_emotion(pl.load_emotion(host))
pl.write_json(pl.emotion_path(host), state)
message = event.get("last_assistant_message") or ""
if message:
pl.append_jsonl(pl.journal_path(host), {
"ts": pl.iso(), "kind": "utterance", "role": "persona", "text": message[:4000],
"mood": pl.mood(state),
})
pending = len(pl.read_jsonl(pl.short_term_path(host)))
out: dict = {"suppressOutput": True}
if pending >= pl.CONSOLIDATE_THRESHOLD:
out["systemMessage"] = (
f"[jsc-persona] `{host}` 的短期記憶已 {pending} 筆,"
"建議執行 /jsc-persona:persona-memory 固化為長期記憶。"
)
print(json.dumps(out, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())