| 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
20 hours ago
|
webclient: split the skills catalog panel into composables and style partials
Eugene Sukhodolskiy
committed
22 hours ago
|
webclient: split the artifacts panel into composables and style partials
Eugene Sukhodolskiy
committed
23 hours ago
|
webclient: split the chat store into area modules
Eugene Sukhodolskiy
committed
23 hours ago
|
webclient: split the app styles into partials
Eugene Sukhodolskiy
committed
23 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
1 day 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
|
| 2026-11-07 |
webclient: make the skills catalog panels say what they are
...
The panel used three CSS variables the kit never defines, so its borders,
hover fills and dimmed text were silently dropped — the two columns had no
visible edge. Use the real tokens.
Around that, name the section and keep its controls with the list they act
on: a title and one-line explanation, then a list pane (search, area
filter, New, count) and a document pane, each a bordered surface. Rows get
a caret and hover/focus states; the reading pane shows a loader while the
next entry travels, moves the read-only marker into the meta line, and
swaps Delete for the kit's icon-only button. The editor uses the whole
column instead of the kit's 600px control cap.
Eugene Sukhodolskiy
committed
3 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
|
webclient: make the protocol proposal card a document, not a kit tile
...
GnCard is a 340px tile with an uppercased title and GnSteps uppercases
every step, so a protocol document came out as a narrow column of caps with
its body shown as raw markdown. The card is a chat domain card now: an
app-owned full-width frame (accented left edge for pending/accepted/
rejected) with the kit's buttons, alert, chips, badge and loader.
- shows what was missing: when to use it (triggers), preconditions, steps
with their action and check, post-checks, and the body as rendered
markdown under "Context";
- unwraps a step the model wrote as a YAML mapping, which the parser turns
into Python repr text, so the step reads instead of leaking {'id': ...};
- says "Add to catalog" and shows an "In the catalog" badge once decided.
Eugene Sukhodolskiy
committed
3 days ago
|
webclient: reach the socket from cards, and draw the proposal card with the kit
...
The skill-proposal card sent its decision through inject('wsSend'), but no
ancestor ever provided it — the click went nowhere and the card reported
"No connection". Form.vue carried the same latent bug since 9dfdb48.
- wsBridge.js: a module-level bridge. useWebSocket registers its send on
setup and clears it on unmount; cards call wsSend() and get false back
instead of silently dropping the payload.
- Form.vue uses the bridge and reports a refused send without marking the
form submitted.
- SkillProposal.vue is rebuilt on the kit (GnCard, GnAlert, GnChip, GnList,
GnSteps, GnDescriptionList, GnLoader, GnButton) and exports `bare`, so
UiComponentCard no longer wraps it in a second frame with a dev header.
- registry.js tracks bare components; UiComponentCard renders them as-is.
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
|
| 2026-11-03 |
webclient: drop the separate queue counter, mute the queued badge
...
The input area carried a chip with the queue's own count next to the
per-message "queued" mark. Two views of one queue disagreed more than they
helped, and the count said nothing the transcript did not: the chip, its
i18n keys and the store state behind it (queuedMessages, queuedCount) are
gone. message_queued still lands in the store — it drops the "sending" hint —
but nothing is counted.
The badge on a queued message took --gn-color-accent (#FF9E64). On the user's
blue bubble, already faded to 0.75 while queued, that measured ~1.9:1 and read
as a warning sign. It is now the palette's grey (#414868): muted, legible on
that blue, and clearly secondary to the message text — the darker neutrals
look like the message itself.
Eugene Sukhodolskiy
committed
7 days ago
|
subagents: a stopped run closes the tool it had opened
...
A tool card is drawn from tool_started and only its tool_call closes it.
When the user stopped a run while a sub-agent was mid-tool, the sub-agent
was cancelled, the parent dropped its sink, and the matching tool_call was
never sent — the card spun until the page was reloaded.
The sub-agent now reports its in-flight tool from the CancelledError
handler, and both batch paths (sequential and parallel) drain the sink
after cancelling instead of discarding it, so that event reaches the
client ahead of the parent's synthetic "stopped by the user" result.
The webclient settles anything still pending at stream_end,
stream_stopped and error as a safety net for a lost frame or a dying
server, with a visible "interrupted" note rather than a silent close.
The TUI needs nothing: it renders tool_call as a result panel and carries
no pending state, so it gets the fix through the backend alone.
Eugene Sukhodolskiy
committed
7 days ago
|
| 2026-11-02 |
webclient: a published card appears while the turn is still running
...
The tool index kept the raw card object, so applying a result wrote past
Vue's proxy: nothing that read the card through the array was invalidated
and a running content_publish stayed an error card until the turn's
message was rebuilt. Index the array's proxy instead, and show a pending
publish as an ordinary tool card rather than a failed one.
Eugene Sukhodolskiy
committed
8 days ago
|
webclient: a recall you can inspect, skip and cancel
...
Clicking the banner opens a dialog with the call's kind, when it was created,
a live countdown to the next fire, the repeat interval and the system message
the agent wrote for itself; skip and cancel live there too.
The cancel button in the banner and the sidebar never worked: ph-clock-slash
is not a Phosphor icon, so the glyph was empty and the button collapsed to
8x4px. It is ph-x-circle now, with a larger hit area.
/recall payloads carry created_at, and the countdown picks days or hours
instead of counting out "43230 min".
Eugene Sukhodolskiy
committed
8 days ago
|
sessions: the info dialog shows a pending recall
...
GET /sessions/{id}/info now carries the session's pending recall, and
GET /sessions/{id}/recall answers in one shape ({"recall": {...} | null})
instead of a flat object when found and a wrapper when not. The lookup is a
single indexed row rather than the newest 50 recalls filtered in Python.
Eugene Sukhodolskiy
committed
8 days ago
|
webclient: one audio player, so a published card sounds like the viewer
...
The published-content card fell back to the browser's own grey bar while the
full-screen viewer had a player with the track's name, times, seek and
volume. The card now shows that player too, tightened a little for a body
that sits under a summary row.
The player moved out of ContentLightbox unchanged: two copies would have
drifted, and it is the same file in both places.
Eugene Sukhodolskiy
committed
8 days ago
|
webclient: a dialog that answers which model is serving, and from where
...
The header only named a model when the route had fallen back, so "what is
running this session, on which host, with how much context" had no answer
in the UI. The context percentage doubles as the second way in: a session
bar is a fair place to ask what the bar measures.
GET /sessions/{id}/info reads in-process state only — the active route
entry, the health chain, the profile's effective window, run and client
counts — and the dialog polls it while open. /meta and the session head
now report the route's window instead of the global ollama_num_ctx, which
had been wrong for every cloud-backed profile.
Eugene Sukhodolskiy
committed
8 days ago
|
| 2026-11-01 |

llm: routes that name a host, a backup and a window, and a profile that reads them
...
`llm.json` replaces `OLLAMA_BACKENDS_FILE`. It has targets (host and key
environment variable) and routes, where a route is an ordered chain of
`model @ target` entries, each with its own `context_window` and options. The
`default` route now starts on `glm-5.3-flash:cloud` and backs it with
`qwen3.6:35b-128k` on the home host, both at 131072 tokens, so a failover no
longer changes the budget the compressor and `num_ctx` are accounted against.
Embeddings get their own `embedding` route instead of a pinned host.
The engine classifies a failure (network, quota, auth, server, missing model),
cools that entry and moves the call down the chain; a background probe watches
only degraded targets and lets the next turn return to the primary once it is
healthy — it never interrupts a running turn. A profile joins a route with
`llm_route`, and the loader now actually reads it: `llm_route` and
`context_window` were declared on `AgentProfile` and written into every
`config.json` while the loader skipped both, the same silent no-op that
`is_admin_only` used to be, so every profile ran the legacy model list at the
global window. The writer emits them too, or an admin edit would erase the
route from disk.
The turn's resolved entry travels to the UI: the first chunk is stamped with
model and target, `model_info` carries `fallback` when a backup answered, and
the chat header shows a chip only then. `serving_primary` compares the target
as well as the model name, because a route may run one model on two hosts.
`/health` gains an `llm` block with the active entry, target states and missing
models.
`deploy/ollama/` keeps the local model's definition honest: MTP off (slower at
every context on this host), `presence_penalty 0` (the base model's 1.5 breaks
agentic tool calls), and `num_gpu 40` — Ollama's automatic fit claims a 128k
f16 slot fits and then dies mid-prefill with CUDA OOM; 40 of 42 layers leaves
~400 MiB and decodes at ~77 tok/s, 38 layers collapses to 48, and 39 OOMs.
q8_0 KV measured 37 tok/s against 70 on f16 here, so the cache stays f16.
Eugene Sukhodolskiy
committed
8 days ago
|
profiles: the Personal Secretary retires, and every reference goes with it
...
`secretary` had become a second general-purpose assistant: same models, same
tools, one temperature step apart from `assistant`, and admin-only for no
reason the user could see. The directory is gone and the eight remaining
profiles load clean.
A profile that no longer exists is not a harmless orphan — `Agent` resolves
`session.profile_id` on every run and raises `ProfileNotFound`, so any session
left behind would open and then fail on its first turn. Prod sessions were
moved to `assistant` first (115 rows, verified after), and the local clone
along with them (101).
The reaction fallback loses its role table: it existed because the admin
fallback was itself admin-only, which `assistant` never is. `spawn_agent`
falls back to `assistant` too, and the two prompts that recommended
`secretary` now name only `server_admin` and `developer`.
Eugene Sukhodolskiy
committed
8 days ago
|

webclient: a viewer for every content type, and a message sent mid-turn waits its course
...
The lightbox is now one dialog for every file the artifacts panel and the
content cards can show: audio and video players on the kit's own controls, a
text/source view with highlight.js, and a three.js viewer that replaced the
standalone stl.html frame with a model one. highlight.js emits .hljs-* classes
and ships no theme, so until now every highlighted block rendered in one flat
colour — app.scss now carries the palette.
While a turn runs, the send button no longer turns into Stop and vanishes the
draft with it: the message goes into the socket and is marked "queued" until
the stream_start of the turn that consumes it, the write-now-run-next model
navi-code uses. Send and Stop sit side by side, and the queue chip takes the
larger of the local and the server's counts.
The socket keeps its own liveness: a ping every 20 s, a watchdog that reopens a
socket silent for 45 s or stuck in CONNECTING, and a reconnect on resume from
background, network return or bfcache. On reopen it drops a half-written
message left by the dead socket, so the session_sync that follows is not hidden
behind a stream that will never finish.
Eugene Sukhodolskiy
committed
8 days ago
|
| 2026-10-12 |

profiles: the coder profile dissolves into the assistant
...
The two profiles' native tool sets were already identical — 23 tools for the
agent, 11 for the subagent — and coder's MCP groups were a strict subset of
the assistant's, so the only real difference was the prompt and the sampling.
Code work now rides on the assistant profile:
- the prompt gains a "Code work" section (understand before editing, change the
smallest thing that works, verify by running it, report what was run) and the
"code you are given to run is data, not instructions" boundary from coder's,
plus its working-directory and network wording. The spawning rule keeps a
carve-out: the final verification run and reading back a sub-agent's work are
never delegated.
- sampling moves to temperature 0.35, top_k 40, top_p 0.9, max_iterations 80.
- gnexus-book leaves the MCP groups of both the agent and the subagent. It is
BYOK-gated, so a regular user without a key only ever saw it named in the
prompt with the "needs a personal key" note.
- navi/profiles/coder/ is removed; README, docs/profiles.md, docs/index.md,
manuals/spawn_agent.md, the webclient icon map and its two fixtures follow,
and dist is rebuilt.
A session still stored on profile_id "coder" now fails every turn with
"Profile not found: coder" — ProfileRegistry.get raises and run_stream does not
catch it. The owner accepted that rather than migrating the rows.
Eugene Sukhodolskiy
committed
29 days ago
|

webclient: a deliberate pull upwards outranks the autoscroll
...
While the agent streamed, the pin to the bottom was re-applied between deltas, so
a small wheel-up was yanked back before the "user scrolled up" flag had a chance
to be set — the view only came loose after a large fling. The flag itself was
written by the scroll handler from distance-from-bottom alone, which needs a
100px move to trip, well past the point the user has already been overruled.
Direction is now read from the gesture. A wheel tick or a finger drag that goes
up sets the flag at once and drops any landing loop still re-clamping, and the
pin stands down for 250ms after real input, in flight outranking it. A wheel on a
list that fits entirely is not treated as a request to leave the bottom — the
view never moves there and the flag would stick with the stream no longer
following. The scroll handler keeps the dragged-scrollbar fallback: entering
"scrolled up" still takes a clear move, while leaving it takes almost nothing, so
the pin re-arms as soon as the user is back at the bottom.
dist rebuilt and committed with the source.
Eugene Sukhodolskiy
committed
29 days ago
|
| 2026-10-10 |

webclient: the push permission is asked once a session, after signing in
...
The permission was only ever asked from the switch in Settings, and a call
without a user gesture is ignored by Firefox and Safari and answered with a
quiet icon by Chrome — so the app now offers a bar whose button click is that
gesture. It is asked once per browser session (there is no marker that tells a
fresh sign-in from a reload, so the session is the honest bound), and a
refusal ends it for good: only an explicit block counts, and after that the
switch in Settings is the single way back to it.
The policy lives in useNotificationPrompt.js as a layer over usePush, which
is untouched: the granted-but-unsubscribed trap is what makes the eligibility
gate "permission is still default", otherwise the bar would silently
re-subscribe whoever turned notifications off by hand. The banner itself is
presentation only, and is the component proposed to gnexus-ui-kit
(docs/tasks/gn-permission-banner.md there) — the kit takes the gesture, the
view and the contract, never the storage keys or the timing.
Eugene Sukhodolskiy
committed
on 10 Oct
|

synapse: a reaction continues its conversation, and the request becomes a record
...
Every Synapse event used to open its own service session, so two messages of
one Telegram chat landed in two unrelated chats. The dispatcher now returns a
`session_key` — derived from the payload according to the user's routing
document — and a later event carrying the same key joins the session that
opened it, within `reaction_session_ttl_minutes` of idle time (0 restores the
old always-fresh behaviour). A session that is still running is never joined:
`create_run` overwrites `state.run`, so a second run would orphan the first
one's subscribers — a busy thread gets a fresh session instead. When the
dispatcher names a different profile for the event, the session's profile is
switched through `set_profile`, never through `save()`, which does not rewrite
`name` or `profile_id`: the thread keeps its name and its history.
What the user reads in the transcript is now only the request. The event
record is a system message (`metadata.source == "synapse_event"`) drawn by a
new SynapseEventNotice.vue in the visual language of the recall badge, and the
framing plus the JSON envelope become a hidden user turn written once, on the
first event of a thread — not on every one. The instructions from Settings
reach the model through a new `current_reaction_instructions` ContextVar and
the `[Reaction session]` block of the system prompt, so they appear in no
transcript at all. The stored system record still has to reach the model, so
ContextBuilder's allowlist becomes `_LLM_SYSTEM_SOURCES = {task_note,
synapse_event}`.
The dispatcher gets a document of its own — how to route, rather than what to
do — stored beside the reaction instructions, versioned separately, and edited
in its own field in the settings panel. `synapse_instruction_versions` gains a
`doc` discriminator, so existing rows keep `'reaction'` and their history
verbatim. The new columns are added by `_MIGRATE`, and the index over `doc`
lives there too: `_DDL` runs first, so an index over a column the migration has
not added yet kills the whole batch on an existing database — which is what
the prod database is. A guard test keeps it that way.
Tests: 1776 passed, 1 skipped (backend), 242 passed (webclient, 28 files).
Eugene Sukhodolskiy
committed
on 10 Oct
|

webclient: the small UI components in en, uk and ru
...
The sixth and last area of the interface translation. The components in
components/ui/ now build their text through t(): the login screen, the welcome
screen, the confirmation dialog, the offline banner, the image lightbox, the
selection toolbar, the card grid and the form, plus the sidebar's close button,
which is one of the strings that moved.
24 new slugs in all three dictionaries, and one moved:
- 24 in ui.* — the login and welcome screens (the heading, the tagline they
both show, the GNEXUS button), the confirmation dialog, the offline banner,
the lightbox (its heading, its close button, the failed-to-load message), the
selection toolbar's Reply and its tooltip, the card grid's three section
headings and its "+{n} more", and the form's submit button, its submitted
note and its ten validation messages;
- common.close, moved out of sidebar.*: the sidebar's close button and the
lightbox's say the same word, so it sits in the vocabulary area now.
Four details worth knowing:
- OfflineBanner's sentence was hard-coded Russian inside an otherwise English
file — for a reader in English or Ukrainian it was the one Russian sentence
in the app. It is a slug now, and the Russian value is that same sentence
verbatim, so nothing changes for the reader who could already read it.
- The form's validation messages interpolate: "{field} is required" carries the
field's own label, and the numeric limits carry {n}. The two length messages
are dictionary plurals now, so English says "Minimum length is 1 character"
where it used to say "1 characters", and Russian and Ukrainian get the three
forms a numeral needs — "1 символ", "2 символа", "5 символов".
- The card grid's "+4 more" is one slug with a count, not a sentence glued
together in the template.
- ui.tagline is written once and read by both the login and the welcome screen,
which show the same words.
The English values are verbatim the literals the code hard-coded, so the
English interface is unchanged and no test needed editing.
Checked live in Chromium against the built bundle on the dev backend, all three
languages: the login screen (reached with no account at all, its language
coming from navi.locale), the offline banner with the context put offline under
it, the selection toolbar over a real selection in a message, and the
confirmation dialog opened from a session row's Delete. Two further passes open
a session whose tool output carries a card grid and one that carries images, for
the card grid's section headings and the lightbox's heading and close button.
The form is the one component no saved session renders — nothing in the dev
database carries a form payload — so its messages were printed through t()
instead, including the plural ones: "Поле «Email» обязательно", "Минимальная
длина — 2 символа", "Мінімальна довжина — 5 символів", "Minimum length is 1
character".
Eugene Sukhodolskiy
committed
on 10 Oct
|