Session management, dual-buffer design, and context compression.
navi/core/session.py)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)
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) |
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.
InMemorySessionStoreSimple 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 Sessionget(session_id) → Session | Nonesave(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) sessionscount_all(user_id=None, is_admin=False, search=None) → total matching sessionssearch_list(limit, offset, user_id=None, is_admin=False, search=None, sort_by="last_active", sort_order="desc") → paginated, filtered, sorted sessionsdelete(session_id) → boollist_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 profileset_pinned(session_id, pinned) → boolset_name(session_id, name) → boolarchive_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 backRequires 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.
navi/core/compressor.py)Keeps the LLM context within the token budget by summarizing old conversation turns.
Three trigger points:
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.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.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 = Truecontext_compression_threshold: float = 0.70 — trigger at 70% of ollama_num_ctxcontext_keep_recent: int = 8 — keep last N conversational turns verbatimcontext_summary_temperature: float = 0.3context_summary_max_tokens: int = 4000 — max output tokens for the summary LLM calloutput_reserve_tokens: int = 2048 — headroom reserved for the response in check_context_sizecontext_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.
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.
compress_context(context, llm, model, temperature, keep_recent, *, max_tokens=None, keep_recent_messages=None, profile=None):
keep_recent/max_tokens from profile.compression_* overrides. Partition messages into to_summarize (old turns) and to_keep (recent keep_recent turns).
_turn_importance scores each turn; an important old turn can be swapped into the kept set in place of a filler-recent one.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.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.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._MAX_SUMMARY_INPUT_CHARS = 24_000 chars.llm.complete() with think=False to produce a bullet-point summary.to_summarize with a single summary message (role=user, is_summary=True).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.
session.messages — full history is always intact.context_keep_recent conversational turns (or the intra-turn recent window).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.Files uploaded via POST /sessions/{id}/files are stored in session_files/{session_id}/.
session_files_max_size_mb (default: 200 MB)cleanup_loop (started on FastAPI startup) deletes orphaned session directories after their DB session no longer exists..sh, .py, .exe, etc.) are rejected.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.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.