Search with Interleaved Messages

Status: Draft | Owner: vineeth | Last updated: 2026-03-25

1. Problem Statement

Honcho’s internal search functions (search_messages, grep_messages, search_messages_temporal) already return rich conversation snippets — matched messages plus surrounding context organized by session, with overlapping windows merged. But the public API discards all of this. The search endpoint in src/routers/sessions.py calls src/utils/search.py:search(), which returns a flat list[models.Message] via RRF-fused semantic + full-text search. The caller gets a ranked list of individual messages with no conversational context around them.

Additionally, the seq_in_session field — a monotonically increasing, per-session integer that defines message ordering — is stored in the database and used extensively by internal functions, but it is never returned in the API response. This prevents callers from constructing positional queries like “give me messages 10 through 25 in this session” or understanding where a message falls within its session’s timeline.

Both gaps force SDK consumers to implement their own context-windowing logic and sequence tracking on the client side, which is wasteful and error-prone.

2. Goals / Non-Goals

Goals

  1. Expose interleaved snippet results from search — Add optional include_context and context_window parameters to the existing POST /sessions/{session_id}/search endpoint. When enabled, the response includes a snippets field containing matched messages alongside their surrounding conversational context, with overlapping ranges merged.

  2. Expose seq_in_session in the API response — Add the field to the Message Pydantic schema so it appears in every API response that returns messages (list, get, search, context, create).

  3. Make seq_in_session filterable — Add it to the message filter column mapping so callers can use range operators (gte, lte, gt, lt) to query message slices by position within a session.

  4. Backward compatibility — Existing clients that do not send the new parameters must see identical response shapes. The flat list[Message] remains the response_model of the search endpoint. Snippets are additive.

Non-Goals

  • Creating new endpoints (no /search/snippets route).
  • Changing the underlying search algorithm (RRF fusion stays as-is).
  • Exposing _build_merged_snippets for cross-session search (the current endpoint is scoped to a single session; cross-session search is out of scope).
  • Altering the internal database schema or adding migrations (the seq_in_session column already exists with a UNIQUE(workspace_name, session_name, seq_in_session) constraint).
  • Modifying the TypeScript SDK (out of scope for this spec; the Python SDK changes are specified below).

3. Design

3.1 Message Response Shape — Add seq_in_session

File: src/schemas/api.py

Current Message schema (lines 279-293):

class Message(MessageBase):
    public_id: str = Field(serialization_alias="id")
    content: str
    peer_name: str = Field(serialization_alias="peer_id")
    session_name: str = Field(serialization_alias="session_id")
    h_metadata: dict[str, Any] = Field(
        default_factory=dict, serialization_alias="metadata"
    )
    created_at: datetime.datetime
    workspace_name: str = Field(serialization_alias="workspace_id")
    token_count: int
 
    model_config = ConfigDict(
        from_attributes=True, populate_by_name=True
    )

Change: Add one field:

class Message(MessageBase):
    public_id: str = Field(serialization_alias="id")
    content: str
    peer_name: str = Field(serialization_alias="peer_id")
    session_name: str = Field(serialization_alias="session_id")
    h_metadata: dict[str, Any] = Field(
        default_factory=dict, serialization_alias="metadata"
    )
    created_at: datetime.datetime
    workspace_name: str = Field(serialization_alias="workspace_id")
    token_count: int
    seq_in_session: int  # <-- NEW
 
    model_config = ConfigDict(
        from_attributes=True, populate_by_name=True
    )

Because from_attributes=True is set and the SQLAlchemy Message model already has seq_in_session: Mapped[int], Pydantic will automatically populate this from any ORM object. No changes are needed in CRUD functions.

Serialized JSON output (every message endpoint):

{
  "id": "abc123nanoid456789a",
  "content": "Hello, how are you?",
  "peer_id": "alice",
  "session_id": "chat-1",
  "metadata": {},
  "created_at": "2026-03-25T10:00:00Z",
  "workspace_id": "my-workspace",
  "token_count": 6,
  "seq_in_session": 1
}

3.2 Filter Integration — Add seq_in_session to Message Filters

File: src/utils/filter.py

Current message column mapping (lines 41-48):

ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES = {
    "workspace_id": "workspace_name",
    "session_id": "session_name",
    "peer_id": "peer_name",
    "token_count": "token_count",
    "created_at": "created_at",
    "metadata": "h_metadata",
}

Change: Add one entry:

ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES = {
    "workspace_id": "workspace_name",
    "session_id": "session_name",
    "peer_id": "peer_name",
    "token_count": "token_count",
    "created_at": "created_at",
    "metadata": "h_metadata",
    "seq_in_session": "seq_in_session",  # <-- NEW
}

This is all that is needed. The existing _build_comparison_conditions function already handles numeric comparison operators (gte, lte, gt, lt, ne, in) for non-JSONB, non-datetime columns by casting values to float. Since seq_in_session is a BigInteger, these comparisons will work correctly.

Usage examples (in filter payloads):

// Messages 10 through 20 in a session
{"seq_in_session": {"gte": 10, "lte": 20}}
 
// Messages after position 50
{"seq_in_session": {"gt": 50}}
 
// Exact position
{"seq_in_session": 15}
 
// Combined with other filters
{"peer_id": "alice", "seq_in_session": {"gte": 1, "lte": 100}}
 
// Using logical operators
{"AND": [{"peer_id": "alice"}, {"seq_in_session": {"gte": 10}}]}

This filter works on both the POST /messages/list endpoint and the POST /search endpoint (via the filters field in MessageSearchOptions).

3.3 Search Endpoint — Add Snippet Context

3.3.1 New Schema Types

File: src/schemas/api.py

Add the following new schemas after the MessageSearchOptions class:

class MessageSnippet(BaseModel):
    """A conversation snippet: matched messages plus surrounding context."""
    matched: list[Message] = Field(
        description="Messages that matched the search query within this snippet"
    )
    context: list[Message] = Field(
        description="All messages in the merged context window (chronological order), including the matched messages"
    )
 
 
class MessageSearchResponse(BaseModel):
    """Response for message search with optional snippet context."""
    results: list[Message] = Field(
        description="Flat list of matched messages (always present for backward compatibility)"
    )
    snippets: list[MessageSnippet] | None = Field(
        default=None,
        description="Conversation snippets with surrounding context. Only present when include_context=true."
    )

3.3.2 Extend search() in src/utils/search.py

The existing search() function returns list[models.Message]. We need a variant that also returns snippets. Rather than changing the signature of search(), add a new function:

async def search_with_context(
    db: AsyncSession,
    query: str,
    *,
    filters: dict[str, Any] | None = None,
    limit: int = 10,
    context_window: int = 2,
) -> tuple[list[models.Message], list[tuple[list[models.Message], list[models.Message]]]]:
    """
    Search messages and build interleaved conversation snippets.
 
    Returns:
        Tuple of:
        - flat results (same as search())
        - snippets: list of (matched_messages, context_messages) tuples
    """
    # Reuse existing search() to get the flat ranked results
    flat_results = await search(db, query, filters=filters, limit=limit)
 
    if not flat_results:
        return flat_results, []
 
    # Extract workspace_name from filters (required for _build_merged_snippets)
    workspace_name: str | None = filters.get("workspace_id") if filters else None
    if workspace_name is None:
        # Cannot build snippets without workspace scope
        return flat_results, []
 
    # Import here to avoid circular imports
    from src.crud.message import _build_merged_snippets
 
    snippets = await _build_merged_snippets(
        db, workspace_name, flat_results, context_window
    )
 
    return flat_results, snippets

3.3.3 Modify the Search Endpoint

File: src/routers/sessions.py

Current endpoint (lines 784-811):

@router.post(
    "/{session_id}/search",
    response_model=list[schemas.Message],
    ...
)
async def search_session(
    workspace_id: str = Path(...),
    session_id: str = Path(...),
    body: schemas.MessageSearchOptions = Body(...),
    db: AsyncSession = db,
):
    filters = body.filters or {}
    filters["workspace_id"] = workspace_id
    filters["session_id"] = session_id
    return await search(db, body.query, filters=filters, limit=body.limit)

Change:

@router.post(
    "/{session_id}/search",
    response_model=schemas.MessageSearchResponse,
    dependencies=[
        Depends(require_auth(workspace_name="workspace_id", session_name="session_id"))
    ],
)
async def search_session(
    workspace_id: str = Path(...),
    session_id: str = Path(...),
    body: schemas.MessageSearchOptions = Body(
        ..., description="Message search parameters"
    ),
    include_context: bool = Query(
        False,
        description="When true, include surrounding conversation context for each matched message as interleaved snippets",
    ),
    context_window: int = Query(
        2,
        ge=0,
        le=10,
        description="Number of messages before and after each match to include in snippet context. Only used when include_context=true.",
    ),
    db: AsyncSession = db,
):
    """
    Search a Session with optional filters. Use `limit` to control the number of results returned.
 
    When `include_context` is true, the response includes a `snippets` field containing
    conversation snippets with surrounding messages. Overlapping context windows within
    the same session are merged to avoid duplicate messages.
    """
    filters = body.filters or {}
    filters["workspace_id"] = workspace_id
    filters["session_id"] = session_id
 
    if include_context:
        from src.utils.search import search_with_context
        flat_results, snippets = await search_with_context(
            db, body.query, filters=filters, limit=body.limit,
            context_window=context_window,
        )
        return schemas.MessageSearchResponse(
            results=flat_results,
            snippets=[
                schemas.MessageSnippet(matched=matched, context=context)
                for matched, context in snippets
            ],
        )
    else:
        flat_results = await search(
            db, body.query, filters=filters, limit=body.limit
        )
        return schemas.MessageSearchResponse(results=flat_results)

Backward compatibility: The response_model changes from list[schemas.Message] to schemas.MessageSearchResponse. However, since existing clients never send include_context=true, they will receive:

{
  "results": [ ... same flat list as before ... ],
  "snippets": null
}

This is a breaking change in response shape — the flat list is now wrapped in a results field. To maintain strict backward compatibility, we have two options:

Option A (recommended): Keep the existing endpoint unchanged and add a separate response path:

@router.post(
    "/{session_id}/search",
    dependencies=[
        Depends(require_auth(workspace_name="workspace_id", session_name="session_id"))
    ],
)
async def search_session(
    workspace_id: str = Path(...),
    session_id: str = Path(...),
    body: schemas.MessageSearchOptions = Body(
        ..., description="Message search parameters"
    ),
    include_context: bool = Query(
        False,
        description="When true, response is a MessageSearchResponse object with results and snippets fields. When false (default), response is a flat list of messages (backward compatible).",
    ),
    context_window: int = Query(
        2,
        ge=0,
        le=10,
        description="Number of messages before/after each match to include in snippet context. Only used when include_context=true.",
    ),
    db: AsyncSession = db,
):
    """
    Search a Session with optional filters.
 
    By default, returns a flat list of matched messages. When `include_context=true`,
    returns a `MessageSearchResponse` with both `results` (flat list) and `snippets`
    (conversation context around each match, with overlapping windows merged).
    """
    filters = body.filters or {}
    filters["workspace_id"] = workspace_id
    filters["session_id"] = session_id
 
    if include_context:
        from src.utils.search import search_with_context
        flat_results, raw_snippets = await search_with_context(
            db, body.query, filters=filters, limit=body.limit,
            context_window=context_window,
        )
        return schemas.MessageSearchResponse(
            results=flat_results,
            snippets=[
                schemas.MessageSnippet(matched=matched, context=context)
                for matched, context in raw_snippets
            ],
        )
    else:
        return await search(
            db, body.query, filters=filters, limit=body.limit
        )

With Option A:

  • include_context=false (default): response is list[Message] — identical to today.
  • include_context=true: response is MessageSearchResponse with results and snippets.

The endpoint drops the explicit response_model so FastAPI does not force a single shape. The OpenAPI schema becomes less precise, but backward compatibility is preserved. Alternatively, the response_model can be set to list[schemas.Message] | schemas.MessageSearchResponse using a Union type, but FastAPI’s OpenAPI generation handles this awkwardly.

Option B: Accept the breaking change, wrap all responses in MessageSearchResponse, and version the API. This is cleaner long-term but requires a coordinated SDK release.

Decision: Use Option A for this implementation. The SDK can abstract over the conditional response shape internally.

3.3.4 Response Shape Examples

Default request (backward compatible):

POST /v1/workspaces/my-ws/sessions/chat-1/search
Content-Type: application/json

{"query": "favorite color", "limit": 5}

Response (200 OK):

[
  {
    "id": "msg_abc1",
    "content": "My favorite color is blue",
    "peer_id": "alice",
    "session_id": "chat-1",
    "metadata": {},
    "created_at": "2026-03-25T10:05:00Z",
    "workspace_id": "my-ws",
    "token_count": 6,
    "seq_in_session": 5
  },
  {
    "id": "msg_abc2",
    "content": "I also like green as a secondary color",
    "peer_id": "alice",
    "session_id": "chat-1",
    "metadata": {},
    "created_at": "2026-03-25T10:10:00Z",
    "workspace_id": "my-ws",
    "token_count": 8,
    "seq_in_session": 12
  }
]

Request with context:

POST /v1/workspaces/my-ws/sessions/chat-1/search?include_context=true&context_window=2
Content-Type: application/json

{"query": "favorite color", "limit": 5}

Response (200 OK):

{
  "results": [
    {
      "id": "msg_abc1",
      "content": "My favorite color is blue",
      "peer_id": "alice",
      "session_id": "chat-1",
      "metadata": {},
      "created_at": "2026-03-25T10:05:00Z",
      "workspace_id": "my-ws",
      "token_count": 6,
      "seq_in_session": 5
    },
    {
      "id": "msg_abc2",
      "content": "I also like green as a secondary color",
      "peer_id": "alice",
      "session_id": "chat-1",
      "metadata": {},
      "created_at": "2026-03-25T10:10:00Z",
      "workspace_id": "my-ws",
      "token_count": 8,
      "seq_in_session": 12
    }
  ],
  "snippets": [
    {
      "matched": [
        {
          "id": "msg_abc1",
          "content": "My favorite color is blue",
          "peer_id": "alice",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:05:00Z",
          "workspace_id": "my-ws",
          "token_count": 6,
          "seq_in_session": 5
        }
      ],
      "context": [
        {
          "id": "msg_ctx1",
          "content": "What colors do you like?",
          "peer_id": "bot",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:04:00Z",
          "workspace_id": "my-ws",
          "token_count": 5,
          "seq_in_session": 3
        },
        {
          "id": "msg_ctx2",
          "content": "Do you have a preference?",
          "peer_id": "bot",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:04:30Z",
          "workspace_id": "my-ws",
          "token_count": 5,
          "seq_in_session": 4
        },
        {
          "id": "msg_abc1",
          "content": "My favorite color is blue",
          "peer_id": "alice",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:05:00Z",
          "workspace_id": "my-ws",
          "token_count": 6,
          "seq_in_session": 5
        },
        {
          "id": "msg_ctx3",
          "content": "Blue is a great choice!",
          "peer_id": "bot",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:05:30Z",
          "workspace_id": "my-ws",
          "token_count": 5,
          "seq_in_session": 6
        },
        {
          "id": "msg_ctx4",
          "content": "Any other favorites?",
          "peer_id": "bot",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:06:00Z",
          "workspace_id": "my-ws",
          "token_count": 4,
          "seq_in_session": 7
        }
      ]
    },
    {
      "matched": [
        {
          "id": "msg_abc2",
          "content": "I also like green as a secondary color",
          "peer_id": "alice",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:10:00Z",
          "workspace_id": "my-ws",
          "token_count": 8,
          "seq_in_session": 12
        }
      ],
      "context": [
        {
          "id": "msg_ctx5",
          "content": "Do you like any other colors?",
          "peer_id": "bot",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:09:00Z",
          "workspace_id": "my-ws",
          "token_count": 6,
          "seq_in_session": 10
        },
        {
          "id": "msg_ctx6",
          "content": "Maybe something complementary?",
          "peer_id": "bot",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:09:30Z",
          "workspace_id": "my-ws",
          "token_count": 4,
          "seq_in_session": 11
        },
        {
          "id": "msg_abc2",
          "content": "I also like green as a secondary color",
          "peer_id": "alice",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:10:00Z",
          "workspace_id": "my-ws",
          "token_count": 8,
          "seq_in_session": 12
        },
        {
          "id": "msg_ctx7",
          "content": "Green and blue go well together",
          "peer_id": "bot",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:10:30Z",
          "workspace_id": "my-ws",
          "token_count": 6,
          "seq_in_session": 13
        },
        {
          "id": "msg_ctx8",
          "content": "They are both cool tones",
          "peer_id": "bot",
          "session_id": "chat-1",
          "metadata": {},
          "created_at": "2026-03-25T10:11:00Z",
          "workspace_id": "my-ws",
          "token_count": 5,
          "seq_in_session": 14
        }
      ]
    }
  ]
}

Note: if two matches are close enough that their context windows overlap (e.g., seq_in_session 5 with context_window=2 and seq_in_session 7 with context_window=2), _build_merged_snippets merges them into a single snippet. The matched list in that snippet will contain both matched messages, and the context list will contain the full merged range without duplicates.

3.4 SDK Changes (Python)

3.4.1 Add seq_in_session to SDK Message

File: sdks/python/src/honcho/api_types.py

In MessageResponse (lines 293-306), add:

class MessageResponse(BaseModel):
    """Message API response."""
 
    model_config = ConfigDict(populate_by_name=True)
 
    id: str
    content: str
    peer_id: str
    session_id: str
    metadata: dict[str, Any] = Field(default_factory=dict)
    created_at: datetime.datetime
    workspace_id: str
    token_count: int
    seq_in_session: int  # <-- NEW

File: sdks/python/src/honcho/message.py

Add seq_in_session to the Message class:

class Message:
    id: str
    content: str
    peer_id: str
    session_id: str
    workspace_id: str
    metadata: dict[str, Any]
    created_at: datetime.datetime
    token_count: int
    seq_in_session: int  # <-- NEW
 
    def __init__(
        self,
        id: str,
        content: str,
        peer_id: str,
        session_id: str,
        workspace_id: str,
        metadata: dict[str, Any],
        created_at: datetime.datetime,
        token_count: int,
        seq_in_session: int,  # <-- NEW
    ) -> None:
        self.id = id
        self.content = content
        self.peer_id = peer_id
        self.session_id = session_id
        self.workspace_id = workspace_id
        self.metadata = metadata
        self.created_at = created_at
        self.token_count = token_count
        self.seq_in_session = seq_in_session  # <-- NEW
 
    @classmethod
    def from_api_response(cls, data: MessageResponse) -> "Message":
        """Create a Message from an API response."""
        return cls(
            id=data.id,
            content=data.content,
            peer_id=data.peer_id,
            session_id=data.session_id,
            workspace_id=data.workspace_id,
            metadata=data.metadata,
            created_at=data.created_at,
            token_count=data.token_count,
            seq_in_session=data.seq_in_session,  # <-- NEW
        )

3.4.2 Add Snippet Types to SDK

File: sdks/python/src/honcho/api_types.py

Add after MessageSearchParams:

class MessageSnippetResponse(BaseModel):
    """A conversation snippet from search results."""
    matched: list[MessageResponse]
    context: list[MessageResponse]
 
 
class MessageSearchResponse(BaseModel):
    """Search response with optional snippet context."""
    results: list[MessageResponse]
    snippets: list[MessageSnippetResponse] | None = None

3.4.3 Add Snippet Wrapper to SDK

File: sdks/python/src/honcho/message.py

Add after the Message class:

class MessageSnippet:
    """A conversation snippet: matched messages plus surrounding context."""
 
    matched: list[Message]
    context: list[Message]
 
    def __init__(self, matched: list[Message], context: list[Message]) -> None:
        self.matched = matched
        self.context = context
 
    def __repr__(self) -> str:
        return (
            f"MessageSnippet(matched={len(self.matched)} messages, "
            f"context={len(self.context)} messages)"
        )

3.4.4 Update Session.search() in SDK

File: sdks/python/src/honcho/session.py

Update the search method to accept optional context parameters and return appropriate types:

@validate_call
def search(
    self,
    query: str = Field(..., min_length=1, description="The search query to use"),
    filters: dict[str, object] | None = Field(
        None, description="Filters to scope the search"
    ),
    limit: int = Field(
        default=10, ge=1, le=100, description="Number of results to return"
    ),
    include_context: bool = Field(
        default=False,
        description="When true, include surrounding conversation context for each match",
    ),
    context_window: int = Field(
        default=2,
        ge=0,
        le=10,
        description="Number of messages before/after each match to include in context",
    ),
) -> list[Message] | tuple[list[Message], list[MessageSnippet]]:
    """
    Search for messages in this session.
 
    Args:
        query: The search query to use
        filters: Filters to scope the search
        limit: Number of results to return (1-100, default: 10)
        include_context: When true, return a tuple of (results, snippets)
        context_window: Number of messages before/after each match (0-10, default: 2)
 
    Returns:
        When include_context=False: list of Message objects (backward compatible)
        When include_context=True: tuple of (results, snippets) where snippets
            are MessageSnippet objects containing matched and context messages
    """
    self._honcho._ensure_workspace()
 
    query_params: dict[str, Any] = {}
    if include_context:
        query_params["include_context"] = True
        query_params["context_window"] = context_window
 
    data = self._honcho._http.post(
        routes.session_search(self.workspace_id, self.id),
        body={"query": query, "filters": filters, "limit": limit},
        query=query_params if query_params else None,
    )
 
    if include_context:
        # Response is a MessageSearchResponse dict
        from .api_types import MessageSearchResponse as MSR
        response = MSR.model_validate(data)
        results = [
            Message.from_api_response(msg) for msg in response.results
        ]
        snippets = []
        if response.snippets:
            for snippet in response.snippets:
                snippets.append(MessageSnippet(
                    matched=[Message.from_api_response(m) for m in snippet.matched],
                    context=[Message.from_api_response(m) for m in snippet.context],
                ))
        return results, snippets
    else:
        # Response is a flat list (backward compatible)
        return [
            Message.from_api_response(MessageResponse.model_validate(msg))
            for msg in data
        ]

The corresponding aio variant in sdks/python/src/honcho/aio.py must be updated with the same signature and logic, using await for the HTTP call.

4. Migration Plan

No database migrations are needed. The seq_in_session column already exists on the messages table with a UNIQUE(workspace_name, session_name, seq_in_session) constraint. This change is purely API-level (serialization and filtering).

5. Implementation Phases

Phase 1: Expose seq_in_session in API responses

  1. Add seq_in_session: int to Message in src/schemas/api.py.
  2. Add "seq_in_session": "seq_in_session" to ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES in src/utils/filter.py.
  3. Update SDK: add seq_in_session to MessageResponse in sdks/python/src/honcho/api_types.py.
  4. Update SDK: add seq_in_session to Message class in sdks/python/src/honcho/message.py (__init__, class annotations, and from_api_response).
  5. Write tests:
    • Verify seq_in_session appears in message creation response.
    • Verify seq_in_session appears in message list response.
    • Verify seq_in_session filter works with gte, lte, gt, lt operators on the list endpoint.
    • Verify seq_in_session exact match filter works.

Phase 2: Expose interleaved search context

  1. Add MessageSnippet and MessageSearchResponse to src/schemas/api.py.
  2. Add search_with_context() to src/utils/search.py.
  3. Modify search_session endpoint in src/routers/sessions.py to accept include_context and context_window query params with conditional return type.
  4. Update src/schemas/__init__.py to export new schemas.
  5. Update SDK: add MessageSnippetResponse and MessageSearchResponse to sdks/python/src/honcho/api_types.py.
  6. Update SDK: add MessageSnippet to sdks/python/src/honcho/message.py.
  7. Update SDK: modify Session.search() in sdks/python/src/honcho/session.py and SessionAio.search() in sdks/python/src/honcho/aio.py.
  8. Write tests:
    • Verify default search (no include_context) returns flat list[Message] (backward compat).
    • Verify include_context=true returns MessageSearchResponse with results and snippets.
    • Verify context_window=0 returns snippets where context only contains matched messages.
    • Verify overlapping context windows are merged (two matches close together produce a single snippet with both in matched).
    • Verify context_window bounds (ge=0, le=10).
    • Verify snippet context messages are sorted by seq_in_session ascending.
    • Verify matched messages within a snippet are a subset of the context messages.

6. Files to Modify (Exact Paths)

Server (honcho)

FileChange
src/schemas/api.pyAdd seq_in_session: int to Message; add MessageSnippet, MessageSearchResponse classes
src/schemas/__init__.pyExport MessageSnippet, MessageSearchResponse
src/utils/filter.pyAdd "seq_in_session": "seq_in_session" to ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES
src/utils/search.pyAdd search_with_context() function
src/routers/sessions.pyAdd include_context, context_window query params to search_session; conditional return logic

Python SDK

FileChange
sdks/python/src/honcho/api_types.pyAdd seq_in_session: int to MessageResponse; add MessageSnippetResponse, MessageSearchResponse
sdks/python/src/honcho/message.pyAdd seq_in_session to Message; add MessageSnippet class
sdks/python/src/honcho/session.pyUpdate Session.search() with include_context, context_window params
sdks/python/src/honcho/aio.pyUpdate SessionAio.search() with matching params

No changes needed

FileReason
src/models.pyseq_in_session already exists on Message model
src/crud/message.py_build_merged_snippets() already exists and works correctly; no changes needed
migrations/No schema changes; column already exists

7. Risk Assessment

Low Risk

  • Adding seq_in_session to the Message schema: This is a purely additive change. The column already exists on the ORM model and from_attributes=True handles the mapping. Existing clients that don’t use this field will simply ignore it. All endpoints that return Message objects will automatically include it.

  • Adding seq_in_session to the filter mapping: The filter system already handles integer comparison operators. The column has a composite unique index (workspace_name, session_name, seq_in_session) that will be used for equality and range queries when combined with session scoping (which the message list endpoint always provides).

Medium Risk

  • Conditional response type on search endpoint: Returning different shapes based on a query parameter is unconventional for REST APIs. The OpenAPI schema will be less precise. However, this is the most pragmatic approach for backward compatibility. The alternative (always returning MessageSearchResponse) would break all existing clients.

  • SDK overloaded return type: search() returning list[Message] | tuple[list[Message], list[MessageSnippet]] is not ideal for type safety. Callers need to know which branch they are on. An alternative is to add a separate method like search_with_context() on the SDK side that always returns the tuple. This would be cleaner:

    # Option: separate method instead of overloaded return
    def search(...) -> list[Message]:  # unchanged
    def search_with_context(...) -> tuple[list[Message], list[MessageSnippet]]:  # new

    This is a decision for the implementor. The spec supports either approach.

Considerations

  • Performance of snippet building: _build_merged_snippets issues one additional query per session that has matches. Since the search endpoint is scoped to a single session, this means exactly one extra query. The query uses the (workspace_name, session_name, seq_in_session) unique index with BETWEEN conditions, so it is efficient. For context_window=10 and limit=100 with all matches in the same session, the worst case is fetching ~2100 messages in a single query, which is acceptable.

  • _build_merged_snippets is a private function: The leading underscore convention marks it as internal. Importing it from search.py crosses a module boundary. If this is a concern, it could be promoted to a public function (remove the underscore) or re-exported via crud/__init__.py. The spec assumes direct import for simplicity.

8. Verification Plan

Unit Tests

  1. seq_in_session serialization: Create messages, verify the API response JSON includes seq_in_session with correct values (1-indexed, monotonically increasing within a session).

  2. seq_in_session filter on list endpoint: Create 20 messages in a session, then:

    • POST /messages/list with filter {"seq_in_session": {"gte": 5, "lte": 10}} — verify exactly 6 messages returned with seq_in_session values 5-10.
    • POST /messages/list with filter {"seq_in_session": 7} — verify exactly 1 message with seq_in_session 7.
    • POST /messages/list with filter {"seq_in_session": {"gt": 18}} — verify 2 messages (19, 20).
  3. Search backward compatibility: POST /search without include_context returns a flat JSON array of message objects (not wrapped in results).

  4. Search with context: POST /search?include_context=true&context_window=2:

    • Response has results (flat list) and snippets (list of snippet objects).
    • Each snippet has matched and context fields.
    • context messages are sorted by seq_in_session ascending.
    • matched messages are a subset of context messages (by id).
  5. Snippet merging: Create messages at seq 1-20. Insert two messages that match a query at seq 5 and seq 7. Search with context_window=2:

    • Both matches should be merged into a single snippet (window [3,7] and [5,9] overlap).
    • The snippet’s matched list should contain both messages.
    • The snippet’s context list should span seq 3-9 (merged range).
  6. Context window bounds: Verify context_window=0 returns snippets where context contains only the matched messages. Verify context_window=11 is rejected with 422.

Integration Tests (SDK)

  1. Create a session with 10 messages, search with include_context=True, verify the SDK returns (results, snippets) tuple with correctly typed MessageSnippet objects.
  2. Search with default params, verify the SDK returns list[Message] (backward compatible).
  3. Verify message.seq_in_session is populated on all Message objects returned by add_messages, messages(), search(), and context().

9. Open Questions

  1. Overloaded return type vs. separate SDK method: Should Session.search() have a conditional return type based on include_context, or should we add a separate Session.search_with_context() method? The latter is more type-safe but adds API surface.

  2. Maximum context_window value: The spec proposes le=10 (max 10 messages on each side, so up to 21 messages per snippet). Should this be higher for use cases with long conversations? A ceiling of 10 keeps response sizes manageable.

  3. Cross-session search with context: The workspace-level search endpoint (if one exists or is planned) would benefit from the same snippet support. This spec only covers session-scoped search. Should cross-session be addressed now or deferred?

  4. Snippet ordering: Snippets are currently returned in the order they are produced by _build_merged_snippets (grouped by session, sorted by seq_in_session within each session). Since this endpoint is single-session, the snippets will be in seq_in_session order. Should we instead order snippets by the relevance rank of their highest-ranked matched message? This would match the results ordering.

  5. Should seq_in_session be aliased in the API response? The column is named seq_in_session internally and this spec exposes it with the same name. An alternative alias like position or sequence might be more user-friendly. However, using the internal name avoids confusion and keeps the filter field name consistent with the response field name.