摘要: running 狀態的氣泡改成有東西就播報:優先顯示助理剛說那句話,否則顯示正在用的工具與對象。 根本原因: 使用者希望氣泡能像 telegram bot 那樣顯示 AI 的思考過程。 實際查證後思考文字拿不到——Claude Code 從 2.1.238 起不再把 thinking 寫進 transcript, 只留加密簽章(本 session 169 個 thinking 區塊文字全為空字串); 比對同樣用 claude-opus-5 但版本為 2.1.237 的舊 session 則存得到, 確認是 Claude Code 版本差異而非模型差異。Claude Code 也沒有對外事件串流, 所以 hook 拿不到思考內容,這點與 Codex CLI 的 --json reasoning 事件不同。 而參考的 cluemarket-tg-bot 其實也沒顯示思考:它的變數雖名為 lastThinkingText, 抓的卻是 text 區塊,另外兩個是寫死的「思考中」字串與工具標籤。 原本 running 狀態除了關閉舊氣泡外沒有任何資訊。 影響: 工作期間看不出 Claude Code 正在做什麼,只知道她在忙。 修法: - hook 補送 toolTarget(依 command / file_path / pattern / url 等順序取一個欄位並截斷, 避免 Write 這類工具把整份檔案內容塞進 payload)、agentType 與 transcriptPath。 - main.js 新增 latestNarration:由 transcript 尾端 192 KB 反向找最後一個非空的 assistant text 區塊,依檔案大小快取,transcript 長到數 MB 也不會變慢; 收到事件時把 narration 與其 uuid 併進 payload 再轉給 renderer。 - renderer 新增 sayActivity:有新的助理說明就顯示第一句,否則顯示工具標籤加對象。 同一段說明只播報一次;waiting 時不播報以免蓋掉固定氣泡。 另加 stripMarkdown,否則助理文字的 **粗體**、反引號、標題與連結語法會直接出現在氣泡裡。 - README 新增「能顯示什麼、不能顯示什麼」一節說明思考文字為何拿不到。 驗證: - 單元測試以 DOM stub 載入完整 renderer.js 並抽出 main.js 的 latestNarration,15 項全過: 對真實 transcript 取得助理說明與 uuid、快取一致、路徑不存在或為 null 時回 null 不拋錯、 firstSentence 取第一句與忽略空行、Bash 取指令前段、檔案路徑只取檔名、過長截斷、 新說明更新 id、同段不重複、waiting 時不播報、空 payload 不拋錯。 - markdown 清理另 7 項全過:粗體、反引號、標題、清單、連結、程式碼區塊、無標記純文字。 - 實機啟動並以真實事件驗證:events.log 顯示 hook 確實帶出 toolTarget 與 transcriptPath; 截圖確認氣泡顯示「⚙️ 執行 npm run dist」,以及傳入真實 transcript 後顯示「💬 助理說明」。 markdown 殘留問題就是在這一步的截圖中發現並修掉的。 - 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar 與 app.asar.unpacked,確認版本 2.0.4 與九項改動都在出貨檔案裡。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
101 lines
3.4 KiB
JavaScript
101 lines
3.4 KiB
JavaScript
#!/usr/bin/env node
|
||
"use strict";
|
||
// Claude Code hook → Claude Pet
|
||
// 讀 stdin 的 hook JSON,POST 到本機的寵物 app。任何失敗都靜默結束(exit 0),絕不阻塞 Claude Code。
|
||
|
||
const http = require("node:http");
|
||
|
||
const PORT = Number(process.env.CLAUDE_PET_PORT || 17333);
|
||
const TIMEOUT_MS = 800;
|
||
|
||
function trim(value, max) {
|
||
if (typeof value !== "string") return undefined;
|
||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||
}
|
||
|
||
function readStdin() {
|
||
return new Promise((resolve) => {
|
||
if (process.stdin.isTTY) return resolve({});
|
||
let data = "";
|
||
const timer = setTimeout(() => resolve(parse(data)), TIMEOUT_MS);
|
||
process.stdin.setEncoding("utf8");
|
||
process.stdin.on("data", (chunk) => { data += chunk; });
|
||
process.stdin.on("end", () => { clearTimeout(timer); resolve(parse(data)); });
|
||
process.stdin.on("error", () => { clearTimeout(timer); resolve(parse(data)); });
|
||
});
|
||
}
|
||
|
||
function parse(text) {
|
||
try { return text.trim() ? JSON.parse(text) : {}; } catch { return {}; }
|
||
}
|
||
|
||
// 從 tool_input 挑一個最有資訊量的欄位當成「正在對什麼做事」。
|
||
// 整包 tool_input 可能很大(例如 Write 帶整份檔案內容),只取一小段。
|
||
const TARGET_KEYS = ["command", "file_path", "pattern", "path", "url", "query", "notebook_path", "description", "prompt"];
|
||
|
||
function toolTarget(toolInput) {
|
||
if (!toolInput || typeof toolInput !== "object") return undefined;
|
||
for (const key of TARGET_KEYS) {
|
||
const v = toolInput[key];
|
||
if (typeof v === "string" && v.trim()) return trim(v.trim().replace(/\s+/g, " "), 160);
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function buildPayload(input) {
|
||
const error = typeof input.error === "string" ? input.error : input.error?.message;
|
||
return {
|
||
event: input.hook_event_name || process.argv[2] || "Unknown",
|
||
sessionId: input.session_id ?? null,
|
||
cwd: input.cwd ?? null,
|
||
permissionMode: input.permission_mode ?? null,
|
||
toolName: input.tool_name ?? null,
|
||
toolTarget: toolTarget(input.tool_input),
|
||
agentType: input.agent_type ?? null,
|
||
// 助理的說明文字要從 transcript 撈;寵物那邊才讀,hook 只負責把路徑帶過去
|
||
transcriptPath: input.transcript_path ?? null,
|
||
notificationType: input.notification_type ?? null,
|
||
title: trim(input.title, 160),
|
||
message: trim(input.message, 240),
|
||
error: trim(error, 240),
|
||
source: input.source ?? null,
|
||
reason: input.reason ?? null,
|
||
trigger: input.trigger ?? null,
|
||
stopHookActive: input.stop_hook_active ?? null,
|
||
lastAssistantMessage: trim(input.last_assistant_message, 300),
|
||
prompt: trim(input.prompt, 120),
|
||
ts: Date.now(),
|
||
};
|
||
}
|
||
|
||
function post(payload) {
|
||
return new Promise((resolve) => {
|
||
const body = Buffer.from(JSON.stringify(payload), "utf8");
|
||
const req = http.request(
|
||
{
|
||
host: "127.0.0.1",
|
||
port: PORT,
|
||
method: "POST",
|
||
path: "/event",
|
||
headers: { "content-type": "application/json", "content-length": body.length },
|
||
},
|
||
(res) => { res.resume(); res.on("end", resolve); res.on("error", resolve); },
|
||
);
|
||
req.setTimeout(TIMEOUT_MS, () => req.destroy());
|
||
req.on("error", resolve);
|
||
req.end(body);
|
||
});
|
||
}
|
||
|
||
(async () => {
|
||
const guard = setTimeout(() => process.exit(0), TIMEOUT_MS * 2 + 500);
|
||
guard.unref();
|
||
try {
|
||
const input = await readStdin();
|
||
await post(buildPayload(input));
|
||
} catch {
|
||
// ignore
|
||
}
|
||
process.exit(0);
|
||
})();
|