| 2026-10-07 |
config: run every profile on glm-5.3-flash:cloud first
...
Four chains still led with gemma4:31b-cloud, so those sessions were resolved
onto gemma4 even though glm-5.3-flash:cloud is the instance default. glm now
leads every profile.
The rest of each chain stays behind it as a fallback — that is what carried
today's 16:57 run through a ReadTimeout against ollama.com instead of failing
it. developer, navi_code, discuss and modeler_3d already led with glm and are
untouched.
Eugene Sukhodolskiy
committed
9 hours ago
|
config: give tgclient the shared key it needs to handshake
...
The server declares a user_key slot and no shared credential, so the
startup handshake went out unauthenticated, got a 401, and the client
was marked disconnected — which kept its tools out of the registry
entirely, groups or no groups. The shared key now backs the handshake
and stays the fallback: a personal key from /mcp-keys still wins.
Eugene Sukhodolskiy
committed
9 hours ago
|
webclient: hold the chat at the bottom when a stream ends
...
The list was pinned per streaming delta, but the last things to land arrive
after that final pin: the stats/rating footer, which renders only once
msg.done is set, and the copy buttons attached to code blocks after render.
Nothing re-clamped afterwards — the length watcher never fires (the message
stays in the array) and the landing loop only runs when a session opens — so
the view was left short of the bottom, looking like it had scrolled up.
Re-clamp through the same settle window the landing uses when streaming goes
true -> false, unless the user has scrolled up or a session is loading.
This addresses the late-layout half of the problem; the row is still remounted
when msg.id becomes h_<n>, which is the other source of a jump.
Eugene Sukhodolskiy
committed
9 hours ago
|
config: tool groups for tgclient and synapse
...
Both servers declare no groups, so every profile asking for "tgclient":
["read", "write"] resolved to nothing: resolve_group reads the static
config, returned [], and no mcp__tgclient__* name ever reached the agent.
Worse, the server's own instructions did reach it — they are selected by
server name, not by group — so the model was told about tools it did not
have. synapse is not exposed by any profile, so its groups change nothing
today; without them, exposing it would repeat the same failure.
read — observe only.
write — changes to sources, types, targets and routing rules.
admin — hub-wide knobs, not routing: issuing and revoking a source's API
key, and settings overrides on top of .env. The same fence
gnexus-book puts around its service-operator tools.
Eugene Sukhodolskiy
committed
10 hours ago
|
deps: declare html2text, which tools/gmail.py imports
...
The import worked on the server only because the package had been
installed into the venv by hand; a fresh sync would have pruned it and
tools/gmail.py would have stopped loading while tools/enabled.json kept
naming gmail. Reload now reports that drift, but the fix is to declare
the dependency. Lock gains html2text and nothing else.
Eugene Sukhodolskiy
committed
10 hours ago
|
webclient: reload tools from a button in the MCP tab
...
Settings → MCP gains a Tools block for admins only: one button, then the
same report the tool prints — what loaded, how many are in the registry,
per-file errors, and names in enabled.json nothing answers to.
It sits inside the existing MCP tab rather than a new one: the reload
rewrites the toolset of the whole server, not just this user's MCP keys,
and it belongs next to the thing it affects. Non-admins never see it.
dist rebuilt together with the source, as the server serves the bundle.
Eugene Sukhodolskiy
committed
10 hours ago
|
webclient: keep MCP rows at content height on a phone
...
.mcp-keys-row stacks into a column below 768px, and .mcp-keys-info kept
its flex: 1 1 240px. That basis is a width in the desktop row and a
height once the row stacks, so every server reserved 240px and its text
sat at the top of the gap — 402px for a row whose content is 90px.
Back to content height on mobile only, and drop the kit's .form-group
bottom margin there: the row's own flex gap already separates the field
from the buttons.
Eugene Sukhodolskiy
committed
10 hours ago
|
admin: POST /admin/tools/reload
...
reload_tools is granted to one profile only (tool_developer), so an admin
whose profile lacks it cannot reload at all — the tool answers "not
found" instead of reloading. The route calls the same reload_all() the
tool does, behind require_admin, for whoever is logged in as an admin.
An admin may read: ok, the tools loaded, the registry total, per-file
errors, names in enabled.json nothing answers to, and the MCP/providers
summary.
Eugene Sukhodolskiy
committed
10 hours ago
|

reload: pick up the new code, and drop MCP tools that are gone
...
reload_tools reported success while three separate things kept it from
doing what it says.
The bytecode cache is keyed on (mtime in whole seconds, file size), so a
tool edited to the same length inside the same second as its previous
load re-ran the OLD code — the reload was real, the new version was not
live. The loader now compiles the source itself instead of consulting
__pycache__ (importlib.invalidate_caches() does not help here).
MCP registrations only ever grew: register_mcp_tools called
register_external, and unregister_external was used in one place, so a
server removed from the config or a tool a server stopped exposing
stayed in the registry and failed only when the model called it. Reload
now clears external tools and rebuilds them.
enabled.json naming a tool that failed to load (the gmail/html2text case)
was visible only as a log line. It is now part of the report.
The reload itself moves to navi/core/reload.py, one implementation shared
by the tool and the admin route, so the two cannot leave different
toolsets behind. list_tools.py also read enabled.json through its own
cwd-relative path, which made it disagree with the real toolset, and the
class-based loader rejected execute(self, params, ctx=None) — the shape
every built-in uses.
Eugene Sukhodolskiy
committed
10 hours ago
|
config: point the tgclient MCP server at the live host
...
The committed value was the local dev address (localhost:8710, where
~/Projects/tgclient-mcp serves it) and the server ran with the wrong public
host, so the tools never connected. Both clones now use
https://tgclientmcp.gnexus.space/mcp.
No default headers: the key is per-user by design (BYOD settings field), and
until one is entered the server answers 401.
Eugene Sukhodolskiy
committed
10 hours ago
|
switch_profile: run the switch alone, then the batch on the new tools
...
A tool called in the same batch as switch_profile was still dispatched against
the old tool_map and died with "tool 'X' not found" — reload_tools did, twice
in one session. The batch is now split: the switch runs first, its
ProfileSwitched event is watched for the target profile, the tools are
re-resolved from it, and the remaining calls run against the new set.
The turn's own profile binding is deliberately left alone — the
end-of-iteration reload in run_stream() is what rebinds profile/llm/schemas,
and pre-setting session.profile_id here would make it skip that step.
Eugene Sukhodolskiy
committed
10 hours ago
|
switch_profile: deliver profile_switched, report the new toolset
...
The tool took its sink as `ctx.event_sink if ctx else current_event_sink.get()`,
but the agent loop builds tool_ctx with event_sink=None — the ContextVar is the
real channel — so the branch always picked None, the event was silently
dropped, and the profile badge in the header never moved. plan.py already had
the fall-through (`if ctx and ctx.event_sink else current_event_sink.get()`);
this is the same fix.
The result also said nothing about what the switch changed, so the model kept
calling tools the target profile does not have (reload_tools after leaving
tool_developer). It now names the gained and lost tools, and the timing claim
is corrected: the new prompt and tools are in force from the next step of the
same turn, not "from the next message".
Eugene Sukhodolskiy
committed
10 hours ago
|

Allocate message sequence numbers in the DB, not in memory
...
save() numbered new messages from Session.db_next_sequence, a counter read
when the session was loaded. Any second writer of the same session handed
out the numbers it still believed were free, and the two inserts collided on
UNIQUE(session_id, sequence_number) — in production, when switch_profile
loaded the session mid-run and saved it back. The turn died with
"Internal error: duplicate key value violates unique constraint".
The range is now claimed inside save()'s transaction with a single
UPDATE ... RETURNING, so concurrent writers serialize on the session row,
and GREATEST() seeds the pre-counter sessions whose next_sequence is still 0
instead of relying on a racy max()+1 fallback in memory.
switch_profile itself no longer saves a session at all: it repoints the row
through a narrow set_profile() UPDATE, which keeps it out of the running
turn's way. It runs mid-turn on a session the turn still holds, so saving a
second copy from there was the collision in the first place.
Eugene Sukhodolskiy
committed
10 hours ago
|
config: add the gntodo MCP server
root
committed
11 hours ago
|
config: capture the live server configuration
...
Profiles gain the gntodo / gnexus-book / gnexus-creds scopes for agent and
subagent runs, refreshed model lists, and write access where the live setup
has it. MCP server configs pick up their user_key slots, gnexus-book learns
delete_pending_change, and the hard-panel / synapse / tgclient servers join
the tree.
root
committed
11 hours ago
|
Merge remote-tracking branch 'origin/master'
root
committed
11 hours ago
|
Await the session pool in notify and three neighbours
...
PgSessionStore._get_pool() is async, and four call sites passed its coroutine
straight into a store constructor, so the first query inside died with
"'coroutine' object has no attribute 'fetchrow'": notify always failed, the
reaction runner never got past reading its settings, synapse_instructions was
unusable, and the BYOK resolver caught the AttributeError and silently fell
back to the default credential.
The tests missed it because each one mocked the store or the pool provider
away; the new ones run on a fake asyncpg pool with nothing faked below the tool.
Eugene Sukhodolskiy
committed
11 hours ago
|
Merge origin/master (b7743f8): PWA icons, MCP settings tab, android push, runbook
root
committed
12 hours ago
|
webclient: serve the PWA artwork past the images cache
...
/images/* is served cache-first from IMAGES_CACHE, which activate deliberately
keeps across builds. That is right for content whose URL is unique and wrong
for the app's own artwork: /images/icon-*, /images/logo-icon* and
/images/apple-splash/* keep their URLs while their contents change with the
logo, so a browser that had once loaded an icon would keep showing the old one
even after a deploy — and the apple-touch-icon is linked from index.html, so it
does travel through the page and the service worker.
Those paths now go network-first with a cached fallback (offline still works);
every other /images/ request is untouched.
Tests: frontend 148 passed; backend 1367 passed, 1 skipped.
Eugene Sukhodolskiy
committed
12 hours ago
|

webclient: draw the PWA icons at full size again
...
The maskable icons and the apple-touch-icon carried the mark at 21% of the
canvas — the artwork scaled down and pasted in the centre — so on a home
screen the logo read as a fragment of itself. The mark takes 73.4% of the
canvas in logo.svg and in the launcher tile of the Android app icon; that is
the proportion all five files use now.
scripts/gen_pwa_icons.py redraws the mark from logo.svg's geometry with
Pillow (already a project dependency, no SVG rasterizer needed) and writes the
whole set, so the scale lives in one constant; --check measures what is on
disk and reports SUSPECT if it drifts again. Two runs produce identical bytes.
The regenerated icon-192/512 are geometrically identical to the rsvg-rendered
ones they replace: ink bbox 376x376 with 68px insets at 50% coverage, and the
same ink mass across the stroke. Only the antialiasing bytes differ.
dist/ rebuilt (it carries a copy of public/ and is served by the backend).
Tests: frontend 148 passed; backend 1367 passed, 1 skipped.
Eugene Sukhodolskiy
committed
12 hours ago
|
deploy: runbook for updating a running instance
...
Adds deploy/UPDATE.md: preflight, the branch shapes (master vs the
tracked .env on deploy), what to rebuild before pushing (the frontend
dist is committed and the server never builds it), the verification
commands that prove the new bundle is the one being served, rollback,
and the traps — tracked secrets in mcp_servers.d/, gitignored vendor
kit dist, service-worker cache, Android app needing only a restart.
Linked from deploy/README.md.
Eugene Sukhodolskiy
committed
12 hours ago
|

MCP settings tab: list every connected server, slot only where declared
...
The tab was empty on every install: it listed only servers whose config
declares a `user_key` slot, and no config declared one — which read as
"no MCP servers connected" even though five are wired to profiles.
- GET /mcp-keys now returns every server referenced by at least one
profile, keyed ones first, with `accepts_user_key`, the slot location
(null when there is none) and the profile ids that connect it. The
per-user key store is skipped entirely when nothing has a slot.
- gnexus-creds declares `user_key: {header: Authorization, prefix:
"Bearer "}` — it is the one server carrying a shared credential, so its
personal-key field is now real: users with a key run under their own,
users without one fall back to the shared default.
- The panel lists all servers (transport + profiles), dims the keyless
rows, and shows a key input only for slotted ones, spelling out the
shared-key fallback.
docs/api.md and docs/mcp.md updated; backend 1367 passed, webclient 148.
Eugene Sukhodolskiy
committed
13 hours ago
|

Android app: native push notifications via JS bridge + background hold
...
WebView has no Push API, so the app shows notifications natively:
- NaviBridge JS interface (window.NaviAndroid): notify(), permission
request with a 'navi-perm-result' event callback, background-mode
start/stop and the Doze-exemption ask.
- BackgroundService: dataSync foreground service + partial wake lock —
holds the process (and the WebView WebSocket) open while the app is
minimized; quiet persistent notification with a "Отключить фон" action.
Notification channels: silent background presence, high-importance
messages.
- Manifest: POST_NOTIFICATIONS / FOREGROUND_SERVICE(_DATA_SYNC) /
WAKE_LOCK / REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + service entry;
notification tap resumes MainActivity (singleTask) into open_url.
- webclient: nativeBridge composable; usePush gets a bridge mode that
replaces web-push entirely (settings toggle keeps working); chat store
raises 'Navi ответила' through the bridge on stream end when the
window is hidden. Fixed a latent undefined-variable in web syncState
(notificationsSupported -> notifSupported()).
- New unit tests for the bridge surface; 147 frontend tests + APK build
pass.
Eugene Sukhodolskiy
committed
13 hours ago
|
Merge remote-tracking branch 'origin/master'
...
# Conflicts:
# webclient/vendor/gnexus-ui-kit/package-lock.json
root
committed
14 hours ago
|
webclient: settings tab polish, light chat-table styling, iOS launch screens
...
- Synapse tab icon was a no-op glyph: ph-diagram-project does not exist in
the bundled Phosphor regular set — the button rendered bare text. Switched
to ph-network, which ships.
- .settings-tabs capped at 1200px width on wide screens (was max-width:none).
- Chat markdown tables: the old .msg-assistant-content .table rules were
dead (markdown never adds a .table class) — retarget .table-wrap table and
restyle lightly: framed border + radius, muted header row, plain row
separators, no uppercase/no hover color jump.
- iOS home-screen: apple-mobile-web-app metas and a full apple-touch-startup-
image set (24 device classes, portrait+landscape) rendered from logo.svg
on the theme background.
Eugene Sukhodolskiy
committed
14 hours ago
|
webclient: PWA hardening — stable height skeleton, touch selection policy, fixed maskable icons
...
- html/body/#app height via percentages instead of 100vh/dvh: the standalone
PWA visual viewport drifts with keyboard/system panels, which let the
document exceed the screen and scroll fixed headers away.
- viewport meta gains interactive-widget=resizes-content; phone-width inputs
(textarea/text) are pinned to 16px to avoid mobile focus zoom.
- Selection policy: service chrome (chat header, sidebar, tab strip, input
bar, meta rows) is unselectable with -webkit-touch-callout, killing the
'Search with Google' sheet on long-press; message content and settings
panels keep selectable text.
- Apple touch icon switched to a proper 180px tile.
- Regenerated maskable icons (were degenerate 1px-tall files): dark
background plus the logo inside the 80% safe zone.
- manifest gains display_override.
Eugene Sukhodolskiy
committed
14 hours ago
|
webclient: fix mobile tab-block drift — column flex nowrap + stretch, kit table scrolls inside its wrapper
Eugene Sukhodolskiy
committed
14 hours ago
|
webclient: reveal the active settings tab when the mobile tab strip scrolls
Eugene Sukhodolskiy
committed
14 hours ago
|
webclient: settings header now literally the chat header; app-wide thin rounded scrollbars (override kit's thick square defaults)
Eugene Sukhodolskiy
committed
14 hours ago
|
webclient: exclusive radio groups in Synapse reactions (distinct names, kit options API); mobile sidebar row swaps close button after New Chat
Eugene Sukhodolskiy
committed
14 hours ago
|