| 2026-11-10 |
core: index-only counts and search positions
...
The sidebar's per-session counts and the search page's match positions ran
over the message heap: rows are wide (content, thinking) and the session
index has no is_display, so every counted row cost a heap fetch. A covering
index on (session_id, sequence_number) INCLUDE (is_display) turns both into
index-only scans.
The match query now numbers positions over that projection and reads content
only for the rows the trigram index returns, instead of numbering every
display row together with its content. On prod (1.3k sessions, 77k messages)
a common term across the largest sessions went from ~1.7 s to ~0.1 s, and the
summary aggregate from ~0.5 s to the index.
Eugene Sukhodolskiy
committed
10 hours ago
|
core: session lists read aggregates, search matches from SQL
...
GET /sessions without paging hydrated every hot message of every listed
session just to return a count and a preview: 313 ms locally, and the whole
hot history allocated. `list_all_summaries` returns the same rows in the same
order with the count and snippet from one aggregate — 30 ms.
`search_list` now returns summaries like `list_page`; "where does the term
appear" comes from `search_matches`, one aggregate that reproduces the display
indices clients scroll to (verified equal to the old Python scan on 105
sessions). The API search path drops from ~500 ms to ~150 ms, most of which is
the ILIKE scan itself — see docs/sessions.md for the pg_trgm note.
The stale-session sweep on POST /sessions takes its candidates from summaries
and loads a history only for a session that actually needs extraction.
Eugene Sukhodolskiy
committed
16 hours ago
|
protocol, clients: acks, queue notices and reconnect without duplicates
...
- client_id is echoed in message_accepted / message_queued; queue overflow
broadcasts message_dropped to every socket watching the session
- stream_end / stream_stopped carry message_index, the global display rank
that also stamps history rows
- webclient: cheap session_sync via /sessions/{id}/meta, the ack deadline is
armed at the write and re-armed while a run is active, fatal 4003/4004 stop
the reconnect loop instead of retrying forever
- TUI: WsBridge posts WsResumed / MessageWritten / SessionUnavailable, the app
drops the half-written turn on reconnect, shows queued and not-delivered
markers on the bubble, and reloads history only when the meta token changes
- dist rebuilt
Eugene Sukhodolskiy
committed
17 hours ago
|

core, webclient: cheap session lists, changed-row saves, smaller bundle
...
The sidebar asked for a count and a 60-char snippet and got every hot
message of every listed session: 2,097 rows / 4.4 MB for 507 sessions,
plus a full Pydantic hydration. list_page now aggregates counts and the
last displayed line in SQL (4.0 ms, 30 rows); get_meta folds its COUNT
into the same round trip.
save() runs after every streaming step and rewrote the whole hot
history each time — 1,016 UPDATEs, 46.8 ms, per call, growing with the
session. A fingerprint of the mutable columns lets it touch only the
rows that changed, which is normally the tail.
HTTP: content-hashed /assets are immutable for a year, gzip drops to
level 6 (same ratio within a couple of percent on JSON, half the CPU),
and session search gets pg_trgm GIN indexes when the extension exists.
Client: one shared highlight.js on lib/common plus the languages we
actually render (openscad included) instead of the full ~190-language
entry point, and the unused @tanstack/vue-virtual is gone.
Bundle 1566 -> 760 kB (gzip 495 -> 238 kB).
Eugene Sukhodolskiy
committed
17 hours ago
|

webclient: make session loads cheap, cancellable and honest
...
Opening a session moved ~1 MB of history JSON and a 1.5 MB bundle
uncompressed, then reloaded the whole transcript on every WebSocket
reconnect; a failed request wiped what was on screen.
Server: GZipMiddleware(1 KB) - one history page shrinks 5.1x, the SPA
bundle 3.2x.
Client:
- session_sync asks /meta instead of reloading history, and only when
the view is idle;
- session loading keeps the previous transcript until the new data
arrives, aborts the request it replaced, ignores stale responses and
surfaces a retryable error banner;
- search-jump pagination no longer pages through the previous session's
history;
- REST calls get a 30 s timeout and caller abort support;
- WS queue overflow rejects the message and marks the bubble "not sent"
instead of silently dropping the oldest;
- tool card bodies mount on first open (a 38-card session drops ~1 MB of
markup and ~25k elements);
- identical /sessions requests coalesce, the streaming watch is a cheap
signature, the content-tool key is one store computed, useTime shares
one 30 s ticker, connect() clears the retry timer.
e2e opens result cards explicitly now that bodies are lazy.
Eugene Sukhodolskiy
committed
18 hours ago
|
| 2026-11-09 |
tools: split filesystem into read, write and file_ai
...
- filesystem_read: read, list, find, find_up, grep, info, exists, diff
- filesystem_write: write, append, edit, edit_lines, insert, mkdir, move, copy, delete
- file_ai: ask (was query), edit (was smart_edit)
- paged numbered reads (500 lines / 50 KB), raw copy mode, binary refusal
- nearest-match and numbered-copy hints on failed edits, context after edit
- guards: empty overwrite, non-empty delete, move/copy overwrite
- cross-tool redirects instead of silent wrong-action errors
- legacy 'filesystem' stays for archived sessions (compression, renderers, webclient)
- profiles, prompts, docs and manuals updated; TUI and webclient render the new tools
Eugene Sukhodolskiy
committed
18 hours ago
|
core: anchor restricted-profile boundaries in subagent prompts
...
Sub-agents of non-admin profiles replaced the parent system prompt with
their own worker prompt, so the "one directory, no network, no secrets"
rules never reached them. build_subagent_system_prompt now appends a
[Boundaries] block for every non-admin profile, whatever prompt was
chosen.
Align the profile prompts with the actual tool surface alongside it:
allow 3D sub-agents to draft geometry but not publish or decide, publish
a preview PNG with the STL in both 3D profiles, document the assistant's
remaining tools, canonicalise MCP names, and fix the developer/navi_code
spawn ending, the server_admin field list, the discuss memory_search
guidance, and the navi-3d/navi-web/gnexus-book instructions.
Eugene Sukhodolskiy
committed
20 hours ago
|
core: split the agent and context builder into packages
...
Agent becomes one class assembled from mixins: runtime (turn lifecycle),
streaming (the turn loop), tools (tool execution), compression (context
gates) and context (tool lists, backend lookup, window). The module-level
helpers and sentinels move to helpers.py.
ContextBuilder splits into helpers (pure truncation/freshness/prompt
blocks) and builder (the class). Both old modules become packages with
the same import surface, so navi.core.agent.Agent and
navi.core.context_builder.ContextBuilder keep working.
Method bodies are unchanged; tests now patch settings/log/should_compress
on the mixin that actually reads them.
Eugene Sukhodolskiy
committed
21 hours ago
|
api: split the admin routes into a package
...
The 796-line module becomes one /admin router plus area modules: common,
sessions, users, memory, profiles, mcp, runtime and recalls. The package
re-exports every view so navi.main and existing imports keep working.
Route set, order and status codes are unchanged.
Eugene Sukhodolskiy
committed
21 hours ago
|
api: split the sessions routes into a package
...
The 1064-line module becomes one /sessions router plus area modules:
common (models and helpers), listing, detail, files, messages, naming,
manage, recall. The package re-exports every view so existing imports
keep working.
Route set, order and status codes are unchanged; tests now patch
settings/asyncio where they are actually read (the submodules).
Eugene Sukhodolskiy
committed
21 hours ago
|
tools: split the filesystem tool into a package
...
`tools/filesystem.py` was 1145 lines: path/format/diff/AI-text helpers at the
top, then a `FilesystemTool` class with every action handler. Move the pure
layer out into `navi/tools/_filesystem/`:
* `helpers.py` — path checks, formatting, line numbering, unified-diff
annotation, chunking, edit-op validation/application, AI prompts;
* `tool.py` — the dispatcher and its read/write/AI handlers.
The package is underscore-prefixed because `navi/tools/filesystem` also has
to stay available as the compatibility shim at the same import path. No
behavior change: the shim re-exports every top-level name the module had, and
the tests patch `settings` on the modules that now read it.
Eugene Sukhodolskiy
committed
21 hours ago
|
core: split the Postgres session store into a package
...
`pg_session_store.py` mixed three layers: schema DDL and migrations, the
row <-> `Message` mapping with its archive-aware hydration, and the store
itself. Split them into `navi/core/sessions/`:
* `ddl.py` — table/index definitions and the one-shot migrations;
* `rows.py` — serialization, dangling-tool-call repair, session hydration;
* `store.py` — `PgSessionStore` and the pending-sweep loop.
`navi/core/pg_session_store.py` stays as a compatibility shim re-exporting
both the public surface and the private helpers the tests use. No behavior
change: every top-level function and every store method is preserved.
Eugene Sukhodolskiy
committed
21 hours ago
|

core: split the compressor into a package
...
`navi/core/compressor.py` was 1355 lines covering three different jobs:
deciding what a pass summarizes, building and calling the summarizer, and
the retry/truncate/persist wrapper around it. Move each to its own module:
* `compression/partition.py` — pure partitioning and the single
`_plan_compression` decision shared with `would_compress`;
* `compression/summarize.py` — prompt formatting, the KEYS line, the
meta-summary and `compress_context`;
* `compression/compressor.py` — `ContextCompressor` and its fallbacks.
`navi/core/compressor.py` stays as a compatibility shim that re-exports the
public surface, so imports elsewhere are untouched. Because each module now
binds its own `settings`, the tests that override settings patch all of them
(and `_meta_summarize`/`compress_context` are patched where they are read,
not on the shim). Adds a regression test for the hard-truncate trim inside a
single oversized turn, which the moved `_split_turn_tail` import silently
broke (ruff F821, no test hit that branch).
Eugene Sukhodolskiy
committed
21 hours ago
|
memory: fix review findings in unified search
...
- index each compression-dropped turn once: messages and context share
objects, and earlier drops stay in the display history
- label raw excerpts against facts even when recall asks for scope=raw
- top up short vector results from the lexical index (NULL embeddings)
- count whole content words only and cap query terms at 12
- carry source_session_id on fact hits
- cap injected_chunk_ids at 100 entries
- keep a session delete successful when chunk cleanup fails
- stop logging query text in the memory paths
- stop fabricating similarity scores for recent chunks
- consult session_messages_archive in latest_summary_at
Eugene Sukhodolskiy
committed
21 hours ago
|

memory: one search across facts, raw history and sketches
...
Compression removes messages from the model's window, not from the
conversation: every compression path now writes the turns it demotes into
`memory_chunks` — verbatim, append-only, with the turn range, timestamps and
breadcrumb keys (the summarizer's `KEYS:` line plus a deterministic extractor,
which also covers the hard-truncate fallback that never sees a summarizer).
`memory_search` is the single read path over the three stores: curated facts
first, then raw excerpts by relevance, then the current session's sketches.
Every hit carries provenance (source, session, turn range, timestamp), and a
raw excerpt that a newer curated fact overrides is labelled outdated rather
than hidden — "we changed our mind, the old value was X" is often the
question. The agent also gets up to two relevant excerpts injected per turn
once the context holds a summary, deduped across turns.
Fixes found by running the pilot against a real database:
- `search_facts` returns ISO-string timestamps while chunks return datetimes;
the freshness comparison raised TypeError and took the whole search down.
- an existing `memory_chunks` table created without pgvector never gained the
`embedding` column (CREATE TABLE IF NOT EXISTS does not alter), and the
INSERT failed on it; the DDL now adds the column, and a database without
pgvector writes text-only rows found through FTS.
Eugene Sukhodolskiy
committed
22 hours ago
|

context: price images by area and detect backend-truncated prompts
...
Every window decision priced an inline image at a flat 500 tokens, while a
1024x1024 screenshot costs ~1026 on qwen3.6 and a 2048x2048 one ~4098. The
undercount kept sessions below the 90% trigger; Ollama then silently truncated
the prompt to about half the window (dropping the system prompt), and the
truncated prompt_eval_count was cached as the session baseline, pinning the
estimate at ~50% for good - the session never compressed while the model was
already told the context was full.
- image_tokens.py prices each image from its header (one token per 32x32 px,
downscaled to <=2048^2, clamped to 256..4096); unreadable headers fall back
to CONTEXT_IMAGE_TOKENS. Used by both estimators, the delta and
check_context_size; the per-turn image budget fits images by their own cost.
- A prompt reported far below what was sent is a truncation: it is not cached
as the baseline, and the post-turn worker compresses regardless of the
threshold. The session keeps the honest token count.
- The pre-turn gate prices the incoming message and its images, which are not
in session.context yet.
- The summarizer receives images under a window-scaled budget, and prompts
carrying images stay out of the calibration fit (their bytes are not in
chars and would skew it).
- Oversized user messages are capped for the LLM view (at least num_ctx/3),
so a paste bigger than the window no longer dead-ends a session.
- The in-flight partition also trims by token budget: a gate that fires can
now actually shrink a turn made of a few huge messages.
Eugene Sukhodolskiy
committed
22 hours ago
|
| 2026-11-08 |

sessions: share a chat as a public read-only link
...
A session's owner can hand out one capability link that opens the display
history without an account: no input, no chat list, no artifacts panel.
The token lives in the URL hash, so it never reaches the server in a request
line or a Referer header.
The link is permanent by decision - no expiry, no revoke. Deleting the chat
removes the share row (FK cascade) and closes it, which is the only switch.
Backend: session_shares table (unique token, FK cascade), POST
/sessions/{id}/share issuing the session's one link (owner or admin; a
read-all permission may read another user's chat but not publish it), and
public GET /shared/{token}[/messages] serving display history only, with
session and owner ids left out of the payload.
Webclient: #share=<token> renders a standalone page with the owner's cards,
rating off, interactive UI components filtered out and scroll-up paging; a
share button in the chat header opens a dialog that creates the link.
Also fixes ChatHeader's rename, which called an undefined `api` and failed
silently.
Eugene Sukhodolskiy
committed
2 days ago
|
llm: rotate the routing config under a running process
...
A key that lives in the central config is only worth centralizing if
publishing it does not cost a fleet restart. The backend bakes host and
Authorization header into its clients, so it now accepts a fresh
LLMConfig: clients are dropped and rebuilt on the next call, health
stays keyed by target name, and the probe follows the new hosts too.
The overlay's llm section is no longer always staged. It is re-parsed
against the local document and swapped live; only a backend without the
swap method or a document that fails to parse falls back to
restart_required.
Eugene Sukhodolskiy
committed
2 days ago
|
overlay: ack the config again after a restart
...
A document that needed a restart was acked as 'staged, restart required'
and never updated afterwards: the machine restarted into the new config but
the hive kept showing the old ack. Startup now re-reports what it is running,
so the status view reflects reality instead of the last live refresh.
Eugene Sukhodolskiy
committed
2 days ago
|
overlay: report llm and mcp changes as restart_required
...
A live refresh cannot swap LLM backends or the MCP pool — both are built
with the container — so a document that changes those sections was reported
as applied while the old wiring kept running. Compare the sections against
the previous document and stage them by name.
Eugene Sukhodolskiy
committed
2 days ago
|
| 2026-11-07 |

navi: overlay the hive config onto the instance's own
...
An instance that knows a hive with the swarm key starts from the hive
document: layers that apply to it are merged over its local config, fields
the hive does not mention stay local, and an empty answer means 'run exactly
your own config'. Settings, profiles, LLM routes and MCP servers are covered;
database URL, ports, auth switches and the channel's own settings can never be
overridden.
Startup fetches with a short timeout, falls back to the last validated overlay
on disk and refuses to crash-loop on a document that killed the previous start.
A live refresh applies secrets and timeouts in place (the frozen singleton is
written through one deliberate escape hatch), rebuilds profiles from their
local base and reports everything else as restart_required. The hive reaches
the instance by push, by the announce reply and by a retry sweep; every apply
is acked back, which is what the hive's status view reads.
Eugene Sukhodolskiy
committed
2 days ago
|
profiles: teach the agent to scout profiles and tools before switching
...
Persona gains a reconnaissance order (list_profiles, then list_tools for the
real toolset, then tool_manual) and the switch-vs-delegate rule: switch when
the task itself is another profile's work, delegate a side piece with
spawn_agent. The profiles block is built in one place and shared with the
admin prompt debug view, and the stale 'next user message' claim is fixed.
list_tools applies the admin-only role gate to an explicitly named profile,
the same one list_profiles uses, so it cannot enumerate hidden profiles.
Eugene Sukhodolskiy
committed
2 days ago
|
skills: re-authorize a replacement when the proposal is accepted
...
A proposal proved ownership when it was written, but the protocol can be
promoted to shared while it sits pending. Check again at the click: a shared
protocol is now admins only, a personal one belongs to its owner.
Eugene Sukhodolskiy
committed
2 days ago
|
webclient: read the skills catalog as documents, split it into two tabs
...
Settings had one long panel mixing skills and protocols as raw markdown,
which was hard to read and wasted the width. Skills and Protocols are now
two tabs over one panel keyed by kind.
Selecting an entry shows the parsed document — triggers, preconditions,
steps, post-checks, escape hatches, body — and an Edit button switches to
the source, so the catalog is read the way the agent loads it. The
single-entry payload carries the parsed document; the list still does not,
so it stays cheap. SkillDocument is shared with the chat proposal card,
which now only draws its own frame and header.
Eugene Sukhodolskiy
committed
3 days ago
|
skills: name the kind when a document already had one implied
...
A proposal is parsed as a protocol when it is stored, but accepting it
re-parsed the same text with no override, so a front matter that left
'kind' out made the catalog refuse a document the user had just agreed
to. The create path and the agent's own skill writes had the same gap.
Eugene Sukhodolskiy
committed
3 days ago
|
prompt: give the methodologies catalog its rules in the system prompt
Eugene Sukhodolskiy
committed
3 days ago
|
profiles: give every working profile the skills and protocol tools
Eugene Sukhodolskiy
committed
3 days ago
|

protocol: run a protocol as a committed plan, with a log of how it ended
...
A protocol was only text until now. The new `protocol` tool starts one: its
steps are spliced into the session's todo plan after the agent's current
position (nothing already done is reordered), a row appears in `protocol_runs`
with a snapshot of the version, steps and post-checks, and the run stays
`running` until it is closed.
`finish` refuses while any step of the run is open, refuses outright when one
failed, and requires a report — it is the run's record for the user. `cancel`
and `fail` require a reason and mark the rest of the run's steps `skipped`, so
the plan does not keep work that was never done. One run per session is active
(a partial unique index enforces it).
The commitment also has to survive the context: every model turn while a run is
open carries a system reminder with the protocol, its steps and the way to close
it, and the final-turn intercept now fires for an open run even when the todo
shows no open steps, so a bare-text turn cannot leave one running.
`protocol` is wired into assistant, developer, discuss and server_admin next to
`skills`; sub-agents get neither, because a protocol belongs to the session's
plan. The run log has no UI yet — `protocol(op='status')` is the current view.
Eugene Sukhodolskiy
committed
3 days ago
|

skills: agent-written skills and protocol proposals with chat buttons
...
A skill the agent writes now lands in the catalog on its own; a protocol
never does. `skills(op="propose")` stores the document in `skill_proposals`
and returns a card through the tool result's `ui_component` metadata — no
`render_component` call, so profiles without `navi_ui` get the card too.
The decision is the server's: the browser sends `skill_proposal` over the
WebSocket, `SkillStore.decide_proposal` writes the catalog row from the
*stored* document, names the clicking user as the author, and answers with
`skill_proposal_decided`. The agent learns the outcome as a hidden user
message, the same path a `navi_ui` form submission takes.
`create`/`update` refuse protocol documents and point at `propose` with
`replaces_id`, so the version history of a protocol always starts from a
user's yes. The chat store now renders `ui_component` from any tool whose
result carries one, not only from `mcp__navi_ui__render_component`.
Eugene Sukhodolskiy
committed
3 days ago
|

skills: catalog of skills and protocols with a settings tab
...
One registry for reusable methodologies: markdown with YAML front matter,
stored in PostgreSQL alongside a full edit history. A row is either a
`skill` (advice, adapt as needed) or a `protocol` (a committed order of
actions), and a protocol is refused on save unless it declares its steps,
post-checks and the three escape hatches — on_deviation, on_blocked,
on_already_done — because an honest run needs a way out when reality
differs from the plan.
- `SkillStore`: lazy DDL, uniqueness on (owner_id, kind, name), version
bump with an appended `skill_versions` row, `content_hash` as the only
hive-sync hook. Personal rows carry an owner; shared rows have an empty
one and are writable by an admin.
- REST `/skills` (list/get/create/update/delete/versions) and the agent
tool `skills` (search/load) that renders a compact document — numbered
steps and escape hatches for a protocol, the instructions for a skill.
- Settings → Скилы и протоколы: catalog with search and kind/area
filters, a document editor that shows the server's validation error
verbatim, create from a template, delete, edit history, and the
personal/shared switch for an admin.
Manuals stay where they are: tool knowledge, separate from this catalog.
Eugene Sukhodolskiy
committed
3 days ago
|