# Sessions

Session management, dual-buffer design, and context compression.

## Session model (`navi/core/session.py`)

```python
class Session(BaseModel):
    id: str                          # UUID
    profile_id: str                  # active profile
    user_id: str | None              # owner (null for legacy sessions)
    messages: list[Message]          # full display history — never compressed
    context: list[Message]           # LLM context — may be replaced with summary
    context_token_count: int         # accumulated tokens; reset to 0 after compression
    pinned: bool                     # pinned sessions appear first in sidebar
    name: str | None                 # auto-generated display name (set after first exchange)
    created_at: datetime
    last_active: datetime
    planning_logs: list[dict]        # raw planning phase outputs per turn (debug)
```

## Message flags

Messages in `session.messages` carry optional flags beyond role/content:

| Flag | Default | Purpose |
|---|---|---|
| `is_context: bool` | `True` | Whether the message is part of `session.context`. Dropped messages are marked `False` rather than deleted — `session.context` is rebuilt from `messages` on load (`[m for m in all if m.is_context]`). |
| `is_display: bool` | `True` | Whether the message is shown in the UI. Summary messages added by compression are `False`. |
| `is_plan: bool` | `False` | Message is a planning phase output (shown as plan card in UI, not text) |
| `is_compression: bool` | `False` | Marker message injected when context compression ran (carries the summary text, `is_context=False`) |
| `is_summary: bool` | `False` | A summary message replacing compressed history in `session.context` (`role=user`) |
| `is_compression_critical: bool` | `False` | Tool result kept verbatim (up to 4000 chars) by the summary formatter instead of capped |
| `is_recall: bool` | `False` | Message was generated by a scheduled recall (styled differently in UI) |
| `thinking: str \| None` | `None` | LLM reasoning captured during a tool-calling turn |
| `metadata: dict` | `{}` | Tool result metadata (e.g. `is_image`, `base64`, `step_text`) |
| `sequence_number`, `elapsed_seconds`, `tool_call_count`, `token_count`, `tool_calls`, `tool_call_id`, `name`, `files`, `images` | — | Per-message bookkeeping (ordering, metrics, tool-call payloads, attachments) |

## Dual-buffer design

Two separate message lists serve different purposes:

| Buffer | Purpose | Modified by compression? |
|---|---|---|
| `session.messages` | Full display history shown in the UI | Never |
| `session.context` | What the LLM sees on each call | Yes — old turns replaced with a summary |

Tool results, image injections, and assistant messages are appended to **both** buffers. When compression runs, only `session.context` is modified.

**Note:** System messages are **not stored** in either buffer. They are injected fresh from the current profile on every LLM call via `_build_context()`. This makes profile switches take effect immediately.

## Session store

### `InMemorySessionStore`
Simple dict-backed store for testing.

### `PgSessionStore` (`navi/core/pg_session_store.py`)
Production store backed by PostgreSQL via asyncpg.

- `create(profile_id, user_id=None)` → new `Session`
- `get(session_id)` → `Session | None`
- `save(session)` — serializes with `model_dump(mode='json')` (required for datetime serialization)
- `list_all(user_id=None, is_admin=False)` → if `is_admin`: all sessions; else: sessions for `user_id` or legacy (`user_id IS NULL`) sessions
- `count_all(user_id=None, is_admin=False, search=None)` → total matching sessions
- `search_list(limit, offset, user_id=None, is_admin=False, search=None, sort_by="last_active", sort_order="desc")` → paginated, filtered, sorted sessions
- `delete(session_id)` → `bool`
- `list_page(user_id=None, is_admin=False, limit=50, offset=0, profile_id=None)` → paginated list with `has_more` flag; `profile_id` filters to one profile
- `set_pinned(session_id, pinned)` → `bool`
- `set_name(session_id, name)` → `bool`
- `archive_old_messages(session_id, threshold)` → moves messages with `sequence_number < threshold` out of the hot table (called by `compress_and_save_session` when `session_messages_window` is exceeded)
- `get_archived_messages(session_id, ...)` → read archived messages back

Requires `DATABASE_URL` env variable (e.g. `postgresql://user:pass@localhost/navi`).

**Ownership:** Legacy sessions (`user_id IS NULL`) are accessible only to admins. New sessions created by authenticated users carry `user_id`. The `list_all()` method respects the `is_admin` flag to filter appropriately.

When `NAVI_AUTH_ENABLED=false`, every session is created with `user_id = 'anonymous'` and all access checks are bypassed. Querying `sessions.user_id = 'anonymous'` is a reliable way to identify sessions created in no-auth mode.

---

## Context compression (`navi/core/compressor.py`)

Keeps the LLM context within the token budget by summarizing old conversation turns.

### When it triggers

Three trigger points:

1. **Pre-turn** (in `run_stream()` → `_compression_events_preturn`): before the first LLM call of a turn, estimates tokens via `estimate_context_tokens(session.context)` (not the stored `context_token_count`) and compresses when `tokens >= num_ctx * threshold`. Guarded by `would_compress()` so `CompressionStarted` is only emitted when the partition can actually shrink the stored context.
2. **Mid-turn** (in `run_stream()` → `_compression_events_midturn`, every iteration > 0): estimates tokens via `real_baseline_estimate(session.context, preflight_ctx)` — real `prompt_tokens` from the previous call (bulk) + a heuristic delta for messages appended since — and compresses with `keep_recent_messages=max(12, context_keep_recent*2)`. This is what keeps long autonomous loops (one user message + many tool iterations = one turn) from exhausting the window.
3. **Post-turn** (via `CompressionWorker`): after `StreamEnd`, the worker re-checks (using the real `context_tokens` from the last call) and compresses if needed, mirroring the mid-turn `keep_recent_messages`.

A fourth, on-demand path is **forced `/compact`** (`compact_stream()`): the client sends `{"type":"compact"}`, bypassing the threshold entirely; emits `CompressionStarted` + `ContextCompressed`; raises `NothingToCompactError` when there is nothing to compress.

Config values (`settings`):
- `context_compression_enabled: bool = True`
- `context_compression_threshold: float = 0.70` — trigger at 70% of `ollama_num_ctx`
- `context_keep_recent: int = 8` — keep last N conversational turns verbatim
- `context_summary_temperature: float = 0.3`
- `context_summary_max_tokens: int = 4000` — max output tokens for the summary LLM call
- `output_reserve_tokens: int = 2048` — headroom reserved for the response in `check_context_size`
- `context_message_token_budget: int = 0` — per-message view truncation budget (`0` = auto, `ollama_num_ctx // 6`)

Per-profile overrides (`AgentProfile`): `compression_keep_recent`, `compression_max_tokens`, `compression_prompt_file` — applied inside `compress_context` / `compress_session` / the summary system prompt. navi_code uses `compression_keep_recent=12`.

### Context size guard

Before every LLM call, `check_context_size(built_ctx, session_context=session.context)` raises `ContextTooLargeError` when the estimated input exceeds `ollama_num_ctx - output_reserve_tokens`. The total uses `real_baseline_estimate()` when a baseline is available (real bulk + heuristic delta), falling back to the `chars // 3 + imgs*500` estimate. The error is surfaced to the user as a synthesized assistant response + `StreamEnd` rather than a raw system error.

### Compression algorithm

`compress_context(context, llm, model, temperature, keep_recent, *, max_tokens=None, keep_recent_messages=None, profile=None)`:

1. Resolve effective `keep_recent`/`max_tokens` from `profile.compression_*` overrides. Partition messages into `to_summarize` (old turns) and `to_keep` (recent `keep_recent` turns).
   - A "turn" = one user message + all following assistant/tool messages up to the next user message.
   - Tool call groups (assistant + results) are never split across the partition.
   - **Adaptive partitioning:** `_turn_importance` scores each turn; an important old turn can be swapped into the kept set in place of a filler-recent one.
   - **Intra-turn fallback** (`partition_current_turn_messages`, when `keep_recent_messages` is set): for a single long turn, keeps the current request + newest N messages verbatim and summarizes older messages from the same turn.
2. **Meta-summary:** if `to_summarize` contains multiple existing summaries totaling > `_META_SUMMARY_THRESHOLD = 8000` chars, consolidate them into one via `_meta_summarize` first so old summaries don't crowd the summarizer input.
3. Format `to_summarize` as plain text. Tool calls are shown as compact previews (max 120 chars for args). **Critical** tool results (`is_compression_critical=True` or critical tool names) survive verbatim up to 4000 chars; others are capped at 300 chars. Base64 images are collected for vision models.
4. Truncate formatted input to `_MAX_SUMMARY_INPUT_CHARS = 24_000` chars.
5. Call `llm.complete()` with `think=False` to produce a bullet-point summary.
6. Replace `to_summarize` with a single summary message (`role=user`, `is_summary=True`).
7. Return `system_msgs + [summary_msg] + to_keep`.

`compress_session` wraps `compress_context` with retry + a **token-budget hard-truncate fallback** (`_hard_truncate`): if the LLM summarization fails twice, it drops oldest turns (keeping system + newest whole turns) until under `_HARD_TRUNCATE_TOKEN_FRAC = 0.5` of the window — a last resort with no LLM call.

`compress_and_save_session` then mutates the session: replaces `session.context`, marks dropped messages `is_context=False`, appends the summary (`is_display=False`) and an `is_compression=True` system marker to `session.messages`, resets `context_token_count`, archives old messages when `session_messages_window` is exceeded, and persists. It also clears the real-token baseline (the context just shrank).

If compression fails, the exception propagates to `CompressionWorker`, which logs a warning and continues — compression failure is non-fatal.

### What is never compressed

- `session.messages` — full history is always intact.
- The last `context_keep_recent` conversational turns (or the intra-turn recent window).
- System messages (never stored in context anyway).

### Events

- `CompressionStarted` — status signal (`context_tokens`, `max_context_tokens`).
- `ContextCompressed` — result (`summary`, `messages_before`, `messages_after`, `context_tokens`, `max_context_tokens`). The TUI renders `summary` as a `Context compressed: N → M messages` card.

---

## Session file uploads

Files uploaded via `POST /sessions/{id}/files` are stored in `session_files/{session_id}/`.

- Max size: `session_files_max_size_mb` (default: 200 MB)
- A background `cleanup_loop` (started on FastAPI startup) deletes orphaned session directories after their DB session no longer exists.
- Executable files (`.sh`, `.py`, `.exe`, etc.) are rejected.
- Duplicate filenames get a numeric suffix.

When files are uploaded via the UI, their paths are appended to the user message content:
```
[Uploaded files on disk:
- filename.pdf → session_files/{id}/filename.pdf]
```

This lets the agent use `filesystem` or `code_exec` to access the files.

`workspace/` is separate from session files. Use `workspace/` for persistent private working files and `session_files/{session_id}/` for files that belong to the current chat.

Two tools expose session files to the user:
- `share_file` copies an existing local file into the session directory and returns a download link. It requires an absolute source path and has its own size limit (`SHARE_FILE_MAX_SIZE_MB`, default 1024 MB).
- `content_publish` registers a file that already exists in the session directory and exposes it as an inline viewer/card through `/sessions/{id}/files/{filename}`. It does not copy files.

---

## Debug endpoints

- `GET /sessions/{id}/context` — returns what the LLM actually sees (may differ from `messages` after compression).
- `GET /sessions/{id}/planning` — returns `session.planning_logs`: raw planning phase outputs per turn.
