Skip to content

Repository files navigation

NexusContext

Objective: A self-hosted, lightweight binary daemon that provides a standardized MCP interface for local codebase indexing, structural code intelligence (knowledge graph), semantic search, and RAG-based LLM orchestration — with a native Linux desktop GUI on top.


1. Architecture Overview

                     ┌─────────────────────────────┐
                     │        nexusd (daemon)       │
                     │  Rust binary, always-on      │
                     │                              │
   MCP clients ──────┤  MCP Server (JSON-RPC/stdio) │
   (IDE, CLI agents) │                              │
                     │  Ingestion Engine             │
   File watcher ─────┤   - tree-sitter parsing       │
                     │   - chunking                  │
                     │                              │
                     │  Knowledge Graph (SQLite)      │
                     │   - nodes/edges, Cypher-lite   │
                     │                              │
   Embedding ────────┤  Embedding Pipeline           │
   endpoint (net)    │   - optional, off by default  │
                     │                              │
                     │  Vector Store (not built yet)  │
                     │                              │
                     │  RAG / Query Planner          │
                     │                              │
   GUI / extension ──┤  Control API (Unix socket)    │
                     └─────────────────────────────┘

Two transports, two purposes:

  • Stdio JSON-RPC — reserved for MCP clients (IDE extensions, CLI agents). This is the actual MCP spec transport and shouldn't be shared with anything else.
  • Local Unix domain socket — a separate control/status API for the GUI and GNOME extension (indexing progress, watched paths, config, ad-hoc search). Keeps the GUI decoupled from whatever MCP client happens to be attached to stdio at the time.

The daemon runs as a systemd --user service, independent of any GUI. The GUI is a client, not a requirement — the tool must be fully usable headless.

2. Component Breakdown

Ingestion Engine

  • Directory watcher (notify crate), git-diff-aware: on file change, re-parse only the changed files rather than polling everything.
  • Tree-sitter parsers per language, extracting functions/classes/interfaces as node boundaries instead of naive line-splitting.
  • Layered ignore rules: hardcoded patterns (.git, node_modules, build dirs) → .gitignore hierarchy → project-specific .nexusignore (gitignore syntax) for one-off excludes. Symlinks always skipped.
  • Incremental re-indexing — only re-parse/re-embed changed nodes, not whole files, on save.

Knowledge Graph Layer (new — the main structural addition over the original proposal)

  • Every ingested file becomes graph nodes (File, Function, Class, Interface, Route, ...) linked by edges (CALLS, IMPORTS, IMPLEMENTS, DEFINES, HTTP_CALLS) derived straight from the tree-sitter AST — no embeddings involved.
  • Stored in SQLite (not LanceDB) at ~/.local/share/nexuscontext/<project-hash>/graph.db — cheap, embeddable, and a natural fit for graph traversal queries via recursive CTEs or a small Cypher-lite query layer.
  • This is what makes trace_call_path, get_architecture, detect_changes (git-diff → affected-symbols mapping), and dead-code detection possible without any embedding backend running at all — directly relevant to keeping Ollama/embeddings optional rather than load-bearing.
  • Semantic search becomes one additional signal layered on top of the graph, not the only retrieval mechanism.

Embedding Pipeline (optional layer — daemon is fully useful without it)

  • No embedding runtime is bundled or hardwired. The daemon speaks the OpenAI-compatible /v1/embeddings API — the de facto standard that Ollama, LM Studio, vLLM, and llama.cpp server all implement — over a plain configurable HTTP endpoint.
  • Config ([embeddings] in config.toml): endpoint (URL, e.g. http://localhost:11434/v1 or a LAN host), model (e.g. nomic-embed-text), optional api_key (blank for local servers that don't need one).
  • Since endpoint is just a URL, "Ollama on this machine" and "Ollama/vLLM on another box on the network" are the same code path — no special-casing.
  • Startup health check against the endpoint; if unreachable, the daemon logs a clear error and keeps running in a degraded state (search/MCP tools return an explicit "embedding backend unavailable" instead of crashing).
  • Retry/timeout are config knobs, not assumptions — useful once the endpoint might be a network hop away rather than localhost.

Vector Store

  • LanceDB, embedded, disk-backed at ~/.local/share/nexuscontext/<project-hash>/vectors/.
  • One table per indexed project/workspace, keyed by content hash to dedupe.
  • Post-write integrity check: after indexing, compare persisted row count against the in-memory count; if it falls suspiciously short, report status: "degraded" from index_status instead of silently claiming success.

MCP Server

  • listTools / callTool per spec, newline-delimited JSON-RPC 2.0 over stdio. Logging goes to stderr exclusively - stdout is reserved for the protocol stream.
  • Structural tools (graph-backed, no embeddings required): index_repository (build/rebuild the graph for a path - the prerequisite for everything else), search_graph, trace_call_path, get_architecture, detect_changes, get_file_context (plain file/line-range read, no embeddings involved either), detect_dead_code, search_code (FTS5 over file content), query_graph (Cypher-lite), delete_project.
  • Retrieval tools (embedding-backed, degrade gracefully with a clear error if no endpoint configured): search_codebase (semantic), query_memory.
  • query_planner tool decides file-read vs. graph search vs. keyword-fallback-graph-search (semantic search once the embeddings pipeline exists) to cut token spend - see Phase 5 for the honest version of what it does today.
  • Tool-description caveats (moved out of the in-schema text in Phase 22 to keep the fixed per-session schema-token cost down - see Phase 21/22):
    • trace_call_path: resolution is name-based, not import-aware - same-file matches win, and a cross-file call resolves only if the callee name is unique project-wide; ambiguous same-named functions across files are left unresolved. Call-graph quality varies by language: solid for Rust/Python/JS/TS/Go/Java/Ruby; structural-only (no call edges) for C/C++/C#/PHP - see language.rs for why.
    • detect_dead_code: call resolution is name-based (same-file, or cross-file only when the name is unique project-wide), so a function called only via an ambiguous same-named cross-file call, or invoked via reflection/routing/dependency injection rather than a direct call, may show up as a false positive - treat results as worth a second look, not a guarantee (see Phase 16 for the incident that drove the limit/total_flagged cap in the first place).
    • query_planner: a specific file goes straight to get_file_context, a single identifier-like token goes to search_graph, and a descriptive multi-word query goes to semantic search if configured or a keyword-over-the-graph fallback otherwise.
    • query_graph: not full Cypher - exactly one pattern shape, MATCH (a:Kind)-[:EDGE_KIND]->(b:Kind) [WHERE a.name = 'value' or b.name = 'value'] RETURN a|b. Kind is Function, Type, File, or Section (a markdown heading; CONTAINS edges link a heading to its nested sub-headings). Fails with a clear error for anything outside that shape rather than guessing.
    • search_codebase/query_memory: requires embeddings.enabled = true and a reachable endpoint/model in config.toml (see the GUI's Config tab), and that the project was reindexed after enabling it. Errors with a specific, actionable reason otherwise - structural tools (search_graph, search_code, query_planner) work regardless.

Control API (for GUI/extension, not MCP)

  • Unix socket, same JSON-RPC framing for consistency, but a distinct method namespace (status.*, config.*, search.adhoc).
  • Exposes: indexing status/progress, per-project stats, config get/set, manual reindex trigger, ad-hoc search for the GUI's own search box.

Desktop GUI — "NexusContext Manager"

  • GTK4 + libadwaita via gtk-rs, native Ubuntu/GNOME look, no Electron overhead.
  • Views:
    • Dashboard — daemon status, projects indexed, auto-sync watcher count, last reindex time.
    • Projects — index/reindex/delete a project, see node/edge counts per project.
    • Search — ad-hoc structural query box with code-preview results (this is the main reason a GUI is worth building at all — trying queries without an agent in the loop).
    • Architecture — node/edge counts, index freshness, busiest files, language breakdown by file extension.
    • Config — embeddings endpoint/model, and the allow_remote opt-in for non-loopback endpoints.
    • Logs — tail of daemon logs for troubleshooting.
  • Talks to the daemon exclusively over the control socket. Never touches stdio.
  • Not required to be running for the daemon or MCP integrations to work — it's a management/inspection tool.

GNOME Shell Extension (optional, thin)

  • Deliberately minimal: a top-bar indicator only.
    • Icon changes state (idle / indexing / error).
    • Dropdown: quick stats + a "Search…" entry that either does an inline quick lookup or launches the full GTK4 app.
  • Runs inside gnome-shell's process (GJS) — this is why it must stay thin. Anything heavier belongs in the GTK4 app, not the extension: Shell extensions that do real work are a common source of Shell crashes and are the most likely part of this stack to break across GNOME version upgrades.

3. Technical Stack

Concern Choice
Daemon language Rust
Knowledge graph SQLite, WAL mode (nodes/edges, FTS5 for content search, Cypher-lite traversal)
Vector engine No dedicated vector DB — the original proposal's "LanceDB" pick was aspirational and never built; embeddings (see Phase 12) are stored as plain BLOBs in the same SQLite graph.db, ranked by brute-force cosine similarity in Rust at query time. Appropriate at this project's actual scale (thousands of chunks, not millions)
Parsing tree-sitter via community TAGS_QUERY conventions (11 languages - see Phase 11) plus a hand-rolled markdown heading parser for .md/.markdown docs (see Phase 14)
Embeddings Real, working (Phase 12) — OpenAI-compatible /v1/embeddings over a configurable HTTP endpoint (Ollama, LM Studio, vLLM, llama.cpp server, local or LAN), backing search_codebase/query_memory for real. Optional and off by default (embeddings.enabled = true required); daemon is fully useful without it; policy-gated (loopback/private by default, allow_remote opt-in for anything else)
MCP transport JSON-RPC 2.0 over stdio
GUI/control transport JSON-RPC 2.0 over Unix domain socket
GUI toolkit GTK4 + libadwaita (gtk-rs)
Shell integration GNOME Shell extension (GJS), status-only
Auto-sync notify-debouncer-mini, 2s debounce, serve-mode only
Config TOML, ~/.config/nexuscontext/config.toml + env var overrides (NEXUS_CACHE_DIR, NEXUS_LOG_LEVEL, NEXUS_LOG_FORMAT=json)
Data dir ~/.local/share/nexuscontext/
Service management systemd --user unit, autostart
Logging tracing crate; NEXUS_LOG_FORMAT=json for structured/machine-parseable logs, plain text by default

4. Full Roadmap

Phase 0 — Scaffolding Cargo workspace with nexusd (daemon), nexus-cli (manual indexing/query CLI), and later nexus-gui as separate crates sharing a nexus-core lib.

Phase 1 — Context-Aware Core Tree-sitter watcher, knowledge graph construction (nodes/edges in SQLite), CLI for manual reindex and graph queries. Embedding pipeline against a configurable endpoint is additive here, not a blocker for the rest of Phase 1.

Phase 2 — MCP Implementation(vertical slice done) listTools/callTool over stdio; index_repository, search_graph, trace_call_path, get_file_context, get_architecture, detect_changes all working end-to-end (verified by piping real JSON-RPC messages into the binary, including a detect_changes run against this repo's own uncommitted diff). search_codebase/query_memory correctly degrade with a clear error, since the embeddings pipeline isn't built yet. Remaining: verify against an actual IDE client (e.g. Claude Code, Continue) rather than hand-crafted JSON-RPC. Stretch goal: an install subcommand that auto-detects installed agents and wires MCP config for each, rather than requiring manual .mcp.json edits.

Phase 3 — Control API + Desktop GUI(vertical slice done) nexusd gained explicit mcp/serve subcommands to resolve the tension between MCP's per-session stdio transport and an always-on daemon. serve hosts the Unix-socket control API (status.get, projects.list/reindex, config.get/set, search.adhoc) and now logs to a file instead of stderr, since the GUI's Logs view needs something to tail. The GTK4/libadwaita app (nexus-gui) has all five views (Dashboard, Projects, Search, Config, Logs) wired to the control socket and was verified running against a real desktop session. Remaining: exercise the interactive paths (button clicks) rather than just the auto-load-on-open calls, and replace the deprecated ViewSwitcherTitle with the AdwBreakpoint-based pattern libadwaita 1.4+ recommends.

Phase 4 — GNOME Shell Integration(vertical slice done) extension/nexuscontext@nexuscontext.local/ - a top-bar icon polling status.get over the control socket every 15s, showing project count or a clear "not reachable" state, plus a menu item that launches the GTK4 app via Gio.Subprocess. Uses the modern ESM extension format (GNOME 45+, targets 45-50). Validated statically - gnome-extensions pack accepts the metadata/structure, and gjs -m confirms the JS parses cleanly (it only fails at the expected point, resolving resource:///org/gnome/shell/..., which only exists inside a running Shell process). Not yet loaded into a live Shell session: doing that requires a full Shell restart, which under Wayland means logging out, so live verification is deferred to whenever that's convenient rather than forced mid-session.

Phase 5 — Agentic Intelligence & Caching(vertical slice done, scope narrowed to what's actually ours to build) query_planner MCP tool: a named file wins outright (file_read), a single identifier-like token goes straight to search_graph (graph_search), and a descriptive multi-word query gets a naive per-word graph search (keyword_fallback_graph_search) - the true semantic-search arm doesn't exist yet since there's no embedding pipeline, so the tool says so explicitly (embeddings_configured + a note) rather than pretending. On caching: we don't control the calling agent's LLM-side prompt cache, so "prefix caching for system prompts" isn't ours to implement directly - what we built instead is an in-process cache for get_architecture, keyed on the project's last_indexed_unix, so repeated calls against an unchanged index skip SQLite entirely and a reindex busts the cache automatically. Verified: all three planner strategies return correct results, and the cache shows miss→hit→(miss after reindex) exactly as expected.

Phase 6 — Packaging & Distribution(.deb + systemd unit done and verified; Flatpak is manifest-only; extensions.gnome.org submission is a manual step)

  • .deb (via cargo-deb, config in crates/nexusd/Cargo.toml): bundles nexusd + nexus + the systemd user unit + README. Built, installed via dpkg -i, verified the real installed binaries and the vendor-shipped unit at /usr/lib/systemd/user/nexuscontext.service both work end-to-end, then cleanly removed via dpkg -r.
  • packaging/systemd/nexuscontext.service: hardened user unit (ProtectSystem=strict, ProtectHome=read-only, RuntimeDirectory=nexuscontext for the control socket, explicit ReadWritePaths for config/data) - live-tested standalone before folding into the .deb, including confirming the hardening doesn't break functionality.
  • packaging/flatpak/org.nexuscontext.Manager.json: manifest only, not built - the GNOME Platform+SDK runtimes are a ~1.5-2GB download, so building was deliberately deferred rather than pulling that into this environment. Needs a generated Cargo vendor file (flatpak-cargo-generator.py) and an actual app icon before it would build/pass Flathub review - both noted in packaging/flatpak/README.md.
  • GNOME extension submission to extensions.gnome.org: a manual, account-based review process on a third-party site - not something to automate. The extension itself is packaged and ready (see Phase 4); submitting it is a step for whoever owns that decision.

Phase 7 — Hardening & Docs(vertical slice done) Config::embeddings_policy() refuses to use a non-loopback/non-private embeddings endpoint unless allow_remote = true is set explicitly - verified blocking a remote endpoint, then unblocking it with the opt-in. Config::allowed_roots (opt-in, empty by default) restricts index_repository/reindex to specific directories, enforced once in nexus_index::index_project so it applies regardless of caller (CLI/MCP/control API) - verified both the allow and refuse paths. NEXUS_LOG_FORMAT=json gives structured logs in both mcp and serve modes. INSTALL.md documents the real, working install/usage flow end-to-end (build, .deb install, systemd unit, MCP client config, CLI, GUI, GNOME extension, config options) rather than the aspirational version.

Phase 8 — Team-Shared Index Artifact(vertical slice done, scope narrowed to what's honest given no incremental indexing) nexus export <path> zstd-compresses the local graph into .nexuscontext/index.db.zst (level 9) and adds a merge=ours .gitattributes line so the binary artifact doesn't cause merge conflicts. nexus import <path> decompresses it straight into place and updates the registry from the imported DB's real stats - skipping the tree-sitter walk entirely. Since there's no incremental diffing yet (an open risk since Phase 1), this only saves the first reindex, not ongoing syncing - reframed from the original "bootstrap + incremental diff" wording to match what's actually built. Never committed unless the user runs export themselves. Verified: exported a real index, imported it into a from-scratch data dir simulating a teammate's clone, and successfully ran search-graph against the imported data with zero reindexing.

Phase 9 — Obsidian-Compatible Markdown Export(vertical slice done; corrected scope below) nexus export <path> --format obsidian writes one .md file per function/type into .nexuscontext/vault/, each with a ## Calls / ## Called by section of [[wikilinks]] derived from the real CALLS edges - a valid Obsidian vault with zero integration code, since a vault is just markdown files. (Correction from the original wording here: this covers the graph only, not "ADRs" - there's no ADR management tool in this project, so there was nothing to export there; that line overclaimed scope that was never built.) Verified: exported the fixture project, confirmed every [[wikilink]] in the vault resolves to an actual file, and fixed a real duplicate-link bug along the way (a caller invoking the same callee twice produced the link twice, now deduped). Static/point-in-time by design - the GTK4 GUI still owns anything needing live daemon state.

Phase 10 — Feature Gap Closure(done - a round of work closing gaps found by comparing against DeusData/codebase-memory-mcp, in priority order)

  • Dead-code detectiondetect_dead_code (MCP tool + nexus dead-code): functions with no inbound CALLS edge, excluding main. Inherits the same-name-resolution caveats below.
  • CLI/MCP parityget_architecture, detect_changes, query_planner were MCP-only; extracted the actual logic into crates/nexus-index/src/queries.rs so nexus architecture/detect-changes/query-planner reach it too, instead of duplicating it.
  • delete_project — removes a project's graph + registry entry (not the source) via MCP tool, nexus delete, the control API, and a Delete button in the GUI's Projects view.
  • GUI Architecture tab — the GUI had no view onto get_architecture at all. New tab: node/edge counts, index freshness, busiest files, and a new language breakdown (file counts per extension).
  • Live file-watching auto-sync — a notify-debouncer-mini watcher in serve mode, 2s debounce, syncs against the registry every 30s to pick up newly-indexed projects, filters obvious noise directories (.git, target, node_modules, .nexuscontext). serve-mode only - mcp sessions don't own background threads. Watched-project count surfaced through status.get and the GUI Dashboard.
  • Multi-agent auto-installnexus install shells out to claude mcp add for Claude Code (the exact mechanism already proven in this project's own setup) and does a merge-safe write to Claude Desktop's claude_desktop_config.json. Anything else gets a generic mcpServers snippet printed rather than a guessed config format.
  • Full-text code searchsearch_code (MCP tool + nexus search-code): SQLite FTS5 over indexed file content, not just symbol names. Query is matched as a literal phrase. Only covers files tree-sitter already parses (Rust/Python), not every file in the repo.
  • Cross-file call resolution — the biggest one. index_directory now runs in two passes: nodes first, then a project-wide name registry resolves call sites across file boundaries once every file is known. Same-file matches still win; a cross-file match resolves only when the callee name is unique project-wide, so ambiguous same-named functions across files stay unresolved rather than guessing wrong. Not import-aware (no use/import parsing) - name-based only.
  • Cypher-lite ad-hoc queryquery_graph (MCP tool + nexus query-graph): deliberately minimal, one pattern shape (MATCH (a:Kind)-[:EDGE]->(b:Kind) [WHERE a.name = 'x'] RETURN a|b), not a real Cypher implementation. Anything else fails with a clear "unsupported" error.
  • WAL modejournal_mode=WAL on every graph.db, so nexusd serve and nexusd mcp can hold concurrent connections to the same graph without one locking out the other.
  • A real concurrency bug, found and fixed — the two-pass cross-file resolution widened the window where two full-rebuilds of the same project (e.g. the watcher firing during a manual reindex) could interleave and produce a dangling foreign key. index_directory now runs inside BEGIN IMMEDIATE/COMMIT with a 30s busy timeout, so a second rebuild blocks until the first commits instead of corrupting it. Verified by intentionally reproducing the race.
  • GUI window-stacking bug, found and fixedconnect_activate was rebuilding an entire new window every time GNOME Shell re-launched the already-running app (app grid, search); it now checks for an existing window and presents that instead.
  • allow_remote gap, found and fixed — the Phase 7 policy field existed in Config but the control API's config_set never read it, and the GUI's Config tab had no checkbox for it - anyone with a legitimate non-loopback endpoint (e.g. a Tailscale-hosted Ollama) would hit RemoteBlocked with no way to opt in short of hand-editing config.toml. Both fixed.
  • A GUI papercut, found and fixed while dogfooding: a long project path in the Projects view pushed the Delete button off-screen behind a horizontal scrollbar. Fixed with middle-ellipsis truncation (keeps both the project name and enough of the parent path visible) plus a tooltip with the full path.

Phase 11 — Multi-Language Support via Generic Tag Extraction(11 languages, up from 2) The honest ceiling on hand-written per-language tree-sitter queries (see Phase 1) was about 2 languages before it stopped being worth the effort per addition. Migrated to tree-sitter-tags, consuming the TAGS_QUERY that nearly every actively-maintained tree-sitter grammar crate already bundles - a community-maintained query using conventional capture names (@definition.function, @reference.call, ...), the same mechanism GitHub's code navigation and Neovim's nvim-treesitter rely on. Adding a language now costs "add the grammar crate + map its extensions," not "write and debug a new query language." Now supports Rust, Python, JavaScript, TypeScript/TSX, Go, Java, C, C++, C#, Ruby, PHP.

Two real bugs found while building this, both fixed:

  • tree-sitter-tags's Tag::span is deliberately just the name token's position (built for "jump to definition" UIs), not the full definition's range - using it directly collapsed every multi-line function down to a single line and broke same-file call resolution entirely. Fixed by deriving line numbers from Tag::range (the correct byte range) via a small line-offset index instead.
  • Some languages' bundled tags.scm only tag the function signature as the definition's range (C/C++ tag function_declarator, not the whole function_definition body) - a call inside the body then falls outside the definition's range entirely under a containment check. Replaced "does this call fall within a function's start/end range" with "which function's start most recently precedes this call" - doesn't depend on the range's end being accurate at all, and works uniformly regardless of how wide a given grammar's tags.scm makes a definition.

Also hit and worked around one upstream data bug: tree-sitter-c-sharp 0.23.5's bundled tags.scm has a malformed bare @module capture (alongside a correct @definition.module for the same node) that tree-sitter-tags rejects outright - stripped that one line before compiling the query rather than dropping C# entirely, since everything else in that file is valid.

Honest per-language tiering (definitions/architecture/dead-code-detection are solid for all 11 - this is specifically about call-graph edges):

  • Full: Rust, Python, JavaScript, TypeScript/TSX, Go, Java, Ruby.
  • Structural only, no call edges: C, C++ (their bundled tags.scm has no call-reference pattern at all), C# (only captures member-access calls like obj.Method(), not bare calls), PHP (similarly only captures qualified/variable calls). This mirrors the same tiering the project this technique was learned from has to contend with too ("Excellent/Good/Functional") - not every language gets equally good results from tree-sitter-only analysis, and that's stated plainly rather than smoothed over.

Verified against purpose-built fixtures for all 11 languages, a regression pass on the original Rust/Python fixtures (identical results to before the migration), and a real 115-file/1067-node/2631-edge Go+JS project - including confirming genuine cross-file CALLS edges between different Go packages at that scale, and cross-checking the file/language counts against git ls-files to confirm .gitignore-respecting indexing was accurate (the project's apparent "11,000+ JS/TS files" were almost entirely node_modules, correctly excluded).

Phase 12 — Real Embeddings Client(search_codebase/query_memory went from an unconditional stub to a real, working feature) Previously, both semantic-search tools returned an unconditional error regardless of config.toml - the [embeddings] section and GUI Config tab existed only as forward-looking network-safety scaffolding, not a working feature. Built for real: a ureq-based HTTP client (pinned to the 2.x line deliberately - 3.x reworked the Agent/timeout API non-trivially) against the OpenAI-compatible /v1/embeddings shape, one embedding row per Function/Type node (reusing tree-sitter's already-computed line boundaries rather than independently rechunking file content) stored in a new embeddings SQLite table, ranked by cosine similarity at query time.

EmbeddingsConfig gained an explicit enabled: bool separate from whether an endpoint/model are merely filled in, and EmbeddingsPolicy widened to NotConfigured/Disabled/RemoteBlocked/Allowed so every caller gets a specific, actionable reason rather than a generic error - all four states point the calling agent back at the structural tools that work regardless. Embedding happens as a third indexing pass, after cross-file call resolution, skipped entirely (zero cost) unless policy is Allowed; requests are batched (32 chunks/call) with a streaming on-batch callback so partial progress persists if the endpoint fails partway through a large project, instead of either a naive per-chunk loop failing slow (up to timeout_secs per chunk) or an all-or-nothing call losing everything already embedded. The GUI gained an "Enable embeddings" toggle (distinct from the existing allow_remote network-safety checkbox) and a "Test Connection" button; the CLI gained test-embeddings and search-codebase; the control API gained embeddings.test.

Verified against a real remote Ollama endpoint (nomic-embed-text) over Tailscale: real ranked semantic search results via the actual MCP tool-call path, the disabled-state and unreachable-endpoint messages both confirmed clean (fast, actionable, no hangs), and a full production reindex of a real 1067-node project producing 952/952 real embeddings end to end. Also fixed a real robustness gap this exposed: the auto-sync watcher had no cooldown between reindex attempts for the same project, so a reindex slow enough to still be running when the next attempt fired (routine now that embeddings make a full reindex network-bound instead of sub-second) could lose the write-lock race against itself indefinitely - added a 45s minimum gap between attempts per project.

Phase 13 — CI/CD & Cross-Platform Release(no build/test safety net or versioned releases existed before this) Four CI jobs on every push/PR to main: fmt, clippy (-D warnings), test-linux, and test-macos (excluding nexus-gui, which is Linux/GNOME-first by design with no cfg gating to make it portable). Getting there required a real mechanical pass first - cargo fmt/clippy had never been enforced, so both had drifted widely across the whole codebase; fixed rather than suppressed (a genuine filter().next_back().rfind(), a redundant Ok(...?) wrap, a doc-comment line-wrap that accidentally read as an unindented markdown list item to clippy's doc lint). One pre-existing deprecation (libadwaita::ViewSwitcherTitle, deprecated since 1.4) is suppressed with a comment rather than migrated blind, since the real fix needs visually verifying the header bar still renders correctly - not possible in this environment.

Tag-triggered (v*) release pipeline, tiered honestly rather than promising uniform cross-platform parity: Linux x86_64 (ubuntu-24.04, full parity - daemon/CLI/GUI, packaged as .deb, .rpm, and a plain tarball) and macOS Apple Silicon (macos-14, CLI + daemon only, nexus-gui excluded entirely rather than attempted). Windows is deliberately excluded - nexusd's control API uses std::os::unix::net directly with zero platform gating, a real prerequisite fix (an isolated #[cfg(unix)] split, since nexusd mcp's stdio path has zero platform-specific code already) tracked as a distinct follow-up rather than attempted here. A fast-fail check-version job asserts the pushed tag matches Cargo.toml's version before spending CI minutes on platform builds.

Real problems found and fixed while getting the first tagged release (v0.1.1) to actually publish, not just building it locally: Ubuntu 24.04's libadwaita-1-dev apt package only provides 1.5.0, but the workspace required the v1_9 Rust binding feature - lowered to v1_5 after confirming nothing in the GUI actually needs a 1.6-1.9-specific API; a separate Intel-macOS (macos-13) release job sat queued with no runner ever assigned for ~20 minutes (confirmed via the Actions API showing no runner_name at all) - GitHub's own reduced capacity for that runner class, not a workflow bug, so it was dropped in favor of Apple Silicon + Rosetta 2 rather than depending on a runner class that may not reliably become available; and the publish job's default GITHUB_TOKEN needed an explicit permissions: contents: write grant, since the repo's default is read-only. All three issues were caught by actually running the pipeline against a real tag push, not just reading the YAML.

Phase 14 — Markdown Docs in the Knowledge Graph(.md/.markdown files went from completely invisible to first-class citizens of the graph, full-text search, and embeddings) Language::from_path returning None for markdown used to gate three unrelated things at once: full-text search, graph nodes, and embeddings - an accidental coupling, since none of those actually require a call graph. New NodeKind::Section (a heading + body down to the next equal-or-shallower heading) and EdgeKind::Contains (parent heading → child heading nesting) give docs their own lightweight structural model instead of forcing them into the code model. crates/nexus-index/src/docs.rs extracts headings in a single pass with stack-based parent tracking (handles level-skips with no phantom intermediate node, and multiple independent top-level trees per file) and tracks fenced-code-block state so a shell comment inside a ``` example isn't misread as a heading - not a deferrable edge case, since it's the default shape of most real READMEs. A real bug was caught writing that fence logic: the closing-fence check compared a line's fence character against itself (always true), so a ~~~ inside a ``` block would incorrectly close it - fixed to compare against the character that actually opened the block.

Markdown routes through a new index_markdown_file() that returns the exact same result shape the code-indexing path already returns, so the entire project-wide embeddings pipeline needed zero changes - it already just consumes (node_id, chunk_text) pairs regardless of source. Verified against purpose-built fixtures (nesting, level-skips, multiple H1s, fenced code containing a heading-like comment) and a real reindex of this repo's own README.md/INSTALL.md: 283 real chunks embedded, and semantic queries like "how do I enable embeddings and test the connection" correctly surfacing a sensibly-ranked mix of code and the actual relevant doc sections.

Phase 15 — Call-Graph Visualization(a new GUI tab renders a function's call neighborhood as an actual image) Considered a full interactive graph/mesh view of the whole project first, and deliberately didn't build it: GTK4 has no native graph-rendering widget, so that would mean either a WebView (directly contradicting this project's own "not a web dashboard bolted on afterward" positioning) or hand-rolling a force-directed layout engine in Cairo - and either way, a whole-project graph turns into an unreadable hairball past a few hundred nodes on any real codebase (the downtime test project alone has 1,067 nodes). Instead: reuse Graphviz's dot (a real, mature layout engine, not reinvented) for the hard part, and scope the visualization to a bounded subgraph - one function's call neighborhood via the same depth-limited BFS that already backs trace_call_path, which solves the hairball problem by construction rather than by accident.

New GraphStore::subgraph_edges (every edge among an already-known node set - generic, not call-graph-specific) and call_graph_dot compose into a Graphviz DOT string; a new viz.call_graph control-API method keeps that generation logic server-side (shared, testable) while the GUI's new Visualize tab is the only place that shells out to dot -Tpng and displays the result, since it's the only client that needs a picture rather than data - graphviz is a Recommends, not a hard Depends, so the rest of the app works fully without it. One real bug caught during verification: trace_calls's existing, established contract (backing trace_call_path for a while now) deliberately excludes the starting function itself, correct for that tool's "you already know the name you're asking about" use case - but a rendered neighborhood diagram without the function the user actually searched for was a real gap, not a data omission to work around; call_graph_dot now explicitly includes it as the visually-anchored (highlighted) node.

Phase 16 — Reliability Hardening from Real Dogfooding(no new tools; three real bugs found by actually using the MCP tools against a real project, not by re-reading code) A separate benchmarking session using the MCP tools directly (not this one) surfaced two real problems: detect_dead_code run untargeted on the 1,067-node downtime project returned all 414 flagged candidates in one response - 99,801 characters, mostly false positives (test functions and reflection/routing-invoked handlers, exactly the failure mode the tool's own description already warns about) - costing more tokens than the caller would have spent just grepping. Fixed with a limit parameter (default 50) and an explicit total_flagged count, so the response stays honest about what's hidden instead of silently truncating. That same session also hit a FOREIGN KEY constraint failed reindexing over an existing index, recoverable only via delete + re-index. It didn't reproduce on a plain sequential re-run of this repo's own index, but reading the code turned up a real, related gap: the background file watcher's auto-reindex and a manual reindex can both run a full clear-and-rebuild for the same project without any synchronization beyond SQLite's own write lock, and underneath that, registry.json was being read-modified-written with a plain fs::write - two overlapping writers can genuinely interleave and corrupt it (silently discarded as "no projects yet" by the loader). Fixed with a process-wide reindex mutex and atomic (temp-file + rename) registry writes. This can't be claimed as a confirmed fix for that exact trace, but it closes the class of bug it looks like.

While in there, added the storage-lifecycle tracking an actual dogfooding session surfaced as missing: the registry only ever recorded last_indexed_unix, with no way to tell "indexed once, never touched again" apart from active use, and no visibility into disk usage at all - a real gap for anyone indexing many projects over weeks and only actively using a few per day. last_queried_unix now moves on every real MCP tool call or GUI/control-API action (not just a reindex), and disk usage per project is computed live from the actual data directory rather than stored and risking drift - both surfaced in the GUI's Projects tab. Deletion stays manual (projects.delete/the GUI's Delete button) - this only makes staleness visible, it doesn't act on it.

Phase 17 — Usage/Resource Observability(instrumentation only - deliberately no quotas, rate limits, or enforcement yet) The same benchmarking session behind Phase 16 asked a fair follow-up: before adding any kind of resource limit, shouldn't you first see real usage patterns rather than guess at sane defaults? Agreed - this phase is observability-first by design, and adding any actual limiting is explicitly deferred to a later phase informed by real data from this one, not bundled in here.

New nexus_core::stats (UsageStats/ToolCallStats) tracks lifetime aggregate call count, error count, total/max latency, and total output bytes per tool/method - not a per-call event log or a new database table, since a long-lived daemon needs durability across restarts but this is explicitly an insight-gathering pass, not an audit trail. Two separate buckets (mcp_tools, control_methods) keep external-agent usage and GUI-originated usage from being conflated into one signal, since they answer different questions. Both dispatchers (tools.rs's call(), control.rs's dispatch()) got instrumented once at the single match they already funnel every request through, rather than touching each of the ~20 individual handler functions. Persistence follows the exact load/temp-write/rename pattern Registry already uses, including the same accepted tradeoff: concurrent writers can lose an increment to a last-writer-wins race, fine for insight-gathering, not fine for anything billing-adjacent.

This phase also closes a documentation gap the Phase 16 session ran into directly: the background auto-sync watcher's existence, and the fact that it always does a full rebuild rather than an incremental diff, wasn't visible anywhere - a caller had no way to know a project could get reindexed out from under it. ProjectEntry gained auto_reindex_count/auto_reindex_fail_count/auto_reindex_total_ms/last_auto_reindex_ms/last_auto_reindex_unix, instrumented directly in watcher.rs's reindex loop and kept separate from manual-reindex stats, since "how often and how expensively is auto-sync rebuilding this on its own" is a different question from "how often did someone ask for a reindex."

New control API method stats.get surfaces all of it (per-tool/method breakdown plus per-project auto-reindex history), and a new GUI Usage tab renders it following the same tabular pattern as the existing Architecture tab - no new UI mechanism invented. Not exposed as an MCP tool: this is inspection surface for the person running the daemon, not something a calling agent needs to query about itself.

Verified: cargo build/test/clippy -D warnings/fmt --check all clean across the workspace. Functional smoke test drove the real nexusd mcp stdio binary against a scratch project - index_repository plus two search_graph calls (one a deliberate error against a nonexistent path) - and confirmed usage_stats.json recorded exactly 1 call/0 errors/1799ms for the former and 2 calls/1 error/1ms total for the latter, with output-byte counts matching the actual response sizes. The control-API stats.get path and GUI tab are implemented and code-reviewed but not yet exercised against a live nexusd serve instance - doing so means installing over the actual running systemd-managed daemon, which isn't something to do to a live service without saying so first.

Dogfooding follow-up (no new functionality): the deferred live-verification of the GTK4 Manager's AdwViewSwitcherTitle header bar (open since Phase 3/4) finally happened on a real desktop - GNOME Shell 50, GTK4 4.22, libadwaita 1.9, Wayland. It renders correctly maximized (all tabs visible with icons+labels); at the default 900x640 window size it collapses to a blank header instead of falling back to a dropdown - cosmetic, not a functional bug, so the migration to explicit AdwHeaderBar + AdwViewSwitcher/AdwViewSwitcherBar stays deferred rather than being rushed through blind. The GNOME Shell extension was also confirmed ACTIVE end-to-end via gnome-extensions info after a real logout/login.

Phase 18 — Warm/Cold Reindex Gating & GUI Export/Import(dogfooding-driven: real registry data showed the auto-sync watcher reindexing projects nobody had queried in over 10 hours)

Dogfooding at a handful of registered projects surfaced a scaling problem: the background watcher (Phase 10) watches every registered project forever and reindexes on any file change, with no notion of whether the project is actually in use. Real registry data confirmed it - one project had been auto-reindexed 10 times (~1.8h of total embedding-call time against a remote Ollama endpoint, ~11min/reindex) while last_queried_unix sat over 10.5 hours stale, and intel_gpu_top on the Ollama host showed its render engine pegged at 100% the whole time. At 100 registered projects this cost scales linearly with idle repos, not with actual usage.

Fix: a new ProjectEntry::is_warm(now, warm_window_secs) predicate, judged only on last_queried_unix (never last_indexed_unix - the latter is bumped by auto-reindex itself, so using it would let a cold project's own watcher-triggered reindex re-arm its warm window, recreating the exact bug). watcher.rs's sync_watches now excludes cold projects (default 6h since last query, [watcher] warm_window_secs in config.toml) from the active watch set entirely - they stop costing an inotify watch, not just stop triggering reindexes. A project going cold isn't silently stale forever, though: tools.rs's MCP dispatch now checks staleness before every tool call and, if the project had gone cold, runs one synchronous catch-up reindex before answering - the caller's first query after a gap costs a real reindex, but every one after that is normal.

Also closed a gap found while researching this: nexus_index::export_project/import_project (Phase 8) already wrote/read a shareable snapshot at <repo>/.nexuscontext/index.db.zst, but only the CLI could call them - no control-API method existed, so the GUI (which only talks to the daemon's control socket) had no way to reach them. Added projects.export/projects.import control-API methods (same shape as the existing projects.reindex/projects.delete) and Import/Export buttons in the GUI's Projects tab. Also hardened export_project to call require_path_allowed, matching import_project/index_project (it was the one entry point that didn't).

Verified: full cargo build/test --workspace clean, including new is_warm unit tests (never-queried, just-queried, exact-boundary, past-window). Live-verified against the real dogfooding daemon - built a real .deb, installed it over the running systemd-managed service, and confirmed against the actual Unix control socket: projects.export/projects.import round-tripped a real project's index (2 nodes/1 edge) with no reindex cost on import. Then, with a temporarily shortened warm window, confirmed a file change to a now-cold project produced zero reindex activity (registry counters unchanged), and that querying it again via the real nexusd mcp stdio path triggered exactly one synchronous catch-up reindex (auto_reindex_count incremented, node count picked up the change made while cold) before returning correct, fresh results.

Phase 19 — The Watcher Was Reindexing Itself(the Phase 18 gating was correct but didn't explain everything still happening on a real, dogfooded project - four iterations before the actual fix held)

Even after Phase 18 shipped, a real project (downtime, 2,414 nodes, embeddings enabled) kept auto-reindexing roughly every 11 minutes, around the clock, with no user activity anywhere near it. Live intel_gpu_top on the remote Ollama host confirmed sustained load; two separate live inotifywait traces against the entire project tree (once filtered to match the watcher's own noise-list, once completely unfiltered including .git) both caught zero real filesystem events across two full cycles - yet nexusd's own log kept logging "file change detected, reindexing" like clockwork. The three measured gaps between triggers (665.97s, 665.22s, 665.33s) matched the project's own recorded reindex duration (666.03s) to within a second, every time - the "trigger" wasn't a new file change at all, it was the previous reindex finishing.

First fix, real but insufficient: the channel between the file-debouncer thread and the reindex loop was a plain std::sync::mpsc::channel() - unbounded, with a single consumer that blocks inside index_project() for the entire reindex. Every separate debounced burst arriving while that consumer is blocked queued up independently instead of being coalesced. Fixed by draining everything already sitting in the channel with a non-blocking rx.try_recv() loop before deciding what to reindex (factored into a shared collect_dirty_roots helper). Installed and watched live - the exact same ~11-minute-interval trigger fired again anyway. This was a real bug worth fixing (a genuine backlog of distinct past edits would still misbehave without it), but it wasn't the bug.

Actual root cause: notify's Linux backend (inotify.rs) subscribes to WatchMask::OPEN, not just writes. Indexing a project means opening every source file to parse it - so the reindex's own reads are indistinguishable from real edits once debounced, and it re-triggers its own next run the instant it finishes, forever, with nothing on disk actually changing.

Second fix, also insufficient: unwatch the project before index_project() runs, re-watch right after. Installed and watched live - a second trigger still fired, seconds after the first reindex completed. Re-establishing a recursive watch isn't free either: notify's setup walks the whole tree, opening every subdirectory to register a watch on it - and since the root's own watch is registered before that walk reaches its children, those subdirectory opens land right back on the watch just re-created. A self-generated burst at the exact moment watching resumes.

Third fix, closer but still short: move last_attempt's timestamp to after re-watching completes rather than before indexing starts, so MIN_REINDEX_GAP (45s) is measured from "just finished" instead of "started 11 minutes ago" - which is what actually gives the self-generated burst a chance to fall inside the gap and get skipped. Installed and watched live - a second trigger still landed, this time ~85 seconds after completion, comfortably past the 45s window. For a project this size (2,414 nodes, many subdirectories), the re-watch walk itself simply takes longer than 45 seconds to finish generating its own noise.

Fix that held: raised MIN_REINDEX_GAP from 45s to 180s - enough margin over any plausible re-watch-walk duration. Installed and watched live: one legitimate reindex fired on restart, completed after ~11 minutes, and zero further triggers appeared over the following 6.65+ minutes - past both the 85s straggler seen before and the new 3-minute window. A genuine edit landing in that window isn't lost, just picked up on the project's next eligible attempt instead of instantly - an acceptable tradeoff against a full, non-incremental reindex being expensive regardless.

Verified: cargo build/test --workspace/clippy -D warnings/fmt --check all clean at every iteration. Every one of the four candidate fixes above was installed as a real .deb over the actual running dogfooding daemon and watched against real trigger timestamps in nexusd.log before being accepted or rejected - none were declared fixed on code review alone, since the first three all looked correct on inspection and weren't.

Phase 20 — Closing the General Case: Any Read Still Looked Like a Change(the Phase 19 gap-timer approach fixed the runaway loop; this closes the broader gap it left behind)

Phase 19's fix stopped a reindex from re-triggering itself, but it didn't address the underlying cause it was built on top of: notify firing on IN_OPEN means any tool reading files in a watched project - git status, cargo build, an editor, another diagnostic command - can still wake the loop and cost a real reindex, even though nothing changed. Confirmed live: running git status/cat/grep against this repo's own tracked files, with no edits at all, produced two real "file change detected, reindexing" log lines with find -newermt and git status both showing nothing had actually changed.

Fix: nexus_index::content_signature(root) walks the project with the exact same ignore-respecting walk and file-type filter index_directory already uses (only files that would actually be indexed - a supported language or markdown), and folds each file's path, size, and mtime into an order-independent hash (entries sorted before hashing, since a directory walk's yield order isn't guaranteed stable). The watcher now computes this once per dirty project, per wake-up, and compares it against the signature recorded after that project's last real reindex - completing a wake-up as a no-op (content is unchanged - skipping, no unwatch/index_project/watch dance at all) unless something in the signature actually differs. Scoped to the watcher's automatic path only; manual reindex requests (CLI, MCP index_repository, control API projects.reindex) stay unconditional, since those are an explicit ask, not a guess.

Verified: 4 new unit tests (signature stable across repeat calls with no changes; changes when a file's content changes; changes when a file is added; unchanged when a non-indexable file changes, e.g. a .bin - the whole point, since indexing would've skipped that file anyway). cargo build/test --workspace/clippy -D warnings/fmt --check all clean. Live-verified against the real dogfooding daemon with NEXUS_LOG_LEVEL=debug temporarily enabled via a systemd override: read this repo's own tracked files with cat/grep against the real running project, confirmed the resulting debounced events reached the loop, passed the MIN_REINDEX_GAP check, and were then correctly discarded with the new "file change detected, but indexed content is unchanged - skipping" line - not just an absence of a reindex, which could have meant several other things, but the actual mechanism firing as designed. downtime's auto_reindex_count stayed unchanged throughout.

Phase 21 — [tools] Config: Trimming the Fixed Per-Session Tool-Schema Tax(dogfooding-driven: a user who restarts Claude Code sessions often traced a real recurring token cost to tools/list itself, not to any tool's response - written up as change_proposal.md)

Every MCP session start pays a fixed cost just to load nexusd's tool schemas, regardless of whether that session ever calls most of them - tool_definitions() returned all 13 tools unconditionally, ~2.5k tokens, with no way to opt into fewer. That's a real, recurring tax working directly against this project's own reason for existing, and it scales with how often a machine restarts agent sessions, not with how the daemon is actually used.

Fix: a new [tools] config section (preset = "minimal" | "standard" | "full", default "standard", plus an optional explicit enabled = [...] list that takes precedence over preset). minimal (5 tools) covers the read-heavy core loop - index_repository, search_code, get_file_context, get_architecture, trace_call_path. standard adds search_graph, detect_changes, detect_dead_code, query_planner (9 total). full restores all 13, including the destructive delete_project, the niche query_graph ad-hoc DSL, and the embeddings-gated search_codebase/query_memory. tools/list (mcp.rs) now loads Config and filters tool_definitions()'s output against the resolved set before returning it.

This is a real behavior change for the default case, not a purely additive one: a user with no [tools] section in config.toml goes from seeing all 13 tools to seeing 9 (standard). That's the intended fix - it's exactly what was costing the token tax - but anyone relying on delete_project, query_graph, search_codebase, or query_memory via MCP needs to set preset = "full" (or add them to enabled) to keep seeing them.

Verified: 7 new unit tests in tools.rs (default resolves to the standard 9; minimal resolves to exactly 5; full matches tool_definitions()'s own name set exactly - a drift guard that fails if a future tool is added to one but not the other; explicit enabled overrides preset; an unknown name in enabled is silently dropped rather than erroring, matching this project's "useful with zero config" philosophy) plus 4 new config.rs TOML round-trip tests. cargo build/test --workspace/clippy --all-targets -D warnings/fmt --check all clean. Manually verified against the real nexusd mcp stdio binary (scratch HOME, not the live dogfooded config): piped a literal tools/list request through with no config, preset = "minimal", preset = "full", and an explicit enabled list, and confirmed the returned tool-name arrays matched expectation in all four cases (9/5/13/2 respectively).

Phase 22 — Trimming Tool Descriptions and Capping Unbounded Responses(the rest of change_proposal.md: schema size isn't the only session-start cost - response size matters once a tool is actually called, and the two currently-unbounded tools hadn't yet learned the lesson detect_dead_code learned the hard way in Phase 16)

Three mechanical, low-risk fixes bundled together, same style as Phase 16:

  • Description trims. The five most verbose tool descriptions (trace_call_path, detect_dead_code, query_planner, query_graph, search_codebase) carried caveats/rationale that belonged in documentation, not in a schema block re-sent every session. Trimmed each to a one- or two-sentence behavioral contract; the moved caveats now live in Section 2's "MCP Server" bullet list above, which is exactly what the trimmed in-schema text now points readers to.
  • trace_call_path result cap. Previously had a depth param but no limit - a high-fan-out function could return an unbounded node set. Added an optional limit (default 100), and the handler now truncates with the same honest total_nodes/shown shape detect_dead_code already used.
  • get_file_context bounded default. Previously _ => Ok(content) fired whenever either start_line or end_line was missing - so passing just one bound silently returned the entire file, not only omitting both. Now: both bounds set → exact slice (unchanged); full = true → whole file (new explicit escape hatch); one bound set → a 300-line window anchored at that bound (closes the actual bug); neither set → first 300 lines with a trailing truncation note.
  • Global limit clamp. search_code, search_graph, query_graph, search_codebase/query_memory, detect_dead_code, and the new trace_call_path limit now all pass through a shared clamp_limit() capping any caller-supplied value at SERVER_MAX_LIMIT (200) - a single bad call from a coding agent can't blow up a response regardless of what limit it asked for. Deliberately scoped to MCP dispatch only; nexus-cli's equivalent flags are a human-operator trust boundary, not an LLM-driven one, and stay as-is.

Real behavior changes, stated plainly (not purely additive): a get_file_context call with no range and no full=true now returns at most the first 300 lines plus a truncation note, where it previously always returned the entire file. A caller passing limit above 200 to any of the tools listed above now gets 200 results back, not the number it asked for.

Not a bug, just worth saying once (closes proposal item 5): tool_definitions() is a pure static literal and initialize's response only embeds env!("CARGO_PKG_VERSION"), a compile-time constant - the ~2.5k-token tools/list cost Phase 21 targets was always a fixed, cache-stable, one-time-per-session tax, not a prompt-caching bug in this server. Recorded here so it isn't re-investigated later as a phantom issue.

Verified: new unit tests for clamp_limit (below/at/above SERVER_MAX_LIMIT) and six new get_file_context tests in nexus-index covering every branch above (small file unaffected, large file truncated with the note present, a lone start_line/end_line each producing a bounded window instead of the whole file, both bounds still unbounded, full=true bypassing truncation regardless of size). cargo build/test --workspace/clippy --all-targets -D warnings/fmt --check all clean. Manually exercised against this repo's own real index via the nexusd mcp stdio binary: trace_call_path on a high-fan-out function returned total_nodes/shown; get_file_context with no range on a file over 300 lines (tools.rs itself) returned the truncation note with the correct total; search_code with limit: 100000 came back capped at 200 results.

Phase 23 — The CLI Was Invisible to Its Own Freshness Machinery(root-caused from a real incident, not a code-review guess)

A real dogfooding session against this project's own downtime project (2,414+ node scale, embeddings enabled) surfaced that nexus search-code/architecture/etc. via the CLI returned stale, silently-wrong results with no warning of any kind - two functions written and committed hours earlier came back as "no matches." Cross-referencing ~/.local/share/nexuscontext/projects.json found the actual cause: last_queried_unix: 0 and auto_reindex_count: 0, despite real, repeated CLI use.

Root cause: the "mark this project as actually used, and catch it up first if the watcher had gone cold" logic (ProjectEntry::is_warm, the Phase 18 gating) was wired into exactly two of this project's three entry points - the MCP tool dispatcher (tools.rs) and the control API (control.rs, itself only doing half the job - it had touch_queried but never the cold-catchup check either). nexus-cli's read/query subcommands - a first-class, documented interface (Phase 1), not a debug side door - called straight into the index with neither. A project checked only via the CLI was judged permanently cold, dropped out of the watcher's active-watch set (Phase 18), and could never self-heal.

Fix: consolidated the duplicated catch-up-then-touch_queried logic from tools.rs/control.rs into one shared nexus_index::touch_and_catchup, wired into all 9 of the CLI's read subcommands (search-graph, trace, architecture, detect-changes, dead-code, query-planner, search-code, query-graph, search-codebase) and used to upgrade control.rs to the same guarantee it was missing. index_repository/Reindex/projects.reindex still skip the catch-up check (they already unconditionally reindex) but still mark the project warm, matching the original nuance exactly.

The obvious risk in shipping that alone: before this fix, cold catch-up had fired zero times ever against the downtime project. Once the CLI's actual, frequent usage pattern started triggering it for real, every catch-up was still a full clear-and-rebuild (GraphStore::clear()) - "always fresh" would have quietly become "the daemon does full-project reindex work far more often than it ever has," a regression dressed up as a fix, and the same shape of scaling problem Phase 18 already found once from a different angle.

Stage 0 of the fix for that: GraphStore::embeddings_snapshot(model) reads every embedded chunk for the configured model, keyed by qualified_name (stable across a rebuild, unlike node_id - fresh autoincrements every clear()) rather than node_id. Taken before clear() wipes the table, threaded through index_directory_inner into a new pure split_reusable_embeddings helper (deliberately no I/O or config lookup, so the reuse-vs-reembed decision is unit-testable on its own): a chunk whose text exactly matches its previous snapshot entry gets that vector reinserted under the new node_id directly, at zero network cost; only genuinely new or changed chunks go to the real embeddings endpoint. embeddings_status now reports both counts, e.g. "ok: 0 chunks embedded, 3092 reused unchanged".

Verified: cargo build/test --workspace/clippy --all-targets -D warnings/fmt --check all clean, with 8 new unit tests (GraphStore::embeddings_snapshot round-tripping a snapshot, surviving a simulated clear-and-reinsert under a new node_id, and correctly scoping by model; split_reusable_embeddings's unchanged/changed/new/mixed-batch cases). Live-verified against the real dogfooding daemon, not just code review: built and installed a real .deb over the running nexuscontext.service, then ran the CLI against the actual downtime project that surfaced this bug - last_queried_unix moved for the first time ever, auto_reindex_count went from 0 to 1 as a genuine catch-up reindex fired, and the two previously-invisible functions resolved correctly afterward. A second, immediate reindex with nothing changed came back "ok: 0 chunks embedded, 3092 reused unchanged" - zero embedding calls, confirming Stage 0 end-to-end on a real project rather than just a synthetic test.

Full per-file incremental diffing (a persistent file_signatures table plus a durable call_sites table so a rename/removal in one file correctly invalidates a resolved call edge from an unchanged caller elsewhere) is deliberately not attempted here - genuinely harder, and it deserves the same iterate-against-a-real-dogfooded-project rigor Phases 19-20 needed before their fix actually held, not a first-shot implementation. Tracked as follow-up.

Phase 24 — v0.1.11 Hotfix: the GUI's Control API Never Should Have Gotten Catch-Up(a real crash, reported by a user within hours of the v0.1.11 release, root-caused and fixed same-day)

Phase 23 upgraded control.rs's dispatch() from a bare touch_queried to the full touch_and_catchup "for consistency" with tools.rs's MCP path - and shipped a real regression doing it. crates/nexus-gui/src/client.rs calls the control socket synchronously on GTK's main thread, on a documented assumption stated in its own comment: "Local Unix-socket calls are sub-millisecond... this is acceptable." That was true when every control method's pre-dispatch hook was a fast registry write. Once projects.architecture/viz.call_graph could trigger a full, potentially multi-minute catch-up reindex first, opening the Architecture or Visualize tab against a cold project froze the entire GTK event loop for the reindex's duration - long enough that the window manager/compositor reasonably concluded the app was unresponsive and killed it. Reported as the GUI "crashing" on Architecture/Visualize; root cause was one line of shared logic applied to a transport it didn't fit.

Fix: reverted control.rs to plain touch_queried only, exactly as it was before Phase 23 - GUI actions mark a project warm again but no longer trigger a synchronous reindex from inside a GTK signal handler. tools.rs's MCP dispatch and the CLI keep the full touch_and_catchup unchanged: a slow tool call or a CLI command blocking is normal, expected behavior for those two transports (the caller already expects a tool/command invocation might take time), not a frozen persistent GUI window.

Verified: cargo build/test --workspace/clippy --all-targets -D warnings/fmt --check all clean. Live-verified against the real dogfooding daemon: built and installed the .deb over the running nexuscontext.service, then timed a raw projects.architecture call directly against the control socket - 0.114s, back in the sub-second range client.rs's own assumption requires, regardless of the queried project's warm/cold state.

5. Why This Counts as "Full-Fledged"

A daemon alone is a backend, not a tool. What makes this complete for a Linux desktop user:

  • Headless-first: daemon + MCP server work with zero GUI, so IDE/agent integration isn't blocked on the GUI being built.
  • A real inspection/management surface (GTK4 app) for the parts a CLI is bad at — browsing search results, seeing indexing status at a glance.
  • Desktop-native integration (top-bar status) without over-investing in Shell extension surface area, which is the most fragile part of any GNOME-integrated tool.
  • Proper packaging (.deb/Flatpak + systemd unit) so it installs and runs like a normal Ubuntu service, not a script someone has to remember to start.

6. Open Risks / Decisions to Revisit

  • LanceDB Rust binding maturity — moot: the vector-store pick was never revisited once the knowledge graph proved sufficient for every structural tool built so far (full-text search also landed on SQLite FTS5 rather than a separate vector engine). Revisit only once the embeddings pipeline actually gets built.
  • Tree-sitter grammar coverage — resolved in Phase 11: 11 languages via the generic tree-sitter-tags mechanism, honestly tiered by call-graph fidelity.
  • Embedding endpoint availability — resolved in Phase 12: a down/misconfigured endpoint returns a specific, actionable error (not a crash or a hang) both at query time and mid-reindex, verified directly against a real unreachable-endpoint test.
  • Remote embedding endpoint = network exposure of code — resolved in Phase 7: Config::embeddings_policy() refuses a non-loopback/non-private endpoint unless allow_remote = true is set explicitly, both in config.toml directly and via the GUI's Config tab checkbox.
  • File watcher cost on large repos — resolved: the live auto-sync watcher (added after the original 10 phases, see below) debounces at 2s and filters obvious noise directories (.git, target, node_modules, .nexuscontext) before triggering a reindex. Extended in Phase 18 for cost at scale (many registered projects, not just one large one): a project not queried in warm_window_secs (6h default) stops being actively watched at all, with a synchronous catch-up reindex the next time it's actually queried. Phase 19 closed the remaining gap on a single actively-watched large project: notify's Linux backend watches file opens, not just writes, so a reindex's own reads of every source file were re-triggering the next reindex indefinitely - fixed by unwatching for the duration of each reindex and giving the gap before the next attempt (MIN_REINDEX_GAP, 180s) enough margin to absorb the watch's own re-registration noise.
  • GNOME extension version churn — GNOME Shell extensions frequently break across major GNOME releases; treat Phase 4 as low-priority/optional and keep it thin enough to be cheap to fix.
  • Graph incremental-update correctness — on file change, edges referencing the changed file (e.g. CALLS into a renamed function) must be retracted and rebuilt, not just the file's own nodes appended. Worth a full-reindex fallback if incremental graph diffing gets too complex early on.
  • Bundled vs. network embedding model — an alternative worth keeping in mind is embedding a small model directly in the binary (zero external process, at the cost of a fixed model). We're deliberately choosing the opposite tradeoff — a configurable external endpoint gives model choice and reuse of whatever's already running, at the cost of "semantic search needs something else up." Worth revisiting only if "zero external dependencies" becomes a hard requirement later; the graph layer already covers the tool's core value without embeddings either way.

Releases

Packages

Contributors

Languages