# 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 by the summary formatter (up to 4000 chars, then head+tail halves) instead of the 800-char preview cap |
| `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) |

### Stored service records

A few `role="system"` messages **are** persisted — they record something that
happened in the session rather than carry a prompt: the compression notice
(`is_compression=True`) and the Synapse event record
(`metadata["source"] == "synapse_event"`, carrying the dispatcher's task text
for one platform event). They are kept out of `session.context` reaching the
model by default: `ContextBuilder.build()` filters stored system messages
through an allowlist (`_LLM_SYSTEM_SOURCES`), and only its members
(`task_note`, `synapse_event`) are handed to the LLM. A stored system message
outside that allowlist shows in the transcript and never reaches the model.
The webclient renders the Synapse record as its own card; every other stored
system message is skipped at display time.

## 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:** Prompt 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. The service records described above are the exception: they are stored messages, but only those the allowlist admits ever reach the model.

## 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
- `list_all_summaries(user_id=None, is_admin=False, special=None)` → the same rows and order as `list_all`, but history is not loaded: `display_message_count` and `preview` come from one aggregate query. List views use it, so `GET /sessions` costs ~10–40 ms instead of ~300 ms and never allocates the hot history.
- `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. Like `list_page`, it returns **summaries**: callers that need "where does the term appear" ask `search_matches`.
- `search_matches(session_ids, search)` → `{session_id: (display indices, snippet)}`, one aggregate over the given sessions instead of their loaded histories. Indices are positions in the display history, the numbering clients scroll to.
- `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`).

**Search speed.** `search_list` matches `content ILIKE '%q%'` over `session_messages` and `session_messages_archive`, which without an index is a sequential scan of every message (~400 ms on a 29k-row dev clone, ~1 s on the 391 MB prod table). The store creates GIN trigram indexes on both `content` columns when `pg_trgm` is **already installed** — it does not create the extension itself, because that needs privileges the service user may not have. Install it once per database:

```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
```

The indexes are built on the next store init (`CREATE INDEX IF NOT EXISTS`, a few seconds on 150 MB) and session search drops to tens of milliseconds.

**Counts and match positions.** The sidebar needs a display count and a preview per session, and a search page also needs *where* the term appears. Both used to walk the heap: the message rows are wide (`content`, `thinking`), and the session index has no `is_display` column. `idx_session_messages_counts` covers `(session_id, sequence_number, is_display)`, so the counts and the position numbering are index-only scans once the table is vacuumed (prod, 1.3k sessions: ~500 ms → ~100 ms). The match query numbers positions over that projection and reads `content` only for the rows the trigram index returns; numbering rows together with their content took ~1.7 s for a common term across the largest sessions, against ~100 ms this way.

**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.

### Token accounting and the window

Every window decision — the trigger, the hysteresis target, the hard-truncate budget, `check_context_size`, image budgeting — is made in **real prompt tokens**, not in a character heuristic.

`ContextCompressor.estimate_tokens(context, model)` prices a message list through `navi/core/token_calibration.py`:

- The calibration fits a line `tokens ≈ overhead + chars × tokens_per_char` to the last 8 observations of `(chars of the built context, real prompt_eval_count)`, recorded by `record_real_baseline()` whenever a real count comes back from Ollama. It needs at least two observations whose sizes differ by 1.25× or more, and rejects an implausible slope (outside 1 token/char … 1 token per 4 chars) — which in practice only happens when a prompt was truncated server-side.
- Without a usable line it falls back to `chars / 1.8`. The old `chars // 3` was measured ~1.8× below the real prompt size on prod: "65% of the window" was met on paper while the real prompt stayed above the window, so compression re-fired on the next messages (48 passes in a day on one session).
- Images are priced one by one from their own header (`navi/core/image_tokens.py`): `ceil(w/32) × ceil(h/32) + 2` after a downscale to at most 2048² px, clamped to 256…4096, measured against qwen3.6. A 1024×1024 image costs ~1026 tokens and a 2048² one ~4098; the flat 500 this code used to charge undercounted them 2×–8×, so a session full of screenshots never reached the 90% trigger. An image whose header cannot be read falls back to `context_image_tokens` (1024). Calibration is per model (`calibration_for(model)`), so an observation from one model never prices another, and it lives in a module-level registry because the post-turn worker builds a fresh compressor per pass.
- A prompt that carried images is **not** fed to the calibration fit: image bytes are not in `chars`, so their tokens would land in the fitted intercept/slope and skew every later text estimate for that model.

**What is counted is the view the model is sent.** `real_baseline_estimate()` prices the delta messages after `truncate_oversized()`, and `compress_session`/`would_compress` wrap their estimator in it too. The raw stored context may hold a megabyte-long tool result that `build()` caps per message; counting it as ~167k tokens made the pre/mid-turn gates fire for work the partition intentionally refuses (a single message cannot be split) and, worse, made `check_context_size` end the run with "context too large" over a prompt the model never received. The reported failure — the *first* operation fills the window, compression does not happen and the session dies — was exactly that mismatch: the check printed the truncated size (~18k) in its own error text while deciding on the raw size (167k).

The window is a parameter, not a global. `AgentProfile.context_window` (or `settings.ollama_num_ctx` when unset) is resolved once per turn (`Agent._window_for`) and threaded through `compress_and_save_session(..., window=)` → `compress_context` / `_hard_truncate` / `check_context_size`, and is also sent to Ollama as `num_ctx`. Without this, a model with a larger real window than `ollama_num_ctx` is compressed against the wrong budget.

### 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 `real_baseline_estimate(session.context, session.context)` (real `prompt_eval_count` from the last call + calibrated delta for the messages appended since it; `estimate_tokens` only when no baseline exists yet) and adds the tokens of the incoming message — including its images — which is not yet in `session.context`. Compresses when `tokens >= window * threshold`. Guarded by `would_compress(..., current_tokens=estimated_tokens, model=...)` so `CompressionStarted` is only emitted when the partition can actually shrink the stored context; the dry run reuses the caller's real-token count instead of re-deriving it with a different heuristic.
2. **Mid-turn** (in `run_stream()` → `_compression_events_midturn`, every iteration > 0): estimates tokens via `real_baseline_estimate(session.context, preflight_ctx)` 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 `ctx.context_tokens` from the last call and `WorkerContext.max_context_tokens` as the window) and delegates to `compress_and_save_session(reason="postturn", window=ctx.max_context_tokens)` with the mid-turn `keep_recent_messages` — the same pipeline (retry, hard-truncate, safety net, archiving) as the pre/mid-turn paths. It also compresses unconditionally when `ctx.context_truncated` is set, because a truncated prompt reports about half the window and the threshold can never see it (see **Truncated prompts** below).

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.90` — trigger at 90% of the active window (real tokens)
- `context_compression_target: float = 0.65` — hysteresis target: after compression the kept region should fit ~65% of the active window, so the trigger doesn't re-fire a few messages later
- `context_keep_recent: int = 4` — keep last N conversational turns verbatim
- `context_summary_temperature: float = 0.3`
- `context_summary_max_tokens: int = 6000` — 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, active window // 6)
- `context_image_tokens: int = 1024` — price of one image whose dimensions cannot be read from its header; readable images are priced by area

Per-profile overrides (`AgentProfile`): `context_window` (the window itself), `compression_keep_recent`, `compression_max_tokens`, `compression_prompt_file` — the compression ones are 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, model=profile.model, window=self._window_for(profile))` raises `ContextTooLargeError` when the estimated input exceeds `window - output_reserve_tokens`. The total uses `real_baseline_estimate()` when a baseline is available (real bulk + calibrated delta), falling back to `estimate_tokens()` (calibrated line, `chars / 1.8` without one, plus the per-image cost described above). `output_reserve_tokens` is capped at a quarter of the window, so a deliberately small test/profile window does not fail every check. The error is surfaced to the user as a synthesized assistant response + `StreamEnd` rather than a raw system error.

### Truncated prompts

Ollama does not error on a prompt bigger than `num_ctx`: it silently evaluates about half the window, drops the head of the prompt (system prompt included) and reports only what it evaluated in `prompt_eval_count`. Recording that count as the session baseline pinned the session at ~50% — every later estimate started from the truncated number, the 90% trigger never fired, and the model kept receiving a prompt whose beginning was gone («сессия заполнена», while Navi's own meter said 50%). `record_real_baseline(..., sent_tokens=…)` therefore compares the reported count with our estimate of the prompt that was sent; below 70% (`_TRUNCATION_RATIO`) nothing is recorded, the previous honest baseline survives, and the compressor raises a `context_truncated` flag that the post-turn worker consumes to compress regardless of the threshold. The agent also keeps the session's token count at the honest estimate rather than the truncated one, so the UI meter does not drop after a truncated call.

### Compression algorithm

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

1. Resolve effective `keep_recent`/`max_tokens` from `profile.compression_*` overrides, then run `_plan_compression(context, keep_recent, keep_recent_messages, window=..., estimate=...)` — the single decision point shared with `would_compress()` (dry-run), so the prediction and the real compression can never drift apart. It partitions messages into `to_summarize` (old turns) and `to_keep` (recent `keep_recent` turns), shrinks `keep_recent` until the kept region fits `window × context_compression_target` measured with `estimate` in real tokens (turn-based mode), and retries with `keep_recent_messages=2` when the mid-turn partition found nothing. `window`/`estimate` default to `settings.ollama_num_ctx` and `estimate_context_tokens`.
   - 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 ≈ 10_600` 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, then head+tail halves (2000+2000) instead of collapsing to the non-critical preview; non-critical results are capped at an 800-char preview. Base64 images are collected for vision models up to an image budget (`window × 0.25`, priced per image); images over it are left out of the call, while their `[+ N image(s)]` note still tells the summarizer they existed. Without the cap, a dozen screenshots made a summarization prompt that could not fit its own window, was silently truncated, and produced a summary written as if the history had been read.
4. Truncate formatted input to `_MAX_SUMMARY_INPUT_CHARS = 32_000` chars, keeping head (75%, oldest messages) + tail (25%, the messages closest to the kept window) — a head-only cut used to silently drop the newest summarized work.
5. Call `llm.complete()` with `think=False` to produce a bullet-point summary. The summary prompt asks the model to end with one `KEYS: item, item, …` line — the breadcrumbs of the compressed history (names, commands, paths, config keys, amounts, tickets, decisions, links). `split_keys_line` removes that line from the summary text and returns the keys; a summary without it comes back unchanged.
6. Replace `to_summarize` with a single summary message (`role=user`, `is_summary=True`), carrying the keys in `metadata["breadcrumbs"]`.
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(session, session_store, ..., window=...)` 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` to the calibrated `estimate_tokens(new_context)`, archives old messages when `session_messages_window` is exceeded, persists, and logs `compressor.compressed` with the trigger `reason` (`preturn` / `midturn` / `postturn` / `forced`). It also clears the real-token baseline (the context just shrank).

The demotion is persisted explicitly. `SessionStore.save()` only writes flags for rows present in `session.messages`; an older summary is context-only (`is_display=False`), so flipping `is_context` on it in memory never reached the database and the summary came back on the next load — on prod that left up to 15 live summaries in one session, ~80% of its context. `compress_and_save_session` therefore collects the demoted context-only rows (seq ≥ 0, absent from `session.messages`) and calls `session_store.set_context_flag(session_id, sequence_numbers, False)`, a narrow `UPDATE session_messages SET is_context = …` by sequence number that deliberately does not filter on `is_display`.

All four trigger paths go through `compress_and_save_session`, so a summarizer LLM failure is always non-fatal: retry with a wider keep window, then hard-truncate. `CompressionWorker` only catches genuinely unexpected errors (e.g. a store failure) and logs a warning.

### 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).

### Raw history index

Compression removes messages from the model's window — it does not have to lose them. On every path through `compress_and_save_session` (pre-turn, mid-turn, post-turn, forced `/compact`) the exact messages that were demoted are also written to `memory_chunks` as append-only verbatim chunks with their turn range, timestamps and breadcrumbs (`navi/memory/_chunks.py`, `navi/memory/breadcrumbs.py`; see `docs/memory.md`). Breadcrumbs are the summary's `KEYS:` line merged with entities extracted from the dropped text, which also covers the hard-truncate fallback that never calls a summarizer.

The index is idempotent via `UNIQUE(session_id, content_hash)` and failures never fail a compression. `PgSessionStore.delete()` removes a session's chunks along with the session. Indexing is gated by `memory_search_enabled`.

The agent then reaches the dropped history through `memory_search(scope="raw")` — or automatically: when the context holds a summary, the agent injects up to two relevant excerpts per turn (`memory_recall_enabled`, `memory_recall_k`, `memory_recall_min_similarity`) and remembers what it has already injected in `session_metadata["injected_chunk_ids"]`.

### 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 the file tools or `code_exec` to access the files.

### Images pasted into the chat

Images sent in the `images` field of a chat message (a paste, or the client's image button) are written into the same session directory, under `image_N.<ext>`. The extension comes from the bytes themselves, never from a client-supplied name, and the bytes are stored exactly as they arrived — a PNG keeps its alpha channel, so re-encoding to JPEG never happens.

The numbering runs across a session, not per format: the first paste is `image_1.png`, a later JPEG is `image_2.jpg`. Sending the same picture twice reuses the file already holding those bytes rather than writing a copy.

A notice stating where they landed is appended to the hidden context copy of the message (the copy the LLM sees, not the one the user sees):
```
[2 images attached inline — already in your context, no extra loading needed.
On disk: session_files/{id}/image_1.png, session_files/{id}/image_2.jpg]
```

Images past the context budget are not sent to the model at all, so the notice names them for the `image_view` tool instead:
```
[3 images attached inline — 1 are in your context, the rest were not loaded;
open them with image_view: session_files/{id}/image_2.png, session_files/{id}/image_3.png]
```

The budget is `0.8 × window − estimated context tokens`, and each image is charged its own cost (pixel area, see **Token accounting and the window**) rather than a flat constant — a 1024² screenshot takes four times the room of a 512² thumbnail, and pricing them alike admitted images the model could not actually take.

Before this, a pasted image existed only in the transcript: an agent that had been told `session_files/{id}/` exists went looking there for "the attached images", found nothing, and spent turns on fruitless `find` calls. An image that cannot be stored (no writable directory) is logged as `agent.image_save_failed` and skipped — it never costs the turn.

Hidden turns (`hidden=True`, e.g. form submissions) write nothing: they carry machine payloads, not pastes.

`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. The card renders STL/glTF models, HTML, SVG, PDF, images, video, audio, and text/source files; the type is detected by `navi/content_types.py` from the extension and the file's signature (a PNG saved as `.txt` still opens as an image, a `.ts` that is not an MPEG transport stream opens as text).

---

## Public read-only share

`POST /sessions/{id}/share` issues one capability link per session (`session_shares` table:
`session_id` PK → `sessions(id)` FK cascade, unique `token`, `created_at`). The URL carries
the token in the hash — `https://…/#share=<token>` — so it never reaches the server in a
request line and stays out of access logs and the `Referer` header.

What the holder sees: `GET /shared/{token}` returns the session name, profile name and
dates plus the newest page of display history (`get_history_page`, the same rows the owner
scrolls through); `GET /shared/{token}/messages?before_seq=` pages further back. What the
holder cannot do, by construction: reply (there is no POST), list chats, open the artifacts
panel or files, read the LLM context, or learn the session and owner ids — the payload omits
both. The webclient renders that feed with the owner's own cards but with the rating
controls off and interactive UI components filtered out.

- **Who may issue it** — the session's owner, or an admin. `navi.sessions.read_all` permits
  reading someone else's session, not publishing it; with auth disabled, only ownerless
  (local-mode) sessions qualify.
- **Lifetime** — no expiry and no revoke, by decision. Deleting the session removes the
  share row and the link stops resolving; that is the only kill switch.
- **Pending sessions** — a lazily-created session has no row yet, so the call answers `409`
  until the first message is saved.

---

## 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.
