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 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) |
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 real_baseline_estimate(session.context, session.context) (real prompt_tokens from the last call + heuristic delta for the messages appended since it; chars // 3 heuristic only when no baseline exists yet) 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) 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 delegates to compress_and_save_session(reason="postturn") with the mid-turn keep_recent_messages — the same pipeline (retry, hard-truncate, safety net, archiving) as the pre/mid-turn paths.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.90 — trigger at 90% of ollama_num_ctxcontext_compression_target: float = 0.65 — hysteresis target: after compression the kept region should fit ~65% of the window, so the trigger doesn't re-fire a few messages latercontext_keep_recent: int = 8 — keep last N conversational turns verbatimcontext_summary_temperature: float = 0.3context_summary_max_tokens: int = 6000 — 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 + 500 per image 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, then run _plan_compression — 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 the 65% target (turn-based mode), and retries with keep_recent_messages=2 when the mid-turn partition found nothing.
_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 ≈ 10_600 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, 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._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.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, persists, and logs compressor.compressed with the trigger reason (preturn / midturn / postturn / forced). It also clears the real-token baseline (the context just shrank).
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.
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.