feat(worklog): 支援 README 定義的摘要 CLI
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# ==============================================================================
|
||||
# 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code transcript
|
||||
# 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code/Codex
|
||||
# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
|
||||
# (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII),
|
||||
# 作為寫入 wiki 前的第二道防線。
|
||||
# 更新時間:2026/07/27 15:09:00
|
||||
# 更新時間:2026/07/27 22:16:00
|
||||
# 相依:Python 3 標準庫。全程僅走 stdin/stdout,不寫任何檔案。
|
||||
# ==============================================================================
|
||||
|
||||
@@ -42,7 +42,11 @@ def redact(text):
|
||||
|
||||
|
||||
def _is_real_user_message(entry):
|
||||
"""判斷 transcript 條目是否為真正的使用者輸入(排除 tool_result 回填的 user 條目)。"""
|
||||
"""判斷 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")
|
||||
@@ -61,8 +65,77 @@ def _blocks(entry):
|
||||
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):
|
||||
|
||||
+88
-27
@@ -1,16 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# 用途:工作證明自動記錄(worklog)。由 Claude Code 的 Stop hook 觸發,
|
||||
# 抽出本輪工作內容 → 呼叫模型濃縮成精簡條目 → 機密遮蔽 →
|
||||
# 用途:工作證明自動記錄(worklog)。由支援 hook 的 CLI 觸發,
|
||||
# 抽出本輪工作內容 → 呼叫已安裝 CLI 濃縮成精簡條目 → 機密遮蔽 →
|
||||
# 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。
|
||||
# 更新時間:2026/07/27 15:09:00
|
||||
# 相依:python3、claude CLI、curl(wiki 走 Python urllib,不需 curl 亦可)。
|
||||
# 更新時間:2026/07/27 22:16:00
|
||||
# 相依:python3、README 定義的任一 headless CLI、curl(wiki 走 Python urllib,不需 curl 亦可)。
|
||||
# 機密:token 僅由環境變數/本機憑證讀取,不 echo、不寫檔;輸出前套用遮蔽規則。
|
||||
# 退出碼:一律 0 —— hook 絕不可阻斷使用者的工作流程。
|
||||
# ==============================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STAGE="worklog"
|
||||
SUPPORTED_CLIS="claude codex agy opencode copilot"
|
||||
FALLBACK_MODEL="claude-haiku-4-5-20251001"
|
||||
MODEL_CACHE="${HOME}/.claude/worklog/model"
|
||||
CACHE_MAX_AGE_DAYS=30
|
||||
@@ -36,7 +37,7 @@ die_quiet() {
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 遞迴防護:摘要用的子 claude 行程會再次觸發 Stop hook,必須在此擋掉
|
||||
# 遞迴防護:摘要用的子 CLI 行程可能再次觸發 Stop hook,必須在此擋掉
|
||||
# ------------------------------------------------------------------------------
|
||||
[ -n "${WORKLOG_CHILD:-}" ] && exit 0
|
||||
|
||||
@@ -48,7 +49,52 @@ die_quiet() {
|
||||
[ -n "${WORKLOG_REPO:-}" ] || die_quiet "未設定 WORKLOG_REPO,略過記錄" "WRN"
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || die_quiet "找不到 python3,略過記錄" "WRN"
|
||||
command -v claude >/dev/null 2>&1 || die_quiet "找不到 claude CLI,略過記錄" "WRN"
|
||||
|
||||
select_worklog_cli() {
|
||||
# 依 README 定義的 headless CLI 選擇摘要執行器;可用 WORKLOG_CLI 強制指定。
|
||||
local requested="${WORKLOG_CLI:-auto}" cli
|
||||
if [ "$requested" != "auto" ]; then
|
||||
case " ${SUPPORTED_CLIS} " in
|
||||
*" ${requested} "*) ;;
|
||||
*) die_quiet "WORKLOG_CLI 不支援:${requested}(可用:auto ${SUPPORTED_CLIS})" "WRN" ;;
|
||||
esac
|
||||
command -v "$requested" >/dev/null 2>&1 || die_quiet "找不到 ${requested} CLI,略過記錄" "WRN"
|
||||
printf '%s' "$requested"
|
||||
return 0
|
||||
fi
|
||||
for cli in $SUPPORTED_CLIS; do
|
||||
if command -v "$cli" >/dev/null 2>&1; then
|
||||
printf '%s' "$cli"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
die_quiet "找不到可用摘要 CLI(需要其一:${SUPPORTED_CLIS})" "WRN"
|
||||
}
|
||||
|
||||
run_summary_cli() {
|
||||
# 各 CLI 依 README 的 headless 指令呼叫;不把工作內容寫入檔案。
|
||||
local cli="$1" prompt="$2" model="$3"
|
||||
case "$cli" in
|
||||
claude)
|
||||
WORKLOG_CHILD=1 timeout 45 claude -p "$prompt" --model "$model" 2>/dev/null
|
||||
;;
|
||||
codex)
|
||||
WORKLOG_CHILD=1 timeout 45 codex exec "$prompt" 2>/dev/null
|
||||
;;
|
||||
agy)
|
||||
WORKLOG_CHILD=1 timeout 45 agy -p "$prompt" 2>/dev/null
|
||||
;;
|
||||
opencode)
|
||||
WORKLOG_CHILD=1 timeout 45 opencode run "$prompt" 2>/dev/null
|
||||
;;
|
||||
copilot)
|
||||
WORKLOG_CHILD=1 timeout 45 copilot -p "$prompt" 2>/dev/null
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
SUMMARY_CLI="$(select_worklog_cli)"
|
||||
[ -n "$SUMMARY_CLI" ] || exit 0
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 讀取 hook 傳入的 JSON(session_id/transcript_path/cwd/stop_hook_active)
|
||||
@@ -64,8 +110,8 @@ try:
|
||||
except ValueError:
|
||||
d = {}
|
||||
print(
|
||||
d.get("session_id", "-") or "-",
|
||||
d.get("transcript_path", "-") or "-",
|
||||
d.get("session_id") or d.get("thread_id") or d.get("conversation_id") or "-",
|
||||
d.get("transcript_path") or d.get("session_path") or d.get("conversation_path") or d.get("path") or "-",
|
||||
"1" if d.get("stop_hook_active") else "0",
|
||||
d.get("cwd", "") or "-",
|
||||
)
|
||||
@@ -73,6 +119,12 @@ print(
|
||||
EOF_HOOK
|
||||
|
||||
[ "$STOP_ACTIVE" = "1" ] && die_quiet "stop_hook_active 為 true,避免迴圈不重複記錄"
|
||||
|
||||
if [ ! -f "$TRANSCRIPT_PATH" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then
|
||||
TRANSCRIPT_PATH="$(find "${HOME}/.codex/sessions" -type f -name "*${CODEX_THREAD_ID}.jsonl" -print -quit 2>/dev/null)"
|
||||
[ -n "$TRANSCRIPT_PATH" ] || TRANSCRIPT_PATH="-"
|
||||
fi
|
||||
|
||||
[ -f "$TRANSCRIPT_PATH" ] || die_quiet "找不到 transcript:${TRANSCRIPT_PATH}" "WRN"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -111,29 +163,35 @@ DURATION="$(python3 "${SCRIPT_DIR}/transcript.py" duration "$TRANSCRIPT_PATH" 2>
|
||||
[ -n "$DURATION" ] || DURATION="未判定"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 模型決定:WORKLOG_MODEL → 快取檔(/jsc:worklog --tune 產生)→ 保底
|
||||
# 模型決定:只有 claude CLI 使用 WORKLOG_MODEL/快取檔;其他 CLI 使用各自預設模型
|
||||
# ------------------------------------------------------------------------------
|
||||
MODEL=""
|
||||
MODEL_NOTE=""
|
||||
if [ -n "${WORKLOG_MODEL:-}" ]; then
|
||||
MODEL="${WORKLOG_MODEL}"
|
||||
elif [ -f "$MODEL_CACHE" ]; then
|
||||
if [ -n "$(find "$MODEL_CACHE" -mtime "+${CACHE_MAX_AGE_DAYS}" 2>/dev/null)" ]; then
|
||||
MODEL="$FALLBACK_MODEL"
|
||||
MODEL_NOTE=" (model: fallback)"
|
||||
log "WRN" "模型快取已超過 ${CACHE_MAX_AGE_DAYS} 天,改用保底模型,建議重跑 /jsc:worklog --tune"
|
||||
else
|
||||
MODEL="$(grep -m1 -E '^model=' "$MODEL_CACHE" 2>/dev/null | cut -d= -f2- | tr -d '[:space:]')"
|
||||
if [ "$SUMMARY_CLI" = "claude" ]; then
|
||||
if [ -n "${WORKLOG_MODEL:-}" ]; then
|
||||
MODEL="${WORKLOG_MODEL}"
|
||||
elif [ -f "$MODEL_CACHE" ]; then
|
||||
if [ -n "$(find "$MODEL_CACHE" -mtime "+${CACHE_MAX_AGE_DAYS}" 2>/dev/null)" ]; then
|
||||
MODEL="$FALLBACK_MODEL"
|
||||
MODEL_NOTE=" (cli: claude, model: fallback)"
|
||||
log "WRN" "模型快取已超過 ${CACHE_MAX_AGE_DAYS} 天,改用保底模型,建議重跑 /jsc:worklog --tune"
|
||||
else
|
||||
MODEL="$(grep -m1 -E '^model=' "$MODEL_CACHE" 2>/dev/null | cut -d= -f2- | tr -d '[:space:]')"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -z "$MODEL" ]; then
|
||||
MODEL="$FALLBACK_MODEL"
|
||||
MODEL_NOTE=" (model: fallback)"
|
||||
log "WRN" "無模型快取,改用保底模型,建議執行 /jsc:worklog --tune"
|
||||
if [ -z "$MODEL" ]; then
|
||||
MODEL="$FALLBACK_MODEL"
|
||||
MODEL_NOTE=" (cli: claude, model: fallback)"
|
||||
log "WRN" "無模型快取,改用保底模型,建議執行 /jsc:worklog --tune"
|
||||
elif [ -z "$MODEL_NOTE" ]; then
|
||||
MODEL_NOTE=" (cli: claude)"
|
||||
fi
|
||||
else
|
||||
MODEL_NOTE=" (cli: ${SUMMARY_CLI})"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 濃縮:交給模型產出精簡條目(子行程帶 WORKLOG_CHILD=1 阻斷遞迴)
|
||||
# 濃縮:交給選定 CLI 產出精簡條目(子行程帶 WORKLOG_CHILD=1 阻斷遞迴)
|
||||
# ------------------------------------------------------------------------------
|
||||
PROMPT="$(cat <<EOF_PROMPT
|
||||
你是工作紀錄濃縮器。輸入是一段 AI 助理與使用者的對話片段(含工具呼叫)。
|
||||
@@ -156,12 +214,15 @@ PROMPT="$(cat <<EOF_PROMPT
|
||||
6. 若沒有遇到明確困難,遇到的困難寫「未遇到明確困難」,解決方式寫「不需額外處理」。
|
||||
7. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
||||
8. 若這段對話沒有實質工作產出(純閒聊、純提問、僅讀取資訊而未產生結論),只輸出一行:SKIP
|
||||
|
||||
對話片段:
|
||||
${TURN}
|
||||
EOF_PROMPT
|
||||
)"
|
||||
|
||||
SUMMARY="$(printf '%s' "$TURN" | WORKLOG_CHILD=1 timeout 45 claude -p "$PROMPT" --model "$MODEL" 2>/dev/null)"
|
||||
SUMMARY="$(run_summary_cli "$SUMMARY_CLI" "$PROMPT" "$MODEL")"
|
||||
if [ -z "$SUMMARY" ]; then
|
||||
log "WRN" "摘要產出為空(模型 ${MODEL}),略過本輪"
|
||||
log "WRN" "摘要產出為空(CLI ${SUMMARY_CLI}),略過本輪"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$SUMMARY" | grep -qiE '^\s*SKIP\s*$' && die_quiet "模型判定本輪無實質工作產出"
|
||||
@@ -184,7 +245,7 @@ export WORKLOG_HOST WORKLOG_REPO
|
||||
if printf '%s' "$ENTRY" | python3 "${SCRIPT_DIR}/wiki_api.py" append "$MARKER" 2>&1 | grep -q '\[ERR\]'; then
|
||||
log "ERR" "寫入 wiki 失敗(專案 ${PROJECT})"
|
||||
else
|
||||
log "INF" "已記錄工作條目(專案 ${PROJECT},模型 ${MODEL})"
|
||||
log "INF" "已記錄工作條目(專案 ${PROJECT},CLI ${SUMMARY_CLI})"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user