Created
January 13, 2026 12:33
-
-
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 | |
| // Appends question/answer pairs to Markdown files on Stop event | |
| import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; | |
| import { dirname, join } from "path"; | |
| // Configuration | |
| const OBSIDIAN_ROOT = "/path/to/obsidian/notes/claude"; | |
| const PROJECT_DEPTH = 2; // 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; | |
| } | |
| interface TranscriptMessage { | |
| type: "user" | "assistant" | "summary" | "file-history-snapshot" | string; | |
| message?: { | |
| role?: string; | |
| content?: Array<{ type: string; text?: string }>; | |
| }; | |
| } | |
| // Read hook input from stdin | |
| function readHookInput(): HookInput { | |
| const input = readFileSync(0, "utf-8"); | |
| return JSON.parse(input); | |
| } | |
| // 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 the latest human/assistant message pair from transcript | |
| function getLatestMessages(transcriptPath: string): { question: string; answer: string } | null { | |
| if (!existsSync(transcriptPath)) { | |
| return null; | |
| } | |
| const content = readFileSync(transcriptPath, "utf-8"); | |
| const lines = content.trim().split("\n"); | |
| let lastHuman: string | null = null; | |
| let lastAssistant: string | null = null; | |
| for (const line of lines) { | |
| try { | |
| const entry = JSON.parse(line) as TranscriptMessage; | |
| // Skip non-message entries (summary, file-history-snapshot, etc.) | |
| if (entry.type !== "user" && entry.type !== "assistant") { | |
| continue; | |
| } | |
| if (entry.type === "user") { | |
| // Extract text from user message | |
| const content = entry.message?.content; | |
| if (typeof content === "string") { | |
| lastHuman = content; | |
| } else if (Array.isArray(content)) { | |
| const textContent = content.find((c) => c.type === "text"); | |
| if (textContent?.text) { | |
| lastHuman = textContent.text; | |
| } | |
| } | |
| } else if (entry.type === "assistant") { | |
| // Extract text from assistant message | |
| const texts = entry.message?.content | |
| ?.filter((c) => c.type === "text") | |
| ?.map((c) => c.text) | |
| ?.filter((t): t is string => !!t); | |
| if (texts && texts.length > 0) { | |
| lastAssistant = texts.join("\n"); | |
| } | |
| } | |
| } catch { | |
| // Skip malformed lines | |
| } | |
| } | |
| if (lastHuman && lastAssistant) { | |
| return { question: lastHuman, answer: lastAssistant }; | |
| } | |
| return null; | |
| } | |
| // 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")}日`; | |
| } | |
| // Generate time header for each session | |
| function generateTimeHeader(): string { | |
| const now = new Date(); | |
| const hours = now.getHours().toString().padStart(2, "0"); | |
| const minutes = now.getMinutes().toString().padStart(2, "0"); | |
| return `## ${hours}:${minutes}`; | |
| } | |
| // Generate markdown content | |
| function generateMarkdownContent(question: string, answer: string, isNewFile: boolean): string { | |
| const timeHeader = generateTimeHeader(); | |
| const content = ` | |
| --- | |
| ${timeHeader} | |
| ### 質問 | |
| ${question} | |
| ### 回答 | |
| ${answer} | |
| `; | |
| if (isNewFile) { | |
| return generateDateHeader() + content; | |
| } | |
| return content; | |
| } | |
| // Main entry point | |
| function main() { | |
| try { | |
| const hookInput = readHookInput(); | |
| const projectName = extractProjectName(hookInput.cwd); | |
| const messages = getLatestMessages(hookInput.transcript_path); | |
| if (!messages) { | |
| process.exit(0); | |
| } | |
| const filePath = generateFilePath(projectName); | |
| const dirPath = dirname(filePath); | |
| // Create directory if it doesn't exist | |
| if (!existsSync(dirPath)) { | |
| mkdirSync(dirPath, { recursive: true }); | |
| } | |
| // Check if file exists | |
| const isNewFile = !existsSync(filePath); | |
| const markdownContent = generateMarkdownContent(messages.question, messages.answer, isNewFile); | |
| // Append to file (create if doesn't exist) | |
| if (isNewFile) { | |
| writeFileSync(filePath, markdownContent); | |
| } else { | |
| const existingContent = readFileSync(filePath, "utf-8"); | |
| writeFileSync(filePath, existingContent + markdownContent); | |
| } | |
| 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": { | |
| "Stop": [ | |
| { | |
| "matcher": "", | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "bun ~/.claude/hooks/save-to-obsidian.ts" | |
| } | |
| ] | |
| } | |
| ] | |
| }, |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment