| 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
9 hours ago
|
navi-3d: keep OpenSCAD diagnostics, stop flagging valid builtins
...
model_compile threw OpenSCAD's stderr away, so a valid model and one that
silently produced an empty or broken mesh looked identical to the caller. The
actionable part — ECHO values, the Top level object/Facets block, CGAL
warnings — is now kept (cache and timing noise filtered, capped at 4000 chars)
and returned in both `output` and `metadata.diagnostics`.
scad_analyze flagged real OpenSCAD builtins (assert, children, each,
linear_extrude, minkowski, text, render, …) as undefined identifiers, and its
for-loop regex only matched `for` at a word boundary so `intersection_for`
tripped it. Both fixed; render_stl.md spells out what a PNG does and does not
confirm now that the frame is autocenter/viewall normalized.
Eugene Sukhodolskiy
committed
16 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
16 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
17 hours ago
|
e2e: drive the real agent and file tools through the built webclient
...
New e2e/ suite (outside testpaths, so the normal run never collects it):
a throwaway Postgres database, a real navi-server with an isolated temp
CWD/workspace, the built SPA served by the backend and the production LLM
route from llm.json. Covers filesystem_write write/read, filesystem_write
edit plus filesystem_read diff, and file_ai edit, asserting both the
rendered tool cards and the files on disk.
Each run drops its database; MCP servers stay off because the temp CWD has
no mcp_servers.d, and failures save a screenshot, the DOM and the browser
log. docs/e2e.md documents the harness, requirements and knobs.
Eugene Sukhodolskiy
committed
17 hours ago
|
webclient: render file-tool results before the markdown heuristic
...
The numbered read header ("[path | N lines | size]") matched the table
pattern and a numbered diff ("- 1│ beta") matched the list pattern, so
looksLikeMarkdown() claimed both results and the dedicated renderers never
ran. Move the tool/action checks ahead of the heuristic; markdown still
renders for tools that have no dedicated renderer.
Eugene Sukhodolskiy
committed
17 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
17 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
19 hours ago
|
webclient: split the skills catalog panel into composables and style partials
Eugene Sukhodolskiy
committed
20 hours ago
|
webclient: split the artifacts panel into composables and style partials
Eugene Sukhodolskiy
committed
20 hours ago
|
webclient: split the chat store into area modules
Eugene Sukhodolskiy
committed
20 hours ago
|
webclient: split the app styles into partials
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
20 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
20 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
20 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
20 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
20 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
20 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
21 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
21 hours ago
|
webclient: reword the Synapse not-configured hint
...
A navi may receive Synapse events without being an event source: the old
text claimed the whole integration was off. Say that outgoing events are
disabled while incoming events, reactions and app delivery keep working.
Rebuilt dist.
Eugene Sukhodolskiy
committed
21 hours ago
|
| 2026-11-08 |
chat: delete the session from the header menu
Eugene Sukhodolskiy
committed
2 days ago
|
chat: fold session info and sharing into one header menu
...
Two icon buttons next to the context chip left the phone header without
room for the title. They now sit behind a GnDropdown triggered by a single
"more" icon, with the panel anchored to the right edge it opens from.
Sharing stays in the menu only while a session is open; the item labels are
computed so they follow the interface language.
Eugene Sukhodolskiy
committed
2 days ago
|

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
|