This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
kb-skills/gsd-openclaw/src/gsd_openclaw/user_context.py
T
starmen1265 9e8fc81469 feat(04-02): implement _handle_new_project with UserContextManager
- Add UserContextManager for per-user project tracking
- Add _handle_new_project method that pre-creates sessions
- Update user context after new-project creation
- Route new-project command in handle_message
- Add LONG_COMMANDS class attribute for timeout control

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:11:32 +08:00

250 lines
8.3 KiB
Python

"""
Per-user context management for gsd-openclaw.
Provides UserContextManager for tracking user project context across chat sessions,
supporting the /gsd:use command requirement.
"""
from __future__ import annotations
import dataclasses
import json
import os
import tempfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
@dataclass
class UserContext:
"""Represents the persistent context for a user.
Tracks the user's current project, recent projects, and preferences
for persistence across chat sessions.
Attributes:
schema_version: Version of the context schema (default "1.0")
user_id: Unique identifier for the user
current_project: The user's currently active project
recent_projects: List of recently used projects (max 5)
last_active_at: ISO format timestamp of last activity
preferences: Optional user preferences dictionary
"""
schema_version: str = "1.0"
user_id: str = ""
current_project: str = ""
recent_projects: List[str] = field(default_factory=list)
last_active_at: str = ""
preferences: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert UserContext to a dictionary.
Returns:
Dictionary representation of the user context.
"""
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> UserContext:
"""Create UserContext from a dictionary.
Unknown fields in the dictionary are ignored.
Args:
data: Dictionary containing user context data.
Returns:
UserContext instance.
"""
# Get field names from the dataclass
field_names = {f.name for f in dataclasses.fields(cls)}
# Filter to only known fields
filtered_data = {k: v for k, v in data.items() if k in field_names}
return cls(**filtered_data)
class UserContextManager:
"""Manager class for per-user context persistence.
Provides a clean interface for loading and saving user context,
with atomic writes and multi-user isolation.
Attributes:
USERS_ROOT: Default root directory for all user contexts.
user_id: Unique identifier for the user.
users_root: Root directory for user contexts (configurable for testing).
context_path: Full path to the user's context.json file.
"""
USERS_ROOT = Path.home() / "gsd-workspaces" / ".users"
def __init__(self, user_id: str, users_root: Optional[Path] = None):
"""Initialize UserContextManager for a user.
Args:
user_id: Unique identifier for the user.
users_root: Optional custom root for user contexts (useful for testing).
Defaults to ~/gsd-workspaces/.users.
"""
self.user_id = user_id
self.users_root = Path(users_root) if users_root else self.USERS_ROOT
self._context_path: Optional[Path] = None
@property
def context_path(self) -> Path:
"""Get the path to the user's context file.
Returns:
Path to context.json file.
"""
return self.users_root / self.user_id / "context.json"
def load(self) -> Optional[UserContext]:
"""Load user context from file with backup fallback.
Returns:
UserContext if loaded successfully, None if no context file exists.
"""
context_path = self.context_path
backup_path = context_path.with_suffix('.json.bak')
def _load_from_path(path: Path) -> UserContext:
"""Internal: Load context from a specific path."""
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
return UserContext.from_dict(data)
# Try main file first, then backup
paths_to_try = []
if context_path.exists():
paths_to_try.append(context_path)
if backup_path.exists() and backup_path != context_path:
paths_to_try.append(backup_path)
if not paths_to_try:
return None
last_error = None
for path in paths_to_try:
try:
return _load_from_path(path)
except json.JSONDecodeError as e:
last_error = e
continue # Try next path (backup)
except (KeyError, TypeError) as e:
last_error = e
continue # Try next path (backup)
# If we get here, all paths failed - return None instead of raising
return None
def save(self, context: UserContext) -> None:
"""Save user context to file atomically with backup rotation.
This function implements crash-safe context persistence:
1. Creates parent directories if they don't exist
2. Rotates existing context file to backup (context.json -> context.json.bak)
3. Writes to a temporary file first
4. Atomically renames temp file to final path
Args:
context: The UserContext to save.
Raises:
OSError: If file operations fail (permissions, disk full, etc.)
"""
context_path = self.context_path
backup_path = context_path.with_suffix('.json.bak')
# 1. Create parent directories if missing
context_path.parent.mkdir(parents=True, exist_ok=True)
# 2. Rotate backup: if context.json exists, move it to context.json.bak
if context_path.exists():
# Remove old backup if exists
if backup_path.exists():
backup_path.unlink()
# Rename current context to backup
context_path.rename(backup_path)
# 3. Write to temp file in same directory (for atomic rename)
fd, temp_path = tempfile.mkstemp(dir=context_path.parent, suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(context.to_dict(), f, indent=2, ensure_ascii=False)
# 4. Atomic replace
os.replace(temp_path, context_path)
except Exception:
# Clean up temp file on failure
if os.path.exists(temp_path):
os.unlink(temp_path)
raise
def get_current_project(self) -> Optional[str]:
"""Get the user's current project.
Returns:
Current project name or None if no context exists.
"""
context = self.load()
if context is None or not context.current_project:
return None
return context.current_project
def set_current_project(self, project: str) -> None:
"""Set the user's current project.
Creates default context if none exists. Updates current_project,
adds to recent_projects (max 5), and updates last_active_at.
Args:
project: Name of the project to set as current.
"""
context = self.load()
if context is None:
context = self.create_default_context()
# Update recent projects
recent = context.recent_projects.copy()
# Remove project if already in list (will be added at front)
if project in recent:
recent.remove(project)
# Add to front of list
recent.insert(0, project)
# Keep only last 5
context.recent_projects = recent[:5]
# Update current project and timestamp
context.current_project = project
context.last_active_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
self.save(context)
def get_recent_projects(self) -> List[str]:
"""Get the user's recent projects list.
Returns:
List of recent project names (may be empty).
"""
context = self.load()
if context is None:
return []
return context.recent_projects
def create_default_context(self) -> UserContext:
"""Create a new default context for the user.
Returns:
New UserContext with default values.
"""
return UserContext(
schema_version="1.0",
user_id=self.user_id,
current_project="",
recent_projects=[],
last_active_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
preferences={}
)