From c7df173de77965269475e0ce5628c629e1920f83 Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Sat, 10 Jan 2026 14:44:40 +0000 Subject: [PATCH 1/4] feat: Add limit parameters and efficiency metrics (closes #77, #78, #79) ## Issue #77: Limit parameters for verbose endpoints - Add `limit` parameter to get_tool_sequences (default: 50) - Add `limit` parameter to get_compaction_events (default: 50) - Add `limit` parameter to get_session_efficiency (default: 50) - Return total_patterns/total_compaction_count for pagination awareness - Use parameterized queries for LIMIT clauses (SQL injection prevention) ## Issue #78: Efficiency metrics in analyze_trends - Add compactions, avg_compactions_per_session to trend comparison - Add files_read_multiple_times as rework indicator - Add avg_result_mb_per_session for context consumption tracking ## Issue #79: Efficiency metrics in classify_sessions - Add efficiency object to each session with: - compaction_count, total_result_mb, files_read_multiple_times - burn_rate: "high" (>2/hr), "medium" (0.5-2/hr), "low" (<0.5/hr) ## Additional improvements - Filter non-actionable commands (pwd, cd, echo, etc.) from permission gaps - Update guide.md with new parameters and efficiency documentation - Add 6 new tests for all new functionality Co-Authored-By: Claude Opus 4.5 --- src/session_analytics/guide.md | 26 +++++-- src/session_analytics/patterns.py | 115 ++++++++++++++++++++++++++++++ src/session_analytics/queries.py | 97 +++++++++++++++++++++++-- src/session_analytics/server.py | 21 ++++-- tests/test_patterns.py | 65 +++++++++++++++++ tests/test_server.py | 75 +++++++++++++++++++ 6 files changed, 385 insertions(+), 14 deletions(-) diff --git a/src/session_analytics/guide.md b/src/session_analytics/guide.md index b2afd14..d8c988d 100644 --- a/src/session_analytics/guide.md +++ b/src/session_analytics/guide.md @@ -36,7 +36,7 @@ identify permission gaps. | Tool | Purpose | |------|---------| -| `get_tool_sequences(days?, min_count?, length?)` | Common tool chains (e.g., Read → Edit → Bash) | +| `get_tool_sequences(days?, min_count?, length?, limit?)` | Common tool chains (e.g., Read → Edit → Bash) | | `sample_sequences(pattern, limit?, context_events?)` | Random samples of a pattern with surrounding context | | `get_permission_gaps(days?, min_count?)` | Commands not covered by settings.json (supports glob patterns) | | `get_insights(days?, refresh?)` | Pre-computed patterns for /improve-workflow | @@ -68,11 +68,22 @@ Each session includes `classification_factors` explaining WHY it was categorized - `trigger`: The threshold that was exceeded (e.g., "error_rate > 15%") - Relevant metrics (error_rate, edit_rate, etc.) +Each session also includes `efficiency` metrics: +- `compaction_count`: Number of context resets +- `total_result_mb`: Total tool result size +- `files_read_multiple_times`: Indicator of rework +- `burn_rate`: "high", "medium", or "low" based on compactions/hour + ### Trend Analysis | Tool | Purpose | |------|---------| -| `analyze_trends(days?, compare_to?)` | Token/event trends with growth rates | +| `analyze_trends(days?, compare_to?)` | Token/event trends with efficiency metrics | + +Returns both core metrics (`events`, `sessions`, `errors`, `tokens`) and `efficiency` metrics: +- `avg_compactions_per_session`: Context resets per session (lower is better) +- `avg_result_mb_per_session`: Context consumption per session +- `files_read_multiple_times`: Rework indicator ### Session Messages @@ -120,10 +131,10 @@ Each session includes `classification_factors` explaining WHY it was categorized | Tool | Purpose | |------|---------| -| `get_compaction_events(days?, session_id?)` | List compaction events (context resets) | +| `get_compaction_events(days?, session_id?, limit?)` | List compaction events (context resets) | | `get_pre_compaction_events(session_id, compaction_timestamp, limit?)` | Events before a compaction for analysis | | `get_large_tool_results(days?, min_size_kb?, limit?)` | Find tool results consuming context space | -| `get_session_efficiency(days?, project?)` | Session efficiency metrics and burn rate | +| `get_session_efficiency(days?, project?, limit?)` | Session efficiency metrics and burn rate | **Context efficiency** helps identify why sessions hit context limits: - **Compactions**: Context resets when Claude summarizes conversation @@ -251,8 +262,11 @@ get_permission_gaps(min_count=5) Add suggestions to `permissions.allow` in your settings. -**Note:** Supports glob pattern matching. Patterns like `Bash(make*)` will correctly -match commands `make`, `make-test`, etc. using fnmatch. +**Notes:** +- Supports glob pattern matching. Patterns like `Bash(make*)` will correctly + match commands `make`, `make-test`, etc. using fnmatch. +- Automatically filters non-actionable commands (shell builtins like `pwd`, `cd`, `echo`, + control flow like `for`, `if`, and info commands like `hostname`, `whoami`) to reduce noise. ### Git Integration diff --git a/src/session_analytics/patterns.py b/src/session_analytics/patterns.py index dbc97fd..2f6eb75 100644 --- a/src/session_analytics/patterns.py +++ b/src/session_analytics/patterns.py @@ -701,6 +701,55 @@ def _command_matches_patterns(cmd: str, base_commands: set[str], glob_patterns: return False +# Commands that don't need allowlisting - shell builtins, context commands, +# and other non-actionable patterns that create noise in permission gap analysis +NON_ACTIONABLE_COMMANDS = frozenset( + { + # Shell builtins / context commands + "pwd", + "cd", + "echo", + "true", + "false", + "exit", + "return", + "export", + "source", + ".", + # Comment prefixes (from multi-line commands) + "#", + # Control flow (from multi-line commands) + "for", + "while", + "if", + "then", + "else", + "fi", + "do", + "done", + "case", + "esac", + # System info commands (informational, not dangerous) + "hostname", + "whoami", + "id", + "uname", + "date", + "uptime", + # File viewers (read-only, typically safe) + "bat", + "less", + "more", + # Variable assignments (captured as commands) + "set", + "unset", + "local", + "declare", + "readonly", + } +) + + def compute_permission_gaps( storage: SQLiteStorage, days: int = 7, @@ -712,6 +761,9 @@ def compute_permission_gaps( Uses fnmatch for glob pattern matching, so patterns like Bash(make*) will correctly match commands like 'make', 'make-test', etc. + Filters out non-actionable commands (shell builtins, context commands) + that would create noise in the results. + Args: storage: Storage instance days: Number of days to analyze @@ -741,6 +793,9 @@ def compute_permission_gaps( patterns = [] for row in rows: cmd = row["command"] + # Skip non-actionable commands (builtins, context commands) + if cmd in NON_ACTIONABLE_COMMANDS: + continue if not _command_matches_patterns(cmd, base_commands, glob_patterns): patterns.append( Pattern( @@ -1179,6 +1234,39 @@ def get_period_metrics(start: datetime, end: datetime) -> dict: ) tokens = token_usage[0] if token_usage else {"input_tokens": 0, "output_tokens": 0} + # Efficiency metrics: compactions and result bytes + efficiency = storage.execute_query( + """ + SELECT + SUM(CASE WHEN entry_type = 'compaction' THEN 1 ELSE 0 END) as compaction_count, + COALESCE(SUM(result_size_bytes), 0) as total_result_bytes + FROM events + WHERE timestamp >= ? AND timestamp < ? + """, + (start, end), + ) + eff = efficiency[0] if efficiency else {"compaction_count": 0, "total_result_bytes": 0} + compaction_count = eff["compaction_count"] or 0 + total_result_bytes = eff["total_result_bytes"] or 0 + + # Files read multiple times (rework indicator) + multi_read = storage.execute_query( + """ + SELECT COUNT(*) as multi_read_files + FROM ( + SELECT file_path + FROM events + WHERE timestamp >= ? AND timestamp < ? + AND tool_name = 'Read' + AND file_path IS NOT NULL + GROUP BY session_id, file_path + HAVING COUNT(*) > 1 + ) + """, + (start, end), + ) + files_read_multiple = multi_read[0]["multi_read_files"] if multi_read else 0 + return { "total_events": total_events, "sessions": sessions, @@ -1187,6 +1275,15 @@ def get_period_metrics(start: datetime, end: datetime) -> dict: "top_tools": top_tools, "input_tokens": tokens["input_tokens"] or 0, "output_tokens": tokens["output_tokens"] or 0, + # Efficiency metrics + "compaction_count": compaction_count, + "total_result_bytes": total_result_bytes, + "files_read_multiple_times": files_read_multiple, + # Per-session averages + "avg_compactions_per_session": compaction_count / sessions if sessions > 0 else 0, + "avg_result_mb_per_session": (total_result_bytes / 1024 / 1024) / sessions + if sessions > 0 + else 0, } def calculate_change(current: float, previous: float) -> dict: @@ -1257,5 +1354,23 @@ def calculate_change(current: float, previous: float) -> dict: current_metrics["output_tokens"], previous_metrics["output_tokens"] ), }, + # Issue #78: Efficiency metrics for workflow improvement tracking + "efficiency": { + "compactions": calculate_change( + current_metrics["compaction_count"], previous_metrics["compaction_count"] + ), + "avg_compactions_per_session": calculate_change( + current_metrics["avg_compactions_per_session"], + previous_metrics["avg_compactions_per_session"], + ), + "files_read_multiple_times": calculate_change( + current_metrics["files_read_multiple_times"], + previous_metrics["files_read_multiple_times"], + ), + "avg_result_mb_per_session": calculate_change( + current_metrics["avg_result_mb_per_session"], + previous_metrics["avg_result_mb_per_session"], + ), + }, "tool_changes": tool_changes[:10], } diff --git a/src/session_analytics/queries.py b/src/session_analytics/queries.py index cef61cd..ae5a974 100644 --- a/src/session_analytics/queries.py +++ b/src/session_analytics/queries.py @@ -1100,7 +1100,7 @@ def classify_sessions( where_clause = " AND ".join(where_parts) - # Get activity stats per session + # Get activity stats per session (including efficiency metrics for #79) # Safe: where_clause is built from hardcoded condition strings above rows = storage.execute_query( f""" @@ -1115,6 +1115,8 @@ def classify_sessions( SUM(CASE WHEN tool_name = 'Bash' AND command IN ('git', 'gh') THEN 1 ELSE 0 END) as git_count, SUM(CASE WHEN tool_name = 'Bash' AND command IN ('make', 'cargo', 'npm', 'pytest') THEN 1 ELSE 0 END) as build_count, SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) as error_count, + SUM(CASE WHEN entry_type = 'compaction' THEN 1 ELSE 0 END) as compaction_count, + COALESCE(SUM(result_size_bytes), 0) as total_result_bytes, MIN(timestamp) as first_seen, MAX(timestamp) as last_seen FROM events @@ -1126,6 +1128,29 @@ def classify_sessions( tuple(params), ) + # Get files read multiple times per session + session_ids = [row["session_id"] for row in rows] + files_read_multiple: dict[str, int] = {} + if session_ids: + placeholders = ",".join("?" * len(session_ids)) + multi_read_rows = storage.execute_query( + f""" + SELECT session_id, COUNT(*) as multi_read_files + FROM ( + SELECT session_id, file_path, COUNT(*) as read_count + FROM events + WHERE session_id IN ({placeholders}) + AND tool_name = 'Read' + AND file_path IS NOT NULL + GROUP BY session_id, file_path + HAVING COUNT(*) > 1 + ) + GROUP BY session_id + """, + tuple(session_ids), + ) + files_read_multiple = {r["session_id"]: r["multi_read_files"] for r in multi_read_rows} + classifications = [] category_counts = { "debugging": 0, @@ -1200,6 +1225,36 @@ def classify_sessions( category_counts[category] += 1 + # Issue #79: Add efficiency metrics + compaction_count = row["compaction_count"] or 0 + total_bytes = row["total_result_bytes"] or 0 + multi_read_files = files_read_multiple.get(row["session_id"], 0) + + # Calculate burn_rate based on compactions per hour + first_seen = row["first_seen"] + last_seen = row["last_seen"] + if first_seen and last_seen: + try: + # Parse timestamps and calculate duration (datetime already imported at module level) + first_dt = datetime.fromisoformat(first_seen.replace("Z", "+00:00")) + last_dt = datetime.fromisoformat(last_seen.replace("Z", "+00:00")) + duration_hours = (last_dt - first_dt).total_seconds() / 3600 + compactions_per_hour = ( + compaction_count / duration_hours if duration_hours > 0 else 0 + ) + except (ValueError, TypeError): + compactions_per_hour = 0 + else: + compactions_per_hour = 0 + + # Classify burn rate: high (>2/hr), medium (0.5-2/hr), low (<0.5/hr) + if compactions_per_hour > 2: + burn_rate = "high" + elif compactions_per_hour > 0.5: + burn_rate = "medium" + else: + burn_rate = "low" + classifications.append( { "session_id": row["session_id"], @@ -1215,6 +1270,12 @@ def classify_sessions( "git_count": row["git_count"] or 0, "error_count": error_count, }, + "efficiency": { + "compaction_count": compaction_count, + "total_result_mb": round(total_bytes / 1024 / 1024, 2), + "files_read_multiple_times": multi_read_files, + "burn_rate": burn_rate, + }, "first_seen": _format_timestamp(row["first_seen"]), "last_seen": _format_timestamp(row["last_seen"]), } @@ -2037,6 +2098,7 @@ def get_compaction_events( storage: SQLiteStorage, days: int = 7, session_id: str | None = None, + limit: int = 50, ) -> dict: """List compaction events where conversation history was truncated. @@ -2047,6 +2109,7 @@ def get_compaction_events( storage: Storage instance days: Number of days to analyze (default: 7) session_id: Optional filter for specific session + limit: Maximum events to return (default: 50) Returns: Dict with compaction events and their timestamps @@ -2062,6 +2125,20 @@ def get_compaction_events( where_clause = " AND ".join(where_parts) + # First get total count + count_row = storage.execute_query( + f"SELECT COUNT(*) as total FROM events WHERE {where_clause}", + tuple(params), + ) + total_count = count_row[0]["total"] if count_row else 0 + + # Then get limited results + query_params = list(params) + limit_clause = "" + if limit > 0: + limit_clause = "LIMIT ?" + query_params.append(limit) + rows = storage.execute_query( f""" SELECT @@ -2073,8 +2150,9 @@ def get_compaction_events( FROM events WHERE {where_clause} ORDER BY timestamp DESC + {limit_clause} """, - tuple(params), + tuple(query_params), ) compactions = [ @@ -2091,6 +2169,8 @@ def get_compaction_events( return { "days": days, "session_id": session_id, + "limit": limit, + "total_compaction_count": total_count, "compaction_count": len(compactions), "compactions": compactions, } @@ -2242,6 +2322,7 @@ def get_session_efficiency( storage: SQLiteStorage, days: int = 7, project: str | None = None, + limit: int = 50, ) -> dict: """Analyze session efficiency: burn rate, compactions, read patterns. @@ -2257,6 +2338,7 @@ def get_session_efficiency( storage: Storage instance days: Number of days to analyze (default: 7) project: Optional project filter + limit: Maximum sessions to return (default: 50) Returns: Dict with efficiency metrics per session @@ -2265,6 +2347,12 @@ def get_session_efficiency( where_clause, params = build_where_clause(cutoff=cutoff, project=project) # Get session-level efficiency metrics + query_params = list(params) + limit_clause = "" + if limit > 0: + limit_clause = "LIMIT ?" + query_params.append(limit) + rows = storage.execute_query( f""" SELECT @@ -2286,9 +2374,9 @@ def get_session_efficiency( GROUP BY session_id HAVING COUNT(*) >= 10 ORDER BY compaction_count DESC, input_tokens DESC - LIMIT 50 + {limit_clause} """, - params, + tuple(query_params), ) # Get files read multiple times per session @@ -2350,6 +2438,7 @@ def get_session_efficiency( return { "days": days, "project": project, + "limit": limit, "session_count": len(sessions), "sessions": sessions, } diff --git a/src/session_analytics/server.py b/src/session_analytics/server.py index 6a4a742..ab6d314 100644 --- a/src/session_analytics/server.py +++ b/src/session_analytics/server.py @@ -210,7 +210,11 @@ def get_token_usage(days: int = 7, project: str | None = None, by: str = "day") @mcp.tool() def get_tool_sequences( - days: int = 7, min_count: int = 3, length: int = 2, expand: bool = False + days: int = 7, + min_count: int = 3, + length: int = 2, + expand: bool = False, + limit: int = 50, ) -> dict: """Get common tool patterns (sequences). @@ -219,6 +223,7 @@ def get_tool_sequences( min_count: Minimum occurrences to include (default: 3) length: Sequence length (default: 2) expand: Expand Bash→commands, Skill→skill names, Task→subagent types (default: False) + limit: Maximum patterns to return (default: 50) Returns: Common tool sequences @@ -227,13 +232,17 @@ def get_tool_sequences( sequence_patterns = patterns.compute_sequence_patterns( storage, days=days, sequence_length=length, min_count=min_count, expand=expand ) + # Apply limit to prevent large responses + limited_patterns = sequence_patterns[:limit] if limit > 0 else sequence_patterns return { "status": "ok", "days": days, "min_count": min_count, "sequence_length": length, "expanded": expand, - "sequences": [{"pattern": p.pattern_key, "count": p.count} for p in sequence_patterns], + "limit": limit, + "total_patterns": len(sequence_patterns), + "sequences": [{"pattern": p.pattern_key, "count": p.count} for p in limited_patterns], } @@ -833,6 +842,7 @@ def get_bus_events( def get_compaction_events( days: int = 7, session_id: str | None = None, + limit: int = 50, ) -> dict: """List compaction events (context resets) across sessions. @@ -842,12 +852,13 @@ def get_compaction_events( Args: days: Number of days to analyze (default: 7) session_id: Filter to specific session + limit: Maximum events to return (default: 50) Returns: List of compaction events with timestamps and session info """ queries.ensure_fresh_data(storage, days=days) - result = queries.get_compaction_events(storage, days=days, session_id=session_id) + result = queries.get_compaction_events(storage, days=days, session_id=session_id, limit=limit) return {"status": "ok", **result} @@ -910,6 +921,7 @@ def get_large_tool_results( def get_session_efficiency( days: int = 7, project: str | None = None, + limit: int = 50, ) -> dict: """Analyze context efficiency and burn rate across sessions. @@ -922,12 +934,13 @@ def get_session_efficiency( Args: days: Number of days to analyze (default: 7) project: Optional project path filter + limit: Maximum sessions to return (default: 50) Returns: Session efficiency metrics sorted by total bytes consumed """ queries.ensure_fresh_data(storage, days=days) - result = queries.get_session_efficiency(storage, days=days, project=project) + result = queries.get_session_efficiency(storage, days=days, project=project, limit=limit) return {"status": "ok", **result} diff --git a/tests/test_patterns.py b/tests/test_patterns.py index 7f707b2..3412d3e 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -231,6 +231,71 @@ def test_permission_gaps_uses_fnmatch(self, pattern_storage): # git has no matching pattern, should still be a gap assert "git" in pattern_keys + def test_permission_gaps_filters_non_actionable_commands(self, storage): + """Test that non-actionable commands are filtered from permission gaps. + + Commands like pwd, cd, echo, and shell builtins should not appear + in permission gap results because they are not actionable. + """ + from session_analytics.patterns import NON_ACTIONABLE_COMMANDS + + now = datetime.now() + + # Add events for various non-actionable commands + events = [ + Event( + id=None, + uuid=f"e-{i}", + timestamp=now - timedelta(hours=i), + session_id="s1", + project_path="-test", + entry_type="tool_use", + tool_name="Bash", + command=cmd, + ) + for i, cmd in enumerate( + # Repeat non-actionable commands to exceed threshold + ["pwd"] * 10 + + ["cd"] * 10 + + ["echo"] * 10 + + ["#"] * 5 + + ["for"] * 5 + + ["hostname"] * 5 + # And one actionable command + + ["cargo"] * 10 + ) + ] + storage.add_events_batch(events) + + with tempfile.TemporaryDirectory() as tmpdir: + settings_path = Path(tmpdir) / "settings.json" + settings_path.write_text('{"permissions": {"allow": []}}') + + patterns = compute_permission_gaps( + storage, days=7, threshold=3, settings_path=settings_path + ) + + pattern_keys = {p.pattern_key for p in patterns} + + # Non-actionable commands should be filtered out + assert "pwd" not in pattern_keys + assert "cd" not in pattern_keys + assert "echo" not in pattern_keys + assert "#" not in pattern_keys + assert "for" not in pattern_keys + assert "hostname" not in pattern_keys + + # Actionable command should still appear + assert "cargo" in pattern_keys + + # Verify the constant has the expected commands + assert "pwd" in NON_ACTIONABLE_COMMANDS + assert "cd" in NON_ACTIONABLE_COMMANDS + assert "echo" in NON_ACTIONABLE_COMMANDS + assert "#" in NON_ACTIONABLE_COMMANDS + assert "for" in NON_ACTIONABLE_COMMANDS + assert "hostname" in NON_ACTIONABLE_COMMANDS + class TestComputeAllPatterns: """Tests for computing all patterns.""" diff --git a/tests/test_server.py b/tests/test_server.py index cdf00b6..7db5e72 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -8,6 +8,7 @@ detect_parallel_sessions, find_related_sessions, get_command_frequency, + get_compaction_events, get_file_activity, get_handoff_context, get_insights, @@ -16,6 +17,7 @@ get_permission_gaps, get_projects, get_session_commits, + get_session_efficiency, get_session_events, get_session_messages, get_session_signals, @@ -289,3 +291,76 @@ def test_get_mcp_usage(): assert "total_mcp_calls" in result assert "servers" in result assert isinstance(result["servers"], list) + + +# Issue #77: Limit parameters for verbose endpoints + + +def test_get_tool_sequences_limit(): + """Test that get_tool_sequences respects limit parameter.""" + result = get_tool_sequences.fn(days=7, limit=5) + assert result["status"] == "ok" + assert result["limit"] == 5 + assert "total_patterns" in result + assert len(result["sequences"]) <= 5 + + +def test_get_compaction_events(): + """Test that get_compaction_events returns compaction data.""" + result = get_compaction_events.fn(days=7, limit=10) + assert result["status"] == "ok" + assert result["limit"] == 10 + assert "total_compaction_count" in result + assert "compaction_count" in result + assert "compactions" in result + assert isinstance(result["compactions"], list) + assert len(result["compactions"]) <= 10 + + +def test_get_session_efficiency(): + """Test that get_session_efficiency returns efficiency metrics.""" + result = get_session_efficiency.fn(days=7, limit=10) + assert result["status"] == "ok" + assert result["limit"] == 10 + assert "session_count" in result + assert "sessions" in result + assert isinstance(result["sessions"], list) + + +# Issue #78: Efficiency metrics in analyze_trends + + +def test_analyze_trends_efficiency(): + """Test that analyze_trends includes efficiency metrics.""" + result = analyze_trends.fn(days=7, compare_to="previous") + assert result["status"] == "ok" + assert "efficiency" in result + efficiency = result["efficiency"] + assert "compactions" in efficiency + assert "avg_compactions_per_session" in efficiency + assert "files_read_multiple_times" in efficiency + assert "avg_result_mb_per_session" in efficiency + # Each should have current/previous/change_pct structure + assert "current" in efficiency["compactions"] + assert "previous" in efficiency["compactions"] + assert "change_pct" in efficiency["compactions"] + + +# Issue #79: Efficiency metrics in classify_sessions + + +def test_classify_sessions_efficiency(): + """Test that classify_sessions includes efficiency metrics.""" + result = classify_sessions.fn(days=7) + assert result["status"] == "ok" + assert "sessions" in result + # Check that sessions have efficiency data (if any sessions exist) + if result["sessions"]: + session = result["sessions"][0] + assert "efficiency" in session + efficiency = session["efficiency"] + assert "compaction_count" in efficiency + assert "total_result_mb" in efficiency + assert "files_read_multiple_times" in efficiency + assert "burn_rate" in efficiency + assert efficiency["burn_rate"] in ["high", "medium", "low"] From 736934aa31e0ab34cbf8714396bfe8f2eb549e49 Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Sat, 10 Jan 2026 15:47:33 +0000 Subject: [PATCH 2/4] fix: Add --limit to CLI commands for MCP parity Address reviewer feedback from claude-review: - Add --limit parameter to sequences, compactions, efficiency commands - Show truncation info in formatters ("Showing X of Y total") - Maintain CLI/MCP parity as required by CLAUDE.md Co-Authored-By: Claude Opus 4.5 --- src/session_analytics/cli.py | 44 ++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/session_analytics/cli.py b/src/session_analytics/cli.py index c0ce7cb..05006b6 100644 --- a/src/session_analytics/cli.py +++ b/src/session_analytics/cli.py @@ -139,11 +139,18 @@ def _format_sequences(data: dict) -> list[str]: desc = "Detailed sequences (Bash→commands, Skill→skills, Task→agents)" else: desc = "Tool chains showing workflow patterns (Read → Edit, etc.)" - lines = [ - desc, - "", - "Sequences:", - ] + + total = data.get("total_patterns", len(data.get("sequences", []))) + shown = len(data.get("sequences", [])) + + lines = [desc, ""] + + # Show truncation info if results are limited + if total > shown: + lines.append(f"Showing {shown} of {total} total patterns") + lines.append("") + + lines.append("Sequences:") for seq in data.get("sequences", []): lines.append(f" {seq['pattern']}: {seq['count']}") return lines @@ -622,13 +629,22 @@ def format_metric(name: str, metric: dict) -> str: def _format_compactions(data: dict) -> list[str]: # Count unique sessions unique_sessions = len({c["session_id"] for c in data.get("compactions", [])}) + total_count = data.get("total_compaction_count", data["compaction_count"]) + shown_count = data["compaction_count"] + lines = [ f"Compaction events (context resets) - last {data.get('days', 7)} days", "", - f"Total compactions: {data['compaction_count']}", - f"Sessions affected: {unique_sessions}", - "", ] + + # Show truncation info if results are limited + if total_count > shown_count: + lines.append(f"Showing {shown_count} of {total_count} total compactions") + else: + lines.append(f"Total compactions: {shown_count}") + lines.append(f"Sessions affected: {unique_sessions}") + lines.append("") + if data.get("compactions"): lines.append("Recent compactions:") for c in data["compactions"][:10]: @@ -785,10 +801,15 @@ def cmd_sequences(args): min_count=args.min_count, expand=args.expand, ) + # Apply limit to match MCP behavior + limit = getattr(args, "limit", 50) + limited_patterns = sequence_patterns[:limit] if limit > 0 else sequence_patterns result = { "days": args.days, "expanded": args.expand, - "sequences": [{"pattern": p.pattern_key, "count": p.count} for p in sequence_patterns], + "limit": limit, + "total_patterns": len(sequence_patterns), + "sequences": [{"pattern": p.pattern_key, "count": p.count} for p in limited_patterns], } print(format_output(result, args.json)) @@ -1141,6 +1162,7 @@ def cmd_compactions(args): storage, days=args.days, session_id=getattr(args, "session_id", None), + limit=getattr(args, "limit", 50), ) print(format_output(result, args.json)) @@ -1176,6 +1198,7 @@ def cmd_efficiency(args): storage, days=args.days, project=getattr(args, "project", None), + limit=getattr(args, "limit", 50), ) print(format_output(result, args.json)) @@ -1459,6 +1482,7 @@ def main(): action="store_true", help="Expand Bash→commands, Skill→skills, Task→agents", ) + sub.add_argument("--limit", type=int, default=50, help="Max patterns to return (default: 50)") sub.set_defaults(func=cmd_sequences) # permissions @@ -1657,6 +1681,7 @@ def main(): sub = subparsers.add_parser("compactions", help="Show compaction events (context resets)") sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") sub.add_argument("--session-id", help="Filter to specific session ID") + sub.add_argument("--limit", type=int, default=50, help="Max events to return (default: 50)") sub.set_defaults(func=cmd_compactions) # pre-compaction @@ -1679,6 +1704,7 @@ def main(): sub = subparsers.add_parser("efficiency", help="Show session context efficiency metrics") sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") sub.add_argument("--project", help="Project path filter") + sub.add_argument("--limit", type=int, default=50, help="Max sessions to return (default: 50)") sub.set_defaults(func=cmd_efficiency) # benchmark (Issue #63) From 6cefc6fc53cbea3b3a3a635290910382976586d4 Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Sat, 10 Jan 2026 16:21:12 +0000 Subject: [PATCH 3/4] feat: Add compaction aggregation and pre-compaction pattern analysis (closes #81) RFC #81 implementation: - Add `aggregate` parameter to `get_compaction_events()` for session-level grouping - Add new `analyze_pre_compaction_patterns()` endpoint to identify antipatterns (consecutive reads, file rework, large results) before compactions - Update CLI with --aggregate flag and new pre-compaction-patterns command - Add benchmark entries for new functionality - Update guide.md documentation Co-Authored-By: Claude Opus 4.5 --- src/session_analytics/cli.py | 115 ++++++++++++++ src/session_analytics/guide.md | 13 +- src/session_analytics/queries.py | 248 ++++++++++++++++++++++++++++++- src/session_analytics/server.py | 36 ++++- tests/test_queries.py | 184 +++++++++++++++++++++++ tests/test_server.py | 42 ++++++ 6 files changed, 630 insertions(+), 8 deletions(-) diff --git a/src/session_analytics/cli.py b/src/session_analytics/cli.py index 05006b6..e3b7e79 100644 --- a/src/session_analytics/cli.py +++ b/src/session_analytics/cli.py @@ -22,6 +22,7 @@ sample_sequences, ) from session_analytics.queries import ( + analyze_pre_compaction_patterns, classify_sessions, detect_parallel_sessions, find_related_sessions, @@ -652,6 +653,78 @@ def _format_compactions(data: dict) -> list[str]: return lines +# Issue #81: Aggregate compactions formatter +@_register_formatter( + lambda d: d.get("aggregate") is True and "sessions" in d and "total_compaction_count" in d +) +def _format_compactions_aggregate(data: dict) -> list[str]: + total_compactions = data.get("total_compaction_count", 0) + total_sessions = data.get("total_sessions_with_compactions", 0) + shown_sessions = data.get("session_count", 0) + + lines = [ + f"Compaction summary by session - last {data.get('days', 7)} days", + "", + f"Total compactions: {total_compactions}", + f"Sessions with compactions: {total_sessions}", + ] + + if total_sessions > shown_sessions: + lines.append(f"Showing {shown_sessions} of {total_sessions} sessions") + lines.append("") + + if data.get("sessions"): + lines.append("Sessions ranked by compaction count:") + for s in data["sessions"][:15]: + lines.append( + f" {s['session_id'][:8]}... - {s['compaction_count']} compactions " + f"({s['total_summary_kb']:.0f}KB summaries)" + ) + return lines + + +# Issue #81: Pre-compaction patterns formatter +@_register_formatter(lambda d: "compactions_analyzed" in d and "patterns" in d) +def _format_pre_compaction_patterns(data: dict) -> list[str]: + lines = [ + f"Pre-compaction pattern analysis - last {data.get('days', 7)} days", + "", + f"Compactions analyzed: {data.get('compactions_analyzed', 0)}", + f"Events analyzed before each: {data.get('events_before', 50)}", + "", + ] + + patterns = data.get("patterns", {}) + if patterns: + lines.append("Detected patterns:") + lines.append(f" Avg consecutive reads: {patterns.get('avg_consecutive_reads', 0):.1f}") + lines.append(f" Avg files re-read: {patterns.get('avg_files_read_multiple_times', 0):.1f}") + lines.append(f" Avg large results (>10KB): {patterns.get('avg_large_results', 0):.1f}") + lines.append("") + + if patterns.get("tool_distribution"): + lines.append("Tool distribution before compactions:") + for t in patterns["tool_distribution"][:5]: + lines.append(f" {t['tool']}: {t['count']}") + lines.append("") + + if patterns.get("top_reread_files"): + lines.append("Most frequently re-read files:") + for f in patterns["top_reread_files"][:5]: + lines.append(f" {f['file']}: {f['read_count']}x") + lines.append("") + + recommendations = data.get("recommendations", []) + if recommendations: + lines.append("Recommendations:") + for r in recommendations: + lines.append(f" - {r}") + elif data.get("compactions_analyzed", 0) > 0: + lines.append("No antipatterns detected - context efficiency looks healthy.") + + return lines + + @_register_formatter(lambda d: "compaction_timestamp" in d and "events" in d and "event_count" in d) def _format_pre_compaction(data: dict) -> list[str]: lines = [ @@ -1163,6 +1236,7 @@ def cmd_compactions(args): days=args.days, session_id=getattr(args, "session_id", None), limit=getattr(args, "limit", 50), + aggregate=getattr(args, "aggregate", False), ) print(format_output(result, args.json)) @@ -1179,6 +1253,18 @@ def cmd_pre_compaction(args): print(format_output(result, args.json)) +def cmd_pre_compaction_patterns(args): + """Analyze patterns in events leading up to compactions.""" + storage = SQLiteStorage() + result = analyze_pre_compaction_patterns( + storage, + days=args.days, + events_before=args.events_before, + limit=args.limit, + ) + print(format_output(result, args.json)) + + def cmd_large_results(args): """Show large tool results that consume context space.""" storage = SQLiteStorage() @@ -1270,6 +1356,9 @@ def cmd_benchmark(args): from session_analytics.patterns import ( sample_sequences as patterns_sample_sequences, ) + from session_analytics.queries import ( + analyze_pre_compaction_patterns as queries_analyze_pre_compaction_patterns, + ) from session_analytics.queries import ( classify_sessions as queries_classify_sessions, ) @@ -1373,10 +1462,17 @@ def cmd_benchmark(args): "get_bus_events": lambda: queries_query_bus_events(storage, days=7, limit=10), # Issue #69: Compaction and efficiency tools "get_compaction_events": lambda: queries_get_compaction_events(storage, days=7), + "get_compaction_events_agg": lambda: queries_get_compaction_events( + storage, days=7, aggregate=True + ), "get_large_tool_results": lambda: queries_get_large_tool_results( storage, days=7, min_size_kb=10, limit=10 ), "get_session_efficiency": lambda: queries_get_session_efficiency(storage, days=7), + # Issue #81: Pre-compaction pattern analysis + "analyze_pre_compaction_patterns": lambda: queries_analyze_pre_compaction_patterns( + storage, days=7 + ), } # Skipped tools (require specific data or modify DB): @@ -1682,6 +1778,9 @@ def main(): sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") sub.add_argument("--session-id", help="Filter to specific session ID") sub.add_argument("--limit", type=int, default=50, help="Max events to return (default: 50)") + sub.add_argument( + "--aggregate", action="store_true", help="Group by session instead of individual events" + ) sub.set_defaults(func=cmd_compactions) # pre-compaction @@ -1691,6 +1790,22 @@ def main(): sub.add_argument("--limit", type=int, default=50, help="Max events to return (default: 50)") sub.set_defaults(func=cmd_pre_compaction) + # pre-compaction-patterns (Issue #81) + sub = subparsers.add_parser( + "pre-compaction-patterns", help="Analyze patterns in events before compactions" + ) + sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") + sub.add_argument( + "--events-before", + type=int, + default=50, + help="Events to analyze before each compaction (default: 50)", + ) + sub.add_argument( + "--limit", type=int, default=20, help="Max compactions to analyze (default: 20)" + ) + sub.set_defaults(func=cmd_pre_compaction_patterns) + # large-results sub = subparsers.add_parser( "large-results", help="Show large tool results consuming context space" diff --git a/src/session_analytics/guide.md b/src/session_analytics/guide.md index d8c988d..8a8adc5 100644 --- a/src/session_analytics/guide.md +++ b/src/session_analytics/guide.md @@ -131,8 +131,9 @@ Returns both core metrics (`events`, `sessions`, `errors`, `tokens`) and `effici | Tool | Purpose | |------|---------| -| `get_compaction_events(days?, session_id?, limit?)` | List compaction events (context resets) | +| `get_compaction_events(days?, session_id?, limit?, aggregate?)` | List compaction events (context resets) | | `get_pre_compaction_events(session_id, compaction_timestamp, limit?)` | Events before a compaction for analysis | +| `analyze_pre_compaction_patterns(days?, events_before?, limit?)` | Aggregated patterns before compactions (RFC #81) | | `get_large_tool_results(days?, min_size_kb?, limit?)` | Find tool results consuming context space | | `get_session_efficiency(days?, project?, limit?)` | Session efficiency metrics and burn rate | @@ -231,10 +232,12 @@ analyze_trends() → "Usage is increasing/decreasing" ### Workflow: Context Efficiency ``` -get_compaction_events() → "When did context resets happen?" -get_session_efficiency() → "Which sessions burn context fastest?" -get_large_tool_results() → "What operations consume the most space?" -get_pre_compaction_events() → "What led up to a specific reset?" +get_compaction_events() → "When did context resets happen?" +get_compaction_events(aggregate=True) → "Which sessions had most compactions?" +analyze_pre_compaction_patterns() → "What patterns precede compactions?" (RFC #81) +get_session_efficiency() → "Which sessions burn context fastest?" +get_large_tool_results() → "What operations consume the most space?" +get_pre_compaction_events() → "What led up to a specific reset?" ``` ## Reference diff --git a/src/session_analytics/queries.py b/src/session_analytics/queries.py index ae5a974..039e630 100644 --- a/src/session_analytics/queries.py +++ b/src/session_analytics/queries.py @@ -2099,6 +2099,7 @@ def get_compaction_events( days: int = 7, session_id: str | None = None, limit: int = 50, + aggregate: bool = False, ) -> dict: """List compaction events where conversation history was truncated. @@ -2110,9 +2111,10 @@ def get_compaction_events( days: Number of days to analyze (default: 7) session_id: Optional filter for specific session limit: Maximum events to return (default: 50) + aggregate: If True, group by session with counts instead of individual events Returns: - Dict with compaction events and their timestamps + Dict with compaction events and their timestamps (or session aggregates if aggregate=True) """ cutoff = get_cutoff(days=days) @@ -2132,7 +2134,63 @@ def get_compaction_events( ) total_count = count_row[0]["total"] if count_row else 0 - # Then get limited results + # Issue #81: Aggregate mode groups by session + if aggregate: + query_params = list(params) + limit_clause = "" + if limit > 0: + limit_clause = "LIMIT ?" + query_params.append(limit) + + rows = storage.execute_query( + f""" + SELECT + session_id, + project_path, + COUNT(*) as compaction_count, + MIN(timestamp) as first_compaction, + MAX(timestamp) as last_compaction, + SUM(result_size_bytes) as total_summary_bytes + FROM events + WHERE {where_clause} + GROUP BY session_id + ORDER BY compaction_count DESC + {limit_clause} + """, + tuple(query_params), + ) + + sessions = [ + { + "session_id": row["session_id"], + "project": row["project_path"], + "compaction_count": row["compaction_count"], + "first_compaction": _format_timestamp(row["first_compaction"]), + "last_compaction": _format_timestamp(row["last_compaction"]), + "total_summary_kb": round((row["total_summary_bytes"] or 0) / 1024, 1), + } + for row in rows + ] + + # Count unique sessions + session_count_row = storage.execute_query( + f"SELECT COUNT(DISTINCT session_id) as count FROM events WHERE {where_clause}", + tuple(params), + ) + total_sessions = session_count_row[0]["count"] if session_count_row else 0 + + return { + "days": days, + "session_id": session_id, + "limit": limit, + "aggregate": True, + "total_compaction_count": total_count, + "total_sessions_with_compactions": total_sessions, + "session_count": len(sessions), + "sessions": sessions, + } + + # Non-aggregate mode: return individual compaction events query_params = list(params) limit_clause = "" if limit > 0: @@ -2170,6 +2228,7 @@ def get_compaction_events( "days": days, "session_id": session_id, "limit": limit, + "aggregate": False, "total_compaction_count": total_count, "compaction_count": len(compactions), "compactions": compactions, @@ -2240,6 +2299,191 @@ def get_pre_compaction_events( } +def analyze_pre_compaction_patterns( + storage: SQLiteStorage, + days: int = 7, + events_before: int = 50, + limit: int = 20, +) -> dict: + """Analyze patterns in events leading up to compactions. + + RFC #81: Identifies antipatterns that accelerate context exhaustion: + - Consecutive reads without edits (exploration without action) + - Files read multiple times before compaction + - Large tool results that bloated context + - Tool distribution before compaction + + Args: + storage: Storage instance + days: Number of days to analyze (default: 7) + events_before: Events to analyze before each compaction (default: 50) + limit: Max compactions to analyze (default: 20) + + Returns: + Dict with aggregated patterns across analyzed compactions + """ + cutoff = get_cutoff(days=days) + + # Get recent compactions + compactions = storage.execute_query( + """ + SELECT session_id, timestamp + FROM events + WHERE timestamp >= ? + AND entry_type = 'compaction' + ORDER BY timestamp DESC + LIMIT ? + """, + (cutoff, limit), + ) + + if not compactions: + return { + "days": days, + "compactions_analyzed": 0, + "patterns": {}, + "recommendations": [], + } + + # Analyze patterns across all compactions + total_consecutive_reads = 0 + total_files_read_multiple = 0 + total_large_results = 0 + tool_counts: dict[str, int] = {} + file_read_counts: dict[str, int] = {} + large_results_by_tool: dict[str, int] = {} + + for compaction in compactions: + session_id = compaction["session_id"] + compact_time = compaction["timestamp"] + + # Get events before this compaction + events = storage.execute_query( + """ + SELECT + entry_type, + tool_name, + file_path, + result_size_bytes + FROM events + WHERE session_id = ? + AND timestamp < ? + ORDER BY timestamp DESC + LIMIT ? + """, + (session_id, compact_time, events_before), + ) + + # Count consecutive reads (no edit between reads) + consecutive_reads = 0 + max_consecutive_reads = 0 + for event in events: + if event["tool_name"] == "Read": + consecutive_reads += 1 + max_consecutive_reads = max(max_consecutive_reads, consecutive_reads) + elif event["tool_name"] == "Edit": + consecutive_reads = 0 + total_consecutive_reads += max_consecutive_reads + + # Count files read multiple times + session_file_counts: dict[str, int] = {} + for event in events: + if event["tool_name"] == "Read" and event["file_path"]: + session_file_counts[event["file_path"]] = ( + session_file_counts.get(event["file_path"], 0) + 1 + ) + multi_read_files = sum(1 for c in session_file_counts.values() if c > 1) + total_files_read_multiple += multi_read_files + + # Aggregate file reads across all compactions + for f, c in session_file_counts.items(): + file_read_counts[f] = file_read_counts.get(f, 0) + c + + # Count large results (>10KB) + for event in events: + size = event["result_size_bytes"] or 0 + if size > 10240: + total_large_results += 1 + tool = event["tool_name"] or "unknown" + large_results_by_tool[tool] = large_results_by_tool.get(tool, 0) + 1 + + # Tool distribution + for event in events: + if event["tool_name"]: + tool_counts[event["tool_name"]] = tool_counts.get(event["tool_name"], 0) + 1 + + # Calculate averages + compactions_analyzed = len(compactions) + avg_consecutive_reads = total_consecutive_reads / compactions_analyzed + avg_files_read_multiple = total_files_read_multiple / compactions_analyzed + avg_large_results = total_large_results / compactions_analyzed + + # Top files read multiple times + top_reread_files = sorted( + [(f, c) for f, c in file_read_counts.items() if c > 1], + key=lambda x: x[1], + reverse=True, + )[:10] + + # Tool distribution sorted by count + tool_distribution = sorted( + tool_counts.items(), + key=lambda x: x[1], + reverse=True, + ) + + # Generate recommendations based on findings + recommendations = [] + if avg_consecutive_reads > 5: + recommendations.append( + f"High consecutive reads ({avg_consecutive_reads:.1f} avg) - " + "consider reading fewer files or using grep to target specific content" + ) + if avg_files_read_multiple > 2: + recommendations.append( + f"Files re-read frequently ({avg_files_read_multiple:.1f} avg) - " + "consider keeping file content in context or summarizing key sections" + ) + if avg_large_results > 3: + recommendations.append( + f"Many large tool results ({avg_large_results:.1f} avg) - " + "use offset/limit parameters or grep to reduce result size" + ) + + # Check for Read-heavy tool distribution + read_count = tool_counts.get("Read", 0) + edit_count = tool_counts.get("Edit", 0) + if read_count > 0 and edit_count > 0: + read_edit_ratio = read_count / edit_count + if read_edit_ratio > 5: + recommendations.append( + f"High read:edit ratio ({read_edit_ratio:.1f}:1) - " + "may indicate over-exploration before action" + ) + + return { + "days": days, + "events_before": events_before, + "compactions_analyzed": compactions_analyzed, + "patterns": { + "avg_consecutive_reads": round(avg_consecutive_reads, 1), + "avg_files_read_multiple_times": round(avg_files_read_multiple, 1), + "avg_large_results": round(avg_large_results, 1), + "tool_distribution": [ + {"tool": tool, "count": count} for tool, count in tool_distribution + ], + "top_reread_files": [{"file": f, "read_count": c} for f, c in top_reread_files], + "large_results_by_tool": [ + {"tool": tool, "count": count} + for tool, count in sorted( + large_results_by_tool.items(), key=lambda x: x[1], reverse=True + ) + ], + }, + "recommendations": recommendations, + } + + def get_large_tool_results( storage: SQLiteStorage, days: int = 7, diff --git a/src/session_analytics/server.py b/src/session_analytics/server.py index ab6d314..b4ee586 100644 --- a/src/session_analytics/server.py +++ b/src/session_analytics/server.py @@ -843,6 +843,7 @@ def get_compaction_events( days: int = 7, session_id: str | None = None, limit: int = 50, + aggregate: bool = False, ) -> dict: """List compaction events (context resets) across sessions. @@ -853,12 +854,16 @@ def get_compaction_events( days: Number of days to analyze (default: 7) session_id: Filter to specific session limit: Maximum events to return (default: 50) + aggregate: If True, group by session with counts instead of individual events Returns: List of compaction events with timestamps and session info + (or session aggregates if aggregate=True) """ queries.ensure_fresh_data(storage, days=days) - result = queries.get_compaction_events(storage, days=days, session_id=session_id, limit=limit) + result = queries.get_compaction_events( + storage, days=days, session_id=session_id, limit=limit, aggregate=aggregate + ) return {"status": "ok", **result} @@ -891,6 +896,35 @@ def get_pre_compaction_events( return {"status": "ok", **result} +@mcp.tool() +def analyze_pre_compaction_patterns( + days: int = 7, + events_before: int = 50, + limit: int = 20, +) -> dict: + """Analyze patterns in events leading up to compactions. + + RFC #81: Identifies antipatterns that accelerate context exhaustion: + - Consecutive reads without edits (exploration without action) + - Files read multiple times before compaction + - Large tool results that bloated context + - Tool distribution before compaction + + Args: + days: Number of days to analyze (default: 7) + events_before: Events to analyze before each compaction (default: 50) + limit: Max compactions to analyze (default: 20) + + Returns: + Dict with aggregated patterns and recommendations + """ + queries.ensure_fresh_data(storage, days=days) + result = queries.analyze_pre_compaction_patterns( + storage, days=days, events_before=events_before, limit=limit + ) + return {"status": "ok", **result} + + @mcp.tool() def get_large_tool_results( days: int = 7, diff --git a/tests/test_queries.py b/tests/test_queries.py index 8f7cd1d..30e5407 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -2402,6 +2402,62 @@ def test_filters_by_session_id(self, storage): assert result["compaction_count"] == 1 assert result["compactions"][0]["session_id"] == "s1" + def test_aggregate_mode_groups_by_session(self, storage): + """Test that aggregate=True groups compactions by session.""" + from session_analytics.queries import get_compaction_events + + now = datetime.now() + events = [ + # Session 1: 3 compactions + Event( + id=None, + uuid="c1", + timestamp=now - timedelta(hours=1), + session_id="s1", + entry_type="compaction", + result_size_bytes=1024, + ), + Event( + id=None, + uuid="c2", + timestamp=now - timedelta(hours=2), + session_id="s1", + entry_type="compaction", + result_size_bytes=2048, + ), + Event( + id=None, + uuid="c3", + timestamp=now - timedelta(hours=3), + session_id="s1", + entry_type="compaction", + result_size_bytes=1024, + ), + # Session 2: 1 compaction + Event( + id=None, + uuid="c4", + timestamp=now - timedelta(hours=1), + session_id="s2", + entry_type="compaction", + result_size_bytes=512, + ), + ] + storage.add_events_batch(events) + + result = get_compaction_events(storage, days=7, aggregate=True) + + assert result["aggregate"] is True + assert result["total_compaction_count"] == 4 + assert result["total_sessions_with_compactions"] == 2 + assert result["session_count"] == 2 + assert len(result["sessions"]) == 2 + # Sessions sorted by compaction count descending + assert result["sessions"][0]["session_id"] == "s1" + assert result["sessions"][0]["compaction_count"] == 3 + assert result["sessions"][1]["session_id"] == "s2" + assert result["sessions"][1]["compaction_count"] == 1 + class TestGetPreCompactionEvents: """Tests for get_pre_compaction_events().""" @@ -2530,6 +2586,134 @@ def test_respects_limit_parameter(self, storage): assert len(result["events"]) == 3 +class TestAnalyzePreCompactionPatterns: + """Tests for analyze_pre_compaction_patterns().""" + + def test_returns_empty_when_no_compactions(self, storage): + """Test that empty result is returned when no compactions exist.""" + from session_analytics.queries import analyze_pre_compaction_patterns + + result = analyze_pre_compaction_patterns(storage, days=7) + + assert result["compactions_analyzed"] == 0 + assert result["patterns"] == {} + assert result["recommendations"] == [] + + def test_detects_consecutive_reads(self, storage): + """Test that consecutive reads are detected as a pattern.""" + from session_analytics.queries import analyze_pre_compaction_patterns + + now = datetime.now() + compaction_time = now - timedelta(hours=1) + events = [ + # Compaction event + Event( + id=None, + uuid="c1", + timestamp=compaction_time, + session_id="s1", + entry_type="compaction", + ), + # 10 consecutive reads before compaction (no edits) + *[ + Event( + id=None, + uuid=f"r{i}", + timestamp=compaction_time - timedelta(minutes=i + 1), + session_id="s1", + entry_type="tool_use", + tool_name="Read", + file_path=f"/test/file{i}.py", + ) + for i in range(10) + ], + ] + storage.add_events_batch(events) + + result = analyze_pre_compaction_patterns(storage, days=7) + + assert result["compactions_analyzed"] == 1 + assert result["patterns"]["avg_consecutive_reads"] >= 10 + assert "tool_distribution" in result["patterns"] + + def test_detects_files_read_multiple_times(self, storage): + """Test that files read multiple times are detected.""" + from session_analytics.queries import analyze_pre_compaction_patterns + + now = datetime.now() + compaction_time = now - timedelta(hours=1) + events = [ + # Compaction event + Event( + id=None, + uuid="c1", + timestamp=compaction_time, + session_id="s1", + entry_type="compaction", + ), + # Same file read 5 times + *[ + Event( + id=None, + uuid=f"r{i}", + timestamp=compaction_time - timedelta(minutes=i + 1), + session_id="s1", + entry_type="tool_use", + tool_name="Read", + file_path="/test/same_file.py", + ) + for i in range(5) + ], + ] + storage.add_events_batch(events) + + result = analyze_pre_compaction_patterns(storage, days=7) + + assert result["compactions_analyzed"] == 1 + assert result["patterns"]["avg_files_read_multiple_times"] >= 1 + # Check that the re-read file appears in top_reread_files + reread_files = [f["file"] for f in result["patterns"]["top_reread_files"]] + assert "/test/same_file.py" in reread_files + + def test_generates_recommendations_for_antipatterns(self, storage): + """Test that recommendations are generated when antipatterns detected.""" + from session_analytics.queries import analyze_pre_compaction_patterns + + now = datetime.now() + compaction_time = now - timedelta(hours=1) + events = [ + # Compaction event + Event( + id=None, + uuid="c1", + timestamp=compaction_time, + session_id="s1", + entry_type="compaction", + ), + # 15 consecutive reads (threshold is 5) + *[ + Event( + id=None, + uuid=f"r{i}", + timestamp=compaction_time - timedelta(minutes=i + 1), + session_id="s1", + entry_type="tool_use", + tool_name="Read", + file_path=f"/test/file{i}.py", + ) + for i in range(15) + ], + ] + storage.add_events_batch(events) + + result = analyze_pre_compaction_patterns(storage, days=7) + + # Should have recommendations about consecutive reads + assert len(result["recommendations"]) >= 1 + # At least one recommendation should mention reads + assert any("read" in r.lower() for r in result["recommendations"]) + + class TestGetLargeToolResults: """Tests for get_large_tool_results().""" diff --git a/tests/test_server.py b/tests/test_server.py index 7db5e72..286acd2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,6 +2,7 @@ from session_analytics.server import ( analyze_failures, + analyze_pre_compaction_patterns, analyze_trends, classify_sessions, correlate_git_with_sessions, @@ -364,3 +365,44 @@ def test_classify_sessions_efficiency(): assert "files_read_multiple_times" in efficiency assert "burn_rate" in efficiency assert efficiency["burn_rate"] in ["high", "medium", "low"] + + +# Issue #81: Compaction aggregation and pre-compaction patterns + + +def test_get_compaction_events_aggregate(): + """Test that get_compaction_events aggregate mode returns session-level data.""" + result = get_compaction_events.fn(days=7, limit=10, aggregate=True) + assert result["status"] == "ok" + assert result["aggregate"] is True + assert "total_compaction_count" in result + assert "total_sessions_with_compactions" in result + assert "session_count" in result + assert "sessions" in result + assert isinstance(result["sessions"], list) + # If sessions exist, verify structure + if result["sessions"]: + session = result["sessions"][0] + assert "session_id" in session + assert "compaction_count" in session + assert "first_compaction" in session + assert "last_compaction" in session + assert "total_summary_kb" in session + + +def test_analyze_pre_compaction_patterns(): + """Test that analyze_pre_compaction_patterns returns pattern data.""" + result = analyze_pre_compaction_patterns.fn(days=7, events_before=50, limit=20) + assert result["status"] == "ok" + assert "compactions_analyzed" in result + assert "patterns" in result + assert "recommendations" in result + assert isinstance(result["recommendations"], list) + # If patterns exist, verify structure + if result.get("compactions_analyzed", 0) > 0: + patterns = result["patterns"] + assert "avg_consecutive_reads" in patterns + assert "avg_files_read_multiple_times" in patterns + assert "avg_large_results" in patterns + assert "tool_distribution" in patterns + assert isinstance(patterns["tool_distribution"], list) From 47b30dde601bac29375f9420b1afed9003bf9187 Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Sat, 10 Jan 2026 16:27:37 +0000 Subject: [PATCH 4/4] fix: Handle zero-edit case in read:edit ratio check Addresses claude-review feedback: when edit_count is 0 but reads > 10, this now generates a "pure exploration pattern" recommendation instead of silently skipping the check. Co-Authored-By: Claude Opus 4.5 --- src/session_analytics/queries.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/session_analytics/queries.py b/src/session_analytics/queries.py index 039e630..2d1ed2b 100644 --- a/src/session_analytics/queries.py +++ b/src/session_analytics/queries.py @@ -2453,7 +2453,13 @@ def analyze_pre_compaction_patterns( # Check for Read-heavy tool distribution read_count = tool_counts.get("Read", 0) edit_count = tool_counts.get("Edit", 0) - if read_count > 0 and edit_count > 0: + if edit_count == 0 and read_count > 10: + # All reads, no edits - pure exploration that consumed context + recommendations.append( + f"Pure exploration pattern ({read_count} reads, 0 edits) - " + "context consumed without productive editing" + ) + elif read_count > 0 and edit_count > 0: read_edit_ratio = read_count / edit_count if read_edit_ratio > 5: recommendations.append(