Skip to content

Instantly share code, notes, and snippets.

@shirou
Last active March 16, 2026 13:19
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/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();
"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