Files
persona/hooks/session_start.mjs
T
jiantw83 721fc0ece8 feat(hooks): SessionStart 注入人格的 AGENTS.md 操作規則
新增 pl.opsBrief(slug):把該人格的 AGENTS.md 全文包成 <persona-ops> 區塊,
由 SessionStart hook 在「接續人格」與「預設人格自動載入」兩條分支各注入一次。

刻意不放進 turnContext——操作規則是低頻的「怎麼做事」,每輪重貼只是燒 context。
超過 OPS_BRIEF_MAX_CHARS(6000)會截斷並指路回原檔;檔案不存在時回空字串。

這讓「這個人格自己的工具箱」可以寫在 AGENTS.md 裡跟著人格走(AGENTS.md 本來
就在 sync 的 wiki 白名單內),不必落在機器本機的 ~/.claude/agents。

selftest 補 5 條:全文注入、指路原檔、不進 turnContext、檔案不存在回空、
超長截斷。全套 345 項通過。
2026-07-31 08:34:38 +00:00

110 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
// 使用者可以指定「預設人格」(`persona.mjs default --persona <slug>`):那是他明示的選擇,
// 不是我們自己挑的,所以只有在他設過的時候才自動載入;沒設定就維持原本的「等他指定」。
const wanted = host ? null : pl.defaultPersona();
const lines = [
"<persona-runtime>",
`PERSONA_SESSION=${sessionId}`,
`人格倉庫:${pl.personaHome()}`,
"規則:",
` 1. 呼叫 persona.mjs 一律帶 \`--session ${sessionId}\`(值不符會被 hook 拒絕)。`,
wanted
? " 2. 使用者已設定**預設人格**(見下方):那是他明示的選擇,直接以它的身分對話;除此之外不要自己挑人格附身。"
: " 2. 人格**只有使用者叫你載入時才載入**/jsc-persona:persona-chat <slug>),不要自己挑一個人格附身。",
" 3. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。",
" 4. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。",
];
/**
* 把 AGENTS.md(操作規則)注入一次。
*
* 放這裡而不是 turnContext:它是低頻的「怎麼做事」,開機讀一次就夠,
* 每輪重貼只是浪費 context。人格自己的工具箱(例如六把劍)寫在裡面就會跟著人格走。
*/
function pushOps(slug) {
const ops = pl.opsBrief(slug);
if (ops) lines.push(ops);
}
/** 列出可用人格,讓使用者挑(沒有預設人格、或預設人格載入失敗時用)。 */
function listAvailable() {
const personas = pl.listPersonas();
if (!personas.length) {
lines.push("尚無任何人格。可用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime(動漫作品+角色名)建立。");
return;
}
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>");
}
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));
pushOps(host);
} catch (err) {
lines.push(`⚠ 無法接續人格 \`${host}\`${err.message}`);
}
} else if (wanted && !pl.personaExists(wanted)) {
lines.push(`⚠ 預設人格 \`${wanted}\` 不存在(設定於 ${pl.homeSettingsPath()})。請告訴使用者,並問他要改設定還是改載別的。`);
listAvailable();
} else if (wanted) {
// 使用者設過預設人格 → 自動載入。拿不到鎖時只回報,**不**自作主張 --takeover。
try {
pl.acquireLock(wanted, sessionId, { cwd });
pl.bindHost(sessionId, wanted, { cwd });
pl.pruneShortTerm(wanted);
pl.rebuildIndex(wanted);
lines.push(`已自動載入預設人格 \`${wanted}\`(使用者設定,來源:${process.env.PERSONA_DEFAULT ? "PERSONA_DEFAULT" : pl.homeSettingsPath()})。`);
lines.push("請照 /jsc-persona:persona-chat 的每輪流程走(語意分析→情緒→回想→3 句內回覆→記憶回寫)。");
lines.push("提醒:這次是本機自動載入,沒有從 Gitea 拉最新狀態;若可能在別台機器動過,先 `sync pull`。");
lines.push(pl.turnContext(wanted, sessionId));
pushOps(wanted);
} catch (err) {
lines.push(`⚠ 預設人格 \`${wanted}\` 自動載入失敗:${err.message}`);
lines.push("不要自作主張 `--takeover`——先把 owner 與最後心跳告訴使用者,讓他決定。");
listAvailable();
}
} else {
lines.push("尚未載入人格。");
listAvailable();
lines.push("想每次開機就自動載入某個人格:`persona.mjs default --persona <slug> --session <session_id>`。");
}
lines.push("</persona-runtime>");
respond({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: lines.join("\n"),
},
suppressOutput: true,
});