From a4961fae5d8b4ebb796e4ed895e7e554f61f0817 Mon Sep 17 00:00:00 2001 From: andev0x Date: Thu, 15 Jan 2026 12:06:43 +0700 Subject: [PATCH] feat(aggregation): add smart session aggregation and analytics Implement a smart aggregation system that converts raw event streams into a human-readable SESSION_SUMMARY.md with analytical insights. Uses a dual-file architecture: - {session_id}_raw.json for immutable, full event history - SESSION_SUMMARY.md for periodically updated summaries and insights --- docs/QUICK_START_AGGREGATION.md | 284 ++++++++++++++++ docs/SMART_AGGREGATION.md | 505 ++++++++++++++++++++++++++++ internal/aggregator/aggregator.go | 357 ++++++++++++++++++++ internal/exporter/smart_markdown.go | 264 +++++++++++++++ internal/models/event.go | 54 +++ internal/recorder/session.go | 140 ++++++-- lua/capytrace/config.lua | 18 + 7 files changed, 1603 insertions(+), 19 deletions(-) create mode 100644 docs/QUICK_START_AGGREGATION.md create mode 100644 docs/SMART_AGGREGATION.md create mode 100644 internal/aggregator/aggregator.go create mode 100644 internal/exporter/smart_markdown.go diff --git a/docs/QUICK_START_AGGREGATION.md b/docs/QUICK_START_AGGREGATION.md new file mode 100644 index 0000000..0ac3100 --- /dev/null +++ b/docs/QUICK_START_AGGREGATION.md @@ -0,0 +1,284 @@ +# Quick Start: Smart Aggregation + +## Installation + +Follow the standard installation instructions in the main README.md. + +## Basic Usage + +### 1. Start a Session + +In Neovim: +```vim +:CapytraceStart +``` + +This will: +- Create a new session with ID `{timestamp}_{projectname}` +- Start recording all events +- Save raw events to `{session_id}_raw.json` immediately +- Begin periodic SESSION_SUMMARY.md updates every 5 minutes + +--- + +### 2. Work Normally + +The plugin automatically tracks: +- ✅ File edits (every keystroke) +- ✅ Cursor movements (smart-filtered) +- ✅ Terminal commands +- ✅ File opens/switches +- ✅ LSP diagnostics + +No action required - just code! + +--- + +### 3. Add Annotations (Optional) + +When you fix a bug or reach a milestone: +```vim +:CapytraceAnnotate Fixed authentication bug - wrong token validation +``` + +This helps the error correction pattern detector correlate fixes with preceding changes. + +--- + +### 4. Check Your Progress + +While the session is active, you can view: +- **Real-time summary**: `~/capytrace_logs/SESSION_SUMMARY.md` +- **Raw events**: `~/capytrace_logs/{session_id}_raw.json` + +SESSION_SUMMARY.md updates automatically every 5 minutes. + +--- + +### 5. End the Session + +```vim +:CapytraceEnd +``` + +This will: +- Stop recording events +- Generate final SESSION_SUMMARY.md with complete analytics +- Save all data to disk + +--- + +## What You'll See + +### SESSION_SUMMARY.md Structure + +```markdown +# Session Summary Report + +## Executive Summary +- Total events, activity blocks, velocity stats +- Focus ratio, flow blocks, idle gaps +- Error corrections + +## Velocity Analysis +- Flow State Blocks (high productivity moments) +- Average and peak velocity + +## Focus Ratio +- Time spent per file +- Distraction time breakdown + +## Error Correction Patterns +- Detected fixes with context + +## Idle Periods +- Gaps where you might have been stuck + +## Activity Timeline +- Aggregated blocks of continuous work +``` + +--- + +## Example Workflow + +### Morning Coding Session + +```vim +" Start session +:CapytraceStart + +" Work on authentication module +" ... make edits to auth.lua ... + +" Add annotation when you hit a problem +:CapytraceAnnotate Stuck on OAuth flow - need to read docs + +" ... continue coding ... + +" Fixed the issue +:CapytraceAnnotate Fixed OAuth - needed to pass state parameter + +" Run tests +:terminal go test ./... + +" Switch to handler +" ... work on handler.go ... + +" End session +:CapytraceEnd +``` + +### Review Your SESSION_SUMMARY.md + +```markdown +## Executive Summary +- Focus Ratio: 88.5% +- Flow State Blocks: 3 +- Idle Gaps: 1 (15m 30s) ← You were reading docs! + +## Error Correction Patterns + +### 1. 10:45:23 - `auth.lua` +**Annotation:** "Fixed OAuth - needed to pass state parameter" +- Blocks affected: 5 +- Changes reversed: 203 ticks +``` + +**Insight:** The 15-minute idle gap correlates with your OAuth research. The summary shows exactly which blocks you had to rewrite afterward. + +--- + +## Tips for Better Analytics + +### 1. Use Meaningful Annotations + +**Good:** +```vim +:CapytraceAnnotate Fixed race condition in request handler +:CapytraceAnnotate Refactored auth to use middleware pattern +``` + +**Less useful:** +```vim +:CapytraceAnnotate Fixed bug +:CapytraceAnnotate Update +``` + +--- + +### 2. Break Sessions into Logical Units + +Instead of one 8-hour session: +```vim +" Morning: Feature work +:CapytraceStart +... work ... +:CapytraceEnd + +" Afternoon: Bug fixes +:CapytraceStart +... work ... +:CapytraceEnd +``` + +This makes summaries more focused and actionable. + +--- + +### 3. Review Summaries Weekly + +Look for patterns: +- Are you more productive in the morning or afternoon? +- Which files/modules cause the most errors? +- Is your focus ratio dropping over time? +- Are idle gaps increasing (sign of complexity/blockers)? + +--- + +## Configuration + +### Basic Setup (Defaults) + +```lua +require("capytrace").setup({ + output_format = "markdown", + save_path = "~/capytrace_logs/", +}) +``` + +### Advanced Setup + +```lua +require("capytrace").setup({ + save_path = "~/coding_sessions/", + + aggregation = { + -- Merge edits within 3 seconds (default: 2s) + merge_window = 3000, + + -- Detect idle after 10 minutes (default: 5min) + idle_threshold = 600000, + + -- Flow state threshold (default: 10 ticks/sec) + flow_velocity_threshold = 15.0, + + -- Add custom distraction patterns + distraction_files = { + "NvimTree", + "Telescope", + "lazy.nvim", + }, + + -- Update summary every 10 minutes (default: 5min) + periodic_update_interval = 600000, + }, +}) +``` + +--- + +## Troubleshooting + +### SESSION_SUMMARY.md is empty + +**Check:** +1. Is the session active? (`:CapytraceStats`) +2. Have you made any edits yet? +3. Wait for the first 5-minute update + +**Solution:** End and restart the session to force generation. + +--- + +### Raw JSON is huge + +**Normal!** Long sessions generate lots of events. + +**Solutions:** +- Use shorter sessions +- The summary is always small and readable +- Raw JSON is only for machine analysis/replay + +--- + +### Velocity seems inconsistent + +**Explanation:** Velocity depends on: +- LSP auto-formatting (inflates ticks) +- Copy-pasting (sudden spikes) +- Your editing style + +**Focus on:** Relative velocity (your fast vs. slow moments), not absolute numbers. + +--- + +## Next Steps + +- Read `docs/SMART_AGGREGATION.md` for deep dive into algorithms +- Check `docs/REFACTORING_SUMMARY.md` for technical details +- Explore the raw JSON for custom analysis + +--- + +*Happy coding! 🚀* diff --git a/docs/SMART_AGGREGATION.md b/docs/SMART_AGGREGATION.md new file mode 100644 index 0000000..29bdba8 --- /dev/null +++ b/docs/SMART_AGGREGATION.md @@ -0,0 +1,505 @@ +# Smart Aggregation System + +## Overview + +The capytrace.nvim smart aggregation system transforms raw event streams into meaningful insights through a dual-file architecture and intelligent analytics. + +--- + +## Dual-File Architecture + +### 1. The Truth: `{session_id}_raw.json` + +**Purpose:** Complete, unmodified event history for machine computation and potential replay features. + +**Contains:** +- Every single event with millisecond precision +- Full context: column, line, changed_tick values +- All cursor movements (after smart filtering) +- Complete terminal commands and annotations +- Unprocessed, immutable data + +**Use cases:** +- Programmatic analysis and data mining +- Building "Replay" features (reviewing coding sessions like videos) +- Debug troubleshooting of the plugin itself +- Long-term archival of coding sessions + +**Example structure:** +```json +{ + "id": "1737000000_myproject", + "project_path": "/home/user/myproject", + "start_time": "2026-01-15T10:00:00Z", + "end_time": "2026-01-15T12:30:00Z", + "active": false, + "events": [ + { + "type": "file_edit", + "timestamp": "2026-01-15T10:05:23.123Z", + "data": { + "filename": "src/auth.lua", + "line": 45, + "column": 12, + "line_count": 120, + "changed_tick": 1523 + } + }, + // ... thousands more events + ] +} +``` + +--- + +### 2. The Story: `SESSION_SUMMARY.md` + +**Purpose:** Human-readable, aggregated summary with advanced analytics and insights. + +**Contains:** +- Activity blocks (merged events within 2 seconds) +- Velocity metrics (flow state detection) +- Focus ratio (main files vs. distractions) +- Error correction patterns +- Idle gap analysis +- Executive summary with key stats + +**Use cases:** +- Quick session review +- Identifying productivity patterns +- Understanding workflow bottlenecks +- Sharing session insights with team members +- Self-reflection on coding habits + +**Updates:** +- **Automatically** every 5 minutes (configurable) +- **Automatically** when session ends +- **Crash-safe**: Raw JSON is saved immediately on every event + +--- + +## Smart Aggregation Rules + +### The Three Golden Rules + +#### 1. The 2-Second Rule: Activity Block Merging + +**Logic:** If `file_edit` events occur less than 2 seconds apart in the same file, merge them into a single Activity Block. + +**Why?** Rapid successive edits represent continuous work, not separate tasks. This reduces noise from hundreds of keystroke events into meaningful "coding bursts." + +**Example:** +``` +Raw events (50 edits in 8 seconds) → 1 Activity Block +``` + +**Activity Block includes:** +- Start and end timestamps +- Total duration +- Number of events +- Delta tick (total changes made) +- Velocity (ticks per second) +- How the block was closed + +--- + +#### 2. Context Switch Rule: Immediate Block Closure + +**Logic:** If the user switches files or executes a terminal command, immediately close the current Activity Block, even if less than 2 seconds have passed. + +**Why?** Context switches indicate a shift in mental focus. Treating them as block boundaries preserves the semantic meaning of each work session. + +**Triggers:** +- Opening a different file (`file_open` event) +- Switching buffers +- Running terminal commands (`terminal_command` event) + +**Example:** +``` +10:05:00 - Edit auth.lua +10:05:01 - Edit auth.lua +10:05:02 - Run 'go test' → Block closed (context_switch) +10:05:05 - Edit handler.go → New block starts +``` + +--- + +#### 3. Idle Rule: Gap Detection + +**Logic:** If no events occur for more than 5 minutes, record an idle gap. This helps identify: +- Moments when you're stuck debugging +- Breaks for coffee/meetings +- Time spent reading documentation + +**Why?** Long pauses reveal workflow inefficiencies or learning opportunities. + +**Analysis provided:** +- Number of idle gaps +- Total idle time +- Timestamps of each gap (for correlation with error corrections) + +--- + +## Advanced Metrics + +### 1. Velocity: Flow State Detection + +**Formula:** `Velocity = Delta Tick / Duration (seconds)` + +**What is it?** The rate of code changes per second. High velocity (>10 ticks/sec) indicates you're in a "flow state" - coding fast and efficiently without interruptions. + +**Insights:** +- **Average Velocity:** Overall coding speed across the session +- **Peak Velocity:** Your fastest moment +- **Flow Blocks:** Specific periods of peak productivity (highlighted in summary) + +**Example:** +``` +Flow State Block: +- File: auth.lua +- Velocity: 15.3 ticks/sec 🔥 +- Duration: 2m 15s +- Changes: 2,065 ticks across 47 events +``` + +**Use case:** Identify optimal working conditions. Were you in flow during morning hours? After coffee? In quiet environments? + +--- + +### 2. Focus Ratio: Time Management + +**Formula:** `Focus Ratio = (Time in main files) / (Time in main files + distraction time)` + +**What is it?** The percentage of time spent on actual code files vs. file browsers, chat tools, or navigation buffers. + +**Distraction files detected:** +- NvimTree, neo-tree, CHADTree (file explorers) +- copilot-chat (AI chat windows) +- undotree, fern (utility buffers) + +**Insights:** +- **Overall Focus:** E.g., "85% of time on main code files" +- **Time by File:** Which files consumed the most attention +- **Distraction Time:** How much time spent in non-code interfaces + +**Example:** +``` +Focus Ratio: 87.5% + +Time Spent by File: +- auth.lua: 45m 30s (35%) +- handler.go: 30m 12s (23%) +- config.yaml: 15m 5s (12%) +... + +Distraction Time: 18m 22s in file browsers/tools +``` + +**Use case:** Optimize your workflow. If distraction time is high, consider using jump-to-definition commands instead of file browsers. + +--- + +### 3. Error Correction Pattern: Debugging Insights + +**Logic:** Detects when you add an annotation like "fix error" or "debug issue" and looks back at recent file edits to identify: +- Lines deleted before the fix +- Negative tick deltas (reversing changes) +- Number of affected blocks + +**Why?** Understanding your error patterns helps you: +- Identify recurring mistake types +- Improve code review practices +- Learn from debugging sessions + +**Example:** +``` +Error Correction Pattern: +- Time: 10:45:23 +- File: auth.lua +- Annotation: "fix authentication bug - wrong token validation" +- Blocks affected: 3 +- Changes reversed: 127 ticks +- Lines deleted: ~8 +``` + +**Use case:** Track which files or functions cause the most errors. Over time, you'll see patterns like "I always mess up error handling in this module." + +--- + +## Configuration Options + +### Default Configuration + +```lua +require("capytrace").setup({ + -- Existing options... + output_format = "markdown", + save_path = "~/capytrace_logs/", + + -- Smart Aggregation Settings + aggregation = { + -- The 2-Second Rule: merge edits within this window (milliseconds) + merge_window = 2000, + + -- The Idle Rule: detect gaps longer than this (milliseconds) + idle_threshold = 300000, -- 5 minutes + + -- Flow State threshold: velocity > this = flow (ticks/sec) + flow_velocity_threshold = 10.0, + + -- Files/buffers that count as distractions (pattern matching) + distraction_files = { + "NvimTree", + "copilot-chat", + "neo-tree", + "CHADTree", + "fern", + "undotree", + }, + + -- How often to update SESSION_SUMMARY.md (milliseconds) + periodic_update_interval = 300000, -- 5 minutes + }, +}) +``` + +--- + +## Data Processing Pipeline + +### Pipeline Flow + +``` +1. Event Occurs in Neovim + ↓ +2. Lua Plugin Captures Event + ↓ +3. Go Backend Receives Event + ↓ +4. [IMMEDIATE] Save to {session_id}_raw.json (crash-safe) + ↓ +5. [BACKGROUND] Aggregator runs every 5 minutes: + - Build activity blocks (3 golden rules) + - Calculate velocity metrics + - Analyze focus ratio + - Detect error patterns + - Find idle gaps + ↓ +6. [BACKGROUND] Generate SESSION_SUMMARY.md + ↓ +7. On Session End: + - Stop periodic updates + - Generate final SESSION_SUMMARY.md + - Close all files +``` + +### Crash Safety + +**Q: What happens if Neovim crashes?** +**A:** Raw JSON is saved immediately on every event. You'll have a complete record up to the last keystroke. SESSION_SUMMARY.md can be regenerated from the raw JSON. + +**Q: What if the aggregation process fails?** +**A:** The raw JSON is always preserved. Aggregation runs in a separate goroutine and never blocks event recording. + +--- + +## Example SESSION_SUMMARY.md + +```markdown +# Session Summary Report + +**Session ID:** `1737000000_myproject` +**Project:** `/home/user/myproject` +**Started:** 2026-01-15 10:00:00 +**Ended:** 2026-01-15 12:30:00 +**Duration:** 2h 30m + +--- + +## Executive Summary + +- **Total Events:** 1,523 +- **Activity Blocks:** 87 +- **Average Velocity:** 8.5 ticks/sec +- **Peak Velocity:** 18.2 ticks/sec +- **Focus Ratio:** 82.3% +- **Flow State Blocks:** 5 +- **Idle Gaps:** 3 (Total: 22m 15s) +- **Error Corrections:** 2 + +## Velocity Analysis + +**What is Velocity?** Delta Tick / Duration. High velocity (>10 ticks/sec) indicates "Flow State" - you're coding fast and efficiently. + +### Flow State Blocks (5) + +These are your most productive moments: + +1. **10:15:30** - `auth.lua` + - Velocity: **18.2 ticks/sec** 🔥 + - Duration: 3m 45s + - Changes: 4,095 ticks across 52 events + +2. **11:05:12** - `handler.go` + - Velocity: **15.7 ticks/sec** 🔥 + - Duration: 2m 20s + - Changes: 2,198 ticks across 38 events + +... + +## Focus Ratio + +**Overall Focus:** 82.3% of time spent on main code files + +### Time Spent by File + +- `auth.lua`: 45m 30s (30.3%) +- `handler.go`: 38m 22s (25.5%) +- `config.yaml`: 18m 5s (12.0%) +... + +**Distraction Time:** 26m 40s in file browsers/tools + +## Error Correction Patterns + +Detected moments where you fixed errors after making mistakes: + +### 1. 10:45:23 - `auth.lua` + +**Annotation:** "fix authentication bug - wrong token validation" + +- Blocks affected: 3 +- Changes reversed: 127 ticks +- Lines deleted: ~8 + +... + +## Idle Periods + +Gaps > 5 minutes where you might have been stuck or took a break: + +1. **10:35:00** - 8m 22s idle +2. **11:30:00** - 10m 15s idle +3. **12:00:00** - 3m 38s idle + +## Activity Timeline + +Aggregated blocks of continuous work (events < 2 seconds apart): + +### 10:15:30 - `auth.lua` + +- **Duration:** 3m 45s +- **Events:** 52 edits +- **Changes:** 3,200 → 7,295 ticks (Δ4,095) +- **Velocity:** 18.2 ticks/sec 🔥 +- **Closed by:** context_switch + +... + +--- + +*Generated by capytrace.nvim with smart aggregation* +*Raw event data available in `1737000000_myproject_raw.json`* +``` + +--- + +## Customization Tips + +### Adjust Merge Window for Different Workflows + +**Fast typists:** +```lua +aggregation = { + merge_window = 1000, -- 1 second (more granular blocks) +} +``` + +**Thoughtful coders:** +```lua +aggregation = { + merge_window = 5000, -- 5 seconds (larger blocks) +} +``` + +--- + +### Custom Distraction Files + +**Add your own patterns:** +```lua +aggregation = { + distraction_files = { + "NvimTree", + "Telescope", -- If you spend too much time searching + "lazy.nvim", -- Plugin manager + "Mason", -- LSP installer + }, +} +``` + +--- + +### Change Flow State Threshold + +**For high-velocity coders:** +```lua +aggregation = { + flow_velocity_threshold = 20.0, -- Higher bar for flow +} +``` + +**For careful coders:** +```lua +aggregation = { + flow_velocity_threshold = 5.0, -- Lower threshold +} +``` + +--- + +## Future Enhancements + +Potential features for smart aggregation: + +1. **Replay Mode:** Play back coding sessions as a video using raw JSON timestamps +2. **Team Analytics:** Compare velocity/focus across team members +3. **Learning Patterns:** Identify correlation between documentation time and error rates +4. **Time-of-Day Insights:** When are you most productive? +5. **LSP Integration:** Correlate diagnostics with velocity drops +6. **Git Integration:** Link activity blocks to commit messages + +--- + +## Troubleshooting + +**Q: SESSION_SUMMARY.md isn't updating** +- Check that `periodic_update_interval` is set +- Ensure the session is active (not just loaded) +- Look for errors in Neovim's `:messages` + +**Q: Raw JSON file is huge** +- This is normal for long sessions with many edits +- Consider splitting sessions or using shorter intervals +- The summary file is always small and readable + +**Q: Velocity seems off** +- Velocity depends on your editor setup (LSP auto-formatting can inflate it) +- Compare relative velocities (your fast vs. slow moments) rather than absolute numbers + +**Q: Focus ratio is lower than expected** +- Check `distraction_files` patterns - you may need to add more +- Some time in file browsers is normal for exploratory work +- Use the "Time by File" breakdown to see where time went + +--- + +## Related Documentation + +- **REFACTORING_SUMMARY.md** - Technical implementation details +- **README.md** - User guide and quick start +- **internal/aggregator/aggregator.go** - Source code for aggregation logic + +--- + +*Generated by capytrace.nvim - Smart session tracking for Neovim* diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go new file mode 100644 index 0000000..bb112f5 --- /dev/null +++ b/internal/aggregator/aggregator.go @@ -0,0 +1,357 @@ +// Package aggregator implements smart event aggregation logic for transforming +// raw event streams into meaningful activity blocks and analytics. +package aggregator + +import ( + "math" + "strings" + "time" + + "github.com/andev0x/capytrace.nvim/internal/models" +) + +// AggregatorConfig holds configuration for aggregation rules. +type AggregatorConfig struct { + // MergeWindow is the time window for merging file_edit events (default: 2 seconds) + MergeWindow time.Duration + + // IdleThreshold is the time after which to record an idle gap (default: 5 minutes) + IdleThreshold time.Duration + + // FlowVelocityThreshold is the minimum velocity to consider a block as "flow state" + FlowVelocityThreshold float64 + + // DistractionFiles are file patterns that count as distractions + DistractionFiles []string +} + +// DefaultConfig returns the default aggregator configuration. +func DefaultConfig() *AggregatorConfig { + return &AggregatorConfig{ + MergeWindow: 2 * time.Second, + IdleThreshold: 5 * time.Minute, + FlowVelocityThreshold: 10.0, // 10 ticks per second + DistractionFiles: []string{ + "NvimTree", + "copilot-chat", + "neo-tree", + "CHADTree", + "fern", + "undotree", + }, + } +} + +// Aggregator processes raw events into activity blocks and analytics. +type Aggregator struct { + config *AggregatorConfig +} + +// New creates a new Aggregator with the given configuration. +func New(config *AggregatorConfig) *Aggregator { + if config == nil { + config = DefaultConfig() + } + return &Aggregator{config: config} +} + +// AggregateSession processes a session's events and returns activity blocks and analytics. +func (a *Aggregator) AggregateSession(session *models.Session) ([]models.ActivityBlock, *models.SessionAnalytics) { + blocks := a.buildActivityBlocks(session.Events) + analytics := a.computeAnalytics(session, blocks) + return blocks, analytics +} + +// buildActivityBlocks implements the three golden rules: +// 1. 2-second rule: Merge file_edit events < 2s apart +// 2. Context Switch rule: Close block on file change or terminal command +// 3. Idle rule: Record gaps > 5 minutes +func (a *Aggregator) buildActivityBlocks(events []models.Event) []models.ActivityBlock { + var blocks []models.ActivityBlock + var currentBlock *models.ActivityBlock + var lastEventTime time.Time + + for i := range events { + event := &events[i] + + // Skip non-file-edit events for block building, but use them as context triggers + if event.Type != "file_edit" { + // Check if this is a context switch trigger + if currentBlock != nil && (event.Type == "terminal_command" || event.Type == "file_open") { + currentBlock.ClosedBy = "context_switch" + blocks = append(blocks, *currentBlock) + currentBlock = nil + } + lastEventTime = event.Timestamp + continue + } + + // Rule 1: Check if we should start a new block or merge with current + if currentBlock == nil { + // Start new block + currentBlock = a.startNewBlock(event) + } else { + timeSinceLastEvent := event.Timestamp.Sub(lastEventTime) + + // Rule 2: Context switch - different file + if event.Data.Filename != currentBlock.Filename { + currentBlock.ClosedBy = "context_switch" + blocks = append(blocks, *currentBlock) + currentBlock = a.startNewBlock(event) + } else if timeSinceLastEvent > a.config.MergeWindow { + // Rule 1: Timeout - more than 2 seconds + currentBlock.ClosedBy = "timeout" + blocks = append(blocks, *currentBlock) + currentBlock = a.startNewBlock(event) + } else { + // Merge into current block + a.mergeIntoBlock(currentBlock, event) + } + } + + lastEventTime = event.Timestamp + } + + // Close final block + if currentBlock != nil { + currentBlock.ClosedBy = "session_end" + blocks = append(blocks, *currentBlock) + } + + return blocks +} + +// startNewBlock creates a new activity block from the first event. +func (a *Aggregator) startNewBlock(event *models.Event) *models.ActivityBlock { + return &models.ActivityBlock{ + StartTime: event.Timestamp, + EndTime: event.Timestamp, + Duration: 0, + Filename: event.Data.Filename, + EventCount: 1, + StartTick: event.Data.ChangedTick, + EndTick: event.Data.ChangedTick, + DeltaTick: 0, + Velocity: 0, + Events: []models.Event{*event}, + } +} + +// mergeIntoBlock adds an event to an existing activity block. +func (a *Aggregator) mergeIntoBlock(block *models.ActivityBlock, event *models.Event) { + block.EndTime = event.Timestamp + block.Duration = block.EndTime.Sub(block.StartTime) + block.EventCount++ + block.EndTick = event.Data.ChangedTick + block.DeltaTick = block.EndTick - block.StartTick + + // Calculate velocity: ticks per second + if block.Duration.Seconds() > 0 { + block.Velocity = float64(block.DeltaTick) / block.Duration.Seconds() + } + + block.Events = append(block.Events, *event) +} + +// computeAnalytics calculates advanced metrics for the session. +func (a *Aggregator) computeAnalytics(session *models.Session, blocks []models.ActivityBlock) *models.SessionAnalytics { + analytics := &models.SessionAnalytics{ + MainFiles: make(map[string]int), + ErrorCorrections: []models.ErrorPattern{}, + IdleGaps: []models.IdleGap{}, + FlowBlocks: []models.ActivityBlock{}, + } + + // Calculate velocity metrics + var totalVelocity float64 + var velocityCount int + + for _, block := range blocks { + if block.Velocity > 0 { + totalVelocity += block.Velocity + velocityCount++ + + if block.Velocity > analytics.PeakVelocity { + analytics.PeakVelocity = block.Velocity + } + + // Identify flow state blocks + if block.Velocity >= a.config.FlowVelocityThreshold { + analytics.FlowBlocks = append(analytics.FlowBlocks, block) + } + } + } + + if velocityCount > 0 { + analytics.AverageVelocity = totalVelocity / float64(velocityCount) + } + + // Calculate focus ratio and idle gaps + analytics.IdleGaps = a.findIdleGaps(session.Events) + for _, gap := range analytics.IdleGaps { + analytics.TotalIdleTime += gap.Duration + } + + // Track file focus time + a.calculateFocusMetrics(session.Events, analytics) + + // Detect error correction patterns + analytics.ErrorCorrections = a.detectErrorPatterns(session.Events) + + return analytics +} + +// findIdleGaps identifies periods of inactivity > 5 minutes. +func (a *Aggregator) findIdleGaps(events []models.Event) []models.IdleGap { + var gaps []models.IdleGap + + for i := 1; i < len(events); i++ { + timeBetween := events[i].Timestamp.Sub(events[i-1].Timestamp) + + if timeBetween > a.config.IdleThreshold { + gaps = append(gaps, models.IdleGap{ + StartTime: events[i-1].Timestamp, + EndTime: events[i].Timestamp, + Duration: timeBetween, + }) + } + } + + return gaps +} + +// calculateFocusMetrics computes focus ratio and distraction time. +func (a *Aggregator) calculateFocusMetrics(events []models.Event, analytics *models.SessionAnalytics) { + var lastEventTime time.Time + var currentFile string + + for _, event := range events { + // Calculate time spent on previous file + if !lastEventTime.IsZero() && currentFile != "" { + duration := event.Timestamp.Sub(lastEventTime) + + if a.isDistractionFile(currentFile) { + analytics.DistractionTime += int(duration.Seconds()) + } else { + analytics.MainFiles[currentFile] += int(duration.Seconds()) + } + } + + // Update current file + if event.Data.Filename != "" { + currentFile = event.Data.Filename + } + lastEventTime = event.Timestamp + } + + // Calculate focus ratio + totalMainTime := 0 + for _, timeSpent := range analytics.MainFiles { + totalMainTime += timeSpent + } + + totalTime := totalMainTime + analytics.DistractionTime + if totalTime > 0 { + analytics.FocusRatio = float64(totalMainTime) / float64(totalTime) + } +} + +// isDistractionFile checks if a filename matches distraction patterns. +func (a *Aggregator) isDistractionFile(filename string) bool { + for _, pattern := range a.config.DistractionFiles { + if strings.Contains(filename, pattern) { + return true + } + } + return false +} + +// detectErrorPatterns identifies error correction events with preceding deletions. +func (a *Aggregator) detectErrorPatterns(events []models.Event) []models.ErrorPattern { + var patterns []models.ErrorPattern + + for i := range events { + event := &events[i] + + // Look for "fix error" annotations + if event.Type == "annotation" && a.containsErrorKeywords(event.Data.Note) { + // Look back at recent file_edit events for deletions + pattern := a.analyzeErrorContext(events, i) + if pattern != nil { + patterns = append(patterns, *pattern) + } + } + } + + return patterns +} + +// containsErrorKeywords checks if an annotation mentions error fixing. +func (a *Aggregator) containsErrorKeywords(note string) bool { + keywords := []string{"fix", "error", "bug", "issue", "mistake", "correct", "debug"} + noteLower := strings.ToLower(note) + + for _, keyword := range keywords { + if strings.Contains(noteLower, keyword) { + return true + } + } + + return false +} + +// analyzeErrorContext examines events before an error annotation. +func (a *Aggregator) analyzeErrorContext(events []models.Event, annotationIndex int) *models.ErrorPattern { + if annotationIndex == 0 { + return nil + } + + annotation := &events[annotationIndex] + + // Look back up to 10 events or 5 minutes + lookbackWindow := 5 * time.Minute + var linesDeleted int + var ticksReversed int + var blocksAffected int + var lastTick int + + for i := annotationIndex - 1; i >= 0 && i >= annotationIndex-10; i-- { + event := &events[i] + + // Stop if outside time window + if annotation.Timestamp.Sub(event.Timestamp) > lookbackWindow { + break + } + + // Count file edits in the same file + if event.Type == "file_edit" && event.Data.Filename == annotation.Data.Filename { + blocksAffected++ + + // Detect deletions (negative tick delta) + if lastTick > 0 && event.Data.ChangedTick < lastTick { + ticksReversed += lastTick - event.Data.ChangedTick + } + + lastTick = event.Data.ChangedTick + + // Approximate line deletions (rough heuristic) + if event.Data.ChangedTick < lastTick { + linesDeleted += int(math.Abs(float64(event.Data.LineCount - lastTick))) + } + } + } + + // Only create pattern if we detected actual corrections + if blocksAffected > 0 { + return &models.ErrorPattern{ + Timestamp: annotation.Timestamp, + Filename: annotation.Data.Filename, + Annotation: annotation.Data.Note, + LinesDeleted: linesDeleted, + TicksReversed: ticksReversed, + BlocksAffected: blocksAffected, + } + } + + return nil +} diff --git a/internal/exporter/smart_markdown.go b/internal/exporter/smart_markdown.go new file mode 100644 index 0000000..21dd8b4 --- /dev/null +++ b/internal/exporter/smart_markdown.go @@ -0,0 +1,264 @@ +package exporter + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/andev0x/capytrace.nvim/internal/aggregator" + "github.com/andev0x/capytrace.nvim/internal/models" +) + +// SmartMarkdownExporter exports sessions as human-readable Markdown files with +// advanced analytics, activity blocks, and smart aggregation. +type SmartMarkdownExporter struct { + aggregator *aggregator.Aggregator +} + +// NewSmartMarkdownExporter creates a new enhanced Markdown exporter with aggregation support. +func NewSmartMarkdownExporter(config *aggregator.AggregatorConfig) *SmartMarkdownExporter { + return &SmartMarkdownExporter{ + aggregator: aggregator.New(config), + } +} + +// Export writes both a raw JSON file and an aggregated SESSION_SUMMARY.md file. +func (e *SmartMarkdownExporter) Export(session *models.Session, savePath string) error { + // 1. Save raw JSON (The Truth) + if err := e.saveRawJSON(session, savePath); err != nil { + return fmt.Errorf("failed to save raw JSON: %w", err) + } + + // 2. Generate and save SESSION_SUMMARY.md (The Story) + if err := e.saveSessionSummary(session, savePath); err != nil { + return fmt.Errorf("failed to save session summary: %w", err) + } + + return nil +} + +// saveRawJSON saves the complete event history as JSON. +func (e *SmartMarkdownExporter) saveRawJSON(session *models.Session, savePath string) error { + filename := fmt.Sprintf("%s_raw.json", session.ID) + fullPath := filepath.Join(savePath, filename) + + // Marshal to JSON with indentation + data, err := json.MarshalIndent(session, "", " ") + if err != nil { + return err + } + + return os.WriteFile(fullPath, data, 0644) +} + +// saveSessionSummary generates and saves the aggregated Markdown summary. +func (e *SmartMarkdownExporter) saveSessionSummary(session *models.Session, savePath string) error { + // Run aggregation + blocks, analytics := e.aggregator.AggregateSession(session) + + // Generate Markdown content + content := e.generateSmartMarkdown(session, blocks, analytics) + + // Save as SESSION_SUMMARY.md + filename := "SESSION_SUMMARY.md" + fullPath := filepath.Join(savePath, filename) + + return os.WriteFile(fullPath, []byte(content), 0644) +} + +// generateSmartMarkdown creates an enhanced Markdown document with analytics. +func (e *SmartMarkdownExporter) generateSmartMarkdown( + session *models.Session, + blocks []models.ActivityBlock, + analytics *models.SessionAnalytics, +) string { + var sb strings.Builder + + // ===== HEADER SECTION ===== + sb.WriteString("# Session Summary Report\n\n") + sb.WriteString(fmt.Sprintf("**Session ID:** `%s`\n", session.ID)) + sb.WriteString(fmt.Sprintf("**Project:** `%s`\n", session.ProjectPath)) + sb.WriteString(fmt.Sprintf("**Started:** %s\n", session.StartTime.Format("2006-01-02 15:04:05"))) + + if !session.EndTime.IsZero() { + sb.WriteString(fmt.Sprintf("**Ended:** %s\n", session.EndTime.Format("2006-01-02 15:04:05"))) + duration := session.EndTime.Sub(session.StartTime) + sb.WriteString(fmt.Sprintf("**Duration:** %s\n", formatDuration(duration))) + } else { + sb.WriteString("**Status:** Active\n") + } + + sb.WriteString("\n---\n\n") + + // ===== EXECUTIVE SUMMARY ===== + sb.WriteString("## Executive Summary\n\n") + sb.WriteString(fmt.Sprintf("- **Total Events:** %d\n", len(session.Events))) + sb.WriteString(fmt.Sprintf("- **Activity Blocks:** %d\n", len(blocks))) + sb.WriteString(fmt.Sprintf("- **Average Velocity:** %.2f ticks/sec\n", analytics.AverageVelocity)) + sb.WriteString(fmt.Sprintf("- **Peak Velocity:** %.2f ticks/sec\n", analytics.PeakVelocity)) + sb.WriteString(fmt.Sprintf("- **Focus Ratio:** %.1f%%\n", analytics.FocusRatio*100)) + sb.WriteString(fmt.Sprintf("- **Flow State Blocks:** %d\n", len(analytics.FlowBlocks))) + sb.WriteString(fmt.Sprintf("- **Idle Gaps:** %d (Total: %s)\n", len(analytics.IdleGaps), formatDuration(analytics.TotalIdleTime))) + sb.WriteString(fmt.Sprintf("- **Error Corrections:** %d\n", len(analytics.ErrorCorrections))) + sb.WriteString("\n") + + // ===== VELOCITY ANALYSIS ===== + sb.WriteString("## Velocity Analysis\n\n") + sb.WriteString("**What is Velocity?** Delta Tick / Duration. High velocity (>10 ticks/sec) indicates \"Flow State\" - you're coding fast and efficiently.\n\n") + + if len(analytics.FlowBlocks) > 0 { + sb.WriteString(fmt.Sprintf("### Flow State Blocks (%d)\n\n", len(analytics.FlowBlocks))) + sb.WriteString("These are your most productive moments:\n\n") + + for i, block := range analytics.FlowBlocks { + sb.WriteString(fmt.Sprintf("%d. **%s** - `%s`\n", i+1, block.StartTime.Format("15:04:05"), filepath.Base(block.Filename))) + sb.WriteString(fmt.Sprintf(" - Velocity: **%.2f ticks/sec** 🔥\n", block.Velocity)) + sb.WriteString(fmt.Sprintf(" - Duration: %s\n", formatDuration(block.Duration))) + sb.WriteString(fmt.Sprintf(" - Changes: %d ticks across %d events\n", block.DeltaTick, block.EventCount)) + sb.WriteString("\n") + } + } else { + sb.WriteString("*No flow state blocks detected. Try to minimize interruptions for deeper focus.*\n\n") + } + + // ===== FOCUS RATIO ===== + sb.WriteString("## Focus Ratio\n\n") + sb.WriteString(fmt.Sprintf("**Overall Focus:** %.1f%% of time spent on main code files\n\n", analytics.FocusRatio*100)) + + if len(analytics.MainFiles) > 0 { + sb.WriteString("### Time Spent by File\n\n") + + // Sort files by time spent (descending) + type fileTime struct { + name string + time int + } + var files []fileTime + for name, timeSpent := range analytics.MainFiles { + files = append(files, fileTime{name, timeSpent}) + } + sort.Slice(files, func(i, j int) bool { + return files[i].time > files[j].time + }) + + for i, ft := range files { + if i >= 10 { + break // Show top 10 + } + percentage := float64(ft.time) / float64(analytics.DistractionTime+ft.time) * 100 + sb.WriteString(fmt.Sprintf("- `%s`: %s (%.1f%%)\n", + filepath.Base(ft.name), + formatDuration(time.Duration(ft.time)*time.Second), + percentage)) + } + sb.WriteString("\n") + } + + if analytics.DistractionTime > 0 { + sb.WriteString(fmt.Sprintf("**Distraction Time:** %s in file browsers/tools\n\n", + formatDuration(time.Duration(analytics.DistractionTime)*time.Second))) + } + + // ===== ERROR CORRECTION PATTERNS ===== + if len(analytics.ErrorCorrections) > 0 { + sb.WriteString("## Error Correction Patterns\n\n") + sb.WriteString("Detected moments where you fixed errors after making mistakes:\n\n") + + for i, pattern := range analytics.ErrorCorrections { + sb.WriteString(fmt.Sprintf("### %d. %s - `%s`\n\n", i+1, + pattern.Timestamp.Format("15:04:05"), + filepath.Base(pattern.Filename))) + sb.WriteString(fmt.Sprintf("**Annotation:** \"%s\"\n\n", pattern.Annotation)) + sb.WriteString(fmt.Sprintf("- Blocks affected: %d\n", pattern.BlocksAffected)) + if pattern.TicksReversed > 0 { + sb.WriteString(fmt.Sprintf("- Changes reversed: %d ticks\n", pattern.TicksReversed)) + } + if pattern.LinesDeleted > 0 { + sb.WriteString(fmt.Sprintf("- Lines deleted: ~%d\n", pattern.LinesDeleted)) + } + sb.WriteString("\n") + } + } + + // ===== IDLE GAPS ===== + if len(analytics.IdleGaps) > 0 { + sb.WriteString("## Idle Periods\n\n") + sb.WriteString("Gaps > 5 minutes where you might have been stuck or took a break:\n\n") + + for i, gap := range analytics.IdleGaps { + sb.WriteString(fmt.Sprintf("%d. **%s** - %s idle\n", i+1, + gap.StartTime.Format("15:04:05"), + formatDuration(gap.Duration))) + } + sb.WriteString("\n") + } + + // ===== ACTIVITY TIMELINE ===== + sb.WriteString("## Activity Timeline\n\n") + sb.WriteString("Aggregated blocks of continuous work (events < 2 seconds apart):\n\n") + + for i, block := range blocks { + if i >= 50 { + sb.WriteString(fmt.Sprintf("\n*...and %d more blocks (see raw JSON for full details)*\n", len(blocks)-i)) + break + } + + sb.WriteString(fmt.Sprintf("### %s - `%s`\n\n", + block.StartTime.Format("15:04:05"), + filepath.Base(block.Filename))) + + sb.WriteString(fmt.Sprintf("- **Duration:** %s\n", formatDuration(block.Duration))) + sb.WriteString(fmt.Sprintf("- **Events:** %d edits\n", block.EventCount)) + sb.WriteString(fmt.Sprintf("- **Changes:** %d → %d ticks (Δ%d)\n", + block.StartTick, block.EndTick, block.DeltaTick)) + + if block.Velocity > 0 { + velocityEmoji := "" + if block.Velocity >= 10 { + velocityEmoji = " 🔥" + } + sb.WriteString(fmt.Sprintf("- **Velocity:** %.2f ticks/sec%s\n", block.Velocity, velocityEmoji)) + } + + sb.WriteString(fmt.Sprintf("- **Closed by:** %s\n", block.ClosedBy)) + sb.WriteString("\n") + } + + // ===== RAW EVENT SUMMARY ===== + sb.WriteString("---\n\n") + sb.WriteString("## Raw Event Statistics\n\n") + + eventCounts := make(map[string]int) + for _, event := range session.Events { + eventCounts[event.Type]++ + } + + for eventType, count := range eventCounts { + sb.WriteString(fmt.Sprintf("- **%s:** %d\n", formatEventType(eventType), count)) + } + + sb.WriteString("\n---\n\n") + sb.WriteString("*Generated by capytrace.nvim with smart aggregation*\n") + sb.WriteString(fmt.Sprintf("*Raw event data available in `%s_raw.json`*\n", session.ID)) + + return sb.String() +} + +// formatDuration converts a duration to a human-readable string. +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%.0fs", d.Seconds()) + } + if d < time.Hour { + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % 60 + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + return fmt.Sprintf("%dh %dm", hours, minutes) +} diff --git a/internal/models/event.go b/internal/models/event.go index 89c1954..2dca68c 100644 --- a/internal/models/event.go +++ b/internal/models/event.go @@ -62,3 +62,57 @@ type SessionSummary struct { Annotations int CursorMoves int } + +// ActivityBlock represents a merged group of related events within a short time window. +// Used for aggregating file_edit events that occur within 2 seconds. +type ActivityBlock struct { + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Duration time.Duration `json:"duration"` + Filename string `json:"filename"` + EventCount int `json:"event_count"` + StartTick int `json:"start_tick"` + EndTick int `json:"end_tick"` + DeltaTick int `json:"delta_tick"` + Velocity float64 `json:"velocity"` // Delta Tick / Duration in seconds + Events []Event `json:"events"` + ClosedBy string `json:"closed_by"` // "context_switch", "idle", "timeout" +} + +// IdleGap represents a period of inactivity during the session. +// Used to identify moments when the developer might be stuck or taking a break. +type IdleGap struct { + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Duration time.Duration `json:"duration"` +} + +// SessionAnalytics provides advanced metrics about a coding session. +type SessionAnalytics struct { + // Velocity metrics + AverageVelocity float64 `json:"average_velocity"` + PeakVelocity float64 `json:"peak_velocity"` + FlowBlocks []ActivityBlock `json:"flow_blocks"` // High velocity blocks + + // Focus metrics + FocusRatio float64 `json:"focus_ratio"` // Main file time / Total time + MainFiles map[string]int `json:"main_files"` // File -> time spent (seconds) + DistractionTime int `json:"distraction_time"` // Time in NvimTree, copilot-chat, etc. + + // Error correction patterns + ErrorCorrections []ErrorPattern `json:"error_corrections"` + + // Idle analysis + IdleGaps []IdleGap `json:"idle_gaps"` + TotalIdleTime time.Duration `json:"total_idle_time"` +} + +// ErrorPattern represents a detected error correction event. +type ErrorPattern struct { + Timestamp time.Time `json:"timestamp"` + Filename string `json:"filename"` + Annotation string `json:"annotation"` + LinesDeleted int `json:"lines_deleted"` + TicksReversed int `json:"ticks_reversed"` // Negative delta + BlocksAffected int `json:"blocks_affected"` +} diff --git a/internal/recorder/session.go b/internal/recorder/session.go index 2c9a383..798f111 100644 --- a/internal/recorder/session.go +++ b/internal/recorder/session.go @@ -8,9 +8,12 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync" "time" + "github.com/andev0x/capytrace.nvim/internal/aggregator" + "github.com/andev0x/capytrace.nvim/internal/exporter" "github.com/andev0x/capytrace.nvim/internal/filter" "github.com/andev0x/capytrace.nvim/internal/models" ) @@ -23,14 +26,18 @@ var ( // Session wraps a models.Session with additional runtime state and filtering capabilities. type Session struct { *models.Session - mu sync.Mutex - cursorFilter *filter.CursorFilter + mu sync.Mutex + cursorFilter *filter.CursorFilter + aggregatorConfig *aggregator.AggregatorConfig + periodicTicker *time.Ticker + stopPeriodicChan chan struct{} } // NewSession creates a new debugging session with the specified parameters. -// It initializes the session with a cursor filter to reduce noise from rapid movements. +// It initializes the session with a cursor filter to reduce noise from rapid movements +// and starts a background goroutine for periodic SESSION_SUMMARY.md updates. func NewSession(id, projectPath, savePath, outputFormat string, filterConfig *filter.FilterConfig) *Session { - return &Session{ + session := &Session{ Session: &models.Session{ ID: id, ProjectPath: projectPath, @@ -40,8 +47,55 @@ func NewSession(id, projectPath, savePath, outputFormat string, filterConfig *fi Events: []models.Event{}, Active: true, }, - cursorFilter: filter.NewCursorFilter(filterConfig), + cursorFilter: filter.NewCursorFilter(filterConfig), + aggregatorConfig: aggregator.DefaultConfig(), + stopPeriodicChan: make(chan struct{}), + } + + // Start periodic aggregation updates (every 5 minutes) + session.startPeriodicAggregation(5 * time.Minute) + + return session +} + +// startPeriodicAggregation starts a background goroutine that regenerates +// SESSION_SUMMARY.md every interval. +func (s *Session) startPeriodicAggregation(interval time.Duration) { + s.periodicTicker = time.NewTicker(interval) + + go func() { + for { + select { + case <-s.periodicTicker.C: + // Regenerate SESSION_SUMMARY.md + s.regenerateSummary() + case <-s.stopPeriodicChan: + return + } + } + }() +} + +// regenerateSummary updates the SESSION_SUMMARY.md file with current session data. +func (s *Session) regenerateSummary() { + s.mu.Lock() + sessionCopy := *s.Session + s.mu.Unlock() + + // Use SmartMarkdownExporter to generate updated summary + smartExporter := exporter.NewSmartMarkdownExporter(s.aggregatorConfig) + if err := smartExporter.Export(&sessionCopy, s.SavePath); err != nil { + // Log error but don't fail the session + fmt.Fprintf(os.Stderr, "Failed to regenerate session summary: %v\n", err) + } +} + +// stopPeriodicAggregation stops the background aggregation goroutine. +func (s *Session) stopPeriodicAggregation() { + if s.periodicTicker != nil { + s.periodicTicker.Stop() } + close(s.stopPeriodicChan) } // Start begins recording a new debugging session and persists it to disk. @@ -67,6 +121,9 @@ func (s *Session) End() error { s.mu.Lock() defer s.mu.Unlock() + // Stop periodic aggregation + s.stopPeriodicAggregation() + // Flush any pending cursor events if pendingEvent := s.cursorFilter.FlushPending(); pendingEvent != nil { s.Session.Events = append(s.Session.Events, *pendingEvent) @@ -89,7 +146,15 @@ func (s *Session) End() error { delete(activeSessions, s.ID) activeSessionsMu.Unlock() - return s.save() + // Save raw JSON + if err := s.save(); err != nil { + return err + } + + // Generate final SESSION_SUMMARY.md + s.regenerateSummary() + + return nil } // AddAnnotation adds a user-provided note to the session timeline. @@ -217,12 +282,13 @@ func (s *Session) addEvent(event models.Event) error { return s.save() } -// save persists the session state to disk as JSON. +// save persists the session state to disk as JSON (The Truth). func (s *Session) save() error { s.mu.Lock() defer s.mu.Unlock() - sessionPath := filepath.Join(s.SavePath, s.ID+".json") + // Save as {session_id}_raw.json to distinguish from exported versions + sessionPath := filepath.Join(s.SavePath, s.ID+"_raw.json") data, err := json.MarshalIndent(s.Session, "", " ") if err != nil { return err @@ -240,11 +306,16 @@ func LoadSession(sessionID string, savePath string, filterConfig *filter.FilterC } activeSessionsMu.RUnlock() - // Try to load from file - sessionPath := filepath.Join(savePath, sessionID+".json") + // Try to load from file (try both _raw.json and .json for backwards compatibility) + sessionPath := filepath.Join(savePath, sessionID+"_raw.json") data, err := os.ReadFile(sessionPath) if err != nil { - return nil, err + // Try old naming scheme + sessionPath = filepath.Join(savePath, sessionID+".json") + data, err = os.ReadFile(sessionPath) + if err != nil { + return nil, err + } } var modelSession models.Session @@ -253,14 +324,19 @@ func LoadSession(sessionID string, savePath string, filterConfig *filter.FilterC } session := &Session{ - Session: &modelSession, - cursorFilter: filter.NewCursorFilter(filterConfig), + Session: &modelSession, + cursorFilter: filter.NewCursorFilter(filterConfig), + aggregatorConfig: aggregator.DefaultConfig(), + stopPeriodicChan: make(chan struct{}), } if session.Active { activeSessionsMu.Lock() activeSessions[sessionID] = session activeSessionsMu.Unlock() + + // Restart periodic aggregation for active sessions + session.startPeriodicAggregation(5 * time.Minute) } return session, nil @@ -274,9 +350,24 @@ func ListSessions(savePath string) ([]string, error) { } var sessions []string + seen := make(map[string]bool) + for _, file := range files { - if filepath.Ext(file.Name()) == ".json" { - sessions = append(sessions, file.Name()[:len(file.Name())-5]) // Remove .json extension + name := file.Name() + + // Handle both _raw.json and .json extensions + if strings.HasSuffix(name, "_raw.json") { + sessionID := strings.TrimSuffix(name, "_raw.json") + if !seen[sessionID] { + sessions = append(sessions, sessionID) + seen[sessionID] = true + } + } else if strings.HasSuffix(name, ".json") && !strings.HasSuffix(name, "_export.json") { + sessionID := strings.TrimSuffix(name, ".json") + if !seen[sessionID] { + sessions = append(sessions, sessionID) + seen[sessionID] = true + } } } @@ -285,10 +376,16 @@ func ListSessions(savePath string) ([]string, error) { // ResumeSession loads a previously saved session and marks it as active again. func ResumeSession(sessionName, savePath string, filterConfig *filter.FilterConfig) (*Session, error) { - sessionPath := filepath.Join(savePath, sessionName+".json") + // Try to load from file (try both _raw.json and .json for backwards compatibility) + sessionPath := filepath.Join(savePath, sessionName+"_raw.json") data, err := os.ReadFile(sessionPath) if err != nil { - return nil, err + // Try old naming scheme + sessionPath = filepath.Join(savePath, sessionName+".json") + data, err = os.ReadFile(sessionPath) + if err != nil { + return nil, err + } } var modelSession models.Session @@ -297,8 +394,10 @@ func ResumeSession(sessionName, savePath string, filterConfig *filter.FilterConf } session := &Session{ - Session: &modelSession, - cursorFilter: filter.NewCursorFilter(filterConfig), + Session: &modelSession, + cursorFilter: filter.NewCursorFilter(filterConfig), + aggregatorConfig: aggregator.DefaultConfig(), + stopPeriodicChan: make(chan struct{}), } session.Active = true @@ -307,6 +406,9 @@ func ResumeSession(sessionName, savePath string, filterConfig *filter.FilterConf activeSessions[session.ID] = session activeSessionsMu.Unlock() + // Start periodic aggregation + session.startPeriodicAggregation(5 * time.Minute) + // Record resume event session.addEvent(models.Event{ Type: "session_resume", diff --git a/lua/capytrace/config.lua b/lua/capytrace/config.lua index 42e793d..d371f3c 100644 --- a/lua/capytrace/config.lua +++ b/lua/capytrace/config.lua @@ -8,9 +8,27 @@ local default_config = { record_git_diff = true, auto_save_on_exit = true, max_cursor_events = 100, -- Limit cursor movement recordings + -- Smart Filter configuration (Anti-Spam Cursor Filter) filter_threshold = 500, -- Idle threshold in milliseconds (default: 500ms) debounce_interval = 200, -- Debounce interval for cursor movements (default: 200ms) + + -- Smart Aggregation configuration (Activity Block Builder) + aggregation = { + merge_window = 2000, -- Time window for merging file_edit events in milliseconds (default: 2s) + idle_threshold = 300000, -- Time to record idle gap in milliseconds (default: 5min) + flow_velocity_threshold = 10.0, -- Minimum velocity to consider flow state (ticks/sec) + distraction_files = { -- File patterns that count as distractions + "NvimTree", + "copilot-chat", + "neo-tree", + "CHADTree", + "fern", + "undotree", + }, + periodic_update_interval = 300000, -- Update SESSION_SUMMARY.md every N milliseconds (default: 5min) + }, + log_events = { terminal_commands = true, file_open = true,