feat!: migrate to OpenAI native tool calls and fix circular deps (#fuck-gemini)

- Fix circular dependencies in agent/tools
- Migrate from custom JSON to OpenAI tool calls format
- Add async streaming (step_stream, complete_stream)
- Simplify prompt system and remove token counting
- Add 5 new API endpoints (/health, /v1/models, /api/memory/*)
- Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language)
- Fix all 500 tests and add coverage config (80% threshold)
- Add comprehensive docs (README, pytest guide)

BREAKING: LLM interface changed, memory injection via get_memory()
This commit is contained in:
2025-12-06 19:11:05 +01:00
parent 2c8cdd3ab1
commit 9ca31e45e0
92 changed files with 7897 additions and 1786 deletions
+3 -2
View File
@@ -1,10 +1,11 @@
"""Knaben API client."""
from .client import KnabenClient
from .dto import TorrentResult
from .exceptions import (
KnabenError,
KnabenConfigurationError,
KnabenAPIError,
KnabenConfigurationError,
KnabenError,
KnabenNotFoundError,
)
+23 -27
View File
@@ -1,12 +1,15 @@
"""Knaben torrent search API client."""
from typing import Dict, Any, Optional, List
import logging
from typing import Any
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from requests.exceptions import HTTPError, RequestException, Timeout
from agent.config import Settings, settings
from .dto import TorrentResult
from .exceptions import KnabenError, KnabenAPIError, KnabenNotFoundError
from .exceptions import KnabenAPIError, KnabenNotFoundError
logger = logging.getLogger(__name__)
@@ -26,9 +29,9 @@ class KnabenClient:
def __init__(
self,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
base_url: str | None = None,
timeout: int | None = None,
config: Settings | None = None,
):
"""
Initialize Knaben client.
@@ -48,10 +51,7 @@ class KnabenClient:
logger.info("Knaben client initialized")
def _make_request(
self,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
def _make_request(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Make a request to Knaben API.
@@ -90,11 +90,7 @@ class KnabenClient:
logger.error(f"Knaben API request failed: {e}")
raise KnabenAPIError(f"Failed to connect to Knaben API: {e}") from e
def search(
self,
query: str,
limit: int = 10
) -> List[TorrentResult]:
def search(self, query: str, limit: int = 10) -> list[TorrentResult]:
"""
Search for torrents.
@@ -138,7 +134,7 @@ class KnabenClient:
# Parse results
results = []
torrents = data.get('hits', [])
torrents = data.get("hits", [])
if not torrents:
logger.info(f"No torrents found for '{query}'")
@@ -155,7 +151,7 @@ class KnabenClient:
logger.info(f"Found {len(results)} torrents for '{query}'")
return results
def _parse_torrent(self, torrent: Dict[str, Any]) -> TorrentResult:
def _parse_torrent(self, torrent: dict[str, Any]) -> TorrentResult:
"""
Parse a torrent result into a TorrentResult object.
@@ -166,17 +162,17 @@ class KnabenClient:
TorrentResult object
"""
# Extract required fields (API uses camelCase)
title = torrent.get('title', 'Unknown')
size = torrent.get('size', 'Unknown')
seeders = int(torrent.get('seeders', 0) or 0)
leechers = int(torrent.get('leechers', 0) or 0)
magnet = torrent.get('magnetUrl', '')
title = torrent.get("title", "Unknown")
size = torrent.get("size", "Unknown")
seeders = int(torrent.get("seeders", 0) or 0)
leechers = int(torrent.get("leechers", 0) or 0)
magnet = torrent.get("magnetUrl", "")
# Extract optional fields
info_hash = torrent.get('hash')
tracker = torrent.get('tracker')
upload_date = torrent.get('date')
category = torrent.get('category')
info_hash = torrent.get("hash")
tracker = torrent.get("tracker")
upload_date = torrent.get("date")
category = torrent.get("category")
return TorrentResult(
title=title,
@@ -187,5 +183,5 @@ class KnabenClient:
info_hash=info_hash,
tracker=tracker,
upload_date=upload_date,
category=category
category=category,
)
+6 -5
View File
@@ -1,17 +1,18 @@
"""Knaben Data Transfer Objects."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class TorrentResult:
"""Represents a torrent search result from Knaben."""
title: str
size: str
seeders: int
leechers: int
magnet: str
info_hash: Optional[str] = None
tracker: Optional[str] = None
upload_date: Optional[str] = None
category: Optional[str] = None
info_hash: str | None = None
tracker: str | None = None
upload_date: str | None = None
category: str | None = None
+4
View File
@@ -3,19 +3,23 @@
class KnabenError(Exception):
"""Base exception for Knaben-related errors."""
pass
class KnabenConfigurationError(KnabenError):
"""Raised when Knaben API is not properly configured."""
pass
class KnabenAPIError(KnabenError):
"""Raised when Knaben API returns an error."""
pass
class KnabenNotFoundError(KnabenError):
"""Raised when no torrents are found."""
pass
+3 -2
View File
@@ -1,11 +1,12 @@
"""qBittorrent API client."""
from .client import QBittorrentClient
from .dto import TorrentInfo
from .exceptions import (
QBittorrentError,
QBittorrentConfigurationError,
QBittorrentAPIError,
QBittorrentAuthError,
QBittorrentConfigurationError,
QBittorrentError,
)
# Global qBittorrent client instance (singleton)
+35 -38
View File
@@ -1,12 +1,15 @@
"""qBittorrent Web API client."""
from typing import Dict, Any, Optional, List
import logging
from typing import Any
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from requests.exceptions import HTTPError, RequestException, Timeout
from agent.config import Settings, settings
from .dto import TorrentInfo
from .exceptions import QBittorrentError, QBittorrentAPIError, QBittorrentAuthError
from .exceptions import QBittorrentAPIError, QBittorrentAuthError
logger = logging.getLogger(__name__)
@@ -27,11 +30,11 @@ class QBittorrentClient:
def __init__(
self,
host: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
host: str | None = None,
username: str | None = None,
password: str | None = None,
timeout: int | None = None,
config: Settings | None = None,
):
"""
Initialize qBittorrent client.
@@ -59,8 +62,8 @@ class QBittorrentClient:
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
files: Optional[Dict[str, Any]] = None
data: dict[str, Any] | None = None,
files: dict[str, Any] | None = None,
) -> Any:
"""
Make a request to qBittorrent API.
@@ -85,7 +88,9 @@ class QBittorrentClient:
if method.upper() == "GET":
response = self.session.get(url, params=data, timeout=self.timeout)
elif method.upper() == "POST":
response = self.session.post(url, data=data, files=files, timeout=self.timeout)
response = self.session.post(
url, data=data, files=files, timeout=self.timeout
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
@@ -99,14 +104,18 @@ class QBittorrentClient:
except Timeout as e:
logger.error(f"qBittorrent API timeout: {e}")
raise QBittorrentAPIError(f"Request timeout after {self.timeout} seconds") from e
raise QBittorrentAPIError(
f"Request timeout after {self.timeout} seconds"
) from e
except HTTPError as e:
logger.error(f"qBittorrent API HTTP error: {e}")
if e.response is not None:
status_code = e.response.status_code
if status_code == 403:
raise QBittorrentAuthError("Authentication required or forbidden") from e
raise QBittorrentAuthError(
"Authentication required or forbidden"
) from e
else:
raise QBittorrentAPIError(f"HTTP {status_code}: {e}") from e
raise QBittorrentAPIError(f"HTTP error: {e}") from e
@@ -126,10 +135,7 @@ class QBittorrentClient:
QBittorrentAuthError: If authentication fails
"""
try:
data = {
"username": self.username,
"password": self.password
}
data = {"username": self.username, "password": self.password}
response = self._make_request("POST", "/api/v2/auth/login", data=data)
@@ -161,10 +167,8 @@ class QBittorrentClient:
return False
def get_torrents(
self,
filter: Optional[str] = None,
category: Optional[str] = None
) -> List[TorrentInfo]:
self, filter: str | None = None, category: str | None = None
) -> list[TorrentInfo]:
"""
Get list of torrents.
@@ -212,9 +216,9 @@ class QBittorrentClient:
def add_torrent(
self,
magnet: str,
category: Optional[str] = None,
save_path: Optional[str] = None,
paused: bool = False
category: str | None = None,
save_path: str | None = None,
paused: bool = False,
) -> bool:
"""
Add a torrent via magnet link.
@@ -234,10 +238,7 @@ class QBittorrentClient:
if not self._authenticated:
self.login()
data = {
"urls": magnet,
"paused": "true" if paused else "false"
}
data = {"urls": magnet, "paused": "true" if paused else "false"}
if category:
data["category"] = category
@@ -248,7 +249,7 @@ class QBittorrentClient:
response = self._make_request("POST", "/api/v2/torrents/add", data=data)
if response == "Ok.":
logger.info(f"Successfully added torrent")
logger.info("Successfully added torrent")
return True
else:
logger.warning(f"Unexpected response: {response}")
@@ -258,11 +259,7 @@ class QBittorrentClient:
logger.error(f"Failed to add torrent: {e}")
raise
def delete_torrent(
self,
torrent_hash: str,
delete_files: bool = False
) -> bool:
def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool:
"""
Delete a torrent.
@@ -281,7 +278,7 @@ class QBittorrentClient:
data = {
"hashes": torrent_hash,
"deleteFiles": "true" if delete_files else "false"
"deleteFiles": "true" if delete_files else "false",
}
try:
@@ -339,7 +336,7 @@ class QBittorrentClient:
logger.error(f"Failed to resume torrent: {e}")
raise
def get_torrent_properties(self, torrent_hash: str) -> Dict[str, Any]:
def get_torrent_properties(self, torrent_hash: str) -> dict[str, Any]:
"""
Get detailed properties of a torrent.
@@ -361,7 +358,7 @@ class QBittorrentClient:
logger.error(f"Failed to get torrent properties: {e}")
raise
def _parse_torrent(self, torrent: Dict[str, Any]) -> TorrentInfo:
def _parse_torrent(self, torrent: dict[str, Any]) -> TorrentInfo:
"""
Parse a torrent dict into a TorrentInfo object.
@@ -384,5 +381,5 @@ class QBittorrentClient:
num_leechs=torrent.get("num_leechs", 0),
ratio=torrent.get("ratio", 0.0),
category=torrent.get("category"),
save_path=torrent.get("save_path")
save_path=torrent.get("save_path"),
)
+4 -3
View File
@@ -1,11 +1,12 @@
"""qBittorrent Data Transfer Objects."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class TorrentInfo:
"""Represents a torrent in qBittorrent."""
hash: str
name: str
size: int
@@ -17,5 +18,5 @@ class TorrentInfo:
num_seeds: int
num_leechs: int
ratio: float
category: Optional[str] = None
save_path: Optional[str] = None
category: str | None = None
save_path: str | None = None
@@ -3,19 +3,23 @@
class QBittorrentError(Exception):
"""Base exception for qBittorrent-related errors."""
pass
class QBittorrentConfigurationError(QBittorrentError):
"""Raised when qBittorrent is not properly configured."""
pass
class QBittorrentAPIError(QBittorrentError):
"""Raised when qBittorrent API returns an error."""
pass
class QBittorrentAuthError(QBittorrentError):
"""Raised when authentication fails."""
pass
+4 -3
View File
@@ -1,10 +1,11 @@
"""TMDB API client."""
from .client import TMDBClient
from .dto import MediaResult, ExternalIds
from .dto import ExternalIds, MediaResult
from .exceptions import (
TMDBError,
TMDBConfigurationError,
TMDBAPIError,
TMDBConfigurationError,
TMDBError,
TMDBNotFoundError,
)
+104 -95
View File
@@ -1,12 +1,19 @@
"""TMDB (The Movie Database) API client."""
from typing import Dict, Any, Optional, List
import logging
from typing import Any
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from requests.exceptions import HTTPError, RequestException, Timeout
from agent.config import Settings, settings
from .dto import MediaResult
from .exceptions import TMDBError, TMDBConfigurationError, TMDBAPIError, TMDBNotFoundError
from .exceptions import (
TMDBAPIError,
TMDBConfigurationError,
TMDBNotFoundError,
)
logger = logging.getLogger(__name__)
@@ -14,88 +21,86 @@ logger = logging.getLogger(__name__)
class TMDBClient:
"""
Client for interacting with The Movie Database (TMDB) API.
This client provides methods to search for movies and TV shows,
retrieve their details, and get external IDs (like IMDb).
Example:
>>> client = TMDBClient()
>>> result = client.search_media("Inception")
>>> print(result.imdb_id)
'tt1375666'
"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
api_key: str | None = None,
base_url: str | None = None,
timeout: int | None = None,
config: Settings | None = None,
):
"""
Initialize TMDB client.
Args:
api_key: TMDB API key (defaults to settings)
base_url: TMDB API base URL (defaults to settings)
timeout: Request timeout in seconds (defaults to settings)
config: Optional Settings instance (for testing)
Raises:
TMDBConfigurationError: If API key is missing
"""
cfg = config or settings
self.api_key = api_key or cfg.tmdb_api_key
self.base_url = base_url or cfg.tmdb_base_url
self.timeout = timeout or cfg.request_timeout
if not self.api_key:
raise TMDBConfigurationError(
"TMDB API key is required. Set TMDB_API_KEY environment variable."
)
if not self.base_url:
raise TMDBConfigurationError(
"TMDB base URL is required. Set TMDB_BASE_URL environment variable."
)
logger.info("TMDB client initialized")
def _make_request(
self,
endpoint: str,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
self, endpoint: str, params: dict[str, Any] | None = None
) -> dict[str, Any]:
"""
Make a request to TMDB API.
Args:
endpoint: API endpoint (e.g., '/search/multi')
params: Query parameters
Returns:
JSON response as dict
Raises:
TMDBAPIError: If request fails
"""
url = f"{self.base_url}{endpoint}"
# Add API key to params
request_params = params or {}
request_params['api_key'] = self.api_key
request_params["api_key"] = self.api_key
try:
logger.debug(f"TMDB request: {endpoint}")
response = requests.get(url, params=request_params, timeout=self.timeout)
response.raise_for_status()
return response.json()
except Timeout as e:
logger.error(f"TMDB API timeout: {e}")
raise TMDBAPIError(f"Request timeout after {self.timeout} seconds") from e
except HTTPError as e:
logger.error(f"TMDB API HTTP error: {e}")
if e.response is not None:
@@ -107,129 +112,133 @@ class TMDBClient:
else:
raise TMDBAPIError(f"HTTP {status_code}: {e}") from e
raise TMDBAPIError(f"HTTP error: {e}") from e
except RequestException as e:
logger.error(f"TMDB API request failed: {e}")
raise TMDBAPIError(f"Failed to connect to TMDB API: {e}") from e
def search_multi(self, query: str) -> List[Dict[str, Any]]:
def search_multi(self, query: str) -> list[dict[str, Any]]:
"""
Search for movies and TV shows.
Args:
query: Search query (movie or TV show title)
Returns:
List of search results
Raises:
TMDBAPIError: If request fails
TMDBNotFoundError: If no results found
"""
if not query or not isinstance(query, str):
raise ValueError("Query must be a non-empty string")
if len(query) > 500:
raise ValueError("Query is too long (max 500 characters)")
data = self._make_request('/search/multi', {'query': query})
results = data.get('results', [])
data = self._make_request("/search/multi", {"query": query})
results = data.get("results", [])
if not results:
raise TMDBNotFoundError(f"No results found for '{query}'")
logger.info(f"Found {len(results)} results for '{query}'")
return results
def get_external_ids(self, media_type: str, tmdb_id: int) -> Dict[str, Any]:
def get_external_ids(self, media_type: str, tmdb_id: int) -> dict[str, Any]:
"""
Get external IDs (IMDb, TVDB, etc.) for a media item.
Args:
media_type: Type of media ('movie' or 'tv')
tmdb_id: TMDB ID of the media
Returns:
Dict with external IDs
Raises:
TMDBAPIError: If request fails
"""
if media_type not in ('movie', 'tv'):
raise ValueError(f"Invalid media_type: {media_type}. Must be 'movie' or 'tv'")
if media_type not in ("movie", "tv"):
raise ValueError(
f"Invalid media_type: {media_type}. Must be 'movie' or 'tv'"
)
endpoint = f"/{media_type}/{tmdb_id}/external_ids"
return self._make_request(endpoint)
def search_media(self, title: str) -> MediaResult:
"""
Search for a media item and return detailed information including IMDb ID.
This is a convenience method that combines search and external ID lookup.
Args:
title: Title of the movie or TV show
Returns:
MediaResult with all available information
Raises:
TMDBAPIError: If request fails
TMDBNotFoundError: If media not found
"""
# Search for media
results = self.search_multi(title)
# Get the first (most relevant) result
top_result = results[0]
# Validate result structure
if 'id' not in top_result or 'media_type' not in top_result:
if "id" not in top_result or "media_type" not in top_result:
raise TMDBAPIError("Invalid TMDB response structure")
tmdb_id = top_result['id']
media_type = top_result['media_type']
tmdb_id = top_result["id"]
media_type = top_result["media_type"]
# Skip if not movie or TV show
if media_type not in ('movie', 'tv'):
if media_type not in ("movie", "tv"):
logger.warning(f"Skipping result of type: {media_type}")
if len(results) > 1:
# Try next result
return self._parse_result(results[1])
raise TMDBNotFoundError(f"No movie or TV show found for '{title}'")
return self._parse_result(top_result)
def _parse_result(self, result: Dict[str, Any]) -> MediaResult:
def _parse_result(self, result: dict[str, Any]) -> MediaResult:
"""
Parse a TMDB result into a MediaResult object.
Args:
result: Raw TMDB result dict
Returns:
MediaResult object
"""
tmdb_id = result['id']
media_type = result['media_type']
title = result.get('title') or result.get('name', 'Unknown')
tmdb_id = result["id"]
media_type = result["media_type"]
title = result.get("title") or result.get("name", "Unknown")
# Get external IDs (including IMDb)
try:
external_ids = self.get_external_ids(media_type, tmdb_id)
imdb_id = external_ids.get('imdb_id')
imdb_id = external_ids.get("imdb_id")
except TMDBAPIError as e:
logger.warning(f"Failed to get external IDs: {e}")
imdb_id = None
# Extract other useful information
overview = result.get('overview')
release_date = result.get('release_date') or result.get('first_air_date')
poster_path = result.get('poster_path')
vote_average = result.get('vote_average')
logger.info(f"Found: {title} (Type: {media_type}, TMDB ID: {tmdb_id}, IMDb: {imdb_id})")
overview = result.get("overview")
release_date = result.get("release_date") or result.get("first_air_date")
poster_path = result.get("poster_path")
vote_average = result.get("vote_average")
logger.info(
f"Found: {title} (Type: {media_type}, TMDB ID: {tmdb_id}, IMDb: {imdb_id})"
)
return MediaResult(
tmdb_id=tmdb_id,
title=title,
@@ -238,43 +247,43 @@ class TMDBClient:
overview=overview,
release_date=release_date,
poster_path=poster_path,
vote_average=vote_average
vote_average=vote_average,
)
def get_movie_details(self, movie_id: int) -> Dict[str, Any]:
def get_movie_details(self, movie_id: int) -> dict[str, Any]:
"""
Get detailed information about a movie.
Args:
movie_id: TMDB movie ID
Returns:
Dict with movie details
Raises:
TMDBAPIError: If request fails
"""
return self._make_request(f'/movie/{movie_id}')
def get_tv_details(self, tv_id: int) -> Dict[str, Any]:
return self._make_request(f"/movie/{movie_id}")
def get_tv_details(self, tv_id: int) -> dict[str, Any]:
"""
Get detailed information about a TV show.
Args:
tv_id: TMDB TV show ID
Returns:
Dict with TV show details
Raises:
TMDBAPIError: If request fails
"""
return self._make_request(f'/tv/{tv_id}')
return self._make_request(f"/tv/{tv_id}")
def is_configured(self) -> bool:
"""
Check if TMDB client is properly configured.
Returns:
True if configured, False otherwise
"""
+13 -11
View File
@@ -1,26 +1,28 @@
"""TMDB Data Transfer Objects."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class MediaResult:
"""Represents a media search result from TMDB."""
tmdb_id: int
title: str
media_type: str # 'movie' or 'tv'
imdb_id: Optional[str] = None
overview: Optional[str] = None
release_date: Optional[str] = None
poster_path: Optional[str] = None
vote_average: Optional[float] = None
imdb_id: str | None = None
overview: str | None = None
release_date: str | None = None
poster_path: str | None = None
vote_average: float | None = None
@dataclass
class ExternalIds:
"""External IDs for a media item."""
imdb_id: Optional[str] = None
tvdb_id: Optional[int] = None
facebook_id: Optional[str] = None
instagram_id: Optional[str] = None
twitter_id: Optional[str] = None
imdb_id: str | None = None
tvdb_id: int | None = None
facebook_id: str | None = None
instagram_id: str | None = None
twitter_id: str | None = None
+4
View File
@@ -3,19 +3,23 @@
class TMDBError(Exception):
"""Base exception for TMDB-related errors."""
pass
class TMDBConfigurationError(TMDBError):
"""Raised when TMDB API is not properly configured."""
pass
class TMDBAPIError(TMDBError):
"""Raised when TMDB API returns an error."""
pass
class TMDBNotFoundError(TMDBError):
"""Raised when media is not found."""
pass
+2 -1
View File
@@ -1,7 +1,8 @@
"""Filesystem operations."""
from .exceptions import FilesystemError, PathTraversalError
from .file_manager import FileManager
from .organizer import MediaOrganizer
from .exceptions import FilesystemError, PathTraversalError
__all__ = [
"FileManager",
+4
View File
@@ -3,19 +3,23 @@
class FilesystemError(Exception):
"""Base exception for filesystem operations."""
pass
class PathTraversalError(FilesystemError):
"""Raised when path traversal attack is detected."""
pass
class FileNotFoundError(FilesystemError):
"""Raised when a file is not found."""
pass
class PermissionDeniedError(FilesystemError):
"""Raised when permission is denied."""
pass
+146 -148
View File
@@ -1,19 +1,22 @@
"""File manager - Migrated from agent/tools/filesystem.py with domain logic extracted."""
from typing import Dict, Any, List
from enum import Enum
from pathlib import Path
"""File manager for filesystem operations."""
import logging
import os
import shutil
from enum import Enum
from pathlib import Path
from typing import Any
from .exceptions import FilesystemError, PathTraversalError
from infrastructure.persistence.memory import Memory
from infrastructure.persistence import get_memory
from .exceptions import PathTraversalError
logger = logging.getLogger(__name__)
class FolderName(Enum):
"""Types of folders that can be managed."""
DOWNLOAD = "download"
TVSHOW = "tvshow"
MOVIE = "movie"
@@ -23,137 +26,116 @@ class FolderName(Enum):
class FileManager:
"""
File manager for filesystem operations.
Handles folder configuration, listing, and file operations with security.
Handles folder configuration, listing, and file operations
with security checks to prevent path traversal attacks.
"""
def __init__(self, memory: Memory):
def set_folder_path(self, folder_name: str, path_value: str) -> dict[str, Any]:
"""
Initialize file manager.
Set a folder path in the configuration.
Validates that the path exists, is a directory, and is readable.
Args:
memory: Memory instance for folder configuration
"""
self.memory = memory
def set_folder_path(self, folder_name: str, path_value: str) -> Dict[str, Any]:
"""
Set a folder path in the configuration with validation.
Args:
folder_name: Name of folder to set (download, tvshow, movie, torrent)
path_value: Absolute path to the folder
folder_name: Name of folder (download, tvshow, movie, torrent).
path_value: Absolute path to the folder.
Returns:
Dict with status or error information
Dict with status or error information.
"""
try:
# Validate folder name
self._validate_folder_name(folder_name)
# Convert to Path object for better handling
path_obj = Path(path_value).resolve()
# Validate path exists and is a directory
if not path_obj.exists():
logger.warning(f"Path does not exist: {path_value}")
return {
"error": "invalid_path",
"message": f"Path does not exist: {path_value}"
"message": f"Path does not exist: {path_value}",
}
if not path_obj.is_dir():
logger.warning(f"Path is not a directory: {path_value}")
return {
"error": "invalid_path",
"message": f"Path is not a directory: {path_value}"
"message": f"Path is not a directory: {path_value}",
}
# Check if path is readable
if not os.access(path_obj, os.R_OK):
logger.warning(f"Path is not readable: {path_value}")
return {
"error": "permission_denied",
"message": f"Path is not readable: {path_value}"
"message": f"Path is not readable: {path_value}",
}
# Store in memory
config = self.memory.get("config", {})
config[f"{folder_name}_folder"] = str(path_obj)
self.memory.set("config", config)
memory = get_memory()
memory.ltm.set_config(f"{folder_name}_folder", str(path_obj))
memory.save()
logger.info(f"Set {folder_name}_folder to: {path_obj}")
return {
"status": "ok",
"folder_name": folder_name,
"path": str(path_obj)
}
return {"status": "ok", "folder_name": folder_name, "path": str(path_obj)}
except ValueError as e:
logger.error(f"Validation error: {e}")
return {"error": "validation_failed", "message": str(e)}
except Exception as e:
logger.error(f"Unexpected error setting path: {e}", exc_info=True)
return {"error": "internal_error", "message": "Failed to set path"}
def list_folder(self, folder_type: str, path: str = ".") -> Dict[str, Any]:
def list_folder(self, folder_type: str, path: str = ".") -> dict[str, Any]:
"""
List contents of a folder with security checks.
List contents of a configured folder.
Includes security checks to prevent path traversal.
Args:
folder_type: Type of folder to list (download, tvshow, movie, torrent)
path: Relative path within the folder (default: ".")
folder_type: Type of folder (download, tvshow, movie, torrent).
path: Relative path within the folder (default: root).
Returns:
Dict with folder contents or error information
Dict with folder contents or error information.
"""
try:
# Validate folder type
self._validate_folder_name(folder_type)
# Sanitize the path
safe_path = self._sanitize_path(path)
# Get root folder from config
memory = get_memory()
folder_key = f"{folder_type}_folder"
config = self.memory.get("config", {})
if folder_key not in config or not config[folder_key]:
folder_path = memory.ltm.get_config(folder_key)
if not folder_path:
logger.warning(f"Folder not configured: {folder_type}")
return {
"error": "folder_not_set",
"message": f"{folder_type.capitalize()} folder not set in config."
"message": f"{folder_type.capitalize()} folder not configured.",
}
root = Path(config[folder_key])
root = Path(folder_path)
target = root / safe_path
# Security check: ensure target is within root
if not self._is_safe_path(root, target):
logger.warning(f"Path traversal attempt detected: {path}")
logger.warning(f"Path traversal attempt: {path}")
return {
"error": "forbidden",
"message": "Access denied: path outside allowed directory"
"message": "Access denied: path outside allowed directory",
}
# Check if target exists
if not target.exists():
logger.warning(f"Path does not exist: {target}")
return {
"error": "not_found",
"message": f"Path does not exist: {safe_path}"
"message": f"Path does not exist: {safe_path}",
}
# Check if target is a directory
if not target.is_dir():
logger.warning(f"Path is not a directory: {target}")
return {
"error": "not_a_directory",
"message": f"Path is not a directory: {safe_path}"
"message": f"Path is not a directory: {safe_path}",
}
# List directory contents
try:
entries = [entry.name for entry in target.iterdir()]
logger.debug(f"Listed {len(entries)} entries in {target}")
@@ -162,147 +144,163 @@ class FileManager:
"folder_type": folder_type,
"path": safe_path,
"entries": sorted(entries),
"count": len(entries)
"count": len(entries),
}
except PermissionError:
logger.warning(f"Permission denied accessing: {target}")
logger.warning(f"Permission denied: {target}")
return {
"error": "permission_denied",
"message": f"Permission denied accessing: {safe_path}"
"message": f"Permission denied: {safe_path}",
}
except PathTraversalError as e:
logger.warning(f"Path traversal attempt: {e}")
return {
"error": "forbidden",
"message": str(e)
}
return {"error": "forbidden", "message": str(e)}
except ValueError as e:
logger.error(f"Validation error: {e}")
return {"error": "validation_failed", "message": str(e)}
except Exception as e:
logger.error(f"Unexpected error listing folder: {e}", exc_info=True)
return {"error": "internal_error", "message": "Failed to list folder"}
def move_file(self, source: str, destination: str) -> Dict[str, Any]:
def move_file(self, source: str, destination: str) -> dict[str, Any]:
"""
Move a file from one location to another with safety checks.
Move a file from one location to another.
Includes validation and verification after move.
Args:
source: Source file path
destination: Destination file path
source: Source file path.
destination: Destination file path.
Returns:
Dict with status or error information
Dict with status or error information.
"""
try:
# Convert to Path objects
source_path = Path(source).resolve()
dest_path = Path(destination).resolve()
logger.info(f"Moving file from {source_path} to {dest_path}")
# Validate source
logger.info(f"Moving file: {source_path} -> {dest_path}")
if not source_path.exists():
return {
"error": "source_not_found",
"message": f"Source file does not exist: {source}"
"message": f"Source does not exist: {source}",
}
if not source_path.is_file():
return {
"error": "source_not_file",
"message": f"Source is not a file: {source}"
"message": f"Source is not a file: {source}",
}
# Get source file size for verification
source_size = source_path.stat().st_size
# Validate destination
dest_parent = dest_path.parent
if not dest_parent.exists():
return {
"error": "destination_dir_not_found",
"message": f"Destination directory does not exist: {dest_parent}"
"message": f"Destination directory does not exist: {dest_parent}",
}
if dest_path.exists():
return {
"error": "destination_exists",
"message": f"Destination file already exists: {destination}"
"message": f"Destination already exists: {destination}",
}
# Perform move
shutil.move(str(source_path), str(dest_path))
# Verify
# Verify move
if not dest_path.exists():
return {
"error": "move_verification_failed",
"message": "File was not moved successfully"
"message": "File was not moved successfully",
}
dest_size = dest_path.stat().st_size
if dest_size != source_size:
return {
"error": "size_mismatch",
"message": f"File size mismatch after move"
"message": "File size mismatch after move",
}
logger.info(f"File successfully moved: {dest_path.name}")
logger.info(f"File moved successfully: {dest_path.name}")
return {
"status": "ok",
"source": str(source_path),
"destination": str(dest_path),
"filename": dest_path.name,
"size": dest_size
"size": dest_size,
}
except Exception as e:
logger.error(f"Error moving file: {e}", exc_info=True)
return {
"error": "move_failed",
"message": str(e)
}
return {"error": "move_failed", "message": str(e)}
def _validate_folder_name(self, folder_name: str) -> bool:
"""Validate folder name against allowed values."""
"""
Validate folder name against allowed values.
Args:
folder_name: Name to validate.
Returns:
True if valid.
Raises:
ValueError: If folder name is invalid.
"""
valid_names = [fn.value for fn in FolderName]
if folder_name not in valid_names:
raise ValueError(
f"Invalid folder_name '{folder_name}'. Must be one of: {', '.join(valid_names)}"
f"Invalid folder_name '{folder_name}'. "
f"Must be one of: {', '.join(valid_names)}"
)
return True
def _sanitize_path(self, path: str) -> str:
"""Sanitize path to prevent path traversal attacks."""
# Normalize path
"""
Sanitize path to prevent path traversal attacks.
Args:
path: Path to sanitize.
Returns:
Sanitized path.
Raises:
PathTraversalError: If path contains traversal attempts.
"""
normalized = os.path.normpath(path)
# Check for absolute paths
if os.path.isabs(normalized):
raise PathTraversalError("Absolute paths are not allowed")
# Check for parent directory references
if normalized.startswith("..") or "/.." in normalized or "\\.." in normalized:
raise PathTraversalError("Parent directory references are not allowed")
# Check for null bytes
raise PathTraversalError("Parent directory references not allowed")
if "\x00" in normalized:
raise PathTraversalError("Null bytes in path are not allowed")
raise PathTraversalError("Null bytes in path not allowed")
return normalized
def _is_safe_path(self, base_path: Path, target_path: Path) -> bool:
"""Check if target path is within base path (prevents path traversal)."""
"""
Check if target path is within base path.
Args:
base_path: The allowed base directory.
target_path: The path to check.
Returns:
True if target is within base, False otherwise.
"""
try:
# Resolve both paths to absolute paths
base_resolved = base_path.resolve()
target_resolved = target_path.resolve()
# Check if target is relative to base
target_resolved.relative_to(base_resolved)
return True
except (ValueError, OSError):
+35 -38
View File
@@ -1,11 +1,10 @@
"""Media organizer - Organizes movies and TV shows into proper folder structures."""
from pathlib import Path
import logging
from typing import Optional
from pathlib import Path
from domain.movies.entities import Movie
from domain.tv_shows.entities import TVShow, Episode
from domain.shared.value_objects import FilePath
from domain.tv_shows.entities import Episode, TVShow
logger = logging.getLogger(__name__)
@@ -13,101 +12,99 @@ logger = logging.getLogger(__name__)
class MediaOrganizer:
"""
Organizes media files into proper folder structures.
This service knows how to organize movies and TV shows according to
common media server conventions (Plex, Jellyfin, etc.).
"""
def __init__(self, movie_folder: Path, tvshow_folder: Path):
"""
Initialize media organizer.
Args:
movie_folder: Root folder for movies
tvshow_folder: Root folder for TV shows
"""
self.movie_folder = movie_folder
self.tvshow_folder = tvshow_folder
def get_movie_destination(self, movie: Movie, filename: str) -> Path:
"""
Get the destination path for a movie file.
Structure: /movies/Movie Title (Year)/Movie.Title.Year.Quality.ext
Args:
movie: Movie entity
filename: Original filename (to extract extension)
Returns:
Full destination path
"""
# Create movie folder
folder_name = movie.get_folder_name()
movie_dir = self.movie_folder / folder_name
# Get extension from original filename
extension = Path(filename).suffix
# Create new filename
new_filename = movie.get_filename() + extension
return movie_dir / new_filename
def get_episode_destination(
self,
show: TVShow,
episode: Episode,
filename: str
self, show: TVShow, episode: Episode, filename: str
) -> Path:
"""
Get the destination path for a TV show episode file.
Structure: /tvshows/Show.Name/Season 01/S01E05.Episode.Title.ext
Args:
show: TVShow entity
episode: Episode entity
filename: Original filename (to extract extension)
Returns:
Full destination path
"""
# Create show folder
show_folder_name = show.get_folder_name()
show_dir = self.tvshow_folder / show_folder_name
# Create season folder
from domain.tv_shows.entities import Season
season = Season(
show_imdb_id=show.imdb_id,
season_number=episode.season_number,
episode_count=0 # Not needed for folder name
episode_count=0, # Not needed for folder name
)
season_folder_name = season.get_folder_name()
season_dir = show_dir / season_folder_name
# Get extension from original filename
extension = Path(filename).suffix
# Create new filename
new_filename = episode.get_filename() + extension
return season_dir / new_filename
def create_movie_directory(self, movie: Movie) -> bool:
"""
Create the directory structure for a movie.
Args:
movie: Movie entity
Returns:
True if successful
"""
folder_name = movie.get_folder_name()
movie_dir = self.movie_folder / folder_name
try:
movie_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Created movie directory: {movie_dir}")
@@ -115,32 +112,32 @@ class MediaOrganizer:
except Exception as e:
logger.error(f"Failed to create movie directory: {e}")
return False
def create_episode_directory(self, show: TVShow, season_number: int) -> bool:
"""
Create the directory structure for a TV show season.
Args:
show: TVShow entity
season_number: Season number
Returns:
True if successful
"""
from domain.tv_shows.entities import Season
from domain.tv_shows.value_objects import SeasonNumber
show_folder_name = show.get_folder_name()
show_dir = self.tvshow_folder / show_folder_name
season = Season(
show_imdb_id=show.imdb_id,
season_number=SeasonNumber(season_number),
episode_count=0
episode_count=0,
)
season_folder_name = season.get_folder_name()
season_dir = show_dir / season_folder_name
try:
season_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Created season directory: {season_dir}")
+24
View File
@@ -1 +1,25 @@
"""Persistence layer - Data storage implementations."""
from .context import (
get_memory,
has_memory,
init_memory,
set_memory,
)
from .memory import (
EpisodicMemory,
LongTermMemory,
Memory,
ShortTermMemory,
)
__all__ = [
"Memory",
"LongTermMemory",
"ShortTermMemory",
"EpisodicMemory",
"init_memory",
"set_memory",
"get_memory",
"has_memory",
]
+79
View File
@@ -0,0 +1,79 @@
"""
Memory context using contextvars.
Provides thread-safe and async-safe access to the Memory instance
without passing it explicitly through all function calls.
Usage:
# At application startup
from infrastructure.persistence import init_memory, get_memory
init_memory("memory_data")
# Anywhere in the code
memory = get_memory()
memory.ltm.set_config("key", "value")
"""
from contextvars import ContextVar
from .memory import Memory
_memory_ctx: ContextVar[Memory | None] = ContextVar("memory", default=None)
def init_memory(storage_dir: str = "memory_data") -> Memory:
"""
Initialize the memory and set it in the context.
Call this once at application startup.
Args:
storage_dir: Directory for persistent storage.
Returns:
The initialized Memory instance.
"""
memory = Memory(storage_dir=storage_dir)
_memory_ctx.set(memory)
return memory
def set_memory(memory: Memory) -> None:
"""
Set an existing Memory instance in the context.
Useful for testing or when injecting a specific instance.
Args:
memory: Memory instance to set.
"""
_memory_ctx.set(memory)
def get_memory() -> Memory:
"""
Get the Memory instance from the context.
Returns:
The Memory instance.
Raises:
RuntimeError: If memory has not been initialized.
"""
memory = _memory_ctx.get()
if memory is None:
raise RuntimeError(
"Memory not initialized. Call init_memory() at application startup."
)
return memory
def has_memory() -> bool:
"""
Check if memory has been initialized.
Returns:
True if memory is available, False otherwise.
"""
return _memory_ctx.get() is not None
+2 -1
View File
@@ -1,7 +1,8 @@
"""JSON-based repository implementations."""
from .movie_repository import JsonMovieRepository
from .tvshow_repository import JsonTVShowRepository
from .subtitle_repository import JsonSubtitleRepository
from .tvshow_repository import JsonTVShowRepository
__all__ = [
"JsonMovieRepository",
@@ -1,11 +1,14 @@
"""JSON-based movie repository implementation."""
from typing import List, Optional, Dict, Any
import logging
from domain.movies.repositories import MovieRepository
import logging
from datetime import datetime
from typing import Any
from domain.movies.entities import Movie
from domain.shared.value_objects import ImdbId
from ..memory import Memory
from domain.movies.repositories import MovieRepository
from domain.movies.value_objects import MovieTitle, Quality, ReleaseYear
from domain.shared.value_objects import FilePath, FileSize, ImdbId
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
@@ -13,103 +16,129 @@ logger = logging.getLogger(__name__)
class JsonMovieRepository(MovieRepository):
"""
JSON-based implementation of MovieRepository.
Stores movies in the memory.json file.
Stores movies in the LTM library using the memory context.
"""
def __init__(self, memory: Memory):
"""
Initialize repository.
Args:
memory: Memory instance for persistence
"""
self.memory = memory
def save(self, movie: Movie) -> None:
"""Save a movie to the repository."""
movies = self._load_all()
"""
Save a movie to the repository.
Updates existing movie if IMDb ID matches.
Args:
movie: Movie entity to save.
"""
memory = get_memory()
movies = memory.ltm.library.get("movies", [])
# Remove existing movie with same IMDb ID
movies = [m for m in movies if m.get('imdb_id') != str(movie.imdb_id)]
# Add new movie
movies = [m for m in movies if m.get("imdb_id") != str(movie.imdb_id)]
movies.append(self._to_dict(movie))
# Save to memory
self.memory.set('movies', movies)
memory.ltm.library["movies"] = movies
memory.save()
logger.debug(f"Saved movie: {movie.imdb_id}")
def find_by_imdb_id(self, imdb_id: ImdbId) -> Optional[Movie]:
"""Find a movie by its IMDb ID."""
movies = self._load_all()
def find_by_imdb_id(self, imdb_id: ImdbId) -> Movie | None:
"""
Find a movie by its IMDb ID.
Args:
imdb_id: IMDb ID to search for.
Returns:
Movie if found, None otherwise.
"""
memory = get_memory()
movies = memory.ltm.library.get("movies", [])
for movie_dict in movies:
if movie_dict.get('imdb_id') == str(imdb_id):
if movie_dict.get("imdb_id") == str(imdb_id):
return self._from_dict(movie_dict)
return None
def find_all(self) -> List[Movie]:
"""Get all movies in the repository."""
movies_dict = self._load_all()
def find_all(self) -> list[Movie]:
"""
Get all movies in the repository.
Returns:
List of all Movie entities.
"""
memory = get_memory()
movies_dict = memory.ltm.library.get("movies", [])
return [self._from_dict(m) for m in movies_dict]
def delete(self, imdb_id: ImdbId) -> bool:
"""Delete a movie from the repository."""
movies = self._load_all()
"""
Delete a movie from the repository.
Args:
imdb_id: IMDb ID of movie to delete.
Returns:
True if deleted, False if not found.
"""
memory = get_memory()
movies = memory.ltm.library.get("movies", [])
initial_count = len(movies)
# Filter out the movie
movies = [m for m in movies if m.get('imdb_id') != str(imdb_id)]
movies = [m for m in movies if m.get("imdb_id") != str(imdb_id)]
if len(movies) < initial_count:
self.memory.set('movies', movies)
memory.ltm.library["movies"] = movies
memory.save()
logger.debug(f"Deleted movie: {imdb_id}")
return True
return False
def exists(self, imdb_id: ImdbId) -> bool:
"""Check if a movie exists in the repository."""
"""
Check if a movie exists in the repository.
Args:
imdb_id: IMDb ID to check.
Returns:
True if exists, False otherwise.
"""
return self.find_by_imdb_id(imdb_id) is not None
def _load_all(self) -> List[Dict[str, Any]]:
"""Load all movies from memory."""
return self.memory.get('movies', [])
def _to_dict(self, movie: Movie) -> Dict[str, Any]:
def _to_dict(self, movie: Movie) -> dict[str, Any]:
"""Convert Movie entity to dict for storage."""
return {
'imdb_id': str(movie.imdb_id),
'title': movie.title.value,
'release_year': movie.release_year.value if movie.release_year else None,
'quality': movie.quality.value,
'file_path': str(movie.file_path) if movie.file_path else None,
'file_size': movie.file_size.bytes if movie.file_size else None,
'tmdb_id': movie.tmdb_id,
'overview': movie.overview,
'poster_path': movie.poster_path,
'vote_average': movie.vote_average,
'added_at': movie.added_at.isoformat(),
"imdb_id": str(movie.imdb_id),
"title": movie.title.value,
"release_year": movie.release_year.value if movie.release_year else None,
"quality": movie.quality.value,
"file_path": str(movie.file_path) if movie.file_path else None,
"file_size": movie.file_size.bytes if movie.file_size else None,
"tmdb_id": movie.tmdb_id,
"added_at": movie.added_at.isoformat(),
}
def _from_dict(self, data: Dict[str, Any]) -> Movie:
def _from_dict(self, data: dict[str, Any]) -> Movie:
"""Convert dict from storage to Movie entity."""
from domain.movies.value_objects import MovieTitle, ReleaseYear, Quality
from domain.shared.value_objects import FilePath, FileSize
from datetime import datetime
# Parse quality string to enum
quality_str = data.get("quality", "unknown")
quality = Quality.from_string(quality_str)
return Movie(
imdb_id=ImdbId(data['imdb_id']),
title=MovieTitle(data['title']),
release_year=ReleaseYear(data['release_year']) if data.get('release_year') else None,
quality=Quality(data.get('quality', 'unknown')),
file_path=FilePath(data['file_path']) if data.get('file_path') else None,
file_size=FileSize(data['file_size']) if data.get('file_size') else None,
tmdb_id=data.get('tmdb_id'),
overview=data.get('overview'),
poster_path=data.get('poster_path'),
vote_average=data.get('vote_average'),
added_at=datetime.fromisoformat(data['added_at']) if data.get('added_at') else datetime.now(),
imdb_id=ImdbId(data["imdb_id"]),
title=MovieTitle(data["title"]),
release_year=(
ReleaseYear(data["release_year"]) if data.get("release_year") else None
),
quality=quality,
file_path=FilePath(data["file_path"]) if data.get("file_path") else None,
file_size=FileSize(data["file_size"]) if data.get("file_size") else None,
tmdb_id=data.get("tmdb_id"),
added_at=(
datetime.fromisoformat(data["added_at"])
if data.get("added_at")
else datetime.now()
),
)
@@ -1,12 +1,13 @@
"""JSON-based subtitle repository implementation."""
from typing import List, Optional, Dict, Any
import logging
from domain.subtitles.repositories import SubtitleRepository
import logging
from typing import Any
from domain.shared.value_objects import FilePath, ImdbId
from domain.subtitles.entities import Subtitle
from domain.subtitles.repositories import SubtitleRepository
from domain.subtitles.value_objects import Language, SubtitleFormat, TimingOffset
from domain.shared.value_objects import ImdbId, FilePath
from ..memory import Memory
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
@@ -14,114 +15,130 @@ logger = logging.getLogger(__name__)
class JsonSubtitleRepository(SubtitleRepository):
"""
JSON-based implementation of SubtitleRepository.
Stores subtitles in the memory.json file.
Stores subtitles in the LTM library using the memory context.
"""
def __init__(self, memory: Memory):
"""
Initialize repository.
Args:
memory: Memory instance for persistence
"""
self.memory = memory
def save(self, subtitle: Subtitle) -> None:
"""Save a subtitle to the repository."""
subtitles = self._load_all()
# Add new subtitle (we allow multiple subtitles for same media)
"""
Save a subtitle to the repository.
Multiple subtitles can exist for the same media.
Args:
subtitle: Subtitle entity to save.
"""
memory = get_memory()
subtitles = memory.ltm.library.get("subtitles", [])
subtitles.append(self._to_dict(subtitle))
# Save to memory
self.memory.set('subtitles', subtitles)
if "subtitles" not in memory.ltm.library:
memory.ltm.library["subtitles"] = []
memory.ltm.library["subtitles"] = subtitles
memory.save()
logger.debug(f"Saved subtitle for: {subtitle.media_imdb_id}")
def find_by_media(
self,
media_imdb_id: ImdbId,
language: Optional[Language] = None,
season: Optional[int] = None,
episode: Optional[int] = None
) -> List[Subtitle]:
"""Find subtitles for a media item."""
subtitles = self._load_all()
language: Language | None = None,
season: int | None = None,
episode: int | None = None,
) -> list[Subtitle]:
"""
Find subtitles for a media item.
Args:
media_imdb_id: IMDb ID of the media.
language: Optional language filter.
season: Optional season number filter.
episode: Optional episode number filter.
Returns:
List of matching Subtitle entities.
"""
memory = get_memory()
subtitles = memory.ltm.library.get("subtitles", [])
results = []
for sub_dict in subtitles:
# Filter by IMDb ID
if sub_dict.get('media_imdb_id') != str(media_imdb_id):
if sub_dict.get("media_imdb_id") != str(media_imdb_id):
continue
# Filter by language if specified
if language and sub_dict.get('language') != language.value:
if language and sub_dict.get("language") != language.value:
continue
# Filter by season/episode if specified
if season is not None and sub_dict.get('season_number') != season:
if season is not None and sub_dict.get("season_number") != season:
continue
if episode is not None and sub_dict.get('episode_number') != episode:
if episode is not None and sub_dict.get("episode_number") != episode:
continue
results.append(self._from_dict(sub_dict))
return results
def delete(self, subtitle: Subtitle) -> bool:
"""Delete a subtitle from the repository."""
subtitles = self._load_all()
"""
Delete a subtitle from the repository.
Matches by file path.
Args:
subtitle: Subtitle entity to delete.
Returns:
True if deleted, False if not found.
"""
memory = get_memory()
subtitles = memory.ltm.library.get("subtitles", [])
initial_count = len(subtitles)
# Filter out the subtitle (match by file path)
subtitles = [
s for s in subtitles
if s.get('file_path') != str(subtitle.file_path)
s for s in subtitles if s.get("file_path") != str(subtitle.file_path)
]
if len(subtitles) < initial_count:
self.memory.set('subtitles', subtitles)
memory.ltm.library["subtitles"] = subtitles
memory.save()
logger.debug(f"Deleted subtitle: {subtitle.file_path}")
return True
return False
def _load_all(self) -> List[Dict[str, Any]]:
"""Load all subtitles from memory."""
return self.memory.get('subtitles', [])
def _to_dict(self, subtitle: Subtitle) -> Dict[str, Any]:
def _to_dict(self, subtitle: Subtitle) -> dict[str, Any]:
"""Convert Subtitle entity to dict for storage."""
return {
'media_imdb_id': str(subtitle.media_imdb_id),
'language': subtitle.language.value,
'format': subtitle.format.value,
'file_path': str(subtitle.file_path),
'season_number': subtitle.season_number,
'episode_number': subtitle.episode_number,
'timing_offset': subtitle.timing_offset.milliseconds,
'hearing_impaired': subtitle.hearing_impaired,
'forced': subtitle.forced,
'source': subtitle.source,
'uploader': subtitle.uploader,
'download_count': subtitle.download_count,
'rating': subtitle.rating,
"media_imdb_id": str(subtitle.media_imdb_id),
"language": subtitle.language.value,
"format": subtitle.format.value,
"file_path": str(subtitle.file_path),
"season_number": subtitle.season_number,
"episode_number": subtitle.episode_number,
"timing_offset": subtitle.timing_offset.milliseconds,
"hearing_impaired": subtitle.hearing_impaired,
"forced": subtitle.forced,
"source": subtitle.source,
"uploader": subtitle.uploader,
"download_count": subtitle.download_count,
"rating": subtitle.rating,
}
def _from_dict(self, data: Dict[str, Any]) -> Subtitle:
def _from_dict(self, data: dict[str, Any]) -> Subtitle:
"""Convert dict from storage to Subtitle entity."""
return Subtitle(
media_imdb_id=ImdbId(data['media_imdb_id']),
language=Language.from_code(data['language']),
format=SubtitleFormat.from_extension(data['format']),
file_path=FilePath(data['file_path']),
season_number=data.get('season_number'),
episode_number=data.get('episode_number'),
timing_offset=TimingOffset(data.get('timing_offset', 0)),
hearing_impaired=data.get('hearing_impaired', False),
forced=data.get('forced', False),
source=data.get('source'),
uploader=data.get('uploader'),
download_count=data.get('download_count'),
rating=data.get('rating'),
media_imdb_id=ImdbId(data["media_imdb_id"]),
language=Language.from_code(data["language"]),
format=SubtitleFormat.from_extension(data["format"]),
file_path=FilePath(data["file_path"]),
season_number=data.get("season_number"),
episode_number=data.get("episode_number"),
timing_offset=TimingOffset(data.get("timing_offset", 0)),
hearing_impaired=data.get("hearing_impaired", False),
forced=data.get("forced", False),
source=data.get("source"),
uploader=data.get("uploader"),
download_count=data.get("download_count"),
rating=data.get("rating"),
)
@@ -1,12 +1,14 @@
"""JSON-based TV show repository implementation."""
from typing import List, Optional, Dict, Any
import logging
from domain.tv_shows.repositories import TVShowRepository
from domain.tv_shows.entities import TVShow
from domain.tv_shows.value_objects import ShowStatus
import logging
from datetime import datetime
from typing import Any
from domain.shared.value_objects import ImdbId
from ..memory import Memory
from domain.tv_shows.entities import TVShow
from domain.tv_shows.repositories import TVShowRepository
from domain.tv_shows.value_objects import ShowStatus
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
@@ -14,99 +16,121 @@ logger = logging.getLogger(__name__)
class JsonTVShowRepository(TVShowRepository):
"""
JSON-based implementation of TVShowRepository.
Stores TV shows in the memory.json file (compatible with existing tv_shows structure).
Stores TV shows in the LTM library using the memory context.
"""
def __init__(self, memory: Memory):
"""
Initialize repository.
Args:
memory: Memory instance for persistence
"""
self.memory = memory
def save(self, show: TVShow) -> None:
"""Save a TV show to the repository."""
shows = self._load_all()
"""
Save a TV show to the repository.
Updates existing show if IMDb ID matches.
Args:
show: TVShow entity to save.
"""
memory = get_memory()
shows = memory.ltm.library.get("tv_shows", [])
# Remove existing show with same IMDb ID
shows = [s for s in shows if s.get('imdb_id') != str(show.imdb_id)]
# Add new show
shows = [s for s in shows if s.get("imdb_id") != str(show.imdb_id)]
shows.append(self._to_dict(show))
# Save to memory
self.memory.set('tv_shows', shows)
memory.ltm.library["tv_shows"] = shows
memory.save()
logger.debug(f"Saved TV show: {show.imdb_id}")
def find_by_imdb_id(self, imdb_id: ImdbId) -> Optional[TVShow]:
"""Find a TV show by its IMDb ID."""
shows = self._load_all()
def find_by_imdb_id(self, imdb_id: ImdbId) -> TVShow | None:
"""
Find a TV show by its IMDb ID.
Args:
imdb_id: IMDb ID to search for.
Returns:
TVShow if found, None otherwise.
"""
memory = get_memory()
shows = memory.ltm.library.get("tv_shows", [])
for show_dict in shows:
if show_dict.get('imdb_id') == str(imdb_id):
if show_dict.get("imdb_id") == str(imdb_id):
return self._from_dict(show_dict)
return None
def find_all(self) -> List[TVShow]:
"""Get all TV shows in the repository."""
shows_dict = self._load_all()
def find_all(self) -> list[TVShow]:
"""
Get all TV shows in the repository.
Returns:
List of all TVShow entities.
"""
memory = get_memory()
shows_dict = memory.ltm.library.get("tv_shows", [])
return [self._from_dict(s) for s in shows_dict]
def delete(self, imdb_id: ImdbId) -> bool:
"""Delete a TV show from the repository."""
shows = self._load_all()
"""
Delete a TV show from the repository.
Args:
imdb_id: IMDb ID of show to delete.
Returns:
True if deleted, False if not found.
"""
memory = get_memory()
shows = memory.ltm.library.get("tv_shows", [])
initial_count = len(shows)
# Filter out the show
shows = [s for s in shows if s.get('imdb_id') != str(imdb_id)]
shows = [s for s in shows if s.get("imdb_id") != str(imdb_id)]
if len(shows) < initial_count:
self.memory.set('tv_shows', shows)
memory.ltm.library["tv_shows"] = shows
memory.save()
logger.debug(f"Deleted TV show: {imdb_id}")
return True
return False
def exists(self, imdb_id: ImdbId) -> bool:
"""Check if a TV show exists in the repository."""
"""
Check if a TV show exists in the repository.
Args:
imdb_id: IMDb ID to check.
Returns:
True if exists, False otherwise.
"""
return self.find_by_imdb_id(imdb_id) is not None
def _load_all(self) -> List[Dict[str, Any]]:
"""Load all TV shows from memory."""
return self.memory.get('tv_shows', [])
def _to_dict(self, show: TVShow) -> Dict[str, Any]:
def _to_dict(self, show: TVShow) -> dict[str, Any]:
"""Convert TVShow entity to dict for storage."""
return {
'imdb_id': str(show.imdb_id),
'title': show.title,
'seasons_count': show.seasons_count,
'status': show.status.value,
'tmdb_id': show.tmdb_id,
'overview': show.overview,
'poster_path': show.poster_path,
'first_air_date': show.first_air_date,
'vote_average': show.vote_average,
'added_at': show.added_at.isoformat(),
"imdb_id": str(show.imdb_id),
"title": show.title,
"seasons_count": show.seasons_count,
"status": show.status.value,
"tmdb_id": show.tmdb_id,
"first_air_date": show.first_air_date,
"added_at": show.added_at.isoformat(),
}
def _from_dict(self, data: Dict[str, Any]) -> TVShow:
def _from_dict(self, data: dict[str, Any]) -> TVShow:
"""Convert dict from storage to TVShow entity."""
from datetime import datetime
return TVShow(
imdb_id=ImdbId(data['imdb_id']),
title=data['title'],
seasons_count=data['seasons_count'],
status=ShowStatus.from_string(data['status']),
tmdb_id=data.get('tmdb_id'),
overview=data.get('overview'),
poster_path=data.get('poster_path'),
first_air_date=data.get('first_air_date'),
vote_average=data.get('vote_average'),
added_at=datetime.fromisoformat(data['added_at']) if data.get('added_at') else datetime.now(),
imdb_id=ImdbId(data["imdb_id"]),
title=data["title"],
seasons_count=data["seasons_count"],
status=ShowStatus.from_string(data["status"]),
tmdb_id=data.get("tmdb_id"),
first_air_date=data.get("first_air_date"),
added_at=(
datetime.fromisoformat(data["added_at"])
if data.get("added_at")
else datetime.now()
),
)
+555 -70
View File
@@ -1,86 +1,571 @@
"""Memory storage - Migrated from agent/memory.py"""
from pathlib import Path
from typing import Any, Dict
import json
"""
Memory - Unified management of 3 memory types.
from agent.config import settings
from agent.parameters import validate_parameter, get_parameter_schema
Architecture:
- LTM (Long-Term Memory): Configuration, library, preferences - Persistent
- STM (Short-Term Memory): Conversation, current workflow - Volatile
- Episodic Memory: Search results, transient states - Very volatile
"""
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# =============================================================================
# LONG-TERM MEMORY (LTM) - Persistent
# =============================================================================
@dataclass
class LongTermMemory:
"""
Long-term memory - Persistent and static.
Stores:
- User configuration (folders, URLs)
- Preferences (quality, languages)
- Library (owned movies/TV shows)
- Followed shows (watchlist)
"""
# Folder and service configuration
config: dict[str, str] = field(default_factory=dict)
# User preferences
preferences: dict[str, Any] = field(
default_factory=lambda: {
"preferred_quality": "1080p",
"preferred_languages": ["en", "fr"],
"auto_organize": False,
"naming_format": "{title}.{year}.{quality}",
}
)
# Library of owned media
library: dict[str, list[dict]] = field(
default_factory=lambda: {"movies": [], "tv_shows": []}
)
# Followed shows (watchlist)
following: list[dict] = field(default_factory=list)
def get_config(self, key: str, default: Any = None) -> Any:
"""Get a configuration value."""
return self.config.get(key, default)
def set_config(self, key: str, value: Any) -> None:
"""Set a configuration value."""
self.config[key] = value
logger.debug(f"LTM: Set config {key}")
def has_config(self, key: str) -> bool:
"""Check if a configuration exists."""
return key in self.config and self.config[key] is not None
def add_to_library(self, media_type: str, media: dict) -> None:
"""Add a media item to the library."""
if media_type not in self.library:
self.library[media_type] = []
# Avoid duplicates by imdb_id
existing_ids = [m.get("imdb_id") for m in self.library[media_type]]
if media.get("imdb_id") not in existing_ids:
media["added_at"] = datetime.now().isoformat()
self.library[media_type].append(media)
logger.info(f"LTM: Added {media.get('title')} to {media_type}")
def get_library(self, media_type: str) -> list[dict]:
"""Get the library for a media type."""
return self.library.get(media_type, [])
def follow_show(self, show: dict) -> None:
"""Add a show to the watchlist."""
existing_ids = [s.get("imdb_id") for s in self.following]
if show.get("imdb_id") not in existing_ids:
show["followed_at"] = datetime.now().isoformat()
self.following.append(show)
logger.info(f"LTM: Now following {show.get('title')}")
def to_dict(self) -> dict:
"""Convert to dictionary for serialization."""
return {
"config": self.config,
"preferences": self.preferences,
"library": self.library,
"following": self.following,
}
@classmethod
def from_dict(cls, data: dict) -> "LongTermMemory":
"""Create an instance from a dictionary."""
return cls(
config=data.get("config", {}),
preferences=data.get(
"preferences",
{
"preferred_quality": "1080p",
"preferred_languages": ["en", "fr"],
"auto_organize": False,
"naming_format": "{title}.{year}.{quality}",
},
),
library=data.get("library", {"movies": [], "tv_shows": []}),
following=data.get("following", []),
)
# =============================================================================
# SHORT-TERM MEMORY (STM) - Conversation
# =============================================================================
@dataclass
class ShortTermMemory:
"""
Short-term memory - Volatile and conversational.
Stores:
- Current conversation history
- Current workflow (what we're doing)
- Extracted entities from conversation
- Current discussion topic
"""
# Conversation message history
conversation_history: list[dict[str, str]] = field(default_factory=list)
# Current workflow
current_workflow: dict | None = None
# Extracted entities (title, year, requested quality, etc.)
extracted_entities: dict[str, Any] = field(default_factory=dict)
# Current conversation topic
current_topic: str | None = None
# History message limit
max_history: int = 20
def add_message(self, role: str, content: str) -> None:
"""Add a message to history."""
self.conversation_history.append(
{"role": role, "content": content, "timestamp": datetime.now().isoformat()}
)
# Keep only the last N messages
if len(self.conversation_history) > self.max_history:
self.conversation_history = self.conversation_history[-self.max_history :]
logger.debug(f"STM: Added {role} message")
def get_recent_history(self, n: int = 10) -> list[dict]:
"""Get the last N messages."""
return self.conversation_history[-n:]
def start_workflow(self, workflow_type: str, target: dict) -> None:
"""Start a new workflow."""
self.current_workflow = {
"type": workflow_type,
"target": target,
"stage": "started",
"started_at": datetime.now().isoformat(),
}
logger.info(f"STM: Started workflow '{workflow_type}'")
def update_workflow_stage(self, stage: str) -> None:
"""Update the workflow stage."""
if self.current_workflow:
self.current_workflow["stage"] = stage
logger.debug(f"STM: Workflow stage -> {stage}")
def end_workflow(self) -> None:
"""End the current workflow."""
if self.current_workflow:
logger.info(f"STM: Ended workflow '{self.current_workflow.get('type')}'")
self.current_workflow = None
def set_entity(self, key: str, value: Any) -> None:
"""Store an extracted entity."""
self.extracted_entities[key] = value
logger.debug(f"STM: Set entity {key}={value}")
def get_entity(self, key: str, default: Any = None) -> Any:
"""Get an extracted entity."""
return self.extracted_entities.get(key, default)
def clear_entities(self) -> None:
"""Clear extracted entities."""
self.extracted_entities = {}
def set_topic(self, topic: str) -> None:
"""Set the current topic."""
self.current_topic = topic
logger.debug(f"STM: Topic -> {topic}")
def clear(self) -> None:
"""Reset short-term memory."""
self.conversation_history = []
self.current_workflow = None
self.extracted_entities = {}
self.current_topic = None
logger.info("STM: Cleared")
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"conversation_history": self.conversation_history,
"current_workflow": self.current_workflow,
"extracted_entities": self.extracted_entities,
"current_topic": self.current_topic,
}
# =============================================================================
# EPISODIC MEMORY - Transient states
# =============================================================================
@dataclass
class EpisodicMemory:
"""
Episodic/sensory memory - Temporary and event-driven.
Stores:
- Last search results
- Active downloads
- Recent errors
- Pending questions awaiting user response
- Background events
"""
# Last search results
last_search_results: dict | None = None
# Active downloads
active_downloads: list[dict] = field(default_factory=list)
# Recent errors
recent_errors: list[dict] = field(default_factory=list)
# Pending question awaiting user response
pending_question: dict | None = None
# Background events (download complete, new files, etc.)
background_events: list[dict] = field(default_factory=list)
# Limits for errors/events kept
max_errors: int = 5
max_events: int = 10
def store_search_results(
self, query: str, results: list[dict], search_type: str = "torrent"
) -> None:
"""
Store search results with index.
Args:
query: The search query
results: List of results
search_type: Type of search (torrent, movie, tvshow)
"""
self.last_search_results = {
"query": query,
"type": search_type,
"timestamp": datetime.now().isoformat(),
"results": [{"index": i + 1, **r} for i, r in enumerate(results)],
}
logger.info(f"Episodic: Stored {len(results)} search results for '{query}'")
def get_result_by_index(self, index: int) -> dict | None:
"""
Get a result by its number (1-indexed).
Args:
index: Result number (1, 2, 3, ...)
Returns:
The result or None if not found
"""
if not self.last_search_results:
logger.warning("Episodic: No search results stored")
return None
for result in self.last_search_results.get("results", []):
if result.get("index") == index:
return result
logger.warning(f"Episodic: Result #{index} not found")
return None
def get_search_results(self) -> dict | None:
"""Get the last search results."""
return self.last_search_results
def clear_search_results(self) -> None:
"""Clear search results."""
self.last_search_results = None
def add_active_download(self, download: dict) -> None:
"""Add an active download."""
download["started_at"] = datetime.now().isoformat()
self.active_downloads.append(download)
logger.info(f"Episodic: Added download '{download.get('name')}'")
def update_download_progress(
self, task_id: str, progress: int, status: str = "downloading"
) -> None:
"""Update download progress."""
for dl in self.active_downloads:
if dl.get("task_id") == task_id:
dl["progress"] = progress
dl["status"] = status
dl["updated_at"] = datetime.now().isoformat()
break
def complete_download(self, task_id: str, file_path: str) -> dict | None:
"""Mark a download as complete and remove it."""
for i, dl in enumerate(self.active_downloads):
if dl.get("task_id") == task_id:
completed = self.active_downloads.pop(i)
completed["status"] = "completed"
completed["file_path"] = file_path
completed["completed_at"] = datetime.now().isoformat()
# Add a background event
self.add_background_event(
"download_complete",
{"name": completed.get("name"), "file_path": file_path},
)
logger.info(f"Episodic: Download completed '{completed.get('name')}'")
return completed
return None
def get_active_downloads(self) -> list[dict]:
"""Get active downloads."""
return self.active_downloads
def add_error(
self, action: str, error: str, context: dict | None = None
) -> None:
"""Record a recent error."""
self.recent_errors.append(
{
"timestamp": datetime.now().isoformat(),
"action": action,
"error": error,
"context": context or {},
}
)
# Keep only the last N errors
self.recent_errors = self.recent_errors[-self.max_errors :]
logger.warning(f"Episodic: Error in '{action}': {error}")
def get_recent_errors(self) -> list[dict]:
"""Get recent errors."""
return self.recent_errors
def set_pending_question(
self,
question: str,
options: list[dict],
context: dict,
question_type: str = "choice",
) -> None:
"""
Record a question awaiting user response.
Args:
question: The question asked
options: List of possible options
context: Question context
question_type: Type of question (choice, confirmation, input)
"""
self.pending_question = {
"type": question_type,
"question": question,
"options": options,
"context": context,
"timestamp": datetime.now().isoformat(),
}
logger.info(f"Episodic: Pending question set ({question_type})")
def get_pending_question(self) -> dict | None:
"""Get the pending question."""
return self.pending_question
def resolve_pending_question(
self, answer_index: int | None = None
) -> dict | None:
"""
Resolve the pending question and return the chosen option.
Args:
answer_index: Answer index (1-indexed) or None to cancel
Returns:
The chosen option or None
"""
if not self.pending_question:
return None
result = None
if answer_index is not None and self.pending_question.get("options"):
for opt in self.pending_question["options"]:
if opt.get("index") == answer_index:
result = opt
break
self.pending_question = None
logger.info("Episodic: Pending question resolved")
return result
def add_background_event(self, event_type: str, data: dict) -> None:
"""Add a background event."""
self.background_events.append(
{
"type": event_type,
"timestamp": datetime.now().isoformat(),
"data": data,
"read": False,
}
)
# Keep only the last N events
self.background_events = self.background_events[-self.max_events :]
logger.info(f"Episodic: Background event '{event_type}'")
def get_unread_events(self) -> list[dict]:
"""Get unread events and mark them as read."""
unread = [e for e in self.background_events if not e.get("read")]
for e in self.background_events:
e["read"] = True
return unread
def clear(self) -> None:
"""Reset episodic memory."""
self.last_search_results = None
self.active_downloads = []
self.recent_errors = []
self.pending_question = None
self.background_events = []
logger.info("Episodic: Cleared")
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"last_search_results": self.last_search_results,
"active_downloads": self.active_downloads,
"recent_errors": self.recent_errors,
"pending_question": self.pending_question,
"background_events": self.background_events,
}
# =============================================================================
# MEMORY MANAGER - Unified manager
# =============================================================================
class Memory:
"""
Generic memory storage for agent state.
Unified manager for the 3 memory types.
Provides a simple key-value store that persists to JSON.
Usage:
memory = Memory("memory_data")
memory.ltm.set_config("download_folder", "/path")
memory.stm.add_message("user", "Hello")
memory.episodic.store_search_results("query", results)
memory.save()
"""
def __init__(self, path: str = "memory.json"):
self.file = Path(path)
self.data: Dict[str, Any] = {}
self.load()
def __init__(self, storage_dir: str = "memory_data"):
"""
Initialize the memory.
def load(self) -> None:
"""Load memory from file or initialize with defaults."""
if self.file.exists():
Args:
storage_dir: Directory for persistent storage
"""
self.storage_dir = Path(storage_dir)
self.storage_dir.mkdir(exist_ok=True)
self.ltm_file = self.storage_dir / "ltm.json"
# Initialize the 3 memory types
self.ltm = self._load_ltm()
self.stm = ShortTermMemory()
self.episodic = EpisodicMemory()
logger.info(f"Memory initialized (storage: {storage_dir})")
def _load_ltm(self) -> LongTermMemory:
"""Load LTM from file."""
if self.ltm_file.exists():
try:
self.data = json.loads(self.file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, IOError) as e:
print(f"Warning: Could not load memory file: {e}")
self.data = {
"config": {},
"tv_shows": [],
"history": [],
}
else:
self.data = {
"config": {},
"tv_shows": [],
"history": [],
}
data = json.loads(self.ltm_file.read_text(encoding="utf-8"))
logger.info("LTM loaded from file")
return LongTermMemory.from_dict(data)
except (OSError, json.JSONDecodeError) as e:
logger.warning(f"Could not load LTM: {e}")
return LongTermMemory()
def save(self) -> None:
self.file.write_text(
json.dumps(self.data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
"""Save LTM (the only persistent memory)."""
try:
self.ltm_file.write_text(
json.dumps(self.ltm.to_dict(), indent=2, ensure_ascii=False),
encoding="utf-8",
)
logger.debug("LTM saved to file")
except OSError as e:
logger.error(f"Failed to save LTM: {e}")
raise
def get(self, key: str, default: Any = None) -> Any:
"""Get a value from memory by key."""
return self.data.get(key, default)
def get_context_for_prompt(self) -> dict:
"""
Generate context to include in the system prompt.
def set(self, key: str, value: Any) -> None:
Returns:
Dictionary with relevant context from all 3 memories
"""
Set a value in memory and save.
Validates the value against the parameter schema if one exists.
"""
# Validate if schema exists
is_valid, error_msg = validate_parameter(key, value)
if not is_valid:
print(f'Validation failed for {key}: {error_msg}')
raise ValueError(f"Invalid value for {key}: {error_msg}")
print(f'Setting {key} in memory to: {value}')
self.data[key] = value
self.save()
return {
"config": self.ltm.config,
"preferences": self.ltm.preferences,
"current_workflow": self.stm.current_workflow,
"current_topic": self.stm.current_topic,
"extracted_entities": self.stm.extracted_entities,
"last_search": {
"query": (
self.episodic.last_search_results.get("query")
if self.episodic.last_search_results
else None
),
"result_count": (
len(self.episodic.last_search_results.get("results", []))
if self.episodic.last_search_results
else 0
),
},
"active_downloads_count": len(self.episodic.active_downloads),
"pending_question": self.episodic.pending_question is not None,
"unread_events": len(
[e for e in self.episodic.background_events if not e.get("read")]
),
}
def has(self, key: str) -> bool:
"""Check if a key exists and has a non-None value."""
return key in self.data and self.data[key] is not None
def append_history(self, role: str, content: str) -> None:
"""
Append a message to conversation history.
Args:
role: Message role ('user' or 'assistant')
content: Message content
"""
if "history" not in self.data:
self.data["history"] = []
self.data["history"].append({
"role": role,
"content": content
})
self.save()
def get_full_state(self) -> dict:
"""Return the full state of all 3 memories (for debug)."""
return {
"ltm": self.ltm.to_dict(),
"stm": self.stm.to_dict(),
"episodic": self.episodic.to_dict(),
}
def clear_session(self) -> None:
"""Clear session memories (STM + Episodic)."""
self.stm.clear()
self.episodic.clear()
logger.info("Session memories cleared")