Skip to content

Instantly share code, notes, and snippets.

@shirou
Last active May 22, 2026 10:23
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
// 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/notes/foo/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>;
}
type ContentBlock = {
type: string;
text?: string;
content?: string | ContentBlock[];
};
type MessageContent = string | ContentBlock[] | undefined;
interface TranscriptMessage {
type: "user" | "assistant" | "summary" | "file-history-snapshot" | string;
message?: {
role?: string;
content?: MessageContent;
};
}
// Extract text blocks (`type: "text"`) from any content shape.
// Skips tool_use / thinking / tool_result.
function extractTextBlocks(content: MessageContent): string {
if (!content) return "";
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text")
.map((c) => c.text)
.filter((t): t is string => !!t)
.join("\n");
}
// Whether this user message is a real prompt (string, or contains a text block) —
// as opposed to a tool_result-only message returned to the assistant.
function isRealUserPrompt(content: MessageContent): boolean {
if (!content) return false;
if (typeof content === "string") return content.length > 0;
return content.some((c) => c.type === "text" && !!c.text);
}
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;
let currentAnswerParts: string[] = [];
const flush = () => {
if (currentQuestion && currentAnswerParts.length > 0) {
pairs.push({
question: currentQuestion,
answer: currentAnswerParts.join("\n\n"),
});
}
currentQuestion = null;
currentAnswerParts = [];
};
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") {
// Real user prompt → close the prior pair and start a new one.
// tool_result-only messages are intermediate; ignore them here.
if (isRealUserPrompt(entry.message?.content)) {
flush();
currentQuestion = extractTextBlocks(entry.message?.content);
}
} else if (entry.type === "assistant" && currentQuestion) {
const text = extractTextBlocks(entry.message?.content);
if (text) {
currentAnswerParts.push(text);
}
}
} catch {
// Skip malformed lines
}
}
flush();
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();
"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