feat(worklog): 新增 token 用量統計、支援 Copilot 自動記錄,worklog 腳本改以 Node 實作
- transcript.py/wiki_api.py 等價移植為 transcript.mjs/wiki_api.mjs:extract/duration/redact/probe/pages/show/append 等子命令行為與 Python 版對拍一致;transcript.mjs 額外處理三個 Python→JS 移植地雷(json.dumps 間距、Unicode 碼點切片、Python repr 格式)。
- 新增 tokens 子命令與 turnTokens()/formatTokens():統計本輪 token 用量,Claude Code/Codex/Copilot 三種 transcript 格式皆支援(Copilot 逐輪只有 outputTokens,輸入固定「未判定」)。
- worklog.sh 條目改為七個固定 bullet(新增「token 用量」),hook 輸入解析同時接受 snake_case 與 Copilot 的 camelCase 欄位。
- hooks.json 的腳本定位邏輯補上 ~/.copilot/installed-plugins 搜尋路徑:先前只搜尋 .claude/.codex 的 plugins cache,導致 Copilot 上 hook 事件雖有觸發(agentStop 會被 Copilot 對應到 hooks.json 的 Stop key)卻找不到 worklog.sh。
- SKILL.md/README.md 同步更新腳本檔名、各助理支援範圍(Copilot 改為 ✅ 並附實測說明,Antigravity/OpenCode 補上不支援的具體原因)。
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
#!/usr/bin/env node
|
||||
// ==============================================================================
|
||||
// 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code/Codex/
|
||||
// GitHub Copilot CLI 的 JSONL 抽出「本輪」對話片段(最後一筆使用者訊息
|
||||
// 之後的全部內容),(2) 估算本輪花費時間,(3) 統計本輪 token 用量,
|
||||
// (4) 對文字做機密遮蔽(token/密碼/PII),作為寫入 wiki 前的第二道防線。
|
||||
// 本檔 REDACT_PATTERNS 對應 /jsc-shared:spec-gitea『機密遮蔽實作』章節
|
||||
// (其他工具的機密遮蔽規則以該章節為準)。
|
||||
// 原 Python 版(transcript.py)的 extract/duration/redact 三個子命令
|
||||
// 已逐一以同一份 transcript 對拍 diff 驗證輸出逐字元相同,token 統計與
|
||||
// Copilot 支援為本次新增,Python 版沒有對應功能可供對拍。
|
||||
// 更新時間:2026/08/11 16:51:56
|
||||
// 相依:Node.js 標準內建功能,無外部套件。全程僅走 stdin/stdout,不寫任何檔案。
|
||||
// ==============================================================================
|
||||
|
||||
import fs from "node:fs";
|
||||
|
||||
// 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入
|
||||
const TOOL_RESULT_LIMIT = 200;
|
||||
const TOOL_INPUT_LIMIT = 160;
|
||||
const TOTAL_LIMIT = 24000;
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// 機密遮蔽規則:命中一律換成 ***
|
||||
// ------------------------------------------------------------------------------
|
||||
// 本檔遮蔽規則對應 shared/scripts/lib/redact-patterns.json(經 /jsc-shared:spec-gitea 收斂)。
|
||||
// 依 A5-3/G1-6 裁決:doc repo 內自帶一份,不跨 repo 讀取 shared/scripts/lib/redact-patterns.json
|
||||
// (執行期無法跨 plugin 存取),異動一律先改 shared 那份規則檔,再回頭同步本檔。
|
||||
//
|
||||
// 移植自 Python re 版時的三個地雷(逐一處理,對應驗收案例見 G1-2):
|
||||
// (a) Python re.sub 預設全域取代,JS String.replace 不是 —— 全部補上 g flag。
|
||||
// (b) Python (?i) inline flag,JS 不支援 —— 改用 RegExp 的 i flag。
|
||||
// (c) 反向參照:Python 用 \1,JS String.replace 用 $1。
|
||||
const REDACT_PATTERNS = [
|
||||
[/[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@/g, "***@"], // URL 內嵌憑證 user:token@
|
||||
[/\b[0-9a-f]{40}\b/g, "***"], // Gitea 40 字元 token
|
||||
[/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "***"], // GitHub token
|
||||
[/\bsk-[A-Za-z0-9_-]{16,}\b/g, "***"], // API key
|
||||
[/\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+/gi, "$1=***"],
|
||||
[/Authorization:\s*(token|bearer)\s+\S+/gi, "Authorization: $1 ***"],
|
||||
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "***"], // Email
|
||||
[/\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b/g, "***"], // 台灣手機
|
||||
[/\b[A-Z][12]\d{8}\b/g, "***"], // 身分證字號
|
||||
];
|
||||
|
||||
/**
|
||||
* 仿 Python `json.dumps(obj, ensure_ascii=False)` 預設格式序列化(`", "`/`": "`
|
||||
* 分隔符,逗號與冒號後都有空格):JS `JSON.stringify` 預設不加這些空格,
|
||||
* 若直接拿來取代會讓 `[tool:xxx]` 那行的參數字串跟 Python 版逐字元不同
|
||||
* (已於 G1-1 對拍真實 subagent transcript 時抓到這個差異)。
|
||||
*/
|
||||
function pyJsonDumps(value) {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(pyJsonDumps).join(", ")}]`;
|
||||
if (typeof value === "object") {
|
||||
const parts = Object.entries(value).map(([k, v]) => `${JSON.stringify(k)}: ${pyJsonDumps(v)}`);
|
||||
return `{${parts.join(", ")}}`;
|
||||
}
|
||||
return "null";
|
||||
}
|
||||
|
||||
/** Python 的 truthy 判斷:`0`/`""`/`None`/`False`/空 list/空 dict 皆為 falsy。 */
|
||||
function isPyTruthy(value) {
|
||||
if (value === null || value === undefined || value === false || value === 0 || value === "") return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === "object") return Object.keys(value).length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仿 Python `repr()`:字串用單引號,list/dict 遞迴展開。用於重現
|
||||
* `str(payload.get("output") or "")` 這類寫法在 `output` 是 list/dict(而非
|
||||
* 字串)時的輸出——Python `str(list)` 會呼叫每個元素的 `repr()`,JS
|
||||
* `String(array)` 只會呼叫 `Array.prototype.toString`(物件變成
|
||||
* `[object Object]`),兩者天差地遠(已於 G1-1 對拍真實 Codex transcript 時
|
||||
* 抓到這個差異:某筆 `function_call_output` 的 `output` 是結構化 array 而非
|
||||
* JSON 字串)。
|
||||
*/
|
||||
function pyRepr(value) {
|
||||
if (value === null || value === undefined) return "None";
|
||||
if (value === true) return "True";
|
||||
if (value === false) return "False";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") {
|
||||
const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
|
||||
let out = "";
|
||||
for (const ch of value) {
|
||||
const cp = ch.codePointAt(0);
|
||||
if (ch === "\\") out += "\\\\";
|
||||
else if (ch === quote) out += `\\${quote}`;
|
||||
else if (ch === "\n") out += "\\n";
|
||||
else if (ch === "\r") out += "\\r";
|
||||
else if (ch === "\t") out += "\\t";
|
||||
// Python repr() 對「不可印字元」一律跳脫成 \uXXXX/\UXXXXXXXX:這裡不追
|
||||
// 完整 Unicode 分類表(那需要外部資料庫),只涵蓋最常見的兩類——C0/C1
|
||||
// 控制字元、與私用區(Private Use Area,已於 G1-1 對拍時在真實 Codex
|
||||
// transcript 的引註標記裡遇到 U+E200 這個案例)。其餘罕見不可印分類
|
||||
// (如某些格式控制符)維持原樣輸出,屬已知、影響範圍為零的簡化
|
||||
// (這段文字只會餵給 LLM 摘要,不影響任何程式邏輯判讀)。
|
||||
else if ((cp >= 0x00 && cp <= 0x1f) || cp === 0x7f || (cp >= 0x80 && cp <= 0x9f)) {
|
||||
out += `\\x${cp.toString(16).padStart(2, "0")}`;
|
||||
} else if ((cp >= 0xe000 && cp <= 0xf8ff) || (cp >= 0xf0000 && cp <= 0xffffd) || (cp >= 0x100000 && cp <= 0x10fffd)) {
|
||||
out += cp <= 0xffff ? `\\u${cp.toString(16).padStart(4, "0")}` : `\\U${cp.toString(16).padStart(8, "0")}`;
|
||||
} else out += ch;
|
||||
}
|
||||
return `${quote}${out}${quote}`;
|
||||
}
|
||||
if (Array.isArray(value)) return `[${value.map(pyRepr).join(", ")}]`;
|
||||
if (typeof value === "object") {
|
||||
const parts = Object.entries(value).map(([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`);
|
||||
return `{${parts.join(", ")}}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** 仿 Python `str(value or fallback)`:字串型別的 `str()` 是自己本身,其餘型別走 `pyRepr`。 */
|
||||
function pythonStrOr(value, fallback) {
|
||||
if (!isPyTruthy(value)) return fallback;
|
||||
return typeof value === "string" ? value : pyRepr(value);
|
||||
}
|
||||
|
||||
/** 依 Unicode 碼點(非 UTF-16 code unit)取字串長度,對齊 Python str 的 len() 語意。 */
|
||||
function codePointLength(text) {
|
||||
return Array.from(text).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依 Unicode 碼點切片,對齊 Python 字串切片語意(避免切斷代理對)。
|
||||
* ⚠️ 必須用這個,不能直接 `str.slice()`:emoji 等 astral-plane 字元在 JS 是
|
||||
* 兩個 UTF-16 code unit,`slice()` 按 code unit 數截斷會比 Python 按碼點數
|
||||
* 截斷的版本少算字元(已於 G1-1 對拍真實 transcript 時抓到這個差異)。
|
||||
*/
|
||||
function codePointSlice(text, start, end) {
|
||||
return Array.from(text).slice(start, end).join("");
|
||||
}
|
||||
|
||||
/** 對文字套用全部機密遮蔽規則,回傳遮蔽後的結果。 */
|
||||
export function redact(text) {
|
||||
let out = text;
|
||||
for (const [pattern, replacement] of REDACT_PATTERNS) {
|
||||
out = out.replace(pattern, replacement);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 判斷 transcript 條目是否為真正的使用者輸入(排除工具回填與環境注入)。 */
|
||||
function isRealUserMessage(entry) {
|
||||
const payload = entry.payload;
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload) && entry.type === "event_msg") {
|
||||
return payload.type === "user_message" && Boolean(String(payload.message ?? "").trim());
|
||||
}
|
||||
// Copilot:type "user.message",取 data.content(不用 transformedContent,那裡混了注入內容)
|
||||
if (entry.type === "user.message") {
|
||||
const content = entry.data && typeof entry.data === "object" ? entry.data.content : undefined;
|
||||
return Boolean(String(content ?? "").trim());
|
||||
}
|
||||
if (entry.type !== "user") return false;
|
||||
const content = entry.message && typeof entry.message === "object" ? entry.message.content : undefined;
|
||||
if (typeof content === "string") return Boolean(content.trim());
|
||||
if (Array.isArray(content)) {
|
||||
return content.some((b) => b && typeof b === "object" && b.type === "text");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 取出條目的 content blocks,統一為 array 形式。 */
|
||||
function blocks(entry) {
|
||||
const content = entry.message && typeof entry.message === "object" ? entry.message.content : undefined;
|
||||
if (typeof content === "string") return [{ type: "text", text: content }];
|
||||
return Array.isArray(content) ? content : [];
|
||||
}
|
||||
|
||||
/** 把 Codex response_item 的 content blocks 轉成純文字片段。 */
|
||||
function payloadTextBlocks(content) {
|
||||
if (typeof content === "string") return [content];
|
||||
if (!Array.isArray(content)) return [];
|
||||
const texts = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if (["input_text", "output_text", "text"].includes(block.type)) {
|
||||
const text = String(block.text ?? "").trim();
|
||||
if (text) texts.push(text);
|
||||
}
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
/** 將 Codex session JSONL 的 payload 格式轉為摘要輸入用純文字。 */
|
||||
function renderCodexPayload(entry) {
|
||||
const payload = entry.payload;
|
||||
if (!payload || typeof payload !== "object") return [];
|
||||
|
||||
const lines = [];
|
||||
const entryType = entry.type;
|
||||
const payloadType = payload.type;
|
||||
|
||||
if (entryType === "event_msg") {
|
||||
if (payloadType === "user_message") {
|
||||
const message = String(payload.message ?? "").trim();
|
||||
if (message) lines.push(`[user] ${message}`);
|
||||
} else if (payloadType === "agent_message") {
|
||||
const message = String(payload.message ?? "").trim();
|
||||
if (message) {
|
||||
const phase = payload.phase || "assistant";
|
||||
lines.push(`[assistant:${phase}] ${message}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (entryType !== "response_item") return lines;
|
||||
|
||||
if (payloadType === "message") {
|
||||
const role = payload.role || "assistant";
|
||||
if (role === "system" || role === "developer") return lines;
|
||||
for (const text of payloadTextBlocks(payload.content)) {
|
||||
// Codex 會把 skill 內容以 user role 注入;避免把整份 SKILL.md 當成本輪工作。
|
||||
if (role === "user" && text.trimStart().startsWith("<skill>")) continue;
|
||||
if (role === "user" && text.trimStart().startsWith("<environment_context>")) continue;
|
||||
lines.push(`[${role}] ${text}`);
|
||||
}
|
||||
} else if (payloadType === "function_call") {
|
||||
const name = payload.name || "?";
|
||||
const raw = pythonStrOr(payload.arguments, "").trim().replace(/\n/g, " ");
|
||||
lines.push(`[tool:${name}] ${codePointSlice(raw, 0, TOOL_INPUT_LIMIT)}`);
|
||||
} else if (payloadType === "function_call_output") {
|
||||
const raw = pythonStrOr(payload.output, "").trim().replace(/\n/g, " ");
|
||||
if (raw) lines.push(`[result] ${codePointSlice(raw, 0, TOOL_RESULT_LIMIT)}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 Copilot events.jsonl 條目轉為摘要輸入用純文字。
|
||||
* ⚠️ user.message/assistant.message 的欄位形狀已於 2026/08/11 實測真實 Copilot
|
||||
* session 確認(見 todo.md H1-1 記錄);tool.execution_complete 與 assistant.message
|
||||
* 的 toolRequests 欄位形狀依規劃文件推斷,尚未實測到含工具呼叫的真實 session,
|
||||
* 若欄位名稱與實際不符請依實測結果修正。
|
||||
*/
|
||||
function renderCopilotEvent(entry) {
|
||||
const data = entry.data;
|
||||
if (!data || typeof data !== "object") return [];
|
||||
|
||||
if (entry.type === "user.message") {
|
||||
const text = String(data.content ?? "").trim();
|
||||
return text ? [`[user] ${text}`] : [];
|
||||
}
|
||||
|
||||
if (entry.type === "assistant.message") {
|
||||
const lines = [];
|
||||
const text = String(data.content ?? "").trim();
|
||||
if (text) lines.push(`[assistant] ${text}`);
|
||||
const toolRequests = Array.isArray(data.toolRequests) ? data.toolRequests : [];
|
||||
for (const req of toolRequests) {
|
||||
const name = (req && (req.name || req.tool)) || "?";
|
||||
const raw = pyJsonDumps((req && (req.input ?? req.arguments)) ?? {});
|
||||
lines.push(`[tool:${name}] ${codePointSlice(raw, 0, TOOL_INPUT_LIMIT)}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (entry.type === "tool.execution_complete") {
|
||||
const raw = String(data.output ?? data.result ?? "").trim().replace(/\n/g, " ");
|
||||
return raw ? [`[result] ${codePointSlice(raw, 0, TOOL_RESULT_LIMIT)}`] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。 */
|
||||
function render(entry) {
|
||||
const copilotLines = renderCopilotEvent(entry);
|
||||
if (copilotLines.length) return copilotLines;
|
||||
|
||||
const codexLines = renderCodexPayload(entry);
|
||||
if (codexLines.length) return codexLines;
|
||||
|
||||
const role = entry.type;
|
||||
const lines = [];
|
||||
for (const block of blocks(entry)) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const kind = block.type;
|
||||
if (kind === "text") {
|
||||
const text = String(block.text ?? "").trim();
|
||||
if (text) lines.push(`[${role}] ${text}`);
|
||||
} else if (kind === "tool_use") {
|
||||
const name = block.name ?? "?";
|
||||
const raw = pyJsonDumps(block.input ?? {});
|
||||
lines.push(`[tool:${name}] ${codePointSlice(raw, 0, TOOL_INPUT_LIMIT)}`);
|
||||
} else if (kind === "tool_result") {
|
||||
let raw = block.content;
|
||||
if (Array.isArray(raw)) {
|
||||
raw = raw
|
||||
.filter((b) => b && typeof b === "object" && b.type === "text")
|
||||
.map((b) => b.text ?? "")
|
||||
.join(" ");
|
||||
}
|
||||
raw = pythonStrOr(raw, "").trim().replace(/\n/g, " ");
|
||||
if (raw) lines.push(`[result] ${codePointSlice(raw, 0, TOOL_RESULT_LIMIT)}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 讀取 transcript JSONL,忽略無法解析的列。 */
|
||||
function readEntries(filePath) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const entries = [];
|
||||
for (const rawLine of raw.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
entries.push(JSON.parse(line));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 找出本輪起點:最後一筆真正使用者訊息的位置。 */
|
||||
function turnStartIndex(entries) {
|
||||
let start = 0;
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
if (isRealUserMessage(entries[index])) {
|
||||
start = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
/** 解析常見 transcript timestamp 格式,回傳 epoch 毫秒;失敗回 null。 */
|
||||
function parseTimestamp(value) {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
let raw = value.trim();
|
||||
if (!/[zZ]$/.test(raw) && !/[+-]\d{2}:\d{2}$/.test(raw)) {
|
||||
raw += "Z"; // 沒有時區資訊時視為 UTC,等同 Python 版 tzinfo=timezone.utc 的退回值
|
||||
}
|
||||
const ms = Date.parse(raw);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
/** 取出 transcript 條目的時間欄位(epoch 毫秒)。 */
|
||||
function entryTimestamp(entry) {
|
||||
for (const key of ["timestamp", "created_at", "time"]) {
|
||||
const t = parseTimestamp(entry[key]);
|
||||
if (t !== null) return t;
|
||||
}
|
||||
const message = entry.message;
|
||||
if (message && typeof message === "object") {
|
||||
for (const key of ["timestamp", "created_at", "time"]) {
|
||||
const t = parseTimestamp(message[key]);
|
||||
if (t !== null) return t;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 等同 Python round():四捨五入時「剛好 .5」採銀行家捨入(就近取偶)。 */
|
||||
function pythonRound(x) {
|
||||
const floor = Math.floor(x);
|
||||
const diff = x - floor;
|
||||
if (diff < 0.5) return floor;
|
||||
if (diff > 0.5) return floor + 1;
|
||||
return floor % 2 === 0 ? floor : floor + 1;
|
||||
}
|
||||
|
||||
/** 把秒數格式化為精簡中文耗時。 */
|
||||
export function formatDuration(seconds) {
|
||||
if (seconds < 0) return "未判定";
|
||||
const minutes = pythonRound(seconds / 60);
|
||||
if (minutes <= 0) return "1 分鐘內";
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours && mins) return `${hours} 小時 ${mins} 分鐘`;
|
||||
if (hours) return `${hours} 小時`;
|
||||
return `${mins} 分鐘`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算本輪花費時間:取本輪起點到最後一筆可解析 timestamp 的差距。
|
||||
* transcript 無時間欄位或本輪少於兩個時間點時回「未判定」,避免臆測。
|
||||
*/
|
||||
export function turnDuration(filePath) {
|
||||
const entries = readEntries(filePath);
|
||||
if (!entries.length) return "未判定";
|
||||
const start = turnStartIndex(entries);
|
||||
const stamps = entries
|
||||
.slice(start)
|
||||
.map(entryTimestamp)
|
||||
.filter((t) => t !== null);
|
||||
if (stamps.length < 2) return "未判定";
|
||||
const seconds = (Math.max(...stamps) - Math.min(...stamps)) / 1000;
|
||||
return formatDuration(seconds);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 從 transcript JSONL 抽出本輪內容:最後一筆真正使用者訊息(含該筆)之後的全部條目。
|
||||
* 不需任何狀態檔即可界定「本輪」,符合工作內容不落地的要求。
|
||||
* 回傳純文字字串;讀取失敗或無內容時回空字串。
|
||||
*/
|
||||
export function extractTurn(filePath) {
|
||||
const entries = readEntries(filePath);
|
||||
if (!entries.length) return "";
|
||||
const start = turnStartIndex(entries);
|
||||
|
||||
const lines = [];
|
||||
for (const entry of entries.slice(start)) {
|
||||
lines.push(...render(entry));
|
||||
}
|
||||
|
||||
let text = lines.join("\n").trim();
|
||||
const totalLen = codePointLength(text);
|
||||
if (totalLen > TOTAL_LIMIT) {
|
||||
const half = Math.floor(TOTAL_LIMIT / 2);
|
||||
const head = codePointSlice(text, 0, half);
|
||||
const tail = codePointSlice(text, totalLen - half, totalLen);
|
||||
text = `${head}\n…(中段省略)…\n${tail}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 把 token 數格式化為精簡字串:<1000 直接輸出整數,>=1000 輸出一位小數的 k 表示。 */
|
||||
export function formatTokens(n) {
|
||||
if (n < 1000) return String(n);
|
||||
return `${(n / 1000).toFixed(1)}k`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 統計本輪 token 用量,回傳 { input, output }(input 為 null 代表「未判定」,
|
||||
* 例如 Copilot 逐輪只有 outputTokens);本輪完全找不到任何用量欄位時回 null
|
||||
* (不可回 { input: 0, output: 0 },否則無法與「真的沒用到 token」區分)。
|
||||
*
|
||||
* 三種格式互斥(依 CLI 各自的 transcript 結構,不會同時出現在同一份檔案),
|
||||
* 依序嘗試 Claude Code → Codex → Copilot,找到哪種格式的用量欄位就用哪種。
|
||||
*/
|
||||
export function turnTokens(filePath) {
|
||||
const entries = readEntries(filePath);
|
||||
if (!entries.length) return null;
|
||||
const start = turnStartIndex(entries);
|
||||
const turn = entries.slice(start);
|
||||
|
||||
// Claude Code:entry.type === "assistant",用量在 message.usage。
|
||||
// input_tokens 只是「未命中快取的殘量」,必須加上 cache_creation_input_tokens
|
||||
// 與 cache_read_input_tokens 才是完整輸入量(本機實測曾見 input_tokens:2 但
|
||||
// cache_creation_input_tokens:48200 的案例,單獨拿 input_tokens 會嚴重低估)。
|
||||
const claudeUsages = turn.filter(
|
||||
(e) => e.type === "assistant" && e.message && typeof e.message === "object" && e.message.usage && typeof e.message.usage === "object",
|
||||
);
|
||||
if (claudeUsages.length) {
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
for (const e of claudeUsages) {
|
||||
const u = e.message.usage;
|
||||
input += Number(u.input_tokens || 0) + Number(u.cache_creation_input_tokens || 0) + Number(u.cache_read_input_tokens || 0);
|
||||
output += Number(u.output_tokens || 0);
|
||||
}
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
// Codex:type === "event_msg" 且 payload.type === "token_count",用量在
|
||||
// payload.info.last_token_usage。input_tokens 已包含 cached_input_tokens,
|
||||
// 不可再加一次;reasoning_output_tokens 是 output_tokens 的子集,不另計。
|
||||
const codexUsages = turn.filter(
|
||||
(e) =>
|
||||
e.type === "event_msg" &&
|
||||
e.payload &&
|
||||
typeof e.payload === "object" &&
|
||||
e.payload.type === "token_count" &&
|
||||
e.payload.info &&
|
||||
typeof e.payload.info === "object" &&
|
||||
e.payload.info.last_token_usage,
|
||||
);
|
||||
if (codexUsages.length) {
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
for (const e of codexUsages) {
|
||||
const u = e.payload.info.last_token_usage;
|
||||
input += Number(u.input_tokens || 0);
|
||||
output += Number(u.output_tokens || 0);
|
||||
}
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
// Copilot:type === "assistant.message",只有 outputTokens,輸入固定「未判定」
|
||||
// (依使用者裁示:不得改讀 session.shutdown.modelMetrics,那是整個 session
|
||||
// 結束才寫的累計值,語意不是「本輪」)。
|
||||
const copilotUsages = turn.filter((e) => e.type === "assistant.message" && e.data && typeof e.data === "object" && typeof e.data.outputTokens === "number");
|
||||
if (copilotUsages.length) {
|
||||
let output = 0;
|
||||
for (const e of copilotUsages) output += Number(e.data.outputTokens || 0);
|
||||
return { input: null, output };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 把 turnTokens() 的結果格式化為條目用字串;找不到用量時回「未判定」。 */
|
||||
export function formatTokenLine(usage) {
|
||||
if (!usage) return "未判定";
|
||||
const inputStr = usage.input === null || usage.input === undefined ? "未判定" : formatTokens(usage.input);
|
||||
const outputStr = formatTokens(usage.output);
|
||||
return `輸入 ${inputStr}/輸出 ${outputStr}`;
|
||||
}
|
||||
|
||||
const USAGE = `用法:transcript.mjs <子命令> [參數]
|
||||
|
||||
extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
|
||||
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
|
||||
tokens <transcript 路徑> 統計本輪 token 用量(輸入/輸出),無法判定時輸出「未判定」
|
||||
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
|
||||
`;
|
||||
|
||||
function readStdin() {
|
||||
try {
|
||||
return fs.readFileSync(0, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** CLI 進入點:解析子命令並執行抽取、估時、統計 token 或遮蔽。 */
|
||||
function main(argv) {
|
||||
if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
|
||||
process.stdout.write(USAGE);
|
||||
return 0;
|
||||
}
|
||||
if (argv[0] === "extract") {
|
||||
if (argv.length < 2) return 2;
|
||||
const text = extractTurn(argv[1]);
|
||||
if (!text) return 1;
|
||||
process.stdout.write(redact(text));
|
||||
return 0;
|
||||
}
|
||||
if (argv[0] === "duration") {
|
||||
if (argv.length < 2) return 2;
|
||||
process.stdout.write(turnDuration(argv[1]));
|
||||
return 0;
|
||||
}
|
||||
if (argv[0] === "tokens") {
|
||||
if (argv.length < 2) return 2;
|
||||
process.stdout.write(formatTokenLine(turnTokens(argv[1])));
|
||||
return 0;
|
||||
}
|
||||
if (argv[0] === "redact") {
|
||||
process.stdout.write(redact(readStdin()));
|
||||
return 0;
|
||||
}
|
||||
process.stdout.write(USAGE);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# ==============================================================================
|
||||
# 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code/Codex
|
||||
# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
|
||||
# (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII),
|
||||
# 作為寫入 wiki 前的第二道防線。本檔 REDACT_PATTERNS 現為
|
||||
# /jsc-shared:spec-gitea『機密遮蔽實作』章節的來源依據(其他工具的
|
||||
# 機密遮蔽規則以該章節為準)。
|
||||
# 更新時間:2026/07/27 22:16: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
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 機密遮蔽規則:命中一律換成 ***
|
||||
# ------------------------------------------------------------------------------
|
||||
# 本檔遮蔽規則對應 shared/scripts/lib/redact-patterns.json(經 /jsc-shared:spec-gitea 收斂)
|
||||
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:]))
|
||||
@@ -0,0 +1,549 @@
|
||||
#!/usr/bin/env node
|
||||
// ==============================================================================
|
||||
// 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、
|
||||
// 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.sh
|
||||
// 與 /jsc-doc:worklog skill 共用,避免兩份實作漂移。
|
||||
// resolveToken() 的優先序實作對應 /jsc-shared:spec-gitea『token 解析
|
||||
// 優先序』章節。mask() 已與 transcript.mjs 的 REDACT_PATTERNS(對應
|
||||
// spec-gitea『機密遮蔽實作』章節)整併:精準抹除已知 secret 值後,
|
||||
// 再套用同一份通用格式規則,避免兩份遮蔽規則各自漂移。
|
||||
// 本檔為 wiki_api.py 的等價 Node 移植:page-name/week 系列日期運算
|
||||
// 已對拍 Python 版逐字元相同;probe/pages/show/append 已對本機
|
||||
// 架設的假 Gitea API 伺服器驗證行為一致(詳見 G1-3 驗收記錄),未對
|
||||
// 正式環境的真實 wiki 資料做寫入測試。
|
||||
// 更新時間:2026/08/11 16:51:56
|
||||
// 相依:Node.js 標準內建功能(node:https、node:zlib 皆不需要),無外部套件。
|
||||
// 機密:token 一律從環境變數或本機憑證檔讀取,絕不輸出、絕不寫入任何檔案。
|
||||
// ==============================================================================
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import https from "node:https";
|
||||
import { URL } from "node:url";
|
||||
import { redact as redactPatterns } from "./transcript.mjs";
|
||||
|
||||
const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
/** 取得 Asia/Taipei 時區的 yyyy/MM/dd HH:mm:ss 時間字串(等同 persona-lib.mjs 的 nowDisplay() 手法)。 */
|
||||
export function nowStr(date = new Date()) {
|
||||
const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${shifted.getUTCFullYear()}/${pad(shifted.getUTCMonth() + 1)}/${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
/** 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr 不污染 stdout。 */
|
||||
export function log(level, message, stage = "wiki_api") {
|
||||
process.stderr.write(`[${nowStr()}][${stage}][${level}]: ${message}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 遮蔽字串中的機密。先精準抹除已知的 secret 值(涵蓋不符合任何通用格式的
|
||||
* token,例如 tea 設定檔內的自訂字串),再套用 transcript.mjs 的 REDACT_PATTERNS
|
||||
* (對應 /jsc-shared:spec-gitea『機密遮蔽實作』章節,同一份規則的唯一權威來源)
|
||||
* 做第二層通用格式掃描,避免 wiki_api.mjs 自己維護一份會漂移的規則。
|
||||
*/
|
||||
export function mask(text, secret) {
|
||||
let out = text;
|
||||
if (secret) out = out.split(secret).join("***");
|
||||
return redactPatterns(out);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// token 解析:GITEA_TOKEN → tea config → git-credentials
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
function expandHome(p) {
|
||||
return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
|
||||
}
|
||||
|
||||
/** 從 tea 設定檔取出指定 host 的 token(找不到回 null)。 */
|
||||
function tokenFromTea(host) {
|
||||
for (const candidate of ["~/.config/tea/config.yml", "~/.tea/config.yml"]) {
|
||||
const f = expandHome(candidate);
|
||||
if (!fs.existsSync(f)) continue;
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(f, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const block of raw.split(/^\s*-\s+name:/m)) {
|
||||
if (!block.includes(host)) continue;
|
||||
const m = block.match(/^\s*token:\s*["']?([A-Za-z0-9_-]+)/m);
|
||||
if (m) return m[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 從 ~/.git-credentials(credential.helper=store)取出指定 host 的密碼作為 token。 */
|
||||
function tokenFromGitCredentials(host) {
|
||||
const f = expandHome("~/.git-credentials");
|
||||
if (!fs.existsSync(f)) return null;
|
||||
let lines;
|
||||
try {
|
||||
lines = fs.readFileSync(f, "utf8").split("\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const line of lines) {
|
||||
const m = line.trim().match(/^https?:\/\/([^:]+):([^@]+)@(.+)$/);
|
||||
if (m && m[3] === host) return decodeURIComponent(m[2]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依固定優先序解析可用 token,並以 GET /repos/<repo> 實際驗證權限。
|
||||
* 優先序:GITEA_TOKEN → tea 設定檔該 host 的 token → ~/.git-credentials。
|
||||
* 回傳 [token, 來源說明];全部失敗回 [null, 說明]。
|
||||
*/
|
||||
export async function resolveToken(host, repo) {
|
||||
const candidates = [];
|
||||
const env = process.env.GITEA_TOKEN;
|
||||
if (env) candidates.push([env, "GITEA_TOKEN"]);
|
||||
const tea = tokenFromTea(host);
|
||||
if (tea && tea !== env) candidates.push([tea, "tea 設定檔"]);
|
||||
const cred = tokenFromGitCredentials(host);
|
||||
if (cred && cred !== env && cred !== tea) candidates.push([cred, "git-credentials"]);
|
||||
|
||||
if (!candidates.length) return [null, "找不到任何可用憑證來源"];
|
||||
|
||||
for (const [token, source] of candidates) {
|
||||
const [code] = await request("GET", `https://${host}/api/v1/repos/${repo}`, token, null);
|
||||
if (code === 200) return [token, source];
|
||||
log("DBG", `${source} 對 ${host} 驗證失敗(HTTP ${code}),改試下一個來源`);
|
||||
}
|
||||
return [null, `${candidates.length} 個憑證來源全部驗證失敗`];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// HTTP
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 發出 Gitea API 請求,回傳 [HTTP 狀態碼, 回應內文字串]。網路層錯誤以 0 表示。
|
||||
* 本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節:
|
||||
* Authorization 標頭帶 `token <token>`、payload 以 UTF-8 JSON 編碼。
|
||||
*/
|
||||
function request(method, url, token, payload) {
|
||||
return new Promise((resolve) => {
|
||||
let data = null;
|
||||
if (payload !== null && payload !== undefined) {
|
||||
data = Buffer.from(JSON.stringify(payload), "utf8");
|
||||
}
|
||||
let target;
|
||||
try {
|
||||
target = new URL(url);
|
||||
} catch (err) {
|
||||
resolve([0, String(err.message || err)]);
|
||||
return;
|
||||
}
|
||||
const headers = {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (data) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Content-Length"] = String(data.length);
|
||||
}
|
||||
const req = https.request(
|
||||
{
|
||||
method,
|
||||
hostname: target.hostname,
|
||||
port: target.port || 443,
|
||||
path: target.pathname + target.search,
|
||||
headers,
|
||||
timeout: 30000,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
resolve([res.statusCode, Buffer.concat(chunks).toString("utf8")]);
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error("timeout"));
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
resolve([0, String(err.message || err)]);
|
||||
});
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 組出 repo 層級的 wiki API base URL。
|
||||
* 本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節:base 為
|
||||
* `https://<host>/api/v1/repos/<owner>/<repo>` 之下的 wiki 路徑。
|
||||
*/
|
||||
function apiBase(host, repo) {
|
||||
return `https://${host}/api/v1/repos/${repo}/wiki`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// wiki 操作
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 列出 wiki 全部頁面(分頁完整讀取),回傳 [狀態, 頁面清單]。
|
||||
* 狀態為 'ok'/'missing'(wiki 尚未初始化)/'error'。清單元素含 title 與 sub_url。
|
||||
*/
|
||||
export async function listPages(host, repo, token) {
|
||||
const pages = [];
|
||||
let pageNo = 1;
|
||||
const limit = 50;
|
||||
for (;;) {
|
||||
const url = `${apiBase(host, repo)}/pages?page=${pageNo}&limit=${limit}`;
|
||||
const [code, body] = await request("GET", url, token, null);
|
||||
if (code === 404) return ["missing", []];
|
||||
if (code !== 200) return ["error", []];
|
||||
let batch;
|
||||
try {
|
||||
batch = JSON.parse(body);
|
||||
} catch {
|
||||
return ["error", []];
|
||||
}
|
||||
if (!Array.isArray(batch)) return ["error", []];
|
||||
pages.push(...batch);
|
||||
if (batch.length < limit) return ["ok", pages];
|
||||
pageNo += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 title 查出 Gitea 實際的 sub_url。
|
||||
* Gitea wiki 會對 title 做轉義(`-` 代表空格,實際 dash 另有轉義形式,例如
|
||||
* title `Worklog-2026-07-W4` 的 sub_url 為 `Worklog-2026-07-W4.-`),因此讀寫
|
||||
* 一律以查表得到的 sub_url 為準,不自行猜測轉義規則。
|
||||
* 本函式實作 /jsc-shared:spec-gitea 的『Wiki 頁名轉義規則』第 2 點:
|
||||
* 以 `GET /wiki/pages` 查表找 sub_url,不用字串取代規則反推。找不到回 null。
|
||||
*/
|
||||
export async function resolveSubUrl(host, repo, token, title) {
|
||||
const [status, pages] = await listPages(host, repo, token);
|
||||
if (status !== "ok") return null;
|
||||
for (const item of pages) {
|
||||
if (item.title === title) return item.sub_url || title;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取 wiki 頁面內容(page 可傳 title 或 sub_url,內部會自動解析)。
|
||||
* 回傳 [狀態, 內容字串];狀態為 'ok'(存在)、'missing'(404)、'error'(其他失敗,
|
||||
* 內容為遮蔽後的錯誤訊息)。
|
||||
*/
|
||||
export async function getPage(host, repo, token, page) {
|
||||
const subUrl = (await resolveSubUrl(host, repo, token, page)) || page;
|
||||
const url = `${apiBase(host, repo)}/page/${encodeURIComponent(subUrl)}`;
|
||||
const [code, body] = await request("GET", url, token, null);
|
||||
if (code === 404) return ["missing", ""];
|
||||
if (code !== 200) return ["error", mask(`HTTP ${code} ${body.slice(0, 200)}`, token)];
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(body);
|
||||
} catch {
|
||||
return ["error", "回應不是合法 JSON"];
|
||||
}
|
||||
const raw = data.content_base64 || "";
|
||||
try {
|
||||
return ["ok", Buffer.from(raw, "base64").toString("utf8")];
|
||||
} catch {
|
||||
return ["error", "content_base64 解碼失敗"];
|
||||
}
|
||||
}
|
||||
|
||||
/** 建立新的 wiki 頁面(wiki 尚未初始化時亦由此初始化)。回傳 [是否成功, 訊息]。 */
|
||||
export async function createPage(host, repo, token, page, content, message) {
|
||||
const url = `${apiBase(host, repo)}/new`;
|
||||
const payload = {
|
||||
title: page,
|
||||
content_base64: Buffer.from(content, "utf8").toString("base64"),
|
||||
message,
|
||||
};
|
||||
const [code, body] = await request("POST", url, token, payload);
|
||||
if (code === 201 || code === 200) return [true, `已建立頁面 ${page}`];
|
||||
return [false, mask(`建立頁面失敗 HTTP ${code} ${body.slice(0, 200)}`, token)];
|
||||
}
|
||||
|
||||
/** 刪除 wiki 頁面(page 可傳 title 或 sub_url)。回傳 [是否成功, 訊息]。 */
|
||||
export async function deletePage(host, repo, token, page) {
|
||||
const subUrl = (await resolveSubUrl(host, repo, token, page)) || page;
|
||||
const url = `${apiBase(host, repo)}/page/${encodeURIComponent(subUrl)}`;
|
||||
const [code, body] = await request("DELETE", url, token, null);
|
||||
if (code === 204 || code === 200) return [true, `已刪除頁面 ${page}`];
|
||||
return [false, mask(`刪除頁面失敗 HTTP ${code} ${body.slice(0, 200)}`, token)];
|
||||
}
|
||||
|
||||
/** 整頁覆寫既有 wiki 頁面(append 由呼叫端先合併內容)。回傳 [是否成功, 訊息]。 */
|
||||
export async function updatePage(host, repo, token, page, content, message) {
|
||||
const subUrl = (await resolveSubUrl(host, repo, token, page)) || page;
|
||||
const url = `${apiBase(host, repo)}/page/${encodeURIComponent(subUrl)}`;
|
||||
const payload = {
|
||||
title: page,
|
||||
content_base64: Buffer.from(content, "utf8").toString("base64"),
|
||||
message,
|
||||
};
|
||||
const [code, body] = await request("PATCH", url, token, payload);
|
||||
if (code === 200 || code === 201) return [true, `已更新頁面 ${page}`];
|
||||
return [false, mask(`更新頁面失敗 HTTP ${code} ${body.slice(0, 200)}`, token)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 將條目追加到週頁尾端:讀取現有內容 → 合併 → 寫回 → 寫後讀取驗證。
|
||||
* marker 為條目內唯一字串(時間戳+session 短碼),用於驗證自己的內容確實落地;
|
||||
* 多個 session 同時寫入時,驗證失敗會重讀最新內容重試,避免互相覆蓋。
|
||||
* 回傳 [是否成功, 訊息]。
|
||||
*/
|
||||
export async function appendEntry(host, repo, token, page, header, entry, marker, retries = 3) {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
const [status, current] = await getPage(host, repo, token, page);
|
||||
if (status === "error") return [false, `讀取頁面失敗:${current}`];
|
||||
|
||||
if (status === "missing") {
|
||||
const content = `${header}\n\n${entry}\n`;
|
||||
const [ok, msg] = await createPage(host, repo, token, page, content, `worklog: 建立 ${page}`);
|
||||
if (!ok) {
|
||||
log("WRN", `第 ${attempt} 次建立失敗:${msg}`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (current.includes(marker)) return [true, "條目已存在,無需重複寫入"];
|
||||
let body = current.replace(/\n+$/, "");
|
||||
if (!body) body = header;
|
||||
const content = `${body}\n\n${entry}\n`;
|
||||
const [ok, msg] = await updatePage(host, repo, token, page, content, `worklog: 追加 ${marker}`);
|
||||
if (!ok) {
|
||||
log("WRN", `第 ${attempt} 次寫入失敗:${msg}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const [verifyStatus, verifyContent] = await getPage(host, repo, token, page);
|
||||
if (verifyStatus === "ok" && verifyContent.includes(marker)) {
|
||||
return [true, `條目已寫入 ${page}(第 ${attempt} 次嘗試)`];
|
||||
}
|
||||
log("WRN", `第 ${attempt} 次寫後驗證未找到條目,準備重試`);
|
||||
}
|
||||
|
||||
return [false, `重試 ${retries} 次仍未成功寫入 ${page}`];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// 週頁命名
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
// 週的定義:星期六起算(六~五),週頁以該週起始的星期六為錨點命名。
|
||||
// 舊規則以 ceil(日/7) 分週,換頁點固定落在每月 8/15/22/29 號,會把同一個工作週
|
||||
// 切成兩頁(例:2026/07/28 二 在 W4、07/29 三 卻跳到 W5),使用者開著舊頁會誤判成
|
||||
// 「worklog 停止記錄」。改以星期六為界後,換頁一律發生在週六,與星期對齊。
|
||||
const WEEK_START_WEEKDAY = 6; // JS Date#getUTCDay():週日 0、週一 1 …… 週五 5、週六 6
|
||||
|
||||
/** 依台北時區「牆上時間」建立一個可用 UTC getter 讀取的 Date(等同 persona-lib.mjs 的位移手法)。 */
|
||||
function taipeiWallClock(date = new Date()) {
|
||||
return new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS);
|
||||
}
|
||||
|
||||
/** 把「台北牆上時間」的 Date 換回真實 epoch(taipeiWallClock 的逆運算)。 */
|
||||
function fromTaipeiWallClock(wall) {
|
||||
return new Date(wall.getTime() - TAIPEI_OFFSET_MS);
|
||||
}
|
||||
|
||||
/** 取得指定時間所屬工作週的起始日(該週的星期六;當天就是星期六時回傳當天),回傳台北牆上時間 Date。 */
|
||||
export function weekStart(when) {
|
||||
const wall = when ? taipeiWallClock(when) : taipeiWallClock();
|
||||
const diff = (wall.getUTCDay() - WEEK_START_WEEKDAY + 7) % 7;
|
||||
const start = new Date(wall);
|
||||
start.setUTCDate(start.getUTCDate() - diff);
|
||||
start.setUTCHours(0, 0, 0, 0);
|
||||
return start;
|
||||
}
|
||||
|
||||
/**
|
||||
* 從週頁名稱反推該週起始的星期六(回傳台北牆上時間 Date)。
|
||||
* 供手動指定頁面時產生正確標題;格式不符或該月不存在第 n 個星期六時回傳 null。
|
||||
*/
|
||||
export function weekStartFromPage(page) {
|
||||
if (!page) return null;
|
||||
const m = String(page).trim().match(/^Worklog-(\d{4})-(\d{2})-W(\d)$/);
|
||||
if (!m) return null;
|
||||
const year = Number(m[1]);
|
||||
const month = Number(m[2]);
|
||||
const week = Number(m[3]);
|
||||
const firstDay = new Date(Date.UTC(year, month - 1, 1));
|
||||
const diff = (WEEK_START_WEEKDAY - firstDay.getUTCDay() + 7) % 7;
|
||||
const firstSaturday = new Date(firstDay);
|
||||
firstSaturday.setUTCDate(firstSaturday.getUTCDate() + diff);
|
||||
const start = new Date(firstSaturday);
|
||||
start.setUTCDate(start.getUTCDate() + 7 * (week - 1));
|
||||
return start.getUTCMonth() === month - 1 ? start : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依台灣時區產生週頁名稱 Worklog-yyyy-MM-W<n>。
|
||||
* 週以星期六起算(六~五),n =該週起始的星期六是當月第幾個星期六。
|
||||
* 跨月的一週歸屬起始星期六所在的月份,確保同一週只會有一頁
|
||||
* (例:2026/08/29 六 ~ 09/04 五 都寫入 Worklog-2026-08-W5)。
|
||||
*/
|
||||
export function weekPageName(when) {
|
||||
const start = weekStart(when);
|
||||
const week = Math.floor((start.getUTCDate() - 1) / 7) + 1;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `Worklog-${start.getUTCFullYear()}-${pad(start.getUTCMonth() + 1)}-W${week}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生週頁首行標題(例:# 2026 年 07 月 第 4 週工作紀錄(07/25 六 ~ 07/31 五))。
|
||||
* 標題含日期範圍,讓開頁的人一眼看出這頁涵蓋哪幾天,不必回頭推算週次。
|
||||
* 傳入 page 時以頁名反推所屬週,避免手動補寫舊頁時寫入當下這週的標題。
|
||||
*/
|
||||
export function weekPageHeader(page, when) {
|
||||
const start = weekStartFromPage(page) || weekStart(when);
|
||||
const end = new Date(start);
|
||||
end.setUTCDate(end.getUTCDate() + 6);
|
||||
const week = Math.floor((start.getUTCDate() - 1) / 7) + 1;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`# ${start.getUTCFullYear()} 年 ${pad(start.getUTCMonth() + 1)} 月 第 ${week} 週工作紀錄` +
|
||||
`(${pad(start.getUTCMonth() + 1)}/${pad(start.getUTCDate())} 六 ~ ${pad(end.getUTCMonth() + 1)}/${pad(end.getUTCDate())} 五)`
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
const USAGE = `用法:wiki_api.mjs <子命令> [參數]
|
||||
|
||||
probe 檢查 host/repo/token/wiki API 可用性
|
||||
page-name 印出當週頁面名稱
|
||||
pages 列出全部頁面(title 與實際 sub_url)
|
||||
show [頁面] 印出指定頁面內容(預設當週頁)
|
||||
append <marker> [頁面] 自 stdin 讀取條目內容並追加(預設當週頁)
|
||||
init [頁面] 若當週頁不存在則建立(僅含標題)
|
||||
delete <頁面> 刪除指定頁面
|
||||
|
||||
環境變數:WORKLOG_HOST(必要)、WORKLOG_REPO(必要)、GITEA_TOKEN(選用,會自動 fallback)
|
||||
`;
|
||||
|
||||
function readStdin() {
|
||||
try {
|
||||
return fs.readFileSync(0, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 讀取並檢查必要環境變數,回傳 [host, repo];缺少時結束程式。 */
|
||||
function readEnv() {
|
||||
const host = (process.env.WORKLOG_HOST || "").trim();
|
||||
const repo = (process.env.WORKLOG_REPO || "").trim();
|
||||
if (!host || !repo) {
|
||||
log("ERR", "缺少 WORKLOG_HOST 或 WORKLOG_REPO");
|
||||
process.exit(2);
|
||||
}
|
||||
return [host, repo];
|
||||
}
|
||||
|
||||
/** CLI 進入點:解析子命令並執行對應 wiki 操作。 */
|
||||
async function main(argv) {
|
||||
if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
|
||||
process.stdout.write(USAGE);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cmd = argv[0];
|
||||
|
||||
if (cmd === "page-name") {
|
||||
process.stdout.write(`${weekPageName()}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [host, repo] = readEnv();
|
||||
const [token, source] = await resolveToken(host, repo);
|
||||
if (!token) {
|
||||
log("ERR", `無可用 token:${source}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (cmd === "probe") {
|
||||
log("INF", `token 來源:${source}`);
|
||||
const [code, body] = await request("GET", `https://${host}/api/v1/version`, token, null);
|
||||
log("INF", `Gitea 版本查詢 HTTP ${code} ${body.slice(0, 80)}`);
|
||||
const [status] = await getPage(host, repo, token, weekPageName());
|
||||
log("INF", `當週頁 ${weekPageName()} 狀態:${status}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (cmd === "pages") {
|
||||
const [status, pages] = await listPages(host, repo, token);
|
||||
if (status !== "ok") {
|
||||
log(status === "missing" ? "WRN" : "ERR", `頁面清單狀態:${status}`);
|
||||
return status === "missing" ? 0 : 1;
|
||||
}
|
||||
for (const item of pages) process.stdout.write(`${item.title}\t${item.sub_url}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (cmd === "delete") {
|
||||
if (argv.length < 2) {
|
||||
log("ERR", "delete 需要頁面名稱");
|
||||
return 2;
|
||||
}
|
||||
const [ok, msg] = await deletePage(host, repo, token, argv[1]);
|
||||
log(ok ? "INF" : "ERR", msg);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
if (cmd === "show") {
|
||||
const page = argv.length > 1 ? argv[1] : weekPageName();
|
||||
const [status, content] = await getPage(host, repo, token, page);
|
||||
if (status === "ok") {
|
||||
process.stdout.write(`${content}\n`);
|
||||
return 0;
|
||||
}
|
||||
log(status === "missing" ? "WRN" : "ERR", `頁面 ${page} 狀態:${status} ${content}`);
|
||||
return status === "missing" ? 0 : 1;
|
||||
}
|
||||
|
||||
if (cmd === "init") {
|
||||
const page = argv.length > 1 ? argv[1] : weekPageName();
|
||||
const [status] = await getPage(host, repo, token, page);
|
||||
if (status === "ok") {
|
||||
log("INF", `頁面 ${page} 已存在,不重建`);
|
||||
return 0;
|
||||
}
|
||||
const [ok, msg] = await createPage(host, repo, token, page, `${weekPageHeader(page)}\n`, `worklog: 初始化 ${page}`);
|
||||
log(ok ? "INF" : "ERR", msg);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
if (cmd === "append") {
|
||||
if (argv.length < 2) {
|
||||
log("ERR", "append 需要 marker 參數");
|
||||
return 2;
|
||||
}
|
||||
const marker = argv[1];
|
||||
const page = argv.length > 2 ? argv[2] : weekPageName();
|
||||
const entry = readStdin().trim();
|
||||
if (!entry) {
|
||||
log("WRN", "條目內容為空,不寫入");
|
||||
return 0;
|
||||
}
|
||||
const [ok, msg] = await appendEntry(host, repo, token, page, weekPageHeader(page), entry, marker);
|
||||
log(ok ? "INF" : "ERR", msg);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
log("ERR", `未知子命令:${cmd}`);
|
||||
process.stdout.write(USAGE);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main(process.argv.slice(2)).then((code) => process.exit(code));
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# ==============================================================================
|
||||
# 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、
|
||||
# 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.sh
|
||||
# 與 /jsc-doc:worklog skill 共用,避免兩份實作漂移。
|
||||
# resolve_token() 的優先序實作對應 /jsc-shared:spec-gitea『token 解析
|
||||
# 優先序』章節。
|
||||
# 更新時間:2026/07/29 19:03:29
|
||||
# 相依:Python 3 標準庫(urllib、base64、json、re)。不需 requests、不需 jq。
|
||||
# 機密:token 一律從環境變數或本機憑證檔讀取,絕不輸出、絕不寫入任何檔案。
|
||||
# ==============================================================================
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TAIPEI = ZoneInfo("Asia/Taipei")
|
||||
|
||||
|
||||
def _ssl_context():
|
||||
"""
|
||||
建立 TLS 連線設定:維持完整憑證鏈驗證,僅關閉 VERIFY_X509_STRICT。
|
||||
|
||||
Python 3.13 起預設啟用 X509 嚴格檢查,內部 CA 憑證若缺少 Subject Key
|
||||
Identifier 會被拒絕(curl 不做此檢查,故 curl 可連而 Python 不行)。
|
||||
此處只放寬擴充欄位的嚴格檢查,主機名稱與憑證鏈驗證仍完整保留。
|
||||
"""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT
|
||||
return ctx
|
||||
|
||||
|
||||
SSL_CONTEXT = _ssl_context()
|
||||
|
||||
|
||||
def now_str():
|
||||
"""取得台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串。"""
|
||||
return datetime.now(TAIPEI).strftime("%Y/%m/%d %H:%M:%S")
|
||||
|
||||
|
||||
def log(level, message, stage="wiki_api"):
|
||||
"""輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr 不污染 stdout。"""
|
||||
print(f"[{now_str()}][{stage}][{level}]: {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def mask(text, secret):
|
||||
"""將字串中的 secret 遮蔽為 ***,避免 token 洩漏到輸出。"""
|
||||
if not secret:
|
||||
return text
|
||||
return text.replace(secret, "***")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# token 解析:GITEA_TOKEN → tea config → git-credentials
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
def _token_from_tea(host):
|
||||
"""從 tea 設定檔取出指定 host 的 token(找不到回 None)。"""
|
||||
for path in ("~/.config/tea/config.yml", "~/.tea/config.yml"):
|
||||
f = os.path.expanduser(path)
|
||||
if not os.path.isfile(f):
|
||||
continue
|
||||
try:
|
||||
raw = open(f, encoding="utf-8").read()
|
||||
except OSError:
|
||||
continue
|
||||
for block in re.split(r"(?m)^\s*-\s+name:", raw):
|
||||
if host not in block:
|
||||
continue
|
||||
m = re.search(r"(?m)^\s*token:\s*[\"']?([A-Za-z0-9_\-]+)", block)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _token_from_git_credentials(host):
|
||||
"""從 ~/.git-credentials(credential.helper=store)取出指定 host 的密碼作為 token。"""
|
||||
f = os.path.expanduser("~/.git-credentials")
|
||||
if not os.path.isfile(f):
|
||||
return None
|
||||
try:
|
||||
lines = open(f, encoding="utf-8").read().splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
for line in lines:
|
||||
m = re.match(r"https?://([^:]+):([^@]+)@(.+)$", line.strip())
|
||||
if m and m.group(3) == host:
|
||||
return urllib.parse.unquote(m.group(2))
|
||||
return None
|
||||
|
||||
|
||||
def resolve_token(host, repo):
|
||||
"""
|
||||
依固定優先序解析可用 token,並以 GET /repos/<repo> 實際驗證權限。
|
||||
|
||||
優先序:GITEA_TOKEN → tea 設定檔該 host 的 token → ~/.git-credentials。
|
||||
回傳 (token, 來源說明);全部失敗回 (None, 說明)。
|
||||
"""
|
||||
candidates = []
|
||||
env = os.environ.get("GITEA_TOKEN")
|
||||
if env:
|
||||
candidates.append((env, "GITEA_TOKEN"))
|
||||
tea = _token_from_tea(host)
|
||||
if tea and tea != env:
|
||||
candidates.append((tea, "tea 設定檔"))
|
||||
cred = _token_from_git_credentials(host)
|
||||
if cred and cred not in (env, tea):
|
||||
candidates.append((cred, "git-credentials"))
|
||||
|
||||
if not candidates:
|
||||
return None, "找不到任何可用憑證來源"
|
||||
|
||||
for token, source in candidates:
|
||||
code, _ = _request("GET", f"https://{host}/api/v1/repos/{repo}", token, None)
|
||||
if code == 200:
|
||||
return token, source
|
||||
log("DBG", f"{source} 對 {host} 驗證失敗(HTTP {code}),改試下一個來源")
|
||||
return None, f"{len(candidates)} 個憑證來源全部驗證失敗"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
def _request(method, url, token, payload):
|
||||
"""
|
||||
發出 Gitea API 請求,回傳 (HTTP 狀態碼, 回應內文字串)。網路層錯誤以 0 表示。
|
||||
|
||||
本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節:
|
||||
Authorization 標頭帶 `token <token>`、payload 以 UTF-8 JSON 編碼。
|
||||
"""
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
req.add_header("Accept", "application/json")
|
||||
if data:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30, context=SSL_CONTEXT) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "replace")
|
||||
except Exception as e: # 網路錯誤、逾時
|
||||
return 0, str(e)
|
||||
|
||||
|
||||
def _api_base(host, repo):
|
||||
"""
|
||||
組出 repo 層級的 wiki API base URL。
|
||||
|
||||
本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節:base 為
|
||||
`https://<host>/api/v1/repos/<owner>/<repo>` 之下的 wiki 路徑。
|
||||
"""
|
||||
return f"https://{host}/api/v1/repos/{repo}/wiki"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# wiki 操作
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
def list_pages(host, repo, token):
|
||||
"""
|
||||
列出 wiki 全部頁面(分頁完整讀取),回傳 (狀態, 頁面清單)。
|
||||
|
||||
狀態為 'ok'/'missing'(wiki 尚未初始化)/'error'。清單元素含 title 與 sub_url。
|
||||
"""
|
||||
pages = []
|
||||
page_no = 1
|
||||
limit = 50
|
||||
while True:
|
||||
url = f"{_api_base(host, repo)}/pages?page={page_no}&limit={limit}"
|
||||
code, body = _request("GET", url, token, None)
|
||||
if code == 404:
|
||||
return "missing", []
|
||||
if code != 200:
|
||||
return "error", []
|
||||
try:
|
||||
batch = json.loads(body)
|
||||
except ValueError:
|
||||
return "error", []
|
||||
if not isinstance(batch, list):
|
||||
return "error", []
|
||||
pages.extend(batch)
|
||||
if len(batch) < limit:
|
||||
return "ok", pages
|
||||
page_no += 1
|
||||
|
||||
|
||||
def resolve_sub_url(host, repo, token, title):
|
||||
"""
|
||||
以 title 查出 Gitea 實際的 sub_url。
|
||||
|
||||
Gitea wiki 會對 title 做轉義(`-` 代表空格,實際 dash 另有轉義形式,例如
|
||||
title `Worklog-2026-07-W4` 的 sub_url 為 `Worklog-2026-07-W4.-`),因此讀寫
|
||||
一律以查表得到的 sub_url 為準,不自行猜測轉義規則。
|
||||
|
||||
本函式實作 /jsc-shared:spec-gitea 的『Wiki 頁名轉義規則』第 2 點:
|
||||
以 `GET /wiki/pages` 查表找 sub_url,不用字串取代規則反推。
|
||||
找不到回 None。
|
||||
"""
|
||||
status, pages = list_pages(host, repo, token)
|
||||
if status != "ok":
|
||||
return None
|
||||
for item in pages:
|
||||
if item.get("title") == title:
|
||||
return item.get("sub_url") or title
|
||||
return None
|
||||
|
||||
|
||||
def get_page(host, repo, token, page):
|
||||
"""
|
||||
讀取 wiki 頁面內容(page 可傳 title 或 sub_url,內部會自動解析)。
|
||||
|
||||
回傳 (狀態, 內容字串);狀態為 'ok'(存在)、'missing'(404,頁面或 wiki 尚未建立)、
|
||||
'error'(其他失敗,內容為遮蔽後的錯誤訊息)。
|
||||
"""
|
||||
sub_url = resolve_sub_url(host, repo, token, page) or page
|
||||
url = f"{_api_base(host, repo)}/page/{urllib.parse.quote(sub_url)}"
|
||||
code, body = _request("GET", url, token, None)
|
||||
if code == 404:
|
||||
return "missing", ""
|
||||
if code != 200:
|
||||
return "error", mask(f"HTTP {code} {body[:200]}", token)
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
return "error", "回應不是合法 JSON"
|
||||
raw = data.get("content_base64") or ""
|
||||
try:
|
||||
return "ok", base64.b64decode(raw).decode("utf-8", "replace")
|
||||
except Exception:
|
||||
return "error", "content_base64 解碼失敗"
|
||||
|
||||
|
||||
def create_page(host, repo, token, page, content, message):
|
||||
"""建立新的 wiki 頁面(wiki 尚未初始化時亦由此初始化)。回傳 (是否成功, 訊息)。"""
|
||||
url = f"{_api_base(host, repo)}/new"
|
||||
payload = {
|
||||
"title": page,
|
||||
"content_base64": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||
"message": message,
|
||||
}
|
||||
code, body = _request("POST", url, token, payload)
|
||||
if code in (201, 200):
|
||||
return True, f"已建立頁面 {page}"
|
||||
return False, mask(f"建立頁面失敗 HTTP {code} {body[:200]}", token)
|
||||
|
||||
|
||||
def delete_page(host, repo, token, page):
|
||||
"""刪除 wiki 頁面(page 可傳 title 或 sub_url)。回傳 (是否成功, 訊息)。"""
|
||||
sub_url = resolve_sub_url(host, repo, token, page) or page
|
||||
url = f"{_api_base(host, repo)}/page/{urllib.parse.quote(sub_url)}"
|
||||
code, body = _request("DELETE", url, token, None)
|
||||
if code in (204, 200):
|
||||
return True, f"已刪除頁面 {page}"
|
||||
return False, mask(f"刪除頁面失敗 HTTP {code} {body[:200]}", token)
|
||||
|
||||
|
||||
def update_page(host, repo, token, page, content, message):
|
||||
"""整頁覆寫既有 wiki 頁面(append 由呼叫端先合併內容)。回傳 (是否成功, 訊息)。"""
|
||||
sub_url = resolve_sub_url(host, repo, token, page) or page
|
||||
url = f"{_api_base(host, repo)}/page/{urllib.parse.quote(sub_url)}"
|
||||
payload = {
|
||||
"title": page,
|
||||
"content_base64": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||
"message": message,
|
||||
}
|
||||
code, body = _request("PATCH", url, token, payload)
|
||||
if code in (200, 201):
|
||||
return True, f"已更新頁面 {page}"
|
||||
return False, mask(f"更新頁面失敗 HTTP {code} {body[:200]}", token)
|
||||
|
||||
|
||||
def append_entry(host, repo, token, page, header, entry, marker, retries=3):
|
||||
"""
|
||||
將條目追加到週頁尾端:讀取現有內容 → 合併 → 寫回 → 寫後讀取驗證。
|
||||
|
||||
marker 為條目內唯一字串(時間戳+session 短碼),用於驗證自己的內容確實落地;
|
||||
多個 session 同時寫入時,驗證失敗會重讀最新內容重試,避免互相覆蓋。
|
||||
回傳 (是否成功, 訊息)。
|
||||
"""
|
||||
for attempt in range(1, retries + 1):
|
||||
status, current = get_page(host, repo, token, page)
|
||||
if status == "error":
|
||||
return False, f"讀取頁面失敗:{current}"
|
||||
|
||||
if status == "missing":
|
||||
content = f"{header}\n\n{entry}\n"
|
||||
ok, msg = create_page(host, repo, token, page, content, f"worklog: 建立 {page}")
|
||||
if not ok:
|
||||
# wiki 已存在但頁面不存在時,建立可能失敗;下一輪改走更新
|
||||
log("WRN", f"第 {attempt} 次建立失敗:{msg}")
|
||||
continue
|
||||
else:
|
||||
if marker in current:
|
||||
return True, "條目已存在,無需重複寫入"
|
||||
body = current.rstrip("\n")
|
||||
if not body:
|
||||
body = header
|
||||
content = f"{body}\n\n{entry}\n"
|
||||
ok, msg = update_page(host, repo, token, page, content, f"worklog: 追加 {marker}")
|
||||
if not ok:
|
||||
log("WRN", f"第 {attempt} 次寫入失敗:{msg}")
|
||||
continue
|
||||
|
||||
verify_status, verify_content = get_page(host, repo, token, page)
|
||||
if verify_status == "ok" and marker in verify_content:
|
||||
return True, f"條目已寫入 {page}(第 {attempt} 次嘗試)"
|
||||
log("WRN", f"第 {attempt} 次寫後驗證未找到條目,準備重試")
|
||||
|
||||
return False, f"重試 {retries} 次仍未成功寫入 {page}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 週頁命名
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# 週的定義:星期六起算(六~五),週頁以該週起始的星期六為錨點命名。
|
||||
# 舊規則以 ceil(日/7) 分週,換頁點固定落在每月 8/15/22/29 號,會把同一個工作週
|
||||
# 切成兩頁(例:2026/07/28 二 在 W4、07/29 三 卻跳到 W5),使用者開著舊頁會誤判成
|
||||
# 「worklog 停止記錄」。改以星期六為界後,換頁一律發生在週六,與星期對齊。
|
||||
WEEK_START_WEEKDAY = 5 # Python weekday():週一 0、週二 1 …… 週六 5、週日 6
|
||||
|
||||
|
||||
def week_start(when=None):
|
||||
"""取得指定時間所屬工作週的起始日(該週的星期六;當天就是星期六時回傳當天)。"""
|
||||
when = when or datetime.now(TAIPEI)
|
||||
return when - timedelta(days=(when.weekday() - WEEK_START_WEEKDAY) % 7)
|
||||
|
||||
|
||||
def week_start_from_page(page):
|
||||
"""
|
||||
從週頁名稱反推該週起始的星期六。
|
||||
|
||||
供手動指定頁面時產生正確標題;格式不符或該月不存在第 n 個星期六時回傳 None。
|
||||
"""
|
||||
if not page:
|
||||
return None
|
||||
matched = re.match(r"^Worklog-(\d{4})-(\d{2})-W(\d)$", page.strip())
|
||||
if not matched:
|
||||
return None
|
||||
year, month, week = (int(matched.group(i)) for i in (1, 2, 3))
|
||||
try:
|
||||
first_day = datetime(year, month, 1, tzinfo=TAIPEI)
|
||||
except ValueError:
|
||||
return None
|
||||
first_saturday = first_day + timedelta(days=(WEEK_START_WEEKDAY - first_day.weekday()) % 7)
|
||||
start = first_saturday + timedelta(days=7 * (week - 1))
|
||||
return start if start.month == month else None
|
||||
|
||||
|
||||
def week_page_name(when=None):
|
||||
"""
|
||||
依台灣時區產生週頁名稱 Worklog-yyyy-MM-W<n>。
|
||||
|
||||
週以星期六起算(六~五),n =該週起始的星期六是當月第幾個星期六。
|
||||
跨月的一週歸屬起始星期六所在的月份,確保同一週只會有一頁
|
||||
(例:2026/08/29 六 ~ 09/04 五 都寫入 Worklog-2026-08-W5)。
|
||||
"""
|
||||
start = week_start(when)
|
||||
week = (start.day - 1) // 7 + 1
|
||||
return f"Worklog-{start.year:04d}-{start.month:02d}-W{week}"
|
||||
|
||||
|
||||
def week_page_header(page=None, when=None):
|
||||
"""
|
||||
產生週頁首行標題(例:# 2026 年 07 月 第 4 週工作紀錄(07/25 六 ~ 07/31 五))。
|
||||
|
||||
標題含日期範圍,讓開頁的人一眼看出這頁涵蓋哪幾天,不必回頭推算週次。
|
||||
傳入 page 時以頁名反推所屬週,避免手動補寫舊頁時寫入當下這週的標題。
|
||||
"""
|
||||
start = week_start_from_page(page) or week_start(when)
|
||||
end = start + timedelta(days=6)
|
||||
week = (start.day - 1) // 7 + 1
|
||||
return (
|
||||
f"# {start.year} 年 {start.month:02d} 月 第 {week} 週工作紀錄"
|
||||
f"({start.month:02d}/{start.day:02d} 六 ~ {end.month:02d}/{end.day:02d} 五)"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
USAGE = """用法:wiki_api.py <子命令> [參數]
|
||||
|
||||
probe 檢查 host/repo/token/wiki API 可用性
|
||||
page-name 印出當週頁面名稱
|
||||
pages 列出全部頁面(title 與實際 sub_url)
|
||||
show [頁面] 印出指定頁面內容(預設當週頁)
|
||||
append <marker> [頁面] 自 stdin 讀取條目內容並追加(預設當週頁)
|
||||
init [頁面] 若當週頁不存在則建立(僅含標題)
|
||||
delete <頁面> 刪除指定頁面
|
||||
|
||||
環境變數:WORKLOG_HOST(必要)、WORKLOG_REPO(必要)、GITEA_TOKEN(選用,會自動 fallback)
|
||||
"""
|
||||
|
||||
|
||||
def _env():
|
||||
"""讀取並檢查必要環境變數,回傳 (host, repo);缺少時結束程式。"""
|
||||
host = os.environ.get("WORKLOG_HOST", "").strip()
|
||||
repo = os.environ.get("WORKLOG_REPO", "").strip()
|
||||
if not host or not repo:
|
||||
log("ERR", "缺少 WORKLOG_HOST 或 WORKLOG_REPO")
|
||||
sys.exit(2)
|
||||
return host, repo
|
||||
|
||||
|
||||
def main(argv):
|
||||
"""CLI 進入點:解析子命令並執行對應 wiki 操作。"""
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(USAGE)
|
||||
return 0
|
||||
|
||||
cmd = argv[0]
|
||||
|
||||
if cmd == "page-name":
|
||||
print(week_page_name())
|
||||
return 0
|
||||
|
||||
host, repo = _env()
|
||||
token, source = resolve_token(host, repo)
|
||||
if not token:
|
||||
log("ERR", f"無可用 token:{source}")
|
||||
return 2
|
||||
|
||||
if cmd == "probe":
|
||||
log("INF", f"token 來源:{source}")
|
||||
code, body = _request("GET", f"https://{host}/api/v1/version", token, None)
|
||||
log("INF", f"Gitea 版本查詢 HTTP {code} {body[:80]}")
|
||||
status, _ = get_page(host, repo, token, week_page_name())
|
||||
log("INF", f"當週頁 {week_page_name()} 狀態:{status}")
|
||||
return 0
|
||||
|
||||
if cmd == "pages":
|
||||
status, pages = list_pages(host, repo, token)
|
||||
if status != "ok":
|
||||
log("WRN" if status == "missing" else "ERR", f"頁面清單狀態:{status}")
|
||||
return 0 if status == "missing" else 1
|
||||
for item in pages:
|
||||
print(f"{item.get('title')}\t{item.get('sub_url')}")
|
||||
return 0
|
||||
|
||||
if cmd == "delete":
|
||||
if len(argv) < 2:
|
||||
log("ERR", "delete 需要頁面名稱")
|
||||
return 2
|
||||
ok, msg = delete_page(host, repo, token, argv[1])
|
||||
log("INF" if ok else "ERR", msg)
|
||||
return 0 if ok else 1
|
||||
|
||||
if cmd == "show":
|
||||
page = argv[1] if len(argv) > 1 else week_page_name()
|
||||
status, content = get_page(host, repo, token, page)
|
||||
if status == "ok":
|
||||
print(content)
|
||||
return 0
|
||||
log("WRN" if status == "missing" else "ERR", f"頁面 {page} 狀態:{status} {content}")
|
||||
return 0 if status == "missing" else 1
|
||||
|
||||
if cmd == "init":
|
||||
page = argv[1] if len(argv) > 1 else week_page_name()
|
||||
status, _ = get_page(host, repo, token, page)
|
||||
if status == "ok":
|
||||
log("INF", f"頁面 {page} 已存在,不重建")
|
||||
return 0
|
||||
ok, msg = create_page(host, repo, token, page, week_page_header(page) + "\n", f"worklog: 初始化 {page}")
|
||||
log("INF" if ok else "ERR", msg)
|
||||
return 0 if ok else 1
|
||||
|
||||
if cmd == "append":
|
||||
if len(argv) < 2:
|
||||
log("ERR", "append 需要 marker 參數")
|
||||
return 2
|
||||
marker = argv[1]
|
||||
page = argv[2] if len(argv) > 2 else week_page_name()
|
||||
entry = sys.stdin.read().strip()
|
||||
if not entry:
|
||||
log("WRN", "條目內容為空,不寫入")
|
||||
return 0
|
||||
ok, msg = append_entry(host, repo, token, page, week_page_header(page), entry, marker)
|
||||
log("INF" if ok else "ERR", msg)
|
||||
return 0 if ok else 1
|
||||
|
||||
log("ERR", f"未知子命令:{cmd}")
|
||||
print(USAGE)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+33
-23
@@ -3,8 +3,8 @@
|
||||
# 用途:工作證明自動記錄(worklog)。由支援 hook 的 CLI 觸發,
|
||||
# 抽出本輪工作內容 → 呼叫已安裝 CLI 濃縮成精簡條目 → 機密遮蔽 →
|
||||
# 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。
|
||||
# 更新時間:2026/07/27 17:27:54
|
||||
# 相依:python3、README 定義的任一 headless CLI、curl(wiki 走 Python urllib,不需 curl 亦可)。
|
||||
# 更新時間:2026/08/11 16:51:56
|
||||
# 相依:node(transcript.mjs/wiki_api.mjs)、README 定義的任一 headless CLI。
|
||||
# 機密:token 僅由環境變數/本機憑證讀取,不 echo、不寫檔;輸出前套用遮蔽規則。
|
||||
# 退出碼:一律 0 —— hook 絕不可阻斷使用者的工作流程。
|
||||
# ==============================================================================
|
||||
@@ -48,7 +48,7 @@ die_quiet() {
|
||||
[ -n "${WORKLOG_HOST:-}" ] || die_quiet "未設定 WORKLOG_HOST,略過記錄" "WRN"
|
||||
[ -n "${WORKLOG_REPO:-}" ] || die_quiet "未設定 WORKLOG_REPO,略過記錄" "WRN"
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || die_quiet "找不到 python3,略過記錄" "WRN"
|
||||
command -v node >/dev/null 2>&1 || die_quiet "找不到 node,略過記錄" "WRN"
|
||||
|
||||
select_worklog_cli() {
|
||||
# 依目前 hook/session 環境優先選擇摘要執行器;可用 WORKLOG_CLI 強制指定。
|
||||
@@ -130,29 +130,34 @@ SUMMARY_CLI="$(select_worklog_cli)"
|
||||
[ -n "$SUMMARY_CLI" ] || exit 0
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 讀取 hook 傳入的 JSON(session_id/transcript_path/cwd/stop_hook_active)
|
||||
# 讀取 hook 傳入的 JSON。欄位名稱大小寫依助理而異:Claude Code/Codex 用
|
||||
# snake_case(session_id/transcript_path),Copilot 的 agentStop 事件用
|
||||
# camelCase(sessionId/transcriptPath,實測見 H1-1),只有 stop_hook_active
|
||||
# 剛好三家都是 snake_case。兩種寫法都要接,缺一個 Copilot 就完全取不到值。
|
||||
# ------------------------------------------------------------------------------
|
||||
HOOK_INPUT="$(cat)"
|
||||
[ -n "$HOOK_INPUT" ] || die_quiet "hook 輸入為空,略過記錄" "WRN"
|
||||
|
||||
read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD <<EOF_HOOK
|
||||
$(printf '%s' "$HOOK_INPUT" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except ValueError:
|
||||
d = {}
|
||||
print(
|
||||
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 "-",
|
||||
)
|
||||
$(printf '%s' "$HOOK_INPUT" | node -e '
|
||||
let raw = "";
|
||||
process.stdin.on("data", (c) => { raw += c; });
|
||||
process.stdin.on("end", () => {
|
||||
let d = {};
|
||||
try { d = JSON.parse(raw); } catch { d = {}; }
|
||||
const sessionId = d.session_id || d.sessionId || d.thread_id || d.conversation_id || "-";
|
||||
const transcriptPath = d.transcript_path || d.transcriptPath || d.session_path || d.conversation_path || d.path || "-";
|
||||
const stopActive = (d.stop_hook_active || d.stopHookActive) ? "1" : "0";
|
||||
const cwd = d.cwd || "-";
|
||||
console.log(sessionId, transcriptPath, stopActive, cwd);
|
||||
});
|
||||
')
|
||||
EOF_HOOK
|
||||
|
||||
[ "$STOP_ACTIVE" = "1" ] && die_quiet "stop_hook_active 為 true,避免迴圈不重複記錄"
|
||||
|
||||
# Codex 的 hook 只給 thread id、不給 transcript 路徑,需要自己找檔案;
|
||||
# Copilot 的 agentStop 事件已直接帶 transcriptPath(見上方解析),不需要這段 fallback。
|
||||
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="-"
|
||||
@@ -190,10 +195,12 @@ fi
|
||||
# ------------------------------------------------------------------------------
|
||||
# 抽出本輪內容(最後一筆使用者訊息之後),並先做一次機密遮蔽
|
||||
# ------------------------------------------------------------------------------
|
||||
TURN="$(python3 "${SCRIPT_DIR}/transcript.py" extract "$TRANSCRIPT_PATH" 2>/dev/null)"
|
||||
TURN="$(node "${SCRIPT_DIR}/transcript.mjs" extract "$TRANSCRIPT_PATH" 2>/dev/null)"
|
||||
[ -n "$TURN" ] || die_quiet "本輪無可記錄內容"
|
||||
DURATION="$(python3 "${SCRIPT_DIR}/transcript.py" duration "$TRANSCRIPT_PATH" 2>/dev/null)"
|
||||
DURATION="$(node "${SCRIPT_DIR}/transcript.mjs" duration "$TRANSCRIPT_PATH" 2>/dev/null)"
|
||||
[ -n "$DURATION" ] || DURATION="未判定"
|
||||
TOKENS="$(node "${SCRIPT_DIR}/transcript.mjs" tokens "$TRANSCRIPT_PATH" 2>/dev/null)"
|
||||
[ -n "$TOKENS" ] || TOKENS="未判定"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 模型決定:只有 claude CLI 使用 WORKLOG_MODEL/快取檔;其他 CLI 使用各自預設模型
|
||||
@@ -232,21 +239,24 @@ PROMPT="$(cat <<EOF_PROMPT
|
||||
|
||||
已判定專案:${PROJECT}
|
||||
已估算花費時間:${DURATION}
|
||||
已統計 token 用量:${TOKENS}
|
||||
|
||||
1. 只輸出 6 個 markdown bullet(以「- 」開頭),不要標題、不要前言、不要結語。
|
||||
2. 六個 bullet 必須依序使用下列欄位名稱,格式固定為「- 欄位名稱:內容」:
|
||||
1. 只輸出 7 個 markdown bullet(以「- 」開頭),不要標題、不要前言、不要結語。
|
||||
2. 七個 bullet 必須依序使用下列欄位名稱,格式固定為「- 欄位名稱:內容」:
|
||||
- 專案/任務名稱
|
||||
- 執行細節與產出
|
||||
- 花費時間
|
||||
- 任務狀態
|
||||
- 遇到的困難
|
||||
- 解決方式
|
||||
- token 用量
|
||||
3. 使用繁體中文(台灣用語),每個 bullet 一行、不超過 90 字,聚焦「做了什麼、動到什麼、結果如何」。
|
||||
4. 保留關鍵事實:檔案/專案/指令/數量/分支/PR/議題編號;不要抄程式碼、不要貼指令全文。
|
||||
5. 花費時間優先使用「已估算花費時間」;無法判定時寫「未判定」。
|
||||
6. 若沒有遇到明確困難,遇到的困難寫「未遇到明確困難」,解決方式寫「不需額外處理」。
|
||||
7. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
||||
8. 若這段對話沒有實質工作產出(純閒聊、純提問、僅讀取資訊而未產生結論),只輸出一行:SKIP
|
||||
9. token 用量一律照抄「已統計 token 用量」,不得自行推算或估計;無法判定時寫「未判定」。
|
||||
|
||||
對話片段:
|
||||
${TURN}
|
||||
@@ -261,9 +271,9 @@ fi
|
||||
printf '%s' "$SUMMARY" | grep -qiE '^\s*SKIP\s*$' && die_quiet "模型判定本輪無實質工作產出"
|
||||
|
||||
# 第二道防線:對模型輸出再做一次機密遮蔽
|
||||
SUMMARY="$(printf '%s' "$SUMMARY" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/dev/null)"
|
||||
SUMMARY="$(printf '%s' "$SUMMARY" | node "${SCRIPT_DIR}/transcript.mjs" redact 2>/dev/null)"
|
||||
# 只保留 bullet 行,避免模型帶出多餘敘述
|
||||
SUMMARY="$(printf '%s\n' "$SUMMARY" | grep -E '^\s*[-*]\s+' | sed -E 's/^\s*[*]/-/' | head -6)"
|
||||
SUMMARY="$(printf '%s\n' "$SUMMARY" | grep -E '^\s*[-*]\s+' | sed -E 's/^\s*[*]/-/' | head -7)"
|
||||
[ -n "$SUMMARY" ] || die_quiet "摘要不含合法條目,略過本輪" "WRN"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -275,7 +285,7 @@ MARKER="worklog:$(TZ='Asia/Taipei' date +'%Y%m%d%H%M%S')-${SESSION_ID:0:8}"
|
||||
ENTRY="$(printf '## %s — %s%s <!-- %s -->\n%s\n' "$STAMP" "$PROJECT" "$MODEL_NOTE" "$MARKER" "$SUMMARY")"
|
||||
|
||||
export WORKLOG_HOST WORKLOG_REPO
|
||||
if printf '%s' "$ENTRY" | python3 "${SCRIPT_DIR}/wiki_api.py" append "$MARKER" 2>&1 | grep -q '\[ERR\]'; then
|
||||
if printf '%s' "$ENTRY" | node "${SCRIPT_DIR}/wiki_api.mjs" append "$MARKER" 2>&1 | grep -q '\[ERR\]'; then
|
||||
log "ERR" "寫入 wiki 失敗(專案 ${PROJECT})"
|
||||
else
|
||||
log "INF" "已記錄工作條目(專案 ${PROJECT},CLI ${SUMMARY_CLI})"
|
||||
|
||||
Reference in New Issue
Block a user