# @psyche/cache

Cache library for Psyche Python services, aligned with `@oshun/cache`.

## Features

- **In-Memory Cache** - LRU cache with TTL support
- **Redis Cache** - Distributed caching with Redis
- **Key Building** - Consistent key generation and namespacing
- **Pub/Sub** - Real-time state synchronization
- **Distributed Locking** - Redlock-compatible locking
- **TTL Presets** - Standard TTL values for common use cases

## Installation

```bash
# Core package (memory cache only)
poetry add psyche-cache

# With Redis support
poetry add "psyche-cache[redis]"

# With all extras
poetry add "psyche-cache[all]"
```

## Quick Start

### In-Memory Cache

```python
from psyche_cache import MemoryCache, TTL

# Create cache
cache = MemoryCache()

# Set with TTL
cache.set("key", {"data": "value"}, ttl_ms=TTL.MEDIUM)  # 5 minutes

# Get value
value = cache.get("key")

# Check existence
if cache.has("key"):
    print("Key exists")

# Delete
cache.delete("key")

# Get statistics
stats = cache.stats()
print(f"Hit rate: {stats.hit_rate:.2%}")
```

### Redis Cache

```python
from psyche_cache import (
    create_redis_cache_client,
    persona_key,
    SetOptions,
    TTL,
)

# Create client
client = await create_redis_cache_client()

# Set value
await client.set(
    persona_key("persona-1", "config"),
    {"name": "Support AI", "role": "assistant"},
    SetOptions(ttl=TTL.LONG),
)

# Get value
config = await client.get(persona_key("persona-1", "config"))

# Batch operations
values = await client.mget("key1", "key2", "key3")
await client.mset([
    {"key": "a", "value": 1, "ttl": TTL.SHORT},
    {"key": "b", "value": 2, "ttl": TTL.SHORT},
])

# Counters
await client.increment("page:views")
count = await client.decrement("inventory:item-1")

# Health check
health = await client.check_health()
print(f"Status: {health.status}, Latency: {health.latency_ms}ms")

await client.close()
```

### Key Building

```python
from psyche_cache import (
    cache_key,
    user_key,
    session_key,
    persona_key,
    avatar_key,
    memory_key,
    key_pattern,
    matches_pattern,
)

# Basic key
key = cache_key("user", "123", "profile")  # "user:123:profile"

# Domain-specific keys
key = user_key("123", "preferences")       # "user:123:preferences"
key = session_key("abc", "state")          # "session:abc:state"
key = persona_key("p1", "config")          # "persona:p1:config"
key = avatar_key("s1", "position")         # "avatar:s1:position"
key = memory_key("u1", "p1", "recent")     # "memory:u1:p1:recent"

# Patterns for scanning
pattern = key_pattern("user", "123")       # "user:123:*"
matches = matches_pattern("user:123:profile", pattern)  # True
```

### TTL Presets

```python
from psyche_cache import TTL, DomainTTL

# Standard TTLs (milliseconds)
TTL.VERY_SHORT  # 30 seconds
TTL.SHORT       # 1 minute
TTL.MEDIUM      # 5 minutes
TTL.LONG        # 15 minutes
TTL.VERY_LONG   # 1 hour
TTL.DAY         # 24 hours
TTL.WEEK        # 7 days

# Domain-specific TTLs
DomainTTL.SESSION          # 1 hour
DomainTTL.PERSONA_CONFIG   # 30 minutes
DomainTTL.AVATAR_STATE     # 1 minute
DomainTTL.VOICE_STATE      # 30 seconds
DomainTTL.MEMORY_CACHE     # 15 minutes
DomainTTL.EMBEDDING_CACHE  # 1 hour
```

### Pub/Sub (Real-time State Sync)

```python
from psyche_cache import (
    create_pubsub_client,
    publish_avatar_state,
    subscribe_avatar_state,
    PubSubChannel,
)

# Create client
pubsub = await create_pubsub_client()

# Subscribe to avatar state updates
async def on_avatar_state(state, channel):
    print(f"Avatar state: {state}")

sub = await subscribe_avatar_state(pubsub, "session-123", on_avatar_state)

# Publish avatar state
await publish_avatar_state(pubsub, "session-123", {
    "position": {"x": 0, "y": 0, "z": 0},
    "animation": "idle",
    "speaking": False,
})

# Custom channels
await pubsub.subscribe("custom:channel", handler)
await pubsub.publish("custom:channel", {"event": "data"})

# Pattern subscription
await pubsub.psubscribe("events:*", handler)

# Cleanup
await sub.unsubscribe()
await pubsub.close()
```

### Distributed Locking

```python
from psyche_cache import (
    create_lock_manager,
    create_redis_cache_client,
    with_lock,
    LockConfig,
)

# Create manager
redis = await create_redis_cache_client()
lock_manager = await create_lock_manager(redis)

# Explicit lock/unlock
lock = await lock_manager.acquire("resource:123")
try:
    # Critical section
    pass
finally:
    await lock.release()

# Context manager
async with with_lock(lock_manager, "resource:456"):
    # Critical section
    pass

# With callback
result = await lock_manager.with_lock(
    "resource:789",
    async_function,
    LockConfig(ttl_ms=60000),
)

# Non-blocking try
lock = await lock_manager.try_acquire("resource:abc")
if lock:
    try:
        # Got the lock
        pass
    finally:
        await lock.release()
else:
    print("Resource is locked")

# Custom config
lock = await lock_manager.acquire(
    "resource",
    LockConfig(
        ttl_ms=30000,
        retry_interval_ms=100,
        max_retries=20,
        auto_extend=True,
    ),
)
```

## Configuration

### Environment Variables

```bash
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=secret
REDIS_DB=0
REDIS_TLS=false
REDIS_KEY_PREFIX=psyche:

# Pub/Sub
PUBSUB_CHANNEL_PREFIX=psyche:
```

### From Environment

```python
from psyche_cache import (
    create_redis_cache_client_from_env,
    create_pubsub_client_from_env,
)

# Uses REDIS_* environment variables
redis = await create_redis_cache_client_from_env()
pubsub = await create_pubsub_client_from_env()
```

### From URL

```python
from psyche_cache import create_redis_cache_client_from_url

redis = await create_redis_cache_client_from_url(
    "redis://user:pass@localhost:6379/0"
)
```

## Psyche-Specific Caching

### Session State Caching

```python
from psyche_cache import (
    RedisCacheClient,
    session_key,
    DomainTTL,
    SetOptions,
)

class SessionCache:
    def __init__(self, redis: RedisCacheClient):
        self._redis = redis

    async def get_state(self, session_id: str) -> dict | None:
        return await self._redis.get(session_key(session_id, "state"))

    async def set_state(self, session_id: str, state: dict) -> None:
        await self._redis.set(
            session_key(session_id, "state"),
            state,
            SetOptions(ttl=DomainTTL.SESSION),
        )

    async def update_activity(self, session_id: str) -> None:
        await self._redis.expire(
            session_key(session_id, "state"),
            DomainTTL.SESSION,
        )
```

### Memory Tier Caching

```python
from psyche_cache import (
    MemoryCache,
    RedisCacheClient,
    memory_key,
    DomainTTL,
)

class MemoryTierCache:
    """Two-tier cache: memory (L1) + Redis (L2)."""

    def __init__(self, l1: MemoryCache, l2: RedisCacheClient):
        self._l1 = l1
        self._l2 = l2

    async def get(self, user_id: str, persona_id: str) -> list | None:
        key = memory_key(user_id, persona_id)

        # Check L1
        memories = self._l1.get(key)
        if memories is not None:
            return memories

        # Check L2
        memories = await self._l2.get(key)
        if memories is not None:
            # Populate L1
            self._l1.set(key, memories, ttl_ms=DomainTTL.MEMORY_CACHE)
            return memories

        return None

    async def set(
        self,
        user_id: str,
        persona_id: str,
        memories: list,
    ) -> None:
        key = memory_key(user_id, persona_id)

        # Write to both tiers
        self._l1.set(key, memories, ttl_ms=DomainTTL.MEMORY_CACHE)
        await self._l2.set(key, memories, SetOptions(ttl=DomainTTL.MEMORY_CACHE))
```

### Avatar State Caching

```python
from psyche_cache import (
    RedisCacheClient,
    PubSubClient,
    avatar_key,
    publish_avatar_state,
    DomainTTL,
    SetOptions,
)

class AvatarStateCache:
    def __init__(self, redis: RedisCacheClient, pubsub: PubSubClient):
        self._redis = redis
        self._pubsub = pubsub

    async def get_state(self, session_id: str) -> dict | None:
        return await self._redis.get(avatar_key(session_id, "state"))

    async def update_state(self, session_id: str, state: dict) -> None:
        # Cache state
        await self._redis.set(
            avatar_key(session_id, "state"),
            state,
            SetOptions(ttl=DomainTTL.AVATAR_STATE),
        )

        # Broadcast to subscribers
        await publish_avatar_state(self._pubsub, session_id, state)
```

## Alignment with @oshun/cache

| TypeScript     | Python             |
| -------------- | ------------------ |
| `CacheClient`  | `RedisCacheClient` |
| `MemoryCache`  | `MemoryCache`      |
| `cacheKey()`   | `cache_key()`      |
| `userKey()`    | `user_key()`       |
| `sessionKey()` | `session_key()`    |
| `TTL.MEDIUM`   | `TTL.MEDIUM`       |
| `PubSubClient` | `PubSubClient`     |
| `LockManager`  | `LockManager`      |
| `withLock()`   | `with_lock()`      |

## Nx Targets

| Target         | Description             |
| -------------- | ----------------------- |
| `build`        | Build the package       |
| `install`      | Install dependencies    |
| `lint`         | Run Ruff linter         |
| `lint-fix`     | Fix linting issues      |
| `format`       | Format with Black       |
| `format-check` | Check formatting        |
| `typecheck`    | Run MyPy                |
| `test`         | Run pytest              |
| `test-cov`     | Run tests with coverage |

## License

Proprietary - Oshun Platform
