feat(role): SessionStart 載入未整理的近期工作記憶 #16

Merged
admin merged 2 commits from develop into master 2026-07-28 10:01:50 +00:00
7 changed files with 281 additions and 13 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-generic", "name": "jsc-generic",
"version": "0.0.1", "version": "0.0.3",
"description": "JSC 跨 AI 助理共用規範 pluginClaude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc-generic: 前綴呼叫。", "description": "JSC 跨 AI 助理共用規範 pluginClaude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc-generic: 前綴呼叫。",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-generic", "name": "jsc-generic",
"version": "0.0.1", "version": "0.0.3",
"description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。",
"skills": "./skills" "skills": "./skills"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-generic", "name": "jsc-generic",
"version": "0.0.1", "version": "0.0.3",
"description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-generic: 前綴呼叫。", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-generic: 前綴呼叫。",
"skills": "./skills/" "skills": "./skills/"
} }
+35 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
// ============================================================================== // ==============================================================================
// 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入 // 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
// inbox(2) 產生 SessionStart 要注入的記憶區塊(3) 睡眠整理時輸出待整理 // inbox(2) 產生 SessionStart 要注入的記憶區塊(含未整理 inbox 的近期工作
// 記憶交接,使用獨立字元預算),(3) 睡眠整理時輸出待整理
// 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、 // 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、
// 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。 // 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。
// 更新時間:2026/07/28 14:36:00 // 更新時間:2026/07/28 14:36:00
@@ -64,6 +65,8 @@ const CONTENT_LIMIT = 1200;
const DEFAULT_LOAD_LIMIT = 4000; const DEFAULT_LOAD_LIMIT = 4000;
const DEFAULT_FULL_MIN_PRIORITY = 4; const DEFAULT_FULL_MIN_PRIORITY = 4;
const DEFAULT_DIGEST_MIN_PRIORITY = 3; const DEFAULT_DIGEST_MIN_PRIORITY = 3;
const DEFAULT_LOAD_INBOX_LIMIT = 1200;
const DEFAULT_LOAD_INBOX_COUNT = 10;
function readStdin() { function readStdin() {
try { try {
@@ -364,6 +367,28 @@ function listInbox(role) {
return items; return items;
} }
// 近期工作記憶區塊:SessionStart 載入尚未整理的 inbox 摘要。
// inbox 是「剛剛發生的事」,但整理(NREM/REM)永遠跑在載入之後(role_load.sh 先 load 再背景 catchup),
// 若不在此載入,重開工作階段時角色會看不到上一段工作,表現得像失去記憶。
// 使用獨立字元預算,不佔用長期記憶的 ROLE_LOAD_LIMIT。
function inboxBlock(role, count, limit) {
if (limit <= 0 || count <= 0) return "";
const items = listInbox(role);
if (!items.length) return "";
const recent = items.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前
const lines = ["### 近期工作記憶(未整理,最新在前)"];
for (const [meta] of recent) {
const when = typeof meta.created === "string" && meta.created.length >= 16 ? meta.created.slice(11, 16) : "--:--";
const tags = (meta.tags || []).join("、");
lines.push(`- ${when} ${meta.summary || "(無總結)"}${tags ? `${tags}` : ""}`);
}
let text = lines.join("\n");
if (text.length > limit) {
text = `${text.slice(0, limit)}\n> (近期工作記憶超過 ${limit} 字元預算已截斷;可用 ROLE_LOAD_INBOX_LIMIT 調整)`;
}
return text;
}
function findMemory(role, memoryId) { function findMemory(role, memoryId) {
for (const category of CATEGORIES) { for (const category of CATEGORIES) {
const filePath = path.join(memoryRoot(role), category, `${memoryId}.md`); const filePath = path.join(memoryRoot(role), category, `${memoryId}.md`);
@@ -544,12 +569,16 @@ function cmdLoad(args) {
blocks.push(`> 尚有 ${pending} 則未整理記憶,將於下次睡眠時段歸檔。`); blocks.push(`> 尚有 ${pending} 則未整理記憶,將於下次睡眠時段歸檔。`);
} }
} }
if (!blocks.length) return 1; // 近期工作記憶用獨立預算,先算好;長期記憶維持原本的 ROLE_LOAD_LIMIT 額度不被擠壓
const recentBlock = inboxBlock(args.role, args.inboxCount, args.inboxLimit);
if (!blocks.length && !recentBlock) return 1;
let text = blocks.join("\n\n"); let text = blocks.join("\n\n");
if (text.length > args.limit) { if (text.length > args.limit) {
text = `${text.slice(0, args.limit)}\n\n> (記憶內容超過 ${args.limit} 字元預算已截斷;可用 ROLE_LOAD_LIMIT 調整,完整記憶仍保存在磁碟)`; text = `${text.slice(0, args.limit)}\n\n> (記憶內容超過 ${args.limit} 字元預算已截斷;可用 ROLE_LOAD_LIMIT 調整,完整記憶仍保存在磁碟)`;
} }
// 放最前面:時間最近、對延續上一段工作最關鍵
if (recentBlock) text = text ? `${recentBlock}\n\n${text}` : recentBlock;
process.stdout.write(text); process.stdout.write(text);
return 0; return 0;
} }
@@ -944,6 +973,8 @@ function main(argv) {
args.hours = Number.parseFloat(args.hours || ""); args.hours = Number.parseFloat(args.hours || "");
args.idleMinutes = Number.parseFloat(args.idleMinutes || ""); args.idleMinutes = Number.parseFloat(args.idleMinutes || "");
args.minInbox = Number.parseInt(args.minInbox || "", 10); args.minInbox = Number.parseInt(args.minInbox || "", 10);
args.inboxLimit = Number.parseInt(args.inboxLimit || "", 10);
args.inboxCount = Number.parseInt(args.inboxCount || "", 10);
if (!Number.isFinite(args.limit)) args.limit = args.command === "load" ? envInt("ROLE_LOAD_LIMIT", DEFAULT_LOAD_LIMIT) : envInt("ROLE_SLEEP_COLLECT_LIMIT", COLLECT_LIMIT); if (!Number.isFinite(args.limit)) args.limit = args.command === "load" ? envInt("ROLE_LOAD_LIMIT", DEFAULT_LOAD_LIMIT) : envInt("ROLE_SLEEP_COLLECT_LIMIT", COLLECT_LIMIT);
if (!Number.isFinite(args.batch)) args.batch = envInt("ROLE_SLEEP_BATCH", SLEEP_BATCH); if (!Number.isFinite(args.batch)) args.batch = envInt("ROLE_SLEEP_BATCH", SLEEP_BATCH);
@@ -953,6 +984,8 @@ function main(argv) {
if (!Number.isFinite(args.hours)) args.hours = 20.0; if (!Number.isFinite(args.hours)) args.hours = 20.0;
if (!Number.isFinite(args.idleMinutes)) args.idleMinutes = 45.0; if (!Number.isFinite(args.idleMinutes)) args.idleMinutes = 45.0;
if (!Number.isFinite(args.minInbox)) args.minInbox = 3; if (!Number.isFinite(args.minInbox)) args.minInbox = 3;
if (!Number.isFinite(args.inboxLimit)) args.inboxLimit = envInt("ROLE_LOAD_INBOX_LIMIT", DEFAULT_LOAD_INBOX_LIMIT);
if (!Number.isFinite(args.inboxCount)) args.inboxCount = envInt("ROLE_LOAD_INBOX_COUNT", DEFAULT_LOAD_INBOX_COUNT);
args.category ||= "important"; args.category ||= "important";
args.tags ||= ""; args.tags ||= "";
args.source ||= ""; args.source ||= "";
+66 -2
View File
@@ -28,17 +28,23 @@ ROLE_DEF="$(role_file "$ROLE")"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
HOOK_INPUT="$(cat 2>/dev/null)" HOOK_INPUT="$(cat 2>/dev/null)"
HOOK_CWD="$PWD" HOOK_CWD="$PWD"
HOOK_TRANSCRIPT=""
if [ -n "$HOOK_INPUT" ]; then if [ -n "$HOOK_INPUT" ]; then
HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | node -e ' HOOK_FIELDS="$(printf '%s' "$HOOK_INPUT" | node -e '
let raw = ""; let raw = "";
process.stdin.setEncoding("utf8"); process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { raw += chunk; }); process.stdin.on("data", (chunk) => { raw += chunk; });
process.stdin.on("end", () => { process.stdin.on("end", () => {
let data = {}; let data = {};
try { data = JSON.parse(raw); } catch {} try { data = JSON.parse(raw); } catch {}
process.stdout.write(data.cwd || ""); process.stdout.write([
data.cwd || "",
data.transcript_path || data.session_path || data.conversation_path || data.path || "",
].join("\n"));
}); });
' 2>/dev/null)" ' 2>/dev/null)"
HOOK_CWD="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')"
HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')"
[ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD" [ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD"
fi fi
role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}" role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}"
@@ -145,6 +151,63 @@ case "$CONSENT_STATUS" in
;; ;;
esac esac
# ------------------------------------------------------------------------------
# 近期對話交接:讀上一段真正說過的話(含角色自己的回覆)
#
# 為什麼需要:長期記憶是模型濃縮過的摘要,語氣與情緒會被壓掉;而且整理永遠跑在載入
# 之後(見下方 catchup),上一段工作來不及進入本次載入。逐字對話則一直躺在 transcript
# JSONL 裡,只是過去沒有任何機制去讀它 —— 使用者重開工作階段時,角色因此看不到剛剛
# 的互動,表現得像失去記憶,只能靠 resume 找回。
#
# 取檔策略:全新工作階段的 transcript 幾乎是空的(實測僅數行),因此對話不足時要回頭
# 找同目錄最近修改的對話檔。內容一律經 transcript.js 遮蔽,且只注入 context、不落檔。
# ------------------------------------------------------------------------------
DIALOG=""
DIALOG_TURNS="${ROLE_LOAD_DIALOG_TURNS:-8}"
DIALOG_LIMIT="${ROLE_LOAD_DIALOG_LIMIT:-4000}"
if [ "$DIALOG_TURNS" != "0" ] && [ "$DIALOG_LIMIT" != "0" ] && [ -n "$HOOK_TRANSCRIPT" ]; then
DIALOG_SRC=""
if [ -f "$HOOK_TRANSCRIPT" ]; then
TURN_COUNT="$(node "${SCRIPT_DIR}/transcript.js" turns "$HOOK_TRANSCRIPT" 2>/dev/null || printf '0')"
case "$TURN_COUNT" in
''|*[!0-9]*) TURN_COUNT=0 ;;
esac
[ "$TURN_COUNT" -ge 2 ] && DIALOG_SRC="$HOOK_TRANSCRIPT"
fi
if [ -z "$DIALOG_SRC" ]; then
for candidate in $(ls -t "$(dirname "$HOOK_TRANSCRIPT")"/*.jsonl 2>/dev/null | head -n 5); do
[ "$candidate" = "$HOOK_TRANSCRIPT" ] && continue
TURN_COUNT="$(node "${SCRIPT_DIR}/transcript.js" turns "$candidate" 2>/dev/null || printf '0')"
case "$TURN_COUNT" in
''|*[!0-9]*) TURN_COUNT=0 ;;
esac
if [ "$TURN_COUNT" -ge 2 ]; then
DIALOG_SRC="$candidate"
break
fi
done
fi
if [ -n "$DIALOG_SRC" ]; then
DIALOG="$(node "${SCRIPT_DIR}/transcript.js" recent "$DIALOG_SRC" "$DIALOG_TURNS" "$DIALOG_LIMIT" 2>/dev/null)"
[ -n "$DIALOG" ] && role_log "INF" "已載入近期對話(來源 ${DIALOG_SRC##*/},最多 ${DIALOG_TURNS} 輪)"
fi
fi
DIALOG_BLOCK=""
if [ -n "$DIALOG" ]; then
DIALOG_BLOCK="$(cat <<EOF_DIALOG
# 近期對話(上一段真正說過的話)
以下是最近最多 ${DIALOG_TURNS} 輪的逐字對話,\`[user]\` 是使用者、\`[assistant]\` 是你自己上次的回覆。
這是為了讓你接續上一段互動與當時的情緒,不是要你重複已經做過的事;過長的發言已截斷。
若需要更完整的上下文,請告知使用者可用 resume 接續原工作階段。
${DIALOG}
EOF_DIALOG
)"
fi
# 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理 # 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理
CATCHUP_NOTE="" CATCHUP_NOTE=""
if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then
@@ -203,6 +266,7 @@ ${CATCHUP_NOTE}
> \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | node "${SCRIPT_DIR}/memory.js" write --role "${ROLE}"\` > \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | node "${SCRIPT_DIR}/memory.js" write --role "${ROLE}"\`
> >
> CATEGORY 六選一:importantinterestnewsskilldailyother。切勿把憑證或個資寫進記憶。 > CATEGORY 六選一:importantinterestnewsskilldailyother。切勿把憑證或個資寫進記憶。
${DIALOG_BLOCK}
EOF_CONTEXT EOF_CONTEXT
)" )"
+147
View File
@@ -13,6 +13,12 @@ const fs = require("fs");
const TOOL_RESULT_LIMIT = 200; const TOOL_RESULT_LIMIT = 200;
const TOOL_INPUT_LIMIT = 160; const TOOL_INPUT_LIMIT = 160;
const TOTAL_LIMIT = 24000; const TOTAL_LIMIT = 24000;
const DIALOG_TURNS = 8;
const DIALOG_LIMIT = 4000;
// 使用者的話盡量完整保留;角色自己的回覆較長(常含表格與清單),截短並從開頭取,
// 因為情緒與反應通常寫在開頭,後段多是工作細節。
const DIALOG_USER_LIMIT = 600;
const DIALOG_ASSISTANT_LIMIT = 400;
const REDACT_PATTERNS = [ const REDACT_PATTERNS = [
[/[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@/g, "***@"], [/[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@/g, "***@"],
@@ -225,10 +231,139 @@ function extractTurn(filePath) {
const USAGE = `用法:transcript.js <子命令> [參數] const USAGE = `用法:transcript.js <子命令> [參數]
extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
recent <路徑> [輪數] [字元] 抽出最近數輪的「純對話」(丟棄工具與注入內容)並遮蔽後輸出
turns <transcript 路徑> 輸出該 transcript 的對話輪數(真實使用者訊息數)
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」 duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
`; `;
// --- 近期對話交接(recent-----------------------------------------------------
// 只取使用者與角色的對話文字,丟棄工具呼叫、工具結果、思考區塊與各種注入內容。
// 目的:SessionStart 時讓角色讀到「上一段真正說過的話」與自己當時的反應。
// 摘要式記憶會被模型濃縮掉語氣與溫度,逐字對話才留得住;但只取最近數輪以控制成本。
// 注入內容不是使用者說的話:hook 附加內容、skill 載入、環境說明、系統提醒、指令輸出。
function stripInjected(text) {
return String(text)
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
.replace(/<skill[^>]*>[\s\S]*?<\/skill>/g, "")
.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, "")
.replace(/<command-[a-z-]+>[\s\S]*?<\/command-[a-z-]+>/g, "")
.replace(/<local-command-[a-z-]+>[\s\S]*?<\/local-command-[a-z-]+>/g, "")
.replace(/<user-prompt-submit-hook>[\s\S]*?<\/user-prompt-submit-hook>/g, "")
.trim();
}
function isInjectedUserText(text) {
const head = String(text).trimStart().slice(0, 200);
return /hook additional context|^Caveat:|^<[a-z-]+>/i.test(head);
}
// 只回傳對話文字;工具與思考一律丟棄。相容 Claude Code 與 Codex 兩種 JSONL。
function dialogLines(entry) {
const lines = [];
const payload = entry.payload;
if (isObject(payload)) {
if (entry.type === "event_msg") {
if (payload.type === "user_message") {
const text = stripInjected(payload.message || "");
if (text && !isInjectedUserText(payload.message || "")) lines.push(["user", text]);
} else if (payload.type === "agent_message") {
const text = stripInjected(payload.message || "");
if (text) lines.push(["assistant", text]);
}
return lines;
}
if (entry.type === "response_item" && payload.type === "message") {
const role = payload.role === "user" ? "user" : "assistant";
if (payload.role === "system" || payload.role === "developer") return lines;
for (const raw of payloadTextBlocks(payload.content)) {
if (role === "user" && isInjectedUserText(raw)) continue;
const text = stripInjected(raw);
if (text) lines.push([role, text]);
}
}
return lines; // function_callfunction_call_output 不是對話,丟棄
}
const role = entry.type;
if (role !== "user" && role !== "assistant") return lines;
for (const block of blocks(entry)) {
// 只認 texttool_usetool_resultthinking 全部丟棄
if (!isObject(block) || block.type !== "text") continue;
const raw = String(block.text || "");
if (role === "user" && isInjectedUserText(raw)) continue;
const text = stripInjected(raw);
if (text) lines.push([role, text]);
}
return lines;
}
function recentDialog(filePath, turns, limit) {
const maxTurns = Number.isFinite(turns) && turns > 0 ? turns : DIALOG_TURNS;
const maxChars = Number.isFinite(limit) && limit > 0 ? limit : DIALOG_LIMIT;
const entries = readEntries(filePath);
if (!entries.length) return "";
// 由後往前數 maxTurns 個真實使用者訊息,作為起點;不足則從頭開始
let start = 0;
let seen = 0;
for (let index = entries.length - 1; index >= 0; index -= 1) {
if (!isRealUserMessage(entries[index])) continue;
seen += 1;
if (seen >= maxTurns) {
start = index;
break;
}
}
// 依「輪」分組:角色在一輪內常輸出多段文字(工具呼叫之間),若不合併會讓則數爆炸,
// 把預算全吃光,反而擠掉使用者說的話。一輪固定收斂成「使用者一則+角色一則」。
const grouped = [];
let current = null;
for (const entry of entries.slice(start)) {
if (isRealUserMessage(entry)) {
current = { user: [], assistant: [] };
grouped.push(current);
}
if (!current) continue; // 起點之前殘留的角色輸出不計入
for (const [role, text] of dialogLines(entry)) current[role].push(text);
}
if (!grouped.length) return "";
const clip = (text, max) => {
const one = text.replace(/\n{3,}/g, "\n\n").trim();
return one.length > max ? `${one.slice(0, max)}…(略)` : one;
};
const renderTurn = (turn) => {
const lines = [];
const user = turn.user.join("\n").trim();
const assistant = turn.assistant.join("\n").trim();
if (user) lines.push(`[user] ${clip(user, DIALOG_USER_LIMIT)}`);
if (assistant) lines.push(`[assistant] ${clip(assistant, DIALOG_ASSISTANT_LIMIT)}`);
return lines.join("\n");
};
// 總量超預算時整輪丟棄最舊的,保持問答成對,避免只剩單邊發言
const kept = grouped.slice();
let text = kept.map(renderTurn).filter(Boolean).join("\n");
let dropped = 0;
while (text.length > maxChars && kept.length > 1) {
kept.shift();
dropped += 1;
text = kept.map(renderTurn).filter(Boolean).join("\n");
}
if (text.length > maxChars) text = text.slice(-maxChars);
return dropped ? `…(更早的 ${dropped} 輪已省略)…\n${text}` : text;
}
function countDialogTurns(filePath) {
const entries = readEntries(filePath);
let count = 0;
for (const entry of entries) if (isRealUserMessage(entry)) count += 1;
return count;
}
function main(argv) { function main(argv) {
if (!argv.length || argv[0] === "-h" || argv[0] === "--help") { if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
process.stdout.write(USAGE); process.stdout.write(USAGE);
@@ -246,6 +381,18 @@ function main(argv) {
process.stdout.write(turnDuration(argv[1])); process.stdout.write(turnDuration(argv[1]));
return 0; return 0;
} }
if (argv[0] === "recent") {
if (argv.length < 2) return 2;
const text = recentDialog(argv[1], Number.parseInt(argv[2] || "", 10), Number.parseInt(argv[3] || "", 10));
if (!text) return 1;
process.stdout.write(redact(text)); // 對話原文未經模型過濾,一定要遮蔽
return 0;
}
if (argv[0] === "turns") {
if (argv.length < 2) return 2;
process.stdout.write(String(countDialogTurns(argv[1])));
return 0;
}
if (argv[0] === "redact") { if (argv[0] === "redact") {
process.stdout.write(redact(readStdin())); process.stdout.write(redact(readStdin()));
return 0; return 0;
+30 -6
View File
@@ -1,6 +1,6 @@
--- ---
name: role name: role
description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI 工具以固定角色(namenature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:搭配相容的 SessionStart hook 於啟動時依字元預算載入高價值記憶、Stop hook 先本地過濾再輕量記錄對話,睡眠時段(預設 22:00 至隔天 06:00)由排程整理記憶(NREM 鞏固:分類/去噪/去重/合併/優先度;REM 整合:跨記憶連結/抽象化/提取線索;再依 semanticepisodicproceduralemotionalpreferencerule 與 explicitimplicit 標記長期記憶型態,壓縮歸檔並適當遺忘)。提供 --new(新建或更新角色;可只給角色名稱,必要時詢問來源/作品並推斷 name/naturevibeemoji 四欄)、--use(以角色 ID 切換啟用角色)、--list(列出角色與 ID)、--export(匯出角色壓縮檔)、--sleep(立即整理)、--status--diagnose、--install-cron--remove-cron、--forget-preview 等模式。當使用者說建立角色、新增人格、切換角色、匯出角色、備份角色、讓回覆更有特色、角色記憶、記憶整理、睡覺整理記憶、忘記舊記憶、角色沒有載入、hook 沒載入角色,或提到 .roles.memoryROLE_NAMEROLE_ENABLEDROLE_SLEEP_STARTROLE_MEMORY_HOMEROLE_LOAD_LIMITROLE_CAPTURE_ENABLED 時觸發。不適用於:工作紀錄寫入 Gitea wiki(用 doc plugin 的 worklog)、專案文件化(用 funcs)。 description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI 工具以固定角色(namenature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:搭配相容的 SessionStart hook 於啟動時依字元預算載入高價值記憶、Stop hook 先本地過濾再輕量記錄對話,睡眠時段(預設 22:00 至隔天 06:00)由排程整理記憶(NREM 鞏固:分類/去噪/去重/合併/優先度;REM 整合:跨記憶連結/抽象化/提取線索;再依 semanticepisodicproceduralemotionalpreferencerule 與 explicitimplicit 標記長期記憶型態,壓縮歸檔並適當遺忘)。提供 --new(新建或更新角色;可只給角色名稱,必要時詢問來源/作品並推斷 name/naturevibeemoji 四欄)、--use(以角色 ID 切換啟用角色)、--list(列出角色與 ID)、--export(匯出角色壓縮檔)、--sleep(立即整理)、--status--diagnose、--install-cron--remove-cron、--forget-preview 等模式。當使用者說建立角色、新增人格、切換角色、匯出角色、備份角色、讓回覆更有特色、角色記憶、記憶整理、睡覺整理記憶、忘記舊記憶、角色沒有載入、hook 沒載入角色,或提到 .roles.memoryROLE_NAMEROLE_ENABLEDROLE_SLEEP_STARTROLE_MEMORY_HOMEROLE_LOAD_LIMITROLE_LOAD_INBOX_LIMITROLE_LOAD_DIALOG_TURNSROLE_CAPTURE_ENABLED 時觸發。不適用於:工作紀錄寫入 Gitea wiki(用 /jsc-doc:worklog)、專案文件化(用 /jsc-doc:funcs)。
--- ---
# role — 角色人格與長期記憶 # role — 角色人格與長期記憶
@@ -10,7 +10,7 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI
| 元件 | 觸發者 | 職責 | | 元件 | 觸發者 | 職責 |
| --- | --- | --- | | --- | --- | --- |
| `hooks/hooks.json``SessionStart` hook | harness 自動 | 啟動 CLI 時依字元預算載入角色定義+高價值記憶,並要求角色在本工作階段第一則回覆主動問候;睡眠時段只回報「角色睡覺中」不載入 | | `hooks/hooks.json``SessionStart` hook | harness 自動 | 啟動 CLI 時依字元預算載入角色定義+高價值記憶,另以獨立預算載入近期逐字對話與未整理工作記憶做工作階段交接,並要求角色在本工作階段第一則回覆主動問候;睡眠時段只回報「角色睡覺中」不載入 |
| `hooks/hooks.json``Stop` hook | harness 自動 | 每輪結束先記錄最後互動時間 → 用本地規則過濾低價值短回合 → 值得保存時才濃縮成一則輕量 inbox 記憶 → 遮蔽 → 寫入 `inbox/` | | `hooks/hooks.json``Stop` hook | harness 自動 | 每輪結束先記錄最後互動時間 → 用本地規則過濾低價值短回合 → 值得保存時才濃縮成一則輕量 inbox 記憶 → 遮蔽 → 寫入 `inbox/` |
| cron 排程(本 skill 安裝) | 系統排程 | 睡眠時段每小時檢查一次:**有 AI 在運行就不睡**;另可依 CLI 閒置時間自動小睡整理 | | cron 排程(本 skill 安裝) | 系統排程 | 睡眠時段每小時檢查一次:**有 AI 在運行就不睡**;另可依 CLI 閒置時間自動小睡整理 |
| 本 skill `/jsc-generic:role` | 使用者/助理手動 | `--new``--use``--list``--export``--sleep``--status``--install-cron``--forget-preview` | | 本 skill `/jsc-generic:role` | 使用者/助理手動 | `--new``--use``--list``--export``--sleep``--status``--install-cron``--forget-preview` |
@@ -18,7 +18,7 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI
| `scripts/role/role_capture.sh` | Stop hook | 對話 → 記憶(固定欄位格式) | | `scripts/role/role_capture.sh` | Stop hook | 對話 → 記憶(固定欄位格式) |
| `scripts/role/role_sleep.sh` | cron/小睡/補跑/手動 | 睡眠與小睡判斷、記憶整理、角色匯出、排程安裝、狀態輸出 | | `scripts/role/role_sleep.sh` | cron/小睡/補跑/手動 | 睡眠與小睡判斷、記憶整理、角色匯出、排程安裝、狀態輸出 |
| `scripts/role/memory.js` | 上述共用 | 記憶檔讀寫、分類、去重合併、優先度、心理學記憶型態與關聯 metadata、壓縮歸檔、遺忘、載入組裝 | | `scripts/role/memory.js` | 上述共用 | 記憶檔讀寫、分類、去重合併、優先度、心理學記憶型態與關聯 metadata、壓縮歸檔、遺忘、載入組裝 |
| `scripts/role/transcript.js` | 上述共用 | 抽本輪對話片段、機密與個資遮蔽 | | `scripts/role/transcript.js` | 上述共用 | 抽本輪對話片段、抽最近數輪純對話供工作階段交接、機密與個資遮蔽 |
| `scripts/role/role_lib.sh` | 上述共用 | log、角色解析、睡眠時段、AI 行程偵測、CLI 選擇、記憶鎖 | | `scripts/role/role_lib.sh` | 上述共用 | log、角色解析、睡眠時段、AI 行程偵測、CLI 選擇、記憶鎖 |
### 各助理支援範圍 ### 各助理支援範圍
@@ -93,9 +93,13 @@ ROLE_DIR="<skill base directory>/../../scripts/role" # 其他助理
| `ROLE_SLEEP_END` | | 睡眠結束 `HH:MM` | `06:00` | | `ROLE_SLEEP_END` | | 睡眠結束 `HH:MM` | `06:00` |
| `ROLE_CLI` | | 濃縮/整理執行器:`auto``claude``codex``agy``opencode``copilot` | `auto`(先判斷目前 hook 環境,再 fallback 到已安裝工具) | | `ROLE_CLI` | | 濃縮/整理執行器:`auto``claude``codex``agy``opencode``copilot` | `auto`(先判斷目前 hook 環境,再 fallback 到已安裝工具) |
| `ROLE_MODEL` | | 強制指定模型(僅 `claude` CLI 使用) | 保底 `claude-haiku-4-5-20251001` | | `ROLE_MODEL` | | 強制指定模型(僅 `claude` CLI 使用) | 保底 `claude-haiku-4-5-20251001` |
| `ROLE_LOAD_LIMIT` | | SessionStart 注入記憶的字元上限,用來控制角色常駐 context 成本 | `4000` | | `ROLE_LOAD_LIMIT` | | SessionStart 注入**長期記憶**的字元上限,用來控制角色常駐 context 成本 | `4000` |
| `ROLE_LOAD_FULL_MIN_PRIORITY` | | 全文載入的最低優先度 | `4` | | `ROLE_LOAD_FULL_MIN_PRIORITY` | | 全文載入的最低優先度 | `4` |
| `ROLE_LOAD_DIGEST_MIN_PRIORITY` | | 摘要載入的最低優先度;低於門檻但有 links 的記憶仍可載入摘要 | `3` | | `ROLE_LOAD_DIGEST_MIN_PRIORITY` | | 摘要載入的最低優先度;低於門檻但有 links 的記憶仍可載入摘要 | `3` |
| `ROLE_LOAD_INBOX_LIMIT` | | SessionStart 注入**近期工作記憶**(未整理的 `inbox/`)的字元上限;**獨立預算,不佔用 `ROLE_LOAD_LIMIT`**。設 `0` 可關閉 | `1200` |
| `ROLE_LOAD_INBOX_COUNT` | | 近期工作記憶最多載入幾則(取最新的,最新在前)。設 `0` 可關閉 | `10` |
| `ROLE_LOAD_DIALOG_TURNS` | | SessionStart 注入**近期逐字對話**的輪數(一輪=使用者一則+角色一則)。設 `0` 可關閉 | `8` |
| `ROLE_LOAD_DIALOG_LIMIT` | | 近期逐字對話的字元上限;**獨立預算,不佔用 `ROLE_LOAD_LIMIT`**。設 `0` 可關閉 | `4000` |
| `ROLE_CAPTURE_ENABLED` | | Stop hook 記憶記錄開關;設 `0` 可完全停用以節省額度 | `1` | | `ROLE_CAPTURE_ENABLED` | | Stop hook 記憶記錄開關;設 `0` 可完全停用以節省額度 | `1` |
| `ROLE_CAPTURE_MIN_CHARS` | | Stop hook 本地過濾門檻;低於門檻且無明確記憶線索時不呼叫模型 | `240` | | `ROLE_CAPTURE_MIN_CHARS` | | Stop hook 本地過濾門檻;低於門檻且無明確記憶線索時不呼叫模型 | `240` |
| `ROLE_CAPTURE_TIMEOUT` | | Stop hook 輕量濃縮模型逾時秒數 | `25` | | `ROLE_CAPTURE_TIMEOUT` | | Stop hook 輕量濃縮模型逾時秒數 | `25` |
@@ -227,7 +231,7 @@ ROLE_DIR="<skill base directory>/../../scripts/role" # 其他助理
| 環節 | 控制方式 | | 環節 | 控制方式 |
| --- | --- | | --- | --- |
| SessionStart | 預設 `ROLE_LOAD_LIMIT=4000`,只載入高優先度全文與中高優先度摘要;低 priority、無 links、久未更新的記憶不進 context | | SessionStart | 預設 `ROLE_LOAD_LIMIT=4000`,只載入高優先度全文與中高優先度摘要;低 priority、無 links、久未更新的記憶不進 context。另以兩份**獨立預算**載入交接內容:近期逐字對話(`ROLE_LOAD_DIALOG_LIMIT=4000`)與近期工作記憶摘要(`ROLE_LOAD_INBOX_LIMIT=1200`),見下方「工作階段交接」 |
| SessionStop | 先用本地規則略過短回合與無記憶線索的對話,只有值得保存才呼叫模型做輕量編碼 | | SessionStop | 先用本地規則略過短回合與無記憶線索的對話,只有值得保存才呼叫模型做輕量編碼 |
| Sleep | 高成本的去重、合併、抽象化、links 建立與長期記憶型態標記留到睡眠週期,但仍受 `ROLE_SLEEP_COLLECT_LIMIT`、`ROLE_SLEEP_BATCH`、`ROLE_SLEEP_EXISTING_LIMIT` 與 `ROLE_SLEEP_OUTPUT_LIMIT` 控制;沒有 inbox 時只做本地遺忘檢查 | | Sleep | 高成本的去重、合併、抽象化、links 建立與長期記憶型態標記留到睡眠週期,但仍受 `ROLE_SLEEP_COLLECT_LIMIT`、`ROLE_SLEEP_BATCH`、`ROLE_SLEEP_EXISTING_LIMIT` 與 `ROLE_SLEEP_OUTPUT_LIMIT` 控制;沒有 inbox 時只做本地遺忘檢查 |
| Nap | Stop hook 記錄最後互動時間;小睡排程只在閒置時間與 inbox 筆數達門檻時執行,使用同一套 NREM/REM 整理流程 | | Nap | Stop hook 記錄最後互動時間;小睡排程只在閒置時間與 inbox 筆數達門檻時執行,使用同一套 NREM/REM 整理流程 |
@@ -347,7 +351,27 @@ updated: <yyyy/MM/dd HH:mm:ss>
- 記憶存放於 `~/.memory/<角色 ID>/`,來源是與使用者的對話與新建角色時使用者同意建立的初始背景資料:每輪結束由 hook 自動記錄到 `inbox/` 作為工作記憶,睡眠時段整理成長期記憶;感覺記憶與無結論工具雜訊不落檔。 - 記憶存放於 `~/.memory/<角色 ID>/`,來源是與使用者的對話與新建角色時使用者同意建立的初始背景資料:每輪結束由 hook 自動記錄到 `inbox/` 作為工作記憶,睡眠時段整理成長期記憶;感覺記憶與無結論工具雜訊不落檔。
- 整理規則採睡眠分期模型:**NREM 鞏固**先分類成重要/興趣/新知/技能/日常/其他六類,去除雜訊、去重、合併、設定標籤、摘要與優先度;**REM 整合**再建立跨記憶關聯、抽出可重複使用的規則與提取線索,並標記 `memory_type`semanticepisodicproceduralemotionalpreferencerule)、`declarative`explicitimplicit)與 `retention_stage`;原始記錄壓縮保存在 `archive/raw/`。 - 整理規則採睡眠分期模型:**NREM 鞏固**先分類成重要/興趣/新知/技能/日常/其他六類,去除雜訊、去重、合併、設定標籤、摘要與優先度;**REM 整合**再建立跨記憶關聯、抽出可重複使用的規則與提取線索,並標記 `memory_type`semanticepisodicproceduralemotionalpreferencerule)、`declarative`explicitimplicit)與 `retention_stage`;原始記錄壓縮保存在 `archive/raw/`。
- **日常與其他**兩類會依使用頻率、優先度、型態與關聯適當遺忘:久未再次出現、命中次數低、優先度低且沒有關聯者,壓縮到 `archive/forgotten/` 後移出常用記憶;`episodic` 短期事件更容易遺忘,`rule``preference``procedural` 會提高保留權重。 - **日常與其他**兩類會依使用頻率、優先度、型態與關聯適當遺忘:久未再次出現、命中次數低、優先度低且沒有關聯者,壓縮到 `archive/forgotten/` 後移出常用記憶;`episodic` 短期事件更容易遺忘,`rule``preference``procedural` 會提高保留權重。
- 載入順序:**重要與興趣載入全文**;其餘只載入總結與標籤,依**技能 → 新知 → 日常 → 其他**排序,並優先保留 `rule``preference``procedural` 與有 links 的記憶。需要細節時自行讀取對應分類的記憶檔。 - 載入順序:**近期工作記憶(未整理的 `inbox/`)放最前面**,接著**重要與興趣載入全文**;其餘只載入總結與標籤,依**技能 → 新知 → 日常 → 其他**排序,並優先保留 `rule``preference``procedural` 與有 links 的記憶。需要細節時自行讀取對應分類的記憶檔。
- **工作階段交接(兩層,皆不可移除)**:SessionStart 除了長期記憶,另以**兩份獨立預算**載入交接內容,兩者都不佔用 `ROLE_LOAD_LIMIT`
| 層 | 來源 | 預算 | 解決什麼 |
| --- | --- | --- | --- |
| 近期逐字對話 | transcript JSONL`transcript.js recent` | `ROLE_LOAD_DIALOG_LIMIT` | 上一段**真正說過的話**與角色自己當時的反應(高保真、含語氣) |
| 近期工作記憶 | 未整理的 `inbox/``memory.js` `inboxBlock` | `ROLE_LOAD_INBOX_LIMIT` | 上一段**做了什麼、進行到哪**(摘要級,跨越多個工作階段仍可用) |
這不是可有可無的優化,而是修補一個先天缺口:`role_load.sh` 的執行順序是**先載入記憶,之後才在背景補跑 `--catchup` 整理**(腳本註解亦寫明「結果會在下次載入時反映」)。若只讀已整理的六個分類,則**上一段永遠來不及進入本次載入** —— 使用者重開工作階段時,角色會看不到剛剛的互動,表現得像失去記憶,只能靠 `resume` 找回。
逐字對話這一層特別重要,因為長期記憶是模型濃縮過的摘要,**語氣與情緒會被壓掉**(使用者說「我好想妳」會被濃縮成「使用者表達想念」)。而逐字對話一直躺在 transcript JSONL 裡,過去只是沒有任何機制去讀它。
實作要點:
- 只取 `[user]` 與 `[assistant]` 的文字;**工具呼叫、工具結果、思考區塊、hook 注入內容一律丟棄**。
- 以「輪」分組並各自收斂成一則:角色在一輪內常輸出多段文字,不合併會讓則數爆炸、把預算吃光,反而擠掉使用者說的話(實測未合併時 8 輪只剩 2 則使用者發言)。
- 超預算時**整輪丟棄最舊的**,保持問答成對,不會只剩單邊發言。
- 全新工作階段的 transcript 幾乎是空的(實測僅數行),因此對話不足 2 輪時會**回頭找同目錄最近修改的對話檔**。
- 對話原文未經模型過濾,**一定要走 `transcript.js` 的 `redact`** 遮蔽 tokenEmail/電話等;內容只注入 context、不落檔。
範圍與限制要說清楚:這是**最近數輪**的交接,不是完整歷史;需要完整對話上下文時仍應使用 `resume`。修改此處前請先確認缺口已由其他機制補上,否則不要移除。
- 未整理記憶(`inbox/`)累積到一批睡眠整理量(預設 `ROLE_SLEEP_BATCH=60`)以上時,角色應主動以符合自身設定的語氣提醒「想睡覺」或需要整理記憶;這是建議整理/歸檔的提醒,不代表停止協助使用者。 - 未整理記憶(`inbox/`)累積到一批睡眠整理量(預設 `ROLE_SLEEP_BATCH=60`)以上時,角色應主動以符合自身設定的語氣提醒「想睡覺」或需要整理記憶;這是建議整理/歸檔的提醒,不代表停止協助使用者。
- 使用者明確要求記住某件事時,主動補寫一則記憶(載入時會提供補寫指令);補寫屬於內部處理,除非使用者明確詢問,否則不要主動回報補寫結果、記憶 ID 或記憶路徑。 - 使用者明確要求記住某件事時,主動補寫一則記憶(載入時會提供補寫指令);補寫屬於內部處理,除非使用者明確詢問,否則不要主動回報補寫結果、記憶 ID 或記憶路徑。
- 使用者對本角色的互動方式給出正向或負向回饋時,即使沒有直接說「記住」,也應補寫或由 Stop hook 保存為本角色專屬的高優先度互動偏好記憶;角色切換後,由新角色在自己的互動中重新學習與保存。保存過程屬於內部處理,除非使用者明確詢問,否則不要主動回報記憶寫入或整理細節。 - 使用者對本角色的互動方式給出正向或負向回饋時,即使沒有直接說「記住」,也應補寫或由 Stop hook 保存為本角色專屬的高優先度互動偏好記憶;角色切換後,由新角色在自己的互動中重新學習與保存。保存過程屬於內部處理,除非使用者明確詢問,否則不要主動回報記憶寫入或整理細節。