From 721fc0ece8fae3c230be5e6398d046a468cdbe13 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 08:34:38 +0000 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20SessionStart=20=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E4=BA=BA=E6=A0=BC=E7=9A=84=20AGENTS.md=20=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E8=A6=8F=E5=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 pl.opsBrief(slug):把該人格的 AGENTS.md 全文包成 區塊, 由 SessionStart hook 在「接續人格」與「預設人格自動載入」兩條分支各注入一次。 刻意不放進 turnContext——操作規則是低頻的「怎麼做事」,每輪重貼只是燒 context。 超過 OPS_BRIEF_MAX_CHARS(6000)會截斷並指路回原檔;檔案不存在時回空字串。 這讓「這個人格自己的工具箱」可以寫在 AGENTS.md 裡跟著人格走(AGENTS.md 本來 就在 sync 的 wiki 白名單內),不必落在機器本機的 ~/.claude/agents。 selftest 補 5 條:全文注入、指路原檔、不進 turnContext、檔案不存在回空、 超長截斷。全套 345 項通過。 --- hooks/session_start.mjs | 13 +++++++++++++ scripts/persona-lib.mjs | 32 ++++++++++++++++++++++++++++++++ scripts/selftest.mjs | 25 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/hooks/session_start.mjs b/hooks/session_start.mjs index 78d5ff6..98ce820 100644 --- a/hooks/session_start.mjs +++ b/hooks/session_start.mjs @@ -37,6 +37,17 @@ const lines = [ " 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(); @@ -58,6 +69,7 @@ if (host && pl.personaExists(host)) { 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}`); } @@ -75,6 +87,7 @@ if (host && pl.personaExists(host)) { 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 與最後心跳告訴使用者,讓他決定。"); diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 87ffa8c..1ee6819 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -1733,6 +1733,9 @@ export function sentenceCount(text) { export const MAX_SENTENCE_CHARS = 45; +// AGENTS.md 開機注入的上限:超過就截斷並指路回原檔,不要一份規則吃掉整個 context。 +export const OPS_BRIEF_MAX_CHARS = 6000; + // 講完就算了:這些開頭都是在解釋自己剛才講的話,真的在講話的人不會這樣。 export const EXPLAINER_OPENERS = [ "我的意思是", "我是想說", "換句話說", "換個說法", "也就是說", "換言之", @@ -3082,6 +3085,35 @@ export function identityBrief(slug) { 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 [ + "", + `以下是 \`${slug}\` 的操作規則(${file}):這輪開機注入一次,之後不再重複貼。`, + "要重看用 `persona.mjs show --what agents --session `;改了就直接編輯那個檔案。", + "", + body, + "", + ].join("\n"); +} + /** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */ export function turnContext(slug, sessionId, prompt = "") { const state = decayEmotion(loadEmotion(slug)); diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 6058ccf..0bb714e 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -81,6 +81,31 @@ check("IDENTITY 五欄位可解析", pl.identityBrief("alpha").includes("Name: A check("SOUL/AGENTS/USER 都有產生", ["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("") && ops.includes("") && 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("")); +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("② 人格鎖:一個人格只能被一個程序載入"); check("建立時即取得鎖", pl.lockStatus("alpha").locked); check("同 session 重入成功", cli(["load", "--persona", "alpha", "--session", S_HOST]).status === 0);