v0.9.20
[0.9.20] - 2026-08-26 — agent capability, open model catalog, Jetson
Agent execution core — the version's reliability spine
- Error-aware dedup: the cross-round dedup set now records only SUCCESSFUL executions. Signatures were inserted before execution, so a transient tool failure (MQTT timeout, extension hiccup) made the model's retry a "duplicate", ending the loop via AllDuplicate with the error in hand. Failed calls can retry — and a failed signature that keeps failing (budget: 3 consecutive failures) is blacklisted so the loop brakes instead of burning all 30 rounds. (StuckDetector removed: its five OpenHands-style patterns were mathematically unreachable behind that same dedup — docs described a brake that never fired; the dedup IS the brake.)
- Context-overflow self-heal: overflow is permanent per
is_permanent(), but on local backends a window smaller than the registry default meant EVERY round overflows. One hard-compaction retry (halved effective window) turns "small-model execution inevitably fails" into "completes". - Remaining-round countdown: within the last 3 rounds a
[System]note tells the model how much runway is left, so it wraps up instead of starting a chain the cap will cut off. - Cancelled exits the loop: the tool-concurrency semaphore closing only broke the batch loop; now the whole round loop stops and Phase 2 summary is skipped (no LLM calls during shutdown).
- Event-trigger retry un-deadened:
execute_with_retrytreatedOk(Failed-record)as success — execute_agent reports LLM/tool failures as Ok records, so the 5s inline retry and the cooldown-clear never fired. Failed status now retries, then errors so the cooldown clears. - Honest success metrics: tool results returning
Ok{success:false}counted as success (success_rate pinned to 1.0); the journal now reflects real outcomes — the learning signal agents train on. - Sampling config wired: the scheduled-agent loop hardcoded temperature 0.7 and ignored
/api/settings/agent; the setting now feeds both chat and scheduled paths. - A bad schedule could silently kill the whole scheduler (found by the agent-module design review): the frontend's "on-demand" option encodes manual-only as
interval_seconds: 0, but that value made the agent due on the very next tick and then hit a division by zero inupdate_next_execution— a panic inside the unguarded scheduler loop that stopped EVERY agent on the platform (process alive, no restart, one panic line in the logs). And creating an on-demand agent 400'd anyway (create rejected 0 while the update path and the read path both honored it). Three-layer fix:0is now a first-class "manual-only" value (schedules to a never-due time, create accepts it — the UI option works again), the reschedule math guards against 0 defensively for legacy rows, and the tick's reservation phase runs in its own spawned task so any future panic there is logged and skipped instead of unwinding the loop.
Cross-session memory actually works
Chat had NO automatic memory write — only what the model chose to write via the memory tool, which small models almost never do, so USER.md/KNOWLEDGE.md stayed empty. After a completed chat turn, a thinking-disabled LLM call extracts durable [user]/[knowledge] facts, merges them deduped + budget-capped, and invalidates the frozen snapshot so the next turn sees them. Verified live on an Orin-class board: LFM's integral thinking consumes ~5700 chars of reasoning, so the extraction budget is 2000 tokens (300/800 ended empty), and the parser tolerates the model's tag-separator variants ([user]: vs [user] fact). The scheduled-agent journal injection is now failure-prioritized (failed runs surfaced first, recency preserved within each group) with the retained window grown 10→20.
Rules become observable — and correct on strings
RuleEvaluated/RuleTriggered/RuleExecutednow actually publish on the EventBus (they existed in the event enum with zero producers — the frontend and extension subscriptions had to poll the history API).- String rules substitute
{value}: extract_trigger_value only surfaced numbers, so a contains/regex rule on a Text field rendered the literal placeholder in its alert. - Cross-source AND no longer flaps: the value cache's 5s TTL made a slow source's value vanish between updates → the AND went false → for_duration kept resetting. Values are now the last-known truth until replaced (cache capped at 4096 entries with oldest-eviction); staleness is the job of
__last_seen_age_secs, not time-based eviction. neomind data sources list: the authoritative source inventory (devices ∪ extensions ∪ transforms) for rule/dashboard/push bindings — the agent had to guess DataSourceId strings.
Open model catalog — three channels
- Local import (
POST /api/builtin-llm/import-local+ a wizard card): bring any GGUF. Magic + zero-tensor validation, header-parsed name/ctx/quant, SHA256-pinned manifest, and it participates in the single-model switch exactly like a curated model. Failure-safe by construction: a same-id re-import back the existing dir aside and restores it; a failed spawn rolls back the import, RESTARTS the previous model's server, and reports an error; a slug colliding with a curated builtin id is rejected upfront. Context is capped at 128K (a header claiming 1M would OOM the KV allocation). - Remote catalog (edit the JSON, clients pick it up): the picker serves
models/catalog.jsonfrom camthink-ai/NeoMind-Runtimes; a new model ships as a catalog edit, no product release. Graceful degradation is the contract: offline/timeout/parse error falls back to the compiled-in curated three (+ any local imports) — never an empty list. 3s timeout, 1h TTL cache,NEOMIND_CATALOG_URLoverride for mirrors. - Custom-backend context window:
max_contexton LLM create/update (merged into capabilities, user value wins over name detection) + a Cloud AI dialog field — an RKLLM3-class backend running-c 16384no longer receives 128K-budgeted prompts. - Diagnosable empty-response errors: "Sorry, the model could not produce a response" now carries the reason (the LLM error string, or an explicit empty-content hint naming likely causes) instead of a bare "Please retry".
Jetson runtime — self-bootstrapping CUDA
Jetson hosts (Orin, sm_87) auto-detect via /etc/nv_tegra-release and fetch OUR gcc-11 CUDA runtime from camthink-ai/NeoMind-Runtimes (SHA-256-pinned — executable downloads get no slack; exec-checked at download and again before trusting a PATH-found binary that the official ubuntu-arm64 build shadows). Verified end-to-end on a real Orin Nano 8GB: the official llama.cpp arm64 build requires gcc-13 libstdc++ which JetPack 6 (gcc-11) lacks — our build fills exactly that gap.
The AI-facing CLI tells the truth (recovery edition)
- recovery_hint taught wrong syntax in five places (device create
--typevs--device-type, control--commandflag vs positional, agent--actionvs positional, transformvaluevsinput, dashboard steering into the full-array replace) — the failure-recovery hint is what the model sees right after a failed command, so wrong syntax steered the retry into a second failure. - Piped commands route to the real shell:
neomind x | grep ywas intercepted in-process with|passed to clap as a literal argument → guaranteed "unexpected argument". The tokenizer now bails on shell constructs outside quotes. - Receipts teach the next step: agent create (paused — activate with…), connector create (test with…), transform create (check executions) — multi-step truncation was a top eval failure class.
neomind config export|import|validatewired (the handlers existed as dead code) andmessage deleteadded — the undo command previously pointed at a subcommand that didn't parse.
Skills: builtin is actually read-only, BM25 ranks the real path
- "Builtin skills are read-only" was documented but not enforced — PUT/DELETE persisted a shadow file that permanently masked the builtin content across upgrades. Both the API handlers and the LLM skill tool now return teaching errors for builtin ids; the
originfield (hardcoded "user" in every response, so?origin=builtinfilters were dead) is serialized from metadata. - BM25 ranks the production skill tool search (it only served the debug endpoint): IDF-weighted lexical ranking rides the flat signals with a two-tier gate — ranking lift for already-positive candidates, strong rare-term rescue (≥2.5 raw) for zero-flat queries, garbage queries still find nothing. Regression test on the real 15-skill corpus locks both properties.
Notification channels survive restarts + six silent failures
- telegram/wecom/dingtalk/slack/feishu channels died on every server restart (
load_persisted_channelsonly had factory branches for webhook/email) — rule alerts silently stopped until each channel was manually recreated. - Cleanup batch: agent
--resources/--metrics/--commandsmalformed JSON now errors instead of silently dropping the binding;device create --adapter-typedefaults to the documentedmqtt; the dashboard full-replace path runs the same known-type gate as add-components; IM bridge re-registration stops the superseded bridge instead of leaving twin polling loops; market extension upgrades carry the user's config forward (and push it to the running process);init_llmprefers the DB's active instance over a stale config.toml (a leftover TOML resurrected a dead backend over the user's activated builtin).
Four one-liners that were each silent failures
share-proxy hardcoded port 127.0.0.1:9375 (non-default-port installs served broken shared dashboards — now resolves the real bind port); deleting ANY dashboard cleared the global default pointer (only the deleted one should); updating a channel wiped its routing filter (register persisted ChannelFilter::default() over the user's); selectedSkills: [] couldn't clear pinned skills (the guard treated "explicitly emptied" as "not provided").
Security hygiene
- Deleting a user or changing a password now revokes that user's sessions immediately — both the in-memory whitelist and every persisted row in
sessions.redb. Previously a JWT minted before the change kept working for up tosession_duration(7 days): a leaked token survived a password rotation, and a deleted user's token kept authenticating. Logout-level revocation existed; user-level did not. - The public auth endpoints are brute-force throttled. The global HTTP limiter sits at flood scale (thousands of req/min) — no defense against password guessing. Login now counts only credential failures (5 per 15 min, keyed per username AND per client IP — either over the cap blocks, so distributed guessing at one account and one host spraying many accounts are both stopped; a successful login clears the counters, so mistyping a few times never locks an honest user out). Register and first-run setup count every attempt per IP (10 per 15 min). 429 +
Retry-Afteron block. The client IP honorsX-Forwarded-For/X-Real-IP(production sits behind nginx); a direct-connection attacker forging those headers defeats only the IP key — the per-username key is the load-bearing half. - API key
permissionsdocumented as informational — the field was accepted, stored, and echoed but never enforced (every key authenticates as a full administrator). The API docs now say so explicitly at every surface that mentions it, so nobody scopes a key down and assumes it limits anything. - Remote-instance API keys are no longer handed back by the API. The instance list previously returned every instance's full key XOR-"encrypted" with a cipher hardcoded in the open-source repo — anyone who could list instances recovered every credential. The backend now returns only the masked key; the frontend keeps its own copy in a per-browser key store from the moment the user entered it (existing setups migrate transparently from the old cache on first read), so instance switching works exactly as before.
- Two pre-existing security holes closed in the share/asset chain (found by the cross-domain review): (1) the share proxy could be traversed with dot-segments —
/share/{token}/proxy/devices/../../auth/keyspassed the first-segment allowlist and the loopback forwarder normalized the path away, giving an anonymous share viewer ANY authenticated route via the internal-proxy header; dot-segments are rejected outright now. (2) The extension asset server joined an attacker-controlled path onto the extension dir — an absolute path (/etc/passwd) REPLACED the base with no..needed, an arbitrary-file read also reachable through the share proxy; absolute paths are rejected and a canonicalized containment check backstops every join trick. - Interactive dashboard share links no longer mean "full write inside the allowlist". An
allow_interactiveshare token previously skipped the method check entirely — an anonymous holder could POST/PUT/DELETE anything under the proxied path prefixes (install extensions, delete agents, rewrite notification channels). Both share modes now pass the same method gate: GET plus a whitelist of read-like POSTs, with interactive adding exactly one write —devices/:id/command/:command(the dashboard's control buttons). PUT/DELETE stay blocked for both modes: interactive means actuating devices, never editing configuration.
Agents: one-shot tasks become a first-class form (design-review follow-ups)
ScheduleType::Manual: a manual-only agent the scheduler never auto-triggers — runs any number of times via invoke/execute (or chat delegation) and idles as the newAgentStatus::Completedbetween runs (a ready-state, not terminal — re-invoking always works). Previously unrepresentable: the frontend's "on-demand" option was aninterval_seconds: 0encoding, rejected by create and never Completing. The editor's on-demand option now sendsmanual(legacy0rows still read correctly), and the agent card renders Completed with its own badge. The building block for chat-side delegation.invokeno longer kills long runs at 60s: the handler used to await the execution inline, so the 60s timeout DROPPED the future — the run died mid-flight with no execution record and no journal entry (a ghost execution the agent could never learn from). The execution now runs in its own task; past the wait window the caller getsstill_executingwith poll pointers (API path + CLI command) while the run completes in the background and writes its record + journal as usual.enable_tool_chainingremoved from the API surface — it was a dead field end to end: the executor decides tool-calling by LLM capability (should_use_toolsnever read it), no UI ever set it. Kept in storage only for bincode compatibility with existing rows, marked deprecated.
Frontend
- First entry into a chat session no longer flashes the "start a new conversation" default for a frame before the real messages load.
- Memory and auto-onboarding configuration moved into Settings — both were platform-level policies hiding in page-local dialogs (the agents-page memory panel and the devices-page pending-drafts toolbar). Memory config now lives in Settings → Preferences (instant-save rows, same fields), auto-onboarding in Settings → Preferences; the original entry points jump straight to the right section. The memory panel keeps content management (view/edit files) and only reads the char limits.
- "Add your own API backend" opens the Cloud AI dialog (protocol chooser) — it built an inline OpenAI type and bypassed the protocol path.
- The builtin model wizard gains the import-your-own-GGUF card + a
Custombadge on imported models. The card is upload-first: drag-and-drop or pick a GGUF (multipart, streamed server-side — GGUFs run to ~5 GB and are never buffered in memory), with the server-path input folded behind an "advanced" toggle for desktop/remote-deployment use. - "Add your own API backend" in the empty-backends state did nothing — the early-return branch never mounted the Cloud AI dialog it opens. (Fixed together with the upload work; the click now opens the protocol chooser as on the populated state.)
- Per-model sampling defaults — the four built-in models each carry their own best-known sampling point now, applied both as llama-server startup defaults (
--temp/--top-p/--top-k) and on the request side (the backend instance): Qwen 3.5 non-thinking 0.7/0.8/20 (official), Gemma 4 1.0/0.95/64 (official model card), Ling-3.0-tiny 1.0/0.95/20 (official), LFM keeps the measured-best 0.6/0.85/20 (beat the official card values in a 154-case A/B). Previously all four shared one global legacy point — Gemma was running 0.6 where Google recommends 1.0. The catalog schema carries the fields (temperature/top_p/top_k, absent → legacy default), custom-imported GGUFs keep the global default, and registry tests lock all four values. - Ling-3.0-tiny joins the model catalog — the remote catalog entry went live in camthink-ai/NeoMind-Runtimes (4.8 GB Q4_K_M, 128K ctx, min 6 GB RAM). Our bundled runtime already carries the BailingMoe3 architecture support merged upstream on 08-17, so the download runs out of the box.
Eval & docs
- Ling-3.0-tiny Q4_K_M validated on the same 30-case agent suite: 77% — ties Qwen 3.5 4B while generating ~45% faster (~110-116 tok/s on M4-class); joins the README's model table as the community-import example.
- Remote catalog notes kept language-neutral (English) in the public NeoMind-Runtimes repo.
Downloads
Desktop Application (Recommended for Personal Use)
- macOS: Download
.dmgfile - Windows: Download
.msiinstaller or.exeportable - Linux: Download
.AppImage(universal) or.deb(Debian/Ubuntu)
Server Deployment
-
Backend Server: Download
neomind-server-{os}-{arch}.tar.gz- Extract and run:
./neomind serve - Default port: 9375
- Extract and run:
-
Frontend Static Files: Download
neomind-web.tar.gz
All-in-One (default, no nginx needed):
- Extract frontend to
/var/www/neomind(or configureNEOMIND_WEB_DIR) - The server serves both API and Web UI on the same port
Frontend-Backend Separation (optional, with nginx):
- Add
USE_NGINX=trueduring installation - Frontend served by nginx on port 80, API on port 9375
One-line Installation (Linux & macOS)
# All-in-one (default)
curl -fsSL https://raw.githubusercontent.com/camthink-ai/NeoMind/main/scripts/install.sh | sh
# Backend only (users connect via local desktop app)
curl -fsSL https://raw.githubusercontent.com/camthink-ai/NeoMind/main/scripts/install.sh | NO_WEB=true sh
# With nginx for frontend-backend separation
curl -fsSL https://raw.githubusercontent.com/camthink-ai/NeoMind/main/scripts/install.sh | USE_NGINX=true shFull Changelog: v0.9.19...v0.9.20