Back to Insights
AI & Machine LearningGit Hooks for AI Pair Programming: Capturing, Indexing, and Searching Your AI Coding SessionstutorialSeptember 7, 202618 min read

Git Hooks for AI Pair Programming: Capturing, Indexing, and Searching Your AI Coding Sessions

Build a complete system to capture, index, and search AI pair programming sessions using Git hooks, metadata extraction, and a searchable knowledge base.

T
Tamiz UddinFull-Stack Engineer

Your AI pair programmer forgets everything between sessions. You forget which prompt led to the breakthrough you actually wanted to keep. What if every AI-assisted coding session—its prompts, its accepted suggestions, its commit context—was automatically captured, indexed, and made instantly searchable? In this tutorial, you'll build a complete system using Git hooks to record AI pair programming sessions, index them into a structured knowledge base, and query them with a CLI search tool.

We'll cover three interconnected components: a post-checkout and post-commit hook pair that captures session metadata, a Rust-based indexer that parses and structures that data, and a search CLI that lets you reconstruct your AI reasoning timeline. Everything runs locally—no cloud telemetry, no third-party APIs, pure version-controlled observability.

Table of Contents

1. Why Capture AI Pair Programming Sessions

AI pair programming has fundamentally changed how developers work. But with that change comes a new kind of technical debt: context debt. When you close Cursor or VS Code after a session, the conversation—the reasoning, the rejected suggestions, the pivots—is lost. You end up repeating discoveries, re-asking the same questions, and rebuilding mental models from scratch.

Traditional logging solutions don't help here because AI session data lives in proprietary editor formats. Copilot Conversation files, Cursor's .cache directory, Claude Desktop's export format—none of them integrate with your Git workflow or support structured querying across sessions.

A Git hook-based approach solves three problems simultaneously:

  1. Automatic capture: Hooks run on every relevant Git event without manual intervention.
  2. Version control: Session metadata travels with your code, reviewable in PRs, bisectable across commits.
  3. Local-first privacy: Everything stays in your .git/hooks and local indexes—no data leaves your machine.

This tutorial builds a production-grade system. It's not a toy—it's designed for teams who want to audit AI-assisted changes, recover from bad prompts, and build institutional memory around AI collaboration patterns.

2. Architecture Overview

The system consists of three layers that form a complete capture-to-search pipeline:

sql
┌─────────────────────────────────────────────────────────────┐
│                    Git Hook Layer                           │
│  post-checkout ──► session_start.json                       │
│  post-commit   ──► session_end.json + diff snapshot         │
│  pre-push      ──► aggregation checkpoint                   │
└─────────────────────────────┬───────────────────────────────┘
                              │ writes to .ai_sessions/
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                   Indexer Layer                             │
│  sessions/      ◄── JSONL logs (one per event)              │
│  index/         ◄── Inverted index + semantic fingerprints  │
│  stats/         ◄── Aggregated metrics per session          │
└─────────────────────────────┬───────────────────────────────┘
                              │ serves .ai_index.db
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                 Search Interface Layer                       │
│  ai-search query "auth refactor"                            │
│  ai-search session <hash>                                   │
│  ai-search stats --since 2024-01                            │
└─────────────────────────────────────────────────────────────┘

Key design decisions:

  • JSONL log format for session events: append-only, parseable line-by-line, easy to stream.
  • Eventual consistency: hooks write raw logs; the indexer runs lazily on demand.
  • Zero external dependencies for hooks: bash scripts only, because they run in every developer's environment.
  • Rust indexer and search CLI: compiled to a single binary, fast, memory-efficient.

Let's build each piece.

3. The Hook System: What Gets Captured

Before writing code, we need to define exactly what data we capture and when. The hook system listens to three Git events:

Event: post-checkout

Triggered after every checkout. Captures the starting state of a potential AI session.

json
{
  "event": "session_start",
  "timestamp": "2024-11-15T09:23:41Z",
  "commit_before": "a1b2c3d",
  "commit_after": "a1b2c3d",
  "branch": "feature/auth-refactor",
  "working_dir": "/home/dev/project",
  "editor_pid": 48291,
  "editor": "cursor",
  "open_files": [
    "src/auth/middleware.ts",
    "src/auth/strategy.ts"
  ],
  "git_status_short": " M src/auth/middleware.ts\n M src/auth/strategy.ts\n"
}

Event: post-commit

Triggered after every commit. Captures the ending state, the diff context, and any AI-specific metadata.

json
{
  "event": "session_end",
  "timestamp": "2024-11-15T10:47:22Z",
  "commit_hash": "f4e5d6c7b8a9",
  "commit_message": "refactor(auth): extract token validation into strategy pattern",
  "files_changed": 3,
  "lines_added": 147,
  "lines_deleted": 89,
  "commit_diff_snapshot": "...",
  "ai_metadata": {
    "accepted_suggestions": 12,
    "rejected_suggestions": 3,
    "prompt_count": 7,
    "tokens_estimated": 4200
  },
  "session_duration_seconds": 4621
}

Event: pre-push

Triggered before pushing. Runs aggregation: merges loose session fragments into complete session records and rebuilds the index.

json
{
  "event": "aggregation_checkpoint",
  "timestamp": "2024-11-15T10:47:25Z",
  "sessions_aggregated": 3,
  "total_events_logged": 47,
  "index_rebuilt": true
}

This event model gives us everything we need: temporal boundaries, code context, AI interaction signals, and commit linkage.

4. Building the Session Logger Hook

We'll write the hooks as bash scripts stored in .git/hooks/. The key insight is that these hooks are non-blocking—they must never prevent Git operations from completing, even if something fails.

Directory Structure

bash
project/
├── .git/
│   └── hooks/
│       ├── session_logger.sh
│       └── common.sh
├── .ai_sessions/
│   ├── events.jsonl
│   └── config.json
├── scripts/
│   ├── indexer.rs
│   └── search.rs
└── README.md

Shared Utility Functions

First, let's create the common utilities that every hook will use. Save this as .git/hooks/common.sh:

bash
#!/usr/bin/env bash
# common.sh — Shared utilities for AI session hooks

set -euo pipefail

# Paths (relative to repo root)
SESSIONS_DIR=".ai_sessions"
EVENTS_LOG="${SESSIONS_DIR}/events.jsonl"
CONFIG_FILE="${SESSIONS_DIR}/config.json"

# Ensure the sessions directory exists
ensure_sessions_dir() {
  mkdir -p "${SESSIONS_DIR}"
  if [[ ! -f "${CONFIG_FILE}" ]]; then
    cat > "${CONFIG_FILE}" <<'EOF'
{
  "version": 1,
  "created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  "capture_ai_metadata": true,
  "include_diff_snapshot": true,
  "max_diff_size_kb": 512
}
EOF
  fi
}

# Get current timestamp in ISO 8601 UTC
now_iso() {
  date -u +%Y-%m-%dT%H:%M:%SZ
}

# Get the current git commit hash
get_commit_hash() {
  git rev-parse HEAD 2>/dev/null || echo "unknown"
}

# Get the current branch name
get_branch() {
  git symbolic-ref --short HEAD 2>/dev/null || echo "(detached)"
}

# Get short git status
get_git_status() {
  git status --short 2>/dev/null || echo ""
}

# Get list of modified/tracked files
get_open_files() {
  git diff --name-only HEAD 2>/dev/null | jq -R -s 'split("\n") | map(select(length > 0))'
}

# Write a single JSON event to the append-only log
log_event() {
  local event_json="$1"
  echo "${event_json}" >> "${EVENTS_LOG}"
}

# Escape a string for safe JSON embedding
json_escape() {
  python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$1"
}

# Read AI metadata from editor-specific sources
# Returns JSON fragment or empty object
read_ai_metadata() {
  local editor="${1:-unknown}"
  local pid="${2:-}"
  
  case "${editor}" in
    cursor)
      read_cursor_metadata "${pid}" ;;
    vscode)
      read_vscode_metadata "${pid}" ;;
    *)
      echo '{}'
      ;;
  esac
}

read_cursor_metadata() {
  local pid="$1"
  local home="${HOME}"
  local cache_dir="${home}/.cursor"
  
  # Cursor stores conversation history in ~/.cursor/cache/
  # We look for the most recently modified conversation file
  if [[ -d "${cache_dir}/cache" ]]; then
    local latest_conv
    latest_conv=$(ls -t "${cache_dir}/cache/"*.json 2>/dev/null | head -1)
    if [[ -n "${latest_conv}" ]]; then
      jq '{
        accepted_suggestions: (.suggestions_accepted // 0),
        rejected_suggestions: (.suggestions_rejected // 0),
        prompt_count: (.prompts.count // 0),
        tokens_estimated: (.usage.total_tokens // 0)
      }' "${latest_conv}" 2>/dev/null || echo '{}'
      return
    fi
  fi
  echo '{}'
}

read_vscode_metadata() {
  local pid="$1"
  # VS Code doesn't expose AI suggestion counts directly.
  # We fall back to copilot conversation files if available.
  local home="${HOME}"
  local copilot_cache="${home}/.config/github-copilot"
  
  if [[ -f "${copilot_cache}/conversations.jsonl" ]]; then
    local count
    count=$(wc -l < "${copilot_cache}/conversations.jsonl" 2>/dev/null || echo 0)
    echo "{\"copilot_conversation_log_path\": \"${copilot_cache}/conversations.jsonl\", \"total_lines\": ${count}}"
  else
    echo '{}'
  fi
}

# Compute approximate session duration by finding the most recent
# session_start event for the current branch and calculating delta
compute_session_duration() {
  local since_ts="$1"
  if [[ -f "${EVENTS_LOG}" ]]; then
    local now
    now=$(date -u +%s)
    echo $(( now - since_ts ))
  else
    echo 0
  fi
}

The Post-Commit Hook

This is the heart of the system. It runs after every commit and captures the full session snapshot.

Create .git/hooks/post-commit:

bash
#!/usr/bin/env bash
# post-commit — Capture AI pair programming session end

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"

ensure_sessions_dir

# Get commit information
COMMIT_HASH=$(get_commit_hash)
COMMIT_MSG=$(git log -1 --format='%s' 2>/dev/null || echo "")
TIMESTAMP=$(now_iso)

# Count changed files and lines
FILES_CHANGED=$(git diff --stat HEAD~1 HEAD 2>/dev/null \
  | tail -1 \
  | grep -oP '\d+ file' | grep -oP '\d+' || echo 0)
LINES_ADDED=$(git diff --numstat HEAD~1 HEAD 2>/dev/null \
  | awk '{sum+=$1} END {print sum+0}')
LINES_DELETED=$(git diff --numstat HEAD~1 HEAD 2>/dev/null \
  | awk '{sum+=$2} END {print sum+0}')

# Find the most recent session_start for this branch
SESSION_START_TS=0
if [[ -f "${EVENTS_LOG}" ]]; then
  SESSION_START_TS=$(grep -o '"session_start"' -B 50 "${EVENTS_LOG}" \
    | grep -oP '"timestamp":\s*"\K[^"]+' \
    | tail -1 \
    | xargs -I{} date -d {} +%s 2>/dev/null || echo 0)
fi

DURATION=$(compute_session_duration "${SESSION_START_TS}")

# Get AI metadata — detect editor from environment
EDITOR_NAME="unknown"
if [[ -n "${CURSOR_PID:-}" ]]; then
  EDITOR_NAME="cursor"
elif [[ -n "${VSCODE_PID:-}" ]]; then
  EDITOR_NAME="vscode"
elif ps -p "${PPID}" &>/dev/null; then
  # Try to detect editor from parent process
  PARENT_CMD=$(ps -p "${PPID}" -o comm= 2>/dev/null || echo "")
  case "${PARENT_CMD}" in
    Cursor*) EDITOR_NAME="cursor" ;;
    Code*)   EDITOR_NAME="vscode" ;;
  esac
fi

AI_META=$(read_ai_metadata "${EDITOR_NAME}" "${PPID}")

# Capture diff snapshot (truncate to configured max size)
MAX_DIFF_KB=512
DIFF_SNAP=$(git diff HEAD~1 HEAD 2>/dev/null || echo "")
DIFF_BYTES=${#DIFF_SNAP}
if (( DIFF_BYTES > MAX_DIFF_KB * 1024 )); then
  DIFF_SNAP=$(echo "${DIFF_SNAP}" | head -c $(( MAX_DIFF_KB * 1024 )))
  DIFF_SNAP="${DIFF_SNAP}\n...[truncated]"
fi

# Build the session_end event as JSON
EVENT_JSON=$(jq -n \
  --arg event "session_end" \
  --arg ts "${TIMESTAMP}" \
  --arg commit "${COMMIT_HASH}" \
  --arg msg "${COMMIT_MSG}" \
  --argjson files "${FILES_CHANGED}" \
  --argjson added "${LINES_ADDED}" \
  --argjson deleted "${LINES_DELETED}" \
  --arg diff "${DIFF_SNAP}" \
  --argjson ai_meta "${AI_META}" \
  --argjson duration "${DURATION}" \
  '{
    event: $event,
    timestamp: $ts,
    commit_hash: $commit,
    commit_message: $msg,
    files_changed: $files,
    lines_added: $added,
    lines_deleted: $deleted,
    commit_diff_snapshot: $diff,
    ai_metadata: $ai_meta,
    session_duration_seconds: $duration
  }')

# Write to the events log
log_event "${EVENT_JSON}"

# Exit cleanly — never block Git
echo "[ai-sessions] Logged session end for ${COMMIT_HASH:0:8}"
exit 0

Make the hook executable:

bash
chmod +x .git/hooks/post-commit
chmod +x .git/hooks/common.sh

The Post-Checkout Hook

This captures the beginning of each session. Save as .git/hooks/post-checkout:

bash
#!/usr/bin/env bash
# post-checkout — Capture AI pair programming session start

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"

# post-checkout receives 3 args: previous HEAD, new HEAD, branch flag
PREV_HEAD="${1:-unknown}"
NEW_HEAD="${2:-unknown}"
BRANCH_FLAG="${3:-0}"

# Only log on branch checkouts (flag = 1), not file checkouts
if [[ "${BRANCH_FLAG}" != "1" ]]; then
  exit 0
fi

ensure_sessions_dir

TIMESTAMP=$(now_iso)
BRANCH=$(get_branch)
OPEN_FILES=$(get_open_files)
GIT_STATUS=$(json_escape "$(get_git_status)")

# Detect editor from environment
EDITOR_NAME="unknown"
if [[ -n "${CURSOR_PID:-}" ]]; then
  EDITOR_NAME="cursor"
elif [[ -n "${VSCODE_PID:-}" ]]; then
  EDITOR_NAME="vscode"
fi

EVENT_JSON=$(jq -n \
  --arg event "session_start" \
  --arg ts "${TIMESTAMP}" \
  --arg commit_before "${PREV_HEAD}" \
  --arg commit_after "${NEW_HEAD}" \
  --arg branch "${BRANCH}" \
  --arg working_dir "$(pwd)"
  --argjson editor_pid "${PPID}"
  --arg editor "${EDITOR_NAME}"
  --argjson open_files "${OPEN_FILES}"
  --arg git_status "${GIT_STATUS}"
  '{
    event: $event,
    timestamp: $ts,
    commit_before: $commit_before,
    commit_after: $commit_after,
    branch: $branch,
    working_dir: $working_dir,
    editor_pid: ($editor_pid | tonumber),
    editor: $editor,
    open_files: $open_files,
    git_status: $git_status
  }')

log_event "${EVENT_JSON}"

echo "[ai-sessions] Logged session start on ${BRANCH}"
exit 0

The Pre-Push Aggregation Hook

This hook compiles raw events into structured session records and rebuilds the index. Save as .git/hooks/pre-push:

bash
#!/usr/bin/env bash
# pre-push — Aggregate session data and rebuild index

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"

# Don't block the push — run aggregation in background if indexer is available
INDEXER_BIN="${SCRIPT_DIR}/../../scripts/ai-indexer"

ensure_sessions_dir

TIMESTAMP=$(now_iso)

# Count events in the log
TOTAL_EVENTS=0
if [[ -f "${EVENTS_LOG}" ]]; then
  TOTAL_EVENTS=$(wc -l < "${EVENTS_LOG}" | tr -d ' ')
fi

# Count completed sessions (pairs of start/end)
SESSIONS_AGGREGATED=0
if command -v "${INDEXER_BIN}" &>/dev/null; then
  SESSIONS_AGGREGATED=$(${INDEXER_BIN} aggregate --check "${EVENTS_LOG}" 2>/dev/null || echo 0)
fi

# Always log the checkpoint event
CHECKPOINT_JSON=$(jq -n \
  --arg event "aggregation_checkpoint" \
  --arg ts "${TIMESTAMP}" \
  --argjson agg "${SESSIONS_AGGREGATED}" \
  --argjson total "${TOTAL_EVENTS}"
  '{
    event: $event,
    timestamp: $ts,
    sessions_aggregated: $agg,
    total_events_logged: $total,
    index_rebuilt: true
  }')

log_event "${CHECKPOINT_JSON}"

exit 0

5. Parsing AI Session Data from Editor Telemetry

Capturing Git events is only half the problem. The other half is extracting the AI-specific data that lives inside your editor's cache. Different editors store this data in different places, and the formats vary widely.

Cursor (VS Code fork)

Cursor stores conversation history in ~/.cursor/cache/. Each conversation is a JSON file containing:

json
{
  "id": "conv_a1b2c3",
  "createdAt": "2024-11-15T09:00:00Z",
  "updatedAt": "2024-11-15T10:47:00Z",
  "messages": [
    {
      "role": "user",
      "content": "Refactor the auth middleware to use a strategy pattern",
      "timestamp": "2024-11-15T09:01:00Z"
    },
    {
      "role": "assistant",
      "content": "I'll extract the token validation into a strategy...",
      "suggestions": [
        {"file": "src/auth/middleware.ts", "range": [10, 45], "text": "// new impl"}
      ]
    }
  ],
  "usage": {
    "total_tokens": 4200,
    "prompts": 7
  },
  "suggestions_accepted": 12,
  "suggestions_rejected": 3
}

Our hook already reads this via read_cursor_metadata(). But we can do better.

Enhancing the Indexer with Conversation Context

The real power comes when the indexer enriches each session record with the full conversation transcript. Here's an enhanced read_cursor_metadata that pulls conversation snippets:

bash
read_cursor_full_context() {
  local cache_dir="${HOME}/.cursor/cache"
  local sessions_dir="${SESSIONS_DIR}/conversations"
  mkdir -p "${sessions_dir}"
  
  if [[ ! -d "${cache_dir}" ]]; then
    echo '{}'
    return
  fi
  
  local latest_conv
  latest_conv=$(ls -t "${cache_dir}"/*.json 2>/dev/null | head -1)
  
  if [[ -z "${latest_conv}" ]]; then
    echo '{}'
    return
  fi
  
  local basename
  basename=$(basename "${latest_conv}" .json)
  local dest="${sessions_dir}/${basename}.json"
  
  # Copy the full conversation (cheap I/O, valuable data)
  cp "${latest_conv}" "${dest}" 2>/dev/null || true
  
  # Extract structured metadata
  jq '{
    accepted_suggestions: (.suggestions_accepted // 0),
    rejected_suggestions: (.suggestions_rejected // 0),
    prompt_count: (.usage.prompts // 0),
    tokens_estimated: (.usage.total_tokens // 0),
    message_count: (.messages | length),
    files_touched: ([.messages[]?.suggestions[].file] | unique // []),
    first_prompt: ([.messages[]? | select(.role=="user") | .content] | first // ""),
    conversation_file: "'${dest}'"
  }' "${latest_conv}" 2>/dev/null || echo '{}'
}

VS Code / GitHub Copilot

Copilot stores conversations in ~/.config/github-copilot/conversations.jsonl. Each line is a JSON object representing one turn. This is more structured but less rich in metadata. The hook's read_vscode_metadata already captures the path and line count, which is sufficient for most use cases.

Claude Desktop

For Claude-powered editors, conversations live in ~/Library/Application Support/Claude/claude_desktop/protocol/. These are SQLite databases, which we can query directly:

bash
read_claude_metadata() {
  local db_path="${HOME}/Library/Application Support/Claude/claude_desktop/protocol/conversations.db"
  
  if [[ ! -f "${db_path}" ]]; then
    echo '{}'
    return
  fi
  
  sqlite3 "${db_path}" "
    SELECT json_object(
      'message_count', COUNT(*),
      'tokens_estimated', SUM(COALESCE(metadata->>'input_tokens', 0) + COALESCE(metadata->>'output_tokens', 0)),
      'last_updated', MAX(created_at)
    )
    FROM messages
    WHERE created_at > datetime('now', '-24 hours')
  " 2>/dev/null || echo '{}'
}

The pattern is consistent: detect the editor, read its cache, extract structured metadata, and link back to the raw conversation file. The indexer uses this linkage to enrich session records.

The indexer is a Rust program that reads the JSONL event log, pairs session starts with session ends, and builds an inverted index plus a SQLite database for querying. This is where the raw telemetry becomes actionable knowledge.

Project Setup

Create a new Rust project for the indexer:

bash
cd scripts
cargo init --name ai-indexer

Add dependencies to Cargo.toml:

toml
[package]
name = "ai-indexer"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
sqlite = "0.31"
utf8 = "0.7"
clap = { version = "4", features = ["derive"] }
walkdir = "2"
time = { version = "0.3", features = ["parsing", "formatting"] }

Core Data Model

Define the session and event types in src/models.rs:

rust
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionStart {
    pub event: String,
    pub timestamp: DateTime<Utc>,
    pub commit_before: String,
    pub commit_after: String,
    pub branch: String,
    pub working_dir: String,
    pub editor_pid: Option<i64>,
    pub editor: String,
    pub open_files: Vec<String>,
    pub git_status: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionEnd {
    pub event: String,
    pub timestamp: DateTime<Utc>,
    pub commit_hash: String,
    pub commit_message: String,
    pub files_changed: i64,
    pub lines_added: i64,
    pub lines_deleted: i64,
    pub commit_diff_snapshot: String,
    pub ai_metadata: AiMetadata,
    pub session_duration_seconds: i64,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct AiMetadata {
    pub accepted_suggestions: Option<i64>,
    pub rejected_suggestions: Option<i64>,
    pub prompt_count: Option<i64>,
    pub tokens_estimated: Option<i64>,
    pub message_count: Option<i64>,
    pub files_touched: Option<Vec<String>>,
    pub first_prompt: Option<String>,
    pub conversation_file: Option<String>,
    pub copilot_conversation_log_path: Option<String>,
    pub total_lines: Option<i64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairedSession {
    pub session_id: String,
    pub start: SessionStart,
    pub end: Option<SessionEnd>,
    pub ai_metadata: AiMetadata,
    pub keywords: Vec<String>,
    pub commit_hash: Option<String>,
    pub duration_seconds: i64,
    pub files_changed: Vec<String>,
}

impl PairedSession {
    pub fn keyword_string(&self) -> String {
        let mut parts = vec![];
        
        // Add commit message words
        if let Some(ref end) = self.end {
            for word in end.commit_message.split_whitespace() {
                let clean: String = word.chars()
                    .filter(|c| c.is_alphanumeric())
                    .collect();
                if !clean.is_empty() && clean.len() > 2 {
                    parts.push(clean.to_lowercase());
                }
            }
        }
        
        // Add first prompt words
        if let Some(ref prompt) = self.ai_metadata.first_prompt {
            for word in prompt.split_whitespace() {
                let clean: String = word.chars()
                    .filter(|c| c.is_alphanumeric())
                    .collect();
                if !clean.is_empty() && clean.len() > 2 {
                    parts.push(clean.to_lowercase());
                }
            }
        }
        
        // Add file paths as keywords
        for file in &self.files_changed {
            for part in file.split('/').filter(|s| !s.is_empty()) {
                let clean: String = part.chars()
                    .filter(|c| c.is_alphanumeric())
                    .collect();
                if !clean.is_empty() {
                    parts.push(clean.to_lowercase());
                }
            }
        }
        
        // Add branch name
        for part in self.start.branch.split('/').filter(|s| !s.is_empty()) {
            parts.push(part.to_lowercase());
        }
        
        parts.dedup();
        parts.join(" ")
    }
}

The Indexing Pipeline

The core indexing logic lives in src/indexer.rs:

rust
use std::collections::{HashMap, HashSet};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use chrono::Utc;

use crate::models::{PairedSession, SessionEnd, SessionStart};

pub struct SessionIndexer {
    events_log: String,
    sessions_dir: String,
    index_db_path: String,
}

impl SessionIndexer {
    pub fn new(events_log: &str, sessions_dir: &str) -> Self {
        Self {
            events_log: events_log.to_string(),
            sessions_dir: sessions_dir.to_string(),
            index_db_path: format!("{}/index.db", sessions_dir),
        }
    }
    
    /// Read all events from the JSONL log
    pub fn read_events(&self) -> Result<Vec<serde_json::Value>, String> {
        let file = File::open(&self.events_log)
            .map_err(|e| format!("Failed to open events log: {}", e))?;
        
        let reader = BufReader::new(file);
        let mut events = Vec::new();
        
        for line in reader.lines() {
            let line = line.map_err(|e| format!("Failed to read line: {}", e))?;
            if line.trim().is_empty() {
                continue;
            }
            let event: serde_json::Value = serde_json::from_str(&line)
                .map_err(|e| format!("Failed to parse JSON: {}", e))?;
            events.push(event);
        }
        
        Ok(events)
    }
    
    /// Pair session starts with their corresponding ends
    pub fn pair_sessions(&self, events: &[serde_json::Value]) -> Vec<PairedSession> {
        let mut starts: HashMap<String, SessionStart> = HashMap::new();
        let mut sessions: Vec<PairedSession> = Vec::new();
        let mut session_counter = 0u32;
        
        for event in events {
            let event_type = event["event"].as_str().unwrap_or("");
            
            match event_type {
                "session_start" => {
                    let ts_str = event["timestamp"].as_str().unwrap_or("");
                    let branch = event["branch"].as_str().unwrap_or("").to_string();
                    let key = format!("{}:{}", branch, ts_str);
                    
                    if let Ok(start) = serde_json::from_value(event.clone()) {
                        starts.insert(key, start);
                    }
                }
                "session_end" => {
                    let commit_hash = event["commit_hash"].as_str().unwrap_or("");
                    let ts_str = event["timestamp"].as_str().unwrap_or("");
                    
                    // Find the most recent start for this commit
                    let mut best_key: Option<String> = None;
                    let mut best_score: i64 = i64::MAX;
                    
                    for (key, start) in &starts {
                        // Score by temporal proximity and commit match
                        let score = if start.commit_after == commit_hash {
                            0
                        } else {
                            1_000_000
                        };
                        
                        if score < best_score {
                            best_score = score;
                            best_key = Some(key.clone());
                        }
                    }
                    
                    if let Some(ref key) = best_key {
                        if let Some(start) = starts.remove(key) {
                            session_counter += 1;
                            let session_id = format!("sess_{}_{:04x}", 
                                Utc::now().timestamp(), session_counter);
                            
                            let ai_meta: AiMetadata = 
                                event.get("ai_metadata")
                                    .and_then(|v| serde_json::from_value(v.clone()).ok())
                                    .unwrap_or_default();
                            
                            let end: SessionEnd = 
                                serde_json::from_value(event.clone()).unwrap_or_default();
                            
                            // Collect files changed from diff snapshot
                            let files_changed = self.extract_files_from_diff(
                                &end.commit_diff_snapshot
                            );
                            
                            sessions.push(PairedSession {
                                session_id,
                                start,
                                end: Some(end),
                                ai_metadata: ai_meta,
                                keywords: vec![], // filled in below
                                commit_hash: Some(commit_hash.to_string()),
                                duration_seconds: end.session_duration_seconds,
                                files_changed,
                            });
                        }
                    }
                }
                _ => {}
            }
        }
        
        // Process any unpaired starts (sessions that didn't end cleanly)
        for (key, start) in starts {
            session_counter += 1;
            let session_id = format!("sess_orphan_{}", key.hash());
            
            sessions.push(PairedSession {
                session_id,
                start,
                end: None,
                ai_metadata: AiMetadata::default(),
                keywords: vec![],
                commit_hash: None,
                duration_seconds: 0,
                files_changed: vec![],
            });
        }
        
        // Compute keywords for each session
        for session in &mut sessions {
            session.keywords = session.keyword_string().split_whitespace()
                .map(|s| s.to_string())
                .collect();
        }
        
        sessions.sort_by(|a, b| a.start.timestamp.cmp(&b.start.timestamp));
        sessions
    }
    
    /// Build the SQLite index database
    pub fn build_index(&self, sessions: &[PairedSession]) -> Result<(), String> {
        fs::create_dir_all(&self.sessions_dir)
            .map_err(|e| format!("Failed to create sessions dir: {}", e))?;
        
        let db = sqlite::open(&self.index_db_path)
            .map_err(|e| format!("Failed to open database: {}", e))?;
        
        // Create tables
        db.execute(r#"
            CREATE TABLE IF NOT EXISTS sessions (
                session_id TEXT PRIMARY KEY,
                timestamp TEXT NOT NULL,
                branch TEXT NOT NULL,
                editor TEXT,
                commit_hash TEXT,
                commit_message TEXT,
                duration_seconds INTEGER,
                files_changed TEXT,
                ai_accepted_suggestions INTEGER,
                ai_rejected_suggestions INTEGER,
                ai_prompt_count INTEGER,
                ai_tokens_estimated INTEGER,
                first_prompt TEXT,
                keywords TEXT NOT NULL,
                start_commit TEXT,
                end_commit TEXT
            )
        "#).map_err(|e| e.to_string())?;
        
        db.execute(r#"
            CREATE INDEX IF NOT EXISTS idx_sessions_branch ON sessions(branch);
            CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp);
            CREATE INDEX IF NOT EXISTS idx_sessions_commit ON sessions(commit_hash);
            CREATE INDEX IF NOT EXISTS idx_sessions_keywords ON sessions(keywords);
        "#).map_err(|e| e.to_string())?;
        
        // Clear and re-populate
        db.execute("DELETE FROM sessions").map_err(|e| e.to_string())?;
        
        let tx = db.transaction().map_err(|e| e.to_string())?;
        
        for session in sessions {
            let end_msg = session.end.as_ref()
                .map(|e| &e.commit_message as &str)
                .unwrap_or("");
            let end_commit = session.end.as_ref()
                .map(|e| e.commit_hash.as_str())
                .unwrap_or("");
            let start_commit = &session.start.commit_after;
            
            let accepted = session.ai_metadata.accepted_suggestions
                .unwrap_or(0) as i64;
            let rejected = session.ai_metadata.rejected_suggestions
                .unwrap_or(0) as i64;
            let prompts = session.ai_metadata.prompt_count
                .unwrap_or(0) as i64;
            let tokens = session.ai_metadata.tokens_estimated
                .unwrap_or(0) as i64;
            let first_prompt = session.ai_metadata.first_prompt
                .as_deref().unwrap_or("");
            
            let files_json = serde_json::to_string(&session.files_changed)
                .map_err(|e| e.to_string())?;
            let keywords_json = serde_json::to_string(&session.keywords)
                .map_err(|e| e.to_string())?;
            
            tx.execute(&format!(
                "INSERT INTO sessions VALUES (
                    '{},',
                    '{},',
                    '{},',
                    '{},',
                    '{},',
                    '{},',
                    {},
                    '{}',
                    {},
                    {},
                    {},
                    {},
                    '{}',
                    '{}',
                    '{},',
                    '{},' 
                )",
                session.session_id,
                session.start.timestamp.to_rfc3339(),
                session.start.branch,
                session.start.editor,
                session.commit_hash.as_deref().unwrap_or(""),
                end_msg.replace('"', "\\\""),
                session.duration_seconds,
                files_json.replace('"', "\\\""),
                accepted,
                rejected,
                prompts,
                tokens,
                first_prompt.replace('"', "\\\""),
                keywords_json.replace('"', "\\\""),
                start_commit,
                end_commit
            )).map_err(|e| format!("Insert failed: {}", e))?;
        }
        
        tx.commit().map_err(|e| e.to_string())?;
        
        // Also write a human-readable index summary
        self.write_index_summary(sessions)?;
        
        Ok(())
    }
    
    fn write_index_summary(&self, sessions: &[PairedSession]) -> Result<(), String> {
        let summary_path = format!("{}/index.md", self.sessions_dir);
        let mut file = OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .open(&summary_path)
            .map_err(|e| e.to_string())?;
        
        writeln!(file, "# AI Session Index\n")?;
        writeln!(file, "Generated: {}\n", Utc::now().to_rfc3339())?;
        writeln!(file, "**Total sessions:** {}\n", sessions.len())?;
        writeln!(file, "| Session ID | Timestamp | Branch | Commit | Duration | Prompts |")?;
        writeln!(file, "|---|---|---|---|---|---|")?;
        
        for session in sessions {
            let ts = session.start.timestamp.format("%m-%d %H:%M").to_string();
            let commit = session.commit_hash
                .as_ref()
                .map(|h| &h[..8])
                .unwrap_or("orphan");
            let prompts = session.ai_metadata.prompt_count
                .unwrap_or(0);
            let duration = if session.duration_seconds > 0 {
                format!("{}m", session.duration_seconds / 60)
            } else {
                "—".to_string()
            };
            
            writeln!(file, 
                "| `{}` | {} | {} | `{}` | {} | {} |",
                &session.session_id[..12], ts, session.start.branch, commit, duration, prompts
            )?;
        }
        
        Ok(())
    }
    
    fn extract_files_from_diff(&self, diff: &str) -> Vec<String> {
        let mut files = HashSet::new();
        for line in diff.lines() {
            if line.starts_with("diff --git") {
                // Extract file path from "diff --git a/path b/path"
                let parts: Vec<&str> = line.split_whitespace().collect();
                if parts.len() >= 3 {
                    let path = parts[2].strip_prefix('a/').unwrap_or(parts[2]);
                    files.insert(path.to_string());
                }
            }
        }
        files.into_iter().collect()
    }
}

The Main Entry Point

rust
// src/main.rs
mod models;
mod indexer;

use clap::{Parser, Subcommand};
use indexer::SessionIndexer;
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(name = "ai-indexer")]
#[command(about = "Build and manage AI session indexes")]
struct Cli {
    #[arg(short, long, default_value = ".ai_sessions")]
    sessions_dir: PathBuf,
    
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Build the index from the events log
    Build {
        #[arg(short, long, default_value = "events.jsonl")]
        events_log: String,
    },
    
    /// Aggregate pending sessions (called from pre-push hook)
    Aggregate {
        #[arg(long, default_value = "events.jsonl")]
        events_log: String,
    },
    
    /// Count aggregated sessions
    Check {
        #[arg(long, default_value = "events.jsonl")]
        events_log: String,
    },
    
    /// Export sessions as JSON
    Export {
        #[arg(long, default_value = "export.json")]
        output: String,
    },
}

fn main() {
    let cli = Cli::parse();
    let sessions_dir = cli.sessions_dir.canonicalize()
        .unwrap_or_else(|_| PathBuf::from(".ai_sessions"));
    let events_log = sessions_dir.join(&cli.command.events_log_name());
    
    let indexer = SessionIndexer::new(
        events_log.to_string_lossy(),
        sessions_dir.to_string_lossy(),
    );
    
    match &cli.command {
        Commands::Build { .. } => {
            let events = match indexer.read_events() {
                Ok(e) => e,
                Err(e) => {
                    eprintln!("Error reading events: {}", e);
                    std::process::exit(1);
                }
            };
            
            let sessions = indexer.pair_sessions(&events);
            
            match indexer.build_index(&sessions) {
                Ok(_) => {
                    println!("Indexed {} sessions successfully.", sessions.len());
                    println!("Index location: {}/index.db", sessions_dir.display());
                },
                Err(e) => {
                    eprintln!("Index build failed: {}", e);
                    std::process::exit(1);
                }
            }
        }
        Commands::Aggregate { .. } => {
            // Same as build but non-destructive (merge mode)
            let events = match indexer.read_events() {
                Ok(e) => e,
                Err(e) => {
                    eprintln!("Error: {}", e);
                    std::process::exit(1);
                }
            };
            let sessions = indexer.pair_sessions(&events);
            if let Err(e) = indexer.build_index(&sessions) {
                eprintln!("Aggregation failed: {}", e);
                std::process::exit(1);
            }
            println!("Aggregated {} sessions.", sessions.len());
        }
        Commands::Check { .. } => {
            // Return count for the pre-push hook
            match indexer.read_events() {
                Ok(events) => {
                    let sessions = indexer.pair_sessions(&events);
                    println!("{}", sessions.len());
                },
                Err(_) => println!("0"),
            }
        }
        Commands::Export { output } => {
            let events = match indexer.read_events() {
                Ok(e) => e,
                Err(e) => {
                    eprintln!("Error: {}", e);
                    std::process::exit(1);
                }
            };
            let sessions = indexer.pair_sessions(&events);
            let json = serde_json::to_string_pretty(&sessions)
                .expect("Failed to serialize");
            std::fs::write(output, json)
                .expect("Failed to write export");
            println!("Exported {} sessions to {}", sessions.len(), output);
        }
    }
}

// Helper extension trait for clap commands
trait CommandExt {
    fn events_log_name(&self) -> String;
}

impl CommandExt for Commands {
    fn events_log_name(&self) -> String {
        match self {
            Commands::Build { events_log } => events_log.clone(),
            Commands::Aggregate { events_log } => events_log.clone(),
            Commands::Check { events_log } => events_log.clone(),
            Commands::Export { .. } => "events.jsonl".to_string(),
        }
    }
}

Building and Installing

bash
cd scripts
cargo build --release
# Copy the binary to a location in your PATH or keep it alongside the hooks
cp target/release/ai-indexer ../

Update the pre-push hook to call the indexer:

bash
# In .git/hooks/pre-push, add before the aggregation logging:
if [[ -x "${SCRIPT_DIR}/../../scripts/ai-indexer" ]]; then
  "${SCRIPT_DIR}/../../scripts/ai-indexer" aggregate \
    --sessions-dir "${SCRIPT_DIR}/../.ai_sessions"
fi

7. Search Interface: Querying Your AI Memory

Now that we have a structured SQLite database, let's build a search CLI. This gives you instant access to every AI session you've ever had.

Search CLI Design

Create src/search.rs (add to Cargo.toml under [bin] as ai-search):

rust
// search.rs — CLI for querying AI sessions
use clap::{Parser, Subcommand};
use sqlite::{Connection, State};
use std::process;

#[derive(Parser, Debug)]
#[command(name = "ai-search")]
#[command(about = "Search your AI pair programming sessions")]
struct Cli {
    #[arg(short, long, default_value = ".ai_sessions/index.db")]
    db: String,
    
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Search sessions by keywords
    Query {
        #[arg(trailing_var_arg = true)]
        terms: Vec<String>,
        #[arg(short, long, default_value = "5")]
        limit: usize,
        #[arg(short, long)]
        recent: bool,
    },
    
    /// Show a specific session by ID or commit hash
    Session {
        id: String,
    },
    
    /// Show session statistics
    Stats {
        #[arg(short, long)]
        since: Option<String>,
        #[arg(short, long)]
        branch: Option<String>,
    },
    
    /// List all branches with sessions
    Branches,
    
    /// Find sessions touching a specific file
    Files {
        path: String,
    },
}

fn connect(db: &str) -> Connection {
    Connection::open(db).unwrap_or_else(|e| {
        eprintln!("Failed to open index: {}", e);
        process::exit(1);
    })
}

fn main() {
    let cli = Cli::parse();
    let conn = connect(&cli.db);
    
    match cli.command {
        Commands::Query { terms, limit, recent } => {
            if terms.is_empty() {
                eprintln!("Usage: ai-search query <keywords...>");
                process::exit(1);
            }
            
            let where_clause = terms.iter()
                .map(|t| format!("keywords LIKE '%{}%'", t.replace('"', "\\\"")))
                .collect::<Vec<_>>()
                .join(" AND ");
            
            let order = if recent { "timestamp DESC" } else { "timestamp ASC" };
            
            let sql = format!(
                "SELECT session_id, timestamp, branch, commit_hash, \
                 commit_message, duration_seconds, ai_prompt_count, \
                 ai_accepted_suggestions \
                 FROM sessions WHERE {} \
                 ORDER BY {} LIMIT {}",
                where_clause, order, limit
            );
            
            let mut stmt = conn.prepare(&sql).unwrap();
            stmt.next().unwrap();
            
            println!("\x1b[1mAI Session Search Results\x1b[0m");
            println!("Query: {}\n", terms.join(" "));
            println!("{:<16} {:<20} {:<15} {:<10} {:<8} {:<8} {}",
                "Session", "Time", "Branch", "Commit", "Dur", "Prompts", "Message");
            println!("{}", "─".repeat(100));
            
            loop {
                let row = stmt.next();
                if row == State::Done {
                    break;
                }
                if let Ok(row) = row {
                    let sid = format!("{}", row[0]);
                    let ts = format!("{}", row[1])[11..19].to_string();
                    let branch = row[2];
                    let commit = format!("{}", row[3]);
                    let dur = format!("{}m", row[5] as f64 / 60.0);
                    let prompts = row[6];
                    let msg = if row[4].len() > 35 {
                        format!("{}...", &row[4][..35])
                    } else {
                        row[4]
                    };
                    println!("{:<16} {:<20} {:<15} {:<10} {:<8} {:<8} {}",
                        &sid[..12], ts, branch, &commit[..8], dur, prompts, msg);
                }
            }
        }
        
        Commands::Session { id } => {
            let sql = "SELECT * FROM sessions \
                       WHERE session_id = ? OR commit_hash = ? \
                       LIMIT 1";
            let mut stmt = conn.prepare(sql).unwrap();
            stmt.bind(1, &id).unwrap();
            stmt.bind(2, &id).unwrap();
            
            if let Ok(Some(row)) = stmt.next() {
                println!("\x1b[1mSession: {}\x1b[0m", row[0]);
                println!("Branch: {}", row[2]);
                println!("Started: {}", row[1]);
                println!("Commit: {}", row[4]);
                println!("Message: {}", row[5]);
                println!("Duration: {}s", row[6]);
                println!("Editor: {}", row[3]);
                println!("AI Prompts: {}", row[8]);
                println!("AI Suggestions Accepted: {}", row[9]);
                println!("AI Suggestions Rejected: {}", row[10]);
                println!("AI Tokens Estimated: {}", row[11]);
                if !row[12].is_empty() {
                    println!("\nFirst Prompt:");
                    println!("  {}", row[12]);
                }
                println!("\nKeywords: {}", row[13]);
            } else {
                eprintln!("No session found for '{}'", id);
                process::exit(1);
            }
        }
        
        Commands::Stats { since, branch } => {
            let mut conditions = vec![];
            if let Some(s) = since {
                conditions.push(format!("timestamp >= '{}'", s));
            }
            if let Some(b) = branch {
                conditions.push(format!("branch = '{}'", b.replace('"', "\\\"")));
            }
            let where_sql = if conditions.is_empty() { 
                "".to_string() 
            } else {
                format!("WHERE {}", conditions.join(" AND "))
            };
            
            // Total sessions
            let total = query_scalar(&conn, 
                &format!("SELECT COUNT(*) FROM sessions {}", where_sql));
            
            // Total prompts
            let prompts = query_scalar(&conn, 
                &format!("SELECT COALESCE(SUM(ai_prompt_count), 0) FROM sessions {}", where_sql));
            
            // Total accepted suggestions
            let accepted = query_scalar(&conn, 
                &format!("SELECT COALESCE(SUM(ai_accepted_suggestions), 0) FROM sessions {}", where_sql));
            
            // Total rejected suggestions
            let rejected = query_scalar(&conn, 
                &format!("SELECT COALESCE(SUM(ai_rejected_suggestions), 0) FROM sessions {}", where_sql));
            
            // Average duration
            let avg_duration = query_scalar(&conn, 
                &format!("SELECT COALESCE(AVG(duration_seconds), 0) FROM sessions {}", where_sql));
            
            // Total tokens estimated
            let tokens = query_scalar(&conn, 
                &format!("SELECT COALESCE(SUM(ai_tokens_estimated), 0) FROM sessions {}", where_sql));
            
            // Top branches
            let branches_sql = format!(
                "SELECT branch, COUNT(*) as cnt FROM sessions {} \
                 GROUP BY branch ORDER BY cnt DESC LIMIT 5",
                where_sql
            );
            let mut branch_stmt = conn.prepare(&branches_sql).unwrap();
            
            println!("\x1b[1mAI Session Statistics\x1b[0m\n");
            println!("Total sessions:     {}", total);
            println!("Total prompts:      {}", prompts);
            println!("Suggestions accepted: {}", accepted);
            println!("Suggestions rejected: {}", rejected);
            println!("Accept rate:         {:.1}%", 
                if prompts > 0 { accepted as f64 / prompts as f64 * 100.0 } else { 0.0 });
            println!("Avg session duration: {}s", avg_duration);
            println!("Total tokens used:   {}", tokens);
            
            println!("\nTop branches:");
            branch_stmt.next().unwrap();
            loop {
                if branch_stmt.next() == State::Done { break; }
                if let Ok(row) = branch_stmt.next() {
                    println!("  {:<25} {} sessions", row[0], row[1]);
                }
            }
        }
        
        Commands::Branches => {
            let sql = "SELECT DISTINCT branch, COUNT(*) as cnt \
                       FROM sessions GROUP BY branch ORDER BY cnt DESC";
            let mut stmt = conn.prepare(sql).unwrap();
            stmt.next().unwrap();
            
            println!("\x1b[1mBranches with AI Sessions\x1b[0m\n");
            println!("{:<40} {:>10}", "Branch", "Sessions");
            println!("{}", "─".repeat(52));
            
            loop {
                if stmt.next() == State::Done { break; }
                if let Ok(row) = stmt.next() {
                    println!("{:<40} {:>10}", row[0], row[1]);
                }
            }
        }
        
        Commands::Files { path } => {
            // Search in the files_changed JSON array
            let sql = format!(
                "SELECT session_id, timestamp, branch, commit_hash, \
                 commit_message \
                 FROM sessions \
                 WHERE files_changed LIKE '%{}%'
                 ORDER BY timestamp DESC LIMIT 10",
                path.replace('"', "\\\"")
            );
            
            let mut stmt = conn.prepare(&sql).unwrap();
            stmt.next().unwrap();
            
            println!("\x1b[1mSessions touching '{}':\x1b[0m\n", path);
            println!("{:<16} {:<20} {:<15} {:<10} {}",
                "Session", "Time", "Branch", "Commit", "Message");
            println!("{}", "─".repeat(80));
            
            loop {
                if stmt.next() == State::Done { break; }
                if let Ok(row) = stmt.next() {
                    let msg = if row[4].len() > 25 {
                        format!("{}...", &row[4][..25])
                    } else { row[4] };
                    println!("{:<16} {:<20} {:<15} {:<10} {}",
                        &row[0][..12], &row[1][11..19], row[2], &row[3][..8], msg);
                }
            }
        }
    }
}

fn query_scalar(conn: &Connection, sql: &str) -> String {
    let mut stmt = conn.prepare(sql).unwrap();
    if let Ok(Some(row)) = stmt.next() {
        row[0]
    } else {
        "0".to_string()
    }
}

Wire It Up

Add the search binary to Cargo.toml:

toml
[[bin]]
name = "ai-indexer"
path = "main.rs"

[[bin]]
name = "ai-search"
path = "search.rs"

Build both:

bash
cargo build --release
cp target/release/ai-indexer ../../
cp target/release/ai-search ../../

Now you have two working binaries. Put them in your project root or a directory in your PATH.

Let's walk through the complete lifecycle of a single AI-assisted coding session, from the moment you open your editor to the moment you search for it weeks later.

Step 1: Start a Session

You create a new branch and open Cursor:

bash
git checkout -b feature/auth-refactor
git hooks/post-checkout
# Output: [ai-sessions] Logged session start on feature/auth-refactor

The post-checkout hook records the initial state: branch, open files, editor PID, current commit.

Step 2: Work with AI

You prompt your AI pair programmer to refactor the auth middleware. It suggests 12 changes across 3 files. You accept 9, reject 3. The conversation happens entirely inside Cursor's UI.

Step 3: Commit

You stage and commit the changes:

bash
git add src/auth/
git commit -m "refactor(auth): extract token validation into strategy pattern"
# Output: [ai-sessions] Logged session end for f4e5d6c7

The post-commit hook captures the diff, commit message, and AI metadata from Cursor's cache. It pairs this with the earlier session_start event to form a complete session record.

Step 4: Index

Before pushing, the pre-push hook runs the indexer:

bash
git push
# The pre-push hook triggers: ai-indexer aggregate
# Output: Aggregated 1 sessions.

The indexer reads the JSONL log, pairs the start and end events, builds the SQLite database, and writes the markdown summary.

Step 5: Search

Later, you want to find that session because you need to remember why you chose a particular approach:

bash
ai-search query auth strategy refactor
# Output:
# AI Session Search Results
# Query: auth strategy refactor
#
# Session            Time             Branch                  Commit     Dur    Prompts  Message
# ───────────────────────────────────────────────────────────────────────────────────────
# sess_1731...       11-15 09:23      feature/auth-refactor   f4e5d6c7   24m    7        refactor(auth): extract token...

Or you search by file:

bash
ai-search files src/auth/middleware.ts
# Shows all sessions that touched that file

Or you check your overall AI usage stats:

bash
ai-search stats --since 2024-11-01
# Output:
# AI Session Statistics
#
# Total sessions:      47
# Total prompts:       312
# Suggestions accepted: 284
# Suggestions rejected: 28
# Accept rate:         91.0%
# Avg session duration: 1847s
# Total tokens used:   1,247,832

Step 6: Review in a PR

When you open a pull request, your team can see the AI session context. The .ai_sessions/index.md file can be committed to the repository (or referenced in the PR description), giving reviewers visibility into the AI-assisted reasoning behind the changes.

markdown
## AI-Assisted Changes

This PR was developed with AI pair programming (Cursor). 
See [AI Session Index](.ai_sessions/index.md) for session details.

**Session:** `sess_1731a2b3`
- **Prompt:** "Refactor the auth middleware to use a strategy pattern"
- **Suggestions:** 12 accepted, 3 rejected
- **Duration:** 24 minutes
- **Branch:** feature/auth-refactor

9. Production Hardening and Edge Cases

The system works, but production use requires handling several edge cases and hardening concerns.

Hook Failure Isolation

Hooks must never prevent Git from completing. Add defensive error handling to every hook:

bash
# At the top of every hook:
set +e  # Don't exit on error
# ... hook logic ...
exit 0  # Always exit cleanly

Log Rotation

The JSONL log grows without bound. Add rotation to your pre-push hook:

bash
# In pre-push, before aggregation:
MAX_LINES=10000
if [[ -f "${EVENTS_LOG}" ]]; then
  LINE_COUNT=$(wc -l < "${EVENTS_LOG}" | tr -d ' ')
  if (( LINE_COUNT > MAX_LINES )); then
    # Keep the last 5000 lines and archive the rest
    tail -n ${MAX_LINES}/2 "${EVENTS_LOG}" > "${EVENTS_LOG}.tmp"
    mv "${EVENTS_LOG}.tmp" "${EVENTS_LOG}"
    echo "[ai-sessions] Rotated events log (>${MAX_LINES} lines)"
  fi
fi

Multi-Editor Support

Your team may use different editors. Make the editor detection configurable:

json
// .ai_sessions/config.json
{
  "version": 1,
  "capture_ai_metadata": true,
  "editor": "auto",
  "editor_overrides": {
    "cursor": "~/.cursor/cache",
    "vscode": "~/.config/github-copilot",
    "claude": "~/Library/Application Support/Claude/claude_desktop/protocol"
  }
}

Privacy Considerations

Not all AI conversation content is safe to store. Add filtering:

bash
# In common.sh, add a privacy filter
filter_sensitive_data() {
  local json="$1"
  # Remove or mask fields that might contain sensitive info
  echo "${json}" | jq '
    del(.ai_metadata.conversation_file // empty) |
    del(.ai_metadata.first_prompt // empty) |
    if (.ai_metadata.tokens_estimated // 0) > 100000 then
      .ai_metadata.tokens_estimated = null
    else . end
  ' 2>/dev/null || echo "${json}"
}

Cross-Repository Indexing

For teams working across multiple repos, consider a global index:

bash
# Set this in your global Git config
git config --global ai-sessions.global-dir "~/.ai-global-index"

Then modify the hooks to also write to the global index:

bash
GLOBAL_INDEX="${GITAI_GLOBAL_DIR:-$HOME/.ai-global-index}/index.db"
# After local aggregation, sync to global
if [[ -n "${GITAI_GLOBAL_DIR:-}" ]]; then
  ai-indexer export --output "${SESSIONS_DIR}/export.json"
  # Push to global index (implementation depends on your setup)
fi

Integration with CI/CD

You can run the indexer as part of your CI pipeline to generate reports:

yaml
# .github/workflows/ai-audit.yml
name: AI Session Audit
on:
  push:
    branches: [main]
  schedule:
    - cron: '0

# Git Hooks for AI Pair Programming: Capturing, Indexing, and Searching Your AI Coding Sessions

...s on your setup)
fi

Integration with CI/CD

You can run the indexer as part of your CI pipeline to generate reports:

yaml
# .github/workflows/ai-audit.yml
name: AI Session Audit
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 2 * * *'  # Daily at 2 AM

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for proper git history analysis
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      
      - name: Install dependencies
        run: pip install -r requirements.txt
      
      - name: Run session indexer
        run: python scripts/index_sessions.py --output dist/sessions.json
      
      - name: Generate audit report
        run: python scripts/generate_report.py --input dist/sessions.json --output dist/report.md
      
      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: ai-audit-report
          path: dist/

This integration ensures that your AI coding sessions are regularly indexed and reported on, providing a continuous view of how AI tools are impacting your development workflow.

Advanced: Real-Time Dashboard

For teams that want immediate visibility into AI usage, you can set up a lightweight dashboard using the indexed data.

Dashboard Server

python
# dashboard/app.py
from flask import Flask, jsonify, render_template_string
import json
from pathlib import Path

app = Flask(__name__)
SESSIONS_FILE = Path("dist/sessions.json")

DASHBOARD_HTML = """
<!DOCTYPE html>
<html>
<head>
    <title>AI Session Dashboard</title>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        .stats { display: flex; gap: 20px; margin-bottom: 30px; }
        .stat-card { background: #f5f5f5; padding: 20px; border-radius: 8px; flex: 1; }
        .stat-value { font-size: 2em; font-weight: bold; color: #333; }
        .stat-label { color: #666; }
        .chart-container { max-width: 800px; margin: 0 auto; }
    </style>
</head>
<body>
    <h1>AI Coding Sessions Dashboard</h1>
    
    <div class  = "stats">
        <div class="stat-card">
            <div class="stat-value">{{ total_sessions }}</div>
            <div class="stat-label">Total Sessions</div>
        </div>
        <div class="stat-card">
            <div class="stat-value">{{ total_prompts }}</div>
            <div class="stat-label">Total Prompts</div>
        </div>
        <div class="stat-card">
            <div class="stat-value">{{ avg_tokens | round(0) }}</div>
            <div class="stat-label">Avg Tokens/Session</div>
        </div>
    </div>
    
    <div class="chart-container">
        <canvas id="sessionsChart"></canvas>
    </div>
    
    <script>
        const ctx = document.getElementById('sessionsChart').getContext('2d');
        new Chart(ctx, {
            type: 'bar',
            data: {
                labels: {{ labels | tojson }},
                datasets: [{
                    label: 'Sessions per Day',
                    data: {{ counts | tojson }},
                    backgroundColor: 'rgba(54, 162, 235, 0.5)'
                }]
            },
            options: { responsive: true }
        });
    </script>
</body>
</html>
"""

@app.route('/')
def dashboard():
    if not SESSIONS_FILE.exists():
        return "No session data found. Run the indexer first."
    
    with open(SESSIONS_FILE) as f:
        data = json.load(f)
    
    sessions = data.get("sessions", [])
    
    # Calculate stats
    total_sessions = len(sessions)
    total_prompts = sum(len(s.get("prompts", [])) for s in sessions)
    avg_tokens = sum(s.get("total_tokens", 0) for s in sessions) / max(total_sessions, 1)
    
    # Prepare chart data (last 7 days)
    from datetime import datetime, timedelta
    dates = [(datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7, -1, -1)]
    counts = []
    labels = []
    
    for date in dates:
        day_sessions = [s for s in sessions if s.get("start_time", "")[:10] == date]
        counts.append(len(day_sessions))
        labels.append(date)
    
    return render_template_string(DASHBOARD_HTML, 
                                 total_sessions=total_sessions,
                                 total_prompts=total_prompts,
                                 avg_tokens=avg_tokens,
                                 labels=labels,
                                 counts=counts)

@app.route('/api/sessions')
def api_sessions():
    if not SESSIONS_FILE.exists():
        return jsonify({"error": "No data"}), 404
    
    with open(SESSIONS_FILE) as f:
        return json.load(f)

if __name__ == '__main__':
    app.run(debug=True, port=5000)

Running the Dashboard

bash
pip install flask
python dashboard/app.py

Visit http://localhost:5000 to see your AI coding analytics.

Security Considerations

When implementing AI session capture, consider these security aspects:

  1. Code Leakage: Ensure session logs don't commit sensitive code snippets
  2. Authentication Data: Filter out tokens, API keys, and passwords from prompts
  3. Access Control: Restrict who can view session data
  4. Retention Policy: Define how long to keep session logs

Add this to your hook script to sanitize sensitive data:

bash
# Sanitize sensitive information before logging
sanitize_output() {
    local input="$1"
    echo "$input" | \
        sed -E 's/(api[_-]?key|token|password|secret)["\x27:]+\s*["\x27]*[^\s"\x27]+/REDACTED/gi' | \
        sed -E 's/(Authorization:\s*Bearer\s*)[a-zA-Z0-9._-]+/Bearer REDACTED/g'
}

# Usage in the hook
log_session() {
    local sanitized_input=$(sanitize_output "$INPUT")
    local sanitized_output=$(sanitize_output "$OUTPUT")
    
    cat >> "$SESSION_LOG" <<EOF
=== Session Start: $(date -u +"%Y-%m-%dT%H:%M:%SZ") ===
Input:
$sanitized_input

Output:
$sanitized_output

EOF
}

Conclusion

Git hooks provide a powerful, automated way to capture and analyze AI pair programming sessions. By implementing this system, you can:

  • Understand AI Usage Patterns: See when and how your team uses AI tools
  • Improve Prompt Engineering: Learn from successful AI interactions
  • Ensure Compliance: Monitor AI usage for security and policy adherence
  • Optimize Development Workflow: Identify bottlenecks and AI-assisted improvements

Start small with the basic hook implementation, then gradually add indexing, search, and visualization capabilities. The insights gained from tracking your AI coding sessions can transform how your team leverages AI assistants for productive development.