From 80f9c2d4939e9963cb787e3c3cdfcfe830aedb9e Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Thu, 1 Jan 2026 18:00:15 +0000 Subject: [PATCH 1/6] RFC #26: Add session outcome detection and enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements data enrichment for deeper session analysis: - Add session outcome detection (success/abandoned/frustrated/unknown) - Add session_commits junction table with timing metadata - Add satisfaction scoring based on error rates and patterns - Add 3 new MCP tools: query_outcomes, update_outcomes, get_session_commits - Add 3 new CLI commands: outcomes, update-outcomes, session-commits - Add --project filter to new CLI commands for consistency - Track session_commits_errors in correlate_git_with_sessions return - Add failed_sessions list to update_session_outcomes return - Add DEBUG logging to get_col helper for migration debugging Closes #26 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/session_analytics/cli.py | 169 +++++++++++++++++- src/session_analytics/ingest.py | 50 +++++- src/session_analytics/patterns.py | 286 ++++++++++++++++++++++++++++++ src/session_analytics/server.py | 80 +++++++++ src/session_analytics/storage.py | 224 ++++++++++++++++++++++- tests/test_cli.py | 196 +++++++++++++++++++- tests/test_patterns.py | 206 +++++++++++++++++++++ tests/test_storage.py | 178 +++++++++++++++++++ 8 files changed, 1379 insertions(+), 10 deletions(-) diff --git a/src/session_analytics/cli.py b/src/session_analytics/cli.py index 10f7d9e..0b02d52 100644 --- a/src/session_analytics/cli.py +++ b/src/session_analytics/cli.py @@ -16,15 +16,25 @@ from session_analytics.patterns import ( analyze_failures as do_analyze_failures, ) -from session_analytics.patterns import analyze_trends as do_analyze_trends +from session_analytics.patterns import ( + analyze_trends as do_analyze_trends, +) from session_analytics.patterns import ( compute_permission_gaps, compute_sequence_patterns, ) -from session_analytics.patterns import get_insights as do_get_insights +from session_analytics.patterns import ( + detect_session_outcomes as do_detect_outcomes, +) +from session_analytics.patterns import ( + get_insights as do_get_insights, +) from session_analytics.patterns import ( sample_sequences as do_sample_sequences, ) +from session_analytics.patterns import ( + update_session_outcomes as do_update_outcomes, +) from session_analytics.queries import ( classify_sessions as do_classify_sessions, ) @@ -337,6 +347,69 @@ def _format_handoff_context(data: dict) -> list[str]: return lines +@_register_formatter(lambda d: "outcome_distribution" in d and "sessions" in d) +def _format_outcomes(data: dict) -> list[str]: + lines = [ + f"Session Outcomes (last {data['days']} days)", + f"Sessions analyzed: {data['sessions_analyzed']}", + "", + "Outcome distribution:", + ] + for outcome, count in data.get("outcome_distribution", {}).items(): + if count > 0: + lines.append(f" {outcome}: {count}") + lines.append("") + + lines.append("Sessions:") + for sess in data.get("sessions", [])[:15]: + confidence = sess.get("confidence", 0) + commit_info = f", {sess['commit_count']} commits" if sess.get("commit_count") else "" + lines.append( + f" {sess['session_id'][:16]} - {sess['outcome']} ({confidence:.0%}){commit_info}" + ) + if len(data.get("sessions", [])) > 15: + lines.append(f" ... and {len(data['sessions']) - 15} more") + return lines + + +@_register_formatter(lambda d: "sessions_detected" in d and "sessions_updated" in d) +def _format_update_outcomes(data: dict) -> list[str]: + lines = [ + f"Updated {data['sessions_updated']} sessions with outcomes", + f"Sessions detected: {data['sessions_detected']}", + "", + "Outcome distribution:", + ] + for outcome, count in data.get("outcome_distribution", {}).items(): + if count > 0: + lines.append(f" {outcome}: {count}") + return lines + + +@_register_formatter(lambda d: "commits" in d and "total_commits" in d) +def _format_session_commits(data: dict) -> list[str]: + lines = [ + f"Session Commits (last {data['days']} days)", + f"Total commits: {data['total_commits']}", + "", + ] + if data.get("session_id"): + lines.insert(1, f"Session: {data['session_id']}") + + for commit in data.get("commits", [])[:20]: + sha = commit.get("sha", "")[:8] + time_to = commit.get("time_to_commit_seconds", 0) + first = " (first)" if commit.get("is_first_commit") else "" + session = commit.get("session_id", "")[:12] if not data.get("session_id") else "" + if session: + lines.append(f" {sha} - {time_to}s{first} [{session}]") + else: + lines.append(f" {sha} - {time_to}s{first}") + if len(data.get("commits", [])) > 20: + lines.append(f" ... and {len(data['commits']) - 20} more") + return lines + + @_register_formatter(lambda d: "metrics" in d and "tool_changes" in d) def _format_trends(data: dict) -> list[str]: def format_metric(name: str, metric: dict) -> str: @@ -621,6 +694,74 @@ def cmd_git_correlate(args): print(format_output(result, args.json)) +def cmd_outcomes(args): + """Show session outcomes (RFC #26).""" + storage = SQLiteStorage() + result = do_detect_outcomes( + storage, + days=args.days, + min_events=args.min_events, + project=args.project, + ) + print(format_output(result, args.json)) + + +def cmd_update_outcomes(args): + """Persist session outcomes to database (RFC #26).""" + storage = SQLiteStorage() + result = do_update_outcomes( + storage, + days=args.days, + project=args.project, + ) + print(format_output(result, args.json)) + + +def cmd_session_commits(args): + """Show session-commit associations (RFC #26).""" + storage = SQLiteStorage() + commits = storage.get_session_commits(args.session_id) if args.session_id else [] + + # If no session_id, get all session commits from recent days + if not args.session_id: + project_filter = "" + params = [f"-{args.days} days"] + if args.project: + project_filter = "AND s.project_path LIKE ?" + params.append(f"%{args.project}%") + + rows = storage.execute_query( + f""" + SELECT sc.session_id, sc.commit_sha, sc.time_to_commit_seconds, + sc.is_first_commit + FROM session_commits sc + JOIN sessions s ON s.id = sc.session_id + WHERE s.first_seen >= datetime('now', ?) + {project_filter} + ORDER BY s.first_seen DESC + """, + tuple(params), + ) + commits = [ + { + "session_id": r["session_id"], + "sha": r["commit_sha"], + "time_to_commit_seconds": r["time_to_commit_seconds"], + "is_first_commit": bool(r["is_first_commit"]), + } + for r in rows + ] + + result = { + "days": args.days, + "session_id": args.session_id, + "project": getattr(args, "project", None), + "total_commits": len(commits), + "commits": commits, + } + print(format_output(result, args.json)) + + def main(): """CLI entry point.""" epilog = """ @@ -791,6 +932,30 @@ def main(): sub.add_argument("--days", type=int, default=7, help="Days to correlate (default: 7)") sub.set_defaults(func=cmd_git_correlate) + # outcomes (RFC #26) + sub = subparsers.add_parser( + "outcomes", help="Show session outcomes (success/abandoned/frustrated)" + ) + sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") + sub.add_argument( + "--min-events", type=int, default=5, help="Min events per session (default: 5)" + ) + sub.add_argument("--project", help="Project path filter") + sub.set_defaults(func=cmd_outcomes) + + # update-outcomes (RFC #26) + sub = subparsers.add_parser("update-outcomes", help="Persist outcomes to database") + sub.add_argument("--days", type=int, default=7, help="Days to process (default: 7)") + sub.add_argument("--project", help="Project path filter") + sub.set_defaults(func=cmd_update_outcomes) + + # session-commits (RFC #26) + sub = subparsers.add_parser("session-commits", help="Show session-commit associations") + sub.add_argument("--session-id", help="Specific session ID (default: all recent)") + sub.add_argument("--days", type=int, default=7, help="Days to look back (default: 7)") + sub.add_argument("--project", help="Project path filter") + sub.set_defaults(func=cmd_session_commits) + args = parser.parse_args() args.func(args) diff --git a/src/session_analytics/ingest.py b/src/session_analytics/ingest.py index b80bc8e..99a193b 100644 --- a/src/session_analytics/ingest.py +++ b/src/session_analytics/ingest.py @@ -599,6 +599,10 @@ def correlate_git_with_sessions( Associates commits with sessions based on timing - if a commit was made during an active session, it's likely related to that session's work. + RFC #26: Also populates session_commits junction table with timing metadata: + - time_to_commit_seconds: Time from session start to commit + - is_first_commit: Whether this was the first commit in the session + Args: storage: Storage instance days: Number of days to correlate (default: 7) @@ -647,8 +651,12 @@ def correlate_git_with_sessions( # Commits just before starting a session are often related preparatory work buffer = timedelta(minutes=5) - # Collect correlations for batch update - correlations: list[tuple[str, str]] = [] # (session_id, sha) + # Collect correlations for batch update: (session_id, sha) + correlations: list[tuple[str, str]] = [] + # Collect session_commits data: (session_id, sha, time_to_commit_seconds, is_first_commit) + session_commit_links: list[tuple[str, str, int | None, bool]] = [] + # Track first commit per session for is_first_commit calculation + session_first_commits: dict[str, tuple[str, datetime]] = {} # session_id -> (sha, time) for commit in commits: commit_time = commit.timestamp @@ -658,12 +666,34 @@ def correlate_git_with_sessions( # Find matching session (commit within session window Β± 5 min buffer) for sr in session_ranges: if (sr["start"] - buffer) <= commit_time <= (sr["end"] + buffer): - correlations.append((sr["session_id"], commit.sha)) + session_id = sr["session_id"] + correlations.append((session_id, commit.sha)) + + # Calculate time to commit (seconds from session start) + time_to_commit = int((commit_time - sr["start"]).total_seconds()) + # Clamp negative values (commits before session start) to 0 + time_to_commit = max(0, time_to_commit) + + # Track earliest commit per session for is_first_commit + if session_id not in session_first_commits: + session_first_commits[session_id] = (commit.sha, commit_time) + elif commit_time < session_first_commits[session_id][1]: + session_first_commits[session_id] = (commit.sha, commit_time) + + session_commit_links.append((session_id, commit.sha, time_to_commit, False)) break + # Mark is_first_commit for each session's earliest commit + session_commit_links_final = [] + for session_id, sha, time_to_commit, _ in session_commit_links: + is_first = session_first_commits.get(session_id, (None,))[0] == sha + session_commit_links_final.append((session_id, sha, time_to_commit, is_first)) + # Batch update all correlations correlated_count = 0 correlation_errors = 0 + session_commits_added = 0 + session_commits_errors = 0 if correlations: try: @@ -684,10 +714,24 @@ def correlate_git_with_sessions( ) correlation_errors = len(correlations) + # RFC #26: Populate session_commits junction table + if session_commit_links_final: + try: + session_commits_added = storage.add_session_commits_batch(session_commit_links_final) + except Exception as e: + logger.error( + "Failed to add %d session_commits: %s", + len(session_commit_links_final), + e, + ) + session_commits_errors = len(session_commit_links_final) + return { "days": days, "sessions_analyzed": len(session_ranges), "commits_checked": len(commits), "commits_correlated": correlated_count, + "session_commits_added": session_commits_added, "correlation_errors": correlation_errors, + "session_commits_errors": session_commits_errors, } diff --git a/src/session_analytics/patterns.py b/src/session_analytics/patterns.py index c4a0273..5fc2e10 100644 --- a/src/session_analytics/patterns.py +++ b/src/session_analytics/patterns.py @@ -769,6 +769,292 @@ def get_insights( return insights +def detect_session_outcomes( + storage: SQLiteStorage, + days: int = 7, + min_events: int = 5, + project: str | None = None, +) -> dict: + """Detect task outcomes for sessions. + + RFC #26: Analyzes sessions to determine likely outcomes: + - success: Task completed (commit made, PR created, tests pass) + - abandoned: User stopped mid-task without completion + - frustrated: High error rate, rework patterns, retries + - unknown: Not enough data to determine + + Args: + storage: Storage instance + days: Number of days to analyze (default: 7) + min_events: Minimum events for a session to be analyzed (default: 5) + project: Optional project path filter + + Returns: + Dict with session outcomes and summary statistics + """ + cutoff = datetime.now() - timedelta(days=days) + + # Build optional project filter + project_filter = "" + params: list = [cutoff] + if project: + project_filter = "AND project_path LIKE ?" + params.append(f"%{project}%") + params.append(min_events) + + # Get session summaries with activity metrics + sessions = storage.execute_query( + f""" + SELECT + session_id, + project_path, + COUNT(*) as event_count, + SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) as error_count, + SUM(CASE WHEN tool_name = 'Edit' THEN 1 ELSE 0 END) as edit_count, + SUM(CASE WHEN command = 'git' THEN 1 ELSE 0 END) as git_count, + SUM(CASE WHEN skill_name IS NOT NULL THEN 1 ELSE 0 END) as skill_count, + MIN(timestamp) as first_event, + MAX(timestamp) as last_event + FROM events + WHERE timestamp >= ? + {project_filter} + GROUP BY session_id + HAVING COUNT(*) >= ? + """, + tuple(params), + ) + + # Get commit counts per session from session_commits + commit_counts = storage.execute_query( + """ + SELECT session_id, COUNT(*) as commit_count + FROM session_commits + GROUP BY session_id + """, + (), + ) + commits_by_session = {r["session_id"]: r["commit_count"] for r in commit_counts} + + # Detect rework patterns for frustration detection + rework_sessions = set() + file_edits = storage.execute_query( + """ + SELECT session_id, file_path, COUNT(*) as edit_count + FROM events + WHERE timestamp >= ? + AND tool_name = 'Edit' + AND file_path IS NOT NULL + GROUP BY session_id, file_path + HAVING COUNT(*) >= 4 + """, + (cutoff,), + ) + for row in file_edits: + rework_sessions.add(row["session_id"]) + + # Check for PR-related activity + pr_sessions = set() + pr_events = storage.execute_query( + """ + SELECT DISTINCT session_id + FROM events + WHERE timestamp >= ? + AND ( + (command = 'gh' AND command_args LIKE 'pr %') + OR skill_name LIKE '%pr%' + OR skill_name LIKE '%commit%' + ) + """, + (cutoff,), + ) + for row in pr_events: + pr_sessions.add(row["session_id"]) + + # Analyze each session + outcomes = [] + outcome_counts = {"success": 0, "abandoned": 0, "frustrated": 0, "unknown": 0} + + for session in sessions: + session_id = session["session_id"] + event_count = session["event_count"] + error_count = session["error_count"] or 0 + edit_count = session["edit_count"] or 0 + git_count = session["git_count"] or 0 + commit_count = commits_by_session.get(session_id, 0) + has_rework = session_id in rework_sessions + has_pr_activity = session_id in pr_sessions + + # Calculate error rate + error_rate = error_count / event_count if event_count > 0 else 0 + + # Calculate session duration + first_event = session["first_event"] + last_event = session["last_event"] + if isinstance(first_event, str): + first_event = datetime.fromisoformat(first_event) + if isinstance(last_event, str): + last_event = datetime.fromisoformat(last_event) + duration_minutes = ( + (last_event - first_event).total_seconds() / 60 if first_event and last_event else 0 + ) + + # Scoring system + success_score = 0.0 + frustrated_score = 0.0 + abandoned_score = 0.0 + + # Success indicators + if commit_count > 0: + success_score += 0.4 + if has_pr_activity: + success_score += 0.3 + if error_rate < 0.05 and event_count > 10: + success_score += 0.2 + if git_count > 0: + success_score += 0.1 + + # Frustration indicators + if error_rate > 0.2: + frustrated_score += 0.4 + if has_rework: + frustrated_score += 0.3 + if error_count > 5: + frustrated_score += 0.2 + if duration_minutes > 60 and commit_count == 0: + frustrated_score += 0.1 + + # Abandoned indicators + if edit_count > 5 and commit_count == 0: + abandoned_score += 0.3 + if event_count < 20 and commit_count == 0 and not has_pr_activity: + abandoned_score += 0.2 + if duration_minutes < 10 and event_count < 15: + abandoned_score += 0.2 + + # Determine outcome + scores = { + "success": success_score, + "frustrated": frustrated_score, + "abandoned": abandoned_score, + } + max_score = max(scores.values()) + + if max_score < 0.3: + outcome = "unknown" + confidence = 1.0 - max_score # Lower confidence when scores are low + else: + outcome = max(scores, key=scores.get) + confidence = min(1.0, max_score + 0.2) # Boost confidence for clear signals + + outcome_counts[outcome] += 1 + outcomes.append( + { + "session_id": session_id, + "project_path": session["project_path"], + "outcome": outcome, + "confidence": round(confidence, 2), + "event_count": event_count, + "error_rate": round(error_rate, 3), + "commit_count": commit_count, + "duration_minutes": round(duration_minutes, 1), + } + ) + + return { + "days": days, + "sessions_analyzed": len(outcomes), + "outcome_distribution": outcome_counts, + "sessions": outcomes, + } + + +def update_session_outcomes( + storage: SQLiteStorage, days: int = 7, project: str | None = None +) -> dict: + """Detect and persist session outcomes to the sessions table. + + RFC #26: Runs outcome detection and updates sessions with outcome, + outcome_confidence, and satisfaction_score fields. + + Args: + storage: Storage instance + days: Number of days to analyze (default: 7) + project: Optional project path filter + + Returns: + Dict with update statistics + """ + + # Detect outcomes + detection_result = detect_session_outcomes(storage, days=days, project=project) + + # Update each session + updated = 0 + errors = 0 + failed_sessions: list[dict] = [] + + for session_data in detection_result["sessions"]: + try: + # Calculate satisfaction score from outcome + satisfaction_map = { + "success": 0.8, + "unknown": 0.5, + "abandoned": 0.3, + "frustrated": 0.2, + } + base_satisfaction = satisfaction_map.get(session_data["outcome"], 0.5) + # Adjust by confidence + satisfaction = base_satisfaction * session_data["confidence"] + + # Get existing session data to preserve other fields + existing = storage.execute_query( + "SELECT * FROM sessions WHERE id = ?", + (session_data["session_id"],), + ) + + if existing: + # Update existing session + storage.execute_write( + """ + UPDATE sessions + SET outcome = ?, + outcome_confidence = ?, + satisfaction_score = ? + WHERE id = ? + """, + ( + session_data["outcome"], + session_data["confidence"], + round(satisfaction, 2), + session_data["session_id"], + ), + ) + updated += 1 + except Exception as e: + logger.error( + "Failed to update outcome for session %s: %s", + session_data["session_id"], + e, + exc_info=True, + ) + errors += 1 + failed_sessions.append( + { + "session_id": session_data["session_id"], + "error": str(e), + } + ) + + return { + "days": days, + "sessions_detected": detection_result["sessions_analyzed"], + "sessions_updated": updated, + "errors": errors, + "failed_sessions": failed_sessions[:10], # Limit to avoid huge payloads + "outcome_distribution": detection_result["outcome_distribution"], + } + + def analyze_trends( storage: SQLiteStorage, days: int = 7, diff --git a/src/session_analytics/server.py b/src/session_analytics/server.py index 9f315ab..24062ee 100644 --- a/src/session_analytics/server.py +++ b/src/session_analytics/server.py @@ -13,6 +13,9 @@ - get_status: Ingestion status + DB stats - get_user_journey: User messages across sessions - search_messages: Full-text search on user messages +- query_outcomes: Session outcome detection (RFC #26) +- update_outcomes: Persist outcomes to database (RFC #26) +- get_session_commits: Session-commit mappings (RFC #26) """ import logging @@ -527,6 +530,83 @@ def correlate_git_with_sessions(days: int = 7) -> dict: return {"status": "ok", **result} +@mcp.tool() +def query_outcomes(days: int = 7, min_events: int = 5) -> dict: + """Detect and return session outcomes. + + RFC #26: Analyzes sessions to determine likely outcomes: + - success: Task completed (commit made, PR created, tests pass) + - abandoned: User stopped mid-task without completion + - frustrated: High error rate, rework patterns, retries + - unknown: Not enough data to determine + + Args: + days: Number of days to analyze (default: 7) + min_events: Minimum events for a session to be analyzed (default: 5) + + Returns: + Session outcomes with confidence scores and distribution + """ + queries.ensure_fresh_data(storage, days=days) + result = patterns.detect_session_outcomes(storage, days=days, min_events=min_events) + return {"status": "ok", **result} + + +@mcp.tool() +def update_outcomes(days: int = 7) -> dict: + """Detect and persist session outcomes to the database. + + RFC #26: Runs outcome detection and updates sessions with outcome, + outcome_confidence, and satisfaction_score fields. + + Args: + days: Number of days to analyze (default: 7) + + Returns: + Update statistics including sessions processed + """ + queries.ensure_fresh_data(storage, days=days) + result = patterns.update_session_outcomes(storage, days=days) + return {"status": "ok", **result} + + +@mcp.tool() +def get_session_commits(session_id: str | None = None, days: int = 7) -> dict: + """Get commits associated with sessions. + + RFC #26: Returns commits linked to sessions with timing metadata: + - time_to_commit_seconds: Time from session start to commit + - is_first_commit: Whether this was the first commit in the session + + Args: + session_id: Specific session ID (optional, returns all if not specified) + days: Number of days to look back (default: 7) + + Returns: + Session-commit mappings with timing metadata + """ + queries.ensure_fresh_data(storage, days=days) + + if session_id: + commits = storage.get_session_commits(session_id) + return { + "status": "ok", + "session_id": session_id, + "commit_count": len(commits), + "commits": commits, + } + else: + # Get all session commits + result = storage.get_commits_for_sessions() + total_commits = sum(len(commits) for commits in result.values()) + return { + "status": "ok", + "session_count": len(result), + "total_commits": total_commits, + "sessions": result, + } + + def create_app(): """Create the ASGI app for uvicorn.""" # stateless_http=True allows resilience to server restarts diff --git a/src/session_analytics/storage.py b/src/session_analytics/storage.py index f3f1a33..68ef211 100644 --- a/src/session_analytics/storage.py +++ b/src/session_analytics/storage.py @@ -86,6 +86,12 @@ class Session: primary_branch: str | None = None slug: str | None = None + # RFC #26: Session enrichment fields + outcome: str | None = None # 'success', 'abandoned', 'frustrated', 'unknown' + outcome_confidence: float | None = None # 0.0 - 1.0 confidence in outcome + satisfaction_score: float | None = None # 0.0 - 1.0 user satisfaction estimate + context_switch_count: int = 0 # Number of mid-session topic changes + @dataclass class IngestionState: @@ -139,7 +145,7 @@ def __post_init__(self): DEFAULT_DB_PATH = Path.home() / ".claude" / "contrib" / "analytics" / "data.db" # Schema version for migrations -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 # Migration functions: dict of version -> (migration_name, migration_func) # Each migration upgrades FROM version-1 TO version @@ -247,6 +253,49 @@ def migrate_v3(conn): """) +@migration(4, "add_session_enrichment") +def migrate_v4(conn): + """Add columns for RFC #26: session outcome tracking and enrichment. + + Adds: + - Session outcome tracking (success, abandoned, frustrated, unknown) + - Session-commit junction table for time-to-commit metrics + - Satisfaction score for user experience tracking + """ + # Check existing session columns + existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(sessions)")} + + # Add outcome tracking columns to sessions + if "outcome" not in existing_cols: + conn.execute("ALTER TABLE sessions ADD COLUMN outcome TEXT") + if "outcome_confidence" not in existing_cols: + conn.execute("ALTER TABLE sessions ADD COLUMN outcome_confidence REAL") + if "satisfaction_score" not in existing_cols: + conn.execute("ALTER TABLE sessions ADD COLUMN satisfaction_score REAL") + if "context_switch_count" not in existing_cols: + conn.execute("ALTER TABLE sessions ADD COLUMN context_switch_count INTEGER DEFAULT 0") + + # Create session_commits junction table for detailed commit tracking + # This allows tracking time_to_commit and multiple commits per session + conn.execute(""" + CREATE TABLE IF NOT EXISTS session_commits ( + session_id TEXT NOT NULL, + commit_sha TEXT NOT NULL, + time_to_commit_seconds INTEGER, + is_first_commit INTEGER DEFAULT 0, + PRIMARY KEY (session_id, commit_sha), + FOREIGN KEY (session_id) REFERENCES sessions(id), + FOREIGN KEY (commit_sha) REFERENCES git_commits(sha) + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_session_commits_session ON session_commits(session_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_session_commits_commit ON session_commits(commit_sha)" + ) + + class SQLiteStorage: """SQLite-backed storage for session analytics.""" @@ -411,7 +460,12 @@ def _init_db(self): total_input_tokens INTEGER DEFAULT 0, total_output_tokens INTEGER DEFAULT 0, primary_branch TEXT, - slug TEXT + slug TEXT, + -- RFC #26: Session enrichment + outcome TEXT, + outcome_confidence REAL, + satisfaction_score REAL, + context_switch_count INTEGER DEFAULT 0 ) """) @@ -460,6 +514,27 @@ def _init_db(self): "CREATE INDEX IF NOT EXISTS idx_git_commits_project ON git_commits(project_path)" ) + # Session-commit junction table for detailed commit tracking (RFC #26) + conn.execute(""" + CREATE TABLE IF NOT EXISTS session_commits ( + session_id TEXT NOT NULL, + commit_sha TEXT NOT NULL, + time_to_commit_seconds INTEGER, + is_first_commit INTEGER DEFAULT 0, + PRIMARY KEY (session_id, commit_sha), + FOREIGN KEY (session_id) REFERENCES sessions(id), + FOREIGN KEY (commit_sha) REFERENCES git_commits(sha) + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_session_commits_session " + "ON session_commits(session_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_session_commits_commit " + "ON session_commits(commit_sha)" + ) + # FTS5 full-text search on user_message_text (RFC #17 Phase 1) conn.execute(""" CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( @@ -690,8 +765,9 @@ def upsert_session(self, session: Session) -> None: id, project_path, first_seen, last_seen, entry_count, tool_use_count, total_input_tokens, total_output_tokens, - primary_branch, slug - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + primary_branch, slug, + outcome, outcome_confidence, satisfaction_score, context_switch_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( session.id, @@ -704,6 +780,10 @@ def upsert_session(self, session: Session) -> None: session.total_output_tokens, session.primary_branch, session.slug, + session.outcome, + session.outcome_confidence, + session.satisfaction_score, + session.context_switch_count, ), ) @@ -723,6 +803,15 @@ def get_session_count(self) -> int: def _row_to_session(self, row: sqlite3.Row) -> Session: """Convert a database row to a Session object.""" + + # Helper to safely get column that might not exist in older schema + def get_col(name: str, default=None): + try: + return row[name] + except (IndexError, KeyError): + logger.debug("Column '%s' not found in row, using default %s", name, default) + return default + return Session( id=row["id"], project_path=row["project_path"], @@ -734,6 +823,11 @@ def _row_to_session(self, row: sqlite3.Row) -> Session: total_output_tokens=row["total_output_tokens"], primary_branch=row["primary_branch"], slug=row["slug"], + # RFC #26: Session enrichment fields + outcome=get_col("outcome"), + outcome_confidence=get_col("outcome_confidence"), + satisfaction_score=get_col("satisfaction_score"), + context_switch_count=get_col("context_switch_count", 0), ) # Ingestion state operations @@ -927,6 +1021,128 @@ def get_git_commit_count(self) -> int: row = conn.execute("SELECT COUNT(*) as count FROM git_commits").fetchone() return row["count"] + # Session-commit correlation operations (RFC #26) + + def add_session_commit( + self, + session_id: str, + commit_sha: str, + time_to_commit_seconds: int | None = None, + is_first_commit: bool = False, + ) -> None: + """Link a commit to a session with timing metadata.""" + with self._connect() as conn: + conn.execute( + """ + INSERT OR REPLACE INTO session_commits ( + session_id, commit_sha, time_to_commit_seconds, is_first_commit + ) VALUES (?, ?, ?, ?) + """, + (session_id, commit_sha, time_to_commit_seconds, 1 if is_first_commit else 0), + ) + + def add_session_commits_batch(self, links: list[tuple[str, str, int | None, bool]]) -> int: + """Add multiple session-commit links in a batch. + + Args: + links: List of (session_id, commit_sha, time_to_commit_seconds, is_first_commit) + + Returns: + Number of rows affected + """ + with self._connect() as conn: + cursor = conn.executemany( + """ + INSERT OR REPLACE INTO session_commits ( + session_id, commit_sha, time_to_commit_seconds, is_first_commit + ) VALUES (?, ?, ?, ?) + """, + [(s, c, t, 1 if f else 0) for s, c, t, f in links], + ) + return cursor.rowcount + + def get_session_commits(self, session_id: str) -> list[dict]: + """Get all commits associated with a session. + + Returns: + List of dicts with commit info and timing + """ + with self._connect() as conn: + rows = conn.execute( + """ + SELECT sc.commit_sha, sc.time_to_commit_seconds, sc.is_first_commit, + gc.timestamp, gc.message + FROM session_commits sc + LEFT JOIN git_commits gc ON sc.commit_sha = gc.sha + WHERE sc.session_id = ? + ORDER BY gc.timestamp + """, + (session_id,), + ).fetchall() + + return [ + { + "sha": row["commit_sha"], + "time_to_commit_seconds": row["time_to_commit_seconds"], + "is_first_commit": bool(row["is_first_commit"]), + "timestamp": row["timestamp"], + "message": row["message"], + } + for row in rows + ] + + def get_commits_for_sessions( + self, session_ids: list[str] | None = None + ) -> dict[str, list[dict]]: + """Get commits grouped by session. + + Args: + session_ids: Optional list of session IDs to filter by + + Returns: + Dict mapping session_id to list of commit info dicts + """ + with self._connect() as conn: + if session_ids: + placeholders = ",".join("?" * len(session_ids)) + rows = conn.execute( + f""" + SELECT sc.session_id, sc.commit_sha, sc.time_to_commit_seconds, + sc.is_first_commit, gc.timestamp, gc.message + FROM session_commits sc + LEFT JOIN git_commits gc ON sc.commit_sha = gc.sha + WHERE sc.session_id IN ({placeholders}) + ORDER BY sc.session_id, gc.timestamp + """, + session_ids, + ).fetchall() + else: + rows = conn.execute( + """ + SELECT sc.session_id, sc.commit_sha, sc.time_to_commit_seconds, + sc.is_first_commit, gc.timestamp, gc.message + FROM session_commits sc + LEFT JOIN git_commits gc ON sc.commit_sha = gc.sha + ORDER BY sc.session_id, gc.timestamp + """ + ).fetchall() + + result: dict[str, list[dict]] = {} + for row in rows: + sid = row["session_id"] + if sid not in result: + result[sid] = [] + result[sid].append( + { + "sha": row["commit_sha"], + "time_to_commit_seconds": row["time_to_commit_seconds"], + "is_first_commit": bool(row["is_first_commit"]), + "timestamp": row["timestamp"], + "message": row["message"], + } + ) + return result + # Full-text search operations def search_user_messages( diff --git a/tests/test_cli.py b/tests/test_cli.py index a298bc3..acd6f4e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,15 +11,18 @@ cmd_commands, cmd_frequency, cmd_insights, + cmd_outcomes, cmd_permissions, cmd_search, cmd_sequences, + cmd_session_commits, cmd_sessions, cmd_status, cmd_tokens, + cmd_update_outcomes, format_output, ) -from session_analytics.storage import Event, Session, SQLiteStorage +from session_analytics.storage import Event, GitCommit, Session, SQLiteStorage @pytest.fixture @@ -386,3 +389,194 @@ class Args: captured = capsys.readouterr() assert "Search: authentication" in captured.out assert "Results:" in captured.out + + def test_cmd_outcomes(self, populated_storage, capsys): + """Test outcomes command (RFC #26).""" + + class Args: + json = False + days = 7 + min_events = 1 + project = None + + with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): + cmd_outcomes(Args()) + + captured = capsys.readouterr() + assert "Session Outcomes" in captured.out + assert "Outcome distribution:" in captured.out + + def test_cmd_outcomes_json(self, populated_storage, capsys): + """Test outcomes command with JSON output.""" + + class Args: + json = True + days = 7 + min_events = 1 + project = None + + with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): + cmd_outcomes(Args()) + + captured = capsys.readouterr() + assert '"outcome_distribution"' in captured.out + assert '"sessions_analyzed"' in captured.out + + def test_cmd_update_outcomes(self, populated_storage, capsys): + """Test update-outcomes command (RFC #26).""" + + class Args: + json = False + days = 7 + project = None + + with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): + cmd_update_outcomes(Args()) + + captured = capsys.readouterr() + assert "Updated" in captured.out + assert "sessions with outcomes" in captured.out + + def test_cmd_session_commits(self, populated_storage, capsys): + """Test session-commits command (RFC #26).""" + # Add a commit and link it to the session + now = datetime.now() + populated_storage.add_git_commit( + GitCommit(sha="abc1234def", timestamp=now, message="Test commit") + ) + populated_storage.add_session_commit("s1", "abc1234def", 300, True) + + class Args: + json = False + days = 7 + session_id = None + project = None + + with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): + cmd_session_commits(Args()) + + captured = capsys.readouterr() + assert "Session Commits" in captured.out + assert "Total commits:" in captured.out + + def test_cmd_session_commits_specific_session(self, populated_storage, capsys): + """Test session-commits command for specific session.""" + now = datetime.now() + populated_storage.add_git_commit( + GitCommit(sha="def5678abc", timestamp=now, message="Test commit 2") + ) + populated_storage.add_session_commit("s1", "def5678abc", 600, False) + + class Args: + json = False + days = 7 + session_id = "s1" + project = None + + with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): + cmd_session_commits(Args()) + + captured = capsys.readouterr() + assert "Session Commits" in captured.out + assert "Total commits:" in captured.out + + +class TestRFC26Formatters: + """Tests for RFC #26 output formatters.""" + + def test_outcomes_format(self): + """Test outcomes formatting.""" + data = { + "days": 7, + "sessions_analyzed": 5, + "outcome_distribution": { + "success": 3, + "abandoned": 1, + "frustrated": 0, + "unknown": 1, + }, + "sessions": [ + { + "session_id": "session-1-abc", + "outcome": "success", + "confidence": 0.85, + "commit_count": 2, + }, + { + "session_id": "session-2-def", + "outcome": "abandoned", + "confidence": 0.6, + "commit_count": 0, + }, + ], + } + result = format_output(data) + assert "Session Outcomes" in result + assert "Sessions analyzed: 5" in result + assert "success: 3" in result + assert "session-1-abc" in result + assert "85%" in result + + def test_update_outcomes_format(self): + """Test update outcomes formatting.""" + data = { + "days": 7, + "sessions_detected": 6, + "sessions_updated": 5, + "errors": 0, + "outcome_distribution": { + "success": 3, + "abandoned": 1, + "unknown": 1, + }, + } + result = format_output(data) + assert "Updated 5 sessions" in result + assert "Sessions detected: 6" in result + assert "success: 3" in result + + def test_session_commits_format(self): + """Test session commits formatting.""" + data = { + "days": 7, + "session_id": None, + "total_commits": 3, + "commits": [ + { + "session_id": "session-1", + "sha": "abc1234def5678", + "time_to_commit_seconds": 300, + "is_first_commit": True, + }, + { + "session_id": "session-1", + "sha": "def5678abc1234", + "time_to_commit_seconds": 600, + "is_first_commit": False, + }, + ], + } + result = format_output(data) + assert "Session Commits" in result + assert "Total commits: 3" in result + assert "abc1234d" in result # First 8 chars of SHA + assert "300s" in result + assert "(first)" in result + + def test_session_commits_format_specific_session(self): + """Test session commits formatting for specific session.""" + data = { + "days": 7, + "session_id": "session-specific", + "total_commits": 1, + "commits": [ + { + "sha": "abc1234def5678", + "time_to_commit_seconds": 450, + "is_first_commit": True, + }, + ], + } + result = format_output(data) + assert "session-specific" in result + assert "450s" in result diff --git a/tests/test_patterns.py b/tests/test_patterns.py index 523f1c1..8fb271d 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -1089,3 +1089,209 @@ def test_insights_graceful_degradation(self, storage): assert "has_trends" in insights["summary"] assert "has_failure_analysis" in insights["summary"] assert "has_classification" in insights["summary"] + + +class TestDetectSessionOutcomes: + """Tests for RFC #26 session outcome detection.""" + + def test_detect_outcomes_empty_database(self, storage): + """Test with empty database.""" + from session_analytics.patterns import detect_session_outcomes + + result = detect_session_outcomes(storage, days=7) + + assert result["sessions_analyzed"] == 0 + assert result["outcome_distribution"] == { + "success": 0, + "abandoned": 0, + "frustrated": 0, + "unknown": 0, + } + + def test_detect_outcome_success_with_commit(self, storage): + """Test that sessions with commits are marked as success.""" + from session_analytics.patterns import detect_session_outcomes + from session_analytics.storage import GitCommit, Session + + now = datetime.now() + + # Create session with events + events = [ + Event( + id=None, + uuid=f"suc-{i}", + timestamp=now - timedelta(hours=1, minutes=i), + session_id="success-session", + project_path="/project", + entry_type="tool_use", + tool_name="Edit" if i % 2 == 0 else "Read", + file_path=f"/file{i}.py", + ) + for i in range(15) # Need enough events + ] + storage.add_events_batch(events) + + # Create session record + storage.upsert_session(Session(id="success-session", project_path="/project")) + + # Add commit and link it + storage.add_git_commit(GitCommit(sha="abc1234", timestamp=now)) + storage.add_session_commit("success-session", "abc1234", 300, True) + + result = detect_session_outcomes(storage, days=7, min_events=5) + + # Should have one session analyzed + assert result["sessions_analyzed"] == 1 + assert result["sessions"][0]["session_id"] == "success-session" + assert result["sessions"][0]["outcome"] == "success" + assert result["sessions"][0]["commit_count"] == 1 + + def test_detect_outcome_frustrated_high_errors(self, storage): + """Test that sessions with high error rate are marked as frustrated.""" + from session_analytics.patterns import detect_session_outcomes + + now = datetime.now() + + # Create session with many errors + events = [] + for i in range(10): + # Tool use + events.append( + Event( + id=None, + uuid=f"frust-use-{i}", + timestamp=now - timedelta(hours=1, minutes=i * 2), + session_id="frustrated-session", + project_path="/project", + entry_type="tool_use", + tool_name="Edit", + tool_id=f"tool-{i}", + file_path="/file.py", + ) + ) + # Error result + events.append( + Event( + id=None, + uuid=f"frust-result-{i}", + timestamp=now - timedelta(hours=1, minutes=i * 2 + 1), + session_id="frustrated-session", + project_path="/project", + entry_type="tool_result", + tool_id=f"tool-{i}", + is_error=True, + ) + ) + storage.add_events_batch(events) + + result = detect_session_outcomes(storage, days=7, min_events=5) + + # Should detect frustrated due to high error rate + assert result["sessions_analyzed"] == 1 + session = result["sessions"][0] + assert session["session_id"] == "frustrated-session" + assert session["error_rate"] >= 0.4 # 50% error rate + assert session["outcome"] in ["frustrated", "unknown"] # High errors = frustrated + + def test_detect_outcome_abandoned_no_commit(self, storage): + """Test that sessions with edits but no commits are marked as abandoned.""" + from session_analytics.patterns import detect_session_outcomes + + now = datetime.now() + + # Create short session with edits but no commit + events = [ + Event( + id=None, + uuid=f"aband-{i}", + timestamp=now - timedelta(minutes=i), + session_id="abandoned-session", + project_path="/project", + entry_type="tool_use", + tool_name="Edit", + file_path="/file.py", + ) + for i in range(8) # Short session with multiple edits + ] + storage.add_events_batch(events) + + result = detect_session_outcomes(storage, days=7, min_events=5) + + # Should detect abandoned (edits but no commit) + assert result["sessions_analyzed"] == 1 + session = result["sessions"][0] + assert session["commit_count"] == 0 + + def test_detect_outcomes_min_events_filter(self, storage): + """Test that sessions below min_events threshold are excluded.""" + from session_analytics.patterns import detect_session_outcomes + + now = datetime.now() + + # Create session with only 3 events + events = [ + Event( + id=None, + uuid=f"small-{i}", + timestamp=now - timedelta(hours=1, minutes=i), + session_id="small-session", + project_path="/project", + entry_type="tool_use", + tool_name="Read", + ) + for i in range(3) + ] + storage.add_events_batch(events) + + result = detect_session_outcomes(storage, days=7, min_events=5) + + # Session should be excluded due to min_events + assert result["sessions_analyzed"] == 0 + + +class TestUpdateSessionOutcomes: + """Tests for persisting session outcomes.""" + + def test_update_session_outcomes(self, storage): + """Test that outcomes are persisted to sessions table.""" + from session_analytics.patterns import update_session_outcomes + from session_analytics.storage import GitCommit, Session + + now = datetime.now() + + # Create session with events + events = [ + Event( + id=None, + uuid=f"persist-{i}", + timestamp=now - timedelta(hours=1, minutes=i), + session_id="persist-session", + project_path="/project", + entry_type="tool_use", + tool_name="Edit", + ) + for i in range(10) + ] + storage.add_events_batch(events) + + # Create session record + storage.upsert_session(Session(id="persist-session", project_path="/project")) + + # Add commit + storage.add_git_commit(GitCommit(sha="def5678", timestamp=now)) + storage.add_session_commit("persist-session", "def5678", 600, True) + + # Run update + result = update_session_outcomes(storage, days=7) + + assert result["sessions_updated"] == 1 + + # Verify the session was updated + rows = storage.execute_query( + "SELECT outcome, outcome_confidence, satisfaction_score FROM sessions WHERE id = ?", + ("persist-session",), + ) + assert len(rows) == 1 + assert rows[0]["outcome"] == "success" + assert rows[0]["outcome_confidence"] is not None + assert rows[0]["satisfaction_score"] is not None diff --git a/tests/test_storage.py b/tests/test_storage.py index f9729be..fd3b991 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -685,3 +685,181 @@ def test_fts_trigger_on_update_value_to_null(self, storage): # Should no longer be in FTS results = storage.search_user_messages("removable") assert len(results) == 0 + + +class TestSessionCommits: + """Tests for RFC #26 session_commits junction table.""" + + def test_add_session_commit(self, storage): + """Test adding a single session-commit link.""" + # First create the session and commit + storage.upsert_session(Session(id="session-1", project_path="project-a")) + storage.add_git_commit(GitCommit(sha="abc1234", timestamp=datetime.now())) + + # Link them + storage.add_session_commit( + session_id="session-1", + commit_sha="abc1234", + time_to_commit_seconds=300, + is_first_commit=True, + ) + + # Verify + commits = storage.get_session_commits("session-1") + assert len(commits) == 1 + assert commits[0]["sha"] == "abc1234" + assert commits[0]["time_to_commit_seconds"] == 300 + assert commits[0]["is_first_commit"] is True + + def test_add_session_commits_batch(self, storage): + """Test batch adding session-commit links.""" + # Create session and commits + storage.upsert_session(Session(id="session-1")) + storage.add_git_commits_batch( + [ + GitCommit(sha="aaa1111", timestamp=datetime.now()), + GitCommit(sha="bbb2222", timestamp=datetime.now()), + GitCommit(sha="ccc3333", timestamp=datetime.now()), + ] + ) + + # Batch link + links = [ + ("session-1", "aaa1111", 100, True), + ("session-1", "bbb2222", 200, False), + ("session-1", "ccc3333", 300, False), + ] + count = storage.add_session_commits_batch(links) + assert count == 3 + + # Verify + commits = storage.get_session_commits("session-1") + assert len(commits) == 3 + + def test_get_commits_for_sessions(self, storage): + """Test getting commits for multiple sessions.""" + # Create sessions + storage.upsert_session(Session(id="session-1")) + storage.upsert_session(Session(id="session-2")) + + # Create commits + storage.add_git_commits_batch( + [ + GitCommit(sha="aaa1111", timestamp=datetime.now()), + GitCommit(sha="bbb2222", timestamp=datetime.now()), + GitCommit(sha="ccc3333", timestamp=datetime.now()), + ] + ) + + # Link commits to sessions + storage.add_session_commits_batch( + [ + ("session-1", "aaa1111", 100, True), + ("session-1", "bbb2222", 200, False), + ("session-2", "ccc3333", 150, True), + ] + ) + + # Get all session commits + result = storage.get_commits_for_sessions() + assert "session-1" in result + assert "session-2" in result + assert len(result["session-1"]) == 2 + assert len(result["session-2"]) == 1 + + # Get for specific sessions + result = storage.get_commits_for_sessions(["session-1"]) + assert "session-1" in result + assert "session-2" not in result + + def test_session_commit_replace_on_conflict(self, storage): + """Test that INSERT OR REPLACE updates existing links.""" + storage.upsert_session(Session(id="session-1")) + storage.add_git_commit(GitCommit(sha="abc1234", timestamp=datetime.now())) + + # First insert + storage.add_session_commit("session-1", "abc1234", 100, False) + commits = storage.get_session_commits("session-1") + assert commits[0]["time_to_commit_seconds"] == 100 + assert commits[0]["is_first_commit"] is False + + # Update via INSERT OR REPLACE + storage.add_session_commit("session-1", "abc1234", 200, True) + commits = storage.get_session_commits("session-1") + assert len(commits) == 1 + assert commits[0]["time_to_commit_seconds"] == 200 + assert commits[0]["is_first_commit"] is True + + +class TestSessionEnrichmentFields: + """Tests for RFC #26 session enrichment fields.""" + + def test_session_with_outcome(self, storage): + """Test storing and retrieving session outcome.""" + session = Session( + id="session-outcome-1", + project_path="project-a", + outcome="success", + outcome_confidence=0.85, + ) + storage.upsert_session(session) + + # Retrieve via execute_query + rows = storage.execute_query( + "SELECT outcome, outcome_confidence FROM sessions WHERE id = ?", + ("session-outcome-1",), + ) + assert len(rows) == 1 + assert rows[0]["outcome"] == "success" + assert rows[0]["outcome_confidence"] == 0.85 + + def test_session_with_satisfaction_score(self, storage): + """Test storing and retrieving satisfaction score.""" + session = Session( + id="session-satisfaction-1", + satisfaction_score=0.72, + ) + storage.upsert_session(session) + + rows = storage.execute_query( + "SELECT satisfaction_score FROM sessions WHERE id = ?", + ("session-satisfaction-1",), + ) + assert rows[0]["satisfaction_score"] == 0.72 + + def test_session_with_context_switch_count(self, storage): + """Test storing and retrieving context switch count.""" + session = Session( + id="session-context-1", + context_switch_count=3, + ) + storage.upsert_session(session) + + rows = storage.execute_query( + "SELECT context_switch_count FROM sessions WHERE id = ?", + ("session-context-1",), + ) + assert rows[0]["context_switch_count"] == 3 + + def test_session_with_all_enrichment_fields(self, storage): + """Test session with all RFC #26 enrichment fields.""" + session = Session( + id="session-full-enrichment", + project_path="project-a", + outcome="frustrated", + outcome_confidence=0.65, + satisfaction_score=0.3, + context_switch_count=5, + ) + storage.upsert_session(session) + + rows = storage.execute_query( + """SELECT outcome, outcome_confidence, satisfaction_score, context_switch_count + FROM sessions WHERE id = ?""", + ("session-full-enrichment",), + ) + assert len(rows) == 1 + assert rows[0]["outcome"] == "frustrated" + assert rows[0]["outcome_confidence"] == 0.65 + assert rows[0]["satisfaction_score"] == 0.3 + assert rows[0]["context_switch_count"] == 5 From 695c4f2f726c5121995599e0723e887fc1897397 Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Thu, 1 Jan 2026 18:26:42 +0000 Subject: [PATCH 2/6] Refactor: Apply RFC #17 'don't over-distill' principle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC #17: "Raw data with light structure beats heavily processed summaries. The LLM can handle context." This refactor removes pre-computed interpretation fields and surfaces raw signals instead: Removed: - outcome column (success/abandoned/frustrated/unknown) - outcome_confidence column - satisfaction_score column - detect_session_outcomes() β†’ replaced with get_session_signals() - update_session_outcomes() β†’ removed entirely - query_outcomes MCP tool β†’ replaced with get_session_signals - update_outcomes MCP tool β†’ removed - outcomes CLI command β†’ replaced with signals Added: - get_session_signals(): Returns raw metrics for LLM interpretation (error_count, error_rate, edit_count, commit_count, has_rework, has_pr_activity, duration_minutes) - Design Philosophy section in CLAUDE.md documenting the principle Kept (observable data): - session_commits junction table with timing data - context_switch_count field - get_session_commits MCP tool and CLI command πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 25 ++++ src/session_analytics/cli.py | 81 ++++--------- src/session_analytics/patterns.py | 189 +++++------------------------- src/session_analytics/server.py | 41 ++----- src/session_analytics/storage.py | 40 ++----- tests/test_cli.py | 97 ++++++--------- tests/test_patterns.py | 184 ++++++++++++----------------- tests/test_storage.py | 64 ++-------- 8 files changed, 225 insertions(+), 496 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 387d83b..0e408d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,27 @@ make dev # Run in dev mode with auto-reload - **Schema Migrations**: Use `@migration(version, name)` decorator in storage.py for DB changes - **Module Imports**: server.py uses `from session_analytics import queries, patterns, ingest` +## Design Philosophy + +**"Don't over-distill"** (RFC #17): Raw data with light structure beats heavily processed summaries. The LLM can handle context. + +This means: +- **Surface raw signals, not interpretations**: Return event counts, error rates, and timing data - not pre-computed labels like "success" or "frustrated" +- **Let the LLM interpret**: The consuming LLM has context we don't (user intent, conversation history). It should decide what patterns mean +- **Avoid premature classification**: Don't try to outsmart the LLM by pre-digesting data. Structured raw data is more useful than simplified conclusions + +Example - instead of: +```python +# BAD: Pre-computed interpretation +{"outcome": "frustrated", "confidence": 0.75} +``` + +Do this: +```python +# GOOD: Raw signals for LLM interpretation +{"error_count": 5, "error_rate": 0.25, "has_rework": True, "commit_count": 0} +``` + ## MCP Tools | Tool | Purpose | @@ -70,6 +91,8 @@ make dev # Run in dev mode with auto-reload | `get_insights` | Pre-computed patterns for /improve-workflow | | `get_user_journey` | User messages across sessions chronologically | | `search_messages` | Full-text search on user messages (FTS5) | +| `get_session_signals` | Raw session metrics for LLM interpretation (RFC #26) | +| `get_session_commits` | Session-commit mappings with timing (RFC #26) | ## CLI Commands @@ -87,6 +110,8 @@ session-analytics-cli permissions # Permission gaps session-analytics-cli insights # For /improve-workflow session-analytics-cli journey # User messages across sessions session-analytics-cli search # Full-text search on messages +session-analytics-cli signals # Raw session signals (RFC #26) +session-analytics-cli session-commits # Session-commit associations (RFC #26) ``` ## Integration diff --git a/src/session_analytics/cli.py b/src/session_analytics/cli.py index 0b02d52..e927d50 100644 --- a/src/session_analytics/cli.py +++ b/src/session_analytics/cli.py @@ -23,17 +23,14 @@ compute_permission_gaps, compute_sequence_patterns, ) -from session_analytics.patterns import ( - detect_session_outcomes as do_detect_outcomes, -) from session_analytics.patterns import ( get_insights as do_get_insights, ) from session_analytics.patterns import ( - sample_sequences as do_sample_sequences, + get_session_signals as do_get_signals, ) from session_analytics.patterns import ( - update_session_outcomes as do_update_outcomes, + sample_sequences as do_sample_sequences, ) from session_analytics.queries import ( classify_sessions as do_classify_sessions, @@ -347,45 +344,36 @@ def _format_handoff_context(data: dict) -> list[str]: return lines -@_register_formatter(lambda d: "outcome_distribution" in d and "sessions" in d) -def _format_outcomes(data: dict) -> list[str]: +@_register_formatter( + lambda d: "sessions_analyzed" in d + and "sessions" in d + and "error_count" in d.get("sessions", [{}])[0] +) +def _format_signals(data: dict) -> list[str]: + """Format raw session signals for display. + + Per RFC #17: Surfaces raw data for LLM interpretation, no outcome labels. + """ lines = [ - f"Session Outcomes (last {data['days']} days)", + f"Session Signals (last {data['days']} days)", f"Sessions analyzed: {data['sessions_analyzed']}", "", - "Outcome distribution:", + "Sessions (raw signals for LLM interpretation):", ] - for outcome, count in data.get("outcome_distribution", {}).items(): - if count > 0: - lines.append(f" {outcome}: {count}") - lines.append("") - - lines.append("Sessions:") for sess in data.get("sessions", [])[:15]: - confidence = sess.get("confidence", 0) commit_info = f", {sess['commit_count']} commits" if sess.get("commit_count") else "" + error_info = f", {sess['error_rate']:.0%} errors" if sess.get("error_rate", 0) > 0 else "" + rework = " [rework]" if sess.get("has_rework") else "" + pr = " [PR]" if sess.get("has_pr_activity") else "" lines.append( - f" {sess['session_id'][:16]} - {sess['outcome']} ({confidence:.0%}){commit_info}" + f" {sess['session_id'][:16]} - {sess['event_count']} events, " + f"{sess['duration_minutes']:.0f}m{commit_info}{error_info}{rework}{pr}" ) if len(data.get("sessions", [])) > 15: lines.append(f" ... and {len(data['sessions']) - 15} more") return lines -@_register_formatter(lambda d: "sessions_detected" in d and "sessions_updated" in d) -def _format_update_outcomes(data: dict) -> list[str]: - lines = [ - f"Updated {data['sessions_updated']} sessions with outcomes", - f"Sessions detected: {data['sessions_detected']}", - "", - "Outcome distribution:", - ] - for outcome, count in data.get("outcome_distribution", {}).items(): - if count > 0: - lines.append(f" {outcome}: {count}") - return lines - - @_register_formatter(lambda d: "commits" in d and "total_commits" in d) def _format_session_commits(data: dict) -> list[str]: lines = [ @@ -694,10 +682,10 @@ def cmd_git_correlate(args): print(format_output(result, args.json)) -def cmd_outcomes(args): - """Show session outcomes (RFC #26).""" +def cmd_signals(args): + """Show raw session signals for LLM interpretation (RFC #26, revised per RFC #17).""" storage = SQLiteStorage() - result = do_detect_outcomes( + result = do_get_signals( storage, days=args.days, min_events=args.min_events, @@ -706,17 +694,6 @@ def cmd_outcomes(args): print(format_output(result, args.json)) -def cmd_update_outcomes(args): - """Persist session outcomes to database (RFC #26).""" - storage = SQLiteStorage() - result = do_update_outcomes( - storage, - days=args.days, - project=args.project, - ) - print(format_output(result, args.json)) - - def cmd_session_commits(args): """Show session-commit associations (RFC #26).""" storage = SQLiteStorage() @@ -932,22 +909,14 @@ def main(): sub.add_argument("--days", type=int, default=7, help="Days to correlate (default: 7)") sub.set_defaults(func=cmd_git_correlate) - # outcomes (RFC #26) - sub = subparsers.add_parser( - "outcomes", help="Show session outcomes (success/abandoned/frustrated)" - ) + # signals (RFC #26, revised per RFC #17 - raw data, no interpretation) + sub = subparsers.add_parser("signals", help="Show raw session signals for LLM interpretation") sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") sub.add_argument( "--min-events", type=int, default=5, help="Min events per session (default: 5)" ) sub.add_argument("--project", help="Project path filter") - sub.set_defaults(func=cmd_outcomes) - - # update-outcomes (RFC #26) - sub = subparsers.add_parser("update-outcomes", help="Persist outcomes to database") - sub.add_argument("--days", type=int, default=7, help="Days to process (default: 7)") - sub.add_argument("--project", help="Project path filter") - sub.set_defaults(func=cmd_update_outcomes) + sub.set_defaults(func=cmd_signals) # session-commits (RFC #26) sub = subparsers.add_parser("session-commits", help="Show session-commit associations") diff --git a/src/session_analytics/patterns.py b/src/session_analytics/patterns.py index 5fc2e10..707cb0f 100644 --- a/src/session_analytics/patterns.py +++ b/src/session_analytics/patterns.py @@ -769,28 +769,29 @@ def get_insights( return insights -def detect_session_outcomes( +def get_session_signals( storage: SQLiteStorage, days: int = 7, min_events: int = 5, project: str | None = None, ) -> dict: - """Detect task outcomes for sessions. + """Get raw session signals for LLM interpretation. - RFC #26: Analyzes sessions to determine likely outcomes: - - success: Task completed (commit made, PR created, tests pass) - - abandoned: User stopped mid-task without completion - - frustrated: High error rate, rework patterns, retries - - unknown: Not enough data to determine + RFC #26 (revised per RFC #17 principle): Extracts observable session data + without interpretation. Per RFC #17: "Don't over-distill - raw data with + light structure beats heavily processed summaries. The LLM can handle context." + + The consuming LLM should interpret these signals to determine outcomes like + success, abandonment, or frustration based on the full context. Args: storage: Storage instance days: Number of days to analyze (default: 7) - min_events: Minimum events for a session to be analyzed (default: 5) + min_events: Minimum events for a session to be included (default: 5) project: Optional project path filter Returns: - Dict with session outcomes and summary statistics + Dict with raw session signals for LLM interpretation """ cutoff = datetime.now() - timedelta(days=days) @@ -835,7 +836,7 @@ def detect_session_outcomes( ) commits_by_session = {r["session_id"]: r["commit_count"] for r in commit_counts} - # Detect rework patterns for frustration detection + # Detect rework patterns (file edited 4+ times in session) rework_sessions = set() file_edits = storage.execute_query( """ @@ -870,24 +871,20 @@ def detect_session_outcomes( for row in pr_events: pr_sessions.add(row["session_id"]) - # Analyze each session - outcomes = [] - outcome_counts = {"success": 0, "abandoned": 0, "frustrated": 0, "unknown": 0} - + # Build raw signals for each session (no interpretation) + signals = [] for session in sessions: session_id = session["session_id"] event_count = session["event_count"] error_count = session["error_count"] or 0 edit_count = session["edit_count"] or 0 git_count = session["git_count"] or 0 + skill_count = session["skill_count"] or 0 commit_count = commits_by_session.get(session_id, 0) - has_rework = session_id in rework_sessions - has_pr_activity = session_id in pr_sessions - # Calculate error rate + # Calculate derived observables (still factual, not interpretive) error_rate = error_count / event_count if event_count > 0 else 0 - # Calculate session duration first_event = session["first_event"] last_event = session["last_event"] if isinstance(first_event, str): @@ -898,160 +895,30 @@ def detect_session_outcomes( (last_event - first_event).total_seconds() / 60 if first_event and last_event else 0 ) - # Scoring system - success_score = 0.0 - frustrated_score = 0.0 - abandoned_score = 0.0 - - # Success indicators - if commit_count > 0: - success_score += 0.4 - if has_pr_activity: - success_score += 0.3 - if error_rate < 0.05 and event_count > 10: - success_score += 0.2 - if git_count > 0: - success_score += 0.1 - - # Frustration indicators - if error_rate > 0.2: - frustrated_score += 0.4 - if has_rework: - frustrated_score += 0.3 - if error_count > 5: - frustrated_score += 0.2 - if duration_minutes > 60 and commit_count == 0: - frustrated_score += 0.1 - - # Abandoned indicators - if edit_count > 5 and commit_count == 0: - abandoned_score += 0.3 - if event_count < 20 and commit_count == 0 and not has_pr_activity: - abandoned_score += 0.2 - if duration_minutes < 10 and event_count < 15: - abandoned_score += 0.2 - - # Determine outcome - scores = { - "success": success_score, - "frustrated": frustrated_score, - "abandoned": abandoned_score, - } - max_score = max(scores.values()) - - if max_score < 0.3: - outcome = "unknown" - confidence = 1.0 - max_score # Lower confidence when scores are low - else: - outcome = max(scores, key=scores.get) - confidence = min(1.0, max_score + 0.2) # Boost confidence for clear signals - - outcome_counts[outcome] += 1 - outcomes.append( + signals.append( { "session_id": session_id, "project_path": session["project_path"], - "outcome": outcome, - "confidence": round(confidence, 2), + # Raw counts "event_count": event_count, - "error_rate": round(error_rate, 3), + "error_count": error_count, + "edit_count": edit_count, + "git_count": git_count, + "skill_count": skill_count, "commit_count": commit_count, + # Derived observables + "error_rate": round(error_rate, 3), "duration_minutes": round(duration_minutes, 1), + # Boolean flags (observable patterns) + "has_rework": session_id in rework_sessions, + "has_pr_activity": session_id in pr_sessions, } ) return { "days": days, - "sessions_analyzed": len(outcomes), - "outcome_distribution": outcome_counts, - "sessions": outcomes, - } - - -def update_session_outcomes( - storage: SQLiteStorage, days: int = 7, project: str | None = None -) -> dict: - """Detect and persist session outcomes to the sessions table. - - RFC #26: Runs outcome detection and updates sessions with outcome, - outcome_confidence, and satisfaction_score fields. - - Args: - storage: Storage instance - days: Number of days to analyze (default: 7) - project: Optional project path filter - - Returns: - Dict with update statistics - """ - - # Detect outcomes - detection_result = detect_session_outcomes(storage, days=days, project=project) - - # Update each session - updated = 0 - errors = 0 - failed_sessions: list[dict] = [] - - for session_data in detection_result["sessions"]: - try: - # Calculate satisfaction score from outcome - satisfaction_map = { - "success": 0.8, - "unknown": 0.5, - "abandoned": 0.3, - "frustrated": 0.2, - } - base_satisfaction = satisfaction_map.get(session_data["outcome"], 0.5) - # Adjust by confidence - satisfaction = base_satisfaction * session_data["confidence"] - - # Get existing session data to preserve other fields - existing = storage.execute_query( - "SELECT * FROM sessions WHERE id = ?", - (session_data["session_id"],), - ) - - if existing: - # Update existing session - storage.execute_write( - """ - UPDATE sessions - SET outcome = ?, - outcome_confidence = ?, - satisfaction_score = ? - WHERE id = ? - """, - ( - session_data["outcome"], - session_data["confidence"], - round(satisfaction, 2), - session_data["session_id"], - ), - ) - updated += 1 - except Exception as e: - logger.error( - "Failed to update outcome for session %s: %s", - session_data["session_id"], - e, - exc_info=True, - ) - errors += 1 - failed_sessions.append( - { - "session_id": session_data["session_id"], - "error": str(e), - } - ) - - return { - "days": days, - "sessions_detected": detection_result["sessions_analyzed"], - "sessions_updated": updated, - "errors": errors, - "failed_sessions": failed_sessions[:10], # Limit to avoid huge payloads - "outcome_distribution": detection_result["outcome_distribution"], + "sessions_analyzed": len(signals), + "sessions": signals, } diff --git a/src/session_analytics/server.py b/src/session_analytics/server.py index 24062ee..601bf43 100644 --- a/src/session_analytics/server.py +++ b/src/session_analytics/server.py @@ -13,8 +13,7 @@ - get_status: Ingestion status + DB stats - get_user_journey: User messages across sessions - search_messages: Full-text search on user messages -- query_outcomes: Session outcome detection (RFC #26) -- update_outcomes: Persist outcomes to database (RFC #26) +- get_session_signals: Raw session signals for LLM interpretation (RFC #26) - get_session_commits: Session-commit mappings (RFC #26) """ @@ -531,42 +530,26 @@ def correlate_git_with_sessions(days: int = 7) -> dict: @mcp.tool() -def query_outcomes(days: int = 7, min_events: int = 5) -> dict: - """Detect and return session outcomes. +def get_session_signals(days: int = 7, min_events: int = 5) -> dict: + """Get raw session signals for LLM interpretation. - RFC #26: Analyzes sessions to determine likely outcomes: - - success: Task completed (commit made, PR created, tests pass) - - abandoned: User stopped mid-task without completion - - frustrated: High error rate, rework patterns, retries - - unknown: Not enough data to determine + RFC #26 (revised per RFC #17 principle): Extracts observable session data + without interpretation. Per RFC #17: "Don't over-distill - raw data with + light structure beats heavily processed summaries. The LLM can handle context." - Args: - days: Number of days to analyze (default: 7) - min_events: Minimum events for a session to be analyzed (default: 5) - - Returns: - Session outcomes with confidence scores and distribution - """ - queries.ensure_fresh_data(storage, days=days) - result = patterns.detect_session_outcomes(storage, days=days, min_events=min_events) - return {"status": "ok", **result} - - -@mcp.tool() -def update_outcomes(days: int = 7) -> dict: - """Detect and persist session outcomes to the database. - - RFC #26: Runs outcome detection and updates sessions with outcome, - outcome_confidence, and satisfaction_score fields. + Returns raw signals like event counts, error rates, commit counts, and + boolean flags (has_rework, has_pr_activity). The consuming LLM should + interpret these to determine outcomes like success or abandonment. Args: days: Number of days to analyze (default: 7) + min_events: Minimum events for a session to be included (default: 5) Returns: - Update statistics including sessions processed + Raw session signals for LLM interpretation """ queries.ensure_fresh_data(storage, days=days) - result = patterns.update_session_outcomes(storage, days=days) + result = patterns.get_session_signals(storage, days=days, min_events=min_events) return {"status": "ok", **result} diff --git a/src/session_analytics/storage.py b/src/session_analytics/storage.py index 68ef211..c505691 100644 --- a/src/session_analytics/storage.py +++ b/src/session_analytics/storage.py @@ -86,10 +86,7 @@ class Session: primary_branch: str | None = None slug: str | None = None - # RFC #26: Session enrichment fields - outcome: str | None = None # 'success', 'abandoned', 'frustrated', 'unknown' - outcome_confidence: float | None = None # 0.0 - 1.0 confidence in outcome - satisfaction_score: float | None = None # 0.0 - 1.0 user satisfaction estimate + # RFC #26: Session enrichment fields (observable data only, no interpretation) context_switch_count: int = 0 # Number of mid-session topic changes @@ -255,23 +252,21 @@ def migrate_v3(conn): @migration(4, "add_session_enrichment") def migrate_v4(conn): - """Add columns for RFC #26: session outcome tracking and enrichment. + """Add columns for RFC #26: session enrichment with observable data. Adds: - - Session outcome tracking (success, abandoned, frustrated, unknown) - Session-commit junction table for time-to-commit metrics - - Satisfaction score for user experience tracking + - Context switch count for tracking topic changes + + Note: This migration intentionally does NOT add outcome/satisfaction columns. + Per RFC #17 design principle: "Don't over-distill - raw data with light + structure beats heavily processed summaries. The LLM can handle context." + Outcome classification should be done by the consuming LLM, not pre-computed. """ # Check existing session columns existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(sessions)")} - # Add outcome tracking columns to sessions - if "outcome" not in existing_cols: - conn.execute("ALTER TABLE sessions ADD COLUMN outcome TEXT") - if "outcome_confidence" not in existing_cols: - conn.execute("ALTER TABLE sessions ADD COLUMN outcome_confidence REAL") - if "satisfaction_score" not in existing_cols: - conn.execute("ALTER TABLE sessions ADD COLUMN satisfaction_score REAL") + # Add observable data columns (no interpretation) if "context_switch_count" not in existing_cols: conn.execute("ALTER TABLE sessions ADD COLUMN context_switch_count INTEGER DEFAULT 0") @@ -461,10 +456,7 @@ def _init_db(self): total_output_tokens INTEGER DEFAULT 0, primary_branch TEXT, slug TEXT, - -- RFC #26: Session enrichment - outcome TEXT, - outcome_confidence REAL, - satisfaction_score REAL, + -- RFC #26: Observable session data (no interpretation) context_switch_count INTEGER DEFAULT 0 ) """) @@ -766,8 +758,8 @@ def upsert_session(self, session: Session) -> None: entry_count, tool_use_count, total_input_tokens, total_output_tokens, primary_branch, slug, - outcome, outcome_confidence, satisfaction_score, context_switch_count - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + context_switch_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( session.id, @@ -780,9 +772,6 @@ def upsert_session(self, session: Session) -> None: session.total_output_tokens, session.primary_branch, session.slug, - session.outcome, - session.outcome_confidence, - session.satisfaction_score, session.context_switch_count, ), ) @@ -823,10 +812,7 @@ def get_col(name: str, default=None): total_output_tokens=row["total_output_tokens"], primary_branch=row["primary_branch"], slug=row["slug"], - # RFC #26: Session enrichment fields - outcome=get_col("outcome"), - outcome_confidence=get_col("outcome_confidence"), - satisfaction_score=get_col("satisfaction_score"), + # RFC #26: Session enrichment (observable data only, no interpretation) context_switch_count=get_col("context_switch_count", 0), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index acd6f4e..9e354dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,15 +11,14 @@ cmd_commands, cmd_frequency, cmd_insights, - cmd_outcomes, cmd_permissions, cmd_search, cmd_sequences, cmd_session_commits, cmd_sessions, + cmd_signals, cmd_status, cmd_tokens, - cmd_update_outcomes, format_output, ) from session_analytics.storage import Event, GitCommit, Session, SQLiteStorage @@ -390,8 +389,8 @@ class Args: assert "Search: authentication" in captured.out assert "Results:" in captured.out - def test_cmd_outcomes(self, populated_storage, capsys): - """Test outcomes command (RFC #26).""" + def test_cmd_signals(self, populated_storage, capsys): + """Test signals command (RFC #26, revised per RFC #17).""" class Args: json = False @@ -400,14 +399,14 @@ class Args: project = None with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): - cmd_outcomes(Args()) + cmd_signals(Args()) captured = capsys.readouterr() - assert "Session Outcomes" in captured.out - assert "Outcome distribution:" in captured.out + assert "Session Signals" in captured.out + assert "Sessions analyzed:" in captured.out - def test_cmd_outcomes_json(self, populated_storage, capsys): - """Test outcomes command with JSON output.""" + def test_cmd_signals_json(self, populated_storage, capsys): + """Test signals command with JSON output.""" class Args: json = True @@ -416,26 +415,11 @@ class Args: project = None with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): - cmd_outcomes(Args()) + cmd_signals(Args()) captured = capsys.readouterr() - assert '"outcome_distribution"' in captured.out assert '"sessions_analyzed"' in captured.out - - def test_cmd_update_outcomes(self, populated_storage, capsys): - """Test update-outcomes command (RFC #26).""" - - class Args: - json = False - days = 7 - project = None - - with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): - cmd_update_outcomes(Args()) - - captured = capsys.readouterr() - assert "Updated" in captured.out - assert "sessions with outcomes" in captured.out + assert '"sessions"' in captured.out def test_cmd_session_commits(self, populated_storage, capsys): """Test session-commits command (RFC #26).""" @@ -482,58 +466,51 @@ class Args: class TestRFC26Formatters: - """Tests for RFC #26 output formatters.""" + """Tests for RFC #26 output formatters (revised per RFC #17 - raw signals only).""" - def test_outcomes_format(self): - """Test outcomes formatting.""" + def test_signals_format(self): + """Test signals formatting (raw data, no interpretation).""" data = { "days": 7, "sessions_analyzed": 5, - "outcome_distribution": { - "success": 3, - "abandoned": 1, - "frustrated": 0, - "unknown": 1, - }, "sessions": [ { "session_id": "session-1-abc", - "outcome": "success", - "confidence": 0.85, + "project_path": "/test", + "event_count": 50, + "error_count": 2, + "edit_count": 10, + "git_count": 5, + "skill_count": 3, "commit_count": 2, + "error_rate": 0.04, + "duration_minutes": 45.0, + "has_rework": False, + "has_pr_activity": True, }, { "session_id": "session-2-def", - "outcome": "abandoned", - "confidence": 0.6, + "project_path": "/test", + "event_count": 20, + "error_count": 5, + "edit_count": 8, + "git_count": 1, + "skill_count": 0, "commit_count": 0, + "error_rate": 0.25, + "duration_minutes": 30.0, + "has_rework": True, + "has_pr_activity": False, }, ], } result = format_output(data) - assert "Session Outcomes" in result + assert "Session Signals" in result assert "Sessions analyzed: 5" in result - assert "success: 3" in result assert "session-1-abc" in result - assert "85%" in result - - def test_update_outcomes_format(self): - """Test update outcomes formatting.""" - data = { - "days": 7, - "sessions_detected": 6, - "sessions_updated": 5, - "errors": 0, - "outcome_distribution": { - "success": 3, - "abandoned": 1, - "unknown": 1, - }, - } - result = format_output(data) - assert "Updated 5 sessions" in result - assert "Sessions detected: 6" in result - assert "success: 3" in result + assert "50 events" in result + assert "[PR]" in result + assert "[rework]" in result def test_session_commits_format(self): """Test session commits formatting.""" diff --git a/tests/test_patterns.py b/tests/test_patterns.py index 8fb271d..7a0b5d2 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -1091,26 +1091,21 @@ def test_insights_graceful_degradation(self, storage): assert "has_classification" in insights["summary"] -class TestDetectSessionOutcomes: - """Tests for RFC #26 session outcome detection.""" +class TestGetSessionSignals: + """Tests for RFC #26 session signals (revised per RFC #17 - raw data only).""" - def test_detect_outcomes_empty_database(self, storage): + def test_get_signals_empty_database(self, storage): """Test with empty database.""" - from session_analytics.patterns import detect_session_outcomes + from session_analytics.patterns import get_session_signals - result = detect_session_outcomes(storage, days=7) + result = get_session_signals(storage, days=7) assert result["sessions_analyzed"] == 0 - assert result["outcome_distribution"] == { - "success": 0, - "abandoned": 0, - "frustrated": 0, - "unknown": 0, - } - - def test_detect_outcome_success_with_commit(self, storage): - """Test that sessions with commits are marked as success.""" - from session_analytics.patterns import detect_session_outcomes + assert result["sessions"] == [] + + def test_get_signals_with_commits(self, storage): + """Test that commit counts are included in signals.""" + from session_analytics.patterns import get_session_signals from session_analytics.storage import GitCommit, Session now = datetime.now() @@ -1119,112 +1114,73 @@ def test_detect_outcome_success_with_commit(self, storage): events = [ Event( id=None, - uuid=f"suc-{i}", + uuid=f"sig-{i}", timestamp=now - timedelta(hours=1, minutes=i), - session_id="success-session", + session_id="signal-session", project_path="/project", entry_type="tool_use", tool_name="Edit" if i % 2 == 0 else "Read", file_path=f"/file{i}.py", ) - for i in range(15) # Need enough events + for i in range(15) ] storage.add_events_batch(events) # Create session record - storage.upsert_session(Session(id="success-session", project_path="/project")) + storage.upsert_session(Session(id="signal-session", project_path="/project")) # Add commit and link it storage.add_git_commit(GitCommit(sha="abc1234", timestamp=now)) - storage.add_session_commit("success-session", "abc1234", 300, True) + storage.add_session_commit("signal-session", "abc1234", 300, True) - result = detect_session_outcomes(storage, days=7, min_events=5) + result = get_session_signals(storage, days=7, min_events=5) - # Should have one session analyzed + # Should have raw signals, no outcome classification assert result["sessions_analyzed"] == 1 - assert result["sessions"][0]["session_id"] == "success-session" - assert result["sessions"][0]["outcome"] == "success" - assert result["sessions"][0]["commit_count"] == 1 + session = result["sessions"][0] + assert session["session_id"] == "signal-session" + assert session["commit_count"] == 1 + assert session["event_count"] == 15 + assert "outcome" not in session # No interpretation + assert "confidence" not in session # No interpretation - def test_detect_outcome_frustrated_high_errors(self, storage): - """Test that sessions with high error rate are marked as frustrated.""" - from session_analytics.patterns import detect_session_outcomes + def test_get_signals_with_errors(self, storage): + """Test that error rates are included in signals.""" + from session_analytics.patterns import get_session_signals now = datetime.now() - # Create session with many errors + # Create session with some errors events = [] for i in range(10): - # Tool use events.append( Event( id=None, - uuid=f"frust-use-{i}", + uuid=f"err-use-{i}", timestamp=now - timedelta(hours=1, minutes=i * 2), - session_id="frustrated-session", + session_id="error-session", project_path="/project", entry_type="tool_use", tool_name="Edit", tool_id=f"tool-{i}", file_path="/file.py", + is_error=(i < 3), # 3 errors out of 10 ) ) - # Error result - events.append( - Event( - id=None, - uuid=f"frust-result-{i}", - timestamp=now - timedelta(hours=1, minutes=i * 2 + 1), - session_id="frustrated-session", - project_path="/project", - entry_type="tool_result", - tool_id=f"tool-{i}", - is_error=True, - ) - ) - storage.add_events_batch(events) - - result = detect_session_outcomes(storage, days=7, min_events=5) - - # Should detect frustrated due to high error rate - assert result["sessions_analyzed"] == 1 - session = result["sessions"][0] - assert session["session_id"] == "frustrated-session" - assert session["error_rate"] >= 0.4 # 50% error rate - assert session["outcome"] in ["frustrated", "unknown"] # High errors = frustrated - - def test_detect_outcome_abandoned_no_commit(self, storage): - """Test that sessions with edits but no commits are marked as abandoned.""" - from session_analytics.patterns import detect_session_outcomes - - now = datetime.now() - - # Create short session with edits but no commit - events = [ - Event( - id=None, - uuid=f"aband-{i}", - timestamp=now - timedelta(minutes=i), - session_id="abandoned-session", - project_path="/project", - entry_type="tool_use", - tool_name="Edit", - file_path="/file.py", - ) - for i in range(8) # Short session with multiple edits - ] storage.add_events_batch(events) - result = detect_session_outcomes(storage, days=7, min_events=5) + result = get_session_signals(storage, days=7, min_events=5) - # Should detect abandoned (edits but no commit) + # Should include error rate as raw signal assert result["sessions_analyzed"] == 1 session = result["sessions"][0] - assert session["commit_count"] == 0 + assert session["error_count"] == 3 + assert session["error_rate"] == 0.3 + assert "outcome" not in session # No interpretation - def test_detect_outcomes_min_events_filter(self, storage): + def test_get_signals_min_events_filter(self, storage): """Test that sessions below min_events threshold are excluded.""" - from session_analytics.patterns import detect_session_outcomes + from session_analytics.patterns import get_session_signals now = datetime.now() @@ -1243,55 +1199,61 @@ def test_detect_outcomes_min_events_filter(self, storage): ] storage.add_events_batch(events) - result = detect_session_outcomes(storage, days=7, min_events=5) + result = get_session_signals(storage, days=7, min_events=5) # Session should be excluded due to min_events assert result["sessions_analyzed"] == 0 - -class TestUpdateSessionOutcomes: - """Tests for persisting session outcomes.""" - - def test_update_session_outcomes(self, storage): - """Test that outcomes are persisted to sessions table.""" - from session_analytics.patterns import update_session_outcomes - from session_analytics.storage import GitCommit, Session + def test_get_signals_includes_all_raw_fields(self, storage): + """Test that all expected raw signal fields are present.""" + from session_analytics.patterns import get_session_signals + from session_analytics.storage import Session now = datetime.now() - # Create session with events + # Create session with various activity events = [ Event( id=None, - uuid=f"persist-{i}", + uuid=f"full-{i}", timestamp=now - timedelta(hours=1, minutes=i), - session_id="persist-session", + session_id="full-session", project_path="/project", entry_type="tool_use", tool_name="Edit", + file_path="/file.py", + command="git" if i == 0 else None, + skill_name="commit" if i == 1 else None, ) for i in range(10) ] storage.add_events_batch(events) + storage.upsert_session(Session(id="full-session", project_path="/project")) - # Create session record - storage.upsert_session(Session(id="persist-session", project_path="/project")) - - # Add commit - storage.add_git_commit(GitCommit(sha="def5678", timestamp=now)) - storage.add_session_commit("persist-session", "def5678", 600, True) + result = get_session_signals(storage, days=7, min_events=5) - # Run update - result = update_session_outcomes(storage, days=7) + assert result["sessions_analyzed"] == 1 + session = result["sessions"][0] - assert result["sessions_updated"] == 1 + # Verify all expected raw signal fields + expected_fields = [ + "session_id", + "project_path", + "event_count", + "error_count", + "edit_count", + "git_count", + "skill_count", + "commit_count", + "error_rate", + "duration_minutes", + "has_rework", + "has_pr_activity", + ] + for field in expected_fields: + assert field in session, f"Missing field: {field}" - # Verify the session was updated - rows = storage.execute_query( - "SELECT outcome, outcome_confidence, satisfaction_score FROM sessions WHERE id = ?", - ("persist-session",), - ) - assert len(rows) == 1 - assert rows[0]["outcome"] == "success" - assert rows[0]["outcome_confidence"] is not None - assert rows[0]["satisfaction_score"] is not None + # Verify NO interpretation fields + interpretation_fields = ["outcome", "confidence", "satisfaction_score"] + for field in interpretation_fields: + assert field not in session, f"Unexpected interpretation field: {field}" diff --git a/tests/test_storage.py b/tests/test_storage.py index fd3b991..89c3ebd 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -792,40 +792,12 @@ def test_session_commit_replace_on_conflict(self, storage): class TestSessionEnrichmentFields: - """Tests for RFC #26 session enrichment fields.""" + """Tests for RFC #26 session enrichment fields (observable data only). - def test_session_with_outcome(self, storage): - """Test storing and retrieving session outcome.""" - session = Session( - id="session-outcome-1", - project_path="project-a", - outcome="success", - outcome_confidence=0.85, - ) - storage.upsert_session(session) - - # Retrieve via execute_query - rows = storage.execute_query( - "SELECT outcome, outcome_confidence FROM sessions WHERE id = ?", - ("session-outcome-1",), - ) - assert len(rows) == 1 - assert rows[0]["outcome"] == "success" - assert rows[0]["outcome_confidence"] == 0.85 - - def test_session_with_satisfaction_score(self, storage): - """Test storing and retrieving satisfaction score.""" - session = Session( - id="session-satisfaction-1", - satisfaction_score=0.72, - ) - storage.upsert_session(session) - - rows = storage.execute_query( - "SELECT satisfaction_score FROM sessions WHERE id = ?", - ("session-satisfaction-1",), - ) - assert rows[0]["satisfaction_score"] == 0.72 + Per RFC #17 principle: "Don't over-distill - raw data with light structure + beats heavily processed summaries." We only store observable data like + context_switch_count, not interpretation fields like outcome/satisfaction. + """ def test_session_with_context_switch_count(self, storage): """Test storing and retrieving context switch count.""" @@ -841,25 +813,13 @@ def test_session_with_context_switch_count(self, storage): ) assert rows[0]["context_switch_count"] == 3 - def test_session_with_all_enrichment_fields(self, storage): - """Test session with all RFC #26 enrichment fields.""" - session = Session( - id="session-full-enrichment", - project_path="project-a", - outcome="frustrated", - outcome_confidence=0.65, - satisfaction_score=0.3, - context_switch_count=5, - ) + def test_session_context_switch_default(self, storage): + """Test that context_switch_count defaults to 0.""" + session = Session(id="session-default") storage.upsert_session(session) rows = storage.execute_query( - """SELECT outcome, outcome_confidence, satisfaction_score, context_switch_count - FROM sessions WHERE id = ?""", - ("session-full-enrichment",), - ) - assert len(rows) == 1 - assert rows[0]["outcome"] == "frustrated" - assert rows[0]["outcome_confidence"] == 0.65 - assert rows[0]["satisfaction_score"] == 0.3 - assert rows[0]["context_switch_count"] == 5 + "SELECT context_switch_count FROM sessions WHERE id = ?", + ("session-default",), + ) + assert rows[0]["context_switch_count"] == 0 From b9f688f2d1816a0f0b484d590202a12920c5a684 Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Thu, 1 Jan 2026 18:52:14 +0000 Subject: [PATCH 3/6] Add session_id filters and fix min_events default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API improvements for LLM data analysis workflows: 1. Add session_id filter to query_timeline and get_user_journey - Allows drilling into a specific session's full trace - Enables: discover sessions β†’ get signals β†’ drill into session 2. Change min_events default from 5 to 1 in get_session_signals - 36% of sessions were being filtered out (premature optimization) - Per RFC #17: let the LLM decide what's meaningful based on event_count - Users can still pass --min-events=5 if they want filtering 3. Update CLAUDE.md with Session Discovery and Drill-In Flow docs New CLI option: session-analytics-cli journey --session-id New MCP parameters: query_timeline(session_id=...) get_user_journey(session_id=...) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 17 +++++++++--- src/session_analytics/cli.py | 4 ++- src/session_analytics/patterns.py | 4 +-- src/session_analytics/queries.py | 20 ++++++++++++-- src/session_analytics/server.py | 28 ++++++++++++++++---- src/session_analytics/storage.py | 4 +++ tests/test_queries.py | 43 +++++++++++++++++++++++++++++++ tests/test_storage.py | 39 ++++++++++++++++++++++++++++ 8 files changed, 145 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0e408d9..6b54caf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,18 +82,27 @@ Do this: | `get_status` | Database stats and last ingestion time | | `ingest_logs` | Refresh data from JSONL files | | `query_tool_frequency` | Tool usage counts (Read, Edit, Bash, etc.) | -| `query_timeline` | Events in time window with filtering | +| `query_timeline` | Events in time window (supports `session_id` filter) | | `query_commands` | Bash command breakdown with prefix filter | -| `query_sessions` | Session metadata and token totals | +| `query_sessions` | Session metadata and token totals (lists all session IDs) | | `query_tokens` | Token usage by day, session, or model | -| `query_sequences` | Common tool patterns (n-grams) | +| `query_sequences` | Common tool patterns (n-grams, `length` param for n-gram size) | | `query_permission_gaps` | Commands needing settings.json entries | | `get_insights` | Pre-computed patterns for /improve-workflow | -| `get_user_journey` | User messages across sessions chronologically | +| `get_user_journey` | User messages across sessions (supports `session_id` filter) | | `search_messages` | Full-text search on user messages (FTS5) | | `get_session_signals` | Raw session metrics for LLM interpretation (RFC #26) | | `get_session_commits` | Session-commit mappings with timing (RFC #26) | +### Session Discovery and Drill-In Flow + +1. **Discover sessions**: `query_sessions()` returns all session IDs with basic metadata +2. **Get signals**: `get_session_signals()` returns raw metrics (error_rate, commit_count, etc.) +3. **Drill into session**: + - `query_timeline(session_id=)` - get full event trace + - `get_user_journey(session_id=)` - get all user messages + - `get_session_commits(session_id=)` - get commit associations + ## CLI Commands All commands support `--json` for machine-readable output: diff --git a/src/session_analytics/cli.py b/src/session_analytics/cli.py index e927d50..a93be56 100644 --- a/src/session_analytics/cli.py +++ b/src/session_analytics/cli.py @@ -554,6 +554,7 @@ def cmd_journey(args): storage, hours=args.hours, include_projects=not args.no_projects, + session_id=getattr(args, "session_id", None), limit=args.limit, ) print(format_output(result, args.json)) @@ -837,6 +838,7 @@ def main(): sub.add_argument("--hours", type=int, default=24, help="Hours to look back (default: 24)") sub.add_argument("--limit", type=int, default=100, help="Max messages (default: 100)") sub.add_argument("--no-projects", action="store_true", help="Exclude project info") + sub.add_argument("--session-id", help="Filter to specific session ID") sub.set_defaults(func=cmd_journey) # search @@ -913,7 +915,7 @@ def main(): sub = subparsers.add_parser("signals", help="Show raw session signals for LLM interpretation") sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") sub.add_argument( - "--min-events", type=int, default=5, help="Min events per session (default: 5)" + "--min-events", type=int, default=1, help="Min events per session (default: 1)" ) sub.add_argument("--project", help="Project path filter") sub.set_defaults(func=cmd_signals) diff --git a/src/session_analytics/patterns.py b/src/session_analytics/patterns.py index 707cb0f..e69a4fd 100644 --- a/src/session_analytics/patterns.py +++ b/src/session_analytics/patterns.py @@ -772,7 +772,7 @@ def get_insights( def get_session_signals( storage: SQLiteStorage, days: int = 7, - min_events: int = 5, + min_events: int = 1, project: str | None = None, ) -> dict: """Get raw session signals for LLM interpretation. @@ -787,7 +787,7 @@ def get_session_signals( Args: storage: Storage instance days: Number of days to analyze (default: 7) - min_events: Minimum events for a session to be included (default: 5) + min_events: Minimum events for a session to be included (default: 1) project: Optional project path filter Returns: diff --git a/src/session_analytics/queries.py b/src/session_analytics/queries.py index 4c18b65..e0cbbe4 100644 --- a/src/session_analytics/queries.py +++ b/src/session_analytics/queries.py @@ -137,6 +137,7 @@ def query_timeline( end: datetime | None = None, tool: str | None = None, project: str | None = None, + session_id: str | None = None, limit: int = 100, ) -> dict: """Get events in a time window. @@ -147,6 +148,7 @@ def query_timeline( end: End of time window (default: now) tool: Optional tool name filter project: Optional project path filter + session_id: Optional session ID filter (get full session trace) limit: Maximum events to return Returns: @@ -162,6 +164,7 @@ def query_timeline( end=end, tool_name=tool, project_path=project, + session_id=session_id, limit=limit, ) @@ -170,6 +173,7 @@ def query_timeline( "end": end.isoformat(), "tool": tool, "project": project, + "session_id": session_id, "count": len(events), "events": [ { @@ -459,6 +463,7 @@ def get_user_journey( storage: SQLiteStorage, hours: int = 24, include_projects: bool = True, + session_id: str | None = None, limit: int = 100, ) -> dict: """Get all user messages chronologically across sessions. @@ -470,6 +475,7 @@ def get_user_journey( storage: Storage instance hours: Number of hours to look back (default: 24) include_projects: Include project info in output (default: True) + session_id: Optional session ID filter (get messages from specific session) limit: Maximum messages to return (default: 100) Returns: @@ -477,9 +483,17 @@ def get_user_journey( """ cutoff = datetime.now() - timedelta(hours=hours) + # Build query with optional session_id filter + session_filter = "" + params: list = [cutoff] + if session_id: + session_filter = "AND session_id = ?" + params.append(session_id) + params.append(limit) + # Query user messages ordered by timestamp rows = storage.execute_query( - """ + f""" SELECT timestamp, session_id, @@ -489,10 +503,11 @@ def get_user_journey( WHERE timestamp >= ? AND entry_type = 'user' AND user_message_text IS NOT NULL + {session_filter} ORDER BY timestamp ASC LIMIT ? """, - (cutoff, limit), + tuple(params), ) # Build journey events @@ -520,6 +535,7 @@ def get_user_journey( return { "hours": hours, + "session_id": session_id, "message_count": len(journey), "projects_visited": list(projects_seen) if include_projects else None, "project_switches": project_switches if include_projects else None, diff --git a/src/session_analytics/server.py b/src/session_analytics/server.py index 601bf43..d834ded 100644 --- a/src/session_analytics/server.py +++ b/src/session_analytics/server.py @@ -120,6 +120,7 @@ def query_timeline( end: str | None = None, tool: str | None = None, project: str | None = None, + session_id: str | None = None, limit: int = 100, ) -> dict: """Get events in a time window. @@ -129,6 +130,7 @@ def query_timeline( end: End time (ISO format, default: now) tool: Optional tool name filter project: Optional project path filter + session_id: Optional session ID filter (get full session trace) limit: Maximum events to return (default: 100) Returns: @@ -141,7 +143,13 @@ def query_timeline( queries.ensure_fresh_data(storage) result = queries.query_timeline( - storage, start=start_dt, end=end_dt, tool=tool, project=project, limit=limit + storage, + start=start_dt, + end=end_dt, + tool=tool, + project=project, + session_id=session_id, + limit=limit, ) return {"status": "ok", **result} @@ -273,7 +281,12 @@ def query_permission_gaps(days: int = 7, threshold: int = 5) -> dict: @mcp.tool() -def get_user_journey(hours: int = 24, include_projects: bool = True, limit: int = 100) -> dict: +def get_user_journey( + hours: int = 24, + include_projects: bool = True, + session_id: str | None = None, + limit: int = 100, +) -> dict: """Get all user messages chronologically across sessions. Shows how the user moved across sessions and projects over time, @@ -282,6 +295,7 @@ def get_user_journey(hours: int = 24, include_projects: bool = True, limit: int Args: hours: Number of hours to look back (default: 24) include_projects: Include project info in output (default: True) + session_id: Optional session ID filter (get messages from specific session) limit: Maximum messages to return (default: 100) Returns: @@ -289,7 +303,11 @@ def get_user_journey(hours: int = 24, include_projects: bool = True, limit: int """ queries.ensure_fresh_data(storage, days=max(1, hours // 24 + 1)) result = queries.get_user_journey( - storage, hours=hours, include_projects=include_projects, limit=limit + storage, + hours=hours, + include_projects=include_projects, + session_id=session_id, + limit=limit, ) return {"status": "ok", **result} @@ -530,7 +548,7 @@ def correlate_git_with_sessions(days: int = 7) -> dict: @mcp.tool() -def get_session_signals(days: int = 7, min_events: int = 5) -> dict: +def get_session_signals(days: int = 7, min_events: int = 1) -> dict: """Get raw session signals for LLM interpretation. RFC #26 (revised per RFC #17 principle): Extracts observable session data @@ -543,7 +561,7 @@ def get_session_signals(days: int = 7, min_events: int = 5) -> dict: Args: days: Number of days to analyze (default: 7) - min_events: Minimum events for a session to be included (default: 5) + min_events: Minimum events for a session to be included (default: 1) Returns: Raw session signals for LLM interpretation diff --git a/src/session_analytics/storage.py b/src/session_analytics/storage.py index c505691..90c86c1 100644 --- a/src/session_analytics/storage.py +++ b/src/session_analytics/storage.py @@ -674,6 +674,7 @@ def get_events_in_range( end: datetime | None = None, tool_name: str | None = None, project_path: str | None = None, + session_id: str | None = None, limit: int = 100, ) -> list[Event]: """Get events within a time range with optional filters.""" @@ -693,6 +694,9 @@ def get_events_in_range( if project_path: conditions.append("project_path = ?") params.append(project_path) + if session_id: + conditions.append("session_id = ?") + params.append(session_id) # Safe: where_clause is built from hardcoded condition strings, not user input where_clause = " AND ".join(conditions) if conditions else "1=1" diff --git a/tests/test_queries.py b/tests/test_queries.py index dd8c8b9..fb6247d 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -189,6 +189,13 @@ def test_timeline_with_time_range(self, populated_storage): assert ts >= start assert ts <= end + def test_timeline_with_session_id_filter(self, populated_storage): + """Test timeline with session_id filter.""" + result = query_timeline(populated_storage, session_id="session-1", limit=100) + assert result["session_id"] == "session-1" + for event in result["events"]: + assert event["session_id"] == "session-1" + class TestQueryCommands: """Tests for command queries.""" @@ -371,6 +378,42 @@ def test_journey_excludes_tool_events(self, storage): # Should only have the user message, not the tool use assert result["message_count"] == 1 + def test_journey_with_session_id_filter(self, storage): + """Test get_user_journey with session_id filter.""" + from session_analytics.queries import get_user_journey + + now = datetime.now() + # Add user messages from two different sessions + storage.add_event( + Event( + id=None, + uuid="journey-1", + timestamp=now - timedelta(hours=1), + session_id="session-target", + project_path="project-a", + entry_type="user", + user_message_text="Message from target session", + ) + ) + storage.add_event( + Event( + id=None, + uuid="journey-2", + timestamp=now - timedelta(hours=1), + session_id="session-other", + project_path="project-a", + entry_type="user", + user_message_text="Message from other session", + ) + ) + + # Filter to only target session + result = get_user_journey(storage, hours=24, session_id="session-target") + + assert result["session_id"] == "session-target" + assert result["message_count"] == 1 + assert result["journey"][0]["session_id"] == "session-target" + class TestDetectParallelSessions: """Tests for detect_parallel_sessions function.""" diff --git a/tests/test_storage.py b/tests/test_storage.py index 89c3ebd..1fcdd5a 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -124,6 +124,45 @@ def test_get_events_by_tool(self, storage): assert len(bash_events) == 1 assert bash_events[0].tool_name == "Bash" + def test_get_events_by_session_id(self, storage): + """Test filtering events by session ID.""" + # Add events from different sessions + storage.add_event( + Event( + id=None, + uuid="uuid-1", + timestamp=datetime.now(), + session_id="session-alpha", + tool_name="Bash", + ) + ) + storage.add_event( + Event( + id=None, + uuid="uuid-2", + timestamp=datetime.now(), + session_id="session-alpha", + tool_name="Read", + ) + ) + storage.add_event( + Event( + id=None, + uuid="uuid-3", + timestamp=datetime.now(), + session_id="session-beta", + tool_name="Edit", + ) + ) + + # Filter by session + alpha_events = storage.get_events_in_range(session_id="session-alpha") + assert len(alpha_events) == 2 + + beta_events = storage.get_events_in_range(session_id="session-beta") + assert len(beta_events) == 1 + assert beta_events[0].session_id == "session-beta" + class TestSessionOperations: """Tests for session CRUD operations.""" From 9fc5ce1a5f028bdad5474bc39a030266d7332e8c Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Thu, 1 Jan 2026 18:58:51 +0000 Subject: [PATCH 4/6] docs: Add session discovery flow to guide.md MCP resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discovery flow (query_sessions β†’ get_session_signals β†’ drill-in) was documented in CLAUDE.md but not in guide.md, which is exposed as the MCP resource `session-analytics://guide` for LLM callers. - Add "Session Discovery and Drill-In" section to guide.md with examples - Add maintainer note to CLAUDE.md to keep both doc surfaces in sync πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 3 +++ src/session_analytics/guide.md | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 6b54caf..90ec34c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,9 @@ Do this: - `get_user_journey(session_id=)` - get all user messages - `get_session_commits(session_id=)` - get commit associations +> **Maintainer note**: This discovery flow is also documented in `src/session_analytics/guide.md` +> (exposed as MCP resource `session-analytics://guide`). Keep both in sync when updating API docs. + ## CLI Commands All commands support `--json` for machine-readable output: diff --git a/src/session_analytics/guide.md b/src/session_analytics/guide.md index 771ab6c..7e7a5f3 100644 --- a/src/session_analytics/guide.md +++ b/src/session_analytics/guide.md @@ -91,6 +91,42 @@ query_tool_frequency(days=30) β†’ {tools: [{name: "Read", count: 1234}, {name: "Edit", count: 567}, ...]} ``` +## Session Discovery and Drill-In + +A common workflow is discovering sessions, getting signals about them, then drilling into interesting ones: + +### 1. Discover sessions +``` +query_sessions(days=7) +β†’ {sessions: [{id: "abc123", project: "my-repo", event_count: 50}, ...]} +``` + +### 2. Get signals for sessions +``` +get_session_signals(days=7) +β†’ {sessions: [ + {session_id: "abc123", error_rate: 0.04, commit_count: 2, has_rework: false, ...}, + {session_id: "def456", error_rate: 0.25, commit_count: 0, has_rework: true, ...} + ]} +``` + +The LLM interprets these raw signals - high error rate + rework + no commits might indicate frustration. + +### 3. Drill into an interesting session +``` +# Get full event trace +query_timeline(session_id="abc123") +β†’ {events: [{tool: "Read", file: "auth.py", ...}, {tool: "Edit", ...}, ...]} + +# Get all user messages +get_user_journey(session_id="abc123") +β†’ {messages: [{content: "Fix the login bug", ...}, ...]} + +# Get commit associations +get_session_commits(session_id="abc123") +β†’ {commits: [{sha: "a1b2c3", time_to_commit_seconds: 1800, is_first_commit: true}]} +``` + ## Common Patterns ### Understanding tool usage From 388fbc159ff4eae84d5b5440cc89aa5266e95d66 Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Thu, 1 Jan 2026 19:01:52 +0000 Subject: [PATCH 5/6] Add make restart command and document when restarts are needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `make restart` to reload LaunchAgent after code changes - Add "When to restart" table to CLAUDE.md clarifying which changes need a restart (MCP tools, queries, storage) vs which don't (CLI, tests, docs) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 16 +++++++++++++++- Makefile | 21 ++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 90ec34c..627e204 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,12 +30,26 @@ Key components: ## Commands ```bash -make check # Run fmt, lint, test (84 tests) +make check # Run fmt, lint, test make install # Install LaunchAgent + CLI make uninstall # Remove LaunchAgent + CLI +make restart # Restart LaunchAgent to pick up code changes make dev # Run in dev mode with auto-reload ``` +### When to restart + +The LaunchAgent runs the installed Python code. After making changes, you need to restart for them to take effect: + +| Change type | Restart needed? | +|-------------|-----------------| +| MCP tools (`server.py`) | Yes - `make restart` | +| Query/pattern logic (`queries.py`, `patterns.py`) | Yes - `make restart` | +| Storage/migrations (`storage.py`) | Yes - `make restart` | +| CLI only (`cli.py`) | No - CLI runs fresh each time | +| Tests | No - pytest runs fresh | +| Documentation (`guide.md`, `CLAUDE.md`) | No | + ## Key Files | File | Purpose | diff --git a/Makefile b/Makefile index a191f7c..4400f97 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check fmt lint test clean install uninstall dev venv +.PHONY: check fmt lint test clean install uninstall restart dev venv # Run all quality gates (format check, lint, tests) check: fmt lint test @@ -56,6 +56,25 @@ install: venv @echo "Make sure ~/.local/bin is in your PATH:" @echo ' export PATH="$$HOME/.local/bin:$$PATH"' +# Restart the LaunchAgent (pick up code changes) +restart: + @PLIST="$$HOME/Library/LaunchAgents/com.evansenter.claude-session-analytics.plist"; \ + if [ -f "$$PLIST" ]; then \ + echo "Restarting session-analytics..."; \ + launchctl unload "$$PLIST" 2>/dev/null || true; \ + launchctl load "$$PLIST"; \ + sleep 1; \ + if launchctl list | grep -q "com.evansenter.claude-session-analytics"; then \ + echo "Service restarted successfully"; \ + else \ + echo "Error: Service failed to start. Check ~/.claude/session-analytics.err"; \ + exit 1; \ + fi; \ + else \ + echo "LaunchAgent not installed. Run: make install"; \ + exit 1; \ + fi + # Uninstall: LaunchAgent + CLI + MCP config uninstall: @echo "Uninstalling..." From 38d8fff64ff70025bf34dbe741eaf1bf19a91167 Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Thu, 1 Jan 2026 19:27:13 +0000 Subject: [PATCH 6/6] Standardize MCP API naming conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align session-analytics API with claude-event-bus conventions: **Tool renames (query_* β†’ list_*/get_*)**: - query_tool_frequency β†’ get_tool_frequency - query_command_frequency β†’ get_command_frequency - query_sessions β†’ list_sessions - query_timeline β†’ get_session_events - query_tokens β†’ get_token_usage - query_sequences β†’ get_tool_sequences - query_permission_gaps β†’ get_permission_gaps - query_outcomes β†’ get_session_signals (revised per RFC #17) **Argument standardization**: - threshold β†’ min_count (consistency) - min_events β†’ min_count (consistency) - hours β†’ days (fractional: 0.5 = 12 hours) - count β†’ limit (alignment with event-bus) **Documentation**: - Add Suggested Workflows section to guide.md with flowcharts - Update CLAUDE.md tool table - Add MCP API Naming Conventions section πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 46 +++++++--- src/session_analytics/cli.py | 41 +++++---- src/session_analytics/guide.md | 135 ++++++++++++++++++++---------- src/session_analytics/patterns.py | 6 +- src/session_analytics/server.py | 87 +++++++++---------- tests/test_cli.py | 6 +- tests/test_patterns.py | 14 ++-- tests/test_server.py | 56 ++++++------- 8 files changed, 237 insertions(+), 154 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 627e204..cd4dae2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,30 @@ The LaunchAgent runs the installed Python code. After making changes, you need t - **Schema Migrations**: Use `@migration(version, name)` decorator in storage.py for DB changes - **Module Imports**: server.py uses `from session_analytics import queries, patterns, ingest` +## MCP API Naming Conventions + +Standard conventions shared with claude-event-bus. See event-bus CLAUDE.md for the canonical reference. + +### Tool Names + +| Prefix | When to use | Example | +|--------|-------------|---------| +| `list_*` | Enumerate items (no complex filtering) | `list_sessions()` | +| `get_*` | Retrieve data with parameters/filters | `get_events(...)` | +| `search_*` | Full-text/fuzzy search | `search_messages(...)` | +| `analyze_*` | Compute derived insights | `analyze_trends(...)` | +| `ingest_*` | Load/import data | `ingest_logs(...)` | + +### Argument Names + +| Concept | Standard Name | Notes | +|---------|---------------|-------| +| Session identifier | `session_id` | Not `session` or `sid` | +| Max results | `limit` | Not `count` or `max` | +| Time window | `days` | Use fractional for hours: `days=0.5` = 12h | +| Project filter | `project` | Not `project_path` | +| Minimum threshold | `min_count` | Not `threshold` or `min_events` | + ## Design Philosophy **"Don't over-distill"** (RFC #17): Raw data with light structure beats heavily processed summaries. The LLM can handle context. @@ -95,26 +119,26 @@ Do this: |------|---------| | `get_status` | Database stats and last ingestion time | | `ingest_logs` | Refresh data from JSONL files | -| `query_tool_frequency` | Tool usage counts (Read, Edit, Bash, etc.) | -| `query_timeline` | Events in time window (supports `session_id` filter) | -| `query_commands` | Bash command breakdown with prefix filter | -| `query_sessions` | Session metadata and token totals (lists all session IDs) | -| `query_tokens` | Token usage by day, session, or model | -| `query_sequences` | Common tool patterns (n-grams, `length` param for n-gram size) | -| `query_permission_gaps` | Commands needing settings.json entries | +| `get_tool_frequency` | Tool usage counts (Read, Edit, Bash, etc.) | +| `get_session_events` | Events in time window (supports `session_id` filter) | +| `get_command_frequency` | Bash command breakdown with prefix filter | +| `list_sessions` | Session metadata and token totals (lists all session IDs) | +| `get_token_usage` | Token usage by day, session, or model | +| `get_tool_sequences` | Common tool patterns (n-grams, `length` param for n-gram size) | +| `get_permission_gaps` | Commands needing settings.json entries | | `get_insights` | Pre-computed patterns for /improve-workflow | -| `get_user_journey` | User messages across sessions (supports `session_id` filter) | +| `get_session_messages` | User messages across sessions (supports `session_id` filter) | | `search_messages` | Full-text search on user messages (FTS5) | | `get_session_signals` | Raw session metrics for LLM interpretation (RFC #26) | | `get_session_commits` | Session-commit mappings with timing (RFC #26) | ### Session Discovery and Drill-In Flow -1. **Discover sessions**: `query_sessions()` returns all session IDs with basic metadata +1. **Discover sessions**: `list_sessions()` returns all session IDs with basic metadata 2. **Get signals**: `get_session_signals()` returns raw metrics (error_rate, commit_count, etc.) 3. **Drill into session**: - - `query_timeline(session_id=)` - get full event trace - - `get_user_journey(session_id=)` - get all user messages + - `get_session_events(session_id=)` - get full event trace + - `get_session_messages(session_id=)` - get all user messages - `get_session_commits(session_id=)` - get commit associations > **Maintainer note**: This discovery flow is also documented in `src/session_analytics/guide.md` diff --git a/src/session_analytics/cli.py b/src/session_analytics/cli.py index a93be56..4ea8f75 100644 --- a/src/session_analytics/cli.py +++ b/src/session_analytics/cli.py @@ -507,7 +507,7 @@ def cmd_sequences(args): def cmd_permissions(args): """Show permission gaps.""" storage = SQLiteStorage() - patterns = compute_permission_gaps(storage, days=args.days, threshold=args.threshold) + patterns = compute_permission_gaps(storage, days=args.days, threshold=args.min_count) result = { "days": args.days, "gaps": [ @@ -540,7 +540,7 @@ def cmd_sample_sequences(args): result = do_sample_sequences( storage, pattern=args.pattern, - count=args.count, + count=args.limit, context_events=args.context, days=args.days, ) @@ -548,11 +548,12 @@ def cmd_sample_sequences(args): def cmd_journey(args): - """Show user journey across sessions.""" + """Show user messages across sessions.""" storage = SQLiteStorage() + hours = int(args.days * 24) result = get_user_journey( storage, - hours=args.hours, + hours=hours, include_projects=not args.no_projects, session_id=getattr(args, "session_id", None), limit=args.limit, @@ -595,9 +596,10 @@ def cmd_search(args): def cmd_parallel(args): """Show parallel session detection.""" storage = SQLiteStorage() + hours = int(args.days * 24) result = detect_parallel_sessions( storage, - hours=args.hours, + hours=hours, min_overlap_minutes=args.min_overlap, ) print(format_output(result, args.json)) @@ -641,10 +643,11 @@ def cmd_classify(args): def cmd_handoff(args): """Show handoff context for a session.""" storage = SQLiteStorage() + hours = int(args.days * 24) result = do_get_handoff_context( storage, session_id=args.session_id, - hours=args.hours, + hours=hours, message_limit=args.limit, ) print(format_output(result, args.json)) @@ -689,7 +692,7 @@ def cmd_signals(args): result = do_get_signals( storage, days=args.days, - min_events=args.min_events, + min_count=args.min_count, project=args.project, ) print(format_output(result, args.json)) @@ -809,7 +812,7 @@ def main(): # permissions sub = subparsers.add_parser("permissions", help="Show permission gaps") sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") - sub.add_argument("--threshold", type=int, default=5, help="Minimum usage count") + sub.add_argument("--min-count", type=int, default=5, help="Minimum usage count (default: 5)") sub.set_defaults(func=cmd_permissions) # insights @@ -827,15 +830,17 @@ def main(): ) sub.add_argument("pattern", help="Pattern to sample (e.g., 'Read β†’ Edit' or 'Read,Edit')") sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") - sub.add_argument("--count", type=int, default=5, help="Number of samples (default: 5)") + sub.add_argument("--limit", type=int, default=5, help="Number of samples (default: 5)") sub.add_argument( "--context", type=int, default=2, help="Context events before/after (default: 2)" ) sub.set_defaults(func=cmd_sample_sequences) - # journey - sub = subparsers.add_parser("journey", help="Show user journey across sessions") - sub.add_argument("--hours", type=int, default=24, help="Hours to look back (default: 24)") + # journey (maps to get_session_messages MCP tool) + sub = subparsers.add_parser("journey", help="Show user messages across sessions") + sub.add_argument( + "--days", type=float, default=1, help="Days to look back (default: 1, supports 0.5 for 12h)" + ) sub.add_argument("--limit", type=int, default=100, help="Max messages (default: 100)") sub.add_argument("--no-projects", action="store_true", help="Exclude project info") sub.add_argument("--session-id", help="Filter to specific session ID") @@ -850,7 +855,9 @@ def main(): # parallel sub = subparsers.add_parser("parallel", help="Detect parallel sessions") - sub.add_argument("--hours", type=int, default=24, help="Hours to look back (default: 24)") + sub.add_argument( + "--days", type=float, default=1, help="Days to look back (default: 1, supports 0.5 for 12h)" + ) sub.add_argument("--min-overlap", type=int, default=5, help="Min overlap minutes (default: 5)") sub.set_defaults(func=cmd_parallel) @@ -884,7 +891,9 @@ def main(): # handoff sub = subparsers.add_parser("handoff", help="Get handoff context for a session") sub.add_argument("--session-id", help="Specific session ID (default: most recent)") - sub.add_argument("--hours", type=int, default=4, help="Hours to look back (default: 4)") + sub.add_argument( + "--days", type=float, default=0.17, help="Days to look back (default: 0.17 = ~4 hours)" + ) sub.add_argument("--limit", type=int, default=10, help="Max messages (default: 10)") sub.set_defaults(func=cmd_handoff) @@ -914,9 +923,7 @@ def main(): # signals (RFC #26, revised per RFC #17 - raw data, no interpretation) sub = subparsers.add_parser("signals", help="Show raw session signals for LLM interpretation") sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") - sub.add_argument( - "--min-events", type=int, default=1, help="Min events per session (default: 1)" - ) + sub.add_argument("--min-count", type=int, default=1, help="Min events per session (default: 1)") sub.add_argument("--project", help="Project path filter") sub.set_defaults(func=cmd_signals) diff --git a/src/session_analytics/guide.md b/src/session_analytics/guide.md index 7e7a5f3..d3ca02b 100644 --- a/src/session_analytics/guide.md +++ b/src/session_analytics/guide.md @@ -20,47 +20,45 @@ identify permission gaps. | Tool | Purpose | |------|---------| -| `query_tool_frequency(days?, project?)` | Tool usage counts (Read, Edit, Bash, etc.) | -| `query_commands(days?, prefix?, project?)` | Bash command breakdown | -| `query_sessions(days?, project?)` | Session metadata and token totals | -| `query_tokens(days?, by?, project?)` | Token usage by day, session, or model | -| `query_timeline(hours?, tool?, session_id?)` | Recent events with filtering | +| `get_tool_frequency(days?, project?)` | Tool usage counts (Read, Edit, Bash, etc.) | +| `get_command_frequency(days?, prefix?, project?)` | Bash command breakdown | +| `list_sessions(days?, project?)` | Session metadata and token totals | +| `get_token_usage(days?, by?, project?)` | Token usage by day, session, or model | +| `get_session_events(days?, tool?, session_id?)` | Recent events with filtering | ### Pattern Analysis | Tool | Purpose | |------|---------| -| `query_sequences(days?, min_count?, length?)` | Common tool chains (e.g., Read β†’ Edit β†’ Bash) | -| `query_permission_gaps(days?, threshold?)` | Commands that should be in settings.json | +| `get_tool_sequences(days?, min_count?, length?)` | Common tool chains (e.g., Read β†’ Edit β†’ Bash) | +| `get_permission_gaps(days?, min_count?)` | Commands that should be in settings.json | | `get_insights(days?, refresh?)` | Pre-computed patterns for /improve-workflow | ### Failure Analysis | Tool | Purpose | |------|---------| -| `query_failure_correlation(days?, project?)` | Correlate tool failures with commands | -| `query_common_failures(days?, min_count?)` | Aggregate failure patterns | +| `analyze_failures(days?, project?)` | Failure patterns, rework, and correlations | ### Session Classification | Tool | Purpose | |------|---------| | `classify_sessions(days?, project?)` | Categorize sessions (debugging, development, research, maintenance) | -| `query_session_progression(session_id)` | Track session stage transitions | ### Trend Analysis | Tool | Purpose | |------|---------| | `analyze_trends(days?, compare_to?)` | Token/event trends with growth rates | -| `compare_periods(days?, metric?)` | Period-over-period comparisons | ### User Workflow | Tool | Purpose | |------|---------| -| `get_user_journey(days?, project?)` | Session summaries with tool chains | +| `get_session_messages(days?, project?)` | User messages across sessions chronologically | | `find_related_sessions(session_id)` | Find sessions with similar patterns | +| `search_messages(query, limit?)` | Full-text search on user messages (FTS5) | ### Git Integration @@ -68,7 +66,13 @@ identify permission gaps. |------|---------| | `ingest_git_history(days?, repo_path?)` | Parse and store git commits | | `correlate_git_with_sessions(days?)` | Link commits to sessions by timing | -| `query_session_commits(session_id)` | Get commits associated with a session | +| `get_session_commits(session_id?)` | Get commits associated with a session | + +### Session Signals + +| Tool | Purpose | +|------|---------| +| `get_session_signals(days?, min_count?)` | Raw session metrics for LLM interpretation | ## Quick Start @@ -87,17 +91,66 @@ Data auto-refreshes when queries detect stale data (>5 min old). ### 3. Query your usage ``` -query_tool_frequency(days=30) +get_tool_frequency(days=30) β†’ {tools: [{name: "Read", count: 1234}, {name: "Edit", count: 567}, ...]} ``` +## Suggested Workflows + +These are common patterns for using the analytics API. They're suggestions, not requirementsβ€” +use the APIs however best fits your needs. + +### Workflow: Broad to Narrow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ BROAD OVERVIEW β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ get_status() β†’ Is data fresh? How many events? β”‚ +β”‚ get_tool_frequency() β†’ What tools are used most? β”‚ +β”‚ get_command_frequency()β†’ What commands are common? β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ DISCOVER PATTERNS β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ list_sessions() β†’ What sessions exist? β”‚ +β”‚ get_session_signals() β†’ Which sessions look interesting? β”‚ +β”‚ classify_sessions() β†’ What type of work (debug, dev, etc)? β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ DRILL INTO SPECIFICS β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ get_session_events(session_id=X) β†’ Full event trace β”‚ +β”‚ get_session_messages(session_id=X) β†’ User intent β”‚ +β”‚ get_session_commits(session_id=X) β†’ Work products β”‚ +β”‚ search_messages("query") β†’ Find specific topics β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Workflow: Question-Based + +| Question | Tools to Use | +|----------|-------------| +| "What have I been working on?" | `list_sessions()` β†’ `get_session_messages()` | +| "Why did session X struggle?" | `get_session_signals(session_id=X)` β†’ `get_session_events(session_id=X)` | +| "What workflows can I automate?" | `get_tool_sequences()` β†’ `get_permission_gaps()` | +| "How has my usage changed?" | `analyze_trends()` | +| "What did I do with feature X?" | `search_messages("feature X")` | + +### Workflow: Improvement-Focused + +``` +get_permission_gaps() β†’ "Add these commands to settings.json" +get_tool_sequences() β†’ "These patterns could be automated" +analyze_failures() β†’ "These commands tend to fail" +analyze_trends() β†’ "Usage is increasing/decreasing" +``` + ## Session Discovery and Drill-In A common workflow is discovering sessions, getting signals about them, then drilling into interesting ones: ### 1. Discover sessions ``` -query_sessions(days=7) +list_sessions(days=7) β†’ {sessions: [{id: "abc123", project: "my-repo", event_count: 50}, ...]} ``` @@ -115,11 +168,11 @@ The LLM interprets these raw signals - high error rate + rework + no commits mig ### 3. Drill into an interesting session ``` # Get full event trace -query_timeline(session_id="abc123") +get_session_events(session_id="abc123") β†’ {events: [{tool: "Read", file: "auth.py", ...}, {tool: "Edit", ...}, ...]} # Get all user messages -get_user_journey(session_id="abc123") +get_session_messages(session_id="abc123") β†’ {messages: [{content: "Fix the login bug", ...}, ...]} # Get commit associations @@ -133,22 +186,22 @@ get_session_commits(session_id="abc123") ``` # What tools do I use most? -query_tool_frequency(days=30) +get_tool_frequency(days=30) # What bash commands do I run? -query_commands(days=30, prefix="git") # Just git commands -query_commands(days=30) # All commands +get_command_frequency(days=30, prefix="git") # Just git commands +get_command_frequency(days=30) # All commands ``` ### Finding workflow sequences ``` # What 2-tool patterns are common? -query_sequences(length=2, min_count=10) +get_tool_sequences(length=2, min_count=10) β†’ [{pattern: "Read β†’ Edit", count: 234}, {pattern: "Grep β†’ Read", count: 156}, ...] # What 3-tool patterns? -query_sequences(length=3, min_count=5) +get_tool_sequences(length=3, min_count=5) β†’ [{pattern: "Read β†’ Edit β†’ Bash", count: 45}, ...] ``` @@ -156,7 +209,7 @@ query_sequences(length=3, min_count=5) ``` # Commands I use frequently but haven't added to settings.json -query_permission_gaps(threshold=5) +get_permission_gaps(min_count=5) β†’ [{command: "npm test", count: 23, suggestion: "Bash(npm test:*)"}, ...] ``` @@ -166,26 +219,26 @@ Add these to your `~/.claude/settings.json` under `permissions.allow`. ``` # Usage by day -query_tokens(days=30, by="day") +get_token_usage(days=30, by="day") # Usage by model -query_tokens(days=30, by="model") +get_token_usage(days=30, by="model") # Usage by session -query_tokens(days=7, by="session") +get_token_usage(days=7, by="session") ``` ### Timeline exploration ``` -# Recent events -query_timeline(hours=24) +# Recent events (1 day = 24 hours) +get_session_events(days=1) # Filter by tool -query_timeline(hours=24, tool="Bash") +get_session_events(days=1, tool="Bash") # Filter by session -query_timeline(session_id="abc123") +get_session_events(session_id="abc123") ``` ### Session classification @@ -213,13 +266,9 @@ Categories: ### Failure analysis ``` -# What commands tend to fail? -query_common_failures(days=30, min_count=3) -β†’ [{tool: "Bash", command: "cargo test", count: 12}, ...] - -# Correlate failures with context -query_failure_correlation(days=30) -β†’ {correlations: [{tool: "Bash", command: "npm install", failure_rate: 0.15}, ...]} +# Analyze failure patterns and rework +analyze_failures(days=30) +β†’ {total_errors: 45, errors_by_tool: [...], rework_patterns: {...}} ``` ### Git integration @@ -234,8 +283,8 @@ correlate_git_with_sessions(days=30) β†’ {sessions_analyzed: 20, commits_correlated: 38} # See what commits were made during a session -query_session_commits(session_id="abc123") -β†’ [{sha: "abc...", message: "Fix auth bug", timestamp: "..."}] +get_session_commits(session_id="abc123") +β†’ [{sha: "abc...", time_to_commit_seconds: 1800, is_first_commit: true}] ``` ### Trend analysis @@ -282,14 +331,14 @@ This powers data-driven workflow improvement suggestions. ### Querying -4. **Start with frequency** - `query_tool_frequency` gives quick overview +4. **Start with frequency** - `get_tool_frequency` gives quick overview 5. **Use day filters** - `days=7` for recent trends, `days=30` for patterns 6. **Project filter** - Most queries accept `project` to focus on one repo ### Permission Gaps -7. **Check weekly** - Run `query_permission_gaps(threshold=3)` to catch new patterns -8. **Higher threshold = less noise** - Start with `threshold=10` if overwhelmed +7. **Check weekly** - Run `get_permission_gaps(min_count=3)` to catch new patterns +8. **Higher min_count = less noise** - Start with `min_count=10` if overwhelmed 9. **Review before adding** - Some commands shouldn't be auto-approved ### Workflow Improvement @@ -327,7 +376,7 @@ on subsequent ingestions, making `ingest_logs` fast for daily use. - Data auto-refreshes on query if stale (>5 min since last ingestion) - Use `get_status()` to check when data was last refreshed - The `project` filter uses LIKE matching - partial names work -- `query_sequences` with `length=3` finds more complex patterns but needs more data +- `get_tool_sequences` with `length=3` finds more complex patterns but needs more data - Permission gaps compare your usage against `~/.claude/settings.json` - Token queries help track API usage costs over time - The CLI (`session-analytics-cli`) mirrors all MCP tools for terminal use diff --git a/src/session_analytics/patterns.py b/src/session_analytics/patterns.py index e69a4fd..7629d04 100644 --- a/src/session_analytics/patterns.py +++ b/src/session_analytics/patterns.py @@ -772,7 +772,7 @@ def get_insights( def get_session_signals( storage: SQLiteStorage, days: int = 7, - min_events: int = 1, + min_count: int = 1, project: str | None = None, ) -> dict: """Get raw session signals for LLM interpretation. @@ -787,7 +787,7 @@ def get_session_signals( Args: storage: Storage instance days: Number of days to analyze (default: 7) - min_events: Minimum events for a session to be included (default: 1) + min_count: Minimum events for a session to be included (default: 1) project: Optional project path filter Returns: @@ -801,7 +801,7 @@ def get_session_signals( if project: project_filter = "AND project_path LIKE ?" params.append(f"%{project}%") - params.append(min_events) + params.append(min_count) # Get session summaries with activity metrics sessions = storage.execute_query( diff --git a/src/session_analytics/server.py b/src/session_analytics/server.py index d834ded..f029313 100644 --- a/src/session_analytics/server.py +++ b/src/session_analytics/server.py @@ -2,19 +2,19 @@ Provides tools for querying Claude Code session logs: - ingest_logs: Refresh data from JSONL files -- query_timeline: Events in time window -- query_tool_frequency: Tool usage counts -- query_commands: Bash command breakdown -- query_sequences: Common tool patterns -- query_permission_gaps: Commands needing settings.json -- query_sessions: Session metadata -- query_tokens: Token usage analysis +- list_sessions: Session metadata +- get_session_events: Events for a session/time window +- get_session_messages: User messages across sessions +- get_session_signals: Raw session signals for LLM interpretation +- get_session_commits: Session-commit mappings +- get_tool_frequency: Tool usage counts +- get_command_frequency: Bash command breakdown +- get_tool_sequences: Common tool patterns +- get_token_usage: Token usage analysis +- get_permission_gaps: Commands needing settings.json - get_insights: Pre-computed patterns for /improve-workflow - get_status: Ingestion status + DB stats -- get_user_journey: User messages across sessions - search_messages: Full-text search on user messages -- get_session_signals: Raw session signals for LLM interpretation (RFC #26) -- get_session_commits: Session-commit mappings (RFC #26) """ import logging @@ -99,7 +99,7 @@ def ingest_logs(days: int = 7, project: str | None = None, force: bool = False) @mcp.tool() -def query_tool_frequency(days: int = 7, project: str | None = None) -> dict: +def get_tool_frequency(days: int = 7, project: str | None = None) -> dict: """Get tool usage frequency counts. Args: @@ -115,7 +115,7 @@ def query_tool_frequency(days: int = 7, project: str | None = None) -> dict: @mcp.tool() -def query_timeline( +def get_session_events( start: str | None = None, end: str | None = None, tool: str | None = None, @@ -123,7 +123,7 @@ def query_timeline( session_id: str | None = None, limit: int = 100, ) -> dict: - """Get events in a time window. + """Get events in a time window or for a specific session. Args: start: Start time (ISO format, default: 24 hours ago) @@ -155,7 +155,9 @@ def query_timeline( @mcp.tool() -def query_commands(days: int = 7, project: str | None = None, prefix: str | None = None) -> dict: +def get_command_frequency( + days: int = 7, project: str | None = None, prefix: str | None = None +) -> dict: """Get Bash command breakdown. Args: @@ -172,8 +174,8 @@ def query_commands(days: int = 7, project: str | None = None, prefix: str | None @mcp.tool() -def query_sessions(days: int = 7, project: str | None = None) -> dict: - """Get session metadata. +def list_sessions(days: int = 7, project: str | None = None) -> dict: + """List all sessions with metadata. Args: days: Number of days to analyze (default: 7) @@ -188,7 +190,7 @@ def query_sessions(days: int = 7, project: str | None = None) -> dict: @mcp.tool() -def query_tokens(days: int = 7, project: str | None = None, by: str = "day") -> dict: +def get_token_usage(days: int = 7, project: str | None = None, by: str = "day") -> dict: """Get token usage analysis. Args: @@ -205,7 +207,7 @@ def query_tokens(days: int = 7, project: str | None = None, by: str = "day") -> @mcp.tool() -def query_sequences(days: int = 7, min_count: int = 3, length: int = 2) -> dict: +def get_tool_sequences(days: int = 7, min_count: int = 3, length: int = 2) -> dict: """Get common tool patterns (sequences). Args: @@ -230,7 +232,7 @@ def query_sequences(days: int = 7, min_count: int = 3, length: int = 2) -> dict: @mcp.tool() -def sample_sequences(pattern: str, count: int = 5, context_events: int = 2, days: int = 7) -> dict: +def sample_sequences(pattern: str, limit: int = 5, context_events: int = 2, days: int = 7) -> dict: """Get random samples of a sequence pattern with surrounding context. Instead of just counting "Read β†’ Edit" occurrences, returns actual examples @@ -238,7 +240,7 @@ def sample_sequences(pattern: str, count: int = 5, context_events: int = 2, days Args: pattern: Sequence pattern (e.g., "Read β†’ Edit" or "Read,Edit") - count: Number of random samples to return (default: 5) + limit: Number of random samples to return (default: 5) context_events: Number of events before/after to include (default: 2) days: Number of days to analyze (default: 7) @@ -247,28 +249,28 @@ def sample_sequences(pattern: str, count: int = 5, context_events: int = 2, days """ queries.ensure_fresh_data(storage, days=days) result = patterns.sample_sequences( - storage, pattern=pattern, count=count, context_events=context_events, days=days + storage, pattern=pattern, count=limit, context_events=context_events, days=days ) return {"status": "ok", **result} @mcp.tool() -def query_permission_gaps(days: int = 7, threshold: int = 5) -> dict: +def get_permission_gaps(days: int = 7, min_count: int = 5) -> dict: """Find commands that may need to be added to settings.json. Args: days: Number of days to analyze (default: 7) - threshold: Minimum usage count to suggest (default: 5) + min_count: Minimum usage count to suggest (default: 5) Returns: Commands that are frequently used but not in allowed list """ queries.ensure_fresh_data(storage, days=days) - gap_patterns = patterns.compute_permission_gaps(storage, days=days, threshold=threshold) + gap_patterns = patterns.compute_permission_gaps(storage, days=days, threshold=min_count) return { "status": "ok", "days": days, - "threshold": threshold, + "min_count": min_count, "gaps": [ { "command": p.pattern_key, @@ -281,8 +283,8 @@ def query_permission_gaps(days: int = 7, threshold: int = 5) -> dict: @mcp.tool() -def get_user_journey( - hours: int = 24, +def get_session_messages( + days: float = 1, include_projects: bool = True, session_id: str | None = None, limit: int = 100, @@ -293,7 +295,7 @@ def get_user_journey( revealing task switching, project interleaving, and work patterns. Args: - hours: Number of hours to look back (default: 24) + days: Number of days to look back (default: 1, supports fractions like 0.5 for 12h) include_projects: Include project info in output (default: True) session_id: Optional session ID filter (get messages from specific session) limit: Maximum messages to return (default: 100) @@ -301,7 +303,8 @@ def get_user_journey( Returns: Journey events with timestamps, sessions, and messages """ - queries.ensure_fresh_data(storage, days=max(1, hours // 24 + 1)) + hours = int(days * 24) + queries.ensure_fresh_data(storage, days=max(1, int(days) + 1)) result = queries.get_user_journey( storage, hours=hours, @@ -361,20 +364,21 @@ def search_messages(query: str, limit: int = 50, project: str | None = None) -> @mcp.tool() -def detect_parallel_sessions(hours: int = 24, min_overlap_minutes: int = 5) -> dict: +def detect_parallel_sessions(days: float = 1, min_overlap_minutes: int = 5) -> dict: """Find sessions that were active simultaneously. Identifies when multiple sessions were active at the same time, indicating worktree usage, waiting on CI, or multi-task work. Args: - hours: Number of hours to look back (default: 24) + days: Number of days to look back (default: 1, supports fractions like 0.5 for 12h) min_overlap_minutes: Minimum overlap to consider parallel (default: 5) Returns: Parallel session periods with timing and session details """ - queries.ensure_fresh_data(storage, days=max(1, hours // 24 + 1)) + hours = int(days * 24) + queries.ensure_fresh_data(storage, days=max(1, int(days) + 1)) result = queries.detect_parallel_sessions( storage, hours=hours, min_overlap_minutes=min_overlap_minutes ) @@ -468,9 +472,7 @@ def classify_sessions(days: int = 7, project: str | None = None) -> dict: @mcp.tool() -def get_handoff_context( - session_id: str | None = None, hours: int = 4, message_limit: int = 10 -) -> dict: +def get_handoff_context(session_id: str | None = None, days: float = 0.17, limit: int = 10) -> dict: """Get context for session handoff (useful for /status-report). Provides recent activity summary including last user messages, @@ -478,15 +480,16 @@ def get_handoff_context( Args: session_id: Specific session ID (default: most recent session) - hours: Hours to look back if no session specified (default: 4) - message_limit: Maximum messages to return (default: 10) + days: Days to look back if no session specified (default: 0.17 = ~4 hours) + limit: Maximum messages to return (default: 10) Returns: Handoff context including messages, files, commands, and activity summary """ - queries.ensure_fresh_data(storage, days=max(1, hours // 24 + 1)) + hours = int(days * 24) + queries.ensure_fresh_data(storage, days=max(1, int(days) + 1)) result = queries.get_handoff_context( - storage, session_id=session_id, hours=hours, message_limit=message_limit + storage, session_id=session_id, hours=hours, message_limit=limit ) return {"status": "ok", **result} @@ -548,7 +551,7 @@ def correlate_git_with_sessions(days: int = 7) -> dict: @mcp.tool() -def get_session_signals(days: int = 7, min_events: int = 1) -> dict: +def get_session_signals(days: int = 7, min_count: int = 1) -> dict: """Get raw session signals for LLM interpretation. RFC #26 (revised per RFC #17 principle): Extracts observable session data @@ -561,13 +564,13 @@ def get_session_signals(days: int = 7, min_events: int = 1) -> dict: Args: days: Number of days to analyze (default: 7) - min_events: Minimum events for a session to be included (default: 1) + min_count: Minimum events for a session to be included (default: 1) Returns: Raw session signals for LLM interpretation """ queries.ensure_fresh_data(storage, days=days) - result = patterns.get_session_signals(storage, days=days, min_events=min_events) + result = patterns.get_session_signals(storage, days=days, min_count=min_count) return {"status": "ok", **result} diff --git a/tests/test_cli.py b/tests/test_cli.py index 9e354dd..04629da 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -272,7 +272,7 @@ def test_cmd_permissions(self, populated_storage, capsys): class Args: json = False days = 7 - threshold = 1 + min_count = 1 with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): cmd_permissions(Args()) @@ -395,7 +395,7 @@ def test_cmd_signals(self, populated_storage, capsys): class Args: json = False days = 7 - min_events = 1 + min_count = 1 project = None with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): @@ -411,7 +411,7 @@ def test_cmd_signals_json(self, populated_storage, capsys): class Args: json = True days = 7 - min_events = 1 + min_count = 1 project = None with patch("session_analytics.cli.SQLiteStorage", return_value=populated_storage): diff --git a/tests/test_patterns.py b/tests/test_patterns.py index 7a0b5d2..542e047 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -1133,7 +1133,7 @@ def test_get_signals_with_commits(self, storage): storage.add_git_commit(GitCommit(sha="abc1234", timestamp=now)) storage.add_session_commit("signal-session", "abc1234", 300, True) - result = get_session_signals(storage, days=7, min_events=5) + result = get_session_signals(storage, days=7, min_count=5) # Should have raw signals, no outcome classification assert result["sessions_analyzed"] == 1 @@ -1169,7 +1169,7 @@ def test_get_signals_with_errors(self, storage): ) storage.add_events_batch(events) - result = get_session_signals(storage, days=7, min_events=5) + result = get_session_signals(storage, days=7, min_count=5) # Should include error rate as raw signal assert result["sessions_analyzed"] == 1 @@ -1178,8 +1178,8 @@ def test_get_signals_with_errors(self, storage): assert session["error_rate"] == 0.3 assert "outcome" not in session # No interpretation - def test_get_signals_min_events_filter(self, storage): - """Test that sessions below min_events threshold are excluded.""" + def test_get_signals_min_count_filter(self, storage): + """Test that sessions below min_count threshold are excluded.""" from session_analytics.patterns import get_session_signals now = datetime.now() @@ -1199,9 +1199,9 @@ def test_get_signals_min_events_filter(self, storage): ] storage.add_events_batch(events) - result = get_session_signals(storage, days=7, min_events=5) + result = get_session_signals(storage, days=7, min_count=5) - # Session should be excluded due to min_events + # Session should be excluded due to min_count assert result["sessions_analyzed"] == 0 def test_get_signals_includes_all_raw_fields(self, storage): @@ -1230,7 +1230,7 @@ def test_get_signals_includes_all_raw_fields(self, storage): storage.add_events_batch(events) storage.upsert_session(Session(id="full-session", project_path="/project")) - result = get_session_signals(storage, days=7, min_events=5) + result = get_session_signals(storage, days=7, min_count=5) assert result["sessions_analyzed"] == 1 session = result["sessions"][0] diff --git a/tests/test_server.py b/tests/test_server.py index d203b57..b02a5d3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,16 +1,16 @@ """Tests for the MCP server.""" from session_analytics.server import ( + get_command_frequency, get_insights, + get_permission_gaps, + get_session_events, get_status, + get_token_usage, + get_tool_frequency, + get_tool_sequences, ingest_logs, - query_commands, - query_permission_gaps, - query_sequences, - query_sessions, - query_timeline, - query_tokens, - query_tool_frequency, + list_sessions, search_messages, ) @@ -34,9 +34,9 @@ def test_ingest_logs(): assert "events_added" in result -def test_query_tool_frequency(): - """Test that query_tool_frequency returns tool counts.""" - result = query_tool_frequency.fn(days=7) +def test_get_tool_frequency(): + """Test that get_tool_frequency returns tool counts.""" + result = get_tool_frequency.fn(days=7) assert result["status"] == "ok" assert "days" in result assert "total_tool_calls" in result @@ -44,9 +44,9 @@ def test_query_tool_frequency(): assert isinstance(result["tools"], list) -def test_query_timeline(): - """Test that query_timeline returns events.""" - result = query_timeline.fn(limit=10) +def test_get_session_events(): + """Test that get_session_events returns events.""" + result = get_session_events.fn(limit=10) assert result["status"] == "ok" assert "start" in result assert "end" in result @@ -54,9 +54,9 @@ def test_query_timeline(): assert isinstance(result["events"], list) -def test_query_commands(): - """Test that query_commands returns command counts.""" - result = query_commands.fn(days=7) +def test_get_command_frequency(): + """Test that get_command_frequency returns command counts.""" + result = get_command_frequency.fn(days=7) assert result["status"] == "ok" assert "days" in result assert "total_commands" in result @@ -64,9 +64,9 @@ def test_query_commands(): assert isinstance(result["commands"], list) -def test_query_sessions(): - """Test that query_sessions returns session info.""" - result = query_sessions.fn(days=7) +def test_list_sessions(): + """Test that list_sessions returns session info.""" + result = list_sessions.fn(days=7) assert result["status"] == "ok" assert "days" in result assert "session_count" in result @@ -74,9 +74,9 @@ def test_query_sessions(): assert isinstance(result["sessions"], list) -def test_query_tokens(): - """Test that query_tokens returns token breakdown.""" - result = query_tokens.fn(days=7, by="day") +def test_get_token_usage(): + """Test that get_token_usage returns token breakdown.""" + result = get_token_usage.fn(days=7, by="day") assert result["status"] == "ok" assert "days" in result assert "group_by" in result @@ -84,18 +84,18 @@ def test_query_tokens(): assert isinstance(result["breakdown"], list) -def test_query_sequences(): - """Test that query_sequences returns sequence patterns.""" - result = query_sequences.fn(days=7, min_count=1, length=2) +def test_get_tool_sequences(): + """Test that get_tool_sequences returns sequence patterns.""" + result = get_tool_sequences.fn(days=7, min_count=1, length=2) assert result["status"] == "ok" assert "days" in result assert "sequences" in result assert isinstance(result["sequences"], list) -def test_query_permission_gaps(): - """Test that query_permission_gaps returns gap analysis.""" - result = query_permission_gaps.fn(days=7, threshold=1) +def test_get_permission_gaps(): + """Test that get_permission_gaps returns gap analysis.""" + result = get_permission_gaps.fn(days=7, min_count=1) assert result["status"] == "ok" assert "days" in result assert "gaps" in result