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:
+125
-3
@@ -732,6 +732,125 @@ export function recall(slug, query, limit = 5) {
|
||||
return scored.slice(0, limit).map((s) => s.meta);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 短期 → 長期的轉入條件(固化門檻)
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
/** 承諾/界線類的關鍵詞:命中就一定要固化。 */
|
||||
const COMMITMENT_RE =
|
||||
/(答應|承諾|保證|說好|約定|一定會|絕對不|不要再|以後都|從今天起|拜託你記住|記住這件事|下次記得|deadline|due)/i;
|
||||
const BOUNDARY_RE = /(不准|不許|禁止|別再|我討厭|我最恨|底線|界線|不能接受|不想聽)/;
|
||||
|
||||
export const PROMOTION_RULES = [
|
||||
{ id: "R1", label: "高顯著度單筆(salience ≥ 60)" },
|
||||
{ id: "R2", label: "主題反覆出現(同 topic ≥ 3 筆,或 ≥ 2 筆且平均 salience ≥ 45)" },
|
||||
{ id: "R3", label: "情緒衝擊大(單筆情緒變動總量 ≥ 25)" },
|
||||
{ id: "R4", label: "承諾/界線(intent=commit 或命中承諾/界線關鍵詞)" },
|
||||
{ id: "R5", label: "人物反覆出現(同一 entity ≥ 2 筆)" },
|
||||
{ id: "R6", label: "容量壓力(短期記憶 ≥ 40 筆,依顯著度排序清出空間)" },
|
||||
];
|
||||
|
||||
function emotionImpact(entry) {
|
||||
return Object.values(entry.emotion_deltas || {}).reduce((sum, v) => sum + Math.abs(Number(v) || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 掃短期記憶,依 PROMOTION_RULES 算出「該轉入長期記憶」的候選。
|
||||
* 回傳 { total, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
|
||||
*/
|
||||
export function promotionCandidates(slug) {
|
||||
const rows = readJsonl(shortTermPath(slug));
|
||||
const total = rows.length;
|
||||
const byTopic = new Map();
|
||||
const byEntity = new Map();
|
||||
const singles = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const entry = { ...row, _index: index };
|
||||
const salience = Number(row.salience || 0);
|
||||
const impact = emotionImpact(row);
|
||||
const text = String(row.text || "");
|
||||
const rules = [];
|
||||
if (salience >= 60) rules.push("R1");
|
||||
if (impact >= 25) rules.push("R3");
|
||||
if (row.intent === "commit" || COMMITMENT_RE.test(text)) rules.push("R4");
|
||||
if (BOUNDARY_RE.test(text)) rules.push("R4");
|
||||
if (rules.length) {
|
||||
singles.push({
|
||||
rules: [...new Set(rules)],
|
||||
key: text.slice(0, 40),
|
||||
kind: "entry",
|
||||
entries: [entry],
|
||||
suggested_type: rules.includes("R4") ? (BOUNDARY_RE.test(text) ? "boundary" : "promise") : "event",
|
||||
suggested_salience: Math.max(salience, rules.includes("R4") ? 80 : 60),
|
||||
});
|
||||
}
|
||||
for (const topic of row.topics || []) {
|
||||
if (!byTopic.has(topic)) byTopic.set(topic, []);
|
||||
byTopic.get(topic).push(entry);
|
||||
}
|
||||
for (const entity of row.entities || []) {
|
||||
if (!byEntity.has(entity)) byEntity.set(entity, []);
|
||||
byEntity.get(entity).push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
const candidates = [...singles];
|
||||
for (const [topic, entries] of byTopic) {
|
||||
const avg = entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length;
|
||||
if (entries.length >= 3 || (entries.length >= 2 && avg >= 45)) {
|
||||
candidates.push({
|
||||
rules: ["R2"],
|
||||
key: topic,
|
||||
kind: "topic",
|
||||
entries,
|
||||
suggested_type: "preference",
|
||||
suggested_salience: Math.min(95, Math.round(avg + 10)),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [entity, entries] of byEntity) {
|
||||
if (entries.length >= 2) {
|
||||
candidates.push({
|
||||
rules: ["R5"],
|
||||
key: entity,
|
||||
kind: "entity",
|
||||
entries,
|
||||
suggested_type: "relationship",
|
||||
suggested_salience: Math.min(90, Math.round(entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length + 5)),
|
||||
});
|
||||
}
|
||||
}
|
||||
const pressure = total >= CONSOLIDATE_THRESHOLD;
|
||||
if (pressure) {
|
||||
const top = [...rows]
|
||||
.map((r, i) => ({ ...r, _index: i }))
|
||||
.sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0))
|
||||
.slice(0, 5);
|
||||
candidates.push({
|
||||
rules: ["R6"],
|
||||
key: `容量壓力(${total} 筆)`,
|
||||
kind: "pressure",
|
||||
entries: top,
|
||||
suggested_type: "event",
|
||||
suggested_salience: 55,
|
||||
});
|
||||
}
|
||||
// 同一則短期記憶可能觸發多條規則 → 依 key 去重、合併規則
|
||||
const merged = new Map();
|
||||
for (const cand of candidates) {
|
||||
const dedupeKey = `${cand.kind}:${cand.key}`;
|
||||
if (merged.has(dedupeKey)) {
|
||||
const prev = merged.get(dedupeKey);
|
||||
prev.rules = [...new Set([...prev.rules, ...cand.rules])];
|
||||
prev.suggested_salience = Math.max(prev.suggested_salience, cand.suggested_salience);
|
||||
} else {
|
||||
merged.set(dedupeKey, { ...cand });
|
||||
}
|
||||
}
|
||||
return { total, pressure, candidates: [...merged.values()] };
|
||||
}
|
||||
|
||||
/** 被回想到就更新 last_seen / recall_count(記憶越常用越不易被淘汰)。 */
|
||||
export function touchRecall(slug, names) {
|
||||
const wanted = new Set(names);
|
||||
@@ -1245,9 +1364,12 @@ export function turnContext(slug, sessionId, prompt = "") {
|
||||
" 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。",
|
||||
);
|
||||
} else {
|
||||
const pending = readJsonl(shortTermPath(slug)).length;
|
||||
if (pending >= CONSOLIDATE_THRESHOLD) {
|
||||
lines.push(`⚠ 短期記憶已累積 ${pending} 筆,建議執行 /jsc-persona:persona-memory 固化為長期記憶。`);
|
||||
const { total, candidates } = promotionCandidates(slug);
|
||||
if (candidates.length) {
|
||||
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
|
||||
lines.push(
|
||||
`⚠ 短期記憶 ${total} 筆,其中 ${candidates.length} 組已達固化條件(${rules})→ 執行 /jsc-persona:persona-memory。`,
|
||||
);
|
||||
}
|
||||
let inbox = [];
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user