Spaces:
Running
Running
RemanenetSpy
fix: move CREATE INDEX statements after ALTER TABLE migration in memory_store schema to prevent UndefinedColumnError on startup
3484a98 | """ | |
| KAAL — Memory Store | |
| ========================== | |
| Dual-calendar PostgreSQL storage engine backed by Neon. | |
| • Event Calendar — structured SVO events with temporal indexing | |
| • Turn Calendar — raw conversation turns for full context | |
| Implements the Chronos research paper's dual-calendar architecture | |
| for temporal-aware retrieval and multi-hop reasoning. | |
| Uses asyncpg connection pooling for high concurrency on Render. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import os | |
| from datetime import datetime | |
| from typing import Optional | |
| import asyncpg | |
| from .models import ( | |
| ConnectorRecord, | |
| EventRecord, | |
| TurnRecord, | |
| TurnRole, | |
| UsageStats, | |
| TierName, | |
| ) | |
| logger = logging.getLogger("chronos.memory_store") | |
| # --------------------------------------------------------------------------- | |
| # Schema | |
| # --------------------------------------------------------------------------- | |
| _SCHEMA_SQL = """ | |
| CREATE EXTENSION IF NOT EXISTS vector; | |
| -- Event Calendar: structured SVO events | |
| CREATE TABLE IF NOT EXISTS events ( | |
| id TEXT PRIMARY KEY, | |
| source_id TEXT NOT NULL, | |
| subject TEXT NOT NULL, | |
| verb TEXT NOT NULL, | |
| object TEXT NOT NULL, | |
| timestamp TIMESTAMPTZ NOT NULL, | |
| datetime_start TIMESTAMPTZ, | |
| datetime_end TIMESTAMPTZ, | |
| entity_aliases JSONB DEFAULT '[]', | |
| confidence REAL DEFAULT 1.0, | |
| metadata_json JSONB DEFAULT '{}', | |
| raw_text TEXT DEFAULT '', | |
| created_at TIMESTAMPTZ NOT NULL, | |
| -- Scope & Bi-temporal validity | |
| scope TEXT NOT NULL DEFAULT 'default', | |
| valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| valid_to TIMESTAMPTZ, | |
| superseded_by TEXT | |
| ); | |
| -- Migration: add new columns if upgrading from an older schema | |
| DO $$ BEGIN | |
| ALTER TABLE events ADD COLUMN IF NOT EXISTS scope TEXT NOT NULL DEFAULT 'default'; | |
| ALTER TABLE events ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(); | |
| ALTER TABLE events ADD COLUMN IF NOT EXISTS valid_to TIMESTAMPTZ; | |
| ALTER TABLE events ADD COLUMN IF NOT EXISTS superseded_by TEXT; | |
| EXCEPTION WHEN others THEN NULL; | |
| END $$; | |
| CREATE INDEX IF NOT EXISTS idx_events_source ON events(source_id); | |
| CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp DESC); | |
| CREATE INDEX IF NOT EXISTS idx_events_subject ON events(subject); | |
| CREATE INDEX IF NOT EXISTS idx_events_verb ON events(verb); | |
| CREATE INDEX IF NOT EXISTS idx_events_scope ON events(scope); | |
| CREATE INDEX IF NOT EXISTS idx_events_active ON events(valid_to) WHERE valid_to IS NULL; | |
| -- Turn Calendar: raw conversation turns | |
| CREATE TABLE IF NOT EXISTS turns ( | |
| id TEXT PRIMARY KEY, | |
| source_id TEXT NOT NULL, | |
| role TEXT NOT NULL DEFAULT 'user', | |
| content TEXT NOT NULL, | |
| timestamp TIMESTAMPTZ NOT NULL, | |
| event_ids JSONB DEFAULT '[]', | |
| created_at TIMESTAMPTZ NOT NULL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_turns_source ON turns(source_id); | |
| CREATE INDEX IF NOT EXISTS idx_turns_timestamp ON turns(timestamp DESC); | |
| -- Connectors: registered SaaS tools | |
| CREATE TABLE IF NOT EXISTS connectors ( | |
| id TEXT PRIMARY KEY, | |
| source_id TEXT NOT NULL, | |
| name TEXT NOT NULL, | |
| description TEXT DEFAULT '', | |
| base_url TEXT NOT NULL, | |
| auth_header TEXT, | |
| endpoints JSONB DEFAULT '[]', | |
| metadata JSONB DEFAULT '{}', | |
| created_at TIMESTAMPTZ NOT NULL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_connectors_source ON connectors(source_id); | |
| -- API Keys & Usage | |
| CREATE TABLE IF NOT EXISTS api_keys ( | |
| key_hash TEXT PRIMARY KEY, | |
| source_id TEXT NOT NULL UNIQUE, | |
| tier TEXT NOT NULL DEFAULT 'explorer', | |
| events_used INTEGER DEFAULT 0, | |
| orchestration_used INTEGER DEFAULT 0, | |
| connectors_used INTEGER DEFAULT 0, | |
| period_start TIMESTAMPTZ NOT NULL, | |
| created_at TIMESTAMPTZ NOT NULL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_apikeys_source ON api_keys(source_id); | |
| """ | |
| class MemoryStore: | |
| """ | |
| Async PostgreSQL dual-calendar memory store using asyncpg connection pool. | |
| Backed by Neon — data persists across all redeploys. | |
| """ | |
| def __init__(self, dsn: Optional[str] = None): | |
| self.dsn = dsn or os.getenv("DATABASE_URL") | |
| if not self.dsn: | |
| raise RuntimeError("DATABASE_URL environment variable is not set.") | |
| self._pool: Optional[asyncpg.Pool] = None | |
| # ------------------------------------------------------------------ | |
| # Lifecycle | |
| # ------------------------------------------------------------------ | |
| async def initialize(self) -> None: | |
| """Create connection pool and apply schema migrations.""" | |
| self._pool = await asyncpg.create_pool( | |
| self.dsn, | |
| min_size=2, | |
| max_size=10, | |
| command_timeout=60, | |
| ) | |
| async with self._pool.acquire() as conn: | |
| await conn.execute(_SCHEMA_SQL) | |
| logger.info("Memory store initialized (Neon PostgreSQL)") | |
| async def close(self) -> None: | |
| """Close the connection pool.""" | |
| if self._pool: | |
| await self._pool.close() | |
| self._pool = None | |
| def pool(self) -> asyncpg.Pool: | |
| if not self._pool: | |
| raise RuntimeError("MemoryStore not initialized. Call initialize() first.") | |
| return self._pool | |
| # ------------------------------------------------------------------ | |
| # Event Calendar | |
| # ------------------------------------------------------------------ | |
| async def insert_event(self, event: EventRecord) -> str: | |
| """Insert a structured SVO event into the Event Calendar.""" | |
| async with self.pool.acquire() as conn: | |
| await conn.execute( | |
| """ | |
| INSERT INTO events | |
| (id, source_id, subject, verb, object, timestamp, | |
| datetime_start, datetime_end, entity_aliases, | |
| confidence, metadata_json, raw_text, created_at, | |
| scope, valid_from, valid_to, superseded_by) | |
| VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) | |
| ON CONFLICT (id) DO NOTHING | |
| """, | |
| event.id, event.source_id, event.subject, event.verb, | |
| event.object, event.timestamp, event.datetime_start, | |
| event.datetime_end, json.dumps(event.entity_aliases), | |
| event.confidence, json.dumps(event.metadata_json), | |
| event.raw_text, event.created_at, | |
| event.scope, event.valid_from, event.valid_to, event.superseded_by, | |
| ) | |
| return event.id | |
| async def insert_events_batch(self, events: list[EventRecord]) -> list[str]: | |
| """Batch insert multiple events using executemany — fastest possible.""" | |
| if not events: | |
| return [] | |
| rows = [ | |
| ( | |
| e.id, e.source_id, e.subject, e.verb, e.object, | |
| e.timestamp, e.datetime_start, e.datetime_end, | |
| json.dumps(e.entity_aliases), e.confidence, | |
| json.dumps(e.metadata_json), e.raw_text, e.created_at, | |
| e.scope, e.valid_from, e.valid_to, e.superseded_by, | |
| ) | |
| for e in events | |
| ] | |
| async with self.pool.acquire() as conn: | |
| await conn.executemany( | |
| """ | |
| INSERT INTO events | |
| (id, source_id, subject, verb, object, timestamp, | |
| datetime_start, datetime_end, entity_aliases, | |
| confidence, metadata_json, raw_text, created_at, | |
| scope, valid_from, valid_to, superseded_by) | |
| VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) | |
| ON CONFLICT (id) DO NOTHING | |
| """, | |
| rows, | |
| ) | |
| return [e.id for e in events] | |
| async def query_temporal( | |
| self, | |
| start: Optional[datetime] = None, | |
| end: Optional[datetime] = None, | |
| source_ids: Optional[list[str]] = None, | |
| scope: Optional[str] = None, | |
| limit: int = 50, | |
| ) -> list[EventRecord]: | |
| """Query ACTIVE (non-superseded) events by time range.""" | |
| conditions: list[str] = ["valid_to IS NULL"] # active facts only | |
| params: list = [] | |
| i = 1 | |
| if start: | |
| conditions.append(f"timestamp >= ${i}"); params.append(start); i += 1 | |
| if end: | |
| conditions.append(f"timestamp <= ${i}"); params.append(end); i += 1 | |
| if source_ids: | |
| conditions.append(f"source_id = ANY(${i})"); params.append(source_ids); i += 1 | |
| if scope: | |
| conditions.append(f"scope = ${i}"); params.append(scope); i += 1 | |
| where = f"WHERE {' AND '.join(conditions)}" | |
| params.append(limit) | |
| query = f"SELECT * FROM events {where} ORDER BY timestamp DESC LIMIT ${i}" | |
| async with self.pool.acquire() as conn: | |
| rows = await conn.fetch(query, *params) | |
| return [self._row_to_event(r) for r in rows] | |
| async def query_by_entity( | |
| self, | |
| entity: str, | |
| source_ids: Optional[list[str]] = None, | |
| scope: Optional[str] = None, | |
| limit: int = 50, | |
| ) -> list[EventRecord]: | |
| """Find ACTIVE events involving a specific entity (subject or object).""" | |
| pattern = f"%{entity}%" | |
| conditions = [ | |
| "valid_to IS NULL", | |
| "(subject ILIKE $1 OR object ILIKE $1 OR entity_aliases::text ILIKE $1)", | |
| ] | |
| params: list = [pattern] | |
| i = 2 | |
| if source_ids: | |
| conditions.append(f"source_id = ANY(${i})"); params.append(source_ids); i += 1 | |
| if scope: | |
| conditions.append(f"scope = ${i}"); params.append(scope); i += 1 | |
| params.append(limit) | |
| where = f"WHERE {' AND '.join(conditions)}" | |
| query = f"SELECT * FROM events {where} ORDER BY timestamp DESC LIMIT ${i}" | |
| async with self.pool.acquire() as conn: | |
| rows = await conn.fetch(query, *params) | |
| return [self._row_to_event(r) for r in rows] | |
| async def multi_hop_query( | |
| self, | |
| entities: list[str], | |
| start: Optional[datetime] = None, | |
| end: Optional[datetime] = None, | |
| source_ids: Optional[list[str]] = None, | |
| scope: Optional[str] = None, | |
| limit: int = 50, | |
| ) -> list[EventRecord]: | |
| """ | |
| Multi-hop temporal query: find ACTIVE events connecting multiple entities. | |
| """ | |
| entity_parts, params = [], [] | |
| i = 1 | |
| for entity in entities: | |
| pattern = f"%{entity}%" | |
| entity_parts.append( | |
| f"(subject ILIKE ${i} OR object ILIKE ${i} OR entity_aliases::text ILIKE ${i})" | |
| ) | |
| params.append(pattern); i += 1 | |
| conditions = ["valid_to IS NULL", f"({' OR '.join(entity_parts)})"] | |
| if start: | |
| conditions.append(f"timestamp >= ${i}"); params.append(start); i += 1 | |
| if end: | |
| conditions.append(f"timestamp <= ${i}"); params.append(end); i += 1 | |
| if source_ids: | |
| conditions.append(f"source_id = ANY(${i})"); params.append(source_ids); i += 1 | |
| if scope: | |
| conditions.append(f"scope = ${i}"); params.append(scope); i += 1 | |
| params.append(limit) | |
| where = f"WHERE {' AND '.join(conditions)}" | |
| query = f"SELECT * FROM events {where} ORDER BY timestamp ASC LIMIT ${i}" | |
| async with self.pool.acquire() as conn: | |
| rows = await conn.fetch(query, *params) | |
| return [self._row_to_event(r) for r in rows] | |
| # ------------------------------------------------------------------ | |
| # Supersession (Bi-temporal validity) | |
| # ------------------------------------------------------------------ | |
| async def find_active_by_subject( | |
| self, | |
| owner_id: str, | |
| scope: str, | |
| subject: str, | |
| limit: int = 10, | |
| ) -> list[EventRecord]: | |
| """ | |
| Efficiently fetch active facts with a matching subject for supersession checks. | |
| Uses the partial index on valid_to IS NULL — very fast even at scale. | |
| """ | |
| pattern = f"%{subject.lower()}%" | |
| async with self.pool.acquire() as conn: | |
| rows = await conn.fetch( | |
| """ | |
| SELECT * FROM events | |
| WHERE valid_to IS NULL | |
| AND scope = $1 | |
| AND metadata_json->>'owner_id' = $2 | |
| AND LOWER(subject) ILIKE $3 | |
| ORDER BY valid_from DESC | |
| LIMIT $4 | |
| """, | |
| scope, owner_id, pattern, limit, | |
| ) | |
| return [self._row_to_event(r) for r in rows] | |
| async def invalidate_event(self, event_id: str, superseded_by: Optional[str] = None) -> None: | |
| """ | |
| Close the validity window of an event (Zep-style bi-temporal invalidation). | |
| Sets valid_to = NOW() rather than deleting — history is preserved. | |
| """ | |
| async with self.pool.acquire() as conn: | |
| await conn.execute( | |
| """ | |
| UPDATE events | |
| SET valid_to = NOW(), superseded_by = $2 | |
| WHERE id = $1 AND valid_to IS NULL | |
| """, | |
| event_id, superseded_by, | |
| ) | |
| async def get_event(self, event_id: str) -> Optional[EventRecord]: | |
| """Fetch a single event by ID.""" | |
| async with self.pool.acquire() as conn: | |
| row = await conn.fetchrow("SELECT * FROM events WHERE id = $1", event_id) | |
| return self._row_to_event(row) if row else None | |
| async def get_events_by_ids(self, event_ids: list[str]) -> list[EventRecord]: | |
| """Fetch multiple events by their IDs.""" | |
| if not event_ids: | |
| return [] | |
| async with self.pool.acquire() as conn: | |
| rows = await conn.fetch( | |
| "SELECT * FROM events WHERE id = ANY($1)", event_ids | |
| ) | |
| return [self._row_to_event(r) for r in rows] | |
| async def count_events(self, source_id: Optional[str] = None) -> int: | |
| """Count total events, optionally filtered by source.""" | |
| async with self.pool.acquire() as conn: | |
| if source_id: | |
| return await conn.fetchval( | |
| "SELECT COUNT(*) FROM events WHERE source_id = $1", source_id | |
| ) | |
| return await conn.fetchval("SELECT COUNT(*) FROM events") | |
| # ------------------------------------------------------------------ | |
| # Turn Calendar | |
| # ------------------------------------------------------------------ | |
| async def insert_turn(self, turn: TurnRecord) -> str: | |
| """Insert a raw conversation turn into the Turn Calendar.""" | |
| async with self.pool.acquire() as conn: | |
| await conn.execute( | |
| """ | |
| INSERT INTO turns (id, source_id, role, content, timestamp, event_ids, created_at) | |
| VALUES ($1,$2,$3,$4,$5,$6,$7) | |
| ON CONFLICT (id) DO NOTHING | |
| """, | |
| turn.id, turn.source_id, turn.role.value, | |
| turn.content, turn.timestamp, | |
| json.dumps(turn.event_ids), turn.created_at, | |
| ) | |
| return turn.id | |
| async def get_turns( | |
| self, | |
| source_id: Optional[str] = None, | |
| start: Optional[datetime] = None, | |
| end: Optional[datetime] = None, | |
| limit: int = 50, | |
| ) -> list[TurnRecord]: | |
| """Query conversation turns.""" | |
| conditions, params = [], [] | |
| i = 1 | |
| if source_id: | |
| conditions.append(f"source_id = ${i}"); params.append(source_id); i += 1 | |
| if start: | |
| conditions.append(f"timestamp >= ${i}"); params.append(start); i += 1 | |
| if end: | |
| conditions.append(f"timestamp <= ${i}"); params.append(end); i += 1 | |
| params.append(limit) | |
| where = f"WHERE {' AND '.join(conditions)}" if conditions else "" | |
| query = f"SELECT * FROM turns {where} ORDER BY timestamp DESC LIMIT ${i}" | |
| async with self.pool.acquire() as conn: | |
| rows = await conn.fetch(query, *params) | |
| return [self._row_to_turn(r) for r in rows] | |
| # ------------------------------------------------------------------ | |
| # Connectors | |
| # ------------------------------------------------------------------ | |
| async def insert_connector(self, connector: ConnectorRecord) -> str: | |
| """Register a SaaS tool connector.""" | |
| async with self.pool.acquire() as conn: | |
| await conn.execute( | |
| """ | |
| INSERT INTO connectors | |
| (id, source_id, name, description, base_url, | |
| auth_header, endpoints, metadata, created_at) | |
| VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) | |
| ON CONFLICT (id) DO UPDATE SET | |
| name=EXCLUDED.name, description=EXCLUDED.description, | |
| base_url=EXCLUDED.base_url, auth_header=EXCLUDED.auth_header, | |
| endpoints=EXCLUDED.endpoints, metadata=EXCLUDED.metadata | |
| """, | |
| connector.id, connector.source_id, connector.name, | |
| connector.description, connector.base_url, connector.auth_header, | |
| json.dumps([ep.model_dump() for ep in connector.endpoints]), | |
| json.dumps(connector.metadata), connector.created_at, | |
| ) | |
| return connector.id | |
| async def get_connectors(self, source_id: Optional[str] = None) -> list[ConnectorRecord]: | |
| """List registered connectors.""" | |
| async with self.pool.acquire() as conn: | |
| if source_id: | |
| rows = await conn.fetch( | |
| "SELECT * FROM connectors WHERE source_id = $1", source_id | |
| ) | |
| else: | |
| rows = await conn.fetch("SELECT * FROM connectors") | |
| return [self._row_to_connector(r) for r in rows] | |
| async def get_connector(self, connector_id: str) -> Optional[ConnectorRecord]: | |
| """Fetch a single connector by ID.""" | |
| async with self.pool.acquire() as conn: | |
| row = await conn.fetchrow( | |
| "SELECT * FROM connectors WHERE id = $1", connector_id | |
| ) | |
| return self._row_to_connector(row) if row else None | |
| async def count_connectors(self, source_id: str) -> int: | |
| """Count connectors for a source.""" | |
| async with self.pool.acquire() as conn: | |
| return await conn.fetchval( | |
| "SELECT COUNT(*) FROM connectors WHERE source_id = $1", source_id | |
| ) | |
| # ------------------------------------------------------------------ | |
| # API Keys & Usage | |
| # ------------------------------------------------------------------ | |
| async def register_api_key( | |
| self, key_hash: str, source_id: str, tier: TierName = TierName.EXPLORER, | |
| ) -> None: | |
| """Register a new API key.""" | |
| now = datetime.utcnow() | |
| async with self.pool.acquire() as conn: | |
| await conn.execute( | |
| """ | |
| INSERT INTO api_keys | |
| (key_hash, source_id, tier, events_used, orchestration_used, | |
| connectors_used, period_start, created_at) | |
| VALUES ($1,$2,$3,0,0,0,$4,$4) | |
| ON CONFLICT (key_hash) DO NOTHING | |
| """, | |
| key_hash, source_id, tier.value, now, | |
| ) | |
| async def get_usage(self, source_id: str) -> Optional[UsageStats]: | |
| """Get usage stats for a source.""" | |
| async with self.pool.acquire() as conn: | |
| row = await conn.fetchrow( | |
| "SELECT * FROM api_keys WHERE source_id = $1", source_id | |
| ) | |
| if not row: | |
| return None | |
| return UsageStats( | |
| source_id=row["source_id"], | |
| tier=TierName(row["tier"]), | |
| events_used=row["events_used"], | |
| orchestration_used=row["orchestration_used"], | |
| connectors_used=row["connectors_used"], | |
| period_start=row["period_start"], | |
| ) | |
| async def increment_usage( | |
| self, source_id: str, events: int = 0, orchestration: int = 0, | |
| ) -> None: | |
| """Increment usage counters for a source.""" | |
| async with self.pool.acquire() as conn: | |
| await conn.execute( | |
| """ | |
| UPDATE api_keys SET | |
| events_used = events_used + $1, | |
| orchestration_used = orchestration_used + $2 | |
| WHERE source_id = $3 | |
| """, | |
| events, orchestration, source_id, | |
| ) | |
| async def validate_api_key(self, key_hash: str) -> Optional[dict]: | |
| """Validate an API key and return source info.""" | |
| async with self.pool.acquire() as conn: | |
| row = await conn.fetchrow( | |
| "SELECT * FROM api_keys WHERE key_hash = $1", key_hash | |
| ) | |
| return dict(row) if row else None | |
| # ------------------------------------------------------------------ | |
| # Row Converters | |
| # ------------------------------------------------------------------ | |
| def _row_to_event(row) -> EventRecord: | |
| return EventRecord( | |
| id=row["id"], | |
| source_id=row["source_id"], | |
| subject=row["subject"], | |
| verb=row["verb"], | |
| object=row["object"], | |
| timestamp=row["timestamp"], | |
| datetime_start=row["datetime_start"], | |
| datetime_end=row["datetime_end"], | |
| entity_aliases=json.loads(row["entity_aliases"]) if isinstance(row["entity_aliases"], str) else (row["entity_aliases"] or []), | |
| confidence=row["confidence"], | |
| metadata_json=json.loads(row["metadata_json"]) if isinstance(row["metadata_json"], str) else (row["metadata_json"] or {}), | |
| raw_text=row["raw_text"] or "", | |
| created_at=row["created_at"], | |
| scope=row["scope"] if "scope" in row.keys() else "default", | |
| valid_from=row["valid_from"] if "valid_from" in row.keys() else row["created_at"], | |
| valid_to=row["valid_to"] if "valid_to" in row.keys() else None, | |
| superseded_by=row["superseded_by"] if "superseded_by" in row.keys() else None, | |
| ) | |
| def _row_to_turn(row) -> TurnRecord: | |
| return TurnRecord( | |
| id=row["id"], | |
| source_id=row["source_id"], | |
| role=TurnRole(row["role"]), | |
| content=row["content"], | |
| timestamp=row["timestamp"], | |
| event_ids=json.loads(row["event_ids"]) if isinstance(row["event_ids"], str) else (row["event_ids"] or []), | |
| created_at=row["created_at"], | |
| ) | |
| def _row_to_connector(row) -> ConnectorRecord: | |
| from .models import ConnectorEndpoint | |
| endpoints_raw = json.loads(row["endpoints"]) if isinstance(row["endpoints"], str) else (row["endpoints"] or []) | |
| return ConnectorRecord( | |
| id=row["id"], | |
| source_id=row["source_id"], | |
| name=row["name"], | |
| description=row["description"] or "", | |
| base_url=row["base_url"], | |
| auth_header=row["auth_header"], | |
| endpoints=[ConnectorEndpoint(**ep) for ep in endpoints_raw], | |
| metadata=json.loads(row["metadata"]) if isinstance(row["metadata"], str) else (row["metadata"] or {}), | |
| created_at=row["created_at"], | |
| ) | |