feat(role): 新增角色人格與長期記憶 skill、hook 與腳本
SessionStart 載入角色與記憶、Stop 記錄對話成記憶,睡眠時段(預設 22:00–06:00) 由 cron 排程整理:分類六類、去重合併、設標籤與一句話總結、壓縮歸檔, 日常與其他依使用頻率遺忘;cron 未執行時由啟動路徑背景補跑。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+314
@@ -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("<skill>"):
|
||||
continue
|
||||
if role == "user" and text.lstrip().startswith("<environment_context>"):
|
||||
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 <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
|
||||
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
|
||||
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:]))
|
||||
Reference in New Issue
Block a user