目次

1.はじめに
2.hooksについて
3.hooksで使うPythonスクリプトについて
4.hooksを動かしてみた
5.おわりに

1.はじめに

AI ツールを日常的に使用している中で、会話履歴を振り返りたい場面がないでしょうか。
AI とのやりとりですが、デフォルト状態では手軽に保存、参照するのは難しいかと思います。
そこで本ブログでは Gemini CLI の hooks 機能を使って、Obsidian の保管庫に AI とのやりとりを自動的に保存する方法を紹介します。

環境情報

  • macOS:Tahoe 26.3.1
  • Node.js のバージョン( node --version ):24.11.1
  • Gemini CLI のバージョン( gemini --version ):0.36.0
  • Obsidian のバージョン:1.12.7

2.hooksについて

hooks とは Gemini CLI のエージェントループ内の特定時点でプログラムを実行できる機能です。

エージェントループ内の特定時点ですが、Hook events から指定します。

今回指定するのは SessionStart と AfterAgent です。

SessionStart はセッション立ち上げ時、AfterAgent は AI がユーザーからの質問、指示に返答し終えたタイミングになります。
Hook events の細かい仕様については、Hooks reference を参照ください。

hooks の利用設定は settings.json で行います。
グローバルに利用したいので、~/.gemini/settings.json に設定します。
hooks のセクションを作り、SessionStart と AfterAgent を設けています。

{
  "security": {
    "auth": {
      "selectedType": "xxxxx"
    }
  },
  "ide": {
    "hasSeenNudge": xxxx
  },
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "name": "obsidian-save",
            "type": "command",
            "command": "python3 ~/.gemini/scripts/obsidian_save.py",
            "description": "Gemini CLIの会話をObsidianに保存する"
          }
        ]
      }
    ],
    "AfterAgent": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "obsidian-save",
            "type": "command",
            "command": "python3 ~/.gemini/scripts/obsidian_save.py",
            "description": "Gemini CLIの会話をObsidianに保存する"
          }
        ]
      }
    ]
  }
}

SessionStart と AfterAgent のどちらも稼働の際に指定のコマンドを実行する形になります。
実行する Python スクリプトの中身については後続で説明いたします。

3.hooksで使うPythonスクリプトについて

Obsidianについて

Python スクリプトの中身の説明の前に Obsidian が何たるかを簡単に説明すると、Markdown 形式を前提としたメモアプリになります。
生成 AI を積極的に活用している方には馴染みのあるサービスかと思います。

Obsidian を使用する際、まずは保管庫という Obsidian が管理するフォルダを作成する必要があり、そのフォルダを Obsidian アプリ内で操作する形となります。

例えば Documents フォルダに obsidian_保管庫というフォルダを作り、そこを保管庫とします。

Obsidian アプリを立ち上げる際、この保管庫を指定して立ち上げると、そのフォルダの内容がアプリ上に表示され、ファイルの作成や編集ができます。

Pythonスクリプトについて

設定ファイルの hooks セクションに設定しているスクリプト(~/.gemini/scripts/obsidian_save.py)ですが、以下の処理を実行します。

SessionStart稼働時

  • Obsidian の保管庫内に gemini-cli/YYYYMMDD/ のフォルダ構造を自動作成する。フォルダがすでに存在する場合はそれを使用し、存在しない場合は新規作成する。
  • 作成したフォルダ内に起動時刻とセッション ID の先頭8文字の組み合わせをファイル名にした Markdown ファイルを新規作成する。
  • セッションの情報を状態管理ファイル(obsidian_state.json)に記録する。

AfterAgent 稼働時

  • 状態管理ファイルからセッション情報を読み込み、前回処理したメッセージ ID を確認する。
  • transcript_path の JSON ファイルを読み込み、差分メッセージを取得して Markdown ファイルに会話のやり取りを書き込む。
  • 処理した最後のメッセージ ID を状態管理ファイルに書き込み、更新する。

スクリプトで扱うデータやファイルに関する補足

  • transcript_path:Gemini CLI が自動生成する会話履歴の JSON ファイルパス。クリーンな会話データを取得するために使用する。
  • 状態管理ファイル(obsidian_state.json):スクリプトが生成するファイル。AfterAgent が稼働するたびに、どこまで書き込んだかを管理するために使用する。このファイルの情報をもとに差分のみを Markdown ファイルに追記する。
  • ログファイル(obsidian_save.log):スクリプトが生成するファイル。SessionStart、AfterAgent の稼働タイミングで指定ログを記録する。

スクリプトの全容はこちらになります。

obsidian_save.py
#!/usr/bin/env python3
"""
Gemini CLI の会話履歴を Obsidian に自動保存するフックスクリプト。

対応イベント:
  - SessionStart : Obsidian に Markdown ファイルを作成
  - AfterAgent   : transcript_path を読んで差分を追記
"""

import json
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path

# ============================================================
# 定数
# ============================================================
JST = timezone(timedelta(hours=9))
OBSIDIAN_VAULT = Path.home() / "Documents" / "obsidian_保管庫"
OUTPUT_BASE = OBSIDIAN_VAULT / "gemini-cli"
STATE_FILE = Path.home() / ".gemini" / "obsidian_state.json"
LOG_FILE = Path.home() / ".gemini" / "logs" / "obsidian_save.log"


# ============================================================
# ユーティリティ
# ============================================================
def log(msg: str) -> None:
    print(msg, file=sys.stderr)
    try:
        LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
        with open(LOG_FILE, "a", encoding="utf-8") as f:
            ts = datetime.now(JST).strftime("%Y-%m-%d %H:%M:%S")
            f.write(f"[{ts}] {msg}\n")
    except Exception:
        pass


def load_state() -> dict:
    if STATE_FILE.exists():
        try:
            return json.loads(STATE_FILE.read_text(encoding="utf-8"))
        except Exception:
            return {}
    return {}


def save_state(state: dict) -> None:
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(
        json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8"
    )


def read_stdin() -> dict:
    try:
        data = sys.stdin.read()
        if data.strip():
            return json.loads(data)
    except Exception:
        pass
    return {}


def now_jst() -> datetime:
    return datetime.now(JST)


def parse_timestamp(ts_str: str) -> datetime:
    try:
        return datetime.fromisoformat(ts_str.replace("Z", "+00:00")).astimezone(JST)
    except Exception:
        return now_jst()


# ============================================================
# transcript.json のパース
# ============================================================
def load_transcript(transcript_path: str) -> dict:
    try:
        return json.loads(Path(transcript_path).read_text(encoding="utf-8"))
    except Exception:
        return {}


def extract_user_text(content) -> str:
    """userメッセージのcontentからテキストを抽出する"""
    if isinstance(content, str):
        return content.strip()
    if isinstance(content, list):
        parts = []
        for block in content:
            if isinstance(block, dict) and "text" in block:
                parts.append(block["text"].strip())
        return "\n".join(parts)
    return ""


def extract_gemini_text(content) -> str:
    """geminiメッセージのcontentからテキストを抽出する"""
    if isinstance(content, str):
        return content.strip()
    return ""


def get_new_messages(transcript: dict, last_id: str) -> list:
    """
    last_id以降の新規メッセージを返す。
    last_idが空の場合は全メッセージを返す。
    typeがuserまたはgeminiのもののみ対象。
    """
    messages = transcript.get("messages", [])
    result = []
    found = (last_id == "")

    for msg in messages:
        msg_type = msg.get("type", "")
        if msg_type not in ("user", "gemini"):
            continue
        if not found:
            if msg.get("id") == last_id:
                found = True
            continue
        result.append(msg)

    return result


# ============================================================
# ファイル生成
# ============================================================
def build_frontmatter(session_id: str, cwd: str, start_dt: datetime) -> str:
    return (
        "---\n"
        f"session_id: {session_id}\n"
        f"project: {cwd}\n"
        f"time_start: {start_dt.isoformat()}\n"
        "tags:\n"
        "  - gemini-cli\n"
        "---\n\n"
    )


def build_header(session_id: str, cwd: str, start_dt: datetime) -> str:
    return (
        "# Gemini CLI セッション\n\n"
        "| Key | Value |\n"
        "|---|---|\n"
        f"| Session | `{session_id[:8]}` |\n"
        f"| Project | `{cwd}` |\n"
        f"| Started | {start_dt.strftime('%Y-%m-%d %H:%M:%S')} |\n\n"
        "---\n\n"
        "## 会話ログ\n\n"
    )


def create_md_file(session_id: str, cwd: str, start_dt: datetime) -> Path:
    date_dir = start_dt.strftime("%Y%m%d")
    time_prefix = start_dt.strftime("%H%M%S")
    short_id = session_id[:8]

    output_dir = OUTPUT_BASE / date_dir
    output_dir.mkdir(parents=True, exist_ok=True)

    path = output_dir / f"{time_prefix}_{short_id}.md"
    path.write_text(
        build_frontmatter(session_id, cwd, start_dt) + build_header(session_id, cwd, start_dt),
        encoding="utf-8",
    )
    return path


# ============================================================
# メッセージ追記
# ============================================================
def append_messages(path: Path, messages: list) -> str:
    """
    メッセージリストをmdファイルに追記する。
    最後に処理したメッセージIDを返す。
    """
    blocks = []
    last_id = ""

    for msg in messages:
        msg_type = msg.get("type", "")
        msg_id = msg.get("id", "")
        timestamp = msg.get("timestamp", "")
        dt = parse_timestamp(timestamp)
        time_str = dt.strftime("%H:%M:%S")

        if msg_type == "user":
            text = extract_user_text(msg.get("content", ""))
            if not text:
                continue
            blocks.append(
                f"### {time_str} 🙋 User\n\n{text}\n\n---\n\n"
            )

        elif msg_type == "gemini":
            text = extract_gemini_text(msg.get("content", ""))
            if not text:
                continue
            blocks.append(
                f"### {time_str} 🤖 Gemini\n\n{text}\n\n---\n\n"
            )

        last_id = msg_id

    if blocks:
        with open(path, "a", encoding="utf-8") as f:
            f.write("".join(blocks))

    return last_id


# ============================================================
# イベントハンドラ
# ============================================================
def handle_session_start(hook_input: dict) -> None:
    session_id = hook_input.get("session_id", "")
    cwd = hook_input.get("cwd", "")
    if not session_id:
        log("SessionStart: session_id が取得できませんでした")
        return

    start_dt = now_jst()
    path = create_md_file(session_id, cwd, start_dt)

    state = load_state()
    state[session_id] = {
        "output_path": str(path),
        "start_time": start_dt.isoformat(),
        "cwd": cwd,
        "last_id": "",
    }
    save_state(state)
    log(f"SessionStart: {session_id[:8]} -> {path}")


def handle_after_agent(hook_input: dict) -> None:
    session_id = hook_input.get("session_id", "")
    transcript_path = hook_input.get("transcript_path", "")
    cwd = hook_input.get("cwd", "")

    if not session_id or not transcript_path:
        log("AfterAgent: session_id または transcript_path が取得できませんでした")
        return

    state = load_state()

    # SessionStartが稼働していない場合(resume等)はここで初期化
    if session_id not in state:
        start_dt = now_jst()
        path = create_md_file(session_id, cwd, start_dt)
        state[session_id] = {
            "output_path": str(path),
            "start_time": start_dt.isoformat(),
            "cwd": cwd,
            "last_id": "",
        }
        save_state(state)
        log(f"AfterAgent: セッション初期化 {session_id[:8]} -> {path}")

    path = Path(state[session_id]["output_path"])
    last_id = state[session_id].get("last_id", "")

    transcript = load_transcript(transcript_path)
    new_messages = get_new_messages(transcript, last_id)

    if new_messages:
        new_last_id = append_messages(path, new_messages)
        if new_last_id:
            state[session_id]["last_id"] = new_last_id
            save_state(state)
        log(f"AfterAgent: {len(new_messages)}件追記完了 {session_id[:8]}")
    else:
        log(f"AfterAgent: 新規メッセージなし {session_id[:8]}")

    print("{}")


# ============================================================
# メイン
# ============================================================
def main() -> None:
    hook_input = read_stdin()
    event = hook_input.get("hook_event_name", "")

    if event == "SessionStart":
        handle_session_start(hook_input)
    elif event == "AfterAgent":
        handle_after_agent(hook_input)
    else:
        log(f"未対応イベント: {event}")

    print("{}")


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        log(f"予期しないエラー: {e}")
        print("{}")
        sys.exit(0)

定数部分ですが、OBSIDIAN_VAULT が Obsidian の保管庫のパス、OUTPUT_BASE が保管庫内の保存先フォルダ名です。
もし、このスクリプトを試される場合、適宜ご自身の環境のものに書き換えてください。

ログファイル(obsidian_save.log)はスクリプト内にローテート処理を実装していないため、長期運用する場合、logrotate などを導入しログが肥大化しないよう管理することをおすすめします。

4.hooksを動かしてみた

hooks が起動し、やり取りの履歴が Markdown ファイルに出力されるかみてみます。
Gemini CLI を起動し、KDDIアイレットについて質問してみました。

質問に対する AI の応答が確認できました。
これでやりとりに関する Markdown ファイルが作成されます。

Documents/
└── obsidian_保管庫/
    └── gemini-cli/
        ├── 20260404/
        │   └── …(他ファイルあり・本図では省略)
        └── 20260405/(新規作成されたフォルダ)
            └── 072338_aadae54e.md(新規作成された Markdown ファイル)

072338_aadae54e.md の中身は以下です。
ヘッダーにセッション ID、プロジェクトのパス、開始時刻、タグが記載され、その後に会話ログとして AI とのやりとりが時系列で記録されます。


072338_aadae54e.md

---
session_id: aadae54e-b73e-489a-ad82-88d1281e498b
project: /Users/xxxx/Documents/0404
time_start: 2026-04-05T07:23:38.525813+09:00
tags:
  - gemini-cli
---

# Gemini CLI セッション

| Key | Value |
|---|---|
| Session | `aadae54e` |
| Project | `/Users/xxxx/Documents/0404` |
| Started | 2026-04-05 07:23:38 |

---

## 会話ログ

### 07:24:02 🙋 User

KDDIアイレットがどんな会社かGoogle検索して教えてください。

---

### 07:24:04 🤖 Gemini

KDDIアイレット(アイレット株式会社)についてお調べします。

---

### 07:26:33 🤖 Gemini

KDDIアイレット株式会社(旧:アイレット株式会社)についてまとめました。

2026年4月1日に、アイレット株式会社とKDDI Digital Divergence Holdings株式会社が合併し、**「KDDIアイレット株式会社」**として新たに始動した企業です。

### 1. 会社概要
*   **名称:** KDDIアイレット株式会社(KDDI iret Inc.)
*   **株主:** KDDI株式会社(100%出資の子会社)
*   **本社:** 東京都港区虎ノ門(虎ノ門ヒルズ ビジネスタワー)
*   **代表者:** 代表取締役社長 岩永 充正
*   **従業員数:** 約1,700名(2026年4月時点)

### 2. 主な事業内容
クラウドとAIを核としたITソリューションをワンストップで提供しています。

*   **クラウド導入・運用(cloudpack):**
    AWS(Amazon Web Services)やGoogle Cloudの導入設計から24時間365日の運用保守まで提供。特にAWSにおいては国内トップレベルの実績があり、10年以上連続で「AWS プレミアティアサービスパートナー」に認定されています。
*   **AIインテグレーション:**
    生成AIの導入支援(「gaipack」など)や、AIを活用した開発・ビジネス変革の支援。
*   **システム・アプリ開発:**
    クラウドネイティブな環境での大規模システムやスマートフォンアプリの開発。
*   **デザイン・UI/UX:**
    ビジネス課題解決のためのクリエイティブ制作やユーザー体験の設計。

### 3. 強みと特徴
*   **技術力:** クラウド認定資格保有者が非常に多く、技術力に定評があります。
*   **一気通貫の支援:** インフラ構築からアプリ開発、デザイン、運用までを一つの会社で完結できる「フルスタック」な対応力が強みです。
*   **KDDIグループの基盤:** KDDIの通信・ネットワーク技術や顧客基盤と、アイレットの機動力・技術力を組み合わせることで、企業のDX(デジタルトランスフォーメーション)を強力に推進しています。

より詳細な情報や特定のサービスについて知りたい場合は、さらに詳しくお調べします。

---

### 07:28:55 🙋 User

代表取締役社長は変わったのではないですか?調べてください。

---

### 07:29:00 🤖 Gemini

KDDIアイレット(旧アイレット)の代表取締役社長について、最新の情報を再確認します。

---

### 07:30:25 🤖 Gemini

おっしゃる通りです。大変失礼いたしました。

2026年4月5日現在の最新情報では、代表取締役社長は交代しています。

2026年4月1日に、アイレット株式会社とKDDI Digital Divergence Holdings株式会社が合併し「KDDIアイレット株式会社」が発足したことに伴い、新体制となっております。

### KDDIアイレット株式会社 新経営体制(2026年4月1日付)

*   **代表取締役社長:髙木 秀悟(たかぎ しゅうご)** 【新任】
*   **代表取締役会長:** 齋藤 将平
*   **取締役副社長:** 岩永 充正(前 アイレット代表取締役社長)
*   **取締役副社長:** 平野 弘紀

### 新社長 髙木 秀悟 氏の背景
髙木氏は、KDDIで法人営業やビジネス企画の要職を歴任し、直近ではKDDIのビジネスイノベーション本部長を務めていた人物です。

今回の体制変更により、前社長の岩永 充正氏は取締役副社長となり、新会社の経営を引き続き支える形となっています。情報のアップデートが遅れており、ご迷惑をおかけしました。

---

Obsidian のアプリ上でも上記ファイルが表示されています。

5.おわりに

Gemini CLI の hooks 機能を活用し、AI とのやりとりを Markdown ファイルとして自動保存する方法を紹介しました。

実際に hooks を設定した上で Gemini CLI を使ってみましたが、使用中の体感として特にパフォーマンスへの影響は感じませんでした。

今回使用したスクリプトはあくまで一例です。
保存先や記録フォーマットなど、ご自身の利用用途に合わせて適宜カスタマイズしてみてください。