diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..aed29cb --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ]; then exec \"$root/scripts/role/role_load.sh\"; fi; script=$(find \"$HOME/.codex/plugins/cache/generic/jsc\" -path '*/scripts/role/role_load.sh' -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$script\" ]; then exec \"$script\"; fi; exit 0", + "timeout": 20 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ]; then exec \"$root/scripts/role/role_capture.sh\"; fi; script=$(find \"$HOME/.codex/plugins/cache/generic/jsc\" -path '*/scripts/role/role_capture.sh' -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$script\" ]; then exec \"$script\"; fi; exit 0", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/scripts/role/memory.py b/scripts/role/memory.py new file mode 100755 index 0000000..ea0c442 --- /dev/null +++ b/scripts/role/memory.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +# ============================================================================== +# 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入 +# inbox,(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理 +# 素材並套用整理結果(分類/去重/標籤/總結/壓縮歸檔),(4) 依使用頻率 +# 遺忘日常與其他類記憶。 +# 更新時間:2026/07/28 00:00:00 +# 相依:Python 3 標準庫。 +# 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。 +# ============================================================================== + +import argparse +import gzip +import hashlib +import json +import os +import re +import shutil +import sys +from datetime import datetime, timedelta, timezone + +# ------------------------------------------------------------------------------ +# 常數:分類、載入策略、遺忘規則 +# ------------------------------------------------------------------------------ + +# 六個分類的目錄名(英文,跨平台安全)與中文標籤 +CATEGORIES = ["important", "interest", "news", "skill", "daily", "other"] +CATEGORY_LABELS = { + "important": "重要", + "interest": "興趣", + "news": "新知", + "skill": "技能", + "daily": "日常", + "other": "其他", +} +# 中文分類名反查(模型可能直接輸出中文) +LABEL_TO_CATEGORY = {label: key for key, label in CATEGORY_LABELS.items()} + +# 載入策略:重要與興趣載入全文,其餘僅載入總結與標籤 +FULL_CATEGORIES = ["important", "interest"] +# 摘要載入順序:技能 → 新知 → 日常 → 其他 +DIGEST_CATEGORIES = ["skill", "news", "daily", "other"] + +# 遺忘規則:(未更新天數門檻, 命中次數上限);只套用於日常與其他 +FORGET_RULES = {"daily": (14, 1), "other": (7, 1)} + +# 單次睡眠整理最多處理的 inbox 筆數,其餘留待下個睡眠週期 +SLEEP_BATCH = 60 +# 送進模型的素材字元上限 +COLLECT_LIMIT = 40000 +# 單則記憶壓縮後的內容字元上限 +CONTENT_LIMIT = 1200 + +TAIPEI = timezone(timedelta(hours=8)) +STAMP_FORMAT = "%Y/%m/%d %H:%M:%S" + + +# ------------------------------------------------------------------------------ +# 路徑與時間 +# ------------------------------------------------------------------------------ + + +def now_stamp(): + """回傳台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串。""" + return datetime.now(TAIPEI).strftime(STAMP_FORMAT) + + +def parse_stamp(value): + """把 yyyy/MM/dd HH:mm:ss 字串解析成帶時區的 datetime,失敗回 None。""" + if not isinstance(value, str) or not value.strip(): + return None + try: + return datetime.strptime(value.strip(), STAMP_FORMAT).replace(tzinfo=TAIPEI) + except ValueError: + return None + + +def memory_root(role): + """回傳指定角色的記憶根目錄(可用 ROLE_MEMORY_HOME 覆寫預設 ~/.memory)。""" + base = os.environ.get("ROLE_MEMORY_HOME") or os.path.join(os.path.expanduser("~"), ".memory") + return os.path.join(base, role) + + +def ensure_layout(role): + """建立角色記憶目錄結構(inbox、六個分類、archive),回傳根目錄。""" + root = memory_root(role) + for sub in ["inbox", "archive/raw", "archive/forgotten"] + CATEGORIES: + os.makedirs(os.path.join(root, sub), exist_ok=True) + return root + + +def state_path(role): + """回傳角色記憶狀態檔(state.json)的路徑。""" + return os.path.join(memory_root(role), "state.json") + + +def read_state(role): + """讀取狀態檔;不存在或損壞時回空 dict。""" + try: + with open(state_path(role), encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def write_state(role, patch): + """把 patch 併入狀態檔後寫回(整份覆寫,內容極小)。""" + state = read_state(role) + state.update(patch) + ensure_layout(role) + with open(state_path(role), "w", encoding="utf-8") as fh: + json.dump(state, fh, ensure_ascii=False, indent=2) + return state + + +# ------------------------------------------------------------------------------ +# 記憶檔格式:YAML 風格 frontmatter + 內文 +# ------------------------------------------------------------------------------ + + +def normalize_category(value): + """把模型輸出的分類(英文或中文)正規化為分類鍵;無法判定時回 other。""" + raw = (value or "").strip().lower() + if raw in CATEGORIES: + return raw + return LABEL_TO_CATEGORY.get((value or "").strip(), "other") + + +def normalize_tags(value): + """把標籤(list 或逗號分隔字串)正規化為去重後的小寫標籤 list,最多 6 個。""" + if isinstance(value, str): + parts = re.split(r"[,、|]", value) + elif isinstance(value, list): + parts = [str(item) for item in value] + else: + parts = [] + tags = [] + for part in parts: + tag = part.strip().strip("[]#").strip() + if tag and tag.lower() not in [t.lower() for t in tags]: + tags.append(tag) + return tags[:6] + + +def one_line(value, limit=120): + """把文字壓成單行並截斷,用於 summary 欄位。""" + text = re.sub(r"\s+", " ", str(value or "")).strip() + return text[:limit] + + +def dump_memory(meta, content): + """把 meta 與內文組成記憶檔全文(frontmatter + 內文)。""" + lines = ["---"] + for key in ["id", "category", "summary", "tags", "created", "updated", "hits", "sources"]: + if key not in meta: + continue + value = meta[key] + if isinstance(value, list): + value = "[" + ", ".join(str(item) for item in value) + "]" + lines.append(f"{key}: {value}") + lines.append("---") + lines.append("") + lines.append(content.strip()) + lines.append("") + return "\n".join(lines) + + +def load_memory(path): + """讀取單一記憶檔,回傳 (meta dict, 內文);讀取失敗回 (None, "")。""" + try: + with open(path, encoding="utf-8") as fh: + raw = fh.read() + except OSError: + return None, "" + + meta = {"path": path, "hits": 0, "tags": []} + body = raw + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + body = parts[2] + for line in parts[1].splitlines(): + if ":" not in line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key in ("tags", "sources"): + meta[key] = normalize_tags(value.strip("[]")) + elif key == "hits": + meta[key] = int(value) if value.isdigit() else 0 + else: + meta[key] = value + meta.setdefault("id", os.path.splitext(os.path.basename(path))[0]) + meta.setdefault("summary", "") + meta.setdefault("created", "") + meta.setdefault("updated", meta.get("created", "")) + return meta, body.strip() + + +def new_id(seed): + """以時間與內容雜湊產生記憶 id,確保同一秒多筆也不碰撞。""" + digest = hashlib.sha1(seed.encode("utf-8", "replace")).hexdigest()[:6] + return f"{datetime.now(TAIPEI).strftime('%Y%m%d-%H%M%S')}-{digest}" + + +def list_memories(role, category): + """列出某分類下的所有記憶(依 updated 新到舊排序)。""" + directory = os.path.join(memory_root(role), category) + items = [] + if not os.path.isdir(directory): + return items + for name in sorted(os.listdir(directory)): + if not name.endswith(".md"): + continue + meta, content = load_memory(os.path.join(directory, name)) + if meta is None: + continue + meta["category"] = category + items.append((meta, content)) + items.sort(key=lambda item: item[0].get("updated") or "", reverse=True) + return items + + +def list_inbox(role): + """列出 inbox 內尚未整理的記憶(依檔名,即時間先後排序)。""" + directory = os.path.join(memory_root(role), "inbox") + items = [] + if not os.path.isdir(directory): + return items + for name in sorted(os.listdir(directory)): + if not name.endswith(".md"): + continue + meta, content = load_memory(os.path.join(directory, name)) + if meta is not None: + items.append((meta, content)) + return items + + +def find_memory(role, memory_id): + """依 id 在六個分類中尋找記憶檔,回傳 (meta, 內文);找不到回 (None, "")。""" + for category in CATEGORIES: + path = os.path.join(memory_root(role), category, f"{memory_id}.md") + if os.path.isfile(path): + meta, content = load_memory(path) + if meta is not None: + meta["category"] = category + return meta, content + return None, "" + + +def archive_file(path, destination_dir): + """把檔案 gzip 後搬到歸檔目錄,原檔刪除;失敗時保留原檔。""" + os.makedirs(destination_dir, exist_ok=True) + target = os.path.join(destination_dir, os.path.basename(path) + ".gz") + try: + with open(path, "rb") as src, gzip.open(target, "wb") as dst: + shutil.copyfileobj(src, dst) + os.remove(path) + return True + except OSError: + return False + + +# ------------------------------------------------------------------------------ +# 子命令:write —— 由 Stop hook 寫入一則未整理記憶 +# ------------------------------------------------------------------------------ + +FIELD_PATTERN = re.compile(r"^\s*(CATEGORY|SUMMARY|TAGS|CONTENT)\s*[::]\s*(.*)$", re.IGNORECASE) + + +def parse_capture(text): + """ + 解析 Stop hook 濃縮器輸出的四欄格式(CATEGORY/SUMMARY/TAGS/CONTENT)。 + + 模型可能夾帶前後贅字,故逐行掃描欄位標記,CONTENT 之後的所有內容視為內文。 + """ + category = summary = "" + tags = [] + content_lines = [] + in_content = False + for line in text.splitlines(): + match = FIELD_PATTERN.match(line) + if match and not (in_content and match.group(1).upper() != "CONTENT"): + field = match.group(1).upper() + value = match.group(2) + if field == "CATEGORY": + category = value + elif field == "SUMMARY": + summary = value + elif field == "TAGS": + tags = normalize_tags(value) + elif field == "CONTENT": + in_content = True + if value.strip(): + content_lines.append(value) + continue + if in_content: + content_lines.append(line) + return category, summary, tags, "\n".join(content_lines).strip() + + +def cmd_write(args): + """把 stdin 的濃縮結果寫成一則 inbox 記憶。""" + raw = sys.stdin.read() + category, summary, tags, content = parse_capture(raw) + if not content and not summary: + return 1 + if not content: + content = summary + content = content[:CONTENT_LIMIT] + stamp = now_stamp() + meta = { + "id": new_id(content + stamp), + "category": normalize_category(category), + "summary": one_line(summary) or one_line(content), + "tags": tags, + "created": stamp, + "updated": stamp, + "hits": 1, + } + if args.project: + meta["sources"] = [args.project] + ensure_layout(args.role) + path = os.path.join(memory_root(args.role), "inbox", f"{meta['id']}.md") + with open(path, "w", encoding="utf-8") as fh: + fh.write(dump_memory(meta, content)) + sys.stdout.write(meta["id"]) + return 0 + + +# ------------------------------------------------------------------------------ +# 子命令:load —— 產生 SessionStart 要注入的記憶區塊 +# ------------------------------------------------------------------------------ + + +def cmd_load(args): + """ + 組出載入用記憶區塊:重要與興趣載入全文,其餘依技能→新知→日常→其他只載總結與標籤。 + + 超過字元上限時截斷並標明,避免佔滿 context。 + """ + limit = args.limit + blocks = [] + total_full = 0 + for category in FULL_CATEGORIES: + items = list_memories(args.role, category) + if not items: + continue + lines = [f"### {CATEGORY_LABELS[category]}記憶(全文)"] + for meta, content in items: + tags = "、".join(meta.get("tags") or []) or "無標籤" + lines.append(f"- **{meta.get('summary') or '(無總結)'}**(標籤:{tags})") + for line in content.splitlines(): + if line.strip(): + lines.append(f" {line.strip()}") + total_full += 1 + blocks.append("\n".join(lines)) + + digest_lines = [] + digest_count = 0 + for category in DIGEST_CATEGORIES: + items = list_memories(args.role, category) + if not items: + continue + digest_lines.append(f"### {CATEGORY_LABELS[category]}記憶(總結)") + for meta, _ in items: + tags = "、".join(meta.get("tags") or []) or "無標籤" + digest_lines.append(f"- {meta.get('summary') or '(無總結)'}(標籤:{tags})") + digest_count += 1 + if digest_lines: + blocks.append("\n".join(digest_lines)) + + pending = len(list_inbox(args.role)) + if pending: + blocks.append(f"> 尚有 {pending} 則未整理記憶,將於下次睡眠時段歸檔。") + + if not blocks: + return 1 + + text = "\n\n".join(blocks) + if len(text) > limit: + text = text[:limit] + f"\n\n> (記憶內容超過 {limit} 字元已截斷,完整記憶仍保存在磁碟)" + sys.stdout.write(text) + return 0 + + +# ------------------------------------------------------------------------------ +# 子命令:collect —— 睡眠整理前輸出待整理素材 +# ------------------------------------------------------------------------------ + + +def cmd_collect(args): + """輸出送進模型的整理素材:inbox 待整理項目 + 既有記憶索引(供去重比對)。""" + inbox = list_inbox(args.role)[:SLEEP_BATCH] + if not inbox: + return 1 + + lines = ["=== INBOX(待整理,每則以 id 標識)==="] + for meta, content in inbox: + lines.append(f"--- id: {meta['id']} | 時間: {meta.get('created', '-')} ---") + lines.append(f"初判分類: {CATEGORY_LABELS.get(meta.get('category', 'other'), '其他')}") + lines.append(f"初判總結: {meta.get('summary', '')}") + lines.append(f"初判標籤: {'、'.join(meta.get('tags') or []) or '無'}") + lines.append("內容:") + lines.append(content) + lines.append("") + + lines.append("=== EXISTING(既有記憶索引,供去重與合併判斷)===") + existing = 0 + for category in CATEGORIES: + for meta, _ in list_memories(args.role, category): + tags = "、".join(meta.get("tags") or []) or "無" + lines.append( + f"- id: {meta['id']} | 分類: {CATEGORY_LABELS[category]} | 標籤: {tags} | 總結: {meta.get('summary', '')}" + ) + existing += 1 + if not existing: + lines.append("(尚無既有記憶)") + + text = "\n".join(lines) + if len(text) > COLLECT_LIMIT: + text = text[:COLLECT_LIMIT] + "\n…(素材過長已截斷,其餘留待下個睡眠週期)…" + sys.stdout.write(text) + return 0 + + +# ------------------------------------------------------------------------------ +# 子命令:apply —— 套用睡眠整理結果 +# ------------------------------------------------------------------------------ + + +def extract_json(text): + """從模型輸出中取出第一個 JSON 物件(容忍 code fence 與前後贅字)。""" + stripped = text.strip() + fence = re.search(r"```(?:json)?\s*(.*?)```", stripped, re.DOTALL) + if fence: + stripped = fence.group(1).strip() + start = stripped.find("{") + end = stripped.rfind("}") + if start < 0 or end <= start: + return None + try: + return json.loads(stripped[start : end + 1]) + except ValueError: + return None + + +def cmd_apply(args): + """ + 讀取 stdin 的整理結果 JSON,寫入分類記憶並歸檔對應的 inbox 原始檔。 + + action 支援 new(新建)/merge(併入既有記憶)/drop(判定無保存價值)。 + 未被提及的 inbox 檔一律保留,留待下個睡眠週期,避免整理失敗造成記憶遺失。 + """ + data = extract_json(sys.stdin.read()) + if not isinstance(data, dict): + sys.stderr.write("整理結果非合法 JSON\n") + return 1 + entries = data.get("memories") + if not isinstance(entries, list) or not entries: + sys.stderr.write("整理結果不含 memories\n") + return 1 + + ensure_layout(args.role) + root = memory_root(args.role) + stamp = now_stamp() + counts = {"new": 0, "merge": 0, "drop": 0} + consumed = [] + + for entry in entries: + if not isinstance(entry, dict): + continue + action = str(entry.get("action") or "new").strip().lower() + sources = [str(item).strip() for item in (entry.get("from") or []) if str(item).strip()] + + if action == "drop": + consumed.extend(sources) + counts["drop"] += 1 + continue + + content = str(entry.get("content") or "").strip()[:CONTENT_LIMIT] + summary = one_line(entry.get("summary")) + tags = normalize_tags(entry.get("tags")) + if not content and not summary: + continue + + if action == "merge": + target_id = str(entry.get("target") or "").strip() + meta, old_content = find_memory(args.role, target_id) + if meta is None: + action = "new" + else: + category = normalize_category(entry.get("category") or meta.get("category")) + merged_tags = normalize_tags((meta.get("tags") or []) + tags) + new_meta = { + "id": meta["id"], + "category": category, + "summary": summary or meta.get("summary", ""), + "tags": merged_tags, + "created": meta.get("created") or stamp, + "updated": stamp, + "hits": int(meta.get("hits") or 0) + 1, + } + old_path = meta["path"] + new_path = os.path.join(root, category, f"{meta['id']}.md") + with open(new_path, "w", encoding="utf-8") as fh: + fh.write(dump_memory(new_meta, content or old_content)) + if os.path.abspath(old_path) != os.path.abspath(new_path): + try: + os.remove(old_path) + except OSError: + pass + consumed.extend(sources) + counts["merge"] += 1 + continue + + category = normalize_category(entry.get("category")) + meta = { + "id": new_id(content + summary + stamp), + "category": category, + "summary": summary or one_line(content), + "tags": tags, + "created": stamp, + "updated": stamp, + "hits": 1, + } + with open(os.path.join(root, category, f"{meta['id']}.md"), "w", encoding="utf-8") as fh: + fh.write(dump_memory(meta, content or summary)) + consumed.extend(sources) + counts["new"] += 1 + + archived = 0 + month_dir = os.path.join(root, "archive", "raw", datetime.now(TAIPEI).strftime("%Y-%m")) + for source_id in set(consumed): + path = os.path.join(root, "inbox", f"{source_id}.md") + if os.path.isfile(path) and archive_file(path, month_dir): + archived += 1 + + write_state(args.role, {"last_sleep": stamp, "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())}) + sys.stdout.write( + f"新增 {counts['new']} 則、合併 {counts['merge']} 則、捨棄 {counts['drop']} 則、歸檔原始記憶 {archived} 則" + ) + return 0 + + +# ------------------------------------------------------------------------------ +# 子命令:forget —— 依使用頻率遺忘日常與其他 +# ------------------------------------------------------------------------------ + + +def cmd_forget(args): + """把日常/其他分類中久未更新且命中次數低的記憶壓縮到 archive/forgotten 後移除。""" + root = ensure_layout(args.role) + now = datetime.now(TAIPEI) + forgotten = [] + for category, (days, max_hits) in FORGET_RULES.items(): + for meta, _ in list_memories(args.role, category): + updated = parse_stamp(meta.get("updated")) or parse_stamp(meta.get("created")) + if updated is None: + continue + if (now - updated).days < days: + continue + if int(meta.get("hits") or 0) > max_hits: + continue + if args.dry_run: + forgotten.append(f"{CATEGORY_LABELS[category]}|{meta.get('summary', '')}") + continue + if archive_file(meta["path"], os.path.join(root, "archive", "forgotten")): + forgotten.append(f"{CATEGORY_LABELS[category]}|{meta.get('summary', '')}") + + if not forgotten: + sys.stdout.write("沒有符合遺忘條件的記憶") + return 0 + prefix = "(預覽)" if args.dry_run else "" + sys.stdout.write(f"{prefix}遺忘 {len(forgotten)} 則:\n" + "\n".join(f"- {item}" for item in forgotten)) + if not args.dry_run: + write_state(args.role, {"last_forget": now_stamp()}) + return 0 + + +# ------------------------------------------------------------------------------ +# 子命令:stats —— 供 skill 與診斷顯示記憶概況 +# ------------------------------------------------------------------------------ + + +def cmd_stats(args): + """輸出記憶統計(各分類筆數、待整理筆數、上次整理時間)。""" + state = read_state(args.role) + rows = [f"| 分類 | 筆數 |", "| --- | --- |"] + for category in CATEGORIES: + rows.append(f"| {CATEGORY_LABELS[category]} | {len(list_memories(args.role, category))} |") + rows.append(f"| 待整理(inbox) | {len(list_inbox(args.role))} |") + rows.append("") + rows.append(f"- 記憶目錄:{memory_root(args.role)}") + rows.append(f"- 上次睡眠整理:{state.get('last_sleep', '尚未整理')}") + rows.append(f"- 上次遺忘:{state.get('last_forget', '尚未執行')}") + sys.stdout.write("\n".join(rows)) + return 0 + + +# ------------------------------------------------------------------------------ +# 子命令:need-sleep —— 判斷是否需要補跑睡眠整理 +# ------------------------------------------------------------------------------ + + +def cmd_mark_sleep(args): + """把本次睡眠週期標記為已整理(inbox 為空、無素材可整理時使用)。""" + write_state(args.role, {"last_sleep": now_stamp(), "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())}) + sys.stdout.write("已更新上次整理時間") + return 0 + + +def cmd_need_sleep(args): + """ + 判斷是否需要補跑整理:距上次整理超過門檻小時數且 inbox 有內容。 + + 輸出 yes/no,供 shell 直接判斷(不用解析 JSON)。 + """ + if not list_inbox(args.role): + sys.stdout.write("no") + return 0 + state = read_state(args.role) + last = parse_stamp(state.get("last_sleep")) + if last is None: + sys.stdout.write("yes") + return 0 + hours = (datetime.now(TAIPEI) - last).total_seconds() / 3600 + sys.stdout.write("yes" if hours >= args.hours else "no") + return 0 + + +# ------------------------------------------------------------------------------ +# CLI +# ------------------------------------------------------------------------------ + + +def build_parser(): + """建立子命令解析器。""" + parser = argparse.ArgumentParser(description="角色記憶儲存引擎") + sub = parser.add_subparsers(dest="command", required=True) + + write = sub.add_parser("write", help="自 stdin 讀濃縮結果寫入 inbox") + write.add_argument("--role", required=True) + write.add_argument("--project", default="") + write.set_defaults(func=cmd_write) + + load = sub.add_parser("load", help="輸出 SessionStart 要注入的記憶區塊") + load.add_argument("--role", required=True) + load.add_argument("--limit", type=int, default=int(os.environ.get("ROLE_LOAD_LIMIT", "8000"))) + load.set_defaults(func=cmd_load) + + collect = sub.add_parser("collect", help="輸出睡眠整理素材") + collect.add_argument("--role", required=True) + collect.set_defaults(func=cmd_collect) + + apply_cmd = sub.add_parser("apply", help="自 stdin 讀整理結果 JSON 並套用") + apply_cmd.add_argument("--role", required=True) + apply_cmd.set_defaults(func=cmd_apply) + + forget = sub.add_parser("forget", help="依使用頻率遺忘日常與其他記憶") + forget.add_argument("--role", required=True) + forget.add_argument("--dry-run", action="store_true") + forget.set_defaults(func=cmd_forget) + + stats = sub.add_parser("stats", help="輸出記憶統計") + stats.add_argument("--role", required=True) + stats.set_defaults(func=cmd_stats) + + mark = sub.add_parser("mark-sleep", help="標記本次睡眠週期已整理") + mark.add_argument("--role", required=True) + mark.set_defaults(func=cmd_mark_sleep) + + need = sub.add_parser("need-sleep", help="判斷是否需要補跑睡眠整理") + need.add_argument("--role", required=True) + need.add_argument("--hours", type=float, default=20.0) + need.set_defaults(func=cmd_need_sleep) + + return parser + + +def main(argv): + """CLI 進入點。""" + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/role/role_capture.sh b/scripts/role/role_capture.sh new file mode 100755 index 0000000..f554b63 --- /dev/null +++ b/scripts/role/role_capture.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# ============================================================================== +# 用途:Stop hook 主程式。每輪對話結束後抽出本輪內容 → 呼叫 headless CLI 濃縮成 +# 一則記憶(分類/總結/標籤/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/, +# 等待睡眠時段整理。睡眠時段雖不載入角色,對話仍照常記錄。 +# 更新時間:2026/07/28 00:00:00 +# 相依:bash、python3、任一 headless CLI、同目錄的 role_lib.sh/memory.py/transcript.py。 +# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.py redact 遮蔽一次。 +# 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。 +# ============================================================================== + +ROLE_STAGE="role-capture" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./role_lib.sh +. "${SCRIPT_DIR}/role_lib.sh" + +role_is_child && exit 0 +role_enabled || exit 0 +command -v python3 >/dev/null 2>&1 || role_quit "找不到 python3,略過記憶記錄" "WRN" + +ROLE="$(role_resolve_name)" +[ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄" +[ -f "$(role_file "$ROLE")" ] || role_quit "找不到角色定義檔,略過記憶記錄" "WRN" + +CLI="$(role_select_cli)" || exit 0 +[ -n "$CLI" ] || exit 0 + +# ------------------------------------------------------------------------------ +# 讀取 hook 傳入的 JSON(session_id/transcript_path/cwd/stop_hook_active) +# ------------------------------------------------------------------------------ +HOOK_INPUT="$(cat)" +[ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN" + +read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD </dev/null)" + [ -n "$TRANSCRIPT_PATH" ] || TRANSCRIPT_PATH="-" +fi +[ -f "$TRANSCRIPT_PATH" ] || role_quit "找不到 transcript:${TRANSCRIPT_PATH}" "WRN" + +TURN="$(python3 "${SCRIPT_DIR}/transcript.py" extract "$TRANSCRIPT_PATH" 2>/dev/null)" +[ -n "$TURN" ] || role_quit "本輪無可記錄內容" + +PROJECT="$(role_project_name "$HOOK_CWD")" + +# ------------------------------------------------------------------------------ +# 濃縮:產出一則記憶(四欄固定格式),交由 memory.py 落檔 +# ------------------------------------------------------------------------------ +PROMPT="$(cat < +SUMMARY: <一句話總結,40 字內> +TAGS: <2 至 4 個標籤,以逗號分隔> +CONTENT: <3 至 6 行要點,每行以「- 」開頭> +2. 分類判準: + - important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。 + - interest(興趣):使用者反覆關注、主動深入的主題與喜好。 + - news(新知):這輪學到的新事實、新工具、新版本、外部資訊。 + - skill(技能):可重複套用的做法、指令、流程、除錯手法。 + - daily(日常):一次性的例行工作與雜項處理。 + - other(其他):不屬於上述任何一類。 +3. 記憶主體是「使用者與這段互動」,不是流水帳:寫值得下次記起來的事,不要抄程式碼、不要貼指令全文。 +4. 使用繁體中文(台灣用語),保留關鍵事實:檔案/專案/指令/數量/分支/議題編號。 +5. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。 +6. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論),只輸出一行:SKIP + +對話片段: +${TURN} +EOF_PROMPT +)" + +RESULT="$(role_run_cli "$CLI" "$PROMPT" 45)" +if [ -z "$RESULT" ]; then + role_log "WRN" "記憶濃縮產出為空(CLI ${CLI}),略過本輪" + exit 0 +fi +printf '%s' "$RESULT" | grep -qiE '^\s*SKIP\s*$' && role_quit "判定本輪無值得記住的內容" + +# 第二道防線:對模型輸出再遮蔽一次機密與個資 +RESULT="$(printf '%s' "$RESULT" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/dev/null)" + +MEMORY_ID="$(printf '%s' "$RESULT" | python3 "${SCRIPT_DIR}/memory.py" write --role "$ROLE" --project "$PROJECT" 2>/dev/null)" +if [ -n "$MEMORY_ID" ]; then + role_log "INF" "已記錄記憶 ${MEMORY_ID}(角色 ${ROLE},專案 ${PROJECT},CLI ${CLI})" +else + role_log "WRN" "記憶寫入失敗或內容不足(角色 ${ROLE})" +fi +exit 0 diff --git a/scripts/role/role_lib.sh b/scripts/role/role_lib.sh new file mode 100755 index 0000000..699542f --- /dev/null +++ b/scripts/role/role_lib.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# ============================================================================== +# 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、 +# 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。 +# 本檔僅供 source,不可直接執行。 +# 更新時間:2026/07/28 00:00:00 +# 相依:bash、python3;摘要路徑需 README 定義的任一 headless CLI。 +# 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。 +# ============================================================================== + +ROLE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROLE_STAGE="${ROLE_STAGE:-role}" +ROLE_SUPPORTED_CLIS="claude codex agy opencode copilot" +ROLE_FALLBACK_MODEL="claude-haiku-4-5-20251001" + +# ------------------------------------------------------------------------------ +# 共用輸出 +# ------------------------------------------------------------------------------ + +role_now() { + # 取得台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串 + TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S' +} + +role_log() { + # 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr + local level="$1" message="$2" stamp + stamp="$(role_now)" + printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >&2 + if [ -n "${ROLE_ERRLOG:-}" ] && [ "$level" = "ERR" ]; then + printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >> "${ROLE_ERRLOG}" 2>/dev/null + fi +} + +role_quit() { + # 記錄原因後以 0 結束:hook 絕不可阻斷使用者流程 + role_log "${2:-DBG}" "$1" + exit 0 +} + +# ------------------------------------------------------------------------------ +# 路徑與啟用判斷 +# ------------------------------------------------------------------------------ + +role_home() { + # 角色定義目錄(預設 ~/.roles) + printf '%s' "${ROLE_HOME:-${HOME}/.roles}" +} + +role_memory_home() { + # 記憶根目錄(預設 ~/.memory),實際記憶放在 /<角色>/ + printf '%s' "${ROLE_MEMORY_HOME:-${HOME}/.memory}" +} + +role_is_child() { + # 判斷本次執行是否來自摘要用的子 CLI 行程,避免 hook 遞迴 + [ -n "${ROLE_CHILD:-}" ] || [ -n "${WORKLOG_CHILD:-}" ] +} + +role_resolve_name() { + # 角色決定順序:ROLE_NAME 環境變數 → <角色目錄>/.active;皆無則輸出空字串 + local name="" active + if [ -n "${ROLE_NAME:-}" ]; then + name="${ROLE_NAME}" + else + active="$(role_home)/.active" + [ -f "$active" ] && name="$(head -n 1 "$active" 2>/dev/null | tr -d '[:space:]')" + fi + printf '%s' "$name" +} + +role_file() { + # 指定角色的定義檔路徑 + printf '%s/%s.md' "$(role_home)" "$1" +} + +role_enabled() { + # 總開關:ROLE_ENABLED=0 強制停用;=1 強制啟用;未設定時「有可解析且存在的角色」才啟用 + case "${ROLE_ENABLED:-}" in + 0|false|no) return 1 ;; + 1|true|yes) return 0 ;; + esac + local name + name="$(role_resolve_name)" + [ -n "$name" ] && [ -f "$(role_file "$name")" ] +} + +role_in_scope() { + # ROLE_SCOPE 為冒號分隔的路徑前綴,未設定則所有目錄都適用 + local cwd="$1" scope + [ -n "${ROLE_SCOPE:-}" ] || return 0 + IFS=':' read -r -a scopes <<< "${ROLE_SCOPE}" + for scope in "${scopes[@]}"; do + [ -n "$scope" ] || continue + case "$cwd" in "${scope%/}"*) return 0 ;; esac + done + return 1 +} + +# ------------------------------------------------------------------------------ +# 睡眠時段 +# ------------------------------------------------------------------------------ + +role_time_to_minutes() { + # 把 HH:MM 轉成當日分鐘數;格式不合法時回傳空字串 + local value="$1" hour minute + case "$value" in + [0-9][0-9]:[0-9][0-9]) ;; + *) return 1 ;; + esac + hour="${value%%:*}" + minute="${value##*:}" + printf '%s' "$((10#${hour} * 60 + 10#${minute}))" +} + +role_sleep_start() { printf '%s' "${ROLE_SLEEP_START:-22:00}"; } +role_sleep_end() { printf '%s' "${ROLE_SLEEP_END:-06:00}"; } + +role_in_sleep_window() { + # 判斷現在是否落在睡眠時段(預設 22:00 至隔日 06:00,跨午夜) + local start end now + start="$(role_time_to_minutes "$(role_sleep_start)")" || return 1 + end="$(role_time_to_minutes "$(role_sleep_end)")" || return 1 + now="$(role_time_to_minutes "$(TZ='Asia/Taipei' date +'%H:%M')")" || return 1 + if [ "$start" -lt "$end" ]; then + [ "$now" -ge "$start" ] && [ "$now" -lt "$end" ] + else + [ "$now" -ge "$start" ] || [ "$now" -lt "$end" ] + fi +} + +# ------------------------------------------------------------------------------ +# AI 行程偵測(睡眠排程的前置檢查) +# ------------------------------------------------------------------------------ + +role_ai_running() { + # 偵測是否有 AI CLI 正在執行;偵測到任何一個即回傳成功(代表「還不能睡」) + local cli pid cmd self="$$" + for cli in $ROLE_SUPPORTED_CLIS; do + for pid in $(pgrep -x "$cli" 2>/dev/null); do + [ "$pid" = "$self" ] && continue + return 0 + done + done + for pid in $(pgrep -f '(^|/)(claude|codex|agy|opencode|copilot)([[:space:]]|$)' 2>/dev/null); do + if [ "$pid" = "$self" ] || [ "$pid" = "$PPID" ]; then + continue + fi + cmd="$(ps -o args= -p "$pid" 2>/dev/null)" + case "$cmd" in + *role_sleep.sh*|*role_capture.sh*|*role_load.sh*|*pgrep*) continue ;; + esac + return 0 + done + return 1 +} + +# ------------------------------------------------------------------------------ +# 摘要 CLI 選擇與呼叫 +# ------------------------------------------------------------------------------ + +role_detect_current_cli() { + # 判斷實際觸發本次執行的助理環境,避免 auto 因 PATH 順序誤選其他 CLI + if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ -n "${CODEX_MANAGED_PACKAGE_ROOT:-}" ]; then + printf 'codex'; return 0 + fi + if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDE_CODE_SSE_PORT:-}" ]; then + printf 'claude'; return 0 + fi + if [ -n "${AGY_SESSION_ID:-}" ] || [ -n "${AGY_WORKSPACE_ID:-}" ]; then + printf 'agy'; return 0 + fi + if [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_CONFIG:-}" ]; then + printf 'opencode'; return 0 + fi + if [ -n "${COPILOT_AGENT_ID:-}" ] || [ -n "${GITHUB_COPILOT_TOKEN:-}" ]; then + printf 'copilot'; return 0 + fi + return 0 +} + +role_select_cli() { + # 選擇摘要/整理用的 headless CLI;可用 ROLE_CLI 強制指定,預設 auto + local requested="${ROLE_CLI:-auto}" cli current + if [ "$requested" != "auto" ]; then + case " ${ROLE_SUPPORTED_CLIS} " in + *" ${requested} "*) ;; + *) role_log "WRN" "ROLE_CLI 不支援:${requested}(可用:auto ${ROLE_SUPPORTED_CLIS})"; return 1 ;; + esac + command -v "$requested" >/dev/null 2>&1 || { role_log "WRN" "找不到 ${requested} CLI"; return 1; } + printf '%s' "$requested"; return 0 + fi + current="$(role_detect_current_cli)" + if [ -n "$current" ] && command -v "$current" >/dev/null 2>&1; then + printf '%s' "$current"; return 0 + fi + for cli in $ROLE_SUPPORTED_CLIS; do + if command -v "$cli" >/dev/null 2>&1; then + printf '%s' "$cli"; return 0 + fi + done + role_log "WRN" "找不到可用 CLI(需要其一:${ROLE_SUPPORTED_CLIS})" + return 1 +} + +role_run_cli() { + # 呼叫選定 CLI 執行提示詞;子行程一律帶 ROLE_CHILD=1 阻斷 hook 遞迴 + local cli="$1" prompt="$2" seconds="${3:-45}" model="${ROLE_MODEL:-}" + [ "$cli" = "claude" ] && [ -z "$model" ] && model="$ROLE_FALLBACK_MODEL" + case "$cli" in + claude) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" claude -p "$prompt" --model "$model" 2>/dev/null ;; + codex) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" codex exec "$prompt" 2>/dev/null ;; + agy) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" agy -p "$prompt" 2>/dev/null ;; + opencode) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" opencode run "$prompt" 2>/dev/null ;; + copilot) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" copilot -p "$prompt" 2>/dev/null ;; + esac +} + +# ------------------------------------------------------------------------------ +# 記憶目錄鎖:避免睡眠整理與對話寫入同時改動同一份記憶 +# ------------------------------------------------------------------------------ + +role_lock_acquire() { + # 以 mkdir 取得鎖(原子操作);逾時視為前次殘留鎖並強制接手 + local role="$1" lock="$(role_memory_home)/$1/.lock" age + mkdir -p "$(dirname "$lock")" 2>/dev/null + if mkdir "$lock" 2>/dev/null; then + printf '%s' "$$" > "$lock/pid" 2>/dev/null + return 0 + fi + age="$(find "$lock" -maxdepth 0 -mmin +30 2>/dev/null)" + if [ -n "$age" ]; then + role_log "WRN" "偵測到超過 30 分鐘的殘留鎖,強制接手:${lock}" + rm -rf "$lock" 2>/dev/null + mkdir "$lock" 2>/dev/null && { printf '%s' "$$" > "$lock/pid" 2>/dev/null; return 0; } + fi + return 1 +} + +role_lock_release() { + # 釋放記憶目錄鎖 + rm -rf "$(role_memory_home)/$1/.lock" 2>/dev/null +} + +role_project_name() { + # 專案判定:git remote 的 / 優先,其次目錄名 + local cwd="$1" origin cleaned owner_repo + [ -d "$cwd" ] || { printf '-'; return 0; } + local project + project="$(basename "$cwd")" + if git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + origin="$(git -C "$cwd" remote get-url origin 2>/dev/null)" + if [ -n "$origin" ]; then + cleaned="${origin%.git}" + cleaned="${cleaned##*://}" + cleaned="${cleaned#*@}" + owner_repo="$(printf '%s' "$cleaned" | awk -F/ 'NF>=2 {print $(NF-1)"/"$NF}')" + [ -n "$owner_repo" ] && project="$owner_repo" + fi + fi + printf '%s' "$project" +} diff --git a/scripts/role/role_load.sh b/scripts/role/role_load.sh new file mode 100755 index 0000000..d0b1689 --- /dev/null +++ b/scripts/role/role_load.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# ============================================================================== +# 用途:SessionStart hook 主程式。CLI 工具啟動時載入角色設定與記憶: +# 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤; +# 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。 +# 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。 +# 更新時間:2026/07/28 00:00:00 +# 相依:bash、python3、同目錄的 role_lib.sh 與 memory.py。 +# 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。 +# ============================================================================== + +ROLE_STAGE="role-load" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./role_lib.sh +. "${SCRIPT_DIR}/role_lib.sh" + +role_is_child && exit 0 +role_enabled || exit 0 +command -v python3 >/dev/null 2>&1 || role_quit "找不到 python3,略過角色載入" "WRN" + +ROLE="$(role_resolve_name)" +[ -n "$ROLE" ] || role_quit "未指定角色(ROLE_NAME 與 .active 皆無),略過角色載入" +ROLE_DEF="$(role_file "$ROLE")" +[ -f "$ROLE_DEF" ] || role_quit "找不到角色定義檔:${ROLE_DEF}" "WRN" + +# ------------------------------------------------------------------------------ +# 讀取 hook 輸入(cwd/source),並套用 ROLE_SCOPE 範圍限制 +# ------------------------------------------------------------------------------ +HOOK_INPUT="$(cat 2>/dev/null)" +HOOK_CWD="$PWD" +if [ -n "$HOOK_INPUT" ]; then + HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) +except ValueError: + data = {} +print(data.get("cwd") or "") +' 2>/dev/null)" + [ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD" +fi +role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}" + +emit_context() { + # 以 JSON 輸出 additionalContext(由 python 負責跳脫,避免內容含引號或換行破壞格式) + printf '%s' "$1" | python3 -c ' +import json, sys +context = sys.stdin.read() +print(json.dumps( + {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}}, + ensure_ascii=False, +)) +' +} + +SLEEP_START="$(role_sleep_start)" +SLEEP_END="$(role_sleep_end)" + +# ------------------------------------------------------------------------------ +# 睡眠時段:不載入角色,只說明目前狀態 +# ------------------------------------------------------------------------------ +if role_in_sleep_window; then + emit_context "$(cat </dev/null)" +[ -n "$DEFINITION" ] || role_quit "角色定義檔為空:${ROLE_DEF}" "WRN" + +MEMORY="$(python3 "${SCRIPT_DIR}/memory.py" load --role "$ROLE" 2>/dev/null)" + +# 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理 +CATCHUP_NOTE="" +if [ "$(python3 "${SCRIPT_DIR}/memory.py" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then + nohup "${SCRIPT_DIR}/role_sleep.sh" --catchup >/dev/null 2>&1 & + CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n' + role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE})" +fi + +CONTEXT="$(cat < 記憶載入規則:重要與興趣記憶載入全文;技能、新知、日常、其他僅載入總結與標籤, +> 需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。 + +> 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事, +> 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔): +> +> \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | python3 "${SCRIPT_DIR}/memory.py" write --role "${ROLE}"\` +> +> CATEGORY 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。 +EOF_CONTEXT +)" + +emit_context "$CONTEXT" +role_log "INF" "已載入角色 ${ROLE}(記憶 $(printf '%s' "$MEMORY" | wc -c) 位元組)" +exit 0 diff --git a/scripts/role/role_sleep.sh b/scripts/role/role_sleep.sh new file mode 100755 index 0000000..0fa2578 --- /dev/null +++ b/scripts/role/role_sleep.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# ============================================================================== +# 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run), +# 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶: +# 分類(重要/興趣/新知/技能/日常/其他)→ 去重合併 → 設標籤與一句話 +# 總結 → 壓縮內容歸檔 → 日常與其他依使用頻率遺忘。 +# 另提供 --catchup(cron 未執行時的補跑)、--force(手動立即整理)、 +# --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。 +# 更新時間:2026/07/28 00:00:00 +# 相依:bash、python3、任一 headless CLI、crontab(僅排程安裝需要)、 +# 同目錄的 role_lib.sh 與 memory.py。 +# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。 +# ============================================================================== + +ROLE_STAGE="role-sleep" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./role_lib.sh +. "${SCRIPT_DIR}/role_lib.sh" + +CRON_MARKER="# jsc-role-sleep" +SLEEP_TIMEOUT="${ROLE_SLEEP_TIMEOUT:-180}" + +usage() { + # 印出用法 + cat <<'EOF_USAGE' +用法:role_sleep.sh <模式> + + --run cron 觸發:在睡眠時段內且無 AI 運行時整理記憶 + --catchup 補跑:cron 未執行時,由 SessionStart hook 於背景呼叫 + --force 立即整理一次(忽略時段與 AI 運行檢查) + --install-cron 安裝/更新睡眠排程(每小時檢查一次) + --remove-cron 移除睡眠排程 + --status 顯示角色、睡眠時段、排程與記憶統計 +EOF_USAGE +} + +require_role() { + # 解析角色並確認定義檔存在,取不到時中止 + ROLE="$(role_resolve_name)" + [ -n "$ROLE" ] || { role_log "WRN" "未指定角色(ROLE_NAME 與 .active 皆無)"; exit 1; } + [ -f "$(role_file "$ROLE")" ] || { role_log "WRN" "找不到角色定義檔:$(role_file "$ROLE")"; exit 1; } +} + +# ------------------------------------------------------------------------------ +# 整理主流程 +# ------------------------------------------------------------------------------ + +sleep_cycle() { + # 執行一次完整記憶整理:收集素材 → 模型分類去重 → 落檔歸檔 → 遺忘 + local reason="$1" cli material prompt result applied forgotten + command -v python3 >/dev/null 2>&1 || { role_log "ERR" "找不到 python3,無法整理記憶"; return 1; } + + if ! role_lock_acquire "$ROLE"; then + role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE})" + return 0 + fi + trap 'role_lock_release "$ROLE"' EXIT + + material="$(python3 "${SCRIPT_DIR}/memory.py" collect --role "$ROLE" 2>/dev/null)" + if [ -z "$material" ]; then + role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason})" + python3 "${SCRIPT_DIR}/memory.py" mark-sleep --role "$ROLE" >/dev/null 2>&1 + forgotten="$(python3 "${SCRIPT_DIR}/memory.py" forget --role "$ROLE" 2>/dev/null)" + role_log "INF" "遺忘檢查:${forgotten}" + role_lock_release "$ROLE" + trap - EXIT + return 0 + fi + + cli="$(role_select_cli)" || { role_lock_release "$ROLE"; trap - EXIT; return 1; } + + prompt="$(cat </dev/null)" + applied="$(printf '%s' "$result" | python3 "${SCRIPT_DIR}/memory.py" apply --role "$ROLE" 2>/dev/null)" + if [ -z "$applied" ]; then + role_log "ERR" "整理結果無法套用(角色 ${ROLE}),保留待整理記憶到下個週期" + role_lock_release "$ROLE" + trap - EXIT + return 1 + fi + role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}" + + forgotten="$(python3 "${SCRIPT_DIR}/memory.py" forget --role "$ROLE" 2>/dev/null | tr '\n' ';')" + role_log "INF" "遺忘檢查:${forgotten}" + + role_lock_release "$ROLE" + trap - EXIT + return 0 +} + +# ------------------------------------------------------------------------------ +# 排程安裝:cron 環境沒有互動 shell 的環境變數,需把必要變數與 PATH 一併寫入 +# ------------------------------------------------------------------------------ + +cron_quote() { + # 把值包成單引號:PATH 等變數常含空白(例如 /mnt/c/Program Files), + # 未加引號會被 cron 的 sh 拆成指令;% 是 cron 的換行符號,一律跳脫。 + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g; s/%/\\\\%/g")" +} + +cron_line() { + # 組出 crontab 條目:睡眠時段內每小時檢查一次 + local env_prefix="PATH=$(cron_quote "$PATH")" + local var + for var in ROLE_ENABLED ROLE_NAME ROLE_HOME ROLE_MEMORY_HOME ROLE_CLI ROLE_MODEL ROLE_SLEEP_START ROLE_SLEEP_END ROLE_SCOPE; do + if [ -n "${!var:-}" ]; then + env_prefix="${env_prefix} ${var}=$(cron_quote "${!var}")" + fi + done + printf '0 %s * * * %s %s --run >> %s 2>&1 %s\n' \ + "$(cron_hours)" "$env_prefix" "$(cron_quote "${SCRIPT_DIR}/role_sleep.sh")" \ + "$(cron_quote "$(sleep_log_path)")" "$CRON_MARKER" +} + +cron_hours() { + # 依睡眠時段換算 cron 小時欄位(每小時檢查一次,讓 AI 運行中的情況能在下個小時重試) + local start end hour hours="" + start="$(role_time_to_minutes "$(role_sleep_start)")" || { printf '22-23,0-5'; return 0; } + end="$(role_time_to_minutes "$(role_sleep_end)")" || { printf '22-23,0-5'; return 0; } + start=$((start / 60)) + end=$((end / 60)) + hour="$start" + while [ "$hour" != "$end" ]; do + hours="${hours}${hours:+,}${hour}" + hour=$(((hour + 1) % 24)) + done + printf '%s' "${hours:-22,23,0,1,2,3,4,5}" +} + +sleep_log_path() { + # 排程輸出的 log 路徑(只記狀態訊息,不含記憶內容) + printf '%s/sleep.log' "$(role_home)" +} + +install_cron() { + # 安裝或更新睡眠排程;以 marker 註解辨識自己的條目,不動使用者其他排程 + command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab,無法安裝排程"; return 1; } + mkdir -p "$(role_home)" 2>/dev/null + local current new + current="$(crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER")" + new="$(printf '%s\n%s' "$current" "$(cron_line)" | sed '/^$/d')" + printf '%s\n' "$new" | crontab - || { role_log "ERR" "寫入 crontab 失敗"; return 1; } + role_log "INF" "已安裝睡眠排程:每日 $(cron_hours) 時整點檢查(角色 ${ROLE},時段 $(role_sleep_start)–$(role_sleep_end))" + role_log "INF" "排程輸出:$(sleep_log_path)" + if ! pgrep -x cron >/dev/null 2>&1 && ! pgrep -x crond >/dev/null 2>&1; then + role_log "WRN" "系統 cron 服務未執行(WSL 常見),排程不會觸發;SessionStart 的背景補跑仍會運作" + fi + return 0 +} + +remove_cron() { + # 移除本 skill 安裝的排程條目 + command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab"; return 1; } + crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER" | crontab - + role_log "INF" "已移除睡眠排程" + return 0 +} + +show_status() { + # 以表格輸出目前角色與記憶狀態(供 skill 的 --status 使用) + local cron_state="未安裝" cron_service="未執行" window="否" + crontab -l 2>/dev/null | grep -qF "$CRON_MARKER" && cron_state="已安裝" + { pgrep -x cron >/dev/null 2>&1 || pgrep -x crond >/dev/null 2>&1; } && cron_service="執行中" + role_in_sleep_window && window="是" + printf '| 項目 | 值 |\n| --- | --- |\n' + printf '| 角色 | %s |\n' "$ROLE" + printf '| 角色定義檔 | %s |\n' "$(role_file "$ROLE")" + printf '| 睡眠時段 | %s–%s |\n' "$(role_sleep_start)" "$(role_sleep_end)" + printf '| 目前是否睡眠中 | %s |\n' "$window" + printf '| cron 排程 | %s |\n' "$cron_state" + printf '| cron 服務 | %s |\n' "$cron_service" + printf '| 摘要 CLI | %s |\n' "$(role_select_cli 2>/dev/null || printf '找不到可用 CLI')" + printf '\n' + python3 "${SCRIPT_DIR}/memory.py" stats --role "$ROLE" 2>/dev/null + printf '\n' +} + +# ------------------------------------------------------------------------------ +# 進入點 +# ------------------------------------------------------------------------------ + +MODE="${1:---status}" +case "$MODE" in + --run) + role_enabled || exit 0 + require_role + if ! role_in_sleep_window; then + role_log "DBG" "目前不在睡眠時段($(role_sleep_start)–$(role_sleep_end)),略過" + exit 0 + fi + if role_ai_running; then + role_log "INF" "偵測到 AI 正在運行,本小時不進入睡眠,下個整點再檢查" + exit 0 + fi + sleep_cycle "cron" + ;; + --catchup) + role_enabled || exit 0 + require_role + if [ "$(python3 "${SCRIPT_DIR}/memory.py" need-sleep --role "$ROLE" 2>/dev/null)" != "yes" ]; then + role_log "DBG" "不需補跑整理" + exit 0 + fi + sleep_cycle "補跑" + ;; + --force) + require_role + sleep_cycle "手動" + ;; + --install-cron) + require_role + install_cron + ;; + --remove-cron) + remove_cron + ;; + --status) + require_role + show_status + ;; + -h|--help) + usage + ;; + *) + usage + exit 1 + ;; +esac diff --git a/scripts/role/transcript.py b/scripts/role/transcript.py new file mode 100755 index 0000000..9068d26 --- /dev/null +++ b/scripts/role/transcript.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +# ============================================================================== +# 用途:角色記憶的 transcript 處理工具。負責 (1) 從 Claude Code/Codex +# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容), +# (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII), +# 作為寫入記憶檔前的第二道防線。 +# 更新時間:2026/07/28 00:00:00 +# 相依:Python 3 標準庫。抽取與遮蔽全程僅走 stdin/stdout,本檔不寫任何檔案。 +# ============================================================================== + +import json +import re +import sys +from datetime import datetime, timezone + +# 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入 +TOOL_RESULT_LIMIT = 200 +TOOL_INPUT_LIMIT = 160 +TOTAL_LIMIT = 24000 + +# ------------------------------------------------------------------------------ +# 機密遮蔽規則:命中一律換成 *** +# ------------------------------------------------------------------------------ +REDACT_PATTERNS = [ + (r"[A-Za-z0-9_\-]*:[A-Za-z0-9_\-]{16,}@", "***@"), # URL 內嵌憑證 user:token@ + (r"\b[0-9a-f]{40}\b", "***"), # Gitea 40 字元 token + (r"\bgh[pousr]_[A-Za-z0-9_]{16,}\b", "***"), # GitHub token + (r"\bsk-[A-Za-z0-9\-_]{16,}\b", "***"), # API key + (r"(?i)\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+", r"\1=***"), + (r"(?i)Authorization:\s*(token|bearer)\s+\S+", r"Authorization: \1 ***"), + (r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", "***"), # Email + (r"\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b", "***"), # 台灣手機 + (r"\b[A-Z][12]\d{8}\b", "***"), # 身分證字號 +] + + +def redact(text): + """對文字套用全部機密遮蔽規則,回傳遮蔽後的結果。""" + for pattern, replacement in REDACT_PATTERNS: + text = re.sub(pattern, replacement, text) + return text + + +def _is_real_user_message(entry): + """判斷 transcript 條目是否為真正的使用者輸入(排除工具回填與環境注入)。""" + payload = entry.get("payload") + if isinstance(payload, dict) and entry.get("type") == "event_msg": + return payload.get("type") == "user_message" and bool(str(payload.get("message") or "").strip()) + + if entry.get("type") != "user": + return False + content = entry.get("message", {}).get("content") + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + return any(b.get("type") == "text" for b in content if isinstance(b, dict)) + return False + + +def _blocks(entry): + """取出條目的 content blocks,統一為 list 形式。""" + content = entry.get("message", {}).get("content") + if isinstance(content, str): + return [{"type": "text", "text": content}] + return content if isinstance(content, list) else [] + + +def _payload_text_blocks(content): + """把 Codex response_item 的 content blocks 轉成純文字片段。""" + if isinstance(content, str): + return [content] + if not isinstance(content, list): + return [] + texts = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") in ("input_text", "output_text", "text"): + text = (block.get("text") or "").strip() + if text: + texts.append(text) + return texts + + +def _render_codex_payload(entry): + """將 Codex session JSONL 的 payload 格式轉為摘要輸入用純文字。""" + payload = entry.get("payload") + if not isinstance(payload, dict): + return [] + + lines = [] + entry_type = entry.get("type") + payload_type = payload.get("type") + + if entry_type == "event_msg": + if payload_type == "user_message": + message = (payload.get("message") or "").strip() + if message: + lines.append(f"[user] {message}") + elif payload_type == "agent_message": + message = (payload.get("message") or "").strip() + if message: + phase = payload.get("phase") or "assistant" + lines.append(f"[assistant:{phase}] {message}") + return lines + + if entry_type != "response_item": + return lines + + if payload_type == "message": + role = payload.get("role") or "assistant" + if role in ("system", "developer"): + return lines + for text in _payload_text_blocks(payload.get("content")): + # Codex 會把 skill 內容以 user role 注入;避免把整份 SKILL.md 當成本輪工作。 + if role == "user" and text.lstrip().startswith(""): + continue + if role == "user" and text.lstrip().startswith(""): + continue + lines.append(f"[{role}] {text}") + elif payload_type == "function_call": + name = payload.get("name") or "?" + raw = str(payload.get("arguments") or "").strip().replace("\n", " ") + lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}") + elif payload_type == "function_call_output": + raw = str(payload.get("output") or "").strip().replace("\n", " ") + if raw: + lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}") + + return lines + + +def _render(entry): + """將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。""" + codex_lines = _render_codex_payload(entry) + if codex_lines: + return codex_lines + + role = entry.get("type") + lines = [] + for block in _blocks(entry): + if not isinstance(block, dict): + continue + kind = block.get("type") + if kind == "text": + text = (block.get("text") or "").strip() + if text: + lines.append(f"[{role}] {text}") + elif kind == "tool_use": + name = block.get("name", "?") + raw = json.dumps(block.get("input", {}), ensure_ascii=False) + lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}") + elif kind == "tool_result": + raw = block.get("content") + if isinstance(raw, list): + raw = " ".join( + b.get("text", "") for b in raw if isinstance(b, dict) and b.get("type") == "text" + ) + raw = str(raw or "").strip().replace("\n", " ") + if raw: + lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}") + return lines + + +def _read_entries(path): + """讀取 transcript JSONL,忽略無法解析的列。""" + try: + with open(path, encoding="utf-8") as fh: + entries = [] + for line in fh: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except ValueError: + continue + except OSError: + return [] + return entries + + +def _turn_start_index(entries): + """找出本輪起點:最後一筆真正使用者訊息的位置。""" + start = 0 + for index in range(len(entries) - 1, -1, -1): + if _is_real_user_message(entries[index]): + start = index + break + return start + + +def _parse_timestamp(value): + """解析常見 transcript timestamp 格式,失敗回 None。""" + if not isinstance(value, str) or not value.strip(): + return None + raw = value.strip() + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(raw) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _entry_timestamp(entry): + """取出 transcript 條目的時間欄位。""" + for key in ("timestamp", "created_at", "time"): + dt = _parse_timestamp(entry.get(key)) + if dt: + return dt + message = entry.get("message") + if isinstance(message, dict): + for key in ("timestamp", "created_at", "time"): + dt = _parse_timestamp(message.get(key)) + if dt: + return dt + return None + + +def format_duration(seconds): + """把秒數格式化為精簡中文耗時。""" + if seconds < 0: + return "未判定" + minutes = int(round(seconds / 60)) + if minutes <= 0: + return "1 分鐘內" + hours, mins = divmod(minutes, 60) + if hours and mins: + return f"{hours} 小時 {mins} 分鐘" + if hours: + return f"{hours} 小時" + return f"{mins} 分鐘" + + +def turn_duration(path): + """ + 估算本輪花費時間:取本輪起點到最後一筆可解析 timestamp 的差距。 + + transcript 無時間欄位或本輪少於兩個時間點時回「未判定」,避免臆測。 + """ + entries = _read_entries(path) + if not entries: + return "未判定" + start = _turn_start_index(entries) + stamps = [dt for dt in (_entry_timestamp(e) for e in entries[start:]) if dt] + if len(stamps) < 2: + return "未判定" + return format_duration((max(stamps) - min(stamps)).total_seconds()) + + +def extract_turn(path): + """ + 從 transcript JSONL 抽出本輪內容:最後一筆真正使用者訊息(含該筆)之後的全部條目。 + + 不需任何狀態檔即可界定「本輪」,符合工作內容不落地的要求。 + 回傳純文字字串;讀取失敗或無內容時回空字串。 + """ + entries = _read_entries(path) + if not entries: + return "" + start = _turn_start_index(entries) + + + lines = [] + for entry in entries[start:]: + lines.extend(_render(entry)) + + text = "\n".join(lines).strip() + if len(text) > TOTAL_LIMIT: + head = text[: TOTAL_LIMIT // 2] + tail = text[-TOTAL_LIMIT // 2 :] + text = f"{head}\n…(中段省略)…\n{tail}" + return text + + +USAGE = """用法:transcript.py <子命令> [參數] + + extract 抽出本輪內容並遮蔽機密後輸出到 stdout + duration 估算本輪花費時間,無法判定時輸出「未判定」 + redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout +""" + + +def main(argv): + """CLI 進入點:解析子命令並執行抽取或遮蔽。""" + if not argv or argv[0] in ("-h", "--help"): + print(USAGE) + return 0 + if argv[0] == "extract": + if len(argv) < 2: + return 2 + text = extract_turn(argv[1]) + if not text: + return 1 + sys.stdout.write(redact(text)) + return 0 + if argv[0] == "duration": + if len(argv) < 2: + return 2 + sys.stdout.write(turn_duration(argv[1])) + return 0 + if argv[0] == "redact": + sys.stdout.write(redact(sys.stdin.read())) + return 0 + print(USAGE) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/skills/role/SKILL.md b/skills/role/SKILL.md new file mode 100644 index 0000000..2bf74ba --- /dev/null +++ b/skills/role/SKILL.md @@ -0,0 +1,310 @@ +--- +name: role +description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI 工具以固定角色(name/nature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:搭配相容的 SessionStart hook 於啟動時載入角色與記憶、Stop hook 記錄對話,睡眠時段(預設 22:00 至隔天 06:00)由排程整理記憶(分類重要/興趣/新知/技能/日常/其他、去重、設標籤與一句話總結、壓縮歸檔,日常與其他依使用頻率遺忘)。提供 --new(新建或更新角色,更新時逐欄核對新舊)、--use(切換啟用角色)、--list、--sleep(立即整理)、--status/--diagnose、--install-cron/--remove-cron、--forget-preview 等模式。當使用者說建立角色、新增人格、切換角色、讓回覆更有特色、角色記憶、記憶整理、睡覺整理記憶、忘記舊記憶、角色沒有載入、hook 沒載入角色,或提到 .roles/.memory/ROLE_NAME/ROLE_ENABLED/ROLE_SLEEP_START/ROLE_MEMORY_HOME 時觸發。不適用於:工作紀錄寫入 Gitea wiki(用 doc plugin 的 worklog)、專案文件化(用 doc-funcs)。 +--- + +# role — 角色人格與長期記憶 + +讓 CLI 工具的回覆帶固定人格,並把與使用者的對話累積成可被下次載入的長期記憶。 +**載入與記錄由 hook 自動完成、不需人工觸發**;本 skill 負責自動路徑之外的人工操作:建立/更新角色、切換角色、手動整理、排程安裝與診斷。 + +| 元件 | 觸發者 | 職責 | +| --- | --- | --- | +| `hooks/hooks.json` 的 `SessionStart` hook | harness 自動 | 啟動 CLI 時載入角色定義+記憶;睡眠時段只回報「角色睡覺中」不載入 | +| `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束抽本輪對話 → 濃縮成一則記憶 → 遮蔽 → 寫入 `inbox/` | +| cron 排程(本 skill 安裝) | 系統排程 | 睡眠時段每小時檢查一次:**有 AI 在運行就不睡**,沒有才進入睡眠整理記憶 | +| 本 skill `/jsc:role` | 使用者/助理手動 | `--new`/`--use`/`--list`/`--sleep`/`--status`/`--install-cron`/`--forget-preview` | +| `scripts/role/role_load.sh` | SessionStart hook | 角色與記憶載入(單一實作,避免漂移) | +| `scripts/role/role_capture.sh` | Stop hook | 對話 → 記憶(四欄固定格式) | +| `scripts/role/role_sleep.sh` | cron/補跑/手動 | 睡眠判斷、記憶整理、排程安裝、狀態輸出 | +| `scripts/role/memory.py` | 上述共用 | 記憶檔讀寫、分類、去重合併、壓縮歸檔、遺忘、載入組裝 | +| `scripts/role/transcript.py` | 上述共用 | 抽本輪對話片段、機密與個資遮蔽 | +| `scripts/role/role_lib.sh` | 上述共用 | log、角色解析、睡眠時段、AI 行程偵測、CLI 選擇、記憶鎖 | + +### 各助理支援範圍 + +| 功能 | Claude Code | Codex | Antigravity | OpenCode | GitHub Copilot | +| --- | --- | --- | --- | --- | --- | +| `SessionStart` 載入角色 | ✅ | ⚠️ 需該版本支援 SessionStart hook | ❌ | ❌ | ❌ | +| `Stop` 記錄記憶 | ✅ | ✅ 需可讀 Codex session JSONL | ❌ | ❌ | ❌ | +| cron 睡眠整理 | ✅ 與助理無關(系統排程) | ✅ | ✅ | ✅ | ✅ | +| `--new`/`--use`/`--sleep` 等模式 | ✅ | ⚠️ 需 plugin 目錄保留 `scripts/` | ⚠️ 同左 | ❌ 只複製 `skills/`,無腳本 | ⚠️ 同左 | +| 濃縮/整理 CLI | `claude -p` | `codex exec` | `agy -p` | `opencode run` | `copilot -p` | + +- **`hooks/hooks.json` 只有 Claude Code 一定會讀**;Codex 會從 `~/.codex/plugins/cache/generic/jsc` 找腳本。其他助理若提供等效 hook,`transcript.py` 需補對應解析器。 +- 不支援 hook 的助理仍可用:cron 排程與手動模式照常運作,只是角色不會自動載入。 + +### 腳本路徑解析(重要) + +skill 執行時的工作目錄是**使用者的專案目錄**,不是 plugin 根目錄,因此**絕不可用相對路徑呼叫腳本**: + +| 環境 | plugin 根目錄 | +| --- | --- | +| Claude Code | `${CLAUDE_PLUGIN_ROOT}` | +| 其他助理 | 本 skill 載入時提示的 base directory(`.../skills/role`)往上兩層 | + +```bash +ROLE_DIR="${CLAUDE_PLUGIN_ROOT}/scripts/role" # Claude Code +ROLE_DIR="/../../scripts/role" # 其他助理 +``` + +以下各模式一律以 `${ROLE_DIR}` 表示該目錄。解析不到或該目錄不存在時,回報「plugin 目錄未包含 scripts/role,本 skill 在此環境不可用」並停止,不要改用相對路徑重試。 + +--- + +## 共用規範(必要前置) + +執行本 skill 前,先以 Skill 工具載入下列共用規範並全程遵守;**任一載入不到時先詢問使用者是否安裝 generic plugin(`https://gitea.jsc.idv.tw/plugins/generic.git`),不安裝則中斷**: + +- `/jsc:spec-output`:繁體中文(台灣用語)、UTF-8 無 BOM、表格與 Mermaid 優先。 +- `/jsc:spec-execution`:自動執行原則(必要決策才中斷)、不臆測。 +- `/jsc:spec-time-log`:時間戳固定 Asia/Taipei `yyyy/MM/dd HH:mm:ss`;訊息格式 `[時間][階段][等級]: 訊息`、一行一則。 + +本 skill 特有補充: + +- **覆寫角色前一定要核對**:`--new` 遇到同名角色時,必須先逐欄列出新舊差異並取得使用者確認才寫入。這是本 skill 明定「一定會中斷詢問」的點,**不得被 `--yes` 略過**。 +- **不臆測角色設定**:`nature`/`vibe`/`emoji` 一律問使用者,不得代填。 +- **記憶只增不刪**:手動模式不得直接刪除分類記憶;淘汰一律走遺忘規則(先壓縮歸檔再移除)。 +- **絕不阻斷**:hook 路徑任何失敗都以 exit 0 結束,只在 stderr 留訊息。 + +--- + +## 環境變數 + +| 變數 | 必要 | 說明 | 未設定 | +| --- | --- | --- | --- | +| `ROLE_ENABLED` | | 總開關:`1` 強制啟用、`0` 強制停用 | **未設定時,只要有可解析且存在的角色就啟用**(沒建過角色的人零影響) | +| `ROLE_NAME` | | 指定本次要載入的角色 | 讀 `~/.roles/.active` | +| `ROLE_HOME` | | 角色定義目錄 | `~/.roles` | +| `ROLE_MEMORY_HOME` | | 記憶根目錄 | `~/.memory` | +| `ROLE_SLEEP_START` | | 睡眠起始 `HH:MM` | `22:00` | +| `ROLE_SLEEP_END` | | 睡眠結束 `HH:MM` | `06:00` | +| `ROLE_CLI` | | 濃縮/整理執行器:`auto`/`claude`/`codex`/`agy`/`opencode`/`copilot` | `auto`(先判斷目前 hook 環境,再 fallback 到已安裝工具) | +| `ROLE_MODEL` | | 強制指定模型(僅 `claude` CLI 使用) | 保底 `claude-haiku-4-5-20251001` | +| `ROLE_LOAD_LIMIT` | | 注入記憶的字元上限 | `8000` | +| `ROLE_SLEEP_TIMEOUT` | | 單次整理的模型逾時秒數 | `180` | +| `ROLE_SCOPE` | | 冒號分隔的路徑前綴,僅這些路徑下的 session 載入/記錄 | 全部 session | +| `ROLE_ERRLOG` | | 錯誤訊息額外寫入的檔案路徑 | 只走 stderr | + +> 角色切換用 `/jsc:role --use <名稱>`(寫 `.active`)即可,一般不需要設 `ROLE_NAME`;`ROLE_NAME` 適合「單一專案固定用某角色」時寫進該環境。 + +--- + +## 模式 + +### `--new`(預設模式) + +建立或更新角色。缺少的資訊**一次問齊**,不得代填: + +| 欄位 | 說明 | 範例 | +| --- | --- | --- | +| `name` | 角色名稱,同時是檔名 `~/.roles/.md` 與記憶目錄名。不得含 `/`、`\`、空白與前後點 | `小豹` | +| `nature` | 本質:這個角色是什麼、專長與行事準則 | 冷靜可靠的資深工程師,重證據、不打包票 | +| `vibe` | 氛圍:語氣、句長、稱呼、幽默感、禁忌 | 簡潔直白、偶爾吐槽,不用客套開場白 | +| `emoji` | 簽名 emoji,一到二個 | 🐆 | + +流程: + +1. 解析 `${ROLE_DIR}`;不存在則中止(見「腳本路徑解析」)。 +2. 以 `AskUserQuestion` 或提問取得四個欄位(使用者已在指令中給的欄位不得重複問)。 +3. 依「角色檔標準格式」產生新內容,`updated` 用當下時間(Asia/Taipei)。 +4. **若 `~/.roles/.md` 已存在**:讀舊檔,以表格逐欄列出差異後**停下來等使用者確認**: + + | 欄位 | 舊值 | 新值 | 變更 | + | --- | --- | --- | --- | + | nature | … | … | 是/否 | + | vibe | … | … | 是/否 | + | emoji | … | … | 是/否 | + | 共用行為區塊 | 版本 A | 版本 B | 是/否 | + + 個性欄位若使用者只想改其中一項,其餘一律沿用舊值;**共用行為區塊一律以本 skill 的最新版本覆寫**(該區塊由系統維護)。使用者不確認就不寫入。 +5. 寫入 `~/.roles/.md`(UTF-8 無 BOM)。 +6. 建立記憶目錄:`python3 "${ROLE_DIR}/memory.py" stats --role ""`(會順帶建好 `inbox/`、六個分類與 `archive/`)。 +7. 若尚未有啟用角色,或使用者要求,寫入 `~/.roles/.active`(單行角色名)。 +8. 執行 `ROLE_NAME="" "${ROLE_DIR}/role_sleep.sh" --install-cron` 安裝睡眠排程(已安裝則更新)。 +9. 回報結果並提醒:**重開 CLI 工作階段**角色才會載入;`SessionStart` hook 只在啟動時觸發。 + +### `--use <名稱>` + +切換啟用角色:確認 `~/.roles/<名稱>.md` 存在後,把名稱寫入 `~/.roles/.active`(覆蓋單行),回報舊角色與新角色,並提醒重開工作階段。 + +### `--list` + +列出 `~/.roles/*.md`,以表格輸出:角色、emoji、nature 摘要、更新時間、是否為 `.active`、記憶總數(可用 `memory.py stats` 取得)。 + +### `--sleep` + +立即執行一次記憶整理(不等排程、忽略時段與 AI 運行檢查): + +```bash +"${ROLE_DIR}/role_sleep.sh" --force +``` + +輸出整理結果(新增/合併/捨棄/歸檔筆數與遺忘清單)。 + +### `--forget-preview` + +只預覽會被遺忘的記憶、不實際刪除: + +```bash +python3 "${ROLE_DIR}/memory.py" forget --role "" --dry-run +``` + +### `--status`/`--diagnose` + +```bash +"${ROLE_DIR}/role_sleep.sh" --status +``` + +輸出角色、定義檔、睡眠時段、目前是否睡眠中、cron 排程與服務狀態、摘要 CLI、各分類記憶筆數、上次整理與遺忘時間。**角色沒有載入時**再逐項檢查: + +| 檢查項 | 判準 | +| --- | --- | +| 角色解析 | `ROLE_NAME` 或 `~/.roles/.active` 是否指向存在的定義檔 | +| 總開關 | `ROLE_ENABLED` 是否被設成 `0` | +| hook 註冊 | plugin 是否已啟用、`hooks/hooks.json` 是否存在(Claude Code 用 `/hooks` 檢視) | +| 工作階段 | 建立角色後是否**重開過** CLI(SessionStart 只在啟動時觸發) | +| 範圍 | `ROLE_SCOPE` 是否把目前目錄排除 | +| 時段 | 目前是否落在睡眠時段(睡眠時本來就不載入角色) | +| 依賴 | `python3` 與 `ROLE_CLI` 選到的 CLI 是否找得到 | +| 排程 | cron 條目是否存在、cron 服務是否執行中(WSL 常未啟動 → 靠啟動時補跑) | + +### `--install-cron`/`--remove-cron` + +安裝或移除睡眠排程。排程條目以 `# jsc-role-sleep` 註解標記,只動自己的條目: + +```bash +"${ROLE_DIR}/role_sleep.sh" --install-cron +``` + +安裝時會把目前的 `PATH` 與 `ROLE_*` 變數固定寫進條目(cron 沒有互動 shell 的環境變數),並在 cron 服務未執行時警告。 + +--- + +## 角色檔標準格式 + +`~/.roles/.md`,UTF-8 無 BOM。個性區塊由使用者決定,**共用行為區塊由本 skill 維護、逐字寫入每個角色檔**: + +````markdown +--- +name: <角色名> +nature: <本質,一句話> +vibe: <氛圍,一句話> +emoji: <簽名 emoji> +created: +updated: +--- + +# <角色名> + +## 本質(nature) + +<3 至 5 行:這個角色是什麼、專長、行事準則、面對不確定時的態度> + +## 氛圍(vibe) + +<3 至 5 行:語氣、句子長度、對使用者的稱呼、幽默感尺度、明確禁忌> + +## 簽名 emoji + + —— 每次回覆使用一次(開頭或結尾擇一固定),不重複刷、不在程式碼與檔案內容中使用。 + +## 共用行為(所有角色一致,由 /jsc:role 維護,請勿手動修改) + + +### 角色邊界 + +- 角色只影響**表達方式**,不影響工作的正確性、完整性與安全性。與使用者的明確指令衝突時,一律以使用者指令為準。 +- 不因角色設定而編造事實、跳過驗證、隱瞞失敗或淡化風險;壞消息照實說,只是用角色的語氣說。 +- 涉及程式碼、指令、檔案內容與報錯訊息時,一律照實輸出,不加角色修飾。 + +### 作息 + +- 每天 **22:00 至隔天 06:00 為睡眠時段**(可用 `ROLE_SLEEP_START`/`ROLE_SLEEP_END` 調整)。 +- 睡眠時段內啟動 CLI **不會載入角色**:以一般助理身分回應,不自稱角色、不使用角色語氣與簽名 emoji。此時對話仍會被記錄成記憶。 +- 睡眠排程每小時檢查一次,**偵測到有 AI 正在運行就不睡**,留到下個整點再試;沒有 AI 運行才進入睡眠並整理記憶。 + +### 記憶 + +- 記憶存放於 `~/.memory/<角色名>/`,來源是與使用者的對話:每輪結束由 hook 自動記錄到 `inbox/`,睡眠時段整理歸檔。 +- 整理規則:分類成**重要/興趣/新知/技能/日常/其他**六類 → 去除重複(重複者併入既有記憶)→ 設定標籤與一句話總結 → 壓縮內容後歸檔;原始記錄壓縮保存在 `archive/raw/`。 +- **日常與其他**兩類會依使用頻率適當遺忘:久未再次出現且命中次數低者,壓縮到 `archive/forgotten/` 後移出常用記憶。 +- 載入順序:**重要與興趣載入全文**;其餘只載入總結與標籤,依**技能 → 新知 → 日常 → 其他**排序。需要細節時自行讀取對應分類的記憶檔。 +- 使用者明確要求記住某件事時,主動補寫一則記憶(載入時會提供補寫指令)。 +- **絕不把憑證與個資寫進記憶**:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。 + +```` + +`~/.roles/.active` 只放一行角色名,代表目前啟用的角色。 + +--- + +## 記憶模型 + +``` +~/.memory/<角色名>/ +├── inbox/ 每輪對話產生、尚未整理的記憶 +├── important/ 重要:長期偏好、規範、決策、身分背景 +├── interest/ 興趣:反覆關注、主動深入的主題 +├── news/ 新知:新事實、新工具、外部資訊 +├── skill/ 技能:可重複套用的做法與流程 +├── daily/ 日常:一次性例行工作 +├── other/ 其他 +├── archive/raw// 已整理的原始記錄(gzip) +├── archive/forgotten/ 已遺忘的記憶(gzip,可考古但不再載入) +└── state.json 上次整理/遺忘時間 +``` + +每則記憶是一個 `.md`,frontmatter 帶 `id`/`category`/`summary`(一句話總結)/`tags`/`created`/`updated`/`hits`(命中次數,去重合併時 +1)。 + +遺忘規則(只套用於日常與其他): + +| 分類 | 未更新天數 | 命中次數 | 動作 | +| --- | --- | --- | --- | +| 日常 daily | ≥ 14 天 | ≤ 1 | 壓縮到 `archive/forgotten/` 後移除 | +| 其他 other | ≥ 7 天 | ≤ 1 | 壓縮到 `archive/forgotten/` 後移除 | + +--- + +## 睡眠與整理流程 + +```mermaid +flowchart TD + A[cron 每小時觸發
睡眠時段內] --> B{有 AI 正在運行?} + B -- 有 --> C[不睡,下個整點再檢查] + B -- 沒有 --> D[進入睡眠,取得記憶鎖] + D --> E[collect:inbox 待整理 + 既有記憶索引] + E --> F{有待整理記憶?} + F -- 沒有 --> G[更新整理時間 → 執行遺忘] + F -- 有 --> H[CLI 分類/去重/標籤/總結/壓縮] + H --> I[apply:寫入分類、原始記錄歸檔] + I --> J[forget:日常與其他依使用頻率遺忘] + J --> K[釋放鎖] + G --> K + L[SessionStart:白天啟動 CLI] --> M{距上次整理 ≥ 20 小時
且 inbox 有內容?} + M -- 是 --> N[背景補跑 --catchup] + M -- 否 --> O[正常載入角色與記憶] +``` + +整理失敗(模型無回應、輸出非合法 JSON)時**保留 inbox 不動**,留到下個週期重做,寧可晚整理也不遺失記憶。 + +--- + +## 機密與 PII(兩道防線) + +| 防線 | 位置 | 內容 | +| --- | --- | --- | +| 1 | 濃縮與整理提示詞 | 明令不得輸出 token/密碼/API key/連線字串/Email/電話/姓名/身分證號 | +| 2 | `transcript.py` 的 `redact` | 正則遮蔽:URL 內嵌憑證、40 字元 hex token、`gh?_`/`sk-` token、`token=`/`password=`、`Authorization:`、Email、台灣手機、身分證號 | + +第二道防線不可移除 —— 模型不一定遵守指令,而記憶會被長期保存並在每次啟動時載入。 + +--- + +## 呼叫方式 + +| 助理 | 呼叫 | +| --- | --- | +| Claude Code / Antigravity | `/jsc:role --new`、`/jsc:role --use 小豹`、`/jsc:role --list`、`/jsc:role --sleep`、`/jsc:role --status` | +| Codex | `$role --status`,或用 `/skills` 選單 | +| OpenCode / GitHub Copilot | 需完整 plugin 目錄保留 `scripts/`;OpenCode 以複製 `skills/` 安裝時不可用 |