from collections.abc import Sequence from datetime import datetime from logging import getLogger from typing import Any from nanoid import generate as generate_nanoid from sqlalchemy import ColumnElement, Select, and_, func, or_, select, text, update from sqlalchemy.ext.asyncio import AsyncSession from src import models, schemas from src.config import settings from src.dependencies import tracked_db from src.embedding_client import embedding_client from src.exceptions import VectorStoreError from src.utils.filter import apply_filter from src.utils.formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern from src.vector_store import VectorRecord, get_external_vector_store from .session import get_or_create_session logger = getLogger(__name__) def _deduplicate_messages( messages: Sequence[models.Message], limit: int ) -> list[models.Message]: """Deduplicate messages by public_id, preserving input order.""" seen: set[str] = set() result: list[models.Message] = [] for msg in messages: if msg.public_id not in seen: seen.add(msg.public_id) result.append(msg) if len(result) >= limit: break return result def _expunge_snippets( db: AsyncSession, snippets: list[tuple[list[models.Message], list[models.Message]]] ) -> None: """Detach snippet messages from the session, guarding against duplicates.""" seen: set[int] = set() for matches, context in snippets: for msg in [*matches, *context]: obj_id = id(msg) if obj_id in seen: continue db.expunge(msg) seen.add(obj_id) async def get_peer_session_names( db: AsyncSession, workspace_name: str, peer_name: str, ) -> list[str]: """Get all session names where a peer has any membership record. Any membership record (regardless of joined_at/left_at) grants visibility to all messages in that session. """ stmt = ( select(models.session_peers_table.c.session_name) .where(models.session_peers_table.c.workspace_name == workspace_name) .where(models.session_peers_table.c.peer_name == peer_name) .distinct() ) result = await db.execute(stmt) return [row[0] for row in result.all()] def _apply_token_limit( base_conditions: list[ColumnElement[Any]], token_limit: int ) -> Select[tuple[models.Message]]: """ Helper function to apply token limit logic to a message query. Creates a subquery that calculates running sum of tokens for most recent messages and returns a select statement that joins with this subquery to limit results based on token count. Args: base_conditions: List of conditions to apply to the base query token_limit: Maximum number of tokens to include in the messages Returns: Select statement with token limit applied """ # Create a subquery that calculates running sum of tokens for most recent messages token_subquery = ( select( models.Message.id, func.sum(models.Message.token_count) .over(order_by=models.Message.id.desc()) .label("running_token_sum"), ) .where(*base_conditions) .subquery() ) # Select Message objects where running sum doesn't exceed token_limit return ( select(models.Message) .join(token_subquery, models.Message.id == token_subquery.c.id) .where(token_subquery.c.running_token_sum <= token_limit) ) async def _build_merged_snippets( db: AsyncSession, workspace_name: str, matched_messages: list[models.Message], context_window: int, ) -> list[tuple[list[models.Message], list[models.Message]]]: """ Group matched messages by session, merge overlapping context ranges, and fetch context. Takes a list of matched messages and builds conversation snippets by: 1. Grouping matches by session name 2. Sorting matches within each session by sequence number 3. Merging overlapping context windows to avoid duplicate context 4. Fetching the full context for each merged range from the database Args: db: Database session workspace_name: Name of the workspace matched_messages: List of messages that matched a search query context_window: Number of messages before/after each match to include Returns: List of tuples: (matched_messages_in_range, context_messages) Each tuple represents a snippet where context_messages includes all messages in the merged range (including the matched messages), ordered chronologically. """ if not matched_messages: return [] session_matches: dict[str, list[models.Message]] = {} for msg in matched_messages: session_matches.setdefault(msg.session_name, []).append(msg) # Build merged ranges per session, then issue a single batched query session_ranges: dict[str, list[tuple[int, int, list[models.Message]]]] = {} for sess_name, matches in session_matches.items(): matches.sort(key=lambda m: m.seq_in_session) merged_ranges: list[tuple[int, int, list[models.Message]]] = [] for match in matches: start = match.seq_in_session - context_window end = match.seq_in_session + context_window if merged_ranges and start <= merged_ranges[-1][1] + 1: prev_start, prev_end, prev_matches = merged_ranges[-1] merged_ranges[-1] = ( prev_start, max(prev_end, end), [*prev_matches, match], ) else: merged_ranges.append((start, end, [match])) session_ranges[sess_name] = merged_ranges # One OR-of-ANDs predicate covers every (session, range) pair session_predicates = [ and_( models.Message.session_name == sess_name, or_( *( models.Message.seq_in_session.between(start_seq, end_seq) for start_seq, end_seq, _ in merged_ranges ) ), ) for sess_name, merged_ranges in session_ranges.items() ] context_stmt = ( select(models.Message) .where(models.Message.workspace_name == workspace_name) .where(or_(*session_predicates)) .order_by( models.Message.session_name.asc(), models.Message.seq_in_session.asc(), ) ) context_result = await db.execute(context_stmt) by_session: dict[str, list[models.Message]] = {} for msg in context_result.scalars().all(): by_session.setdefault(msg.session_name, []).append(msg) snippets: list[ tuple[list[models.Message], list[models.Message]] ] = [] # list of tuples, each containing query matches and context messages for sess_name, merged_ranges in session_ranges.items(): all_context_messages = by_session.get(sess_name, []) for start_seq, end_seq, range_matches in merged_ranges: context_messages = [ msg for msg in all_context_messages if start_seq <= msg.seq_in_session <= end_seq ] snippets.append((range_matches, context_messages)) return snippets async def create_messages( db: AsyncSession, messages: list[schemas.MessageCreate], workspace_name: str, session_name: str, ) -> list[models.Message]: """ Bulk create messages for a session while maintaining order. Args: db: Database session messages: List of messages to create workspace_name: Name of the workspace session_name: Name of the session to create messages in Returns: List of created message objects """ # Get or create session with peers in messages list peers = {message.peer_name: schemas.SessionPeerConfig() for message in messages} await get_or_create_session( db, session=schemas.SessionCreate(name=session_name, peers=peers), workspace_name=workspace_name, ) await db.execute(text("SET LOCAL lock_timeout = '5s'")) await db.execute( text( "SELECT pg_advisory_xact_lock(hashtext(:workspace_name), hashtext(:session_name))" ), {"workspace_name": workspace_name, "session_name": session_name}, ) # Get the last sequence number on a session - uses (workspace_name, session_name, seq_in_session) index last_seq = ( await db.scalar( select(models.Message.seq_in_session) .where( models.Message.workspace_name == workspace_name, models.Message.session_name == session_name, ) .order_by(models.Message.seq_in_session.desc()) .limit(1) ) or 0 ) # Create list of message objects (this will trigger the before_insert event) message_objects: list[models.Message] = [] for offset, message in enumerate(messages, start=1): message_seq_in_session = last_seq + offset message_obj = models.Message( session_name=session_name, peer_name=message.peer_name, content=message.content, h_metadata=message.metadata or {}, workspace_name=workspace_name, public_id=generate_nanoid(), token_count=len(message.encoded_message), created_at=message.created_at, # Use provided created_at if available seq_in_session=message_seq_in_session, ) message_objects.append(message_obj) db.add_all(message_objects) # Commit here to release the advisory lock before generating embeddings await db.commit() try: if settings.EMBED_MESSAGES: encoded_message_lookup = { msg.public_id: orig_msg.encoded_message for msg, orig_msg in zip(message_objects, messages, strict=True) } id_resource_dict = { message.public_id: ( message.content, encoded_message_lookup[message.public_id], ) for message in message_objects } embedding_dict = await embedding_client.batch_embed(id_resource_dict) external_vector_store = get_external_vector_store() # Determine if we need to persist embeddings to postgres # True when: TYPE=pgvector OR still migrating (dual-write to both stores) store_embeddings_in_postgres = ( settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED ) # Create MessageEmbedding entries embedding_objects: list[models.MessageEmbedding] = [] # Maps emb index -> (chunk_position, embedding vector) pending_embedding_data: dict[int, tuple[int, list[float]]] = {} for message_obj in message_objects: embeddings = embedding_dict.get(message_obj.public_id, []) for chunk_position, embedding in enumerate(embeddings): embedding_obj = models.MessageEmbedding( content=message_obj.content, message_id=message_obj.public_id, workspace_name=workspace_name, session_name=session_name, peer_name=message_obj.peer_name, sync_state="pending", embedding=embedding if store_embeddings_in_postgres else None, ) emb_idx = len(embedding_objects) pending_embedding_data[emb_idx] = (chunk_position, embedding) embedding_objects.append(embedding_obj) # Always create MessageEmbedding rows so reconciliation can track sync state # even when embeddings aren't stored in postgres embedding_ids: list[int] = [] if embedding_objects: db.add_all(embedding_objects) await db.flush() embedding_ids = [emb.id for emb in embedding_objects] await db.commit() # If no external vector store (pgvector-only mode), mark as synced immediately if external_vector_store is None: if embedding_ids: await db.execute( update(models.MessageEmbedding) .where(models.MessageEmbedding.id.in_(embedding_ids)) .values( sync_state="synced", last_sync_at=func.now(), sync_attempts=0, ) ) await db.commit() else: # External vector store - build and upsert vector records namespace = external_vector_store.get_vector_namespace( "message", workspace_name ) # Build vector records with {message_id}_{chunk_position} as vector ID vector_records: list[VectorRecord] = [] for emb_idx, emb in enumerate(embedding_objects): chunk_position, embedding = pending_embedding_data[emb_idx] vector_id = f"{emb.message_id}_{chunk_position}" vector_records.append( VectorRecord( id=vector_id, embedding=list(embedding), metadata={ "message_id": emb.message_id, "session_name": emb.session_name, "peer_name": emb.peer_name, }, ) ) # Upsert to external vector store and update sync state if vector_records: try: await external_vector_store.upsert_many( namespace, vector_records ) # Success: mark as synced if we have DB rows if embedding_ids: await db.execute( update(models.MessageEmbedding) .where(models.MessageEmbedding.id.in_(embedding_ids)) .values( sync_state="synced", last_sync_at=func.now(), sync_attempts=0, ) ) await db.commit() except VectorStoreError: logger.warning( "Vector store unavailable; leaving message vectors unsynced" ) if embedding_ids: await db.execute( update(models.MessageEmbedding) .where(models.MessageEmbedding.id.in_(embedding_ids)) .values( sync_attempts=models.MessageEmbedding.sync_attempts + 1, last_sync_at=func.now(), ) ) await db.commit() except Exception: logger.exception("Unexpected error upserting message vectors") if embedding_ids: await db.execute( update(models.MessageEmbedding) .where(models.MessageEmbedding.id.in_(embedding_ids)) .values( sync_attempts=models.MessageEmbedding.sync_attempts + 1, last_sync_at=func.now(), ) ) await db.commit() except Exception: logger.exception( "Failed to generate message embeddings for %s messages in workspace %s and session %s.", len(message_objects), workspace_name, session_name, ) return message_objects async def get_messages( workspace_name: str, session_name: str, reverse: bool | None = False, filters: dict[str, Any] | None = None, token_limit: int | None = None, message_count_limit: int | None = None, ) -> Select[tuple[models.Message]]: """ Get messages from a session. If token_limit is provided, the n most recent messages with token count adding up to the limit will be returned. If message_count_limit is provided, the n most recent messages will be returned. If both are provided, message_count_limit will be used. Args: workspace_name: Name of the workspace session_name: Name of the session reverse: Whether to reverse the order of messages filters: Filter to apply to the messages token_limit: Maximum number of tokens to include in the messages message_count_limit: Maximum number of messages to include Returns: Select statement for the messages """ # Base query with workspace and session filters base_conditions = [ models.Message.workspace_name == workspace_name, models.Message.session_name == session_name, ] # Apply message count limit first (takes precedence over token limit) if message_count_limit is not None: stmt = select(models.Message).where(*base_conditions) stmt = apply_filter(stmt, models.Message, filters) # For message count limit, we want the most recent N messages # So we order by id desc to get most recent, then apply limit stmt = stmt.order_by(models.Message.id.desc()).limit(message_count_limit) # Apply final ordering based on reverse parameter if reverse: stmt = stmt.order_by(models.Message.id.desc()) else: stmt = stmt.order_by(models.Message.id.asc()) elif token_limit is not None: # Apply token limit logic using helper function stmt = _apply_token_limit(base_conditions, token_limit) stmt = apply_filter(stmt, models.Message, filters) # Apply final ordering based on reverse parameter if reverse: stmt = stmt.order_by(models.Message.id.desc()) else: stmt = stmt.order_by(models.Message.id.asc()) else: # Default case - no limits applied stmt = select(models.Message).where(*base_conditions) stmt = apply_filter(stmt, models.Message, filters) if reverse: stmt = stmt.order_by(models.Message.id.desc()) else: stmt = stmt.order_by(models.Message.id.asc()) return stmt async def get_messages_id_range( db: AsyncSession, workspace_name: str, session_name: str, start_id: int = 0, end_id: int | None = None, token_limit: int | None = None, ) -> list[models.Message]: """ Get messages from a session by primary key ID range. If end_id is not provided, all messages after and including start_id will be returned. If start_id is not provided, start will be beginning of session. Note: list is *inclusive* of the end_id message and start_id message. Args: db: Database session workspace_name: Name of the workspace session_name: Name of the session start_id: Primary key ID of the first message to return end_id: Primary key ID of the last message (exclusive) Returns: List of messages """ if start_id < 0 or (end_id is not None and (start_id >= end_id or end_id <= 0)): return [] base_conditions = [ models.Message.workspace_name == workspace_name, models.Message.session_name == session_name, ] if end_id: base_conditions.append( and_(models.Message.id >= start_id, models.Message.id < end_id) ) else: base_conditions.append(models.Message.id >= start_id) if token_limit: # Apply token limit logic using helper function stmt = _apply_token_limit(base_conditions, token_limit) stmt = stmt.order_by(models.Message.id) else: stmt = select(models.Message).where(*base_conditions) result = await db.execute(stmt) return list(result.scalars().all()) async def get_messages_by_seq_range( db: AsyncSession, workspace_name: str, session_name: str, start_seq: int = 1, end_seq: int | None = None, ) -> list[models.Message]: """ Get messages from a session by seq_in_session range. This is useful for getting the last N messages in a session. Args: db: Database session workspace_name: Name of the workspace session_name: Name of the session start_seq: Sequence number of the first message to return (inclusive) end_seq: Sequence number of the last message to return (inclusive) Returns: List of messages ordered by seq_in_session """ if start_seq < 1 or (end_seq is not None and start_seq > end_seq): return [] base_conditions = [ models.Message.workspace_name == workspace_name, models.Message.session_name == session_name, ] if end_seq is not None: base_conditions.append( and_( models.Message.seq_in_session >= start_seq, models.Message.seq_in_session <= end_seq, ) ) else: base_conditions.append(models.Message.seq_in_session >= start_seq) stmt = ( select(models.Message) .where(*base_conditions) .order_by(models.Message.seq_in_session.asc()) ) result = await db.execute(stmt) return list(result.scalars().all()) async def get_message_seq_in_session( db: AsyncSession, workspace_name: str, session_name: str, message_id: int, ) -> int: """ Get the sequence number of a message within a session. Args: db: Database session session_name: Name of the session message_id: Primary key ID of the message Returns: The sequence number of the message (1-indexed) """ stmt = ( select(models.Message.seq_in_session) .where(models.Message.workspace_name == workspace_name) .where(models.Message.session_name == session_name) .where(models.Message.id == message_id) ) seq: int | None = await db.scalar(stmt) return int(seq) if seq is not None else 0 async def get_message( db: AsyncSession, workspace_name: str, session_name: str, message_id: str, ) -> models.Message | None: stmt = ( select(models.Message) .where(models.Message.workspace_name == workspace_name) .where(models.Message.session_name == session_name) .where(models.Message.public_id == message_id) ) result = await db.execute(stmt) return result.scalar_one_or_none() async def update_message( db: AsyncSession, message: schemas.MessageUpdate, workspace_name: str, session_name: str, message_id: str, ) -> bool: honcho_message = await get_message( db, workspace_name=workspace_name, session_name=session_name, message_id=message_id, ) if honcho_message is None: raise ValueError("Message not found or does not belong to user") if ( message.metadata is not None ): # Need to explicitly be there won't make it empty by default honcho_message.h_metadata = message.metadata await db.commit() # await db.refresh(honcho_message) return honcho_message async def _search_messages_external( workspace_name: str, query_embedding: list[float], limit: int, *, session_name: str | None = None, allowed_session_names: list[str] | None = None, after_date: datetime | None = None, before_date: datetime | None = None, ) -> list[str]: """Query the external vector store and return ordered message IDs. Multiple vector records can map to the same message (chunked embeddings), so we oversample from the vector store and deduplicate by message_id. """ external_vector_store = get_external_vector_store() if external_vector_store is None: return [] namespace = external_vector_store.get_vector_namespace("message", workspace_name) vector_filters: dict[str, Any] = {} if session_name: vector_filters["session_name"] = session_name elif allowed_session_names is not None: vector_filters["session_name"] = {"in": allowed_session_names} # Oversample: chunks can map to the same message, and date filters are # applied post-fetch (vector stores don't support temporal filtering), # so fetch extra to compensate for both deduplication and filtering. has_date_filters = after_date is not None or before_date is not None oversample = 6 if has_date_filters else 3 vector_results = await external_vector_store.query( namespace, query_embedding, top_k=limit * oversample, filters=vector_filters if vector_filters else None, ) if not vector_results: return [] # Deduplicate by message_id preserving similarity order seen: dict[str, None] = {} for vr in vector_results: mid = vr.metadata.get("message_id") if mid and mid not in seen: seen[mid] = None message_ids = list(seen.keys()) if not message_ids: return [] return message_ids async def _fetch_messages_by_ids( db: AsyncSession, workspace_name: str, message_ids: list[str], *, after_date: datetime | None = None, before_date: datetime | None = None, ) -> list[models.Message]: """Fetch messages by ID, preserving the supplied ordering.""" fetch_stmt = ( select(models.Message) .where(models.Message.public_id.in_(message_ids)) .where(models.Message.workspace_name == workspace_name) ) if after_date: fetch_stmt = fetch_stmt.where(models.Message.created_at >= after_date) if before_date: fetch_stmt = fetch_stmt.where(models.Message.created_at <= before_date) result = await db.execute(fetch_stmt) messages_by_id = {msg.public_id: msg for msg in result.scalars().all()} return [messages_by_id[mid] for mid in message_ids if mid in messages_by_id] async def _search_messages_pgvector( db: AsyncSession, workspace_name: str, session_name: str | None, *, query_embedding: list[float], allowed_session_names: list[str] | None = None, after_date: datetime | None = None, before_date: datetime | None = None, limit: int = 10, context_window: int = 2, ) -> list[tuple[list[models.Message], list[models.Message]]]: """Run semantic message search against pgvector-backed embeddings.""" # pgvector path: cosine distance in SQL # Oversample because a message with multiple embedding chunks can # produce duplicate rows; we deduplicate in Python to preserve HNSW # index usage (a DISTINCT ON subquery would prevent the index scan). match_stmt = ( select(models.Message) .join( models.MessageEmbedding, models.Message.public_id == models.MessageEmbedding.message_id, ) .where(models.MessageEmbedding.workspace_name == workspace_name) .order_by(models.MessageEmbedding.embedding.cosine_distance(query_embedding)) .limit(limit * 2) ) if session_name: match_stmt = match_stmt.where( models.MessageEmbedding.session_name == session_name ) elif allowed_session_names is not None: match_stmt = match_stmt.where( models.MessageEmbedding.session_name.in_(allowed_session_names) ) if after_date: match_stmt = match_stmt.where(models.Message.created_at >= after_date) if before_date: match_stmt = match_stmt.where(models.Message.created_at <= before_date) result = await db.execute(match_stmt) matched_messages = _deduplicate_messages(result.scalars().all(), limit) return await _build_merged_snippets( db, workspace_name, matched_messages, context_window ) async def _semantic_search_messages( workspace_name: str, session_name: str | None, *, query_embedding: list[float], limit: int = 10, context_window: int = 2, operation_name: str, after_date: datetime | None = None, before_date: datetime | None = None, observer: str | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: """Run semantic message search with optional temporal filters. When observer is provided and session_name is None, results are scoped to sessions the observer has any membership record in. """ # Pre-fetch peer session scope if needed (short-lived DB session) allowed_session_names: list[str] | None = None if observer and not session_name: async with tracked_db(f"{operation_name}.peer_scope") as db: allowed_session_names = await get_peer_session_names( db, workspace_name, observer ) if not allowed_session_names: return [] if settings.VECTOR_STORE.TYPE != "pgvector" and settings.VECTOR_STORE.MIGRATED: message_ids = await _search_messages_external( workspace_name, query_embedding, limit, session_name=session_name, allowed_session_names=allowed_session_names, after_date=after_date, before_date=before_date, ) if not message_ids: return [] async with tracked_db(operation_name) as db: matched_messages = ( await _fetch_messages_by_ids( db, workspace_name, message_ids, after_date=after_date, before_date=before_date, ) )[:limit] snippets = await _build_merged_snippets( db, workspace_name, matched_messages, context_window ) _expunge_snippets(db, snippets) return snippets async with tracked_db(operation_name) as db: snippets = await _search_messages_pgvector( db, workspace_name, session_name, query_embedding=query_embedding, allowed_session_names=allowed_session_names, after_date=after_date, before_date=before_date, limit=limit, context_window=context_window, ) _expunge_snippets(db, snippets) return snippets async def search_messages( workspace_name: str, session_name: str | None, query: str, limit: int = 10, context_window: int = 2, embedding: list[float] | None = None, observer: str | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: """ Search for messages using semantic similarity and return conversation snippets. Each result includes matched messages plus surrounding context. Overlapping snippets within the same session are merged to avoid repetition. Args: workspace_name: Name of the workspace session_name: Name of the session (optional) query: Search query text limit: Maximum number of matching messages to return context_window: Number of messages before/after each match to include embedding: Optional pre-computed embedding observer: When provided and session_name is None, scope results to sessions this peer belongs to Returns: List of tuples: (matched_messages, context_messages) Each snippet may contain multiple matches if they were close together. Context messages are ordered chronologically and include the matched messages. """ query_embedding = ( embedding if embedding is not None else await embedding_client.embed(query) ) return await _semantic_search_messages( workspace_name, session_name, query_embedding=query_embedding, limit=limit, context_window=context_window, operation_name="message.search_messages", observer=observer, ) async def _grep_messages_internal( db: AsyncSession, workspace_name: str, session_name: str | None, text: str, limit: int = 10, context_window: int = 2, allowed_session_names: list[str] | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: """Internal implementation of exact-text message search.""" # Build the base query with ILIKE for case-insensitive text search escaped_text = escape_ilike_pattern(text) match_stmt = ( select(models.Message) .where(models.Message.workspace_name == workspace_name) .where( models.Message.content.ilike(f"%{escaped_text}%", escape=ILIKE_ESCAPE_CHAR) ) .order_by(models.Message.created_at.desc()) .limit(limit) ) if session_name: match_stmt = match_stmt.where(models.Message.session_name == session_name) elif allowed_session_names is not None: match_stmt = match_stmt.where( models.Message.session_name.in_(allowed_session_names) ) result = await db.execute(match_stmt) matched_messages = list(result.scalars().all()) return await _build_merged_snippets( db, workspace_name, matched_messages, context_window ) async def grep_messages( workspace_name: str, session_name: str | None, text: str, limit: int = 10, context_window: int = 2, observer: str | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: """ Search for messages containing specific text (case-insensitive substring match). Unlike semantic search, this finds EXACT text matches. Useful for finding specific names, dates, phrases, or keywords. Args: workspace_name: Name of the workspace session_name: Name of the session (optional - searches all sessions if None) text: Text to search for (case-insensitive) limit: Maximum number of matching messages to return context_window: Number of messages before/after each match to include observer: When provided and session_name is None, scope results to sessions this peer belongs to Returns: List of tuples: (matched_messages, context_messages) Each snippet may contain multiple matches if they were close together. """ async with tracked_db("message.grep_messages") as db: # Pre-fetch peer session scope if needed allowed_session_names = None if observer and not session_name: allowed_session_names = await get_peer_session_names( db, workspace_name, observer ) if not allowed_session_names: return [] snippets = await _grep_messages_internal( db, workspace_name, session_name, text, limit, context_window, allowed_session_names=allowed_session_names, ) _expunge_snippets(db, snippets) return snippets async def get_messages_by_date_range( db: AsyncSession, workspace_name: str, session_name: str | None, after_date: datetime | None = None, before_date: datetime | None = None, limit: int = 20, order: str = "desc", observer: str | None = None, ) -> list[models.Message]: """ Get messages within a date range. Args: db: Database session workspace_name: Name of the workspace session_name: Name of the session (optional - searches all sessions if None) after_date: Return messages after this datetime before_date: Return messages before this datetime limit: Maximum messages to return order: Sort order - 'asc' for oldest first, 'desc' for newest first observer: When provided and session_name is None, scope results to sessions this peer belongs to Returns: List of messages within the date range """ # Pre-fetch peer session scope if needed allowed_session_names = None if observer and not session_name: allowed_session_names = await get_peer_session_names( db, workspace_name, observer ) if not allowed_session_names: return [] stmt = select(models.Message).where(models.Message.workspace_name == workspace_name) if session_name: stmt = stmt.where(models.Message.session_name == session_name) elif allowed_session_names is not None: stmt = stmt.where(models.Message.session_name.in_(allowed_session_names)) if after_date: stmt = stmt.where(models.Message.created_at >= after_date) if before_date: stmt = stmt.where(models.Message.created_at <= before_date) if order == "asc": stmt = stmt.order_by(models.Message.created_at.asc()) else: stmt = stmt.order_by(models.Message.created_at.desc()) stmt = stmt.limit(limit) result = await db.execute(stmt) return list(result.scalars().all()) async def search_messages_temporal( workspace_name: str, session_name: str | None, query: str, after_date: datetime | None = None, before_date: datetime | None = None, limit: int = 10, context_window: int = 2, embedding: list[float] | None = None, observer: str | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: """ Search for messages using semantic similarity with optional date filtering. Combines the power of semantic search with time constraints. Use after_date to find recent mentions, or before_date to find what was said before a certain point. Args: workspace_name: Name of the workspace session_name: Name of the session (optional) query: Search query text after_date: Only return messages after this datetime before_date: Only return messages before this datetime limit: Maximum number of matching messages to return context_window: Number of messages before/after each match to include embedding: Optional pre-computed embedding for the query observer: When provided and session_name is None, scope results to sessions this peer belongs to Returns: List of tuples: (matched_messages, context_messages) Each snippet may contain multiple matches if they were close together. """ query_embedding = ( embedding if embedding is not None else await embedding_client.embed(query) ) return await _semantic_search_messages( workspace_name, session_name, query_embedding=query_embedding, after_date=after_date, before_date=before_date, limit=limit, context_window=context_window, operation_name="message.search_messages_temporal", observer=observer, )