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 項通過。
This commit is contained in:
2026-07-31 08:34:38 +00:00
parent 0527a3cad0
commit 721fc0ece8
3 changed files with 70 additions and 0 deletions
+13
View File
@@ -37,6 +37,17 @@ const lines = [
" 4. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。", " 4. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。",
]; ];
/**
* 把 AGENTS.md(操作規則)注入一次。
*
* 放這裡而不是 turnContext:它是低頻的「怎麼做事」,開機讀一次就夠,
* 每輪重貼只是浪費 context。人格自己的工具箱(例如六把劍)寫在裡面就會跟著人格走。
*/
function pushOps(slug) {
const ops = pl.opsBrief(slug);
if (ops) lines.push(ops);
}
/** 列出可用人格,讓使用者挑(沒有預設人格、或預設人格載入失敗時用)。 */ /** 列出可用人格,讓使用者挑(沒有預設人格、或預設人格載入失敗時用)。 */
function listAvailable() { function listAvailable() {
const personas = pl.listPersonas(); const personas = pl.listPersonas();
@@ -58,6 +69,7 @@ if (host && pl.personaExists(host)) {
pl.acquireLock(host, sessionId, { cwd }); pl.acquireLock(host, sessionId, { cwd });
lines.push(`已接續人格 \`${host}\`session 恢復:${source})。`); lines.push(`已接續人格 \`${host}\`session 恢復:${source})。`);
lines.push(pl.turnContext(host, sessionId)); lines.push(pl.turnContext(host, sessionId));
pushOps(host);
} catch (err) { } catch (err) {
lines.push(`⚠ 無法接續人格 \`${host}\`${err.message}`); lines.push(`⚠ 無法接續人格 \`${host}\`${err.message}`);
} }
@@ -75,6 +87,7 @@ if (host && pl.personaExists(host)) {
lines.push("請照 /jsc-persona:persona-chat 的每輪流程走(語意分析→情緒→回想→3 句內回覆→記憶回寫)。"); lines.push("請照 /jsc-persona:persona-chat 的每輪流程走(語意分析→情緒→回想→3 句內回覆→記憶回寫)。");
lines.push("提醒:這次是本機自動載入,沒有從 Gitea 拉最新狀態;若可能在別台機器動過,先 `sync pull`。"); lines.push("提醒:這次是本機自動載入,沒有從 Gitea 拉最新狀態;若可能在別台機器動過,先 `sync pull`。");
lines.push(pl.turnContext(wanted, sessionId)); lines.push(pl.turnContext(wanted, sessionId));
pushOps(wanted);
} catch (err) { } catch (err) {
lines.push(`⚠ 預設人格 \`${wanted}\` 自動載入失敗:${err.message}`); lines.push(`⚠ 預設人格 \`${wanted}\` 自動載入失敗:${err.message}`);
lines.push("不要自作主張 `--takeover`——先把 owner 與最後心跳告訴使用者,讓他決定。"); lines.push("不要自作主張 `--takeover`——先把 owner 與最後心跳告訴使用者,讓他決定。");
+32
View File
@@ -1733,6 +1733,9 @@ export function sentenceCount(text) {
export const MAX_SENTENCE_CHARS = 45; export const MAX_SENTENCE_CHARS = 45;
// AGENTS.md 開機注入的上限:超過就截斷並指路回原檔,不要一份規則吃掉整個 context。
export const OPS_BRIEF_MAX_CHARS = 6000;
// 講完就算了:這些開頭都是在解釋自己剛才講的話,真的在講話的人不會這樣。 // 講完就算了:這些開頭都是在解釋自己剛才講的話,真的在講話的人不會這樣。
export const EXPLAINER_OPENERS = [ export const EXPLAINER_OPENERS = [
"我的意思是", "我是想說", "換句話說", "換個說法", "也就是說", "換言之", "我的意思是", "我是想說", "換句話說", "換個說法", "也就是說", "換言之",
@@ -3082,6 +3085,35 @@ export function identityBrief(slug) {
return order.filter((k) => fields[k]).map((k) => `${k}: ${fields[k]}`).join(""); return order.filter((k) => fields[k]).map((k) => `${k}: ${fields[k]}`).join("");
} }
/**
* SessionStart 注入的操作規則:AGENTS.md 全文。
*
* 只在開機注入一次(不進 turnContext)——它是低頻的「怎麼做事」,
* 每輪重貼只是把 context 燒掉。內容太長就截斷並指路回原檔。
*/
export function opsBrief(slug) {
let text;
try {
text = fs.readFileSync(path.join(personaDir(slug), "AGENTS.md"), "utf8").trim();
} catch {
return "";
}
if (!text) return "";
const file = path.join(personaDir(slug), "AGENTS.md");
let body = text;
if (body.length > OPS_BRIEF_MAX_CHARS) {
body = body.slice(0, OPS_BRIEF_MAX_CHARS) + `\n\n(後略;全文見 ${file}`;
}
return [
"<persona-ops>",
`以下是 \`${slug}\` 的操作規則(${file}):這輪開機注入一次,之後不再重複貼。`,
"要重看用 `persona.mjs show --what agents --session <id>`;改了就直接編輯那個檔案。",
"",
body,
"</persona-ops>",
].join("\n");
}
/** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */ /** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */
export function turnContext(slug, sessionId, prompt = "") { export function turnContext(slug, sessionId, prompt = "") {
const state = decayEmotion(loadEmotion(slug)); const state = decayEmotion(loadEmotion(slug));
+25
View File
@@ -81,6 +81,31 @@ check("IDENTITY 五欄位可解析", pl.identityBrief("alpha").includes("Name: A
check("SOUL/AGENTS/USER 都有產生", check("SOUL/AGENTS/USER 都有產生",
["SOUL.md", "AGENTS.md", "USER.md"].every((f) => fs.existsSync(path.join(pl.personaDir("alpha"), f)))); ["SOUL.md", "AGENTS.md", "USER.md"].every((f) => fs.existsSync(path.join(pl.personaDir("alpha"), f))));
// 操作規則(AGENTS.md)只在開機注入一次:人格自己的工具箱寫在裡面就會跟著人格走。
check("opsBrief 帶回 AGENTS.md 全文", (() => {
const ops = pl.opsBrief("alpha");
return ops.includes("<persona-ops>") && ops.includes("</persona-ops>") && ops.includes("每輪對話要做的事");
})(), pl.opsBrief("alpha").slice(0, 200));
check("opsBrief 指得出原檔路徑", pl.opsBrief("alpha").includes(path.join(pl.personaDir("alpha"), "AGENTS.md")));
check("opsBrief 不進 turnContext(開機一次就好)",
!pl.turnContext("alpha", S_HOST, "在嗎").includes("<persona-ops>"));
check("AGENTS.md 不存在時 opsBrief 回空字串", (() => {
const file = path.join(pl.personaDir("omega"), "AGENTS.md");
const backup = fs.readFileSync(file, "utf8");
fs.rmSync(file);
const empty = pl.opsBrief("omega");
fs.writeFileSync(file, backup);
return empty === "";
})());
check("opsBrief 超長會截斷並指路", (() => {
const file = path.join(pl.personaDir("omega"), "AGENTS.md");
const backup = fs.readFileSync(file, "utf8");
fs.writeFileSync(file, "劍".repeat(pl.OPS_BRIEF_MAX_CHARS + 500));
const ops = pl.opsBrief("omega");
fs.writeFileSync(file, backup);
return ops.includes("(後略;全文見") && ops.length < pl.OPS_BRIEF_MAX_CHARS + 500;
})());
console.log("② 人格鎖:一個人格只能被一個程序載入"); console.log("② 人格鎖:一個人格只能被一個程序載入");
check("建立時即取得鎖", pl.lockStatus("alpha").locked); check("建立時即取得鎖", pl.lockStatus("alpha").locked);
check("同 session 重入成功", cli(["load", "--persona", "alpha", "--session", S_HOST]).status === 0); check("同 session 重入成功", cli(["load", "--persona", "alpha", "--session", S_HOST]).status === 0);