Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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):"
Expand Down Expand Up @@ -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 \
Expand Down
8 changes: 8 additions & 0 deletions scripts/agent-session-analytics-push.service
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions scripts/agent-session-analytics-push.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[Unit]
Description=Agent Session Analytics - periodic push timer

[Timer]
OnBootSec=60
OnUnitActiveSec=300

[Install]
WantedBy=timers.target
28 changes: 28 additions & 0 deletions scripts/com.evansenter.agent-session-analytics-push.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.evansenter.agent-session-analytics-push</string>

<key>ProgramArguments</key>
<array>
<string>__CLI_PATH__</string>
<string>push</string>
<string>--url</string>
<string>__REMOTE_URL__</string>
</array>

<key>StartInterval</key>
<integer>300</integer>

<key>StandardOutPath</key>
<string>__HOME__/.claude/contrib/agent-session-analytics/push.log</string>

<key>StandardErrorPath</key>
<string>__HOME__/.claude/contrib/agent-session-analytics/push.err</string>

<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
88 changes: 88 additions & 0 deletions scripts/install-push-schedule.sh
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions scripts/uninstall-push-schedule.sh
Original file line number Diff line number Diff line change
@@ -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
16 changes: 10 additions & 6 deletions src/agent_session_analytics/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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 = [
Expand Down
4 changes: 2 additions & 2 deletions src/agent_session_analytics/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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.
Expand Down
39 changes: 36 additions & 3 deletions src/agent_session_analytics/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down