Newer
Older
navi-1 / navi / core / sessions / rows.py
"""Message rows: the `Message` <-> table-column mapping and session hydration.

Covers the column mapping, the archive-aware loading of sessions, and the
one-shot repair of tool-call batches torn apart by a crash (or by a page
boundary). Nothing here touches the `sessions` row itself — `store` does that.
"""

import json

import asyncpg

from navi.llm.base import Message, ToolCallRequest

from ..session import Session


def _serialize(messages: list[Message]) -> str:
    return json.dumps(
        [m.model_dump(mode="json", exclude_none=True) for m in messages],
        ensure_ascii=False,
    )


def _deserialize(raw: str) -> list[Message]:
    if not raw:
        return []
    return [Message.model_validate(m) for m in json.loads(raw)]


def repair_dangling_tool_calls(messages: list[Message]) -> int:
    """Synthesize placeholder results for assistant tool_calls that lost their
    role=tool pair (a crash mid-batch, e.g. a parallel batch killed by a server
    restart). Returns the number of placeholders added.

    Placeholders are inserted right after the last tool message of the affected
    batch (before the next non-tool message) and marked ``is_context=False`` —
    display history stays coherent, the LLM context is untouched.
    """
    answered = {
        m.tool_call_id for m in messages if m.role == "tool" and m.tool_call_id
    }
    out: list[Message] = []
    pending: list[Message] = []
    for m in messages:
        if m.role != "tool" and pending:
            out.extend(pending)
            pending = []
        out.append(m)
        if m.role == "assistant" and m.tool_calls:
            for tc in m.tool_calls:
                if not tc.id or tc.id in answered:
                    continue
                pending.append(
                    Message(
                        role="tool",
                        content="[Interrupted before result]",
                        tool_call_id=tc.id,
                        name=tc.name,
                        metadata={},
                        is_context=False,
                    )
                )
    out.extend(pending)
    if len(out) != len(messages):
        added = len(out) - len(messages)
        messages[:] = out
        return added
    return 0


def _message_key(m: Message) -> tuple:
    """Stable key for matching a message between messages[] and context[].

    Used only by the one-shot boot migration for legacy JSON rows.
    """
    return (
        m.role,
        m.content,
        m.tool_call_id,
        m.name,
        m.is_summary,
        m.is_plan,
        m.is_compression,
        m.is_recall,
        m.thinking,
        m.created_at.isoformat() if m.created_at else None,
        json.dumps([tc.model_dump(mode="json") for tc in m.tool_calls], ensure_ascii=False) if m.tool_calls else None,
        json.dumps(m.files, ensure_ascii=False) if m.files else None,
        json.dumps(m.metadata, ensure_ascii=False) if m.metadata else None,
    )


def _row_to_message(row: asyncpg.Record) -> Message:
    images = json.loads(row["images"]) if row.get("images") else None
    tool_calls = None
    raw_tool_calls = row.get("tool_calls")
    if raw_tool_calls:
        tool_calls = [ToolCallRequest.model_validate(tc) for tc in json.loads(raw_tool_calls)]
    files = json.loads(row["files"]) if row.get("files") else None
    metadata = json.loads(row["metadata"]) if row.get("metadata") else {}

    return Message(
        role=row["role"],
        content=row["content"],
        images=images,
        tool_calls=tool_calls,
        tool_call_id=row.get("tool_call_id"),
        name=row.get("name"),
        created_at=row.get("created_at"),
        is_summary=bool(row.get("is_summary", False)),
        thinking=row.get("thinking"),
        is_plan=bool(row.get("is_plan", False)),
        is_compression=bool(row.get("is_compression", False)),
        is_context=bool(row.get("is_context", True)),
        is_display=bool(row.get("is_display", True)),
        elapsed_seconds=row.get("elapsed_seconds"),
        tool_call_count=row.get("tool_call_count"),
        token_count=row.get("token_count"),
        files=files,
        metadata=metadata,
        is_recall=bool(row.get("is_recall", False)),
        sequence_number=row.get("sequence_number", 0),
    )


def message_fingerprint(m: Message) -> tuple:
    """The mutable columns ``save()`` refreshes, as one comparable value.

    ``thinking`` grows during a turn; length plus the (cached) string hash is
    enough to tell "same as last write" from "changed" without keeping a copy.
    """
    thinking = m.thinking or ""
    return (
        m.is_context,
        m.is_display,
        m.is_summary,
        m.is_plan,
        m.is_compression,
        m.is_recall,
        len(thinking),
        hash(thinking),
        m.elapsed_seconds,
        m.tool_call_count,
        m.token_count,
    )


def saved_fingerprints(messages: list[Message]) -> dict[int, tuple]:
    """Baseline for an in-memory session: what the DB already holds."""
    return {
        m.sequence_number: message_fingerprint(m)
        for m in messages
        if m.sequence_number >= 0
    }


def preview_of(messages: list[Message]) -> str:
    """The sidebar snippet: the last user/assistant message, 60 chars."""
    for msg in reversed(messages):
        if msg.role in ("user", "assistant") and msg.content:
            return msg.content[:60]
    return ""


async def _load_messages_map(conn: asyncpg.Connection, session_ids: list[str]) -> dict[str, list[Message]]:
    """Batch-load all messages for the given session IDs."""
    if not session_ids:
        return {}
    rows = await conn.fetch(
        "SELECT * FROM session_messages WHERE session_id = ANY($1) ORDER BY sequence_number",
        session_ids,
    )
    result: dict[str, list[Message]] = {sid: [] for sid in session_ids}
    for row in rows:
        sid = row["session_id"]
        result[sid].append(_row_to_message(row))
    return result


def _session_head(row: asyncpg.Record) -> Session:
    """The `sessions` row alone — no message payload."""
    planning_logs_raw = row["planning_logs"]
    planning_logs = json.loads(planning_logs_raw) if planning_logs_raw else []
    s = Session(
        id=row["id"],
        profile_id=row["profile_id"],
        user_id=row["user_id"],
        pinned=bool(row["pinned"]),
        special=bool(row.get("special", False)),
        name=row["name"],
        created_at=row["created_at"],
        last_active=row["last_active"],
        context_token_count=row["context_token_count"] or 0,
        planning_logs=planning_logs,
    )
    s.archive_threshold = row.get("archive_threshold", 0) or 0
    s.session_metadata = json.loads(row.get("session_metadata") or "{}") if row.get("session_metadata") else {}
    return s


def _next_sequence(row: asyncpg.Record, hot_msgs: list[Message]) -> int:
    next_seq = row.get("next_sequence", 0) or 0
    if next_seq == 0:
        next_seq = max((m.sequence_number for m in hot_msgs), default=-1) + 1
    return next_seq


async def _build_sessions(
    conn: asyncpg.Connection,
    rows: list[asyncpg.Record],
) -> list[Session]:
    """Hydrate session rows with hot (non-archived) messages."""
    session_ids = [r["id"] for r in rows]
    messages_map = await _load_messages_map(conn, session_ids)
    sessions: list[Session] = []
    for row in rows:
        all_msgs = messages_map.get(row["id"], [])
        archive_threshold = row.get("archive_threshold", 0) or 0
        hot_msgs = [m for m in all_msgs if m.sequence_number >= archive_threshold]
        messages = [m for m in hot_msgs if m.is_display]
        context = [m for m in hot_msgs if m.is_context]

        s = _session_head(row)
        s.messages = messages
        s.context = context
        s.db_message_count = len(hot_msgs)
        s.db_next_sequence = _next_sequence(row, hot_msgs)
        s.display_message_count = len(messages)
        s.preview = preview_of(messages)
        # What the DB already holds: save() compares against this and rewrites
        # only the rows whose mutable state changed (usually the tail).
        s._saved_fingerprints = saved_fingerprints(hot_msgs)
        sessions.append(s)
    return sessions


async def _load_summary_stats(
    conn: asyncpg.Connection, session_ids: list[str]
) -> dict[str, tuple[int, int, str]]:
    """Display/total message counts and sidebar preview per session, in one query."""
    if not session_ids:
        return {}
    rows = await conn.fetch(
        """
        SELECT m.session_id,
               count(*) AS total_count,
               count(*) FILTER (WHERE m.is_display) AS display_count,
               (SELECT left(m2.content, 60)
                  FROM session_messages m2
                 WHERE m2.session_id = m.session_id
                   AND m2.is_display
                   AND m2.role IN ('user', 'assistant')
                   AND coalesce(m2.content, '') <> ''
                 ORDER BY m2.sequence_number DESC
                 LIMIT 1) AS preview
          FROM session_messages m
          JOIN sessions s ON s.id = m.session_id
         WHERE m.session_id = ANY($1)
           AND m.sequence_number >= s.archive_threshold
         GROUP BY m.session_id
        """,
        session_ids,
    )
    return {
        r["session_id"]: (
            int(r["total_count"] or 0),
            int(r["display_count"] or 0),
            r["preview"] or "",
        )
        for r in rows
    }


async def _build_session_summaries(
    conn: asyncpg.Connection,
    rows: list[asyncpg.Record],
) -> list[Session]:
    """Session rows with only what the sidebar shows: count and preview.

    ``GET /sessions`` used to hydrate every hot message of every listed
    session (content, thinking, tool-call JSON) just to read the last line and
    a count. This keeps the list query proportional to the number of sessions,
    not to their history.
    """
    stats = await _load_summary_stats(conn, [r["id"] for r in rows])
    sessions: list[Session] = []
    for row in rows:
        total, display, preview = stats.get(row["id"], (0, 0, ""))
        s = _session_head(row)
        s.db_message_count = total
        s.db_next_sequence = row.get("next_sequence", 0) or 0
        s.display_message_count = display
        s.preview = preview
        sessions.append(s)
    return sessions


def _special_frag(special: bool | None) -> str:
    """SQL fragment for the special filter; '' when unfiltered."""
    if special is None:
        return ""
    return f"special = {str(bool(special)).upper()}"


def _special_and(special: bool | None) -> str:
    frag = _special_frag(special)
    return f" AND {frag}" if frag else ""


def _special_where(special: bool | None) -> str:
    frag = _special_frag(special)
    return f" WHERE {frag}" if frag else ""