diff --git a/Makefile b/Makefile index e756542..186817c 100644 --- a/Makefile +++ b/Makefile @@ -87,6 +87,9 @@ install-client: echo " claude mcp add --transport http --scope user agent-session-analytics $(REMOTE_URL)"; \ fi @echo "" + @echo "Installing push schedule..." + REMOTE_URL="$(REMOTE_URL)" ./scripts/install-push-schedule.sh + @echo "" @echo "Client installation complete!" @echo "" @echo "Add to your shell profile (~/.zshrc, ~/.bashrc, or ~/.extra):" @@ -123,9 +126,10 @@ restart: fi; \ fi -# Uninstall: service + CLI + MCP config +# Uninstall: service + push schedule + CLI + MCP config uninstall: @echo "Uninstalling..." + @./scripts/uninstall-push-schedule.sh 2>/dev/null || true @if [ "$$(uname)" = "Darwin" ]; then \ ./scripts/uninstall-launchagent.sh; \ else \ diff --git a/scripts/agent-session-analytics-push.service b/scripts/agent-session-analytics-push.service new file mode 100644 index 0000000..b225257 --- /dev/null +++ b/scripts/agent-session-analytics-push.service @@ -0,0 +1,8 @@ +[Unit] +Description=Agent Session Analytics - push local session data to remote server + +[Service] +Type=oneshot +ExecStart=__CLI_PATH__ push --url __REMOTE_URL__ +StandardOutput=append:__HOME__/.claude/contrib/agent-session-analytics/push.log +StandardError=append:__HOME__/.claude/contrib/agent-session-analytics/push.err diff --git a/scripts/agent-session-analytics-push.timer b/scripts/agent-session-analytics-push.timer new file mode 100644 index 0000000..ce8ba45 --- /dev/null +++ b/scripts/agent-session-analytics-push.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Agent Session Analytics - periodic push timer + +[Timer] +OnBootSec=60 +OnUnitActiveSec=300 + +[Install] +WantedBy=timers.target diff --git a/scripts/com.evansenter.agent-session-analytics-push.plist b/scripts/com.evansenter.agent-session-analytics-push.plist new file mode 100644 index 0000000..dda2807 --- /dev/null +++ b/scripts/com.evansenter.agent-session-analytics-push.plist @@ -0,0 +1,28 @@ + + + + + Label + com.evansenter.agent-session-analytics-push + + ProgramArguments + + __CLI_PATH__ + push + --url + __REMOTE_URL__ + + + StartInterval + 300 + + StandardOutPath + __HOME__/.claude/contrib/agent-session-analytics/push.log + + StandardErrorPath + __HOME__/.claude/contrib/agent-session-analytics/push.err + + ProcessType + Background + + diff --git a/scripts/install-push-schedule.sh b/scripts/install-push-schedule.sh new file mode 100755 index 0000000..71e059b --- /dev/null +++ b/scripts/install-push-schedule.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Install a periodic push schedule (LaunchAgent on macOS, systemd timer on Linux) +# Requires: CLI installed, REMOTE_URL set + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +CLI_PATH="${CLI_PATH:-$HOME/.local/bin/agent-session-analytics-cli}" +REMOTE_URL="${REMOTE_URL:-${1:-}}" + +if [[ -z "$REMOTE_URL" ]]; then + echo "Error: REMOTE_URL is required" + echo "Usage: REMOTE_URL=https://server/mcp $0" + echo " or: $0 https://server/mcp" + exit 1 +fi + +if [[ ! -x "$CLI_PATH" ]]; then + echo "Error: CLI not found at $CLI_PATH" + echo "Run install-cli.sh first" + exit 1 +fi + +mkdir -p "$HOME/.claude/contrib/agent-session-analytics" + +if [[ "$(uname)" == "Darwin" ]]; then + LABEL="com.evansenter.agent-session-analytics-push" + PLIST_TEMPLATE="$SCRIPT_DIR/com.evansenter.agent-session-analytics-push.plist" + PLIST_DEST="$HOME/Library/LaunchAgents/$LABEL.plist" + + mkdir -p "$HOME/Library/LaunchAgents" + + # Stop existing if running + if launchctl list 2>/dev/null | grep -q "$LABEL"; then + echo "Stopping existing push schedule..." + launchctl unload "$PLIST_DEST" 2>/dev/null || true + fi + + echo "Installing push LaunchAgent..." + sed -e "s|__CLI_PATH__|$CLI_PATH|g" \ + -e "s|__REMOTE_URL__|$REMOTE_URL|g" \ + -e "s|__HOME__|$HOME|g" \ + "$PLIST_TEMPLATE" > "$PLIST_DEST" + + launchctl load "$PLIST_DEST" + + if launchctl list 2>/dev/null | grep -q "$LABEL"; then + echo "Push schedule installed (every 5 min)" + echo " Logs: ~/.claude/contrib/agent-session-analytics/push.log" + else + echo "Error: Push schedule failed to start" + exit 1 + fi +else + SERVICE_TEMPLATE="$SCRIPT_DIR/agent-session-analytics-push.service" + TIMER_TEMPLATE="$SCRIPT_DIR/agent-session-analytics-push.timer" + SERVICE_DIR="$HOME/.config/systemd/user" + SERVICE_NAME="agent-session-analytics-push" + + mkdir -p "$SERVICE_DIR" + + # Stop existing if running + if systemctl --user is-active "$SERVICE_NAME.timer" &>/dev/null; then + echo "Stopping existing push timer..." + systemctl --user stop "$SERVICE_NAME.timer" + fi + + echo "Installing push systemd timer..." + sed -e "s|__CLI_PATH__|$CLI_PATH|g" \ + -e "s|__REMOTE_URL__|$REMOTE_URL|g" \ + -e "s|__HOME__|$HOME|g" \ + "$SERVICE_TEMPLATE" > "$SERVICE_DIR/$SERVICE_NAME.service" + + cp "$TIMER_TEMPLATE" "$SERVICE_DIR/$SERVICE_NAME.timer" + + systemctl --user daemon-reload + systemctl --user enable --now "$SERVICE_NAME.timer" + + if systemctl --user is-active "$SERVICE_NAME.timer" &>/dev/null; then + echo "Push timer installed (every 5 min)" + echo " Logs: ~/.claude/contrib/agent-session-analytics/push.log" + echo " Status: systemctl --user status $SERVICE_NAME.timer" + else + echo "Error: Push timer failed to start" + exit 1 + fi +fi diff --git a/scripts/uninstall-push-schedule.sh b/scripts/uninstall-push-schedule.sh new file mode 100755 index 0000000..e430932 --- /dev/null +++ b/scripts/uninstall-push-schedule.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Remove the periodic push schedule (LaunchAgent on macOS, systemd timer on Linux) + +set -e + +if [[ "$(uname)" == "Darwin" ]]; then + LABEL="com.evansenter.agent-session-analytics-push" + PLIST_DEST="$HOME/Library/LaunchAgents/$LABEL.plist" + + if launchctl list 2>/dev/null | grep -q "$LABEL"; then + echo "Stopping push schedule..." + launchctl unload "$PLIST_DEST" 2>/dev/null || true + fi + + if [[ -f "$PLIST_DEST" ]]; then + rm "$PLIST_DEST" + echo "Push LaunchAgent removed" + else + echo "Push LaunchAgent not installed" + fi +else + SERVICE_NAME="agent-session-analytics-push" + SERVICE_DIR="$HOME/.config/systemd/user" + + if systemctl --user is-active "$SERVICE_NAME.timer" &>/dev/null; then + echo "Stopping push timer..." + systemctl --user stop "$SERVICE_NAME.timer" + fi + + systemctl --user disable "$SERVICE_NAME.timer" 2>/dev/null || true + + removed=false + for f in "$SERVICE_DIR/$SERVICE_NAME.service" "$SERVICE_DIR/$SERVICE_NAME.timer"; do + if [[ -f "$f" ]]; then + rm "$f" + removed=true + fi + done + + if $removed; then + systemctl --user daemon-reload + echo "Push timer removed" + else + echo "Push timer not installed" + fi +fi diff --git a/src/agent_session_analytics/cli.py b/src/agent_session_analytics/cli.py index bf233ae..b608731 100644 --- a/src/agent_session_analytics/cli.py +++ b/src/agent_session_analytics/cli.py @@ -525,9 +525,11 @@ def _format_handoff_context(data: dict) -> list[str]: @_register_formatter( - lambda d: "sessions_analyzed" in d - and "sessions" in d - and (len(d.get("sessions", [])) == 0 or "error_count" in d.get("sessions", [{}])[0]) + lambda d: ( + "sessions_analyzed" in d + and "sessions" in d + and (len(d.get("sessions", [])) == 0 or "error_count" in d.get("sessions", [{}])[0]) + ) ) def _format_signals(data: dict) -> list[str]: """Format raw session signals for display.""" @@ -773,9 +775,11 @@ def _format_large_results(data: dict) -> list[str]: @_register_formatter( - lambda d: "sessions" in d - and "session_count" in d - and any("efficiency_signals" in s for s in d.get("sessions", [])) + lambda d: ( + "sessions" in d + and "session_count" in d + and any("efficiency_signals" in s for s in d.get("sessions", [])) + ) ) def _format_efficiency(data: dict) -> list[str]: lines = [ diff --git a/src/agent_session_analytics/guide.md b/src/agent_session_analytics/guide.md index 9d988b7..6c95995 100644 --- a/src/agent_session_analytics/guide.md +++ b/src/agent_session_analytics/guide.md @@ -184,7 +184,7 @@ get_status() ingest_logs(days=7) → {files_processed: 12, entries_added: 847, entries_skipped: 23} ``` -Data auto-refreshes when queries detect stale data (>5 min old). +The server automatically ingests local data on startup and every 5 minutes. For on-demand refresh (e.g., after pushing remote data), call `ingest_logs()` directly. ### 3. Query your usage ``` @@ -297,7 +297,7 @@ get_session_commits(session_id="abc") ## Tips -- **Auto-refresh**: Queries auto-ingest when data is stale (>5 min). Use `get_status()` to check. +- **Auto-ingestion**: The server automatically ingests local JSONL files on startup and every 5 minutes. Use `get_status()` to check last ingestion time. Manual `ingest_logs()` is available for on-demand refresh. - **Project filter**: Most queries accept `project` - uses LIKE matching, partial names work. - **Day filters**: `days=7` for recent trends, `days=30` for patterns. - **Permission gaps**: Compare against `~/.claude/settings.json`. Higher `min_count` = less noise. diff --git a/src/agent_session_analytics/server.py b/src/agent_session_analytics/server.py index 4156b3f..c4b03cc 100644 --- a/src/agent_session_analytics/server.py +++ b/src/agent_session_analytics/server.py @@ -3,9 +3,12 @@ Provides tools for querying Claude Code session logs. See guide.md for full API reference. """ +import asyncio import logging import os import sqlite3 +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress from importlib.metadata import version from pathlib import Path @@ -30,12 +33,42 @@ if os.environ.get("DEV_MODE"): logger.setLevel(logging.DEBUG) -# Initialize MCP server -mcp = FastMCP("agent-session-analytics") - # Initialize storage storage = SQLiteStorage() +INGEST_INTERVAL_SECONDS = 300 # 5 minutes + + +@asynccontextmanager +async def server_lifespan(server) -> AsyncIterator[dict]: + """Run initial ingestion on startup and start periodic background ingestion.""" + try: + logger.info("Running startup ingestion...") + await asyncio.to_thread(ingest.ingest_logs, storage, days=1) + logger.info("Startup ingestion complete") + except Exception: + logger.exception("Startup ingestion failed, server starting anyway") + task = asyncio.create_task(_periodic_ingest()) + yield {} + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +async def _periodic_ingest(): + """Background loop: ingest local JSONL files every 5 minutes.""" + while True: + await asyncio.sleep(INGEST_INTERVAL_SECONDS) + try: + await asyncio.to_thread(ingest.ingest_logs, storage, days=1) + logger.info("Background ingestion complete") + except Exception: + logger.exception("Background ingestion failed") + + +# Initialize MCP server +mcp = FastMCP("agent-session-analytics", lifespan=server_lifespan) + @mcp.resource("agent-session-analytics://guide", description="Usage guide and best practices") def usage_guide() -> str: