Skip to content

Instantly share code, notes, and snippets.

@shirou
Created January 13, 2026 12:33
Show Gist options
  • Select an option

  • Save shirou/d193f398898f10b82724809d8ccd99c1 to your computer and use it in GitHub Desktop.

Select an option

Save shirou/d193f398898f10b82724809d8ccd99c1 to your computer and use it in GitHub Desktop.
Save claude code history on obsidian via hooks
#!/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();
"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