Last active
March 16, 2026 13:19
-
-
Save shirou/d193f398898f10b82724809d8ccd99c1 to your computer and use it in GitHub Desktop.
Save claude code history on obsidian via hooks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| // Hook to save Claude Code conversations to Obsidian | |
| // Uses sessionStart, postToolUse, and sessionEnd lifecycle events | |
| // | |
| // Usage: bun save-to-obsidian.ts <sessionStart|postToolUse|sessionEnd> | |
| import { | |
| readFileSync, | |
| writeFileSync, | |
| existsSync, | |
| mkdirSync, | |
| appendFileSync, | |
| unlinkSync, | |
| } from "fs"; | |
| import { dirname, join } from "path"; | |
| import { tmpdir } from "os"; | |
| // Configuration | |
| const OBSIDIAN_ROOT = | |
| "/mnt/c/Users/shirou/Documents/ubuntu/obsidian_notes/notes/claude"; | |
| const PROJECT_DEPTH = 1; // Number of directory levels from the bottom to use as project name | |
| interface HookInput { | |
| session_id: string; | |
| transcript_path: string; | |
| cwd: string; | |
| hook_event_name: string; | |
| tool_name?: string; | |
| tool_input?: Record<string, unknown>; | |
| } | |
| interface TranscriptMessage { | |
| type: "user" | "assistant" | "summary" | "file-history-snapshot" | string; | |
| message?: { | |
| role?: string; | |
| content?: string | Array<{ type: string; text?: string }>; | |
| }; | |
| } | |
| interface SessionState { | |
| session_id: string; | |
| cwd: string; | |
| transcript_path: string; | |
| start_time: string; | |
| tool_usage: Record<string, number>; | |
| } | |
| // Read hook input from stdin | |
| function readHookInput(): HookInput { | |
| const input = readFileSync(0, "utf-8"); | |
| return JSON.parse(input); | |
| } | |
| // Session state file path (per session) | |
| function sessionStatePath(sessionId: string): string { | |
| return join(tmpdir(), `obsidian-hook-${sessionId}.json`); | |
| } | |
| // Extract project name from cwd (last N directory levels) | |
| function extractProjectName(cwd: string): string { | |
| const parts = cwd.split("/").filter((p) => p.length > 0); | |
| return parts.slice(-PROJECT_DEPTH).join("/"); | |
| } | |
| // Get all Q&A pairs from transcript | |
| function getAllMessages( | |
| transcriptPath: string, | |
| ): Array<{ question: string; answer: string }> { | |
| if (!existsSync(transcriptPath)) { | |
| return []; | |
| } | |
| const content = readFileSync(transcriptPath, "utf-8"); | |
| const lines = content.trim().split("\n"); | |
| const pairs: Array<{ question: string; answer: string }> = []; | |
| let currentQuestion: string | null = null; | |
| for (const line of lines) { | |
| try { | |
| const entry = JSON.parse(line) as TranscriptMessage; | |
| if (entry.type !== "user" && entry.type !== "assistant") { | |
| continue; | |
| } | |
| if (entry.type === "user") { | |
| const msgContent = entry.message?.content; | |
| if (typeof msgContent === "string") { | |
| currentQuestion = msgContent; | |
| } else if (Array.isArray(msgContent)) { | |
| const textContent = msgContent.find((c) => c.type === "text"); | |
| if (textContent?.text) { | |
| currentQuestion = textContent.text; | |
| } | |
| } | |
| } else if (entry.type === "assistant" && currentQuestion) { | |
| const texts = entry.message?.content; | |
| if (Array.isArray(texts)) { | |
| const textParts = texts | |
| .filter((c) => c.type === "text") | |
| .map((c) => c.text) | |
| .filter((t): t is string => !!t); | |
| if (textParts.length > 0) { | |
| pairs.push({ | |
| question: currentQuestion, | |
| answer: textParts.join("\n"), | |
| }); | |
| currentQuestion = null; | |
| } | |
| } | |
| } | |
| } catch { | |
| // Skip malformed lines | |
| } | |
| } | |
| return pairs; | |
| } | |
| // Generate file path from date | |
| function generateFilePath(projectName: string): string { | |
| const now = new Date(); | |
| const year = now.getFullYear().toString(); | |
| const month = (now.getMonth() + 1).toString().padStart(2, "0"); | |
| const day = now.getDate().toString().padStart(2, "0"); | |
| const filename = `${year}-${month}-${day}.md`; | |
| return join(OBSIDIAN_ROOT, projectName, year, month, filename); | |
| } | |
| // Generate date header for new files | |
| function generateDateHeader(): string { | |
| const now = new Date(); | |
| const year = now.getFullYear(); | |
| const month = now.getMonth() + 1; | |
| const day = now.getDate(); | |
| return `# ${year}年${month.toString().padStart(2, "0")}月${day.toString().padStart(2, "0")}日`; | |
| } | |
| // Format tool usage summary | |
| function formatToolSummary(toolUsage: Record<string, number>): string { | |
| const entries = Object.entries(toolUsage).sort(([, a], [, b]) => b - a); | |
| if (entries.length === 0) return ""; | |
| return entries.map(([tool, count]) => `${tool}(${count})`).join(", "); | |
| } | |
| // ─── SESSION START ────────────────────────────────────────── | |
| function onSessionStart(hookInput: HookInput): void { | |
| const state: SessionState = { | |
| session_id: hookInput.session_id, | |
| cwd: hookInput.cwd, | |
| transcript_path: hookInput.transcript_path, | |
| start_time: new Date().toISOString(), | |
| tool_usage: {}, | |
| }; | |
| // Save session state to temp file | |
| writeFileSync(sessionStatePath(hookInput.session_id), JSON.stringify(state)); | |
| } | |
| // ─── POST TOOL USE ────────────────────────────────────────── | |
| function onPostToolUse(hookInput: HookInput): void { | |
| const statePath = sessionStatePath(hookInput.session_id); | |
| if (!existsSync(statePath)) return; | |
| const state: SessionState = JSON.parse(readFileSync(statePath, "utf-8")); | |
| // Track tool usage counts | |
| const toolName = hookInput.tool_name ?? "unknown"; | |
| state.tool_usage[toolName] = (state.tool_usage[toolName] ?? 0) + 1; | |
| writeFileSync(statePath, JSON.stringify(state)); | |
| } | |
| // ─── SESSION END ──────────────────────────────────────────── | |
| function onSessionEnd(hookInput: HookInput): void { | |
| const statePath = sessionStatePath(hookInput.session_id); | |
| // Load session state (fall back to hookInput if no state file) | |
| let state: SessionState | null = null; | |
| if (existsSync(statePath)) { | |
| state = JSON.parse(readFileSync(statePath, "utf-8")); | |
| } | |
| const cwd = state?.cwd ?? hookInput.cwd; | |
| const transcriptPath = state?.transcript_path ?? hookInput.transcript_path; | |
| const projectName = extractProjectName(cwd); | |
| const pairs = getAllMessages(transcriptPath); | |
| if (pairs.length === 0) { | |
| // Cleanup state file | |
| if (existsSync(statePath)) unlinkSync(statePath); | |
| return; | |
| } | |
| const filePath = generateFilePath(projectName); | |
| const dirPath = dirname(filePath); | |
| // Create directory if it doesn't exist | |
| if (!existsSync(dirPath)) { | |
| mkdirSync(dirPath, { recursive: true }); | |
| } | |
| // Build session content | |
| const isNewFile = !existsSync(filePath); | |
| const now = new Date(); | |
| const hours = now.getHours().toString().padStart(2, "0"); | |
| const minutes = now.getMinutes().toString().padStart(2, "0"); | |
| let sessionContent = ""; | |
| // Session header with time and metadata | |
| sessionContent += `\n\n---\n\n## ${hours}:${minutes}\n\n`; | |
| // Add tool usage summary if available | |
| if (state?.tool_usage && Object.keys(state.tool_usage).length > 0) { | |
| const toolSummary = formatToolSummary(state.tool_usage); | |
| sessionContent += `> **使用ツール**: ${toolSummary}\n\n`; | |
| } | |
| // Add all Q&A pairs | |
| for (const pair of pairs) { | |
| sessionContent += `### 質問\n${pair.question}\n\n### 回答\n${pair.answer}\n\n`; | |
| } | |
| // Write to file | |
| if (isNewFile) { | |
| writeFileSync(filePath, generateDateHeader() + sessionContent); | |
| } else { | |
| appendFileSync(filePath, sessionContent); | |
| } | |
| // Cleanup session state file | |
| if (existsSync(statePath)) { | |
| unlinkSync(statePath); | |
| } | |
| } | |
| // ─── Dispatch ─────────────────────────────────────────────── | |
| function main() { | |
| try { | |
| const event = process.argv[2]; | |
| const hookInput = readHookInput(); | |
| switch (event) { | |
| case "sessionStart": | |
| onSessionStart(hookInput); | |
| break; | |
| case "postToolUse": | |
| onPostToolUse(hookInput); | |
| break; | |
| case "sessionEnd": | |
| onSessionEnd(hookInput); | |
| break; | |
| default: | |
| console.error( | |
| "Usage: save-to-obsidian.ts <sessionStart|postToolUse|sessionEnd>", | |
| ); | |
| process.exit(1); | |
| } | |
| process.exit(0); | |
| } catch (error) { | |
| console.error("save-to-obsidian error:", error); | |
| process.exit(0); | |
| } | |
| } | |
| main(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "hooks": { | |
| "SessionStart": [ | |
| { | |
| "matcher": "", | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "bun ~/.claude/hooks/save-to-obsidian.ts sessionStart" | |
| } | |
| ] | |
| } | |
| ], | |
| "PostToolUse": [ | |
| { | |
| "matcher": "", | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "bun ~/.claude/hooks/save-to-obsidian.ts postToolUse", | |
| "timeout": 3000 | |
| } | |
| ] | |
| } | |
| ], | |
| "SessionEnd": [ | |
| { | |
| "matcher": "", | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "bun ~/.claude/hooks/save-to-obsidian.ts sessionEnd" | |
| } | |
| ] | |
| } | |
| ] | |
| }, | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment