
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.
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
- 2. Architecture Overview
- 3. The Hook System: What Gets Captured
- 4. Building the Session Logger Hook
- 5. Parsing AI Session Data from Editor Telemetry
- 6. The Indexer: Structuring Sessions for Search
- 7. Search Interface: Querying Your AI Memory
- 8. Full Workflow: From Commit to Search
- 9. Production Hardening and Edge Cases
- 10. Frequently Asked Questions
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:
- Automatic capture: Hooks run on every relevant Git event without manual intervention.
- Version control: Session metadata travels with your code, reviewable in PRs, bisectable across commits.
- Local-first privacy: Everything stays in your
.git/hooksand 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:
┌─────────────────────────────────────────────────────────────┐
│ 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.
{
"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.
{
"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.
{
"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
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:
#!/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:
#!/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:
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:
#!/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:
#!/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:
{
"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:
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:
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.
6. The Indexer: Structuring Sessions for Search
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:
cd scripts
cargo init --name ai-indexer
Add dependencies to Cargo.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:
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:
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
// 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
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:
# 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):
// 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:
[[bin]]
name = "ai-indexer"
path = "main.rs"
[[bin]]
name = "ai-search"
path = "search.rs"
Build both:
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.
8. Full Workflow: From Commit to Search
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:
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:
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:
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:
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:
ai-search files src/auth/middleware.ts
# Shows all sessions that touched that file
Or you check your overall AI usage stats:
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.
## 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:
# 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:
# 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:
// .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:
# 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:
# 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:
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:
# .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:
# .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
# 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
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:
- Code Leakage: Ensure session logs don't commit sensitive code snippets
- Authentication Data: Filter out tokens, API keys, and passwords from prompts
- Access Control: Restrict who can view session data
- Retention Policy: Define how long to keep session logs
Add this to your hook script to sanitize sensitive data:
# 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.