#!/usr/bin/env python3 # ============================================================================== # 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code transcript # JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容), # (2) 對文字做機密遮蔽(token/密碼/PII),作為寫入 wiki 前的第二道防線。 # 更新時間:2026/07/27 11:14:16 # 相依:Python 3 標準庫。全程僅走 stdin/stdout,不寫任何檔案。 # ============================================================================== import json import re import sys # 單則工具結果/參數的擷取上限,避免整份 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 條目是否為真正的使用者輸入(排除 tool_result 回填的 user 條目)。""" 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 _render(entry): """將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。""" 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 extract_turn(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 "" start = 0 for index in range(len(entries) - 1, -1, -1): if _is_real_user_message(entries[index]): start = index break 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 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] == "redact": sys.stdout.write(redact(sys.stdin.read())) return 0 print(USAGE) return 2 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))