-**[📖 文档中心](docs/README.md)** • **[👥 用户指南](docs/user-guide/README.md)** • **[💻 开发者文档](docs/developers/README.md)** • **[🤝 贡献](docs/developers/getting-started/development-workflow.md)**
+**[📖 文档](docs/README.md)** • **[👥 用户指南](docs/user-guide/README.md)** • **[💻 开发者指南](docs/developers/README.md)**
iDO 团队用 ❤️ 制作
diff --git a/backend/agents/action_agent.py b/backend/agents/action_agent.py
index 21f7cc5..86041c2 100644
--- a/backend/agents/action_agent.py
+++ b/backend/agents/action_agent.py
@@ -92,6 +92,36 @@ async def extract_and_save_actions(
try:
logger.debug(f"ActionAgent: Processing {len(records)} records")
+ # Pre-persist all screenshots to prevent cache eviction during LLM processing
+ screenshot_records = [
+ r for r in records if r.type == RecordType.SCREENSHOT_RECORD
+ ]
+ screenshot_hashes = [
+ r.data.get("hash")
+ for r in screenshot_records
+ if r.data and r.data.get("hash")
+ ]
+
+ if screenshot_hashes:
+ logger.debug(
+ f"ActionAgent: Pre-persisting {len(screenshot_hashes)} screenshots "
+ f"before LLM call to prevent cache eviction"
+ )
+ persist_results = self.image_manager.persist_images_batch(screenshot_hashes)
+
+ # Log pre-persistence results
+ success_count = sum(1 for success in persist_results.values() if success)
+ if success_count < len(screenshot_hashes):
+ logger.warning(
+ f"ActionAgent: Pre-persistence incomplete: "
+ f"{success_count}/{len(screenshot_hashes)} images persisted. "
+ f"Some images may already be evicted from cache."
+ )
+ else:
+ logger.debug(
+ f"ActionAgent: Successfully pre-persisted all {len(screenshot_hashes)} screenshots"
+ )
+
# Step 1: Extract actions using LLM
actions = await self._extract_actions(
records, input_usage_hint, keyboard_records, mouse_records, enable_supervisor
@@ -102,10 +132,7 @@ async def extract_and_save_actions(
return 0
# Step 2: Validate and resolve screenshot hashes
- screenshot_records = [
- r for r in records if r.type == RecordType.SCREENSHOT_RECORD
- ]
-
+ # (screenshot_records already created above for pre-persistence)
resolved_actions: List[Dict[str, Any]] = []
for action_data in actions:
action_hashes = self._resolve_action_screenshot_hashes(
@@ -349,6 +376,7 @@ async def extract_and_save_actions_from_scenes(
keyboard_records: Optional[List[RawRecord]] = None,
mouse_records: Optional[List[RawRecord]] = None,
enable_supervisor: bool = False,
+ behavior_analysis: Optional[Dict[str, Any]] = None,
) -> int:
"""
Extract and save actions from pre-processed scene descriptions (memory-only, text-based)
@@ -358,6 +386,7 @@ async def extract_and_save_actions_from_scenes(
keyboard_records: Keyboard event records for context
mouse_records: Mouse event records for context
enable_supervisor: Whether to enable supervisor validation (default False)
+ behavior_analysis: Behavior classification result from BehaviorAnalyzer
Returns:
Number of actions saved
@@ -370,7 +399,7 @@ async def extract_and_save_actions_from_scenes(
# Step 1: Extract actions from scenes using LLM (text-only, no images)
actions = await self._extract_actions_from_scenes(
- scenes, keyboard_records, mouse_records, enable_supervisor
+ scenes, keyboard_records, mouse_records, enable_supervisor, behavior_analysis
)
if not actions:
@@ -455,12 +484,24 @@ def _persist_action_screenshots(self, screenshot_hashes: list[str]) -> None:
results = self.image_manager.persist_images_batch(screenshot_hashes)
- # Log warnings for failed persists
+ # Enhanced logging for failed persists
failed = [h for h, success in results.items() if not success]
if failed:
- logger.warning(
- f"Failed to persist {len(failed)} screenshots (likely evicted from memory): "
- f"{[h[:8] for h in failed]}"
+ logger.error(
+ f"ActionAgent: Image persistence FAILURE: {len(failed)}/{len(screenshot_hashes)} images lost. "
+ f"Action will be saved with broken image references. "
+ f"\nFailed hashes: {[h[:8] + '...' for h in failed[:5]]}"
+ f"{' (and ' + str(len(failed) - 5) + ' more)' if len(failed) > 5 else ''}"
+ f"\nRoot cause: Images evicted from memory cache before persistence."
+ f"\nRecommendations:"
+ f"\n 1. Increase memory_ttl in config.toml (current: {self.image_manager.memory_ttl}s, recommended: ≥180s)"
+ f"\n 2. Run GET /image/persistence-health to check system health"
+ f"\n 3. Run POST /image/cleanup-broken-actions to fix existing issues"
+ f"\n 4. Consider increasing memory_cache_size (current: {self.image_manager.memory_cache_size}, recommended: ≥1000)"
+ )
+ else:
+ logger.debug(
+ f"ActionAgent: Successfully persisted all {len(screenshot_hashes)} screenshots"
)
except Exception as e:
@@ -542,6 +583,7 @@ async def _extract_actions_from_scenes(
keyboard_records: Optional[List[RawRecord]] = None,
mouse_records: Optional[List[RawRecord]] = None,
enable_supervisor: bool = False,
+ behavior_analysis: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""
Extract actions from scene descriptions using LLM (text-only, no images)
@@ -551,6 +593,7 @@ async def _extract_actions_from_scenes(
keyboard_records: Keyboard event records for context
mouse_records: Mouse event records for context
enable_supervisor: Whether to enable supervisor validation
+ behavior_analysis: Behavior classification result from BehaviorAnalyzer
Returns:
List of action dictionaries
@@ -564,9 +607,19 @@ async def _extract_actions_from_scenes(
# Build input usage hint from keyboard/mouse records
input_usage_hint = self._build_input_usage_hint(keyboard_records, mouse_records)
+ # NEW: Format behavior context for prompt
+ behavior_context = ""
+ if behavior_analysis:
+ language = self._get_language()
+ from processing.behavior_analyzer import BehaviorAnalyzer
+ analyzer = BehaviorAnalyzer()
+ behavior_context = analyzer.format_behavior_context(
+ behavior_analysis, language
+ )
+
# Build messages (text-only, no images)
messages = self._build_action_from_scenes_messages(
- scenes, input_usage_hint
+ scenes, input_usage_hint, behavior_context
)
# Get configuration parameters
@@ -820,6 +873,7 @@ def _build_action_from_scenes_messages(
self,
scenes: List[Dict[str, Any]],
input_usage_hint: str,
+ behavior_context: str = "",
) -> List[Dict[str, Any]]:
"""
Build action extraction messages from scenes (text-only, no images)
@@ -827,6 +881,7 @@ def _build_action_from_scenes_messages(
Args:
scenes: List of scene description dictionaries
input_usage_hint: Keyboard/mouse activity hint
+ behavior_context: Formatted behavior classification context
Returns:
Message list
@@ -866,6 +921,7 @@ def _build_action_from_scenes_messages(
"user_prompt_template",
scenes_text=scenes_text,
input_usage_hint=input_usage_hint,
+ behavior_context=behavior_context,
)
# Build complete messages (text-only, no images)
diff --git a/backend/agents/event_agent.py b/backend/agents/event_agent.py
index 184f590..bc321f9 100644
--- a/backend/agents/event_agent.py
+++ b/backend/agents/event_agent.py
@@ -31,6 +31,7 @@ class EventAgent:
def __init__(
self,
+ coordinator=None,
aggregation_interval: int = 600, # 10 minutes
time_window_hours: int = 1, # Look back 1 hour for unaggregated actions
):
@@ -38,9 +39,11 @@ def __init__(
Initialize EventAgent
Args:
+ coordinator: Reference to PipelineCoordinator (for accessing pomodoro session)
aggregation_interval: How often to run aggregation (seconds, default 10min)
time_window_hours: Time window to look back for unaggregated actions (hours)
"""
+ self.coordinator = coordinator
self.aggregation_interval = aggregation_interval
self.time_window_hours = time_window_hours
@@ -191,6 +194,11 @@ async def _aggregate_events(self):
else str(end_time)
)
+ # Get current pomodoro session ID if active
+ pomodoro_session_id = None
+ if self.coordinator and hasattr(self.coordinator, 'pomodoro_manager'):
+ pomodoro_session_id = self.coordinator.pomodoro_manager.get_current_session_id()
+
# Save event
await self.db.events.save(
event_id=event_id,
@@ -199,6 +207,7 @@ async def _aggregate_events(self):
start_time=start_time,
end_time=end_time,
source_action_ids=[str(aid) for aid in source_action_ids if aid],
+ pomodoro_session_id=pomodoro_session_id,
)
self.stats["events_created"] += 1
diff --git a/backend/agents/knowledge_agent.py b/backend/agents/knowledge_agent.py
index a2c1475..3165471 100644
--- a/backend/agents/knowledge_agent.py
+++ b/backend/agents/knowledge_agent.py
@@ -3,8 +3,6 @@
Extracts knowledge from screenshots and merges related knowledge periodically
"""
-import asyncio
-import json
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -381,4 +379,3 @@ def _calculate_knowledge_timestamp_from_scenes(
)
return min(timestamps) if timestamps else datetime.now()
-
diff --git a/backend/agents/raw_agent.py b/backend/agents/raw_agent.py
index 6f43476..c8bef28 100644
--- a/backend/agents/raw_agent.py
+++ b/backend/agents/raw_agent.py
@@ -75,6 +75,7 @@ async def extract_scenes(
records: List[RawRecord],
keyboard_records: Optional[List[RawRecord]] = None,
mouse_records: Optional[List[RawRecord]] = None,
+ behavior_analysis: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""
Extract scene descriptions from raw records (screenshots)
@@ -83,6 +84,7 @@ async def extract_scenes(
records: List of raw records (mainly screenshots)
keyboard_records: Keyboard event records for timestamp extraction
mouse_records: Mouse event records for timestamp extraction
+ behavior_analysis: Behavior classification result from BehaviorAnalyzer
Returns:
List of scene description dictionaries:
@@ -113,9 +115,19 @@ async def extract_scenes(
# Build input usage hint from keyboard/mouse records
input_usage_hint = self._build_input_usage_hint(keyboard_records, mouse_records)
+ # NEW: Format behavior context for prompt
+ behavior_context = ""
+ if behavior_analysis:
+ language = self._get_language()
+ from processing.behavior_analyzer import BehaviorAnalyzer
+ analyzer = BehaviorAnalyzer()
+ behavior_context = analyzer.format_behavior_context(
+ behavior_analysis, language
+ )
+
# Build messages (including screenshots)
messages = await self._build_scene_extraction_messages(
- records, input_usage_hint
+ records, input_usage_hint, behavior_context
)
# Get configuration parameters
@@ -190,6 +202,7 @@ async def _build_scene_extraction_messages(
self,
records: List[RawRecord],
input_usage_hint: str,
+ behavior_context: str = "",
) -> List[Dict[str, Any]]:
"""
Build scene extraction messages (including system prompt, user prompt, screenshots)
@@ -197,6 +210,7 @@ async def _build_scene_extraction_messages(
Args:
records: Record list (mainly screenshots)
input_usage_hint: Keyboard/mouse activity hint
+ behavior_context: Formatted behavior classification context
Returns:
Message list
@@ -209,6 +223,7 @@ async def _build_scene_extraction_messages(
"raw_extraction",
"user_prompt_template",
input_usage_hint=input_usage_hint,
+ behavior_context=behavior_context,
)
# Build message content (text + screenshots)
diff --git a/backend/agents/session_agent.py b/backend/agents/session_agent.py
index daad37c..d79a089 100644
--- a/backend/agents/session_agent.py
+++ b/backend/agents/session_agent.py
@@ -13,6 +13,7 @@
from core.json_parser import parse_json_from_response
from core.logger import get_logger
from core.settings import get_settings
+from llm.focus_evaluator import get_focus_evaluator
from llm.manager import get_llm_manager
from llm.prompt_manager import get_prompt_manager
@@ -40,6 +41,7 @@ def __init__(
min_event_actions: int = 2, # Minimum 2 actions per event
merge_time_gap_tolerance: int = 300, # 5 minutes tolerance for adjacent activities
merge_similarity_threshold: float = 0.6, # Minimum similarity score for merging
+ enable_periodic_aggregation: bool = False, # Disabled by default, only use Pomodoro-triggered aggregation
):
"""
Initialize SessionAgent
@@ -52,6 +54,7 @@ def __init__(
min_event_actions: Minimum number of actions per event (default 2)
merge_time_gap_tolerance: Max time gap (seconds) to consider for merging adjacent activities (default 300s/5min)
merge_similarity_threshold: Minimum semantic similarity score (0-1) required for merging (default 0.6)
+ enable_periodic_aggregation: Whether to enable periodic aggregation (default False, only Pomodoro-triggered)
"""
self.aggregation_interval = aggregation_interval
self.time_window_min = time_window_min
@@ -60,6 +63,7 @@ def __init__(
self.min_event_actions = min_event_actions
self.merge_time_gap_tolerance = merge_time_gap_tolerance
self.merge_similarity_threshold = merge_similarity_threshold
+ self.enable_periodic_aggregation = enable_periodic_aggregation
# Initialize components
self.db = get_db()
@@ -80,7 +84,8 @@ def __init__(
}
logger.debug(
- f"SessionAgent initialized (interval: {aggregation_interval}s, "
+ f"SessionAgent initialized (periodic_aggregation: {'enabled' if enable_periodic_aggregation else 'disabled'}, "
+ f"interval: {aggregation_interval}s, "
f"time_window: {time_window_min}-{time_window_max}min, "
f"quality_filter: min_duration={min_event_duration_seconds}s, min_actions={min_event_actions}, "
f"merge_config: gap_tolerance={merge_time_gap_tolerance}s, similarity_threshold={merge_similarity_threshold})"
@@ -98,14 +103,18 @@ async def start(self):
self.is_running = True
- # Start aggregation task
- self.aggregation_task = asyncio.create_task(
- self._periodic_session_aggregation()
- )
-
- logger.info(
- f"SessionAgent started (aggregation interval: {self.aggregation_interval}s)"
- )
+ # Only start periodic aggregation if enabled
+ if self.enable_periodic_aggregation:
+ self.aggregation_task = asyncio.create_task(
+ self._periodic_session_aggregation()
+ )
+ logger.info(
+ f"SessionAgent started with periodic aggregation (interval: {self.aggregation_interval}s)"
+ )
+ else:
+ logger.info(
+ "SessionAgent started in Pomodoro-only mode (periodic aggregation disabled)"
+ )
async def stop(self):
"""Stop the session agent"""
@@ -306,6 +315,17 @@ async def _get_unaggregated_events(
filtered_count += 1
continue
+ # Skip Pomodoro events (handled by work phase aggregation)
+ # These events are processed when each Pomodoro work phase ends
+ if event.get("pomodoro_session_id"):
+ filtered_count += 1
+ logger.debug(
+ f"Skipping Pomodoro event {event.get('id')} "
+ f"(session: {event.get('pomodoro_session_id')}) - "
+ f"handled by work phase aggregation"
+ )
+ continue
+
# Quality filter 1: Check minimum number of actions
source_action_ids = event.get("source_action_ids", [])
if len(source_action_ids) < self.min_event_actions:
@@ -487,6 +507,15 @@ async def _cluster_events_to_sessions(
f"After overlap merging: {len(activities)} activities"
)
+ # CRITICAL: Final validation to ensure no time overlaps
+ is_valid, overlap_errors = self._validate_no_time_overlap(activities)
+ if not is_valid:
+ logger.error(
+ "Time overlap validation FAILED in event clustering:\n" +
+ "\n".join(overlap_errors) +
+ "\nThis indicates overlap detection algorithm may need adjustment"
+ )
+
# Validate with supervisor, passing original events for semantic validation
activities = await self._validate_activities_with_supervisor(
activities, events
@@ -502,6 +531,7 @@ async def _validate_activities_with_supervisor(
self,
activities: List[Dict[str, Any]],
source_events: Optional[List[Dict[str, Any]]] = None,
+ source_actions: Optional[List[Dict[str, Any]]] = None,
max_iterations: int = 3,
) -> List[Dict[str, Any]]:
"""
@@ -509,7 +539,8 @@ async def _validate_activities_with_supervisor(
Args:
activities: List of activities to validate
- source_events: Optional list of all source events for semantic validation
+ source_events: Optional list of all source events for semantic validation (deprecated)
+ source_actions: Optional list of all source actions for semantic and temporal validation (preferred)
max_iterations: Maximum number of validation iterations (default: 3)
Returns:
@@ -540,9 +571,39 @@ async def _validate_activities_with_supervisor(
for activity in current_activities
]
- # Build event mapping for semantic validation
+ # Build action/event mapping for semantic and temporal validation
+ # Prefer actions over events (action-based aggregation)
+ actions_for_validation = None
events_for_validation = None
- if source_events:
+
+ if source_actions:
+ # Create a mapping of action IDs to actions for lookup
+ action_map = {action.get("id"): action for action in source_actions if action.get("id")}
+
+ # For each activity, collect its source actions
+ actions_for_validation = []
+ for activity in current_activities:
+ source_action_ids = activity.get("source_action_ids", [])
+ activity_actions = []
+ for action_id in source_action_ids:
+ if action_id in action_map:
+ activity_actions.append(action_map[action_id])
+
+ # Add all actions (we'll pass them all and let supervisor map them)
+ actions_for_validation.extend(activity_actions)
+
+ # Remove duplicates while preserving order
+ seen_ids = set()
+ unique_actions = []
+ for action in actions_for_validation:
+ action_id = action.get("id")
+ if action_id and action_id not in seen_ids:
+ seen_ids.add(action_id)
+ unique_actions.append(action)
+ actions_for_validation = unique_actions
+
+ elif source_events:
+ # Fallback to events for backward compatibility
# Create a mapping of event IDs to events for lookup
event_map = {event.get("id"): event for event in source_events if event.get("id")}
@@ -568,9 +629,11 @@ async def _validate_activities_with_supervisor(
unique_events.append(event)
events_for_validation = unique_events
- # Validate with source events
+ # Validate with source actions (preferred) or events (fallback)
result = await supervisor.validate(
- activities_for_validation, source_events=events_for_validation
+ activities_for_validation,
+ source_events=events_for_validation,
+ source_actions=actions_for_validation
)
# Check if we have revised content
@@ -710,6 +773,8 @@ def _merge_overlapping_activities(
"""
Detect and merge overlapping activities to prevent duplicate time consumption
+ Enhanced algorithm that handles all overlap cases including nested and multi-way overlaps.
+
Args:
activities: List of activity dictionaries
@@ -725,133 +790,213 @@ def _merge_overlapping_activities(
key=lambda a: a.get("start_time") or datetime.min
)
- merged: List[Dict[str, Any]] = []
- current = sorted_activities[0].copy()
+ # Iterative merging until no more overlaps found
+ max_iterations = 10 # Prevent infinite loop
+ iteration = 0
- for i in range(1, len(sorted_activities)):
- next_activity = sorted_activities[i]
+ while iteration < max_iterations:
+ iteration += 1
+ merged_any = False
+ merged: List[Dict[str, Any]] = []
+ skip_indices: set = set()
- # Check for time overlap or proximity
- current_end = current.get("end_time")
- next_start = next_activity.get("start_time")
-
- should_merge = False
- merge_reason = ""
+ for i in range(len(sorted_activities)):
+ if i in skip_indices:
+ continue
- if current_end and next_start:
- # Convert to datetime if needed
- if isinstance(current_end, str):
- current_end = datetime.fromisoformat(current_end)
- if isinstance(next_start, str):
- next_start = datetime.fromisoformat(next_start)
+ current = sorted_activities[i]
+ current_start = self._parse_datetime(current.get("start_time"))
+ current_end = self._parse_datetime(current.get("end_time"))
- # Calculate time gap between activities
- time_gap = (next_start - current_end).total_seconds()
+ if not current_start or not current_end:
+ merged.append(current)
+ continue
- # Case 1: Direct time overlap (original logic)
- if next_start < current_end:
- should_merge = True
- merge_reason = "time_overlap"
+ # Check all remaining activities for overlap
+ merged_with = []
+ for j in range(i + 1, len(sorted_activities)):
+ if j in skip_indices:
+ continue
- # Case 2: Adjacent or small gap with semantic similarity
- elif 0 <= time_gap <= self.merge_time_gap_tolerance:
- # Calculate semantic similarity
- similarity = self._calculate_activity_similarity(current, next_activity)
+ next_activity = sorted_activities[j]
+ next_start = self._parse_datetime(next_activity.get("start_time"))
+ next_end = self._parse_datetime(next_activity.get("end_time"))
- if similarity >= self.merge_similarity_threshold:
- should_merge = True
- merge_reason = f"proximity_similarity (gap: {time_gap:.0f}s, similarity: {similarity:.2f})"
+ if not next_start or not next_end:
+ continue
- # Perform merge if criteria met
- if should_merge:
- logger.debug(
- f"Merging activities (reason: {merge_reason}): '{current.get('title')}' and '{next_activity.get('title')}'"
+ # Check for time overlap or proximity
+ should_merge, merge_reason = self._should_merge_activities(
+ current_start, current_end, current,
+ next_start, next_end, next_activity
)
- # Merge source_event_ids (remove duplicates)
- current_events = set(current.get("source_event_ids", []))
- next_events = set(next_activity.get("source_event_ids", []))
- merged_events = list(current_events | next_events)
-
- # Update end_time to the latest
- next_end = next_activity.get("end_time")
- if isinstance(next_end, str):
- next_end = datetime.fromisoformat(next_end)
- if next_end and next_end > current_end:
- current["end_time"] = next_end
-
- # Merge topic_tags
- current_tags = set(current.get("topic_tags", []))
- next_tags = set(next_activity.get("topic_tags", []))
- merged_tags = list(current_tags | next_tags)
-
- # Update current with merged data
- current["source_event_ids"] = merged_events
- current["topic_tags"] = merged_tags
-
- # Merge titles and descriptions based on duration
- # Calculate durations to determine primary activity
- current_start = current.get("start_time")
- if isinstance(current_start, str):
- current_start = datetime.fromisoformat(current_start)
- next_start_dt = next_activity.get("start_time")
- if isinstance(next_start_dt, str):
- next_start_dt = datetime.fromisoformat(next_start_dt)
-
- current_duration = (current_end - current_start).total_seconds() if current_start and current_end else 0
- next_duration = (next_end - next_start_dt).total_seconds() if next_start_dt and next_end else 0
-
- current_title = current.get("title", "")
- next_title = next_activity.get("title", "")
- current_desc = current.get("description", "")
- next_desc = next_activity.get("description", "")
-
- # Select title from the longer-duration activity (primary activity)
- if next_title and next_title != current_title:
- if next_duration > current_duration:
- # Next activity is primary, use its title
- logger.debug(
- f"Selected '{next_title}' as primary (duration: {next_duration:.0f}s > {current_duration:.0f}s)"
- )
- current["title"] = next_title
- # Add current as secondary context in description if needed
- if current_desc and current_title:
- current["description"] = f"{next_desc}\n\n[Related: {current_title}]\n{current_desc}" if next_desc else current_desc
- elif next_desc:
- current["description"] = next_desc
- else:
- # Current activity is primary, keep its title
- logger.debug(
- f"Kept '{current_title}' as primary (duration: {current_duration:.0f}s >= {next_duration:.0f}s)"
- )
- # Keep current title, add next as secondary context
- if next_desc and next_title:
- if current_desc:
- current["description"] = f"{current_desc}\n\n[Related: {next_title}]\n{next_desc}"
- else:
- current["description"] = next_desc
- # If only next has description, use it
- elif next_desc and not current_desc:
- current["description"] = next_desc
- else:
- # Same title or one is empty, just merge descriptions
- if next_desc and next_desc != current_desc:
- if current_desc:
- current["description"] = f"{current_desc}\n\n{next_desc}"
- else:
- current["description"] = next_desc
+ if should_merge:
+ logger.debug(
+ f"Iteration {iteration}: Merging activities (reason: {merge_reason}): "
+ f"'{current.get('title')}' ({current_start.strftime('%H:%M')}-{current_end.strftime('%H:%M')}) and "
+ f"'{next_activity.get('title')}' ({next_start.strftime('%H:%M')}-{next_end.strftime('%H:%M')})"
+ )
+ merged_with.append(j)
+ skip_indices.add(j)
+ merged_any = True
+
+ # Merge all overlapping activities into current
+ if merged_with:
+ for j in merged_with:
+ current = self._merge_two_activities(
+ current, sorted_activities[j], f"overlap_iter_{iteration}"
+ )
- logger.debug(
- f"Merged into: '{current.get('title')}' with {len(merged_events)} events"
- )
- continue
+ merged.append(current)
+
+ # Prepare for next iteration
+ sorted_activities = merged
+
+ # Exit if no merges happened in this iteration
+ if not merged_any:
+ logger.debug(f"Overlap merging converged after {iteration} iterations")
+ break
+
+ if iteration >= max_iterations:
+ logger.warning(f"Overlap merging reached max iterations ({max_iterations})")
- # No overlap, save current and move to next
- merged.append(current)
- current = next_activity.copy()
+ return sorted_activities
- # Don't forget the last activity
- merged.append(current)
+ def _parse_datetime(self, dt: Any) -> Optional[datetime]:
+ """Parse datetime from various formats"""
+ if isinstance(dt, datetime):
+ return dt
+ elif isinstance(dt, str):
+ try:
+ return datetime.fromisoformat(dt)
+ except (ValueError, TypeError):
+ return None
+ return None
+
+ def _should_merge_activities(
+ self,
+ start1: datetime, end1: datetime, activity1: Dict[str, Any],
+ start2: datetime, end2: datetime, activity2: Dict[str, Any]
+ ) -> tuple[bool, str]:
+ """
+ Determine if two activities should be merged
+
+ Returns:
+ Tuple of (should_merge, merge_reason)
+ """
+ # Case 1: Complete overlap (one contains the other)
+ if (start1 <= start2 and end1 >= end2) or (start2 <= start1 and end2 >= end1):
+ return True, "complete_overlap"
+
+ # Case 2: Partial overlap
+ if (start1 <= start2 < end1) or (start2 <= start1 < end2):
+ return True, "partial_overlap"
+
+ # Case 3: Adjacent or small gap with semantic similarity
+ # Calculate time gap (positive = gap, negative = overlap)
+ if start2 > end1:
+ time_gap = (start2 - end1).total_seconds()
+ else:
+ time_gap = (start1 - end2).total_seconds()
+
+ if 0 <= time_gap <= self.merge_time_gap_tolerance:
+ similarity = self._calculate_activity_similarity(activity1, activity2)
+ if similarity >= self.merge_similarity_threshold:
+ return True, f"proximity_similarity (gap: {time_gap:.0f}s, similarity: {similarity:.2f})"
+
+ return False, ""
+
+ def _merge_two_activities(
+ self,
+ activity1: Dict[str, Any],
+ activity2: Dict[str, Any],
+ merge_reason: str
+ ) -> Dict[str, Any]:
+ """
+ Merge two activities into one
+
+ Args:
+ activity1: First activity (will be the base)
+ activity2: Second activity (will be merged into first)
+ merge_reason: Reason for merge
+
+ Returns:
+ Merged activity
+ """
+ # Parse timestamps
+ start1 = self._parse_datetime(activity1.get("start_time")) or datetime.min
+ end1 = self._parse_datetime(activity1.get("end_time")) or datetime.min
+ start2 = self._parse_datetime(activity2.get("start_time")) or datetime.min
+ end2 = self._parse_datetime(activity2.get("end_time")) or datetime.min
+
+ # Merge time range
+ merged_start = min(start1, start2)
+ merged_end = max(end1, end2)
+
+ # Merge source_event_ids or source_action_ids
+ events1 = set(activity1.get("source_event_ids", []))
+ events2 = set(activity2.get("source_event_ids", []))
+ actions1 = set(activity1.get("source_action_ids", []))
+ actions2 = set(activity2.get("source_action_ids", []))
+
+ merged_events = list(events1 | events2) if events1 or events2 else None
+ merged_actions = list(actions1 | actions2) if actions1 or actions2 else None
+
+ # Merge topic_tags
+ tags1 = set(activity1.get("topic_tags", []))
+ tags2 = set(activity2.get("topic_tags", []))
+ merged_tags = list(tags1 | tags2)
+
+ # Determine primary activity based on duration
+ duration1 = (end1 - start1).total_seconds()
+ duration2 = (end2 - start2).total_seconds()
+
+ if duration2 > duration1:
+ primary = activity2
+ secondary = activity1
+ else:
+ primary = activity1
+ secondary = activity2
+
+ # Merge titles and descriptions
+ title = primary.get("title", "")
+ description = primary.get("description", "")
+
+ # Add secondary activity context if titles differ
+ secondary_title = secondary.get("title", "")
+ secondary_desc = secondary.get("description", "")
+
+ if secondary_title and secondary_title != title:
+ if secondary_desc:
+ description = f"{description}\n\n[Related: {secondary_title}]\n{secondary_desc}" if description else secondary_desc
+ elif secondary_desc and secondary_desc != description:
+ description = f"{description}\n\n{secondary_desc}" if description else secondary_desc
+
+ # Calculate duration
+ duration_minutes = int((merged_end - merged_start).total_seconds() / 60)
+
+ # Build merged activity
+ merged = {
+ "id": activity1.get("id", str(uuid.uuid4())),
+ "title": title,
+ "description": description,
+ "start_time": merged_start,
+ "end_time": merged_end,
+ "session_duration_minutes": duration_minutes,
+ "topic_tags": merged_tags,
+ }
+
+ # Add source IDs
+ if merged_events:
+ merged["source_event_ids"] = merged_events
+ if merged_actions:
+ merged["source_action_ids"] = merged_actions
+
+ # Preserve other fields from primary activity
+ for key in ["pomodoro_session_id", "pomodoro_work_phase", "focus_score", "created_at"]:
+ if key in primary:
+ merged[key] = primary[key]
return merged
@@ -1317,9 +1462,1035 @@ def get_stats(self) -> Dict[str, Any]:
"""Get statistics information"""
return {
"is_running": self.is_running,
+ "enable_periodic_aggregation": self.enable_periodic_aggregation,
"aggregation_interval": self.aggregation_interval,
"time_window_min": self.time_window_min,
"time_window_max": self.time_window_max,
"language": self._get_language(),
"stats": self.stats.copy(),
}
+
+ # ========== Pomodoro Work Phase Aggregation Methods ==========
+
+ async def aggregate_work_phase(
+ self,
+ session_id: str,
+ work_phase: int,
+ phase_start_time: datetime,
+ phase_end_time: datetime,
+ ) -> List[Dict[str, Any]]:
+ """
+ Aggregate actions from a single Pomodoro work phase into activities
+
+ NEW: Direct Actions → Activities aggregation (NO Events layer)
+
+ This method is triggered when a Pomodoro work phase ends (work → break transition).
+ It creates activities specifically for that work phase, with intelligent merging
+ with activities from previous work phases in the same session.
+
+ Args:
+ session_id: Pomodoro session ID
+ work_phase: Work phase number (1-based, e.g., 1, 2, 3, 4)
+ phase_start_time: When this work phase started
+ phase_end_time: When this work phase ended
+
+ Returns:
+ List of created/updated activity dictionaries
+ """
+ try:
+ logger.info(
+ f"Starting work phase aggregation (ACTION-BASED): session={session_id}, "
+ f"phase={work_phase}, duration={(phase_end_time - phase_start_time).total_seconds() / 60:.1f}min"
+ )
+
+ # Step 1: Get actions directly for this work phase (NO WAITING for events)
+ actions = await self._get_work_phase_actions(
+ session_id, phase_start_time, phase_end_time
+ )
+
+ if not actions:
+ logger.warning(
+ f"No actions found for work phase {work_phase}. "
+ f"User may have been idle during this phase."
+ )
+ return []
+
+ logger.debug(
+ f"Found {len(actions)} actions for work phase {work_phase} "
+ f"(session: {session_id})"
+ )
+
+ # Step 2: Cluster actions into activities using NEW LLM prompt
+ activities = await self._cluster_actions_to_activities(actions)
+
+ if not activities:
+ logger.debug(
+ f"No activities generated from action clustering for work phase {work_phase}"
+ )
+ return []
+
+ # Step 2.3: Filter out short-duration activities
+ # RELAXED THRESHOLD for Pomodoro work phases (use 1 min instead of 2 min)
+ # Pomodoro sessions are already 25-minute focused work, so we trust shorter activities
+ activities = self._filter_activities_by_duration(activities, min_duration_minutes=1)
+
+ if not activities:
+ logger.debug(
+ f"No activities remaining after duration filtering for work phase {work_phase}"
+ )
+ return []
+
+ # Step 2.5: Validate activities with supervisor (check temporal continuity and semantic accuracy)
+ activities = await self._validate_activities_with_supervisor(
+ activities, source_actions=actions
+ )
+
+ # Step 2.7: CRITICAL - Final validation to ensure no time overlaps
+ is_valid, overlap_errors = self._validate_no_time_overlap(activities)
+ if not is_valid:
+ logger.error(
+ f"Time overlap validation FAILED for work phase {work_phase}:\n" +
+ "\n".join(overlap_errors) +
+ "\nForcing merge of all overlapping activities..."
+ )
+ # Force merge overlapping activities
+ activities = self._merge_overlapping_activities(activities)
+
+ # Re-validate after forced merge
+ is_valid, overlap_errors = self._validate_no_time_overlap(activities)
+ if not is_valid:
+ logger.error(
+ "Time overlap still exists after forced merge:\n" +
+ "\n".join(overlap_errors) +
+ "\nThis should not happen - keeping activities as-is but logging critical error"
+ )
+ else:
+ logger.info("Successfully resolved all time overlaps after forced merge")
+
+ # Step 3: Get existing activities from this session (previous work phases)
+ existing_session_activities = await self._get_session_activities(session_id)
+
+ # Step 4: Merge with existing activities from same session (relaxed threshold)
+ activities_to_save, activities_to_update = await self._merge_within_session(
+ new_activities=activities,
+ existing_activities=existing_session_activities,
+ session_id=session_id,
+ )
+
+ # Step 5: Evaluate focus scores using LLM in parallel, then save activities
+ # Get focus evaluator
+ focus_evaluator = get_focus_evaluator()
+
+ # Get session context (user intent and related todos) for better evaluation
+ session_context = await self._get_session_context(session_id)
+
+ # Batch evaluate all activities in parallel
+ logger.info(
+ f"Starting parallel LLM focus evaluation for {len(activities_to_save)} activities"
+ )
+ eval_tasks = [
+ focus_evaluator.evaluate_activity_focus(
+ act, session_context=session_context
+ )
+ for act in activities_to_save
+ ]
+ eval_results = await asyncio.gather(*eval_tasks, return_exceptions=True)
+
+ # Process results with error handling and save activities
+ saved_activities = []
+ for activity, eval_result in zip(activities_to_save, eval_results):
+ # Add pomodoro metadata
+ activity["pomodoro_session_id"] = session_id
+ activity["pomodoro_work_phase"] = work_phase
+ activity["aggregation_mode"] = "action_based"
+
+ # Process LLM evaluation result with fallback
+ if isinstance(eval_result, Exception):
+ # Fallback to algorithm on error
+ logger.warning(
+ f"LLM evaluation failed for activity '{activity.get('title', 'Untitled')}': {eval_result}. "
+ "Falling back to algorithm-based scoring."
+ )
+ activity["focus_score"] = self._calculate_focus_score_from_actions(
+ activity
+ )
+ else:
+ # Use LLM evaluation score (0-100 scale)
+ activity["focus_score"] = eval_result.get("focus_score", 50)
+ logger.debug(
+ f"LLM focus score for '{activity.get('title', 'Untitled')[:50]}': "
+ f"{activity['focus_score']} (reasoning: {eval_result.get('reasoning', '')[:100]})"
+ )
+
+ # Save to database with ACTION sources
+ await self.db.activities.save(
+ activity_id=activity["id"],
+ title=activity["title"],
+ description=activity["description"],
+ start_time=(
+ activity["start_time"].isoformat()
+ if isinstance(activity["start_time"], datetime)
+ else activity["start_time"]
+ ),
+ end_time=(
+ activity["end_time"].isoformat()
+ if isinstance(activity["end_time"], datetime)
+ else activity["end_time"]
+ ),
+ source_event_ids=None, # NOT USED in action-based mode
+ source_action_ids=activity["source_action_ids"], # NEW: action IDs
+ aggregation_mode="action_based", # NEW FLAG
+ session_duration_minutes=activity.get("session_duration_minutes"),
+ topic_tags=activity.get("topic_tags", []),
+ pomodoro_session_id=activity.get("pomodoro_session_id"),
+ pomodoro_work_phase=activity.get("pomodoro_work_phase"),
+ focus_score=activity.get("focus_score"),
+ )
+
+ # NO NEED to mark events as aggregated (we're bypassing events)
+
+ saved_activities.append(activity)
+ logger.debug(
+ f"Created activity '{activity['title']}' for work phase {work_phase} "
+ f"(focus_score: {activity['focus_score']:.2f}, actions: {len(activity['source_action_ids'])})"
+ )
+
+ # Step 6: Update existing activities
+ for update_data in activities_to_update:
+ await self.db.activities.save(
+ activity_id=update_data["id"],
+ title=update_data["title"],
+ description=update_data["description"],
+ start_time=(
+ update_data["start_time"].isoformat()
+ if isinstance(update_data["start_time"], datetime)
+ else update_data["start_time"]
+ ),
+ end_time=(
+ update_data["end_time"].isoformat()
+ if isinstance(update_data["end_time"], datetime)
+ else update_data["end_time"]
+ ),
+ source_event_ids=None,
+ source_action_ids=update_data["source_action_ids"],
+ aggregation_mode="action_based",
+ session_duration_minutes=update_data.get("session_duration_minutes"),
+ topic_tags=update_data.get("topic_tags", []),
+ pomodoro_session_id=update_data.get("pomodoro_session_id"),
+ pomodoro_work_phase=update_data.get("pomodoro_work_phase"),
+ focus_score=update_data.get("focus_score"),
+ )
+
+ # NO NEED to mark events as aggregated (action-based mode)
+
+ saved_activities.append(update_data)
+ logger.debug(
+ f"Updated existing activity '{update_data['title']}' with new actions "
+ f"(merge reason: {update_data.get('_merge_reason', 'unknown')})"
+ )
+
+ logger.info(
+ f"Work phase {work_phase} aggregation completed (ACTION-BASED): "
+ f"{len(activities_to_save)} new activities, {len(activities_to_update)} updated"
+ )
+
+ return saved_activities
+
+ except Exception as e:
+ logger.error(
+ f"Failed to aggregate work phase {work_phase} for session {session_id}: {e}",
+ exc_info=True,
+ )
+ return []
+
+ async def _get_work_phase_events(
+ self,
+ session_id: str,
+ start_time: datetime,
+ end_time: datetime,
+ max_retries: int = 3,
+ ) -> List[Dict[str, Any]]:
+ """
+ Get events within a specific work phase time window
+
+ Includes retry mechanism to handle Action → Event aggregation delays.
+
+ Args:
+ session_id: Pomodoro session ID
+ start_time: Work phase start time
+ end_time: Work phase end time
+ max_retries: Maximum number of retries (default: 3)
+
+ Returns:
+ List of event dictionaries
+ """
+ for attempt in range(max_retries):
+ try:
+ # Get all events in this time window
+ all_events = await self.db.events.get_in_timeframe(
+ start_time.isoformat(), end_time.isoformat()
+ )
+
+ # Filter for this specific pomodoro session (if events are tagged)
+ # Note: Events may not have pomodoro_session_id if they were created
+ # before the session was tagged, so we'll filter primarily by time
+ events = [
+ event
+ for event in all_events
+ if event.get("aggregated_into_activity_id") is None
+ ]
+
+ if events:
+ logger.debug(
+ f"Found {len(events)} unaggregated events for work phase "
+ f"(attempt {attempt + 1}/{max_retries})"
+ )
+ return events
+
+ # No events found, wait and retry
+ if attempt < max_retries - 1:
+ logger.debug(
+ f"No events found for work phase yet, retrying in 5s "
+ f"(attempt {attempt + 1}/{max_retries})"
+ )
+ await asyncio.sleep(5)
+
+ except Exception as e:
+ logger.error(
+ f"Error fetching work phase events (attempt {attempt + 1}): {e}",
+ exc_info=True,
+ )
+ if attempt < max_retries - 1:
+ await asyncio.sleep(5)
+
+ # All retries exhausted
+ logger.warning(
+ f"No events found for work phase after {max_retries} attempts. "
+ f"Time window: {start_time.isoformat()} to {end_time.isoformat()}"
+ )
+ return []
+
+ async def _get_session_activities(
+ self, session_id: str
+ ) -> List[Dict[str, Any]]:
+ """
+ Get all activities associated with a Pomodoro session
+
+ Args:
+ session_id: Pomodoro session ID
+
+ Returns:
+ List of activity dictionaries
+ """
+ try:
+ # Query activities by pomodoro_session_id
+ # This will be implemented in activities repository
+ activities = await self.db.activities.get_by_pomodoro_session(session_id)
+ logger.debug(
+ f"Found {len(activities)} existing activities for session {session_id}"
+ )
+ return activities
+ except Exception as e:
+ logger.error(
+ f"Error fetching session activities for {session_id}: {e}",
+ exc_info=True,
+ )
+ return []
+
+ async def _get_session_context(
+ self, session_id: str
+ ) -> Dict[str, Any]:
+ """
+ Get session context including user intent and related todos
+
+ Args:
+ session_id: Pomodoro session ID
+
+ Returns:
+ Dictionary containing:
+ - user_intent: User's description of work goal (str)
+ - related_todos: List of related todo items (List[Dict])
+ """
+ try:
+ # Get session information
+ session = await self.db.pomodoro_sessions.get_by_id(session_id)
+ if not session:
+ logger.warning(f"Session {session_id} not found")
+ return {"user_intent": None, "related_todos": []}
+
+ user_intent = session.get("user_intent", "")
+ associated_todo_id = session.get("associated_todo_id")
+
+ # Get related todos
+ related_todos = []
+ if associated_todo_id:
+ # Fetch the specific associated todo
+ todo = await self.db.todos.get_by_id(associated_todo_id)
+ if todo and not todo.get("deleted", False):
+ related_todos.append(todo)
+
+ logger.debug(
+ f"Session context for {session_id}: intent='{user_intent[:50] if user_intent else 'None'}', "
+ f"related_todos={len(related_todos)}"
+ )
+
+ return {
+ "user_intent": user_intent,
+ "related_todos": related_todos,
+ }
+
+ except Exception as e:
+ logger.error(
+ f"Error fetching session context for {session_id}: {e}",
+ exc_info=True,
+ )
+ return {"user_intent": None, "related_todos": []}
+
+ def _merge_activities(
+ self,
+ existing_activity: Dict[str, Any],
+ new_activity: Dict[str, Any],
+ merge_reason: str,
+ ) -> Dict[str, Any]:
+ """
+ Merge two activities into one
+
+ Args:
+ existing_activity: The existing activity to merge into
+ new_activity: The new activity to merge
+ merge_reason: Reason for the merge
+
+ Returns:
+ Merged activity dictionary
+ """
+ # Parse timestamps
+ existing_start = (
+ existing_activity["start_time"]
+ if isinstance(existing_activity["start_time"], datetime)
+ else datetime.fromisoformat(existing_activity["start_time"])
+ )
+ existing_end = (
+ existing_activity["end_time"]
+ if isinstance(existing_activity["end_time"], datetime)
+ else datetime.fromisoformat(existing_activity["end_time"])
+ )
+ new_start = (
+ new_activity["start_time"]
+ if isinstance(new_activity["start_time"], datetime)
+ else datetime.fromisoformat(new_activity["start_time"])
+ )
+ new_end = (
+ new_activity["end_time"]
+ if isinstance(new_activity["end_time"], datetime)
+ else datetime.fromisoformat(new_activity["end_time"])
+ )
+
+ # Merge source_event_ids
+ existing_events = set(existing_activity.get("source_event_ids", []))
+ new_events = set(new_activity.get("source_event_ids", []))
+ all_events = list(existing_events | new_events)
+
+ # Update time range
+ merged_start = min(existing_start, new_start)
+ merged_end = max(existing_end, new_end) if new_end else existing_end
+
+ # Calculate new duration
+ duration_minutes = int((merged_end - merged_start).total_seconds() / 60)
+
+ # Merge topic tags
+ existing_tags = set(existing_activity.get("topic_tags", []))
+ new_tags = set(new_activity.get("topic_tags", []))
+ merged_tags = list(existing_tags | new_tags)
+
+ # Determine primary title/description based on duration
+ existing_duration = (existing_end - existing_start).total_seconds()
+ new_duration = (new_end - new_start).total_seconds() if new_end else 0
+
+ if new_duration > existing_duration:
+ # New activity is primary
+ title = new_activity.get("title", existing_activity.get("title", ""))
+ description = new_activity.get("description", "")
+ if description and existing_activity.get("description"):
+ description = f"{description}\n\n[Related: {existing_activity.get('title')}]\n{existing_activity.get('description')}"
+ elif existing_activity.get("description"):
+ description = existing_activity.get("description")
+ else:
+ # Existing activity is primary
+ title = existing_activity.get("title", "")
+ description = existing_activity.get("description", "")
+ if new_activity.get("description") and new_activity.get("title"):
+ if description:
+ description = f"{description}\n\n[Related: {new_activity.get('title')}]\n{new_activity.get('description')}"
+ else:
+ description = new_activity.get("description", "")
+
+ # Create merged activity
+ merged_activity = {
+ "id": existing_activity["id"],
+ "title": title,
+ "description": description,
+ "start_time": merged_start,
+ "end_time": merged_end,
+ "source_event_ids": all_events,
+ "session_duration_minutes": duration_minutes,
+ "topic_tags": merged_tags,
+ }
+
+ return merged_activity
+
+ async def _merge_within_session(
+ self,
+ new_activities: List[Dict[str, Any]],
+ existing_activities: List[Dict[str, Any]],
+ session_id: str,
+ ) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+ """
+ Merge new activities with existing activities from the same Pomodoro session
+
+ Uses relaxed similarity threshold compared to global merging, since activities
+ within the same Pomodoro session are more likely to be related (same user intent).
+
+ Merge conditions:
+ - Must have same pomodoro_session_id
+ - Case 1: Direct time overlap
+ - Case 2: Time gap ≤ 5 minutes AND semantic similarity ≥ 0.5 (relaxed from 0.6)
+
+ Args:
+ new_activities: Newly generated activities from current work phase
+ existing_activities: Activities from previous work phases in same session
+ session_id: Pomodoro session ID
+
+ Returns:
+ Tuple of (activities_to_save, activities_to_update)
+ - activities_to_save: New activities that don't merge with existing ones
+ - activities_to_update: Existing activities that absorbed new activities
+ """
+ if not existing_activities:
+ # No existing activities to merge with
+ return (new_activities, [])
+
+ # Relaxed similarity threshold for same-session merging
+ session_similarity_threshold = 0.5 # Lower than global threshold (0.6)
+
+ activities_to_save = []
+ activities_to_update = []
+
+ for new_activity in new_activities:
+ merged = False
+
+ # Check for merge with each existing activity
+ for existing_activity in existing_activities:
+ # Parse timestamps
+ new_start = (
+ new_activity["start_time"]
+ if isinstance(new_activity["start_time"], datetime)
+ else datetime.fromisoformat(new_activity["start_time"])
+ )
+ new_end = (
+ new_activity["end_time"]
+ if isinstance(new_activity["end_time"], datetime)
+ else datetime.fromisoformat(new_activity["end_time"])
+ )
+ existing_start = (
+ existing_activity["start_time"]
+ if isinstance(existing_activity["start_time"], datetime)
+ else datetime.fromisoformat(existing_activity["start_time"])
+ )
+ existing_end = (
+ existing_activity["end_time"]
+ if isinstance(existing_activity["end_time"], datetime)
+ else datetime.fromisoformat(existing_activity["end_time"])
+ )
+
+ # Check merge conditions
+ should_merge = False
+ merge_reason = ""
+
+ # Case 1: Time overlap
+ if new_start <= existing_end and new_end >= existing_start:
+ should_merge = True
+ merge_reason = "time_overlap"
+
+ # Case 2: Adjacent/close with semantic similarity
+ else:
+ # Calculate time gap
+ if new_start > existing_end:
+ time_gap = (new_start - existing_end).total_seconds()
+ else:
+ time_gap = (existing_start - new_end).total_seconds()
+
+ if 0 <= time_gap <= self.merge_time_gap_tolerance:
+ # Calculate semantic similarity (reuse existing method)
+ similarity = self._calculate_activity_similarity(
+ existing_activity, new_activity
+ )
+
+ if similarity >= session_similarity_threshold:
+ should_merge = True
+ merge_reason = f"session_proximity_similarity (gap: {time_gap:.0f}s, similarity: {similarity:.2f})"
+
+ if should_merge:
+ # Merge new activity into existing activity
+ merged_activity = self._merge_activities(
+ existing_activity, new_activity, merge_reason
+ )
+
+ # Track which new events were added
+ merged_activity["_new_event_ids"] = new_activity["source_event_ids"]
+ merged_activity["_merge_reason"] = merge_reason
+
+ activities_to_update.append(merged_activity)
+ merged = True
+
+ logger.debug(
+ f"Merging new activity '{new_activity['title']}' into "
+ f"existing '{existing_activity['title']}' (reason: {merge_reason})"
+ )
+ break
+
+ if not merged:
+ # No merge found, save as new activity
+ activities_to_save.append(new_activity)
+
+ return (activities_to_save, activities_to_update)
+
+ def _calculate_focus_score(self, activity: Dict[str, Any]) -> float:
+ """
+ Calculate focus score for an activity based on multiple factors
+
+ Focus score ranges from 0.0 (very unfocused) to 1.0 (highly focused).
+
+ Factors:
+ 1. Event density (30% weight): Events per minute
+ - High density (>2 events/min) → frequent task switching → lower score
+ - Low density (<0.5 events/min) → sustained work or idle → moderate/high score
+
+ 2. Topic consistency (40% weight): Number of unique topics
+ - 1 topic → highly focused on single subject → high score
+ - 2 topics → related tasks → good score
+ - 3+ topics → scattered attention → lower score
+
+ 3. Duration (30% weight): Time spent on activity
+ - >20 min → deep work session → high score
+ - 10-20 min → moderate work session → good score
+ - 5-10 min → brief focus → moderate score
+ - <5 min → very brief → low score
+
+ Args:
+ activity: Activity dictionary with source_event_ids, session_duration_minutes, topic_tags
+
+ Returns:
+ Focus score between 0.0 and 1.0
+ """
+ score = 1.0
+
+ # Factor 1: Event density (30% weight)
+ event_count = len(activity.get("source_event_ids", []))
+ duration_minutes = activity.get("session_duration_minutes", 1)
+
+ if duration_minutes > 0:
+ events_per_minute = event_count / duration_minutes
+
+ if events_per_minute > 2.0:
+ # Too many events per minute → frequent switching
+ score *= 0.7
+ elif events_per_minute < 0.5:
+ # Very few events → either deep focus or idle time
+ # Slightly penalize to account for possible idle time
+ score *= 0.95
+ # else: 0.5-2.0 events/min is normal working pace, no adjustment
+
+ # Factor 2: Topic consistency (40% weight)
+ topic_count = len(activity.get("topic_tags", []))
+
+ if topic_count == 0:
+ # No topics identified → unclear focus
+ score *= 0.8
+ elif topic_count == 1:
+ # Single topic → highly focused
+ score *= 1.0
+ elif topic_count == 2:
+ # Two related topics → good focus
+ score *= 0.9
+ else:
+ # Multiple topics → scattered attention
+ score *= 0.7
+
+ # Factor 3: Duration (30% weight)
+ if duration_minutes > 20:
+ # Deep work session
+ score *= 1.0
+ elif duration_minutes > 10:
+ # Moderate work session
+ score *= 0.8
+ elif duration_minutes > 5:
+ # Brief focus period
+ score *= 0.6
+ else:
+ # Very brief activity
+ score *= 0.4
+
+ # Ensure score stays within bounds
+ final_score = min(1.0, max(0.0, score))
+
+ return round(final_score, 2)
+
+ async def _get_work_phase_actions(
+ self,
+ session_id: str,
+ start_time: datetime,
+ end_time: datetime,
+ ) -> List[Dict[str, Any]]:
+ """
+ Get actions within a specific work phase time window
+
+ Args:
+ session_id: Pomodoro session ID (not used for filtering, as actions don't have session_id)
+ start_time: Work phase start time
+ end_time: Work phase end time
+
+ Returns:
+ List of action dictionaries
+ """
+ try:
+ # Get all actions in this time window
+ actions = await self.db.actions.get_in_timeframe(
+ start_time.isoformat(), end_time.isoformat()
+ )
+
+ logger.debug(
+ f"Found {len(actions)} actions for work phase "
+ f"({start_time.isoformat()} to {end_time.isoformat()})"
+ )
+
+ return actions
+
+ except Exception as e:
+ logger.error(
+ f"Error fetching work phase actions: {e}",
+ exc_info=True,
+ )
+ return []
+
+ async def _cluster_actions_to_activities(
+ self, actions: List[Dict[str, Any]]
+ ) -> List[Dict[str, Any]]:
+ """
+ Use LLM to cluster actions into activity-level work sessions
+
+ Uses the new 'action_aggregation' prompt (not 'session_aggregation')
+
+ Args:
+ actions: List of action dictionaries
+
+ Returns:
+ List of activity dictionaries with source_action_ids
+ """
+ if not actions:
+ return []
+
+ try:
+ logger.debug(f"Clustering {len(actions)} actions into activities (ACTION-BASED)")
+
+ # Build actions JSON with index
+ actions_with_index = [
+ {
+ "index": i + 1,
+ "title": action.get("title", ""),
+ "description": action.get("description", ""),
+ "timestamp": action.get("timestamp", ""),
+ }
+ for i, action in enumerate(actions)
+ ]
+ actions_json = json.dumps(actions_with_index, ensure_ascii=False, indent=2)
+
+ # Get current language and prompt manager
+ language = self._get_language()
+ from llm.prompt_manager import get_prompt_manager
+
+ prompt_manager = get_prompt_manager(language)
+
+ # Build messages using NEW prompt
+ messages = prompt_manager.build_messages(
+ "action_aggregation", # NEW PROMPT CATEGORY
+ "user_prompt_template",
+ actions_json=actions_json,
+ )
+
+ # Get configuration parameters
+ config_params = prompt_manager.get_config_params("action_aggregation")
+
+ # Call LLM
+ response = await self.llm_manager.chat_completion(messages, **config_params)
+ content = response.get("content", "").strip()
+
+ # Parse JSON (already imported at top of file)
+ result = parse_json_from_response(content)
+
+ if not isinstance(result, dict):
+ logger.warning(
+ f"Action clustering result format error: {content[:200]}"
+ )
+ return []
+
+ activities_data = result.get("activities", [])
+
+ # Convert to complete activity objects
+ activities = []
+ for activity_data in activities_data:
+ # Normalize source indexes
+ normalized_indexes = self._normalize_source_indexes(
+ activity_data.get("source"), len(actions)
+ )
+
+ if not normalized_indexes:
+ continue
+
+ source_action_ids: List[str] = []
+ source_actions: List[Dict[str, Any]] = []
+ for idx in normalized_indexes:
+ action = actions[idx - 1]
+ action_id = action.get("id")
+ if action_id:
+ source_action_ids.append(action_id)
+ source_actions.append(action)
+
+ if not source_actions:
+ continue
+
+ # Get timestamps from ACTIONS (not events)
+ start_time = None
+ end_time = None
+ for a in source_actions:
+ timestamp = a.get("timestamp")
+ if timestamp:
+ if isinstance(timestamp, str):
+ timestamp = datetime.fromisoformat(timestamp)
+ if start_time is None or timestamp < start_time:
+ start_time = timestamp
+ if end_time is None or timestamp > end_time:
+ end_time = timestamp
+
+ if not start_time:
+ start_time = datetime.now()
+ if not end_time:
+ end_time = start_time
+
+ # Calculate duration
+ duration_seconds = (end_time - start_time).total_seconds()
+
+ # CRITICAL FIX: For single-action activities, use minimum duration
+ # This prevents activities from being filtered out due to zero duration
+ if len(source_actions) == 1 and duration_seconds < 60:
+ # Single action represents a meaningful work moment
+ # Use 5 minutes as reasonable default duration
+ duration_minutes = 5
+ logger.debug(
+ f"Single-action activity: using default duration of 5min "
+ f"(original: {duration_seconds:.1f}s)"
+ )
+ else:
+ duration_minutes = int(duration_seconds / 60)
+
+ # Extract topic tags from LLM response
+ topic_tags = activity_data.get("topic_tags", [])
+
+ activity = {
+ "id": str(uuid.uuid4()),
+ "title": activity_data.get("title", "Unnamed activity"),
+ "description": activity_data.get("description", ""),
+ "start_time": start_time,
+ "end_time": end_time,
+ "source_action_ids": source_action_ids, # NEW: action IDs instead of event IDs
+ "topic_tags": topic_tags,
+ "session_duration_minutes": duration_minutes,
+ "created_at": datetime.now(),
+ }
+
+ activities.append(activity)
+
+ logger.debug(
+ f"Clustering completed: generated {len(activities)} activities from {len(actions)} actions"
+ )
+
+ return activities
+
+ except Exception as e:
+ logger.error(
+ f"Failed to cluster actions to activities: {e}", exc_info=True
+ )
+ return []
+
+ def _filter_activities_by_duration(
+ self, activities: List[Dict[str, Any]], min_duration_minutes: int = 2
+ ) -> List[Dict[str, Any]]:
+ """
+ Filter out activities with duration less than min_duration_minutes
+
+ Args:
+ activities: List of activities to filter
+ min_duration_minutes: Minimum duration in minutes (default: 2)
+
+ Returns:
+ Filtered list of activities
+ """
+ if not activities:
+ return activities
+
+ filtered_activities = []
+ filtered_count = 0
+
+ for activity in activities:
+ # Calculate duration
+ start_time = activity.get("start_time")
+ end_time = activity.get("end_time")
+
+ if not start_time or not end_time:
+ # No time info, keep it
+ filtered_activities.append(activity)
+ continue
+
+ # Convert to datetime if needed
+ if isinstance(start_time, str):
+ start_time = datetime.fromisoformat(start_time)
+ if isinstance(end_time, str):
+ end_time = datetime.fromisoformat(end_time)
+
+ # Calculate duration in minutes
+ duration_minutes = (end_time - start_time).total_seconds() / 60
+
+ if duration_minutes >= min_duration_minutes:
+ filtered_activities.append(activity)
+ else:
+ filtered_count += 1
+ logger.debug(
+ f"Filtered out short activity '{activity.get('title', 'Unnamed')}' "
+ f"(duration: {duration_minutes:.1f}min < {min_duration_minutes}min)"
+ )
+
+ if filtered_count > 0:
+ logger.info(
+ f"Filtered out {filtered_count} activities with duration < {min_duration_minutes} minutes"
+ )
+
+ return filtered_activities
+
+ def _validate_no_time_overlap(
+ self, activities: List[Dict[str, Any]]
+ ) -> tuple[bool, List[str]]:
+ """
+ Final validation to ensure no time overlaps exist in activities
+
+ Args:
+ activities: List of activities to validate
+
+ Returns:
+ Tuple of (is_valid, list of error messages)
+ """
+ if len(activities) <= 1:
+ return True, []
+
+ errors = []
+
+ # Check all pairs for overlap
+ for i in range(len(activities)):
+ activity1 = activities[i]
+ start1 = self._parse_datetime(activity1.get("start_time"))
+ end1 = self._parse_datetime(activity1.get("end_time"))
+
+ if not start1 or not end1:
+ continue
+
+ for j in range(i + 1, len(activities)):
+ activity2 = activities[j]
+ start2 = self._parse_datetime(activity2.get("start_time"))
+ end2 = self._parse_datetime(activity2.get("end_time"))
+
+ if not start2 or not end2:
+ continue
+
+ # Check for any time overlap
+ if (start1 <= start2 < end1) or (start2 <= start1 < end2) or \
+ (start1 <= start2 and end1 >= end2) or (start2 <= start1 and end2 >= end1):
+ error_msg = (
+ f"Time overlap detected: "
+ f"Activity '{activity1.get('title', 'Untitled')}' "
+ f"({start1.strftime('%H:%M')}-{end1.strftime('%H:%M')}) "
+ f"overlaps with "
+ f"Activity '{activity2.get('title', 'Untitled')}' "
+ f"({start2.strftime('%H:%M')}-{end2.strftime('%H:%M')})"
+ )
+ errors.append(error_msg)
+
+ return len(errors) == 0, errors
+
+ def _calculate_focus_score_from_actions(self, activity: Dict[str, Any]) -> float:
+ """
+ Calculate focus score for an ACTION-BASED activity
+
+ Similar to _calculate_focus_score() but uses actions instead of events
+
+ Focus score factors:
+ 1. Action density (30% weight): Actions per minute
+ 2. Topic consistency (40% weight): Number of unique topics
+ 3. Duration (30% weight): Time spent on activity
+
+ Args:
+ activity: Activity dictionary with source_action_ids
+
+ Returns:
+ Focus score between 0.0 and 1.0
+ """
+ score = 1.0
+
+ # Factor 1: Action density (30% weight)
+ action_count = len(activity.get("source_action_ids", []))
+ duration_minutes = activity.get("session_duration_minutes", 1)
+
+ if duration_minutes > 0:
+ actions_per_minute = action_count / duration_minutes
+
+ # Actions are finer-grained than events, so adjust thresholds
+ # Normal range: 0.5-3 actions/min (vs 0.5-2 events/min)
+ if actions_per_minute > 3.0:
+ # Too many actions per minute → frequent switching
+ score *= 0.7
+ elif actions_per_minute < 0.5:
+ # Very few actions → either deep focus or idle time
+ score *= 0.95
+ # else: 0.5-3.0 actions/min is normal working pace, no adjustment
+
+ # Factor 2: Topic consistency (40% weight)
+ topic_count = len(activity.get("topic_tags", []))
+
+ if topic_count == 0:
+ # No topics identified → unclear focus
+ score *= 0.8
+ elif topic_count == 1:
+ # Single topic → highly focused
+ score *= 1.0
+ elif topic_count == 2:
+ # Two related topics → good focus
+ score *= 0.9
+ else:
+ # Multiple topics → scattered attention
+ score *= 0.7
+
+ # Factor 3: Duration (30% weight)
+ if duration_minutes > 20:
+ # Deep work session
+ score *= 1.0
+ elif duration_minutes > 10:
+ # Moderate work session
+ score *= 0.8
+ elif duration_minutes > 5:
+ # Brief focus period
+ score *= 0.6
+ else:
+ # Very brief activity
+ score *= 0.4
+
+ # Ensure score stays within bounds
+ final_score = min(1.0, max(0.0, score))
+
+ return round(final_score, 2)
diff --git a/backend/agents/supervisor.py b/backend/agents/supervisor.py
index 91b8617..e1900b9 100644
--- a/backend/agents/supervisor.py
+++ b/backend/agents/supervisor.py
@@ -421,12 +421,14 @@ async def validate(
Args:
content: List of activity items to validate
**kwargs: Additional context
- - source_events: Optional list of source events for semantic validation
+ - source_events: Optional list of source events for semantic validation (deprecated)
+ - source_actions: Optional list of source actions for semantic and temporal validation (preferred)
Returns:
SupervisorResult with validation results
"""
source_events = kwargs.get("source_events")
+ source_actions = kwargs.get("source_actions")
if not content:
return SupervisorResult(
@@ -439,9 +441,48 @@ async def validate(
activities_json = json.dumps(content, ensure_ascii=False, indent=2, default=str)
- # Build source events section if provided
+ # Build source section (prefer actions over events)
source_events_section = ""
- if source_events:
+ if source_actions:
+ # Enrich actions with duration for better analysis
+ enriched_actions = []
+ for action in source_actions:
+ action_copy = action.copy()
+ start = action.get("start_time") or action.get("timestamp")
+ end = action.get("end_time")
+
+ if start and end:
+ # Calculate duration
+ if isinstance(start, str):
+ start = datetime.fromisoformat(start)
+ if isinstance(end, str):
+ end = datetime.fromisoformat(end)
+
+ duration = (end - start).total_seconds()
+ action_copy["duration_seconds"] = int(duration)
+ action_copy["duration_display"] = self._format_duration(duration)
+ elif start:
+ # Action with only timestamp (no end time)
+ action_copy["duration_seconds"] = 0
+ action_copy["duration_display"] = "instant"
+
+ enriched_actions.append(action_copy)
+
+ source_actions_json = json.dumps(
+ enriched_actions, ensure_ascii=False, indent=2, default=str
+ )
+ source_events_section = f"""
+【Source Actions for Semantic and Temporal Validation】
+The following are the source actions that were aggregated into the activities above.
+Each action includes its duration and timestamp. Use these to:
+1. Calculate time distribution across different themes
+2. Identify the dominant theme (most time spent)
+3. Verify that activity titles reflect the dominant theme, not minor topics
+4. **Check temporal continuity**: Calculate time gaps between actions and ensure adjacent activities have reasonable time intervals
+
+{source_actions_json}
+"""
+ elif source_events:
# Enrich events with duration for better analysis
enriched_events = []
for event in source_events:
diff --git a/backend/agents/todo_agent.py b/backend/agents/todo_agent.py
index deb434b..1639525 100644
--- a/backend/agents/todo_agent.py
+++ b/backend/agents/todo_agent.py
@@ -152,6 +152,7 @@ async def extract_todos_from_scenes(
# Calculate timestamp from scenes
todo_timestamp = self._calculate_todo_timestamp_from_scenes(scenes)
+ # AI-generated todos will have automatic expiration set in save()
await self.db.todos.save(
todo_id=todo_id,
title=todo_data.get("title", ""),
@@ -159,6 +160,7 @@ async def extract_todos_from_scenes(
keywords=todo_data.get("keywords", []),
created_at=todo_timestamp.isoformat(),
completed=todo_data.get("completed", False),
+ source_type="ai",
)
saved_count += 1
diff --git a/backend/config/config.toml b/backend/config/config.toml
index 2f33b91..0e89bc9 100644
--- a/backend/config/config.toml
+++ b/backend/config/config.toml
@@ -28,9 +28,9 @@ enable_phash = true
# Memory-first storage configuration
enable_memory_first = true # Master switch
-memory_ttl_multiplier = 2.5 # TTL = processing_interval * multiplier
+memory_ttl_multiplier = 5.0 # TTL = processing_interval * multiplier (increased for better persistence)
memory_ttl_min = 60 # Minimum TTL (seconds)
-memory_ttl_max = 120 # Maximum TTL (seconds)
+memory_ttl_max = 300 # Maximum TTL (seconds) (increased from 120 to prevent eviction during LLM processing)
# Screenshot configuration
[screenshot]
@@ -141,7 +141,8 @@ crop_threshold = 30
# Memory cache size (images)
# Description: Cache recent image base64 data in memory
# Recommendation: 200-500 (memory usage ~100-250MB)
-memory_cache_size = 500
+# Increased to 1000 for better persistence reliability (memory usage ~500-1000MB)
+memory_cache_size = 1000
# ========== Optimization Effect Estimation ==========
# Based on default configuration (aggressive + hybrid), with 20 original screenshots as example:
@@ -187,9 +188,11 @@ merge_similarity_threshold = 0.6
# Similarity threshold (0.0-1.0)
# Description: Two screenshots are considered duplicates if similarity exceeds this value
# - 0.85-0.90: Relaxed mode (retains more screenshots, suitable for fast-changing scenarios)
-# - 0.90-0.95: Standard mode (recommended, balances deduplication and information retention) ⭐
+# - 0.90-0.95: Standard mode (recommended, balances deduplication and information retention)
# - 0.95-0.98: Strict mode (aggressive deduplication, suitable for static content scenarios)
-screenshot_similarity_threshold = 0.92 # Optimized: increased from 0.90 for more aggressive deduplication
+# OPTIMIZED: Lowered from 0.92 to 0.88 for video-watching scenarios ⭐
+# This allows more content variations to be captured (e.g., video progress, UI changes)
+screenshot_similarity_threshold = 0.88
# Hash cache size
# Description: Keep hash values of the last N screenshots for comparison
@@ -210,11 +213,43 @@ screenshot_hash_algorithms = ["phash", "dhash", "average_hash"]
# - false: Disable - always use fixed threshold
enable_adaptive_threshold = true
+# ========== Static Scene Optimization ==========
+# Time-based forced processing (seconds)
+# Description: Maximum time to wait before forcing action extraction, even if screenshot threshold not reached
+# This ensures activity is captured in static scenes (reading, watching videos, thinking)
+# - 120: Aggressive (2 minutes, more LLM calls but better coverage)
+# - 180: Balanced (3 minutes, recommended) ⭐
+# - 300: Conservative (5 minutes, fewer LLM calls)
+max_accumulation_time = 180
+
+# Periodic sampling interval (seconds)
+# Description: Minimum interval between kept samples during deduplication
+# Even if screenshots are identical (static scene), at least one sample is kept every N seconds
+# This ensures time coverage in the accumulated screenshots
+# - 20: Aggressive (more samples, higher LLM cost)
+# - 30: Balanced (recommended) ⭐
+# - 45: Conservative (fewer samples)
+min_sample_interval = 30
+
# Action extraction configuration (RawAgent → ActionAgent flow)
-# Threshold calculation: target 8 screenshots after filtering
-# Typical filtering rate: 30-40% (dedup + content analysis)
-# Formula: threshold = max_screenshots / (1 - filter_rate) ≈ 8 / 0.65 ≈ 12
-action_extraction_threshold = 12 # Optimized: trigger when 12 screenshots accumulated (~12 seconds)
+# OPTIMIZED: Balanced threshold for better data generation in Pomodoro sessions
+#
+# Threshold calculation: Balance between LLM calls and data completeness
+# With 1 screenshot/second capture rate:
+# - 12: Trigger every ~12 seconds (frequent, 300 calls/hour, high data completeness)
+# - 20: Trigger every ~20 seconds (balanced, 180 calls/hour)
+# - 25: Trigger every ~25 seconds (optimized, 144 calls/hour) ⭐ OPTIMIZED
+# - 40: Trigger every ~40 seconds (conservative, 90 calls/hour, may miss activities)
+#
+# RATIONALE: Previous threshold of 40 caused "no actions found" errors in Pomodoro sessions
+# because screenshots were too sparse (especially for video-watching scenarios).
+# Lowering to 25 provides:
+# - 60% more action generation frequency (40→25 screenshots)
+# - Better activity coverage in diverse scenarios (coding, watching, reading)
+# - Acceptable token cost (~$20-25/hour vs $18.50/hour at threshold=40)
+#
+# Note: Combined with screenshot_similarity_threshold=0.88, this provides optimal balance
+action_extraction_threshold = 25 # OPTIMIZED: Lowered from 40 for better Pomodoro data generation
max_screenshots_per_extraction = 8 # Final limit: ImageSampler will select best 8 from filtered results
# UI configuration
@@ -226,6 +261,68 @@ recent_events_count = 5 # Number of recent events to display (can be modified i
# System language configuration, affects prompt selection
default_language = "zh" # zh | en
+# Pomodoro mode screenshot buffering configuration
+[pomodoro]
+# Enable screenshot buffering during Pomodoro sessions
+# Description: Batch RawRecord generation to reduce memory pressure
+# - true: Enable buffering (recommended for Pomodoro mode) ⭐
+# - false: Disable (use normal flow)
+enable_screenshot_buffering = true
+
+# Count threshold - trigger batch when this many screenshots accumulated
+# Description: With 1 screenshot/sec capture rate:
+# - 25: Batch every ~25 seconds (matches action_extraction_threshold) ⭐ OPTIMIZED
+# - 40: Batch every ~40 seconds (balanced)
+# - 60: Batch every ~60 seconds (conservative)
+# - 100: Batch every ~100 seconds (less frequent, more memory)
+# OPTIMIZED: Lowered from 40 to 25 to align with action_extraction_threshold
+# This ensures faster action generation and better Pomodoro session data
+screenshot_buffer_count_threshold = 25
+
+# Time threshold - trigger batch after this many seconds elapsed
+# Description: Ensures timely processing even if screenshot rate is low
+# - 30: More frequent batching (recommended) ⭐ OPTIMIZED
+# - 45: Balanced
+# - 60: Conservative
+# OPTIMIZED: Lowered from 45s to 30s for more responsive action extraction
+screenshot_buffer_time_threshold = 30
+
+# Maximum buffer size - emergency flush to prevent memory overflow
+# Description: Should be 2-4x count_threshold for safety
+# - 50: Conservative (2x count_threshold=25)
+# - 100: Standard (4x count_threshold=25) ⭐ OPTIMIZED
+# - 160: Generous (6.4x count_threshold=25)
+screenshot_buffer_max_size = 160
+
+# Processing timeout - maximum time to wait for batch processing
+# Description: Timeout for LLM calls (should be 2x max LLM timeout)
+# - 600: 10 minutes (for fast LLM providers)
+# - 720: 12 minutes (recommended for most cases) ⭐
+# - 900: 15 minutes (for slow/local LLM providers)
+screenshot_buffer_processing_timeout = 720
+
+# ========== Coding Scene Optimization ==========
+# Coding-specific optimizations for IDEs, terminals, and code editors
+# When a coding app is detected, more permissive thresholds are used
+# to capture small but meaningful changes (cursor movement, typing)
+[coding_optimization]
+# Enable coding scene detection and adaptive thresholds
+enabled = true
+
+# Similarity threshold for coding scenes (0.0-1.0)
+# Higher value = more screenshots retained (less aggressive deduplication)
+# - 0.90: Conservative (may still miss some small changes)
+# - 0.92: Balanced (recommended) ⭐
+# - 0.95: Aggressive (retains most changes, higher LLM cost)
+coding_similarity_threshold = 0.92
+
+# Content analysis thresholds for coding (dark themes)
+# These are lower than default because:
+# - Dark-themed IDEs have low contrast
+# - Typing creates small visual changes
+coding_min_contrast = 25.0
+coding_min_activity = 5.0
+
# Database configuration
[database]
# Database file path (relative to data directory or absolute path)
diff --git a/backend/config/loader.py b/backend/config/loader.py
index ec188c6..d7630e9 100644
--- a/backend/config/loader.py
+++ b/backend/config/loader.py
@@ -134,7 +134,20 @@ def _merge_configs(
result = base.copy()
# Filter out system-level sections from user config
- system_sections = {'processing', 'monitoring', 'image', 'image_optimization'}
+ # These settings are managed by backend and should not be overridden by users
+ system_sections = {
+ 'monitoring', # Capture intervals, processing intervals
+ 'server', # Host, port, debug mode
+ 'logging', # Log level, directory, file rotation
+ 'image', # Image compression, dimensions, phash
+ 'image_optimization', # Optimization strategies, thresholds
+ 'processing', # Screenshot deduplication, similarity thresholds
+ 'ui', # UI settings (managed by frontend/backend)
+ 'pomodoro', # Pomodoro buffer settings (system-level)
+ }
+
+ # System-level keys within [screenshot] section
+ screenshot_system_keys = {'smart_capture_enabled', 'inactive_timeout'}
for key, value in override.items():
# Skip system-level sections
@@ -145,6 +158,25 @@ def _merge_configs(
)
continue
+ # Special handling for [screenshot] section (mixed user/system settings)
+ if key == 'screenshot' and isinstance(value, dict):
+ # Filter out system-level keys
+ user_screenshot_config = {
+ k: v for k, v in value.items()
+ if k not in screenshot_system_keys
+ }
+ if screenshot_system_keys & set(value.keys()):
+ logger.debug(
+ f"Ignoring system-level keys in [screenshot]: "
+ f"{screenshot_system_keys & set(value.keys())}"
+ )
+ # Merge user-level screenshot settings
+ if key in result and isinstance(result[key], dict):
+ result[key] = self._merge_configs(result[key], user_screenshot_config)
+ else:
+ result[key] = user_screenshot_config
+ continue
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
# Recursively merge nested dictionaries
result[key] = self._merge_configs(result[key], value)
@@ -177,7 +209,12 @@ def _get_default_config_content(self) -> str:
"""Get default configuration content for user configuration
Note: Only user-configurable items should be in user config.
- Development settings (logging, monitoring, etc.) are in project config.
+ System settings (logging, monitoring, processing, etc.) are in backend/config/config.toml.
+
+ User-configurable settings:
+ - [database]: Database storage path
+ - [screenshot]: Screenshot storage path, screen settings
+ - [language]: UI language preference
"""
# Avoid circular imports: use path directly, don't import get_data_dir
config_dir = Path.home() / ".config" / "ido"
@@ -187,9 +224,17 @@ def _get_default_config_content(self) -> str:
return f"""# iDO User Configuration File
# Location: ~/.config/ido/config.toml
#
-# This file contains user-level settings only.
-# System-level settings ([processing], [monitoring], [image], etc.) are managed
-# in project configuration (backend/config/config.toml) and cannot be overridden here.
+# ⚠️ IMPORTANT: This file contains USER-LEVEL settings only.
+#
+# System-level settings (capture intervals, processing thresholds, optimization parameters, etc.)
+# are managed in backend/config/config.toml and CANNOT be overridden here.
+#
+# If you add system-level sections here, they will be IGNORED during config merge.
+#
+# User-configurable settings:
+# - [database]: Database file location
+# - [screenshot]: Screenshot storage path, monitor settings
+# - [language]: UI language preference
[database]
# Database storage location
@@ -198,9 +243,23 @@ def _get_default_config_content(self) -> str:
[screenshot]
# Screenshot storage location
save_path = '{screenshots_dir}'
-# Force save interval when screenshots are being filtered as duplicates (seconds)
-# Even if screenshots are identical, force save one after this interval
+
+# Force save interval (seconds)
+# When screenshots are filtered as duplicates, force save one after this interval
force_save_interval = 60
+
+# Monitor/screen configuration (auto-detected, can be customized)
+# Note: This will be auto-populated when application first runs
+# [[screenshot.screen_settings]]
+# monitor_index = 1
+# monitor_name = "Display 1"
+# is_enabled = true
+# resolution = "1920x1080"
+# is_primary = true
+
+[language]
+# UI language: "en" (English) or "zh" (Chinese)
+default_language = "zh"
"""
def _replace_env_vars(self, content: str) -> str:
diff --git a/backend/config/prompts_en.toml b/backend/config/prompts_en.toml
index 252d140..8922385 100644
--- a/backend/config/prompts_en.toml
+++ b/backend/config/prompts_en.toml
@@ -85,6 +85,30 @@ Before and after generation, automatically check:
- If multiple similar operations are split into multiple actions → merge into one action;
- If `keywords` contain generic terms (e.g., "code", "browser", "document") → replace them.
+-------------------------------------
+【Behavior Context Interpretation】
+-------------------------------------
+The system provides behavior classification based on keyboard and mouse patterns:
+
+**OPERATION Mode (Active Work):**
+- High keyboard activity (frequent typing, shortcuts)
+- Precise mouse clicks and drags
+- User is actively creating, coding, writing, or designing
+- Actions should focus on: what was built/written/created, technical details, problem-solving
+
+**BROWSING Mode (Passive Consumption):**
+- Low keyboard activity (minimal typing)
+- Continuous scrolling, few clicks
+- User is consuming content (reading, watching, learning)
+- Actions should focus on: what was learned/researched, key topics, sources
+
+**MIXED Mode:**
+- Combination of both patterns
+- User may be alternating between creation and reference
+- Actions should capture both aspects
+
+**Important:** Use behavior context as a hint, NOT a strict constraint. Visual evidence from screenshots takes priority.
+
-------------------------------------
【Output Objective】
-------------------------------------
@@ -99,6 +123,9 @@ user_prompt_template = """Here are the user's recent screenshots:
Here is the user's mouse/keyboard usage during this period:
{input_usage_hint}
+**Behavior Classification:**
+{behavior_context}
+
**Important Note About Perception State:**
- If keyboard/mouse perception is disabled, the system cannot capture these inputs, so you will not have that contextual information.
- When a certain input type's perception is disabled, rely more on visual clues from screenshots to infer user activities.
@@ -803,6 +830,141 @@ Think carefully, then output **only** the following JSON (no explanatory text):
}}
```"""
+[prompts.action_aggregation]
+system_prompt = """You are an expert in understanding work sessions and aggregating fine-grained actions into coherent activities.
+
+Your task is to cluster a series of ACTIONS (fine-grained operations) into ACTIVITIES (coarse-grained work sessions) based on thematic relevance, time continuity, and goal association.
+
+-------------------------------------
+【Core Principles】
+-------------------------------------
+1. **Time Uniqueness (HIGHEST PRIORITY)**: Only one activity can exist in the same time period
+ - If multiple actions overlap or are close in time, they MUST be merged into one activity
+ - When user does multiple things in the same period, identify the primary activity and mention secondary activities in description
+ - Absolutely PROHIBIT creating overlapping activities
+2. **Thematic Coherence**: Group actions that serve the same high-level goal or project
+3. **Time Continuity**: Actions within reasonable time gaps (≤5min) likely belong together
+4. **Goal Association**: Different objects/files serving the same work goal should merge
+5. **Project Consistency**: Same repo/branch/feature indicates same activity
+6. **Workflow Continuity**: Actions forming a logical workflow (write → test → debug → fix)
+
+-------------------------------------
+【Activity Granularity Guidelines】
+-------------------------------------
+- One activity = one focused work session on a coherent theme
+- Merge actions on the same file/feature/problem into one activity
+- Separate activities when switching to a different project/goal/context
+- Examples of ONE activity:
+ - "Implement user authentication feature" (includes writing code, testing, debugging, fixing)
+ - "Research and implement Docker deployment" (includes reading docs, writing config, troubleshooting)
+ - "Debug payment gateway integration" (includes analyzing logs, testing API, fixing bugs)
+- Examples of MULTIPLE activities:
+ - "Implement auth feature" + "Review email from manager" + "Update project roadmap"
+
+-------------------------------------
+【Activity Structure】
+-------------------------------------
+- **title**: Concise summary of the work session (what was accomplished)
+ Format: `[Action Verb] [Object/Feature] ([Context or Purpose])`
+ Examples:
+ - "Implement user authentication with JWT tokens"
+ - "Debug and fix Docker build configuration errors"
+ - "Research TypeScript generics for API client refactoring"
+
+- **description**: Comprehensive narrative of the work session:
+ - Context: What project/feature/problem was being worked on
+ - Actions taken: High-level summary of key steps (not exhaustive list)
+ - Challenges: Any issues encountered and how they were resolved
+ - Outcome: What was achieved by the end
+
+- **topic_tags**: 2-5 high-level semantic tags
+ Examples: ["authentication", "backend"], ["docker", "deployment"], ["typescript", "refactoring"]
+ Avoid generic tags like "code", "debugging", "work"
+
+-------------------------------------
+【Source Action Handling】
+-------------------------------------
+- Use `source` field to list the 1-based indexes of actions that belong to this activity
+- Preserve action order (chronological)
+- Every action must be assigned to exactly one activity (no overlap, no omission)
+
+-------------------------------------
+【Quality Constraints】
+-------------------------------------
+- Each activity should represent ≥5 minutes of focused work
+- Avoid micro-activities (e.g., "Saved file", "Opened browser")
+- Merge trivial actions into meaningful work sessions
+- If actions are too fragmented/unrelated, it's okay to have multiple small activities
+
+-------------------------------------
+【Output Objective】
+-------------------------------------
+Generate high-quality activity summaries that:
+1. Accurately reflect the user's work flow and accomplishments
+2. Are useful for time tracking and work review
+3. Provide semantic context for future retrieval and analysis
+"""
+
+user_prompt_template = """Here are the user's actions during a work period:
+(Actions are ordered chronologically from earliest to latest)
+
+{actions_json}
+
+-------------------------------------
+【Task】
+-------------------------------------
+Cluster these actions into coherent activities (work sessions).
+
+**KEY CONSTRAINT: Only one activity can exist in the same time period!**
+
+Consider (in priority order):
+1. **Time Uniqueness** (HIGHEST PRIORITY): Absolutely PROHIBIT creating overlapping activities
+ - Check each activity's time range to ensure no overlap
+ - Actions close in time should be merged even if themes differ
+ - If user does multiple things simultaneously, choose the primary activity as title and mention others in description
+2. **Thematic relevance** (HIGH PRIORITY): Same goal/project
+3. **Time continuity** (HIGH PRIORITY): Actions close in time
+4. **Workflow coherence**: Logical progression
+5. **Context switches**: Different projects/apps/domains
+
+**Example (WRONG)**:
+❌ Activity 1: 00:10-00:20 "Watch technical video"
+❌ Activity 2: 00:15-00:25 "Debug system"
+(These activities overlap in time and MUST be merged!)
+
+**Example (CORRECT)**:
+✅ Activity: 00:10-00:25 "Debug system while referencing technical video tutorial"
+(Primary activity is debugging, watching video is auxiliary and mentioned in description)
+
+-------------------------------------
+【Output Format】
+-------------------------------------
+Return ONLY the following JSON structure:
+
+```json
+{{
+ "activities": [
+ {{
+ "title": "string",
+ "description": "string",
+ "topic_tags": ["string"],
+ "source": [1, 2, 3]
+ }}
+ ]
+}}
+```
+
+Where:
+- `source`: 1-based indexes of actions that belong to this activity
+- Every action index (1 to N) must appear exactly once across all activities
+- Activities are ordered chronologically by their earliest action
+- **MUST ensure no time overlap between activities**
+"""
+
+[config.action_aggregation]
+temperature = 0.3
+max_tokens = 4000
+
[prompts.session_aggregation]
system_prompt = """You are a work session analysis expert. Task: Aggregate Events (medium-grained work segments) into Activities (coarse-grained work sessions).
@@ -1994,10 +2156,29 @@ system_prompt = """You are a professional activity (work session) quality review
--------------------------------------
【Review Criteria】(Ordered by Priority)
--------------------------------------
-1. **Semantic Accuracy Check (HIGHEST PRIORITY)**:
- **When Source Events are provided, this is the MOST CRITICAL check**
+1. **Temporal Continuity Check (HIGHEST PRIORITY - for Pomodoro work phases)**:
+ **When activities come from the same work phase, temporal continuity is the MOST CRITICAL check**
+ - Check if time gaps between adjacent activities are reasonable
+ - Normal gap: ≤2 minutes (task switching, reading materials, etc.)
+ - Suspicious gap: 2-5 minutes (possible break or unrelated activity)
+ - Abnormal gap: >5 minutes (should split or mark)
+
+ **Handling Time Gap Issues**:
+ - If >5-minute gap exists, MUST check if activities should be split
+ - If gap is reasonable but activities have different themes, keep them split
+ - If gap is very small (<30 seconds) and activities have similar themes, should merge
+
+ **Examples**:
+ ❌ Wrong: Activity A (10:00-10:15) and Activity B (10:15-10:30) with no gap, and same theme
+ ✅ Correct: Should merge into single activity (10:00-10:30)
+
+ ❌ Wrong: Activity A (10:00-10:10) and Activity B (10:20-10:30) with 10-minute gap
+ ✅ Correct: Keep split, or mark that there might be break/unrelated activity in between
+
+2. **Semantic Accuracy Check (HIGH PRIORITY)**:
+ **When Source Events/Actions are provided, this is a CRITICAL check**
- Does the title reflect the **primary theme** (the theme with most time spent)?
- - Analyze time distribution across all Source Events, calculate time spent per theme
+ - Analyze time distribution across all Source Events/Actions, calculate time spent per theme
- If the title describes a minor theme (time ratio <40%), it MUST be corrected to the primary theme
- With multiple themes, the title MUST only reflect the one consuming the most time
- Description can mention other minor themes, but title MUST focus on the primary theme
@@ -2009,32 +2190,32 @@ system_prompt = """You are a professional activity (work session) quality review
❌ Wrong: 5 events involving Frontend dev (total 50min) and Backend debugging (10min) → Title: "Full-Stack Development - Frontend-Backend Integration"
✅ Correct: Title should be: "Frontend Development - UI Component Implementation" (Backend debugging can be briefly mentioned in description)
-2. **Title Quality Check**:
+3. **Title Quality Check**:
- Is the title within 20 characters? (character count)
- Does it follow the "[Topic] - [Core Work]" format?
- Does it avoid using semicolons (;) to separate multiple topics?
- Does it avoid overly broad or vague descriptions (e.g., "Work processing", "Daily tasks")?
- Does it describe a single work session, not multiple parallel sessions?
-3. **Description Quality Check**:
+4. **Description Quality Check**:
- Is the description within 150-250 words, 3-5 sentences?
- Does it use structured expression (bullet points or short paragraphs)?
- Does it avoid lengthy narrative descriptions?
- Are excessive details omitted (such as action numbers, detailed file paths)?
- Are key facts preserved (commands, parameters, branch names, PR/Issue numbers, error codes)?
-4. **Session Consistency Check**:
+5. **Session Consistency Check**:
- Does each activity contain only one clear work topic or project?
- If it contains multiple unrelated topics, should it be split?
- Are topic transition points correctly identified?
- Do merged activities truly belong to the same work session?
-5. **Information Density Check**:
+6. **Information Density Check**:
- Does each sentence in the description contain valuable information?
- Are repetitive operation descriptions removed?
- Are similar operations compressed using summary language?
-6. **Deduplication Check**:
+7. **Deduplication Check**:
- Are there duplicate or highly similar activities?
- Should similar activities be merged?
@@ -2112,11 +2293,12 @@ Output the following JSON structure (no explanatory text):
```
**Review Priorities**:
-1. If Source Events are provided, analyze each event's duration and calculate time distribution per theme
-2. Confirm activity title reflects the theme with largest time ratio (recommend >40%)
-3. Strictly check title length (MUST be ≤20 characters) and format
-4. Check description length (MUST be within 150-250 words)
-5. When any issue is found, MUST provide corrections in revised_activities"""
+1. **Temporal Continuity**: Check time gaps between adjacent activities (<2min normal, 2-5min suspicious, >5min abnormal)
+2. If Source Events/Actions are provided, analyze each's duration and calculate time distribution per theme
+3. Confirm activity title reflects the theme with largest time ratio (recommend >40%)
+4. Strictly check title length (MUST be ≤20 characters) and format
+5. Check description length (MUST be within 150-250 words)
+6. When any issue is found, MUST provide corrections in revised_activities"""
[config.activity_supervisor]
max_tokens = 2000
@@ -2393,3 +2575,287 @@ Think carefully and output only the following JSON structure (no explanatory tex
]
}}
```"""
+
+[prompts.focus_score_evaluation]
+system_prompt = """You are a focus evaluation expert who can comprehensively assess focus quality based on user work activity data.
+
+Your task is to analyze activity records from a Pomodoro work session, evaluate focus from multiple dimensions, and provide a 0-100 score with detailed analysis and suggestions.
+
+-------------------------------------
+【Evaluation Dimensions】
+-------------------------------------
+Analyze focus from these 5 dimensions:
+
+1. **Topic Consistency** - Weight: 30%
+ - Whether activities revolve around a single or closely related topics
+ - Topic switching frequency and reasonableness
+ - Correlation between multiple topics (do they serve the same goal)
+
+2. **Duration Depth** - Weight: 25%
+ - Duration of individual activities
+ - Presence of deep work sessions (>15 minutes)
+ - Reasonableness of time distribution
+
+3. **Switching Rhythm** - Weight: 20%
+ - Whether activity switching frequency is reasonable
+ - Presence of overly frequent task switching (<5 minutes)
+ - Whether switches have clear phase-based reasons
+
+4. **Work Quality** - Weight: 15%
+ - Whether activity descriptions show clear work outcomes
+ - Substantial progress (coding, writing, analysis, etc.)
+ - Obvious distraction behaviors (entertainment, chatting, etc.)
+
+5. **Goal Orientation** - Weight: 10%
+ - Whether activities have clear goals and direction
+ - Advancing specific tasks vs aimless browsing
+ - Consistency between actions and expected goals
+
+-------------------------------------
+【Scoring Standards】
+-------------------------------------
+Based on comprehensive evaluation of the 5 dimensions, provide 0-100 focus score:
+
+- **90-100 (Excellent)**: Highly focused, single-topic deep work, minimal distraction
+- **80-89 (Very Good)**: Very good focus, clear topic, occasional reasonable switches
+- **70-79 (Good)**: Generally focused, some topic switches but within control
+- **60-69 (Moderate)**: Average focus, considerable topic switches or distractions
+- **50-59 (Poor)**: Insufficient focus, frequent switches or obvious distractions
+- **0-49 (Very Poor)**: Severe lack of focus, excessive distractions or no clear goal
+
+-------------------------------------
+【Evaluation Principles】
+-------------------------------------
+1. **Context Understanding**: Fully understand logical connections between activities
+2. **Reasonableness Judgment**: Some "switches" may be work-necessary (checking docs, testing, review)
+3. **Holistic Perspective**: Evaluate from entire work session perspective
+4. **Encourage Depth**: Give higher evaluation to long-term deep work
+5. **Tolerate Necessary Switches**: Development, writing inherently require switching between tools/resources
+6. **Identify Real Distractions**: Focus on entertainment, chatting unrelated to work goals
+
+-------------------------------------
+【Special Scenarios】
+-------------------------------------
+- **Development**: Switching between editor, terminal, browser(docs), testing tools is normal
+- **Writing**: Switching between documents, materials, search engines is necessary
+- **Learning**: Video learning + note-taking + practice is reasonable multi-tasking
+- **Design**: Switching between design tools, references, previews is normal
+- **Break Time**: If explicitly marked as break, handle separately without affecting work session score
+
+-------------------------------------
+【Output Format】
+-------------------------------------
+After careful analysis, output the following JSON structure (no other text):
+
+```json
+{
+ "focus_score": 85,
+ "focus_level": "excellent",
+ "dimension_scores": {
+ "topic_consistency": 90,
+ "duration_depth": 85,
+ "switching_rhythm": 80,
+ "work_quality": 85,
+ "goal_orientation": 88
+ },
+ "analysis": {
+ "strengths": [
+ "Maintained 25 minutes of focus watching Bilibili anime, showing good entertainment focus",
+ "Highly consistent topic, entire session around Log Horizon"
+ ],
+ "weaknesses": [
+ "Activity type is entertainment rather than work/learning, focus quality needs goal context"
+ ],
+ "suggestions": [
+ "If this is planned break time, focus performance is good",
+ "If this is work session, suggest adjusting activity type back to work tasks"
+ ]
+ },
+ "work_type": "entertainment",
+ "is_focused_work": false,
+ "distraction_percentage": 0,
+ "deep_work_minutes": 0,
+ "context_summary": "User watched Log Horizon anime for 50 minutes across two viewing activities, showing high focus in entertainment context."
+}
+```
+
+Field descriptions:
+- `focus_score`: Integer score 0-100
+- `focus_level`: "excellent"(>=80) | "good"(60-79) | "moderate"(40-59) | "low"(<40)
+- `dimension_scores`: 0-100 scores for each dimension
+- `strengths`: Focus strengths (2-4 items)
+- `weaknesses`: Focus weaknesses (1-3 items, can be empty)
+- `suggestions`: Improvement suggestions (2-4 items)
+- `work_type`: "development" | "writing" | "learning" | "research" | "design" | "communication" | "entertainment" | "productivity_analysis" | "mixed" | "unclear"
+- `is_focused_work`: Whether it is high-quality focused work
+- `distraction_percentage`: Distraction time percentage (0-100)
+- `deep_work_minutes`: Deep work duration (minutes)
+- `context_summary`: Brief summary of overall work situation (1-2 sentences)
+"""
+
+user_prompt_template = """Please evaluate the focus of the following Pomodoro work session:
+
+**Session Information**
+- Time Period: {start_time} - {end_time}
+- Total Duration: {total_duration} minutes
+- Activity Count: {activity_count}
+- Topic Tags: {topic_tags}
+
+**Activity Details**
+{activities_detail}
+
+Please comprehensively evaluate the focus quality of this work session based on the above information."""
+
+[prompts.activity_focus_evaluation]
+system_prompt = """You are a focus evaluation expert who can assess the focus quality of individual work activities.
+
+Your task is to analyze a single activity record and evaluate its focus quality based on task clarity, duration quality, work substance, and goal directedness. Provide a 0-100 score with reasoning.
+
+-------------------------------------
+【Evaluation Dimensions】
+-------------------------------------
+Analyze focus from these 4 dimensions:
+
+1. **Task Clarity** - Weight: 30%
+ - How specific and well-defined is the task?
+ - Clear action verbs and concrete objects vs vague descriptions
+ - Whether the activity title and description are informative
+
+2. **Duration Quality** - Weight: 30%
+ - Is the duration appropriate for the task type?
+ - Too short (<2 min) may indicate distraction or task-switching
+ - Longer durations (>10 min) often indicate sustained focus
+ - Consider task nature (quick lookup vs deep work)
+
+3. **Work Substance** - Weight: 25%
+ - Evidence of progress or concrete outcomes
+ - Substantial work (coding, writing, analysis) vs passive browsing
+ - Clear work artifacts vs entertainment/social activities
+
+4. **Goal Directedness** - Weight: 15%
+ - Does the activity have a clear purpose?
+ - Advancing specific goals vs aimless exploration
+ - Connection to larger work objectives
+
+-------------------------------------
+【Scoring Standards】
+-------------------------------------
+Based on comprehensive evaluation of the 4 dimensions, provide 0-100 focus score:
+
+- **90-100 (Excellent)**: Highly focused, clear goal, substantial work, appropriate duration
+- **80-89 (Very Good)**: Very good focus, clear task, meaningful progress
+- **70-79 (Good)**: Generally focused work with minor issues (too short, somewhat vague)
+- **60-69 (Moderate)**: Moderate focus, unclear goal or minimal substance
+- **50-59 (Poor)**: Insufficient focus, very short duration or unclear purpose
+- **0-49 (Very Poor)**: Distraction, entertainment, or no clear work goal
+
+-------------------------------------
+【Evaluation Principles】
+-------------------------------------
+1. **Context Awareness**: Consider the type of work and typical patterns
+2. **Duration Judgment**: Short activities aren't always bad (quick fixes, lookups are valid)
+3. **Substance Over Form**: Prioritize evidence of actual work over activity length
+4. **Goal Recognition**: Identify whether the activity advances work objectives
+5. **Work Type Distinction**: Development, writing, learning have different patterns
+
+-------------------------------------
+【Special Scenarios】
+-------------------------------------
+- **Quick Lookups**: Short duration (1-3 min) is normal for documentation checks
+- **Deep Work**: Long duration (>15 min) in coding/writing indicates excellent focus
+- **Task Switching**: Rapid switches between related tools (editor→terminal→browser) can be productive
+- **Entertainment**: Social media, videos, games should receive low scores unless work-related
+- **Communication**: Work-related chat/meetings are valid, social chat is not
+
+-------------------------------------
+【Output Format】
+-------------------------------------
+Output the following JSON structure (no other text):
+
+```json
+{
+ "focus_score": 85,
+ "reasoning": "Clear implementation task with appropriate 18-minute duration showing sustained focus. Concrete outcome (authentication middleware) with specific technical details. Strong task clarity and work substance.",
+ "work_type": "development",
+ "is_productive": true
+}
+```
+
+Field descriptions:
+- `focus_score`: Integer score 0-100
+- `reasoning`: Brief explanation of the score (2-3 sentences)
+- `work_type`: "development" | "writing" | "learning" | "research" | "design" | "communication" | "entertainment" | "productivity_analysis" | "mixed" | "unclear"
+- `is_productive`: Boolean indicating whether this is productive work vs distraction
+"""
+
+user_prompt_template = """Please evaluate the focus quality of the following activity:
+
+**Activity Information**
+- Title: {title}
+- Description: {description}
+- Duration: {duration_minutes} minutes
+- Topics: {topics}
+- Action Count: {action_count}
+
+**Activity Actions**
+{actions_summary}
+
+Based on the above information, evaluate this activity's focus quality and provide a score."""
+
+[prompts.knowledge_merge_analysis]
+system_prompt = """You are a knowledge management expert specializing in identifying and merging similar content.
+
+Your task is to analyze a list of knowledge entries and identify groups that should be merged based on:
+1. **Content similarity**: Similar topics, concepts, or information
+2. **Semantic overlap**: Redundant or overlapping descriptions
+3. **Consistency**: Entries that describe the same thing in different ways
+
+Guidelines:
+- Only suggest merging when similarity score is above the threshold
+- Preserve all unique information when creating merged descriptions
+- **REFINE keywords**: Select only 2-4 most essential, representative tags from all entries (avoid redundancy, keep core concepts)
+- Provide clear reasons for why entries should be merged
+- If entries are distinct despite similar keywords, keep them separate"""
+
+user_prompt_template = """Analyze the following knowledge entries for similarity and suggest merge operations.
+
+**Knowledge Entries:**
+```json
+{knowledge_json}
+```
+
+**Similarity Threshold:** {threshold}
+
+**Instructions:**
+1. Group entries with similarity score >= {threshold}
+2. For each group, generate:
+ - Merged title (concise, captures all content)
+ - Merged description (comprehensive, preserves all unique info)
+ - **Refined keywords** (2-4 most essential, representative tags - NO redundancy)
+ - Similarity score (0.0-1.0)
+ - Merge reason (brief explanation)
+
+**Response Format (JSON only):**
+```json
+{{
+ "merge_clusters": [
+ {{
+ "knowledge_ids": ["id1", "id2"],
+ "merged_title": "...",
+ "merged_description": "...",
+ "merged_keywords": ["tag1", "tag2"],
+ "similarity_score": 0.85,
+ "merge_reason": "Both entries describe..."
+ }}
+ ]
+}}
+```
+
+**Important**: Keep merged_keywords concise (2-4 tags max). Select only the most essential, representative tags from the group. Avoid redundant or overly specific tags.
+
+If no similar entries found, return: {{"merge_clusters": []}}"""
+
+[config.knowledge_merge_analysis]
+max_tokens = 4000
+temperature = 0.3
+
diff --git a/backend/config/prompts_zh.toml b/backend/config/prompts_zh.toml
index 4f21fa1..93642d9 100644
--- a/backend/config/prompts_zh.toml
+++ b/backend/config/prompts_zh.toml
@@ -103,6 +103,30 @@ system_prompt = """你是用户桌面活动理解与动作抽取的专家。你
- 若多个相似操作被拆分成多个动作 → 合并为一个动作;
- 若 `keywords` 含泛词(如"代码""浏览器""文档") → 替换。
+-------------------------------------
+【行为上下文解读】
+-------------------------------------
+系统基于键盘鼠标模式提供行为分类:
+
+**操作模式(主动工作):**
+- 高频键盘活动(频繁打字、快捷键)
+- 精确的鼠标点击和拖拽
+- 用户正在主动创建、编码、写作或设计
+- 动作应聚焦于:构建/编写/创建的内容、技术细节、问题解决
+
+**浏览模式(被动消费):**
+- 低频键盘活动(极少打字)
+- 持续滚动、很少点击
+- 用户正在消费内容(阅读、观看、学习)
+- 动作应聚焦于:学习/研究的内容、关键主题、信息来源
+
+**混合模式:**
+- 两种模式的组合
+- 用户可能在创作和参考之间切换
+- 动作应捕获两方面内容
+
+**重要:** 行为上下文仅作为提示,非严格约束。截图的视觉证据优先。
+
-------------------------------------
【输出目标】
-------------------------------------
@@ -116,6 +140,9 @@ user_prompt_template = """这是感知到的用户最近的屏幕截图信息:
这是这段时间里面用户的输入感知状态和使用情况:
{input_usage_hint}
+**行为分类:**
+{behavior_context}
+
**关于感知状态的重要说明:**
- 如果键盘/鼠标感知被禁用,系统将无法捕获这些输入,因此你将无法获得该上下文信息。
- 当某种输入类型的感知被禁用时,请更多地依赖截图中的视觉线索来推断用户活动。
@@ -809,6 +836,141 @@ temperature = 0.5
max_tokens = 4000
temperature = 0.8
+[prompts.action_aggregation]
+system_prompt = """你是工作会话理解和聚合领域的专家。
+
+你的任务是将一系列细粒度的ACTIONS(操作记录)聚类为粗粒度的ACTIVITIES(工作会话),基于主题相关性、时间连续性和目标关联性。
+
+-------------------------------------
+【核心原则】
+-------------------------------------
+1. **时间唯一性(最高优先级)**:同一时间段内只能有一个activity
+ - 如果多个actions在时间上重叠或接近,必须合并为一个activity
+ - 用户在同一时段做了多件事时,识别主要活动并在description中提及次要活动
+ - 绝对禁止创建时间重叠的activities
+2. **主题连贯性**:将服务于同一高层目标或项目的actions分组到一起
+3. **时间连续性**:时间间隔较短(≤5分钟)的actions通常属于同一会话
+4. **目标关联性**:不同对象/文件只要服务于同一工作目标就应该合并
+5. **项目一致性**:同一仓库/分支/功能模块表明是同一activity
+6. **工作流连续性**:形成逻辑工作流的actions(编写 → 测试 → 调试 → 修复)
+
+-------------------------------------
+【Activity粒度指导】
+-------------------------------------
+- 一个activity = 围绕连贯主题的一次专注工作会话
+- 将在同一文件/功能/问题上的actions合并为一个activity
+- 当切换到不同项目/目标/上下文时,分离为不同的activities
+- 一个activity的示例:
+ - "实现用户认证功能"(包含编写代码、测试、调试、修复)
+ - "研究并实现Docker部署"(包含阅读文档、编写配置、排查问题)
+ - "调试支付网关集成"(包含分析日志、测试API、修复bug)
+- 多个activities的示例:
+ - "实现认证功能" + "回复经理邮件" + "更新项目路线图"
+
+-------------------------------------
+【Activity结构】
+-------------------------------------
+- **title**: 工作会话的简洁总结(完成了什么)
+ 格式:`[动作动词] [对象/功能] ([上下文或目的])`
+ 示例:
+ - "实现JWT令牌的用户认证"
+ - "调试并修复Docker构建配置错误"
+ - "研究TypeScript泛型以重构API客户端"
+
+- **description**: 工作会话的完整叙述:
+ - 上下文:正在处理什么项目/功能/问题
+ - 采取的行动:关键步骤的高层总结(不是详尽列表)
+ - 挑战:遇到的问题及解决方式
+ - 结果:最终实现了什么
+
+- **topic_tags**: 2-5个高层语义标签
+ 示例:["认证", "后端"], ["docker", "部署"], ["typescript", "重构"]
+ 避免通用标签如"代码"、"调试"、"工作"
+
+-------------------------------------
+【源Action处理】
+-------------------------------------
+- 使用`source`字段列出属于此activity的actions的索引(从1开始)
+- 保持action顺序(按时间顺序)
+- 每个action必须且仅被分配给一个activity(不重叠、不遗漏)
+
+-------------------------------------
+【质量约束】
+-------------------------------------
+- 每个activity应该代表≥5分钟的专注工作
+- 避免微型activities(如"保存文件"、"打开浏览器")
+- 将琐碎的actions合并为有意义的工作会话
+- 如果actions太分散/不相关,可以有多个小activities
+
+-------------------------------------
+【输出目标】
+-------------------------------------
+生成高质量的activity摘要,能够:
+1. 准确反映用户的工作流程和成果
+2. 对时间跟踪和工作回顾有用
+3. 为未来检索和分析提供语义上下文
+"""
+
+user_prompt_template = """以下是用户在某个工作期间的actions:
+(actions按时间顺序从早到晚排列)
+
+{actions_json}
+
+-------------------------------------
+【任务】
+-------------------------------------
+将这些actions聚类为连贯的activities(工作会话)。
+
+**关键约束:同一时间段内只能有一个activity!**
+
+考虑因素(按优先级排序):
+1. **时间唯一性**(最高优先级):绝对禁止创建时间重叠的activities
+ - 检查每个activity的时间范围,确保没有重叠
+ - 时间接近的actions应该合并,即使主题不同
+ - 如果用户同时做了多件事,选择主要活动作为title,其他活动在description中说明
+2. **主题相关性**(高优先级):相同目标/项目
+3. **时间连续性**(高优先级):actions时间接近
+4. **工作流连贯性**:逻辑递进
+5. **上下文切换**:不同项目/应用/领域
+
+**示例(错误)**:
+❌ Activity 1: 00:10-00:20 "观看技术视频"
+❌ Activity 2: 00:15-00:25 "调试系统"
+(这两个activity时间重叠,必须合并!)
+
+**示例(正确)**:
+✅ Activity: 00:10-00:25 "调试系统并参考技术视频教程"
+(主要活动是调试,观看视频是辅助活动,在description中说明)
+
+-------------------------------------
+【输出格式】
+-------------------------------------
+仅返回以下JSON结构:
+
+```json
+{{
+ "activities": [
+ {{
+ "title": "string",
+ "description": "string",
+ "topic_tags": ["string"],
+ "source": [1, 2, 3]
+ }}
+ ]
+}}
+```
+
+其中:
+- `source`: 属于此activity的actions的索引(从1开始)
+- 每个action索引(1到N)必须在所有activities中恰好出现一次
+- Activities按其最早action的时间顺序排列
+- **必须保证activities之间没有时间重叠**
+"""
+
+[config.action_aggregation]
+temperature = 0.3
+max_tokens = 4000
+
[prompts.session_aggregation]
system_prompt = """你是工作会话分析专家。任务:将Events(中等粒度工作片段)聚合为Activities(粗粒度工作会话)。
@@ -1642,10 +1804,29 @@ system_prompt = """你是专业的活动(工作会话)质量审查专家。
--------------------------------------
【审查标准】(按优先级排序)
--------------------------------------
-1. **语义准确性检查**(最高优先级):
- **当提供了Source Events时,这是最关键的检查项**
+1. **时间连续性检查**(最高优先级 - 针对番茄钟工作阶段):
+ **当activities来自同一工作阶段时,时间连续性是最关键的检查项**
+ - 检查相邻activities之间的时间间隔是否合理
+ - 正常间隔:≤2分钟(切换任务、查看资料等)
+ - 可疑间隔:2-5分钟(可能是休息或无关活动)
+ - 异常间隔:>5分钟(应该拆分或标记)
+
+ **时间间隔问题处理**:
+ - 如果存在>5分钟的间隔,必须检查是否应该拆分为独立activities
+ - 如果间隔合理但activities主题不同,保持拆分状态
+ - 如果间隔很小(<30秒)且activities主题相似,应该合并
+
+ **示例**:
+ ❌ 错误:Activity A (10:00-10:15) 和 Activity B (10:15-10:30) 之间无间隔,且主题相同
+ ✅ 正确:应该合并为单个activity (10:00-10:30)
+
+ ❌ 错误:Activity A (10:00-10:10) 和 Activity B (10:20-10:30) 之间有10分钟间隔
+ ✅ 正确:保持拆分,或标记中间可能有休息/无关活动
+
+2. **语义准确性检查**(高优先级):
+ **当提供了Source Events/Actions时,这是关键检查项**
- 标题是否反映了**主要主题**(花费时间最多的主题)?
- - 分析所有Source Events的时长分布,计算每个主题所占时间
+ - 分析所有Source Events/Actions的时长分布,计算每个主题所占时间
- 如果标题描述的是次要主题(时间占比<40%),必须修正为主要主题
- 如果有多个主题,标题必须只反映占用时间最多的那个主题
- 描述可以提及其他次要主题,但标题必须聚焦主要主题
@@ -1657,32 +1838,32 @@ system_prompt = """你是专业的活动(工作会话)质量审查专家。
❌ 错误:5个events涉及前端开发(共50分钟)和后端调试(10分钟)→ 标题:"全栈开发 - 前后端联调"
✅ 正确:标题应该是:"前端开发 - UI组件实现"(后端调试可以在描述中简单提及)
-2. **标题质量检查**:
+3. **标题质量检查**:
- 标题是否控制在20字以内?(中文计数)
- 是否遵循"[主题] - [核心工作内容]"格式?
- 是否避免使用分号(;)分隔多个主题?
- 是否避免过于宽泛或笼统的表述(如"工作处理"、"日常任务")?
- 是否描述单一的工作会话,而非多个并列的会话?
-3. **描述质量检查**:
+4. **描述质量检查**:
- 描述是否控制在150-250字,3-5句话?
- 是否使用结构化表达(分点或简短段落)?
- 是否避免了冗长的流水账描述?
- 是否省略了过度细节(如action编号、详细文件路径)?
- 是否保留了关键事实(命令、参数、分支名、PR/Issue号、错误代码)?
-4. **会话一致性检查**:
+5. **会话一致性检查**:
- 每个activity是否只包含一个明确的工作主题或项目?
- 如果包含多个不相关的主题,是否应该拆分?
- 是否正确识别了主题切换点?
- 合并的activities是否真的属于同一工作会话?
-5. **信息密度检查**:
+6. **信息密度检查**:
- 描述的每句话是否包含有价值的信息?
- 是否去除了重复性操作描述?
- 是否用概括性语言压缩相似操作?
-6. **去重检查**:
+7. **去重检查**:
- 是否存在重复或高度相似的activities?
- 相似activities是否应该合并?
@@ -1760,11 +1941,12 @@ user_prompt_template = """请审查以下Activity列表的质量:
```
**审查重点**:
-1. 如果提供了Source Events,请务必分析每个event的时长,计算各主题的时间分布
-2. 确认activity标题反映的是时长占比最大的主题(建议>40%)
-3. 严格检查标题长度(必须≤20字)和格式
-4. 检查描述长度(必须在150-250字之间)
-5. 发现任何问题时,必须在revised_activities中提供修正版本"""
+1. **时间连续性**:检查相邻activities的时间间隔(<2分钟正常,2-5分钟可疑,>5分钟异常)
+2. 如果提供了Source Events/Actions,请务必分析每个的时长,计算各主题的时间分布
+3. 确认activity标题反映的是时长占比最大的主题(建议>40%)
+4. 严格检查标题长度(必须≤20字)和格式
+5. 检查描述长度(必须在150-250字之间)
+6. 发现任何问题时,必须在revised_activities中提供修正版本"""
[config.activity_supervisor]
max_tokens = 2000
@@ -2039,3 +2221,294 @@ user_prompt_template = """这里是来自用户最近活动的结构化场景描
]
}}
```"""
+
+[prompts.focus_score_evaluation]
+system_prompt = """你是专注度评估专家,能够基于用户的工作活动数据,综合评估其专注度质量。
+
+你的任务是分析一个番茄钟工作阶段的活动记录,从多个维度评估用户的专注度,并给出0-100分的评分和详细的分析建议。
+
+-------------------------------------
+【评估维度】
+-------------------------------------
+你需要从以下5个维度分析专注度:
+
+1. **主题一致性 (Topic Consistency)** - 权重:30%
+ - 评估活动是否围绕单一主题或密切相关的主题展开
+ - 主题切换频率和切换的合理性
+ - 多主题间的关联度(是否服务于同一大目标)
+
+2. **持续深度 (Duration Depth)** - 权重:25%
+ - 单个活动的持续时间
+ - 是否有足够长的深度工作时段(>15分钟)
+ - 时间分布的合理性
+
+3. **切换节奏 (Switching Rhythm)** - 权重:20%
+ - 活动切换的频率是否合理
+ - 是否存在过于频繁的任务切换(<5分钟就切换)
+ - 切换是否有明确的阶段性原因
+
+4. **工作质量 (Work Quality)** - 权重:15%
+ - 活动描述是否体现出明确的工作成果
+ - 是否有实质性的进展(编码、写作、分析等)
+ - 是否存在明显的分心行为(娱乐、闲聊等)
+
+5. **目标导向 (Goal Orientation)** - 权重:10%
+ - 活动是否有明确的目标和方向
+ - 是否在推进具体任务而非漫无目的浏览
+ - 行动与预期目标的一致性
+
+-------------------------------------
+【评分标准】
+-------------------------------------
+基于上述5个维度的综合评估,给出0-100分的专注度评分:
+
+- **90-100分 (卓越)**: 高度专注,单一主题深度工作,极少分心
+- **80-89分 (优秀)**: 专注度很好,主题明确,偶有合理切换
+- **70-79分 (良好)**: 基本专注,有少量主题切换但在可控范围
+- **60-69分 (中等)**: 专注度一般,存在较多主题切换或分心
+- **50-59分 (较差)**: 专注度不足,频繁切换或有明显分心
+- **0-49分 (差)**: 严重缺乏专注,大量分心或无明确目标
+
+-------------------------------------
+【评估原则】
+-------------------------------------
+1. **上下文理解**: 充分理解活动之间的逻辑关联,不要机械判断
+2. **合理性判断**: 某些"切换"可能是工作必需(如查文档、测试、review)
+3. **整体视角**: 从整个工作阶段的角度评估,而非孤立看单个活动
+4. **鼓励深度**: 对长时间深度工作给予更高评价
+5. **容忍必要切换**: 开发、写作等工作本身就需要在不同工具/资源间切换
+6. **识别真实分心**: 重点识别与工作目标无关的娱乐、闲聊等行为
+
+-------------------------------------
+【特殊场景处理】
+-------------------------------------
+- **开发工作**: 在编辑器、终端、浏览器(查文档)、测试工具间切换是正常的,不应过度扣分
+- **写作工作**: 在文档、资料、搜索引擎间切换是必需的
+- **学习场景**: 视频学习+笔记记录+实践操作是合理的多任务组合
+- **设计工作**: 在设计工具、参考资料、预览之间切换是正常的
+- **休息时间**: 如果明确标注为休息,应单独处理,不影响工作时段评分
+
+-------------------------------------
+【输出格式】
+-------------------------------------
+仔细分析后,输出以下JSON结构(无其他文字):
+
+```json
+{
+ "focus_score": 85,
+ "focus_level": "excellent",
+ "dimension_scores": {
+ "topic_consistency": 90,
+ "duration_depth": 85,
+ "switching_rhythm": 80,
+ "work_quality": 85,
+ "goal_orientation": 88
+ },
+ "analysis": {
+ "strengths": [
+ "持续25分钟专注于Bilibili动漫观看,展现出良好的娱乐专注度",
+ "主题高度一致,全程围绕《记录的地平线》展开"
+ ],
+ "weaknesses": [
+ "活动类型为娱乐而非工作学习,专注度质量需要结合工作目标评估"
+ ],
+ "suggestions": [
+ "如果这是计划内的休息时间,专注度表现很好",
+ "如果这是工作时段,建议调整活动类型回归工作任务"
+ ]
+ },
+ "work_type": "entertainment",
+ "is_focused_work": false,
+ "distraction_percentage": 0,
+ "deep_work_minutes": 0,
+ "context_summary": "用户在50分钟的时段内观看了《记录的地平线》动漫,包含两段观看活动,展现出娱乐场景下的高度专注。"
+}
+```
+
+字段说明:
+- `focus_score`: 0-100的整数评分
+- `focus_level`: "excellent"(>=80) | "good"(60-79) | "moderate"(40-59) | "low"(<40)
+- `dimension_scores`: 各维度的0-100分评分
+- `strengths`: 专注度的优点(2-4条)
+- `weaknesses`: 专注度的不足(1-3条,可为空数组)
+- `suggestions`: 改进建议(2-4条)
+- `work_type`: "development" | "writing" | "learning" | "research" | "design" | "communication" | "entertainment" | "productivity_analysis" | "mixed" | "unclear"
+- `is_focused_work`: 是否为高质量的专注工作
+- `distraction_percentage`: 分心时间占比(0-100)
+- `deep_work_minutes`: 深度工作时长(分钟)
+- `context_summary`: 整体工作情况的简要总结(1-2句话)
+"""
+
+user_prompt_template = """请评估以下番茄钟工作阶段的专注度:
+
+**工作阶段信息**
+- 时段: {start_time} - {end_time}
+- 总时长: {total_duration} 分钟
+- 活动数量: {activity_count} 个
+- 主题标签: {topic_tags}
+
+**活动详情**
+{activities_detail}
+
+请根据上述信息,综合评估这个工作阶段的专注度质量。"""
+
+[prompts.activity_focus_evaluation]
+system_prompt = """你是专注度评估专家,能够评估单个工作活动的专注度质量。
+
+你的任务是分析一个独立的活动记录,基于任务清晰度、时长质量、工作实质和目标导向性评估其专注度。给出0-100分的评分及理由。
+
+-------------------------------------
+【评估维度】
+-------------------------------------
+你需要从以下4个维度分析专注度:
+
+1. **任务清晰度 (Task Clarity)** - 权重:30%
+ - 任务是否具体明确、定义清楚?
+ - 是否使用明确的动作动词和具体对象,而非模糊描述
+ - 活动标题和描述是否信息丰富
+
+2. **时长质量 (Duration Quality)** - 权重:30%
+ - 时长是否与任务类型相匹配?
+ - 过短(<2分钟)可能表示分心或任务切换
+ - 较长时长(>10分钟)通常表示持续专注
+ - 需考虑任务性质(快速查询 vs 深度工作)
+
+3. **工作实质 (Work Substance)** - 权重:25%
+ - 是否有进展或具体成果的证据
+ - 实质性工作(编码、写作、分析)vs 被动浏览
+ - 明确的工作产物 vs 娱乐/社交活动
+
+4. **目标导向 (Goal Directedness)** - 权重:15%
+ - 活动是否有明确的目的?
+ - 推进具体目标 vs 漫无目的的探索
+ - 与用户工作目标和待办事项的关联性
+ - 活动内容是否直接服务于用户声明的工作目标
+
+-------------------------------------
+【评分标准】
+-------------------------------------
+基于上述4个维度的综合评估,给出0-100分的专注度评分:
+
+- **90-100分 (卓越)**: 高度专注,目标明确,实质性工作,时长合理
+- **80-89分 (优秀)**: 专注度很好,任务清晰,有意义的进展
+- **70-79分 (良好)**: 基本专注的工作,但有小问题(过短、略模糊)
+- **60-69分 (中等)**: 中等专注度,目标不明确或实质内容少
+- **50-59分 (较差)**: 专注度不足,时长很短或目的不清
+- **0-49分 (差)**: 分心、娱乐或无明确工作目标
+
+-------------------------------------
+【评估原则】
+-------------------------------------
+1. **上下文感知**: 考虑工作类型和典型模式
+2. **时长判断**: 短活动不总是坏事(快速修复、查询是有效的)
+3. **实质优先**: 优先考虑实际工作的证据而非活动长度
+4. **目标识别**: 识别活动是否推进工作目标
+5. **工作类型区分**: 开发、写作、学习有不同的模式
+6. **目标契合度**: 当提供了用户工作目标或待办事项时,重点评估活动与目标的契合度。活动越贴合用户声明的工作目标,专注度评分应越高
+
+-------------------------------------
+【特殊场景处理】
+-------------------------------------
+- **快速查询**: 文档查阅的短时长(1-3分钟)是正常的
+- **深度工作**: 编码/写作的长时长(>15分钟)表示优秀专注度
+- **任务切换**: 相关工具间的快速切换(编辑器→终端→浏览器)可以是高效的
+- **娱乐活动**: 社交媒体、视频、游戏应给低分,除非与工作相关
+- **沟通**: 工作相关的聊天/会议是有效的,社交闲聊则不是
+
+-------------------------------------
+【输出格式】
+-------------------------------------
+输出以下JSON结构(无其他文字):
+
+```json
+{
+ "focus_score": 85,
+ "reasoning": "任务清晰,实现身份验证中间件,18分钟的时长展现持续专注。有具体成果(身份验证中间件)和技术细节。任务清晰度和工作实质都很强。",
+ "work_type": "development",
+ "is_productive": true
+}
+```
+
+字段说明:
+- `focus_score`: 0-100的整数评分
+- `reasoning`: 评分的简要解释(2-3句话)
+- `work_type`: "development" | "writing" | "learning" | "research" | "design" | "communication" | "entertainment" | "productivity_analysis" | "mixed" | "unclear"
+- `is_productive`: 布尔值,表示这是否为高效工作而非分心
+"""
+
+user_prompt_template = """请评估以下活动的专注度质量:
+
+**活动信息**
+- 标题: {title}
+- 描述: {description}
+- 时长: {duration_minutes} 分钟
+- 主题: {topics}
+- 动作数量: {action_count}
+
+**活动动作**
+{actions_summary}
+
+**工作目标和上下文**
+- 用户工作目标: {user_intent}
+- 相关待办事项:
+{related_todos}
+
+基于上述信息(特别是工作目标和待办事项),评估这个活动的专注度质量并给出评分。重点评估活动与用户工作目标的契合度。"""
+
+[prompts.knowledge_merge_analysis]
+system_prompt = """你是一位知识管理专家,擅长识别和合并相似内容。
+
+你的任务是分析一组知识条目,并根据以下标准识别应该合并的组:
+1. **内容相似性**:相似的主题、概念或信息
+2. **语义重叠**:冗余或重叠的描述
+3. **一致性**:用不同方式描述同一事物的条目
+
+指导原则:
+- 只在相似度分数高于阈值时建议合并
+- 创建合并描述时保留所有独特信息
+- **精简关键词**:仅选择 2-4 个最重要、最有代表性的标签(避免冗余,保留核心概念)
+- 提供清晰的合并理由
+- 如果条目虽然关键词相似但内容不同,保持分离"""
+
+user_prompt_template = """分析以下知识条目的相似性并建议合并操作。
+
+**知识条目:**
+```json
+{knowledge_json}
+```
+
+**相似度阈值:** {threshold}
+
+**指令:**
+1. 将相似度 >= {threshold} 的条目分组
+2. 为每组生成:
+ - 合并后的标题(简洁,涵盖所有内容)
+ - 合并后的描述(全面,保留所有独特信息)
+ - **精简关键词**(2-4 个最重要、最有代表性的标签 - 无冗余)
+ - 相似度分数 (0.0-1.0)
+ - 合并理由(简要说明)
+
+**响应格式(仅 JSON):**
+```json
+{{
+ "merge_clusters": [
+ {{
+ "knowledge_ids": ["id1", "id2"],
+ "merged_title": "...",
+ "merged_description": "...",
+ "merged_keywords": ["标签1", "标签2"],
+ "similarity_score": 0.85,
+ "merge_reason": "这两个条目都描述了..."
+ }}
+ ]
+}}
+```
+
+**重要提示**:merged_keywords 应保持简洁(最多 2-4 个标签)。仅选择组中最重要、最有代表性的标签。避免冗余或过于具体的标签。
+
+如果没有找到相似条目,返回:{{"merge_clusters": []}}"""
+
+[config.knowledge_merge_analysis]
+max_tokens = 4000
+temperature = 0.3
+
diff --git a/backend/core/coordinator.py b/backend/core/coordinator.py
index cc7a8e0..f9fe11d 100644
--- a/backend/core/coordinator.py
+++ b/backend/core/coordinator.py
@@ -5,7 +5,7 @@
import asyncio
from datetime import datetime, timedelta
-from typing import Any, Dict, Optional
+from typing import Any, Dict, List, Optional
from config.loader import get_config
from core.db import get_db
@@ -28,21 +28,37 @@ def __init__(self, config: Dict[str, Any]):
config: Configuration dictionary
"""
self.config = config
- self.processing_interval = config.get("monitoring.processing_interval", 30)
- self.window_size = config.get("monitoring.window_size", 60)
- self.capture_interval = config.get("monitoring.capture_interval", 1.0)
+
+ # Access nested config correctly
+ monitoring_config = config.get("monitoring", {})
+ self.processing_interval = monitoring_config.get("processing_interval", 30)
+ self.window_size = monitoring_config.get("window_size", 60)
+ self.capture_interval = monitoring_config.get("capture_interval", 1.0)
+
+ # Event-driven processing configuration
+ self.enable_event_driven = True # Enable event-driven processing
+ self.processing_threshold = 20 # Trigger processing when 20+ records accumulated
+ self.fallback_check_interval = 300 # Fallback check every 5 minutes (when no events)
+ self._pending_records_count = 0 # Track pending records
+ self._process_trigger = asyncio.Event() # Event to trigger processing
# Initialize managers (lazy import to avoid circular dependencies)
self.perception_manager = None
self.processing_pipeline = None
self.action_agent = None
self.raw_agent = None
- self.event_agent = None
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # self.event_agent = None
self.session_agent = None
self.todo_agent = None
self.knowledge_agent = None
self.diary_agent = None
self.cleanup_agent = None
+ self.pomodoro_manager = None
+
+ # Pomodoro mode state
+ self.pomodoro_mode = False
+ self.current_pomodoro_session_id: Optional[str] = None
# Running state
self.is_running = False
@@ -159,8 +175,9 @@ def _on_system_sleep(self) -> None:
# Pause all agents
try:
- if self.event_agent:
- self.event_agent.pause()
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # if self.event_agent:
+ # self.event_agent.pause()
if self.session_agent:
self.session_agent.pause()
if self.cleanup_agent:
@@ -179,8 +196,9 @@ def _on_system_wake(self) -> None:
# Resume all agents
try:
- if self.event_agent:
- self.event_agent.resume()
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # if self.event_agent:
+ # self.event_agent.resume()
if self.session_agent:
self.session_agent.resume()
if self.cleanup_agent:
@@ -232,6 +250,12 @@ def _init_managers(self):
enable_adaptive_threshold=processing_config.get(
"enable_adaptive_threshold", True
),
+ max_accumulation_time=processing_config.get(
+ "max_accumulation_time", 180
+ ),
+ min_sample_interval=processing_config.get(
+ "min_sample_interval", 30.0
+ ),
)
if self.action_agent is None:
@@ -249,16 +273,18 @@ def _init_managers(self):
)
)
- if self.event_agent is None:
- from agents.event_agent import EventAgent
-
- processing_config = self.config.get("processing", {})
- self.event_agent = EventAgent(
- aggregation_interval=processing_config.get(
- "event_aggregation_interval", 600
- ),
- time_window_hours=processing_config.get("event_time_window_hours", 1),
- )
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # if self.event_agent is None:
+ # from agents.event_agent import EventAgent
+ #
+ # processing_config = self.config.get("processing", {})
+ # self.event_agent = EventAgent(
+ # coordinator=self,
+ # aggregation_interval=processing_config.get(
+ # "event_aggregation_interval", 600
+ # ),
+ # time_window_hours=processing_config.get("event_time_window_hours", 1),
+ # )
if self.session_agent is None:
from agents.session_agent import SessionAgent
@@ -315,6 +341,11 @@ def _init_managers(self):
),
)
+ if self.pomodoro_manager is None:
+ from core.pomodoro_manager import PomodoroManager
+
+ self.pomodoro_manager = PomodoroManager(self)
+
# Link agents
if self.processing_pipeline:
# Link action_agent to pipeline for action extraction
@@ -376,9 +407,10 @@ async def start(self) -> None:
logger.error("Action agent initialization failed")
raise Exception("Action agent initialization failed")
- if not self.event_agent:
- logger.error("Event agent initialization failed")
- raise Exception("Event agent initialization failed")
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # if not self.event_agent:
+ # logger.error("Event agent initialization failed")
+ # raise Exception("Event agent initialization failed")
if not self.session_agent:
logger.error("Session agent initialization failed")
@@ -401,15 +433,18 @@ async def start(self) -> None:
raise Exception("Cleanup agent initialization failed")
# Start all components in parallel (they are independent)
+ # NOTE: Perception manager is NOT started by default - it will be started
+ # when a Pomodoro session begins (Active mode strategy)
logger.debug(
- "Starting perception manager, processing pipeline, agents in parallel..."
+ "Starting processing pipeline and agents (perception will start with Pomodoro)..."
)
start_time = datetime.now()
await asyncio.gather(
- self.perception_manager.start(),
+ # self.perception_manager.start(), # Disabled: starts with Pomodoro
self.processing_pipeline.start(),
- self.event_agent.start(),
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # self.event_agent.start(),
self.session_agent.start(),
self.diary_agent.start(),
self.cleanup_agent.start(),
@@ -420,6 +455,12 @@ async def start(self) -> None:
f"All components started (took {elapsed:.2f}s)"
)
+ # Check for orphaned Pomodoro sessions from previous run
+ if self.pomodoro_manager:
+ orphaned_count = await self.pomodoro_manager.check_orphaned_sessions()
+ if orphaned_count > 0:
+ logger.info(f"✓ Recovered {orphaned_count} orphaned Pomodoro session(s)")
+
# Start scheduled processing loop
self.is_running = True
self._set_state(mode="running", error=None)
@@ -473,9 +514,10 @@ async def stop(self, *, quiet: bool = False) -> None:
await self.session_agent.stop()
log("Session agent stopped")
- if self.event_agent:
- await self.event_agent.stop()
- log("Event agent stopped")
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # if self.event_agent:
+ # await self.event_agent.stop()
+ # log("Event agent stopped")
# Note: ActionAgent has no start/stop methods (it's stateless)
@@ -501,92 +543,174 @@ async def stop(self, *, quiet: bool = False) -> None:
self._last_processed_timestamp = None
async def _processing_loop(self) -> None:
- """Scheduled processing loop"""
+ """Event-driven processing loop with fallback polling"""
try:
- # First iteration has shorter delay, then use normal interval
- first_iteration = True
last_ttl_cleanup = datetime.now() # Track last TTL cleanup time
+ last_fallback_check = datetime.now() # Track last fallback check
+
+ logger.info(
+ f"Processing loop started: event_driven={'enabled' if self.enable_event_driven else 'disabled'}, "
+ f"threshold={self.processing_threshold} records, "
+ f"fallback_interval={self.fallback_check_interval}s"
+ )
while self.is_running:
- # First iteration starts quickly (100ms), then use configured interval
- wait_time = 0.1 if first_iteration else self.processing_interval
- await asyncio.sleep(wait_time)
-
- if not self.is_running:
- break
-
- first_iteration = False
-
- # Skip processing if paused (system sleep)
- if self.is_paused:
- logger.debug("Coordinator paused, skipping processing cycle")
- continue
-
- # Periodic TTL cleanup for memory-only images
- now = datetime.now()
- if (now - last_ttl_cleanup).total_seconds() >= self.processing_interval:
- try:
- if self.processing_pipeline and self.processing_pipeline.image_manager:
- evicted = self.processing_pipeline.image_manager.cleanup_expired_memory_images()
- if evicted > 0:
- logger.debug(f"TTL cleanup: evicted {evicted} expired memory-only images")
- last_ttl_cleanup = now
- except Exception as e:
- logger.error(f"TTL cleanup failed: {e}")
-
- if not self.perception_manager:
- logger.error("Perception manager not initialized")
- raise Exception("Perception manager not initialized")
-
- if not self.processing_pipeline:
- logger.error("Processing pipeline not initialized")
- raise Exception("Processing pipeline not initialized")
-
- # Fetch records newer than the last processed timestamp to avoid duplicates
- end_time = datetime.now()
- if self._last_processed_timestamp is None:
- start_time = end_time - timedelta(seconds=self.processing_interval)
- else:
- start_time = self._last_processed_timestamp
-
- records = self.perception_manager.get_records_in_timeframe(
- start_time, end_time
- )
+ try:
+ if self.enable_event_driven:
+ # Wait for event trigger OR fallback timeout
+ try:
+ await asyncio.wait_for(
+ self._process_trigger.wait(),
+ timeout=self.fallback_check_interval
+ )
+ self._process_trigger.clear()
+ logger.debug("Processing triggered by event")
+ except asyncio.TimeoutError:
+ # Fallback check after timeout
+ now = datetime.now()
+ if (now - last_fallback_check).total_seconds() >= self.fallback_check_interval:
+ logger.debug(f"Fallback check after {self.fallback_check_interval}s timeout")
+ last_fallback_check = now
+ else:
+ continue
+ else:
+ # Legacy polling mode
+ await asyncio.sleep(self.processing_interval)
+
+ if not self.is_running:
+ break
+
+ # Skip processing if paused (system sleep)
+ if self.is_paused:
+ logger.debug("Coordinator paused, skipping processing cycle")
+ continue
+
+ # Periodic TTL cleanup for memory-only images
+ now = datetime.now()
+ if (now - last_ttl_cleanup).total_seconds() >= self.processing_interval:
+ try:
+ if self.processing_pipeline and self.processing_pipeline.image_manager:
+ evicted = self.processing_pipeline.image_manager.cleanup_expired_memory_images()
+ if evicted > 0:
+ logger.debug(f"TTL cleanup: evicted {evicted} expired memory-only images")
+ last_ttl_cleanup = now
+ except Exception as e:
+ logger.error(f"TTL cleanup failed: {e}")
+
+ if not self.perception_manager:
+ logger.error("Perception manager not initialized")
+ raise Exception("Perception manager not initialized")
+
+ if not self.processing_pipeline:
+ logger.error("Processing pipeline not initialized")
+ raise Exception("Processing pipeline not initialized")
+
+ # CRITICAL FIX: Check for expiring records BEFORE normal processing
+ # This prevents records from being auto-cleaned before they can be processed into actions
+ # Particularly important during Pomodoro mode when user may be idle (reading, thinking)
+ expiring_records = self.perception_manager.get_expiring_records()
+ if expiring_records and self._last_processed_timestamp:
+ # Filter out already processed records
+ expiring_records = [
+ record
+ for record in expiring_records
+ if record.timestamp > self._last_processed_timestamp
+ ]
+
+ if expiring_records:
+ logger.warning(
+ f"Found {len(expiring_records)} records about to expire, "
+ f"forcing processing to prevent data loss"
+ )
+ # Force process expiring records immediately
+ result = await self.processing_pipeline.process_raw_records(expiring_records)
+
+ # Update last processed timestamp
+ latest_record_time = max(
+ (record.timestamp for record in expiring_records), default=None
+ )
+ if latest_record_time:
+ self._last_processed_timestamp = latest_record_time
+
+ # Update statistics
+ self.stats["total_processing_cycles"] += 1
+ self.stats["last_processing_time"] = datetime.now()
+ self._pending_records_count = 0
+
+ logger.info(f"Processed {len(expiring_records)} expiring records to prevent loss")
+
+ # Fetch records newer than the last processed timestamp to avoid duplicates
+ end_time = datetime.now()
+ if self._last_processed_timestamp is None:
+ start_time = end_time - timedelta(seconds=self.processing_interval)
+ else:
+ start_time = self._last_processed_timestamp
+
+ records = self.perception_manager.get_records_in_timeframe(
+ start_time, end_time
+ )
- if self._last_processed_timestamp is not None:
- records = [
- record
- for record in records
- if record.timestamp > self._last_processed_timestamp
- ]
+ if self._last_processed_timestamp is not None:
+ records = [
+ record
+ for record in records
+ if record.timestamp > self._last_processed_timestamp
+ ]
- if records:
- logger.debug(f"Starting to process {len(records)} records")
+ if records:
+ logger.info(f"Processing {len(records)} records (triggered by: {'event' if self.enable_event_driven else 'polling'})")
- # Process records
- result = await self.processing_pipeline.process_raw_records(records)
+ # Reset pending count
+ self._pending_records_count = 0
- # Update last processed timestamp so future cycles skip these records
- latest_record_time = max(
- (record.timestamp for record in records), default=None
- )
- if latest_record_time:
- self._last_processed_timestamp = latest_record_time
+ # Process records
+ result = await self.processing_pipeline.process_raw_records(records)
- # Update statistics
- self.stats["total_processing_cycles"] += 1
- self.stats["last_processing_time"] = datetime.now()
+ # Update last processed timestamp so future cycles skip these records
+ latest_record_time = max(
+ (record.timestamp for record in records), default=None
+ )
+ if latest_record_time:
+ self._last_processed_timestamp = latest_record_time
- logger.debug(
- f"Processing completed: {len(result.get('events', []))} events, {len(result.get('activities', []))} activities"
- )
- else:
- logger.debug("No new records to process")
+ # Update statistics
+ self.stats["total_processing_cycles"] += 1
+ self.stats["last_processing_time"] = datetime.now()
+
+ logger.debug(
+ f"Processing completed: {len(result.get('events', []))} events, {len(result.get('activities', []))} activities"
+ )
+ # No "else" logging when no records to reduce noise
+
+ except Exception as loop_error:
+ logger.error(f"Error in processing loop iteration: {loop_error}", exc_info=True)
+ # Continue running despite errors
except asyncio.CancelledError:
logger.debug("Processing loop cancelled")
except Exception as e:
- logger.error(f"Processing loop failed: {e}")
+ logger.error(f"Processing loop failed: {e}", exc_info=True)
+
+ def notify_records_available(self, count: int = 1) -> None:
+ """
+ Notify coordinator that new records are available (called by PerceptionManager)
+
+ This enables event-driven processing instead of polling.
+
+ Args:
+ count: Number of new records added
+ """
+ if not self.enable_event_driven:
+ return
+
+ self._pending_records_count += count
+
+ # Trigger processing if threshold reached
+ if self._pending_records_count >= self.processing_threshold:
+ logger.debug(
+ f"Triggering processing: {self._pending_records_count} records >= threshold {self.processing_threshold}"
+ )
+ self._process_trigger.set()
def get_stats(self) -> Dict[str, Any]:
"""Get coordinator statistics"""
@@ -596,7 +720,8 @@ def get_stats(self) -> Dict[str, Any]:
perception_stats = {}
processing_stats = {}
action_agent_stats = {}
- event_agent_stats = {}
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # event_agent_stats = {}
session_agent_stats = {}
todo_agent_stats = {}
knowledge_agent_stats = {}
@@ -611,8 +736,9 @@ def get_stats(self) -> Dict[str, Any]:
if self.action_agent:
action_agent_stats = self.action_agent.get_stats()
- if self.event_agent:
- event_agent_stats = self.event_agent.get_stats()
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # if self.event_agent:
+ # event_agent_stats = self.event_agent.get_stats()
if self.session_agent:
session_agent_stats = self.session_agent.get_stats()
@@ -649,7 +775,8 @@ def get_stats(self) -> Dict[str, Any]:
"perception": perception_stats,
"processing": processing_stats,
"action_agent": action_agent_stats,
- "event_agent": event_agent_stats,
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # "event_agent": event_agent_stats,
"session_agent": session_agent_stats,
"todo_agent": todo_agent_stats,
"knowledge_agent": knowledge_agent_stats,
@@ -663,6 +790,163 @@ def get_stats(self) -> Dict[str, Any]:
return {"error": str(e)}
+ async def enter_pomodoro_mode(self, session_id: str) -> None:
+ """
+ Enter Pomodoro mode - start perception and disable continuous processing
+
+ Changes:
+ 1. Start perception manager (if not already running)
+ 2. Stop processing_loop (cancel task)
+ 3. Set pomodoro_mode = True
+ 4. Set current_pomodoro_session_id
+ 5. Perception captures and tags records
+ 6. Records are saved to DB instead of processed
+
+ Args:
+ session_id: Pomodoro session identifier
+ """
+ logger.info(f"→ Entering Pomodoro mode: {session_id}")
+
+ self.pomodoro_mode = True
+ self.current_pomodoro_session_id = session_id
+
+ # Start perception manager if not already running
+ if self.perception_manager and not self.perception_manager.is_running:
+ try:
+ logger.info("Starting perception manager for Pomodoro mode...")
+ await self.perception_manager.start()
+ logger.info("✓ Perception manager started")
+ except Exception as e:
+ logger.error(f"Failed to start perception manager: {e}", exc_info=True)
+ raise
+ elif not self.perception_manager:
+ logger.error("Perception manager is None, cannot start")
+ else:
+ logger.debug("Perception manager already running")
+
+ # Keep processing loop running - do NOT cancel it
+ # This allows Actions (30s) to continue normally
+
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # # NEW: Pause EventAgent during Pomodoro mode (action-based aggregation)
+ # # We directly aggregate Actions → Activities, bypassing Events layer
+ # try:
+ # if self.event_agent:
+ # self.event_agent.pause()
+ # logger.debug("✓ EventAgent paused (using action-based aggregation)")
+ # except Exception as e:
+ # logger.error(f"Failed to pause EventAgent: {e}")
+
+ # Pause SessionAgent (activity generation deferred until phase ends)
+ try:
+ if self.session_agent:
+ self.session_agent.pause()
+ logger.debug("✓ SessionAgent paused (activity generation deferred)")
+ except Exception as e:
+ logger.error(f"Failed to pause SessionAgent: {e}")
+
+ # Notify perception manager of Pomodoro mode (for tagging records)
+ if self.perception_manager:
+ self.perception_manager.set_pomodoro_session(session_id)
+
+ logger.info(
+ "✓ Pomodoro mode active - normal processing continues, "
+ "activity generation paused until session ends"
+ )
+
+ async def exit_pomodoro_mode(self) -> None:
+ """
+ Exit Pomodoro mode - stop perception and trigger activity generation
+
+ When Pomodoro ends:
+ - Stop perception manager
+ - Resume SessionAgent
+ - Trigger immediate activity aggregation for accumulated Events
+ """
+ logger.info("→ Exiting Pomodoro mode")
+
+ self.pomodoro_mode = False
+ session_id = self.current_pomodoro_session_id
+ self.current_pomodoro_session_id = None
+
+ # Stop perception manager
+ if self.perception_manager and self.perception_manager.is_running:
+ try:
+ logger.debug("Stopping perception manager...")
+ await self.perception_manager.stop()
+ logger.debug("✓ Perception manager stopped")
+ except Exception as e:
+ logger.error(f"Failed to stop perception manager: {e}")
+
+ # Processing loop is still running - no need to resume
+
+ # DISABLED: EventAgent removed - using action-based aggregation only
+ # # Resume EventAgent (for Normal Mode event generation)
+ # try:
+ # if self.event_agent:
+ # self.event_agent.resume()
+ # logger.debug("✓ EventAgent resumed (Normal Mode event generation)")
+ # except Exception as e:
+ # logger.error(f"Failed to resume EventAgent: {e}")
+
+ # Resume SessionAgent (no need to trigger aggregation, already done per work phase)
+ try:
+ if self.session_agent:
+ self.session_agent.resume()
+ logger.debug("✓ SessionAgent resumed (activities already aggregated per work phase)")
+ except Exception as e:
+ logger.error(f"Failed to resume SessionAgent: {e}")
+
+ # Notify perception manager to exit Pomodoro mode
+ if self.perception_manager:
+ self.perception_manager.clear_pomodoro_session()
+
+ logger.info(f"✓ Idle mode resumed - perception stopped (exited session: {session_id})")
+
+ async def force_process_records(self, records: List[Any]) -> Dict[str, Any]:
+ """
+ Force process records immediately (used for phase settlement)
+
+ This method bypasses the normal processing loop and directly processes
+ records through the pipeline. Used to ensure no data loss during phase transitions.
+
+ Args:
+ records: List of RawRecord objects to process
+
+ Returns:
+ Processing result with events and activities count
+ """
+ if not records:
+ logger.debug("No records to force process")
+ return {"processed": 0, "events": [], "activities": []}
+
+ logger.info(f"Force processing {len(records)} records for phase settlement")
+
+ try:
+ if not self.processing_pipeline:
+ logger.error("Processing pipeline not available")
+ return {"error": "Processing pipeline not available"}
+
+ # Directly process through the pipeline
+ result = await self.processing_pipeline.process_raw_records(records)
+
+ # Update last processed timestamp
+ if records:
+ latest_record_time = max(record.timestamp for record in records)
+ self._last_processed_timestamp = latest_record_time
+
+ logger.info(
+ f"✓ Force processing completed: "
+ f"{len(result.get('events', []))} events, {len(result.get('activities', []))} activities"
+ )
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Failed to force process records: {e}", exc_info=True)
+ return {"error": str(e)}
+
+
def get_coordinator() -> PipelineCoordinator:
"""Get global coordinator singleton"""
global _coordinator
diff --git a/backend/core/db/__init__.py b/backend/core/db/__init__.py
index 9f3c36f..f812584 100644
--- a/backend/core/db/__init__.py
+++ b/backend/core/db/__init__.py
@@ -16,12 +16,16 @@
# Three-layer architecture repositories
from .actions import ActionsRepository
from .activities import ActivitiesRepository
+from .activity_ratings import ActivityRatingsRepository
from .base import BaseRepository
from .conversations import ConversationsRepository, MessagesRepository
from .diaries import DiariesRepository
from .events import EventsRepository
from .knowledge import KnowledgeRepository
from .models import LLMModelsRepository
+from .pomodoro_sessions import PomodoroSessionsRepository
+from .pomodoro_work_phases import PomodoroWorkPhasesRepository
+from .raw_records import RawRecordsRepository
from .session_preferences import SessionPreferencesRepository
from .settings import SettingsRepository
from .todos import TodosRepository
@@ -69,76 +73,41 @@ def __init__(self, db_path: Path):
self.actions = ActionsRepository(db_path)
self.session_preferences = SessionPreferencesRepository(db_path)
+ # Pomodoro feature repositories
+ self.pomodoro_sessions = PomodoroSessionsRepository(db_path)
+ self.work_phases = PomodoroWorkPhasesRepository(db_path)
+ self.raw_records = RawRecordsRepository(db_path)
+
+ # Activity ratings repository
+ self.activity_ratings = ActivityRatingsRepository(db_path)
+
logger.debug(f"✓ DatabaseManager initialized with path: {db_path}")
def _initialize_database(self):
"""
- Initialize database schema - create all tables and indexes
+ Initialize database schema using version-based migrations
This is called automatically when DatabaseManager is instantiated.
- It ensures all required tables and indexes exist.
+ It runs all pending migrations to ensure database is up to date.
"""
- import sqlite3
-
- from core.sqls import migrations, schema
+ from migrations import MigrationRunner
try:
- conn = sqlite3.connect(str(self.db_path))
- cursor = conn.cursor()
+ # Create migration runner
+ runner = MigrationRunner(self.db_path)
- # Create all tables
- for table_sql in schema.ALL_TABLES:
- cursor.execute(table_sql)
-
- # Create all indexes
- for index_sql in schema.ALL_INDEXES:
- cursor.execute(index_sql)
-
- # Run migrations for new columns
- self._run_migrations(cursor)
-
- conn.commit()
- conn.close()
+ # Run all pending migrations
+ executed_count = runner.run_migrations()
- logger.debug(f"✓ Database schema initialized: {len(schema.ALL_TABLES)} tables, {len(schema.ALL_INDEXES)} indexes")
+ if executed_count > 0:
+ logger.info(f"✓ Database schema initialized: {executed_count} migration(s) executed")
+ else:
+ logger.debug("✓ Database schema up to date")
except Exception as e:
logger.error(f"Failed to initialize database schema: {e}", exc_info=True)
raise
- def _run_migrations(self, cursor):
- """
- Run database migrations to add new columns to existing tables
-
- Args:
- cursor: Database cursor
- """
- import sqlite3
-
- from core.sqls import migrations
-
- # List of migrations to run (column name, migration SQL)
- migration_list = [
- ("actions.extract_knowledge", migrations.ADD_ACTIONS_EXTRACT_KNOWLEDGE_COLUMN),
- ("actions.knowledge_extracted", migrations.ADD_ACTIONS_KNOWLEDGE_EXTRACTED_COLUMN),
- ("knowledge.source_action_id", migrations.ADD_KNOWLEDGE_SOURCE_ACTION_ID_COLUMN),
- ]
-
- for column_desc, migration_sql in migration_list:
- try:
- cursor.execute(migration_sql)
- logger.info(f"✓ Migration applied: {column_desc}")
- except sqlite3.OperationalError as e:
- error_msg = str(e).lower()
- # Column might already exist, which is fine
- if "duplicate column" in error_msg or "already exists" in error_msg:
- logger.debug(f"Column {column_desc} already exists, skipping")
- else:
- # Real error, log as warning but continue
- logger.warning(f"Migration failed for {column_desc}: {e}")
- except Exception as e:
- # Unexpected error
- logger.error(f"Unexpected error in migration for {column_desc}: {e}", exc_info=True)
def get_connection(self):
"""
@@ -305,8 +274,9 @@ def get_db() -> DatabaseManager:
config = get_config()
- # Read database path from config.toml
- configured_path = config.get("database.path", "")
+ # Read database path from config.toml (access nested config correctly)
+ database_config = config.get("database", {})
+ configured_path = database_config.get("path", "")
# If path is configured and not empty, use it; otherwise use default
if configured_path and configured_path.strip():
@@ -380,6 +350,9 @@ def switch_database(new_db_path: str) -> bool:
"LLMModelsRepository",
"ActionsRepository",
"SessionPreferencesRepository",
+ "PomodoroSessionsRepository",
+ "RawRecordsRepository",
+ "ActivityRatingsRepository",
# Unified manager
"DatabaseManager",
# Global access functions
diff --git a/backend/core/db/actions.py b/backend/core/db/actions.py
index ae0ea98..fbe19b5 100644
--- a/backend/core/db/actions.py
+++ b/backend/core/db/actions.py
@@ -508,3 +508,90 @@ def get_all_referenced_image_hashes(self) -> set:
except Exception as e:
logger.error(f"Failed to get referenced image hashes: {e}", exc_info=True)
return set()
+
+ async def get_all_actions_with_screenshots(
+ self, limit: Optional[int] = None
+ ) -> List[Dict[str, Any]]:
+ """Get all actions that have screenshot references
+
+ Used for image persistence health checks to validate that referenced
+ images actually exist on disk.
+
+ Args:
+ limit: Maximum number of actions to return (None = unlimited)
+
+ Returns:
+ List of {id, created_at, screenshots: [...hashes]}
+ """
+ try:
+ query = """
+ SELECT DISTINCT a.id, a.created_at
+ FROM actions a
+ INNER JOIN action_images ai ON a.id = ai.action_id
+ WHERE a.deleted = 0
+ ORDER BY a.created_at DESC
+ """
+ if limit:
+ query += f" LIMIT {limit}"
+
+ with self._get_conn() as conn:
+ cursor = conn.execute(query)
+ rows = cursor.fetchall()
+
+ actions = []
+ for row in rows:
+ screenshots = await self._load_screenshots(row["id"])
+ if screenshots: # Only include if has screenshots
+ actions.append({
+ "id": row["id"],
+ "created_at": row["created_at"],
+ "screenshots": screenshots,
+ })
+
+ logger.debug(
+ f"Found {len(actions)} actions with screenshots"
+ + (f" (limit: {limit})" if limit else "")
+ )
+ return actions
+
+ except Exception as e:
+ logger.error(f"Failed to get actions with screenshots: {e}", exc_info=True)
+ return []
+
+ async def remove_screenshots(self, action_id: str) -> int:
+ """Remove all screenshot references from an action
+
+ Deletes all entries in action_images table for the given action,
+ effectively clearing the image references while keeping the action itself.
+
+ Args:
+ action_id: Action ID
+
+ Returns:
+ Number of references removed
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ "SELECT COUNT(*) as count FROM action_images WHERE action_id = ?",
+ (action_id,),
+ )
+ count = cursor.fetchone()["count"]
+
+ conn.execute(
+ "DELETE FROM action_images WHERE action_id = ?",
+ (action_id,),
+ )
+ conn.commit()
+
+ logger.debug(
+ f"Removed {count} screenshot references from action {action_id}"
+ )
+ return count
+
+ except Exception as e:
+ logger.error(
+ f"Failed to remove screenshots from action {action_id}: {e}",
+ exc_info=True,
+ )
+ raise
diff --git a/backend/core/db/activities.py b/backend/core/db/activities.py
index f82367e..21369f2 100644
--- a/backend/core/db/activities.py
+++ b/backend/core/db/activities.py
@@ -27,23 +27,62 @@ async def save(
description: str,
start_time: str,
end_time: str,
- source_event_ids: List[str],
+ source_event_ids: Optional[List[str]] = None,
+ source_action_ids: Optional[List[str]] = None,
+ aggregation_mode: str = "action_based",
session_duration_minutes: Optional[int] = None,
topic_tags: Optional[List[str]] = None,
user_merged_from_ids: Optional[List[str]] = None,
user_split_into_ids: Optional[List[str]] = None,
+ pomodoro_session_id: Optional[str] = None,
+ pomodoro_work_phase: Optional[int] = None,
+ focus_score: Optional[float] = None,
) -> None:
- """Save or update an activity (work session)"""
+ """
+ Save or update an activity (work session)
+
+ Args:
+ activity_id: Unique activity ID
+ title: Activity title
+ description: Activity description
+ start_time: Activity start time (ISO format)
+ end_time: Activity end time (ISO format)
+ source_event_ids: List of event IDs (for event-based aggregation, deprecated)
+ source_action_ids: List of action IDs (for action-based aggregation, preferred)
+ aggregation_mode: 'event_based' or 'action_based' (default: 'action_based')
+ session_duration_minutes: Session duration in minutes
+ topic_tags: List of topic tags
+ user_merged_from_ids: IDs of activities merged by user
+ user_split_into_ids: IDs of activities split by user
+ pomodoro_session_id: Associated Pomodoro session ID
+ pomodoro_work_phase: Work phase number (1-4)
+ focus_score: Focus metric (0.0-1.0)
+
+ Raises:
+ ValueError: If neither source_event_ids nor source_action_ids is provided
+ """
+ # Validation: at least one source type must be provided
+ if not source_event_ids and not source_action_ids:
+ raise ValueError("Either source_event_ids or source_action_ids must be provided")
+
+ # Auto-detect aggregation mode if source_action_ids is provided
+ if source_action_ids:
+ aggregation_mode = "action_based"
+ elif source_event_ids and not source_action_ids:
+ aggregation_mode = "event_based"
+
try:
with self._get_conn() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO activities (
id, title, description, start_time, end_time,
- source_event_ids, session_duration_minutes, topic_tags,
+ source_event_ids, source_action_ids, aggregation_mode,
+ session_duration_minutes, topic_tags,
user_merged_from_ids, user_split_into_ids,
+ pomodoro_session_id, pomodoro_work_phase, focus_score,
created_at, updated_at, deleted
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)
""",
(
activity_id,
@@ -51,15 +90,20 @@ async def save(
description,
start_time,
end_time,
- json.dumps(source_event_ids),
+ json.dumps(source_event_ids) if source_event_ids else None,
+ json.dumps(source_action_ids) if source_action_ids else None,
+ aggregation_mode,
session_duration_minutes,
json.dumps(topic_tags) if topic_tags else None,
json.dumps(user_merged_from_ids) if user_merged_from_ids else None,
json.dumps(user_split_into_ids) if user_split_into_ids else None,
+ pomodoro_session_id,
+ pomodoro_work_phase,
+ focus_score,
),
)
conn.commit()
- logger.debug(f"Saved activity: {activity_id}")
+ logger.debug(f"Saved activity: {activity_id} (mode: {aggregation_mode})")
except Exception as e:
logger.error(f"Failed to save activity {activity_id}: {e}", exc_info=True)
raise
@@ -145,8 +189,10 @@ async def get_recent(
cursor = conn.execute(
f"""
SELECT id, title, description, start_time, end_time,
- source_event_ids, session_duration_minutes, topic_tags,
+ source_event_ids, source_action_ids, aggregation_mode,
+ session_duration_minutes, topic_tags,
user_merged_from_ids, user_split_into_ids,
+ pomodoro_session_id, pomodoro_work_phase, focus_score,
created_at, updated_at
FROM activities
WHERE {where_clause}
@@ -170,8 +216,10 @@ async def get_by_id(self, activity_id: str) -> Optional[Dict[str, Any]]:
cursor = conn.execute(
"""
SELECT id, title, description, start_time, end_time,
- source_event_ids, session_duration_minutes, topic_tags,
+ source_event_ids, source_action_ids, aggregation_mode,
+ session_duration_minutes, topic_tags,
user_merged_from_ids, user_split_into_ids,
+ pomodoro_session_id, pomodoro_work_phase, focus_score,
created_at, updated_at
FROM activities
WHERE id = ? AND deleted = 0
@@ -208,8 +256,10 @@ async def get_by_ids(self, activity_ids: List[str]) -> List[Dict[str, Any]]:
cursor = conn.execute(
f"""
SELECT id, title, description, start_time, end_time,
- source_event_ids, session_duration_minutes, topic_tags,
+ source_event_ids, source_action_ids, aggregation_mode,
+ session_duration_minutes, topic_tags,
user_merged_from_ids, user_split_into_ids,
+ pomodoro_session_id, pomodoro_work_phase, focus_score,
created_at, updated_at
FROM activities
WHERE id IN ({placeholders}) AND deleted = 0
@@ -242,8 +292,10 @@ async def get_by_date(
cursor = conn.execute(
"""
SELECT id, title, description, start_time, end_time,
- source_event_ids, session_duration_minutes, topic_tags,
+ source_event_ids, source_action_ids, aggregation_mode,
+ session_duration_minutes, topic_tags,
user_merged_from_ids, user_split_into_ids,
+ pomodoro_session_id, pomodoro_work_phase, focus_score,
created_at, updated_at
FROM activities
WHERE deleted = 0
@@ -404,6 +456,13 @@ async def get_count_by_date(self) -> Dict[str, int]:
def _row_to_dict(self, row) -> Dict[str, Any]:
"""Convert database row to dictionary"""
+ # Helper function to safely get column value
+ def safe_get(row, key, default=None):
+ try:
+ return row[key]
+ except (KeyError, IndexError):
+ return default
+
return {
"id": row["id"],
"title": row["title"],
@@ -413,6 +472,10 @@ def _row_to_dict(self, row) -> Dict[str, Any]:
"source_event_ids": json.loads(row["source_event_ids"])
if row["source_event_ids"]
else [],
+ "source_action_ids": json.loads(safe_get(row, "source_action_ids"))
+ if safe_get(row, "source_action_ids")
+ else [],
+ "aggregation_mode": safe_get(row, "aggregation_mode", "action_based"),
"session_duration_minutes": row["session_duration_minutes"],
"topic_tags": json.loads(row["topic_tags"]) if row["topic_tags"] else [],
"user_merged_from_ids": json.loads(row["user_merged_from_ids"])
@@ -421,6 +484,277 @@ def _row_to_dict(self, row) -> Dict[str, Any]:
"user_split_into_ids": json.loads(row["user_split_into_ids"])
if row["user_split_into_ids"]
else None,
+ "pomodoro_session_id": safe_get(row, "pomodoro_session_id"),
+ "pomodoro_work_phase": safe_get(row, "pomodoro_work_phase"),
+ "focus_score": safe_get(row, "focus_score"),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
+
+ async def get_by_pomodoro_session(
+ self, session_id: str
+ ) -> List[Dict[str, Any]]:
+ """
+ Get all activities associated with a Pomodoro session
+
+ Args:
+ session_id: Pomodoro session ID
+
+ Returns:
+ List of activity dictionaries, ordered by work phase and start time
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT id, title, description, start_time, end_time,
+ source_event_ids, source_action_ids, aggregation_mode,
+ session_duration_minutes, topic_tags,
+ pomodoro_session_id, pomodoro_work_phase, focus_score,
+ user_merged_from_ids, user_split_into_ids,
+ created_at, updated_at
+ FROM activities
+ WHERE pomodoro_session_id = ? AND deleted = 0
+ ORDER BY pomodoro_work_phase ASC, start_time ASC
+ """,
+ (session_id,),
+ )
+ rows = cursor.fetchall()
+
+ activities = [self._row_to_dict(row) for row in rows]
+
+ logger.debug(
+ f"Retrieved {len(activities)} activities for Pomodoro session {session_id}"
+ )
+
+ return activities
+
+ except Exception as e:
+ logger.error(
+ f"Failed to get activities for Pomodoro session {session_id}: {e}",
+ exc_info=True,
+ )
+ return []
+
+ async def find_unlinked_overlapping_activities(
+ self,
+ session_start_time: str,
+ session_end_time: str,
+ ) -> List[Dict[str, Any]]:
+ """
+ Find activities that overlap with session time and have no pomodoro_session_id
+
+ Overlap logic: activity overlaps if activity.start_time < session.end_time
+ AND activity.end_time > session.start_time
+
+ Args:
+ session_start_time: Session start (ISO format)
+ session_end_time: Session end (ISO format)
+
+ Returns:
+ List of unlinked activity dictionaries
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT id, title, description, start_time, end_time,
+ source_event_ids, source_action_ids, aggregation_mode,
+ session_duration_minutes, topic_tags,
+ pomodoro_session_id, pomodoro_work_phase, focus_score,
+ user_merged_from_ids, user_split_into_ids,
+ created_at, updated_at
+ FROM activities
+ WHERE deleted = 0
+ AND pomodoro_session_id IS NULL
+ AND start_time < ?
+ AND end_time > ?
+ ORDER BY start_time ASC
+ """,
+ (session_end_time, session_start_time),
+ )
+ rows = cursor.fetchall()
+
+ activities = [self._row_to_dict(row) for row in rows]
+
+ logger.debug(
+ f"Found {len(activities)} unlinked activities overlapping "
+ f"{session_start_time} - {session_end_time}"
+ )
+
+ return activities
+
+ except Exception as e:
+ logger.error(
+ f"Failed to find overlapping activities: {e}",
+ exc_info=True,
+ )
+ return []
+
+ async def link_activities_to_session(
+ self,
+ activity_ids: List[str],
+ session_id: str,
+ work_phase: Optional[int] = None,
+ ) -> int:
+ """
+ Link multiple activities to a Pomodoro session
+
+ Args:
+ activity_ids: List of activity IDs to link
+ session_id: Pomodoro session ID
+ work_phase: Optional work phase number (if known)
+
+ Returns:
+ Number of activities linked
+ """
+ try:
+ if not activity_ids:
+ return 0
+
+ placeholders = ",".join("?" * len(activity_ids))
+ params = [session_id, work_phase] + activity_ids
+
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ f"""
+ UPDATE activities
+ SET pomodoro_session_id = ?,
+ pomodoro_work_phase = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id IN ({placeholders})
+ AND deleted = 0
+ AND pomodoro_session_id IS NULL
+ """,
+ params,
+ )
+ conn.commit()
+ linked_count = cursor.rowcount
+
+ logger.info(
+ f"Linked {linked_count} activities to session {session_id}"
+ )
+
+ return linked_count
+
+ except Exception as e:
+ logger.error(
+ f"Failed to link activities to session: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def delete_by_session_id(self, session_id: str) -> int:
+ """
+ Soft delete all activities linked to a Pomodoro session
+ Used for cascade deletion when a session is deleted
+
+ Args:
+ session_id: Pomodoro session ID
+
+ Returns:
+ Number of activities deleted
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE activities
+ SET deleted = 1, updated_at = CURRENT_TIMESTAMP
+ WHERE pomodoro_session_id = ? AND deleted = 0
+ """,
+ (session_id,),
+ )
+ conn.commit()
+ deleted_count = cursor.rowcount
+
+ logger.info(
+ f"Cascade deleted {deleted_count} activities for session {session_id}"
+ )
+ return deleted_count
+
+ except Exception as e:
+ logger.error(
+ f"Failed to cascade delete activities for session {session_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def update_focus_score(self, activity_id: str, focus_score: float) -> None:
+ """
+ Update focus score for a specific activity
+
+ Args:
+ activity_id: Activity ID
+ focus_score: Focus score (0.0-100.0)
+ """
+ try:
+ # Ensure focus_score is within valid range
+ focus_score = max(0.0, min(100.0, focus_score))
+
+ with self._get_conn() as conn:
+ conn.execute(
+ """
+ UPDATE activities
+ SET focus_score = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (focus_score, activity_id),
+ )
+ conn.commit()
+
+ logger.debug(f"Updated focus_score for activity {activity_id}: {focus_score}")
+
+ except Exception as e:
+ logger.error(
+ f"Failed to update focus_score for activity {activity_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def batch_update_focus_scores(
+ self, activity_scores: List[Dict[str, Any]]
+ ) -> int:
+ """
+ Batch update focus scores for multiple activities
+
+ Args:
+ activity_scores: List of dicts with 'activity_id' and 'focus_score' keys
+
+ Returns:
+ Number of activities updated
+ """
+ if not activity_scores:
+ return 0
+
+ try:
+ with self._get_conn() as conn:
+ updated_count = 0
+ for item in activity_scores:
+ activity_id = item.get("activity_id")
+ focus_score = item.get("focus_score", 0.0)
+
+ if not activity_id:
+ continue
+
+ # Ensure focus_score is within valid range
+ focus_score = max(0.0, min(100.0, focus_score))
+
+ cursor = conn.execute(
+ """
+ UPDATE activities
+ SET focus_score = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (focus_score, activity_id),
+ )
+ updated_count += cursor.rowcount
+
+ conn.commit()
+
+ logger.info(f"Batch updated focus_scores for {updated_count} activities")
+ return updated_count
+
+ except Exception as e:
+ logger.error(f"Failed to batch update focus_scores: {e}", exc_info=True)
+ raise
diff --git a/backend/core/db/activity_ratings.py b/backend/core/db/activity_ratings.py
new file mode 100644
index 0000000..6665271
--- /dev/null
+++ b/backend/core/db/activity_ratings.py
@@ -0,0 +1,199 @@
+"""
+ActivityRatings Repository - Handles multi-dimensional activity ratings
+Manages user ratings for activities across different dimensions (focus, productivity, etc.)
+"""
+
+import uuid
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from core.logger import get_logger
+
+from .base import BaseRepository
+
+logger = get_logger(__name__)
+
+
+class ActivityRatingsRepository(BaseRepository):
+ """Repository for managing activity ratings in the database"""
+
+ def __init__(self, db_path: Path):
+ super().__init__(db_path)
+
+ async def save_rating(
+ self,
+ activity_id: str,
+ dimension: str,
+ rating: int,
+ note: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """
+ Save or update a rating for an activity dimension
+
+ Args:
+ activity_id: Activity ID
+ dimension: Rating dimension (e.g., 'focus_level', 'productivity')
+ rating: Rating value (1-5)
+ note: Optional note/comment
+
+ Returns:
+ The saved rating record
+
+ Raises:
+ ValueError: If rating is out of range (1-5)
+ """
+ if not 1 <= rating <= 5:
+ raise ValueError(f"Rating must be between 1 and 5, got {rating}")
+
+ try:
+ rating_id = str(uuid.uuid4())
+
+ with self._get_conn() as conn:
+ # Use INSERT OR REPLACE to handle updates
+ # SQLite will replace if (activity_id, dimension) already exists
+ conn.execute(
+ """
+ INSERT INTO activity_ratings (
+ id, activity_id, dimension, rating, note,
+ created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ ON CONFLICT(activity_id, dimension)
+ DO UPDATE SET
+ rating = excluded.rating,
+ note = excluded.note,
+ updated_at = CURRENT_TIMESTAMP
+ """,
+ (rating_id, activity_id, dimension, rating, note),
+ )
+ conn.commit()
+
+ # Fetch the saved rating
+ cursor = conn.execute(
+ """
+ SELECT id, activity_id, dimension, rating, note,
+ created_at, updated_at
+ FROM activity_ratings
+ WHERE activity_id = ? AND dimension = ?
+ """,
+ (activity_id, dimension),
+ )
+ row = cursor.fetchone()
+
+ logger.debug(
+ f"Saved rating for activity {activity_id}, "
+ f"dimension {dimension}: {rating}"
+ )
+
+ if not row:
+ raise ValueError(f"Failed to retrieve saved rating")
+
+ result = self._row_to_dict(row)
+ if not result:
+ raise ValueError(f"Failed to convert rating to dict")
+
+ return result
+
+ except Exception as e:
+ logger.error(
+ f"Failed to save rating for activity {activity_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def get_ratings_by_activity(
+ self, activity_id: str
+ ) -> List[Dict[str, Any]]:
+ """
+ Get all ratings for an activity
+
+ Args:
+ activity_id: Activity ID
+
+ Returns:
+ List of rating records
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT id, activity_id, dimension, rating, note,
+ created_at, updated_at
+ FROM activity_ratings
+ WHERE activity_id = ?
+ ORDER BY dimension
+ """,
+ (activity_id,),
+ )
+ rows = cursor.fetchall()
+ return [self._row_to_dict(row) for row in rows]
+
+ except Exception as e:
+ logger.error(
+ f"Failed to get ratings for activity {activity_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def delete_rating(self, activity_id: str, dimension: str) -> None:
+ """
+ Delete a specific rating
+
+ Args:
+ activity_id: Activity ID
+ dimension: Rating dimension
+ """
+ try:
+ with self._get_conn() as conn:
+ conn.execute(
+ """
+ DELETE FROM activity_ratings
+ WHERE activity_id = ? AND dimension = ?
+ """,
+ (activity_id, dimension),
+ )
+ conn.commit()
+ logger.debug(
+ f"Deleted rating for activity {activity_id}, dimension {dimension}"
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Failed to delete rating for activity {activity_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def get_average_ratings_by_dimension(
+ self, start_date: str, end_date: str
+ ) -> Dict[str, float]:
+ """
+ Get average ratings by dimension for a date range
+
+ Args:
+ start_date: Start date (YYYY-MM-DD)
+ end_date: End date (YYYY-MM-DD)
+
+ Returns:
+ Dict mapping dimension to average rating
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT ar.dimension, AVG(ar.rating) as avg_rating
+ FROM activity_ratings ar
+ JOIN activities a ON ar.activity_id = a.id
+ WHERE DATE(a.start_time) >= ? AND DATE(a.start_time) <= ?
+ GROUP BY ar.dimension
+ """,
+ (start_date, end_date),
+ )
+ rows = cursor.fetchall()
+ return {row[0]: row[1] for row in rows}
+
+ except Exception as e:
+ logger.error(
+ f"Failed to get average ratings for date range {start_date} to {end_date}: {e}",
+ exc_info=True,
+ )
+ raise
diff --git a/backend/core/db/events.py b/backend/core/db/events.py
index 6206803..e3de1dc 100644
--- a/backend/core/db/events.py
+++ b/backend/core/db/events.py
@@ -29,6 +29,7 @@ async def save(
end_time: str,
source_action_ids: List[str],
version: int = 1,
+ pomodoro_session_id: Optional[str] = None,
) -> None:
"""Save or update an event"""
try:
@@ -37,8 +38,8 @@ async def save(
"""
INSERT OR REPLACE INTO events (
id, title, description, start_time, end_time,
- source_action_ids, version, created_at, deleted
- ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 0)
+ source_action_ids, version, pomodoro_session_id, created_at, deleted
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 0)
""",
(
event_id,
@@ -48,10 +49,11 @@ async def save(
end_time,
json.dumps(source_action_ids),
version,
+ pomodoro_session_id,
),
)
conn.commit()
- logger.debug(f"Saved event: {event_id}")
+ logger.debug(f"Saved event: {event_id}" + (f" (session: {pomodoro_session_id})" if pomodoro_session_id else ""))
except Exception as e:
logger.error(f"Failed to save event {event_id}: {e}", exc_info=True)
raise
diff --git a/backend/core/db/knowledge.py b/backend/core/db/knowledge.py
index 00a9d01..8744726 100644
--- a/backend/core/db/knowledge.py
+++ b/backend/core/db/knowledge.py
@@ -29,6 +29,7 @@ async def save(
*,
created_at: Optional[str] = None,
source_action_id: Optional[str] = None,
+ favorite: bool = False,
) -> None:
"""Save or update knowledge"""
try:
@@ -38,8 +39,8 @@ async def save(
"""
INSERT OR REPLACE INTO knowledge (
id, title, description, keywords,
- source_action_id, created_at, deleted
- ) VALUES (?, ?, ?, ?, ?, ?, 0)
+ source_action_id, created_at, deleted, favorite
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?)
""",
(
knowledge_id,
@@ -48,6 +49,7 @@ async def save(
json.dumps(keywords, ensure_ascii=False),
source_action_id,
created,
+ int(favorite),
),
)
conn.commit()
@@ -66,6 +68,7 @@ async def save(
"keywords": keywords,
"created_at": created,
"source_action_id": source_action_id,
+ "favorite": favorite,
"type": "original",
}
)
@@ -89,7 +92,7 @@ async def get_list(self, include_deleted: bool = False) -> List[Dict[str, Any]]:
with self._get_conn() as conn:
cursor = conn.execute(
f"""
- SELECT id, title, description, keywords, source_action_id, created_at, deleted
+ SELECT id, title, description, keywords, source_action_id, created_at, deleted, favorite
FROM knowledge
{base_where}
ORDER BY created_at DESC
@@ -99,6 +102,12 @@ async def get_list(self, include_deleted: bool = False) -> List[Dict[str, Any]]:
knowledge_list: List[Dict[str, Any]] = []
for row in rows:
+ # Handle favorite field which might not exist in older databases
+ try:
+ favorite = bool(row["favorite"])
+ except (KeyError, IndexError):
+ favorite = False
+
knowledge_list.append(
{
"id": row["id"],
@@ -110,6 +119,7 @@ async def get_list(self, include_deleted: bool = False) -> List[Dict[str, Any]]:
"source_action_id": row["source_action_id"],
"created_at": row["created_at"],
"deleted": bool(row["deleted"]),
+ "favorite": favorite,
}
)
@@ -139,6 +149,47 @@ async def delete(self, knowledge_id: str) -> None:
)
raise
+ async def update(
+ self,
+ knowledge_id: str,
+ title: str,
+ description: str,
+ keywords: List[str],
+ ) -> None:
+ """Update knowledge title, description, and keywords"""
+ try:
+ with self._get_conn() as conn:
+ conn.execute(
+ """
+ UPDATE knowledge
+ SET title = ?, description = ?, keywords = ?
+ WHERE id = ? AND deleted = 0
+ """,
+ (
+ title,
+ description,
+ json.dumps(keywords, ensure_ascii=False),
+ knowledge_id,
+ ),
+ )
+ conn.commit()
+ logger.debug(f"Updated knowledge: {knowledge_id}")
+
+ # Send event to frontend
+ from core.events import emit_knowledge_updated
+
+ emit_knowledge_updated({
+ "id": knowledge_id,
+ "title": title,
+ "description": description,
+ "keywords": keywords,
+ })
+ except Exception as e:
+ logger.error(
+ f"Failed to update knowledge {knowledge_id}: {e}", exc_info=True
+ )
+ raise
+
async def delete_batch(self, knowledge_ids: List[str]) -> int:
"""Soft delete multiple knowledge rows"""
if not knowledge_ids:
@@ -188,6 +239,97 @@ async def delete_by_date_range(self, start_iso: str, end_iso: str) -> int:
)
return 0
+ async def hard_delete(self, knowledge_id: str) -> bool:
+ """Hard delete knowledge from database (permanent deletion)"""
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ "DELETE FROM knowledge WHERE id = ?", (knowledge_id,)
+ )
+ conn.commit()
+
+ if cursor.rowcount > 0:
+ logger.debug(f"Hard deleted knowledge: {knowledge_id}")
+ # Send event to frontend
+ from core.events import emit_knowledge_deleted
+ emit_knowledge_deleted(knowledge_id)
+ return True
+ return False
+ except Exception as e:
+ logger.error(
+ f"Failed to hard delete knowledge {knowledge_id}: {e}", exc_info=True
+ )
+ raise
+
+ async def hard_delete_batch(self, knowledge_ids: List[str]) -> int:
+ """Hard delete multiple knowledge rows (permanent deletion)"""
+ if not knowledge_ids:
+ return 0
+
+ try:
+ placeholders = ",".join("?" for _ in knowledge_ids)
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ f"DELETE FROM knowledge WHERE id IN ({placeholders})",
+ knowledge_ids,
+ )
+ conn.commit()
+ deleted_count = cursor.rowcount
+
+ if deleted_count > 0:
+ logger.debug(f"Hard deleted {deleted_count} knowledge entries")
+
+ return deleted_count
+
+ except Exception as e:
+ logger.error(f"Failed to batch hard delete knowledge: {e}", exc_info=True)
+ return 0
+
+ async def toggle_favorite(self, knowledge_id: str) -> Optional[bool]:
+ """Toggle favorite status of knowledge
+
+ Returns:
+ New favorite status (True/False) if successful, None if knowledge not found
+ """
+ try:
+ with self._get_conn() as conn:
+ # Get current favorite status
+ cursor = conn.execute(
+ "SELECT favorite FROM knowledge WHERE id = ? AND deleted = 0",
+ (knowledge_id,)
+ )
+ row = cursor.fetchone()
+
+ if not row:
+ logger.warning(f"Knowledge {knowledge_id} not found or deleted")
+ return None
+
+ current_favorite = bool(row["favorite"])
+ new_favorite = not current_favorite
+
+ # Update favorite status
+ conn.execute(
+ "UPDATE knowledge SET favorite = ? WHERE id = ?",
+ (int(new_favorite), knowledge_id)
+ )
+ conn.commit()
+
+ logger.debug(f"Toggled favorite for knowledge {knowledge_id}: {new_favorite}")
+
+ # Send update event to frontend
+ from core.events import emit_knowledge_updated
+
+ emit_knowledge_updated({
+ "id": knowledge_id,
+ "favorite": new_favorite
+ })
+
+ return new_favorite
+
+ except Exception as e:
+ logger.error(f"Failed to toggle favorite for knowledge {knowledge_id}: {e}", exc_info=True)
+ raise
+
async def get_count_by_date(self) -> Dict[str, int]:
"""
Get knowledge count grouped by date
diff --git a/backend/core/db/pomodoro_sessions.py b/backend/core/db/pomodoro_sessions.py
new file mode 100644
index 0000000..90d8115
--- /dev/null
+++ b/backend/core/db/pomodoro_sessions.py
@@ -0,0 +1,609 @@
+"""
+PomodoroSessions Repository - Handles Pomodoro session lifecycle
+Manages session metadata, status tracking, and processing state
+"""
+
+import json
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from core.logger import get_logger
+
+from .base import BaseRepository
+
+logger = get_logger(__name__)
+
+
+class PomodoroSessionsRepository(BaseRepository):
+ """Repository for managing Pomodoro sessions in the database"""
+
+ def __init__(self, db_path: Path):
+ super().__init__(db_path)
+
+ async def create(
+ self,
+ session_id: str,
+ user_intent: str,
+ planned_duration_minutes: int,
+ start_time: str,
+ status: str = "active",
+ associated_todo_id: Optional[str] = None,
+ work_duration_minutes: int = 25,
+ break_duration_minutes: int = 5,
+ total_rounds: int = 4,
+ ) -> None:
+ """
+ Create a new Pomodoro session
+
+ Args:
+ session_id: Unique session identifier
+ user_intent: User's description of what they plan to work on
+ planned_duration_minutes: Planned session duration (total for all rounds)
+ start_time: ISO format start timestamp
+ status: Session status (default: 'active')
+ associated_todo_id: Optional TODO ID to associate with this session
+ work_duration_minutes: Duration of each work phase (default: 25)
+ break_duration_minutes: Duration of each break phase (default: 5)
+ total_rounds: Total number of work rounds (default: 4)
+ """
+ try:
+ with self._get_conn() as conn:
+ conn.execute(
+ """
+ INSERT INTO pomodoro_sessions (
+ id, user_intent, planned_duration_minutes,
+ start_time, status, associated_todo_id,
+ work_duration_minutes, break_duration_minutes, total_rounds,
+ current_round, current_phase, phase_start_time, completed_rounds,
+ created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'work', ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ """,
+ (
+ session_id,
+ user_intent,
+ planned_duration_minutes,
+ start_time,
+ status,
+ associated_todo_id,
+ work_duration_minutes,
+ break_duration_minutes,
+ total_rounds,
+ start_time, # phase_start_time = start_time initially
+ ),
+ )
+ conn.commit()
+ logger.debug(f"Created Pomodoro session: {session_id}")
+ except Exception as e:
+ logger.error(f"Failed to create Pomodoro session {session_id}: {e}", exc_info=True)
+ raise
+
+ async def update(self, session_id: str, **kwargs) -> None:
+ """
+ Update Pomodoro session fields
+
+ Args:
+ session_id: Session ID to update
+ **kwargs: Fields to update (e.g., end_time, status, processing_status)
+ """
+ try:
+ if not kwargs:
+ return
+
+ set_clauses = []
+ params = []
+
+ for key, value in kwargs.items():
+ set_clauses.append(f"{key} = ?")
+ params.append(value)
+
+ set_clauses.append("updated_at = CURRENT_TIMESTAMP")
+ params.append(session_id)
+
+ query = f"""
+ UPDATE pomodoro_sessions
+ SET {', '.join(set_clauses)}
+ WHERE id = ?
+ """
+
+ with self._get_conn() as conn:
+ conn.execute(query, params)
+ conn.commit()
+ logger.debug(f"Updated Pomodoro session {session_id}: {list(kwargs.keys())}")
+ except Exception as e:
+ logger.error(f"Failed to update Pomodoro session {session_id}: {e}", exc_info=True)
+ raise
+
+ async def get_by_id(self, session_id: str) -> Optional[Dict[str, Any]]:
+ """
+ Get session by ID
+
+ Args:
+ session_id: Session ID
+
+ Returns:
+ Session dictionary or None if not found
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT * FROM pomodoro_sessions
+ WHERE id = ? AND deleted = 0
+ """,
+ (session_id,),
+ )
+ row = cursor.fetchone()
+ return self._row_to_dict(row)
+ except Exception as e:
+ logger.error(f"Failed to get Pomodoro session {session_id}: {e}", exc_info=True)
+ raise
+
+ async def get_by_status(
+ self,
+ status: str,
+ limit: int = 100,
+ offset: int = 0,
+ ) -> List[Dict[str, Any]]:
+ """
+ Get sessions by status
+
+ Args:
+ status: Session status ('active', 'completed', 'abandoned', etc.)
+ limit: Maximum number of results
+ offset: Number of results to skip
+
+ Returns:
+ List of session dictionaries
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT * FROM pomodoro_sessions
+ WHERE status = ? AND deleted = 0
+ ORDER BY start_time DESC
+ LIMIT ? OFFSET ?
+ """,
+ (status, limit, offset),
+ )
+ rows = cursor.fetchall()
+ return self._rows_to_dicts(rows)
+ except Exception as e:
+ logger.error(f"Failed to get sessions by status {status}: {e}", exc_info=True)
+ raise
+
+ async def get_by_processing_status(
+ self,
+ processing_status: str,
+ limit: int = 100,
+ offset: int = 0,
+ ) -> List[Dict[str, Any]]:
+ """
+ Get sessions by processing status
+
+ Args:
+ processing_status: Processing status ('pending', 'processing', 'completed', 'failed')
+ limit: Maximum number of results
+ offset: Number of results to skip
+
+ Returns:
+ List of session dictionaries
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT * FROM pomodoro_sessions
+ WHERE processing_status = ? AND deleted = 0
+ ORDER BY start_time DESC
+ LIMIT ? OFFSET ?
+ """,
+ (processing_status, limit, offset),
+ )
+ rows = cursor.fetchall()
+ return self._rows_to_dicts(rows)
+ except Exception as e:
+ logger.error(
+ f"Failed to get sessions by processing status {processing_status}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def get_recent(
+ self,
+ limit: int = 10,
+ offset: int = 0,
+ ) -> List[Dict[str, Any]]:
+ """
+ Get recent Pomodoro sessions
+
+ Args:
+ limit: Maximum number of results
+ offset: Number of results to skip
+
+ Returns:
+ List of session dictionaries
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT * FROM pomodoro_sessions
+ WHERE deleted = 0
+ ORDER BY start_time DESC
+ LIMIT ? OFFSET ?
+ """,
+ (limit, offset),
+ )
+ rows = cursor.fetchall()
+ return self._rows_to_dicts(rows)
+ except Exception as e:
+ logger.error(f"Failed to get recent Pomodoro sessions: {e}", exc_info=True)
+ raise
+
+ async def get_stats(
+ self,
+ start_date: Optional[str] = None,
+ end_date: Optional[str] = None,
+ ) -> Optional[Dict[str, Any]]:
+ """
+ Get Pomodoro session statistics
+
+ Args:
+ start_date: Optional start date (ISO format)
+ end_date: Optional end date (ISO format)
+
+ Returns:
+ Dictionary with statistics (total, completed, abandoned, avg_duration, etc.)
+ """
+ try:
+ with self._get_conn() as conn:
+ where_clauses = ["deleted = 0"]
+ params = []
+
+ if start_date:
+ where_clauses.append("start_time >= ?")
+ params.append(start_date)
+ if end_date:
+ where_clauses.append("start_time <= ?")
+ params.append(end_date)
+
+ where_sql = " AND ".join(where_clauses)
+
+ cursor = conn.execute(
+ f"""
+ SELECT
+ COUNT(*) as total,
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
+ SUM(CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END) as abandoned,
+ SUM(CASE WHEN status = 'interrupted' THEN 1 ELSE 0 END) as interrupted,
+ AVG(actual_duration_minutes) as avg_duration,
+ SUM(actual_duration_minutes) as total_duration
+ FROM pomodoro_sessions
+ WHERE {where_sql}
+ """,
+ params,
+ )
+ row = cursor.fetchone()
+ return self._row_to_dict(row) if row else None
+ except Exception as e:
+ logger.error(f"Failed to get Pomodoro session stats: {e}", exc_info=True)
+ raise
+
+ async def soft_delete(self, session_id: str) -> None:
+ """
+ Soft delete a session
+
+ Args:
+ session_id: Session ID to delete
+ """
+ try:
+ with self._get_conn() as conn:
+ conn.execute(
+ """
+ UPDATE pomodoro_sessions
+ SET deleted = 1, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (session_id,),
+ )
+ conn.commit()
+ logger.debug(f"Soft deleted Pomodoro session: {session_id}")
+ except Exception as e:
+ logger.error(f"Failed to soft delete Pomodoro session {session_id}: {e}", exc_info=True)
+ raise
+
+ async def hard_delete_old(self, days: int = 90) -> int:
+ """
+ Hard delete old completed sessions
+
+ Args:
+ days: Delete sessions older than this many days
+
+ Returns:
+ Number of sessions deleted
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM pomodoro_sessions
+ WHERE deleted = 1
+ AND created_at < datetime('now', '-' || ? || ' days')
+ """,
+ (days,),
+ )
+ conn.commit()
+ deleted_count = cursor.rowcount
+ logger.debug(f"Hard deleted {deleted_count} old Pomodoro sessions")
+ return deleted_count
+ except Exception as e:
+ logger.error(f"Failed to hard delete old sessions: {e}", exc_info=True)
+ raise
+
+ async def update_todo_association(
+ self, session_id: str, todo_id: Optional[str]
+ ) -> None:
+ """
+ Update the associated TODO for a Pomodoro session
+
+ Args:
+ session_id: Session ID
+ todo_id: TODO ID to associate (None to clear association)
+ """
+ try:
+ with self._get_conn() as conn:
+ conn.execute(
+ """
+ UPDATE pomodoro_sessions
+ SET associated_todo_id = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (todo_id, session_id),
+ )
+ conn.commit()
+ logger.debug(
+ f"Updated TODO association for session {session_id}: {todo_id}"
+ )
+ except Exception as e:
+ logger.error(
+ f"Failed to update TODO association for session {session_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def get_sessions_by_todo(self, todo_id: str) -> List[Dict[str, Any]]:
+ """
+ Get all sessions associated with a TODO
+
+ Args:
+ todo_id: TODO ID
+
+ Returns:
+ List of session dictionaries
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT * FROM pomodoro_sessions
+ WHERE associated_todo_id = ? AND deleted = 0
+ ORDER BY start_time DESC
+ """,
+ (todo_id,),
+ )
+ rows = cursor.fetchall()
+ return self._rows_to_dicts(rows)
+ except Exception as e:
+ logger.error(
+ f"Failed to get sessions for TODO {todo_id}: {e}", exc_info=True
+ )
+ raise
+
+ async def get_daily_stats(self, date: str) -> Dict[str, Any]:
+ """
+ Get Pomodoro statistics for a specific date
+
+ Args:
+ date: Date in YYYY-MM-DD format
+
+ Returns:
+ Dictionary with daily statistics:
+ - completed_count: Number of completed sessions
+ - total_focus_minutes: Total focus time in minutes
+ - average_duration_minutes: Average session duration
+ - sessions: List of sessions for that day
+ """
+ try:
+ with self._get_conn() as conn:
+ # Get aggregated stats
+ cursor = conn.execute(
+ """
+ SELECT
+ COUNT(*) as completed_count,
+ COALESCE(SUM(actual_duration_minutes), 0) as total_focus_minutes,
+ COALESCE(AVG(actual_duration_minutes), 0) as average_duration_minutes
+ FROM pomodoro_sessions
+ WHERE DATE(start_time) = ?
+ AND status = 'completed'
+ AND deleted = 0
+ """,
+ (date,),
+ )
+ stats_row = cursor.fetchone()
+
+ # Get session list for the day (only completed sessions)
+ cursor = conn.execute(
+ """
+ SELECT * FROM pomodoro_sessions
+ WHERE DATE(start_time) = ?
+ AND status = 'completed'
+ AND deleted = 0
+ ORDER BY start_time DESC
+ """,
+ (date,),
+ )
+ sessions = self._rows_to_dicts(cursor.fetchall())
+
+ return {
+ "completed_count": stats_row[0] if stats_row else 0,
+ "total_focus_minutes": int(stats_row[1]) if stats_row else 0,
+ "average_duration_minutes": int(stats_row[2]) if stats_row else 0,
+ "sessions": sessions,
+ }
+ except Exception as e:
+ logger.error(f"Failed to get daily stats for {date}: {e}", exc_info=True)
+ raise
+
+ async def switch_phase(
+ self, session_id: str, new_phase: str, phase_start_time: str
+ ) -> Dict[str, Any]:
+ """
+ Switch to next phase in Pomodoro session
+
+ Phase transitions:
+ - work → break: Increment completed_rounds
+ - break → work: Increment current_round
+ - Automatically mark as completed if all rounds finished
+
+ Args:
+ session_id: Session ID
+ new_phase: New phase ('work' or 'break')
+ phase_start_time: ISO timestamp when new phase starts
+
+ Returns:
+ Updated session record
+ """
+ try:
+ with self._get_conn() as conn:
+ # Get current session state
+ cursor = conn.execute(
+ """
+ SELECT current_phase, current_round, completed_rounds, total_rounds
+ FROM pomodoro_sessions
+ WHERE id = ?
+ """,
+ (session_id,),
+ )
+ row = cursor.fetchone()
+ if not row:
+ raise ValueError(f"Session {session_id} not found")
+
+ current_phase, current_round, completed_rounds, total_rounds = row
+
+ # Calculate new state based on phase transition
+ if current_phase == "work" and new_phase == "break":
+ # Completed a work phase
+ completed_rounds += 1
+ # current_round stays the same during break
+
+ elif current_phase == "break" and new_phase == "work":
+ # Starting next work round
+ current_round += 1
+
+ # Check if all rounds are completed
+ new_status = "active"
+ if completed_rounds >= total_rounds and new_phase == "break":
+ # After completing the last work round, mark as completed
+ new_status = "completed"
+ new_phase = "completed"
+
+ # Update session
+ conn.execute(
+ """
+ UPDATE pomodoro_sessions
+ SET current_phase = ?,
+ current_round = ?,
+ completed_rounds = ?,
+ phase_start_time = ?,
+ status = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (
+ new_phase,
+ current_round,
+ completed_rounds,
+ phase_start_time,
+ new_status,
+ session_id,
+ ),
+ )
+ conn.commit()
+
+ logger.debug(
+ f"Switched session {session_id} to phase '{new_phase}', "
+ f"round {current_round}/{total_rounds}, "
+ f"completed {completed_rounds}"
+ )
+
+ # Return updated session
+ return await self.get_by_id(session_id) or {}
+
+ except Exception as e:
+ logger.error(
+ f"Failed to switch phase for session {session_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def update_llm_evaluation(
+ self,
+ session_id: str,
+ evaluation_result: Dict[str, Any]
+ ) -> None:
+ """
+ Save LLM evaluation result to database
+
+ Args:
+ session_id: Session ID
+ evaluation_result: Complete LLM evaluation dict (will be JSON-serialized)
+ """
+ try:
+ from datetime import datetime
+
+ with self._get_conn() as conn:
+ conn.execute(
+ """
+ UPDATE pomodoro_sessions
+ SET llm_evaluation_result = ?,
+ llm_evaluation_computed_at = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (
+ json.dumps(evaluation_result),
+ datetime.now().isoformat(),
+ session_id,
+ ),
+ )
+ conn.commit()
+ logger.debug(f"Saved LLM evaluation for session {session_id}")
+ except Exception as e:
+ logger.error(
+ f"Failed to save LLM evaluation for session {session_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def get_llm_evaluation(self, session_id: str) -> Optional[Dict[str, Any]]:
+ """
+ Retrieve cached LLM evaluation result
+
+ Args:
+ session_id: Session ID
+
+ Returns:
+ LLM evaluation dict or None if not cached
+ """
+ try:
+ session = await self.get_by_id(session_id)
+ if not session or not session.get("llm_evaluation_result"):
+ return None
+
+ return json.loads(session["llm_evaluation_result"])
+ except Exception as e:
+ logger.warning(
+ f"Failed to retrieve cached LLM evaluation for {session_id}: {e}"
+ )
+ return None
diff --git a/backend/core/db/pomodoro_work_phases.py b/backend/core/db/pomodoro_work_phases.py
new file mode 100644
index 0000000..513b1e0
--- /dev/null
+++ b/backend/core/db/pomodoro_work_phases.py
@@ -0,0 +1,203 @@
+"""
+Repository for Pomodoro work phases.
+
+This repository handles phase-level tracking for Pomodoro sessions,
+enabling independent status management and retry mechanisms for each work phase.
+"""
+
+from pathlib import Path
+from typing import Optional, List, Dict, Any
+from uuid import uuid4
+
+from core.logger import get_logger
+from core.sqls.queries import (
+ INSERT_WORK_PHASE,
+ SELECT_WORK_PHASES_BY_SESSION,
+ SELECT_WORK_PHASE_BY_SESSION_AND_NUMBER,
+ UPDATE_WORK_PHASE_STATUS,
+ UPDATE_WORK_PHASE_COMPLETED,
+ INCREMENT_WORK_PHASE_RETRY,
+)
+
+from .base import BaseRepository
+
+logger = get_logger(__name__)
+
+
+class PomodoroWorkPhasesRepository(BaseRepository):
+ """Repository for managing Pomodoro work phase records."""
+
+ def __init__(self, db_path: Path):
+ super().__init__(db_path)
+
+ async def create(
+ self,
+ session_id: str,
+ phase_number: int,
+ phase_start_time: str,
+ phase_end_time: Optional[str] = None,
+ status: str = "pending",
+ retry_count: int = 0,
+ ) -> str:
+ """
+ Create a work phase record.
+
+ Args:
+ session_id: Pomodoro session ID
+ phase_number: Phase number (1-4)
+ phase_start_time: ISO format start time
+ phase_end_time: ISO format end time (optional)
+ status: Initial status (default: pending)
+ retry_count: Initial retry count (default: 0)
+
+ Returns:
+ phase_id: Created phase record ID
+ """
+ phase_id = str(uuid4())
+
+ try:
+ with self._get_conn() as conn:
+ conn.execute(
+ INSERT_WORK_PHASE,
+ (
+ phase_id,
+ session_id,
+ phase_number,
+ status,
+ phase_start_time,
+ phase_end_time,
+ retry_count,
+ ),
+ )
+ conn.commit()
+
+ logger.info(
+ f"Created work phase: id={phase_id}, session={session_id}, "
+ f"phase={phase_number}, status={status}"
+ )
+
+ return phase_id
+ except Exception as e:
+ logger.error(f"Failed to create work phase: {e}", exc_info=True)
+ raise
+
+ async def get_by_session(self, session_id: str) -> List[Dict[str, Any]]:
+ """
+ Get all work phase records for a session.
+
+ Args:
+ session_id: Pomodoro session ID
+
+ Returns:
+ List of phase records (sorted by phase_number ASC)
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(SELECT_WORK_PHASES_BY_SESSION, (session_id,))
+ rows = cursor.fetchall()
+ return [dict(row) for row in rows]
+ except Exception as e:
+ logger.error(f"Failed to get phases for session {session_id}: {e}", exc_info=True)
+ return []
+
+ async def get_by_session_and_phase(
+ self, session_id: str, phase_number: int
+ ) -> Optional[Dict[str, Any]]:
+ """
+ Get a specific work phase record.
+
+ Args:
+ session_id: Pomodoro session ID
+ phase_number: Phase number (1-4)
+
+ Returns:
+ Phase record dict or None if not found
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ SELECT_WORK_PHASE_BY_SESSION_AND_NUMBER, (session_id, phase_number)
+ )
+ row = cursor.fetchone()
+ return dict(row) if row else None
+ except Exception as e:
+ logger.error(
+ f"Failed to get phase for session {session_id}, phase {phase_number}: {e}",
+ exc_info=True
+ )
+ return None
+
+ async def update_status(
+ self,
+ phase_id: str,
+ status: str,
+ processing_error: Optional[str] = None,
+ retry_count: Optional[int] = None,
+ ) -> None:
+ """
+ Update phase status and error information.
+
+ Args:
+ phase_id: Phase record ID
+ status: New status (pending/processing/completed/failed)
+ processing_error: Error message (optional)
+ retry_count: Retry count (optional)
+ """
+ try:
+ with self._get_conn() as conn:
+ conn.execute(
+ UPDATE_WORK_PHASE_STATUS,
+ (status, processing_error, retry_count, phase_id),
+ )
+ conn.commit()
+
+ logger.debug(
+ f"Updated phase status: id={phase_id}, status={status}, "
+ f"error={processing_error}, retry_count={retry_count}"
+ )
+ except Exception as e:
+ logger.error(f"Failed to update phase status: {e}", exc_info=True)
+ raise
+
+ async def mark_completed(self, phase_id: str, activity_count: int) -> None:
+ """
+ Mark phase as completed with activity count.
+
+ Args:
+ phase_id: Phase record ID
+ activity_count: Number of activities created for this phase
+ """
+ try:
+ with self._get_conn() as conn:
+ conn.execute(UPDATE_WORK_PHASE_COMPLETED, (activity_count, phase_id))
+ conn.commit()
+
+ logger.info(
+ f"Marked phase completed: id={phase_id}, activity_count={activity_count}"
+ )
+ except Exception as e:
+ logger.error(f"Failed to mark phase completed: {e}", exc_info=True)
+ raise
+
+ async def increment_retry_count(self, phase_id: str) -> int:
+ """
+ Increment retry count and return new value.
+
+ Args:
+ phase_id: Phase record ID
+
+ Returns:
+ New retry count value
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(INCREMENT_WORK_PHASE_RETRY, (phase_id,))
+ row = cursor.fetchone()
+ conn.commit()
+
+ new_count = row[0] if row else 0
+ logger.debug(f"Incremented retry count for phase {phase_id}: {new_count}")
+ return new_count
+ except Exception as e:
+ logger.error(f"Failed to increment retry count: {e}", exc_info=True)
+ return 0
diff --git a/backend/core/db/raw_records.py b/backend/core/db/raw_records.py
new file mode 100644
index 0000000..a2086ec
--- /dev/null
+++ b/backend/core/db/raw_records.py
@@ -0,0 +1,227 @@
+"""
+RawRecords Repository - Handles raw record persistence for Pomodoro sessions
+Raw records are temporary storage for screenshots, keyboard, and mouse activity
+"""
+
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from core.logger import get_logger
+
+from .base import BaseRepository
+
+logger = get_logger(__name__)
+
+
+class RawRecordsRepository(BaseRepository):
+ """Repository for managing raw records in the database"""
+
+ def __init__(self, db_path: Path):
+ super().__init__(db_path)
+
+ async def save(
+ self,
+ timestamp: str,
+ record_type: str,
+ data: str,
+ pomodoro_session_id: Optional[str] = None,
+ ) -> Optional[int]:
+ """
+ Save a raw record to database
+
+ Args:
+ timestamp: ISO format timestamp
+ record_type: Type of record (SCREENSHOT_RECORD, KEYBOARD_RECORD, MOUSE_RECORD)
+ data: JSON string of record data
+ pomodoro_session_id: Optional Pomodoro session ID
+
+ Returns:
+ Record ID if successful, None otherwise
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO raw_records (
+ timestamp, type, data, pomodoro_session_id, created_at
+ ) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
+ """,
+ (timestamp, record_type, data, pomodoro_session_id),
+ )
+ conn.commit()
+ record_id = cursor.lastrowid
+ logger.debug(
+ f"Saved raw record: {record_id}, "
+ f"type={record_type}, pomodoro_session={pomodoro_session_id}"
+ )
+ return record_id
+ except Exception as e:
+ logger.error(f"Failed to save raw record: {e}", exc_info=True)
+ raise
+
+ async def get_by_session(
+ self,
+ session_id: str,
+ limit: int = 100,
+ offset: int = 0,
+ ) -> List[Dict[str, Any]]:
+ """
+ Get raw records for a specific Pomodoro session
+
+ Args:
+ session_id: Pomodoro session ID
+ limit: Maximum number of records to return
+ offset: Number of records to skip
+
+ Returns:
+ List of raw record dictionaries
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT * FROM raw_records
+ WHERE pomodoro_session_id = ?
+ ORDER BY timestamp ASC
+ LIMIT ? OFFSET ?
+ """,
+ (session_id, limit, offset),
+ )
+ rows = cursor.fetchall()
+ return self._rows_to_dicts(rows)
+ except Exception as e:
+ logger.error(
+ f"Failed to get raw records for session {session_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def count_by_session(self, session_id: str) -> int:
+ """
+ Count raw records for a session
+
+ Args:
+ session_id: Pomodoro session ID
+
+ Returns:
+ Number of raw records
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT COUNT(*) as count FROM raw_records
+ WHERE pomodoro_session_id = ?
+ """,
+ (session_id,),
+ )
+ row = cursor.fetchone()
+ return row["count"] if row else 0
+ except Exception as e:
+ logger.error(
+ f"Failed to count raw records for session {session_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def delete_by_session(self, session_id: str) -> int:
+ """
+ Delete raw records for a session
+
+ Args:
+ session_id: Pomodoro session ID
+
+ Returns:
+ Number of records deleted
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM raw_records
+ WHERE pomodoro_session_id = ?
+ """,
+ (session_id,),
+ )
+ conn.commit()
+ deleted_count = cursor.rowcount
+ logger.debug(
+ f"Deleted {deleted_count} raw records for session {session_id}"
+ )
+ return deleted_count
+ except Exception as e:
+ logger.error(
+ f"Failed to delete raw records for session {session_id}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def get_by_time_range(
+ self,
+ start_time: str,
+ end_time: str,
+ record_type: Optional[str] = None,
+ session_id: Optional[str] = None,
+ ) -> List[Dict[str, Any]]:
+ """
+ Get raw records within a time range
+
+ Args:
+ start_time: Start timestamp (ISO format)
+ end_time: End timestamp (ISO format)
+ record_type: Optional filter by record type
+ session_id: Optional filter by Pomodoro session ID
+
+ Returns:
+ List of raw record dictionaries
+ """
+ try:
+ with self._get_conn() as conn:
+ # Build query based on filters
+ if record_type and session_id:
+ cursor = conn.execute(
+ """
+ SELECT * FROM raw_records
+ WHERE timestamp >= ? AND timestamp <= ?
+ AND type = ? AND pomodoro_session_id = ?
+ ORDER BY timestamp ASC
+ """,
+ (start_time, end_time, record_type, session_id),
+ )
+ elif record_type:
+ cursor = conn.execute(
+ """
+ SELECT * FROM raw_records
+ WHERE timestamp >= ? AND timestamp <= ? AND type = ?
+ ORDER BY timestamp ASC
+ """,
+ (start_time, end_time, record_type),
+ )
+ elif session_id:
+ cursor = conn.execute(
+ """
+ SELECT * FROM raw_records
+ WHERE timestamp >= ? AND timestamp <= ?
+ AND pomodoro_session_id = ?
+ ORDER BY timestamp ASC
+ """,
+ (start_time, end_time, session_id),
+ )
+ else:
+ cursor = conn.execute(
+ """
+ SELECT * FROM raw_records
+ WHERE timestamp >= ? AND timestamp <= ?
+ ORDER BY timestamp ASC
+ """,
+ (start_time, end_time),
+ )
+ rows = cursor.fetchall()
+ return self._rows_to_dicts(rows)
+ except Exception as e:
+ logger.error(
+ f"Failed to get raw records by time range: {e}", exc_info=True
+ )
+ raise
diff --git a/backend/core/db/todos.py b/backend/core/db/todos.py
index bc3c8d8..a51be16 100644
--- a/backend/core/db/todos.py
+++ b/backend/core/db/todos.py
@@ -3,7 +3,7 @@
"""
import json
-from datetime import datetime
+from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -13,6 +13,9 @@
logger = get_logger(__name__)
+# Default expiration for AI-generated todos (3 days)
+DEFAULT_TODO_EXPIRATION_DAYS = 3
+
class TodosRepository(BaseRepository):
"""Repository for managing todos in the database"""
@@ -33,18 +36,43 @@ async def save(
scheduled_end_time: Optional[str] = None,
recurrence_rule: Optional[Dict[str, Any]] = None,
created_at: Optional[str] = None,
+ expires_at: Optional[str] = None,
+ source_type: str = "ai",
) -> None:
- """Save or update a todo"""
+ """Save or update a todo
+
+ Args:
+ todo_id: Unique todo identifier
+ title: Todo title
+ description: Todo description
+ keywords: List of keywords/tags
+ completed: Whether todo is completed
+ scheduled_date: Optional scheduled date (YYYY-MM-DD)
+ scheduled_time: Optional scheduled time (HH:MM)
+ scheduled_end_time: Optional scheduled end time (HH:MM)
+ recurrence_rule: Optional recurrence configuration
+ created_at: Custom creation timestamp (ISO format)
+ expires_at: Custom expiration timestamp (ISO format), auto-calculated for AI todos if not provided
+ source_type: 'ai' or 'manual' - defaults to 'ai'
+ """
try:
created = created_at or datetime.now().isoformat()
+
+ # Calculate expiration for AI-generated todos if not provided
+ calculated_expires_at = expires_at
+ if source_type == "ai" and expires_at is None:
+ expiration_time = datetime.fromisoformat(created) + timedelta(days=DEFAULT_TODO_EXPIRATION_DAYS)
+ calculated_expires_at = expiration_time.isoformat()
+
with self._get_conn() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO todos (
id, title, description, keywords,
created_at, completed, deleted,
- scheduled_date, scheduled_time, scheduled_end_time, recurrence_rule
- ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
+ scheduled_date, scheduled_time, scheduled_end_time, recurrence_rule,
+ expires_at, source_type
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
""",
(
todo_id,
@@ -57,10 +85,12 @@ async def save(
scheduled_time,
scheduled_end_time,
json.dumps(recurrence_rule) if recurrence_rule else None,
+ calculated_expires_at,
+ source_type,
),
)
conn.commit()
- logger.debug(f"Saved todo: {todo_id}")
+ logger.debug(f"Saved todo: {todo_id} (source: {source_type}, expires: {calculated_expires_at})")
# Send event to frontend
from core.events import emit_todo_created
@@ -77,6 +107,8 @@ async def save(
"scheduled_end_time": scheduled_end_time,
"recurrence_rule": recurrence_rule,
"created_at": created,
+ "expires_at": calculated_expires_at,
+ "source_type": source_type,
"type": "original",
}
)
@@ -85,40 +117,119 @@ async def save(
raise
+ async def get_by_id(self, todo_id: str) -> Optional[Dict[str, Any]]:
+ """
+ Get a single todo by ID
+
+ Args:
+ todo_id: Todo ID
+
+ Returns:
+ Todo dict or None if not found
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ SELECT id, title, description, keywords,
+ created_at, completed, deleted, scheduled_date, scheduled_time,
+ scheduled_end_time, recurrence_rule, expires_at, source_type
+ FROM todos
+ WHERE id = ?
+ """,
+ (todo_id,),
+ )
+ row = cursor.fetchone()
+
+ if row:
+ return {
+ "id": row["id"],
+ "title": row["title"],
+ "description": row["description"],
+ "keywords": json.loads(row["keywords"])
+ if row["keywords"]
+ else [],
+ "created_at": row["created_at"],
+ "completed": bool(row["completed"]),
+ "deleted": bool(row["deleted"]),
+ "scheduled_date": row["scheduled_date"],
+ "scheduled_time": row["scheduled_time"],
+ "scheduled_end_time": row["scheduled_end_time"],
+ "recurrence_rule": json.loads(row["recurrence_rule"])
+ if row["recurrence_rule"]
+ else None,
+ "expires_at": row["expires_at"],
+ "source_type": row["source_type"] or "ai",
+ }
+
+ return None
+
+ except Exception as e:
+ logger.error(f"Failed to get todo by ID {todo_id}: {e}", exc_info=True)
+ return None
+
async def get_list(
- self, include_completed: bool = False
+ self, include_completed: bool = False, include_expired: bool = False
) -> List[Dict[str, Any]]:
"""
Get todo list (from todos table)
Args:
include_completed: Whether to include completed todos
+ include_expired: Whether to include expired AI todos (default False)
Returns:
List of todo dictionaries
"""
try:
+ now_iso = datetime.now().isoformat()
+
if include_completed:
- query = """
- SELECT id, title, description, keywords,
- created_at, completed, deleted, scheduled_date, scheduled_time,
- scheduled_end_time, recurrence_rule
- FROM todos
- WHERE deleted = 0
- ORDER BY completed ASC, created_at DESC
- """
+ if include_expired:
+ query = """
+ SELECT id, title, description, keywords,
+ created_at, completed, deleted, scheduled_date, scheduled_time,
+ scheduled_end_time, recurrence_rule, expires_at, source_type
+ FROM todos
+ WHERE deleted = 0
+ ORDER BY completed ASC, created_at DESC
+ """
+ else:
+ query = """
+ SELECT id, title, description, keywords,
+ created_at, completed, deleted, scheduled_date, scheduled_time,
+ scheduled_end_time, recurrence_rule, expires_at, source_type
+ FROM todos
+ WHERE deleted = 0
+ AND (source_type = 'manual' OR expires_at IS NULL OR expires_at > ?)
+ ORDER BY completed ASC, created_at DESC
+ """
else:
- query = """
- SELECT id, title, description, keywords,
- created_at, completed, deleted, scheduled_date, scheduled_time,
- scheduled_end_time, recurrence_rule
- FROM todos
- WHERE deleted = 0 AND completed = 0
- ORDER BY created_at DESC
- """
+ if include_expired:
+ query = """
+ SELECT id, title, description, keywords,
+ created_at, completed, deleted, scheduled_date, scheduled_time,
+ scheduled_end_time, recurrence_rule, expires_at, source_type
+ FROM todos
+ WHERE deleted = 0 AND completed = 0
+ ORDER BY created_at DESC
+ """
+ else:
+ query = """
+ SELECT id, title, description, keywords,
+ created_at, completed, deleted, scheduled_date, scheduled_time,
+ scheduled_end_time, recurrence_rule, expires_at, source_type
+ FROM todos
+ WHERE deleted = 0 AND completed = 0
+ AND (source_type = 'manual' OR expires_at IS NULL OR expires_at > ?)
+ ORDER BY created_at DESC
+ """
with self._get_conn() as conn:
- cursor = conn.execute(query)
+ if include_completed and include_expired:
+ cursor = conn.execute(query)
+ else:
+ cursor = conn.execute(query, (now_iso,))
rows = cursor.fetchall()
todo_list: List[Dict[str, Any]] = []
@@ -140,6 +251,8 @@ async def get_list(
"recurrence_rule": json.loads(row["recurrence_rule"])
if row["recurrence_rule"]
else None,
+ "expires_at": row["expires_at"],
+ "source_type": row["source_type"] or "ai",
}
)
@@ -160,6 +273,9 @@ async def schedule(
"""
Schedule todo to a specific date and optional time window
+ Scheduling a todo clears its expiration time, as the todo is now in
+ an active/scheduled state and should not expire.
+
Args:
todo_id: Todo ID
scheduled_date: Scheduled date in YYYY-MM-DD format
@@ -176,11 +292,12 @@ async def schedule(
recurrence_json = json.dumps(recurrence_rule) if recurrence_rule else None
+ # Clear expires_at when scheduling (todo is now active)
cursor.execute(
"""
UPDATE todos
SET scheduled_date = ?, scheduled_time = ?,
- scheduled_end_time = ?, recurrence_rule = ?
+ scheduled_end_time = ?, recurrence_rule = ?, expires_at = NULL
WHERE id = ? AND deleted = 0
""",
(
@@ -197,7 +314,7 @@ async def schedule(
"""
SELECT id, title, description, keywords,
created_at, completed, deleted, scheduled_date, scheduled_time,
- scheduled_end_time, recurrence_rule
+ scheduled_end_time, recurrence_rule, expires_at, source_type
FROM todos
WHERE id = ? AND deleted = 0
""",
@@ -222,6 +339,8 @@ async def schedule(
"recurrence_rule": json.loads(row["recurrence_rule"])
if row["recurrence_rule"]
else None,
+ "expires_at": row["expires_at"],
+ "source_type": row["source_type"] or "ai",
}
# Send event to frontend
@@ -238,7 +357,12 @@ async def schedule(
return None
async def unschedule(self, todo_id: str) -> Optional[Dict[str, Any]]:
- """Clear scheduling info for a todo"""
+ """Clear scheduling info for a todo
+
+ Note: When unscheduling, we do NOT restore the expiration time.
+ The todo remains without expiration since the user has explicitly
+ interacted with it (scheduled then unscheduled).
+ """
try:
with self._get_conn() as conn:
cursor = conn.cursor()
@@ -259,7 +383,8 @@ async def unschedule(self, todo_id: str) -> Optional[Dict[str, Any]]:
cursor.execute(
"""
SELECT id, title, description, keywords,
- created_at, completed, deleted, scheduled_date
+ created_at, completed, deleted, scheduled_date,
+ expires_at, source_type
FROM todos
WHERE id = ? AND deleted = 0
""",
@@ -279,6 +404,8 @@ async def unschedule(self, todo_id: str) -> Optional[Dict[str, Any]]:
"completed": bool(row["completed"]),
"deleted": bool(row["deleted"]),
"scheduled_date": row["scheduled_date"],
+ "expires_at": row["expires_at"],
+ "source_type": row["source_type"] or "ai",
}
# Send event to frontend
@@ -294,6 +421,24 @@ async def unschedule(self, todo_id: str) -> Optional[Dict[str, Any]]:
logger.error(f"Failed to unschedule todo: {e}", exc_info=True)
return None
+ async def complete(self, todo_id: str) -> None:
+ """Mark a todo as completed"""
+ try:
+ with self._get_conn() as conn:
+ conn.execute(
+ "UPDATE todos SET completed = 1 WHERE id = ?", (todo_id,)
+ )
+ conn.commit()
+ logger.debug(f"Completed todo: {todo_id}")
+
+ # Send event to frontend
+ from core.events import emit_todo_updated
+
+ emit_todo_updated({"id": todo_id, "completed": True})
+ except Exception as e:
+ logger.error(f"Failed to complete todo {todo_id}: {e}", exc_info=True)
+ raise
+
async def delete(self, todo_id: str) -> None:
"""Soft delete a todo"""
try:
@@ -360,3 +505,71 @@ async def delete_by_date_range(self, start_iso: str, end_iso: str) -> int:
exc_info=True,
)
return 0
+
+ async def delete_expired(self) -> int:
+ """
+ Soft delete expired AI-generated todos
+
+ Removes todos that:
+ - Are AI-generated (source_type = 'ai')
+ - Have an expires_at timestamp in the past
+ - Are not already deleted
+ - Are not completed
+
+ Returns:
+ Number of todos soft-deleted
+ """
+ try:
+ now_iso = datetime.now().isoformat()
+
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE todos
+ SET deleted = 1
+ WHERE deleted = 0
+ AND completed = 0
+ AND source_type = 'ai'
+ AND expires_at IS NOT NULL
+ AND expires_at < ?
+ """,
+ (now_iso,),
+ )
+ deleted_count = cursor.rowcount
+ conn.commit()
+
+ if deleted_count > 0:
+ logger.info(f"Soft-deleted {deleted_count} expired AI todos")
+
+ return deleted_count
+
+ except Exception as e:
+ logger.error(f"Failed to delete expired todos: {e}", exc_info=True)
+ return 0
+
+ async def delete_soft_deleted_permanent(self) -> int:
+ """
+ Permanently delete all soft-deleted todos
+
+ This is a cleanup operation that removes todos that have been
+ soft-deleted (deleted = 1) from the database.
+
+ Returns:
+ Number of todos permanently deleted
+ """
+ try:
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ "DELETE FROM todos WHERE deleted = 1"
+ )
+ deleted_count = cursor.rowcount
+ conn.commit()
+
+ if deleted_count > 0:
+ logger.info(f"Permanently deleted {deleted_count} soft-deleted todos")
+
+ return deleted_count
+
+ except Exception as e:
+ logger.error(f"Failed to permanently delete soft-deleted todos: {e}", exc_info=True)
+ return 0
diff --git a/backend/core/events.py b/backend/core/events.py
index e60d194..24837c0 100644
--- a/backend/core/events.py
+++ b/backend/core/events.py
@@ -207,6 +207,7 @@ def emit_monitors_changed(
) -> bool:
"""
Send \"monitors changed\" event to frontend when connected displays change.
+ Also notifies the perception manager to update monitor bounds.
"""
from datetime import datetime
@@ -219,6 +220,17 @@ def emit_monitors_changed(
success = _emit("monitors-changed", payload)
if success:
logger.debug("✅ Monitors changed event sent")
+
+ # Notify perception manager to update monitor tracker bounds
+ try:
+ from core.coordinator import get_coordinator
+ coordinator = get_coordinator()
+ if coordinator and coordinator.perception_manager:
+ coordinator.perception_manager.handle_monitors_changed()
+ logger.debug("✓ Perception manager notified of monitor changes")
+ except Exception as e:
+ logger.error(f"Failed to notify perception manager of monitor changes: {e}")
+
return success
@@ -267,6 +279,7 @@ def emit_chat_message_chunk(
done: bool = False,
message_id: Optional[str] = None,
timestamp: Optional[str] = None,
+ error: bool = False,
) -> bool:
"""
Send "chat message chunk" event to frontend (for streaming output)
@@ -275,7 +288,9 @@ def emit_chat_message_chunk(
conversation_id: Conversation ID
chunk: Text chunk content
done: Whether completed (True indicates streaming output ended)
- message_id: Message ID (optional, provided when completed)
+ message_id: Message ID (optional, provided when completed successfully)
+ timestamp: Optional timestamp
+ error: Whether this is an error response (True indicates stream failed)
Returns:
True if sent successfully, False otherwise
@@ -284,6 +299,7 @@ def emit_chat_message_chunk(
"conversationId": conversation_id,
"chunk": chunk,
"done": done,
+ "error": error,
}
if message_id is not None:
@@ -291,7 +307,10 @@ def emit_chat_message_chunk(
success = _emit("chat-message-chunk", payload)
if success and done:
- logger.debug(f"✅ Chat message completion event sent: {conversation_id}")
+ if error:
+ logger.debug(f"❌ Chat message error event sent: {conversation_id}")
+ else:
+ logger.debug(f"✅ Chat message completion event sent: {conversation_id}")
return success
@@ -544,3 +563,219 @@ def emit_todo_deleted(todo_id: str, timestamp: Optional[str] = None) -> bool:
if success:
logger.debug(f"✅ TODO deletion event sent: {todo_id}")
return success
+
+
+
+def emit_pomodoro_processing_progress(
+ session_id: str, job_id: str, processed: int
+) -> bool:
+ """
+ Send Pomodoro processing progress event to frontend
+
+ Args:
+ session_id: Pomodoro session ID
+ job_id: Processing job ID
+ processed: Number of records processed
+
+ Returns:
+ True if sent successfully, False otherwise
+ """
+ payload = {
+ "session_id": session_id,
+ "job_id": job_id,
+ "processed": processed,
+ }
+
+ logger.debug(
+ f"[emit_pomodoro_processing_progress] Session: {session_id}, "
+ f"Job: {job_id}, Processed: {processed}"
+ )
+ return _emit("pomodoro-processing-progress", payload)
+
+
+def emit_pomodoro_processing_complete(
+ session_id: str, job_id: str, total_processed: int
+) -> bool:
+ """
+ Send Pomodoro processing completion event to frontend
+
+ Args:
+ session_id: Pomodoro session ID
+ job_id: Processing job ID
+ total_processed: Total number of records processed
+
+ Returns:
+ True if sent successfully, False otherwise
+ """
+ payload = {
+ "session_id": session_id,
+ "job_id": job_id,
+ "total_processed": total_processed,
+ }
+
+ logger.debug(
+ f"[emit_pomodoro_processing_complete] Session: {session_id}, "
+ f"Job: {job_id}, Total: {total_processed}"
+ )
+ return _emit("pomodoro-processing-complete", payload)
+
+
+def emit_pomodoro_processing_failed(
+ session_id: str, job_id: str, error: str
+) -> bool:
+ """
+ Send Pomodoro processing failure event to frontend
+
+ Args:
+ session_id: Pomodoro session ID
+ job_id: Processing job ID
+ error: Error message
+
+ Returns:
+ True if sent successfully, False otherwise
+ """
+ payload = {
+ "session_id": session_id,
+ "job_id": job_id,
+ "error": error,
+ }
+
+ logger.debug(
+ f"[emit_pomodoro_processing_failed] Session: {session_id}, "
+ f"Job: {job_id}, Error: {error}"
+ )
+ return _emit("pomodoro-processing-failed", payload)
+
+
+def emit_pomodoro_phase_switched(
+ session_id: str,
+ new_phase: str,
+ current_round: int,
+ total_rounds: int,
+ completed_rounds: int,
+) -> bool:
+ """
+ Send Pomodoro phase switch event to frontend
+
+ Emitted when session automatically switches between work/break phases.
+
+ Args:
+ session_id: Pomodoro session ID
+ new_phase: New phase ('work', 'break', or 'completed')
+ current_round: Current round number (1-based)
+ total_rounds: Total number of rounds
+ completed_rounds: Number of completed work rounds
+
+ Returns:
+ True if sent successfully, False otherwise
+ """
+ payload = {
+ "session_id": session_id,
+ "new_phase": new_phase,
+ "current_round": current_round,
+ "total_rounds": total_rounds,
+ "completed_rounds": completed_rounds,
+ }
+
+ logger.debug(
+ f"[emit_pomodoro_phase_switched] Session: {session_id}, "
+ f"Phase: {new_phase}, Round: {current_round}/{total_rounds}, "
+ f"Completed: {completed_rounds}"
+ )
+ return _emit("pomodoro-phase-switched", payload)
+
+
+def emit_pomodoro_work_phase_completed(
+ session_id: str,
+ work_phase: int,
+ activity_count: int,
+) -> bool:
+ """
+ Send Pomodoro work phase completed event to frontend
+
+ Emitted when a work phase completes and activities have been generated.
+ Allows frontend to display notifications and refresh session detail views.
+
+ Args:
+ session_id: Pomodoro session ID
+ work_phase: Work phase number (1-based)
+ activity_count: Number of activities created/updated for this work phase
+
+ Returns:
+ True if sent successfully, False otherwise
+ """
+ payload = {
+ "session_id": session_id,
+ "work_phase": work_phase,
+ "activity_count": activity_count,
+ }
+
+ logger.debug(
+ f"[emit_pomodoro_work_phase_completed] Session: {session_id}, "
+ f"Phase: {work_phase}, Activities: {activity_count}"
+ )
+ return _emit("pomodoro-work-phase-completed", payload)
+
+
+def emit_pomodoro_work_phase_failed(
+ session_id: str,
+ work_phase: int,
+ error: str,
+) -> bool:
+ """
+ Send Pomodoro work phase failed event to frontend
+
+ Emitted when a work phase aggregation fails after all retries exhausted.
+ Frontend should display error state and retry button.
+
+ Args:
+ session_id: Pomodoro session ID
+ work_phase: Work phase number (1-based)
+ error: Error message describing the failure
+
+ Returns:
+ True if sent successfully, False otherwise
+ """
+ payload = {
+ "session_id": session_id,
+ "work_phase": work_phase,
+ "error": error,
+ }
+
+ logger.debug(
+ f"[emit_pomodoro_work_phase_failed] Session: {session_id}, "
+ f"Phase: {work_phase}, Error: {error}"
+ )
+ return _emit("pomodoro-work-phase-failed", payload)
+
+
+def emit_pomodoro_session_deleted(
+ session_id: str,
+ timestamp: Optional[str] = None,
+) -> bool:
+ """
+ Send Pomodoro session deleted event to frontend
+
+ Emitted when a session is deleted. Frontend should refresh session list
+ and clear any selected session state.
+
+ Args:
+ session_id: Pomodoro session ID
+ timestamp: Deletion timestamp
+
+ Returns:
+ True if sent successfully, False otherwise
+ """
+ from datetime import datetime
+
+ resolved_timestamp = timestamp or datetime.now().isoformat()
+ payload = {
+ "type": "session_deleted",
+ "data": {"id": session_id, "deletedAt": resolved_timestamp},
+ "timestamp": resolved_timestamp,
+ }
+
+ success = _emit("session-deleted", payload)
+ if success:
+ logger.debug(f"✅ Pomodoro session deletion event sent: {session_id}")
+ return success
diff --git a/backend/core/pomodoro_manager.py b/backend/core/pomodoro_manager.py
new file mode 100644
index 0000000..1d9de6a
--- /dev/null
+++ b/backend/core/pomodoro_manager.py
@@ -0,0 +1,1831 @@
+"""
+Pomodoro Manager - Manages Pomodoro session lifecycle
+
+Responsibilities:
+1. Start/stop Pomodoro sessions
+2. Coordinate with PipelineCoordinator (enter/exit Pomodoro mode)
+3. Trigger deferred batch processing after session completion
+4. Track session metadata and handle orphaned sessions
+"""
+
+import asyncio
+import uuid
+from datetime import datetime, timedelta
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
+
+from core.db import get_db
+from core.events import (
+ emit_pomodoro_phase_switched,
+ emit_pomodoro_processing_complete,
+ emit_pomodoro_processing_failed,
+ emit_pomodoro_processing_progress,
+ emit_pomodoro_work_phase_completed,
+ emit_pomodoro_work_phase_failed,
+)
+from core.logger import get_logger
+
+if TYPE_CHECKING:
+ from llm.focus_evaluator import FocusEvaluator
+
+logger = get_logger(__name__)
+
+
+class _Constants:
+ """Pomodoro manager constants to eliminate magic numbers"""
+
+ # Session timing
+ PROCESSING_STUCK_THRESHOLD_MINUTES = 15
+ MIN_SESSION_DURATION_MINUTES = 2
+
+ # Processing timeouts
+ MAX_PHASE_WAIT_SECONDS = 300 # 5 minutes
+ TOTAL_PROCESSING_TIMEOUT_SECONDS = 600 # 10 minutes
+ POLL_INTERVAL_SECONDS = 3
+
+ # Retry configuration
+ MAX_RETRIES = 1
+ RETRY_DELAY_SECONDS = 10
+
+ # Default Pomodoro settings
+ DEFAULT_WORK_DURATION_MINUTES = 25
+ DEFAULT_BREAK_DURATION_MINUTES = 5
+ DEFAULT_TOTAL_ROUNDS = 4
+
+
+class PomodoroSession:
+ """Pomodoro session data class"""
+
+ def __init__(
+ self,
+ session_id: str,
+ user_intent: str,
+ duration_minutes: int,
+ start_time: datetime,
+ ):
+ self.id = session_id
+ self.user_intent = user_intent
+ self.duration_minutes = duration_minutes
+ self.start_time = start_time
+
+
+class PomodoroManager:
+ """
+ Pomodoro session manager
+
+ Handles Pomodoro lifecycle and coordinates with coordinator
+ """
+
+ def __init__(self, coordinator):
+ """
+ Initialize Pomodoro manager
+
+ Args:
+ coordinator: Reference to PipelineCoordinator instance
+ """
+ self.coordinator = coordinator
+ self.db = get_db()
+ self.current_session: PomodoroSession | None = None
+ self.is_active = False
+ self._processing_tasks: dict[str, asyncio.Task] = {}
+
+ # ============================================================
+ # Helper Methods - Session State Management
+ # ============================================================
+
+ def _clear_session_state(self) -> None:
+ """Clear current session state (unified cleanup)"""
+ self.is_active = False
+ self.current_session = None
+
+ def _cancel_phase_timer(self, session_id: str) -> None:
+ """Cancel phase timer for a session if running"""
+ if session_id in self._processing_tasks:
+ self._processing_tasks[session_id].cancel()
+ del self._processing_tasks[session_id]
+ logger.debug(f"Cancelled phase timer for session {session_id}")
+
+ def _get_session_defaults(self, session: dict[str, Any]) -> tuple[int, int, int]:
+ """
+ Get session configuration with defaults
+
+ Returns:
+ Tuple of (work_duration, break_duration, total_rounds)
+ """
+ return (
+ session.get("work_duration_minutes", _Constants.DEFAULT_WORK_DURATION_MINUTES),
+ session.get("break_duration_minutes", _Constants.DEFAULT_BREAK_DURATION_MINUTES),
+ session.get("total_rounds", _Constants.DEFAULT_TOTAL_ROUNDS),
+ )
+
+ # ============================================================
+ # Helper Methods - Time Calculations
+ # ============================================================
+
+ def _calculate_elapsed_minutes(self, end_time: datetime) -> float:
+ """Calculate elapsed minutes from session start"""
+ if not self.current_session:
+ return 0.0
+ return (end_time - self.current_session.start_time).total_seconds() / 60
+
+ async def _calculate_actual_work_minutes(
+ self,
+ session: dict[str, Any],
+ end_time: datetime,
+ ) -> int:
+ """
+ Calculate actual work duration in minutes
+
+ For completed rounds: use full work_duration
+ For current incomplete work phase: use actual elapsed time
+
+ Args:
+ session: Session record from database
+ end_time: Session end time
+
+ Returns:
+ Actual work minutes (integer)
+ """
+ completed_rounds = session.get("completed_rounds", 0)
+ work_duration, _, _ = self._get_session_defaults(session)
+ current_phase = session.get("current_phase", "work")
+
+ # Calculate time for completed rounds
+ actual_work_minutes = completed_rounds * work_duration
+
+ # If ending during work phase, add actual time worked in current phase
+ if current_phase == "work":
+ phase_start_time_str = session.get("phase_start_time")
+ if phase_start_time_str:
+ phase_start_time = datetime.fromisoformat(phase_start_time_str)
+ current_phase_minutes = (end_time - phase_start_time).total_seconds() / 60
+ actual_work_minutes += int(current_phase_minutes)
+ logger.debug(
+ f"Adding {int(current_phase_minutes)}min from current work phase"
+ )
+ else:
+ # Fallback: use full work_duration for current phase
+ actual_work_minutes += work_duration
+ logger.warning("No phase_start_time found, using full work_duration")
+
+ return actual_work_minutes
+
+ # ============================================================
+ # Helper Methods - Event Emission
+ # ============================================================
+
+ def _emit_phase_completion_event(
+ self,
+ session_id: str,
+ session: dict[str, Any] | None = None,
+ phase: str = "completed",
+ ) -> None:
+ """
+ Emit phase switched event (unified helper)
+
+ Args:
+ session_id: Session ID
+ session: Optional session dict for round info
+ phase: Phase name (default: "completed")
+ """
+ current_round = 1
+ total_rounds = _Constants.DEFAULT_TOTAL_ROUNDS
+ completed_rounds = 0
+
+ if session:
+ current_round = session.get("current_round", 1)
+ total_rounds = session.get("total_rounds", _Constants.DEFAULT_TOTAL_ROUNDS)
+ completed_rounds = session.get("completed_rounds", 0)
+
+ emit_pomodoro_phase_switched(
+ session_id=session_id,
+ new_phase=phase,
+ current_round=current_round,
+ total_rounds=total_rounds,
+ completed_rounds=completed_rounds,
+ )
+ logger.debug(f"Emitted phase event: session={session_id}, phase={phase}")
+
+ def _emit_progress_event(
+ self, session_id: str, job_id: str, processed: int
+ ) -> None:
+ """Emit progress event for frontend"""
+ try:
+ emit_pomodoro_processing_progress(session_id, job_id, processed)
+ except Exception as e:
+ logger.debug(f"Failed to emit progress event: {e}")
+
+ def _emit_completion_event(
+ self, session_id: str, job_id: str, total_processed: int
+ ) -> None:
+ """Emit completion event for frontend"""
+ try:
+ emit_pomodoro_processing_complete(session_id, job_id, total_processed)
+ except Exception as e:
+ logger.debug(f"Failed to emit completion event: {e}")
+
+ def _emit_failure_event(
+ self, session_id: str, job_id: str, error: str
+ ) -> None:
+ """Emit failure event for frontend"""
+ try:
+ emit_pomodoro_processing_failed(session_id, job_id, error)
+ except Exception as e:
+ logger.debug(f"Failed to emit failure event: {e}")
+
+ # ============================================================
+ # Helper Methods - Processing Status Checks
+ # ============================================================
+
+ async def _check_and_handle_stuck_processing(self) -> None:
+ """
+ Check for orphaned ACTIVE sessions only (not processing status)
+
+ Processing status is now independent and should NOT block new sessions.
+ Only check for truly orphaned active sessions from app crashes.
+
+ Background processing runs independently and doesn't prevent new sessions.
+ """
+ # ✅ NEW: Only check status="active" (orphaned sessions from crashes)
+ # Processing status is independent and doesn't block new sessions
+ active_sessions = await self.db.pomodoro_sessions.get_by_status("active")
+ if not active_sessions:
+ return
+
+ # Orphaned active session found - clean it up
+ for session in active_sessions:
+ session_id = session["id"]
+ logger.warning(
+ f"Found orphaned active session {session_id}, "
+ f"marking as abandoned (app restart detected)"
+ )
+
+ # Force end the orphaned session
+ # This code path only triggers on app restart after crash
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ status="abandoned",
+ processing_status="failed",
+ processing_error="Session orphaned (app restart detected)",
+ )
+
+ def _classify_aggregation_error(self, error: Exception) -> str:
+ """
+ Classify aggregation errors for better user feedback.
+
+ Returns:
+ - 'no_actions_found': No user activity during phase
+ - 'llm_clustering_failed': LLM API call failed
+ - 'supervisor_validation_failed': Activity validation failed
+ - 'database_save_failed': Database operation failed
+ - 'unknown_error': Unclassified error
+ """
+ error_str = str(error).lower()
+
+ if "no actions found" in error_str or "no action" in error_str:
+ return "no_actions_found"
+ elif "clustering" in error_str or "llm" in error_str or "api" in error_str:
+ return "llm_clustering_failed"
+ elif "supervisor" in error_str or "validation" in error_str:
+ return "supervisor_validation_failed"
+ elif "database" in error_str or "sql" in error_str:
+ return "database_save_failed"
+ else:
+ return "unknown_error"
+
+ # ============================================================
+ # Main Public Methods
+ # ============================================================
+
+ async def start_pomodoro(
+ self,
+ user_intent: str,
+ duration_minutes: int = 25,
+ associated_todo_id: Optional[str] = None,
+ work_duration_minutes: int = 25,
+ break_duration_minutes: int = 5,
+ total_rounds: int = 4,
+ ) -> str:
+ """
+ Start a new Pomodoro session with rounds
+
+ Actions:
+ 1. Create pomodoro_sessions record
+ 2. Signal coordinator to enter "pomodoro mode"
+ 3. Start phase timer for automatic work/break switching
+ 4. Coordinator disables continuous processing
+ 5. PerceptionManager captures during work phase only
+
+ Args:
+ user_intent: User's description of what they plan to work on
+ duration_minutes: Total planned duration (calculated from rounds)
+ associated_todo_id: Optional TODO ID to associate with this session
+ work_duration_minutes: Duration of each work phase (default: 25)
+ break_duration_minutes: Duration of each break phase (default: 5)
+ total_rounds: Total number of work rounds (default: 4)
+
+ Returns:
+ session_id
+
+ Raises:
+ ValueError: If a Pomodoro session is already active
+ """
+ if self.is_active:
+ raise ValueError("A Pomodoro session is already active")
+
+ # Check for stuck processing sessions
+ await self._check_and_handle_stuck_processing()
+
+ session_id = str(uuid.uuid4())
+ start_time = datetime.now()
+
+ # Calculate total duration: (work + break) * rounds - last break
+ total_duration = (work_duration_minutes + break_duration_minutes) * total_rounds - break_duration_minutes
+
+ try:
+ # Save to database
+ await self.db.pomodoro_sessions.create(
+ session_id=session_id,
+ user_intent=user_intent,
+ planned_duration_minutes=total_duration,
+ start_time=start_time.isoformat(),
+ status="active",
+ associated_todo_id=associated_todo_id,
+ work_duration_minutes=work_duration_minutes,
+ break_duration_minutes=break_duration_minutes,
+ total_rounds=total_rounds,
+ )
+
+ # Create session object
+ self.current_session = PomodoroSession(
+ session_id=session_id,
+ user_intent=user_intent,
+ duration_minutes=total_duration,
+ start_time=start_time,
+ )
+ self.is_active = True
+
+ # Signal coordinator to enter pomodoro mode (work phase)
+ await self.coordinator.enter_pomodoro_mode(session_id)
+
+ # Start phase timer for automatic switching
+ self._start_phase_timer(session_id, work_duration_minutes)
+
+ # Emit phase switch event to notify frontend that work phase started
+ emit_pomodoro_phase_switched(
+ session_id=session_id,
+ new_phase="work",
+ current_round=1,
+ total_rounds=total_rounds,
+ completed_rounds=0,
+ )
+
+ logger.info(
+ f"✓ Pomodoro session started: {session_id}, "
+ f"intent='{user_intent}', rounds={total_rounds}, "
+ f"work={work_duration_minutes}min, break={break_duration_minutes}min"
+ )
+
+ return session_id
+
+ except Exception as e:
+ logger.error(f"Failed to start Pomodoro session: {e}", exc_info=True)
+ # Cleanup on failure
+ self.is_active = False
+ self.current_session = None
+ raise
+
+ def _start_phase_timer(self, session_id: str, duration_minutes: int) -> None:
+ """
+ Start a timer for current phase
+
+ When timer expires, automatically switch to next phase.
+
+ Args:
+ session_id: Session ID
+ duration_minutes: Duration of current phase in minutes
+ """
+ # Cancel any existing timer for this session
+ if session_id in self._processing_tasks:
+ self._processing_tasks[session_id].cancel()
+
+ # Create async task for phase timer
+ async def phase_timer():
+ try:
+ # Wait for phase duration
+ await asyncio.sleep(duration_minutes * 60)
+
+ # Switch to next phase
+ await self._auto_switch_phase(session_id)
+
+ except asyncio.CancelledError:
+ logger.debug(f"Phase timer cancelled for session {session_id}")
+ except Exception as e:
+ logger.error(
+ f"Error in phase timer for session {session_id}: {e}",
+ exc_info=True,
+ )
+
+ # Store task reference
+ task = asyncio.create_task(phase_timer())
+ self._processing_tasks[session_id] = task
+
+ async def _auto_switch_phase(self, session_id: str) -> None:
+ """
+ Automatically switch to next phase when current phase completes
+
+ Phase transitions:
+ - work → break: Stop perception, start break timer
+ - break → work: Start perception, start work timer
+ - If all rounds completed: End session
+
+ Args:
+ session_id: Session ID
+ """
+ try:
+ # Get current session state
+ session = await self.db.pomodoro_sessions.get_by_id(session_id)
+ if not session:
+ logger.warning(f"Session {session_id} not found for phase switch")
+ return
+
+ current_phase = session.get("current_phase", "work")
+ current_round = session.get("current_round", 1)
+ work_duration, break_duration, total_rounds = self._get_session_defaults(session)
+
+ logger.info(
+ f"Auto-switching phase for session {session_id}: "
+ f"current_phase={current_phase}, round={current_round}/{total_rounds}"
+ )
+
+ # Determine next phase
+ if current_phase == "work":
+ # Work phase completed, switch to break
+ new_phase = "break"
+ next_duration = break_duration
+
+ # Calculate phase timing
+ phase_start_time_str = session.get("phase_start_time")
+ if phase_start_time_str:
+ phase_start_time = datetime.fromisoformat(phase_start_time_str)
+ else:
+ # Fallback to session start time if phase start time not available
+ phase_start_time = datetime.fromisoformat(
+ session.get("start_time", datetime.now().isoformat())
+ )
+ phase_end_time = datetime.now()
+
+ # ★ NEW: Create phase record BEFORE triggering aggregation ★
+ phase_id = await self.db.work_phases.create(
+ session_id=session_id,
+ phase_number=current_round,
+ phase_start_time=phase_start_time.isoformat(),
+ phase_end_time=phase_end_time.isoformat(),
+ status="pending",
+ )
+
+ logger.info(
+ f"Created phase record: session={session_id}, "
+ f"phase={current_round}, id={phase_id}"
+ )
+
+ # ★ FORCE SETTLEMENT: Process all pending records before phase ends ★
+ # This ensures no actions are lost during phase transition
+ logger.info(
+ f"Force settling all pending records for work phase {current_round}"
+ )
+ settlement_result = await self.force_settlement(session_id)
+ if settlement_result.get("success"):
+ logger.info(
+ f"✓ Force settlement successful: "
+ f"{settlement_result['records_processed']['total']} records processed, "
+ f"{settlement_result['events_generated']} events, "
+ f"{settlement_result['activities_generated']} activities"
+ )
+ else:
+ logger.warning(
+ f"Force settlement had issues but continuing: "
+ f"{settlement_result.get('error', 'Unknown error')}"
+ )
+
+ # ★ CRITICAL: Stop perception AFTER force settlement ★
+ # This ensures no new records are captured while we're aggregating
+ # Stop perception during break
+ await self.coordinator.exit_pomodoro_mode()
+
+ # ★ Trigger aggregation AFTER stopping perception ★
+ # This guarantees all captured records have been processed
+ asyncio.create_task(
+ self._aggregate_work_phase_activities(
+ session_id=session_id,
+ work_phase=current_round,
+ phase_start_time=phase_start_time,
+ phase_end_time=phase_end_time,
+ phase_id=phase_id,
+ )
+ )
+
+ elif current_phase == "break":
+ # Break completed, switch to next work round
+ new_phase = "work"
+ next_duration = work_duration
+
+ # Resume perception for work phase
+ await self.coordinator.enter_pomodoro_mode(session_id)
+
+ else:
+ logger.warning(f"Unknown phase '{current_phase}' for session {session_id}")
+ return
+
+ # Update session phase in database (this increments completed_rounds for work→break)
+ phase_start_time = datetime.now().isoformat()
+ updated_session = await self.db.pomodoro_sessions.switch_phase(
+ session_id, new_phase, phase_start_time
+ )
+
+ # Check if session completed after phase switch (all rounds done)
+ if updated_session.get("status") == "completed":
+ # All rounds completed, end session
+ await self._complete_session(session_id)
+ return
+
+ # Start timer for next phase
+ self._start_phase_timer(session_id, next_duration)
+
+ # Emit phase switch event to frontend
+ emit_pomodoro_phase_switched(
+ session_id=session_id,
+ new_phase=new_phase,
+ current_round=updated_session.get("current_round", current_round),
+ total_rounds=total_rounds,
+ completed_rounds=updated_session.get("completed_rounds", 0),
+ )
+
+ logger.info(
+ f"✓ Switched to {new_phase} phase for session {session_id}, "
+ f"duration={next_duration}min"
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Failed to auto-switch phase for session {session_id}: {e}",
+ exc_info=True,
+ )
+
+ async def _complete_session(self, session_id: str) -> None:
+ """
+ Complete a Pomodoro session after all rounds finished
+
+ Args:
+ session_id: Session ID
+ """
+ try:
+ logger.info(f"Completing Pomodoro session {session_id}: all rounds finished")
+
+ # Mark session as completed
+ end_time = datetime.now()
+ session = await self.db.pomodoro_sessions.get_by_id(session_id)
+
+ if session:
+ # Calculate actual work duration based on completed rounds
+ completed_rounds = session.get("completed_rounds", 0)
+ work_duration = session.get("work_duration_minutes", 25)
+ actual_work_minutes = completed_rounds * work_duration
+
+ logger.info(
+ f"Session completed: {completed_rounds} rounds × {work_duration}min = {actual_work_minutes}min"
+ )
+
+ await self.db.pomodoro_sessions.update(
+ session_id,
+ status="completed",
+ end_time=end_time.isoformat(),
+ actual_duration_minutes=actual_work_minutes,
+ current_phase="completed",
+ )
+
+ # Emit completion event to frontend (so desktop clock can switch to normal mode)
+ self._emit_phase_completion_event(session_id, session, "completed")
+ logger.info(f"Emitted completion event for session {session_id}")
+
+ # Cleanup
+ self._clear_session_state()
+ self._cancel_phase_timer(session_id)
+
+ # Exit pomodoro mode
+ await self.coordinator.exit_pomodoro_mode()
+
+ # Trigger batch processing
+ await self._trigger_batch_processing(session_id)
+
+ logger.info(f"✓ Session {session_id} completed successfully")
+
+ except Exception as e:
+ logger.error(f"Failed to complete session {session_id}: {e}", exc_info=True)
+
+ # ============================================================
+ # Helper Methods - End Pomodoro Workflow
+ # ============================================================
+
+ async def _handle_too_short_session(
+ self,
+ session_id: str,
+ end_time: datetime,
+ elapsed_minutes: float,
+ ) -> dict[str, Any]:
+ """
+ Handle sessions that are too short (< 2 minutes) - immediate return
+
+ Args:
+ session_id: Session ID
+ end_time: Session end time
+ elapsed_minutes: Elapsed duration in minutes
+
+ Returns:
+ Response dict for too-short session
+ """
+ logger.warning(
+ f"Pomodoro session {session_id} too short ({elapsed_minutes:.1f}min), marking as abandoned"
+ )
+
+ # Update database (fast)
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ end_time=end_time.isoformat(),
+ actual_duration_minutes=int(elapsed_minutes),
+ status="abandoned",
+ processing_status="failed",
+ )
+
+ # Emit completion event IMMEDIATELY for frontend/clock to reset state
+ self._emit_phase_completion_event(session_id)
+ logger.info(f"Emitted completion event for abandoned session {session_id}")
+
+ # Exit pomodoro mode and cleanup
+ await self.coordinator.exit_pomodoro_mode()
+ self._clear_session_state()
+
+ return {
+ "session_id": session_id,
+ "status": "abandoned",
+ "actual_work_minutes": 0,
+ "raw_records_count": 0, # ✅ Added back for compatibility
+ "message": "Session too short, marked as abandoned",
+ }
+
+ async def _process_incomplete_phases(
+ self,
+ session_id: str,
+ session: dict[str, Any],
+ end_time: datetime,
+ ) -> None:
+ """
+ Process all work phases that occurred during session (parallel processing)
+
+ CRITICAL: This is a background task that must not crash.
+ All errors are isolated and logged.
+
+ Args:
+ session_id: Session ID
+ session: Session record from database
+ end_time: Session end time
+ """
+ try:
+ current_phase = session.get("current_phase", "work")
+ current_round = session.get("current_round", 1)
+ completed_rounds = session.get("completed_rounds", 0)
+
+ # Identify all work phases to process
+ work_phases_to_process = list(range(1, completed_rounds + 1))
+
+ # Include current work phase if session ended during work
+ if current_phase == "work" and current_round not in work_phases_to_process:
+ work_phases_to_process.append(current_round)
+ # Increment completed_rounds to reflect this work phase
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ completed_rounds=completed_rounds + 1,
+ )
+
+ logger.info(
+ f"Session termination: processing {len(work_phases_to_process)} work phases "
+ f"in parallel: {work_phases_to_process}"
+ )
+
+ # Create phase records and trigger parallel aggregation
+ aggregation_tasks = []
+ for phase_num in work_phases_to_process:
+ # Use unified time window calculation
+ phase_start, phase_end = await self._get_phase_time_window(session, phase_num)
+
+ # Use actual end time for last phase (if ending during work)
+ if phase_num == max(work_phases_to_process) and current_phase == "work":
+ phase_end = min(phase_end, end_time)
+
+ # Check if phase record already exists
+ existing_phase = await self.db.work_phases.get_by_session_and_phase(
+ session_id, phase_num
+ )
+
+ # Skip if already completed or processing
+ if existing_phase and existing_phase["status"] in ("completed", "processing"):
+ logger.info(
+ f"Phase {phase_num} already {existing_phase['status']}, skipping"
+ )
+ continue
+
+ # Create or get phase record
+ if existing_phase:
+ phase_id = existing_phase["id"]
+ else:
+ phase_id = await self.db.work_phases.create(
+ session_id=session_id,
+ phase_number=phase_num,
+ phase_start_time=phase_start.isoformat(),
+ phase_end_time=phase_end.isoformat(),
+ status="pending",
+ )
+
+ # Create parallel task (don't await)
+ task = asyncio.create_task(
+ self._aggregate_work_phase_activities(
+ session_id=session_id,
+ work_phase=phase_num,
+ phase_start_time=phase_start,
+ phase_end_time=phase_end,
+ phase_id=phase_id,
+ )
+ )
+ aggregation_tasks.append(task)
+
+ logger.info(
+ f"Triggered parallel aggregation for {len(aggregation_tasks)} work phases"
+ )
+
+ except Exception as e:
+ # ✅ Isolate all errors to prevent crash
+ logger.error(
+ f"Error processing incomplete phases for session {session_id}: {e}",
+ exc_info=True,
+ )
+ # Don't re-raise - this is a background task
+
+ async def _update_session_metadata(
+ self,
+ session_id: str,
+ end_time: datetime,
+ actual_work_minutes: int,
+ status: str,
+ ) -> None:
+ """
+ Update session metadata in database (fast, non-blocking)
+
+ Args:
+ session_id: Session ID
+ end_time: Session end time
+ actual_work_minutes: Actual work duration in minutes
+ status: Session status
+ """
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ end_time=end_time.isoformat(),
+ actual_duration_minutes=actual_work_minutes,
+ status=status,
+ processing_status="pending",
+ )
+
+ async def _background_finalize_session(
+ self,
+ session_id: str,
+ ) -> None:
+ """
+ Background task: force settlement, cleanup, trigger processing
+
+ CRITICAL: This task MUST NEVER crash or block new sessions.
+ All errors are logged but do not propagate.
+
+ This runs asynchronously after user-facing state has been updated.
+
+ Args:
+ session_id: Session ID
+ """
+ try:
+ logger.info(f"Starting background finalization for session {session_id}")
+
+ # Force settlement: process all pending records
+ # ✅ Isolate settlement errors to prevent crash
+ try:
+ logger.info("Force settling all pending records for session end")
+ settlement_result = await self.force_settlement(session_id)
+ if settlement_result.get("success"):
+ logger.info(
+ f"✓ Force settlement successful: "
+ f"{settlement_result['records_processed']['total']} records processed, "
+ f"{settlement_result['events_generated']} events, "
+ f"{settlement_result['activities_generated']} activities"
+ )
+ else:
+ logger.warning(
+ f"Force settlement had issues but continuing: "
+ f"{settlement_result.get('error', 'Unknown error')}"
+ )
+ except Exception as e:
+ # ✅ Isolate settlement errors
+ logger.error(f"Force settlement failed: {e}", exc_info=True)
+
+ # Trigger batch processing
+ # ✅ Isolate batch processing errors to prevent crash
+ try:
+ await self._trigger_batch_processing(session_id)
+ except Exception as e:
+ # ✅ Isolate batch processing errors
+ logger.error(f"Batch processing failed: {e}", exc_info=True)
+
+ logger.info(f"✓ Background finalization completed for session {session_id}")
+
+ except Exception as e:
+ # ✅ Catch-all for any unexpected errors
+ logger.error(
+ f"Unexpected error in background finalization: {e}",
+ exc_info=True,
+ )
+ # Mark processing as failed so it doesn't appear stuck
+ try:
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ processing_status="failed",
+ processing_error=f"Background finalization error: {str(e)}",
+ )
+ except:
+ # Even DB update failure shouldn't crash
+ pass
+
+ # ============================================================
+ # Public Methods - Session Control
+ # ============================================================
+
+ async def end_pomodoro(self, status: str = "completed") -> dict[str, Any]:
+ """
+ End current Pomodoro session (manual termination)
+
+ IMPORTANT: This method returns IMMEDIATELY after updating user-facing state.
+ All heavy processing (settlement, aggregation) happens in background.
+
+ Workflow:
+ 1. ✅ Validate session (fast)
+ 2. ✅ Cancel phase timer (fast)
+ 3. ✅ Update database metadata (fast)
+ 4. ✅ Flush perception buffers (fast)
+ 5. ✅ Emit completion event (fast)
+ 6. ✅ Exit pomodoro mode (fast)
+ 7. ✅ Clear local state (fast)
+ 8. ✅ Return immediately to user
+ 9. 🔄 Start background processing (async, non-blocking)
+
+ Args:
+ status: Session status ('completed', 'abandoned', 'interrupted')
+
+ Returns:
+ {
+ "session_id": str,
+ "status": str,
+ "actual_work_minutes": int
+ }
+
+ Raises:
+ ValueError: If no active Pomodoro session
+ """
+ if not self.is_active or not self.current_session:
+ raise ValueError("No active Pomodoro session")
+
+ session_id = self.current_session.id
+ end_time = datetime.now()
+ elapsed_duration = self._calculate_elapsed_minutes(end_time)
+
+ # Cancel phase timer if running
+ self._cancel_phase_timer(session_id)
+
+ try:
+ # Check if session is too short (< 2 minutes)
+ if elapsed_duration < _Constants.MIN_SESSION_DURATION_MINUTES:
+ return await self._handle_too_short_session(
+ session_id, end_time, elapsed_duration
+ )
+
+ # ========== FAST PATH: Immediate user-facing updates ==========
+
+ # Get session data
+ session = await self.db.pomodoro_sessions.get_by_id(session_id)
+
+ # Calculate actual work duration
+ actual_work_minutes = (
+ await self._calculate_actual_work_minutes(session, end_time)
+ if session
+ else int(elapsed_duration)
+ )
+
+ # Update database metadata (fast, no heavy processing)
+ await self._update_session_metadata(
+ session_id, end_time, actual_work_minutes, status
+ )
+
+ # Flush ImageConsumer buffer (fast)
+ perception_manager = self.coordinator.perception_manager
+ if perception_manager and perception_manager.image_consumer:
+ remaining = perception_manager.image_consumer.flush()
+ logger.debug(f"Flushed {len(remaining)} buffered screenshots")
+
+ # Emit completion event IMMEDIATELY for frontend/clock
+ self._emit_phase_completion_event(session_id, session, "completed")
+ logger.info(f"Emitted completion event for session {session_id}")
+
+ # Exit pomodoro mode (stops perception)
+ await self.coordinator.exit_pomodoro_mode()
+
+ # Clear local state
+ self._clear_session_state()
+
+ logger.info(
+ f"✓ Pomodoro session ended (immediate response): {session_id}, "
+ f"status={status}, elapsed={elapsed_duration:.1f}min, "
+ f"actual_work={actual_work_minutes}min"
+ )
+
+ # ========== BACKGROUND PATH: Heavy processing (non-blocking) ==========
+
+ # Trigger background tasks asynchronously
+ if session:
+ # Process incomplete work phases (parallel, background)
+ asyncio.create_task(
+ self._process_incomplete_phases(session_id, session, end_time)
+ )
+
+ # Trigger background finalization (settlement + batch processing)
+ asyncio.create_task(self._background_finalize_session(session_id))
+
+ logger.debug(f"Background processing started for session {session_id}")
+
+ # ========== IMMEDIATE RETURN ==========
+
+ # Count raw records for compatibility with frontend/handler
+ raw_count = await self.db.raw_records.count_by_session(session_id)
+
+ return {
+ "session_id": session_id,
+ "status": status,
+ "actual_work_minutes": actual_work_minutes,
+ "raw_records_count": raw_count, # ✅ Added back for compatibility
+ "message": "Session ended successfully. Background processing started.",
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to end Pomodoro session: {e}", exc_info=True)
+ # Ensure state is cleaned up even on error
+ self._clear_session_state()
+ raise
+
+ async def _trigger_batch_processing(self, session_id: str) -> str:
+ """
+ Trigger background batch processing for Pomodoro session
+
+ Creates async task that:
+ 1. Loads all RawRecords with pomodoro_session_id
+ 2. Processes through normal pipeline (deferred)
+ 3. Updates processing_status as it progresses
+ 4. Emits events for frontend to track progress
+
+ Args:
+ session_id: Pomodoro session ID
+
+ Returns:
+ job_id: Processing job identifier
+ """
+ job_id = str(uuid.uuid4())
+
+ # Create background task
+ task = asyncio.create_task(self._process_pomodoro_batch(session_id, job_id))
+
+ # Store task reference
+ self._processing_tasks[job_id] = task
+
+ logger.debug(f"✓ Batch processing triggered: job={job_id}, session={session_id}")
+
+ return job_id
+
+ async def _process_pomodoro_batch(self, session_id: str, job_id: str):
+ """
+ SIMPLIFIED: Wait for all work phases to complete and trigger LLM evaluation
+
+ NOTE: Batch processing of raw records is removed. All data processing
+ now happens through phase-level aggregation in _aggregate_work_phase_activities.
+
+ Steps:
+ 1. Update status to 'processing'
+ 2. Wait for all work phases to complete (max 5 minutes)
+ 3. Trigger LLM evaluation (with timeout protection)
+ 4. Update status to 'completed'
+ 5. Emit completion event
+
+ Args:
+ session_id: Pomodoro session ID
+ job_id: Processing job ID
+ """
+ try:
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ processing_status="processing",
+ processing_started_at=datetime.now().isoformat(),
+ )
+
+ logger.info(f"→ Waiting for work phases to complete: {session_id}")
+
+ # Wrap entire processing in timeout (max 10 minutes total)
+ # This prevents processing from hanging indefinitely
+ try:
+ await asyncio.wait_for(
+ self._wait_and_trigger_llm_evaluation(session_id),
+ timeout=_Constants.TOTAL_PROCESSING_TIMEOUT_SECONDS
+ )
+ except asyncio.TimeoutError:
+ logger.error(
+ f"Processing timeout (10 minutes) for session {session_id}, "
+ f"marking as failed"
+ )
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ processing_status="failed",
+ processing_error="Processing timeout (10 minutes exceeded)",
+ )
+ self._emit_failure_event(session_id, job_id, "Processing timeout")
+ self._processing_tasks.pop(job_id, None)
+ return
+
+ # Update status
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ processing_status="completed",
+ processing_completed_at=datetime.now().isoformat(),
+ )
+
+ logger.info(f"✓ Pomodoro session completed: {session_id}")
+
+ # Emit completion event
+ self._emit_completion_event(session_id, job_id, 0)
+
+ # Cleanup task reference
+ self._processing_tasks.pop(job_id, None)
+
+ except Exception as e:
+ logger.error(
+ f"✗ Pomodoro session completion failed: {e}", exc_info=True
+ )
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ processing_status="failed",
+ processing_error=str(e),
+ )
+
+ # Emit failure event
+ self._emit_failure_event(session_id, job_id, str(e))
+
+ # Cleanup task reference
+ self._processing_tasks.pop(job_id, None)
+
+
+
+ async def _wait_and_trigger_llm_evaluation(self, session_id: str) -> None:
+ """
+ Wait for all work phases to complete successfully, then trigger LLM evaluation.
+
+ This ensures AI analysis only runs after all activity data is ready.
+ For initial generation, retries are automatic. For subsequent failures,
+ users can manually retry.
+
+ Args:
+ session_id: Pomodoro session ID
+ """
+ try:
+ logger.info(f"Waiting for all work phases to complete for session {session_id}")
+
+ # Get session info
+ session = await self.db.pomodoro_sessions.get_by_id(session_id)
+ if not session:
+ logger.warning(f"Session {session_id} not found, skipping LLM evaluation wait")
+ return
+
+ # Check if session is still active/pending (not already ended)
+ session_status = session.get("processing_status", "pending")
+ if session_status not in ("pending", "processing"):
+ logger.info(
+ f"Session {session_id} status is '{session_status}', skipping LLM evaluation wait"
+ )
+ return
+
+ # Use completed_rounds instead of total_rounds
+ # When user ends session early, only completed_rounds phases are created
+ completed_rounds = session.get("completed_rounds", 0)
+ if completed_rounds == 0:
+ # No work phases completed, skip waiting
+ logger.info(
+ f"No completed work phases for session {session_id}, "
+ f"proceeding directly to LLM evaluation"
+ )
+ await self._compute_and_cache_llm_evaluation(session_id, is_first_attempt=True)
+ return
+
+ expected_phases = completed_rounds
+ waited_time = 0
+
+ # Wait for all completed phases to reach terminal state (completed or failed)
+ while waited_time < _Constants.MAX_PHASE_WAIT_SECONDS:
+ # Re-check session status in case it was ended during wait
+ session = await self.db.pomodoro_sessions.get_by_id(session_id)
+ if session:
+ current_status = session.get("processing_status", "pending")
+ if current_status not in ("pending", "processing"):
+ logger.info(
+ f"Session {session_id} status changed to '{current_status}', "
+ f"stopping LLM evaluation wait"
+ )
+ return
+
+ phases = await self.db.work_phases.get_by_session(session_id)
+
+ # Check if all expected work phases exist and have terminal status
+ completed_phases = [p for p in phases if p["status"] == "completed"]
+ failed_phases = [p for p in phases if p["status"] == "failed"]
+ terminal_phases = completed_phases + failed_phases
+
+ if len(terminal_phases) >= expected_phases:
+ # All expected phases have reached terminal state
+ logger.info(
+ f"All {expected_phases} work phases reached terminal state: "
+ f"completed={len(completed_phases)}, failed={len(failed_phases)}"
+ )
+ break
+
+ # Still waiting for phases to complete
+ logger.debug(
+ f"Waiting for work phases: {len(terminal_phases)}/{expected_phases} complete, "
+ f"waited {waited_time}s"
+ )
+
+ await asyncio.sleep(_Constants.POLL_INTERVAL_SECONDS)
+ waited_time += _Constants.POLL_INTERVAL_SECONDS
+
+ if waited_time >= _Constants.MAX_PHASE_WAIT_SECONDS:
+ logger.warning(
+ f"Timeout waiting for work phases to complete ({_Constants.MAX_PHASE_WAIT_SECONDS}s), "
+ f"proceeding with LLM evaluation anyway"
+ )
+
+ # Now trigger LLM evaluation
+ await self._compute_and_cache_llm_evaluation(session_id, is_first_attempt=True)
+
+ except Exception as e:
+ logger.error(
+ f"Error waiting for phases before LLM evaluation: {e}",
+ exc_info=True
+ )
+ # Continue to try LLM evaluation anyway
+ await self._compute_and_cache_llm_evaluation(session_id, is_first_attempt=True)
+
+ async def _compute_and_cache_llm_evaluation(
+ self, session_id: str, is_first_attempt: bool = False
+ ) -> None:
+ """
+ Compute LLM focus evaluation and cache to database
+
+ Called after all work phases complete to pre-compute evaluation.
+ Failures are logged but don't block session completion.
+
+ This method now also updates individual activity focus_scores for
+ better data granularity and frontend display.
+
+ Args:
+ session_id: Pomodoro session ID
+ is_first_attempt: Whether this is the first automatic attempt
+ """
+ try:
+ logger.info(f"Computing LLM focus evaluation for session {session_id}")
+
+ # Get session and activities
+ session = await self.db.pomodoro_sessions.get_by_id(session_id)
+ if not session:
+ logger.warning(f"Session {session_id} not found for LLM evaluation")
+ return
+
+ activities = await self.db.activities.get_by_pomodoro_session(session_id)
+
+ if not activities:
+ logger.info(f"No activities for session {session_id}, skipping LLM evaluation")
+ return
+
+ # Compute LLM evaluation (session-level)
+ from llm.focus_evaluator import get_focus_evaluator
+
+ focus_evaluator = get_focus_evaluator()
+ llm_result = await focus_evaluator.evaluate_focus(
+ activities=activities,
+ session_info=session,
+ )
+
+ # Cache session-level result to database
+ await self.db.pomodoro_sessions.update_llm_evaluation(
+ session_id, llm_result
+ )
+
+ logger.info(
+ f"✓ LLM evaluation cached for session {session_id}: "
+ f"score={llm_result.get('focus_score')}, "
+ f"level={llm_result.get('focus_level')}"
+ )
+
+ # Update individual activity focus scores for better granularity
+ await self._update_activity_focus_scores(
+ session_id, activities, session, focus_evaluator
+ )
+
+ except Exception as e:
+ # Don't crash session completion if LLM evaluation fails
+ logger.error(
+ f"Failed to compute LLM evaluation for session {session_id}: {e}",
+ exc_info=True,
+ )
+ # Continue gracefully - evaluation can be computed on-demand later
+
+ async def _update_activity_focus_scores(
+ self,
+ session_id: str,
+ activities: list[dict[str, Any]],
+ session: dict[str, Any],
+ focus_evaluator: "FocusEvaluator",
+ ) -> None:
+ """
+ Update focus scores for individual activities
+
+ This provides better granularity than session-level scores and enables
+ per-activity focus analysis in the frontend.
+
+ Args:
+ session_id: Pomodoro session ID
+ activities: List of activity dictionaries
+ session: Session dictionary with user_intent and related_todos
+ focus_evaluator: FocusEvaluator instance
+ """
+ try:
+ logger.debug(f"Updating focus scores for {len(activities)} activities")
+
+ # Prepare session context for evaluation
+ session_context = {
+ "user_intent": session.get("user_intent"),
+ "related_todos": session.get("related_todos", []),
+ }
+
+ # Evaluate and update each activity
+ activity_scores = []
+ for activity in activities:
+ try:
+ # Evaluate single activity focus
+ activity_eval = await focus_evaluator.evaluate_activity_focus(
+ activity=activity,
+ session_context=session_context,
+ )
+
+ focus_score = activity_eval.get("focus_score", 50.0)
+ activity_scores.append({
+ "activity_id": activity["id"],
+ "focus_score": focus_score,
+ })
+
+ logger.debug(
+ f"Activity '{activity.get('title', 'Untitled')[:30]}' "
+ f"focus_score: {focus_score}"
+ )
+
+ except Exception as e:
+ logger.warning(
+ f"Failed to evaluate activity {activity.get('id')}: {e}, "
+ f"using default score"
+ )
+ # Use default score on failure
+ activity_scores.append({
+ "activity_id": activity["id"],
+ "focus_score": 50.0,
+ })
+
+ # Batch update all activity focus scores
+ if activity_scores:
+ updated_count = await self.db.activities.batch_update_focus_scores(
+ activity_scores
+ )
+ logger.info(
+ f"✓ Updated focus_scores for {updated_count} activities "
+ f"in session {session_id}"
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Failed to update activity focus scores for session {session_id}: {e}",
+ exc_info=True,
+ )
+ # Non-critical error, continue gracefully
+
+ async def check_orphaned_sessions(self) -> int:
+ """
+ Check for orphaned sessions from previous runs
+
+ Orphaned sessions are active sessions that were not properly closed
+ (e.g., due to app crash or system shutdown).
+
+ This should be called on application startup.
+
+ Returns:
+ Number of orphaned sessions found and recovered
+ """
+ try:
+ orphaned = await self.db.pomodoro_sessions.get_by_status("active")
+
+ if not orphaned:
+ return 0
+
+ logger.warning(f"Found {len(orphaned)} orphaned Pomodoro session(s)")
+
+ for session in orphaned:
+ session_id = session["id"]
+ recovery_time = datetime.now()
+
+ # Calculate actual work duration
+ completed_rounds = session.get("completed_rounds", 0)
+ work_duration = session.get("work_duration_minutes", 25)
+ actual_work_minutes = completed_rounds * work_duration
+
+ # If session was interrupted during a work phase, add actual time worked
+ if session.get("current_phase") == "work":
+ phase_start_time_str = session.get("phase_start_time")
+ if phase_start_time_str:
+ try:
+ phase_start_time = datetime.fromisoformat(phase_start_time_str)
+ # Calculate actual time worked in interrupted phase (in minutes)
+ current_phase_minutes = (recovery_time - phase_start_time).total_seconds() / 60
+ actual_work_minutes += int(current_phase_minutes)
+ logger.info(
+ f"Orphaned session {session_id} interrupted during work phase: "
+ f"adding {int(current_phase_minutes)}min to total"
+ )
+ except Exception as e:
+ # Fallback: if phase_start_time parsing fails, use full work_duration
+ actual_work_minutes += work_duration
+ logger.warning(
+ f"Failed to parse phase_start_time for orphaned session {session_id}, "
+ f"using full work_duration: {e}"
+ )
+ else:
+ # Fallback: if no phase_start_time, use full work_duration
+ actual_work_minutes += work_duration
+ logger.warning(
+ f"No phase_start_time for orphaned session {session_id}, "
+ f"using full work_duration ({work_duration}min)"
+ )
+
+ # Auto-end as 'abandoned' (SIMPLIFIED: interrupted → abandoned)
+ await self.db.pomodoro_sessions.update(
+ session_id=session_id,
+ end_time=recovery_time.isoformat(),
+ actual_duration_minutes=actual_work_minutes,
+ status="abandoned",
+ processing_status="pending",
+ )
+
+ # Trigger batch processing
+ await self._trigger_batch_processing(session_id)
+
+ logger.info(
+ f"✓ Recovered orphaned session: {session_id}, "
+ f"actual_work={actual_work_minutes}min, triggering analysis"
+ )
+
+ return len(orphaned)
+
+ except Exception as e:
+ logger.error(f"Failed to check orphaned sessions: {e}", exc_info=True)
+ return 0
+
+ async def get_current_session_info(self) -> dict[str, Any] | None:
+ """
+ Get current session information with rounds and phase data
+
+ Returns:
+ Session info dict or None if no active session
+ """
+ if not self.is_active or not self.current_session:
+ return None
+
+ # Fetch full session info from database to get all fields
+ session_record = await self.db.pomodoro_sessions.get_by_id(
+ self.current_session.id
+ )
+
+ if not session_record:
+ return None
+
+ now = datetime.now()
+ elapsed_minutes = (
+ now - self.current_session.start_time
+ ).total_seconds() / 60
+
+ # Get phase information
+ current_phase = session_record.get("current_phase", "work")
+ phase_start_time_str = session_record.get("phase_start_time")
+ work_duration = session_record.get("work_duration_minutes", 25)
+ break_duration = session_record.get("break_duration_minutes", 5)
+
+ # Calculate remaining time in current phase
+ remaining_phase_seconds = None
+ if phase_start_time_str:
+ try:
+ phase_start = datetime.fromisoformat(phase_start_time_str)
+ phase_elapsed = (now - phase_start).total_seconds()
+
+ # Determine phase duration
+ phase_duration_seconds = (
+ work_duration * 60
+ if current_phase == "work"
+ else break_duration * 60
+ )
+
+ remaining_phase_seconds = max(
+ 0, int(phase_duration_seconds - phase_elapsed)
+ )
+ except Exception as e:
+ logger.warning(f"Failed to calculate remaining time: {e}")
+
+ session_info = {
+ "session_id": self.current_session.id,
+ "user_intent": self.current_session.user_intent,
+ "start_time": self.current_session.start_time.isoformat(),
+ "elapsed_minutes": int(elapsed_minutes),
+ "planned_duration_minutes": self.current_session.duration_minutes,
+ "associated_todo_id": session_record.get("associated_todo_id"),
+ "associated_todo_title": None,
+ # Rounds data
+ "work_duration_minutes": work_duration,
+ "break_duration_minutes": break_duration,
+ "total_rounds": session_record.get("total_rounds", 4),
+ "current_round": session_record.get("current_round", 1),
+ "current_phase": current_phase,
+ "phase_start_time": phase_start_time_str,
+ "completed_rounds": session_record.get("completed_rounds", 0),
+ "remaining_phase_seconds": remaining_phase_seconds,
+ }
+
+ # If there's an associated TODO, fetch its title
+ todo_id = session_info["associated_todo_id"]
+ if todo_id:
+ try:
+ # Ensure todo_id is a string for type safety
+ todo_id_str = str(todo_id) if not isinstance(todo_id, str) else todo_id
+ todo = await self.db.todos.get_by_id(todo_id_str)
+ if todo and not todo.get("deleted"):
+ session_info["associated_todo_title"] = todo.get("title")
+ except Exception as e:
+ logger.warning(
+ f"Failed to fetch TODO title for session {self.current_session.id}: {e}"
+ )
+
+ return session_info
+
+ def _classify_aggregation_error(self, error: Exception) -> str:
+ """
+ Classify aggregation errors for better user feedback.
+
+ Returns:
+ - 'no_actions_found': No user activity during phase
+ - 'llm_clustering_failed': LLM API call failed
+ - 'supervisor_validation_failed': Activity validation failed
+ - 'database_save_failed': Database operation failed
+ - 'unknown_error': Unclassified error
+ """
+ error_str = str(error).lower()
+
+ if "no actions found" in error_str or "no action" in error_str:
+ return "no_actions_found"
+ elif "clustering" in error_str or "llm" in error_str or "api" in error_str:
+ return "llm_clustering_failed"
+ elif "supervisor" in error_str or "validation" in error_str:
+ return "supervisor_validation_failed"
+ elif "database" in error_str or "sql" in error_str:
+ return "database_save_failed"
+ else:
+ return "unknown_error"
+
+ async def _get_phase_time_window(
+ self,
+ session: dict[str, Any],
+ phase_number: int
+ ) -> tuple[datetime, datetime]:
+ """
+ Unified phase time window calculation logic
+
+ IMPORTANT: Uses user-configured durations from session record, NOT hardcoded defaults.
+
+ Priority:
+ 1. Use actual times from work_phases table (if phase completed)
+ 2. Calculate from session start + user-configured durations (fallback)
+
+ Args:
+ session: Session record dict from database
+ phase_number: Phase number (1-based)
+
+ Returns:
+ Tuple of (phase_start_time, phase_end_time)
+ """
+ try:
+ # Try to get actual phase record from database (most accurate)
+ phase_record = await self.db.work_phases.get_by_session_and_phase(
+ session['id'], phase_number
+ )
+
+ if phase_record and phase_record.get('phase_start_time'):
+ # Use actual recorded times (preferred)
+ start_time = datetime.fromisoformat(phase_record['phase_start_time'])
+ end_time_str = phase_record.get('phase_end_time')
+ end_time = datetime.fromisoformat(end_time_str) if end_time_str else datetime.now()
+
+ logger.debug(
+ f"Using actual phase times from DB: session={session['id']}, "
+ f"phase={phase_number}, start={start_time.isoformat()}, end={end_time.isoformat()}"
+ )
+
+ return (start_time, end_time)
+
+ except Exception as e:
+ logger.warning(f"Failed to query phase record from DB: {e}")
+
+ # Fallback: Calculate from session start + user-configured durations
+ # ⚠️ CRITICAL: Use user-configured durations, NOT hardcoded values
+ session_start = datetime.fromisoformat(session['start_time'])
+ work_duration = session.get('work_duration_minutes', 25) # User-configured
+ break_duration = session.get('break_duration_minutes', 5) # User-configured
+
+ # Calculate offset for this phase
+ # Phase 1: offset = 0
+ # Phase 2: offset = work_duration + break_duration
+ # Phase 3: offset = 2 * (work_duration + break_duration)
+ offset_minutes = (phase_number - 1) * (work_duration + break_duration)
+
+ start_time = session_start + timedelta(minutes=offset_minutes)
+ end_time = start_time + timedelta(minutes=work_duration)
+
+ logger.debug(
+ f"Calculated phase times: session={session['id']}, phase={phase_number}, "
+ f"work_duration={work_duration}min, break_duration={break_duration}min, "
+ f"start={start_time.isoformat()}, end={end_time.isoformat()}"
+ )
+
+ return (start_time, end_time)
+
+ async def _aggregate_work_phase_activities(
+ self,
+ session_id: str,
+ work_phase: int,
+ phase_start_time: datetime,
+ phase_end_time: datetime,
+ phase_id: str | None = None,
+ ) -> None:
+ """
+ Aggregate actions into activities for a work phase WITH SIMPLIFIED RETRY.
+
+ Retry Strategy:
+ - Attempt 1: Immediate
+ - Attempt 2: After 10 seconds
+ - After 2 attempts: Mark as 'failed' (user can manually retry)
+
+ Args:
+ session_id: Session ID
+ work_phase: Phase number (1-4)
+ phase_start_time: Phase start time
+ phase_end_time: Phase end time
+ phase_id: Existing phase record ID (optional)
+ """
+ try:
+ # Get or create phase record
+ if not phase_id:
+ existing_phase = await self.db.work_phases.get_by_session_and_phase(
+ session_id, work_phase
+ )
+ if existing_phase:
+ phase_id = existing_phase["id"]
+ else:
+ phase_id = await self.db.work_phases.create(
+ session_id=session_id,
+ phase_number=work_phase,
+ phase_start_time=phase_start_time.isoformat(),
+ phase_end_time=phase_end_time.isoformat(),
+ status="pending",
+ )
+
+ # SIMPLIFIED RETRY LOOP: Only 1 retry
+ for attempt in range(_Constants.MAX_RETRIES + 1):
+ try:
+ # Update status to processing
+ await self.db.work_phases.update_status(
+ phase_id, "processing", None, attempt
+ )
+
+ logger.info(
+ f"Processing work phase: session={session_id}, "
+ f"phase={work_phase}, attempt={attempt + 1}/{_Constants.MAX_RETRIES + 1}"
+ )
+
+ # Get SessionAgent from coordinator
+ session_agent = self.coordinator.session_agent
+ if not session_agent:
+ raise ValueError("SessionAgent not available")
+
+ # Delegate to SessionAgent for actual aggregation
+ activities = await session_agent.aggregate_work_phase(
+ session_id=session_id,
+ work_phase=work_phase,
+ phase_start_time=phase_start_time,
+ phase_end_time=phase_end_time,
+ )
+
+ # Validate result
+ if not activities:
+ raise ValueError("No actions found for work phase")
+
+ # SUCCESS - Mark completed
+ await self.db.work_phases.mark_completed(phase_id, len(activities))
+
+ logger.info(
+ f"✓ Work phase aggregation completed: "
+ f"session={session_id}, phase={work_phase}, "
+ f"activities={len(activities)}"
+ )
+
+ # Emit success event
+ emit_pomodoro_work_phase_completed(session_id, work_phase, len(activities))
+
+ return # Exit retry loop on success
+
+ except Exception as e:
+ # Classify error for better reporting
+ error_type = self._classify_aggregation_error(e)
+ error_message = f"{error_type}: {str(e)}"
+
+ logger.warning(
+ f"Work phase aggregation attempt {attempt + 1} failed: "
+ f"{error_message}"
+ )
+
+ if attempt < _Constants.MAX_RETRIES:
+ # Schedule retry after 10 seconds
+ new_retry_count = await self.db.work_phases.increment_retry_count(
+ phase_id
+ )
+
+ await self.db.work_phases.update_status(
+ phase_id, "pending", error_message, new_retry_count
+ )
+
+ logger.info(
+ f"Retrying work phase in {_Constants.RETRY_DELAY_SECONDS}s "
+ f"(retry {new_retry_count}/{_Constants.MAX_RETRIES})"
+ )
+
+ await asyncio.sleep(_Constants.RETRY_DELAY_SECONDS)
+ else:
+ # All retries exhausted - mark as failed
+ # User can manually retry via API
+ await self.db.work_phases.update_status(
+ phase_id, "failed", error_message, _Constants.MAX_RETRIES
+ )
+
+ logger.error(
+ f"✗ Work phase aggregation failed after {_Constants.MAX_RETRIES + 1} attempts: "
+ f"session={session_id}, phase={work_phase}, error={error_message}"
+ )
+
+ # Emit failure event
+ emit_pomodoro_work_phase_failed(session_id, work_phase, error_message)
+
+ return # Don't raise - allow other phases to continue
+
+ except Exception as e:
+ # Outer exception handler (should rarely trigger)
+ logger.error(
+ f"Unexpected error in work phase aggregation: {e}", exc_info=True
+ )
+
+ def get_current_session_id(self) -> str | None:
+ """
+ Get current active Pomodoro session ID
+
+ Returns:
+ Session ID if a Pomodoro session is active, None otherwise
+ """
+ if self.is_active and self.current_session:
+ return self.current_session.id
+ return None
+
+ async def force_settlement(self, session_id: str) -> dict[str, Any]:
+ """
+ Force settlement of all pending records for phase completion
+
+ This method ensures no data loss by:
+ 1. Flushing ImageConsumer buffered screenshots
+ 2. Collecting all unprocessed records from storage
+ 3. Immediately processing them through the pipeline
+
+ Called during phase transitions to guarantee all captured actions
+ are processed into events before the phase ends.
+
+ Args:
+ session_id: Pomodoro session ID
+
+ Returns:
+ Dict with settlement results including counts of processed records
+ """
+ logger.info(f"Starting force settlement for session: {session_id}")
+
+ all_records = []
+ records_count = {
+ "image_consumer": 0,
+ "storage": 0,
+ "event_buffer": 0,
+ "total": 0
+ }
+
+ try:
+ # Step 1: Flush ImageConsumer buffered screenshots
+ perception_manager = self.coordinator.perception_manager
+ if perception_manager and perception_manager.image_consumer:
+ logger.debug("Flushing ImageConsumer buffer...")
+ buffered_records = perception_manager.image_consumer.flush()
+ if buffered_records:
+ all_records.extend(buffered_records)
+ records_count["image_consumer"] = len(buffered_records)
+ logger.info(f"Flushed {len(buffered_records)} records from ImageConsumer")
+
+ # Step 2: Get all records from SlidingWindowStorage
+ if perception_manager and perception_manager.storage:
+ logger.debug("Collecting records from SlidingWindowStorage...")
+ storage_records = perception_manager.storage.get_records()
+ if storage_records:
+ all_records.extend(storage_records)
+ records_count["storage"] = len(storage_records)
+ logger.info(f"Collected {len(storage_records)} records from SlidingWindowStorage")
+
+ # Step 3: Get all events from EventBuffer
+ if perception_manager and perception_manager.event_buffer:
+ logger.debug("Collecting events from EventBuffer...")
+ event_records = perception_manager.event_buffer.get_all()
+ if event_records:
+ all_records.extend(event_records)
+ records_count["event_buffer"] = len(event_records)
+ logger.info(f"Collected {len(event_records)} events from EventBuffer")
+
+ records_count["total"] = len(all_records)
+
+ # Step 4: Sort records by timestamp to ensure correct processing order
+ all_records.sort(key=lambda r: r.timestamp)
+
+ # Step 5: Force process all records immediately
+ if all_records:
+ logger.info(
+ f"Force processing {len(all_records)} total records for phase settlement"
+ )
+ result = await self.coordinator.force_process_records(all_records)
+
+ events_count = len(result.get("events", []))
+ activities_count = len(result.get("activities", []))
+
+ logger.info(
+ f"✓ Force settlement completed: "
+ f"{records_count['total']} records → {events_count} events → {activities_count} activities"
+ )
+
+ return {
+ "success": True,
+ "records_processed": records_count,
+ "events_generated": events_count,
+ "activities_generated": activities_count,
+ "result": result
+ }
+ else:
+ logger.info("No pending records to settle")
+ return {
+ "success": True,
+ "records_processed": records_count,
+ "events_generated": 0,
+ "activities_generated": 0,
+ "message": "No pending records"
+ }
+
+ except Exception as e:
+ logger.error(f"Force settlement failed for session {session_id}: {e}", exc_info=True)
+ return {
+ "success": False,
+ "error": str(e),
+ "records_processed": records_count
+ }
diff --git a/backend/core/protocols.py b/backend/core/protocols.py
index d34dcec..0441870 100644
--- a/backend/core/protocols.py
+++ b/backend/core/protocols.py
@@ -246,6 +246,50 @@ async def get_all(
class KnowledgeRepositoryProtocol(Protocol):
"""Protocol for knowledge repository operations"""
+ async def save(
+ self,
+ knowledge_id: str,
+ title: str,
+ description: str,
+ keywords: List[str],
+ *,
+ created_at: Optional[str] = None,
+ source_action_id: Optional[str] = None,
+ favorite: bool = False,
+ ) -> None:
+ """Save or update knowledge"""
+ ...
+
+ async def get_list(self, include_deleted: bool = False) -> List[Dict[str, Any]]:
+ """Get knowledge list"""
+ ...
+
+ async def delete(self, knowledge_id: str) -> None:
+ """Soft delete knowledge"""
+ ...
+
+ async def hard_delete(self, knowledge_id: str) -> bool:
+ """Hard delete knowledge (permanent deletion)"""
+ ...
+
+ async def hard_delete_batch(self, knowledge_ids: List[str]) -> int:
+ """Hard delete multiple knowledge entries (permanent deletion)"""
+ ...
+
+ async def update(
+ self,
+ knowledge_id: str,
+ title: str,
+ description: str,
+ keywords: List[str],
+ ) -> None:
+ """Update knowledge"""
+ ...
+
+ async def toggle_favorite(self, knowledge_id: str) -> Optional[bool]:
+ """Toggle favorite status"""
+ ...
+
async def insert(self, knowledge_data: Dict[str, Any]) -> int:
"""Insert new knowledge"""
...
diff --git a/backend/core/settings.py b/backend/core/settings.py
index 153f394..bcb6990 100644
--- a/backend/core/settings.py
+++ b/backend/core/settings.py
@@ -5,7 +5,7 @@
import json
import os
-from typing import Any, Dict, Optional, cast
+from typing import Any, Dict, List, Optional, cast
from core.logger import get_logger
from core.paths import get_data_dir
@@ -252,17 +252,6 @@ def set_screenshot_path(self, path: str) -> bool:
logger.error(f"Failed to update screenshot save path in config: {e}")
return False
- def get_screenshot_force_save_interval(self) -> float:
- """Get screenshot force save interval (seconds)
-
- Returns the interval in seconds after which a screenshot will be force-saved
- even if it appears to be a duplicate. Default is 60 seconds (1 minute).
- """
- if not self.config_loader:
- return 60.0 # Default 1 minute
-
- return float(self.config_loader.get("screenshot.force_save_interval", 60.0))
-
# ======================== Live2D Configuration ========================
@staticmethod
@@ -674,12 +663,398 @@ def get(self, key: str, default: Any = None) -> Any:
return value
def get_language(self) -> str:
- """Get current language setting
+ """Get current language setting from database
Returns:
Language code (zh or en), defaults to zh
"""
- return self.get("language.default_language", "zh")
+ if not self.db:
+ return "zh"
+
+ try:
+ # Read from database (user-level setting)
+ language = self.db.settings.get("language.default_language", "zh")
+
+ # Validate value
+ if language not in ["zh", "en"]:
+ return "zh"
+
+ return language
+ except Exception as e:
+ logger.warning(f"Failed to read language from database: {e}")
+ return "zh"
+
+ # ======================== Pomodoro Buffering Configuration ========================
+
+ def get_pomodoro_buffering_config(self) -> Dict[str, Any]:
+ """Get Pomodoro screenshot buffering configuration
+
+ Screenshot buffering improves performance by batching screenshots before
+ sending to LLM for processing. This reduces API calls and improves response time.
+
+ Returns:
+ Dictionary with buffering configuration:
+ - enabled: Whether buffering is enabled (default: True)
+ - count_threshold: Number of screenshots to trigger batch (default: 20)
+ Lowered from 50 to 20 to match action extraction threshold.
+ At 1 screenshot/sec, this means 20 seconds worst case delay.
+ - time_threshold: Seconds elapsed to trigger batch (default: 30.0)
+ Lowered from 60 to 30 seconds to reduce latency during idle periods.
+ - max_buffer_size: Emergency flush limit (default: 200)
+ Safety limit to prevent memory issues if processing is slow.
+ - processing_timeout: Timeout for LLM calls in seconds (default: 720.0)
+ 12 minutes timeout for batch processing. If exceeded, buffer is reset.
+
+ Note: After Phase 1 optimization (Jan 2026), these settings work well with
+ the simplified retry mechanism (1 retry instead of 4).
+ """
+ return {
+ "enabled": self.get("pomodoro.enable_screenshot_buffering", True),
+ # CRITICAL FIX: Lowered from 50 to 20 to match action extraction threshold
+ # This ensures screenshots are batched more frequently and don't get stuck in buffer
+ # At 1 screenshot/sec, 20 screenshots = 20 seconds worst case delay
+ "count_threshold": int(self.get("pomodoro.screenshot_buffer_count_threshold", 20)),
+ # CRITICAL FIX: Lowered from 60 to 30 seconds to reduce latency
+ # This prevents screenshots from being buffered too long during idle periods
+ "time_threshold": float(self.get("pomodoro.screenshot_buffer_time_threshold", 30.0)),
+ "max_buffer_size": int(self.get("pomodoro.screenshot_buffer_max_size", 200)),
+ "processing_timeout": float(self.get("pomodoro.screenshot_buffer_processing_timeout", 720.0)),
+ }
+
+ # ======================== Pomodoro Goal Configuration ========================
+
+ @staticmethod
+ def _default_pomodoro_goal_settings() -> Dict[str, Any]:
+ """Get default pomodoro goal configuration"""
+ return {
+ "daily_focus_goal_minutes": 120, # 2 hours
+ "weekly_focus_goal_minutes": 600, # 10 hours
+ }
+
+ def get_pomodoro_goal_settings(self) -> Dict[str, Any]:
+ """Get Pomodoro goal configuration from database
+
+ Returns:
+ Dictionary with goal configuration:
+ - daily_focus_goal_minutes: Daily focus time goal in minutes (default: 120)
+ - weekly_focus_goal_minutes: Weekly focus time goal in minutes (default: 600)
+ """
+ defaults = self._default_pomodoro_goal_settings()
+
+ if not self.db:
+ logger.warning("Database not initialized, using defaults")
+ return defaults
+
+ try:
+ merged = self._load_dict_from_db("pomodoro", defaults)
+
+ # Validate ranges: daily 30-720 minutes (0.5-12h), weekly 60-5040 minutes (1-84h)
+ merged["daily_focus_goal_minutes"] = max(
+ 30, min(720, int(merged.get("daily_focus_goal_minutes", 120)))
+ )
+ merged["weekly_focus_goal_minutes"] = max(
+ 60, min(5040, int(merged.get("weekly_focus_goal_minutes", 600)))
+ )
+
+ return merged
+ except Exception as exc:
+ logger.warning(f"Failed to read Pomodoro goal settings from database, using defaults: {exc}")
+ return defaults
+
+ def update_pomodoro_goal_settings(self, updates: Dict[str, Any]) -> Dict[str, Any]:
+ """Update Pomodoro goal configuration values in database
+
+ Args:
+ updates: Dictionary with goal updates (daily_focus_goal_minutes, weekly_focus_goal_minutes)
+
+ Returns:
+ Updated goal configuration dictionary
+ """
+ if not self.db:
+ logger.error("Database not initialized")
+ return self._default_pomodoro_goal_settings()
+
+ current = self.get_pomodoro_goal_settings()
+ merged = current.copy()
+
+ if "daily_focus_goal_minutes" in updates:
+ merged["daily_focus_goal_minutes"] = max(30, min(720, int(updates["daily_focus_goal_minutes"])))
+ if "weekly_focus_goal_minutes" in updates:
+ merged["weekly_focus_goal_minutes"] = max(60, min(5040, int(updates["weekly_focus_goal_minutes"])))
+
+ try:
+ self._save_dict_to_db("pomodoro", merged)
+ logger.debug("✓ Pomodoro goal settings updated in database")
+ except Exception as exc:
+ logger.error(f"Failed to update Pomodoro goal settings in database: {exc}")
+
+ return merged
+
+ def get_screenshot_screen_settings(self) -> List[Dict[str, Any]]:
+ """Get screenshot screen settings from database
+
+ Returns:
+ List of screen settings dictionaries
+ """
+ if not self.db:
+ logger.warning("Database not initialized, returning empty screen settings")
+ return []
+
+ try:
+ # Read screen settings from database
+ all_settings = self.db.settings.get_all()
+
+ # Group settings by screen index
+ screens_dict: Dict[int, Dict[str, Any]] = {}
+ for key, value in all_settings.items():
+ if key.startswith("screenshot.screen_settings."):
+ # Extract screen index and property name
+ # Format: screenshot.screen_settings.{index}.{property}
+ parts = key.split(".", 3)
+ if len(parts) >= 4:
+ try:
+ screen_index = int(parts[2])
+ property_name = parts[3]
+
+ if screen_index not in screens_dict:
+ screens_dict[screen_index] = {}
+
+ # Add the property to the screen dict
+ screens_dict[screen_index][property_name] = value
+ except (ValueError, IndexError) as e:
+ logger.warning(f"Failed to parse screen setting key {key}: {e}")
+ continue
+
+ # Convert to list and sort by monitor_index
+ screens = list(screens_dict.values())
+ screens.sort(key=lambda x: x.get("monitor_index", 0))
+
+ logger.debug(f"✓ Loaded {len(screens)} screen settings from database")
+ return screens
+ except Exception as e:
+ logger.error(f"Failed to read screenshot screen settings from database: {e}")
+ import traceback
+ logger.error(traceback.format_exc())
+ return []
+
+ def get_font_size(self) -> str:
+ """Get current font size setting from database
+
+ Returns:
+ Font size (small, default, large, extra-large), defaults to default
+ """
+ if not self.db:
+ return "default"
+
+ try:
+ # Read from database (user-level setting)
+ font_size = self.db.settings.get("ui.font_size", "default")
+
+ # Validate value
+ valid_sizes = ["small", "default", "large", "extra-large"]
+ if font_size not in valid_sizes:
+ return "default"
+
+ return font_size
+ except Exception as e:
+ logger.warning(f"Failed to read font size from database: {e}")
+ return "default"
+
+ # ======================== Voice and Clock Settings ========================
+
+ @staticmethod
+ def _default_voice_settings() -> Dict[str, Any]:
+ """Get default notification sound settings (kept as voice for backward compatibility)"""
+ return {
+ "enabled": True,
+ "volume": 0.8,
+ "sound_theme": "8bit",
+ "custom_sounds": None
+ }
+
+ def get_voice_settings(self) -> Dict[str, Any]:
+ """Get voice settings from database"""
+ defaults = self._default_voice_settings()
+
+ if not self.db:
+ logger.warning("Database not initialized, using defaults")
+ return defaults
+
+ try:
+ merged = self._load_dict_from_db("voice", defaults)
+
+ # Validate and normalize values
+ merged["enabled"] = bool(merged.get("enabled", True))
+ volume = merged.get("volume", 0.8)
+ merged["volume"] = max(0.0, min(1.0, float(volume)))
+
+ # Migration: convert old language setting to sound_theme
+ if "language" in merged and "sound_theme" not in merged:
+ merged["sound_theme"] = "8bit" # Default theme for migrated settings
+ logger.debug("Migrated old voice.language to voice.sound_theme")
+
+ sound_theme = merged.get("sound_theme", "8bit")
+ merged["sound_theme"] = sound_theme if sound_theme in ["8bit", "16bit", "custom"] else "8bit"
+
+ # Handle custom sounds (JSON)
+ custom_sounds = merged.get("custom_sounds")
+ if custom_sounds and isinstance(custom_sounds, str):
+ try:
+ merged["custom_sounds"] = json.loads(custom_sounds)
+ except json.JSONDecodeError:
+ merged["custom_sounds"] = None
+ elif not isinstance(custom_sounds, dict):
+ merged["custom_sounds"] = None
+
+ return merged
+ except Exception as exc:
+ logger.warning(f"Failed to read voice settings from database, using defaults: {exc}")
+ return defaults
+
+ def update_voice_settings(self, updates: Dict[str, Any]) -> Dict[str, Any]:
+ """Update notification sound settings in database (kept as voice for backward compatibility)"""
+ if not self.db:
+ logger.error("Database not initialized")
+ return self._default_voice_settings()
+
+ current = self.get_voice_settings()
+ merged = current.copy()
+
+ if "enabled" in updates:
+ merged["enabled"] = bool(updates.get("enabled", True))
+ if "volume" in updates:
+ volume = float(updates.get("volume", 0.8))
+ merged["volume"] = max(0.0, min(1.0, volume))
+ if "sound_theme" in updates:
+ sound_theme = updates.get("sound_theme", "8bit")
+ merged["sound_theme"] = sound_theme if sound_theme in ["8bit", "16bit", "custom"] else "8bit"
+ if "custom_sounds" in updates:
+ custom_sounds = updates.get("custom_sounds")
+ merged["custom_sounds"] = custom_sounds if isinstance(custom_sounds, dict) else None
+
+ try:
+ self._save_dict_to_db("voice", merged)
+ logger.debug("✓ Notification sound settings updated in database")
+ except Exception as exc:
+ logger.error(f"Failed to update notification sound settings in database: {exc}")
+
+ return merged
+
+ @staticmethod
+ def _default_clock_settings() -> Dict[str, Any]:
+ """Get default clock settings"""
+ return {
+ "enabled": True,
+ "position": "bottom-right",
+ "size": "medium",
+ "custom_x": None,
+ "custom_y": None,
+ "custom_width": None,
+ "custom_height": None,
+ "use_custom_position": False
+ }
+
+ def get_clock_settings(self) -> Dict[str, Any]:
+ """Get clock settings from database"""
+ defaults = self._default_clock_settings()
+
+ if not self.db:
+ logger.warning("Database not initialized, using defaults")
+ return defaults
+
+ try:
+ merged = self._load_dict_from_db("clock", defaults)
+
+ # Validate and normalize values
+ merged["enabled"] = bool(merged.get("enabled", True))
+ position = merged.get("position", "bottom-right")
+ if position not in ["bottom-right", "bottom-left", "top-right", "top-left"]:
+ position = "bottom-right"
+ merged["position"] = position
+ size = merged.get("size", "medium")
+ if size not in ["small", "medium", "large"]:
+ size = "medium"
+ merged["size"] = size
+
+ # Custom position fields
+ merged["custom_x"] = merged.get("custom_x")
+ merged["custom_y"] = merged.get("custom_y")
+ merged["custom_width"] = merged.get("custom_width")
+ merged["custom_height"] = merged.get("custom_height")
+ merged["use_custom_position"] = bool(merged.get("use_custom_position", False))
+
+ return merged
+ except Exception as exc:
+ logger.warning(f"Failed to read clock settings from database, using defaults: {exc}")
+ return defaults
+
+ def update_clock_settings(self, updates: Dict[str, Any]) -> Dict[str, Any]:
+ """Update clock settings in database"""
+ if not self.db:
+ logger.error("Database not initialized")
+ return self._default_clock_settings()
+
+ current = self.get_clock_settings()
+ merged = current.copy()
+
+ if "enabled" in updates:
+ merged["enabled"] = bool(updates.get("enabled", True))
+ if "position" in updates:
+ position = updates.get("position", "bottom-right")
+ if position in ["bottom-right", "bottom-left", "top-right", "top-left"]:
+ merged["position"] = position
+ if "size" in updates:
+ size = updates.get("size", "medium")
+ if size in ["small", "medium", "large"]:
+ merged["size"] = size
+
+ # Custom position fields
+ if "custom_x" in updates:
+ merged["custom_x"] = updates.get("custom_x")
+ if "custom_y" in updates:
+ merged["custom_y"] = updates.get("custom_y")
+ if "custom_width" in updates:
+ merged["custom_width"] = updates.get("custom_width")
+ if "custom_height" in updates:
+ merged["custom_height"] = updates.get("custom_height")
+ if "use_custom_position" in updates:
+ merged["use_custom_position"] = bool(updates.get("use_custom_position", False))
+
+ try:
+ self._save_dict_to_db("clock", merged)
+ logger.debug("✓ Clock settings updated in database")
+ except Exception as exc:
+ logger.error(f"Failed to update clock settings in database: {exc}")
+
+ return merged
+
+ def set_font_size(self, font_size: str) -> bool:
+ """Set application font size
+
+ Args:
+ font_size: Font size (small, default, large, extra-large)
+
+ Returns:
+ True if successful, False otherwise
+ """
+ # Validate font size
+ valid_sizes = ["small", "default", "large", "extra-large"]
+ if font_size not in valid_sizes:
+ logger.error(f"Invalid font size: {font_size}. Must be one of {valid_sizes}")
+ return False
+
+ try:
+ # Save to database instead of TOML file
+ # This ensures only user-level settings are stored, not system settings
+ self._save_dict_to_db("ui", {"font_size": font_size})
+
+ # Update cache to ensure immediate effect
+ self._config_cache["ui.font_size"] = font_size
+ logger.debug(f"✓ Application font size updated to: {font_size}")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to set font size: {e}")
+ return False
def set_language(self, language: str) -> bool:
"""Set application language
@@ -690,39 +1065,83 @@ def set_language(self, language: str) -> bool:
Returns:
True if successful, False otherwise
"""
- if not self.config_loader:
- logger.error("Configuration loader not initialized")
- return False
-
# Validate language code
if language not in ["zh", "en"]:
logger.error(f"Invalid language code: {language}. Must be 'zh' or 'en'")
return False
try:
- # Update configuration file
- result = self.config_loader.set("language.default_language", language)
- if result:
- # Update cache to ensure immediate effect
- self._config_cache["language.default_language"] = language
- logger.debug(f"✓ Application language updated to: {language}")
- return result
+ # Save to database instead of TOML file
+ # This ensures only user-level settings are stored, not system settings
+ self._save_dict_to_db("language", {"default_language": language})
+
+ # Update cache to ensure immediate effect
+ self._config_cache["language.default_language"] = language
+ logger.debug(f"✓ Application language updated to: {language}")
+ return True
except Exception as e:
logger.error(f"Failed to set language: {e}")
return False
def set(self, key: str, value: Any) -> bool:
- """Set any configuration item"""
- if not self.config_loader:
- logger.error("Configuration loader not initialized")
- return False
+ """Set any configuration item
+
+ Determines the appropriate storage location based on configuration type:
+ - User-level settings: Saved to TOML config file
+ - System-level settings: Saved to database
+ """
+ # User-level configuration keys that should be saved to TOML file
+ user_level_keys = {
+ "ui.font_size",
+ "language.default_language",
+ "screenshot.save_path",
+ "screenshot.force_save_interval",
+ "database.path",
+ }
try:
- result = self.config_loader.set(key, value)
- if result:
- # Invalidate cache when config is modified
- self._invalidate_cache()
- return result
+ # Check if this is a user-level setting
+ is_user_level = key in user_level_keys
+
+ if is_user_level:
+ # Save user-level setting to TOML config file
+ if not self.config_loader:
+ logger.error("Configuration loader not initialized")
+ return False
+
+ result = self.config_loader.set(key, value)
+ if result:
+ # Update cache to ensure immediate effect
+ self._config_cache[key] = value
+ # Invalidate cache when config is modified
+ self._invalidate_cache()
+ return result
+ else:
+ # Save system-level setting to database
+ # Parse the key to extract prefix and individual key
+ if "." in key:
+ prefix, individual_key = key.rsplit(".", 1)
+ else:
+ prefix = key
+ individual_key = "value"
+
+ # Special handling for complex data structures like screen_settings array
+ if key == "screenshot.screen_settings" and isinstance(value, list):
+ # Save each screen setting as a separate database entry
+ for idx, screen in enumerate(value):
+ screen_key = f"screenshot.screen_settings.{idx}"
+ if isinstance(screen, dict):
+ self._save_dict_to_db(screen_key, screen)
+ else:
+ self._save_dict_to_db(screen_key, {"value": screen})
+ else:
+ self._save_dict_to_db(prefix, {individual_key: value})
+
+ # Update cache to ensure immediate effect
+ self._config_cache[key] = value
+ logger.debug(f"✓ System configuration {key} saved to database")
+ return True
+
except Exception as e:
logger.error(f"Failed to set configuration {key}: {e}")
return False
diff --git a/backend/core/sqls/__init__.py b/backend/core/sqls/__init__.py
index a59d4b2..7e00f6b 100644
--- a/backend/core/sqls/__init__.py
+++ b/backend/core/sqls/__init__.py
@@ -3,6 +3,6 @@
Provides centralized SQL statement management for better maintainability
"""
-from . import migrations, queries, schema
+from . import queries, schema
-__all__ = ["schema", "migrations", "queries"]
+__all__ = ["schema", "queries"]
diff --git a/backend/core/sqls/migrations.py b/backend/core/sqls/migrations.py
deleted file mode 100644
index ad9b878..0000000
--- a/backend/core/sqls/migrations.py
+++ /dev/null
@@ -1,178 +0,0 @@
-"""
-Database migration SQL statements
-Contains all ALTER TABLE and data migration statements
-"""
-
-# Events table migrations
-CREATE_EVENTS_NEW_TABLE = """
- CREATE TABLE events_new (
- id TEXT PRIMARY KEY,
- start_time TEXT,
- end_time TEXT,
- type TEXT,
- summary TEXT,
- source_data TEXT,
- title TEXT DEFAULT '',
- description TEXT DEFAULT '',
- keywords TEXT,
- timestamp TEXT,
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
- )
-"""
-
-MIGRATE_EVENTS_DATA = """
- INSERT INTO events_new (
- id, start_time, end_time, type, summary, source_data,
- title, description, keywords, timestamp, created_at
- )
- SELECT
- id, start_time, end_time, type, summary, source_data,
- COALESCE(title, SUBSTR(COALESCE(summary, ''), 1, 100)),
- COALESCE(description, COALESCE(summary, '')),
- keywords, timestamp, created_at
- FROM events
-"""
-
-DROP_OLD_EVENTS_TABLE = "DROP TABLE events"
-
-RENAME_EVENTS_TABLE = "ALTER TABLE events_new RENAME TO events"
-
-ADD_EVENTS_TITLE_COLUMN = """
- ALTER TABLE events
- ADD COLUMN title TEXT DEFAULT ''
-"""
-
-UPDATE_EVENTS_TITLE = """
- UPDATE events
- SET title = SUBSTR(COALESCE(summary, ''), 1, 100)
- WHERE title = '' OR title IS NULL
-"""
-
-ADD_EVENTS_DESCRIPTION_COLUMN = """
- ALTER TABLE events
- ADD COLUMN description TEXT DEFAULT ''
-"""
-
-UPDATE_EVENTS_DESCRIPTION = """
- UPDATE events
- SET description = COALESCE(summary, '')
- WHERE description = '' OR description IS NULL
-"""
-
-ADD_EVENTS_KEYWORDS_COLUMN = """
- ALTER TABLE events
- ADD COLUMN keywords TEXT DEFAULT NULL
-"""
-
-ADD_EVENTS_TIMESTAMP_COLUMN = """
- ALTER TABLE events
- ADD COLUMN timestamp TEXT DEFAULT NULL
-"""
-
-UPDATE_EVENTS_TIMESTAMP = """
- UPDATE events
- SET timestamp = start_time
- WHERE timestamp IS NULL AND start_time IS NOT NULL
-"""
-
-ADD_EVENTS_DELETED_COLUMN = """
- ALTER TABLE events
- ADD COLUMN deleted BOOLEAN DEFAULT 0
-"""
-
-# Activities table migrations
-CREATE_ACTIVITIES_NEW_TABLE = """
- CREATE TABLE activities_new (
- id TEXT PRIMARY KEY,
- title TEXT NOT NULL,
- description TEXT NOT NULL,
- start_time TEXT NOT NULL,
- end_time TEXT NOT NULL,
- source_events TEXT,
- version INTEGER DEFAULT 1,
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
- deleted BOOLEAN DEFAULT 0,
- source_event_ids TEXT
- )
-"""
-
-MIGRATE_ACTIVITIES_DATA = """
- INSERT INTO activities_new (
- id, title, description, start_time, end_time, source_events,
- version, created_at, deleted, source_event_ids
- )
- SELECT
- id, COALESCE(title, SUBSTR(description, 1, 50)), description,
- start_time, end_time, source_events,
- COALESCE(version, 1), created_at,
- COALESCE(deleted, 0), source_event_ids
- FROM activities
-"""
-
-DROP_OLD_ACTIVITIES_TABLE = "DROP TABLE activities"
-
-RENAME_ACTIVITIES_TABLE = "ALTER TABLE activities_new RENAME TO activities"
-
-ADD_ACTIVITIES_VERSION_COLUMN = """
- ALTER TABLE activities
- ADD COLUMN version INTEGER DEFAULT 1
-"""
-
-ADD_ACTIVITIES_TITLE_COLUMN = """
- ALTER TABLE activities
- ADD COLUMN title TEXT DEFAULT ''
-"""
-
-UPDATE_ACTIVITIES_TITLE = """
- UPDATE activities
- SET title = SUBSTR(description, 1, 50)
- WHERE title = '' OR title IS NULL
-"""
-
-ADD_ACTIVITIES_DELETED_COLUMN = """
- ALTER TABLE activities
- ADD COLUMN deleted BOOLEAN DEFAULT 0
-"""
-
-ADD_ACTIVITIES_SOURCE_EVENT_IDS_COLUMN = """
- ALTER TABLE activities
- ADD COLUMN source_event_ids TEXT DEFAULT NULL
-"""
-
-UPDATE_ACTIVITIES_SOURCE_EVENT_IDS = """
- UPDATE activities
- SET source_event_ids = source_events
- WHERE source_event_ids IS NULL AND source_events IS NOT NULL
-"""
-
-# LLM models table migrations
-ADD_LLM_MODELS_LAST_TEST_STATUS_COLUMN = """
- ALTER TABLE llm_models ADD COLUMN last_test_status INTEGER DEFAULT 0
-"""
-
-ADD_LLM_MODELS_LAST_TESTED_AT_COLUMN = """
- ALTER TABLE llm_models ADD COLUMN last_tested_at TEXT
-"""
-
-ADD_LLM_MODELS_LAST_TEST_ERROR_COLUMN = """
- ALTER TABLE llm_models ADD COLUMN last_test_error TEXT
-"""
-
-# Messages table migrations
-ADD_MESSAGES_IMAGES_COLUMN = """
- ALTER TABLE messages ADD COLUMN images TEXT
-"""
-
-# Actions table migrations
-ADD_ACTIONS_EXTRACT_KNOWLEDGE_COLUMN = """
- ALTER TABLE actions ADD COLUMN extract_knowledge BOOLEAN DEFAULT 0
-"""
-
-ADD_ACTIONS_KNOWLEDGE_EXTRACTED_COLUMN = """
- ALTER TABLE actions ADD COLUMN knowledge_extracted BOOLEAN DEFAULT 0
-"""
-
-# Knowledge table migrations
-ADD_KNOWLEDGE_SOURCE_ACTION_ID_COLUMN = """
- ALTER TABLE knowledge ADD COLUMN source_action_id TEXT
-"""
diff --git a/backend/core/sqls/queries.py b/backend/core/sqls/queries.py
index 7621903..d57004c 100644
--- a/backend/core/sqls/queries.py
+++ b/backend/core/sqls/queries.py
@@ -176,11 +176,11 @@
# Maintenance / cleanup queries
DELETE_EVENT_IMAGES_BEFORE_TIMESTAMP = """
DELETE FROM event_images
- WHERE event_id IN (SELECT id FROM events WHERE timestamp < ?)
+ WHERE event_id IN (SELECT id FROM events WHERE start_time < ?)
"""
DELETE_EVENTS_BEFORE_TIMESTAMP = """
- DELETE FROM events WHERE timestamp < ?
+ DELETE FROM events WHERE start_time < ?
"""
SOFT_DELETE_ACTIVITIES_BEFORE_START_TIME = """
@@ -301,3 +301,45 @@
# Pragma queries (for table inspection)
PRAGMA_TABLE_INFO = "PRAGMA table_info({})"
+
+# ==================== Pomodoro Work Phases Queries ====================
+
+INSERT_WORK_PHASE = """
+ INSERT INTO pomodoro_work_phases (
+ id, session_id, phase_number, status,
+ phase_start_time, phase_end_time, retry_count
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+"""
+
+SELECT_WORK_PHASES_BY_SESSION = """
+ SELECT * FROM pomodoro_work_phases
+ WHERE session_id = ?
+ ORDER BY phase_number ASC
+"""
+
+SELECT_WORK_PHASE_BY_SESSION_AND_NUMBER = """
+ SELECT * FROM pomodoro_work_phases
+ WHERE session_id = ? AND phase_number = ?
+"""
+
+UPDATE_WORK_PHASE_STATUS = """
+ UPDATE pomodoro_work_phases
+ SET status = ?, processing_error = ?, retry_count = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+"""
+
+UPDATE_WORK_PHASE_COMPLETED = """
+ UPDATE pomodoro_work_phases
+ SET status = 'completed', activity_count = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+"""
+
+INCREMENT_WORK_PHASE_RETRY = """
+ UPDATE pomodoro_work_phases
+ SET retry_count = retry_count + 1,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ RETURNING retry_count
+"""
diff --git a/backend/core/sqls/schema.py b/backend/core/sqls/schema.py
index 8f31740..c228e1d 100644
--- a/backend/core/sqls/schema.py
+++ b/backend/core/sqls/schema.py
@@ -39,6 +39,7 @@
source_action_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT 0,
+ favorite BOOLEAN DEFAULT 0,
FOREIGN KEY (source_action_id) REFERENCES actions(id) ON DELETE SET NULL
)
"""
@@ -55,7 +56,9 @@
scheduled_date TEXT,
scheduled_time TEXT,
scheduled_end_time TEXT,
- recurrence_rule TEXT
+ recurrence_rule TEXT,
+ expires_at TEXT,
+ source_type TEXT DEFAULT 'ai'
)
"""
@@ -80,8 +83,13 @@
session_duration_minutes INTEGER,
topic_tags TEXT,
source_event_ids TEXT,
+ source_action_ids TEXT,
+ aggregation_mode TEXT DEFAULT 'action_based' CHECK(aggregation_mode IN ('event_based', 'action_based')),
user_merged_from_ids TEXT,
user_split_into_ids TEXT,
+ pomodoro_session_id TEXT,
+ pomodoro_work_phase INTEGER,
+ focus_score REAL,
deleted BOOLEAN DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
@@ -228,6 +236,60 @@
)
"""
+CREATE_POMODORO_SESSIONS_TABLE = """
+ CREATE TABLE IF NOT EXISTS pomodoro_sessions (
+ id TEXT PRIMARY KEY,
+ user_intent TEXT NOT NULL,
+ planned_duration_minutes INTEGER DEFAULT 25,
+ actual_duration_minutes INTEGER,
+ start_time TEXT NOT NULL,
+ end_time TEXT,
+ status TEXT NOT NULL,
+ processing_status TEXT DEFAULT 'pending',
+ processing_started_at TEXT,
+ processing_completed_at TEXT,
+ processing_error TEXT,
+ llm_evaluation_result TEXT,
+ llm_evaluation_computed_at TEXT,
+ interruption_count INTEGER DEFAULT 0,
+ interruption_reasons TEXT,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN DEFAULT 0,
+ CHECK(status IN ('active', 'completed', 'abandoned')),
+ CHECK(processing_status IN ('pending', 'processing', 'completed', 'failed'))
+ )
+"""
+
+CREATE_POMODORO_WORK_PHASES_TABLE = """
+ CREATE TABLE IF NOT EXISTS pomodoro_work_phases (
+ id TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL,
+ phase_number INTEGER NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ processing_error TEXT,
+ retry_count INTEGER DEFAULT 0,
+ phase_start_time TEXT NOT NULL,
+ phase_end_time TEXT,
+ activity_count INTEGER DEFAULT 0,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (session_id) REFERENCES pomodoro_sessions(id) ON DELETE CASCADE,
+ CHECK(status IN ('pending', 'processing', 'completed', 'failed')),
+ UNIQUE(session_id, phase_number)
+ )
+"""
+
+CREATE_POMODORO_WORK_PHASES_SESSION_INDEX = """
+ CREATE INDEX IF NOT EXISTS idx_work_phases_session
+ ON pomodoro_work_phases(session_id, phase_number)
+"""
+
+CREATE_POMODORO_WORK_PHASES_STATUS_INDEX = """
+ CREATE INDEX IF NOT EXISTS idx_work_phases_status
+ ON pomodoro_work_phases(status)
+"""
+
CREATE_KNOWLEDGE_CREATED_INDEX = """
CREATE INDEX IF NOT EXISTS idx_knowledge_created
ON knowledge(created_at DESC)
@@ -243,6 +305,11 @@
ON knowledge(source_action_id)
"""
+CREATE_KNOWLEDGE_FAVORITE_INDEX = """
+ CREATE INDEX IF NOT EXISTS idx_knowledge_favorite
+ ON knowledge(favorite)
+"""
+
CREATE_TODOS_CREATED_INDEX = """
CREATE INDEX IF NOT EXISTS idx_todos_created
ON todos(created_at DESC)
@@ -386,6 +453,28 @@
ON session_preferences(confidence_score DESC)
"""
+# ============ Pomodoro Sessions Indexes ============
+
+CREATE_POMODORO_SESSIONS_STATUS_INDEX = """
+ CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_status
+ ON pomodoro_sessions(status)
+"""
+
+CREATE_POMODORO_SESSIONS_PROCESSING_STATUS_INDEX = """
+ CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_processing_status
+ ON pomodoro_sessions(processing_status)
+"""
+
+CREATE_POMODORO_SESSIONS_START_TIME_INDEX = """
+ CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_start_time
+ ON pomodoro_sessions(start_time DESC)
+"""
+
+CREATE_POMODORO_SESSIONS_CREATED_INDEX = """
+ CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_created
+ ON pomodoro_sessions(created_at DESC)
+"""
+
# All table creation statements in order
ALL_TABLES = [
CREATE_RAW_RECORDS_TABLE,
@@ -405,6 +494,9 @@
CREATE_ACTIONS_TABLE,
CREATE_ACTION_IMAGES_TABLE,
CREATE_SESSION_PREFERENCES_TABLE,
+ # Pomodoro feature
+ CREATE_POMODORO_SESSIONS_TABLE,
+ CREATE_POMODORO_WORK_PHASES_TABLE,
]
# All index creation statements
@@ -416,10 +508,13 @@
CREATE_KNOWLEDGE_CREATED_INDEX,
CREATE_KNOWLEDGE_DELETED_INDEX,
CREATE_KNOWLEDGE_SOURCE_ACTION_INDEX,
+ CREATE_KNOWLEDGE_FAVORITE_INDEX,
CREATE_TODOS_CREATED_INDEX,
CREATE_TODOS_COMPLETED_INDEX,
CREATE_TODOS_DELETED_INDEX,
CREATE_DIARIES_DATE_INDEX,
+ CREATE_POMODORO_WORK_PHASES_SESSION_INDEX,
+ CREATE_POMODORO_WORK_PHASES_STATUS_INDEX,
CREATE_LLM_USAGE_TIMESTAMP_INDEX,
CREATE_LLM_USAGE_MODEL_INDEX,
CREATE_LLM_USAGE_MODEL_CONFIG_ID_INDEX,
@@ -441,4 +536,9 @@
CREATE_ACTION_IMAGES_HASH_INDEX,
CREATE_SESSION_PREFERENCES_TYPE_INDEX,
CREATE_SESSION_PREFERENCES_CONFIDENCE_INDEX,
+ # Pomodoro sessions indexes
+ CREATE_POMODORO_SESSIONS_STATUS_INDEX,
+ CREATE_POMODORO_SESSIONS_PROCESSING_STATUS_INDEX,
+ CREATE_POMODORO_SESSIONS_START_TIME_INDEX,
+ CREATE_POMODORO_SESSIONS_CREATED_INDEX,
]
diff --git a/backend/handlers/__init__.py b/backend/handlers/__init__.py
index 7f4dbcf..b9997bf 100644
--- a/backend/handlers/__init__.py
+++ b/backend/handlers/__init__.py
@@ -174,15 +174,15 @@ def register_fastapi_routes(app: "FastAPI", prefix: str = "/api") -> None:
}
if method == "GET":
- app.get(**route_params)(func) # type: ignore
+ app.get(**route_params)(func)
elif method == "POST":
- app.post(**route_params)(func) # type: ignore
+ app.post(**route_params)(func)
elif method == "PUT":
- app.put(**route_params)(func) # type: ignore
+ app.put(**route_params)(func)
elif method == "DELETE":
- app.delete(**route_params)(func) # type: ignore
+ app.delete(**route_params)(func)
elif method == "PATCH":
- app.patch(**route_params)(func) # type: ignore
+ app.patch(**route_params)(func)
else:
logger.warning(f"Unknown HTTP method: {method} for {handler_name}")
continue
@@ -206,11 +206,18 @@ def register_fastapi_routes(app: "FastAPI", prefix: str = "/api") -> None:
# ruff: noqa: E402
from . import (
activities,
+ activity_ratings,
agents,
chat,
events,
insights,
+ knowledge_merge,
monitoring,
+ pomodoro,
+ pomodoro_goals,
+ pomodoro_linking,
+ pomodoro_presets,
+ pomodoro_stats,
processing,
resources,
system,
@@ -222,11 +229,15 @@ def register_fastapi_routes(app: "FastAPI", prefix: str = "/api") -> None:
"register_fastapi_routes",
"get_registered_handlers",
"activities",
+ "activity_ratings",
"agents",
"chat",
"events",
"insights",
"monitoring",
+ "pomodoro",
+ "pomodoro_presets",
+ "pomodoro_stats",
"processing",
"resources",
"system",
diff --git a/backend/handlers/activity_ratings.py b/backend/handlers/activity_ratings.py
new file mode 100644
index 0000000..f1235ec
--- /dev/null
+++ b/backend/handlers/activity_ratings.py
@@ -0,0 +1,211 @@
+"""
+Activity Ratings Handler - API endpoints for multi-dimensional activity ratings
+
+Endpoints:
+- POST /activities/rating/save - Save or update an activity rating
+- POST /activities/rating/get - Get all ratings for an activity
+- POST /activities/rating/delete - Delete a specific rating
+"""
+
+from datetime import datetime
+from typing import List, Optional
+
+from core.db import get_db
+from core.logger import get_logger
+from models.base import BaseModel
+from models.responses import TimedOperationResponse
+
+# CRITICAL: Use relative import to avoid circular imports
+from . import api_handler
+
+logger = get_logger(__name__)
+
+
+# ============ Request Models ============
+
+
+class SaveActivityRatingRequest(BaseModel):
+ """Request to save or update an activity rating"""
+
+ activity_id: str
+ dimension: str
+ rating: int # 1-5
+ note: Optional[str] = None
+
+
+class GetActivityRatingsRequest(BaseModel):
+ """Request to get ratings for an activity"""
+
+ activity_id: str
+
+
+class DeleteActivityRatingRequest(BaseModel):
+ """Request to delete a specific rating"""
+
+ activity_id: str
+ dimension: str
+
+
+# ============ Response Models ============
+
+
+class ActivityRatingData(BaseModel):
+ """Individual rating record"""
+
+ id: str
+ activity_id: str
+ dimension: str
+ rating: int
+ note: Optional[str] = None
+ created_at: str
+ updated_at: str
+
+
+class SaveActivityRatingResponse(TimedOperationResponse):
+ """Response after saving a rating"""
+
+ data: Optional[ActivityRatingData] = None
+
+
+class GetActivityRatingsResponse(TimedOperationResponse):
+ """Response with list of ratings"""
+
+ data: Optional[List[ActivityRatingData]] = None
+
+
+# ============ API Handlers ============
+
+
+@api_handler(
+ body=SaveActivityRatingRequest,
+ method="POST",
+ path="/activities/rating/save",
+ tags=["activities"],
+)
+async def save_activity_rating(
+ body: SaveActivityRatingRequest,
+) -> SaveActivityRatingResponse:
+ """
+ Save or update an activity rating
+
+ Supports multi-dimensional ratings:
+ - focus_level: How focused were you? (1-5)
+ - productivity: How productive was this session? (1-5)
+ - importance: How important was this activity? (1-5)
+ - satisfaction: How satisfied are you with the outcome? (1-5)
+ """
+ try:
+ db = get_db()
+
+ # Validate rating range
+ if not 1 <= body.rating <= 5:
+ return SaveActivityRatingResponse(
+ success=False,
+ message="Rating must be between 1 and 5",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Save rating
+ rating_record = await db.activity_ratings.save_rating(
+ activity_id=body.activity_id,
+ dimension=body.dimension,
+ rating=body.rating,
+ note=body.note,
+ )
+
+ logger.info(
+ f"Saved activity rating: {body.activity_id} - "
+ f"{body.dimension} = {body.rating}"
+ )
+
+ return SaveActivityRatingResponse(
+ success=True,
+ message="Rating saved successfully",
+ data=ActivityRatingData(**rating_record),
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to save activity rating: {e}", exc_info=True)
+ return SaveActivityRatingResponse(
+ success=False,
+ message=f"Failed to save rating: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=GetActivityRatingsRequest,
+ method="POST",
+ path="/activities/rating/get",
+ tags=["activities"],
+)
+async def get_activity_ratings(
+ body: GetActivityRatingsRequest,
+) -> GetActivityRatingsResponse:
+ """
+ Get all ratings for an activity
+
+ Returns ratings for all dimensions that have been rated.
+ """
+ try:
+ db = get_db()
+
+ # Fetch ratings
+ ratings = await db.activity_ratings.get_ratings_by_activity(body.activity_id)
+
+ logger.debug(f"Retrieved {len(ratings)} ratings for activity {body.activity_id}")
+
+ return GetActivityRatingsResponse(
+ success=True,
+ message=f"Retrieved {len(ratings)} rating(s)",
+ data=[ActivityRatingData(**r) for r in ratings],
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to get activity ratings: {e}", exc_info=True)
+ return GetActivityRatingsResponse(
+ success=False,
+ message=f"Failed to get ratings: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=DeleteActivityRatingRequest,
+ method="POST",
+ path="/activities/rating/delete",
+ tags=["activities"],
+)
+async def delete_activity_rating(
+ body: DeleteActivityRatingRequest,
+) -> TimedOperationResponse:
+ """
+ Delete a specific activity rating
+
+ Removes the rating for a specific dimension.
+ """
+ try:
+ db = get_db()
+
+ # Delete rating
+ await db.activity_ratings.delete_rating(body.activity_id, body.dimension)
+
+ logger.info(
+ f"Deleted activity rating: {body.activity_id} - {body.dimension}"
+ )
+
+ return TimedOperationResponse(
+ success=True,
+ message="Rating deleted successfully",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to delete activity rating: {e}", exc_info=True)
+ return TimedOperationResponse(
+ success=False,
+ message=f"Failed to delete rating: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
diff --git a/backend/handlers/events.py b/backend/handlers/events.py
index fe37413..c4a5212 100644
--- a/backend/handlers/events.py
+++ b/backend/handlers/events.py
@@ -358,3 +358,77 @@ async def delete_event(body: DeleteEventRequest) -> TimedOperationResponse:
data={"deleted": True, "eventId": body.event_id},
timestamp=datetime.now().isoformat(),
)
+
+
+@api_handler(
+ body=GetActionsByEventRequest, # Reuse the same request model
+ method="POST",
+ path="/activities/get-actions",
+ tags=["activities"],
+)
+async def get_actions_by_activity(
+ body: GetActionsByEventRequest,
+) -> GetActionsByEventResponse:
+ """
+ Get all actions for a specific activity (action-based aggregation drill-down).
+
+ Args:
+ body: Request containing event_id (but we'll use it as activity_id)
+
+ Returns:
+ Response with list of actions including screenshots
+ """
+ try:
+ db = get_db()
+
+ # Note: Reusing GetActionsByEventRequest, so field is event_id but we treat it as activity_id
+ activity_id = body.event_id
+
+ # Get the activity to find source action IDs
+ activity = await db.activities.get_by_id(activity_id)
+ if not activity:
+ return GetActionsByEventResponse(
+ success=False, actions=[], error="Activity not found"
+ )
+
+ # Get source action IDs (action-based aggregation)
+ source_action_ids = activity.get("source_action_ids", [])
+ if not source_action_ids:
+ # Fallback to event-based if activity is old format
+ source_event_ids = activity.get("source_event_ids", [])
+ if source_event_ids:
+ # Get actions from events (backward compatibility)
+ all_action_ids = []
+ for event_id in source_event_ids:
+ event = await db.events.get_by_id(event_id)
+ if event:
+ all_action_ids.extend(event.get("source_action_ids", []))
+ source_action_ids = all_action_ids
+
+ if not source_action_ids:
+ return GetActionsByEventResponse(success=True, actions=[])
+
+ # Get actions by IDs (this will automatically load screenshots)
+ action_dicts = await db.actions.get_by_ids(source_action_ids)
+
+ # Convert to ActionResponse objects
+ actions = [
+ ActionResponse(
+ id=a["id"],
+ title=a["title"],
+ description=a["description"],
+ keywords=a.get("keywords", []),
+ timestamp=a["timestamp"],
+ screenshots=a.get("screenshots", []),
+ created_at=a["created_at"],
+ )
+ for a in action_dicts
+ ]
+
+ return GetActionsByEventResponse(success=True, actions=actions)
+
+ except Exception as e:
+ logger.error(f"Failed to get actions by activity: {e}", exc_info=True)
+ return GetActionsByEventResponse(
+ success=False, actions=[], error=str(e)
+ )
diff --git a/backend/handlers/insights.py b/backend/handlers/insights.py
index 38b83f4..a5f9964 100644
--- a/backend/handlers/insights.py
+++ b/backend/handlers/insights.py
@@ -11,20 +11,30 @@
from core.db import get_db
from core.logger import get_logger
from models.requests import (
+ CreateKnowledgeRequest,
+ CreateTodoRequest,
DeleteItemRequest,
GenerateDiaryRequest,
GetDiaryListRequest,
GetRecentEventsRequest,
GetTodoListRequest,
ScheduleTodoRequest,
+ ToggleKnowledgeFavoriteRequest,
UnscheduleTodoRequest,
+ UpdateKnowledgeRequest,
)
from models.responses import (
+ CreateKnowledgeResponse,
+ CreateTodoResponse,
DeleteDiaryResponse,
DiaryData,
DiaryListData,
GenerateDiaryResponse,
GetDiaryListResponse,
+ KnowledgeData,
+ TodoData,
+ ToggleKnowledgeFavoriteResponse,
+ UpdateKnowledgeResponse,
)
from perception.image_manager import get_image_manager
@@ -33,6 +43,17 @@
logger = get_logger(__name__)
+async def _check_knowledge_merge_lock() -> None:
+ """Check if knowledge analysis is in progress and raise error if so"""
+ from services.knowledge_merger import KnowledgeMerger
+
+ if KnowledgeMerger.is_locked():
+ raise RuntimeError(
+ "Cannot modify knowledge while analysis is in progress. "
+ "Please wait for the analysis to complete or cancel it."
+ )
+
+
def get_pipeline():
"""Get new architecture processing pipeline instance"""
coordinator = get_coordinator()
@@ -194,6 +215,9 @@ async def delete_knowledge(body: DeleteItemRequest) -> Dict[str, Any]:
@returns Deletion result
"""
try:
+ # Check if analysis is in progress
+ await _check_knowledge_merge_lock()
+
db, _ = _get_data_access()
await db.knowledge.delete(body.id)
@@ -212,6 +236,192 @@ async def delete_knowledge(body: DeleteItemRequest) -> Dict[str, Any]:
}
+@api_handler(
+ body=ToggleKnowledgeFavoriteRequest,
+ method="POST",
+ path="/insights/toggle-knowledge-favorite",
+ tags=["insights"],
+ summary="Toggle knowledge favorite status",
+ description="Toggle the favorite status of a knowledge item",
+)
+async def toggle_knowledge_favorite(body: ToggleKnowledgeFavoriteRequest) -> ToggleKnowledgeFavoriteResponse:
+ """Toggle knowledge favorite status
+
+ @param body - Contains knowledge ID
+ @returns Updated knowledge data with new favorite status
+ """
+ try:
+ # Check if analysis is in progress
+ await _check_knowledge_merge_lock()
+
+ db, _ = _get_data_access()
+ new_favorite = await db.knowledge.toggle_favorite(body.id)
+
+ if new_favorite is None:
+ return ToggleKnowledgeFavoriteResponse(
+ success=False,
+ message="Knowledge not found",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Get updated knowledge data
+ knowledge_list = await db.knowledge.get_list()
+ knowledge_item = next((k for k in knowledge_list if k["id"] == body.id), None)
+
+ if knowledge_item:
+ knowledge_data = KnowledgeData(**knowledge_item)
+ return ToggleKnowledgeFavoriteResponse(
+ success=True,
+ data=knowledge_data,
+ message=f"Knowledge {'favorited' if new_favorite else 'unfavorited'}",
+ timestamp=datetime.now().isoformat(),
+ )
+ else:
+ return ToggleKnowledgeFavoriteResponse(
+ success=False,
+ message="Failed to retrieve updated knowledge",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to toggle knowledge favorite: {e}", exc_info=True)
+ return ToggleKnowledgeFavoriteResponse(
+ success=False,
+ message=f"Failed to toggle knowledge favorite: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=CreateKnowledgeRequest,
+ method="POST",
+ path="/insights/create-knowledge",
+ tags=["insights"],
+ summary="Create knowledge manually",
+ description="Create a new knowledge item manually",
+)
+async def create_knowledge(body: CreateKnowledgeRequest) -> CreateKnowledgeResponse:
+ """Create knowledge manually
+
+ @param body - Contains title, description, and keywords
+ @returns Created knowledge data
+ """
+ try:
+ # Check if analysis is in progress
+ await _check_knowledge_merge_lock()
+
+ db, _ = _get_data_access()
+
+ # Generate unique ID
+ knowledge_id = str(uuid.uuid4())
+ created_at = datetime.now().isoformat()
+
+ # Save knowledge
+ await db.knowledge.save(
+ knowledge_id=knowledge_id,
+ title=body.title,
+ description=body.description,
+ keywords=body.keywords,
+ created_at=created_at,
+ source_action_id=None, # Manual creation has no source action
+ favorite=False,
+ )
+
+ # Return created knowledge
+ knowledge_data = KnowledgeData(
+ id=knowledge_id,
+ title=body.title,
+ description=body.description,
+ keywords=body.keywords,
+ created_at=created_at,
+ source_action_id=None,
+ favorite=False,
+ deleted=False,
+ )
+
+ return CreateKnowledgeResponse(
+ success=True,
+ data=knowledge_data,
+ message="Knowledge created successfully",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to create knowledge: {e}", exc_info=True)
+ return CreateKnowledgeResponse(
+ success=False,
+ message=f"Failed to create knowledge: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=UpdateKnowledgeRequest,
+ method="POST",
+ path="/insights/update-knowledge",
+ tags=["insights"],
+ summary="Update knowledge",
+ description="Update an existing knowledge item",
+)
+async def update_knowledge(body: UpdateKnowledgeRequest) -> UpdateKnowledgeResponse:
+ """Update knowledge
+
+ @param body - Contains knowledge ID, title, description, and keywords
+ @returns Updated knowledge data
+ """
+ try:
+ # Check if analysis is in progress
+ await _check_knowledge_merge_lock()
+
+ db, _ = _get_data_access()
+
+ # Check if knowledge exists
+ knowledge_list = await db.knowledge.get_list()
+ knowledge_item = next((k for k in knowledge_list if k["id"] == body.id), None)
+
+ if not knowledge_item:
+ return UpdateKnowledgeResponse(
+ success=False,
+ message="Knowledge not found",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Update knowledge
+ await db.knowledge.update(
+ knowledge_id=body.id,
+ title=body.title,
+ description=body.description,
+ keywords=body.keywords,
+ )
+
+ # Get updated knowledge
+ knowledge_list = await db.knowledge.get_list()
+ updated_knowledge = next((k for k in knowledge_list if k["id"] == body.id), None)
+
+ if updated_knowledge:
+ knowledge_data = KnowledgeData(**updated_knowledge)
+ return UpdateKnowledgeResponse(
+ success=True,
+ data=knowledge_data,
+ message="Knowledge updated successfully",
+ timestamp=datetime.now().isoformat(),
+ )
+ else:
+ return UpdateKnowledgeResponse(
+ success=False,
+ message="Failed to retrieve updated knowledge",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to update knowledge: {e}", exc_info=True)
+ return UpdateKnowledgeResponse(
+ success=False,
+ message=f"Failed to update knowledge: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
# ============ Todo Related Interfaces ============
@@ -252,6 +462,39 @@ async def get_todo_list(body: GetTodoListRequest) -> Dict[str, Any]:
}
+@api_handler(
+ body=DeleteItemRequest,
+ method="POST",
+ path="/insights/complete-todo",
+ tags=["insights"],
+ summary="Complete todo",
+ description="Mark specified todo as completed",
+)
+async def complete_todo(body: DeleteItemRequest) -> Dict[str, Any]:
+ """Complete todo (mark as completed)
+
+ @param body - Contains todo ID to complete
+ @returns Completion result
+ """
+ try:
+ db, _ = _get_data_access()
+ await db.todos.complete(body.id)
+
+ return {
+ "success": True,
+ "message": "Todo completed",
+ "timestamp": datetime.now().isoformat(),
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to complete todo: {e}", exc_info=True)
+ return {
+ "success": False,
+ "message": f"Failed to complete todo: {str(e)}",
+ "timestamp": datetime.now().isoformat(),
+ }
+
+
@api_handler(
body=DeleteItemRequest,
method="POST",
@@ -647,3 +890,110 @@ async def get_knowledge_count_by_date() -> Dict[str, Any]:
"message": f"Failed to get knowledge count by date: {str(e)}",
"timestamp": datetime.now().isoformat(),
}
+
+
+# ============ Manual Todo Creation ============
+
+
+@api_handler(
+ body=CreateTodoRequest,
+ method="POST",
+ path="/insights/create-todo",
+ tags=["insights"],
+ summary="Create todo manually",
+ description="Create a new todo manually (no expiration, source_type='manual')",
+)
+async def create_todo(body: CreateTodoRequest) -> CreateTodoResponse:
+ """Create a todo manually
+
+ Manually created todos have no expiration time and source_type='manual'.
+ They will persist until explicitly deleted or completed.
+
+ @param body - Contains title, description, keywords, and optional scheduling info
+ @returns Created todo data
+ """
+ try:
+ db, _ = _get_data_access()
+
+ # Generate unique ID
+ todo_id = str(uuid.uuid4())
+ created_at = datetime.now().isoformat()
+
+ # Save todo manually (source_type='manual', no expiration)
+ await db.todos.save(
+ todo_id=todo_id,
+ title=body.title,
+ description=body.description,
+ keywords=body.keywords,
+ created_at=created_at,
+ completed=False,
+ scheduled_date=body.scheduled_date,
+ scheduled_time=body.scheduled_time,
+ scheduled_end_time=body.scheduled_end_time,
+ source_type="manual",
+ )
+
+ # Get the saved todo
+ saved_todo = await db.todos.get_by_id(todo_id)
+
+ if saved_todo:
+ todo_data = TodoData(**saved_todo)
+ return CreateTodoResponse(
+ success=True,
+ data=todo_data,
+ message="Todo created successfully",
+ timestamp=datetime.now().isoformat(),
+ )
+ else:
+ return CreateTodoResponse(
+ success=False,
+ message="Failed to retrieve created todo",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to create todo: {e}", exc_info=True)
+ return CreateTodoResponse(
+ success=False,
+ message=f"Failed to create todo: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ method="POST",
+ path="/insights/cleanup-expired-todos",
+ tags=["insights"],
+ summary="Clean up expired todos",
+ description="Soft delete all expired AI-generated todos",
+)
+async def cleanup_expired_todos() -> Dict[str, Any]:
+ """Clean up expired AI-generated todos
+
+ Soft deletes todos that:
+ - Are AI-generated (source_type='ai')
+ - Have an expires_at timestamp in the past
+ - Are not already deleted
+ - Are not completed
+
+ @returns Cleanup result with count of deleted todos
+ """
+ try:
+ db, _ = _get_data_access()
+
+ deleted_count = await db.todos.delete_expired()
+
+ return {
+ "success": True,
+ "message": f"Cleaned up {deleted_count} expired todos",
+ "data": {"deleted_count": deleted_count},
+ "timestamp": datetime.now().isoformat(),
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to cleanup expired todos: {e}", exc_info=True)
+ return {
+ "success": False,
+ "message": f"Failed to cleanup expired todos: {str(e)}",
+ "timestamp": datetime.now().isoformat(),
+ }
diff --git a/backend/handlers/knowledge_merge.py b/backend/handlers/knowledge_merge.py
new file mode 100644
index 0000000..6bfc94c
--- /dev/null
+++ b/backend/handlers/knowledge_merge.py
@@ -0,0 +1,217 @@
+"""
+Knowledge merge handlers for analyzing and merging similar knowledge entries.
+"""
+
+from datetime import datetime
+from typing import Any, Dict, cast
+
+from core.db import get_db
+from core.logger import get_logger
+from core.protocols import KnowledgeRepositoryProtocol
+from llm.manager import get_llm_manager
+from llm.prompt_manager import PromptManager
+from models.requests import (
+ AnalyzeKnowledgeMergeRequest,
+ ExecuteKnowledgeMergeRequest,
+ MergeGroup,
+)
+from models.responses import (
+ AnalyzeKnowledgeMergeResponse,
+ ExecuteKnowledgeMergeResponse,
+ MergeSuggestion,
+ MergeResult,
+)
+from services.knowledge_merger import KnowledgeMerger
+
+# CRITICAL: Use relative import to avoid circular imports
+from . import api_handler
+
+logger = get_logger(__name__)
+
+
+@api_handler(
+ method="GET",
+ path="/knowledge/analysis-status",
+ tags=["knowledge"],
+ summary="Get knowledge analysis status",
+ description="Check if knowledge analysis is currently in progress",
+)
+async def get_analysis_status() -> Dict[str, Any]:
+ """Get current knowledge analysis status"""
+ try:
+ is_locked = KnowledgeMerger.is_locked()
+ return {
+ "success": True,
+ "data": {
+ "is_analyzing": is_locked,
+ },
+ "timestamp": datetime.now().isoformat(),
+ }
+ except Exception as e:
+ logger.error(f"Failed to get analysis status: {e}", exc_info=True)
+ return {
+ "success": False,
+ "message": f"Failed to get analysis status: {str(e)}",
+ "timestamp": datetime.now().isoformat(),
+ }
+
+
+@api_handler(
+ body=AnalyzeKnowledgeMergeRequest,
+ method="POST",
+ path="/knowledge/analyze-merge",
+ tags=["knowledge"],
+)
+async def analyze_knowledge_merge(
+ body: AnalyzeKnowledgeMergeRequest,
+) -> AnalyzeKnowledgeMergeResponse:
+ """
+ Analyze knowledge entries for similarity and generate merge suggestions.
+ Uses LLM to detect similar content and propose merges.
+ """
+ try:
+ db = get_db()
+ knowledge_repo = cast(KnowledgeRepositoryProtocol, db.knowledge)
+ llm_manager = get_llm_manager()
+ prompt_manager = PromptManager()
+
+ merger = KnowledgeMerger(knowledge_repo, prompt_manager, llm_manager)
+
+ # Analyze similarities
+ suggestions_data, total_tokens = await merger.analyze_similarities(
+ filter_by_keyword=body.filter_by_keyword,
+ include_favorites=body.include_favorites,
+ similarity_threshold=body.similarity_threshold,
+ )
+
+ # Convert to response models
+ suggestions = [
+ MergeSuggestion(
+ group_id=s.group_id,
+ knowledge_ids=s.knowledge_ids,
+ merged_title=s.merged_title,
+ merged_description=s.merged_description,
+ merged_keywords=s.merged_keywords,
+ similarity_score=s.similarity_score,
+ merge_reason=s.merge_reason,
+ estimated_tokens=s.estimated_tokens,
+ )
+ for s in suggestions_data
+ ]
+
+ # Calculate analyzed count
+ knowledge_list = await merger._fetch_knowledge(
+ body.filter_by_keyword, body.include_favorites
+ )
+
+ logger.info(
+ f"Analyzed {len(knowledge_list)} knowledge entries, "
+ f"found {len(suggestions)} merge suggestions, "
+ f"used {total_tokens} tokens"
+ )
+
+ return AnalyzeKnowledgeMergeResponse(
+ success=True,
+ message=f"Found {len(suggestions)} merge suggestions",
+ timestamp=datetime.now().isoformat(),
+ suggestions=suggestions,
+ total_estimated_tokens=total_tokens,
+ analyzed_count=len(knowledge_list),
+ suggested_merge_count=len(suggestions),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to analyze knowledge merge: {e}", exc_info=True)
+ return AnalyzeKnowledgeMergeResponse(
+ success=False,
+ message="Failed to analyze knowledge merge",
+ error=str(e),
+ timestamp=datetime.now().isoformat(),
+ suggestions=[],
+ total_estimated_tokens=0,
+ analyzed_count=0,
+ suggested_merge_count=0,
+ )
+
+
+@api_handler(
+ body=ExecuteKnowledgeMergeRequest,
+ method="POST",
+ path="/knowledge/execute-merge",
+ tags=["knowledge"],
+)
+async def execute_knowledge_merge(
+ body: ExecuteKnowledgeMergeRequest,
+) -> ExecuteKnowledgeMergeResponse:
+ """
+ Execute approved knowledge merge operations.
+ Creates merged knowledge entries and soft-deletes sources.
+ """
+ try:
+ db = get_db()
+ knowledge_repo = cast(KnowledgeRepositoryProtocol, db.knowledge)
+ llm_manager = get_llm_manager()
+ prompt_manager = PromptManager()
+
+ merger = KnowledgeMerger(knowledge_repo, prompt_manager, llm_manager)
+
+ # Convert request models to service models
+ merge_groups = []
+ for group in body.merge_groups:
+ from services.knowledge_merger import MergeGroup as ServiceMergeGroup
+
+ merge_groups.append(
+ ServiceMergeGroup(
+ group_id=group.group_id,
+ knowledge_ids=group.knowledge_ids,
+ merged_title=group.merged_title,
+ merged_description=group.merged_description,
+ merged_keywords=group.merged_keywords,
+ merge_reason=group.merge_reason,
+ keep_favorite=group.keep_favorite,
+ )
+ )
+
+ # Execute merge
+ results_data = await merger.execute_merge(merge_groups)
+
+ # Convert to response models
+ results = [
+ MergeResult(
+ group_id=r.group_id,
+ merged_knowledge_id=r.merged_knowledge_id,
+ deleted_knowledge_ids=r.deleted_knowledge_ids,
+ success=r.success,
+ error=r.error,
+ )
+ for r in results_data
+ ]
+
+ total_merged = sum(1 for r in results if r.success)
+ total_deleted = sum(len(r.deleted_knowledge_ids) for r in results if r.success)
+
+ logger.info(
+ f"Executed merge: {total_merged}/{len(results)} groups successful, "
+ f"{total_deleted} knowledge entries deleted"
+ )
+
+ return ExecuteKnowledgeMergeResponse(
+ success=True,
+ message=f"Successfully merged {total_merged} groups",
+ timestamp=datetime.now().isoformat(),
+ results=results,
+ total_merged=total_merged,
+ total_deleted=total_deleted,
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to execute knowledge merge: {e}", exc_info=True)
+ return ExecuteKnowledgeMergeResponse(
+ success=False,
+ message="Failed to execute knowledge merge",
+ error=str(e),
+ timestamp=datetime.now().isoformat(),
+ results=[],
+ total_merged=0,
+ total_deleted=0,
+ )
diff --git a/backend/handlers/monitoring.py b/backend/handlers/monitoring.py
index 7fbe9ef..e18e33f 100644
--- a/backend/handlers/monitoring.py
+++ b/backend/handlers/monitoring.py
@@ -247,15 +247,27 @@ async def get_monitors_auto_refresh_status() -> Dict[str, Any]:
async def get_screen_settings() -> Dict[str, Any]:
"""Get screen capture settings.
- Returns current screen capture settings from config.
+ Returns current screen capture settings from database.
"""
settings = get_settings()
- screens = settings.get("screenshot.screen_settings", []) or []
- return {
- "success": True,
- "data": {"screens": screens, "count": len(screens)},
- "timestamp": datetime.now().isoformat(),
- }
+
+ try:
+ screens = settings.get_screenshot_screen_settings()
+ logger.debug(f"✓ Loaded {len(screens)} screen settings from database")
+
+ return {
+ "success": True,
+ "data": {"screens": screens, "count": len(screens)},
+ "timestamp": datetime.now().isoformat(),
+ }
+ except Exception as e:
+ logger.error(f"Failed to load screen settings: {e}")
+ return {
+ "success": False,
+ "message": f"Failed to load screen settings: {str(e)}",
+ "data": {"screens": [], "count": 0},
+ "timestamp": datetime.now().isoformat(),
+ }
@api_handler()
diff --git a/backend/handlers/pomodoro.py b/backend/handlers/pomodoro.py
new file mode 100644
index 0000000..28ca507
--- /dev/null
+++ b/backend/handlers/pomodoro.py
@@ -0,0 +1,504 @@
+"""
+Pomodoro timer API handlers
+
+Endpoints:
+- POST /pomodoro/start - Start a Pomodoro session
+- POST /pomodoro/end - End current Pomodoro session
+- GET /pomodoro/status - Get current Pomodoro session status
+- POST /pomodoro/retry-work-phase - Manually retry work phase activity aggregation
+"""
+
+import asyncio
+from datetime import datetime, timedelta
+from typing import Optional
+
+from core.coordinator import get_coordinator
+from core.db import get_db
+from core.logger import get_logger
+from models.base import BaseModel
+from models.responses import (
+ EndPomodoroData,
+ EndPomodoroResponse,
+ GetPomodoroStatusResponse,
+ PomodoroSessionData,
+ StartPomodoroResponse,
+ TimedOperationResponse,
+ WorkPhaseInfo,
+ GetSessionPhasesResponse,
+)
+
+from . import api_handler
+
+logger = get_logger(__name__)
+
+
+class StartPomodoroRequest(BaseModel):
+ """Start Pomodoro request with rounds configuration"""
+
+ user_intent: str
+ duration_minutes: int = 25 # Legacy field, calculated from rounds
+ associated_todo_id: Optional[str] = None # Optional TODO association
+ work_duration_minutes: int = 25 # Duration of work phase
+ break_duration_minutes: int = 5 # Duration of break phase
+ total_rounds: int = 4 # Number of work rounds
+
+
+class EndPomodoroRequest(BaseModel):
+ """End Pomodoro request"""
+
+ status: str = "completed" # completed, abandoned, interrupted
+
+
+class RetryWorkPhaseRequest(BaseModel):
+ """Retry work phase activity aggregation request"""
+
+ session_id: str
+ work_phase: int
+
+
+class GetSessionPhasesRequest(BaseModel):
+ """Get session phases request"""
+
+ session_id: str
+
+
+class RetryLLMEvaluationRequest(BaseModel):
+ """Retry LLM evaluation request"""
+
+ session_id: str
+
+
+@api_handler(
+ body=StartPomodoroRequest,
+ method="POST",
+ path="/pomodoro/start",
+ tags=["pomodoro"],
+)
+async def start_pomodoro(body: StartPomodoroRequest) -> StartPomodoroResponse:
+ """
+ Start a new Pomodoro session
+
+ Args:
+ body: Request containing user_intent and duration_minutes
+
+ Returns:
+ StartPomodoroResponse with session data
+
+ Raises:
+ ValueError: If a Pomodoro session is already active or previous session is still processing
+ """
+ try:
+ coordinator = get_coordinator()
+
+ if not coordinator.pomodoro_manager:
+ return StartPomodoroResponse(
+ success=False,
+ message="Pomodoro manager not initialized",
+ error="Pomodoro manager not initialized",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Start Pomodoro session
+ session_id = await coordinator.pomodoro_manager.start_pomodoro(
+ user_intent=body.user_intent,
+ duration_minutes=body.duration_minutes,
+ associated_todo_id=body.associated_todo_id,
+ work_duration_minutes=body.work_duration_minutes,
+ break_duration_minutes=body.break_duration_minutes,
+ total_rounds=body.total_rounds,
+ )
+
+ # Get session info
+ session_info = await coordinator.pomodoro_manager.get_current_session_info()
+
+ if not session_info:
+ return StartPomodoroResponse(
+ success=False,
+ message="Failed to retrieve session info",
+ error="Failed to retrieve session info after starting",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ logger.info(
+ f"Pomodoro session started via API: {session_id}, intent='{body.user_intent}'"
+ )
+
+ return StartPomodoroResponse(
+ success=True,
+ message="Pomodoro session started successfully",
+ data=PomodoroSessionData(
+ session_id=session_info["session_id"],
+ user_intent=session_info["user_intent"],
+ start_time=session_info["start_time"],
+ elapsed_minutes=session_info["elapsed_minutes"],
+ planned_duration_minutes=session_info["planned_duration_minutes"],
+ associated_todo_id=session_info.get("associated_todo_id"),
+ associated_todo_title=session_info.get("associated_todo_title"),
+ work_duration_minutes=session_info.get("work_duration_minutes", 25),
+ break_duration_minutes=session_info.get("break_duration_minutes", 5),
+ total_rounds=session_info.get("total_rounds", 4),
+ current_round=session_info.get("current_round", 1),
+ current_phase=session_info.get("current_phase", "work"),
+ phase_start_time=session_info.get("phase_start_time"),
+ completed_rounds=session_info.get("completed_rounds", 0),
+ remaining_phase_seconds=session_info.get("remaining_phase_seconds"),
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except ValueError as e:
+ # Expected errors (session already active, previous processing)
+ logger.warning(f"Failed to start Pomodoro session: {e}")
+ return StartPomodoroResponse(
+ success=False,
+ message=str(e),
+ error=str(e),
+ timestamp=datetime.now().isoformat(),
+ )
+ except Exception as e:
+ logger.error(f"Unexpected error starting Pomodoro session: {e}", exc_info=True)
+ return StartPomodoroResponse(
+ success=False,
+ message="Failed to start Pomodoro session",
+ error=str(e),
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=EndPomodoroRequest,
+ method="POST",
+ path="/pomodoro/end",
+ tags=["pomodoro"],
+)
+async def end_pomodoro(body: EndPomodoroRequest) -> EndPomodoroResponse:
+ """
+ End current Pomodoro session
+
+ Args:
+ body: Request containing status (completed/abandoned/interrupted)
+
+ Returns:
+ EndPomodoroResponse with processing job info
+
+ Raises:
+ ValueError: If no active Pomodoro session
+ """
+ try:
+ coordinator = get_coordinator()
+
+ if not coordinator.pomodoro_manager:
+ return EndPomodoroResponse(
+ success=False,
+ message="Pomodoro manager not initialized",
+ error="Pomodoro manager not initialized",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # End Pomodoro session
+ result = await coordinator.pomodoro_manager.end_pomodoro(status=body.status)
+
+ logger.info(
+ f"Pomodoro session ended via API: {result['session_id']}, status={body.status}"
+ )
+
+ return EndPomodoroResponse(
+ success=True,
+ message="Pomodoro session ended successfully",
+ data=EndPomodoroData(
+ session_id=result["session_id"],
+ status=result["status"], # ✅ Use new field
+ actual_work_minutes=result["actual_work_minutes"], # ✅ Use new field
+ raw_records_count=result.get("raw_records_count", 0), # ✅ Safe access
+ processing_job_id=None, # ✅ Deprecated, always None now
+ message=result.get("message", ""),
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except ValueError as e:
+ # Expected error (no active session)
+ logger.warning(f"Failed to end Pomodoro session: {e}")
+ return EndPomodoroResponse(
+ success=False,
+ message=str(e),
+ error=str(e),
+ timestamp=datetime.now().isoformat(),
+ )
+ except Exception as e:
+ logger.error(f"Unexpected error ending Pomodoro session: {e}", exc_info=True)
+ return EndPomodoroResponse(
+ success=False,
+ message="Failed to end Pomodoro session",
+ error=str(e),
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(method="GET", path="/pomodoro/status", tags=["pomodoro"])
+async def get_pomodoro_status() -> GetPomodoroStatusResponse:
+ """
+ Get current Pomodoro session status
+
+ Returns:
+ GetPomodoroStatusResponse with current session info or None if no active session
+ """
+ try:
+ coordinator = get_coordinator()
+
+ if not coordinator.pomodoro_manager:
+ return GetPomodoroStatusResponse(
+ success=False,
+ message="Pomodoro manager not initialized",
+ error="Pomodoro manager not initialized",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Get current session info
+ session_info = await coordinator.pomodoro_manager.get_current_session_info()
+
+ if not session_info:
+ # No active session
+ return GetPomodoroStatusResponse(
+ success=True,
+ message="No active Pomodoro session",
+ data=None,
+ timestamp=datetime.now().isoformat(),
+ )
+
+ return GetPomodoroStatusResponse(
+ success=True,
+ message="Active Pomodoro session found",
+ data=PomodoroSessionData(
+ session_id=session_info["session_id"],
+ user_intent=session_info["user_intent"],
+ start_time=session_info["start_time"],
+ elapsed_minutes=session_info["elapsed_minutes"],
+ planned_duration_minutes=session_info["planned_duration_minutes"],
+ # Add all missing fields for complete session state
+ associated_todo_id=session_info.get("associated_todo_id"),
+ associated_todo_title=session_info.get("associated_todo_title"),
+ work_duration_minutes=session_info.get("work_duration_minutes", 25),
+ break_duration_minutes=session_info.get("break_duration_minutes", 5),
+ total_rounds=session_info.get("total_rounds", 4),
+ current_round=session_info.get("current_round", 1),
+ current_phase=session_info.get("current_phase", "work"),
+ phase_start_time=session_info.get("phase_start_time"),
+ completed_rounds=session_info.get("completed_rounds", 0),
+ remaining_phase_seconds=session_info.get("remaining_phase_seconds"),
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Unexpected error getting Pomodoro status: {e}", exc_info=True
+ )
+ return GetPomodoroStatusResponse(
+ success=False,
+ message="Failed to get Pomodoro status",
+ error=str(e),
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=RetryWorkPhaseRequest,
+ method="POST",
+ path="/pomodoro/retry-work-phase",
+ tags=["pomodoro"],
+)
+async def retry_work_phase_aggregation(
+ body: RetryWorkPhaseRequest,
+) -> EndPomodoroResponse:
+ """
+ Manually trigger work phase activity aggregation (for retry)
+
+ This endpoint allows users to manually retry activity aggregation for a specific
+ work phase if the automatic aggregation failed or was incomplete.
+
+ Args:
+ body: Request containing session_id and work_phase number
+
+ Returns:
+ EndPomodoroResponse with success status and processing details
+ """
+ try:
+ coordinator = get_coordinator()
+
+ if not coordinator.pomodoro_manager:
+ return EndPomodoroResponse(
+ success=False,
+ message="Pomodoro manager not initialized",
+ error="Pomodoro manager not initialized",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Get session from database
+ db = get_db()
+ session = await db.pomodoro_sessions.get_by_id(body.session_id)
+ if not session:
+ return EndPomodoroResponse(
+ success=False,
+ message=f"Session {body.session_id} not found",
+ error=f"Session {body.session_id} not found",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Validate work_phase number
+ total_rounds = session.get("total_rounds", 4)
+ if body.work_phase < 1 or body.work_phase > total_rounds:
+ return EndPomodoroResponse(
+ success=False,
+ message=f"Invalid work phase {body.work_phase}. Must be between 1 and {total_rounds}",
+ error="Invalid work phase number",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Calculate phase time range based on work_phase
+ # Note: This is a simplified calculation. For precise timing,
+ # we would need to store each phase's start/end time in database.
+ session_start = datetime.fromisoformat(session["start_time"])
+ work_duration = session.get("work_duration_minutes", 25)
+ break_duration = session.get("break_duration_minutes", 5)
+
+ # Calculate phase start time
+ # Each complete round = work + break
+ # For work_phase N: start = session_start + (N-1) * (work + break)
+ phase_start_offset = (body.work_phase - 1) * (work_duration + break_duration)
+ phase_start_time = session_start + timedelta(minutes=phase_start_offset)
+
+ # Phase end time = start + work_duration
+ phase_end_time = phase_start_time + timedelta(minutes=work_duration)
+
+ # Use session end time if this was the last work phase
+ if session.get("end_time"):
+ session_end = datetime.fromisoformat(session["end_time"])
+ if phase_end_time > session_end:
+ phase_end_time = session_end
+
+ logger.info(
+ f"Manually triggering work phase aggregation: "
+ f"session={body.session_id}, phase={body.work_phase}, "
+ f"time_range={phase_start_time.isoformat()} to {phase_end_time.isoformat()}"
+ )
+
+ # Trigger aggregation in background (non-blocking)
+ asyncio.create_task(
+ coordinator.pomodoro_manager._aggregate_work_phase_activities(
+ session_id=body.session_id,
+ work_phase=body.work_phase,
+ phase_start_time=phase_start_time,
+ phase_end_time=phase_end_time,
+ )
+ )
+
+ return EndPomodoroResponse(
+ success=True,
+ message=f"Work phase {body.work_phase} aggregation triggered successfully",
+ data=EndPomodoroData(
+ session_id=body.session_id,
+ status="processing", # Work phase being retried
+ actual_work_minutes=0, # Not applicable for retry
+ processing_job_id=None, # Background task, no job ID
+ raw_records_count=0,
+ message=f"Aggregation triggered for work phase {body.work_phase}",
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Failed to retry work phase aggregation: {e}", exc_info=True
+ )
+ return EndPomodoroResponse(
+ success=False,
+ message="Failed to retry work phase aggregation",
+ error=str(e),
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=GetSessionPhasesRequest,
+ method="POST",
+ path="/pomodoro/get-session-phases",
+ tags=["pomodoro"],
+)
+async def get_session_phases(
+ body: GetSessionPhasesRequest,
+) -> GetSessionPhasesResponse:
+ """
+ Get all work phase records for a session.
+ Used by frontend to display phase status and retry buttons.
+ """
+ try:
+ db = get_db()
+ phases = await db.work_phases.get_by_session(body.session_id)
+
+ phase_infos = [
+ WorkPhaseInfo(
+ phase_id=p["id"],
+ phase_number=p["phase_number"],
+ status=p["status"],
+ processing_error=p.get("processing_error"),
+ retry_count=p.get("retry_count", 0),
+ phase_start_time=p["phase_start_time"],
+ phase_end_time=p.get("phase_end_time"),
+ activity_count=p.get("activity_count", 0),
+ )
+ for p in phases
+ ]
+
+ return GetSessionPhasesResponse(
+ success=True, data=phase_infos, timestamp=datetime.now().isoformat()
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to get session phases: {e}", exc_info=True)
+ return GetSessionPhasesResponse(
+ success=False, error=str(e), timestamp=datetime.now().isoformat()
+ )
+
+
+@api_handler(
+ body=RetryLLMEvaluationRequest,
+ method="POST",
+ path="/pomodoro/retry-llm-evaluation",
+ tags=["pomodoro"],
+)
+async def retry_llm_evaluation(
+ body: RetryLLMEvaluationRequest,
+) -> TimedOperationResponse:
+ """
+ Manually retry LLM focus evaluation for a session.
+ Independent from phase aggregation retry.
+ """
+ try:
+ coordinator = get_coordinator()
+
+ if not coordinator.pomodoro_manager:
+ return TimedOperationResponse(
+ success=False,
+ error="Pomodoro manager not initialized",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Trigger LLM evaluation (non-blocking)
+ asyncio.create_task(
+ coordinator.pomodoro_manager._compute_and_cache_llm_evaluation(
+ body.session_id
+ )
+ )
+
+ return TimedOperationResponse(
+ success=True,
+ message="LLM evaluation retry triggered successfully",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to retry LLM evaluation: {e}", exc_info=True)
+ return TimedOperationResponse(
+ success=False, error=str(e), timestamp=datetime.now().isoformat()
+ )
diff --git a/backend/handlers/pomodoro_goals.py b/backend/handlers/pomodoro_goals.py
new file mode 100644
index 0000000..5d8b2ca
--- /dev/null
+++ b/backend/handlers/pomodoro_goals.py
@@ -0,0 +1,137 @@
+"""
+Pomodoro Goals Handler - API endpoints for managing focus time goals
+"""
+
+from datetime import datetime
+from typing import Optional
+
+from core.logger import get_logger
+from core.settings import get_settings
+from models.base import BaseModel
+from models.responses import TimedOperationResponse
+
+# CRITICAL: Use relative import to avoid circular imports
+from . import api_handler
+
+logger = get_logger(__name__)
+
+
+# ============ Request Models ============
+
+
+class UpdatePomodoroGoalsRequest(BaseModel):
+ """Request to update Pomodoro goal settings"""
+
+ daily_focus_goal_minutes: Optional[int] = None
+ weekly_focus_goal_minutes: Optional[int] = None
+
+
+# ============ Response Models ============
+
+
+class PomodoroGoalsData(BaseModel):
+ """Pomodoro goal settings data"""
+
+ daily_focus_goal_minutes: int
+ weekly_focus_goal_minutes: int
+
+
+class GetPomodoroGoalsResponse(TimedOperationResponse):
+ """Response with Pomodoro goal settings"""
+
+ data: Optional[PomodoroGoalsData] = None
+
+
+class UpdatePomodoroGoalsResponse(TimedOperationResponse):
+ """Response with updated Pomodoro goal settings"""
+
+ data: Optional[PomodoroGoalsData] = None
+
+
+# ============ API Handlers ============
+
+
+@api_handler(
+ method="GET",
+ path="/pomodoro/goals",
+ tags=["pomodoro"],
+)
+async def get_pomodoro_goals() -> GetPomodoroGoalsResponse:
+ """
+ Get Pomodoro focus time goal settings
+
+ Returns:
+ - daily_focus_goal_minutes: Daily goal in minutes
+ - weekly_focus_goal_minutes: Weekly goal in minutes
+ """
+ try:
+ settings = get_settings()
+ goals = settings.get_pomodoro_goal_settings()
+
+ return GetPomodoroGoalsResponse(
+ success=True,
+ message="Retrieved Pomodoro goals",
+ data=PomodoroGoalsData(
+ daily_focus_goal_minutes=goals["daily_focus_goal_minutes"],
+ weekly_focus_goal_minutes=goals["weekly_focus_goal_minutes"],
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+ except Exception as e:
+ logger.error(f"Failed to get Pomodoro goals: {e}", exc_info=True)
+ return GetPomodoroGoalsResponse(
+ success=False,
+ message=f"Failed to get goals: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=UpdatePomodoroGoalsRequest,
+ method="POST",
+ path="/pomodoro/goals",
+ tags=["pomodoro"],
+)
+async def update_pomodoro_goals(
+ body: UpdatePomodoroGoalsRequest,
+) -> UpdatePomodoroGoalsResponse:
+ """
+ Update Pomodoro focus time goal settings
+
+ Args:
+ body: Contains daily and/or weekly goal values
+
+ Returns:
+ Updated goal settings
+ """
+ try:
+ settings = get_settings()
+
+ # Build update dict with only provided fields
+ updates = {}
+ if body.daily_focus_goal_minutes is not None:
+ updates["daily_focus_goal_minutes"] = body.daily_focus_goal_minutes
+ if body.weekly_focus_goal_minutes is not None:
+ updates["weekly_focus_goal_minutes"] = body.weekly_focus_goal_minutes
+
+ # Update settings
+ updated_goals = settings.update_pomodoro_goal_settings(updates)
+
+ logger.info(f"Updated Pomodoro goals: {updated_goals}")
+
+ return UpdatePomodoroGoalsResponse(
+ success=True,
+ message="Pomodoro goals updated successfully",
+ data=PomodoroGoalsData(
+ daily_focus_goal_minutes=updated_goals["daily_focus_goal_minutes"],
+ weekly_focus_goal_minutes=updated_goals["weekly_focus_goal_minutes"],
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+ except Exception as e:
+ logger.error(f"Failed to update Pomodoro goals: {e}", exc_info=True)
+ return UpdatePomodoroGoalsResponse(
+ success=False,
+ message=f"Failed to update goals: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
diff --git a/backend/handlers/pomodoro_linking.py b/backend/handlers/pomodoro_linking.py
new file mode 100644
index 0000000..849b0e6
--- /dev/null
+++ b/backend/handlers/pomodoro_linking.py
@@ -0,0 +1,296 @@
+"""
+Pomodoro Activity Linking Handler - API endpoints for linking unlinked activities
+
+Provides endpoints to find and link unlinked activities to Pomodoro sessions
+based on time overlap.
+"""
+
+from datetime import datetime, timedelta
+from typing import Any, Dict, List, Optional
+
+from core.db import get_db
+from core.logger import get_logger
+from models.base import BaseModel
+from models.responses import TimedOperationResponse
+
+# CRITICAL: Use relative import to avoid circular imports
+from . import api_handler
+
+logger = get_logger(__name__)
+
+
+# ============ Request Models ============
+
+
+class FindUnlinkedActivitiesRequest(BaseModel):
+ """Request to find activities that could be linked to a session"""
+
+ session_id: str
+
+
+class LinkActivitiesRequest(BaseModel):
+ """Request to link activities to a session"""
+
+ session_id: str
+ activity_ids: List[str]
+
+
+# ============ Response Models ============
+
+
+class UnlinkedActivityData(BaseModel):
+ """Activity that could be linked to session"""
+
+ id: str
+ title: str
+ start_time: str
+ end_time: str
+ session_duration_minutes: int
+
+
+class FindUnlinkedActivitiesResponse(TimedOperationResponse):
+ """Response with unlinked activities"""
+
+ activities: List[UnlinkedActivityData] = []
+
+
+class LinkActivitiesResponse(TimedOperationResponse):
+ """Response after linking activities"""
+
+ linked_count: int = 0
+
+
+# ============ API Handlers ============
+
+
+@api_handler(
+ body=FindUnlinkedActivitiesRequest,
+ method="POST",
+ path="/pomodoro/find-unlinked-activities",
+ tags=["pomodoro"],
+)
+async def find_unlinked_activities(
+ body: FindUnlinkedActivitiesRequest,
+) -> FindUnlinkedActivitiesResponse:
+ """
+ Find activities that overlap with session time but aren't linked
+
+ Returns list of activities that could be retroactively linked
+ """
+ try:
+ db = get_db()
+
+ # Get session
+ session = await db.pomodoro_sessions.get_by_id(body.session_id)
+ if not session:
+ return FindUnlinkedActivitiesResponse(
+ success=False,
+ message=f"Session not found: {body.session_id}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Find overlapping activities
+ overlapping = await db.activities.find_unlinked_overlapping_activities(
+ session_start_time=session["start_time"],
+ session_end_time=session.get("end_time", datetime.now().isoformat()),
+ )
+
+ # Convert to response format
+ activity_data = [
+ UnlinkedActivityData(
+ id=a["id"],
+ title=a["title"],
+ start_time=a["start_time"],
+ end_time=a["end_time"],
+ session_duration_minutes=a.get("session_duration_minutes", 0),
+ )
+ for a in overlapping
+ ]
+
+ logger.debug(
+ f"Found {len(activity_data)} unlinked activities for session {body.session_id}"
+ )
+
+ return FindUnlinkedActivitiesResponse(
+ success=True,
+ message=f"Found {len(activity_data)} unlinked activities",
+ activities=activity_data,
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to find unlinked activities: {e}", exc_info=True)
+ return FindUnlinkedActivitiesResponse(
+ success=False,
+ message=str(e),
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=LinkActivitiesRequest,
+ method="POST",
+ path="/pomodoro/link-activities",
+ tags=["pomodoro"],
+)
+async def link_activities_to_session(
+ body: LinkActivitiesRequest,
+) -> LinkActivitiesResponse:
+ """
+ Link selected activities to a Pomodoro session
+
+ Updates activity records with pomodoro_session_id and auto-categorizes
+ work_phase based on the activity's time period
+ """
+ try:
+ db = get_db()
+
+ # Verify session exists
+ session = await db.pomodoro_sessions.get_by_id(body.session_id)
+ if not session:
+ return LinkActivitiesResponse(
+ success=False,
+ message=f"Session not found: {body.session_id}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Calculate phase timeline to determine work phases
+ phase_timeline = _calculate_phase_timeline_for_linking(session)
+
+ # Link each activity with auto-categorized work phase
+ linked_count = 0
+ for activity_id in body.activity_ids:
+ # Get activity to check its start time
+ activity = await db.activities.get_by_id(activity_id)
+ if not activity:
+ logger.warning(f"Activity {activity_id} not found, skipping")
+ continue
+
+ # Determine work phase based on activity start time
+ work_phase = _determine_work_phase(
+ activity["start_time"], phase_timeline
+ )
+
+ # Link activity with auto-categorized work phase
+ count = await db.activities.link_activities_to_session(
+ activity_ids=[activity_id],
+ session_id=body.session_id,
+ work_phase=work_phase,
+ )
+ linked_count += count
+
+ logger.debug(
+ f"Linked activity {activity_id} to session {body.session_id}, "
+ f"phase: {work_phase or 'unassigned'}"
+ )
+
+ logger.info(
+ f"Linked {linked_count} activities to session {body.session_id} "
+ f"with auto-categorized phases"
+ )
+
+ return LinkActivitiesResponse(
+ success=True,
+ message=f"Successfully linked {linked_count} activities with auto-categorized phases",
+ linked_count=linked_count,
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to link activities: {e}", exc_info=True)
+ return LinkActivitiesResponse(
+ success=False,
+ message=str(e),
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+# ============ Helper Functions ============
+
+
+def _calculate_phase_timeline_for_linking(session: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """
+ Calculate work phase timeline for a session
+
+ Returns only work phases (not breaks) with their time ranges
+ """
+ start_time = datetime.fromisoformat(session["start_time"])
+ work_duration = session.get("work_duration_minutes", 25)
+ break_duration = session.get("break_duration_minutes", 5)
+ completed_rounds = session.get("completed_rounds", 0)
+
+ timeline = []
+ current_time = start_time
+
+ for round_num in range(1, completed_rounds + 1):
+ # Work phase
+ work_end = current_time + timedelta(minutes=work_duration)
+ timeline.append({
+ "phase_number": round_num,
+ "start_time": current_time.isoformat(),
+ "end_time": work_end.isoformat(),
+ })
+ current_time = work_end
+
+ # Add break duration to move to next work phase
+ current_time = current_time + timedelta(minutes=break_duration)
+
+ return timeline
+
+
+def _determine_work_phase(
+ activity_start_time: str, phase_timeline: List[Dict[str, Any]]
+) -> Optional[int]:
+ """
+ Determine which work phase an activity belongs to based on its start time
+
+ Args:
+ activity_start_time: ISO format timestamp of activity start
+ phase_timeline: List of work phase dictionaries with start_time, end_time
+
+ Returns:
+ Work phase number (1-based) or None if activity doesn't fall in any work phase
+ """
+ try:
+ activity_time = datetime.fromisoformat(activity_start_time)
+
+ for phase in phase_timeline:
+ phase_start = datetime.fromisoformat(phase["start_time"])
+ phase_end = datetime.fromisoformat(phase["end_time"])
+
+ # Check if activity starts within this work phase
+ if phase_start <= activity_time <= phase_end:
+ return phase["phase_number"]
+
+ # Activity doesn't fall within any work phase
+ # Assign to nearest work phase
+ nearest_phase = None
+ min_distance = None
+
+ for phase in phase_timeline:
+ phase_start = datetime.fromisoformat(phase["start_time"])
+ phase_end = datetime.fromisoformat(phase["end_time"])
+
+ # Calculate distance from activity to this phase
+ if activity_time < phase_start:
+ distance = (phase_start - activity_time).total_seconds()
+ elif activity_time > phase_end:
+ distance = (activity_time - phase_end).total_seconds()
+ else:
+ # This shouldn't happen as we already checked above
+ return phase["phase_number"]
+
+ if min_distance is None or distance < min_distance:
+ min_distance = distance
+ nearest_phase = phase["phase_number"]
+
+ logger.debug(
+ f"Activity at {activity_start_time} doesn't fall in any work phase, "
+ f"assigning to nearest phase: {nearest_phase}"
+ )
+
+ return nearest_phase
+
+ except Exception as e:
+ logger.error(f"Error determining work phase: {e}", exc_info=True)
+ return None
diff --git a/backend/handlers/pomodoro_phase_endpoints.py b/backend/handlers/pomodoro_phase_endpoints.py
new file mode 100644
index 0000000..6ee0e88
--- /dev/null
+++ b/backend/handlers/pomodoro_phase_endpoints.py
@@ -0,0 +1,210 @@
+"""
+New Pomodoro API endpoints for phase-level retry mechanisms.
+
+These endpoints should be added to backend/handlers/pomodoro.py
+"""
+
+from datetime import datetime
+from typing import List, Optional
+import asyncio
+
+from models.base import BaseModel
+from models.responses import (
+ TimedOperationResponse,
+ WorkPhaseInfo,
+ GetSessionPhasesResponse,
+)
+from . import api_handler
+
+# ==================== Request Models ====================
+
+
+class RetryWorkPhaseRequest(BaseModel):
+ session_id: str
+ work_phase: int
+
+
+class GetSessionPhasesRequest(BaseModel):
+ session_id: str
+
+
+class RetryLLMEvaluationRequest(BaseModel):
+ session_id: str
+
+
+# ==================== API Handlers ====================
+
+
+@api_handler(
+ body=RetryWorkPhaseRequest,
+ method="POST",
+ path="/pomodoro/retry-work-phase",
+ tags=["pomodoro"],
+)
+async def retry_work_phase_aggregation(
+ body: RetryWorkPhaseRequest,
+) -> TimedOperationResponse:
+ """
+ FIXED: Retry work phase aggregation using stored timing.
+
+ Now uses phase_start_time/phase_end_time from phase record
+ instead of hardcoded calculations.
+ """
+ from core.coordinator import get_coordinator
+ from core.db import get_db
+ from core.logger import get_logger
+
+ logger = get_logger(__name__)
+
+ try:
+ coordinator = get_coordinator()
+ db = get_db()
+
+ if not coordinator.pomodoro_manager:
+ return TimedOperationResponse(
+ success=False,
+ error="Pomodoro manager not initialized",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # ★ Get phase record (contains accurate timing) ★
+ phase_record = await db.work_phases.get_by_session_and_phase(
+ body.session_id, body.work_phase
+ )
+
+ if not phase_record:
+ return TimedOperationResponse(
+ success=False,
+ error=f"Phase record not found for session {body.session_id}, phase {body.work_phase}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # ★ Extract timing from phase record (NOT calculated) ★
+ phase_start_time = datetime.fromisoformat(phase_record["phase_start_time"])
+ phase_end_time = datetime.fromisoformat(phase_record["phase_end_time"])
+
+ logger.info(
+ f"Manual retry: session={body.session_id}, phase={body.work_phase}, "
+ f"time_range={phase_start_time.isoformat()} to {phase_end_time.isoformat()}"
+ )
+
+ # Reset status for retry (clear error, reset retry count)
+ await db.work_phases.update_status(phase_record["id"], "pending", None, 0)
+
+ # Trigger aggregation (non-blocking)
+ asyncio.create_task(
+ coordinator.pomodoro_manager._aggregate_work_phase_activities(
+ session_id=body.session_id,
+ work_phase=body.work_phase,
+ phase_start_time=phase_start_time,
+ phase_end_time=phase_end_time,
+ phase_id=phase_record["id"],
+ )
+ )
+
+ return TimedOperationResponse(
+ success=True,
+ message=f"Work phase {body.work_phase} retry triggered successfully",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to retry work phase: {e}", exc_info=True)
+ return TimedOperationResponse(
+ success=False, error=str(e), timestamp=datetime.now().isoformat()
+ )
+
+
+@api_handler(
+ body=GetSessionPhasesRequest,
+ method="POST",
+ path="/pomodoro/get-session-phases",
+ tags=["pomodoro"],
+)
+async def get_session_phases(
+ body: GetSessionPhasesRequest,
+) -> GetSessionPhasesResponse:
+ """
+ Get all work phase records for a session.
+ Used by frontend to display phase status and retry buttons.
+ """
+ from core.db import get_db
+ from core.logger import get_logger
+
+ logger = get_logger(__name__)
+
+ try:
+ db = get_db()
+ phases = await db.work_phases.get_by_session(body.session_id)
+
+ phase_infos = [
+ WorkPhaseInfo(
+ phase_id=p["id"],
+ phase_number=p["phase_number"],
+ status=p["status"],
+ processing_error=p.get("processing_error"),
+ retry_count=p.get("retry_count", 0),
+ phase_start_time=p["phase_start_time"],
+ phase_end_time=p.get("phase_end_time"),
+ activity_count=p.get("activity_count", 0),
+ )
+ for p in phases
+ ]
+
+ return GetSessionPhasesResponse(
+ success=True, data=phase_infos, timestamp=datetime.now().isoformat()
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to get session phases: {e}", exc_info=True)
+ return GetSessionPhasesResponse(
+ success=False, error=str(e), timestamp=datetime.now().isoformat()
+ )
+
+
+@api_handler(
+ body=RetryLLMEvaluationRequest,
+ method="POST",
+ path="/pomodoro/retry-llm-evaluation",
+ tags=["pomodoro"],
+)
+async def retry_llm_evaluation(
+ body: RetryLLMEvaluationRequest,
+) -> TimedOperationResponse:
+ """
+ Manually retry LLM focus evaluation for a session.
+ Independent from phase aggregation retry.
+ """
+ from core.coordinator import get_coordinator
+ from core.logger import get_logger
+
+ logger = get_logger(__name__)
+
+ try:
+ coordinator = get_coordinator()
+
+ if not coordinator.pomodoro_manager:
+ return TimedOperationResponse(
+ success=False,
+ error="Pomodoro manager not initialized",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Trigger LLM evaluation (non-blocking, manual retry)
+ asyncio.create_task(
+ coordinator.pomodoro_manager._compute_and_cache_llm_evaluation(
+ body.session_id, is_first_attempt=False
+ )
+ )
+
+ return TimedOperationResponse(
+ success=True,
+ message="LLM evaluation retry triggered successfully",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to retry LLM evaluation: {e}", exc_info=True)
+ return TimedOperationResponse(
+ success=False, error=str(e), timestamp=datetime.now().isoformat()
+ )
diff --git a/backend/handlers/pomodoro_presets.py b/backend/handlers/pomodoro_presets.py
new file mode 100644
index 0000000..c23ca13
--- /dev/null
+++ b/backend/handlers/pomodoro_presets.py
@@ -0,0 +1,123 @@
+"""
+Pomodoro Configuration Presets - API endpoint for getting preset configurations
+
+Provides predefined Pomodoro configurations for common use cases.
+"""
+
+from datetime import datetime
+from typing import List
+
+from core.logger import get_logger
+from models.base import BaseModel
+from models.responses import TimedOperationResponse
+
+# CRITICAL: Use relative import to avoid circular imports
+from . import api_handler
+
+logger = get_logger(__name__)
+
+
+# ============ Response Models ============
+
+
+class PomodoroPreset(BaseModel):
+ """Pomodoro configuration preset"""
+
+ id: str
+ name: str
+ description: str
+ work_duration_minutes: int
+ break_duration_minutes: int
+ total_rounds: int
+ icon: str = "⏱️"
+
+
+class GetPomodoroPresetsResponse(TimedOperationResponse):
+ """Response with list of Pomodoro presets"""
+
+ data: List[PomodoroPreset] = []
+
+
+# ============ Preset Definitions ============
+
+POMODORO_PRESETS = [
+ PomodoroPreset(
+ id="classic",
+ name="Classic Pomodoro",
+ description="Traditional 25/5 technique - 4 rounds",
+ work_duration_minutes=25,
+ break_duration_minutes=5,
+ total_rounds=4,
+ icon="🍅",
+ ),
+ PomodoroPreset(
+ id="deep-work",
+ name="Deep Work",
+ description="Extended focus sessions - 50/10 for intense work",
+ work_duration_minutes=50,
+ break_duration_minutes=10,
+ total_rounds=3,
+ icon="🎯",
+ ),
+ PomodoroPreset(
+ id="quick-sprint",
+ name="Quick Sprint",
+ description="Short bursts - 15/3 for quick tasks",
+ work_duration_minutes=15,
+ break_duration_minutes=3,
+ total_rounds=6,
+ icon="⚡",
+ ),
+ PomodoroPreset(
+ id="ultra-focus",
+ name="Ultra Focus",
+ description="Maximum concentration - 90/15 for deep thinking",
+ work_duration_minutes=90,
+ break_duration_minutes=15,
+ total_rounds=2,
+ icon="🧠",
+ ),
+ PomodoroPreset(
+ id="balanced",
+ name="Balanced Flow",
+ description="Moderate pace - 40/8 for sustained productivity",
+ work_duration_minutes=40,
+ break_duration_minutes=8,
+ total_rounds=4,
+ icon="⚖️",
+ ),
+]
+
+
+# ============ API Handler ============
+
+
+@api_handler(method="GET", path="/pomodoro/presets", tags=["pomodoro"])
+async def get_pomodoro_presets() -> GetPomodoroPresetsResponse:
+ """
+ Get available Pomodoro configuration presets
+
+ Returns a list of predefined configurations including:
+ - Classic Pomodoro (25/5)
+ - Deep Work (50/10)
+ - Quick Sprint (15/3)
+ - Ultra Focus (90/15)
+ - Balanced Flow (40/8)
+ """
+ try:
+ logger.debug(f"Returning {len(POMODORO_PRESETS)} Pomodoro presets")
+
+ return GetPomodoroPresetsResponse(
+ success=True,
+ message=f"Retrieved {len(POMODORO_PRESETS)} presets",
+ data=POMODORO_PRESETS,
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to get Pomodoro presets: {e}", exc_info=True)
+ return GetPomodoroPresetsResponse(
+ success=False,
+ message=f"Failed to get presets: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
diff --git a/backend/handlers/pomodoro_stats.py b/backend/handlers/pomodoro_stats.py
new file mode 100644
index 0000000..81ed21d
--- /dev/null
+++ b/backend/handlers/pomodoro_stats.py
@@ -0,0 +1,760 @@
+"""
+Pomodoro Statistics Handler - API endpoints for Pomodoro session statistics
+
+Endpoints:
+- POST /pomodoro/stats - Get Pomodoro statistics for a specific date
+- POST /pomodoro/session-detail - Get detailed session data with activities
+- DELETE /pomodoro/sessions/delete - Delete a session and cascade delete activities
+"""
+
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from core.db import get_db
+from core.events import emit_pomodoro_session_deleted
+from core.logger import get_logger
+from llm.focus_evaluator import get_focus_evaluator
+from models.base import BaseModel
+from models.responses import (
+ DeletePomodoroSessionData,
+ DeletePomodoroSessionRequest,
+ DeletePomodoroSessionResponse,
+ FocusMetrics,
+ GetPomodoroSessionDetailRequest,
+ GetPomodoroSessionDetailResponse,
+ LLMFocusAnalysis,
+ LLMFocusDimensionScores,
+ LLMFocusEvaluation,
+ PhaseTimelineItem,
+ PomodoroActivityData,
+ PomodoroSessionData,
+ PomodoroSessionDetailData,
+ TimedOperationResponse,
+)
+
+# CRITICAL: Use relative import to avoid circular imports
+from . import api_handler
+
+logger = get_logger(__name__)
+
+
+# ============ Request Models ============
+
+
+class GetPomodoroStatsRequest(BaseModel):
+ """Request to get Pomodoro statistics for a specific date"""
+
+ date: str # YYYY-MM-DD format
+
+
+class GetPomodoroPeriodStatsRequest(BaseModel):
+ """Request to get Pomodoro statistics for a time period"""
+
+ period: str # "week", "month", or "year"
+ reference_date: Optional[str] = None # YYYY-MM-DD format (defaults to today)
+
+
+# ============ Response Models ============
+
+
+class PomodoroStatsData(BaseModel):
+ """Pomodoro statistics for a specific date"""
+
+ date: str
+ completed_count: int
+ total_focus_minutes: int
+ average_duration_minutes: int
+ sessions: List[Dict[str, Any]] # Recent sessions for the day
+
+
+class GetPomodoroStatsResponse(TimedOperationResponse):
+ """Response with Pomodoro statistics"""
+
+ data: Optional[PomodoroStatsData] = None
+
+
+class DailyFocusData(BaseModel):
+ """Daily focus data for a specific day"""
+
+ day: str # Day label (e.g., "Mon", "周一")
+ date: str # YYYY-MM-DD format
+ sessions: int
+ minutes: int
+
+
+class PomodoroPeriodStatsData(BaseModel):
+ """Pomodoro statistics for a time period"""
+
+ period: str # "week", "month", or "year"
+ start_date: str # YYYY-MM-DD
+ end_date: str # YYYY-MM-DD
+ weekly_total: int # Total sessions in period
+ focus_hours: float # Total focus hours
+ daily_average: float # Average sessions per day
+ completion_rate: int # Percentage of goal completion
+ daily_data: List[DailyFocusData] # Daily breakdown
+
+
+class GetPomodoroPeriodStatsResponse(TimedOperationResponse):
+ """Response with Pomodoro period statistics"""
+
+ data: Optional[PomodoroPeriodStatsData] = None
+
+
+# ============ API Handlers ============
+
+
+@api_handler(
+ body=GetPomodoroStatsRequest,
+ method="POST",
+ path="/pomodoro/stats",
+ tags=["pomodoro"],
+)
+async def get_pomodoro_stats(
+ body: GetPomodoroStatsRequest,
+) -> GetPomodoroStatsResponse:
+ """
+ Get Pomodoro statistics for a specific date
+
+ Returns:
+ - Number of completed sessions
+ - Total focus time (minutes)
+ - Average session duration (minutes)
+ - List of all sessions for that day
+ """
+ try:
+ db = get_db()
+
+ # Validate date format
+ try:
+ datetime.fromisoformat(body.date)
+ except ValueError:
+ return GetPomodoroStatsResponse(
+ success=False,
+ message="Invalid date format. Expected YYYY-MM-DD",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Get daily stats from repository
+ stats = await db.pomodoro_sessions.get_daily_stats(body.date)
+
+ # Optionally fetch associated TODO titles and activity counts for sessions
+ sessions_with_todos = []
+ for session in stats.get("sessions", []):
+ session_data = dict(session)
+
+ # If session has associated_todo_id, fetch TODO title
+ if session_data.get("associated_todo_id"):
+ try:
+ todo = await db.todos.get_by_id(session_data["associated_todo_id"])
+ if todo and not todo.get("deleted"):
+ session_data["associated_todo_title"] = todo.get("title")
+ else:
+ session_data["associated_todo_title"] = None
+ except Exception as e:
+ logger.warning(
+ f"Failed to fetch TODO for session {session_data.get('id')}: {e}"
+ )
+ session_data["associated_todo_title"] = None
+
+ # Use actual_duration_minutes as pure work duration
+ # This reflects actual work time (completed rounds + partial current round if stopped early)
+ session_data["pure_work_duration_minutes"] = session_data.get("actual_duration_minutes", 0)
+
+ # Get activity count for this session
+ session_id = session_data.get("id")
+ if session_id:
+ try:
+ activities = await db.activities.get_by_pomodoro_session(session_id)
+ session_data["activity_count"] = len(activities)
+ except Exception as e:
+ logger.warning(
+ f"Failed to fetch activities for session {session_id}: {e}"
+ )
+ session_data["activity_count"] = 0
+ else:
+ session_data["activity_count"] = 0
+
+ sessions_with_todos.append(session_data)
+
+ logger.debug(
+ f"Retrieved Pomodoro stats for {body.date}: "
+ f"{stats['completed_count']} completed, "
+ f"{stats['total_focus_minutes']} minutes"
+ )
+
+ return GetPomodoroStatsResponse(
+ success=True,
+ message=f"Retrieved statistics for {body.date}",
+ data=PomodoroStatsData(
+ date=body.date,
+ completed_count=stats["completed_count"],
+ total_focus_minutes=stats["total_focus_minutes"],
+ average_duration_minutes=stats["average_duration_minutes"],
+ sessions=sessions_with_todos,
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to get Pomodoro stats: {e}", exc_info=True)
+ return GetPomodoroStatsResponse(
+ success=False,
+ message=f"Failed to get statistics: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=GetPomodoroSessionDetailRequest,
+ method="POST",
+ path="/pomodoro/session-detail",
+ tags=["pomodoro"],
+)
+async def get_pomodoro_session_detail(
+ body: GetPomodoroSessionDetailRequest,
+) -> GetPomodoroSessionDetailResponse:
+ """
+ Get detailed Pomodoro session with activities and focus metrics
+
+ Returns:
+ - Full session data
+ - All activities generated during this session (ordered by work phase)
+ - Calculated focus metrics (overall_focus_score, activity_count, topic_diversity, etc.)
+ """
+ try:
+ db = get_db()
+
+ # Get session
+ session = await db.pomodoro_sessions.get_by_id(body.session_id)
+ if not session:
+ return GetPomodoroSessionDetailResponse(
+ success=False,
+ message=f"Session not found: {body.session_id}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Get activities for this session
+ activities = await db.activities.get_by_pomodoro_session(body.session_id)
+
+ # Convert activities to Pydantic models
+ activity_data_list = [
+ PomodoroActivityData(
+ id=activity["id"],
+ title=activity["title"],
+ description=activity["description"],
+ start_time=activity["start_time"],
+ end_time=activity["end_time"],
+ session_duration_minutes=activity.get("session_duration_minutes") or 0,
+ work_phase=activity.get("pomodoro_work_phase"),
+ focus_score=activity.get("focus_score"),
+ topic_tags=activity.get("topic_tags") or [],
+ source_event_ids=activity.get("source_event_ids") or [],
+ source_action_ids=activity.get("source_action_ids") or [],
+ aggregation_mode=activity.get("aggregation_mode", "action_based"),
+ )
+ for activity in activities
+ ]
+
+ # Calculate focus metrics
+ focus_metrics_dict = _calculate_session_focus_metrics(session, activities)
+ focus_metrics = FocusMetrics(
+ overall_focus_score=focus_metrics_dict["overall_focus_score"],
+ activity_count=focus_metrics_dict["activity_count"],
+ topic_diversity=focus_metrics_dict["topic_diversity"],
+ average_activity_duration=focus_metrics_dict["average_activity_duration"],
+ focus_level=focus_metrics_dict["focus_level"],
+ )
+
+ # Use actual_duration_minutes as pure work duration
+ # This reflects actual work time (completed rounds + partial current round if stopped early)
+ session_with_pure_duration = dict(session)
+ session_with_pure_duration["pure_work_duration_minutes"] = session_with_pure_duration.get("actual_duration_minutes", 0)
+
+ # Calculate phase timeline
+ phase_timeline_raw = await _calculate_phase_timeline(session)
+ phase_timeline = [
+ PhaseTimelineItem(
+ phase_type=phase["phase_type"],
+ phase_number=phase["phase_number"],
+ start_time=phase["start_time"],
+ end_time=phase["end_time"],
+ duration_minutes=phase["duration_minutes"],
+ )
+ for phase in phase_timeline_raw
+ ]
+
+ logger.debug(
+ f"Retrieved session detail for {body.session_id}: "
+ f"{len(activities)} activities, "
+ f"focus score: {focus_metrics.overall_focus_score:.2f}"
+ )
+
+ # LLM-based focus evaluation (cache-first with on-demand fallback)
+ llm_evaluation = None
+ try:
+ # Step 1: Try to load from cache first
+ cached_result = await db.pomodoro_sessions.get_llm_evaluation(body.session_id)
+
+ if cached_result:
+ # Cache hit - use cached result
+ logger.debug(f"Using cached LLM evaluation for {body.session_id}")
+ llm_evaluation = LLMFocusEvaluation(
+ focus_score=cached_result["focus_score"],
+ focus_level=cached_result["focus_level"],
+ dimension_scores=LLMFocusDimensionScores(**cached_result["dimension_scores"]),
+ analysis=LLMFocusAnalysis(**cached_result["analysis"]),
+ work_type=cached_result["work_type"],
+ is_focused_work=cached_result["is_focused_work"],
+ distraction_percentage=cached_result["distraction_percentage"],
+ deep_work_minutes=cached_result["deep_work_minutes"],
+ context_summary=cached_result["context_summary"],
+ )
+ else:
+ # Step 2: Cache miss - compute on-demand (backward compatibility)
+ logger.info(
+ f"Cache miss, computing on-demand LLM evaluation for {body.session_id}"
+ )
+
+ focus_evaluator = get_focus_evaluator()
+ llm_result = await focus_evaluator.evaluate_focus(
+ activities=activities,
+ session_info=session,
+ )
+
+ # Convert to Pydantic model
+ llm_evaluation = LLMFocusEvaluation(
+ focus_score=llm_result["focus_score"],
+ focus_level=llm_result["focus_level"],
+ dimension_scores=LLMFocusDimensionScores(**llm_result["dimension_scores"]),
+ analysis=LLMFocusAnalysis(**llm_result["analysis"]),
+ work_type=llm_result["work_type"],
+ is_focused_work=llm_result["is_focused_work"],
+ distraction_percentage=llm_result["distraction_percentage"],
+ deep_work_minutes=llm_result["deep_work_minutes"],
+ context_summary=llm_result["context_summary"],
+ )
+
+ # Step 3: Cache the result for future requests
+ try:
+ await db.pomodoro_sessions.update_llm_evaluation(
+ body.session_id, llm_result
+ )
+ logger.info(f"Cached on-demand evaluation for {body.session_id}")
+ except Exception as cache_error:
+ logger.warning(
+ f"Failed to cache on-demand evaluation: {cache_error}"
+ )
+ # Continue - caching failure doesn't affect response
+
+ logger.info(
+ f"LLM focus evaluation completed for {body.session_id}: "
+ f"score={llm_evaluation.focus_score}, level={llm_evaluation.focus_level}"
+ )
+
+ except Exception as e:
+ logger.warning(
+ f"LLM focus evaluation failed for {body.session_id}: {e}. "
+ f"Continuing with basic metrics only."
+ )
+ # Continue without LLM evaluation - it's optional
+
+ return GetPomodoroSessionDetailResponse(
+ success=True,
+ message="Session details retrieved",
+ data=PomodoroSessionDetailData(
+ session=session_with_pure_duration,
+ activities=activity_data_list,
+ focus_metrics=focus_metrics,
+ llm_focus_evaluation=llm_evaluation,
+ phase_timeline=phase_timeline,
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Failed to get session detail for {body.session_id}: {e}",
+ exc_info=True,
+ )
+ return GetPomodoroSessionDetailResponse(
+ success=False,
+ message=f"Failed to get session details: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+# ============ Helper Functions ============
+
+
+def _calculate_session_focus_metrics(
+ session: Dict[str, Any], activities: List[Dict[str, Any]]
+) -> Dict[str, Any]:
+ """
+ Calculate session-level focus metrics
+
+ Metrics:
+ - overall_focus_score: Weighted average of activity focus scores (by duration)
+ - activity_count: Number of activities in session
+ - topic_diversity: Number of unique topics across all activities
+ - average_activity_duration: Average duration per activity (minutes)
+ - focus_level: Human-readable level (excellent/good/moderate/low)
+
+ Args:
+ session: Session dictionary
+ activities: List of activity dictionaries
+
+ Returns:
+ Dictionary with calculated metrics
+ """
+ if not activities:
+ return {
+ "overall_focus_score": 0.0,
+ "activity_count": 0,
+ "topic_diversity": 0,
+ "average_activity_duration": 0,
+ "focus_level": "low",
+ }
+
+ # Calculate weighted average focus score (weighted by activity duration)
+ total_duration = sum(
+ activity.get("session_duration_minutes") or 0 for activity in activities
+ )
+
+ if total_duration > 0:
+ weighted_score = sum(
+ (activity.get("focus_score") or 0.5)
+ * (activity.get("session_duration_minutes") or 0)
+ for activity in activities
+ ) / total_duration
+ else:
+ # If no duration info, use simple average
+ weighted_score = sum(
+ activity.get("focus_score") or 0.5 for activity in activities
+ ) / len(activities)
+
+ # Calculate topic diversity
+ all_topics = set()
+ for activity in activities:
+ all_topics.update(activity.get("topic_tags") or [])
+
+ # Calculate average activity duration
+ average_duration = (
+ total_duration / len(activities) if len(activities) > 0 else 0
+ )
+
+ # Map score to focus level
+ focus_level = _get_focus_level(weighted_score)
+
+ return {
+ "overall_focus_score": round(weighted_score, 2),
+ "activity_count": len(activities),
+ "topic_diversity": len(all_topics),
+ "average_activity_duration": round(average_duration, 1),
+ "focus_level": focus_level,
+ }
+
+
+def _get_focus_level(score: float) -> str:
+ """
+ Map focus score to human-readable level
+
+ Args:
+ score: Focus score (0.0-1.0)
+
+ Returns:
+ Focus level: "excellent", "good", "moderate", or "low"
+ """
+ if score >= 0.8:
+ return "excellent"
+ elif score >= 0.6:
+ return "good"
+ elif score >= 0.4:
+ return "moderate"
+ else:
+ return "low"
+
+
+async def _calculate_phase_timeline(session: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """
+ Reconstruct work/break phase timeline from session data
+
+ Calculates the timeline based on completed_rounds and actual session duration.
+ For the last round, uses actual end_time instead of configured duration.
+
+ Args:
+ session: Session dictionary with metadata
+
+ Returns:
+ List of phase dictionaries with start_time, end_time, phase_type, phase_number
+ """
+ from datetime import timedelta
+
+ start_time = datetime.fromisoformat(session["start_time"])
+ end_time_str = session.get("end_time")
+ work_duration = session.get("work_duration_minutes", 25)
+ break_duration = session.get("break_duration_minutes", 5)
+ completed_rounds = session.get("completed_rounds", 0)
+ total_rounds = session.get("total_rounds", 4)
+ status = session.get("status", "active")
+
+ if completed_rounds == 0:
+ return []
+
+ timeline = []
+ current_time = start_time
+
+ # Calculate timeline for all completed rounds
+ for round_num in range(1, completed_rounds + 1):
+ is_last_round = (round_num == completed_rounds)
+
+ # Work phase
+ if is_last_round and end_time_str:
+ # Last round - use actual end_time
+ work_end = datetime.fromisoformat(end_time_str)
+ duration = int((work_end - current_time).total_seconds() / 60)
+ else:
+ # Complete round - use configured duration
+ work_end = current_time + timedelta(minutes=work_duration)
+ duration = work_duration
+
+ timeline.append({
+ "phase_type": "work",
+ "phase_number": round_num,
+ "start_time": current_time.isoformat(),
+ "end_time": work_end.isoformat(),
+ "duration_minutes": duration,
+ })
+ current_time = work_end
+
+ # Break phase - only add if NOT the last completed round
+ # (i.e., there's another work round after this one)
+ should_add_break = not is_last_round
+
+ if should_add_break:
+ break_end = current_time + timedelta(minutes=break_duration)
+ timeline.append({
+ "phase_type": "break",
+ "phase_number": round_num,
+ "start_time": current_time.isoformat(),
+ "end_time": break_end.isoformat(),
+ "duration_minutes": break_duration,
+ })
+ current_time = break_end
+
+ return timeline
+
+
+@api_handler(
+ body=GetPomodoroPeriodStatsRequest,
+ method="POST",
+ path="/pomodoro/period-stats",
+ tags=["pomodoro"],
+)
+async def get_pomodoro_period_stats(
+ body: GetPomodoroPeriodStatsRequest,
+) -> GetPomodoroPeriodStatsResponse:
+ """
+ Get Pomodoro statistics for a time period (week/month/year)
+
+ Returns:
+ - Period summary statistics (total sessions, focus hours, daily average, completion rate)
+ - Daily breakdown data for visualization
+ """
+ try:
+ from datetime import timedelta
+
+ db = get_db()
+
+ # Get reference date (default to today)
+ if body.reference_date:
+ try:
+ reference_date = datetime.fromisoformat(body.reference_date).date()
+ except ValueError:
+ return GetPomodoroPeriodStatsResponse(
+ success=False,
+ message="Invalid reference_date format. Expected YYYY-MM-DD",
+ timestamp=datetime.now().isoformat(),
+ )
+ else:
+ reference_date = datetime.now().date()
+
+ # Calculate period range
+ if body.period == "week":
+ # Last 7 days including today
+ start_date = reference_date - timedelta(days=6)
+ end_date = reference_date
+ daily_count = 7
+ elif body.period == "month":
+ # Last 30 days
+ start_date = reference_date - timedelta(days=29)
+ end_date = reference_date
+ daily_count = 30
+ elif body.period == "year":
+ # Last 365 days
+ start_date = reference_date - timedelta(days=364)
+ end_date = reference_date
+ daily_count = 365
+ else:
+ return GetPomodoroPeriodStatsResponse(
+ success=False,
+ message=f"Invalid period: {body.period}. Must be 'week', 'month', or 'year'",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # Fetch daily stats for the entire period
+ daily_data = []
+ total_sessions = 0
+ total_minutes = 0
+
+ current_date = start_date
+ while current_date <= end_date:
+ date_str = current_date.isoformat()
+ day_stats = await db.pomodoro_sessions.get_daily_stats(date_str)
+
+ # Get day label (weekday name)
+ day_label = current_date.strftime("%a") # Mon, Tue, etc.
+
+ daily_data.append(
+ DailyFocusData(
+ day=day_label,
+ date=date_str,
+ sessions=day_stats["completed_count"],
+ minutes=day_stats["total_focus_minutes"],
+ )
+ )
+
+ total_sessions += day_stats["completed_count"]
+ total_minutes += day_stats["total_focus_minutes"]
+
+ current_date += timedelta(days=1)
+
+ # Calculate summary statistics
+ focus_hours = round(total_minutes / 60, 1)
+ daily_average = round(total_sessions / daily_count, 1)
+
+ # Calculate completion rate based on user's focus time goal
+ from core.settings import get_settings
+
+ settings = get_settings()
+ goals = settings.get_pomodoro_goal_settings()
+
+ # Determine goal based on period
+ if body.period == "week":
+ goal_minutes = goals["weekly_focus_goal_minutes"]
+ elif body.period == "month":
+ # Pro-rate weekly goal to monthly (assume 4.3 weeks/month)
+ goal_minutes = int(goals["weekly_focus_goal_minutes"] * 4.3)
+ elif body.period == "year":
+ # Pro-rate weekly goal to yearly (52 weeks/year)
+ goal_minutes = goals["weekly_focus_goal_minutes"] * 52
+ else:
+ goal_minutes = goals["daily_focus_goal_minutes"]
+
+ # Calculate completion rate based on total focus time (not session count)
+ completion_rate = min(100, int((total_minutes / goal_minutes) * 100)) if goal_minutes > 0 else 0
+
+ logger.debug(
+ f"Retrieved Pomodoro period stats for {body.period}: "
+ f"{total_sessions} sessions, {focus_hours} hours"
+ )
+
+ return GetPomodoroPeriodStatsResponse(
+ success=True,
+ message=f"Retrieved statistics for {body.period}",
+ data=PomodoroPeriodStatsData(
+ period=body.period,
+ start_date=start_date.isoformat(),
+ end_date=end_date.isoformat(),
+ weekly_total=total_sessions,
+ focus_hours=focus_hours,
+ daily_average=daily_average,
+ completion_rate=completion_rate,
+ daily_data=daily_data,
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to get Pomodoro period stats: {e}", exc_info=True)
+ return GetPomodoroPeriodStatsResponse(
+ success=False,
+ message=f"Failed to get period statistics: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
+
+
+@api_handler(
+ body=DeletePomodoroSessionRequest,
+ method="DELETE",
+ path="/pomodoro/sessions/delete",
+ tags=["pomodoro"],
+)
+async def delete_pomodoro_session(
+ body: DeletePomodoroSessionRequest,
+) -> DeletePomodoroSessionResponse:
+ """
+ Delete a Pomodoro session and cascade delete all linked activities
+
+ This operation:
+ 1. Validates session exists and is not already deleted
+ 2. Soft deletes all activities linked to this session (cascade)
+ 3. Soft deletes the session itself
+ 4. Emits deletion event to notify frontend
+
+ Args:
+ body: Request containing session_id
+
+ Returns:
+ Response with deletion result and count of cascade-deleted activities
+ """
+ try:
+ db = get_db()
+
+ # Validate session exists and is not deleted
+ session = await db.pomodoro_sessions.get_by_id(body.session_id)
+ if not session:
+ return DeletePomodoroSessionResponse(
+ success=False,
+ error="Session not found or already deleted",
+ timestamp=datetime.now().isoformat(),
+ )
+
+ # CASCADE: Soft delete all activities linked to this session
+ deleted_activities_count = await db.activities.delete_by_session_id(
+ body.session_id
+ )
+
+ # Soft delete the session
+ await db.pomodoro_sessions.soft_delete(body.session_id)
+
+ # Emit deletion event to frontend
+ emit_pomodoro_session_deleted(
+ body.session_id, datetime.now().isoformat()
+ )
+
+ logger.info(
+ f"Deleted Pomodoro session {body.session_id} "
+ f"and cascade deleted {deleted_activities_count} activities"
+ )
+
+ return DeletePomodoroSessionResponse(
+ success=True,
+ message=f"Session deleted successfully. {deleted_activities_count} linked activities also removed.",
+ data=DeletePomodoroSessionData(
+ session_id=body.session_id,
+ deleted_activities_count=deleted_activities_count,
+ ),
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Failed to delete Pomodoro session {body.session_id}: {e}",
+ exc_info=True,
+ )
+ return DeletePomodoroSessionResponse(
+ success=False,
+ error=f"Failed to delete session: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
diff --git a/backend/handlers/resources.py b/backend/handlers/resources.py
index 746dcf1..46b4409 100644
--- a/backend/handlers/resources.py
+++ b/backend/handlers/resources.py
@@ -21,6 +21,7 @@
from core.settings import get_settings
from models.base import OperationResponse, TimedOperationResponse
from models.requests import (
+ CleanupBrokenActionsRequest,
CleanupImagesRequest,
CreateModelRequest,
DeleteModelRequest,
@@ -36,10 +37,14 @@
)
from models.responses import (
CachedImagesResponse,
+ CleanupBrokenActionsResponse,
CleanupImagesResponse,
+ CleanupSoftDeletedResponse,
ClearMemoryCacheResponse,
ImageOptimizationConfigResponse,
ImageOptimizationStatsResponse,
+ ImagePersistenceHealthData,
+ ImagePersistenceHealthResponse,
ImageStatsResponse,
ReadImageFileResponse,
UpdateImageOptimizationConfigResponse,
@@ -373,6 +378,231 @@ async def read_image_file(body: ReadImageFileRequest) -> ReadImageFileResponse:
return ReadImageFileResponse(success=False, error=str(e))
+@api_handler(
+ body=None, method="GET", path="/image/persistence-health", tags=["image"]
+)
+async def check_image_persistence_health() -> ImagePersistenceHealthResponse:
+ """
+ Check health of image persistence system
+
+ Analyzes all actions with screenshots to determine how many have missing
+ image files on disk. Provides statistics for diagnostics.
+
+ Returns:
+ Health check results with statistics
+ """
+ try:
+ db = get_db()
+ image_manager = get_image_manager()
+
+ # Get all actions with screenshots (limit to 1000 for performance)
+ actions = await db.actions.get_all_actions_with_screenshots(limit=1000)
+
+ total_actions = len(actions)
+ actions_all_ok = 0
+ actions_partial_missing = 0
+ actions_all_missing = 0
+ total_references = 0
+ images_found = 0
+ images_missing = 0
+ actions_with_issues = []
+
+ for action in actions:
+ screenshots = action.get("screenshots", [])
+ if not screenshots:
+ continue
+
+ total_references += len(screenshots)
+ missing_hashes = []
+
+ # Check each screenshot
+ for img_hash in screenshots:
+ thumbnail_path = image_manager.thumbnails_dir / f"{img_hash}.jpg"
+ if thumbnail_path.exists():
+ images_found += 1
+ else:
+ images_missing += 1
+ missing_hashes.append(img_hash)
+
+ # Classify action based on missing images
+ if not missing_hashes:
+ actions_all_ok += 1
+ elif len(missing_hashes) == len(screenshots):
+ actions_all_missing += 1
+ # Sample first 10 actions with all images missing
+ if len(actions_with_issues) < 10:
+ actions_with_issues.append({
+ "id": action["id"],
+ "created_at": action["created_at"],
+ "total_screenshots": len(screenshots),
+ "missing_screenshots": len(missing_hashes),
+ "status": "all_missing",
+ })
+ else:
+ actions_partial_missing += 1
+ # Sample first 10 actions with partial missing
+ if len(actions_with_issues) < 10:
+ actions_with_issues.append({
+ "id": action["id"],
+ "created_at": action["created_at"],
+ "total_screenshots": len(screenshots),
+ "missing_screenshots": len(missing_hashes),
+ "status": "partial_missing",
+ })
+
+ # Calculate missing rate
+ missing_rate = (
+ (images_missing / total_references * 100) if total_references > 0 else 0.0
+ )
+
+ # Get cache stats
+ cache_stats = image_manager.get_stats()
+
+ data = ImagePersistenceHealthData(
+ total_actions=total_actions,
+ actions_with_screenshots=total_actions,
+ actions_all_images_ok=actions_all_ok,
+ actions_partial_missing=actions_partial_missing,
+ actions_all_missing=actions_all_missing,
+ total_image_references=total_references,
+ images_found=images_found,
+ images_missing=images_missing,
+ missing_rate_percent=round(missing_rate, 2),
+ memory_cache_current_size=cache_stats.get("cache_count", 0),
+ memory_cache_max_size=cache_stats.get("cache_limit", 0),
+ memory_ttl_seconds=cache_stats.get("memory_ttl", 0),
+ actions_with_issues=actions_with_issues,
+ )
+
+ logger.info(
+ f"Image persistence health check: {images_missing}/{total_references} images missing "
+ f"({missing_rate:.2f}%), {actions_all_missing} actions with all images missing"
+ )
+
+ return ImagePersistenceHealthResponse(
+ success=True,
+ message=f"Health check completed: {missing_rate:.2f}% images missing",
+ data=data,
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to check image persistence health: {e}", exc_info=True)
+ return ImagePersistenceHealthResponse(success=False, error=str(e))
+
+
+@api_handler(
+ body=CleanupBrokenActionsRequest,
+ method="POST",
+ path="/image/cleanup-broken-actions",
+ tags=["image"],
+)
+async def cleanup_broken_action_images(
+ body: CleanupBrokenActionsRequest,
+) -> CleanupBrokenActionsResponse:
+ """
+ Clean up actions with missing image references
+
+ Supports three strategies:
+ - delete_actions: Soft-delete actions with all images missing
+ - remove_references: Clear image references, keep action metadata
+ - dry_run: Report what would be cleaned without making changes
+
+ Args:
+ body: Cleanup request with strategy and optional action IDs
+
+ Returns:
+ Cleanup results with statistics
+ """
+ try:
+ db = get_db()
+ image_manager = get_image_manager()
+
+ # Get actions to process
+ if body.action_ids:
+ # Process specific actions
+ actions = []
+ for action_id in body.action_ids:
+ action = await db.actions.get_by_id(action_id)
+ if action:
+ actions.append(action)
+ else:
+ # Process all actions with screenshots
+ actions = await db.actions.get_all_actions_with_screenshots(limit=10000)
+
+ actions_processed = 0
+ actions_deleted = 0
+ references_removed = 0
+
+ for action in actions:
+ screenshots = action.get("screenshots", [])
+ if not screenshots:
+ continue
+
+ # Check which images are missing
+ missing_hashes = []
+ for img_hash in screenshots:
+ thumbnail_path = image_manager.thumbnails_dir / f"{img_hash}.jpg"
+ if not thumbnail_path.exists():
+ missing_hashes.append(img_hash)
+
+ if not missing_hashes:
+ continue # All images present
+
+ actions_processed += 1
+ all_missing = len(missing_hashes) == len(screenshots)
+
+ if body.strategy == "delete_actions" and all_missing:
+ # Only delete if all images are missing
+ logger.info(
+ f"Deleted action {action['id']} with {len(screenshots)} missing images"
+ )
+ await db.actions.delete(action["id"])
+ actions_deleted += 1
+
+ elif body.strategy == "remove_references":
+ # Remove screenshot references
+ logger.info(
+ f"Removed screenshot references from action {action['id']}"
+ )
+ removed = await db.actions.remove_screenshots(action["id"])
+ references_removed += removed
+
+ elif body.strategy == "dry_run":
+ # Dry run - just log what would be done
+ if all_missing:
+ logger.info(
+ f"[DRY RUN] Would delete action {action['id']} "
+ f"with {len(screenshots)} missing images"
+ )
+ else:
+ logger.info(
+ f"[DRY RUN] Would remove {len(missing_hashes)} "
+ f"screenshot references from action {action['id']}"
+ )
+
+ message = f"Cleanup completed ({body.strategy}): "
+ if body.strategy == "delete_actions":
+ message += f"deleted {actions_deleted} actions"
+ elif body.strategy == "remove_references":
+ message += f"removed {references_removed} references"
+ else: # dry_run
+ message += f"would process {actions_processed} actions"
+
+ logger.info(message)
+
+ return CleanupBrokenActionsResponse(
+ success=True,
+ message=message,
+ actions_processed=actions_processed,
+ actions_deleted=actions_deleted,
+ references_removed=references_removed,
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to cleanup broken actions: {e}", exc_info=True)
+ return CleanupBrokenActionsResponse(success=False, error=str(e))
+
+
# ============================================================================
# Model Management
# ============================================================================
@@ -1127,3 +1357,51 @@ async def get_llm_usage_trend(
message=f"Failed to get LLM usage trend: {str(e)}",
timestamp=datetime.now().isoformat(),
)
+
+
+# ============ Soft-Deleted Items Cleanup ============
+
+
+@api_handler(
+ method="POST",
+ path="/resources/cleanup-soft-deleted",
+ tags=["resources"],
+ summary="Permanently delete soft-deleted items",
+ description="Permanently delete all soft-deleted items (todos, knowledge, etc.) from the database",
+)
+async def cleanup_soft_deleted_items() -> CleanupSoftDeletedResponse:
+ """Permanently delete all soft-deleted items
+
+ This cleanup operation permanently removes items that have been
+ soft-deleted (deleted = 1) from the database.
+
+ Currently supports:
+ - Todos
+
+ @returns Cleanup result with counts for each item type
+ """
+ try:
+ db = get_db()
+
+ results: Dict[str, int] = {}
+
+ # Clean up soft-deleted todos
+ todos_deleted = await db.todos.delete_soft_deleted_permanent()
+ results["todos"] = todos_deleted
+
+ total_deleted = sum(results.values())
+
+ return CleanupSoftDeletedResponse(
+ success=True,
+ message=f"Permanently deleted {total_deleted} soft-deleted items",
+ data=results,
+ timestamp=datetime.now().isoformat(),
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to cleanup soft-deleted items: {e}", exc_info=True)
+ return CleanupSoftDeletedResponse(
+ success=False,
+ message=f"Failed to cleanup soft-deleted items: {str(e)}",
+ timestamp=datetime.now().isoformat(),
+ )
diff --git a/backend/handlers/system.py b/backend/handlers/system.py
index 56a46ba..f80803f 100644
--- a/backend/handlers/system.py
+++ b/backend/handlers/system.py
@@ -176,6 +176,10 @@ async def get_settings_info() -> GetSettingsInfoResponse:
settings = get_settings()
all_settings = settings.get_all()
+ # Get voice and clock settings
+ voice_settings = settings.get_voice_settings()
+ clock_settings = settings.get_clock_settings()
+
return GetSettingsInfoResponse(
success=True,
data=SettingsInfoData(
@@ -183,6 +187,23 @@ async def get_settings_info() -> GetSettingsInfoResponse:
database={"path": settings.get_database_path()},
screenshot={"savePath": settings.get_screenshot_path()},
language=settings.get_language(),
+ font_size=settings.get_font_size(),
+ voice={
+ "enabled": voice_settings["enabled"],
+ "volume": voice_settings["volume"],
+ "soundTheme": voice_settings.get("sound_theme", "8bit"),
+ "customSounds": voice_settings.get("custom_sounds")
+ },
+ clock={
+ "enabled": clock_settings["enabled"],
+ "position": clock_settings["position"],
+ "size": clock_settings["size"],
+ "customX": clock_settings.get("custom_x"),
+ "customY": clock_settings.get("custom_y"),
+ "customWidth": clock_settings.get("custom_width"),
+ "customHeight": clock_settings.get("custom_height"),
+ "useCustomPosition": clock_settings.get("use_custom_position", False)
+ },
image={
"memoryCacheSize": int(settings.get("image.memory_cache_size", 500))
},
@@ -231,6 +252,81 @@ async def update_settings(body: UpdateSettingsRequest) -> UpdateSettingsResponse
timestamp=timestamp,
)
+ # Update font size
+ if body.font_size:
+ if not settings.set_font_size(body.font_size):
+ return UpdateSettingsResponse(
+ success=False,
+ message="Failed to update font size. Must be 'small', 'default', 'large', or 'extra-large'",
+ timestamp=timestamp,
+ )
+
+ # Update notification sound settings (kept as voice for backward compatibility)
+ if (
+ body.voice_enabled is not None
+ or body.voice_volume is not None
+ or body.voice_sound_theme is not None
+ or body.voice_custom_sounds is not None
+ ):
+ voice_updates = {}
+ if body.voice_enabled is not None:
+ voice_updates["enabled"] = body.voice_enabled
+ if body.voice_volume is not None:
+ voice_updates["volume"] = body.voice_volume
+ if body.voice_sound_theme is not None:
+ voice_updates["sound_theme"] = body.voice_sound_theme
+ if body.voice_custom_sounds is not None:
+ voice_updates["custom_sounds"] = body.voice_custom_sounds
+
+ try:
+ settings.update_voice_settings(voice_updates)
+ except Exception as e:
+ logger.error(f"Failed to update notification sound settings: {e}")
+ return UpdateSettingsResponse(
+ success=False,
+ message=f"Failed to update voice settings: {str(e)}",
+ timestamp=timestamp,
+ )
+
+ # Update clock settings
+ if (
+ body.clock_enabled is not None
+ or body.clock_position is not None
+ or body.clock_size is not None
+ or body.clock_custom_x is not None
+ or body.clock_custom_y is not None
+ or body.clock_custom_width is not None
+ or body.clock_custom_height is not None
+ or body.clock_use_custom_position is not None
+ ):
+ clock_updates = {}
+ if body.clock_enabled is not None:
+ clock_updates["enabled"] = body.clock_enabled
+ if body.clock_position is not None:
+ clock_updates["position"] = body.clock_position
+ if body.clock_size is not None:
+ clock_updates["size"] = body.clock_size
+ if body.clock_custom_x is not None:
+ clock_updates["custom_x"] = body.clock_custom_x
+ if body.clock_custom_y is not None:
+ clock_updates["custom_y"] = body.clock_custom_y
+ if body.clock_custom_width is not None:
+ clock_updates["custom_width"] = body.clock_custom_width
+ if body.clock_custom_height is not None:
+ clock_updates["custom_height"] = body.clock_custom_height
+ if body.clock_use_custom_position is not None:
+ clock_updates["use_custom_position"] = body.clock_use_custom_position
+
+ try:
+ settings.update_clock_settings(clock_updates)
+ except Exception as e:
+ logger.error(f"Failed to update clock settings: {e}")
+ return UpdateSettingsResponse(
+ success=False,
+ message=f"Failed to update clock settings: {str(e)}",
+ timestamp=timestamp,
+ )
+
return UpdateSettingsResponse(
success=True,
message="Configuration updated successfully",
@@ -437,14 +533,16 @@ async def check_initial_setup() -> CheckInitialSetupResponse:
has_completed_setup = (setup_completed_str or "false").lower() in ("true", "1", "yes")
# Determine if setup is needed
- # Setup is required if user hasn't completed setup AND there are no models configured
- needs_setup = not has_completed_setup and not has_models
+ # IMPORTANT: Setup is required if there are no models configured,
+ # regardless of has_completed_setup status.
+ # This ensures that if user deletes their models/config, they'll see the setup again.
+ needs_setup = not has_models
logger.debug(
f"Initial setup check: has_models={has_models}, "
f"has_active_model={has_active_model}, "
f"has_completed_setup={has_completed_setup}, "
- f"needs_setup={needs_setup}"
+ f"needs_setup={needs_setup} (always true when no models)"
)
return CheckInitialSetupResponse(
diff --git a/backend/llm/focus_evaluator.py b/backend/llm/focus_evaluator.py
new file mode 100644
index 0000000..be692bf
--- /dev/null
+++ b/backend/llm/focus_evaluator.py
@@ -0,0 +1,529 @@
+"""
+LLM-based Focus Score Evaluator
+
+Provides intelligent focus score evaluation using LLM instead of hardcoded rules.
+"""
+
+import json
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from core.logger import get_logger
+
+from .manager import get_llm_manager
+from .prompt_manager import get_prompt_manager
+
+logger = get_logger(__name__)
+
+
+class FocusEvaluator:
+ """LLM-based focus score evaluator"""
+
+ def __init__(self):
+ self.llm_manager = get_llm_manager()
+ self.prompt_manager = get_prompt_manager()
+
+ def _format_activities_detail(self, activities: List[Dict[str, Any]]) -> str:
+ """
+ Format activities into human-readable detail text
+
+ Args:
+ activities: List of activity dictionaries
+
+ Returns:
+ Formatted activities detail string
+ """
+ if not activities:
+ return "No activities recorded"
+
+ lines = []
+ for i, activity in enumerate(activities, 1):
+ title = activity.get("title", "Untitled Activity")
+ description = activity.get("description", "")
+ duration = activity.get("session_duration_minutes", 0)
+ start_time = activity.get("start_time", "")
+ end_time = activity.get("end_time", "")
+ topics = activity.get("topic_tags", [])
+ action_count = len(activity.get("source_action_ids", []))
+
+ lines.append(f"\n### Activity {i}")
+ lines.append(f"**Title**: {title}")
+ if description:
+ lines.append(f"**Description**: {description}")
+ lines.append(f"**Time**: {start_time} - {end_time}")
+ lines.append(f"**Duration**: {duration:.1f} minutes")
+ lines.append(f"**Action Count**: {action_count}")
+ if topics:
+ lines.append(f"**Topics**: {', '.join(topics)}")
+
+ return "\n".join(lines)
+
+ def _collect_all_topics(self, activities: List[Dict[str, Any]]) -> List[str]:
+ """
+ Collect all unique topic tags from activities
+
+ Args:
+ activities: List of activity dictionaries
+
+ Returns:
+ List of unique topic tags
+ """
+ all_topics = set()
+ for activity in activities:
+ topics = activity.get("topic_tags", [])
+ all_topics.update(topics)
+ return sorted(list(all_topics))
+
+ async def evaluate_focus(
+ self,
+ activities: List[Dict[str, Any]],
+ session_info: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ """
+ Evaluate focus score using LLM
+
+ Args:
+ activities: List of activity dictionaries from a work session
+ session_info: Optional session metadata (start_time, end_time, etc.)
+
+ Returns:
+ Dictionary containing:
+ - focus_score: 0-100 integer score
+ - focus_level: "excellent" | "good" | "moderate" | "low"
+ - dimension_scores: Dict of 5 dimension scores
+ - analysis: Dict with strengths, weaknesses, suggestions
+ - work_type: Type of work
+ - is_focused_work: Boolean
+ - distraction_percentage: 0-100
+ - deep_work_minutes: Float
+ - context_summary: String summary
+ """
+ if not activities:
+ logger.warning("No activities provided for focus evaluation")
+ return self._get_default_evaluation()
+
+ # Calculate session metadata
+ total_duration = sum(
+ activity.get("session_duration_minutes", 0) for activity in activities
+ )
+ activity_count = len(activities)
+ all_topics = self._collect_all_topics(activities)
+
+ # Determine session time range
+ if session_info:
+ start_time = session_info.get("start_time", "")
+ end_time = session_info.get("end_time", "")
+ else:
+ # Extract from activities
+ start_times = [a.get("start_time") for a in activities if a.get("start_time")]
+ end_times = [a.get("end_time") for a in activities if a.get("end_time")]
+ start_time = min(start_times) if start_times else ""
+ end_time = max(end_times) if end_times else ""
+
+ # Format activities detail
+ activities_detail = self._format_activities_detail(activities)
+
+ # Get prompt template
+ try:
+ user_prompt_template = self.prompt_manager.get_prompt(
+ "focus_score_evaluation", "user_prompt_template"
+ )
+ system_prompt = self.prompt_manager.get_prompt(
+ "focus_score_evaluation", "system_prompt"
+ )
+ except Exception as e:
+ logger.error(f"Failed to load focus evaluation prompts: {e}")
+ return self._get_default_evaluation()
+
+ # Fill in prompt template
+ user_prompt = user_prompt_template.format(
+ start_time=start_time,
+ end_time=end_time,
+ total_duration=f"{total_duration:.1f}",
+ activity_count=activity_count,
+ topic_tags=", ".join(all_topics) if all_topics else "None",
+ activities_detail=activities_detail,
+ )
+
+ # Call LLM
+ try:
+ messages = [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ]
+
+ response = await self.llm_manager.chat_completion(
+ messages,
+ max_tokens=1500,
+ temperature=0.3, # Lower temperature for more consistent evaluation
+ request_type="focus_evaluation",
+ )
+
+ content = response.get("content", "")
+
+ # Parse JSON response
+ evaluation = self._parse_llm_response(content)
+
+ # Validate and normalize the evaluation
+ evaluation = self._validate_evaluation(evaluation)
+
+ logger.info(
+ f"LLM focus evaluation completed: score={evaluation.get('focus_score')}, "
+ f"level={evaluation.get('focus_level')}"
+ )
+
+ return evaluation
+
+ except Exception as e:
+ logger.error(f"LLM focus evaluation failed: {e}", exc_info=True)
+ return self._get_default_evaluation()
+
+ def _parse_llm_response(self, content: str) -> Dict[str, Any]:
+ """
+ Parse LLM JSON response
+
+ Args:
+ content: LLM response content
+
+ Returns:
+ Parsed evaluation dict
+ """
+ # Try to extract JSON from markdown code blocks
+ if "```json" in content:
+ start = content.find("```json") + 7
+ end = content.find("```", start)
+ json_str = content[start:end].strip()
+ elif "```" in content:
+ start = content.find("```") + 3
+ end = content.find("```", start)
+ json_str = content[start:end].strip()
+ else:
+ json_str = content.strip()
+
+ # Parse JSON
+ try:
+ evaluation = json.loads(json_str)
+ return evaluation
+ except json.JSONDecodeError as e:
+ logger.error(f"Failed to parse LLM JSON response: {e}\nContent: {content}")
+ raise
+
+ def _validate_evaluation(self, evaluation: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Validate and normalize evaluation result
+
+ Args:
+ evaluation: Raw evaluation dict from LLM
+
+ Returns:
+ Validated and normalized evaluation dict
+ """
+ # Ensure focus_score is 0-100 integer
+ focus_score = int(evaluation.get("focus_score", 50))
+ focus_score = max(0, min(100, focus_score))
+ evaluation["focus_score"] = focus_score
+
+ # Normalize focus_level based on score
+ score_to_level = {
+ (80, 100): "excellent",
+ (60, 79): "good",
+ (40, 59): "moderate",
+ (0, 39): "low",
+ }
+ for (min_score, max_score), level in score_to_level.items():
+ if min_score <= focus_score <= max_score:
+ evaluation["focus_level"] = level
+ break
+
+ # Ensure dimension_scores exist and are valid
+ if "dimension_scores" not in evaluation:
+ evaluation["dimension_scores"] = {}
+
+ for dimension in [
+ "topic_consistency",
+ "duration_depth",
+ "switching_rhythm",
+ "work_quality",
+ "goal_orientation",
+ ]:
+ if dimension not in evaluation["dimension_scores"]:
+ evaluation["dimension_scores"][dimension] = focus_score
+ else:
+ score = int(evaluation["dimension_scores"][dimension])
+ evaluation["dimension_scores"][dimension] = max(0, min(100, score))
+
+ # Ensure analysis structure exists
+ if "analysis" not in evaluation:
+ evaluation["analysis"] = {}
+
+ for key in ["strengths", "weaknesses", "suggestions"]:
+ if key not in evaluation["analysis"]:
+ evaluation["analysis"][key] = []
+
+ # Ensure other fields have defaults
+ # Validate work_type against allowed values
+ allowed_work_types = {
+ "development",
+ "writing",
+ "learning",
+ "research",
+ "design",
+ "communication",
+ "entertainment",
+ "productivity_analysis",
+ "mixed",
+ "unclear",
+ }
+ work_type = evaluation.get("work_type", "unclear")
+ if work_type not in allowed_work_types:
+ logger.warning(
+ f"Invalid work_type '{work_type}' from LLM, defaulting to 'unclear'. "
+ f"Allowed values: {allowed_work_types}"
+ )
+ work_type = "unclear"
+ evaluation["work_type"] = work_type
+
+ evaluation.setdefault("is_focused_work", focus_score >= 60)
+ evaluation.setdefault("distraction_percentage", max(0, 100 - focus_score))
+ evaluation.setdefault("deep_work_minutes", 0)
+ evaluation.setdefault("context_summary", "")
+
+ return evaluation
+
+ def _get_default_evaluation(self) -> Dict[str, Any]:
+ """
+ Get default evaluation result (used when LLM fails)
+
+ Returns:
+ Default evaluation dict
+ """
+ return {
+ "focus_score": 50,
+ "focus_level": "moderate",
+ "dimension_scores": {
+ "topic_consistency": 50,
+ "duration_depth": 50,
+ "switching_rhythm": 50,
+ "work_quality": 50,
+ "goal_orientation": 50,
+ },
+ "analysis": {
+ "strengths": [],
+ "weaknesses": ["Unable to evaluate focus - using default score"],
+ "suggestions": ["Please ensure LLM is properly configured"],
+ },
+ "work_type": "unclear",
+ "is_focused_work": False,
+ "distraction_percentage": 50,
+ "deep_work_minutes": 0,
+ "context_summary": "Focus evaluation unavailable",
+ }
+
+ async def evaluate_activity_focus(
+ self, activity: Dict[str, Any], session_context: Optional[Dict[str, Any]] = None
+ ) -> Dict[str, Any]:
+ """
+ Evaluate focus score for a single activity using LLM
+
+ Args:
+ activity: Single activity dictionary containing title, description, duration, topics, actions
+ session_context: Optional session context containing user_intent and related_todos
+
+ Returns:
+ Dictionary containing:
+ - focus_score: 0-100 integer score
+ - reasoning: Brief explanation of the score
+ - work_type: Type of work
+ - is_productive: Boolean indicating if this is productive work
+ """
+ if not activity:
+ logger.warning("No activity provided for focus evaluation")
+ return self._get_default_activity_evaluation()
+
+ # Extract activity information
+ title = activity.get("title", "Untitled Activity")
+ description = activity.get("description", "")
+ duration_minutes = activity.get("session_duration_minutes", 0)
+ topics = activity.get("topic_tags", [])
+ actions = activity.get("actions", [])
+ action_count = len(actions)
+
+ # Format actions summary
+ actions_summary = self._format_actions_summary(actions)
+
+ # Extract session context
+ user_intent = ""
+ related_todos_summary = ""
+ if session_context:
+ user_intent = session_context.get("user_intent", "") or ""
+ related_todos = session_context.get("related_todos", [])
+ if related_todos:
+ todos_lines = []
+ for todo in related_todos:
+ todo_title = todo.get("title", "")
+ todo_desc = todo.get("description", "")
+ if todo_desc:
+ todos_lines.append(f"- {todo_title}: {todo_desc}")
+ else:
+ todos_lines.append(f"- {todo_title}")
+ related_todos_summary = "\n".join(todos_lines)
+
+ # Get prompt template
+ try:
+ user_prompt_template = self.prompt_manager.get_prompt(
+ "activity_focus_evaluation", "user_prompt_template"
+ )
+ system_prompt = self.prompt_manager.get_prompt(
+ "activity_focus_evaluation", "system_prompt"
+ )
+ except Exception as e:
+ logger.error(f"Failed to load activity focus evaluation prompts: {e}")
+ return self._get_default_activity_evaluation()
+
+ # Fill in prompt template
+ user_prompt = user_prompt_template.format(
+ title=title,
+ description=description or "No description",
+ duration_minutes=f"{duration_minutes:.1f}",
+ topics=", ".join(topics) if topics else "None",
+ action_count=action_count,
+ actions_summary=actions_summary,
+ user_intent=user_intent or "No work goal specified",
+ related_todos=related_todos_summary or "No related todos",
+ )
+
+ # Call LLM
+ try:
+ messages = [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ]
+
+ response = await self.llm_manager.chat_completion(
+ messages,
+ max_tokens=500,
+ temperature=0.3, # Lower temperature for consistent evaluation
+ request_type="activity_focus_evaluation",
+ )
+
+ content = response.get("content", "")
+
+ # Parse JSON response
+ evaluation = self._parse_activity_llm_response(content)
+
+ # Validate and normalize the evaluation
+ evaluation = self._validate_activity_evaluation(evaluation)
+
+ logger.debug(
+ f"Activity focus evaluation completed: score={evaluation.get('focus_score')}, "
+ f"activity='{title[:50]}'"
+ )
+
+ return evaluation
+
+ except Exception as e:
+ logger.error(f"LLM activity focus evaluation failed: {e}", exc_info=True)
+ return self._get_default_activity_evaluation()
+
+ def _format_actions_summary(self, actions: List[Dict[str, Any]]) -> str:
+ """
+ Format actions into a concise summary
+
+ Args:
+ actions: List of action dictionaries
+
+ Returns:
+ Formatted actions summary string
+ """
+ if not actions:
+ return "No actions recorded"
+
+ lines = []
+ for i, action in enumerate(actions[:5], 1): # Limit to first 5 actions
+ title = action.get("title", "Untitled")
+ lines.append(f"{i}. {title}")
+
+ if len(actions) > 5:
+ lines.append(f"... and {len(actions) - 5} more actions")
+
+ return "\n".join(lines)
+
+ def _parse_activity_llm_response(self, content: str) -> Dict[str, Any]:
+ """
+ Parse LLM JSON response for activity evaluation
+
+ Args:
+ content: LLM response content
+
+ Returns:
+ Parsed evaluation dict
+ """
+ # Try to extract JSON from markdown code blocks
+ if "```json" in content:
+ start = content.find("```json") + 7
+ end = content.find("```", start)
+ json_str = content[start:end].strip()
+ elif "```" in content:
+ start = content.find("```") + 3
+ end = content.find("```", start)
+ json_str = content[start:end].strip()
+ else:
+ json_str = content.strip()
+
+ # Parse JSON
+ try:
+ evaluation = json.loads(json_str)
+ return evaluation
+ except json.JSONDecodeError as e:
+ logger.error(
+ f"Failed to parse activity LLM JSON response: {e}\nContent: {content}"
+ )
+ raise
+
+ def _validate_activity_evaluation(self, evaluation: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Validate and normalize activity evaluation result
+
+ Args:
+ evaluation: Raw evaluation dict from LLM
+
+ Returns:
+ Validated and normalized evaluation dict
+ """
+ # Ensure focus_score is 0-100 integer
+ focus_score = int(evaluation.get("focus_score", 50))
+ focus_score = max(0, min(100, focus_score))
+ evaluation["focus_score"] = focus_score
+
+ # Ensure other required fields have defaults
+ evaluation.setdefault("reasoning", "")
+ evaluation.setdefault("work_type", "unclear")
+ evaluation.setdefault("is_productive", focus_score >= 60)
+
+ return evaluation
+
+ def _get_default_activity_evaluation(self) -> Dict[str, Any]:
+ """
+ Get default activity evaluation result (used when LLM fails)
+
+ Returns:
+ Default evaluation dict
+ """
+ return {
+ "focus_score": 50,
+ "reasoning": "Unable to evaluate activity focus - using default score",
+ "work_type": "unclear",
+ "is_productive": False,
+ }
+
+
+# Global instance
+_focus_evaluator: Optional[FocusEvaluator] = None
+
+
+def get_focus_evaluator() -> FocusEvaluator:
+ """Get global FocusEvaluator instance"""
+ global _focus_evaluator
+ if _focus_evaluator is None:
+ _focus_evaluator = FocusEvaluator()
+ return _focus_evaluator
diff --git a/backend/llm/manager.py b/backend/llm/manager.py
index ef4617c..85f489b 100644
--- a/backend/llm/manager.py
+++ b/backend/llm/manager.py
@@ -116,6 +116,51 @@ def force_reload(self):
else:
logger.debug("No client to reload, will create on next request")
+ async def health_check(self) -> Dict[str, Any]:
+ """
+ Check if LLM service is available
+
+ Returns:
+ Dict with 'available' (bool), 'latency_ms' (int), and optional 'error' (str)
+ """
+ import time
+
+ start_time = time.perf_counter()
+ try:
+ client = self._ensure_client()
+ # Use a minimal request to check connectivity
+ messages = [{"role": "user", "content": "hi"}]
+ result = await client.chat_completion(
+ messages=messages,
+ max_tokens=1,
+ temperature=0.0,
+ )
+ latency_ms = int((time.perf_counter() - start_time) * 1000)
+
+ # Check if we got a valid response (not an error message)
+ content = result.get("content", "")
+ if content and not content.startswith("[Error]"):
+ return {
+ "available": True,
+ "latency_ms": latency_ms,
+ "model": client.model,
+ "provider": client.provider,
+ }
+ else:
+ return {
+ "available": False,
+ "latency_ms": latency_ms,
+ "error": content or "Empty response",
+ }
+ except Exception as e:
+ latency_ms = int((time.perf_counter() - start_time) * 1000)
+ logger.warning(f"LLM health check failed: {e}")
+ return {
+ "available": False,
+ "latency_ms": latency_ms,
+ "error": str(e),
+ }
+
def reload_on_next_request(self):
"""
Mark client for reload on next request
diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py
new file mode 100644
index 0000000..947e87b
--- /dev/null
+++ b/backend/migrations/__init__.py
@@ -0,0 +1,12 @@
+"""
+Database migrations module - Version-based migration system
+
+This module provides a versioned migration system that:
+1. Tracks applied migrations in schema_migrations table
+2. Runs migrations in order by version number
+3. Supports both SQL-based and Python-based migrations
+"""
+
+from .runner import MigrationRunner
+
+__all__ = ["MigrationRunner"]
diff --git a/backend/migrations/base.py b/backend/migrations/base.py
new file mode 100644
index 0000000..00a8e6b
--- /dev/null
+++ b/backend/migrations/base.py
@@ -0,0 +1,51 @@
+"""
+Base migration class
+
+All migrations should inherit from this base class
+"""
+
+import sqlite3
+from abc import ABC, abstractmethod
+from typing import Optional
+
+
+class BaseMigration(ABC):
+ """
+ Base class for database migrations
+
+ Each migration must:
+ 1. Define a unique version string (e.g., "0001", "0002")
+ 2. Provide a description
+ 3. Implement the up() method
+ 4. Optionally implement the down() method for rollbacks
+ """
+
+ # Must be overridden in subclass
+ version: str = ""
+ description: str = ""
+
+ @abstractmethod
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Execute migration (upgrade database)
+
+ Args:
+ cursor: SQLite cursor for executing SQL commands
+ """
+ pass
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback migration (downgrade database)
+
+ Args:
+ cursor: SQLite cursor for executing SQL commands
+
+ Note:
+ This is optional. Many migrations cannot be safely rolled back.
+ If not implemented, rollback will be skipped with a warning.
+ """
+ pass
+
+ def __repr__(self) -> str:
+ return f"
"
diff --git a/backend/migrations/runner.py b/backend/migrations/runner.py
new file mode 100644
index 0000000..04681d9
--- /dev/null
+++ b/backend/migrations/runner.py
@@ -0,0 +1,265 @@
+"""
+Migration runner - Manages database schema versioning
+
+Responsibilities:
+1. Create schema_migrations table if not exists
+2. Discover all migration files
+3. Determine which migrations need to run
+4. Execute migrations in order
+5. Record successful migrations
+"""
+
+import importlib
+import sqlite3
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Type
+
+from core.logger import get_logger
+
+from .base import BaseMigration
+
+logger = get_logger(__name__)
+
+
+class MigrationRunner:
+ """
+ Database migration runner with version tracking
+
+ Usage:
+ runner = MigrationRunner(db_path)
+ runner.run_migrations()
+ """
+
+ SCHEMA_MIGRATIONS_TABLE = """
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version TEXT PRIMARY KEY,
+ description TEXT NOT NULL,
+ applied_at TEXT NOT NULL
+ )
+ """
+
+ def __init__(self, db_path: Path):
+ """
+ Initialize migration runner
+
+ Args:
+ db_path: Path to SQLite database
+ """
+ self.db_path = db_path
+ self.migrations: Dict[str, Type[BaseMigration]] = {}
+
+ def _ensure_schema_migrations_table(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Create schema_migrations table if it doesn't exist
+
+ Args:
+ cursor: Database cursor
+ """
+ cursor.execute(self.SCHEMA_MIGRATIONS_TABLE)
+ logger.debug("✓ schema_migrations table ready")
+
+ def _get_applied_versions(self, cursor: sqlite3.Cursor) -> set:
+ """
+ Get set of already-applied migration versions
+
+ Args:
+ cursor: Database cursor
+
+ Returns:
+ Set of version strings
+ """
+ cursor.execute("SELECT version FROM schema_migrations")
+ rows = cursor.fetchall()
+ return {row[0] for row in rows}
+
+ def _discover_migrations(self) -> List[Type[BaseMigration]]:
+ """
+ Discover all migration classes from versions directory
+
+ Returns:
+ List of migration classes sorted by version
+ """
+ migrations_dir = Path(__file__).parent / "versions"
+
+ if not migrations_dir.exists():
+ logger.warning(f"Migrations directory not found: {migrations_dir}")
+ return []
+
+ discovered = []
+
+ # Import all Python files in versions directory
+ for migration_file in sorted(migrations_dir.glob("*.py")):
+ if migration_file.name.startswith("_"):
+ continue # Skip __init__.py and other private files
+
+ module_name = f"migrations.versions.{migration_file.stem}"
+
+ try:
+ module = importlib.import_module(module_name)
+
+ # Find migration class in module
+ for attr_name in dir(module):
+ attr = getattr(module, attr_name)
+
+ # Check if it's a migration class
+ if (
+ isinstance(attr, type)
+ and issubclass(attr, BaseMigration)
+ and attr is not BaseMigration
+ ):
+ discovered.append(attr)
+ logger.debug(f"Discovered migration: {attr.version} - {attr.description}")
+
+ except Exception as e:
+ logger.error(f"Failed to load migration {migration_file}: {e}", exc_info=True)
+
+ # Sort by version
+ discovered.sort(key=lambda m: m.version)
+
+ return discovered
+
+ def _record_migration(
+ self, cursor: sqlite3.Cursor, migration: BaseMigration
+ ) -> None:
+ """
+ Record successful migration in schema_migrations table
+
+ Args:
+ cursor: Database cursor
+ migration: Migration instance
+ """
+ cursor.execute(
+ """
+ INSERT INTO schema_migrations (version, description, applied_at)
+ VALUES (?, ?, ?)
+ """,
+ (
+ migration.version,
+ migration.description,
+ datetime.now().isoformat(),
+ ),
+ )
+ logger.info(f"✓ Recorded migration: {migration.version}")
+
+ def run_migrations(self) -> int:
+ """
+ Run all pending migrations
+
+ Returns:
+ Number of migrations executed
+ """
+ try:
+ conn = sqlite3.connect(str(self.db_path))
+ cursor = conn.cursor()
+
+ # Ensure tracking table exists
+ self._ensure_schema_migrations_table(cursor)
+ conn.commit()
+
+ # Get applied versions
+ applied_versions = self._get_applied_versions(cursor)
+ logger.debug(f"Applied migrations: {applied_versions}")
+
+ # Discover all migrations
+ all_migrations = self._discover_migrations()
+
+ if not all_migrations:
+ logger.info("No migrations found")
+ conn.close()
+ return 0
+
+ # Filter to pending migrations
+ pending_migrations = [
+ m for m in all_migrations if m.version not in applied_versions
+ ]
+
+ if not pending_migrations:
+ logger.info("✓ All migrations up to date")
+ conn.close()
+ return 0
+
+ logger.info(f"Found {len(pending_migrations)} pending migration(s)")
+
+ # Execute each pending migration
+ executed_count = 0
+ for migration_class in pending_migrations:
+ migration = migration_class()
+
+ logger.info(f"Running migration {migration.version}: {migration.description}")
+
+ try:
+ # Execute migration
+ migration.up(cursor)
+
+ # Record success
+ self._record_migration(cursor, migration)
+ conn.commit()
+
+ executed_count += 1
+ logger.info(f"✓ Migration {migration.version} completed successfully")
+
+ except Exception as e:
+ logger.error(
+ f"✗ Migration {migration.version} failed: {e}",
+ exc_info=True,
+ )
+ conn.rollback()
+ raise
+
+ conn.close()
+
+ logger.info(f"✓ Successfully executed {executed_count} migration(s)")
+ return executed_count
+
+ except Exception as e:
+ logger.error(f"Migration runner failed: {e}", exc_info=True)
+ raise
+
+ def get_migration_status(self) -> Dict[str, Any]:
+ """
+ Get current migration status
+
+ Returns:
+ Dictionary with migration status information
+ """
+ try:
+ conn = sqlite3.connect(str(self.db_path))
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+
+ # Ensure tracking table exists
+ self._ensure_schema_migrations_table(cursor)
+
+ # Get applied migrations
+ cursor.execute(
+ """
+ SELECT version, description, applied_at
+ FROM schema_migrations
+ ORDER BY version
+ """
+ )
+ applied = [dict(row) for row in cursor.fetchall()]
+
+ # Discover all migrations
+ all_migrations = self._discover_migrations()
+
+ applied_versions = {m["version"] for m in applied}
+ pending = [
+ {"version": m.version, "description": m.description}
+ for m in all_migrations
+ if m.version not in applied_versions
+ ]
+
+ conn.close()
+
+ return {
+ "applied_count": len(applied),
+ "pending_count": len(pending),
+ "applied": applied,
+ "pending": pending,
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to get migration status: {e}", exc_info=True)
+ raise
diff --git a/backend/migrations/versions/0001_initial_schema.py b/backend/migrations/versions/0001_initial_schema.py
new file mode 100644
index 0000000..c1c730c
--- /dev/null
+++ b/backend/migrations/versions/0001_initial_schema.py
@@ -0,0 +1,33 @@
+"""
+Migration 0001: Initial database schema
+
+Creates all base tables and indexes for the iDO application
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0001"
+ description = "Initial database schema with all base tables"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Create all initial tables and indexes"""
+ from core.sqls import schema
+
+ # Create all tables
+ for table_sql in schema.ALL_TABLES:
+ cursor.execute(table_sql)
+
+ # Create all indexes
+ for index_sql in schema.ALL_INDEXES:
+ cursor.execute(index_sql)
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported for initial schema
+ Would require dropping all tables
+ """
+ pass
diff --git a/backend/migrations/versions/0002_add_knowledge_actions_columns.py b/backend/migrations/versions/0002_add_knowledge_actions_columns.py
new file mode 100644
index 0000000..27a8f45
--- /dev/null
+++ b/backend/migrations/versions/0002_add_knowledge_actions_columns.py
@@ -0,0 +1,53 @@
+"""
+Migration 0002: Add knowledge extraction columns to actions table
+
+Adds columns to support knowledge extraction feature
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0002"
+ description = "Add knowledge extraction columns to actions and knowledge tables"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Add columns for knowledge extraction feature"""
+
+ # List of column additions (with error handling for already-exists)
+ columns_to_add = [
+ (
+ "actions",
+ "extract_knowledge",
+ "ALTER TABLE actions ADD COLUMN extract_knowledge BOOLEAN DEFAULT 0",
+ ),
+ (
+ "actions",
+ "knowledge_extracted",
+ "ALTER TABLE actions ADD COLUMN knowledge_extracted BOOLEAN DEFAULT 0",
+ ),
+ (
+ "knowledge",
+ "source_action_id",
+ "ALTER TABLE knowledge ADD COLUMN source_action_id TEXT",
+ ),
+ ]
+
+ for table, column, sql in columns_to_add:
+ try:
+ cursor.execute(sql)
+ except sqlite3.OperationalError as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg or "already exists" in error_msg:
+ # Column already exists, skip
+ pass
+ else:
+ raise
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported (SQLite doesn't support DROP COLUMN in older versions)
+ """
+ pass
diff --git a/backend/migrations/versions/0003_add_pomodoro_feature.py b/backend/migrations/versions/0003_add_pomodoro_feature.py
new file mode 100644
index 0000000..97534b2
--- /dev/null
+++ b/backend/migrations/versions/0003_add_pomodoro_feature.py
@@ -0,0 +1,125 @@
+"""
+Migration 0003: Add Pomodoro feature
+
+Adds columns to existing tables for Pomodoro session tracking:
+- pomodoro_session_id to raw_records, actions, events, activities
+- user_intent and pomodoro_status to activities
+
+Also creates indexes for efficient querying
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0003"
+ description = "Add Pomodoro feature columns and indexes"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Add Pomodoro-related columns and indexes"""
+
+ # Column additions
+ columns_to_add = [
+ (
+ "raw_records",
+ "pomodoro_session_id",
+ "ALTER TABLE raw_records ADD COLUMN pomodoro_session_id TEXT",
+ ),
+ (
+ "actions",
+ "pomodoro_session_id",
+ "ALTER TABLE actions ADD COLUMN pomodoro_session_id TEXT",
+ ),
+ (
+ "events",
+ "pomodoro_session_id",
+ "ALTER TABLE events ADD COLUMN pomodoro_session_id TEXT",
+ ),
+ (
+ "activities",
+ "pomodoro_session_id",
+ "ALTER TABLE activities ADD COLUMN pomodoro_session_id TEXT",
+ ),
+ (
+ "activities",
+ "user_intent",
+ "ALTER TABLE activities ADD COLUMN user_intent TEXT",
+ ),
+ (
+ "activities",
+ "pomodoro_status",
+ "ALTER TABLE activities ADD COLUMN pomodoro_status TEXT",
+ ),
+ ]
+
+ for table, column, sql in columns_to_add:
+ try:
+ cursor.execute(sql)
+ except sqlite3.OperationalError as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg or "already exists" in error_msg:
+ # Column already exists, skip
+ pass
+ else:
+ raise
+
+ # Index creation
+ indexes_to_create = [
+ (
+ "idx_raw_records_pomodoro_session",
+ """
+ CREATE INDEX IF NOT EXISTS idx_raw_records_pomodoro_session
+ ON raw_records(pomodoro_session_id)
+ """,
+ ),
+ (
+ "idx_actions_pomodoro_session",
+ """
+ CREATE INDEX IF NOT EXISTS idx_actions_pomodoro_session
+ ON actions(pomodoro_session_id)
+ """,
+ ),
+ (
+ "idx_events_pomodoro_session",
+ """
+ CREATE INDEX IF NOT EXISTS idx_events_pomodoro_session
+ ON events(pomodoro_session_id)
+ """,
+ ),
+ (
+ "idx_activities_pomodoro_session",
+ """
+ CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_session
+ ON activities(pomodoro_session_id)
+ """,
+ ),
+ (
+ "idx_activities_pomodoro_status",
+ """
+ CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_status
+ ON activities(pomodoro_status)
+ """,
+ ),
+ ]
+
+ for index_name, sql in indexes_to_create:
+ try:
+ cursor.execute(sql)
+ except Exception as e:
+ # Index creation failures are usually safe to ignore
+ # (index might already exist)
+ pass
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported (SQLite doesn't support DROP COLUMN easily)
+
+ To rollback, you would need to:
+ 1. Create new tables without the columns
+ 2. Copy data
+ 3. Drop old tables
+ 4. Rename new tables
+ """
+ pass
diff --git a/backend/migrations/versions/0004_add_pomodoro_todo_ratings.py b/backend/migrations/versions/0004_add_pomodoro_todo_ratings.py
new file mode 100644
index 0000000..5d7162c
--- /dev/null
+++ b/backend/migrations/versions/0004_add_pomodoro_todo_ratings.py
@@ -0,0 +1,107 @@
+"""
+Migration 0004: Add Pomodoro-TODO association and Activity ratings
+
+Changes:
+1. Add associated_todo_id column to pomodoro_sessions table
+2. Create activity_ratings table for multi-dimensional activity ratings
+3. Add indexes for efficient querying
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0004"
+ description = "Add Pomodoro-TODO association and Activity ratings"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Add Pomodoro-TODO association and activity ratings tables"""
+
+ # 1. Add associated_todo_id column to pomodoro_sessions
+ try:
+ cursor.execute(
+ """
+ ALTER TABLE pomodoro_sessions
+ ADD COLUMN associated_todo_id TEXT
+ """
+ )
+ except sqlite3.OperationalError as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg or "already exists" in error_msg:
+ # Column already exists, skip
+ pass
+ else:
+ raise
+
+ # 2. Create index for associated_todo_id
+ try:
+ cursor.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_todo
+ ON pomodoro_sessions(associated_todo_id)
+ """
+ )
+ except Exception:
+ # Index creation failures are usually safe to ignore
+ pass
+
+ # 3. Create activity_ratings table
+ try:
+ cursor.execute(
+ """
+ CREATE TABLE IF NOT EXISTS activity_ratings (
+ id TEXT PRIMARY KEY,
+ activity_id TEXT NOT NULL,
+ dimension TEXT NOT NULL,
+ rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5),
+ note TEXT,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
+ UNIQUE(activity_id, dimension)
+ )
+ """
+ )
+ except Exception as e:
+ # Table might already exist
+ pass
+
+ # 4. Create indexes for activity_ratings
+ indexes_to_create = [
+ (
+ "idx_activity_ratings_activity",
+ """
+ CREATE INDEX IF NOT EXISTS idx_activity_ratings_activity
+ ON activity_ratings(activity_id)
+ """
+ ),
+ (
+ "idx_activity_ratings_dimension",
+ """
+ CREATE INDEX IF NOT EXISTS idx_activity_ratings_dimension
+ ON activity_ratings(dimension)
+ """
+ ),
+ ]
+
+ for index_name, sql in indexes_to_create:
+ try:
+ cursor.execute(sql)
+ except Exception:
+ # Index creation failures are usually safe to ignore
+ pass
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported (SQLite doesn't support DROP COLUMN easily)
+
+ To rollback, you would need to:
+ 1. Drop activity_ratings table
+ 2. Create new pomodoro_sessions table without associated_todo_id
+ 3. Copy data
+ 4. Drop old pomodoro_sessions table
+ 5. Rename new table
+ """
+ pass
diff --git a/backend/migrations/versions/0005_add_pomodoro_rounds.py b/backend/migrations/versions/0005_add_pomodoro_rounds.py
new file mode 100644
index 0000000..d80f60e
--- /dev/null
+++ b/backend/migrations/versions/0005_add_pomodoro_rounds.py
@@ -0,0 +1,143 @@
+"""
+Migration 0005: Add Pomodoro rounds and phase management
+
+Adds support for multi-round Pomodoro sessions with work/break phases:
+- work_duration_minutes: Duration of work phase (default 25)
+- break_duration_minutes: Duration of break phase (default 5)
+- total_rounds: Total number of work rounds to complete (default 4)
+- current_round: Current round number (1-based)
+- current_phase: Current phase (work/break/completed)
+- phase_start_time: When current phase started
+- completed_rounds: Number of completed work rounds
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0005"
+ description = "Add Pomodoro rounds and phase management"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Add Pomodoro rounds-related columns and work phases table"""
+
+ # Create pomodoro_work_phases table
+ try:
+ cursor.execute(
+ """
+ CREATE TABLE IF NOT EXISTS pomodoro_work_phases (
+ id TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL,
+ phase_number INTEGER NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ processing_error TEXT,
+ retry_count INTEGER DEFAULT 0,
+ phase_start_time TEXT NOT NULL,
+ phase_end_time TEXT,
+ activity_count INTEGER DEFAULT 0,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (session_id) REFERENCES pomodoro_sessions(id) ON DELETE CASCADE,
+ CHECK(status IN ('pending', 'processing', 'completed', 'failed')),
+ UNIQUE(session_id, phase_number)
+ )
+ """
+ )
+ except Exception:
+ # Table might already exist
+ pass
+
+ # Column additions for round management
+ columns_to_add = [
+ (
+ "pomodoro_sessions",
+ "work_duration_minutes",
+ "ALTER TABLE pomodoro_sessions ADD COLUMN work_duration_minutes INTEGER DEFAULT 25",
+ ),
+ (
+ "pomodoro_sessions",
+ "break_duration_minutes",
+ "ALTER TABLE pomodoro_sessions ADD COLUMN break_duration_minutes INTEGER DEFAULT 5",
+ ),
+ (
+ "pomodoro_sessions",
+ "total_rounds",
+ "ALTER TABLE pomodoro_sessions ADD COLUMN total_rounds INTEGER DEFAULT 4",
+ ),
+ (
+ "pomodoro_sessions",
+ "current_round",
+ "ALTER TABLE pomodoro_sessions ADD COLUMN current_round INTEGER DEFAULT 1",
+ ),
+ (
+ "pomodoro_sessions",
+ "current_phase",
+ "ALTER TABLE pomodoro_sessions ADD COLUMN current_phase TEXT DEFAULT 'work'",
+ ),
+ (
+ "pomodoro_sessions",
+ "phase_start_time",
+ "ALTER TABLE pomodoro_sessions ADD COLUMN phase_start_time TEXT",
+ ),
+ (
+ "pomodoro_sessions",
+ "completed_rounds",
+ "ALTER TABLE pomodoro_sessions ADD COLUMN completed_rounds INTEGER DEFAULT 0",
+ ),
+ ]
+
+ for table, column, sql in columns_to_add:
+ try:
+ cursor.execute(sql)
+ except sqlite3.OperationalError as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg or "already exists" in error_msg:
+ # Column already exists, skip
+ pass
+ else:
+ raise
+
+ # Add index for current_phase for efficient querying
+ try:
+ cursor.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_phase
+ ON pomodoro_sessions(current_phase)
+ """
+ )
+ except Exception:
+ # Index creation failures are usually safe to ignore
+ pass
+
+ # Create indexes for pomodoro_work_phases table
+ indexes_to_create = [
+ """
+ CREATE INDEX IF NOT EXISTS idx_work_phases_session
+ ON pomodoro_work_phases(session_id, phase_number)
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_work_phases_status
+ ON pomodoro_work_phases(status)
+ """,
+ ]
+
+ for index_sql in indexes_to_create:
+ try:
+ cursor.execute(index_sql)
+ except Exception:
+ # Index creation failures are usually safe to ignore
+ pass
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported (SQLite doesn't support DROP COLUMN easily)
+
+ To rollback, you would need to:
+ 1. Create new pomodoro_sessions table without the new columns
+ 2. Copy data
+ 3. Drop old table
+ 4. Rename new table
+ """
+ pass
diff --git a/backend/migrations/versions/0006_add_activity_work_phase_tracking.py b/backend/migrations/versions/0006_add_activity_work_phase_tracking.py
new file mode 100644
index 0000000..1519568
--- /dev/null
+++ b/backend/migrations/versions/0006_add_activity_work_phase_tracking.py
@@ -0,0 +1,70 @@
+"""
+Migration 0006: Add work phase tracking and focus score to activities
+
+Adds columns to activities table for better Pomodoro session tracking:
+- pomodoro_work_phase: Track which work round (1-4) generated the activity
+- focus_score: Pre-calculated focus metric (0.0-1.0) for each activity
+
+Also creates index for efficient querying by session and work phase
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0006"
+ description = "Add work phase tracking and focus score to activities"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Add work phase and focus score columns to activities table"""
+
+ # Column additions
+ columns_to_add = [
+ (
+ "activities",
+ "pomodoro_work_phase",
+ "ALTER TABLE activities ADD COLUMN pomodoro_work_phase INTEGER",
+ ),
+ (
+ "activities",
+ "focus_score",
+ "ALTER TABLE activities ADD COLUMN focus_score REAL DEFAULT 0.5",
+ ),
+ ]
+
+ for table, column, sql in columns_to_add:
+ try:
+ cursor.execute(sql)
+ except sqlite3.OperationalError as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg or "already exists" in error_msg:
+ # Column already exists, skip
+ pass
+ else:
+ raise
+
+ # Index creation for efficient querying
+ index_sql = """
+ CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_work_phase
+ ON activities(pomodoro_session_id, pomodoro_work_phase)
+ """
+ try:
+ cursor.execute(index_sql)
+ except Exception:
+ # Index creation failures are usually safe to ignore
+ # (index might already exist)
+ pass
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported (SQLite doesn't support DROP COLUMN easily)
+
+ To rollback, you would need to:
+ 1. Create new table without the columns
+ 2. Copy data
+ 3. Drop old table
+ 4. Rename new table
+ """
+ pass
diff --git a/backend/migrations/versions/0007_add_action_based_aggregation.py b/backend/migrations/versions/0007_add_action_based_aggregation.py
new file mode 100644
index 0000000..abeebe2
--- /dev/null
+++ b/backend/migrations/versions/0007_add_action_based_aggregation.py
@@ -0,0 +1,83 @@
+"""
+Migration 0007: Add action-based aggregation support to activities
+
+Adds columns to activities table to support direct action→activity aggregation:
+- source_action_ids: JSON array of action IDs (alternative to source_event_ids)
+- aggregation_mode: Flag to indicate 'event_based' or 'action_based' aggregation
+
+This migration enables the unified action-based architecture where both Normal Mode
+and Pomodoro Mode can aggregate actions directly into activities, bypassing the
+Events layer for better temporal continuity.
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0007"
+ description = "Add action-based aggregation support to activities"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Add action-based aggregation columns to activities table"""
+
+ # Column additions
+ columns_to_add = [
+ (
+ "activities",
+ "source_action_ids",
+ "ALTER TABLE activities ADD COLUMN source_action_ids TEXT",
+ ),
+ (
+ "activities",
+ "aggregation_mode",
+ "ALTER TABLE activities ADD COLUMN aggregation_mode TEXT DEFAULT 'action_based'",
+ ),
+ ]
+
+ for table, column, sql in columns_to_add:
+ try:
+ cursor.execute(sql)
+ except sqlite3.OperationalError as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg or "already exists" in error_msg:
+ # Column already exists, skip
+ pass
+ else:
+ raise
+
+ # Set existing activities to event_based mode (they have source_event_ids)
+ update_sql = """
+ UPDATE activities
+ SET aggregation_mode = 'event_based'
+ WHERE aggregation_mode IS NULL AND source_event_ids IS NOT NULL
+ """
+ try:
+ cursor.execute(update_sql)
+ except Exception:
+ # If update fails, it's not critical - new activities will default to action_based
+ pass
+
+ # Add index for aggregation_mode for efficient querying
+ index_sql = """
+ CREATE INDEX IF NOT EXISTS idx_activities_aggregation_mode
+ ON activities(aggregation_mode)
+ """
+ try:
+ cursor.execute(index_sql)
+ except Exception:
+ # Index creation failures are usually safe to ignore
+ pass
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported (SQLite doesn't support DROP COLUMN easily)
+
+ To rollback, you would need to:
+ 1. Create new table without the columns
+ 2. Copy data
+ 3. Drop old table
+ 4. Rename new table
+ """
+ pass
diff --git a/backend/migrations/versions/0008_add_knowledge_favorite.py b/backend/migrations/versions/0008_add_knowledge_favorite.py
new file mode 100644
index 0000000..f1af7ab
--- /dev/null
+++ b/backend/migrations/versions/0008_add_knowledge_favorite.py
@@ -0,0 +1,48 @@
+"""
+Migration 0008: Add favorite column to knowledge table
+
+Adds favorite column to support favoriting knowledge items
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0008"
+ description = "Add favorite column to knowledge table"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Add favorite column to knowledge table"""
+
+ # Add favorite column
+ try:
+ cursor.execute(
+ "ALTER TABLE knowledge ADD COLUMN favorite BOOLEAN DEFAULT 0"
+ )
+ except sqlite3.OperationalError as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg or "already exists" in error_msg:
+ # Column already exists, skip
+ pass
+ else:
+ raise
+
+ # Create index for favorite column
+ try:
+ cursor.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_knowledge_favorite
+ ON knowledge(favorite)
+ """
+ )
+ except sqlite3.OperationalError:
+ # Index might already exist, ignore
+ pass
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported (SQLite doesn't support DROP COLUMN in older versions)
+ """
+ pass
diff --git a/backend/migrations/versions/0009_add_llm_evaluation.py b/backend/migrations/versions/0009_add_llm_evaluation.py
new file mode 100644
index 0000000..fc2a71a
--- /dev/null
+++ b/backend/migrations/versions/0009_add_llm_evaluation.py
@@ -0,0 +1,56 @@
+"""
+Migration 0009: Add LLM evaluation fields to pomodoro_sessions
+
+Adds llm_evaluation_result and llm_evaluation_computed_at columns
+to support caching LLM focus evaluations
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0009"
+ description = "Add LLM evaluation fields to pomodoro_sessions"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Add LLM evaluation columns to pomodoro_sessions table"""
+
+ # Add llm_evaluation_result column
+ try:
+ cursor.execute(
+ """
+ ALTER TABLE pomodoro_sessions
+ ADD COLUMN llm_evaluation_result TEXT
+ """
+ )
+ except sqlite3.OperationalError as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg or "already exists" in error_msg:
+ # Column already exists, skip
+ pass
+ else:
+ raise
+
+ # Add llm_evaluation_computed_at column
+ try:
+ cursor.execute(
+ """
+ ALTER TABLE pomodoro_sessions
+ ADD COLUMN llm_evaluation_computed_at TEXT
+ """
+ )
+ except sqlite3.OperationalError as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg or "already exists" in error_msg:
+ # Column already exists, skip
+ pass
+ else:
+ raise
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported (SQLite doesn't support DROP COLUMN in older versions)
+ """
+ pass
diff --git a/backend/migrations/versions/0010_simplify_pomodoro_status.py b/backend/migrations/versions/0010_simplify_pomodoro_status.py
new file mode 100644
index 0000000..e3e10df
--- /dev/null
+++ b/backend/migrations/versions/0010_simplify_pomodoro_status.py
@@ -0,0 +1,45 @@
+"""
+Migration 0010: Simplify Pomodoro status values
+
+Simplifies status and processing_status values by merging similar states:
+- status: 'interrupted' and 'too_short' → 'abandoned'
+- processing_status: 'skipped' → 'failed'
+
+This reduces state complexity from 10 combinations to 7 combinations.
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0010"
+ description = "Simplify Pomodoro status values"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Merge similar status values to simplify state management"""
+
+ # Merge 'interrupted' and 'too_short' into 'abandoned'
+ cursor.execute(
+ """
+ UPDATE pomodoro_sessions
+ SET status = 'abandoned'
+ WHERE status IN ('interrupted', 'too_short')
+ """
+ )
+
+ # Merge 'skipped' into 'failed'
+ cursor.execute(
+ """
+ UPDATE pomodoro_sessions
+ SET processing_status = 'failed'
+ WHERE processing_status = 'skipped'
+ """
+ )
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported - cannot reliably restore original status values
+ """
+ pass
diff --git a/backend/migrations/versions/0011_add_todo_expiration.py b/backend/migrations/versions/0011_add_todo_expiration.py
new file mode 100644
index 0000000..3003fa2
--- /dev/null
+++ b/backend/migrations/versions/0011_add_todo_expiration.py
@@ -0,0 +1,49 @@
+"""
+Migration 0011: Add todo expiration and source tracking
+
+Adds expiration and source tracking columns to todos table:
+- expires_at: Expiration timestamp for AI-generated todos (default 3 days)
+- source_type: 'ai' or 'manual' to track todo origin
+
+This enables automatic cleanup of expired AI-generated todos.
+"""
+
+import sqlite3
+
+from migrations.base import BaseMigration
+
+
+class Migration(BaseMigration):
+ version = "0011"
+ description = "Add todo expiration and source tracking"
+
+ def up(self, cursor: sqlite3.Cursor) -> None:
+ """Add expires_at and source_type columns to todos table"""
+
+ # Add expires_at column (NULL means no expiration)
+ cursor.execute(
+ """
+ ALTER TABLE todos ADD COLUMN expires_at TEXT
+ """
+ )
+
+ # Add source_type column (default 'ai' for existing records)
+ cursor.execute(
+ """
+ ALTER TABLE todos ADD COLUMN source_type TEXT DEFAULT 'ai'
+ """
+
+ )
+
+ # Update existing records to have source_type = 'ai'
+ cursor.execute(
+ """
+ UPDATE todos SET source_type = 'ai' WHERE source_type IS NULL
+ """
+ )
+
+ def down(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Rollback not supported - cannot reliably restore original schema
+ """
+ pass
diff --git a/backend/migrations/versions/__init__.py b/backend/migrations/versions/__init__.py
new file mode 100644
index 0000000..54f45bb
--- /dev/null
+++ b/backend/migrations/versions/__init__.py
@@ -0,0 +1,11 @@
+"""
+Migration versions directory
+
+Each migration file should be named: XXXX_description.py
+Where XXXX is a 4-digit version number (e.g., 0001, 0002, etc.)
+
+Example:
+ 0001_initial_schema.py
+ 0002_add_three_layer_architecture.py
+ 0003_add_pomodoro_feature.py
+"""
diff --git a/backend/models/requests.py b/backend/models/requests.py
index 78f666c..141833d 100644
--- a/backend/models/requests.py
+++ b/backend/models/requests.py
@@ -4,7 +4,7 @@
"""
from datetime import datetime
-from typing import List, Optional
+from typing import List, Literal, Optional
from pydantic import Field
@@ -252,11 +252,37 @@ class UpdateSettingsRequest(BaseModel):
@property databasePath - Path to the database file (optional).
@property screenshotSavePath - Path to save screenshots (optional).
@property language - Application language (zh or en) (optional).
+ @property fontSize - Application font size (small, default, large, extra-large) (optional).
+ @property voiceEnabled - Enable voice reminders (optional).
+ @property voiceVolume - Voice volume (0.0-1.0) (optional).
+ @property voiceLanguage - Voice language (zh-CN or en-US) (optional).
+ @property voiceId - Voice ID (optional).
+ @property clockEnabled - Enable desktop clock (optional).
+ @property clockPosition - Clock position (bottom-right, bottom-left, top-right, top-left) (optional).
+ @property clockSize - Clock size (small, medium, large) (optional).
+ @property clockCustomX - Custom X position in screen coordinates (optional).
+ @property clockCustomY - Custom Y position in screen coordinates (optional).
+ @property clockCustomWidth - Custom window width (optional).
+ @property clockCustomHeight - Custom window height (optional).
+ @property clockUseCustomPosition - Whether to use custom position instead of preset position (optional).
"""
database_path: Optional[str] = None
screenshot_save_path: Optional[str] = None
language: Optional[str] = None
+ font_size: Optional[str] = None
+ voice_enabled: Optional[bool] = None
+ voice_volume: Optional[float] = None
+ voice_sound_theme: Optional[str] = None
+ voice_custom_sounds: Optional[dict] = None
+ clock_enabled: Optional[bool] = None
+ clock_position: Optional[str] = None
+ clock_size: Optional[str] = None
+ clock_custom_x: Optional[int] = None
+ clock_custom_y: Optional[int] = None
+ clock_custom_width: Optional[int] = None
+ clock_custom_height: Optional[int] = None
+ clock_use_custom_position: Optional[bool] = None
class UpdateLive2DSettingsRequest(BaseModel):
@@ -620,6 +646,17 @@ class ReadImageFileRequest(BaseModel):
file_path: str
+class CleanupBrokenActionsRequest(BaseModel):
+ """Request parameters for cleaning up actions with missing images.
+
+ @property strategy - Cleanup strategy: delete_actions, remove_references, or dry_run.
+ @property actionIds - Optional list of specific action IDs to process.
+ """
+
+ strategy: Literal["delete_actions", "remove_references", "dry_run"]
+ action_ids: Optional[List[str]] = None
+
+
# ============================================================================
# Three-Layer Architecture Request Models (Activities → Events → Actions)
# ============================================================================
@@ -822,3 +859,105 @@ class DeleteDiariesByDateRequest(BaseModel):
start_date: str = Field(..., pattern=r"^\d{4}-\d{2}-\d{2}$")
end_date: str = Field(..., pattern=r"^\d{4}-\d{2}-\d{2}$")
+
+
+class ToggleKnowledgeFavoriteRequest(BaseModel):
+ """Request parameters for toggling knowledge favorite status.
+
+ @property id - Knowledge ID to toggle favorite status
+ """
+
+ id: str
+
+
+class CreateKnowledgeRequest(BaseModel):
+ """Request parameters for manually creating knowledge.
+
+ @property title - Knowledge title
+ @property description - Knowledge description
+ @property keywords - List of keywords/tags
+ """
+
+ title: str = Field(..., min_length=1, max_length=500)
+ description: str = Field(..., min_length=1)
+ keywords: List[str] = Field(default_factory=list)
+
+
+class UpdateKnowledgeRequest(BaseModel):
+ """Request parameters for updating knowledge.
+
+ @property id - Knowledge ID to update
+ @property title - Knowledge title
+ @property description - Knowledge description
+ @property keywords - List of keywords/tags
+ """
+
+ id: str
+ title: str = Field(..., min_length=1, max_length=500)
+ description: str = Field(..., min_length=1)
+ keywords: List[str] = Field(default_factory=list)
+
+
+class AnalyzeKnowledgeMergeRequest(BaseModel):
+ """Request parameters for analyzing knowledge similarity and generating merge suggestions.
+
+ @property filter_by_keyword - Only analyze knowledge with this keyword (None = all)
+ @property include_favorites - Whether to include favorite knowledge in analysis
+ @property similarity_threshold - Similarity threshold for merging (0.0-1.0)
+ """
+
+ filter_by_keyword: Optional[str] = None
+ include_favorites: bool = True
+ similarity_threshold: float = Field(default=0.7, ge=0.0, le=1.0)
+
+
+class MergeGroup(BaseModel):
+ """Represents a user-confirmed merge group.
+
+ @property group_id - Unique identifier for this merge group
+ @property knowledge_ids - List of knowledge IDs to merge
+ @property merged_title - Title for the merged knowledge
+ @property merged_description - Description for the merged knowledge
+ @property merged_keywords - Keywords for the merged knowledge
+ @property merge_reason - Optional reason for merging
+ @property keep_favorite - Whether to keep favorite status if any source is favorite
+ """
+
+ group_id: str
+ knowledge_ids: List[str]
+ merged_title: str = Field(..., min_length=1, max_length=500)
+ merged_description: str = Field(..., min_length=1)
+ merged_keywords: List[str] = Field(default_factory=list)
+ merge_reason: Optional[str] = None
+ keep_favorite: bool = True
+
+
+class ExecuteKnowledgeMergeRequest(BaseModel):
+ """Request parameters for executing approved knowledge merge operations.
+
+ @property merge_groups - List of merge groups to execute
+ """
+
+ merge_groups: List[MergeGroup]
+
+
+# ============ Todo Requests ============
+
+
+class CreateTodoRequest(BaseModel):
+ """Request parameters for manually creating a todo.
+
+ @property title - Todo title (required)
+ @property description - Todo description (required)
+ @property keywords - List of keywords/tags (optional)
+ @property scheduled_date - Optional scheduled date (YYYY-MM-DD format)
+ @property scheduled_time - Optional scheduled time (HH:MM format)
+ @property scheduled_end_time - Optional scheduled end time (HH:MM format)
+ """
+
+ title: str = Field(..., min_length=1, max_length=500)
+ description: str = Field(..., min_length=1)
+ keywords: List[str] = Field(default_factory=list)
+ scheduled_date: Optional[str] = Field(None, pattern=r"^\d{4}-\d{2}-\d{2}$")
+ scheduled_time: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$")
+ scheduled_end_time: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$")
diff --git a/backend/models/responses.py b/backend/models/responses.py
index ca232c2..444f444 100644
--- a/backend/models/responses.py
+++ b/backend/models/responses.py
@@ -3,7 +3,7 @@
Provides strongly typed response models for better type safety and auto-generation
"""
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Literal, Optional
from models.base import BaseModel, OperationResponse, TimedOperationResponse
@@ -197,6 +197,39 @@ class ImageOptimizationStatsResponse(OperationResponse):
config: Optional[Dict[str, Any]] = None
+class ImagePersistenceHealthData(BaseModel):
+ """Data model for image persistence health check"""
+
+ total_actions: int
+ actions_with_screenshots: int
+ actions_all_images_ok: int
+ actions_partial_missing: int
+ actions_all_missing: int
+ total_image_references: int
+ images_found: int
+ images_missing: int
+ missing_rate_percent: float
+ memory_cache_current_size: int
+ memory_cache_max_size: int
+ memory_ttl_seconds: int
+ actions_with_issues: List[Dict[str, Any]]
+
+
+class ImagePersistenceHealthResponse(OperationResponse):
+ """Response containing image persistence health check results"""
+
+ data: Optional[ImagePersistenceHealthData] = None
+
+
+class CleanupBrokenActionsResponse(OperationResponse):
+ """Response after cleaning up broken action images"""
+
+ actions_processed: int = 0
+ actions_deleted: int = 0
+ references_removed: int = 0
+ images_removed: int = 0
+
+
class UpdateImageOptimizationConfigResponse(OperationResponse):
"""Response after updating image optimization configuration"""
@@ -255,6 +288,9 @@ class SettingsInfoData(BaseModel):
database: Dict[str, str]
screenshot: Dict[str, str]
language: str
+ font_size: str = "default"
+ voice: Optional[Dict[str, Any]] = None
+ clock: Optional[Dict[str, Any]] = None
image: Dict[str, Any]
@@ -343,3 +379,311 @@ class CompleteInitialSetupResponse(TimedOperationResponse):
pass
+# Pomodoro Feature Response Models
+class PomodoroSessionData(BaseModel):
+ """Pomodoro session data with rounds support"""
+
+ session_id: str
+ user_intent: str
+ start_time: str
+ elapsed_minutes: int
+ planned_duration_minutes: int
+ associated_todo_id: Optional[str] = None
+ associated_todo_title: Optional[str] = None
+ # Rounds configuration
+ work_duration_minutes: int = 25
+ break_duration_minutes: int = 5
+ total_rounds: int = 4
+ current_round: int = 1
+ current_phase: Literal["work", "break", "completed"] = "work"
+ phase_start_time: Optional[str] = None
+ completed_rounds: int = 0
+ # Calculated fields for frontend
+ remaining_phase_seconds: Optional[int] = None
+ pure_work_duration_minutes: int = 0 # completed_rounds × work_duration_minutes (excludes breaks)
+
+
+class StartPomodoroResponse(TimedOperationResponse):
+ """Response after starting a Pomodoro session"""
+
+ data: Optional[PomodoroSessionData] = None
+
+
+class EndPomodoroData(BaseModel):
+ """End Pomodoro session result data"""
+
+ session_id: str
+ status: str # Session status (completed, abandoned, etc.)
+ actual_work_minutes: int # Actual work duration in minutes
+ raw_records_count: int = 0 # Number of raw records captured
+ processing_job_id: Optional[str] = None # Deprecated, always None now
+ message: str = "" # Optional message for user
+
+
+class EndPomodoroResponse(TimedOperationResponse):
+ """Response after ending a Pomodoro session"""
+
+ data: Optional[EndPomodoroData] = None
+
+
+class GetPomodoroStatusResponse(TimedOperationResponse):
+ """Response for getting current Pomodoro session status"""
+
+ data: Optional[PomodoroSessionData] = None
+
+
+# Pomodoro Session Detail Models (with activities and focus metrics)
+
+
+class PomodoroActivityData(BaseModel):
+ """Activity data for Pomodoro session detail view"""
+
+ id: str
+ title: str
+ description: str
+ start_time: str
+ end_time: str
+ session_duration_minutes: int
+ work_phase: Optional[int] = None # Which work round (1-4)
+ focus_score: Optional[float] = None # Focus metric (0.0-1.0)
+ topic_tags: List[str] = []
+ source_event_ids: List[str] = [] # Deprecated, for backward compatibility
+ source_action_ids: List[str] = [] # NEW: Primary source for action-based aggregation
+ aggregation_mode: str = "action_based" # NEW: 'event_based' or 'action_based'
+
+
+class PhaseTimelineItem(BaseModel):
+ """Single phase in timeline (work or break)"""
+
+ phase_type: Literal["work", "break"]
+ phase_number: int # 1-based round number
+ start_time: str
+ end_time: str
+ duration_minutes: int
+
+
+class FocusMetrics(BaseModel):
+ """Focus metrics for a Pomodoro session"""
+
+ overall_focus_score: float # Weighted average focus score (0.0-1.0)
+ activity_count: int # Number of activities in session
+ topic_diversity: int # Number of unique topics
+ average_activity_duration: float # Average duration per activity (minutes)
+ focus_level: str # Human-readable level: excellent/good/moderate/low
+
+
+class LLMFocusAnalysis(BaseModel):
+ """Detailed focus analysis from LLM evaluation"""
+
+ strengths: List[str] # Focus strengths (2-4 items)
+ weaknesses: List[str] # Focus weaknesses (1-3 items)
+ suggestions: List[str] # Improvement suggestions (2-4 items)
+
+
+class LLMFocusDimensionScores(BaseModel):
+ """Detailed dimension scores from LLM evaluation"""
+
+ topic_consistency: int # 0-100 score for topic consistency
+ duration_depth: int # 0-100 score for duration depth
+ switching_rhythm: int # 0-100 score for switching rhythm
+ work_quality: int # 0-100 score for work quality
+ goal_orientation: int # 0-100 score for goal orientation
+
+
+class LLMFocusEvaluation(BaseModel):
+ """Complete LLM-based focus evaluation result"""
+
+ focus_score: int # 0-100 integer score
+ focus_level: Literal["excellent", "good", "moderate", "low"] # Focus quality level
+ dimension_scores: LLMFocusDimensionScores # Detailed dimension scores
+ analysis: LLMFocusAnalysis # Detailed analysis
+ work_type: Literal[
+ "development",
+ "writing",
+ "learning",
+ "research",
+ "design",
+ "communication",
+ "entertainment",
+ "productivity_analysis",
+ "mixed",
+ "unclear",
+ ] # Type of work activity
+ is_focused_work: bool # Whether it's high-quality focused work
+ distraction_percentage: int # Distraction time percentage (0-100)
+ deep_work_minutes: float # Deep work duration (minutes)
+ context_summary: str # Overall work summary
+
+
+class PomodoroSessionDetailData(BaseModel):
+ """Detailed Pomodoro session with activities and focus metrics"""
+
+ session: Dict[str, Any] # Full session data
+ activities: List[PomodoroActivityData]
+ focus_metrics: FocusMetrics # Calculated focus metrics
+ llm_focus_evaluation: Optional[LLMFocusEvaluation] = None # LLM-based detailed evaluation
+ phase_timeline: List[PhaseTimelineItem] = [] # Work/break phase timeline
+
+
+class GetPomodoroSessionDetailRequest(BaseModel):
+ """Request to get detailed Pomodoro session information"""
+
+ session_id: str
+
+
+class GetPomodoroSessionDetailResponse(TimedOperationResponse):
+ """Response with detailed Pomodoro session data"""
+
+ data: Optional[PomodoroSessionDetailData] = None
+
+
+class DeletePomodoroSessionRequest(BaseModel):
+ """Request to delete a Pomodoro session"""
+
+ session_id: str
+
+
+class DeletePomodoroSessionData(BaseModel):
+ """Data returned after deleting a session"""
+
+ session_id: str
+ deleted_activities_count: int
+
+
+class DeletePomodoroSessionResponse(TimedOperationResponse):
+ """Response after deleting a Pomodoro session"""
+
+ data: Optional[DeletePomodoroSessionData] = None
+
+
+# Knowledge responses
+class KnowledgeData(BaseModel):
+ """Knowledge item data"""
+
+ id: str
+ title: str
+ description: str
+ keywords: List[str]
+ created_at: Optional[str] = None
+ source_action_id: Optional[str] = None
+ favorite: bool = False
+ deleted: bool = False
+
+
+class ToggleKnowledgeFavoriteResponse(TimedOperationResponse):
+ """Response after toggling knowledge favorite status"""
+
+ data: Optional[KnowledgeData] = None
+
+
+class CreateKnowledgeResponse(TimedOperationResponse):
+ """Response after creating knowledge"""
+
+ data: Optional[KnowledgeData] = None
+
+
+class UpdateKnowledgeResponse(TimedOperationResponse):
+ """Response after updating knowledge"""
+
+ data: Optional[KnowledgeData] = None
+
+
+class MergeSuggestion(BaseModel):
+ """Represents a suggested merge of similar knowledge entries"""
+
+ group_id: str
+ knowledge_ids: List[str]
+ merged_title: str
+ merged_description: str
+ merged_keywords: List[str]
+ similarity_score: float
+ merge_reason: str
+ estimated_tokens: int
+
+
+class AnalyzeKnowledgeMergeResponse(TimedOperationResponse):
+ """Response after analyzing knowledge for merge suggestions"""
+
+ suggestions: List[MergeSuggestion]
+ total_estimated_tokens: int
+ analyzed_count: int
+ suggested_merge_count: int
+
+
+class MergeResult(BaseModel):
+ """Result of executing a merge operation"""
+
+ group_id: str
+ merged_knowledge_id: str
+ deleted_knowledge_ids: List[str]
+ success: bool
+ error: Optional[str] = None
+
+
+class ExecuteKnowledgeMergeResponse(TimedOperationResponse):
+ """Response after executing knowledge merge operations"""
+
+ results: List[MergeResult]
+ total_merged: int
+ total_deleted: int
+
+
+# ==================== Pomodoro Work Phases ====================
+
+
+class WorkPhaseInfo(BaseModel):
+ """Work phase status information"""
+
+ phase_id: str
+ phase_number: int
+ status: str # pending/processing/completed/failed
+ processing_error: Optional[str] = None
+ retry_count: int
+ phase_start_time: str
+ phase_end_time: Optional[str] = None
+ activity_count: int
+
+
+class GetSessionPhasesResponse(TimedOperationResponse):
+ """Response for get_session_phases endpoint"""
+
+ data: Optional[List[WorkPhaseInfo]] = None
+
+
+# ============ Todo Responses ============
+
+
+class TodoData(BaseModel):
+ """Todo data for API responses"""
+
+ id: str
+ title: str
+ description: str
+ keywords: List[str]
+ created_at: Optional[str] = None
+ completed: bool = False
+ deleted: bool = False
+ scheduled_date: Optional[str] = None
+ scheduled_time: Optional[str] = None
+ scheduled_end_time: Optional[str] = None
+ recurrence_rule: Optional[Dict[str, Any]] = None
+ expires_at: Optional[str] = None
+ source_type: str = "ai"
+
+
+class CreateTodoResponse(TimedOperationResponse):
+ """Response for creating a todo manually"""
+
+ data: Optional[TodoData] = None
+
+
+class CleanupExpiredTodosResponse(TimedOperationResponse):
+ """Response for cleanup_expired_todos endpoint"""
+
+ data: Optional[Dict[str, int]] = None
+
+
+class CleanupSoftDeletedResponse(TimedOperationResponse):
+ """Response for cleanup_soft_deleted endpoint"""
+
+ data: Optional[Dict[str, int]] = None
diff --git a/backend/perception/active_monitor_tracker.py b/backend/perception/active_monitor_tracker.py
index c1dcbcd..69c671b 100644
--- a/backend/perception/active_monitor_tracker.py
+++ b/backend/perception/active_monitor_tracker.py
@@ -1,8 +1,13 @@
"""
Active monitor tracker for smart screenshot filtering
-Tracks which monitor is currently active based on mouse position,
+Tracks which monitor is currently active based on mouse/keyboard activity,
enabling smart screenshot capture that only captures the active screen.
+
+Key behavior:
+- Always uses the last known mouse position to determine active monitor
+- Never falls back to capturing all monitors due to inactivity
+- This ensures correct behavior when watching videos (cursor hidden but still on one screen)
"""
import time
@@ -14,19 +19,13 @@
class ActiveMonitorTracker:
- """Tracks the currently active monitor based on mouse activity"""
-
- def __init__(self, inactive_timeout: float = 30.0):
- """
- Initialize active monitor tracker
+ """Tracks the currently active monitor based on user activity"""
- Args:
- inactive_timeout: Seconds of inactivity before considering all monitors active
- """
+ def __init__(self):
+ """Initialize active monitor tracker"""
self._current_monitor_index: int = 1 # Default to primary monitor
self._monitors_info: List[Dict] = []
self._last_activity_time: float = time.time()
- self._inactive_timeout: float = inactive_timeout
self._last_mouse_position: Optional[tuple[int, int]] = None
def update_monitors_info(self, monitors: List[Dict]) -> None:
@@ -64,6 +63,16 @@ def update_from_mouse(self, x: int, y: int) -> None:
self._last_activity_time = time.time()
self._last_mouse_position = (x, y)
+ def update_from_keyboard(self) -> None:
+ """
+ Update last activity time from keyboard event
+
+ Keeps the tracker aware of user activity even when mouse isn't moving
+ (e.g., watching videos, reading content, typing)
+ """
+ self._last_activity_time = time.time()
+ logger.debug("Activity time updated from keyboard event")
+
def _get_monitor_from_position(self, x: int, y: int) -> int:
"""
Determine which monitor contains the given coordinates
@@ -103,21 +112,15 @@ def get_active_monitor_index(self) -> int:
"""
Get the currently active monitor index
+ Always returns the last known active monitor based on mouse position.
+ Never returns "capture all" - maintains single monitor focus even
+ during long periods of inactivity (e.g., watching videos).
+
Returns:
Monitor index (1-based)
"""
return self._current_monitor_index
- def should_capture_all_monitors(self) -> bool:
- """
- Check if we should capture all monitors (due to inactivity timeout)
-
- Returns:
- True if inactive for too long, False otherwise
- """
- inactive_duration = time.time() - self._last_activity_time
- return inactive_duration >= self._inactive_timeout
-
def get_stats(self) -> Dict:
"""Get tracker statistics for debugging"""
inactive_duration = time.time() - self._last_activity_time
@@ -126,6 +129,4 @@ def get_stats(self) -> Dict:
"monitors_count": len(self._monitors_info),
"last_mouse_position": self._last_mouse_position,
"inactive_duration_seconds": round(inactive_duration, 2),
- "should_capture_all": self.should_capture_all_monitors(),
- "inactive_timeout": self._inactive_timeout,
}
diff --git a/backend/perception/image_consumer.py b/backend/perception/image_consumer.py
new file mode 100644
index 0000000..729d838
--- /dev/null
+++ b/backend/perception/image_consumer.py
@@ -0,0 +1,478 @@
+"""
+Image Consumer - Batch screenshot buffering for Pomodoro mode
+
+Accumulates screenshot metadata and generates RawRecords in batches
+when threshold is reached (count-based OR time-based).
+
+This component provides:
+1. Dual buffer architecture (accumulating + processing)
+2. State machine for batch lifecycle management
+3. Timeout protection for long-running LLM calls
+4. Hybrid threshold triggering (count + time)
+"""
+
+import uuid
+from collections import OrderedDict
+from dataclasses import dataclass
+from datetime import datetime
+from enum import Enum
+from typing import Any, Callable, Dict, List, Optional
+
+from core.logger import get_logger
+from core.models import RawRecord, RecordType
+
+logger = get_logger(__name__)
+
+
+class BatchState(Enum):
+ """Batch processing state"""
+ IDLE = "idle" # No processing, ready to accept new batch
+ READY_TO_PROCESS = "ready_to_process" # Batch prepared, about to trigger
+ PROCESSING = "processing" # Batch being processed by LLM
+
+
+@dataclass
+class ScreenshotMetadata:
+ """Lightweight screenshot metadata for buffering"""
+ img_hash: str
+ timestamp: datetime
+ monitor_index: int
+ monitor_info: Dict[str, Any]
+ active_window: Optional[Dict[str, Any]]
+ screenshot_path: str
+ width: int
+ height: int
+
+
+class ImageConsumer:
+ """
+ Screenshot buffering and batch RawRecord generation for Pomodoro mode
+
+ Uses dual-buffer architecture to isolate accumulating screenshots
+ from those being processed by LLM, preventing data confusion during
+ HTTP timeouts.
+
+ Batch Triggering:
+ - COUNT threshold: 50 screenshots (default)
+ - TIME threshold: 60 seconds elapsed (default)
+ - OVERFLOW protection: 200 screenshots max (safety limit)
+
+ State Machine:
+ IDLE → READY_TO_PROCESS → PROCESSING → IDLE
+ """
+
+ def __init__(
+ self,
+ count_threshold: int = 50,
+ time_threshold: float = 60.0,
+ max_buffer_size: int = 200,
+ processing_timeout: float = 720.0,
+ on_batch_ready: Optional[Callable[[List[RawRecord], Callable[[bool], None]], None]] = None,
+ image_manager: Optional[Any] = None,
+ ):
+ """
+ Initialize ImageConsumer
+
+ Args:
+ count_threshold: Trigger batch when this many screenshots accumulated
+ time_threshold: Trigger batch after this many seconds elapsed
+ max_buffer_size: Emergency flush when buffer exceeds this size
+ processing_timeout: Timeout for batch processing (default: 12 minutes)
+ on_batch_ready: Callback to invoke with generated RawRecords batch
+ Signature: (records: List[RawRecord], on_completed: Callable[[bool], None]) -> None
+ image_manager: Reference to ImageManager for cache validation
+ """
+ self.count_threshold = count_threshold
+ self.time_threshold = time_threshold
+ self.max_buffer_size = max_buffer_size
+ self.processing_timeout = processing_timeout
+ self.on_batch_ready = on_batch_ready
+ self.image_manager = image_manager
+
+ # Dual buffer architecture
+ self._accumulating_buffer: List[ScreenshotMetadata] = []
+ self._processing_buffer: Optional[List[ScreenshotMetadata]] = None
+
+ # State machine
+ self._batch_state: BatchState = BatchState.IDLE
+ self._processing_batch_id: Optional[str] = None
+ self._processing_start_time: Optional[datetime] = None
+
+ # Track first screenshot time for time threshold
+ self._first_screenshot_time: Optional[datetime] = None
+
+ # Statistics
+ self.stats: Dict[str, int] = {
+ "total_screenshots_consumed": 0,
+ "batches_generated": 0,
+ "total_records_generated": 0,
+ "cache_misses": 0,
+ "timeout_resets": 0,
+ "overflow_flushes": 0,
+ "concurrent_trigger_attempts": 0,
+ "count_triggers": 0,
+ "time_triggers": 0,
+ }
+
+ logger.info(
+ f"ImageConsumer initialized: count_threshold={count_threshold}, "
+ f"time_threshold={time_threshold}s, max_buffer={max_buffer_size}, "
+ f"processing_timeout={processing_timeout}s"
+ )
+
+ def consume_screenshot(
+ self,
+ img_hash: str,
+ timestamp: datetime,
+ monitor_index: int,
+ monitor_info: Dict[str, Any],
+ active_window: Optional[Dict[str, Any]],
+ screenshot_path: str,
+ width: int = 0,
+ height: int = 0,
+ ) -> None:
+ """
+ Consume a screenshot (store metadata for later batch processing)
+
+ Lightweight storage - only metadata (~1KB), actual image in ImageManager cache.
+
+ Args:
+ img_hash: Perceptual hash of the screenshot
+ timestamp: Capture timestamp
+ monitor_index: Monitor index
+ monitor_info: Monitor information dict
+ active_window: Active window information (optional)
+ screenshot_path: Virtual path to screenshot
+ width: Screenshot width
+ height: Screenshot height
+ """
+ metadata = ScreenshotMetadata(
+ img_hash=img_hash,
+ timestamp=timestamp,
+ monitor_index=monitor_index,
+ monitor_info=monitor_info,
+ active_window=active_window,
+ screenshot_path=screenshot_path,
+ width=width,
+ height=height,
+ )
+
+ # Always add to accumulating buffer (even when processing)
+ self._accumulating_buffer.append(metadata)
+ self.stats["total_screenshots_consumed"] += 1
+
+ # Track first screenshot time for time threshold
+ if self._first_screenshot_time is None:
+ self._first_screenshot_time = timestamp
+
+ # Log state for debugging
+ if self._batch_state == BatchState.PROCESSING:
+ logger.debug(
+ f"Screenshot queued (batch {self._processing_batch_id} processing): "
+ f"accumulating={len(self._accumulating_buffer)}"
+ )
+
+ # Check overflow protection
+ if len(self._accumulating_buffer) >= self.max_buffer_size:
+ logger.warning(
+ f"Buffer overflow detected ({len(self._accumulating_buffer)} >= {self.max_buffer_size}), "
+ f"force flushing"
+ )
+ self.stats["overflow_flushes"] += 1
+ self._trigger_batch_generation("overflow")
+ return
+
+ # Check if should trigger batch
+ should_trigger, reason = self._should_trigger_batch()
+ if should_trigger and reason: # Ensure reason is not None
+ self._trigger_batch_generation(reason)
+
+ def _should_trigger_batch(self) -> tuple[bool, Optional[str]]:
+ """
+ Check if batch should be triggered
+
+ Returns:
+ (should_trigger, reason)
+ reason: "count" | "time" | None
+ """
+ # Don't trigger if already processing
+ if self._batch_state == BatchState.PROCESSING:
+ return False, None
+
+ # Check count threshold
+ if len(self._accumulating_buffer) >= self.count_threshold:
+ return True, "count"
+
+ # Check time threshold
+ if self._first_screenshot_time:
+ elapsed = (datetime.now() - self._first_screenshot_time).total_seconds()
+ if elapsed >= self.time_threshold:
+ return True, "time"
+
+ return False, None
+
+ def _trigger_batch_generation(self, reason: str) -> None:
+ """
+ Trigger batch generation (state transition)
+
+ Args:
+ reason: Trigger reason ("count", "time", "overflow", "manual")
+ """
+ if self._batch_state == BatchState.PROCESSING:
+ logger.warning(
+ f"Cannot trigger new batch (reason: {reason}): "
+ f"previous batch {self._processing_batch_id} still processing"
+ )
+ self.stats["concurrent_trigger_attempts"] += 1
+ return
+
+ if len(self._accumulating_buffer) == 0:
+ logger.debug("No screenshots to process, skipping batch generation")
+ return
+
+ # Update trigger stats
+ if reason == "count":
+ self.stats["count_triggers"] += 1
+ elif reason == "time":
+ self.stats["time_triggers"] += 1
+
+ # State: IDLE → READY_TO_PROCESS
+ self._batch_state = BatchState.READY_TO_PROCESS
+
+ # Move accumulating buffer to processing buffer
+ self._processing_buffer = self._accumulating_buffer
+ self._accumulating_buffer = []
+ self._first_screenshot_time = None # Reset time tracker
+
+ # Generate batch ID and record start time
+ self._processing_batch_id = str(uuid.uuid4())
+ self._processing_start_time = datetime.now()
+
+ batch_size = len(self._processing_buffer)
+ logger.info(
+ f"Triggering batch generation: batch_id={self._processing_batch_id[:8]}, "
+ f"size={batch_size}, reason={reason}"
+ )
+
+ # Generate RawRecords from metadata
+ try:
+ records = self._generate_raw_records_batch(self._processing_buffer)
+
+ # State: READY_TO_PROCESS → PROCESSING
+ self._batch_state = BatchState.PROCESSING
+
+ logger.debug(
+ f"Batch {self._processing_batch_id[:8]} ready: "
+ f"{len(records)}/{batch_size} records generated"
+ )
+
+ # Invoke callback with completion handler
+ if self.on_batch_ready:
+ self.on_batch_ready(records, self._on_batch_completed)
+ else:
+ logger.warning("No on_batch_ready callback registered, auto-completing")
+ self._on_batch_completed(True)
+
+ except Exception as e:
+ logger.error(f"Failed to generate batch {self._processing_batch_id[:8]}: {e}", exc_info=True)
+ # Reset state on failure
+ self._processing_buffer = None
+ self._batch_state = BatchState.IDLE
+ self._processing_batch_id = None
+ self._processing_start_time = None
+
+ def _generate_raw_records_batch(self, metadata_list: List[ScreenshotMetadata]) -> List[RawRecord]:
+ """
+ Generate RawRecords from buffered screenshot metadata
+
+ Args:
+ metadata_list: List of screenshot metadata
+
+ Returns:
+ List of RawRecord objects ready for processing
+ """
+ records = []
+ failed_count = 0
+
+ for meta in metadata_list:
+ # Verify image still in cache (if image_manager available)
+ if self.image_manager:
+ if not self.image_manager.get_from_cache(meta.img_hash):
+ logger.warning(
+ f"Image {meta.img_hash[:8]} evicted from cache before batch processing, "
+ f"skipping this screenshot"
+ )
+ failed_count += 1
+ self.stats["cache_misses"] += 1
+ continue
+
+ # Create RawRecord from metadata
+ screenshot_data = {
+ "action": "capture",
+ "width": meta.width,
+ "height": meta.height,
+ "format": "JPEG",
+ "hash": meta.img_hash,
+ "monitor": meta.monitor_info,
+ "monitor_index": meta.monitor_index,
+ "timestamp": meta.timestamp.isoformat(),
+ "screenshotPath": meta.screenshot_path,
+ }
+
+ if meta.active_window:
+ screenshot_data["active_window"] = meta.active_window
+
+ record = RawRecord(
+ timestamp=meta.timestamp,
+ type=RecordType.SCREENSHOT_RECORD,
+ data=screenshot_data,
+ screenshot_path=meta.screenshot_path,
+ )
+
+ records.append(record)
+
+ if failed_count > 0:
+ logger.warning(
+ f"Lost {failed_count}/{len(metadata_list)} screenshots due to cache eviction. "
+ f"Consider increasing image.memory_cache_size in config."
+ )
+
+ self.stats["batches_generated"] += 1
+ self.stats["total_records_generated"] += len(records)
+
+ return records
+
+ def _on_batch_completed(self, success: bool) -> None:
+ """
+ Batch processing completion callback
+
+ Args:
+ success: Whether batch processing succeeded
+ """
+ if not self._processing_start_time:
+ logger.warning("Batch completion called but no processing start time recorded")
+ return
+
+ elapsed = (datetime.now() - self._processing_start_time).total_seconds()
+ batch_id_short = self._processing_batch_id[:8] if self._processing_batch_id else "unknown"
+
+ if success:
+ logger.info(f"Batch {batch_id_short} completed successfully in {elapsed:.1f}s")
+ else:
+ logger.error(f"Batch {batch_id_short} failed after {elapsed:.1f}s")
+
+ # Clear processing buffer
+ self._processing_buffer = None
+ self._processing_batch_id = None
+ self._processing_start_time = None
+
+ # State: PROCESSING → IDLE
+ self._batch_state = BatchState.IDLE
+
+ logger.debug(
+ f"State reset to IDLE, accumulating buffer size: {len(self._accumulating_buffer)}"
+ )
+
+ def check_processing_timeout(self) -> bool:
+ """
+ Check if currently processing batch has timed out
+
+ Should be called periodically (e.g., on each screenshot event).
+
+ Returns:
+ True if timeout detected and state was reset
+ """
+ if self._batch_state != BatchState.PROCESSING:
+ return False
+
+ if not self._processing_start_time:
+ return False
+
+ elapsed = (datetime.now() - self._processing_start_time).total_seconds()
+
+ if elapsed > self.processing_timeout:
+ batch_id_short = self._processing_batch_id[:8] if self._processing_batch_id else "unknown"
+ logger.error(
+ f"Batch {batch_id_short} processing timeout detected: "
+ f"{elapsed:.1f}s > {self.processing_timeout}s, forcing reset"
+ )
+
+ # Force reset state (discard timed-out batch)
+ self._processing_buffer = None
+ self._processing_batch_id = None
+ self._processing_start_time = None
+ self._batch_state = BatchState.IDLE
+
+ self.stats["timeout_resets"] += 1
+
+ logger.warning(
+ f"State reset due to timeout, accumulating buffer size: {len(self._accumulating_buffer)}"
+ )
+
+ return True
+
+ return False
+
+ def flush(self) -> List[RawRecord]:
+ """
+ Force flush all buffered screenshots (called on Pomodoro end)
+
+ Flushes both accumulating and processing buffers.
+
+ Returns:
+ List of RawRecord objects from both buffers
+ """
+ records = []
+
+ # Flush processing buffer if exists
+ if self._processing_buffer:
+ logger.info(
+ f"Flushing processing buffer: batch_id={self._processing_batch_id[:8] if self._processing_batch_id else 'none'}, "
+ f"size={len(self._processing_buffer)}"
+ )
+ processing_records = self._generate_raw_records_batch(self._processing_buffer)
+ records.extend(processing_records)
+
+ # Clear processing state
+ self._processing_buffer = None
+ self._processing_batch_id = None
+ self._processing_start_time = None
+ self._batch_state = BatchState.IDLE
+
+ # Flush accumulating buffer
+ if self._accumulating_buffer:
+ logger.info(
+ f"Flushing accumulating buffer: size={len(self._accumulating_buffer)}"
+ )
+ accumulating_records = self._generate_raw_records_batch(self._accumulating_buffer)
+ records.extend(accumulating_records)
+
+ # Clear accumulating buffer
+ self._accumulating_buffer = []
+ self._first_screenshot_time = None
+
+ if records:
+ logger.info(f"Flushed total {len(records)} records from both buffers")
+ else:
+ logger.debug("No buffered screenshots to flush")
+
+ return records
+
+ def get_stats(self) -> Dict[str, Any]:
+ """
+ Get consumer statistics
+
+ Returns:
+ Dictionary with statistics including buffer sizes
+ """
+ return {
+ **self.stats,
+ "accumulating_buffer_size": len(self._accumulating_buffer),
+ "processing_buffer_size": len(self._processing_buffer) if self._processing_buffer else 0,
+ "batch_state": self._batch_state.value,
+ "processing_batch_id": self._processing_batch_id,
+ "processing_elapsed_seconds": (
+ (datetime.now() - self._processing_start_time).total_seconds()
+ if self._processing_start_time else None
+ ),
+ }
diff --git a/backend/perception/image_manager.py b/backend/perception/image_manager.py
index b4e9495..33ae9f0 100644
--- a/backend/perception/image_manager.py
+++ b/backend/perception/image_manager.py
@@ -30,7 +30,7 @@ def __init__(
str
] = None, # Screenshot storage root directory (override config)
enable_memory_first: bool = True, # Enable memory-first storage strategy
- memory_ttl: int = 75, # TTL for memory-only images (seconds)
+ memory_ttl: int = 180, # TTL for memory-only images (seconds) - Updated to 180s to meet recommended minimum
):
# Try to read custom path from configuration
try:
@@ -69,6 +69,15 @@ def __init__(
# Image metadata: hash -> (timestamp, is_persisted)
self._image_metadata: dict[str, Tuple[datetime, bool]] = {}
+ # Persistence statistics tracking
+ self.persistence_stats = {
+ "total_persist_attempts": 0,
+ "successful_persists": 0,
+ "failed_persists": 0,
+ "cache_misses": 0,
+ "already_persisted": 0,
+ }
+
self._ensure_directories()
logger.debug(
@@ -79,6 +88,14 @@ def __init__(
f"quality={thumbnail_quality}, base_dir={self.base_dir}"
)
+ # Validation: Warn if TTL seems too low for reliable persistence
+ if self.memory_ttl < 120:
+ logger.warning(
+ f"Memory TTL ({self.memory_ttl}s) is low and may cause image persistence failures. "
+ f"Recommended: ≥180s for reliable persistence. "
+ f"Increase 'image.memory_ttl_multiplier' in config.toml to fix."
+ )
+
def _select_thumbnail_size(self, img: Image.Image) -> Tuple[int, int]:
"""Choose target size based on orientation and resolution"""
width, height = img.size
@@ -255,7 +272,7 @@ def _create_thumbnail(self, img_bytes: bytes) -> bytes:
return img_bytes # Return original if thumbnail creation fails
def process_image_for_cache(self, img_hash: str, img_bytes: bytes) -> None:
- """Process image: create thumbnail and store based on memory-first strategy
+ """Process image: create thumbnail and store both in memory and disk for reliability
Args:
img_hash: Image hash value
@@ -264,17 +281,20 @@ def process_image_for_cache(self, img_hash: str, img_bytes: bytes) -> None:
try:
# Create thumbnail
thumbnail_bytes = self._create_thumbnail(img_bytes)
+ thumbnail_base64 = base64.b64encode(thumbnail_bytes).decode("utf-8")
- if self.enable_memory_first:
- # Memory-first: store in memory only
- thumbnail_base64 = base64.b64encode(thumbnail_bytes).decode("utf-8")
- self.add_to_cache(img_hash, thumbnail_base64)
- self._image_metadata[img_hash] = (datetime.now(), False) # Mark as memory-only
- logger.debug(f"Stored image in memory: {img_hash[:8]}...")
- else:
- # Legacy: immediate disk save
- self.save_thumbnail(img_hash, thumbnail_bytes)
- logger.debug(f"Processed image (thumbnail only) for hash: {img_hash[:8]}...")
+ # Always store in memory for fast access
+ self.add_to_cache(img_hash, thumbnail_base64)
+
+ # Always persist to disk immediately to prevent image loss
+ # This ensures images are never lost even if:
+ # 1. Memory cache is full and LRU evicts them
+ # 2. TTL cleanup removes them
+ # 3. System crashes before action persistence
+ self.save_thumbnail(img_hash, thumbnail_bytes)
+ self._image_metadata[img_hash] = (datetime.now(), True) # Mark as persisted
+
+ logger.debug(f"Stored image in memory AND disk: {img_hash[:8]}...")
except Exception as e:
logger.error(f"Failed to process image for cache: {e}")
@@ -288,15 +308,19 @@ def persist_image(self, img_hash: str) -> bool:
True if persisted successfully, False otherwise
"""
try:
+ self.persistence_stats["total_persist_attempts"] += 1
+
# Check if already persisted
metadata = self._image_metadata.get(img_hash)
if metadata and metadata[1]: # is_persisted = True
+ self.persistence_stats["already_persisted"] += 1
logger.debug(f"Image already persisted: {img_hash[:8]}...")
return True
# Check if exists on disk already
thumbnail_path = self.thumbnails_dir / f"{img_hash}.jpg"
if thumbnail_path.exists():
+ self.persistence_stats["already_persisted"] += 1
# Update metadata
self._image_metadata[img_hash] = (datetime.now(), True)
logger.debug(f"Image already on disk: {img_hash[:8]}...")
@@ -305,6 +329,8 @@ def persist_image(self, img_hash: str) -> bool:
# Get from memory cache
img_data = self.get_from_cache(img_hash)
if not img_data:
+ self.persistence_stats["failed_persists"] += 1
+ self.persistence_stats["cache_misses"] += 1
logger.warning(
f"Image not found in memory cache (likely evicted): {img_hash[:8]}... "
f"Cannot persist to disk."
@@ -317,11 +343,13 @@ def persist_image(self, img_hash: str) -> bool:
# Update metadata
self._image_metadata[img_hash] = (datetime.now(), True)
+ self.persistence_stats["successful_persists"] += 1
logger.debug(f"Persisted image to disk: {img_hash[:8]}...")
return True
except Exception as e:
+ self.persistence_stats["failed_persists"] += 1
logger.error(f"Failed to persist image {img_hash[:8]}: {e}")
return False
@@ -557,6 +585,14 @@ def get_stats(self) -> Dict[str, Any]:
else:
memory_only_count += 1
+ # Calculate persistence success rate
+ total_attempts = self.persistence_stats["total_persist_attempts"]
+ success_rate = (
+ self.persistence_stats["successful_persists"] / total_attempts
+ if total_attempts > 0
+ else 1.0
+ )
+
return {
"memory_cache_count": memory_count,
"memory_cache_limit": self.memory_cache_size,
@@ -572,6 +608,9 @@ def get_stats(self) -> Dict[str, Any]:
"memory_ttl_seconds": self.memory_ttl,
"memory_only_images": memory_only_count,
"persisted_images_in_cache": persisted_count,
+ # Persistence stats
+ "persistence_success_rate": round(success_rate, 4),
+ "persistence_stats": self.persistence_stats,
}
except Exception as e:
@@ -659,7 +698,7 @@ def get_image_manager() -> ImageManager:
"""Get image manager singleton"""
global _image_manager
if _image_manager is None:
- _image_manager = ImageManager()
+ _image_manager = init_image_manager()
return _image_manager
@@ -674,16 +713,26 @@ def init_image_manager(**kwargs) -> ImageManager:
config = get_config().load()
- enable_memory_first = config.get("image.enable_memory_first", True)
- processing_interval = config.get("monitoring.processing_interval", 30)
- multiplier = config.get("image.memory_ttl_multiplier", 2.5)
- ttl_min = config.get("image.memory_ttl_min", 60)
- ttl_max = config.get("image.memory_ttl_max", 120)
+ # Access nested config values correctly
+ image_config = config.get("image", {})
+ monitoring_config = config.get("monitoring", {})
+
+ enable_memory_first = image_config.get("enable_memory_first", True)
+ processing_interval = monitoring_config.get("processing_interval", 30)
+ multiplier = image_config.get("memory_ttl_multiplier", 2.5)
+ ttl_min = image_config.get("memory_ttl_min", 60)
+ ttl_max = image_config.get("memory_ttl_max", 300)
# Calculate dynamic TTL
calculated_ttl = int(processing_interval * multiplier)
memory_ttl = max(ttl_min, min(ttl_max, calculated_ttl))
+ logger.debug(
+ f"ImageManager config: processing_interval={processing_interval}, "
+ f"multiplier={multiplier}, ttl_min={ttl_min}, ttl_max={ttl_max}, "
+ f"calculated_ttl={calculated_ttl}, final_memory_ttl={memory_ttl}"
+ )
+
if "enable_memory_first" not in kwargs:
kwargs["enable_memory_first"] = enable_memory_first
if "memory_ttl" not in kwargs:
@@ -691,10 +740,10 @@ def init_image_manager(**kwargs) -> ImageManager:
logger.info(
f"ImageManager: memory_first={enable_memory_first}, "
- f"TTL={memory_ttl}s (processing_interval={processing_interval}s)"
+ f"TTL={memory_ttl}s (processing_interval={processing_interval}s * multiplier={multiplier})"
)
except Exception as e:
- logger.warning(f"Failed to calculate memory TTL from config: {e}")
+ logger.warning(f"Failed to calculate memory TTL from config: {e}", exc_info=True)
_image_manager = ImageManager(**kwargs)
return _image_manager
diff --git a/backend/perception/manager.py b/backend/perception/manager.py
index d79a2bf..6f31348 100644
--- a/backend/perception/manager.py
+++ b/backend/perception/manager.py
@@ -6,6 +6,7 @@
"""
import asyncio
+import time
from datetime import datetime
from typing import Any, Callable, Dict, Optional
@@ -53,8 +54,7 @@ def __init__(
self.on_system_wake_callback = on_system_wake
# Initialize active monitor tracker for smart screenshot capture
- # inactive_timeout will be loaded from settings during start()
- self.monitor_tracker = ActiveMonitorTracker(inactive_timeout=30.0)
+ self.monitor_tracker = ActiveMonitorTracker()
# Create active window capture first (needed by screenshot capture for context enrichment)
# No callback needed as window info is embedded in screenshot records
@@ -89,6 +89,15 @@ def __init__(
self.keyboard_enabled = True
self.mouse_enabled = True
+ # Pomodoro mode state
+ self.pomodoro_session_id: Optional[str] = None
+
+ # ImageConsumer for Pomodoro buffering (initialized when Pomodoro starts)
+ self.image_consumer: Optional[Any] = None
+
+ # Event loop reference (set when start() is called)
+ self._event_loop: Optional[asyncio.AbstractEventLoop] = None
+
def _on_screen_lock(self) -> None:
"""Screen lock/system sleep callback"""
if not self.is_running:
@@ -115,6 +124,16 @@ def _on_screen_lock(self) -> None:
except Exception as e:
logger.error(f"Failed to pause capturers: {e}")
+ def _notify_record_available(self) -> None:
+ """Notify coordinator that a new record is available (event-driven triggering)"""
+ try:
+ from core.coordinator import get_coordinator
+ coordinator = get_coordinator()
+ if coordinator:
+ coordinator.notify_records_available(count=1)
+ except Exception as e:
+ logger.error(f"Failed to notify coordinator: {e}")
+
def _on_screen_unlock(self) -> None:
"""Screen unlock/system wake callback"""
if not self.is_running or not self.is_paused:
@@ -148,13 +167,25 @@ def _on_keyboard_event(self, record: RawRecord) -> None:
return
try:
- # Record all keyboard events for subsequent processing to preserve usage context
+ # Tag with Pomodoro session ID if active (for future use)
+ if self.pomodoro_session_id:
+ record.data['pomodoro_session_id'] = self.pomodoro_session_id
+
+ # Always add to memory for real-time viewing and processing
self.storage.add_record(record)
self.event_buffer.add(record)
if self.on_data_captured:
self.on_data_captured(record)
+ # Notify coordinator that a new record is available
+ self._notify_record_available()
+
+ # Update monitor tracker with keyboard activity
+ # This keeps smart capture aware of user activity even when mouse is hidden
+ if self.monitor_tracker:
+ self.monitor_tracker.update_from_keyboard()
+
logger.debug(
f"Keyboard event recorded: {record.data.get('key', 'unknown')}"
)
@@ -170,12 +201,20 @@ def _on_mouse_event(self, record: RawRecord) -> None:
try:
# Only record important mouse events
if self.mouse_capture.is_important_event(record.data):
+ # Tag with Pomodoro session ID if active (for future use)
+ if self.pomodoro_session_id:
+ record.data['pomodoro_session_id'] = self.pomodoro_session_id
+
+ # Always add to memory for real-time viewing and processing
self.storage.add_record(record)
self.event_buffer.add(record)
if self.on_data_captured:
self.on_data_captured(record)
+ # Notify coordinator that a new record is available
+ self._notify_record_available()
+
logger.debug(
f"Mouse event recorded: {record.data.get('action', 'unknown')}"
)
@@ -201,18 +240,59 @@ def _on_screenshot_event(self, record: RawRecord) -> None:
try:
if record: # Screenshot may be None (duplicate screenshots)
- self.storage.add_record(record)
- self.event_buffer.add(record)
-
- if self.on_data_captured:
- self.on_data_captured(record)
+ # NEW: In Pomodoro mode with buffering, check timeout and route to ImageConsumer
+ if self.pomodoro_session_id and self.image_consumer:
+ # Periodic timeout check (performance: ~1ms per check)
+ self.image_consumer.check_processing_timeout()
+
+ # Send to ImageConsumer for buffering
+ self.image_consumer.consume_screenshot(
+ img_hash=record.data.get("hash", ""),
+ timestamp=record.timestamp,
+ monitor_index=record.data.get("monitor_index", 0),
+ monitor_info=record.data.get("monitor", {}),
+ active_window=record.data.get("active_window"),
+ screenshot_path=record.screenshot_path or "",
+ width=record.data.get("width", 0),
+ height=record.data.get("height", 0),
+ )
+ # Don't process immediately - ImageConsumer will batch
+ logger.debug(f"Screenshot buffered for Pomodoro session: {record.data.get('hash', '')[:8]}")
+ return
+
+ # Normal flow (non-Pomodoro or buffering disabled)
+ self._on_screenshot_captured(record)
- logger.debug(
- f"Screenshot recorded: {record.data.get('width', 0)}x{record.data.get('height', 0)}"
- )
except Exception as e:
logger.error(f"Failed to process screenshot event: {e}")
+ def _on_screenshot_captured(self, record: RawRecord) -> None:
+ """Process captured screenshot (common path for buffered and normal flow)
+
+ Args:
+ record: RawRecord containing screenshot data
+ """
+ try:
+ # Tag with Pomodoro session ID if active
+ if self.pomodoro_session_id:
+ record.data['pomodoro_session_id'] = self.pomodoro_session_id
+
+ # Always add to memory for real-time viewing and processing
+ self.storage.add_record(record)
+ self.event_buffer.add(record)
+
+ if self.on_data_captured:
+ self.on_data_captured(record)
+
+ # Notify coordinator that a new record is available
+ self._notify_record_available()
+
+ logger.debug(
+ f"Screenshot recorded: {record.data.get('width', 0)}x{record.data.get('height', 0)}"
+ )
+ except Exception as e:
+ logger.error(f"Failed to record screenshot: {e}")
+
async def start(self) -> None:
"""Start perception manager"""
from datetime import datetime
@@ -226,6 +306,9 @@ async def start(self) -> None:
self.is_running = True
self.is_paused = False
+ # Store event loop reference for sync callbacks
+ self._event_loop = asyncio.get_running_loop()
+
# Load perception settings
from core.settings import get_settings
@@ -233,9 +316,8 @@ async def start(self) -> None:
self.keyboard_enabled = settings.get("perception.keyboard_enabled", True)
self.mouse_enabled = settings.get("perception.mouse_enabled", True)
- # Load smart capture settings
- inactive_timeout = settings.get("screenshot.inactive_timeout", 30.0)
- self.monitor_tracker._inactive_timeout = float(inactive_timeout)
+ # Note: inactive_timeout setting is no longer used
+ # Smart capture now always uses last known mouse position
# Start screen state monitor
start_time = datetime.now()
@@ -309,6 +391,9 @@ async def stop(self) -> None:
self.is_running = False
self.is_paused = False
+ # Clear event loop reference
+ self._event_loop = None
+
# Stop screen state monitor
self.screen_state_monitor.stop()
@@ -345,19 +430,29 @@ async def stop(self) -> None:
async def _screenshot_loop(self) -> None:
"""Screenshot loop task"""
try:
- loop = asyncio.get_event_loop()
+ iteration = 0
+
while self.is_running:
- # Execute synchronous screenshot operation in thread pool to avoid blocking event loop
- await loop.run_in_executor(
- None,
- self.screenshot_capture.capture_with_interval,
- self.capture_interval,
- )
- await asyncio.sleep(0.1) # Brief sleep to avoid excessive CPU usage
+ iteration += 1
+ loop_start = time.time()
+
+ # Directly call capture() without interval checking
+ # The loop itself controls the timing
+ try:
+ self.screenshot_capture.capture()
+ except Exception as e:
+ logger.error(f"Screenshot capture failed: {e}", exc_info=True)
+
+ elapsed = time.time() - loop_start
+
+ # Sleep for the interval, accounting for capture time
+ sleep_time = max(0.1, self.capture_interval - elapsed)
+ await asyncio.sleep(sleep_time)
+
except asyncio.CancelledError:
logger.debug("Screenshot loop task cancelled")
except Exception as e:
- logger.error(f"Screenshot loop task failed: {e}")
+ logger.error(f"Screenshot loop task failed: {e}", exc_info=True)
async def _cleanup_loop(self) -> None:
"""Cleanup loop task"""
@@ -408,6 +503,18 @@ def get_records_in_timeframe(
"""Get records within specified time range"""
return self.storage.get_records_in_timeframe(start_time, end_time)
+ def get_expiring_records(self, expiration_threshold: Optional[int] = None) -> list:
+ """
+ Get records that are about to expire (for pre-processing before cleanup)
+
+ Args:
+ expiration_threshold: Time in seconds before expiration to consider
+
+ Returns:
+ List of records that are about to expire
+ """
+ return self.storage.get_expiring_records(expiration_threshold)
+
def get_records_in_last_n_seconds(self, seconds: int) -> list:
"""Get records from last N seconds"""
from datetime import datetime, timedelta
@@ -451,6 +558,19 @@ def _update_monitor_info(self) -> None:
except Exception as e:
logger.error(f"Failed to update monitor info: {e}")
+ def handle_monitors_changed(self) -> None:
+ """Handle monitor configuration changes (rotation, resolution, etc.)
+
+ This should be called when the 'monitors-changed' event is detected
+ to update monitor bounds in the active monitor tracker.
+ """
+ if not self.is_running:
+ logger.debug("Perception not running, skipping monitor update")
+ return
+
+ logger.info("Monitor configuration changed, updating monitor tracker")
+ self._update_monitor_info()
+
def get_stats(self) -> Dict[str, Any]:
"""Get manager statistics"""
try:
@@ -523,3 +643,123 @@ def update_perception_settings(
logger.debug(
f"Perception settings updated: keyboard={self.keyboard_enabled}, mouse={self.mouse_enabled}"
)
+
+ def set_pomodoro_session(self, session_id: str) -> None:
+ """
+ Set Pomodoro session ID for tagging captured records
+
+ Args:
+ session_id: Pomodoro session identifier
+ """
+ self.pomodoro_session_id = session_id
+
+ # Initialize ImageConsumer for this session
+ from core.settings import get_settings
+
+ config = get_settings().get_pomodoro_buffering_config()
+
+ if config["enabled"]:
+ from perception.image_consumer import ImageConsumer
+ from perception.image_manager import get_image_manager
+
+ self.image_consumer = ImageConsumer(
+ count_threshold=config["count_threshold"],
+ time_threshold=config["time_threshold"],
+ max_buffer_size=config["max_buffer_size"],
+ processing_timeout=config["processing_timeout"],
+ on_batch_ready=self._on_batch_ready,
+ image_manager=get_image_manager(),
+ )
+ logger.info(
+ f"✓ ImageConsumer initialized for Pomodoro session {session_id}: "
+ f"count_threshold={config['count_threshold']}, "
+ f"time_threshold={config['time_threshold']}s"
+ )
+ else:
+ logger.debug("Screenshot buffering disabled, using normal flow")
+
+ logger.debug(f"✓ Pomodoro session set: {session_id}")
+
+ def clear_pomodoro_session(self) -> None:
+ """Clear Pomodoro session ID (exit Pomodoro mode)"""
+ session_id = self.pomodoro_session_id
+
+ # Flush remaining screenshots before clearing
+ if self.image_consumer:
+ remaining = self.image_consumer.flush()
+ if remaining:
+ logger.info(f"Flushing {len(remaining)} buffered screenshots")
+ for record in remaining:
+ # Tag with session ID and process normally
+ record.data['pomodoro_session_id'] = session_id
+ self._on_screenshot_captured(record)
+
+ # Get stats before cleanup
+ stats = self.image_consumer.get_stats()
+ logger.info(
+ f"ImageConsumer stats: batches={stats['batches_generated']}, "
+ f"records={stats['total_records_generated']}, "
+ f"cache_misses={stats['cache_misses']}, "
+ f"timeouts={stats['timeout_resets']}"
+ )
+
+ self.image_consumer = None
+
+ self.pomodoro_session_id = None
+ logger.debug(f"✓ Pomodoro session cleared: {session_id}")
+
+ def _on_batch_ready(
+ self,
+ raw_records: list,
+ on_completed: Callable[[bool], None],
+ ) -> None:
+ """
+ Batch ready callback from ImageConsumer
+
+ Args:
+ raw_records: List of RawRecord objects from batch
+ on_completed: Callback to invoke when batch processing completes
+ Signature: (success: bool) -> None
+ """
+ logger.debug(f"Processing batch of {len(raw_records)} RawRecords")
+
+ try:
+ for record in raw_records:
+ # Tag with session ID (should already be set but ensure consistency)
+ if self.pomodoro_session_id:
+ record.data['pomodoro_session_id'] = self.pomodoro_session_id
+
+ # Process through normal screenshot captured path
+ self._on_screenshot_captured(record)
+
+ # Batch processed successfully
+ on_completed(True)
+ logger.debug(f"✓ Batch of {len(raw_records)} records processed successfully")
+
+ # Note: _on_screenshot_captured already calls _notify_record_available() for each record
+ # No need to notify again here
+
+ except Exception as e:
+ logger.error(f"Failed to process batch: {e}", exc_info=True)
+ on_completed(False)
+
+ async def _persist_raw_record(self, record: RawRecord) -> None:
+ """
+ Persist raw record to database (Pomodoro mode)
+
+ Args:
+ record: RawRecord to persist
+ """
+ try:
+ import json
+ from core.db import get_db
+
+ db = get_db()
+ await db.raw_records.save(
+ timestamp=record.timestamp.isoformat(),
+ record_type=record.type.value, # Convert enum to string
+ data=json.dumps(record.data),
+ pomodoro_session_id=record.data.get('pomodoro_session_id'),
+ )
+ except Exception as e:
+ logger.error(f"Failed to persist raw record: {e}", exc_info=True)
diff --git a/backend/perception/screenshot_capture.py b/backend/perception/screenshot_capture.py
index 9a90aa9..7e2d20d 100644
--- a/backend/perception/screenshot_capture.py
+++ b/backend/perception/screenshot_capture.py
@@ -40,9 +40,6 @@ def __init__(
self._last_screenshot_time = 0
# Per-monitor deduplication state
self._last_hashes: Dict[int, Optional[str]] = {}
- self._last_force_save_times: Dict[int, float] = {}
- # Force save interval: read from settings, default 60 seconds
- self._force_save_interval = self._get_force_save_interval()
self._screenshot_count = 0
self._compression_quality = 90
self._max_width = 2560
@@ -91,7 +88,7 @@ def _get_enabled_monitor_indices(self) -> List[int]:
"""Load enabled monitor indices from settings, with smart capture support.
Returns:
- - If smart capture enabled and tracker available: [active_monitor_index]
+ - If smart capture enabled and tracker available: intersection of active monitor and enabled monitors
- If screen settings exist and some are enabled: list of enabled indices
- If screen settings exist but none enabled: empty list (respect user's choice)
- If no screen settings configured or read fails: [1] (primary)
@@ -99,35 +96,41 @@ def _get_enabled_monitor_indices(self) -> List[int]:
try:
settings = get_settings()
+ # Get configured enabled monitors from settings
+ # Use the proper method to read screen settings from database
+ screens = settings.get_screenshot_screen_settings()
+ if not screens:
+ # Not configured -> default to primary only
+ configured_enabled = [1]
+ else:
+ configured_enabled = [int(s.get("monitor_index")) for s in screens if s.get("is_enabled")]
+ # Deduplicate while preserving order
+ seen = set()
+ result: List[int] = []
+ for i in configured_enabled:
+ if i not in seen:
+ seen.add(i)
+ result.append(i)
+ configured_enabled = result
+
# Check if smart capture is enabled
smart_capture_enabled = settings.get("screenshot.smart_capture_enabled", False)
if smart_capture_enabled and self.monitor_tracker:
- # Check if we should capture all monitors due to inactivity
- if self.monitor_tracker.should_capture_all_monitors():
- logger.debug(
- "Inactivity timeout reached, capturing all enabled monitors"
- )
- else:
- # Only capture the active monitor
- active_index = self.monitor_tracker.get_active_monitor_index()
+ # Smart capture: only capture the active monitor if it's enabled in settings
+ # Always uses last known mouse position, never falls back to "capture all"
+ active_index = self.monitor_tracker.get_active_monitor_index()
+ if active_index in configured_enabled:
logger.debug(f"Smart capture: only capturing monitor {active_index}")
return [active_index]
+ else:
+ logger.debug(
+ f"Smart capture: active monitor {active_index} is not enabled in settings, skipping"
+ )
+ return []
- # Fallback to configured screen settings
- screens = settings.get("screenshot.screen_settings", None)
- if not isinstance(screens, list) or len(screens) == 0:
- # Not configured -> default to primary only
- return [1]
- enabled = [int(s.get("monitor_index")) for s in screens if s.get("is_enabled")]
- # Deduplicate while preserving order
- seen = set()
- result: List[int] = []
- for i in enabled:
- if i not in seen:
- seen.add(i)
- result.append(i)
- return result
+ # Return configured enabled monitors (smart capture disabled)
+ return configured_enabled
except Exception as e:
logger.warning(f"Failed to read screen settings, fallback to primary: {e}")
return [1]
@@ -135,7 +138,7 @@ def _get_enabled_monitor_indices(self) -> List[int]:
def _capture_one_monitor(
self, sct: MSSBase, monitor_index: int
) -> Optional[RawRecord]:
- """Capture one monitor and emit a record if not duplicate (or force-save interval reached)."""
+ """Capture one monitor and emit a record if not duplicate."""
try:
monitor = sct.monitors[monitor_index]
screenshot = sct.grab(monitor)
@@ -145,24 +148,13 @@ def _capture_one_monitor(
img = self._process_image(img)
img_hash = self._calculate_hash(img)
- current_time = time.time()
last_hash = self._last_hashes.get(monitor_index)
- last_force = self._last_force_save_times.get(monitor_index, 0.0)
is_duplicate = last_hash == img_hash
- time_since_force_save = current_time - last_force
- should_force_save = time_since_force_save >= self._force_save_interval
- if is_duplicate and not should_force_save:
+ if is_duplicate:
logger.debug(f"Skip duplicate screenshot on monitor {monitor_index}")
return None
- if is_duplicate and should_force_save:
- logger.debug(
- f"Force keep duplicate screenshot on monitor {monitor_index} "
- f"({time_since_force_save:.1f}s since last save)"
- )
- self._last_force_save_times[monitor_index] = current_time
-
self._last_hashes[monitor_index] = img_hash
self._screenshot_count += 1
@@ -323,7 +315,9 @@ def capture_with_interval(self, interval: float = 1.0):
return
current_time = time.time()
- if current_time - self._last_screenshot_time >= interval:
+ time_since_last = current_time - self._last_screenshot_time
+
+ if time_since_last >= interval:
self.capture()
self._last_screenshot_time = current_time
@@ -366,23 +360,6 @@ def get_stats(self) -> dict:
"tmp_dir": self.tmp_dir,
}
- def _get_force_save_interval(self) -> float:
- """Get force save interval from settings
-
- Returns the interval in seconds after which a screenshot will be force-saved
- even if it appears to be a duplicate. Defaults to 60 seconds (1 minute).
- """
- try:
- settings = get_settings()
- interval = settings.get_screenshot_force_save_interval()
- logger.debug(f"Force save interval: {interval}s")
- return interval
- except Exception as e:
- logger.warning(
- f"Failed to read force save interval from settings: {e}, using default 60s"
- )
- return 60.0 # Default 1 minute
-
def _ensure_tmp_dir(self) -> None:
"""Ensure tmp directory exists"""
try:
diff --git a/backend/perception/storage.py b/backend/perception/storage.py
index f34aa1a..9a70c8c 100644
--- a/backend/perception/storage.py
+++ b/backend/perception/storage.py
@@ -113,13 +113,52 @@ def _cleanup_expired_records(self) -> None:
current_time = datetime.now()
cutoff_time = current_time - timedelta(seconds=self.window_size)
- # Remove expired records from left side
+ # Count how many records will be removed
+ removed_count = 0
while self.records and self.records[0].timestamp < cutoff_time:
self.records.popleft()
+ removed_count += 1
+
+ if removed_count > 0:
+ logger.debug(f"Cleaned up {removed_count} expired records (older than {self.window_size}s)")
except Exception as e:
logger.error(f"Failed to clean up expired records: {e}")
+ def get_expiring_records(self, expiration_threshold: Optional[int] = None) -> List[RawRecord]:
+ """
+ Get records that are about to expire (for pre-processing before cleanup)
+
+ Args:
+ expiration_threshold: Time in seconds before expiration to consider (default: 90% of window_size)
+
+ Returns:
+ List of records that are about to expire
+ """
+ try:
+ with self.lock:
+ if expiration_threshold is None:
+ # Default: records older than 90% of window size (e.g., 54s for 60s window)
+ expiration_threshold = int(self.window_size * 0.9)
+
+ current_time = datetime.now()
+ expiration_cutoff = current_time - timedelta(seconds=expiration_threshold)
+
+ # Find records that are old but not yet expired
+ expiring_records = []
+ for record in self.records:
+ if record.timestamp < expiration_cutoff:
+ expiring_records.append(record)
+ else:
+ # Records are sorted by time, so we can stop here
+ break
+
+ return expiring_records
+
+ except Exception as e:
+ logger.error(f"Failed to get expiring records: {e}")
+ return []
+
def clear(self) -> None:
"""Clear all records"""
try:
diff --git a/backend/processing/behavior_analyzer.py b/backend/processing/behavior_analyzer.py
new file mode 100644
index 0000000..f2d51bd
--- /dev/null
+++ b/backend/processing/behavior_analyzer.py
@@ -0,0 +1,458 @@
+"""
+Behavior Analyzer - Classify user behavior patterns from keyboard/mouse data
+
+This module analyzes keyboard and mouse activity patterns to distinguish between:
+- Operation (active work): coding, writing, designing
+- Browsing (passive consumption): reading, watching, learning
+- Mixed: combination of both
+
+The classification is based on:
+- Keyboard activity (60% weight): event frequency, typing intensity, modifier usage
+- Mouse activity (40% weight): click/scroll/drag ratios, position variance
+"""
+
+from datetime import datetime, timedelta
+from typing import Any, Dict, List, Optional
+
+from core.logger import get_logger
+from core.models import RawRecord, RecordType
+
+logger = get_logger(__name__)
+
+
+class BehaviorAnalyzer:
+ """
+ Analyzes keyboard and mouse patterns to classify user behavior
+
+ Behavior Types:
+ - operation: Active work (coding, writing, designing)
+ - browsing: Passive consumption (reading, watching, browsing)
+ - mixed: Combination of both
+ """
+
+ def __init__(
+ self,
+ operation_threshold: float = 0.6,
+ browsing_threshold: float = 0.3,
+ keyboard_weight: float = 0.6,
+ mouse_weight: float = 0.4,
+ ):
+ """
+ Initialize behavior analyzer
+
+ Args:
+ operation_threshold: Score threshold for operation classification (default: 0.6)
+ browsing_threshold: Score threshold for browsing classification (default: 0.3)
+ keyboard_weight: Weight for keyboard metrics 0-1 (default: 0.6)
+ mouse_weight: Weight for mouse metrics 0-1 (default: 0.4)
+ """
+ self.operation_threshold = operation_threshold
+ self.browsing_threshold = browsing_threshold
+ self.keyboard_weight = keyboard_weight
+ self.mouse_weight = mouse_weight
+
+ logger.debug(
+ f"BehaviorAnalyzer initialized "
+ f"(op_threshold={operation_threshold}, "
+ f"browse_threshold={browsing_threshold}, "
+ f"kb_weight={keyboard_weight}, "
+ f"mouse_weight={mouse_weight})"
+ )
+
+ def analyze(
+ self,
+ keyboard_records: Optional[List[RawRecord]] = None,
+ mouse_records: Optional[List[RawRecord]] = None,
+ ) -> Dict[str, Any]:
+ """
+ Analyze behavior from keyboard and mouse records
+
+ Args:
+ keyboard_records: Filtered keyboard events
+ mouse_records: Filtered mouse events
+
+ Returns:
+ Behavior analysis result dictionary with structure:
+ {
+ "behavior_type": "operation" | "browsing" | "mixed",
+ "confidence": 0.0-1.0,
+ "metrics": {
+ "keyboard_activity": {...},
+ "mouse_activity": {...},
+ "combined_score": float,
+ "reasoning": str
+ }
+ }
+ """
+ # Calculate time window
+ time_window = self._calculate_time_window(keyboard_records, mouse_records)
+
+ # Analyze keyboard patterns
+ kb_metrics = self._analyze_keyboard_activity(
+ keyboard_records or [], time_window
+ )
+
+ # Analyze mouse patterns
+ mouse_metrics = self._analyze_mouse_activity(mouse_records or [], time_window)
+
+ # Classify behavior
+ result = self._classify_behavior(kb_metrics, mouse_metrics)
+
+ logger.debug(
+ f"Behavior analysis: {result['behavior_type']} "
+ f"(confidence={result['confidence']:.2f}, "
+ f"kb_score={kb_metrics['score']:.2f}, "
+ f"mouse_score={mouse_metrics['score']:.2f})"
+ )
+
+ return result
+
+ def _calculate_time_window(
+ self,
+ keyboard_records: Optional[List[RawRecord]],
+ mouse_records: Optional[List[RawRecord]],
+ ) -> float:
+ """
+ Calculate analysis time window from record timestamps
+
+ Args:
+ keyboard_records: Keyboard event records
+ mouse_records: Mouse event records
+
+ Returns:
+ Time window duration in seconds (minimum 1.0)
+ """
+ all_records = []
+ if keyboard_records:
+ all_records.extend(keyboard_records)
+ if mouse_records:
+ all_records.extend(mouse_records)
+
+ if not all_records:
+ return 20.0 # default 20 seconds
+
+ timestamps = [r.timestamp for r in all_records]
+ time_span = (max(timestamps) - min(timestamps)).total_seconds()
+
+ return max(time_span, 1.0) # at least 1 second
+
+ def _analyze_keyboard_activity(
+ self, keyboard_records: List[RawRecord], time_window: float
+ ) -> Dict[str, Any]:
+ """
+ Analyze keyboard patterns to determine activity level
+
+ Metrics:
+ 1. Events per minute (EPM) - raw activity level
+ 2. Typing intensity - char keys / total keys ratio
+ 3. Modifier usage - shortcuts (cmd+key, ctrl+key) ratio
+
+ Args:
+ keyboard_records: Keyboard event records
+ time_window: Analysis window duration in seconds
+
+ Returns:
+ Keyboard activity metrics dict
+ """
+ if not keyboard_records:
+ return {
+ "events_per_minute": 0,
+ "typing_intensity": 0,
+ "modifier_usage": 0,
+ "score": 0,
+ }
+
+ # Calculate events per minute
+ epm = len(keyboard_records) / (time_window / 60) if time_window > 0 else 0
+
+ # Classify key types
+ char_keys = 0 # a-z, 0-9 (actual typing)
+ special_keys = 0 # enter, backspace, arrows (navigation)
+ modifier_combos = 0 # cmd+s, ctrl+c (shortcuts)
+
+ for record in keyboard_records:
+ key_type = record.data.get("key_type", "")
+ modifiers = record.data.get("modifiers", [])
+
+ if key_type == "char":
+ char_keys += 1
+ elif key_type == "special":
+ special_keys += 1
+
+ if modifiers and len(modifiers) > 0:
+ modifier_combos += 1
+
+ total_keys = char_keys + special_keys
+
+ # Calculate ratios
+ typing_intensity = char_keys / total_keys if total_keys > 0 else 0
+ modifier_usage = modifier_combos / total_keys if total_keys > 0 else 0
+
+ # Scoring (0-1 scale)
+ # Operation: high EPM (>10), high typing (>0.6), moderate modifiers (>0.1)
+ # Browsing: low EPM (<5), low typing (<0.3), few modifiers (<0.05)
+
+ epm_score = min(epm / 20, 1.0) # normalize to 20 EPM = 1.0
+ typing_score = typing_intensity
+ modifier_score = min(modifier_usage / 0.2, 1.0) # 20% modifiers = 1.0
+
+ # Weighted combination
+ score = epm_score * 0.4 + typing_score * 0.4 + modifier_score * 0.2
+
+ return {
+ "events_per_minute": epm,
+ "typing_intensity": typing_intensity,
+ "modifier_usage": modifier_usage,
+ "score": score,
+ }
+
+ def _analyze_mouse_activity(
+ self, mouse_records: List[RawRecord], time_window: float
+ ) -> Dict[str, Any]:
+ """
+ Analyze mouse patterns to determine work style
+
+ Patterns:
+ - Operation: precise clicks, drags, frequent position changes
+ - Browsing: continuous scrolling, few clicks, linear movement
+
+ Args:
+ mouse_records: Mouse event records
+ time_window: Analysis window duration in seconds
+
+ Returns:
+ Mouse activity metrics dict
+ """
+ if not mouse_records:
+ return {
+ "click_ratio": 0,
+ "scroll_ratio": 0,
+ "drag_ratio": 0,
+ "precision_score": 0,
+ "score": 0,
+ }
+
+ # Count event types
+ clicks = 0
+ scrolls = 0
+ drags = 0
+ positions = []
+
+ for record in mouse_records:
+ action = record.data.get("action", "")
+
+ if action in ["click", "press", "release"]:
+ clicks += 1
+ position = record.data.get("position")
+ if position:
+ positions.append(position)
+ elif action == "scroll":
+ scrolls += 1
+ elif action in ["drag", "drag_end"]:
+ drags += 1
+ position = record.data.get("position")
+ if position:
+ positions.append(position)
+
+ total_events = clicks + scrolls + drags
+
+ # Calculate ratios
+ click_ratio = clicks / total_events if total_events > 0 else 0
+ scroll_ratio = scrolls / total_events if total_events > 0 else 0
+ drag_ratio = drags / total_events if total_events > 0 else 0
+
+ # Calculate precision score (movement variance)
+ # High variance = precise targeting (operation)
+ # Low variance = linear scrolling (browsing)
+ precision_score = self._calculate_position_variance(positions)
+
+ # Scoring
+ # Operation: high clicks (>0.4), low scroll (<0.5), high precision (>0.5)
+ # Browsing: low clicks (<0.2), high scroll (>0.7), low precision (<0.3)
+
+ click_score = click_ratio
+ scroll_score = 1.0 - scroll_ratio # inverse (low scroll = high score)
+ drag_score = drag_ratio * 2.0 # drags strongly indicate operation
+ precision_score_normalized = precision_score
+
+ score = (
+ click_score * 0.3
+ + scroll_score * 0.2
+ + min(drag_score, 1.0) * 0.2
+ + precision_score_normalized * 0.3
+ )
+
+ return {
+ "click_ratio": click_ratio,
+ "scroll_ratio": scroll_ratio,
+ "drag_ratio": drag_ratio,
+ "precision_score": precision_score,
+ "score": score,
+ }
+
+ def _calculate_position_variance(self, positions: List[tuple]) -> float:
+ """
+ Calculate normalized variance of mouse positions
+
+ Higher variance indicates precise targeting (operation mode)
+ Lower variance indicates linear movement (browsing mode)
+
+ Args:
+ positions: List of (x, y) position tuples
+
+ Returns:
+ Normalized variance score (0-1)
+ """
+ if len(positions) < 2:
+ return 0.5 # neutral score for insufficient data
+
+ # Calculate variance manually (avoiding numpy dependency)
+ x_coords = [p[0] for p in positions]
+ y_coords = [p[1] for p in positions]
+
+ # Mean
+ x_mean = sum(x_coords) / len(x_coords)
+ y_mean = sum(y_coords) / len(y_coords)
+
+ # Variance
+ x_var = sum((x - x_mean) ** 2 for x in x_coords) / len(x_coords)
+ y_var = sum((y - y_mean) ** 2 for y in y_coords) / len(y_coords)
+
+ # Average variance
+ avg_variance = (x_var + y_var) / 2
+
+ # Normalize to 0-1 (assuming screen ~1920x1080)
+ # High variance (100000+) = 1.0, low variance = 0.0
+ normalized = min(avg_variance / 100000, 1.0)
+
+ return normalized
+
+ def _classify_behavior(
+ self, kb_metrics: Dict[str, Any], mouse_metrics: Dict[str, Any]
+ ) -> Dict[str, Any]:
+ """
+ Combine keyboard and mouse metrics to determine behavior type
+
+ Weighting:
+ - Keyboard: 60% (stronger signal for operation vs browsing)
+ - Mouse: 40% (supporting evidence)
+
+ Args:
+ kb_metrics: Keyboard activity metrics
+ mouse_metrics: Mouse activity metrics
+
+ Returns:
+ Classification result with behavior_type, confidence, and metrics
+ """
+ kb_score = kb_metrics["score"]
+ mouse_score = mouse_metrics["score"]
+
+ # Weighted combination
+ combined_score = kb_score * self.keyboard_weight + mouse_score * self.mouse_weight
+
+ # Classification thresholds
+ if combined_score >= self.operation_threshold:
+ behavior_type = "operation"
+ confidence = min(combined_score, 1.0)
+ elif combined_score <= self.browsing_threshold:
+ behavior_type = "browsing"
+ confidence = min(1.0 - combined_score, 1.0)
+ else:
+ behavior_type = "mixed"
+ # Lower confidence in middle range
+ confidence = 1.0 - abs(combined_score - 0.5) * 2
+
+ # Generate reasoning
+ reasoning = self._generate_reasoning(
+ behavior_type, kb_metrics, mouse_metrics, combined_score
+ )
+
+ return {
+ "behavior_type": behavior_type,
+ "confidence": confidence,
+ "metrics": {
+ "keyboard_activity": kb_metrics,
+ "mouse_activity": mouse_metrics,
+ "combined_score": combined_score,
+ "reasoning": reasoning,
+ },
+ }
+
+ def _generate_reasoning(
+ self,
+ behavior_type: str,
+ kb_metrics: Dict[str, Any],
+ mouse_metrics: Dict[str, Any],
+ combined_score: float,
+ ) -> str:
+ """
+ Generate human-readable explanation for classification
+
+ Args:
+ behavior_type: Classified behavior type
+ kb_metrics: Keyboard activity metrics
+ mouse_metrics: Mouse activity metrics
+ combined_score: Combined classification score
+
+ Returns:
+ Reasoning string explaining the classification
+ """
+ kb_epm = kb_metrics["events_per_minute"]
+ typing = kb_metrics["typing_intensity"]
+ scroll_ratio = mouse_metrics["scroll_ratio"]
+ click_ratio = mouse_metrics["click_ratio"]
+
+ if behavior_type == "operation":
+ return (
+ f"High keyboard activity ({kb_epm:.1f} EPM) with "
+ f"{typing*100:.0f}% typing and {click_ratio*100:.0f}% mouse clicks "
+ f"indicates active work (coding, writing, or design)"
+ )
+ elif behavior_type == "browsing":
+ return (
+ f"Low keyboard activity ({kb_epm:.1f} EPM) with "
+ f"{scroll_ratio*100:.0f}% scrolling indicates passive consumption "
+ f"(reading, watching, or browsing)"
+ )
+ else:
+ return (
+ f"Mixed activity pattern (score: {combined_score:.2f}) suggests "
+ f"combination of active work and information gathering"
+ )
+
+ def format_behavior_context(
+ self, analysis_result: Dict[str, Any], language: str = "en"
+ ) -> str:
+ """
+ Format behavior analysis for prompt inclusion
+
+ Args:
+ analysis_result: Result from analyze() method
+ language: Language code ("en" or "zh")
+
+ Returns:
+ Formatted context string for LLM prompt
+ """
+ behavior_type = analysis_result["behavior_type"]
+ confidence = analysis_result["confidence"]
+ reasoning = analysis_result["metrics"]["reasoning"]
+
+ kb_epm = analysis_result["metrics"]["keyboard_activity"]["events_per_minute"]
+ kb_typing = analysis_result["metrics"]["keyboard_activity"]["typing_intensity"]
+ mouse_clicks = analysis_result["metrics"]["mouse_activity"]["click_ratio"]
+ mouse_scrolls = analysis_result["metrics"]["mouse_activity"]["scroll_ratio"]
+
+ if language == "zh":
+ # Chinese format
+ context = f"""行为类型:{behavior_type.upper()} (置信度: {confidence:.0%})
+- 键盘:{kb_epm:.1f} 次/分钟 ({kb_typing:.0%} 打字)
+- 鼠标:{mouse_clicks:.0%} 点击, {mouse_scrolls:.0%} 滚动
+- 分析:{reasoning}"""
+ else:
+ # English format
+ context = f"""Behavior Type: {behavior_type.upper()} (Confidence: {confidence:.0%})
+- Keyboard: {kb_epm:.1f} events/min ({kb_typing:.0%} typing)
+- Mouse: {mouse_clicks:.0%} clicks, {mouse_scrolls:.0%} scrolling
+- Analysis: {reasoning}"""
+
+ return context.strip()
diff --git a/backend/processing/coding_detector.py b/backend/processing/coding_detector.py
new file mode 100644
index 0000000..b8b6463
--- /dev/null
+++ b/backend/processing/coding_detector.py
@@ -0,0 +1,231 @@
+"""
+Coding Scene Detector - Detect coding applications for adaptive filtering
+
+This module identifies coding environments (IDEs, terminals, editors) from
+active window information to apply coding-specific optimization thresholds.
+
+When a coding scene is detected, the image filter uses more permissive
+thresholds since code editors have minimal visual changes during typing.
+"""
+
+import re
+from typing import Optional
+
+from core.logger import get_logger
+
+logger = get_logger(__name__)
+
+# Bundle IDs for common coding applications (macOS)
+CODING_BUNDLE_IDS = [
+ # IDEs and Editors
+ "com.microsoft.VSCode",
+ "com.microsoft.VSCodeInsiders",
+ "com.apple.dt.Xcode",
+ "com.jetbrains.", # All JetBrains IDEs (prefix match)
+ "com.sublimetext.",
+ "com.sublimehq.Sublime-Text",
+ "org.vim.",
+ "com.qvacua.VimR",
+ "com.neovide.",
+ "com.github.atom",
+ "dev.zed.Zed",
+ "com.cursor.Cursor",
+ "ai.cursor.",
+ # Terminals
+ "com.googlecode.iterm2",
+ "com.apple.Terminal",
+ "io.alacritty",
+ "com.github.wez.wezterm",
+ "net.kovidgoyal.kitty",
+ "co.zeit.hyper",
+ # Other development tools
+ "com.postmanlabs.mac",
+ "com.insomnia.app",
+]
+
+# App names for common coding applications
+CODING_APP_NAMES = [
+ # IDEs
+ "Visual Studio Code",
+ "Code",
+ "Code - Insiders",
+ "Cursor",
+ "Zed",
+ "Xcode",
+ "IntelliJ IDEA",
+ "PyCharm",
+ "WebStorm",
+ "CLion",
+ "GoLand",
+ "Rider",
+ "Android Studio",
+ "PhpStorm",
+ "RubyMine",
+ "DataGrip",
+ # Editors
+ "Sublime Text",
+ "Atom",
+ "Vim",
+ "NeoVim",
+ "Neovide",
+ "VimR",
+ "Emacs",
+ "Nova",
+ # Terminals
+ "Terminal",
+ "iTerm",
+ "iTerm2",
+ "Alacritty",
+ "WezTerm",
+ "kitty",
+ "Hyper",
+ # Other
+ "Postman",
+ "Insomnia",
+]
+
+# Window title patterns that indicate coding activity
+CODE_FILE_PATTERNS = [
+ # Source code file extensions
+ r"\.(py|pyw|pyx|pxd)\b", # Python
+ r"\.(js|mjs|cjs|jsx)\b", # JavaScript
+ r"\.(ts|tsx|mts|cts)\b", # TypeScript
+ r"\.(go|mod|sum)\b", # Go
+ r"\.(rs|rlib)\b", # Rust
+ r"\.(java|kt|kts|scala)\b", # JVM languages
+ r"\.(c|h|cpp|hpp|cc|cxx)\b", # C/C++
+ r"\.(swift|m|mm)\b", # Apple languages
+ r"\.(rb|rake|gemspec)\b", # Ruby
+ r"\.(php|phtml)\b", # PHP
+ r"\.(vue|svelte)\b", # Frontend frameworks
+ r"\.(html|htm|css|scss|sass|less)\b", # Web
+ r"\.(json|yaml|yml|toml|xml)\b", # Config files
+ r"\.(sh|bash|zsh|fish)\b", # Shell scripts
+ r"\.(sql|graphql|gql)\b", # Query languages
+ r"\.(md|mdx|rst|txt)\b", # Documentation
+ # Git and version control
+ r"\[Git\]",
+ r"- Git$",
+ r"COMMIT_EDITMSG",
+ r"\.git/",
+ # Editor indicators
+ r"- vim$",
+ r"- nvim$",
+ r"- NVIM$",
+ r"\(INSERT\)",
+ r"\(NORMAL\)",
+ r"\(VISUAL\)",
+ # Terminal indicators
+ r"@.*:.*\$", # Shell prompt pattern
+ r"bash|zsh|fish|sh\s*$",
+]
+
+
+class CodingSceneDetector:
+ """
+ Detects if the current active window is a coding environment.
+
+ Uses multiple signals:
+ 1. Application bundle ID (most reliable on macOS)
+ 2. Application name
+ 3. Window title patterns (code file extensions, git, vim modes)
+ """
+
+ def __init__(self):
+ """Initialize the detector with compiled regex patterns."""
+ self._compiled_patterns = [
+ re.compile(pattern, re.IGNORECASE)
+ for pattern in CODE_FILE_PATTERNS
+ ]
+ logger.debug(
+ f"CodingSceneDetector initialized with "
+ f"{len(CODING_BUNDLE_IDS)} bundle IDs, "
+ f"{len(CODING_APP_NAMES)} app names, "
+ f"{len(CODE_FILE_PATTERNS)} title patterns"
+ )
+
+ def is_coding_scene(
+ self,
+ app_name: Optional[str] = None,
+ bundle_id: Optional[str] = None,
+ window_title: Optional[str] = None,
+ ) -> bool:
+ """
+ Determine if the active window is a coding environment.
+
+ Args:
+ app_name: Application name (e.g., "Visual Studio Code")
+ bundle_id: macOS bundle identifier (e.g., "com.microsoft.VSCode")
+ window_title: Window title (e.g., "main.py - project - VSCode")
+
+ Returns:
+ True if any signal indicates a coding environment.
+ """
+ # Check bundle ID (most reliable)
+ if bundle_id:
+ for pattern in CODING_BUNDLE_IDS:
+ if bundle_id.startswith(pattern):
+ logger.debug(f"Coding scene detected via bundle_id: {bundle_id}")
+ return True
+
+ # Check app name
+ if app_name:
+ # Direct match
+ if app_name in CODING_APP_NAMES:
+ logger.debug(f"Coding scene detected via app_name: {app_name}")
+ return True
+ # Case-insensitive partial match for some apps
+ app_lower = app_name.lower()
+ for coding_app in CODING_APP_NAMES:
+ if coding_app.lower() in app_lower:
+ logger.debug(
+ f"Coding scene detected via app_name (partial): {app_name}"
+ )
+ return True
+
+ # Check window title patterns
+ if window_title:
+ for pattern in self._compiled_patterns:
+ if pattern.search(window_title):
+ logger.debug(
+ f"Coding scene detected via window_title pattern: "
+ f"'{window_title}' matched '{pattern.pattern}'"
+ )
+ return True
+
+ return False
+
+ def is_coding_record(self, record_data: Optional[dict]) -> bool:
+ """
+ Check if a record's active_window indicates coding.
+
+ Args:
+ record_data: Record data dict containing active_window info.
+
+ Returns:
+ True if the record is from a coding environment.
+ """
+ if not record_data:
+ return False
+
+ active_window = record_data.get("active_window", {})
+ if not active_window:
+ return False
+
+ return self.is_coding_scene(
+ app_name=active_window.get("app_name"),
+ bundle_id=active_window.get("app_bundle_id"),
+ window_title=active_window.get("window_title"),
+ )
+
+
+# Singleton instance for reuse
+_detector: Optional[CodingSceneDetector] = None
+
+
+def get_coding_detector() -> CodingSceneDetector:
+ """Get or create the singleton CodingSceneDetector instance."""
+ global _detector
+ if _detector is None:
+ _detector = CodingSceneDetector()
+ return _detector
diff --git a/backend/processing/image/analysis.py b/backend/processing/image/analysis.py
index aa40410..8f48b62 100644
--- a/backend/processing/image/analysis.py
+++ b/backend/processing/image/analysis.py
@@ -162,7 +162,8 @@ def has_significant_content(
self,
img_bytes: bytes,
min_contrast: float = 50.0,
- min_activity: float = 10.0
+ min_activity: float = 10.0,
+ is_coding_scene: bool = False,
) -> Tuple[bool, str]:
"""
Determine if image has significant content worth processing
@@ -171,12 +172,19 @@ def has_significant_content(
img_bytes: Image data
min_contrast: Minimum contrast threshold
min_activity: Minimum edge activity threshold
+ is_coding_scene: Whether this is from a coding environment
+ (relaxed thresholds for dark themes)
Returns:
(should_include, reason)
"""
metrics = self.analyze(img_bytes)
+ # Adjust thresholds for coding scenes (dark themes, minimal visual changes)
+ if is_coding_scene:
+ min_contrast = 25.0 # Lower for dark-themed IDEs
+ min_activity = 5.0 # Lower for typing (small pixel changes)
+
# Rule 1: High contrast = potentially meaningful interface change
if metrics["contrast"] > min_contrast:
self.stats["high_contrast_included"] += 1
@@ -187,7 +195,11 @@ def has_significant_content(
self.stats["motion_detected"] += 1
return True, "Motion detected"
- # Rule 3: Low contrast and no motion = possibly blank/waiting screen
+ # Rule 3: For coding scenes, check complexity (text patterns)
+ if is_coding_scene and metrics["complexity"] > 15.0:
+ return True, "Coding content detected"
+
+ # Rule 4: Low contrast and no motion = possibly blank/waiting screen
if metrics["contrast"] < 20:
self.stats["static_skipped"] += 1
return False, "Static/blank content"
diff --git a/backend/processing/image_filter.py b/backend/processing/image_filter.py
index 1af4fd9..cb93ff7 100644
--- a/backend/processing/image_filter.py
+++ b/backend/processing/image_filter.py
@@ -1,16 +1,22 @@
"""
Unified image filtering and optimization
Integrates deduplication, content analysis, and compression into a single preprocessing stage
+
+Supports coding scene detection for adaptive thresholds:
+- Coding scenes (IDEs, terminals) use more permissive thresholds
+- This helps capture small but meaningful changes during coding
"""
import base64
import io
from collections import deque
+from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from core.logger import get_logger
from core.models import RawRecord, RecordType
from perception.image_manager import get_image_manager
+from processing.coding_detector import get_coding_detector
logger = get_logger(__name__)
@@ -50,6 +56,8 @@ def __init__(
enable_content_analysis: bool = True,
# Compression settings
enable_compression: bool = True,
+ # Periodic sampling settings
+ min_sample_interval: float = 30.0,
):
"""
Initialize unified image filter
@@ -62,6 +70,7 @@ def __init__(
enable_adaptive_threshold: Enable scene-adaptive thresholds
enable_content_analysis: Enable content analysis (skip static screens)
enable_compression: Enable image compression
+ min_sample_interval: Minimum seconds between kept samples in static scenes (default 30)
"""
self.enable_deduplication = enable_deduplication and IMAGEHASH_AVAILABLE
self.similarity_threshold = similarity_threshold
@@ -69,6 +78,8 @@ def __init__(
self.enable_adaptive_threshold = enable_adaptive_threshold
self.enable_content_analysis = enable_content_analysis
self.enable_compression = enable_compression
+ self.min_sample_interval = min_sample_interval
+ self.last_kept_timestamp: Optional[datetime] = None
# Initialize hash algorithms with weights
if hash_algorithms is None:
@@ -79,6 +90,7 @@ def __init__(
self.hash_cache: deque = deque(maxlen=hash_cache_size)
self.image_manager = get_image_manager()
+ self.coding_detector = get_coding_detector()
# Initialize components
self._init_content_analyzer()
@@ -207,8 +219,11 @@ def filter_screenshots(self, records: List[RawRecord]) -> List[RawRecord]:
# Step 2: Content analysis
if self.enable_content_analysis and self.content_analyzer:
+ # Check if this is a coding scene for relaxed thresholds
+ is_coding = self.coding_detector.is_coding_record(record.data)
has_content, reason = self.content_analyzer.has_significant_content(
- img_bytes
+ img_bytes,
+ is_coding_scene=is_coding,
)
if not has_content:
self.stats["content_filtered"] += 1
@@ -295,6 +310,18 @@ def _check_duplicate(
return False, 0.0
try:
+ # Periodic sampling: force keep at least one sample every min_sample_interval
+ # This ensures time coverage even in static scenes (reading, watching)
+ force_keep = False
+ if self.last_kept_timestamp is not None:
+ elapsed = (record.timestamp - self.last_kept_timestamp).total_seconds()
+ if elapsed >= self.min_sample_interval:
+ force_keep = True
+ logger.debug(
+ f"Periodic sampling: keeping screenshot after {elapsed:.1f}s "
+ f"(interval: {self.min_sample_interval}s)"
+ )
+
# Load PIL Image
img = Image.open(io.BytesIO(img_bytes))
@@ -314,15 +341,17 @@ def _check_duplicate(
max_similarity = max(max_similarity, similarity)
# Detect scene type and get adaptive threshold
- scene_type = self._detect_scene_type(max_similarity)
+ # Pass record to check for coding scene
+ scene_type = self._detect_scene_type(max_similarity, record)
adaptive_threshold = self._get_adaptive_threshold(scene_type)
- # Check if duplicate
- if max_similarity >= adaptive_threshold:
+ # Check if duplicate (but respect periodic sampling)
+ if max_similarity >= adaptive_threshold and not force_keep:
return True, max_similarity
- # Not duplicate, add to cache
+ # Not duplicate (or force kept), add to cache and update timestamp
self.hash_cache.append((record.timestamp, multi_hash))
+ self.last_kept_timestamp = record.timestamp
return False, max_similarity
except Exception as e:
@@ -383,8 +412,24 @@ def _calculate_similarity(
return total_similarity / total_weight if total_weight > 0 else 0.0
- def _detect_scene_type(self, similarity: float) -> str:
- """Detect scene type based on similarity"""
+ def _detect_scene_type(
+ self, similarity: float, record: Optional[RawRecord] = None
+ ) -> str:
+ """
+ Detect scene type based on similarity and active window context.
+
+ Scene types:
+ - 'coding': IDEs, terminals, code editors (more permissive threshold)
+ - 'static': Almost identical content (aggressive deduplication)
+ - 'video': High similarity with motion (preserve key frames)
+ - 'normal': Regular interactive content (default threshold)
+ """
+ # Check for coding scene first (highest priority)
+ if record and record.data:
+ if self.coding_detector.is_coding_record(record.data):
+ return 'coding'
+
+ # Similarity-based detection
if similarity >= 0.99:
return 'static' # Almost identical (documents, reading)
elif similarity >= 0.95:
@@ -393,11 +438,22 @@ def _detect_scene_type(self, similarity: float) -> str:
return 'normal' # Regular interactive content
def _get_adaptive_threshold(self, scene_type: str) -> float:
- """Get adaptive threshold based on scene type"""
+ """
+ Get adaptive threshold based on scene type.
+
+ Thresholds:
+ - coding: 0.92 (more permissive, capture small code changes)
+ - static: 0.85 (aggressive deduplication for static content)
+ - video: 0.98 (preserve key frames)
+ - normal: configured threshold (default 0.88)
+ """
if not self.enable_adaptive_threshold:
return self.similarity_threshold
- if scene_type == 'static':
+ if scene_type == 'coding':
+ # More permissive for coding - capture cursor movement, typing
+ return 0.92
+ elif scene_type == 'static':
return 0.85 # Aggressive deduplication for static content
elif scene_type == 'video':
return 0.98 # Preserve key frames in video
@@ -409,8 +465,9 @@ def get_stats(self) -> Dict[str, Any]:
return self.stats.copy()
def reset_state(self):
- """Reset deduplication state (clears hash cache)"""
+ """Reset deduplication state (clears hash cache and periodic sampling)"""
self.hash_cache.clear()
+ self.last_kept_timestamp = None
self.stats = {
"total_processed": 0,
"duplicates_skipped": 0,
diff --git a/backend/processing/pipeline.py b/backend/processing/pipeline.py
index 63e93d6..2f0ab20 100644
--- a/backend/processing/pipeline.py
+++ b/backend/processing/pipeline.py
@@ -2,8 +2,9 @@
Processing pipeline (agent-based architecture)
Simplified pipeline that delegates to specialized agents:
- raw_records → ActionAgent → actions (complete flow: extract + save)
-- actions → EventAgent → events (complete flow: aggregate + save)
-- events → SessionAgent → activities (complete flow: aggregate + save)
+- actions → SessionAgent → activities (action-based aggregation)
+
+EventAgent has been DISABLED - using direct action-based aggregation only.
Pipeline now only handles:
- Filtering raw records
@@ -20,6 +21,7 @@
from core.models import RawRecord, RecordType
from perception.image_manager import get_image_manager
+from .behavior_analyzer import BehaviorAnalyzer
from .image_filter import ImageFilter
from .image_sampler import ImageSampler
from .record_filter import RecordFilter
@@ -41,6 +43,8 @@ def __init__(
screenshot_hash_cache_size: int = 10,
screenshot_hash_algorithms: Optional[List[str]] = None,
enable_adaptive_threshold: bool = True,
+ max_accumulation_time: int = 180,
+ min_sample_interval: float = 30.0,
):
"""
Initialize processing pipeline
@@ -55,11 +59,15 @@ def __init__(
screenshot_hash_cache_size: Number of hashes to cache for comparison
screenshot_hash_algorithms: List of hash algorithms to use
enable_adaptive_threshold: Whether to enable scene-adaptive thresholds
+ max_accumulation_time: Maximum time (seconds) before forcing extraction even if threshold not reached
+ min_sample_interval: Minimum interval (seconds) between kept samples in static scenes
"""
self.screenshot_threshold = screenshot_threshold
self.max_screenshots_per_extraction = max_screenshots_per_extraction
self.activity_summary_interval = activity_summary_interval
self.language = language
+ self.max_accumulation_time = max_accumulation_time
+ self.last_extraction_time: Optional[datetime] = None
# Initialize image preprocessing components
# ImageFilter: handles deduplication, content analysis, and compression
@@ -71,6 +79,7 @@ def __init__(
enable_adaptive_threshold=enable_adaptive_threshold,
enable_content_analysis=True, # Always enable content analysis
enable_compression=True, # Always enable compression
+ min_sample_interval=min_sample_interval, # Periodic sampling for static scenes
)
# ImageSampler: handles sampling when sending to LLM
@@ -92,6 +101,15 @@ def __init__(
click_merge_threshold=0.5,
)
+ # BehaviorAnalyzer: analyzes keyboard/mouse patterns to classify user behavior
+ # Helps LLM distinguish between operation (active work) and browsing (passive consumption)
+ self.behavior_analyzer = BehaviorAnalyzer(
+ operation_threshold=0.6,
+ browsing_threshold=0.3,
+ keyboard_weight=0.6,
+ mouse_weight=0.4,
+ )
+
self.db = get_db()
self.image_manager = get_image_manager()
@@ -108,8 +126,8 @@ def __init__(
self.screenshot_accumulator: List[RawRecord] = []
# Note: No scheduled tasks in pipeline anymore
- # - Event aggregation: handled by EventAgent
- # - Session aggregation: handled by SessionAgent
+ # - Event aggregation: DISABLED (action-based aggregation only)
+ # - Session aggregation: handled by SessionAgent (action-based)
# - Knowledge merge: handled by KnowledgeAgent
# - Todo merge: handled by TodoAgent
@@ -141,15 +159,16 @@ async def start(self):
return
self.is_running = True
+ self.last_extraction_time = datetime.now() # Initialize time-based trigger
- # Note: Event aggregation is now handled by EventAgent (started by coordinator)
+ # Note: Event aggregation DISABLED - using action-based aggregation only
# Note: Todo merge and knowledge merge are handled by TodoAgent and KnowledgeAgent (started by coordinator)
# Pipeline now only handles action extraction (triggered by raw record processing)
logger.info(f"Processing pipeline started (language: {self.language})")
logger.debug(f"- Screenshot threshold: {self.screenshot_threshold}")
logger.debug("- Action extraction: handled inline via ActionAgent")
- logger.debug("- Event aggregation: handled by EventAgent")
+ logger.debug("- Event aggregation: DISABLED (action-based aggregation only)")
logger.debug("- Todo extraction and merge: handled by TodoAgent")
logger.debug("- Knowledge extraction and merge: handled by KnowledgeAgent")
@@ -160,7 +179,7 @@ async def stop(self):
self.is_running = False
- # Note: Event aggregation task removed as aggregation is handled by EventAgent
+ # Note: Event aggregation DISABLED - using action-based aggregation only
# Note: Todo and knowledge merge tasks removed as merging is handled by dedicated agents
# Process remaining accumulated screenshots with a hard timeout to avoid shutdown hangs
@@ -250,6 +269,22 @@ async def process_raw_records(self, raw_records: List[RawRecord]) -> Dict[str, A
)
should_process = True
+ # Time-based forced processing: ensure activity is captured in static scenes
+ # (e.g., reading, watching videos) even when screenshot count is low
+ if (
+ not should_process
+ and len(self.screenshot_accumulator) > 0
+ and self.last_extraction_time is not None
+ ):
+ time_since_last = (datetime.now() - self.last_extraction_time).total_seconds()
+ if time_since_last >= self.max_accumulation_time:
+ logger.info(
+ f"Time-based forced processing: {time_since_last:.0f}s elapsed "
+ f"(threshold: {self.max_accumulation_time}s), "
+ f"processing {len(self.screenshot_accumulator)} accumulated screenshots"
+ )
+ should_process = True
+
if should_process:
# Step 6: Sample screenshots before sending to LLM
# This enforces time interval and max count limits
@@ -268,9 +303,10 @@ async def process_raw_records(self, raw_records: List[RawRecord]) -> Dict[str, A
mouse_records,
)
- # Clear accumulator
+ # Clear accumulator and update extraction time
processed_count = len(self.screenshot_accumulator)
self.screenshot_accumulator = []
+ self.last_extraction_time = datetime.now()
return {
"processed": processed_count,
@@ -325,12 +361,24 @@ async def _extract_actions(
logger.error("ActionAgent not available, cannot process actions")
raise Exception("ActionAgent not available")
+ # NEW: Analyze behavior patterns from keyboard/mouse data
+ behavior_analysis = self.behavior_analyzer.analyze(
+ keyboard_records=keyboard_records,
+ mouse_records=mouse_records,
+ )
+
+ logger.debug(
+ f"Behavior analysis: {behavior_analysis['behavior_type']} "
+ f"(confidence={behavior_analysis['confidence']:.2f})"
+ )
+
# Step 1: Extract scene descriptions from screenshots (RawAgent)
logger.debug("Step 1: Extracting scene descriptions via RawAgent")
scenes = await self.raw_agent.extract_scenes(
records,
keyboard_records=keyboard_records,
mouse_records=mouse_records,
+ behavior_analysis=behavior_analysis, # NEW: pass behavior context
)
if not scenes:
@@ -352,6 +400,7 @@ async def _extract_actions(
scenes,
keyboard_records=keyboard_records,
mouse_records=mouse_records,
+ behavior_analysis=behavior_analysis, # NEW: pass behavior context
)
# Update statistics
@@ -470,7 +519,7 @@ def _build_input_usage_hint(self, has_keyboard: bool, has_mouse: bool) -> str:
# ============ Scheduled Tasks ============
- # Note: Event aggregation is now handled by EventAgent (started by coordinator)
+ # Note: Event aggregation DISABLED - using action-based aggregation only
# Note: Knowledge merge is now handled by KnowledgeAgent (started by coordinator)
# Note: Todo merge is now handled by TodoAgent (started by coordinator)
diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py
index 7424f43..519cf33 100644
--- a/backend/services/chat_service.py
+++ b/backend/services/chat_service.py
@@ -585,12 +585,17 @@ async def _process_stream(
# 3. Stream responses from the LLM (with timeout)
full_response = ""
+ is_error_response = False
try:
# timeout may not exist when python version < 3.11, but we use python 3.14
async with asyncio.timeout(TIMEOUT_SECONDS): # type: ignore[attr-defined]
async for chunk in self.llm_manager.chat_completion_stream(messages, model_id=model_id):
full_response += chunk
+ # Check if chunk contains error pattern (LLM client yields errors as chunks)
+ if chunk.startswith("[Error]") or chunk.startswith("[错误]"):
+ is_error_response = True
+
# Send chunks to the frontend in real time
emit_chat_message_chunk(
conversation_id=conversation_id, chunk=chunk, done=False
@@ -599,7 +604,7 @@ async def _process_stream(
error_msg = "Request timeout, please check network connection"
logger.error(f"❌ LLM call timed out ({TIMEOUT_SECONDS}s): {conversation_id}")
- # Emit the timeout error
+ # Emit the timeout error with error=True
await self.save_message(
conversation_id=conversation_id,
role="assistant",
@@ -607,17 +612,34 @@ async def _process_stream(
metadata={"error": True, "error_type": "timeout"},
)
emit_chat_message_chunk(
- conversation_id=conversation_id, chunk="", done=True
+ conversation_id=conversation_id, chunk=error_msg, done=True, error=True
+ )
+ return
+
+ # 4. Handle error responses (LLM client yields errors as chunks instead of raising)
+ if is_error_response:
+ logger.error(f"❌ LLM returned error response: {full_response[:100]}")
+ await self.save_message(
+ conversation_id=conversation_id,
+ role="assistant",
+ content=full_response,
+ metadata={"error": True, "error_type": "llm"},
+ )
+ emit_chat_message_chunk(
+ conversation_id=conversation_id,
+ chunk=full_response,
+ done=True,
+ error=True,
)
return
- # 4. Save the assistant response
+ # 5. Save the assistant response (normal completion)
assistant_message = await self.save_message(
conversation_id=conversation_id, role="assistant", content=full_response
)
self._maybe_update_conversation_title(conversation_id)
- # 5. Emit the completion signal
+ # 6. Emit the completion signal
emit_chat_message_chunk(
conversation_id=conversation_id,
chunk="",
@@ -642,10 +664,10 @@ async def _process_stream(
except Exception as e:
logger.error(f"Streaming message failed: {e}", exc_info=True)
- # Emit the error signal
+ # Emit the error signal with error=True
error_message = f"[错误] {str(e)[:100]}"
emit_chat_message_chunk(
- conversation_id=conversation_id, chunk=error_message, done=True
+ conversation_id=conversation_id, chunk=error_message, done=True, error=True
)
# Persist the error message
diff --git a/backend/services/knowledge_merger.py b/backend/services/knowledge_merger.py
new file mode 100644
index 0000000..390c81a
--- /dev/null
+++ b/backend/services/knowledge_merger.py
@@ -0,0 +1,448 @@
+from typing import List, Dict, Optional, Tuple, Any
+from datetime import datetime
+import json
+import uuid
+
+from core.logger import get_logger
+from llm.manager import get_llm_manager
+from llm.prompt_manager import PromptManager
+from core.protocols import KnowledgeRepositoryProtocol
+
+logger = get_logger(__name__)
+
+
+class MergeSuggestion:
+ """Represents a suggested merge of similar knowledge entries"""
+
+ def __init__(
+ self,
+ group_id: str,
+ knowledge_ids: List[str],
+ merged_title: str,
+ merged_description: str,
+ merged_keywords: List[str],
+ similarity_score: float,
+ merge_reason: str,
+ estimated_tokens: int = 0,
+ ):
+ self.group_id = group_id
+ self.knowledge_ids = knowledge_ids
+ self.merged_title = merged_title
+ self.merged_description = merged_description
+ self.merged_keywords = merged_keywords
+ self.similarity_score = similarity_score
+ self.merge_reason = merge_reason
+ self.estimated_tokens = estimated_tokens
+
+
+class MergeGroup:
+ """Represents a user-confirmed merge group"""
+
+ def __init__(
+ self,
+ group_id: str,
+ knowledge_ids: List[str],
+ merged_title: str,
+ merged_description: str,
+ merged_keywords: List[str],
+ merge_reason: Optional[str] = None,
+ keep_favorite: bool = True,
+ ):
+ self.group_id = group_id
+ self.knowledge_ids = knowledge_ids
+ self.merged_title = merged_title
+ self.merged_description = merged_description
+ self.merged_keywords = merged_keywords
+ self.merge_reason = merge_reason
+ self.keep_favorite = keep_favorite
+
+
+class MergeResult:
+ """Result of executing a merge operation"""
+
+ def __init__(
+ self,
+ group_id: str,
+ merged_knowledge_id: str,
+ deleted_knowledge_ids: List[str],
+ success: bool,
+ error: Optional[str] = None,
+ ):
+ self.group_id = group_id
+ self.merged_knowledge_id = merged_knowledge_id
+ self.deleted_knowledge_ids = deleted_knowledge_ids
+ self.success = success
+ self.error = error
+
+
+class KnowledgeMerger:
+ """Service for analyzing and merging similar knowledge entries"""
+
+ _instance: Optional["KnowledgeMerger"] = None
+ _lock: bool = False # Global lock for analysis state
+
+ def __init__(
+ self,
+ knowledge_repo: KnowledgeRepositoryProtocol,
+ prompt_manager: PromptManager,
+ llm_manager,
+ ):
+ self.knowledge_repo = knowledge_repo
+ self.prompt_manager = prompt_manager
+ self.llm_manager = llm_manager
+
+ @classmethod
+ def get_instance(
+ cls,
+ knowledge_repo: Optional[KnowledgeRepositoryProtocol] = None,
+ prompt_manager: Optional[PromptManager] = None,
+ llm_manager = None,
+ ) -> "KnowledgeMerger":
+ """Get singleton instance of KnowledgeMerger"""
+ if cls._instance is None:
+ if knowledge_repo is None or prompt_manager is None or llm_manager is None:
+ raise ValueError(
+ "First initialization requires all parameters: knowledge_repo, prompt_manager, llm_manager"
+ )
+ cls._instance = cls(knowledge_repo, prompt_manager, llm_manager)
+ return cls._instance
+
+ @classmethod
+ def is_locked(cls) -> bool:
+ """Check if analysis is currently in progress"""
+ return cls._lock
+
+ @classmethod
+ def set_lock(cls, locked: bool) -> None:
+ """Set the lock state"""
+ cls._lock = locked
+
+ async def health_check(self) -> Tuple[bool, Optional[str]]:
+ """
+ Check if LLM service is available.
+
+ Returns:
+ (is_available, error_message)
+ """
+ try:
+ result = await self.llm_manager.health_check()
+ if result.get("available"):
+ logger.info(
+ f"LLM health check passed: {result.get('model')} "
+ f"({result.get('provider')}), latency={result.get('latency_ms')}ms"
+ )
+ return True, None
+ else:
+ error = result.get("error", "Unknown error")
+ logger.warning(f"LLM health check failed: {error}")
+ return False, f"LLM service unavailable: {error}"
+ except Exception as e:
+ logger.error(f"LLM health check error: {e}")
+ return False, f"Health check error: {str(e)}"
+
+ async def analyze_similarities(
+ self,
+ filter_by_keyword: Optional[str],
+ include_favorites: bool,
+ similarity_threshold: float,
+ ) -> Tuple[List[MergeSuggestion], int]:
+ """
+ Analyze knowledge entries for similarity and generate merge suggestions.
+
+ Args:
+ filter_by_keyword: Only analyze knowledge with this keyword (None = all)
+ include_favorites: Whether to include favorite knowledge
+ similarity_threshold: Similarity threshold (0.0-1.0)
+
+ Returns:
+ (suggestions, total_tokens_used)
+ """
+ # Check if analysis is already in progress
+ if self.is_locked():
+ raise RuntimeError("Knowledge analysis is already in progress. Please wait for it to complete.")
+
+ # Set lock to prevent concurrent analysis
+ self.set_lock(True)
+ logger.info("Knowledge analysis started - lock acquired")
+
+ try:
+ # 0. Check LLM availability first
+ llm_available, llm_error = await self.health_check()
+ if not llm_available:
+ logger.error(f"LLM service not available: {llm_error}")
+ raise RuntimeError(
+ f"Cannot analyze knowledge: LLM service is not available. "
+ f"{llm_error}"
+ )
+ except Exception as e:
+ # Release lock on any error during initialization
+ self.set_lock(False)
+ logger.info("Knowledge analysis initialization failed - lock released")
+ raise
+
+ # 1. Fetch knowledge from database
+ knowledge_list = await self._fetch_knowledge(
+ filter_by_keyword, include_favorites
+ )
+
+ if len(knowledge_list) < 2:
+ logger.info("Not enough knowledge entries to analyze")
+ self.set_lock(False)
+ logger.info("Knowledge analysis - not enough data - lock released")
+ return [], 0
+
+ # 2. Group by keywords (tag-based categorization)
+ grouped = self._group_by_keywords(knowledge_list)
+
+ # 3. Analyze each group with LLM
+ all_suggestions = []
+ total_tokens = 0
+
+ try:
+ for keyword, group in grouped.items():
+ if len(group) < 2:
+ continue # Skip groups with single item
+
+ logger.info(f"Analyzing group '{keyword}' with {len(group)} entries")
+ suggestions, tokens = await self._analyze_group(group, similarity_threshold)
+ all_suggestions.extend(suggestions)
+ total_tokens += tokens
+
+ logger.info(f"Analysis completed - lock will be released, found {len(all_suggestions)} suggestions")
+ return all_suggestions, total_tokens
+ except Exception as e:
+ logger.error(f"Analysis failed: {e}", exc_info=True)
+ raise
+ finally:
+ # Always release lock when done (success or error)
+ self.set_lock(False)
+ logger.info("Knowledge analysis finished - lock released")
+
+ async def _fetch_knowledge(
+ self, filter_by_keyword: Optional[str], include_favorites: bool
+ ) -> List[Dict[str, Any]]:
+ """Fetch knowledge entries based on filter criteria"""
+ all_knowledge = await self.knowledge_repo.get_list(include_deleted=False)
+
+ filtered = all_knowledge
+
+ # Filter by keyword
+ if filter_by_keyword:
+ filtered = [k for k in filtered if filter_by_keyword in k.get("keywords", [])]
+
+ # Filter favorites
+ if not include_favorites:
+ filtered = [k for k in filtered if not k.get("favorite", False)]
+
+ return filtered
+
+ def _group_by_keywords(
+ self, knowledge_list: List[Dict[str, Any]]
+ ) -> Dict[str, List[Dict[str, Any]]]:
+ """
+ Group knowledge by primary keyword (first keyword).
+ Knowledge without keywords goes to 'untagged' group.
+ """
+ groups: Dict[str, List[Dict[str, Any]]] = {}
+
+ for k in knowledge_list:
+ keywords = k.get("keywords", [])
+ primary_keyword = keywords[0] if keywords else "untagged"
+ if primary_keyword not in groups:
+ groups[primary_keyword] = []
+ groups[primary_keyword].append(k)
+
+ return groups
+
+ async def _analyze_group(
+ self, group: List[Dict[str, Any]], threshold: float
+ ) -> Tuple[List[MergeSuggestion], int]:
+ """
+ Use LLM to analyze similarity within a group and generate merge suggestions.
+
+ Strategy:
+ 1. Send group to LLM with prompt asking for similarity analysis
+ 2. LLM returns clusters of similar knowledge
+ 3. For each cluster, LLM generates merged title/description
+ """
+ # Build prompt with knowledge details
+ knowledge_json = json.dumps(
+ [
+ {
+ "id": k.get("id"),
+ "title": k.get("title"),
+ "description": k.get("description"),
+ "keywords": k.get("keywords", []),
+ }
+ for k in group
+ ],
+ ensure_ascii=False,
+ indent=2,
+ )
+
+ # Build messages with template variables
+ messages = self.prompt_manager.build_messages(
+ category="knowledge_merge_analysis",
+ prompt_type="user_prompt_template",
+ knowledge_json=knowledge_json,
+ threshold=threshold,
+ )
+
+ # Get config params
+ config_params = self.prompt_manager.get_config_params("knowledge_merge_analysis")
+ max_tokens = config_params.get("max_tokens", 4000)
+ temperature = config_params.get("temperature", 0.3)
+
+ try:
+ # Call LLM
+ response = await self.llm_manager.chat_completion(
+ messages=messages,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ response_format={"type": "json_object"},
+ )
+
+ # Parse response
+ content = response.get("content", "")
+ usage = response.get("usage", {})
+ tokens_used = usage.get("total_tokens", 0)
+
+ try:
+ result = json.loads(content)
+ suggestions = self._parse_llm_suggestions(result, group, tokens_used)
+ return suggestions, tokens_used
+ except json.JSONDecodeError as e:
+ logger.error(f"Failed to parse LLM response: {e}")
+ logger.error(f"Response content: {content}")
+ return [], tokens_used
+
+ except Exception as e:
+ logger.error(f"Failed to call LLM for group analysis: {e}", exc_info=True)
+ return [], 0
+
+ def _parse_llm_suggestions(
+ self, llm_result: Dict, group: List[Dict[str, Any]], tokens_used: int
+ ) -> List[MergeSuggestion]:
+ """
+ Parse LLM response into MergeSuggestion objects.
+
+ Expected LLM response format:
+ {
+ "merge_clusters": [
+ {
+ "knowledge_ids": ["id1", "id2", "id3"],
+ "merged_title": "...",
+ "merged_description": "...",
+ "merged_keywords": ["tag1", "tag2"],
+ "similarity_score": 0.85,
+ "merge_reason": "These entries discuss the same topic..."
+ }
+ ]
+ }
+ """
+ suggestions = []
+
+ for idx, cluster in enumerate(llm_result.get("merge_clusters", [])):
+ # Validate required fields
+ if not cluster.get("knowledge_ids"):
+ logger.warning(f"Cluster {idx} missing knowledge_ids, skipping")
+ continue
+
+ # Collect keywords from all knowledge in cluster
+ all_keywords = set()
+ for kid in cluster["knowledge_ids"]:
+ k = next((k for k in group if k.get("id") == kid), None)
+ if k:
+ keywords = k.get("keywords", [])
+ if keywords:
+ all_keywords.update(keywords)
+
+ # Use LLM-provided keywords if available, otherwise use collected keywords
+ merged_keywords = cluster.get("merged_keywords", list(all_keywords))
+
+ suggestion = MergeSuggestion(
+ group_id=f"merge_{uuid.uuid4().hex[:8]}_{int(datetime.now().timestamp())}",
+ knowledge_ids=cluster["knowledge_ids"],
+ merged_title=cluster.get("merged_title", "Merged Knowledge"),
+ merged_description=cluster.get("merged_description", ""),
+ merged_keywords=merged_keywords,
+ similarity_score=cluster.get("similarity_score", 0.0),
+ merge_reason=cluster.get("merge_reason", "Similar content detected"),
+ estimated_tokens=tokens_used // len(llm_result.get("merge_clusters", [])),
+ )
+ suggestions.append(suggestion)
+
+ return suggestions
+
+ async def execute_merge(
+ self, merge_groups: List[MergeGroup]
+ ) -> List[MergeResult]:
+ """
+ Execute approved merge operations.
+
+ For each group:
+ 1. Create new merged knowledge entry
+ 2. Soft-delete source knowledge entries
+ 3. Record merge history (optional)
+ """
+ results = []
+
+ for group in merge_groups:
+ try:
+ # Create merged knowledge
+ merged_id = f"k_{uuid.uuid4().hex}"
+
+ # Fetch source knowledge to check favorites
+ all_knowledge = await self.knowledge_repo.get_list(include_deleted=False)
+ sources = [k for k in all_knowledge if k.get("id") in group.knowledge_ids]
+
+ # Check if any source is favorite
+ is_favorite = any(k.get("favorite", False) for k in sources)
+
+ # Create new knowledge
+ await self.knowledge_repo.save(
+ knowledge_id=merged_id,
+ title=group.merged_title,
+ description=group.merged_description,
+ keywords=group.merged_keywords,
+ source_action_id=None, # No single source
+ favorite=is_favorite and group.keep_favorite,
+ )
+
+ logger.info(f"Created merged knowledge: {merged_id}")
+
+ # Hard-delete source knowledge (permanent deletion for merge operation)
+ deleted_ids = []
+ for kid in group.knowledge_ids:
+ try:
+ await self.knowledge_repo.hard_delete(kid)
+ deleted_ids.append(kid)
+ logger.info(f"Hard deleted source knowledge: {kid}")
+ except Exception as e:
+ logger.error(f"Failed to hard delete knowledge {kid}: {e}")
+
+ # Record history (if implemented)
+ # await self._record_merge_history(merged_id, group.knowledge_ids, group.merge_reason)
+
+ results.append(
+ MergeResult(
+ group_id=group.group_id,
+ merged_knowledge_id=merged_id,
+ deleted_knowledge_ids=deleted_ids,
+ success=True,
+ )
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to merge group {group.group_id}: {e}", exc_info=True)
+ results.append(
+ MergeResult(
+ group_id=group.group_id,
+ merged_knowledge_id="",
+ deleted_knowledge_ids=[],
+ success=False,
+ error=str(e),
+ )
+ )
+
+ return results
diff --git a/clock.html b/clock.html
new file mode 100644
index 0000000..55118c0
--- /dev/null
+++ b/clock.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ iDO Clock
+
+
+
+
+
+
+
diff --git a/docs/developers/architecture/three-layer-design.md b/docs/developers/architecture/three-layer-design.md
index e6107c2..2fb29e3 100644
--- a/docs/developers/architecture/three-layer-design.md
+++ b/docs/developers/architecture/three-layer-design.md
@@ -584,7 +584,7 @@ Each layer is independently configurable:
# config.toml
[monitoring] # Perception layer
-capture_interval = 1 # seconds
+capture_interval = 0.2 # seconds (5 screenshots per second)
window_size = 20 # seconds
[processing] # Processing layer
diff --git a/docs/user-guide/faq.md b/docs/user-guide/faq.md
index 9791a49..a098796 100644
--- a/docs/user-guide/faq.md
+++ b/docs/user-guide/faq.md
@@ -17,11 +17,8 @@ iDO itself is free and open source. However, you need to provide your own LLM AP
Currently:
- ✅ **macOS** 13 (Ventura) or later
-
-Coming soon:
-
-- ⏳ **Windows** 10 or later
-- ⏳ **Linux** (Ubuntu 20.04+)
+- ✅ **Windows** 10 or later
+- ✅ **Linux** (Ubuntu 20.04+ or equivalent)
### Is my data private?
@@ -66,7 +63,7 @@ On macOS, iDO requires:
**Accessibility** - To monitor keyboard and mouse events
**Screen Recording** - To capture screenshots
-You'll be prompted to grant these on first run. See [Installation Guide](./installation.md#1-grant-system-permissions) for details.
+You'll be prompted to grant these on first run. See [Installation Guide](./installation.md) for details.
### Do I need an OpenAI API key?
@@ -78,13 +75,13 @@ Get an OpenAI API key at: https://platform.openai.com/api-keys
**Recommended**:
-- **gpt-4** - Best quality, ~$0.05-0.10 per hour
-- **gpt-3.5-turbo** - Good quality, ~$0.01-0.02 per hour
+- **gpt-4o-mini** - Best value, good quality, ~$0.01 per hour (default)
+- **gpt-4o** - Higher quality, ~$0.05-0.10 per hour
**Tips**:
-- Start with gpt-3.5-turbo to save costs
-- Upgrade to gpt-4 if summaries aren't accurate enough
+- Start with gpt-4o-mini (default) for best value
+- Upgrade to gpt-4o if you need higher quality
- You can change models anytime in Settings
## Usage
@@ -93,7 +90,7 @@ Get an OpenAI API key at: https://platform.openai.com/api-keys
iDO uses a three-layer approach:
-1. **Perception Layer**: Captures keyboard, mouse, and screenshots every 1-3 seconds
+1. **Perception Layer**: Captures keyboard, mouse, and screenshots (default: 0.2 seconds)
2. **Processing Layer**: Filters noise and uses LLM to create meaningful activity summaries
3. **Consumption Layer**: Displays activities and generates task recommendations
@@ -122,7 +119,7 @@ Storage varies based on your settings:
**Factors**:
-- Capture interval (1s = more screenshots)
+- Capture interval (0.2s = 5 screenshots/sec/monitor)
- Image quality (85% is default)
- Number of monitors
- Image optimization (reduces duplicates)
@@ -161,7 +158,7 @@ Storage varies based on your settings:
1. ✅ Permissions granted (Accessibility + Screen Recording)
2. ✅ At least one monitor enabled in Settings
-3. ✅ Capture is running (Dashboard shows "Running")
+3. ✅ Check system status in sidebar
**Solutions**:
@@ -206,7 +203,7 @@ Storage varies based on your settings:
- Verify LLM connection (Settings → Test Connection)
- Check API key has available credits
-- Try a different model (gpt-4 vs gpt-3.5-turbo)
+- Try a different model (gpt-4o vs gpt-4o-mini)
- Review logs for errors
### iDO is using too much CPU/RAM
@@ -296,13 +293,13 @@ View the source code: https://github.com/UbiquantAI/iDO
**Differences**:
-| Feature | iDO | Rewind.ai |
-| ------------ | ---------------------------- | ------------- |
-| **Source** | Open source | Closed source |
-| **Privacy** | Local-only | Cloud option |
-| **LLM** | Bring your own | Built-in |
-| **Cost** | Free (+ API costs) | Subscription |
-| **Platform** | macOS (Linux/Windows coming) | macOS only |
+| Feature | iDO | Rewind.ai |
+| ------------ | --------------------- | ------------- |
+| **Source** | Open source | Closed source |
+| **Privacy** | Local-only | Cloud option |
+| **LLM** | Bring your own | Built-in |
+| **Cost** | Free (+ API costs) | Subscription |
+| **Platform** | macOS, Windows, Linux | macOS only |
## Features & Roadmap
@@ -340,12 +337,12 @@ See our roadmap: https://github.com/UbiquantAI/iDO/issues
**Planned features**:
-- Windows and Linux support
- App-specific filtering
- Automatic data retention policies
- Task manager integrations
- Custom agents
- Team/multi-user support
+- Mobile companion app
### Can I build custom agents?
@@ -369,13 +366,13 @@ See [Backend Development Guide](../developers/guides/backend/README.md#agent-sys
**Costs depend on**:
-- LLM model (gpt-4 vs gpt-3.5-turbo)
+- LLM model (gpt-4o vs gpt-4o-mini)
- Capture interval (more screenshots = more API calls)
- Activity complexity
**Tips to reduce costs**:
-- Use gpt-3.5-turbo instead of gpt-4
+- Use gpt-4o-mini (default) for best value
- Increase capture interval to 2-3 seconds
- Disable capture when not needed
diff --git a/docs/user-guide/features.md b/docs/user-guide/features.md
index 9e87273..1515be4 100644
--- a/docs/user-guide/features.md
+++ b/docs/user-guide/features.md
@@ -11,306 +11,272 @@ iDO is a local-first AI desktop copilot that:
- **✅ Recommends tasks** - Suggests what to do next based on your patterns
- **🔒 Keeps data private** - Everything stays on your device
-## Core Features
+## Main Features
-### 1. Activity Timeline
+### 1. Pomodoro Focus Mode
-**What it does**: Automatically captures and organizes your computer activities
-
-**How it works**:
-- Monitors keyboard and mouse events
-- Takes periodic screenshots
-- Groups related events into activities
-- Uses AI to generate descriptive titles
+**What it does**: Focus Mode with intelligent Pomodoro timer for capturing and analyzing your focused work
**How to use**:
-1. Navigate to **Activity Timeline** in the sidebar
-2. Browse activities grouped by date
-3. Click any activity to see details:
- - Screenshots from that time period
- - Event descriptions
- - Duration and timestamps
-**Benefits**:
-- Review what you worked on
-- Find information from past activities
-- Track how you spend time
+1. Navigate to **Pomodoro** in the sidebar
+2. Left panel shows your scheduled todos - click one to select
+3. Or enter a custom task description manually
+4. Choose a mode: Classic (25/5), Deep (50/10), Quick (15/3), Focus (90/15)
+5. Click **Start** to begin
+
+**Interface**:
-### 2. AI-Powered Summaries
+- **Left sidebar**: Scheduled todos with count badge for quick selection
+- **Main panel**: Timer display with mode selector and task input
+- **Task association**: Link sessions to AI-generated todos or manual intent
+- **Phase display**: Shows current phase (Work/Break) and round progress
-**What it does**: Uses LLMs to create human-readable activity descriptions
+**Features**:
-**How it works**:
-- Analyzes screenshots and event data
-- Generates concise, meaningful titles
-- Groups similar events together
-- Filters out noise and interruptions
+- 4 preset modes with customizable duration
+- Links sessions to AI-generated todos
+- Real-time countdown with circular progress
+- Phase notifications (work/break transitions)
+- Activity capture during work phases
+- Work phase status tracking (activities captured during session)
-**Example**:
-Instead of seeing raw events like:
-- `Mouse click at (450, 320)`
-- `Keyboard input: "const foo =..."`
-- `Window focus: VSCode`
+### 2. Pomodoro Review
-You see:
-- `Writing TypeScript code in VSCode`
+**What it does**: Review your focus sessions and track your productivity
-**Benefits**:
-- Easy-to-understand activity history
-- No manual note-taking required
-- Context-aware descriptions
+**How to use**:
-### 3. Smart Task Recommendations
+1. Navigate to **Pomodoro Review** in the sidebar
+2. View period statistics (weekly total, daily average, completion rate)
+3. Check the weekly focus chart for trends
+4. Select a date using the date picker
+5. Browse sessions and click to view detailed breakdown
+6. Review AI-generated focus analysis in the session dialog
-**What it does**: AI agents analyze your activities and suggest tasks
+**Interface**:
-**How it works**:
-1. Agents monitor your activity stream
-2. Detect patterns and context (coding, writing, browsing, etc.)
-3. Generate relevant task suggestions
-4. Prioritize based on importance
+- **Statistics Overview Cards**: Weekly total, focus hours, daily average, completion rate
+- **Weekly Focus Chart**: Bar chart showing daily focus minutes with goal line
+- **Time Period Selector**: Switch between week/month/year views
+- **Date Picker**: Select specific dates to view sessions
+- **Session List**: Click sessions to open detailed dialog
+- **Session Detail Dialog**: Shows focus metrics, activity timeline, LLM analysis
-**How to use**:
-1. Navigate to **Agents** in the sidebar
-2. View AI-generated task recommendations
-3. Mark tasks as complete
-4. See how tasks relate to specific activities
+**Features**:
-**Example agents**:
-- **Code Review Agent**: Suggests code review tasks when you're coding
-- **Documentation Agent**: Recommends writing docs after implementing features
-- **Research Agent**: Proposes follow-up research based on browsing
+- Period statistics with visual overview
+- Activity timeline during each session
+- AI-powered focus quality evaluation (strengths, weaknesses, suggestions)
+- Work type analysis (deep work, distractions, focus streaks)
+- Weekly focus goal tracking
+- Distraction percentage analysis
-**Benefits**:
-- Never forget important tasks
-- Context-aware reminders
-- Learn from your patterns
+### 3. Knowledge
-### 4. Privacy-First Design
+**What it does**: All long-term knowledge captured from your recent activity
-**What it does**: Keeps all your data on your device
+**How to use**:
-**How it works**:
-- All data stored in local SQLite database
-- Screenshots saved to local disk
-- LLM calls use your own API key
-- No cloud uploads or syncing
+1. Navigate to **Knowledge** in the sidebar
+2. Use left sidebar to filter by category/keyword
+3. Search or filter (All/Favorites/Recent)
+4. Click a card to view details or edit
+5. Use **Smart Merge** to find and combine similar knowledge
+6. Create new notes manually
-**What gets sent to LLM**:
-- Screenshots (as base64 data)
-- Event summaries (no raw keystrokes)
-- Timestamps and window titles
+**Interface**:
-**What never leaves your device**:
-- Raw database
-- Complete keystroke logs
-- Sensitive information
+- **Left Sidebar**: Category filter showing keyword counts
+- **Search Bar**: Full-text search across titles, descriptions, keywords
+- **Filter Tabs**: All / Favorites / Recent (last 7 days)
+- **Action Buttons**: Smart Merge, New Note
+- **Knowledge Cards Grid**: Scrollable card list with hover actions
+- **Detail Dialog**: View/edit knowledge details
-**Benefits**:
-- Full control over your data
-- Works offline (except LLM calls)
-- No subscription or vendor lock-in
+**Features**:
-### 5. Customizable Capture
+- AI-generated from activities
+- Full-text search across all cards
+- Favorites with quick toggle
+- Category/keyword filtering
+- Smart duplicate detection and merging with configurable thresholds
+- Create manual notes
+- Retry dialog for LLM errors
-**What it does**: Control what and how iDO captures
+### 4. Todos
-**Settings you can adjust**:
+**What it does**: AI will automatically generate todos from your activities
-**Capture Interval**
-- How often screenshots are taken
-- Default: 1 second
-- Range: 0.5 - 5 seconds
-- Lower = more detailed, higher = less disk space
+**How to use**:
-**Screen Selection**
-- Choose which monitors to capture
-- Enable/disable per monitor
-- Useful for privacy (exclude personal monitor)
+1. Navigate to **Todos** in the sidebar
+2. Toggle between Cards View and Calendar View
+3. Use left sidebar to filter by category/keyword
+4. Click a todo to view details or edit
+5. Drag todos to calendar to schedule
+6. Click **Create Todo** to add manually
+7. Send todos to Chat for agent execution
-**Image Quality**
-- Screenshot compression level
-- Default: 85%
-- Range: 50% - 100%
-- Lower = smaller files, higher = better quality
+**Interface**:
-**Image Optimization**
-- Smart screenshot deduplication
-- Skips nearly-identical screenshots
-- Saves disk space automatically
+- **View Mode Toggle**: Switch between Cards and Calendar views
+- **Left Sidebar**: Category filter
+- **Cards View**: Grid of todo cards with hover actions
+- **Calendar View**: Full calendar with drag-to-schedule
+- **Pending Section**: Quick access to unscheduled todos (calendar view)
+- **Detail Dialog**: View/edit schedule, recurrence, send to chat
+- **Create Todo Dialog**: Manual todo creation
-**How to configure**:
-1. Open **Settings** → **Screen Capture**
-2. Adjust preferences
-3. Click **Save**
+**Features**:
-### 6. Search and Filter
+- Auto-generated from activities
+- Manual creation supported
+- Calendar scheduling with start/end times
+- Recurrence rules (daily, weekly, etc.)
+- Keywords and priority levels
+- Drag-and-drop to calendar
+- Send to Chat for AI execution
+- Linked to Pomodoro sessions
+- Filter by category
-**What it does**: Find past activities quickly
+### 5. Diary
-**Search by**:
-- Keywords in activity titles
-- Date ranges
-- Duration
-- Applications used
+**What it does**: Daily journals compiled from your AI activity summaries
**How to use**:
-1. Go to **Activity Timeline**
-2. Use the search bar at the top
-3. Apply filters (date, duration, etc.)
-4. Click any result to view details
-### 7. Multi-Language Support
+1. Navigate to **Diary** in the sidebar
+2. Scroll through past diaries or use date picker
+3. Click a diary card to view full content
+4. Edit summaries as needed
+5. Select a date and click **Generate Diary** to create new
-**What it does**: Use iDO in your preferred language
+**Interface**:
-**Supported languages**:
-- English
-- 中文 (Chinese)
+- **Action Bar**: Refresh, Load More, Date Picker, Generate button
+- **Diary Cards**: Scrollable list of daily summaries
+- **Diary Card**: Shows date, key highlights, work categories
-**How to change**:
-1. Open **Settings** → **Preferences**
-2. Select **Language**
-3. Choose your language
-4. UI updates immediately
+**Features**:
-### 8. Theme Customization
+- AI-generated daily summaries
+- Scrollable history with load more
+- Select specific dates to generate
+- Editable content
+- Key highlights extraction
+- Work type categorization
+- Delete diaries
-**What it does**: Adjust visual appearance
+### 6. Chat
-**Available themes**:
-- **Light Mode**: Bright interface
-- **Dark Mode**: Easy on the eyes
-- **System**: Match OS theme
+**What it does**: Conversational interface about your activity history with streaming responses and image support
-**How to change**:
-1. Open **Settings** → **Appearance**
-2. Select theme
-3. Changes apply immediately
-
-## Interface Overview
-
-### Dashboard
+**How to use**:
-**What you'll see**:
-- System status (Running / Stopped)
-- Active LLM model
-- Statistics (events captured, activities created)
-- Recent activity summary
+1. Navigate to **Chat** in the sidebar
+2. Select an existing conversation or create a new one
+3. Type a question about your activities
+4. Get streaming responses grounded in your data
+5. Optionally drag & drop images to analyze
-**Actions**:
-- Start/stop activity capture
-- View system health
-- Quick access to settings
+**Interface**:
-### Activity Timeline
+- **Left Sidebar**: Conversation list with new/delete actions (desktop)
+- **Mobile Overlay**: Slide-out conversation list (mobile)
+- **Message Area**: Scrollable message history with streaming support
+- **Activity Context**: Shown when conversation is linked to an activity
+- **Input Area**: Text input with image drag-drop, model selector
-**Layout**:
-- Chronological list grouped by date
-- Sticky date headers
-- Activity cards with:
- - Title
- - Duration
- - Thumbnail screenshot
- - Timestamp
+**Features**:
-**Interactions**:
-- Click activity → View details
-- Scroll → Auto-load more
-- Search → Filter results
+- Context-aware responses grounded in your activity data
+- Streaming AI responses for real-time feedback
+- Image drag-and-drop support (PNG, JPG, GIF)
+- Model selection per conversation
+- Auto-generated conversation titles
+- Activity context linking
+- Send todos/knowledge to chat from other pages
+- Retry failed responses
+- Cancel streaming responses
-### Activity Details
+**Example questions**:
-**What you'll see**:
-- Full activity title and description
-- All screenshots from that period
-- Event timeline
-- Related tasks (if any)
+- "What did I work on yesterday?"
+- "How much time did I spend coding this week?"
+- "What were my main activities?"
+- "Summarize my focus sessions from last week"
-**Actions**:
-- Navigate between screenshots
-- Export activity data
-- Generate tasks from this activity
+### 7. Dashboard
-### Agents View
+**What it does**: View Token usage and Agent task statistics
-**What you'll see**:
-- List of AI-generated tasks
-- Task status (pending, completed)
-- Priority levels
-- Source activities
+**How to use**:
-**Actions**:
-- Mark task as complete
-- View source activity
-- Dismiss tasks
+1. Navigate to **Dashboard** in the sidebar
+2. Filter by all models or select a specific model
+3. View token usage, API calls, and cost metrics
+4. Check usage trends over time with interactive chart
+
+**Interface**:
+
+- **Model Filter**: Dropdown to select all models or specific model
+- **LLM Stats Cards Grid**:
+ - Total Tokens (with description)
+ - Total API Calls (with description)
+ - Total Cost (single model view)
+ - Models Used (all models view)
+ - Model Price (single model view)
+- **Usage Trend Chart**: Interactive chart with dimension and range selectors
+- **Trend Dimensions**: Focus minutes, LLM tokens, API calls
+- **Trend Ranges**: Week, Month, Year
+
+**Features**:
+
+- Real-time LLM usage statistics
+- Token count and API call tracking
+- Cost analysis per model
+- Usage trend visualization
+- Model performance comparison
+- Currency-aware cost display
+- Responsive layout for all screen sizes
+
+**Metrics tracked**:
+
+- Total tokens processed
+- Total API calls made
+- Total cost (currency-formatted)
+- Models used in selected period
+- Per-million-token pricing (input/output)
-### Settings
+## Interface Overview
-**Categories**:
-- **LLM Configuration**: API key, model selection
-- **Screen Capture**: Monitor selection, intervals
-- **Preferences**: Language, theme, notifications
-- **Privacy**: Data retention, export options
-- **About**: Version info, licenses
+### Sidebar Navigation
-## Data Management
+| Icon | Page | Description |
+| ------------- | --------------- | ------------------------------------- |
+| Timer | Pomodoro | Focus timer with task linking |
+| History | Pomodoro Review | Session history and metrics |
+| BookOpen | Knowledge | AI-generated knowledge cards |
+| CheckSquare | Todos | AI-generated tasks |
+| NotebookPen | Diary | Daily work summaries |
+| MessageSquare | Chat | Conversational AI about your history |
+| BarChart | Dashboard | Statistics and usage tracking |
+| Settings | Settings | App configuration (bottom of sidebar) |
-### Storage Location
+### Data Storage
All data is stored locally:
+
- **macOS**: `~/.config/ido/`
- **Windows**: `%APPDATA%\ido\`
- **Linux**: `~/.config/ido/`
-### Data Retention
-
-**Automatic cleanup** (coming soon):
-- Screenshots older than 30 days (configurable)
-- Completed tasks older than 90 days
-- Logs older than 7 days
-
-**Manual cleanup**:
-1. **Settings** → **Privacy**
-2. Choose what to delete
-3. Confirm deletion
+Contains:
-### Export Data
-
-**Export options** (coming soon):
-- Export activities as JSON
-- Export screenshots as ZIP
-- Export database backup
-
-## Tips for Best Results
-
-### 1. Configure Permissions Properly
-
-**macOS**: Grant both Accessibility and Screen Recording permissions for full functionality
-
-### 2. Choose the Right LLM Model
-
-- **gpt-4**: Best quality summaries, slower, more expensive
-- **gpt-3.5-turbo**: Good quality, faster, cheaper
-- **Other models**: Experiment with compatible OpenAI-style APIs
-
-### 3. Adjust Capture Interval
-
-- **Fast work** (coding, design): 1 second intervals
-- **General use**: 2-3 second intervals
-- **Browsing/reading**: 3-5 second intervals
-
-### 4. Manage Disk Space
-
-- Enable **Image Optimization** to reduce duplicates
-- Lower **Image Quality** if disk space is limited
-- Disable capture on unused monitors
-
-### 5. Review Activities Regularly
-
-- Check timeline daily to verify accuracy
-- Dismiss irrelevant tasks
-- Adjust settings based on results
+- `ido.db` - SQLite database
+- `screenshots/` - Captured screenshots
+- `logs/` - Application logs
## Privacy Features
@@ -329,27 +295,18 @@ All data is stored locally:
⚠️ **Database is unencrypted** - Store in encrypted volume if needed
⚠️ **Logs may contain info** - Review before sharing
-## Limitations
-
-### Current Limitations
-
-- **macOS only** (Windows/Linux coming soon)
-- **Single user** (no multi-user accounts)
-- **Local only** (no cloud sync)
-- **Requires LLM API** (costs apply for API calls)
-
-### Performance Considerations
+## Performance
- **CPU usage**: ~2-5% during capture
- **Memory**: ~200-500 MB RAM
- **Disk**: ~100-500 MB per day (varies by interval and quality)
-- **LLM costs**: $0.01-0.10 per hour of activity (varies by model)
+- **Screenshot interval**: 0.2 seconds (5 screenshots/sec/monitor)
## Next Steps
-- **[Read FAQ](./faq.md)** - Common questions
+- **[Installation Guide](./installation.md)** - Set up iDO
+- **[FAQ](./faq.md)** - Common questions
- **[Troubleshooting](./troubleshooting.md)** - Fix issues
-- **[Installation Guide](./installation.md)** - Re-visit setup
## Need Help?
diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md
index c7b4d6f..fabe2a4 100644
--- a/docs/user-guide/installation.md
+++ b/docs/user-guide/installation.md
@@ -7,12 +7,12 @@ This guide will help you download and install iDO on your computer.
### Supported Platforms
- **macOS**: 13 (Ventura) or later
-- **Windows**: 10 or later (Coming soon)
-- **Linux**: Ubuntu 20.04+ or equivalent (Coming soon)
+- **Windows**: 10 or later
+- **Linux**: Ubuntu 20.04+ or equivalent
### Minimum Hardware
-- **CPU**: 64-bit processor
+- **CPU**: 64-bit processor (Apple Silicon or Intel)
- **RAM**: 4 GB minimum, 8 GB recommended
- **Disk Space**: 500 MB for application, plus space for activity data
- **Display**: 1280x720 minimum resolution
@@ -29,14 +29,14 @@ Download the latest version from GitHub:
#### macOS
-- Download `iDO_x.x.x_aarch64.dmg` (Apple Silicon - M1/M2/M3)
+- Download `iDO_x.x.x_aarch64.dmg` (Apple Silicon - M1/M2/M3/M4)
- Download `iDO_x.x.x_x64.dmg` (Intel Mac)
-#### Windows (Coming Soon)
+#### Windows
- `iDO_x.x.x_x64_en-US.msi` - Windows installer
-#### Linux (Coming Soon)
+#### Linux
- `ido_x.x.x_amd64.deb` - Debian/Ubuntu
- `ido-x.x.x-1.x86_64.rpm` - Fedora/RHEL
@@ -60,23 +60,21 @@ Download the latest version from GitHub:
3. Click **Open Anyway**
4. Confirm by clicking **Open**
-**Unsigned build workaround**: If the downloaded build remains blocked after approving it in Privacy & Security, clear the quarantine flag and add an ad-hoc signature:
+**Unsigned build workaround**: If the downloaded build remains blocked after approving it in Privacy & Security, clear the quarantine flag:
```bash
xattr -cr /Applications/iDO.app
codesign -s - -f /Applications/iDO.app
```
-Then launch iDO again from Applications.
-
-### Windows (Coming Soon)
+### Windows
1. **Download** the `.msi` installer
2. **Double-click** the installer
3. **Follow** the installation wizard
4. **Launch** iDO from the Start Menu
-### Linux (Coming Soon)
+### Linux
#### Debian/Ubuntu (.deb)
@@ -100,71 +98,69 @@ chmod +x ido_x.x.x_amd64.AppImage
## First Run Setup
-When you first launch iDO, you'll need to complete some setup steps:
+When you first launch iDO, you'll go through an initial setup wizard with 6 steps:
-### 1. Grant System Permissions
+### 1. Welcome
-#### macOS Permissions
+Get started with iDO and learn about key features.
-iDO requires the following permissions:
+### 2. Screen Selection
-**Accessibility Permission** (Required)
+Choose which monitors to capture:
-- Allows iDO to monitor keyboard and mouse events
-- Go to **System Settings** → **Privacy & Security** → **Accessibility**
-- Enable iDO in the list
+- View all detected displays
+- Enable/disable specific monitors
+- By default, only the primary monitor is enabled
-**Screen Recording Permission** (Required)
+**Default Settings**:
+- **Capture interval**: 0.2 seconds (5 screenshots per second per monitor)
+- **Image quality**: 85%
+- **Smart deduplication**: Enabled
-- Allows iDO to capture screenshots
-- Go to **System Settings** → **Privacy & Security** → **Screen Recording**
-- Enable iDO in the list
+### 3. LLM Provider Configuration
-iDO will guide you through granting these permissions on first run.
+iDO uses an LLM (Large Language Model) to analyze your activities:
-### 2. Configure LLM Provider
+1. Enter your API endpoint and key
+2. Select a model (default: gpt-4o-mini)
+3. Test the connection
-iDO uses an LLM (Large Language Model) to analyze your activities:
+**Supported Providers**:
-1. **Open Settings** → **LLM Configuration**
-2. **Choose Provider**: OpenAI (recommended) or compatible API
-3. **Enter API Key**: Your OpenAI API key
- - Get one at https://platform.openai.com/api-keys
-4. **Select Model**:
- - `gpt-4` - Most capable (recommended)
- - `gpt-3.5-turbo` - Faster and cheaper
-5. **Test Connection**: Click to verify it works
+- OpenAI (GPT-4, GPT-3.5-Turbo)
+- Anthropic (Claude)
+- Local models (Ollama, LM Studio, etc.)
+- Any OpenAI-compatible API
**Privacy Note**: Your API key is stored locally and used only to make LLM requests on your behalf. iDO does not send data to any iDO servers.
-### 3. Configure Screen Capture (Optional)
+### 4. Grant System Permissions
-Choose which monitors to capture:
+#### macOS Permissions
+
+iDO requires the following permissions:
-1. **Open Settings** → **Screen Capture**
-2. **View Monitors**: See all detected displays
-3. **Toggle On/Off**: Enable/disable specific monitors
-4. **Save**: Apply your preferences
+**Accessibility Permission** (Required)
-By default, only the primary monitor is enabled.
+- Allows iDO to monitor keyboard and mouse events
+- Go to **System Settings** → **Privacy & Security** → **Accessibility**
+- Enable iDO in the list
-### 4. Adjust Preferences (Optional)
+**Screen Recording Permission** (Required)
-Fine-tune iDO to your liking:
+- Allows iDO to capture screenshots
+- Go to **System Settings** → **Privacy & Security** → **Screen Recording**
+- Enable iDO in the list
-- **Capture Interval**: How often to take screenshots (default: 1 second)
-- **Image Quality**: Balance quality vs disk space (default: 85%)
-- **Language**: English or 中文 (Chinese)
-- **Theme**: Light or Dark mode
+The app will guide you through granting these permissions.
-## Verify Installation
+### 5. Set Goals (Optional)
-To verify iDO is working correctly:
+Define your focus goals and preferences for AI-generated tasks.
-1. **Check Dashboard**: You should see system status as "Running"
-2. **Use Your Computer**: Browse, type, etc. for 1-2 minutes
-3. **View Timeline**: Navigate to Activity Timeline
-4. **See Activities**: You should see captured activities with screenshots
+### 6. Complete
+
+You're ready to start using iDO!
## Data Storage
@@ -188,13 +184,13 @@ This directory contains:
2. **Drag** iDO from Applications to Trash
3. **Remove data** (optional): Delete `~/.config/ido/`
-### Windows (Coming Soon)
+### Windows
1. **Control Panel** → **Programs** → **Uninstall a program**
2. Select iDO and click **Uninstall**
3. **Remove data** (optional): Delete `%APPDATA%\ido\`
-### Linux (Coming Soon)
+### Linux
```bash
# Debian/Ubuntu
@@ -207,6 +203,17 @@ sudo rpm -e ido
rm -rf ~/.config/ido/
```
+## Verify Installation
+
+To verify iDO is working correctly:
+
+1. **Complete the setup wizard**
+2. **Grant permissions** when prompted
+3. **Configure your LLM** model
+4. **Start using your computer** for a few minutes
+5. **Navigate to Insights** → **Knowledge** or **Todos**
+6. **Check for AI-generated content** from your activities
+
## Troubleshooting
### App Won't Launch
@@ -227,13 +234,14 @@ rm -rf ~/.config/ido/
### LLM Connection Failed
-**Issue**: Test connection fails
+**Issue**: Model test connection fails
**Solutions**:
1. Verify your API key is correct
2. Check your internet connection
-3. Try a different model (e.g., switch from gpt-4 to gpt-3.5-turbo)
+3. Verify the model endpoint is accessible
+4. Try a different model
### High CPU/Memory Usage
@@ -241,9 +249,10 @@ rm -rf ~/.config/ido/
**Solutions**:
-1. Increase capture interval (Settings → 2-3 seconds instead of 1)
+1. Increase capture interval (Settings → 2-5 seconds instead of 0.2)
2. Lower image quality (Settings → 70% instead of 85%)
3. Disable unused monitors
+4. Reduce number of monitors
For more troubleshooting help, see the [Troubleshooting Guide](./troubleshooting.md).
diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md
index 3188920..5f3ebdc 100644
--- a/docs/user-guide/troubleshooting.md
+++ b/docs/user-guide/troubleshooting.md
@@ -160,7 +160,7 @@ pnpm check-i18n
```toml
# Edit backend/config/config.toml
[monitoring]
- capture_interval = 1 # Try lower value
+ capture_interval = 0.2 # Default is 0.2s (5 screenshots/sec)
```
### LLM Connection Failed
@@ -309,7 +309,7 @@ codesign --force --deep --sign "Developer ID" ./target/release/bundle/macos/iDO.
# 1. Reduce capture interval
# Edit backend/config/config.toml
[monitoring]
-capture_interval = 2 # Increase from 1 to 2 seconds
+capture_interval = 1 # Increase from 0.2 to 1+ seconds
# 2. Disable screenshot capture temporarily
# Settings → Screen Capture → Disable all monitors
diff --git a/package.json b/package.json
index 1a31895..3ce656b 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"@hookform/resolvers": "^5.2.2",
"@icons-pack/react-simple-icons": "^13.8.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
+ "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -55,6 +56,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/vite": "^4.1.17",
+ "@tanstack/react-query": "^5.90.13",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-http": "~2.4.4",
@@ -67,6 +69,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "framer-motion": "12.23.26",
"harden-react-markdown": "^1.1.5",
"highlight.js": "^11.11.1",
"i18next": "^25.6.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 76667e1..78395a2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-collapsible':
+ specifier: ^1.1.12
+ version: 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-context-menu':
specifier: ^2.2.16
version: 2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -68,6 +71,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.17
version: 4.1.17(rolldown-vite@7.2.2(@types/node@22.19.0)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1))
+ '@tanstack/react-query':
+ specifier: ^5.90.13
+ version: 5.90.13(react@19.2.0)
'@tauri-apps/api':
specifier: ^2.9.0
version: 2.9.0
@@ -104,6 +110,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
+ framer-motion:
+ specifier: 12.23.26
+ version: 12.23.26(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
harden-react-markdown:
specifier: ^1.1.5
version: 1.1.5(react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
@@ -957,7 +966,7 @@ packages:
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/primitive@1.1.3':
- resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+ resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==, tarball: https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz}
'@radix-ui/react-alert-dialog@1.1.15':
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
@@ -985,6 +994,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-collapsible@1.1.12':
+ resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==, tarball: https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@@ -999,7 +1021,7 @@ packages:
optional: true
'@radix-ui/react-compose-refs@1.1.2':
- resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+ resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==, tarball: https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -1021,7 +1043,7 @@ packages:
optional: true
'@radix-ui/react-context@1.1.2':
- resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+ resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==, tarball: https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -1100,7 +1122,7 @@ packages:
optional: true
'@radix-ui/react-id@1.1.1':
- resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+ resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==, tarball: https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -1174,7 +1196,7 @@ packages:
optional: true
'@radix-ui/react-presence@1.1.5':
- resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==, tarball: https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1187,7 +1209,7 @@ packages:
optional: true
'@radix-ui/react-primitive@2.1.3':
- resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==, tarball: https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1291,7 +1313,7 @@ packages:
optional: true
'@radix-ui/react-slot@1.2.3':
- resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+ resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==, tarball: https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -1384,7 +1406,7 @@ packages:
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
- resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+ resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==, tarball: https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -1657,6 +1679,14 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
+ '@tanstack/query-core@5.90.13':
+ resolution: {integrity: sha512-3VzxSkv4ojPPHu0WfOwZ/W5CuN7evAXPzQS+Py2glGxk59Wp+k2T/wgRfrgXAcX1kCTvD9RYUcVEHkMXkEN5jw==, tarball: https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.13.tgz}
+
+ '@tanstack/react-query@5.90.13':
+ resolution: {integrity: sha512-i6DY9wnghE0ghHJfDrnnFNatn4CNBzMZv4xPzKB7Lb9zMAoImAxPKoGK9gLOm79aopDa07p6ytlFFWotvwj3DQ==, tarball: https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.13.tgz}
+ peerDependencies:
+ react: ^18 || ^19
+
'@tauri-apps/api@2.9.0':
resolution: {integrity: sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==}
@@ -2492,6 +2522,20 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
+ framer-motion@12.23.26:
+ resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==, tarball: https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
fs-extra@8.1.0:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'}
@@ -3228,6 +3272,12 @@ packages:
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==, tarball: https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz}
engines: {node: '>=0.10.0'}
+ motion-dom@12.23.23:
+ resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==, tarball: https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz}
+
+ motion-utils@12.23.6:
+ resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==, tarball: https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -5003,6 +5053,22 @@ snapshots:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
+ '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.2
+ '@types/react-dom': 19.2.2(@types/react@19.2.2)
+
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
@@ -5644,6 +5710,13 @@ snapshots:
tailwindcss: 4.1.17
vite: rolldown-vite@7.2.2(@types/node@22.19.0)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1)
+ '@tanstack/query-core@5.90.13': {}
+
+ '@tanstack/react-query@5.90.13(react@19.2.0)':
+ dependencies:
+ '@tanstack/query-core': 5.90.13
+ react: 19.2.0
+
'@tauri-apps/api@2.9.0': {}
'@tauri-apps/cli-darwin-arm64@2.9.4':
@@ -6465,6 +6538,15 @@ snapshots:
format@0.2.2: {}
+ framer-motion@12.23.26(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ dependencies:
+ motion-dom: 12.23.23
+ motion-utils: 12.23.6
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+
fs-extra@8.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -7475,6 +7557,12 @@ snapshots:
modify-values@1.0.1: {}
+ motion-dom@12.23.23:
+ dependencies:
+ motion-utils: 12.23.6
+
+ motion-utils@12.23.6: {}
+
ms@2.1.3: {}
nano-spawn@2.0.0: {}
diff --git a/scripts/assign_activity_phases.py b/scripts/assign_activity_phases.py
new file mode 100755
index 0000000..3cad802
--- /dev/null
+++ b/scripts/assign_activity_phases.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""
+Assign work phases to existing Pomodoro activities that don't have phases
+
+This script finds all activities linked to Pomodoro sessions but missing
+work_phase assignment, and automatically assigns the correct phase based
+on the activity's start time and the session's phase timeline.
+"""
+
+import asyncio
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+# Add backend to path
+backend_path = Path(__file__).parent.parent / "backend"
+sys.path.insert(0, str(backend_path))
+
+from core.db import get_db
+from core.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+def calculate_phase_timeline(session: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """
+ Calculate work phase timeline for a session
+
+ Returns only work phases (not breaks) with their time ranges
+ """
+ start_time = datetime.fromisoformat(session["start_time"])
+ work_duration = session.get("work_duration_minutes", 25)
+ break_duration = session.get("break_duration_minutes", 5)
+ completed_rounds = session.get("completed_rounds", 0)
+
+ timeline = []
+ current_time = start_time
+
+ for round_num in range(1, completed_rounds + 1):
+ # Work phase
+ work_end = current_time + timedelta(minutes=work_duration)
+ timeline.append(
+ {
+ "phase_number": round_num,
+ "start_time": current_time.isoformat(),
+ "end_time": work_end.isoformat(),
+ }
+ )
+ current_time = work_end
+
+ # Add break duration to move to next work phase
+ current_time = current_time + timedelta(minutes=break_duration)
+
+ return timeline
+
+
+def determine_work_phase(
+ activity_start_time: str, phase_timeline: List[Dict[str, Any]]
+) -> Optional[int]:
+ """
+ Determine which work phase an activity belongs to based on its start time
+
+ Args:
+ activity_start_time: ISO format timestamp of activity start
+ phase_timeline: List of work phase dictionaries with start_time, end_time
+
+ Returns:
+ Work phase number (1-based) or None if no phases available
+ """
+ if not phase_timeline:
+ return None
+
+ try:
+ activity_time = datetime.fromisoformat(activity_start_time)
+
+ # First, check if activity falls within any work phase
+ for phase in phase_timeline:
+ phase_start = datetime.fromisoformat(phase["start_time"])
+ phase_end = datetime.fromisoformat(phase["end_time"])
+
+ if phase_start <= activity_time <= phase_end:
+ return phase["phase_number"]
+
+ # Activity doesn't fall within any work phase
+ # Assign to nearest work phase
+ nearest_phase = None
+ min_distance = None
+
+ for phase in phase_timeline:
+ phase_start = datetime.fromisoformat(phase["start_time"])
+ phase_end = datetime.fromisoformat(phase["end_time"])
+
+ # Calculate distance from activity to this phase
+ if activity_time < phase_start:
+ distance = (phase_start - activity_time).total_seconds()
+ elif activity_time > phase_end:
+ distance = (activity_time - phase_end).total_seconds()
+ else:
+ # This shouldn't happen as we already checked above
+ return phase["phase_number"]
+
+ if min_distance is None or distance < min_distance:
+ min_distance = distance
+ nearest_phase = phase["phase_number"]
+
+ if nearest_phase:
+ logger.debug(
+ f"Activity at {activity_start_time} doesn't fall in any work phase, "
+ f"assigning to nearest phase: {nearest_phase}"
+ )
+
+ return nearest_phase
+
+ except Exception as e:
+ logger.error(f"Error determining work phase: {e}", exc_info=True)
+ return None
+
+
+async def assign_phases():
+ """Main function to assign phases to activities"""
+ db = get_db()
+
+ # Find all activities with session but no work phase
+ logger.info("Finding activities that need phase assignment...")
+
+ with db.get_connection() as conn:
+ cursor = conn.execute(
+ """
+ SELECT id, title, start_time, pomodoro_session_id
+ FROM activities
+ WHERE pomodoro_session_id IS NOT NULL
+ AND pomodoro_work_phase IS NULL
+ AND deleted = 0
+ ORDER BY start_time
+ """
+ )
+ activities = cursor.fetchall()
+
+ if not activities:
+ logger.info("✓ No activities need phase assignment")
+ return
+
+ logger.info(f"Found {len(activities)} activities needing phase assignment")
+
+ updated_count = 0
+ failed_count = 0
+
+ for activity_row in activities:
+ activity_id = activity_row[0]
+ title = activity_row[1]
+ start_time = activity_row[2]
+ session_id = activity_row[3]
+
+ try:
+ # Get session
+ session = await db.pomodoro_sessions.get_by_id(session_id)
+ if not session:
+ logger.warning(
+ f"Session {session_id} not found for activity {activity_id}"
+ )
+ failed_count += 1
+ continue
+
+ # Calculate phase timeline
+ phase_timeline = calculate_phase_timeline(session)
+
+ if not phase_timeline:
+ logger.warning(
+ f"No phases calculated for session {session_id} "
+ f"(completed_rounds: {session.get('completed_rounds', 0)})"
+ )
+ failed_count += 1
+ continue
+
+ # Determine work phase
+ work_phase = determine_work_phase(start_time, phase_timeline)
+
+ if work_phase is None:
+ logger.warning(
+ f"Could not determine phase for activity {activity_id} "
+ f"(start_time: {start_time})"
+ )
+ failed_count += 1
+ continue
+
+ # Update activity with work phase
+ conn.execute(
+ """
+ UPDATE activities
+ SET pomodoro_work_phase = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (work_phase, activity_id),
+ )
+
+ logger.info(
+ f"✓ Assigned phase {work_phase} to activity: {title} "
+ f"(session: {session.get('user_intent', 'Unknown')[:30]}...)"
+ )
+ updated_count += 1
+
+ except Exception as e:
+ logger.error(
+ f"Failed to process activity {activity_id}: {e}", exc_info=True
+ )
+ failed_count += 1
+
+ conn.commit()
+
+ logger.info("=" * 60)
+ logger.info(f"Phase assignment completed:")
+ logger.info(f" ✓ Updated: {updated_count}")
+ logger.info(f" ✗ Failed: {failed_count}")
+ logger.info(f" Total processed: {len(activities)}")
+ logger.info("=" * 60)
+
+
+def main():
+ """Entry point"""
+ logger.info("Starting activity phase assignment...")
+ asyncio.run(assign_phases())
+ logger.info("Done!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/check_i18n_usage.py b/scripts/check_i18n_usage.py
new file mode 100644
index 0000000..21653c6
--- /dev/null
+++ b/scripts/check_i18n_usage.py
@@ -0,0 +1,302 @@
+#!/usr/bin/env python3
+"""
+Check i18n key usage and identify unused keys.
+This script:
+1. Extracts all i18n keys from the locale files
+2. Searches for usage of each key in the codebase
+3. Reports which keys are unused
+"""
+
+import json
+import re
+import subprocess
+from pathlib import Path
+from typing import Dict, List, Set
+
+# Project root directory
+PROJECT_ROOT = Path(__file__).parent.parent
+
+# Locale files
+EN_LOCALE = PROJECT_ROOT / "src/locales/en.ts"
+ZH_CN_LOCALE = PROJECT_ROOT / "src/locales/zh-CN.ts"
+
+# Directories to search for usage
+SEARCH_DIR = PROJECT_ROOT / "src"
+
+
+def extract_keys_recursive(obj: dict, prefix: str = "") -> List[str]:
+ """
+ Recursively extract all leaf keys from a nested dictionary.
+ """
+ keys = []
+
+ for key, value in obj.items():
+ full_key = f"{prefix}.{key}" if prefix else key
+
+ if isinstance(value, dict):
+ # Recursively process nested objects
+ keys.extend(extract_keys_recursive(value, full_key))
+ elif isinstance(value, list):
+ # Handle arrays - add the key itself
+ keys.append(full_key)
+ else:
+ # Leaf node - add the key
+ keys.append(full_key)
+
+ return keys
+
+
+def parse_typescript_object(file_path: Path) -> dict:
+ """
+ Parse TypeScript object by converting to JSON-like format.
+ This is a simplified approach that works for our use case.
+ """
+ with open(file_path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ # Remove type annotations and comments
+ content = re.sub(r"//.*", "", content) # Remove line comments
+ content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) # Remove block comments
+
+ # Extract the object literal
+ match = re.search(r"(?:export const \w+ = |= )(\{.*\})", content, re.DOTALL)
+ if not match:
+ print("ERROR: Could not parse file")
+ return {}
+
+ obj_str = match.group(1)
+
+ # Simple transformation to make it JSON-like
+ # Replace single quotes with double quotes
+ obj_str = re.sub(r"'([^']*)'", r'"\1"', obj_str)
+
+ # Remove 'as const' and 'satisfies Translation'
+ obj_str = re.sub(r"\s*as\s+const\s*$", "", obj_str)
+ obj_str = re.sub(r"\s*satisfies\s+\w+\s*$", "", obj_str)
+
+ # Handle template literals (keep them as strings)
+ obj_str = re.sub(r"`([^`]*)`", r'"\1"', obj_str)
+
+ # Add quotes to unquoted keys
+ obj_str = re.sub(r'(\w+):', r'"\1":', obj_str)
+
+ # Remove trailing commas
+ obj_str = re.sub(r",(\s*[}\]])", r"\1", obj_str)
+
+ try:
+ return json.loads(obj_str)
+ except json.JSONDecodeError as e:
+ print(f"JSON parse error: {e}")
+ # Fallback: manual parsing
+ return parse_manually(file_path)
+
+
+def parse_manually(file_path: Path) -> dict:
+ """
+ Manual parsing as fallback.
+ Build the key tree by tracking nesting levels.
+ """
+ with open(file_path, "r", encoding="utf-8") as f:
+ lines = f.readlines()
+
+ result = {}
+ stack = [result]
+ key_stack = []
+
+ in_export = False
+ for line in lines:
+ stripped = line.strip()
+
+ # Skip empty lines and comments
+ if not stripped or stripped.startswith("//"):
+ continue
+
+ # Start tracking after "export const en ="
+ if "export const" in line and "=" in line:
+ in_export = True
+ continue
+
+ if not in_export:
+ continue
+
+ # End of object
+ if stripped == "} as const" or stripped == "} as const satisfies Translation":
+ break
+
+ # Match key: value or key: {
+ match = re.match(r"^(\w+):\s*(.*)$", stripped)
+ if not match:
+ # Check for closing braces
+ if stripped.startswith("}"):
+ if stack and len(stack) > 1:
+ stack.pop()
+ if key_stack:
+ key_stack.pop()
+ continue
+
+ key = match.group(1)
+ rest = match.group(2).strip()
+
+ # Determine if this is a nested object or a leaf value
+ if rest.startswith("{"):
+ # Nested object
+ new_dict = {}
+ stack[-1][key] = new_dict
+ stack.append(new_dict)
+ key_stack.append(key)
+ elif rest.startswith("["):
+ # Array value - treat as leaf
+ stack[-1][key] = []
+ else:
+ # Leaf value (string, number, etc.)
+ # Extract value (remove trailing comma)
+ value = re.sub(r",$", "", rest)
+ stack[-1][key] = value
+
+ return result
+
+
+def extract_all_keys(file_path: Path) -> Set[str]:
+ """Extract all i18n keys from a locale file."""
+ print(f"Extracting i18n keys from {file_path.name}...")
+
+ # Try JSON-like parsing first
+ obj = parse_typescript_object(file_path)
+
+ if not obj:
+ print("Falling back to manual parsing...")
+ obj = parse_manually(file_path)
+
+ if not obj:
+ print("ERROR: Could not parse file")
+ return set()
+
+ keys = extract_keys_recursive(obj)
+ print(f"Found {len(keys)} keys")
+
+ return set(keys)
+
+
+def search_key_usage(key: str) -> bool:
+ """
+ Search for usage of a key in the codebase using ripgrep.
+ Returns True if the key is used, False otherwise.
+ """
+ # Escape special regex characters in the key
+ escaped_key = re.escape(key)
+
+ # Search for patterns like t('key') or t("key")
+ # Use word boundary to avoid partial matches
+ patterns = [
+ f"t\\(['\\\"]({escaped_key})['\\\"]",
+ f"t\\(['\\\"]({escaped_key})\\.", # Dynamic keys like t('key.subkey')
+ ]
+
+ for pattern in patterns:
+ try:
+ result = subprocess.run(
+ ["rg", "-q", pattern, str(SEARCH_DIR)],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ return True
+ except FileNotFoundError:
+ # ripgrep not available, fall back to simple text search
+ try:
+ # Just search for the key name literally
+ simple_pattern = f't("{key}")'
+ result = subprocess.run(
+ ["grep", "-r", "-q", simple_pattern, str(SEARCH_DIR)],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ return True
+
+ simple_pattern2 = f"t('{key}')"
+ result = subprocess.run(
+ ["grep", "-r", "-q", simple_pattern2, str(SEARCH_DIR)],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ return True
+ except Exception:
+ pass
+
+ return False
+
+
+def find_unused_keys(keys: Set[str]) -> Set[str]:
+ """Find keys that are not used in the codebase."""
+ print("\nSearching for key usage in the codebase...")
+ print("This may take a while...\n")
+
+ unused = set()
+ used = set()
+
+ total = len(keys)
+ for idx, key in enumerate(sorted(keys), 1):
+ if idx % 50 == 0 or idx == total:
+ print(f"Progress: {idx}/{total} ({idx*100//total}%)")
+
+ if not search_key_usage(key):
+ unused.add(key)
+ else:
+ used.add(key)
+
+ print(f"\nUsed keys: {len(used)}")
+ print(f"Unused keys: {len(unused)}")
+
+ return unused
+
+
+def main():
+ print("=" * 60)
+ print("i18n Key Usage Checker")
+ print("=" * 60)
+
+ # Extract all keys
+ all_keys = extract_all_keys(EN_LOCALE)
+
+ if not all_keys:
+ print("No keys found!")
+ return
+
+ # Sample a few keys to verify parsing
+ sample_keys = sorted(all_keys)[:10]
+ print("\nSample keys (first 10):")
+ for key in sample_keys:
+ print(f" - {key}")
+
+ # Find unused keys
+ unused_keys = find_unused_keys(all_keys)
+
+ # Report results
+ print("\n" + "=" * 60)
+ print("RESULTS")
+ print("=" * 60)
+
+ if unused_keys:
+ print(f"\nFound {len(unused_keys)} unused keys:\n")
+ for key in sorted(unused_keys):
+ print(f" - {key}")
+
+ # Save to file for review
+ output_file = PROJECT_ROOT / "unused_i18n_keys.txt"
+ with open(output_file, "w") as f:
+ f.write("Unused i18n keys:\n")
+ f.write("=" * 60 + "\n\n")
+ for key in sorted(unused_keys):
+ f.write(f"{key}\n")
+
+ print(f"\nResults saved to: {output_file}")
+ else:
+ print("\nNo unused keys found! All keys are being used.")
+
+ print("\n" + "=" * 60)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate-sounds.js b/scripts/generate-sounds.js
new file mode 100644
index 0000000..b794664
--- /dev/null
+++ b/scripts/generate-sounds.js
@@ -0,0 +1,202 @@
+#!/usr/bin/env node
+
+/**
+ * Generate 8-bit/16-bit style notification sounds for Pomodoro phase transitions
+ * Creates simple WAV files with retro chiptune aesthetic
+ */
+
+import fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+// WAV file header structure
+function createWavHeader(dataLength, sampleRate, numChannels, bitsPerSample) {
+ const byteRate = (sampleRate * numChannels * bitsPerSample) / 8
+ const blockAlign = (numChannels * bitsPerSample) / 8
+ const buffer = Buffer.alloc(44)
+
+ // "RIFF" chunk descriptor
+ buffer.write('RIFF', 0)
+ buffer.writeUInt32LE(36 + dataLength, 4) // File size - 8
+ buffer.write('WAVE', 8)
+
+ // "fmt " sub-chunk
+ buffer.write('fmt ', 12)
+ buffer.writeUInt32LE(16, 16) // Subchunk1Size (16 for PCM)
+ buffer.writeUInt16LE(1, 20) // AudioFormat (1 for PCM)
+ buffer.writeUInt16LE(numChannels, 22)
+ buffer.writeUInt32LE(sampleRate, 24)
+ buffer.writeUInt32LE(byteRate, 28)
+ buffer.writeUInt16LE(blockAlign, 32)
+ buffer.writeUInt16LE(bitsPerSample, 34)
+
+ // "data" sub-chunk
+ buffer.write('data', 36)
+ buffer.writeUInt32LE(dataLength, 40)
+
+ return buffer
+}
+
+// Generate 8-bit square wave (retro game sound)
+function generate8BitChime(frequency, duration, sampleRate = 22050) {
+ const numSamples = Math.floor(duration * sampleRate)
+ const samples = Buffer.alloc(numSamples)
+
+ for (let i = 0; i < numSamples; i++) {
+ const t = i / sampleRate
+ // Square wave with envelope
+ const envelope = Math.exp(-t * 3) // Fast decay
+ const wave = Math.sin(2 * Math.PI * frequency * t) > 0 ? 1 : -1
+ const sample = wave * envelope * 127
+ samples.writeInt8(Math.floor(sample), i)
+ }
+
+ return samples
+}
+
+// Generate 16-bit bell-like sound
+function generate16BitBell(frequency, duration, sampleRate = 44100) {
+ const numSamples = Math.floor(duration * sampleRate)
+ const samples = Buffer.alloc(numSamples * 2) // 16-bit = 2 bytes per sample
+
+ for (let i = 0; i < numSamples; i++) {
+ const t = i / sampleRate
+ // Harmonic bell sound with overtones
+ const envelope = Math.exp(-t * 4)
+ const fundamental = Math.sin(2 * Math.PI * frequency * t)
+ const harmonic1 = 0.5 * Math.sin(2 * Math.PI * frequency * 2 * t)
+ const harmonic2 = 0.25 * Math.sin(2 * Math.PI * frequency * 3 * t)
+ const wave = fundamental + harmonic1 + harmonic2
+ const sample = wave * envelope * 16384 // 16-bit range
+ samples.writeInt16LE(Math.floor(sample), i * 2)
+ }
+
+ return samples
+}
+
+// Generate ascending 8-bit arpeggio (victory/completion sound)
+function generate8BitMelody(sampleRate = 22050) {
+ const duration = 1.2
+ const numSamples = Math.floor(duration * sampleRate)
+ const samples = Buffer.alloc(numSamples)
+
+ // Arpeggio notes: C5, E5, G5, C6 (major chord)
+ const notes = [523.25, 659.25, 783.99, 1046.5]
+ const noteDuration = duration / notes.length
+
+ for (let i = 0; i < numSamples; i++) {
+ const t = i / sampleRate
+ const noteIndex = Math.floor(t / noteDuration)
+ const frequency = notes[Math.min(noteIndex, notes.length - 1)]
+ const noteTime = t - noteIndex * noteDuration
+
+ // Square wave with note-specific envelope
+ const envelope = Math.exp(-noteTime * 5)
+ const wave = Math.sin(2 * Math.PI * frequency * noteTime) > 0 ? 1 : -1
+ const sample = wave * envelope * 127
+ samples.writeInt8(Math.floor(sample), i)
+ }
+
+ return samples
+}
+
+// Write WAV file
+function writeWavFile(filename, samples, sampleRate, bitsPerSample) {
+ const header = createWavHeader(samples.length, sampleRate, 1, bitsPerSample)
+ const wavData = Buffer.concat([header, samples])
+ fs.writeFileSync(filename, wavData)
+ console.log(`✓ Generated ${filename} (${Math.round(wavData.length / 1024)}KB)`)
+}
+
+// Generate 16-bit bell with two notes (ding-dong pattern)
+function generate16BitDingDong(freq1, freq2, sampleRate = 44100) {
+ const noteDuration = 0.35 // Each note duration
+ const totalDuration = noteDuration * 2 + 0.1 // Two notes with gap
+ const numSamples = Math.floor(totalDuration * sampleRate)
+ const samples = Buffer.alloc(numSamples * 2) // 16-bit = 2 bytes per sample
+
+ for (let i = 0; i < numSamples; i++) {
+ const t = i / sampleRate
+ let wave = 0
+
+ // First note (ding)
+ if (t < noteDuration) {
+ const envelope = Math.exp(-t * 4.5)
+ const fundamental = Math.sin(2 * Math.PI * freq1 * t)
+ const harmonic1 = 0.5 * Math.sin(2 * Math.PI * freq1 * 2 * t)
+ const harmonic2 = 0.25 * Math.sin(2 * Math.PI * freq1 * 3 * t)
+ wave = (fundamental + harmonic1 + harmonic2) * envelope
+ }
+ // Second note (dong)
+ else if (t >= noteDuration + 0.05 && t < totalDuration) {
+ const t2 = t - (noteDuration + 0.05)
+ const envelope = Math.exp(-t2 * 4)
+ const fundamental = Math.sin(2 * Math.PI * freq2 * t2)
+ const harmonic1 = 0.5 * Math.sin(2 * Math.PI * freq2 * 2 * t2)
+ const harmonic2 = 0.25 * Math.sin(2 * Math.PI * freq2 * 3 * t2)
+ wave = (fundamental + harmonic1 + harmonic2) * envelope
+ }
+
+ const sample = wave * 16384 // 16-bit range
+ samples.writeInt16LE(Math.floor(sample), i * 2)
+ }
+
+ return samples
+}
+
+// Generate 16-bit bell with ascending arpeggio (C-E-G chord)
+function generate16BitArpeggio(sampleRate = 44100) {
+ const notes = [523.25, 659.25, 783.99] // C5, E5, G5 (major chord)
+ const noteDuration = 0.3
+ const totalDuration = noteDuration * notes.length + 0.2
+ const numSamples = Math.floor(totalDuration * sampleRate)
+ const samples = Buffer.alloc(numSamples * 2) // 16-bit
+
+ for (let i = 0; i < numSamples; i++) {
+ const t = i / sampleRate
+ const noteIndex = Math.floor(t / noteDuration)
+ const frequency = notes[Math.min(noteIndex, notes.length - 1)]
+ const noteTime = t - noteIndex * noteDuration
+
+ // Only play during note duration, not in gaps
+ let wave = 0
+ if (noteIndex < notes.length && noteTime < noteDuration - 0.05) {
+ const envelope = Math.exp(-noteTime * 5)
+ const fundamental = Math.sin(2 * Math.PI * frequency * noteTime)
+ const harmonic1 = 0.5 * Math.sin(2 * Math.PI * frequency * 2 * noteTime)
+ const harmonic2 = 0.25 * Math.sin(2 * Math.PI * frequency * 3 * noteTime)
+ wave = (fundamental + harmonic1 + harmonic2) * envelope
+ }
+
+ const sample = wave * 16384 // 16-bit range
+ samples.writeInt16LE(Math.floor(sample), i * 2)
+ }
+
+ return samples
+}
+
+// Main execution
+const outputDir = path.join(__dirname, '../src/assets/sounds')
+
+console.log('Generating notification sounds based on 16-bit bell tone...\n')
+
+// 1. Work phase complete - 16-bit ding-dong (E5 -> C5, cheerful descending)
+const workComplete = generate16BitDingDong(659.25, 523.25, 44100) // E5 -> C5
+writeWavFile(path.join(outputDir, 'work-complete.wav'), workComplete, 44100, 16)
+
+// 2. Break phase complete - 16-bit single bell (C5, gentle, calming)
+const breakComplete = generate16BitBell(523.25, 0.8, 44100) // C5 note
+writeWavFile(path.join(outputDir, 'break-complete.wav'), breakComplete, 44100, 16)
+
+// 3. Session complete - 16-bit ascending arpeggio (C5-E5-G5, celebratory)
+const sessionComplete = generate16BitArpeggio(44100)
+writeWavFile(path.join(outputDir, 'session-complete.wav'), sessionComplete, 44100, 16)
+
+console.log('\n✅ All notification sounds generated successfully!')
+console.log('Sound design:')
+console.log(' - Work Complete: E5→C5 ding-dong (descending, satisfying completion)')
+console.log(' - Break Complete: C5 single bell (gentle reminder)')
+console.log(' - Session Complete: C5-E5-G5 arpeggio (ascending, celebratory)')
diff --git a/scripts/remove_unused_i18n_keys.py b/scripts/remove_unused_i18n_keys.py
new file mode 100644
index 0000000..7695dc0
--- /dev/null
+++ b/scripts/remove_unused_i18n_keys.py
@@ -0,0 +1,216 @@
+#!/usr/bin/env python3
+"""
+Remove unused i18n keys from locale files.
+Reads the unused keys from unused_i18n_keys.txt and removes them from both locale files.
+"""
+
+import re
+from pathlib import Path
+from typing import Set, List, Dict
+
+# Project root directory
+PROJECT_ROOT = Path(__file__).parent.parent
+
+# Locale files
+EN_LOCALE = PROJECT_ROOT / "src/locales/en.ts"
+ZH_CN_LOCALE = PROJECT_ROOT / "src/locales/zh-CN.ts"
+
+# Unused keys file
+UNUSED_KEYS_FILE = PROJECT_ROOT / "unused_i18n_keys.txt"
+
+
+def load_unused_keys() -> Set[str]:
+ """Load the list of unused keys from the file."""
+ print(f"Loading unused keys from {UNUSED_KEYS_FILE.name}...")
+
+ unused_keys = set()
+
+ with open(UNUSED_KEYS_FILE, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith("=") and line != "Unused i18n keys:":
+ unused_keys.add(line)
+
+ print(f"Loaded {len(unused_keys)} unused keys")
+ return unused_keys
+
+
+def should_remove_key(full_path: str, unused_keys: Set[str]) -> bool:
+ """
+ Check if a key should be removed.
+ A key should be removed if it's in the unused list AND it's a leaf node
+ (i.e., not a parent of other used keys).
+ """
+ return full_path in unused_keys
+
+
+def remove_keys_from_file(file_path: Path, unused_keys: Set[str]) -> None:
+ """
+ Remove unused keys from a locale file.
+ This function:
+ 1. Reads the file line by line
+ 2. Tracks the current key path
+ 3. Skips lines that belong to unused keys
+ 4. Writes the cleaned content back to the file
+ """
+ print(f"\nProcessing {file_path.name}...")
+
+ with open(file_path, "r", encoding="utf-8") as f:
+ lines = f.readlines()
+
+ new_lines = []
+ skip_until_depth = None
+ current_path = []
+ depth = 0
+ removed_count = 0
+
+ for i, line in enumerate(lines):
+ stripped = line.strip()
+
+ # Always keep header and footer
+ if (
+ not stripped
+ or stripped.startswith("//")
+ or stripped.startswith("/*")
+ or "import" in line
+ or "export const" in line
+ or "export type" in line
+ or "type DeepStringify" in line
+ or stripped.startswith("?")
+ or stripped.startswith(":")
+ or stripped.startswith("}")
+ and i >= len(lines) - 5
+ ):
+ new_lines.append(line)
+ continue
+
+ # Count braces to track depth
+ line_open_braces = line.count("{") - line.count("'{'") - line.count('"{')
+ line_close_braces = line.count("}") - line.count("'}'") - line.count('"}' )
+
+ # Check if we're currently skipping content
+ if skip_until_depth is not None:
+ # Check if we've returned to the skip depth (closing brace)
+ if stripped.startswith("}") and depth <= skip_until_depth:
+ skip_until_depth = None
+ depth -= line_close_braces
+ if current_path:
+ current_path.pop()
+ else:
+ depth += line_open_braces - line_close_braces
+ continue
+
+ # Try to match a key definition
+ key_match = re.match(r"^(\w+):\s*(.*)$", stripped)
+
+ if key_match:
+ key_name = key_match.group(1)
+ rest = key_match.group(2).strip()
+
+ # Build full path
+ full_path = ".".join(current_path + [key_name])
+
+ # Check if this key should be removed
+ if should_remove_key(full_path, unused_keys):
+ print(f" Removing: {full_path}")
+ removed_count += 1
+
+ # If this is a nested object, skip until we close it
+ if rest.startswith("{"):
+ skip_until_depth = depth
+ current_path.append(key_name)
+ depth += line_open_braces - line_close_braces
+ continue
+
+ # This key is kept - check if it's a nested object
+ if rest.startswith("{"):
+ current_path.append(key_name)
+
+ # Handle closing braces
+ if stripped.startswith("}"):
+ if current_path:
+ current_path.pop()
+
+ # Update depth
+ depth += line_open_braces - line_close_braces
+
+ # Keep this line
+ new_lines.append(line)
+
+ # Write back to file
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.writelines(new_lines)
+
+ print(f" Removed {removed_count} keys from {file_path.name}")
+
+
+def clean_empty_objects(file_path: Path) -> None:
+ """
+ Clean up empty objects left after removing keys.
+ For example, if we remove all keys from an object, remove the empty object too.
+ """
+ print(f"\nCleaning empty objects in {file_path.name}...")
+
+ with open(file_path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ # Remove empty objects (key: {})
+ # This regex matches: key: {\n }
+ content = re.sub(r"(\w+):\s*\{\s*\},?\n", "", content)
+ content = re.sub(r"(\w+):\s*\{\s*\}\n", "", content)
+
+ # Remove trailing commas before closing braces
+ content = re.sub(r",(\s*\})", r"\1", content)
+
+ # Remove multiple consecutive blank lines
+ content = re.sub(r"\n\n\n+", "\n\n", content)
+
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.write(content)
+
+ print(f" Cleaned empty objects in {file_path.name}")
+
+
+def main():
+ print("=" * 60)
+ print("i18n Unused Keys Removal Tool")
+ print("=" * 60)
+
+ # Load unused keys
+ unused_keys = load_unused_keys()
+
+ if not unused_keys:
+ print("\nNo unused keys to remove!")
+ return
+
+ # Create backups
+ print("\nCreating backups...")
+ import shutil
+
+ shutil.copy2(EN_LOCALE, str(EN_LOCALE) + ".backup")
+ shutil.copy2(ZH_CN_LOCALE, str(ZH_CN_LOCALE) + ".backup")
+ print(" Backups created (.backup files)")
+
+ # Remove keys from both files
+ remove_keys_from_file(EN_LOCALE, unused_keys)
+ remove_keys_from_file(ZH_CN_LOCALE, unused_keys)
+
+ # Clean up empty objects
+ clean_empty_objects(EN_LOCALE)
+ clean_empty_objects(ZH_CN_LOCALE)
+
+ print("\n" + "=" * 60)
+ print("COMPLETED")
+ print("=" * 60)
+ print("\nUnused keys have been removed from both locale files.")
+ print("Backup files have been created (.backup extension).")
+ print("\nNext steps:")
+ print("1. Run `pnpm check-i18n` to verify the changes")
+ print("2. Test the application to ensure nothing is broken")
+ print("3. If everything works, delete the .backup files")
+ print("4. If there are issues, restore from .backup files")
+ print("\n" + "=" * 60)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/remove_unused_i18n_keys_v2.py b/scripts/remove_unused_i18n_keys_v2.py
new file mode 100644
index 0000000..2bff8b9
--- /dev/null
+++ b/scripts/remove_unused_i18n_keys_v2.py
@@ -0,0 +1,307 @@
+#!/usr/bin/env python3
+"""
+Remove unused i18n keys from locale files (improved version).
+This version handles multi-line string values correctly.
+"""
+
+import re
+from pathlib import Path
+from typing import Set, List
+
+# Project root directory
+PROJECT_ROOT = Path(__file__).parent.parent
+
+# Locale files
+EN_LOCALE = PROJECT_ROOT / "src/locales/en.ts"
+ZH_CN_LOCALE = PROJECT_ROOT / "src/locales/zh-CN.ts"
+
+# Unused keys file
+UNUSED_KEYS_FILE = PROJECT_ROOT / "unused_i18n_keys.txt"
+
+
+def load_unused_keys() -> Set[str]:
+ """Load the list of unused keys from the file."""
+ print(f"Loading unused keys from {UNUSED_KEYS_FILE.name}...")
+
+ unused_keys = set()
+
+ with open(UNUSED_KEYS_FILE, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith("=") and line != "Unused i18n keys:":
+ unused_keys.add(line)
+
+ print(f"Loaded {len(unused_keys)} unused keys")
+ return unused_keys
+
+
+def remove_keys_from_content(content: str, unused_keys: Set[str]) -> tuple[str, int]:
+ """
+ Remove unused keys from file content.
+ Returns (new_content, removed_count)
+ """
+ # Group unused keys by their parent path for efficient removal
+ keys_by_depth = {}
+ for key in unused_keys:
+ parts = key.split(".")
+ depth = len(parts)
+ if depth not in keys_by_depth:
+ keys_by_depth[depth] = set()
+ keys_by_depth[depth].add(key)
+
+ removed_count = 0
+
+ # Remove keys from deepest to shallowest to avoid issues
+ for depth in sorted(keys_by_depth.keys(), reverse=True):
+ for key in keys_by_depth[depth]:
+ # Build regex pattern to match the key and its value
+ parts = key.split(".")
+ key_name = parts[-1]
+
+ # Pattern to match:
+ # - key name
+ # - optional whitespace
+ # - colon
+ # - value (can be string, object, or array)
+ # - optional comma
+ # Handles multi-line strings and objects
+
+ # For leaf keys (strings):
+ # keyName: 'value',
+ # or
+ # keyName:
+ # 'multi-line value',
+ pattern_simple = rf"^\s*{re.escape(key_name)}:\s*['\"`].*?['\"`],?\s*$"
+
+ # For nested objects:
+ # keyName: {{ ... }},
+ # We need to match balanced braces
+
+ # Try simple pattern first (single-line or multi-line string)
+ lines = content.split("\n")
+ new_lines = []
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ stripped = line.strip()
+
+ # Check if this line starts with our key
+ if re.match(rf"^\s*{re.escape(key_name)}:\s*", line):
+ # Check the current path context to ensure we're removing the right key
+ # For now, we'll use a simpler approach: just match the key name
+ # and check if it's a simple value or nested object
+
+ rest_of_line = line.split(":", 1)[1].strip()
+
+ if rest_of_line.startswith("{"):
+ # Nested object - skip until closing brace
+ brace_count = rest_of_line.count("{") - rest_of_line.count("}")
+ i += 1
+ while i < len(lines) and brace_count > 0:
+ brace_count += (
+ lines[i].count("{") - lines[i].count("}")
+ )
+ i += 1
+ removed_count += 1
+ continue
+ elif rest_of_line.startswith("["):
+ # Array - skip until closing bracket
+ bracket_count = rest_of_line.count("[") - rest_of_line.count(
+ "]"
+ )
+ i += 1
+ while i < len(lines) and bracket_count > 0:
+ bracket_count += (
+ lines[i].count("[") - lines[i].count("]")
+ )
+ i += 1
+ removed_count += 1
+ continue
+ else:
+ # Simple value - might be multi-line
+ # Skip this line and check if next line continues the value
+ if not rest_of_line.endswith(",") and not rest_of_line.endswith("'") and not rest_of_line.endswith('"'):
+ # Multi-line string, check next line
+ i += 1
+ if i < len(lines) and lines[i].strip().startswith("'"):
+ i += 1 # Skip the value line too
+ removed_count += 1
+ i += 1
+ continue
+
+ new_lines.append(line)
+ i += 1
+
+ content = "\n".join(new_lines)
+
+ return content, removed_count
+
+
+def clean_empty_objects(content: str) -> str:
+ """Clean up empty objects and formatting."""
+ # Remove empty objects
+ content = re.sub(r"\w+:\s*\{\s*\},?\n", "", content)
+
+ # Remove trailing commas before closing braces
+ content = re.sub(r",(\s*\})", r"\1", content)
+
+ # Remove multiple consecutive blank lines
+ content = re.sub(r"\n\n\n+", "\n\n", content)
+
+ # Fix spacing issues
+ lines = content.split("\n")
+ fixed_lines = []
+ for i, line in enumerate(lines):
+ # Skip empty lines at the start of an object
+ if i > 0 and line.strip() == "" and lines[i - 1].strip().endswith("{"):
+ continue
+ fixed_lines.append(line)
+
+ return "\n".join(fixed_lines)
+
+
+def remove_keys_manually(file_path: Path, unused_keys: Set[str]) -> int:
+ """
+ Use Edit tool approach - build map of which lines to keep.
+ This is more reliable for complex TypeScript objects.
+ """
+ print(f"\nProcessing {file_path.name} with manual approach...")
+
+ with open(file_path, "r", encoding="utf-8") as f:
+ lines = f.readlines()
+
+ # Build a context path as we scan through the file
+ current_path = []
+ lines_to_keep = []
+ skip_until_line = -1
+ removed_count = 0
+
+ for line_num, line in enumerate(lines):
+ # Skip if we're in a block we're removing
+ if line_num <= skip_until_line:
+ continue
+
+ stripped = line.strip()
+
+ # Keep structural lines
+ if (
+ not stripped
+ or stripped.startswith("//")
+ or stripped.startswith("/*")
+ or "import " in line
+ or "export " in line
+ or "type " in line
+ or stripped.startswith("?")
+ or stripped.startswith(":")
+ ):
+ lines_to_keep.append(line)
+ continue
+
+ # Try to extract key name
+ key_match = re.match(r"^(\s*)(\w+):\s*(.*)$", line)
+
+ if key_match:
+ indent = key_match.group(1)
+ key_name = key_match.group(2)
+ rest = key_match.group(3).strip()
+
+ # Update current path based on indentation
+ indent_level = len(indent) // 2
+ current_path = current_path[:indent_level]
+ full_path = ".".join(current_path + [key_name])
+
+ # Check if this key should be removed
+ if full_path in unused_keys:
+ print(f" Removing: {full_path}")
+ removed_count += 1
+
+ # Skip this key and its value
+ if rest.startswith("{"):
+ # Find the closing brace
+ brace_count = 1
+ for j in range(line_num + 1, len(lines)):
+ brace_count += lines[j].count("{") - lines[j].count("}")
+ if brace_count == 0:
+ skip_until_line = j
+ break
+ else:
+ # Simple value - skip just this line
+ # Check if next line is a continuation
+ if line_num + 1 < len(lines):
+ next_line = lines[line_num + 1].strip()
+ if next_line and not next_line.startswith("}") and not re.match(r"^\w+:", next_line):
+ skip_until_line = line_num + 1
+ continue
+
+ # This key is kept
+ if rest.startswith("{"):
+ current_path.append(key_name)
+
+ # Handle closing braces
+ if stripped.startswith("}"):
+ if current_path:
+ current_path.pop()
+
+ lines_to_keep.append(line)
+
+ # Write back
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.writelines(lines_to_keep)
+
+ return removed_count
+
+
+def main():
+ print("=" * 60)
+ print("i18n Unused Keys Removal Tool (v2)")
+ print("=" * 60)
+
+ # Load unused keys
+ unused_keys = load_unused_keys()
+
+ if not unused_keys:
+ print("\nNo unused keys to remove!")
+ return
+
+ # Create backups (if not already exist)
+ print("\nChecking backups...")
+ import shutil
+
+ if not EN_LOCALE.with_suffix(".ts.backup").exists():
+ shutil.copy2(EN_LOCALE, str(EN_LOCALE) + ".backup")
+ shutil.copy2(ZH_CN_LOCALE, str(ZH_CN_LOCALE) + ".backup")
+ print(" Backups created (.backup files)")
+ else:
+ print(" Using existing backups")
+
+ # Remove keys from both files
+ en_removed = remove_keys_manually(EN_LOCALE, unused_keys)
+ print(f" Removed {en_removed} keys from en.ts")
+
+ zh_removed = remove_keys_manually(ZH_CN_LOCALE, unused_keys)
+ print(f" Removed {zh_removed} keys from zh-CN.ts")
+
+ # Clean up formatting
+ print("\nCleaning up formatting...")
+ for file_path in [EN_LOCALE, ZH_CN_LOCALE]:
+ with open(file_path, "r", encoding="utf-8") as f:
+ content = f.read()
+ content = clean_empty_objects(content)
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.write(content)
+
+ print("\n" + "=" * 60)
+ print("COMPLETED")
+ print("=" * 60)
+ print("\nUnused keys have been removed from both locale files.")
+ print("Backup files are available (.backup extension).")
+ print("\nNext steps:")
+ print("1. Run `pnpm check-i18n` to verify the changes")
+ print("2. Test the application to ensure nothing is broken")
+ print("3. If everything works, delete the .backup files")
+ print("4. If there are issues, restore from .backup files")
+ print("\n" + "=" * 60)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src-tauri/python/ido_app/__init__.py b/src-tauri/python/ido_app/__init__.py
index f379c97..3d98a93 100644
--- a/src-tauri/python/ido_app/__init__.py
+++ b/src-tauri/python/ido_app/__init__.py
@@ -134,7 +134,8 @@ def log_main(msg: str) -> None:
)
# ⭐ The CLI to run `json-schema-to-typescript`,
# `--format=false` is optional to improve performance
- json2ts_cmd = "pnpm json2ts --format=false"
+ # `--unknownAny=false` uses 'any' instead of 'unknown' for better compatibility
+ json2ts_cmd = "pnpm json2ts --format=false --unknownAny=false"
# ⭐ Start the background task to generate TypeScript types
portal.start_task_soon(
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index eb02705..b7969c5 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -8,7 +8,9 @@
"devUrl": "http://127.0.0.1:1420/",
"beforeBuildCommand": "pnpm run build",
"frontendDist": "../dist",
- "features": ["pytauri/standalone"]
+ "features": [
+ "pytauri/standalone"
+ ]
},
"app": {
"security": {
@@ -16,7 +18,9 @@
"assetProtocol": {
"enable": true,
"scope": {
- "allow": ["$HOME/.config/ido/**"],
+ "allow": [
+ "$HOME/.config/ido/**"
+ ],
"deny": []
}
}
@@ -35,7 +39,9 @@
},
"plugins": {
"sql": {
- "preload": ["sqlite:ido.db"]
+ "preload": [
+ "sqlite:ido.db"
+ ]
},
"process": {
"allow-exit": true
diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json
index de2ef28..76fcccf 100644
--- a/src-tauri/tauri.macos.conf.json
+++ b/src-tauri/tauri.macos.conf.json
@@ -8,7 +8,9 @@
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm run build",
"frontendDist": "../dist",
- "features": ["pytauri/standalone"]
+ "features": [
+ "pytauri/standalone"
+ ]
},
"app": {
"withGlobalTauri": true,
@@ -20,7 +22,7 @@
"width": 1300,
"height": 1000,
"minWidth": 1020,
- "minHeight": 600,
+ "minHeight": 840,
"fullscreen": false,
"resizable": true,
"center": true,
@@ -35,8 +37,16 @@
},
"bundle": {
"active": true,
- "targets": ["app"],
- "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
+ "targets": [
+ "app"
+ ],
+ "icon": [
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.icns",
+ "icons/icon.ico"
+ ],
"macOS": {
"entitlements": "entitlements.plist",
"minimumSystemVersion": "10.15",
diff --git a/src/assets/sounds/break-complete.wav b/src/assets/sounds/break-complete.wav
new file mode 100644
index 0000000..bad86e2
Binary files /dev/null and b/src/assets/sounds/break-complete.wav differ
diff --git a/src/assets/sounds/session-complete.wav b/src/assets/sounds/session-complete.wav
new file mode 100644
index 0000000..2bb19e8
Binary files /dev/null and b/src/assets/sounds/session-complete.wav differ
diff --git a/src/assets/sounds/work-complete.wav b/src/assets/sounds/work-complete.wav
new file mode 100644
index 0000000..9597ef2
Binary files /dev/null and b/src/assets/sounds/work-complete.wav differ
diff --git a/src/clock/App.css b/src/clock/App.css
new file mode 100644
index 0000000..1040908
--- /dev/null
+++ b/src/clock/App.css
@@ -0,0 +1,78 @@
+/* Clock app styles */
+
+.clock-container {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(17, 24, 39, 0.9);
+ border-radius: 8px;
+ position: relative;
+ /* Enable window dragging for the entire container */
+ -webkit-app-region: drag;
+ -webkit-user-select: none;
+ user-select: none;
+ cursor: move;
+}
+
+.progress-ring {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%) rotate(-90deg);
+ /* Scale to fit container while maintaining aspect ratio */
+ max-width: 90%;
+ max-height: 90%;
+}
+
+.progress-ring-circle {
+ opacity: 0.3;
+}
+
+.progress-ring-progress {
+ filter: drop-shadow(0 0 6px currentColor);
+}
+
+.clock-content {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ color: white;
+}
+
+.phase-label {
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 2px;
+ margin-bottom: 4px;
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
+}
+
+.time-display {
+ font-size: 48px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
+ line-height: 1;
+}
+
+.round-info {
+ font-size: 12px;
+ color: #9ca3af;
+ margin-top: 8px;
+}
+
+.user-intent {
+ font-size: 11px;
+ color: #6b7280;
+ max-width: 160px;
+ margin-top: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/src/clock/App.tsx b/src/clock/App.tsx
new file mode 100644
index 0000000..a478c29
--- /dev/null
+++ b/src/clock/App.tsx
@@ -0,0 +1,204 @@
+/**
+ * Clock window app - Desktop countdown timer
+ */
+
+import { useEffect, useState } from 'react'
+import { listen } from '@tauri-apps/api/event'
+import './App.css'
+
+interface ClockState {
+ sessionId: string | null
+ phase: 'work' | 'break' | 'completed' | null
+ remainingSeconds: number
+ totalSeconds: number
+ currentRound: number
+ totalRounds: number
+ completedRounds: number
+ userIntent: string
+ phaseStartTime: string | null
+ workDurationMinutes: number
+ breakDurationMinutes: number
+}
+
+function App() {
+ const [state, setState] = useState({
+ sessionId: null,
+ phase: null,
+ remainingSeconds: 0,
+ totalSeconds: 0,
+ currentRound: 1,
+ totalRounds: 4,
+ completedRounds: 0,
+ userIntent: '',
+ phaseStartTime: null,
+ workDurationMinutes: 25,
+ breakDurationMinutes: 5
+ })
+
+ const [displayTime, setDisplayTime] = useState({
+ minutes: 0,
+ seconds: 0
+ })
+
+ const [currentClockTime, setCurrentClockTime] = useState({
+ hours: 0,
+ minutes: 0
+ })
+
+ const [currentTime, setCurrentTime] = useState(Date.now())
+
+ useEffect(() => {
+ const unlisten = listen('clock-update', (event) => {
+ console.log('[Clock App] State update:', event.payload)
+ const newState = event.payload
+
+ // Update state (displayTime will be calculated from phaseStartTime)
+ setState(newState)
+ })
+
+ return () => {
+ unlisten.then((fn) => fn())
+ }
+ }, [])
+
+ // Show current time when no Pomodoro session is active
+ useEffect(() => {
+ if (!state.sessionId || !state.phase) {
+ const updateClock = () => {
+ const now = new Date()
+ setCurrentClockTime({
+ hours: now.getHours(),
+ minutes: now.getMinutes()
+ })
+ }
+
+ updateClock()
+ const interval = setInterval(updateClock, 1000)
+ return () => clearInterval(interval)
+ }
+ }, [state.sessionId, state.phase])
+
+ // Update current time every second for real-time calculation (like main app)
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setCurrentTime(Date.now())
+ }, 1000)
+
+ return () => clearInterval(interval)
+ }, [])
+
+ // Calculate display time based on phaseStartTime (same as main app)
+ useEffect(() => {
+ if (state.phase === 'completed' || !state.phaseStartTime || !state.phase) {
+ return
+ }
+
+ const phaseStartTime = new Date(state.phaseStartTime).getTime()
+ const phaseDuration = state.phase === 'work' ? state.workDurationMinutes * 60 : state.breakDurationMinutes * 60
+
+ const elapsedSeconds = Math.floor((currentTime - phaseStartTime) / 1000)
+ const remainingSeconds = Math.max(0, phaseDuration - elapsedSeconds)
+
+ setDisplayTime({
+ minutes: Math.floor(remainingSeconds / 60),
+ seconds: remainingSeconds % 60
+ })
+ }, [currentTime, state.phaseStartTime, state.phase, state.workDurationMinutes, state.breakDurationMinutes])
+
+ const getPhaseColor = () => {
+ switch (state.phase) {
+ case 'work':
+ return '#ef4444' // red
+ case 'break':
+ return '#22c55e' // green
+ case 'completed':
+ return '#3b82f6' // blue
+ default:
+ return '#6b7280' // gray
+ }
+ }
+
+ const getPhaseLabel = () => {
+ if (!state.phase) {
+ return 'CLOCK'
+ }
+ switch (state.phase) {
+ case 'work':
+ return 'WORK'
+ case 'break':
+ return 'BREAK'
+ case 'completed':
+ return 'DONE'
+ default:
+ return ''
+ }
+ }
+
+ // Calculate progress based on phaseStartTime (same as main app)
+ const progress = (() => {
+ if (!state.phaseStartTime || !state.phase || state.phase === 'completed') {
+ return 0
+ }
+
+ const phaseStartTime = new Date(state.phaseStartTime).getTime()
+ const phaseDuration = state.phase === 'work' ? state.workDurationMinutes * 60 : state.breakDurationMinutes * 60
+
+ const elapsedSeconds = Math.floor((currentTime - phaseStartTime) / 1000)
+ return Math.min(100, (elapsedSeconds / phaseDuration) * 100)
+ })()
+
+ const circumference = 2 * Math.PI * 90
+ const strokeDashoffset = circumference - (progress / 100) * circumference
+
+ return (
+
+
+
+
+
+
+
+
+ {getPhaseLabel()}
+
+
+
+ {state.phase
+ ? `${String(displayTime.minutes).padStart(2, '0')}:${String(displayTime.seconds).padStart(2, '0')}`
+ : `${String(currentClockTime.hours).padStart(2, '0')}:${String(currentClockTime.minutes).padStart(2, '0')}`}
+
+
+ {state.phase && (
+
+ Round {state.currentRound}/{state.totalRounds}
+
+ )}
+
+ {state.userIntent &&
{state.userIntent}
}
+
+
+ )
+}
+
+export default App
diff --git a/src/clock/index.tsx b/src/clock/index.tsx
new file mode 100644
index 0000000..c381bd8
--- /dev/null
+++ b/src/clock/index.tsx
@@ -0,0 +1,13 @@
+/**
+ * Clock window main entry point
+ */
+
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+
+ReactDOM.createRoot(document.getElementById('clock-root')!).render(
+
+
+
+)
diff --git a/src/components/activity/ActionCard.tsx b/src/components/activity/ActionCard.tsx
index 564a821..09c93cb 100644
--- a/src/components/activity/ActionCard.tsx
+++ b/src/components/activity/ActionCard.tsx
@@ -90,7 +90,7 @@ export function ActionCard({ action, isExpanded = false, onToggleExpand }: Actio
{/* Title - takes up remaining space and wraps */}
-
{action.title}
+ {action.title}
{/* Timestamp and Screenshots button - takes up actual space */}
@@ -164,8 +164,14 @@ export function ActionCard({ action, isExpanded = false, onToggleExpand }: Actio
>
) : (
-
+
+ Image Lost
+ {import.meta.env.DEV && (
+
+ {screenshot.substring(0, 8)}...
+
+ )}
)}
diff --git a/src/components/activity/ActivityItem.tsx b/src/components/activity/ActivityItem.tsx
index 944e838..6c31f32 100644
--- a/src/components/activity/ActivityItem.tsx
+++ b/src/components/activity/ActivityItem.tsx
@@ -2,7 +2,18 @@ import { Activity } from '@/lib/types/activity'
import { useActivityStore } from '@/lib/stores/activity'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
-import { Clock, Loader2, MessageSquare, Sparkles, Trash2, Timer, Layers, ChevronDown, ChevronUp } from 'lucide-react'
+import {
+ Clock,
+ Loader2,
+ MessageSquare,
+ Sparkles,
+ Trash2,
+ Timer,
+ Layers,
+ ChevronDown,
+ ChevronUp,
+ Target
+} from 'lucide-react'
import { EventCard } from './EventCard'
import { ActionCard } from './ActionCard'
import { cn, formatDuration } from '@/lib/utils'
@@ -31,6 +42,48 @@ interface ActivityItemProps {
onToggleSelection?: (activityId: string) => void
}
+// Helper function to get focus score display info
+function getFocusScoreInfo(focusScore: number | undefined) {
+ if (focusScore === undefined || focusScore === null) {
+ return null
+ }
+
+ // Define score levels and their styling
+ if (focusScore >= 80) {
+ return {
+ level: 'excellent',
+ label: 'Excellent Focus',
+ variant: 'default' as const,
+ bgClass: 'bg-green-500/10 border-green-500/20',
+ textClass: 'text-green-700 dark:text-green-400'
+ }
+ } else if (focusScore >= 60) {
+ return {
+ level: 'good',
+ label: 'Good Focus',
+ variant: 'secondary' as const,
+ bgClass: 'bg-blue-500/10 border-blue-500/20',
+ textClass: 'text-blue-700 dark:text-blue-400'
+ }
+ } else if (focusScore >= 40) {
+ return {
+ level: 'moderate',
+ label: 'Moderate Focus',
+ variant: 'outline' as const,
+ bgClass: 'bg-yellow-500/10 border-yellow-500/20',
+ textClass: 'text-yellow-700 dark:text-yellow-400'
+ }
+ } else {
+ return {
+ level: 'low',
+ label: 'Low Focus',
+ variant: 'destructive' as const,
+ bgClass: 'bg-red-500/10 border-red-500/20',
+ textClass: 'text-red-700 dark:text-red-400'
+ }
+ }
+}
+
export function ActivityItem({
activity,
selectionMode = false,
@@ -77,11 +130,10 @@ export function ActivityItem({
return formatDuration(duration, 'short')
}, [duration])
- // Determine if this is a milestone (long activity > 30 minutes)
- const isMilestone = useMemo(() => {
- const durationMinutes = duration / (1000 * 60)
- return durationMinutes > 30
- }, [duration])
+ // Get focus score display info
+ const focusScoreInfo = useMemo(() => {
+ return getFocusScoreInfo(activity.focusScore)
+ }, [activity.focusScore])
// Safely format time range with fallback for invalid timestamps
let timeRange = '-- : -- : -- ~ -- : -- : --'
@@ -260,10 +312,13 @@ export function ActivityItem({
{durationFormatted}
- {isMilestone && (
-